Clientes y Facturas de cliente
This commit is contained in:
parent
8efd73abb4
commit
e5cb2b318d
@ -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>
|
||||||
|
|||||||
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"}>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)}
|
|
||||||
/>
|
/>
|
||||||
</>}
|
</>}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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": {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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] });
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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"}>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 }) => {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user