From 8ecebc64bb10126672ead1bee9695ddc1e2fcc84 Mon Sep 17 00:00:00 2001 From: david Date: Mon, 15 Jun 2026 11:13:16 +0200 Subject: [PATCH] . --- .../use-list-proformas.controller.ts | 20 +- .../list/ui/pages/list-proformas-page.tsx | 3 +- .../shared/ui/blocks/proforma-layout.tsx | 8 +- modules/customers/src/web/customer-routes.tsx | 67 ++++- .../use-list-customers-page.controller.ts | 1 + .../use-list-customers.controller.ts | 240 +++++++++++++++--- .../blocks/customers-grid/customers-grid.tsx | 43 +++- .../web/list/ui/pages/list-customers-page.tsx | 164 +++++++----- .../web/shared/ui/blocks/customer-layout.tsx | 3 +- 9 files changed, 406 insertions(+), 143 deletions(-) diff --git a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts index 1687bb70..0322d023 100644 --- a/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts +++ b/modules/customer-invoices/src/web/proformas/list/controllers/use-list-proformas.controller.ts @@ -28,25 +28,23 @@ const EMPTY_PROFORMAS_LIST: ProformaList = { }; // Campos que se permiten ordenar para la lista de proformas (consulta). -type ProformaListSortField = "invoiceNumber" | "recipientName" | "invoiceDate"; - -type ProformaListApiSortField = "invoice_number" | "recipient_name" | "invoice_date"; - -const PROFORMA_LIST_SORT_FIELDS: Record = { +const PROFORMA_LIST_SORT_FIELDS = { invoiceNumber: "invoice_number", recipientName: "recipient_name", invoiceDate: "invoice_date", -}; +} as const; -const DEFAULT_API_SORT_FIELD: ProformaListApiSortField = "invoice_date"; +type ProformaListSortField = keyof typeof PROFORMA_LIST_SORT_FIELDS; const DEFAULT_SORT = { field: "invoiceDate", direction: "desc", } satisfies DataTableSort; +const DEFAULT_API_SORT_FIELD = PROFORMA_LIST_SORT_FIELDS.invoiceDate; + const isProformaListSortField = (value: string | null): value is ProformaListSortField => { - return value === "invoiceNumber" || value === "recipientName" || value === "invoiceDate"; + return value !== null && value in PROFORMA_LIST_SORT_FIELDS; }; const isSortDirection = (value: string | null): value is DataTableSortDirection => { @@ -117,7 +115,9 @@ export const useListProformasController = () => { orderBy, order, filters: - statusFilter === "all" ? [] : [{ field: "status", operator: "eq", value: statusFilter }], + statusFilter === "all" + ? [] + : [{ field: "status", operator: "EQUALS", value: statusFilter }], }), [debouncedSearch, pageIndex, pageSize, orderBy, order, statusFilter] ); @@ -134,7 +134,7 @@ export const useListProformasController = () => { // Reset page to 1 when status filter changes setSearchParams((prev) => { const params = new URLSearchParams(prev); - params.set("page", "1"); + params.set("page", String(INITIAL_PAGE_INDEX + 1)); return params; }); return nextValue; diff --git a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx index ab77d5f0..f48f830b 100644 --- a/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx +++ b/modules/customer-invoices/src/web/proformas/list/ui/pages/list-proformas-page.tsx @@ -22,7 +22,7 @@ import { FilterIcon, PlusIcon, } from "lucide-react"; -import { createSearchParams, useLocation, useNavigate } from "react-router-dom"; +import { createSearchParams, useNavigate } from "react-router-dom"; import { useTranslation } from "../../../../i18n"; import { ChangeProformaStatusDialog } from "../../../change-status"; @@ -39,7 +39,6 @@ import { ProformaStatusBadge } from "../components"; export const ListProformasPage = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const location = useLocation(); const { currentReturnTo } = useReturnToNavigation({ fallbackPath: "/proformas", diff --git a/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-layout.tsx b/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-layout.tsx index 5fd6fe15..0d870da0 100644 --- a/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-layout.tsx +++ b/modules/customer-invoices/src/web/proformas/shared/ui/blocks/proforma-layout.tsx @@ -1,9 +1,5 @@ -import type { ReactNode } from "react"; +import type { PropsWithChildren } from "react"; -interface ProformaLayoutProps { - children: ReactNode; -} - -export function ProformaLayout({ children }: ProformaLayoutProps) { +export function ProformaLayout({ children }: PropsWithChildren) { return
{children}
; } diff --git a/modules/customers/src/web/customer-routes.tsx b/modules/customers/src/web/customer-routes.tsx index de7b618e..8cd1aca5 100644 --- a/modules/customers/src/web/customer-routes.tsx +++ b/modules/customers/src/web/customer-routes.tsx @@ -6,13 +6,17 @@ import { Outlet, type RouteObject } from "react-router-dom"; const CustomerLayout = lazy(() => import("./shared/ui").then((m) => ({ default: m.CustomerLayout })) ); -const CustomersList = lazy(() => import("./list").then((m) => ({ default: m.ListCustomersPage }))); -const CustomerView = lazy(() => import("./view").then((m) => ({ default: m.CustomerViewPage }))); +const CustomersListPage = lazy(() => + import("./list").then((m) => ({ default: m.ListCustomersPage })) +); +const CustomerViewPage = lazy(() => + import("./view").then((m) => ({ default: m.CustomerViewPage })) +); -const CustomerCreate = lazy(() => +const CustomerCreatePage = lazy(() => import("./create").then((m) => ({ default: m.CustomerCreatePage })) ); -const CustomerUpdate = lazy(() => +const CustomerUpdatePage = lazy(() => import("./update").then((m) => ({ default: m.CustomerUpdatePage })) ); @@ -20,21 +24,56 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => { return [ { path: "customers", + handle: { + layout: "app-sidebar", + protected: true, + }, element: ( ), children: [ - { path: "", index: true, element: }, // index - { path: "list", element: }, - //{ path: "create", element: }, - { path: ":id", element: }, - { path: ":id/edit", element: }, - { path: "create", element: }, + { + index: true, + element: , + }, + { + path: "list", + element: , + }, + ], + }, - // - /* + { + path: "customers/create", + handle: { + layout: "app-fullscreen", + protected: true, + }, + element: , + }, + + { + path: "customers/:id/edit", + handle: { + layout: "app-fullscreen", + protected: true, + }, + element: , + }, + + { + path: "customers/:id", + handle: { + layout: "app-fullscreen", + protected: true, + }, + element: , + }, + + /* + children: [ { path: ":id", element: }, { path: ":id/edit", element: }, { path: ":id/delete", element: }, @@ -43,8 +82,8 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => { { path: ":id/email", element: }, { path: ":id/download", element: }, { path: ":id/duplicate", element: }, - { path: ":id/preview", element: },*/ + { path: ":id/preview", element: }, ], - }, + */ ]; }; diff --git a/modules/customers/src/web/list/controllers/use-list-customers-page.controller.ts b/modules/customers/src/web/list/controllers/use-list-customers-page.controller.ts index 409c7ec1..2f302e77 100644 --- a/modules/customers/src/web/list/controllers/use-list-customers-page.controller.ts +++ b/modules/customers/src/web/list/controllers/use-list-customers-page.controller.ts @@ -6,6 +6,7 @@ import { useListCustomersController } from "./use-list-customers.controller"; export const useListCustomersPageController = () => { const listCtrl = useListCustomersController(); + const [searchParams] = useSearchParams(); const customerId = searchParams.get("customerId") ?? ""; diff --git a/modules/customers/src/web/list/controllers/use-list-customers.controller.ts b/modules/customers/src/web/list/controllers/use-list-customers.controller.ts index 069c2603..ef242d8f 100644 --- a/modules/customers/src/web/list/controllers/use-list-customers.controller.ts +++ b/modules/customers/src/web/list/controllers/use-list-customers.controller.ts @@ -1,55 +1,225 @@ import { useDebounce } from "@erp/core/hooks"; -import { useMemo, useState } from "react"; +import { INITIAL_PAGE_INDEX, INITIAL_PAGE_SIZE } from "@repo/rdx-criteria"; +import { + type DataTableSort, + type DataTableSortDirection, + useDataTablePreferences, +} from "@repo/rdx-ui/components"; +import { NumberHelper } from "@repo/rdx-utils"; +import { useCallback, useMemo, useState } from "react"; +import { useSearchParams } from "react-router-dom"; -import { type ListCustomersByCriteriaParams, useCustomersListQuery } from "../../shared"; +import { + type CustomerList, + type CustomerStatus, + type ListCustomersByCriteriaParams, + useCustomersListQuery, +} from "../../shared"; + +type CustomerListStatusFilter = "all" | CustomerStatus; + +// Datos por defecto mientras se carga la consulta o en caso de error. +const EMPTY_CUSTOMERS_LIST: CustomerList = { + items: [], + page: INITIAL_PAGE_INDEX, + perPage: INITIAL_PAGE_SIZE, + totalPages: 0, + totalItems: 0, +}; + +// Campos que se permiten ordenar para la lista de proformas (consulta). +const CUSTOMER_LIST_SORT_FIELDS = { + name: "name", + tradeName: "trade_name", + reference: "reference", + tin: "tin", + emailPrimary: "email_primary", + mobilePrimary: "mobile_primary", +} as const; + +type CustomerListSortField = keyof typeof CUSTOMER_LIST_SORT_FIELDS; + +const DEFAULT_SORT = { + field: "NAME", + direction: "desc", +} satisfies DataTableSort; + +const DEFAULT_API_SORT_FIELD = CUSTOMER_LIST_SORT_FIELDS.name; + +const isCustomerListSortField = (value: string | null): value is CustomerListSortField => { + return value !== null && value in CUSTOMER_LIST_SORT_FIELDS; +}; + +const isSortDirection = (value: string | null): value is DataTableSortDirection => { + return value === "asc" || value === "desc"; +}; export const useListCustomersController = () => { - const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(5); const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [searchParams, setSearchParams] = useSearchParams(); - const debouncedQ = useDebounce(search, 300); + const tablePreferences = useDataTablePreferences({ + storageKey: "customers:list:grid", + defaultPageSize: EMPTY_CUSTOMERS_LIST.perPage, + defaultColumnVisibility: { + reference: true, + recipientName: true, + status: true, + totalAmountFmt: true, + invoiceDate: true, + }, + defaultSort: DEFAULT_SORT, + }); + const { pageSize: preferencesPageSize, setPageSize: setPageSizePreference } = tablePreferences; + const { sort: preferencesSorting, setSort: setSortPreference } = tablePreferences; + + // Parse page from URL (1-based) or default to 1 + const urlPage = NumberHelper.parsePositiveInteger( + searchParams.get("page"), + INITIAL_PAGE_INDEX + 1 + ); + const pageIndex = urlPage - 1; // Convert to 0-based for internal use + + // Parse pageSize from URL or use preferences + const urlPageSize = searchParams.get("pageSize"); + const pageSize = urlPageSize + ? NumberHelper.parsePositiveInteger(urlPageSize, preferencesPageSize) + : preferencesPageSize; + + const debouncedSearch = useDebounce(search, 300); + + // Criterios de ordenamiento + const urlSortFieldValue = searchParams.get("sortField"); + const urlSortDirectionValue = searchParams.get("sortDirection"); + + const urlSort = + isCustomerListSortField(urlSortFieldValue) && isSortDirection(urlSortDirectionValue) + ? { + field: urlSortFieldValue, + direction: urlSortDirectionValue, + } + : undefined; + + const sort = urlSort ?? preferencesSorting ?? DEFAULT_SORT; + + const orderBy = + CUSTOMER_LIST_SORT_FIELDS[sort.field as CustomerListSortField] ?? DEFAULT_API_SORT_FIELD; + + const order = sort.direction; + + // Construir criterios de consulta const criteria = useMemo>( () => ({ - q: debouncedQ || "", + q: debouncedSearch || "", pageNumber: pageIndex, pageSize, - order: "desc", - orderBy: "name", - //filters: statusFilter === "all" ? [] : [{ field: "status", operator: "eq", value: statusFilter }], + orderBy, + order, + filters: + statusFilter === "all" + ? [] + : [{ field: "status", operator: "EQUALS", value: statusFilter }], }), - [debouncedQ, pageIndex, pageSize /*statusFilter*/] + [debouncedSearch, pageIndex, pageSize, orderBy, order, statusFilter] ); const query = useCustomersListQuery({ criteria }); - const setSearchValue = (value: string) => { - const nextValue = value.trim().replace(/\s+/g, " "); + const setStatusFilterValue = useCallback( + (value: string) => { + const nextValue = (value || "all") as CustomerListStatusFilter; - setSearch((prev) => { - if (prev === nextValue) return prev; + setStatusFilter((prev) => { + if (prev === nextValue) return prev; - // Sólo si la búsqueda realmente cambia, - // reseteamos la página a 0 para evitar inconsistencias - setPageIndex(0); - return nextValue; - }); - }; + // Reset page to 1 when status filter changes + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.set("page", String(INITIAL_PAGE_INDEX + 1)); + return params; + }); + return nextValue; + }); + }, + [setSearchParams] + ); - const setPageSizeValue = (value: number) => { - setPageSize((prev) => { - if (prev === value) return prev; + const setSearchValue = useCallback( + (value: string) => { + const nextValue = value.trim().replace(/\s+/g, " "); - // Sólo si el tamaño de página realmente cambia, - // reseteamos la página a 0 para evitar inconsistencias - setPageIndex(0); - return value; - }); - }; + setSearch((prev) => { + if (prev === nextValue) return prev; + + // Reset page to 1 when search changes + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.set("page", String(INITIAL_PAGE_INDEX + 1)); // Convert to 1-based for URL + return params; + }); + return nextValue; + }); + }, + [setSearchParams] + ); + + const setPageIndexValue = useCallback( + (newPageIndex: number) => { + const newPage = newPageIndex + 1; // Convert to 1-based for URL + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.set("page", String(newPage)); + return params; + }); + }, + [setSearchParams] + ); + + const setPageSizeValue = useCallback( + (value: number) => { + if (pageSize === value) return; + + // Reset page to 1 and update pageSize when it changes + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.set("page", String(INITIAL_PAGE_INDEX + 1)); // Convert to 1-based for URL + params.set("pageSize", String(value)); + return params; + }); + setPageSizePreference(value); + }, + [pageSize, setSearchParams, setPageSizePreference] + ); + + const setSortValue = useCallback( + (nextSort: DataTableSort) => { + if (!isCustomerListSortField(nextSort.field)) { + return; + } + + if (sort.field === nextSort.field && sort.direction === nextSort.direction) { + return; + } + + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + + params.set("sortField", nextSort.field); + params.set("sortDirection", nextSort.direction); + params.set("page", String(INITIAL_PAGE_INDEX + 1)); + + return params; + }); + + setSortPreference(nextSort); + }, + [setSearchParams, sort.field, sort.direction, setSortPreference] + ); return { - data: query.data, + data: query.data ?? EMPTY_CUSTOMERS_LIST, isLoading: query.isLoading, isFetching: query.isFetching, @@ -58,12 +228,20 @@ export const useListCustomersController = () => { refetch: query.refetch, + tablePreferences, + pageIndex, pageSize, - setPageIndex, + setPageIndex: setPageIndexValue, setPageSize: setPageSizeValue, search, setSearchValue, + + sort, + setSort: setSortValue, + + statusFilter, + setStatusFilter: setStatusFilterValue, }; }; diff --git a/modules/customers/src/web/list/ui/blocks/customers-grid/customers-grid.tsx b/modules/customers/src/web/list/ui/blocks/customers-grid/customers-grid.tsx index ba2ca8f2..8a23e9b1 100644 --- a/modules/customers/src/web/list/ui/blocks/customers-grid/customers-grid.tsx +++ b/modules/customers/src/web/list/ui/blocks/customers-grid/customers-grid.tsx @@ -1,6 +1,5 @@ -import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components"; -import type { ColumnDef } from "@tanstack/react-table"; -import { useNavigate } from "react-router-dom"; +import { DataTable, type DataTableSort, SkeletonDataTable } from "@repo/rdx-ui/components"; +import type { ColumnDef, OnChangeFn, VisibilityState } from "@tanstack/react-table"; import { useTranslation } from "../../../../i18n"; import type { CustomerList, CustomerListRow } from "../../../../shared"; @@ -8,6 +7,7 @@ import type { CustomerList, CustomerListRow } from "../../../../shared"; interface CustomersGridProps { data?: CustomerList; loading: boolean; + fetching?: boolean; columns: ColumnDef[]; @@ -16,46 +16,67 @@ interface CustomersGridProps { onPageChange: (pageIndex: number) => void; onPageSizeChange: (size: number) => void; - onRowClick?: (row: CustomerListRow) => void; + sort: DataTableSort; + onSortChange?: (nextSort: DataTableSort) => void; + + columnVisibility?: VisibilityState; + onColumnVisibilityChange?: OnChangeFn; + + onRowClick?: (customerId: string) => void; } export const CustomersGrid = ({ data, - loading, columns, + + loading, + fetching, + pageIndex, pageSize, onPageChange, onPageSizeChange, + + sort, + onSortChange, + + columnVisibility, + onColumnVisibilityChange, + onRowClick, }: CustomersGridProps) => { - const navigate = useNavigate(); const { t } = useTranslation(); - const { items, totalItems: total_items } = data || { items: [], totalItems: 0 }; + const { items, totalItems } = data || { items: [], totalItems: 0 }; - if (loading) + if (loading) { return ( ); + } return ( onRowClick?.(row)} + onSortChange={onSortChange} + //onRowClick={(row) => onRowClick?.(row.id)} pageIndex={pageIndex} pageSize={pageSize} - totalItems={total_items} + sort={sort} + totalItems={totalItems} /> ); }; diff --git a/modules/customers/src/web/list/ui/pages/list-customers-page.tsx b/modules/customers/src/web/list/ui/pages/list-customers-page.tsx index 3b4a871a..0688512b 100644 --- a/modules/customers/src/web/list/ui/pages/list-customers-page.tsx +++ b/modules/customers/src/web/list/ui/pages/list-customers-page.tsx @@ -1,5 +1,6 @@ import { PageHeader, SimpleSearchInput } from "@erp/core/components"; -import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components"; +import { useReturnToNavigation } from "@erp/core/hooks"; +import { AppContent, BackHistoryButton } from "@repo/rdx-ui/components"; import { Button, ResizableHandle, @@ -7,7 +8,7 @@ import { ResizablePanelGroup, } from "@repo/shadcn-ui/components"; import { PlusIcon } from "lucide-react"; -import { useNavigate } from "react-router-dom"; +import { createSearchParams, useNavigate } from "react-router-dom"; import { useTranslation } from "../../../i18n"; import { ErrorAlert } from "../../../shared/ui"; @@ -18,11 +19,33 @@ export const ListCustomersPage = () => { const { t } = useTranslation(); const navigate = useNavigate(); + const { currentReturnTo } = useReturnToNavigation({ + fallbackPath: "/customers", + }); + const { listCtrl, panelCtrl } = useListCustomersPageController(); + const handleEditClick = (customerId: string) => { + navigate({ + pathname: `/customers/${customerId}/edit`, + search: createSearchParams({ + returnTo: currentReturnTo, + }).toString(), + }); + }; + + const handleViewClick = (customerId: string) => { + navigate({ + pathname: `/customers/${customerId}/`, + search: createSearchParams({ + returnTo: currentReturnTo, + }).toString(), + }); + }; + const columns = useCustomersGridColumns({ - onEditClick: (customer) => navigate(`/customers/${customer.id}/edit`), - onViewClick: (customer) => navigate(`/customers/${customer.id}`), + onEditClick: (customer) => handleEditClick(customer.id), + onViewClick: (customer) => handleViewClick(customer.id), onSummaryClick: (customer) => panelCtrl.openCustomerPanel(customer.id, "view"), //onDeleteClick: (customer) => null, //confirmDelete(inv.id), }); @@ -30,8 +53,8 @@ export const ListCustomersPage = () => { const isPanelOpen = panelCtrl.panelState.isOpen; const listContent = ( -
-
+
+
{ />
- +
+ panelCtrl.openCustomerPanel(customerId, "view")} + onSortChange={listCtrl.setSort} + pageIndex={listCtrl.pageIndex} + pageSize={listCtrl.pageSize} + sort={listCtrl.sort} + /> +
); @@ -64,61 +95,60 @@ export const ListCustomersPage = () => { } return ( - <> - - navigate("/customers/create")} - > - - {t("pages.create.title")} - - } - title={t("pages.list.title")} - /> - - - - {isPanelOpen ? ( - + {/* Header */} + navigate("/proformas/create")} + size={"default"} > - - {listContent} - + + {t("pages.proformas.create.title")} + + } + title={t("pages.proformas.list.title")} + /> - + {/* Table */} + {isPanelOpen ? ( + + + {listContent} + - -
- navigate(`/customers/${customer.id}/edit`)} - onOpenChange={(open) => { - if (!open) { - panelCtrl.closePanel(); - return; - } + - panelCtrl.panelState.onOpenChange(true); - }} - open={panelCtrl.panelState.isOpen} - visibility={panelCtrl.panelState.visibility} - /> -
-
-
- ) : ( -
{listContent}
- )} -
- + +
+ navigate(`/customers/${customer.id}/edit`)} + onOpenChange={(open) => { + if (!open) { + panelCtrl.closePanel(); + return; + } + + panelCtrl.panelState.onOpenChange(true); + }} + open={panelCtrl.panelState.isOpen} + visibility={panelCtrl.panelState.visibility} + /> +
+
+ + ) : ( +
{listContent}
+ )} +
); }; diff --git a/modules/customers/src/web/shared/ui/blocks/customer-layout.tsx b/modules/customers/src/web/shared/ui/blocks/customer-layout.tsx index ec1cd087..5c49a23d 100644 --- a/modules/customers/src/web/shared/ui/blocks/customer-layout.tsx +++ b/modules/customers/src/web/shared/ui/blocks/customer-layout.tsx @@ -1,6 +1,5 @@ import type { PropsWithChildren } from "react"; export const CustomerLayout = ({ children }: PropsWithChildren) => { - //return {children}; - return
{children}
; + return
{children}
; };