Clientes y Facturas de cliente

This commit is contained in:
David Arranz 2025-10-20 20:40:28 +02:00
parent cad3a403b9
commit 7cad265aff
36 changed files with 1005 additions and 449 deletions

View File

@ -1,3 +1,4 @@
import { INITIAL_PAGE_SIZE } from "@repo/rdx-criteria";
import { z } from "zod/v4";
/**
@ -31,3 +32,21 @@ export const CriteriaSchema = z.object({
});
export type CriteriaDTO = z.infer<typeof CriteriaSchema>;
export function normalizeCriteriaDTO(criteria: CriteriaDTO = {}) {
const {
pageNumber = INITIAL_PAGE_SIZE,
pageSize = INITIAL_PAGE_SIZE,
q = "",
filters = [],
orderBy = "",
order = "",
} = criteria;
// Para mantener un orden estable de filtros
const stableFilters = [...filters].sort(
(a, b) => a.field.localeCompare(b.field) || a.op.localeCompare(b.op)
);
return { pageNumber, pageSize, q, filters: stableFilters, orderBy, order };
}

View File

@ -8,6 +8,7 @@ import { useTranslation } from "../../../i18n.ts";
import { useUnsavedChangesContext } from "../use-unsaved-changes-notifier";
export type CancelFormButtonProps = {
formId?: string;
to?: string; /// Ruta a la que navegar si no se pasa onCancel
onCancel?: () => void | Promise<void>; // Prioritaria sobre "to"
label?: string;
@ -19,6 +20,7 @@ export type CancelFormButtonProps = {
};
export const CancelFormButton = ({
formId,
to,
onCancel,
label,
@ -51,6 +53,7 @@ export const CancelFormButton = ({
return (
<Button
form={formId}
type='button'
variant={variant}
size={size}

View File

@ -33,7 +33,6 @@ export type UpdateCommitButtonGroupProps = {
disabled?: boolean;
preventDoubleSubmit?: boolean; // Evita múltiples submits mientras loading
preview?
cancel?: CancelFormButtonProps & { show?: boolean };
submit?: GroupSubmitButtonProps; // props directas a SubmitButton

View File

@ -1,5 +1,5 @@
// components/CustomerSkeleton.tsx
import { AppBreadcrumb, AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useTranslation } from "../i18n";
@ -7,7 +7,6 @@ export const CustomerInvoiceEditorSkeleton = () => {
const { t } = useTranslation();
return (
<>
<AppBreadcrumb />
<AppContent>
<div className='flex items-center justify-between'>
<div className='space-y-2' aria-hidden='true'>

View File

@ -1,4 +1,4 @@
import { AppBreadcrumb, AppContent } from "@repo/rdx-ui/components";
import { AppContent } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useNavigate } from "react-router-dom";
import { useCreateCustomerInvoiceMutation } from "../../hooks";
@ -50,7 +50,6 @@ export const CustomerInvoiceCreate = () => {
return (
<>
<AppBreadcrumb />
<AppContent>
<div className='flex items-center justify-between space-y-2'>
<div>

View File

@ -6,7 +6,7 @@ import {
} from "@erp/core/hooks";
import { AppContent, AppHeader } from "@repo/rdx-ui/components";
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { useMemo } from 'react';
import { useId, useMemo } from 'react';
import { FieldErrors, FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useInvoiceContext } from '../../context';
@ -33,6 +33,7 @@ export const InvoiceUpdateComp = ({
const navigate = useNavigate();
const { invoice_id } = useInvoiceContext(); // ahora disponible desde el inicio
const context = useInvoiceContext();
const formId = useId();
const isPending = !invoiceData;
@ -57,12 +58,13 @@ export const InvoiceUpdateComp = ({
});
const handleSubmit = (formData: InvoiceFormData) => {
console.log('Guardo factura')
const dto = invoiceDtoToFormAdapter.toDto(formData, context)
console.log("dto => ", dto);
mutate(
{ id: invoice_id, data: dto as Partial<InvoiceFormData> },
{
onSuccess: () => showSuccessToast(t("pages.update.successTitle")),
onSuccess: () => showSuccessToast(t("pages.update.success.title"), t("pages.update.success.message")),
onError: (e) => showErrorToast(t("pages.update.errorTitle"), e.message),
}
);
@ -91,8 +93,8 @@ export const InvoiceUpdateComp = ({
<UpdateCommitButtonGroup
isLoading={isPending}
submit={{ formId: "invoice-update-form", variant: 'default', disabled: isPending, label: t("pages.edit.actions.save_draft") }}
cancel={{ to: "/customer-invoices/list" }}
submit={{ formId, variant: 'default', disabled: isPending, label: t("pages.edit.actions.save_draft") }}
cancel={{ formId, to: "/customer-invoices/list" }}
onBack={() => navigate(-1)}
/>
}
@ -102,7 +104,7 @@ export const InvoiceUpdateComp = ({
<AppContent>
<FormProvider {...form}>
<InvoiceUpdateForm
formId="invoice-update-form"
formId={formId}
onSubmit={handleSubmit}
onError={handleError}
className="bg-white rounded-xl border shadow-xl max-w-full"

View File

@ -21,7 +21,7 @@ export const InvoiceUpdateForm = ({
const form = useFormContext<InvoiceFormData>();
return (
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)} >
<form noValidate id={formId} onSubmit={form.handleSubmit(onSubmit, onError)}>
<section className={cn("p-6 space-y-6", className)}>
<div className="w-full p-6 bg-transparent grid grid-cols-1 lg:grid-cols-3 gap-6">
<InvoiceRecipient className="flex flex-col" />
@ -37,7 +37,6 @@ export const InvoiceUpdateForm = ({
<div className="w-full p-6">
<FormDebug />
</div>
</section>
</form>
);

View File

@ -1,36 +1,7 @@
import { MetadataSchema } from "@erp/core";
import { z } from "zod/v4";
import {
GetCustomerByIdResponseDTO,
GetCustomerByIdResponseSchema,
} from "./get-customer-by-id.response.dto";
export const CreateCustomerResponseSchema = z.object({
id: z.uuid(),
company_id: z.uuid(),
reference: z.string(),
is_company: z.string(),
name: z.string(),
trade_name: z.string(),
tin: z.string(),
street: z.string(),
street2: z.string(),
city: z.string(),
province: z.string(),
postal_code: z.string(),
country: z.string(),
email: z.string(),
phone: z.string(),
fax: z.string(),
website: z.string(),
legal_record: z.string(),
default_taxes: z.array(z.string()),
status: z.string(),
language_code: z.string(),
currency_code: z.string(),
metadata: MetadataSchema.optional(),
});
export type CustomerCreationResponseDTO = z.infer<typeof CreateCustomerResponseSchema>;
export const CreateCustomerResponseSchema = GetCustomerByIdResponseSchema;
export type CustomerCreationResponseDTO = GetCustomerByIdResponseDTO;

View File

@ -3,7 +3,12 @@
"more_details": "More details",
"back_to_list": "Back to the list",
"cancel": "Cancel",
"save": "Save"
"save": "Save",
"saving": "Saving...",
"validating_form": {
"title": "Check the fields",
"message": "There are validation errors in the form"
}
},
"catalog": {
"status": {
@ -30,11 +35,19 @@
},
"create": {
"title": "New customer",
"description": "Create a new customer"
"description": "Create a new customer",
"error": {
"title": "Error creating",
"message": "The new customer could not be created"
}
},
"update": {
"title": "Update customer",
"description": "Update a customer"
"description": "Update a customer",
"success": {
"title": "Customer updated",
"message": "The customer has been successfully updated"
}
}
},
"form_fields": {

View File

@ -3,7 +3,12 @@
"more_details": "Más detalles",
"back_to_list": "Back to the list",
"cancel": "Cancelar",
"save": "Guardar"
"save": "Guardar",
"saving": "Guardando...",
"validating_form": {
"title": "Revisa los campos",
"message": "Hay errores de validación en el formulario"
}
},
"catalog": {
"status": {
@ -31,12 +36,20 @@
"create": {
"title": "Nuevo cliente",
"description": "Crear un nuevo cliente",
"back_to_list": "Volver a la lista"
"back_to_list": "Volver a la lista",
"error": {
"title": "Error al crear",
"message": "No se pudo crear el nuevo cliente"
}
},
"update": {
"title": "Modificación de cliente",
"description": "Modificar los datos de un cliente",
"back_to_list": "Back to the list"
"back_to_list": "Back to the list",
"success": {
"title": "Cliente actualizado",
"message": "El cliente ha sido actualizado correctamente"
}
}
},
"form_fields": {

View File

@ -19,7 +19,7 @@ import {
import { Building, Calendar, Mail, MapPin, Phone, Plus, User } from "lucide-react";
import { useState } from "react";
import { ListCustomersResponseDTO } from "../../common";
import { useCustomersQuery } from "../hooks";
import { useCustomerListQuery } from "../hooks";
type Customer = ListCustomersResponseDTO["items"][number];
@ -131,7 +131,7 @@ export const ClientSelectorModal = () => {
const [debouncedSearch] = useDebounce(search, 400);
const { data, isLoading, isError, refetch } = useCustomersQuery({
const { data, isLoading, isError, refetch } = useCustomerListQuery({
filters: buildTextFilters(["name", "email", "trade_name"], debouncedSearch),
pageSize,
pageNumber,

View File

@ -1,100 +0,0 @@
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from "@repo/shadcn-ui/components";
import { Plus } from "lucide-react";
interface CustomerFormDialogProps {
open: boolean;
onOpenChange: (o: boolean) => void;
client: Omit<Client, "id">;
onChange: (c: Omit<Client, "id">) => void;
onSubmit: () => void;
}
export const CreateCustomerFormDialog = ({
open,
onOpenChange,
client,
onChange,
onSubmit,
}: CustomerFormDialogProps) => {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='sm:max-w-[500px] bg-card border-border'>
<DialogHeader>
<DialogTitle className='flex items-center gap-2'>
<Plus className='size-5' /> Agregar Nuevo Cliente
</DialogTitle>
<DialogDescription>
Complete la información del cliente. Los campos marcados con * son obligatorios.
</DialogDescription>
</DialogHeader>
<div className='grid gap-4 py-4'>
{/* Nombre */}
<div className='grid gap-2'>
<Label htmlFor='name'>Nombre completo *</Label>
<Input
id='name'
value={client.name}
onChange={(e) => onChange({ ...client, name: e.target.value })}
/>
</div>
{/* Email */}
<div className='grid gap-2'>
<Label htmlFor='email'>Email *</Label>
<Input
id='email'
type='email'
value={client.email}
onChange={(e) => onChange({ ...client, email: e.target.value })}
/>
</div>
{/* Teléfono / NIF */}
<div className='grid grid-cols-2 gap-4'>
<div className='grid gap-2'>
<Label htmlFor='phone'>Teléfono</Label>
<Input
id='phone'
value={client.phone ?? ""}
onChange={(e) => onChange({ ...client, phone: e.target.value })}
/>
</div>
<div className='grid gap-2'>
<Label htmlFor='taxId'>NIF/CIF</Label>
<Input
id='taxId'
value={client.taxId ?? ""}
onChange={(e) => onChange({ ...client, taxId: e.target.value })}
/>
</div>
</div>
{/* Empresa */}
<div className='grid gap-2'>
<Label htmlFor='company'>Empresa</Label>
<Input
id='company'
value={client.company ?? ""}
onChange={(e) => onChange({ ...client, company: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button onClick={onSubmit} disabled={!client.name || !client.email}>
<Plus className='mr-2 size-4' /> Crear Cliente
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@ -1,10 +1,10 @@
import {
Button, Item,
ItemContent,
ItemDescription,
ItemFooter,
ItemTitle
} from "@repo/shadcn-ui/components";
import { cn } from '@repo/shadcn-ui/lib/utils';
import {
EyeIcon,
MapPinIcon,
@ -61,18 +61,27 @@ export const CustomerCard = ({
<ItemContent>
<ItemTitle className="flex items-start gap-2 w-full justify-between">
<span className="grow text-balance">{customer.name}</span>
<Button
type="button"
variant="ghost"
size="sm"
className="cursor-pointer"
onClick={onViewCustomer}
aria-label="Ver ficha completa del cliente"
>
<EyeIcon className="size-4 text-muted-foreground" />
</Button>
{/* Eye solo si onViewCustomer existe */}
{onViewCustomer && (
<Button
type='button'
variant='ghost'
size='sm'
className='cursor-pointer'
onClick={onViewCustomer}
aria-label='Ver ficha completa del cliente'
>
<EyeIcon className='size-4 text-muted-foreground' />
</Button>
)}
</ItemTitle>
<ItemDescription className="text-sm text-muted-foreground">
<div
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
"text-sm text-muted-foreground"
)}>
{/* TIN en su propia línea si existe */}
{customer.tin && (
<div className="font-mono tabular-nums">{customer.tin}</div>
@ -113,32 +122,35 @@ export const CustomerCard = ({
) : (
<span className="italic text-muted-foreground">Sin dirección</span>
)}
</ItemDescription>
</div>
</ItemContent>
{/* Footer con acciones */}
<ItemFooter className="flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={onChangeCustomer}
className="flex-1 min-w-36 gap-2 cursor-pointer"
>
<RefreshCwIcon className="size-4" />
<span className="text-sm text-muted-foreground">Cambiar de cliente</span>
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={onAddNewCustomer}
className="flex-1 min-w-36 gap-2 cursor-pointer"
>
<UserPlusIcon className="size-4" />
<span className="text-sm text-muted-foreground">Nuevo cliente</span>
</Button>
{onChangeCustomer && (
<Button
type='button'
variant='outline'
size='sm'
onClick={onChangeCustomer}
className='flex-1 min-w-36 gap-2 cursor-pointer'
>
<RefreshCwIcon className='size-4' />
<span className='text-sm text-muted-foreground'>Cambiar de cliente</span>
</Button>
)}
{onAddNewCustomer && (
<Button
type='button'
variant='outline'
size='sm'
onClick={onAddNewCustomer}
className='flex-1 min-w-36 gap-2 cursor-pointer'
>
<UserPlusIcon className='size-4' />
<span className='text-sm text-muted-foreground'>Nuevo cliente</span>
</Button>
)}
</ItemFooter>
</Item>
);

View File

@ -0,0 +1,91 @@
import { UnsavedChangesProvider, useUnsavedChangesContext } from "@erp/core/hooks";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@repo/shadcn-ui/components";
import { Plus } from "lucide-react";
import { useId } from 'react';
import { useTranslation } from "../../i18n";
import { useCustomerCreateController } from '../../pages/create/use-customer-create-controller';
import { CustomerFormData } from "../../schemas";
import { CustomerEditForm } from '../editor';
type CustomerCreateModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
client: CustomerFormData;
onChange: (customer: CustomerFormData) => void;
onSubmit: () => void; // ← mantenemos tu firma (no se usa directamente aquí)
};
export function CustomerCreateModal({
open,
onOpenChange,
}: CustomerCreateModalProps) {
const { t } = useTranslation();
const formId = useId();
const {
form, isCreating, isCreateError, createError,
handleSubmit, handleError, FormProvider
} = useCustomerCreateController();
const { isDirty } = form.formState;
const guardClose = async (nextOpen: boolean) => {
if (nextOpen) return onOpenChange(true);
if (isCreating) return;
const { requestConfirm } = useUnsavedChangesContext();
const ok = await requestConfirm();
if (ok) onOpenChange(false);
};
return (
<UnsavedChangesProvider isDirty={isDirty}>
<FormProvider {...form}>
<Dialog open={open} onOpenChange={guardClose}>
<DialogContent className="bg-card border-border p-0 max-w-[calc(100vw-2rem)] sm:max-w-[min(100vw-3rem,1280px)] h-[calc(100dvh-2rem)]">
<DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="flex items-center gap-2">
<Plus className="size-5" /> {t("pages.create.title")}
</DialogTitle>
<DialogDescription>{t("pages.create.subtitle")}</DialogDescription>
</DialogHeader>
<div className="px-6 py-4 overflow-y-auto h:[calc(100%-8rem)]">
<CustomerEditForm
formId={formId}
onSubmit={(data: CustomerFormData) => handleSubmit(data, () => onOpenChange(false))}
onError={handleError}
className="max-w-none"
/>
{isCreateError && (
<p role="alert" className="mt-3 text-sm text-destructive">
{(createError as Error)?.message}
</p>
)}
</div>
<DialogFooter className="px-6 py-4 border-t bg-card">
<Button type="button" form={formId} variant="outline" className='cursor-pointer' onClick={() => guardClose(false)} disabled={isCreating}>
{t('common.cancel', "Cancelar")}
</Button>
<Button type="submit" form={formId} disabled={isCreating} className='cursor-pointer'>
{isCreating ? <span aria-live="polite">{t('common.saving', "Guardando")}</span> : <span>{t('common.save', "Guardar")}</span>}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</FormProvider>
</UnsavedChangesProvider>
);
}

View File

@ -7,7 +7,6 @@ type CustomerModalSelectorFieldProps<TFormValues extends FieldValues> = {
control: Control<TFormValues>;
name: FieldPath<TFormValues>;
disabled?: boolean;
required?: boolean;
readOnly?: boolean;
className?: string;
};
@ -15,9 +14,8 @@ type CustomerModalSelectorFieldProps<TFormValues extends FieldValues> = {
export function CustomerModalSelectorField<TFormValues extends FieldValues>({
control,
name,
disabled = false,
required = false,
readOnly = false,
disabled = false, // Solo lectura y sin botones
readOnly = false, // Solo se puede ver la ficha del cliente
className,
}: CustomerModalSelectorFieldProps<TFormValues>) {
const isDisabled = disabled;
@ -29,10 +27,14 @@ export function CustomerModalSelectorField<TFormValues extends FieldValues>({
name={name}
render={({ field }) => {
const { name, value, onChange, onBlur, ref } = field;
console.log({ name, value, onChange, onBlur, ref });
return (
<FormItem className={className}>
<CustomerModalSelector value={value} onValueChange={onChange} />
<CustomerModalSelector
value={value as string | undefined}
onValueChange={onChange}
disabled={isDisabled}
readOnly={isReadOnly}
/>
</FormItem>
);
}}

View File

@ -1,10 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { useCustomersSearchQuery } from "../../hooks";
import { CustomerSummary, defaultCustomerFormData } from "../../schemas";
import { CreateCustomerFormDialog } from "./create-customer-form-dialog";
import { useEffect, useId, useMemo, useState } from "react";
import { useCustomerListQuery } from "../../hooks";
import { CustomerFormData, CustomerSummary, defaultCustomerFormData } from "../../schemas";
import { CustomerCard } from "./customer-card";
import { CustomerCreateModal } from './customer-create-modal';
import { CustomerEmptyCard } from "./customer-empty-card";
import { CustomerSearchDialog } from "./customer-search-dialog";
import { CustomerViewDialog } from './customer-view-dialog';
// Debounce pequeño y tipado
function useDebouncedValue<T>(value: T, delay = 300) {
@ -16,11 +17,12 @@ function useDebouncedValue<T>(value: T, delay = 300) {
return debounced;
}
interface CustomerModalSelectorProps {
type CustomerModalSelectorProps = {
value?: string;
onValueChange?: (id: string) => void;
disabled?: boolean;
readOnly?: boolean;
disabled?: boolean; // Solo lectura total (sin botones ni selección)
readOnly?: boolean; // Ver ficha, pero no cambiar/crear
initialCustomer?: CustomerSummary;
className?: string;
}
@ -34,57 +36,77 @@ export const CustomerModalSelector = ({
className,
}: CustomerModalSelectorProps) => {
const dialogId = useId();
const [showSearch, setShowSearch] = useState(false);
const [showForm, setShowForm] = useState(false);
const [showNewForm, setShowNewForm] = useState(false);
const [showView, setShowView] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const debouncedQuery = useDebouncedValue(searchQuery, 300);
// Cliente seleccionado y creación local optimista
// Cliente seleccionado + creados localmente (optimista)
const [selected, setSelected] = useState<CustomerSummary | null>(initialCustomer ?? null);
const [newClient, setNewClient] =
useState<Omit<CustomerSummary, "id" | "status" | "company_id">>(defaultCustomerFormData);
const [localCreated, setLocalCreated] = useState<CustomerSummary[]>([]);
const [newClient, setNewClient] =
useState<Omit<CustomerFormData, "id" | "status" | "company_id">>(defaultCustomerFormData);
const criteria = useMemo(
() => ({
q: debouncedQuery || "",
pageSize: 5,
orderBy: "updated_at" as const,
order: "asc" as const,
}),
[debouncedQuery]
);
// Consulta solo cuando el diálogo de búsqueda está abierto
const {
data: remoteCustomers = [],
data: remoteCustomersPage,
isLoading,
isError,
error,
} = useCustomersSearchQuery({
q: debouncedQuery,
pageSize: 5,
orderBy: "updated_at",
order: "asc",
});
} = useCustomerListQuery(
{
enabled: showSearch, // <- evita llamadas innecesarias
criteria
}
);
// Combinar locales optimistas + remotos
const customers: CustomerSummary[] = useMemo(() => {
const remoteCustomers = remoteCustomersPage ? remoteCustomersPage.items : []
const byId = new Map<string, CustomerSummary>();
[...localCreated, ...remoteCustomers].forEach((c) => byId.set(c.id, c as CustomerSummary));
return Array.from(byId.values());
}, [localCreated, remoteCustomers]);
}, [localCreated, remoteCustomersPage]);
// Sync con `value`
// Sync con value e initialCustomer
useEffect(() => {
const found = customers.find((c) => c.id === value) ?? initialCustomer;
setSelected(found);
}, [value, customers]);
const found = customers.find((c) => c.id === value) ?? initialCustomer ?? null;
setSelected(found ?? null);
}, [value, customers, initialCustomer]);
// Crear cliente (optimista) mapeando desde CustomerDraft -> CustomerSummary
const handleCreate = () => {
if (!newClient.name || !newClient.email) return;
const newCustomer: CustomerSummary = {
id: crypto.randomUUID?.() ?? Date.now().toString(),
...newClient,
};
if (!newClient.name || !newClient.email_primary) return;
const newCustomer: CustomerSummary = defaultCustomerFormData as CustomerSummary;
setLocalCreated((prev) => [newCustomer, ...prev]);
onValueChange?.(newCustomer.id); // <- ahora el "source of truth" es React Hook Form
setShowForm(false);
onValueChange?.(newCustomer.id); // RHF es el source of truth
setShowNewForm(false);
setShowSearch(false);
};
console.log(selected);
// Handlers de tarjeta según modo
const canChange = !disabled && !readOnly;
const canCreate = !disabled && !readOnly;
const canView = !!selected && !disabled;
return (
<>
@ -93,15 +115,24 @@ export const CustomerModalSelector = ({
<CustomerCard
className={className}
customer={selected}
onChangeCustomer={() => setShowSearch(true)}
onViewCustomer={() => null}
onAddNewCustomer={() => null}
onViewCustomer={canView ? () => setShowView(true) : undefined}
onChangeCustomer={canChange ? () => setShowSearch(true) : undefined}
onAddNewCustomer={canCreate ? () => setShowNewForm(true) : undefined}
/>
) : (
<CustomerEmptyCard
className={className}
onClick={() => setShowSearch(true)}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && setShowSearch(true)}
onClick={!disabled && !readOnly ? () => setShowSearch(true) : undefined}
onKeyDown={
!disabled && !readOnly
? (e) => {
if (e.key === "Enter" || e.key === " ") setShowSearch(true);
}
: undefined
}
aria-haspopup="dialog"
aria-controls={dialogId}
aria-disabled={disabled || readOnly}
/>
)}
</div>
@ -119,23 +150,28 @@ export const CustomerModalSelector = ({
setShowSearch(false);
}}
onCreateClient={(name) => {
setNewClient({ name: name ?? "", email: "" });
setShowForm(true);
setNewClient((prev) => ({ ...prev, name: name ?? "" }));
setShowNewForm(true);
}}
isLoading={isLoading}
isError={isError}
errorMessage={
isError ? ((error as Error)?.message ?? "Error al cargar clientes") : undefined
}
errorMessage={isError ? ((error as Error)?.message ?? "Error al cargar clientes") : undefined}
/>
<CreateCustomerFormDialog
open={showForm}
onOpenChange={setShowForm}
<CustomerViewDialog
customerId={selected?.id ?? null}
open={showView}
onOpenChange={setShowView}
/>
{/* Diálogo de alta rápida */}
<CustomerCreateModal
open={showNewForm}
onOpenChange={setShowNewForm}
client={newClient}
onChange={setNewClient}
onSubmit={handleCreate}
/>
</>
);
};
};

View File

@ -71,7 +71,8 @@ export const CustomerSearchDialog = ({
<div className='px-6 pb-3'>
<Command className='border rounded-lg' shouldFilter={false}>
<CommandInput
placeholder='Buscar cliente...'
autoFocus
placeholder="Buscar cliente..."
value={searchQuery}
onValueChange={onSearchQueryChange}
/>

View File

@ -0,0 +1,209 @@
import {
Badge, Button, Card, CardContent, CardHeader, CardTitle,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@repo/shadcn-ui/components";
import { Banknote, FileText, Languages, Mail, MapPin, Phone, Smartphone, X } from "lucide-react";
// CustomerViewDialog.tsx
import { useCustomerQuery } from "../../hooks";
type CustomerViewDialogProps = {
customerId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const CustomerViewDialog = ({
customerId,
open,
onOpenChange,
}: CustomerViewDialogProps) => {
const {
data: customer,
isLoading,
isError,
error,
} = useCustomerQuery(customerId ?? "", { enabled: open && !!customerId });
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl bg-card border-border p-0">
<DialogHeader className="px-6 pt-6">
<DialogTitle id="customer-view-title" className="flex items-center justify-between">
<span className="text-balance">
{customer?.name ?? "Cliente"}
{customer?.trade_name && (
<span className="ml-2 text-muted-foreground">({customer.trade_name})</span>
)}
</span>
<Button
type="button"
size="icon"
variant="ghost"
className="cursor-pointer"
onClick={() => onOpenChange(false)}
aria-label="Cerrar"
>
<X className="size-4" />
</Button>
</DialogTitle>
<DialogDescription className="px-0">
{customer?.tin ? (
<Badge variant="secondary" className="ml-0 font-mono">{customer.tin}</Badge>
) : (
<span className="text-muted-foreground">Ficha del cliente</span>
)}
</DialogDescription>
</DialogHeader>
<div className="px-6 pb-6">
{isLoading && <p role="status" aria-live="polite">Cargando</p>}
{isError && (
<p role="alert" className="text-destructive">
{(error as Error)?.message ?? "No se pudo cargar el cliente"}
</p>
)}
{!isLoading && !isError && customer && (
<div className="grid gap-6 md:grid-cols-2 max-h-[70vh] overflow-y-auto pr-1">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<FileText className="size-5 text-primary" />
Información Básica
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<dt className="text-sm text-muted-foreground">Nombre</dt>
<dd className="mt-1">{customer.name}</dd>
</div>
{customer.reference && (
<div>
<dt className="text-sm text-muted-foreground">Referencia</dt>
<dd className="mt-1 font-mono">{customer.reference}</dd>
</div>
)}
{customer.legal_record && (
<div>
<dt className="text-sm text-muted-foreground">Registro Legal</dt>
<dd className="mt-1">{customer.legal_record}</dd>
</div>
)}
{!!customer.default_taxes?.length && (
<div>
<dt className="text-sm text-muted-foreground">Impuestos por defecto</dt>
<dd className="mt-1 flex flex-wrap gap-1">
{customer.default_taxes.map((tax: string) => (
<Badge key={tax} variant="secondary">{tax}</Badge>
))}
</dd>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<MapPin className="size-5 text-primary" />
Dirección
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<dt className="text-sm text-muted-foreground">Calle</dt>
<dd className="mt-1">
{customer.street}
{customer.street2 && (<><br />{customer.street2}</>)}
</dd>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Ciudad</dt>
<dd className="mt-1">{customer.city}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Código Postal</dt>
<dd className="mt-1">{customer.postal_code}</dd>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Provincia</dt>
<dd className="mt-1">{customer.province}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">País</dt>
<dd className="mt-1">{customer.country}</dd>
</div>
</div>
</CardContent>
</Card>
<Card className="md:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Mail className="size-5 text-primary" />
Contacto y Preferencias
</CardTitle>
</CardHeader>
<CardContent className="grid gap-6 md:grid-cols-2">
<div className="space-y-3">
{customer.email_primary && (
<div className="flex items-start gap-3">
<Mail className="mt-0.5 size-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm text-muted-foreground">Email</dt>
<dd className="mt-1">{customer.email_primary}</dd>
</div>
</div>
)}
{customer.mobile_primary && (
<div className="flex items-start gap-3">
<Smartphone className="mt-0.5 size-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm text-muted-foreground">Móvil</dt>
<dd className="mt-1">{customer.mobile_primary}</dd>
</div>
</div>
)}
{customer.phone_primary && (
<div className="flex items-start gap-3">
<Phone className="mt-0.5 size-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm text-muted-foreground">Teléfono</dt>
<dd className="mt-1">{customer.phone_primary}</dd>
</div>
</div>
)}
</div>
<div className="space-y-3">
<div className="flex items-start gap-3">
<Languages className="mt-0.5 size-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm text-muted-foreground">Idioma</dt>
<dd className="mt-1">{customer.language_code}</dd>
</div>
</div>
<div className="flex items-start gap-3">
<Banknote className="mt-0.5 size-4 text-muted-foreground" />
<div className="flex-1">
<dt className="text-sm text-muted-foreground">Moneda</dt>
<dd className="mt-1">{customer.currency_code}</dd>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@ -11,21 +11,32 @@ import {
RadioGroup,
RadioGroupItem
} from '@repo/shadcn-ui/components';
import { useEffect } from 'react';
import { Controller, useFormContext } from "react-hook-form";
import { CustomerInvoiceTaxesMultiSelect } from '../../../../../customer-invoices/src/web/components';
import { useTranslation } from "../../i18n";
import { CustomerFormData } from "../../schemas";
export const CustomerBasicInfoFields = () => {
interface CustomerBasicInfoFieldsProps {
focusRef?: React.RefObject<HTMLInputElement>;
}
export const CustomerBasicInfoFields = ({ focusRef }: CustomerBasicInfoFieldsProps) => {
const { t } = useTranslation();
const { control } = useFormContext<CustomerFormData>();
// Enfoca el primer campo recibido
useEffect(() => {
focusRef?.current?.focus?.();
}, [focusRef]);
return (
<FieldSet>
<FieldLegend>{t("form_groups.basic_info.title")}</FieldLegend>
<FieldDescription>{t("form_groups.basic_info.description")}</FieldDescription>
<FieldGroup className='grid grid-cols-1 gap-x-6 lg:grid-cols-4'>
<Field className='lg:col-span-2'>
<Field className='lg:col-span-2' ref={focusRef}>
<TextField
control={control}
name='name'

View File

@ -8,14 +8,15 @@ import { CustomerAddressFields } from "./customer-address-fields";
import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
import { CustomerContactFields } from './customer-contact-fields';
interface CustomerFormProps {
type CustomerFormProps = {
formId: string;
onSubmit: (data: CustomerFormData) => void;
onError: (errors: FieldErrors<CustomerFormData>) => void;
className?: string;
}
focusRef?: React.RefObject<HTMLInputElement>;
};
export const CustomerEditForm = ({ formId, onSubmit, onError, className }: CustomerFormProps) => {
export const CustomerEditForm = ({ formId, onSubmit, onError, className, focusRef }: CustomerFormProps) => {
const form = useFormContext<CustomerFormData>();
return (
@ -26,7 +27,7 @@ export const CustomerEditForm = ({ formId, onSubmit, onError, className }: Custo
<FormDebug />
</div>
<div className='w-full xl:grow space-y-6'>
<CustomerBasicInfoFields />
<CustomerBasicInfoFields focusRef={focusRef} />
<CustomerContactFields />
<CustomerAddressFields />
<CustomerAdditionalConfigFields />

View File

@ -1,5 +1,5 @@
// components/CustomerSkeleton.tsx
import { AppBreadcrumb, AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components";
import { useTranslation } from "../../i18n";
@ -7,7 +7,6 @@ export const CustomerEditorSkeleton = () => {
const { t } = useTranslation();
return (
<>
<AppBreadcrumb />
<AppContent>
<div className='flex items-center justify-between'>
<div className='space-y-2' aria-hidden='true'>

View File

@ -1,6 +1,5 @@
export * from "./use-create-customer-mutation";
export * from "./use-customer-list-query";
export * from "./use-customer-query";
export * from "./use-customers-context";
export * from "./use-customers-query";
export * from "./use-customers-search-query";
export * from "./use-update-customer-mutation";

View File

@ -1,49 +1,82 @@
import { useDataSource } from "@erp/core/hooks";
import { UniqueID, ValidationErrorCollection } from "@repo/rdx-ddd";
import { DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { ZodError } from "zod/v4";
import { CreateCustomerRequestSchema } from "../../common";
import { Customer, CustomerFormData } from "../schemas";
import { CUSTOMERS_LIST_KEY } from "./use-update-customer-mutation";
import { CUSTOMERS_LIST_KEY, invalidateCustomerListCache } from "./use-customer-list-query";
import { getCustomerQueryKey } from "./use-customer-query";
export const CUSTOMER_CREATE_KEY = ["customers", "create"] as const;
type CreateCustomerPayload = {
data: CustomerFormData;
};
// Helpers de validación a errores de dominio
export function toValidationErrors(error: ZodError<unknown>) {
return error.issues.map((err) => ({
field: err.path.join("."),
message: err.message,
}));
}
export function useCreateCustomer() {
const queryClient = useQueryClient();
const dataSource = useDataSource();
const schema = CreateCustomerRequestSchema;
return useMutation<Customer, DefaultError, CreateCustomerPayload>({
mutationKey: ["customer:create"],
mutationKey: CUSTOMER_CREATE_KEY,
mutationFn: async (payload) => {
const { data } = payload;
const customerId = UniqueID.generateNewID();
mutationFn: async (data) => {
const id = UniqueID.generateNewID().toString();
const payload = { ...data, id };
const newCustomerData = {
...data,
id: customerId.toString(),
};
console.log("payload => ", payload);
const result = schema.safeParse(payload);
result.error;
const result = schema.safeParse(newCustomerData);
if (!result.success) {
// Construye errores detallados
const validationErrors = result.error.issues.map((err) => ({
field: err.path.join("."),
message: err.message,
}));
throw new ValidationErrorCollection("Validation failed", validationErrors);
const errorDetails = toValidationErrors(result.error);
console.log(errorDetails);
throw new ValidationErrorCollection("Validation failed", errorDetails);
}
const created = await dataSource.createOne("customers", newCustomerData);
const created = await dataSource.createOne("customers", payload);
return created as Customer;
},
onSuccess: () => {
onSuccess: (created) => {
// Invalida el listado para refrescar desde servidor
queryClient.invalidateQueries({ queryKey: CUSTOMERS_LIST_KEY });
invalidateCustomerListCache(queryClient);
// Sincroniza detalle
queryClient.setQueryData(getCustomerQueryKey(created.id), created);
},
onSettled: () => {
// Refresca todos los listados
invalidateCustomerListCache(queryClient);
},
onMutate: async ({ data }) => {
// Cancelar queries del listado para evitar overwrite
await queryClient.cancelQueries({ queryKey: [CUSTOMERS_LIST_KEY] });
const optimisticId = UniqueID.generateNewID().toString();
const optimisticCustomer: Customer = { ...data, id: optimisticId } as Customer;
// Snapshot previo
const previous = queryClient.getQueryData<Customer[]>([CUSTOMERS_LIST_KEY]);
// Optimista: prepend
queryClient.setQueryData<Customer[]>([CUSTOMERS_LIST_KEY], (old) => [
optimisticCustomer,
...(old ?? []),
]);
return { previous, optimisticId };
},
});
}

View File

@ -0,0 +1,82 @@
import { CriteriaDTO, normalizeCriteriaDTO } from '@erp/core';
import { useDataSource } from "@erp/core/hooks";
import { DefaultError, QueryClient, QueryKey, useQuery } from "@tanstack/react-query";
import { CustomerSummary, CustomersPage } from '../schemas';
export const CUSTOMERS_LIST_KEY = ["customers", "list"] as const
export const getCustomersListQueryKey = (criteria: CriteriaDTO) =>
[...CUSTOMERS_LIST_KEY, normalizeCriteriaDTO(criteria)] satisfies QueryKey;
type CustomerListQueryOptions = {
enabled?: boolean;
criteria?: CriteriaDTO
};
// Obtener todos los clientes
export const useCustomerListQuery = (options?: CustomerListQueryOptions) => {
const dataSource = useDataSource();
const enabled = options?.enabled ?? true;
const criteria = options?.criteria ?? {};
return useQuery<CustomersPage, DefaultError>({
queryKey: getCustomersListQueryKey(criteria),
queryFn: async ({ signal }) => {
return await dataSource.getList<CustomersPage>("customers", { signal, ...criteria });
},
enabled,
placeholderData: (previousData, previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
});
};
export function cancelCustomerListQueries(qc: QueryClient) {
return qc.cancelQueries({ queryKey: CUSTOMERS_LIST_KEY });
}
export function invalidateCustomerListCache(qc: QueryClient) {
return qc.invalidateQueries({ queryKey: CUSTOMERS_LIST_KEY });
}
export function getAllCustomerListQueryKeys(qc: QueryClient): QueryKey[] {
// Nota: `getQueriesData` devuelve pares [key, data]
const entries = qc.getQueriesData<CustomersPage>({ queryKey: [CUSTOMERS_LIST_KEY] });
return entries.map(([key]) => key);
}
// Inserta o reemplaza un customer en todas las páginas donde aparezca
export function upsertCustomerIntoListCaches(qc: QueryClient, customer: CustomerSummary) {
const keys = getAllCustomerListQueryKeys(qc);
for (const key of keys) {
const page = qc.getQueryData<CustomersPage>(key);
if (!page) continue;
const idx = page.items.findIndex((c) => c.id === customer.id);
if (idx === -1) continue;
const nextItems = page.items.slice();
nextItems[idx] = { ...page.items[idx], ...customer };
qc.setQueryData<CustomersPage>(key, { ...page, items: nextItems });
}
}
export function deleteCustomerIntoListCaches(qc: QueryClient, customerId: string): {
snapshots: Array<{ key: QueryKey; page?: CustomersPage }>;
} {
const snapshots = getAllCustomerListQueryKeys(qc).map((key) => ({
key,
page: qc.getQueryData<CustomersPage>(key),
}));
for (const { key, page } of snapshots) {
if (!page) continue;
qc.setQueryData<CustomersPage>(key, {
...page,
items: page.items.filter((c) => c.id !== customerId),
total_items: Math.max(0, page.total_items - 1),
});
}
return { snapshots: snapshots as Array<{ key: QueryKey; page?: CustomersPage }> };
}

View File

@ -1,48 +1,49 @@
import { useDataSource } from "@erp/core/hooks";
import { DefaultError, type QueryKey, useQuery } from "@tanstack/react-query";
import { DefaultError, QueryClient, type QueryKey, useQuery } from "@tanstack/react-query";
import { Customer } from "../schemas";
export const CUSTOMER_QUERY_KEY = (id: string): QueryKey => ["customer", id] as const;
export const CUSTOMER_DETAIL_SCOPE = "customers:detail" as const;
type CustomerQueryOptions = {
export const getCustomerQueryKey = (id: string) =>
[CUSTOMER_DETAIL_SCOPE, { id }] satisfies QueryKey;
type CustomerQueryOptions<TSelected> = {
enabled?: boolean;
staleTime?: number;
select?: (data: Customer) => TSelected;
placeholderData?: Customer;
};
export function useCustomerQuery(customerId?: string, options?: CustomerQueryOptions) {
export function useCustomerQuery<TSelected = Customer>(
customerId?: string,
options?: CustomerQueryOptions<TSelected>
) {
const dataSource = useDataSource();
const enabled = (options?.enabled ?? true) && !!customerId;
const enabled = (options?.enabled ?? true) && Boolean(customerId);
return useQuery<Customer, DefaultError>({
queryKey: CUSTOMER_QUERY_KEY(customerId ?? "unknown"),
return useQuery<Customer, DefaultError, TSelected>({
queryKey: getCustomerQueryKey(customerId ?? "unknown"),
queryFn: async (context) => {
const { signal } = context;
if (!customerId) {
if (!customerId) throw new Error("customerId is required");
}
return await dataSource.getOne<Customer>("customers", customerId, {
signal,
});
return await dataSource.getOne<Customer>("customers", customerId, { signal });
},
enabled,
staleTime: options?.staleTime ?? 60_000, // 1 min por defecto
select: options?.select,
placeholderData: options?.placeholderData,
});
}
/*
export function useQuery<
TQueryFnData = unknown,
TError = unknown,
TData = TQueryFnData,
TQueryKey extends QueryKey = QueryKey
>
export function invalidateCustomerDetailCache(qc: QueryClient, id: string) {
return qc.invalidateQueries({
queryKey: getCustomerQueryKey(id ?? "unknown"),
exact: Boolean(id),
});
}
TQueryFnData: the type returned from the queryFn.
TError: the type of Errors to expect from the queryFn.
TData: the type our data property will eventually have.
Only relevant if you use the select option,
because then the data property can be different
from what the queryFn returns.
Otherwise, it will default to whatever the queryFn returns.
TQueryKey: the type of our queryKey, only relevant
if you use the queryKey that is passed to your queryFn.
*/
export function setCustomerDetailCache(qc: QueryClient, id: string, data: unknown) {
qc.setQueryData(getCustomerQueryKey(id), data);
}

View File

@ -1,40 +0,0 @@
import { CriteriaDTO } from '@erp/core';
import { useDataSource } from "@erp/core/hooks";
import { DefaultError, QueryKey, useQuery } from "@tanstack/react-query";
import { CustomersPage } from '../schemas';
export const CUSTOMERS_QUERY_KEY = (criteria: CriteriaDTO): QueryKey => [
"customer_invoices", {
pageNumber: criteria.pageNumber ?? 0,
pageSize: criteria.pageSize ?? 10,
q: criteria.q ?? "",
filters: criteria.filters ?? [],
orderBy: criteria.orderBy ?? "",
order: criteria.order ?? "",
},
];
type CustomersQueryOptions = {
enabled?: boolean;
criteria?: CriteriaDTO
};
// Obtener todos los clientes
export const useCustomersQuery = (options?: CustomersQueryOptions) => {
const dataSource = useDataSource();
const enabled = options?.enabled ?? true;
const criteria = options?.criteria ?? {};
return useQuery<CustomersPage, DefaultError>({
queryKey: CUSTOMERS_QUERY_KEY(criteria),
queryFn: async ({ signal }) => {
return await dataSource.getList<CustomersPage>("customers", {
signal,
...criteria,
});
},
enabled,
placeholderData: (previousData, previousQuery) => previousData, // Mantener datos previos mientras se carga nueva datos (antiguo `keepPreviousData`)
});
};

View File

@ -1,31 +0,0 @@
import { useDataSource } from "@erp/core/hooks";
import { useQuery } from "@tanstack/react-query";
import { CustomerSummary, CustomersPage } from "../schemas";
export interface CustomersCriteria {
q?: string;
orderBy?: string;
order?: string;
pageSize?: number;
pageNumber?: number;
}
// Obtener todos los clientes
export const useCustomersSearchQuery = (criteria: CustomersCriteria) => {
const dataSource = useDataSource();
return useQuery({
queryKey: ["customer", criteria],
queryFn: async (context) => {
const { signal } = context;
const customers = await dataSource.getList("customers", {
signal,
...criteria,
});
return customers as CustomersPage;
},
select: (data) => data.items as CustomerSummary[],
});
};

View File

@ -0,0 +1,50 @@
import { useDataSource } from "@erp/core/hooks";
import { DefaultError, QueryKey, useMutation, useQueryClient } from "@tanstack/react-query";
import { CustomersPage } from "../schemas";
import { cancelCustomerListQueries, deleteCustomerIntoListCaches } from "./use-customer-list-query";
import { invalidateCustomerDetailCache } from "./use-customer-query";
export const CUSTOMER_DELETE_KEY = ["customers", "delete"] as const;
type DeleteCustomerPayload = {
id: string;
};
export function useDeleteCustomer() {
const queryClient = useQueryClient();
const dataSource = useDataSource();
return useMutation<{ id: string }, DefaultError, DeleteCustomerPayload>({
mutationKey: CUSTOMER_DELETE_KEY,
mutationFn: async ({ id: customerId }) => {
if (!customerId) {
throw new Error("customerId is required");
}
await dataSource.deleteOne("customers", customerId);
return { id: customerId };
},
onMutate: async ({ id }) => {
await cancelCustomerListQueries(queryClient);
return deleteCustomerIntoListCaches(queryClient, id);
},
onError: (_e, _v, ctx) => {
if (!ctx) return;
const { snapshots } = ctx as ReturnType<typeof deleteCustomerIntoListCaches>;
for (const snap of snapshots as Array<{ key: QueryKey; page?: CustomersPage }>) {
if (snap.page) queryClient.setQueryData(snap.key, snap.page);
}
},
onSuccess: ({ id }) => {
invalidateCustomerDetailCache(queryClient, id);
},
onSettled: (data) => {
deleteCustomerIntoListCaches(queryClient, data?.id ?? "unknown");
},
});
}

View File

@ -1,11 +1,13 @@
import { useDataSource } from "@erp/core/hooks";
import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { UpdateCustomerByIdRequestDTO, UpdateCustomerByIdRequestSchema } from "../../common";
import { CustomerFormData } from "../schemas";
import { CUSTOMER_QUERY_KEY } from "./use-customer-query";
import { UpdateCustomerByIdRequestSchema } from "../../common";
import { Customer, CustomerFormData } from "../schemas";
import { toValidationErrors } from "./use-create-customer-mutation";
import { upsertCustomerIntoListCaches } from "./use-customer-list-query";
import { setCustomerDetailCache } from "./use-customer-query";
export const CUSTOMERS_LIST_KEY = ["customers"] as const;
export const CUSTOMER_UPDATE_KEY = ["customers", "update"] as const;
type UpdateCustomerContext = {};
@ -19,8 +21,8 @@ export function useUpdateCustomer() {
const dataSource = useDataSource();
const schema = UpdateCustomerByIdRequestSchema;
return useMutation<CustomerFormData, Error, UpdateCustomerPayload, UpdateCustomerContext>({
mutationKey: ["customer:update"], //, customerId],
return useMutation<Customer, Error, UpdateCustomerPayload, UpdateCustomerContext>({
mutationKey: CUSTOMER_UPDATE_KEY,
mutationFn: async (payload) => {
const { id: customerId, data } = payload;
@ -30,32 +32,21 @@ export function useUpdateCustomer() {
const result = schema.safeParse(data);
if (!result.success) {
// Construye errores detallados
const validationErrors = result.error.issues.map((err) => ({
field: err.path.join("."),
message: err.message,
}));
throw new ValidationErrorCollection("Validation failed", validationErrors);
throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
}
const updated = await dataSource.updateOne("customers", customerId, data);
return updated as CustomerFormData;
return updated as Customer;
},
onSuccess: (updated: CustomerFormData, variables) => {
onSuccess: (updated: Customer, variables) => {
const { id: customerId } = variables;
// Refresca inmediatamente el detalle
queryClient.setQueryData<UpdateCustomerByIdRequestDTO>(
CUSTOMER_QUERY_KEY(customerId),
updated
);
// Actualiza detalle
setCustomerDetailCache(queryClient, customerId, updated);
// Otra opción es invalidar el detalle para forzar refetch:
// queryClient.invalidateQueries({ queryKey: CUSTOMER_QUERY_KEY(customerId) });
// Invalida el listado para refrescar desde servidor
queryClient.invalidateQueries({ queryKey: CUSTOMERS_LIST_KEY });
// Actualiza todas las páginas donde aparezca
upsertCustomerIntoListCaches(queryClient, { ...updated });
},
});
}

View File

@ -2,60 +2,20 @@ import { AppContent, AppHeader } from "@repo/rdx-ui/components";
import { useNavigate } from "react-router-dom";
import { PageHeader } from '@erp/core/components';
import { UnsavedChangesProvider, UpdateCommitButtonGroup, useHookForm } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { FieldErrors, FormProvider } from "react-hook-form";
import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks";
import { CustomerEditForm, ErrorAlert } from "../../components";
import { useCreateCustomer } from "../../hooks";
import { useTranslation } from "../../i18n";
import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas";
import { useCustomerCreateController } from './use-customer-create-controller';
export const CustomerCreatePage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
// 1) Estado de creación (mutación)
const {
mutate,
isPending: isCreating,
isError: isCreateError,
error: createError,
} = useCreateCustomer();
form, isCreating, isCreateError, createError,
handleSubmit, handleError, FormProvider
} = useCustomerCreateController();
// 2) Form hook
const form = useHookForm<CustomerFormData>({
resolverSchema: CustomerFormSchema,
initialValues: defaultCustomerFormData,
disabled: isCreating,
});
// 3) Submit con navegación condicionada por éxito
const handleSubmit = (formData: CustomerFormData) => {
mutate(
{ data: formData },
{
onSuccess(data) {
showSuccessToast(t("pages.create.successTitle"), t("pages.create.successMsg"));
// 🔹 limpiar el form e isDirty pasa a false
form.reset(defaultCustomerFormData);
navigate("/customers/list", {
state: { customerId: data.id, isNew: true },
replace: true,
});
},
onError(error) {
showErrorToast(t("pages.create.errorTitle"), error.message);
},
}
);
};
const handleError = (errors: FieldErrors<CustomerFormData>) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
const handleBack = () => {
navigate(-1);
@ -100,7 +60,11 @@ export const CustomerCreatePage = () => {
<FormProvider {...form}>
<CustomerEditForm
formId='customer-create-form'
onSubmit={handleSubmit}
onSubmit={(data) =>
handleSubmit(data, ({ id }) =>
navigate("/customers/list", { state: { customerId: id, isNew: true }, replace: true })
)
}
onError={handleError}
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto"
/>

View File

@ -0,0 +1,73 @@
import { useHookForm } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
import { FieldErrors, FormProvider } from "react-hook-form";
import { useCreateCustomer } from "../../hooks";
import { useTranslation } from "../../i18n";
import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas";
export const useCustomerCreateController = () => {
const { t } = useTranslation();
// 1) Estado de creación (mutación)
const {
mutate,
isPending: isCreating,
isError: isCreateError,
error: createError,
} = useCreateCustomer();
// 2) Form hook
const form = useHookForm<CustomerFormData>({
resolverSchema: CustomerFormSchema,
initialValues: defaultCustomerFormData,
disabled: isCreating,
});
const handleSubmit = (formData: CustomerFormData, onSuccess?: (data: { id: string }) => void) => {
console.log(formData);
mutate(
{ data: formData },
{
onSuccess(data) {
form.reset(defaultCustomerFormData);
showSuccessToast(
t("pages.create.successTitle", "Cliente creado"),
t("pages.create.successMsg", "Se ha creado correctamente.")
);
onSuccess?.({ id: data.id });
},
onError(err: unknown) {
console.log("No se pudo crear el cliente.");
const msg =
(err as Error)?.message ??
t("pages.create.error.message", "No se pudo crear el cliente.");
showErrorToast(t("pages.create.error.title", "Error al crear"), msg);
},
}
);
};
const handleError = (errors: FieldErrors<CustomerFormData>) => {
// 1) Enfoca el primer error
const firstKey = Object.keys(errors)[0] as keyof CustomerFormData | undefined;
if (firstKey) {
const el = document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`);
el?.focus();
}
// 2) Toast informativo
showWarningToast(
t("forms.validation.title", "Revisa los campos"),
t("forms.validation.msg", "Hay errores de validación en el formulario.")
);
};
return {
form,
isCreating,
isCreateError,
createError,
handleSubmit,
handleError,
FormProvider, // para no re-importar fuera
};
};

View File

@ -5,7 +5,7 @@ import { PlusIcon } from "lucide-react";
import { useMemo, useState } from 'react';
import { Outlet, useNavigate } from "react-router-dom";
import { ErrorAlert } from '../../components';
import { useCustomersQuery } from '../../hooks';
import { useCustomerListQuery } from '../../hooks';
import { useTranslation } from "../../i18n";
import { CustomersListGrid } from './customers-list-grid';
@ -34,7 +34,7 @@ export const CustomersListPage = () => {
isLoading,
isError,
error,
} = useCustomersQuery({
} = useCustomerListQuery({
criteria
});
@ -68,7 +68,6 @@ export const CustomersListPage = () => {
);
}
return (
<>
<AppHeader>

View File

@ -74,7 +74,7 @@ export function CustomerEditModal({ customerId, open, onOpenChange }: CustomerEd
{ id: customerId!, data: patchData },
{
onSuccess(data) {
showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg"));
showSuccessToast(t("pages.update.success.title"), t("pages.update.success.message"));
// 🔹 limpiar el form e isDirty pasa a false
form.reset(data);

View File

@ -63,7 +63,7 @@ export const CustomerUpdatePage = () => {
{ id: customerId!, data: patchData },
{
onSuccess(data) {
showSuccessToast(t("pages.update.successTitle"), t("pages.update.successMsg"));
showSuccessToast(t("pages.update.success.title"), t("pages.update.success.message"));
// 🔹 limpiar el form e isDirty pasa a false
form.reset(data);

View File

@ -1 +1,2 @@
export * from "./use-device-info.ts";
export * from "./use-row-selection.ts";

View File

@ -0,0 +1,155 @@
import { useEffect, useMemo, useState } from "react";
export const DEFAULT_PHONE_MAX = 768;
export const DEFAULT_TABLET_MAX = 1024;
type DeviceType = "phone" | "tablet" | "desktop";
type DeviceInfo = {
isMobile: boolean;
deviceType: DeviceType;
// Señales útiles para decisiones finas de UX:
widthNarrow: boolean; // <= phoneMax
widthTablet: boolean; // > phoneMax && <= tabletMax
pointerCoarse: boolean; // puntero táctil grueso
hoverNone: boolean; // no hay hover
maxTouchPoints: number;
// Info de plataforma cuando está disponible
platform?: string;
model?: string;
uaMobileHint?: boolean; // de UA/Client Hints si se puede
};
type Options = {
phoneMax?: number; // por defecto DEFAULT_PHONE_MAX
tabletMax?: number; // por defecto DEFAULT_TABLET_MAX
// Para SSR/Next: primera suposición que evitará saltos visuales
serverGuess?: Partial<Pick<DeviceInfo, "deviceType" | "isMobile">>;
};
/** Hook base para media queries */
function useMediaQuery(query: string, fallback = false) {
const [matches, setMatches] = useState(fallback);
useEffect(() => {
if (typeof window === "undefined") return; // SSR
const mql = window.matchMedia(query);
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches);
setMatches(mql.matches);
mql.addEventListener?.("change", onChange);
return () => mql.removeEventListener?.("change", onChange);
}, [query]);
return matches;
}
/** Clasificador a partir de señales */
function classifyDevice(
widthNarrow: boolean,
widthTablet: boolean,
pointerCoarse: boolean,
hoverNone: boolean
): DeviceType {
// Heurística práctica:
if (pointerCoarse && (widthNarrow || hoverNone)) return "phone";
if (pointerCoarse && widthTablet) return "tablet";
return "desktop";
}
export function useDeviceInfo(opts: Options = {}): DeviceInfo {
const phoneMax = opts.phoneMax ?? DEFAULT_PHONE_MAX;
const tabletMax = opts.tabletMax ?? DEFAULT_TABLET_MAX;
// Inicialización segura para SSR para evitar hydration mismatch.
// Si hay serverGuess, úsalo; si no, asume desktop hasta montar.
const [initial, setInitial] = useState<DeviceInfo | null>(() => {
if (typeof window === "undefined") {
const deviceType = opts.serverGuess?.deviceType ?? "desktop";
const isMobile = opts.serverGuess?.isMobile ?? deviceType !== "desktop";
return {
isMobile,
deviceType,
widthNarrow: deviceType === "phone",
widthTablet: deviceType === "tablet",
pointerCoarse: deviceType !== "desktop",
hoverNone: deviceType !== "desktop",
maxTouchPoints: 0,
};
}
return null; // en cliente, calcularemos real en useEffect
});
const widthNarrow = useMediaQuery(`(max-width: ${phoneMax}px)`, initial?.widthNarrow ?? false);
const widthTablet = useMediaQuery(
`(min-width: ${phoneMax + 1}px) and (max-width: ${tabletMax}px)`,
initial?.widthTablet ?? false
);
const pointerCoarse = useMediaQuery("(pointer: coarse)", initial?.pointerCoarse ?? false);
const hoverNone = useMediaQuery("(hover: none)", initial?.hoverNone ?? false);
const [maxTouchPoints, setMaxTouchPoints] = useState(initial?.maxTouchPoints ?? 0);
const [uaHints, setUaHints] = useState<{ platform?: string; model?: string; mobile?: boolean }>(
{}
);
useEffect(() => {
if (typeof window === "undefined") return;
// maxTouchPoints es útil para detectar iPadOS que se disfraza de Mac
setMaxTouchPoints(navigator.maxTouchPoints || 0);
// Client Hints (Chromium). No bloquea si no existen.
(async () => {
const nav = navigator as any;
if (nav.userAgentData?.getHighEntropyValues) {
try {
const { platform, mobile, model } = await nav.userAgentData.getHighEntropyValues([
"platform",
"mobile",
"model",
]);
setUaHints({ platform, mobile, model });
} catch {
// ignorar excepciones
}
}
})();
}, []);
const deviceType = useMemo(() => {
// Si es Mac pero con touchPoints > 1 => probablemente iPadOS en modo desktop
const forceTouch =
typeof window !== "undefined" &&
/Macintosh/i.test(navigator.userAgent || "") &&
maxTouchPoints > 1;
return classifyDevice(
widthNarrow,
widthTablet,
pointerCoarse || forceTouch,
hoverNone || forceTouch
);
}, [widthNarrow, widthTablet, pointerCoarse, hoverNone, maxTouchPoints]);
const isMobile = deviceType !== "desktop";
// Si teníamos estado inicial en SSR, al primer render en cliente lo reemplazamos por el real
useEffect(() => {
if (initial) setInitial(null);
}, [initial]);
return {
isMobile,
deviceType,
widthNarrow,
widthTablet,
pointerCoarse,
hoverNone,
maxTouchPoints,
platform: uaHints.platform,
model: uaHints.model,
uaMobileHint: uaHints.mobile,
};
}