Clientes y Facturas de cliente

This commit is contained in:
David Arranz 2025-10-23 19:29:52 +02:00
parent 8efd73abb4
commit e5cb2b318d
26 changed files with 492 additions and 388 deletions

View File

@ -61,19 +61,7 @@ export const App = () => {
<RouterProvider router={appRouter} /> <RouterProvider router={appRouter} />
</Suspense> </Suspense>
</TooltipProvider> </TooltipProvider>
<Toaster <Toaster expand={true} position='bottom-center'
toastOptions={
{
//unstyled: true,
/*classNames: {
error: "bg-red-400",
success: "text-green-400",
warning: "text-yellow-400",
info: "bg-blue-400",
},*/
}
}
position='bottom-center'
/> />
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />} {import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
</AuthProvider> </AuthProvider>

View File

@ -9,21 +9,15 @@ type UnsavedChangesDialogProps = {
export function UnsavedChangesDialog({ open, onConfirm }: UnsavedChangesDialogProps) { export function UnsavedChangesDialog({ open, onConfirm }: UnsavedChangesDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const defaultTitle = t ? t("hooks.unsaved_changes_dialog.title") : "¿Descartar cambios?"; return <CustomDialog
const defaultDescription = t open={open}
? t("hooks.unsaved_changes_dialog.description") onConfirm={onConfirm}
: "Tienes cambios sin guardar. Si sales de esta página, se perderán."; title={t("hooks.unsaved_changes_dialog.title", "¿Descartar cambios?")}
const defaultCancelLabel = t ? t("hooks.unsaved_changes_dialog.cancel") : "Seguir en esta página"; description={t(
const defaultConfirmLabel = t ? t("hooks.unsaved_changes_dialog.confirm") : "Descartar cambios"; "hooks.unsaved_changes_dialog.description",
"Tienes cambios sin guardar. Si sales de esta página, se perderán."
return ( )}
<CustomDialog cancelLabel={t("hooks.unsaved_changes_dialog.cancel", "Seguir en esta página")}
open={open} confirmLabel={t("hooks.unsaved_changes_dialog.confirm", "Descartar cambios")}
onConfirm={onConfirm} />
title={defaultTitle}
description={defaultDescription}
confirmLabel={defaultConfirmLabel}
cancelLabel={defaultCancelLabel}
/>
);
} }

View File

@ -23,7 +23,7 @@ export function UnsavedChangesProvider({
isDirty: boolean; isDirty: boolean;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [resolver, setResolver] = useState<(ok: boolean) => void>(); const [resolver, setResolver] = useState<((ok: boolean) => void) | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// requestConfirm() devuelve una promesa que resuelve true/false // requestConfirm() devuelve una promesa que resuelve true/false
@ -37,15 +37,16 @@ export function UnsavedChangesProvider({
const handleConfirm = (ok: boolean) => { const handleConfirm = (ok: boolean) => {
resolver?.(ok); resolver?.(ok);
setResolver(undefined); setResolver(null);
setOpen(false); setOpen(false);
}; };
// 🔹 bloquea F5/cierre de pestaña // bloquea F5/cierre de pestaña
useBeforeUnload( useBeforeUnload(
(event) => { (event) => {
if (isDirty) { if (isDirty) {
event.preventDefault(); event.preventDefault();
event.returnValue = ""; // requerido por Chrome/Edge
} }
}, },
{ capture: true } { capture: true }
@ -55,13 +56,14 @@ export function UnsavedChangesProvider({
const navigator = useContext(UNSAFE_NavigationContext).navigator as any; const navigator = useContext(UNSAFE_NavigationContext).navigator as any;
useEffect(() => { useEffect(() => {
if (!isDirty) return;
const push = navigator.push; const push = navigator.push;
navigator.push = async (...args: any[]) => {
const ok = await requestConfirm(); if (isDirty) {
if (ok) push.apply(navigator, args); navigator.push = async (...args: any[]) => {
}; const ok = await requestConfirm();
if (ok) push.apply(navigator, args);
};
}
return () => { return () => {
navigator.push = push; navigator.push = push;
@ -72,18 +74,21 @@ export function UnsavedChangesProvider({
const blocker = useBlocker(() => isDirty); const blocker = useBlocker(() => isDirty);
useEffect(() => { useEffect(() => {
if (blocker.state === "blocked") { if (blocker.state !== "blocked") return;
(async () => { let active = true;
const ok = await requestConfirm();
if (ok) { (async () => {
blocker.proceed(); const ok = await requestConfirm();
} else { if (!active) return;
blocker.reset(); ok ? blocker.proceed() : blocker.reset();
} })();
})();
} return () => {
active = false;
};
}, [blocker, requestConfirm]); }, [blocker, requestConfirm]);
return ( return (
<UnsavedChangesContext.Provider value={{ requestConfirm }}> <UnsavedChangesContext.Provider value={{ requestConfirm }}>
{children} {children}

View File

@ -16,7 +16,7 @@ export class CustomerInvoiceApplicationService {
/** /**
* Construye un nuevo agregado CustomerInvoice a partir de props validadas. * Construye un nuevo agregado CustomerInvoice a partir de props validadas.
* *
* @param companyId - Identificador de la empresa a la que pertenece el cliente. * @param companyId - Identificador de la empresa a la que pertenece la factura.
* @param props - Las propiedades ya validadas para crear la factura. * @param props - Las propiedades ya validadas para crear la factura.
* @param invoiceId - Identificador UUID de la factura (opcional). * @param invoiceId - Identificador UUID de la factura (opcional).
* @returns Result<CustomerInvoice, Error> - El agregado construido o un error si falla la creación. * @returns Result<CustomerInvoice, Error> - El agregado construido o un error si falla la creación.
@ -32,7 +32,7 @@ export class CustomerInvoiceApplicationService {
/** /**
* Guarda una nueva factura y devuelve la factura guardada. * Guarda una nueva factura y devuelve la factura guardada.
* *
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente. * @param companyId - Identificador de la empresa a la que pertenece la factura.
* @param invoice - El agregado a guardar. * @param invoice - El agregado a guardar.
* @param transaction - Transacción activa para la operación. * @param transaction - Transacción activa para la operación.
* @returns Result<CustomerInvoice, Error> - El agregado guardado o un error si falla la operación. * @returns Result<CustomerInvoice, Error> - El agregado guardado o un error si falla la operación.
@ -53,7 +53,7 @@ export class CustomerInvoiceApplicationService {
/** /**
* Actualiza una factura existente y devuelve la factura actualizada. * Actualiza una factura existente y devuelve la factura actualizada.
* *
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente. * @param companyId - Identificador de la empresa a la que pertenece la factura.
* @param invoice - El agregado a guardar. * @param invoice - El agregado a guardar.
* @param transaction - Transacción activa para la operación. * @param transaction - Transacción activa para la operación.
* @returns Result<CustomerInvoice, Error> - El agregado guardado o un error si falla la operación. * @returns Result<CustomerInvoice, Error> - El agregado guardado o un error si falla la operación.
@ -75,7 +75,7 @@ export class CustomerInvoiceApplicationService {
* *
* Comprueba si existe o no en persistencia una factura con el ID proporcionado * Comprueba si existe o no en persistencia una factura con el ID proporcionado
* *
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente. * @param companyId - Identificador de la empresa a la que pertenece la factura.
* @param invoiceId - Identificador UUID de la factura. * @param invoiceId - Identificador UUID de la factura.
* @param transaction - Transacción activa para la operación. * @param transaction - Transacción activa para la operación.
* @returns Result<Boolean, Error> - Existe la factura o no. * @returns Result<Boolean, Error> - Existe la factura o no.
@ -92,7 +92,7 @@ export class CustomerInvoiceApplicationService {
/** /**
* Obtiene una colección de facturas que cumplen con los filtros definidos en un objeto Criteria. * Obtiene una colección de facturas que cumplen con los filtros definidos en un objeto Criteria.
* *
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente. * @param companyId - Identificador de la empresa a la que pertenece la factura.
* @param criteria - Objeto con condiciones de filtro, paginación y orden. * @param criteria - Objeto con condiciones de filtro, paginación y orden.
* @param transaction - Transacción activa para la operación. * @param transaction - Transacción activa para la operación.
* @returns Result<Collection<CustomerInvoiceListDTO>, Error> - Colección de facturas o error. * @returns Result<Collection<CustomerInvoiceListDTO>, Error> - Colección de facturas o error.
@ -124,7 +124,7 @@ export class CustomerInvoiceApplicationService {
* Actualiza parcialmente una factura existente con nuevos datos. * Actualiza parcialmente una factura existente con nuevos datos.
* No lo guarda en el repositorio. * No lo guarda en el repositorio.
* *
* @param companyId - Identificador de la empresa a la que pertenece el cliente. * @param companyId - Identificador de la empresa a la que pertenece la factura.
* @param invoiceId - Identificador de la factura a actualizar. * @param invoiceId - Identificador de la factura a actualizar.
* @param changes - Subconjunto de props válidas para aplicar. * @param changes - Subconjunto de props válidas para aplicar.
* @param transaction - Transacción activa para la operación. * @param transaction - Transacción activa para la operación.
@ -154,7 +154,7 @@ export class CustomerInvoiceApplicationService {
/** /**
* Elimina (o marca como eliminada) una factura según su ID. * Elimina (o marca como eliminada) una factura según su ID.
* *
* @param companyId - Identificador de la empresa a la que pertenece el cliente. * @param companyId - Identificador de la empresa a la que pertenece la factura.
* @param invoiceId - Identificador UUID de la factura. * @param invoiceId - Identificador UUID de la factura.
* @param transaction - Transacción activa para la operación. * @param transaction - Transacción activa para la operación.
* @returns Result<boolean, Error> - Resultado de la operación. * @returns Result<boolean, Error> - Resultado de la operación.

View File

@ -4,7 +4,7 @@ import { UniqueID } from "@repo/rdx-ddd";
import { Result } from "@repo/rdx-utils"; import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize"; import { Transaction } from "sequelize";
import { CreateCustomerInvoiceRequestDTO } from "../../../../common/dto"; import { CreateCustomerInvoiceRequestDTO } from "../../../../common/dto";
import { CustomerInvoiceApplicationService } from "../../../domain"; import { CustomerInvoiceApplicationService } from "../../customer-invoice-application.service";
import { CustomerInvoiceFullPresenter } from "../../presenters"; import { CustomerInvoiceFullPresenter } from "../../presenters";
import { CreateCustomerInvoicePropsMapper } from "./map-dto-to-create-customer-invoice-props"; import { CreateCustomerInvoicePropsMapper } from "./map-dto-to-create-customer-invoice-props";
@ -53,7 +53,11 @@ export class CreateCustomerInvoiceUseCase {
return Result.fail(existsGuard.error); return Result.fail(existsGuard.error);
} }
const saveResult = await this.service.saveInvoice(newInvoice, transaction); const saveResult = await this.service.createInvoiceInCompany(
companyId,
newInvoice,
transaction
);
if (saveResult.isFailure) { if (saveResult.isFailure) {
return Result.fail(saveResult.error); return Result.fail(saveResult.error);
} }

View File

@ -2,9 +2,8 @@ import type { CellKeyDownEvent, RowClickedEvent } from "ag-grid-community";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { SimpleSearchInput } from '@erp/core/components';
import { DataTable, SkeletonDataTable } from '@repo/rdx-ui/components'; import { DataTable, SkeletonDataTable } from '@repo/rdx-ui/components';
import { Button, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Spinner } from '@repo/shadcn-ui/components';
import { FileDownIcon, FilterIcon, SearchIcon, XIcon } from 'lucide-react';
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { usePinnedPreviewSheet } from '../../hooks'; import { usePinnedPreviewSheet } from '../../hooks';
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
@ -100,15 +99,6 @@ export const InvoicesListGrid = ({
[goToRow] [goToRow]
); );
// Handlers de búsqueda
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => onSearchChange(e.target.value);
const handleClear = () => onSearchChange("");
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
// Envío inmediato: forzar “salto” del debounce
onSearchChange((e.target as HTMLInputElement).value);
}
};
const handleRowClick = useCallback( const handleRowClick = useCallback(
(invoice: InvoiceSummaryFormData, _i: number, e: React.MouseEvent) => { (invoice: InvoiceSummaryFormData, _i: number, e: React.MouseEvent) => {
@ -137,35 +127,8 @@ export const InvoicesListGrid = ({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Barra de filtros */} {/* Barra de filtros */}
<div className="flex flex-col sm:flex-row gap-4 mb-6"> <div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1" aria-label={t("pages.list.searchPlaceholder")}> <SimpleSearchInput onSearchChange={onSearchChange} loading={loading} />
<InputGroup className='bg-background' data-disabled={loading}> {/*<Select value={statusFilter} onValueChange={setStatusFilter}>
<InputGroupInput
placeholder={t("common.search_placeholder")}
value={searchValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
inputMode="search"
autoComplete="off"
spellCheck={false}
disabled={loading}
/>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
{loading && <Spinner />}
{!searchValue && !loading && <InputGroupButton variant='secondary' className='cursor-pointer'>
{t("common.search")}
</InputGroupButton>}
{searchValue && !loading && <InputGroupButton variant='secondary' className='cursor-pointer' aria-label={t("common.clear")} onClick={handleClear}>
<XIcon className="size-4" aria-hidden />
<span className="sr-only">{t("common.search")}</span>
</InputGroupButton>}
</InputGroupAddon>
</InputGroup>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-48 bg-white border-gray-200 shadow-sm"> <SelectTrigger className="w-full sm:w-48 bg-white border-gray-200 shadow-sm">
<FilterIcon className="mr-2 h-4 w-4" /> <FilterIcon className="mr-2 h-4 w-4" />
<SelectValue placeholder="Estado" /> <SelectValue placeholder="Estado" />
@ -180,7 +143,7 @@ export const InvoicesListGrid = ({
<Button variant="outline" className="border-blue-200 text-blue-600 hover:bg-blue-50 bg-transparent"> <Button variant="outline" className="border-blue-200 text-blue-600 hover:bg-blue-50 bg-transparent">
<FileDownIcon className="mr-2 h-4 w-4" /> <FileDownIcon className="mr-2 h-4 w-4" />
Exportar Exportar
</Button> </Button>*/}
</div> </div>
<div className="relative flex"> <div className="relative flex">
<div className={preview.isPinned ? "flex-1 mr-[500px]" : "flex-1"}> <div className={preview.isPinned ? "flex-1 mr-[500px]" : "flex-1"}>

View File

@ -1,6 +1,6 @@
import { PageHeader } from '@erp/core/components'; import { PageHeader } from '@erp/core/components';
import { ErrorAlert } from '@erp/customers/components'; import { ErrorAlert } from '@erp/customers/components';
import { AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components"; import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components"; import { Button } from "@repo/shadcn-ui/components";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
@ -17,16 +17,15 @@ export const InvoiceListPage = () => {
const [pageIndex, setPageIndex] = useState(0); const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedQ = useDebounce(search, 300);
const criteria = useMemo( const criteria = useMemo(
() => ({ () => ({
q: debouncedQ || "", q: search || "",
pageSize, pageSize,
pageNumber: pageIndex, pageNumber: pageIndex,
}), }),
[pageSize, pageIndex, debouncedQ] [pageSize, pageIndex, search]
); );
const { const {

View File

@ -91,16 +91,11 @@ export const InvoiceUpdateComp = ({
title={`${t("pages.edit.title")} #${invoiceData.invoice_number}`} title={`${t("pages.edit.title")} #${invoiceData.invoice_number}`}
description={t("pages.edit.description")} description={t("pages.edit.description")}
rightSlot={<> rightSlot={<>
<button type="submit" form={formId} onClick={(e) => {
e.preventDefault(); const submit = form.handleSubmit(handleSubmit, handleError);
void submit(e)
}}>Enviar</button>
<UpdateCommitButtonGroup <UpdateCommitButtonGroup
isLoading={isPending} isLoading={isPending}
submit={{ formId, variant: 'default', disabled: isPending, label: t("pages.edit.actions.save_draft") }} submit={{ formId, variant: 'default', disabled: isPending, label: t("pages.edit.actions.save_draft") }}
cancel={{ formId, to: "/customer-invoices/list" }} cancel={{ formId, to: "/customer-invoices/list" }}
onBack={() => navigate(-1)}
/> />
</>} </>}
/> />

View File

@ -3,12 +3,28 @@ import { Criteria } from "@repo/rdx-criteria/server";
import { UniqueID } from "@repo/rdx-ddd"; import { UniqueID } from "@repo/rdx-ddd";
import { Collection, Result } from "@repo/rdx-utils"; import { Collection, Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize"; import { Transaction } from "sequelize";
import { Customer, CustomerPatchProps, ICustomerRepository } from "../domain"; import { Customer, CustomerPatchProps, CustomerProps, ICustomerRepository } from "../domain";
import { CustomerListDTO } from "../infrastructure"; import { CustomerListDTO } from "../infrastructure";
export class CustomerApplicationService { export class CustomerApplicationService {
constructor(private readonly repository: ICustomerRepository) {} constructor(private readonly repository: ICustomerRepository) {}
/**
* Construye un nuevo agregado CustomerInvoice a partir de props validadas.
*
* @param companyId - Identificador UUID de la empresa a la que pertenece el cliente.
* @param props - Las propiedades ya validadas para crear el cliente.
* @param customerId - Identificador UUID del cliente (opcional).
* @returns Result<Customer, Error> - El agregado construido o un error si falla la creación.
*/
buildCustomerInCompany(
companyId: UniqueID,
props: Omit<CustomerProps, "companyId">,
customerId?: UniqueID
): Result<Customer, Error> {
return Customer.create({ ...props, companyId }, customerId);
}
/** /**
* Guarda un nuevo cliente y devuelve el cliente guardado. * Guarda un nuevo cliente y devuelve el cliente guardado.
* *

View File

@ -4,7 +4,7 @@ import { Result } from "@repo/rdx-utils";
import { Transaction } from "sequelize"; import { Transaction } from "sequelize";
import { CreateCustomerRequestDTO } from "../../../../common"; import { CreateCustomerRequestDTO } from "../../../../common";
import { logger } from "../../..//helpers"; import { logger } from "../../..//helpers";
import { CustomerApplicationService } from "../../../domain"; import { CustomerApplicationService } from "../../customer-application.service";
import { CustomerFullPresenter } from "../../presenters"; import { CustomerFullPresenter } from "../../presenters";
import { CustomerNotExistsInCompanySpecification } from "../../specs"; import { CustomerNotExistsInCompanySpecification } from "../../specs";
import { mapDTOToCreateCustomerProps } from "./map-dto-to-create-customer-props"; import { mapDTOToCreateCustomerProps } from "./map-dto-to-create-customer-props";
@ -63,7 +63,11 @@ export class CreateCustomerUseCase {
logger.debug(JSON.stringify(newCustomer, null, 6)); logger.debug(JSON.stringify(newCustomer, null, 6));
const saveResult = await this.service.saveCustomer(newCustomer, transaction); const saveResult = await this.service.createCustomerInCompany(
companyId,
newCustomer,
transaction
);
if (saveResult.isFailure) { if (saveResult.isFailure) {
return Result.fail(saveResult.error); return Result.fail(saveResult.error);
} }

View File

@ -23,14 +23,11 @@
"title": "Customer list", "title": "Customer list",
"description": "List all customers", "description": "List all customers",
"grid_columns": { "grid_columns": {
"name": "Name", "customer": "Customer",
"trade_name": "Trade name",
"status": "Status", "status": "Status",
"email": "Email", "contact": "Contact",
"phone": "Phone", "address": "Address",
"city": "City", "actions": "Actions"
"tin": "TIN",
"mobile": "Mobile"
} }
}, },
"create": { "create": {

View File

@ -23,14 +23,11 @@
"title": "Lista de clientes", "title": "Lista de clientes",
"description": "Lista todos los clientes", "description": "Lista todos los clientes",
"grid_columns": { "grid_columns": {
"name": "Nombre", "customer": "Cliente",
"trade_name": "Nombre comercial",
"status": "Estado", "status": "Estado",
"email": "Correo electrónico", "contact": "Contacto",
"phone": "Teléfono", "address": "Dirección",
"city": "Ciudad", "actions": "Acciones"
"tin": "Nº Id.",
"mobile": "Móvil"
} }
}, },
"create": { "create": {

View File

@ -62,15 +62,15 @@ export function CustomerCreateModal({
<UnsavedChangesProvider isDirty={isDirty}> <UnsavedChangesProvider isDirty={isDirty}>
<Dialog open={open} onOpenChange={guardClose}> <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)]"> <DialogContent className="bg-card border-border p-0 max-w-[calc(100vw-2rem)] sm:[calc(max-w-3xl-2rem)] h-[calc(100dvh-2rem)]">
<DialogHeader className="px-6 pt-6 pb-4 border-b"> <DialogHeader className="px-6 pt-6 pb-4 border-b">
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Plus className="size-5" /> {t("pages.create.title")} <Plus className="size-5" /> {t("pages.create.title")}
</DialogTitle> </DialogTitle>
<DialogDescription>{t("pages.create.subtitle")}</DialogDescription> <DialogDescription className='text-left'>{t("pages.create.description")}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="px-6 py-4 overflow-y-auto h:[calc(100%-8rem)]"> <div className="overflow-y-auto h:[calc(100%-8rem)]">
<FormProvider {...form}> <FormProvider {...form}>
<CustomerEditForm <CustomerEditForm
formId={formId} formId={formId}

View File

@ -1,8 +1,6 @@
import { FormDebug } from "@erp/core/components"; import { FormDebug } from "@erp/core/components";
import { FieldErrors, useFormContext } from "react-hook-form";
import { cn } from '@repo/shadcn-ui/lib/utils'; import { cn } from '@repo/shadcn-ui/lib/utils';
import { CustomerFormData } from "../../schemas";
import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields"; import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
import { CustomerAddressFields } from "./customer-address-fields"; import { CustomerAddressFields } from "./customer-address-fields";
import { CustomerBasicInfoFields } from "./customer-basic-info-fields"; import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
@ -10,22 +8,16 @@ import { CustomerContactFields } from './customer-contact-fields';
type CustomerFormProps = { type CustomerFormProps = {
formId: string; formId: string;
onSubmit: (data: CustomerFormData) => void; onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
onError: (errors: FieldErrors<CustomerFormData>) => void;
className?: string; className?: string;
focusRef?: React.RefObject<HTMLInputElement>; focusRef?: React.RefObject<HTMLInputElement>;
}; };
export const CustomerEditForm = ({ formId, onSubmit, onError, className, focusRef }: CustomerFormProps) => { export const CustomerEditForm = ({ formId, onSubmit, className, focusRef }: CustomerFormProps) => {
const form = useFormContext<CustomerFormData>();
return ( return (
<form noValidate id={formId} onSubmit={(event: React.FormEvent<HTMLFormElement>) => { <form noValidate id={formId} onSubmit={onSubmit}>
event.stopPropagation();
form.handleSubmit(onSubmit, onError)(event)
}}>
<FormDebug /> <FormDebug />
<section className={cn("space-y-6 p-6 xl:grid-cols-2 xl:grid xl:gap-6", className)}> <section className={cn("space-y-6 p-6", className)}>
<CustomerBasicInfoFields focusRef={focusRef} /> <CustomerBasicInfoFields focusRef={focusRef} />
<CustomerAddressFields /> <CustomerAddressFields />
<CustomerContactFields /> <CustomerContactFields />

View File

@ -5,7 +5,7 @@ import { ZodError } from "zod/v4";
import { CreateCustomerRequestSchema } from "../../common"; import { CreateCustomerRequestSchema } from "../../common";
import { Customer, CustomerFormData } from "../schemas"; import { Customer, CustomerFormData } from "../schemas";
import { CUSTOMERS_LIST_KEY, invalidateCustomerListCache } from "./use-customer-list-query"; import { CUSTOMERS_LIST_KEY, invalidateCustomerListCache } from "./use-customer-list-query";
import { getCustomerQueryKey } from "./use-customer-query"; import { setCustomerDetailCache } from "./use-customer-query";
export const CUSTOMER_CREATE_KEY = ["customers", "create"] as const; export const CUSTOMER_CREATE_KEY = ["customers", "create"] as const;
@ -33,26 +33,24 @@ export function useCreateCustomer() {
const id = UniqueID.generateNewID().toString(); const id = UniqueID.generateNewID().toString();
const payload = { ...data, id }; const payload = { ...data, id };
console.log("payload => ", payload);
const result = schema.safeParse(payload); const result = schema.safeParse(payload);
result.error;
if (!result.success) { if (!result.success) {
const errorDetails = toValidationErrors(result.error); throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error));
console.log(errorDetails);
throw new ValidationErrorCollection("Validation failed", errorDetails);
} }
const created = await dataSource.createOne("customers", payload); const created = await dataSource.createOne("customers", payload);
return created as Customer; return created as Customer;
}, },
onSuccess: (created) => { onSuccess: (created: Customer, variables) => {
const { id: customerId } = created;
// Invalida el listado para refrescar desde servidor // Invalida el listado para refrescar desde servidor
invalidateCustomerListCache(queryClient); invalidateCustomerListCache(queryClient);
// Sincroniza detalle // Sincroniza detalle
queryClient.setQueryData(getCustomerQueryKey(created.id), created); setCustomerDetailCache(queryClient, customerId, created);
}, },
onSettled: () => { onSettled: () => {
@ -60,7 +58,7 @@ export function useCreateCustomer() {
invalidateCustomerListCache(queryClient); invalidateCustomerListCache(queryClient);
}, },
onMutate: async ({ data }) => { onMutate: async ({ data }, context) => {
// Cancelar queries del listado para evitar overwrite // Cancelar queries del listado para evitar overwrite
await queryClient.cancelQueries({ queryKey: [CUSTOMERS_LIST_KEY] }); await queryClient.cancelQueries({ queryKey: [CUSTOMERS_LIST_KEY] });

View File

@ -1,10 +1,13 @@
import { useDataSource } from "@erp/core/hooks"; import { useDataSource } from "@erp/core/hooks";
import { ValidationErrorCollection } from "@repo/rdx-ddd"; import { ValidationErrorCollection } from "@repo/rdx-ddd";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { DefaultError, useMutation, useQueryClient } from "@tanstack/react-query";
import { UpdateCustomerByIdRequestSchema } from "../../common"; import { UpdateCustomerByIdRequestSchema } from "../../common";
import { Customer, CustomerFormData } from "../schemas"; import { Customer, CustomerFormData } from "../schemas";
import { toValidationErrors } from "./use-create-customer-mutation"; import { toValidationErrors } from "./use-create-customer-mutation";
import { upsertCustomerIntoListCaches } from "./use-customer-list-query"; import {
invalidateCustomerListCache,
upsertCustomerIntoListCaches,
} from "./use-customer-list-query";
import { setCustomerDetailCache } from "./use-customer-query"; import { setCustomerDetailCache } from "./use-customer-query";
export const CUSTOMER_UPDATE_KEY = ["customers", "update"] as const; export const CUSTOMER_UPDATE_KEY = ["customers", "update"] as const;
@ -21,7 +24,7 @@ export function useUpdateCustomer() {
const dataSource = useDataSource(); const dataSource = useDataSource();
const schema = UpdateCustomerByIdRequestSchema; const schema = UpdateCustomerByIdRequestSchema;
return useMutation<Customer, Error, UpdateCustomerPayload, UpdateCustomerContext>({ return useMutation<Customer, DefaultError, UpdateCustomerPayload, UpdateCustomerContext>({
mutationKey: CUSTOMER_UPDATE_KEY, mutationKey: CUSTOMER_UPDATE_KEY,
mutationFn: async (payload) => { mutationFn: async (payload) => {
@ -40,7 +43,10 @@ export function useUpdateCustomer() {
}, },
onSuccess: (updated: Customer, variables) => { onSuccess: (updated: Customer, variables) => {
const { id: customerId } = variables; const { id: customerId } = updated;
// Invalida el listado para refrescar desde servidor
invalidateCustomerListCache(queryClient);
// Actualiza detalle // Actualiza detalle
setCustomerDetailCache(queryClient, customerId, updated); setCustomerDetailCache(queryClient, customerId, updated);
@ -48,5 +54,10 @@ export function useUpdateCustomer() {
// Actualiza todas las páginas donde aparezca // Actualiza todas las páginas donde aparezca
upsertCustomerIntoListCaches(queryClient, { ...updated }); upsertCustomerIntoListCaches(queryClient, { ...updated });
}, },
onSettled: () => {
// Refresca todos los listados
invalidateCustomerListCache(queryClient);
},
}); });
} }

View File

@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom";
import { PageHeader } from '@erp/core/components'; import { PageHeader } from '@erp/core/components';
import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks"; import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks";
import { useId } from 'react';
import { CustomerEditForm, ErrorAlert } from "../../components"; import { CustomerEditForm, ErrorAlert } from "../../components";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { useCustomerCreateController } from './use-customer-create-controller'; import { useCustomerCreateController } from './use-customer-create-controller';
@ -11,17 +10,18 @@ import { useCustomerCreateController } from './use-customer-create-controller';
export const CustomerCreatePage = () => { export const CustomerCreatePage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const formId = useId();
const { const {
form, isCreating, isCreateError, createError, form, formId, onSubmit, resetForm,
handleSubmit, handleError, FormProvider
} = useCustomerCreateController();
isCreating, isCreateError, createError,
FormProvider
} = useCustomerCreateController({
onCreated: (created) =>
navigate("/customers/list", { state: { customerId: created.id, isNew: true }, replace: true })
});
const handleBack = () => {
navigate(-1);
};
return ( return (
<UnsavedChangesProvider isDirty={form.formState.isDirty}> <UnsavedChangesProvider isDirty={form.formState.isDirty}>
@ -42,7 +42,7 @@ export const CustomerCreatePage = () => {
formId: formId, formId: formId,
disabled: isCreating, disabled: isCreating,
}} }}
onBack={() => handleBack()} onReset={resetForm}
/> />
} }
/> />
@ -61,14 +61,9 @@ export const CustomerCreatePage = () => {
<FormProvider {...form}> <FormProvider {...form}>
<CustomerEditForm <CustomerEditForm
formId={formId} formId={formId} // para que el botón del header pueda hacer submit
onSubmit={(data) => onSubmit={onSubmit}
handleSubmit(data, ({ id }) => className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto mt-6"
navigate("/customers/list", { state: { customerId: id, isNew: true }, replace: true })
)
}
onError={handleError}
/> />
</FormProvider> </FormProvider>

View File

@ -1,14 +1,22 @@
import { useHookForm } from "@erp/core/hooks"; import { useHookForm } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers"; import { showErrorToast, showSuccessToast } from "@repo/rdx-ui/helpers";
import { useId } from "react"; import { useId } from "react";
import { FieldErrors, FormProvider } from "react-hook-form"; import { FormProvider } from "react-hook-form";
import { useCreateCustomer } from "../../hooks"; import { useCreateCustomer } from "../../hooks";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas"; import {
Customer,
CustomerFormData,
CustomerFormSchema,
defaultCustomerFormData,
} from "../../schemas";
export interface UseCustomerCreateControllerOptions { export interface UseCustomerCreateControllerOptions {
onCreated?(created: CustomerFormData): void; // navegación, cierre modal, etc. onCreated?(created: Customer): void;
successToasts?: boolean; // permite desactivar toasts si el host los gestiona successToasts?: boolean; // mostrar o no toast automáticcamente
onError?(error: Error, data: CustomerFormData): void;
errorToasts?: boolean; // mostrar o no toast automáticcamente
} }
export const useCustomerCreateController = (options?: UseCustomerCreateControllerOptions) => { export const useCustomerCreateController = (options?: UseCustomerCreateControllerOptions) => {
@ -17,7 +25,7 @@ export const useCustomerCreateController = (options?: UseCustomerCreateControlle
// 1) Estado de creación (mutación) // 1) Estado de creación (mutación)
const { const {
mutate, mutateAsync,
isPending: isCreating, isPending: isCreating,
isError: isCreateError, isError: isCreateError,
error: createError, error: createError,
@ -30,57 +38,69 @@ export const useCustomerCreateController = (options?: UseCustomerCreateControlle
disabled: isCreating, disabled: isCreating,
}); });
const handleSubmit = (formData: CustomerFormData) => { /** Handlers */
console.log(formData);
mutate(
{
data: formData,
},
{
onSuccess(data) {
form.reset(defaultCustomerFormData);
if (options?.successToasts !== false) {
showSuccessToast(
t("pages.create.successTitle", "Cliente creado"),
t("pages.create.successMsg", "Se ha creado correctamente.")
);
}
options?.onCreated?.(data);
},
onError(err: unknown) { const resetForm = () => form.reset(defaultCustomerFormData);
console.log("No se pudo crear el cliente.");
const msg = // Versión sincronizada
(err as Error)?.message ?? const submitHandler = form.handleSubmit(async (formData) => {
t("pages.create.error.message", "No se pudo crear el cliente."); try {
showErrorToast(t("pages.create.error.title", "Error al crear"), msg); // Enviamos cambios al servidor
}, const created = await mutateAsync({ data: formData });
// Ha ido bien -> actualizamos form con datos reales
// keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render.
form.reset(created, { keepDirty: false });
console.log("form.formState =>", form.formState);
if (options?.successToasts !== false) {
showSuccessToast(
t("pages.create.success.title", "Cliente creado"),
t("pages.create.success.message", "Se ha creado correctamente.")
);
} }
); options?.onCreated?.(created);
}; } catch (err: unknown) {
console.log("No se pudo crear el cliente.");
const handleError = (errors: FieldErrors<CustomerFormData>) => { // 1) Enfoca el primer error
// 1) Enfoca el primer error /*const firstKey = Object.keys(errors)[0] as keyof CustomerFormData | undefined;
const firstKey = Object.keys(errors)[0] as keyof CustomerFormData | undefined; if (firstKey) {
if (firstKey) { const el = document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`);
const el = document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`); el?.focus();
el?.focus(); }*/
const error = err as Error;
if (options?.errorToasts !== false) {
showErrorToast(t("pages.update.error.title"), error.message);
}
options?.onError?.(error, formData);
} }
// 2) Toast informativo });
showWarningToast(
t("forms.validation.title", "Revisa los campos"), // Evento onSubmit ya preparado para el <form>
t("forms.validation.msg", "Hay errores de validación en el formulario.") const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
); event.stopPropagation(); // <-- evita que el submit se propage por los padre en el árbol DOM
submitHandler(event);
}; };
return { return {
// form
form, form,
formId, formId,
// handlers del form
onSubmit,
resetForm,
// mutation
isCreating, isCreating,
isCreateError, isCreateError,
createError, createError,
handleSubmit,
handleError, // Por comodidad
FormProvider, // para no re-importar fuera FormProvider,
}; };
}; };

View File

@ -1,7 +1,6 @@
import { SimpleSearchInput } from '@erp/core/components';
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components"; import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, Spinner } from '@repo/shadcn-ui/components';
import { SearchIcon, XIcon } from 'lucide-react';
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
@ -97,34 +96,7 @@ export const CustomersListGrid = ({
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Barra de filtros */} {/* Barra de filtros */}
<div className="flex flex-col sm:flex-row gap-4 mb-6"> <div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1 max-w-lg" aria-label={t("pages.list.searchPlaceholder")}> <SimpleSearchInput onSearchChange={onSearchChange} loading={loading} />
<InputGroup className='bg-background' data-disabled={loading}>
<InputGroupInput
placeholder={t("common.search_placeholder")}
value={searchValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
inputMode="search"
autoComplete="off"
spellCheck={false}
disabled={loading}
/>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
{loading && <Spinner />}
{!searchValue && !loading && <InputGroupButton variant='secondary' className='cursor-pointer'>
{t("common.search")}
</InputGroupButton>}
{searchValue && !loading && <InputGroupButton variant='secondary' className='cursor-pointer' aria-label={t("common.clear")} onClick={handleClear}>
<XIcon className="size-4" aria-hidden />
<span className="sr-only">{t("common.search")}</span>
</InputGroupButton>}
</InputGroupAddon>
</InputGroup>
</div>
</div> </div>
<div className="relative flex"> <div className="relative flex">
<div className={/*preview.isPinned ? "flex-1 mr-[500px]" : */"flex-1"}> <div className={/*preview.isPinned ? "flex-1 mr-[500px]" : */"flex-1"}>

View File

@ -16,8 +16,8 @@ export const CustomersListPage = () => {
const [pageIndex, setPageIndex] = useState(0); const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedQ = useDebounce(search, 300);
const debouncedQ = useDebounce(search, 300);
const criteria = useMemo( const criteria = useMemo(
() => ({ () => ({
@ -34,12 +34,9 @@ export const CustomersListPage = () => {
isLoading, isLoading,
isError, isError,
error, error,
} = useCustomerListQuery({ } = useCustomerListQuery({ criteria });
criteria
});
const handlePageChange = (newPageIndex: number) => { const handlePageChange = (newPageIndex: number) => {
// TanStack usa pageIndex 0-based → API usa 0-based también
setPageIndex(newPageIndex); setPageIndex(newPageIndex);
}; };
@ -49,7 +46,6 @@ export const CustomersListPage = () => {
}; };
const handleSearchChange = (value: string) => { const handleSearchChange = (value: string) => {
// Normalización ligera: recorta y colapsa espacios internos
const cleaned = value.trim().replace(/\s+/g, " "); const cleaned = value.trim().replace(/\s+/g, " ");
setSearch(cleaned); setSearch(cleaned);
setPageIndex(0); setPageIndex(0);

View File

@ -1,11 +1,16 @@
import { DataTableColumnHeader } from '@repo/rdx-ui/components';
import { import {
Avatar, Avatar,
AvatarFallback, AvatarFallback,
Badge, Badge,
Button, Button,
DropdownMenu, DropdownMenuContent, DropdownMenu, DropdownMenuContent,
DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
Tooltip,
TooltipContent,
TooltipTrigger
} from '@repo/shadcn-ui/components'; } from '@repo/shadcn-ui/components';
import { cn } from '@repo/shadcn-ui/lib/utils';
import type { ColumnDef } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table";
import { Building2Icon, GlobeIcon, MailIcon, MoreHorizontalIcon, PencilIcon, PhoneIcon, User2Icon } from 'lucide-react'; import { Building2Icon, GlobeIcon, MailIcon, MoreHorizontalIcon, PencilIcon, PhoneIcon, User2Icon } from 'lucide-react';
import * as React from "react"; import * as React from "react";
@ -22,24 +27,33 @@ function shortId(id: string) {
return id ? `${id.slice(0, 4)}_${id.slice(-4)}` : "-"; return id ? `${id.slice(0, 4)}_${id.slice(-4)}` : "-";
} }
const statuses = {
inactive: 'text-gray-400 bg-gray-100 dark:text-gray-500 dark:bg-gray-100/10',
active: 'text-green-500 bg-green-500/10 dark:text-green-400 dark:bg-green-400/10',
error: 'text-rose-500 bg-rose-500/10 dark:text-rose-400 dark:bg-rose-400/10',
}
// ---- Helpers UI ---- // ---- Helpers UI ----
const StatusBadge = ({ status }: { status: string }) => { const StatusBadge = ({ status }: { status: string }) => {
// Map visual simple; ajustar a tu catálogo real // Map visual simple; ajustar a tu catálogo real
const v = const statusClass = React.useMemo(() => status.toLowerCase() === 'active' ? statuses.active : statuses.inactive, [status]);
status.toLowerCase() === "active" const contentTxt = React.useMemo(() => status.toLowerCase() === 'active' ? 'El cliente está activo' : 'El cliente está inactivo', [status]);
? "default"
: status.toLowerCase() === "inactive"
? "outline"
: "secondary";
return ( return (
<Badge variant={v as any} className="uppercase tracking-wide text-[11px]"> <Tooltip>
{status} <TooltipTrigger asChild>
</Badge> <div className={cn('flex-none rounded-full p-1', statusClass)}>
); <div className="size-2 rounded-full bg-current" />
</div>
</TooltipTrigger>
<TooltipContent>{contentTxt}
</TooltipContent>
</Tooltip>
)
}; };
const KindBadge = ({ isCompany }: { isCompany: boolean }) => ( const KindBadge = ({ isCompany }: { isCompany: boolean }) => (
<Badge variant="outline" className="gap-1"> <Badge variant="outline" className="gap-1 tracking-wide text-xs text-foreground/70">
{isCompany ? <Building2Icon className="size-3.5" /> : <User2Icon className="size-3.5" />} {isCompany ? <Building2Icon className="size-3.5" /> : <User2Icon className="size-3.5" />}
{isCompany ? "Company" : "Person"} {isCompany ? "Company" : "Person"}
</Badge> </Badge>
@ -50,23 +64,30 @@ const Soft = ({ children }: { children: React.ReactNode }) => (
); );
const ContactCell = ({ customer }: { customer: CustomerSummaryFormData }) => ( const ContactCell = ({ customer }: { customer: CustomerSummaryFormData }) => (
<div className="grid gap-1"> <div className="grid gap-1 text-foreground text-sm my-1.5">
<div className="flex items-center gap-2 group:text-foreground group-hover:text-primary">
<MailIcon className="size-3.5 group" /> {customer.email_primary && (
<a className="group" href={`mailto:${customer.email_primary}`}> <div className='flex items-center gap-2'>
{customer.email_primary || <Soft></Soft>} <MailIcon className='size-3.5' />
</a> <a className='group' href={`mailto:${customer.email_primary}`}>
{customer.email_secondary && <Soft> {customer.email_secondary}</Soft>} {customer.email_primary}
</div> </a>
<div className="flex flex-wrap items-center gap-2 group:text-foreground group-hover:text-primary"> </div>
)}
{customer.email_secondary && (
<div className="flex items-center gap-2"><MailIcon className="size-3.5" />{customer.email_secondary}</div>
)}
<div className="flex flex-wrap items-center gap-2">
<PhoneIcon className="size-3.5 group" /> <PhoneIcon className="size-3.5 group" />
<span>{customer.phone_primary || customer.mobile_primary || <Soft>-</Soft>}</span> <span>{customer.phone_primary || customer.mobile_primary || <Soft>-</Soft>}</span>
{customer.phone_secondary && <Soft> {customer.phone_secondary}</Soft>} {customer.phone_secondary && <Soft> {customer.phone_secondary}</Soft>}
{customer.mobile_secondary && <Soft> {customer.mobile_secondary}</Soft>} {customer.mobile_secondary && <Soft> {customer.mobile_secondary}</Soft>}
{customer.fax && <Soft> fax {customer.fax}</Soft>} {false && customer.fax && <Soft> fax {customer.fax}</Soft>}
</div> </div>
{customer.website && ( {false && customer.website && (
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<GlobeIcon className="size-3.5" /> <GlobeIcon className="size-3.5" />
<a className="underline underline-offset-2" href={safeHttp(customer.website)} target="_blank" rel="noreferrer"> <a className="underline underline-offset-2" href={safeHttp(customer.website)} target="_blank" rel="noreferrer">
{customer.website} {customer.website}
@ -76,13 +97,13 @@ const ContactCell = ({ customer }: { customer: CustomerSummaryFormData }) => (
</div> </div>
); );
const AddressCell: React.FC<{ c: CustomerSummaryFormData }> = ({ c }) => { const AddressCell = ({ c }: { c: CustomerSummaryFormData }) => {
const line1 = [c.street, c.street2].filter(Boolean).join(", "); const line1 = [c.street, c.street2].filter(Boolean).join(", ");
const line2 = [c.postal_code, c.city].filter(Boolean).join(" "); const line2 = [c.postal_code, c.city].filter(Boolean).join(" ");
const line3 = [c.province, c.country].filter(Boolean).join(", "); const line3 = [c.province, c.country].filter(Boolean).join(", ");
return ( return (
<address className="not-italic grid gap-1 text-sm"> <address className="not-italic grid gap-1 text-foreground text-sm">
<div>{line1 || <Soft></Soft>}</div> <div>{line1 || <Soft>-</Soft>}</div>
<div>{[line2, line3].filter(Boolean).join(" • ")}</div> <div>{[line2, line3].filter(Boolean).join(" • ")}</div>
</address> </address>
); );
@ -99,6 +120,7 @@ function safeHttp(url: string) {
return `https://${url}`; return `https://${url}`;
} }
export function useCustomersListColumns( export function useCustomersListColumns(
handlers: CustomerActionHandlers = {} handlers: CustomerActionHandlers = {}
): ColumnDef<CustomerSummaryFormData>[] { ): ColumnDef<CustomerSummaryFormData>[] {
@ -110,31 +132,30 @@ export function useCustomersListColumns(
return React.useMemo<ColumnDef<CustomerSummaryFormData>[]>(() => [ return React.useMemo<ColumnDef<CustomerSummaryFormData>[]>(() => [
// Identidad + estado + metadatos (columna compuesta) // Identidad + estado + metadatos (columna compuesta)
{ {
id: "identity", id: "customer",
header: "Customer", header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.customer")} className="text-left" />
),
accessorFn: (row) => row.name, // para ordenar/buscar por nombre accessorFn: (row) => row.name, // para ordenar/buscar por nombre
enableHiding: false, enableHiding: false,
size: 380, size: 140,
minSize: 120,
cell: ({ row }) => { cell: ({ row }) => {
const c = row.original; const c = row.original;
const isCompany = String(c.is_company).toLowerCase() === "true"; const isCompany = String(c.is_company).toLowerCase() === "true";
return ( return (
<div className="flex items-start gap-3"> <div className="flex items-start gap-1 my-1.5">
<Avatar className="size-10"> <Avatar className="size-10 hidden">
<AvatarFallback aria-label={c.name}>{initials(c.name)}</AvatarFallback> <AvatarFallback aria-label={c.name}>{initials(c.name)}</AvatarFallback>
</Avatar> </Avatar>
<div className="min-w-0 grid gap-1"> <div className="min-w-0 grid gap-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-medium truncate">{c.name}</span> <StatusBadge status={c.status} /> <span className="font-medium truncate text-primary">{c.name}</span>
{c.trade_name && <Soft>({c.trade_name})</Soft>} {c.trade_name && <Soft>({c.trade_name})</Soft>}
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{c.tin && <span className="font-medium truncate">{c.tin}</span>} {c.tin && <span className="font-base truncate">{c.tin}</span>}
</div>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge status={c.status} />
<KindBadge isCompany={isCompany} /> <KindBadge isCompany={isCompany} />
{c.reference && <Badge variant="secondary">Ref: {c.reference}</Badge>}
</div> </div>
</div> </div>
</div> </div>
@ -145,27 +166,34 @@ export function useCustomersListColumns(
// Contacto (emails, teléfonos, web) // Contacto (emails, teléfonos, web)
{ {
id: "contact", id: "contact",
header: "Contact", header: ({ column }) => (
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.contact")} className="text-left" />
),
accessorFn: (r) => `${r.email_primary} ${r.phone_primary} ${r.mobile_primary} ${r.website}`, accessorFn: (r) => `${r.email_primary} ${r.phone_primary} ${r.mobile_primary} ${r.website}`,
size: 420, size: 140,
minSize: 120,
cell: ({ row }) => <ContactCell customer={row.original} />, cell: ({ row }) => <ContactCell customer={row.original} />,
}, },
// Dirección (múltiples campos en bloque) // Dirección (múltiples campos en bloque)
{ {
id: "address", id: "address",
header: "Address", header: t("pages.list.grid_columns.address"),
accessorFn: (r) => accessorFn: (r) =>
`${r.street} ${r.street2} ${r.city} ${r.postal_code} ${r.province} ${r.country}`, `${r.street} ${r.street2} ${r.city} ${r.postal_code} ${r.province} ${r.country}`,
size: 360, size: 140,
minSize: 120,
cell: ({ row }) => <AddressCell c={row.original} />, cell: ({ row }) => <AddressCell c={row.original} />,
}, },
// Acciones // Acciones
{ {
id: "actions", id: "actions",
header: () => <span className="sr-only">Actions</span>, header: ({ column }) => (
size: 72, <DataTableColumnHeader column={column} title={t("pages.list.grid_columns.actions")} className="text-right" />
),
size: 64,
minSize: 64,
enableSorting: false, enableSorting: false,
enableHiding: false, enableHiding: false,
cell: ({ row }) => { cell: ({ row }) => {

View File

@ -1,92 +1,36 @@
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components"; import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { useNavigate } from "react-router-dom";
import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client";
import { PageHeader } from '@erp/core/components'; import { PageHeader } from '@erp/core/components';
import { import {
UnsavedChangesProvider, UnsavedChangesProvider,
UpdateCommitButtonGroup, UpdateCommitButtonGroup,
useHookForm, useUrlParamId
useUrlParamId,
} from "@erp/core/hooks"; } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
import { FieldErrors, FormProvider } from "react-hook-form";
import { import {
CustomerEditForm, CustomerEditForm,
CustomerEditorSkeleton, CustomerEditorSkeleton,
ErrorAlert, ErrorAlert,
NotFoundCard, NotFoundCard,
} from "../../components"; } from "../../components";
import { useCustomerQuery, useUpdateCustomer } from "../../hooks";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas"; import { useCustomerUpdateController } from './use-customer-update-controller';
export const CustomerUpdatePage = () => { export const CustomerUpdatePage = () => {
const customerId = useUrlParamId(); const customerId = useUrlParamId();
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
// 1) Estado de carga del cliente (query)
const { const {
data: customerData, form, formId, onSubmit, resetForm,
isLoading: isLoadingCustomer,
isError: isLoadError,
error: loadError,
} = useCustomerQuery(customerId, { enabled: !!customerId });
// 2) Estado de actualización (mutación) customerData,
const { isLoading, isLoadError, loadError,
mutate,
isPending: isUpdating,
isError: isUpdateError,
error: updateError,
} = useUpdateCustomer();
// 3) Form hook isUpdating, isUpdateError, updateError,
const form = useHookForm<CustomerFormData>({
resolverSchema: CustomerFormSchema,
initialValues: customerData ?? defaultCustomerFormData,
disabled: isUpdating,
});
// 4) Submit con navegación condicionada por éxito FormProvider
const handleSubmit = (formData: CustomerFormData) => { } = useCustomerUpdateController(customerId, {});
const { dirtyFields } = form.formState;
if (!formHasAnyDirty(dirtyFields)) { if (isLoading) {
showWarningToast("No hay cambios para guardar");
return;
}
const patchData = pickFormDirtyValues(formData, dirtyFields);
mutate(
{ id: customerId!, data: patchData },
{
onSuccess(data) {
showSuccessToast(t("pages.update.success.title"), t("pages.update.success.message"));
// 🔹 limpiar el form e isDirty pasa a false
form.reset(data);
},
onError(error) {
showErrorToast(t("pages.update.errorTitle"), error.message);
},
}
);
};
const handleReset = () => form.reset(customerData ?? defaultCustomerFormData);
const handleBack = () => {
navigate(-1);
};
const handleError = (errors: FieldErrors<CustomerFormData>) => {
console.error("Errores en el formulario:", errors);
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
};
if (isLoadingCustomer) {
return <CustomerEditorSkeleton />; return <CustomerEditorSkeleton />;
} }
@ -136,15 +80,15 @@ export const CustomerUpdatePage = () => {
isLoading={isUpdating} isLoading={isUpdating}
disabled={isUpdating} disabled={isUpdating}
cancel={{ cancel={{
formId,
to: "/customers/list", to: "/customers/list",
disabled: isUpdating, disabled: isUpdating,
}} }}
submit={{ submit={{
formId: "customer-update-form", formId,
disabled: isUpdating, disabled: isUpdating,
}} }}
onBack={() => handleBack()} onReset={resetForm}
onReset={() => handleReset()}
/> />
} }
/> />
@ -163,10 +107,9 @@ export const CustomerUpdatePage = () => {
<FormProvider {...form}> <FormProvider {...form}>
<CustomerEditForm <CustomerEditForm
formId={"customer-update-form"} // para que el botón del header pueda hacer submit formId={formId} // para que el botón del header pueda hacer submit
onSubmit={handleSubmit} onSubmit={onSubmit}
onError={handleError} className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto mt-6"
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto"
/> />
</FormProvider> </FormProvider>

View File

@ -0,0 +1,146 @@
import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client";
import { useHookForm } from "@erp/core/hooks";
import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
import { useEffect, useId, useMemo } from "react";
import { FieldErrors, FormProvider } from "react-hook-form";
import { useCustomerQuery, useUpdateCustomer } from "../../hooks";
import { useTranslation } from "../../i18n";
import {
Customer,
CustomerFormData,
CustomerFormSchema,
defaultCustomerFormData,
} from "../../schemas";
export interface UseCustomerUpdateControllerOptions {
onUpdated?(updated: Customer): void;
successToasts?: boolean; // mostrar o no toast automáticcamente
onError?(error: Error, patchData: ReturnType<typeof pickFormDirtyValues>): void;
errorToasts?: boolean; // mostrar o no toast automáticcamente
}
export const useCustomerUpdateController = (
customerId?: string,
options?: UseCustomerUpdateControllerOptions
) => {
const { t } = useTranslation();
const formId = useId(); // id único por instancia
// 1) Estado de carga del cliente (query)
const {
data: customerData,
isLoading,
isError: isLoadError,
error: loadError,
} = useCustomerQuery(customerId, { enabled: Boolean(customerId) });
// 2) Estado de creación (mutación)
const {
mutateAsync,
isPending: isUpdating,
isError: isUpdateError,
error: updateError,
} = useUpdateCustomer();
const initialValues = useMemo(() => customerData ?? defaultCustomerFormData, [customerData]);
// 3) Form hook
const form = useHookForm<CustomerFormData>({
resolverSchema: CustomerFormSchema,
initialValues,
disabled: isLoading || isUpdating,
});
/** Reiniciar el form al recibir datos */
useEffect(() => {
// keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render.
if (customerData) form.reset(customerData, { keepDirty: false });
}, [customerData, form]);
/** Handlers */
const resetForm = () => form.reset(customerData ?? defaultCustomerFormData);
// Versión sincronizada
const submitHandler = form.handleSubmit(
async (formData) => {
if (!customerId) {
showErrorToast(t("pages.update.error.title"), "Falta el ID del cliente");
return;
}
const { dirtyFields } = form.formState;
if (!formHasAnyDirty(dirtyFields)) {
showWarningToast(t("pages.update.error.no_changes"), "No hay cambios para guardar");
return;
}
const patchData = pickFormDirtyValues(formData, dirtyFields);
const previousData = customerData;
try {
// Enviamos cambios al servidor
const updated = await mutateAsync({ id: customerId, data: patchData });
// Ha ido bien -> actualizamos form con datos reales
// keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render.
form.reset(updated, { keepDirty: false });
if (options?.successToasts !== false) {
showSuccessToast(
t("pages.update.success.title", "Cliente modificado"),
t("pages.update.success.message", "Se ha modificado correctamente.")
);
}
options?.onUpdated?.(updated);
} catch (error: any) {
// Algo ha fallado -> revertimos cambios
form.reset(previousData ?? defaultCustomerFormData);
if (options?.errorToasts !== false) {
showErrorToast(t("pages.update.error.title"), error.message);
}
options?.onError?.(error, patchData);
}
},
(errors: FieldErrors<CustomerFormData>) => {
const firstKey = Object.keys(errors)[0] as keyof CustomerFormData | undefined;
if (firstKey) document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`)?.focus();
showWarningToast(
t("forms.validation.title", "Revisa los campos"),
t("forms.validation.message", "Hay errores de validación en el formulario.")
);
}
);
// Evento onSubmit ya preparado para el <form>
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.stopPropagation(); // <-- evita que el submit se propage por los padre en el árbol DOM
submitHandler(event);
};
return {
// form
form,
formId,
// handlers del form
onSubmit,
resetForm,
// carga de datos
customerData,
isLoading,
isLoadError,
loadError,
// mutation
isUpdating,
isUpdateError,
updateError,
// Por comodidad
FormProvider,
};
};

View File

@ -11,12 +11,8 @@ import { BaseError } from "./base-error";
/** Error base de dominio: no depende de infra ni HTTP */ /** Error base de dominio: no depende de infra ni HTTP */
export class DomainError extends BaseError<"domain"> { export class DomainError extends BaseError<"domain"> {
public readonly layer = "domain" as const; public readonly layer = "domain" as const;
constructor( constructor(message: string, options?: ErrorOptions & { metadata?: Record<string, unknown> }) {
message: string, super("DomainError", message, "DOMAIN_ERROR", options);
code = "DOMAIN_ERROR",
options?: ErrorOptions & { metadata?: Record<string, unknown> }
) {
super("DomainError", message, code, options);
} }
} }

View File

@ -8,6 +8,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { useState } from 'react';
interface CustomDialogProps { interface CustomDialogProps {
open: boolean; open: boolean;
@ -26,18 +27,35 @@ export const CustomDialog = ({
cancelLabel, cancelLabel,
confirmLabel, confirmLabel,
}: CustomDialogProps) => { }: CustomDialogProps) => {
const [closedByAction, setClosedByAction] = useState(false);
const handleClose = (ok: boolean) => {
setClosedByAction(true);
onConfirm(ok);
};
return ( return (
<AlertDialog open={open} onOpenChange={(open) => !open && onConfirm(false)}> <AlertDialog
<AlertDialogContent> open={open}
<AlertDialogHeader> onOpenChange={(nextOpen) => {
<AlertDialogTitle>{title}</AlertDialogTitle> if (!nextOpen && !closedByAction) onConfirm(false);
<AlertDialogDescription>{description}</AlertDialogDescription> if (nextOpen) setClosedByAction(false);
}}
>
<AlertDialogContent className="max-w-md rounded-2xl border border-border shadow-lg">
<AlertDialogHeader className="space-y-2">
<AlertDialogTitle className="text-lg font-semibold">{title}</AlertDialogTitle>
<AlertDialogDescription className="text-sm text-muted-foreground" aria-live="assertive">
{description}
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter className="mt-12">
<AlertDialogCancel onClick={() => onConfirm(false)}>{cancelLabel}</AlertDialogCancel> <AlertDialogCancel autoFocus className="min-w-[120px]">{cancelLabel}</AlertDialogCancel>
<AlertDialogAction onClick={() => onConfirm(true)}>{confirmLabel}</AlertDialogAction> <AlertDialogAction className="min-w-[120px] bg-destructive text-white hover:bg-destructive/90">
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
); );
}; };

View File

@ -3,35 +3,62 @@ import { toast } from "@repo/shadcn-ui/components";
/** /**
* Muestra un toast de aviso * Muestra un toast de aviso
*/ */
export function showInfoToast(title: string, description?: string) { export function showInfoToast(title: string, description?: string, id?: number | string) {
toast.info(title, { toast.info(title, {
description, description,
id,
}); });
} }
/** /**
* Muestra un toast de aviso * Muestra un toast de aviso
*/ */
export function showWarningToast(title: string, description?: string) { export function showWarningToast(title: string, description?: string, id?: number | string) {
toast.warning(title, { toast.warning(title, {
description, description,
id,
}); });
} }
/** /**
* Muestra un toast de éxito * Muestra un toast de éxito
*/ */
export function showSuccessToast(title: string, description?: string) { export function showSuccessToast(title: string, description?: string, id?: number | string) {
toast.success(title, { toast.success(title, {
description, description,
id,
}); });
} }
/** /**
* Muestra un toast de error * Muestra un toast de error
*/ */
export function showErrorToast(title: string, description?: string) { export function showErrorToast(title: string, description?: string, id?: number | string) {
toast.error(title, { toast.error(title, {
description, description,
id,
});
}
/**
* Toast con opción "Undo" (deshacer)
* @param message
* @param onUndo
* @returns
*/
export function showUndoToast(
message: string,
onUndo: () => void,
id?: number | string
): number | string {
return toast(message, {
id,
description: "",
action: {
label: "Undo",
onClick: onUndo,
},
duration: 5000,
}); });
} }