diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 2513578f..f277f91f 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -61,19 +61,7 @@ export const App = () => { - {import.meta.env.DEV && } diff --git a/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/unsaved-changes-dialog.tsx b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/unsaved-changes-dialog.tsx index d2bd83eb..e2d3150a 100644 --- a/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/unsaved-changes-dialog.tsx +++ b/modules/core/src/web/hooks/use-unsaved-changes-notifier/components/unsaved-changes-dialog.tsx @@ -9,21 +9,15 @@ type UnsavedChangesDialogProps = { export function UnsavedChangesDialog({ open, onConfirm }: UnsavedChangesDialogProps) { const { t } = useTranslation(); - const defaultTitle = t ? t("hooks.unsaved_changes_dialog.title") : "¿Descartar cambios?"; - const defaultDescription = t - ? t("hooks.unsaved_changes_dialog.description") - : "Tienes cambios sin guardar. Si sales de esta página, se perderán."; - const defaultCancelLabel = t ? t("hooks.unsaved_changes_dialog.cancel") : "Seguir en esta página"; - const defaultConfirmLabel = t ? t("hooks.unsaved_changes_dialog.confirm") : "Descartar cambios"; - - return ( - - ); + return } diff --git a/modules/core/src/web/hooks/use-unsaved-changes-notifier/use-unsaved-changes-notifier.tsx b/modules/core/src/web/hooks/use-unsaved-changes-notifier/use-unsaved-changes-notifier.tsx index 5ca0c852..96fdd395 100644 --- a/modules/core/src/web/hooks/use-unsaved-changes-notifier/use-unsaved-changes-notifier.tsx +++ b/modules/core/src/web/hooks/use-unsaved-changes-notifier/use-unsaved-changes-notifier.tsx @@ -23,7 +23,7 @@ export function UnsavedChangesProvider({ isDirty: boolean; children: React.ReactNode; }) { - const [resolver, setResolver] = useState<(ok: boolean) => void>(); + const [resolver, setResolver] = useState<((ok: boolean) => void) | null>(null); const [open, setOpen] = useState(false); // requestConfirm() devuelve una promesa que resuelve true/false @@ -37,15 +37,16 @@ export function UnsavedChangesProvider({ const handleConfirm = (ok: boolean) => { resolver?.(ok); - setResolver(undefined); + setResolver(null); setOpen(false); }; - // 🔹 bloquea F5/cierre de pestaña + // bloquea F5/cierre de pestaña useBeforeUnload( (event) => { if (isDirty) { event.preventDefault(); + event.returnValue = ""; // requerido por Chrome/Edge } }, { capture: true } @@ -55,13 +56,14 @@ export function UnsavedChangesProvider({ const navigator = useContext(UNSAFE_NavigationContext).navigator as any; useEffect(() => { - if (!isDirty) return; - const push = navigator.push; - navigator.push = async (...args: any[]) => { - const ok = await requestConfirm(); - if (ok) push.apply(navigator, args); - }; + + if (isDirty) { + navigator.push = async (...args: any[]) => { + const ok = await requestConfirm(); + if (ok) push.apply(navigator, args); + }; + } return () => { navigator.push = push; @@ -72,18 +74,21 @@ export function UnsavedChangesProvider({ const blocker = useBlocker(() => isDirty); useEffect(() => { - if (blocker.state === "blocked") { - (async () => { - const ok = await requestConfirm(); - if (ok) { - blocker.proceed(); - } else { - blocker.reset(); - } - })(); - } + if (blocker.state !== "blocked") return; + let active = true; + + (async () => { + const ok = await requestConfirm(); + if (!active) return; + ok ? blocker.proceed() : blocker.reset(); + })(); + + return () => { + active = false; + }; }, [blocker, requestConfirm]); + return ( {children} diff --git a/modules/customer-invoices/src/api/application/customer-invoice-application.service.ts b/modules/customer-invoices/src/api/application/customer-invoice-application.service.ts index 0f18a1e3..d147da98 100644 --- a/modules/customer-invoices/src/api/application/customer-invoice-application.service.ts +++ b/modules/customer-invoices/src/api/application/customer-invoice-application.service.ts @@ -16,7 +16,7 @@ export class CustomerInvoiceApplicationService { /** * 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 invoiceId - Identificador UUID de la factura (opcional). * @returns Result - 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. * - * @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 transaction - Transacción activa para la operación. * @returns Result - 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. * - * @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 transaction - Transacción activa para la operación. * @returns Result - 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 * - * @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 transaction - Transacción activa para la operación. * @returns Result - 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. * - * @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 transaction - Transacción activa para la operación. * @returns Result, Error> - Colección de facturas o error. @@ -124,7 +124,7 @@ export class CustomerInvoiceApplicationService { * Actualiza parcialmente una factura existente con nuevos datos. * 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 changes - Subconjunto de props válidas para aplicar. * @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. * - * @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 transaction - Transacción activa para la operación. * @returns Result - Resultado de la operación. diff --git a/modules/customer-invoices/src/api/application/use-cases/create/create-customer-invoice.use-case.ts b/modules/customer-invoices/src/api/application/use-cases/create/create-customer-invoice.use-case.ts index 1ee79819..31bb1e4d 100644 --- a/modules/customer-invoices/src/api/application/use-cases/create/create-customer-invoice.use-case.ts +++ b/modules/customer-invoices/src/api/application/use-cases/create/create-customer-invoice.use-case.ts @@ -4,7 +4,7 @@ import { UniqueID } from "@repo/rdx-ddd"; import { Result } from "@repo/rdx-utils"; import { Transaction } from "sequelize"; import { CreateCustomerInvoiceRequestDTO } from "../../../../common/dto"; -import { CustomerInvoiceApplicationService } from "../../../domain"; +import { CustomerInvoiceApplicationService } from "../../customer-invoice-application.service"; import { CustomerInvoiceFullPresenter } from "../../presenters"; import { CreateCustomerInvoicePropsMapper } from "./map-dto-to-create-customer-invoice-props"; @@ -53,7 +53,11 @@ export class CreateCustomerInvoiceUseCase { 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) { return Result.fail(saveResult.error); } diff --git a/modules/customer-invoices/src/web/pages/list/invoices-list-grid.tsx b/modules/customer-invoices/src/web/pages/list/invoices-list-grid.tsx index 1b1d3357..70d8f96e 100644 --- a/modules/customer-invoices/src/web/pages/list/invoices-list-grid.tsx +++ b/modules/customer-invoices/src/web/pages/list/invoices-list-grid.tsx @@ -2,9 +2,8 @@ import type { CellKeyDownEvent, RowClickedEvent } from "ag-grid-community"; import { useCallback, useState } from "react"; +import { SimpleSearchInput } from '@erp/core/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 { usePinnedPreviewSheet } from '../../hooks'; import { useTranslation } from "../../i18n"; @@ -100,15 +99,6 @@ export const InvoicesListGrid = ({ [goToRow] ); - // Handlers de búsqueda - const handleInputChange = (e: React.ChangeEvent) => onSearchChange(e.target.value); - const handleClear = () => onSearchChange(""); - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - // Envío inmediato: forzar “salto” del debounce - onSearchChange((e.target as HTMLInputElement).value); - } - }; const handleRowClick = useCallback( (invoice: InvoiceSummaryFormData, _i: number, e: React.MouseEvent) => { @@ -137,35 +127,8 @@ export const InvoicesListGrid = ({
{/* Barra de filtros */}
-
- - - - - - - {loading && } - {!searchValue && !loading && - {t("common.search")} - } - {searchValue && !loading && - - {t("common.search")} - } - - - -
- @@ -180,7 +143,7 @@ export const InvoicesListGrid = ({ + */}
diff --git a/modules/customer-invoices/src/web/pages/list/invoices-list-page.tsx b/modules/customer-invoices/src/web/pages/list/invoices-list-page.tsx index c84b5c18..e98e781f 100644 --- a/modules/customer-invoices/src/web/pages/list/invoices-list-page.tsx +++ b/modules/customer-invoices/src/web/pages/list/invoices-list-page.tsx @@ -1,6 +1,6 @@ import { PageHeader } from '@erp/core/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 { PlusIcon } from "lucide-react"; import { useMemo, useState } from 'react'; @@ -17,16 +17,15 @@ export const InvoiceListPage = () => { const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(10); const [search, setSearch] = useState(""); - const debouncedQ = useDebounce(search, 300); const criteria = useMemo( () => ({ - q: debouncedQ || "", + q: search || "", pageSize, pageNumber: pageIndex, }), - [pageSize, pageIndex, debouncedQ] + [pageSize, pageIndex, search] ); const { diff --git a/modules/customer-invoices/src/web/pages/update/invoice-update-comp.tsx b/modules/customer-invoices/src/web/pages/update/invoice-update-comp.tsx index d027b2f3..35d016cd 100644 --- a/modules/customer-invoices/src/web/pages/update/invoice-update-comp.tsx +++ b/modules/customer-invoices/src/web/pages/update/invoice-update-comp.tsx @@ -91,16 +91,11 @@ export const InvoiceUpdateComp = ({ title={`${t("pages.edit.title")} #${invoiceData.invoice_number}`} description={t("pages.edit.description")} rightSlot={<> - navigate(-1)} /> } /> diff --git a/modules/customers/src/api/application/customer-application.service.ts b/modules/customers/src/api/application/customer-application.service.ts index 89699bda..9332adff 100644 --- a/modules/customers/src/api/application/customer-application.service.ts +++ b/modules/customers/src/api/application/customer-application.service.ts @@ -3,12 +3,28 @@ import { Criteria } from "@repo/rdx-criteria/server"; import { UniqueID } from "@repo/rdx-ddd"; import { Collection, Result } from "@repo/rdx-utils"; import { Transaction } from "sequelize"; -import { Customer, CustomerPatchProps, ICustomerRepository } from "../domain"; +import { Customer, CustomerPatchProps, CustomerProps, ICustomerRepository } from "../domain"; import { CustomerListDTO } from "../infrastructure"; export class CustomerApplicationService { 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 - El agregado construido o un error si falla la creación. + */ + buildCustomerInCompany( + companyId: UniqueID, + props: Omit, + customerId?: UniqueID + ): Result { + return Customer.create({ ...props, companyId }, customerId); + } + /** * Guarda un nuevo cliente y devuelve el cliente guardado. * diff --git a/modules/customers/src/api/application/use-cases/create/create-customer.use-case.ts b/modules/customers/src/api/application/use-cases/create/create-customer.use-case.ts index c70d4c94..e840a0a6 100644 --- a/modules/customers/src/api/application/use-cases/create/create-customer.use-case.ts +++ b/modules/customers/src/api/application/use-cases/create/create-customer.use-case.ts @@ -4,7 +4,7 @@ import { Result } from "@repo/rdx-utils"; import { Transaction } from "sequelize"; import { CreateCustomerRequestDTO } from "../../../../common"; import { logger } from "../../..//helpers"; -import { CustomerApplicationService } from "../../../domain"; +import { CustomerApplicationService } from "../../customer-application.service"; import { CustomerFullPresenter } from "../../presenters"; import { CustomerNotExistsInCompanySpecification } from "../../specs"; import { mapDTOToCreateCustomerProps } from "./map-dto-to-create-customer-props"; @@ -63,7 +63,11 @@ export class CreateCustomerUseCase { 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) { return Result.fail(saveResult.error); } diff --git a/modules/customers/src/common/locales/en.json b/modules/customers/src/common/locales/en.json index 2a381d7b..f11267a0 100644 --- a/modules/customers/src/common/locales/en.json +++ b/modules/customers/src/common/locales/en.json @@ -23,14 +23,11 @@ "title": "Customer list", "description": "List all customers", "grid_columns": { - "name": "Name", - "trade_name": "Trade name", + "customer": "Customer", "status": "Status", - "email": "Email", - "phone": "Phone", - "city": "City", - "tin": "TIN", - "mobile": "Mobile" + "contact": "Contact", + "address": "Address", + "actions": "Actions" } }, "create": { diff --git a/modules/customers/src/common/locales/es.json b/modules/customers/src/common/locales/es.json index da251cee..9a6db22d 100644 --- a/modules/customers/src/common/locales/es.json +++ b/modules/customers/src/common/locales/es.json @@ -23,14 +23,11 @@ "title": "Lista de clientes", "description": "Lista todos los clientes", "grid_columns": { - "name": "Nombre", - "trade_name": "Nombre comercial", + "customer": "Cliente", "status": "Estado", - "email": "Correo electrónico", - "phone": "Teléfono", - "city": "Ciudad", - "tin": "Nº Id.", - "mobile": "Móvil" + "contact": "Contacto", + "address": "Dirección", + "actions": "Acciones" } }, "create": { 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 index 21791a87..b48b13f5 100644 --- 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 @@ -62,15 +62,15 @@ export function CustomerCreateModal({ - + {t("pages.create.title")} - {t("pages.create.subtitle")} + {t("pages.create.description")} -
+
void; - onError: (errors: FieldErrors) => void; + onSubmit: (event: React.FormEvent) => void; className?: string; focusRef?: React.RefObject; }; -export const CustomerEditForm = ({ formId, onSubmit, onError, className, focusRef }: CustomerFormProps) => { - const form = useFormContext(); - +export const CustomerEditForm = ({ formId, onSubmit, className, focusRef }: CustomerFormProps) => { return ( -
) => { - event.stopPropagation(); - form.handleSubmit(onSubmit, onError)(event) - }}> + -
+
diff --git a/modules/customers/src/web/hooks/use-create-customer-mutation.ts b/modules/customers/src/web/hooks/use-create-customer-mutation.ts index 6826f55c..23cc20c6 100644 --- a/modules/customers/src/web/hooks/use-create-customer-mutation.ts +++ b/modules/customers/src/web/hooks/use-create-customer-mutation.ts @@ -5,7 +5,7 @@ import { ZodError } from "zod/v4"; import { CreateCustomerRequestSchema } from "../../common"; import { Customer, CustomerFormData } from "../schemas"; 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; @@ -33,26 +33,24 @@ export function useCreateCustomer() { const id = UniqueID.generateNewID().toString(); const payload = { ...data, id }; - console.log("payload => ", payload); const result = schema.safeParse(payload); - result.error; - if (!result.success) { - const errorDetails = toValidationErrors(result.error); - console.log(errorDetails); - throw new ValidationErrorCollection("Validation failed", errorDetails); + throw new ValidationErrorCollection("Validation failed", toValidationErrors(result.error)); } + const created = await dataSource.createOne("customers", payload); return created as Customer; }, - onSuccess: (created) => { + onSuccess: (created: Customer, variables) => { + const { id: customerId } = created; + // Invalida el listado para refrescar desde servidor invalidateCustomerListCache(queryClient); // Sincroniza detalle - queryClient.setQueryData(getCustomerQueryKey(created.id), created); + setCustomerDetailCache(queryClient, customerId, created); }, onSettled: () => { @@ -60,7 +58,7 @@ export function useCreateCustomer() { invalidateCustomerListCache(queryClient); }, - onMutate: async ({ data }) => { + onMutate: async ({ data }, context) => { // Cancelar queries del listado para evitar overwrite await queryClient.cancelQueries({ queryKey: [CUSTOMERS_LIST_KEY] }); diff --git a/modules/customers/src/web/hooks/use-update-customer-mutation.ts b/modules/customers/src/web/hooks/use-update-customer-mutation.ts index 8732aa30..562bca2d 100644 --- a/modules/customers/src/web/hooks/use-update-customer-mutation.ts +++ b/modules/customers/src/web/hooks/use-update-customer-mutation.ts @@ -1,10 +1,13 @@ import { useDataSource } from "@erp/core/hooks"; 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 { Customer, CustomerFormData } from "../schemas"; 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"; export const CUSTOMER_UPDATE_KEY = ["customers", "update"] as const; @@ -21,7 +24,7 @@ export function useUpdateCustomer() { const dataSource = useDataSource(); const schema = UpdateCustomerByIdRequestSchema; - return useMutation({ + return useMutation({ mutationKey: CUSTOMER_UPDATE_KEY, mutationFn: async (payload) => { @@ -40,7 +43,10 @@ export function useUpdateCustomer() { }, onSuccess: (updated: Customer, variables) => { - const { id: customerId } = variables; + const { id: customerId } = updated; + + // Invalida el listado para refrescar desde servidor + invalidateCustomerListCache(queryClient); // Actualiza detalle setCustomerDetailCache(queryClient, customerId, updated); @@ -48,5 +54,10 @@ export function useUpdateCustomer() { // Actualiza todas las páginas donde aparezca upsertCustomerIntoListCaches(queryClient, { ...updated }); }, + + onSettled: () => { + // Refresca todos los listados + invalidateCustomerListCache(queryClient); + }, }); } diff --git a/modules/customers/src/web/pages/create/customer-create-page.tsx b/modules/customers/src/web/pages/create/customer-create-page.tsx index a245a5d4..889d7c3f 100644 --- a/modules/customers/src/web/pages/create/customer-create-page.tsx +++ b/modules/customers/src/web/pages/create/customer-create-page.tsx @@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom"; import { PageHeader } from '@erp/core/components'; import { UnsavedChangesProvider, UpdateCommitButtonGroup } from "@erp/core/hooks"; -import { useId } from 'react'; import { CustomerEditForm, ErrorAlert } from "../../components"; import { useTranslation } from "../../i18n"; import { useCustomerCreateController } from './use-customer-create-controller'; @@ -11,17 +10,18 @@ import { useCustomerCreateController } from './use-customer-create-controller'; export const CustomerCreatePage = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const formId = useId(); const { - form, isCreating, isCreateError, createError, - handleSubmit, handleError, FormProvider - } = useCustomerCreateController(); + form, formId, onSubmit, resetForm, + isCreating, isCreateError, createError, + + FormProvider + } = useCustomerCreateController({ + onCreated: (created) => + navigate("/customers/list", { state: { customerId: created.id, isNew: true }, replace: true }) + }); - const handleBack = () => { - navigate(-1); - }; return ( @@ -42,7 +42,7 @@ export const CustomerCreatePage = () => { formId: formId, disabled: isCreating, }} - onBack={() => handleBack()} + onReset={resetForm} /> } /> @@ -61,14 +61,9 @@ export const CustomerCreatePage = () => { - handleSubmit(data, ({ id }) => - navigate("/customers/list", { state: { customerId: id, isNew: true }, replace: true }) - ) - } - onError={handleError} - + formId={formId} // para que el botón del header pueda hacer submit + onSubmit={onSubmit} + className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto mt-6" /> diff --git a/modules/customers/src/web/pages/create/use-customer-create-controller.ts b/modules/customers/src/web/pages/create/use-customer-create-controller.ts index e264bf3a..67de33d3 100644 --- a/modules/customers/src/web/pages/create/use-customer-create-controller.ts +++ b/modules/customers/src/web/pages/create/use-customer-create-controller.ts @@ -1,14 +1,22 @@ 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 { FieldErrors, FormProvider } from "react-hook-form"; +import { FormProvider } from "react-hook-form"; import { useCreateCustomer } from "../../hooks"; import { useTranslation } from "../../i18n"; -import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas"; +import { + Customer, + CustomerFormData, + CustomerFormSchema, + defaultCustomerFormData, +} from "../../schemas"; export interface UseCustomerCreateControllerOptions { - onCreated?(created: CustomerFormData): void; // navegación, cierre modal, etc. - successToasts?: boolean; // permite desactivar toasts si el host los gestiona + onCreated?(created: Customer): void; + 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) => { @@ -17,7 +25,7 @@ export const useCustomerCreateController = (options?: UseCustomerCreateControlle // 1) Estado de creación (mutación) const { - mutate, + mutateAsync, isPending: isCreating, isError: isCreateError, error: createError, @@ -30,57 +38,69 @@ export const useCustomerCreateController = (options?: UseCustomerCreateControlle disabled: isCreating, }); - const handleSubmit = (formData: CustomerFormData) => { - 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); - }, + /** Handlers */ - 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 resetForm = () => form.reset(defaultCustomerFormData); + + // Versión sincronizada + const submitHandler = form.handleSubmit(async (formData) => { + try { + // 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) => { - // 1) Enfoca el primer error - const firstKey = Object.keys(errors)[0] as keyof CustomerFormData | undefined; - if (firstKey) { - const el = document.querySelector(`[name="${String(firstKey)}"]`); - el?.focus(); + // 1) Enfoca el primer error + /*const firstKey = Object.keys(errors)[0] as keyof CustomerFormData | undefined; + if (firstKey) { + const el = document.querySelector(`[name="${String(firstKey)}"]`); + 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"), - t("forms.validation.msg", "Hay errores de validación en el formulario.") - ); + }); + + // Evento onSubmit ya preparado para el + const onSubmit = (event: React.FormEvent) => { + 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, + + // mutation isCreating, isCreateError, createError, - handleSubmit, - handleError, - FormProvider, // para no re-importar fuera + + // Por comodidad + FormProvider, }; }; diff --git a/modules/customers/src/web/pages/list/customers-list-grid.tsx b/modules/customers/src/web/pages/list/customers-list-grid.tsx index e6a2f802..d4d9e5fb 100644 --- a/modules/customers/src/web/pages/list/customers-list-grid.tsx +++ b/modules/customers/src/web/pages/list/customers-list-grid.tsx @@ -1,7 +1,6 @@ +import { SimpleSearchInput } from '@erp/core/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 { useNavigate } from "react-router-dom"; import { useTranslation } from "../../i18n"; @@ -97,34 +96,7 @@ export const CustomersListGrid = ({
{/* Barra de filtros */}
-
- - - - - - - {loading && } - {!searchValue && !loading && - {t("common.search")} - } - {searchValue && !loading && - - {t("common.search")} - } - - - -
+
diff --git a/modules/customers/src/web/pages/list/customers-list-page.tsx b/modules/customers/src/web/pages/list/customers-list-page.tsx index b9ded23c..911579d1 100644 --- a/modules/customers/src/web/pages/list/customers-list-page.tsx +++ b/modules/customers/src/web/pages/list/customers-list-page.tsx @@ -16,8 +16,8 @@ export const CustomersListPage = () => { const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(10); const [search, setSearch] = useState(""); - const debouncedQ = useDebounce(search, 300); + const debouncedQ = useDebounce(search, 300); const criteria = useMemo( () => ({ @@ -34,12 +34,9 @@ export const CustomersListPage = () => { isLoading, isError, error, - } = useCustomerListQuery({ - criteria - }); + } = useCustomerListQuery({ criteria }); const handlePageChange = (newPageIndex: number) => { - // TanStack usa pageIndex 0-based → API usa 0-based también setPageIndex(newPageIndex); }; @@ -49,7 +46,6 @@ export const CustomersListPage = () => { }; const handleSearchChange = (value: string) => { - // Normalización ligera: recorta y colapsa espacios internos const cleaned = value.trim().replace(/\s+/g, " "); setSearch(cleaned); setPageIndex(0); diff --git a/modules/customers/src/web/pages/list/use-customers-list-columns.tsx b/modules/customers/src/web/pages/list/use-customers-list-columns.tsx index 598c8042..8774c711 100644 --- a/modules/customers/src/web/pages/list/use-customers-list-columns.tsx +++ b/modules/customers/src/web/pages/list/use-customers-list-columns.tsx @@ -1,11 +1,16 @@ +import { DataTableColumnHeader } from '@repo/rdx-ui/components'; import { Avatar, AvatarFallback, Badge, Button, DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger + DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, + Tooltip, + TooltipContent, + TooltipTrigger } from '@repo/shadcn-ui/components'; +import { cn } from '@repo/shadcn-ui/lib/utils'; import type { ColumnDef } from "@tanstack/react-table"; import { Building2Icon, GlobeIcon, MailIcon, MoreHorizontalIcon, PencilIcon, PhoneIcon, User2Icon } from 'lucide-react'; import * as React from "react"; @@ -22,24 +27,33 @@ function shortId(id: string) { 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 ---- const StatusBadge = ({ status }: { status: string }) => { // Map visual simple; ajustar a tu catálogo real - const v = - status.toLowerCase() === "active" - ? "default" - : status.toLowerCase() === "inactive" - ? "outline" - : "secondary"; + const statusClass = React.useMemo(() => status.toLowerCase() === 'active' ? statuses.active : statuses.inactive, [status]); + const contentTxt = React.useMemo(() => status.toLowerCase() === 'active' ? 'El cliente está activo' : 'El cliente está inactivo', [status]); + return ( - - {status} - - ); + + +
+
+
+ + {contentTxt} + + + ) }; const KindBadge = ({ isCompany }: { isCompany: boolean }) => ( - + {isCompany ? : } {isCompany ? "Company" : "Person"} @@ -50,23 +64,30 @@ const Soft = ({ children }: { children: React.ReactNode }) => ( ); const ContactCell = ({ customer }: { customer: CustomerSummaryFormData }) => ( -
-
- - - {customer.email_primary || } - - {customer.email_secondary && • {customer.email_secondary}} -
-
+
+ + {customer.email_primary && ( + + )} + + {customer.email_secondary && ( +
{customer.email_secondary}
+ )} + +
{customer.phone_primary || customer.mobile_primary || -} {customer.phone_secondary && • {customer.phone_secondary}} {customer.mobile_secondary && • {customer.mobile_secondary}} - {customer.fax && • fax {customer.fax}} + {false && customer.fax && • fax {customer.fax}}
- {customer.website && ( -
+ {false && customer.website && ( + ); -const AddressCell: React.FC<{ c: CustomerSummaryFormData }> = ({ c }) => { +const AddressCell = ({ c }: { c: CustomerSummaryFormData }) => { const line1 = [c.street, c.street2].filter(Boolean).join(", "); const line2 = [c.postal_code, c.city].filter(Boolean).join(" "); const line3 = [c.province, c.country].filter(Boolean).join(", "); return ( -
-
{line1 || }
+
+
{line1 || -}
{[line2, line3].filter(Boolean).join(" • ")}
); @@ -99,6 +120,7 @@ function safeHttp(url: string) { return `https://${url}`; } + export function useCustomersListColumns( handlers: CustomerActionHandlers = {} ): ColumnDef[] { @@ -110,31 +132,30 @@ export function useCustomersListColumns( return React.useMemo[]>(() => [ // Identidad + estado + metadatos (columna compuesta) { - id: "identity", - header: "Customer", + id: "customer", + header: ({ column }) => ( + + ), accessorFn: (row) => row.name, // para ordenar/buscar por nombre enableHiding: false, - size: 380, + size: 140, + minSize: 120, cell: ({ row }) => { const c = row.original; const isCompany = String(c.is_company).toLowerCase() === "true"; return ( -
- +
+ {initials(c.name)}
- {c.name} + {c.name} {c.trade_name && ({c.trade_name})}
- {c.tin && {c.tin}} -
-
- + {c.tin && {c.tin}} - {c.reference && Ref: {c.reference}}
@@ -145,27 +166,34 @@ export function useCustomersListColumns( // Contacto (emails, teléfonos, web) { id: "contact", - header: "Contact", + header: ({ column }) => ( + + ), accessorFn: (r) => `${r.email_primary} ${r.phone_primary} ${r.mobile_primary} ${r.website}`, - size: 420, + size: 140, + minSize: 120, cell: ({ row }) => , }, // Dirección (múltiples campos en bloque) { id: "address", - header: "Address", + header: t("pages.list.grid_columns.address"), accessorFn: (r) => `${r.street} ${r.street2} ${r.city} ${r.postal_code} ${r.province} ${r.country}`, - size: 360, + size: 140, + minSize: 120, cell: ({ row }) => , }, // Acciones { id: "actions", - header: () => Actions, - size: 72, + header: ({ column }) => ( + + ), + size: 64, + minSize: 64, enableSorting: false, enableHiding: false, cell: ({ row }) => { diff --git a/modules/customers/src/web/pages/update/customer-update-page.tsx b/modules/customers/src/web/pages/update/customer-update-page.tsx index 272b62b5..8a59f7c8 100644 --- a/modules/customers/src/web/pages/update/customer-update-page.tsx +++ b/modules/customers/src/web/pages/update/customer-update-page.tsx @@ -1,92 +1,36 @@ 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 { UnsavedChangesProvider, UpdateCommitButtonGroup, - useHookForm, - useUrlParamId, + useUrlParamId } from "@erp/core/hooks"; -import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers"; -import { FieldErrors, FormProvider } from "react-hook-form"; import { CustomerEditForm, CustomerEditorSkeleton, ErrorAlert, NotFoundCard, } from "../../components"; -import { useCustomerQuery, useUpdateCustomer } from "../../hooks"; import { useTranslation } from "../../i18n"; -import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas"; +import { useCustomerUpdateController } from './use-customer-update-controller'; export const CustomerUpdatePage = () => { const customerId = useUrlParamId(); const { t } = useTranslation(); - const navigate = useNavigate(); - // 1) Estado de carga del cliente (query) const { - data: customerData, - isLoading: isLoadingCustomer, - isError: isLoadError, - error: loadError, - } = useCustomerQuery(customerId, { enabled: !!customerId }); + form, formId, onSubmit, resetForm, - // 2) Estado de actualización (mutación) - const { - mutate, - isPending: isUpdating, - isError: isUpdateError, - error: updateError, - } = useUpdateCustomer(); + customerData, + isLoading, isLoadError, loadError, - // 3) Form hook - const form = useHookForm({ - resolverSchema: CustomerFormSchema, - initialValues: customerData ?? defaultCustomerFormData, - disabled: isUpdating, - }); + isUpdating, isUpdateError, updateError, - // 4) Submit con navegación condicionada por éxito - const handleSubmit = (formData: CustomerFormData) => { - const { dirtyFields } = form.formState; + FormProvider + } = useCustomerUpdateController(customerId, {}); - if (!formHasAnyDirty(dirtyFields)) { - 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) => { - console.error("Errores en el formulario:", errors); - // Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario - }; - - if (isLoadingCustomer) { + if (isLoading) { return ; } @@ -136,15 +80,15 @@ export const CustomerUpdatePage = () => { isLoading={isUpdating} disabled={isUpdating} cancel={{ + formId, to: "/customers/list", disabled: isUpdating, }} submit={{ - formId: "customer-update-form", + formId, disabled: isUpdating, }} - onBack={() => handleBack()} - onReset={() => handleReset()} + onReset={resetForm} /> } /> @@ -163,10 +107,9 @@ export const CustomerUpdatePage = () => { diff --git a/modules/customers/src/web/pages/update/use-customer-update-controller.ts b/modules/customers/src/web/pages/update/use-customer-update-controller.ts new file mode 100644 index 00000000..653ef61b --- /dev/null +++ b/modules/customers/src/web/pages/update/use-customer-update-controller.ts @@ -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): 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({ + 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) => { + const firstKey = Object.keys(errors)[0] as keyof CustomerFormData | undefined; + if (firstKey) document.querySelector(`[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 + const onSubmit = (event: React.FormEvent) => { + 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, + }; +}; diff --git a/packages/rdx-ddd/src/errors/domain-error.ts b/packages/rdx-ddd/src/errors/domain-error.ts index 75f7e873..a562a8ec 100644 --- a/packages/rdx-ddd/src/errors/domain-error.ts +++ b/packages/rdx-ddd/src/errors/domain-error.ts @@ -11,12 +11,8 @@ import { BaseError } from "./base-error"; /** Error base de dominio: no depende de infra ni HTTP */ export class DomainError extends BaseError<"domain"> { public readonly layer = "domain" as const; - constructor( - message: string, - code = "DOMAIN_ERROR", - options?: ErrorOptions & { metadata?: Record } - ) { - super("DomainError", message, code, options); + constructor(message: string, options?: ErrorOptions & { metadata?: Record }) { + super("DomainError", message, "DOMAIN_ERROR", options); } } diff --git a/packages/rdx-ui/src/components/custom-dialog.tsx b/packages/rdx-ui/src/components/custom-dialog.tsx index 66aab446..1c2cc50a 100644 --- a/packages/rdx-ui/src/components/custom-dialog.tsx +++ b/packages/rdx-ui/src/components/custom-dialog.tsx @@ -8,6 +8,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@repo/shadcn-ui/components"; +import { useState } from 'react'; interface CustomDialogProps { open: boolean; @@ -26,18 +27,35 @@ export const CustomDialog = ({ cancelLabel, confirmLabel, }: CustomDialogProps) => { + const [closedByAction, setClosedByAction] = useState(false); + + const handleClose = (ok: boolean) => { + setClosedByAction(true); + onConfirm(ok); + }; + return ( - !open && onConfirm(false)}> - - - {title} - {description} + { + if (!nextOpen && !closedByAction) onConfirm(false); + if (nextOpen) setClosedByAction(false); + }} + > + + + {title} + + {description} + - - onConfirm(false)}>{cancelLabel} - onConfirm(true)}>{confirmLabel} + + {cancelLabel} + + {confirmLabel} + ); -}; +}; \ No newline at end of file diff --git a/packages/rdx-ui/src/helpers/toast-utils.ts b/packages/rdx-ui/src/helpers/toast-utils.ts index dc573f04..5a1e53fd 100644 --- a/packages/rdx-ui/src/helpers/toast-utils.ts +++ b/packages/rdx-ui/src/helpers/toast-utils.ts @@ -3,35 +3,62 @@ import { toast } from "@repo/shadcn-ui/components"; /** * 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, { description, + id, }); } /** * 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, { description, + id, }); } /** * 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, { description, + id, }); } /** * 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, { 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, }); }