Clientes y Facturas de cliente
This commit is contained in:
parent
8efd73abb4
commit
e5cb2b318d
@ -61,19 +61,7 @@ export const App = () => {
|
||||
<RouterProvider router={appRouter} />
|
||||
</Suspense>
|
||||
</TooltipProvider>
|
||||
<Toaster
|
||||
toastOptions={
|
||||
{
|
||||
//unstyled: true,
|
||||
/*classNames: {
|
||||
error: "bg-red-400",
|
||||
success: "text-green-400",
|
||||
warning: "text-yellow-400",
|
||||
info: "bg-blue-400",
|
||||
},*/
|
||||
}
|
||||
}
|
||||
position='bottom-center'
|
||||
<Toaster expand={true} position='bottom-center'
|
||||
/>
|
||||
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} />}
|
||||
</AuthProvider>
|
||||
|
||||
@ -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 (
|
||||
<CustomDialog
|
||||
open={open}
|
||||
onConfirm={onConfirm}
|
||||
title={defaultTitle}
|
||||
description={defaultDescription}
|
||||
confirmLabel={defaultConfirmLabel}
|
||||
cancelLabel={defaultCancelLabel}
|
||||
/>
|
||||
);
|
||||
return <CustomDialog
|
||||
open={open}
|
||||
onConfirm={onConfirm}
|
||||
title={t("hooks.unsaved_changes_dialog.title", "¿Descartar cambios?")}
|
||||
description={t(
|
||||
"hooks.unsaved_changes_dialog.description",
|
||||
"Tienes cambios sin guardar. Si sales de esta página, se perderán."
|
||||
)}
|
||||
cancelLabel={t("hooks.unsaved_changes_dialog.cancel", "Seguir en esta página")}
|
||||
confirmLabel={t("hooks.unsaved_changes_dialog.confirm", "Descartar cambios")}
|
||||
/>
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<UnsavedChangesContext.Provider value={{ requestConfirm }}>
|
||||
{children}
|
||||
|
||||
@ -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<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.
|
||||
*
|
||||
* @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<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.
|
||||
*
|
||||
* @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<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
|
||||
*
|
||||
* @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<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.
|
||||
*
|
||||
* @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<Collection<CustomerInvoiceListDTO>, 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<boolean, Error> - Resultado de la operación.
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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<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(
|
||||
(invoice: InvoiceSummaryFormData, _i: number, e: React.MouseEvent) => {
|
||||
@ -137,35 +127,8 @@ export const InvoicesListGrid = ({
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Barra de filtros */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="relative flex-1" aria-label={t("pages.list.searchPlaceholder")}>
|
||||
<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>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SimpleSearchInput onSearchChange={onSearchChange} loading={loading} />
|
||||
{/*<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full sm:w-48 bg-white border-gray-200 shadow-sm">
|
||||
<FilterIcon className="mr-2 h-4 w-4" />
|
||||
<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">
|
||||
<FileDownIcon className="mr-2 h-4 w-4" />
|
||||
Exportar
|
||||
</Button>
|
||||
</Button>*/}
|
||||
</div>
|
||||
<div className="relative flex">
|
||||
<div className={preview.isPinned ? "flex-1 mr-[500px]" : "flex-1"}>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -91,16 +91,11 @@ export const InvoiceUpdateComp = ({
|
||||
title={`${t("pages.edit.title")} #${invoiceData.invoice_number}`}
|
||||
description={t("pages.edit.description")}
|
||||
rightSlot={<>
|
||||
<button type="submit" form={formId} onClick={(e) => {
|
||||
e.preventDefault(); const submit = form.handleSubmit(handleSubmit, handleError);
|
||||
void submit(e)
|
||||
}}>Enviar</button>
|
||||
<UpdateCommitButtonGroup
|
||||
isLoading={isPending}
|
||||
|
||||
submit={{ formId, variant: 'default', disabled: isPending, label: t("pages.edit.actions.save_draft") }}
|
||||
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 { 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<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.
|
||||
*
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -62,15 +62,15 @@ export function CustomerCreateModal({
|
||||
<UnsavedChangesProvider isDirty={isDirty}>
|
||||
|
||||
<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">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="size-5" /> {t("pages.create.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{t("pages.create.subtitle")}</DialogDescription>
|
||||
<DialogDescription className='text-left'>{t("pages.create.description")}</DialogDescription>
|
||||
</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}>
|
||||
<CustomerEditForm
|
||||
formId={formId}
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import { FormDebug } from "@erp/core/components";
|
||||
import { FieldErrors, useFormContext } from "react-hook-form";
|
||||
|
||||
import { cn } from '@repo/shadcn-ui/lib/utils';
|
||||
import { CustomerFormData } from "../../schemas";
|
||||
import { CustomerAdditionalConfigFields } from "./customer-additional-config-fields";
|
||||
import { CustomerAddressFields } from "./customer-address-fields";
|
||||
import { CustomerBasicInfoFields } from "./customer-basic-info-fields";
|
||||
@ -10,22 +8,16 @@ import { CustomerContactFields } from './customer-contact-fields';
|
||||
|
||||
type CustomerFormProps = {
|
||||
formId: string;
|
||||
onSubmit: (data: CustomerFormData) => void;
|
||||
onError: (errors: FieldErrors<CustomerFormData>) => void;
|
||||
onSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
|
||||
className?: string;
|
||||
focusRef?: React.RefObject<HTMLInputElement>;
|
||||
};
|
||||
|
||||
export const CustomerEditForm = ({ formId, onSubmit, onError, className, focusRef }: CustomerFormProps) => {
|
||||
const form = useFormContext<CustomerFormData>();
|
||||
|
||||
export const CustomerEditForm = ({ formId, onSubmit, className, focusRef }: CustomerFormProps) => {
|
||||
return (
|
||||
<form noValidate id={formId} onSubmit={(event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.stopPropagation();
|
||||
form.handleSubmit(onSubmit, onError)(event)
|
||||
}}>
|
||||
<form noValidate id={formId} onSubmit={onSubmit}>
|
||||
<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} />
|
||||
<CustomerAddressFields />
|
||||
<CustomerContactFields />
|
||||
|
||||
@ -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] });
|
||||
|
||||
|
||||
@ -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<Customer, Error, UpdateCustomerPayload, UpdateCustomerContext>({
|
||||
return useMutation<Customer, DefaultError, UpdateCustomerPayload, UpdateCustomerContext>({
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<UnsavedChangesProvider isDirty={form.formState.isDirty}>
|
||||
@ -42,7 +42,7 @@ export const CustomerCreatePage = () => {
|
||||
formId: formId,
|
||||
disabled: isCreating,
|
||||
}}
|
||||
onBack={() => handleBack()}
|
||||
onReset={resetForm}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
@ -61,14 +61,9 @@ export const CustomerCreatePage = () => {
|
||||
|
||||
<FormProvider {...form}>
|
||||
<CustomerEditForm
|
||||
formId={formId}
|
||||
onSubmit={(data) =>
|
||||
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"
|
||||
/>
|
||||
</FormProvider>
|
||||
|
||||
|
||||
@ -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<CustomerFormData>) => {
|
||||
// 1) Enfoca el primer error
|
||||
const firstKey = Object.keys(errors)[0] as keyof CustomerFormData | undefined;
|
||||
if (firstKey) {
|
||||
const el = document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`);
|
||||
el?.focus();
|
||||
// 1) Enfoca el primer error
|
||||
/*const firstKey = Object.keys(errors)[0] as keyof CustomerFormData | undefined;
|
||||
if (firstKey) {
|
||||
const el = document.querySelector<HTMLElement>(`[name="${String(firstKey)}"]`);
|
||||
el?.focus();
|
||||
}*/
|
||||
|
||||
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 <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,
|
||||
|
||||
// mutation
|
||||
isCreating,
|
||||
isCreateError,
|
||||
createError,
|
||||
handleSubmit,
|
||||
handleError,
|
||||
FormProvider, // para no re-importar fuera
|
||||
|
||||
// Por comodidad
|
||||
FormProvider,
|
||||
};
|
||||
};
|
||||
|
||||
@ -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 = ({
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Barra de filtros */}
|
||||
<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")}>
|
||||
<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>
|
||||
<SimpleSearchInput onSearchChange={onSearchChange} loading={loading} />
|
||||
</div>
|
||||
<div className="relative flex">
|
||||
<div className={/*preview.isPinned ? "flex-1 mr-[500px]" : */"flex-1"}>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 (
|
||||
<Badge variant={v as any} className="uppercase tracking-wide text-[11px]">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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 }) => (
|
||||
<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 ? "Company" : "Person"}
|
||||
</Badge>
|
||||
@ -50,23 +64,30 @@ const Soft = ({ children }: { children: React.ReactNode }) => (
|
||||
);
|
||||
|
||||
const ContactCell = ({ customer }: { customer: CustomerSummaryFormData }) => (
|
||||
<div className="grid gap-1">
|
||||
<div className="flex items-center gap-2 group:text-foreground group-hover:text-primary">
|
||||
<MailIcon className="size-3.5 group" />
|
||||
<a className="group" href={`mailto:${customer.email_primary}`}>
|
||||
{customer.email_primary || <Soft>—</Soft>}
|
||||
</a>
|
||||
{customer.email_secondary && <Soft>• {customer.email_secondary}</Soft>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 group:text-foreground group-hover:text-primary">
|
||||
<div className="grid gap-1 text-foreground text-sm my-1.5">
|
||||
|
||||
{customer.email_primary && (
|
||||
<div className='flex items-center gap-2'>
|
||||
<MailIcon className='size-3.5' />
|
||||
<a className='group' href={`mailto:${customer.email_primary}`}>
|
||||
{customer.email_primary}
|
||||
</a>
|
||||
</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" />
|
||||
<span>{customer.phone_primary || customer.mobile_primary || <Soft>-</Soft>}</span>
|
||||
{customer.phone_secondary && <Soft>• {customer.phone_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>
|
||||
{customer.website && (
|
||||
<div className="flex items-center gap-2">
|
||||
{false && customer.website && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<GlobeIcon className="size-3.5" />
|
||||
<a className="underline underline-offset-2" href={safeHttp(customer.website)} target="_blank" rel="noreferrer">
|
||||
{customer.website}
|
||||
@ -76,13 +97,13 @@ const ContactCell = ({ customer }: { customer: CustomerSummaryFormData }) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
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 (
|
||||
<address className="not-italic grid gap-1 text-sm">
|
||||
<div>{line1 || <Soft>—</Soft>}</div>
|
||||
<address className="not-italic grid gap-1 text-foreground text-sm">
|
||||
<div>{line1 || <Soft>-</Soft>}</div>
|
||||
<div>{[line2, line3].filter(Boolean).join(" • ")}</div>
|
||||
</address>
|
||||
);
|
||||
@ -99,6 +120,7 @@ function safeHttp(url: string) {
|
||||
return `https://${url}`;
|
||||
}
|
||||
|
||||
|
||||
export function useCustomersListColumns(
|
||||
handlers: CustomerActionHandlers = {}
|
||||
): ColumnDef<CustomerSummaryFormData>[] {
|
||||
@ -110,31 +132,30 @@ export function useCustomersListColumns(
|
||||
return React.useMemo<ColumnDef<CustomerSummaryFormData>[]>(() => [
|
||||
// Identidad + estado + metadatos (columna compuesta)
|
||||
{
|
||||
id: "identity",
|
||||
header: "Customer",
|
||||
id: "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
|
||||
enableHiding: false,
|
||||
size: 380,
|
||||
size: 140,
|
||||
minSize: 120,
|
||||
cell: ({ row }) => {
|
||||
const c = row.original;
|
||||
const isCompany = String(c.is_company).toLowerCase() === "true";
|
||||
return (
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="size-10">
|
||||
<div className="flex items-start gap-1 my-1.5">
|
||||
<Avatar className="size-10 hidden">
|
||||
<AvatarFallback aria-label={c.name}>{initials(c.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 grid gap-1">
|
||||
<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>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{c.tin && <span className="font-medium truncate">{c.tin}</span>}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={c.status} />
|
||||
{c.tin && <span className="font-base truncate">{c.tin}</span>}
|
||||
<KindBadge isCompany={isCompany} />
|
||||
{c.reference && <Badge variant="secondary">Ref: {c.reference}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -145,27 +166,34 @@ export function useCustomersListColumns(
|
||||
// Contacto (emails, teléfonos, web)
|
||||
{
|
||||
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}`,
|
||||
size: 420,
|
||||
size: 140,
|
||||
minSize: 120,
|
||||
cell: ({ row }) => <ContactCell customer={row.original} />,
|
||||
},
|
||||
|
||||
// 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 }) => <AddressCell c={row.original} />,
|
||||
},
|
||||
|
||||
// Acciones
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 72,
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title={t("pages.list.grid_columns.actions")} className="text-right" />
|
||||
),
|
||||
size: 64,
|
||||
minSize: 64,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
|
||||
@ -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<CustomerFormData>({
|
||||
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<CustomerFormData>) => {
|
||||
console.error("Errores en el formulario:", errors);
|
||||
// Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
|
||||
};
|
||||
|
||||
if (isLoadingCustomer) {
|
||||
if (isLoading) {
|
||||
return <CustomerEditorSkeleton />;
|
||||
}
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
<FormProvider {...form}>
|
||||
<CustomerEditForm
|
||||
formId={"customer-update-form"} // para que el botón del header pueda hacer submit
|
||||
onSubmit={handleSubmit}
|
||||
onError={handleError}
|
||||
className="bg-white rounded-xl border shadow-xl max-w-7xl mx-auto"
|
||||
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"
|
||||
/>
|
||||
</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 */
|
||||
export class DomainError extends BaseError<"domain"> {
|
||||
public readonly layer = "domain" as const;
|
||||
constructor(
|
||||
message: string,
|
||||
code = "DOMAIN_ERROR",
|
||||
options?: ErrorOptions & { metadata?: Record<string, unknown> }
|
||||
) {
|
||||
super("DomainError", message, code, options);
|
||||
constructor(message: string, options?: ErrorOptions & { metadata?: Record<string, unknown> }) {
|
||||
super("DomainError", message, "DOMAIN_ERROR", options);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<AlertDialog open={open} onOpenChange={(open) => !open && onConfirm(false)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && !closedByAction) onConfirm(false);
|
||||
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>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => onConfirm(false)}>{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => onConfirm(true)}>{confirmLabel}</AlertDialogAction>
|
||||
<AlertDialogFooter className="mt-12">
|
||||
<AlertDialogCancel autoFocus className="min-w-[120px]">{cancelLabel}</AlertDialogCancel>
|
||||
<AlertDialogAction className="min-w-[120px] bg-destructive text-white hover:bg-destructive/90">
|
||||
{confirmLabel}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user