diff --git a/modules/customers/src/web/pages/list/customers-list-page.tsx b/modules/customers/src/web/pages/list/customers-list-page.tsx
index b9ded23c..911579d1 100644
--- a/modules/customers/src/web/pages/list/customers-list-page.tsx
+++ b/modules/customers/src/web/pages/list/customers-list-page.tsx
@@ -16,8 +16,8 @@ export const CustomersListPage = () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
- const debouncedQ = useDebounce(search, 300);
+ const debouncedQ = useDebounce(search, 300);
const criteria = useMemo(
() => ({
@@ -34,12 +34,9 @@ export const CustomersListPage = () => {
isLoading,
isError,
error,
- } = useCustomerListQuery({
- criteria
- });
+ } = useCustomerListQuery({ criteria });
const handlePageChange = (newPageIndex: number) => {
- // TanStack usa pageIndex 0-based → API usa 0-based también
setPageIndex(newPageIndex);
};
@@ -49,7 +46,6 @@ export const CustomersListPage = () => {
};
const handleSearchChange = (value: string) => {
- // Normalización ligera: recorta y colapsa espacios internos
const cleaned = value.trim().replace(/\s+/g, " ");
setSearch(cleaned);
setPageIndex(0);
diff --git a/modules/customers/src/web/pages/list/use-customers-list-columns.tsx b/modules/customers/src/web/pages/list/use-customers-list-columns.tsx
index 598c8042..8774c711 100644
--- a/modules/customers/src/web/pages/list/use-customers-list-columns.tsx
+++ b/modules/customers/src/web/pages/list/use-customers-list-columns.tsx
@@ -1,11 +1,16 @@
+import { DataTableColumnHeader } from '@repo/rdx-ui/components';
import {
Avatar,
AvatarFallback,
Badge,
Button,
DropdownMenu, DropdownMenuContent,
- DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger
+ DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger
} from '@repo/shadcn-ui/components';
+import { cn } from '@repo/shadcn-ui/lib/utils';
import type { ColumnDef } from "@tanstack/react-table";
import { Building2Icon, GlobeIcon, MailIcon, MoreHorizontalIcon, PencilIcon, PhoneIcon, User2Icon } from 'lucide-react';
import * as React from "react";
@@ -22,24 +27,33 @@ function shortId(id: string) {
return id ? `${id.slice(0, 4)}_${id.slice(-4)}` : "-";
}
+const statuses = {
+ inactive: 'text-gray-400 bg-gray-100 dark:text-gray-500 dark:bg-gray-100/10',
+ active: 'text-green-500 bg-green-500/10 dark:text-green-400 dark:bg-green-400/10',
+ error: 'text-rose-500 bg-rose-500/10 dark:text-rose-400 dark:bg-rose-400/10',
+}
+
// ---- Helpers UI ----
const StatusBadge = ({ status }: { status: string }) => {
// Map visual simple; ajustar a tu catálogo real
- const v =
- status.toLowerCase() === "active"
- ? "default"
- : status.toLowerCase() === "inactive"
- ? "outline"
- : "secondary";
+ const statusClass = React.useMemo(() => status.toLowerCase() === 'active' ? statuses.active : statuses.inactive, [status]);
+ const contentTxt = React.useMemo(() => status.toLowerCase() === 'active' ? 'El cliente está activo' : 'El cliente está inactivo', [status]);
+
return (
-
- {status}
-
- );
+
+
+
+
+ {contentTxt}
+
+
+ )
};
const KindBadge = ({ isCompany }: { isCompany: boolean }) => (
-
+
{isCompany ? : }
{isCompany ? "Company" : "Person"}
@@ -50,23 +64,30 @@ const Soft = ({ children }: { children: React.ReactNode }) => (
);
const ContactCell = ({ customer }: { customer: CustomerSummaryFormData }) => (
-
-
-
+
+
+ {customer.email_primary && (
+
+ )}
+
+ {customer.email_secondary && (
+
{customer.email_secondary}
+ )}
+
+
{customer.phone_primary || customer.mobile_primary || -}
{customer.phone_secondary &&
• {customer.phone_secondary}}
{customer.mobile_secondary &&
• {customer.mobile_secondary}}
- {customer.fax &&
• fax {customer.fax}}
+ {false && customer.fax &&
• fax {customer.fax}}
- {customer.website && (
-
+ {false && customer.website && (
+
);
-const AddressCell: React.FC<{ c: CustomerSummaryFormData }> = ({ c }) => {
+const AddressCell = ({ c }: { c: CustomerSummaryFormData }) => {
const line1 = [c.street, c.street2].filter(Boolean).join(", ");
const line2 = [c.postal_code, c.city].filter(Boolean).join(" ");
const line3 = [c.province, c.country].filter(Boolean).join(", ");
return (
-
- {line1 || —}
+
+ {line1 || -}
{[line2, line3].filter(Boolean).join(" • ")}
);
@@ -99,6 +120,7 @@ function safeHttp(url: string) {
return `https://${url}`;
}
+
export function useCustomersListColumns(
handlers: CustomerActionHandlers = {}
): ColumnDef[] {
@@ -110,31 +132,30 @@ export function useCustomersListColumns(
return React.useMemo[]>(() => [
// Identidad + estado + metadatos (columna compuesta)
{
- id: "identity",
- header: "Customer",
+ id: "customer",
+ header: ({ column }) => (
+
+ ),
accessorFn: (row) => row.name, // para ordenar/buscar por nombre
enableHiding: false,
- size: 380,
+ size: 140,
+ minSize: 120,
cell: ({ row }) => {
const c = row.original;
const isCompany = String(c.is_company).toLowerCase() === "true";
return (
-
-
+
+
{initials(c.name)}
- {c.name}
+ {c.name}
{c.trade_name && ({c.trade_name})}
- {c.tin && {c.tin}}
-
-
-
+ {c.tin && {c.tin}}
- {c.reference && Ref: {c.reference}}
@@ -145,27 +166,34 @@ export function useCustomersListColumns(
// Contacto (emails, teléfonos, web)
{
id: "contact",
- header: "Contact",
+ header: ({ column }) => (
+
+ ),
accessorFn: (r) => `${r.email_primary} ${r.phone_primary} ${r.mobile_primary} ${r.website}`,
- size: 420,
+ size: 140,
+ minSize: 120,
cell: ({ row }) => ,
},
// Dirección (múltiples campos en bloque)
{
id: "address",
- header: "Address",
+ header: t("pages.list.grid_columns.address"),
accessorFn: (r) =>
`${r.street} ${r.street2} ${r.city} ${r.postal_code} ${r.province} ${r.country}`,
- size: 360,
+ size: 140,
+ minSize: 120,
cell: ({ row }) => ,
},
// Acciones
{
id: "actions",
- header: () => Actions,
- size: 72,
+ header: ({ column }) => (
+
+ ),
+ size: 64,
+ minSize: 64,
enableSorting: false,
enableHiding: false,
cell: ({ row }) => {
diff --git a/modules/customers/src/web/pages/update/customer-update-page.tsx b/modules/customers/src/web/pages/update/customer-update-page.tsx
index 272b62b5..8a59f7c8 100644
--- a/modules/customers/src/web/pages/update/customer-update-page.tsx
+++ b/modules/customers/src/web/pages/update/customer-update-page.tsx
@@ -1,92 +1,36 @@
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
-import { useNavigate } from "react-router-dom";
-import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client";
import { PageHeader } from '@erp/core/components';
import {
UnsavedChangesProvider,
UpdateCommitButtonGroup,
- useHookForm,
- useUrlParamId,
+ useUrlParamId
} from "@erp/core/hooks";
-import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
-import { FieldErrors, FormProvider } from "react-hook-form";
import {
CustomerEditForm,
CustomerEditorSkeleton,
ErrorAlert,
NotFoundCard,
} from "../../components";
-import { useCustomerQuery, useUpdateCustomer } from "../../hooks";
import { useTranslation } from "../../i18n";
-import { CustomerFormData, CustomerFormSchema, defaultCustomerFormData } from "../../schemas";
+import { useCustomerUpdateController } from './use-customer-update-controller';
export const CustomerUpdatePage = () => {
const customerId = useUrlParamId();
const { t } = useTranslation();
- const navigate = useNavigate();
- // 1) Estado de carga del cliente (query)
const {
- data: customerData,
- isLoading: isLoadingCustomer,
- isError: isLoadError,
- error: loadError,
- } = useCustomerQuery(customerId, { enabled: !!customerId });
+ form, formId, onSubmit, resetForm,
- // 2) Estado de actualización (mutación)
- const {
- mutate,
- isPending: isUpdating,
- isError: isUpdateError,
- error: updateError,
- } = useUpdateCustomer();
+ customerData,
+ isLoading, isLoadError, loadError,
- // 3) Form hook
- const form = useHookForm({
- resolverSchema: CustomerFormSchema,
- initialValues: customerData ?? defaultCustomerFormData,
- disabled: isUpdating,
- });
+ isUpdating, isUpdateError, updateError,
- // 4) Submit con navegación condicionada por éxito
- const handleSubmit = (formData: CustomerFormData) => {
- const { dirtyFields } = form.formState;
+ FormProvider
+ } = useCustomerUpdateController(customerId, {});
- if (!formHasAnyDirty(dirtyFields)) {
- showWarningToast("No hay cambios para guardar");
- return;
- }
-
- const patchData = pickFormDirtyValues(formData, dirtyFields);
- mutate(
- { id: customerId!, data: patchData },
- {
- onSuccess(data) {
- showSuccessToast(t("pages.update.success.title"), t("pages.update.success.message"));
-
- // 🔹 limpiar el form e isDirty pasa a false
- form.reset(data);
- },
- onError(error) {
- showErrorToast(t("pages.update.errorTitle"), error.message);
- },
- }
- );
- };
-
- const handleReset = () => form.reset(customerData ?? defaultCustomerFormData);
-
- const handleBack = () => {
- navigate(-1);
- };
-
- const handleError = (errors: FieldErrors) => {
- console.error("Errores en el formulario:", errors);
- // Aquí puedes manejar los errores, por ejemplo, mostrar un mensaje al usuario
- };
-
- if (isLoadingCustomer) {
+ if (isLoading) {
return ;
}
@@ -136,15 +80,15 @@ export const CustomerUpdatePage = () => {
isLoading={isUpdating}
disabled={isUpdating}
cancel={{
+ formId,
to: "/customers/list",
disabled: isUpdating,
}}
submit={{
- formId: "customer-update-form",
+ formId,
disabled: isUpdating,
}}
- onBack={() => handleBack()}
- onReset={() => handleReset()}
+ onReset={resetForm}
/>
}
/>
@@ -163,10 +107,9 @@ export const CustomerUpdatePage = () => {
diff --git a/modules/customers/src/web/pages/update/use-customer-update-controller.ts b/modules/customers/src/web/pages/update/use-customer-update-controller.ts
new file mode 100644
index 00000000..653ef61b
--- /dev/null
+++ b/modules/customers/src/web/pages/update/use-customer-update-controller.ts
@@ -0,0 +1,146 @@
+import { formHasAnyDirty, pickFormDirtyValues } from "@erp/core/client";
+import { useHookForm } from "@erp/core/hooks";
+import { showErrorToast, showSuccessToast, showWarningToast } from "@repo/rdx-ui/helpers";
+import { useEffect, useId, useMemo } from "react";
+import { FieldErrors, FormProvider } from "react-hook-form";
+import { useCustomerQuery, useUpdateCustomer } from "../../hooks";
+import { useTranslation } from "../../i18n";
+import {
+ Customer,
+ CustomerFormData,
+ CustomerFormSchema,
+ defaultCustomerFormData,
+} from "../../schemas";
+
+export interface UseCustomerUpdateControllerOptions {
+ onUpdated?(updated: Customer): void;
+ successToasts?: boolean; // mostrar o no toast automáticcamente
+
+ onError?(error: Error, patchData: ReturnType): void;
+ errorToasts?: boolean; // mostrar o no toast automáticcamente
+}
+
+export const useCustomerUpdateController = (
+ customerId?: string,
+ options?: UseCustomerUpdateControllerOptions
+) => {
+ const { t } = useTranslation();
+ const formId = useId(); // id único por instancia
+
+ // 1) Estado de carga del cliente (query)
+ const {
+ data: customerData,
+ isLoading,
+ isError: isLoadError,
+ error: loadError,
+ } = useCustomerQuery(customerId, { enabled: Boolean(customerId) });
+
+ // 2) Estado de creación (mutación)
+ const {
+ mutateAsync,
+ isPending: isUpdating,
+ isError: isUpdateError,
+ error: updateError,
+ } = useUpdateCustomer();
+
+ const initialValues = useMemo(() => customerData ?? defaultCustomerFormData, [customerData]);
+
+ // 3) Form hook
+ const form = useHookForm({
+ resolverSchema: CustomerFormSchema,
+ initialValues,
+ disabled: isLoading || isUpdating,
+ });
+
+ /** Reiniciar el form al recibir datos */
+ useEffect(() => {
+ // keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render.
+ if (customerData) form.reset(customerData, { keepDirty: false });
+ }, [customerData, form]);
+
+ /** Handlers */
+
+ const resetForm = () => form.reset(customerData ?? defaultCustomerFormData);
+
+ // Versión sincronizada
+ const submitHandler = form.handleSubmit(
+ async (formData) => {
+ if (!customerId) {
+ showErrorToast(t("pages.update.error.title"), "Falta el ID del cliente");
+ return;
+ }
+
+ const { dirtyFields } = form.formState;
+ if (!formHasAnyDirty(dirtyFields)) {
+ showWarningToast(t("pages.update.error.no_changes"), "No hay cambios para guardar");
+ return;
+ }
+
+ const patchData = pickFormDirtyValues(formData, dirtyFields);
+ const previousData = customerData;
+
+ try {
+ // Enviamos cambios al servidor
+ const updated = await mutateAsync({ id: customerId, data: patchData });
+
+ // Ha ido bien -> actualizamos form con datos reales
+ // keepDirty = false -> deja el formulario sin cambios sin tener que esperar al siguiente render.
+ form.reset(updated, { keepDirty: false });
+
+ if (options?.successToasts !== false) {
+ showSuccessToast(
+ t("pages.update.success.title", "Cliente modificado"),
+ t("pages.update.success.message", "Se ha modificado correctamente.")
+ );
+ }
+ options?.onUpdated?.(updated);
+ } catch (error: any) {
+ // Algo ha fallado -> revertimos cambios
+ form.reset(previousData ?? defaultCustomerFormData);
+ if (options?.errorToasts !== false) {
+ showErrorToast(t("pages.update.error.title"), error.message);
+ }
+ options?.onError?.(error, patchData);
+ }
+ },
+ (errors: FieldErrors) => {
+ const firstKey = Object.keys(errors)[0] as keyof CustomerFormData | undefined;
+ if (firstKey) document.querySelector(`[name="${String(firstKey)}"]`)?.focus();
+
+ showWarningToast(
+ t("forms.validation.title", "Revisa los campos"),
+ t("forms.validation.message", "Hay errores de validación en el formulario.")
+ );
+ }
+ );
+
+ // Evento onSubmit ya preparado para el