Clientes y Facturas de cliente

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

View File

@ -61,19 +61,7 @@ export const App = () => {
<RouterProvider router={appRouter} />
</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>

View File

@ -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")}
/>
}

View File

@ -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}

View File

@ -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.

View File

@ -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);
}

View File

@ -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"}>

View File

@ -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 {

View File

@ -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)}
/>
</>}
/>

View File

@ -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.
*

View File

@ -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);
}

View File

@ -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": {

View File

@ -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": {

View File

@ -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}

View File

@ -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 />

View File

@ -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] });

View File

@ -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);
},
});
}

View File

@ -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>

View File

@ -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,
};
};

View File

@ -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"}>

View File

@ -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);

View File

@ -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 }) => {

View File

@ -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>

View File

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

View File

@ -11,12 +11,8 @@ import { BaseError } from "./base-error";
/** Error base de dominio: no depende de infra ni HTTP */
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);
}
}

View File

@ -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>
);
};
};

View File

@ -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,
});
}