From 7cad265affb9fbd32a8bd94d5f5c3121eff48f8c Mon Sep 17 00:00:00 2001 From: david Date: Mon, 20 Oct 2025 20:40:28 +0200 Subject: [PATCH] Clientes y Facturas de cliente --- modules/core/src/common/dto/critera.dto.ts | 19 ++ .../components/cancel-form-button.tsx | 3 + .../components/form-commit-button-group.tsx | 1 - .../customer-invoice-editor-skeleton.tsx | 3 +- .../create/create-customer-invoice-page.tsx | 3 +- .../web/pages/update/invoice-update-comp.tsx | 12 +- .../web/pages/update/invoice-update-form.tsx | 3 +- .../response/create-customer.result.dto.ts | 41 +--- modules/customers/src/common/locales/en.json | 19 +- modules/customers/src/common/locales/es.json | 19 +- .../web/components/client-selector-modal.tsx | 4 +- .../create-customer-form-dialog.tsx | 100 --------- .../customer-modal-selector/customer-card.tsx | 80 ++++--- .../customer-create-modal.tsx | 91 ++++++++ .../customer-modal-selector-field.tsx | 14 +- .../customer-modal-selector.tsx | 130 +++++++---- .../customer-search-dialog.tsx | 3 +- .../customer-view-dialog.tsx | 209 ++++++++++++++++++ .../editor/customer-basic-info-fields.tsx | 15 +- .../components/editor/customer-edit-form.tsx | 9 +- .../editor/customer-editor-skeleton.tsx | 3 +- modules/customers/src/web/hooks/index.ts | 3 +- .../web/hooks/use-create-customer-mutation.ts | 75 +++++-- .../src/web/hooks/use-customer-list-query.tsx | 82 +++++++ .../src/web/hooks/use-customer-query.ts | 57 ++--- .../src/web/hooks/use-customers-query.tsx | 40 ---- .../web/hooks/use-customers-search-query.tsx | 31 --- .../web/hooks/use-delete-customer-mutation.ts | 50 +++++ .../web/hooks/use-update-customer-mutation.ts | 41 ++-- .../web/pages/create/customer-create-page.tsx | 56 +---- .../create/use-customer-create-controller.ts | 73 ++++++ .../web/pages/list/customers-list-page.tsx | 5 +- .../pages/update/customer-update-modal.tsx | 2 +- .../web/pages/update/customer-update-page.tsx | 2 +- packages/rdx-ui/src/hooks/index.ts | 1 + packages/rdx-ui/src/hooks/use-device-info.ts | 155 +++++++++++++ 36 files changed, 1005 insertions(+), 449 deletions(-) delete mode 100644 modules/customers/src/web/components/customer-modal-selector/create-customer-form-dialog.tsx create mode 100644 modules/customers/src/web/components/customer-modal-selector/customer-create-modal.tsx create mode 100644 modules/customers/src/web/components/customer-modal-selector/customer-view-dialog.tsx create mode 100644 modules/customers/src/web/hooks/use-customer-list-query.tsx delete mode 100644 modules/customers/src/web/hooks/use-customers-query.tsx delete mode 100644 modules/customers/src/web/hooks/use-customers-search-query.tsx create mode 100644 modules/customers/src/web/hooks/use-delete-customer-mutation.ts create mode 100644 modules/customers/src/web/pages/create/use-customer-create-controller.ts create mode 100644 packages/rdx-ui/src/hooks/use-device-info.ts diff --git a/modules/core/src/common/dto/critera.dto.ts b/modules/core/src/common/dto/critera.dto.ts index 34dc6e4a..98d7630d 100644 --- a/modules/core/src/common/dto/critera.dto.ts +++ b/modules/core/src/common/dto/critera.dto.ts @@ -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; + +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 }; +} diff --git a/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/cancel-form-button.tsx b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/cancel-form-button.tsx index 71c9bdd1..40c3dab7 100644 --- a/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/cancel-form-button.tsx +++ b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/cancel-form-button.tsx @@ -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; // 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 ( - - - - - ); -}; diff --git a/modules/customers/src/web/components/customer-modal-selector/customer-card.tsx b/modules/customers/src/web/components/customer-modal-selector/customer-card.tsx index 3b9be44e..1c74913e 100644 --- a/modules/customers/src/web/components/customer-modal-selector/customer-card.tsx +++ b/modules/customers/src/web/components/customer-modal-selector/customer-card.tsx @@ -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 = ({ {customer.name} - + {/* Eye solo si onViewCustomer existe */} + {onViewCustomer && ( + + )} - +
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 && (
{customer.tin}
@@ -113,32 +122,35 @@ export const CustomerCard = ({ ) : ( Sin dirección )} - +
{/* Footer con acciones */} - - - + {onChangeCustomer && ( + + )} + {onAddNewCustomer && ( + + )} ); diff --git a/modules/customers/src/web/components/customer-modal-selector/customer-create-modal.tsx b/modules/customers/src/web/components/customer-modal-selector/customer-create-modal.tsx new file mode 100644 index 00000000..0a13619c --- /dev/null +++ b/modules/customers/src/web/components/customer-modal-selector/customer-create-modal.tsx @@ -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 ( + + + + + + + {t("pages.create.title")} + + {t("pages.create.subtitle")} + + +
+ + handleSubmit(data, () => onOpenChange(false))} + onError={handleError} + className="max-w-none" + /> + + {isCreateError && ( +

+ {(createError as Error)?.message} +

+ )} +
+ + + + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector-field.tsx b/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector-field.tsx index 610146da..e024582f 100644 --- a/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector-field.tsx +++ b/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector-field.tsx @@ -7,7 +7,6 @@ type CustomerModalSelectorFieldProps = { control: Control; name: FieldPath; disabled?: boolean; - required?: boolean; readOnly?: boolean; className?: string; }; @@ -15,9 +14,8 @@ type CustomerModalSelectorFieldProps = { export function CustomerModalSelectorField({ 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) { const isDisabled = disabled; @@ -29,10 +27,14 @@ export function CustomerModalSelectorField({ name={name} render={({ field }) => { const { name, value, onChange, onBlur, ref } = field; - console.log({ name, value, onChange, onBlur, ref }); return ( - + ); }} diff --git a/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector.tsx b/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector.tsx index 4953aef6..808a64c0 100644 --- a/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector.tsx +++ b/modules/customers/src/web/components/customer-modal-selector/customer-modal-selector.tsx @@ -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(value: T, delay = 300) { @@ -16,11 +17,12 @@ function useDebouncedValue(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(initialCustomer ?? null); - - const [newClient, setNewClient] = - useState>(defaultCustomerFormData); - const [localCreated, setLocalCreated] = useState([]); + const [newClient, setNewClient] = + useState>(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(); [...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 = ({ setShowSearch(true)} - onViewCustomer={() => null} - onAddNewCustomer={() => null} + onViewCustomer={canView ? () => setShowView(true) : undefined} + onChangeCustomer={canChange ? () => setShowSearch(true) : undefined} + onAddNewCustomer={canCreate ? () => setShowNewForm(true) : undefined} /> ) : ( 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} /> )} @@ -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} /> - + + {/* Diálogo de alta rápida */} + ); -}; +}; \ No newline at end of file diff --git a/modules/customers/src/web/components/customer-modal-selector/customer-search-dialog.tsx b/modules/customers/src/web/components/customer-modal-selector/customer-search-dialog.tsx index a113c3d5..5c6febb6 100644 --- a/modules/customers/src/web/components/customer-modal-selector/customer-search-dialog.tsx +++ b/modules/customers/src/web/components/customer-modal-selector/customer-search-dialog.tsx @@ -71,7 +71,8 @@ export const CustomerSearchDialog = ({
diff --git a/modules/customers/src/web/components/customer-modal-selector/customer-view-dialog.tsx b/modules/customers/src/web/components/customer-modal-selector/customer-view-dialog.tsx new file mode 100644 index 00000000..5c91937b --- /dev/null +++ b/modules/customers/src/web/components/customer-modal-selector/customer-view-dialog.tsx @@ -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 ( + + + + + + {customer?.name ?? "Cliente"} + {customer?.trade_name && ( + ({customer.trade_name}) + )} + + + + + {customer?.tin ? ( + {customer.tin} + ) : ( + Ficha del cliente + )} + + + +
+ {isLoading &&

Cargando…

} + {isError && ( +

+ {(error as Error)?.message ?? "No se pudo cargar el cliente"} +

+ )} + + {!isLoading && !isError && customer && ( +
+ + + + + Información Básica + + + +
+
Nombre
+
{customer.name}
+
+ {customer.reference && ( +
+
Referencia
+
{customer.reference}
+
+ )} + {customer.legal_record && ( +
+
Registro Legal
+
{customer.legal_record}
+
+ )} + {!!customer.default_taxes?.length && ( +
+
Impuestos por defecto
+
+ {customer.default_taxes.map((tax: string) => ( + {tax} + ))} +
+
+ )} +
+
+ + + + + + Dirección + + + +
+
Calle
+
+ {customer.street} + {customer.street2 && (<>
{customer.street2})} +
+
+
+
+
Ciudad
+
{customer.city}
+
+
+
Código Postal
+
{customer.postal_code}
+
+
+
+
+
Provincia
+
{customer.province}
+
+
+
País
+
{customer.country}
+
+
+
+
+ + + + + + Contacto y Preferencias + + + +
+ {customer.email_primary && ( +
+ +
+
Email
+
{customer.email_primary}
+
+
+ )} + {customer.mobile_primary && ( +
+ +
+
Móvil
+
{customer.mobile_primary}
+
+
+ )} + {customer.phone_primary && ( +
+ +
+
Teléfono
+
{customer.phone_primary}
+
+
+ )} +
+ +
+
+ +
+
Idioma
+
{customer.language_code}
+
+
+
+ +
+
Moneda
+
{customer.currency_code}
+
+
+
+
+
+
+ )} +
+
+
+ ); +}; diff --git a/modules/customers/src/web/components/editor/customer-basic-info-fields.tsx b/modules/customers/src/web/components/editor/customer-basic-info-fields.tsx index 95f66883..88d9d1c2 100644 --- a/modules/customers/src/web/components/editor/customer-basic-info-fields.tsx +++ b/modules/customers/src/web/components/editor/customer-basic-info-fields.tsx @@ -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; +} + +export const CustomerBasicInfoFields = ({ focusRef }: CustomerBasicInfoFieldsProps) => { const { t } = useTranslation(); const { control } = useFormContext(); + // Enfoca el primer campo recibido + useEffect(() => { + focusRef?.current?.focus?.(); + }, [focusRef]); + + return (
{t("form_groups.basic_info.title")} {t("form_groups.basic_info.description")} - + void; onError: (errors: FieldErrors) => void; className?: string; -} + focusRef?: React.RefObject; +}; -export const CustomerEditForm = ({ formId, onSubmit, onError, className }: CustomerFormProps) => { +export const CustomerEditForm = ({ formId, onSubmit, onError, className, focusRef }: CustomerFormProps) => { const form = useFormContext(); return ( @@ -26,7 +27,7 @@ export const CustomerEditForm = ({ formId, onSubmit, onError, className }: Custo
- + diff --git a/modules/customers/src/web/components/editor/customer-editor-skeleton.tsx b/modules/customers/src/web/components/editor/customer-editor-skeleton.tsx index ca0b7bab..872650b0 100644 --- a/modules/customers/src/web/components/editor/customer-editor-skeleton.tsx +++ b/modules/customers/src/web/components/editor/customer-editor-skeleton.tsx @@ -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 ( <> -