This commit is contained in:
David Arranz 2026-06-15 11:13:16 +02:00
parent 7bd20c22df
commit 8ecebc64bb
9 changed files with 406 additions and 143 deletions

View File

@ -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<ProformaListSortField, ProformaListApiSortField> = {
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;

View File

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

View File

@ -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 <div className="flex flex-col h-full w-full">{children}</div>;
}

View File

@ -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: (
<CustomerLayout>
<Outlet context={params} />
</CustomerLayout>
),
children: [
{ path: "", index: true, element: <CustomersList /> }, // index
{ path: "list", element: <CustomersList /> },
//{ path: "create", element: <CustomerAdd /> },
{ path: ":id", element: <CustomerView /> },
{ path: ":id/edit", element: <CustomerUpdate /> },
{ path: "create", element: <CustomerCreate /> },
{
index: true,
element: <CustomersListPage />,
},
{
path: "list",
element: <CustomersListPage />,
},
],
},
//
/*
{
path: "customers/create",
handle: {
layout: "app-fullscreen",
protected: true,
},
element: <CustomerCreatePage />,
},
{
path: "customers/:id/edit",
handle: {
layout: "app-fullscreen",
protected: true,
},
element: <CustomerUpdatePage />,
},
{
path: "customers/:id",
handle: {
layout: "app-fullscreen",
protected: true,
},
element: <CustomerViewPage />,
},
/*
children: [
{ path: ":id", element: <CustomersList /> },
{ path: ":id/edit", element: <CustomersList /> },
{ path: ":id/delete", element: <CustomersList /> },
@ -43,8 +82,8 @@ export const CustomerRoutes = (params: ModuleClientParams): RouteObject[] => {
{ path: ":id/email", element: <CustomersList /> },
{ path: ":id/download", element: <CustomersList /> },
{ path: ":id/duplicate", element: <CustomersList /> },
{ path: ":id/preview", element: <CustomersList /> },*/
{ path: ":id/preview", element: <CustomersList /> },
],
},
*/
];
};

View File

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

View File

@ -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<CustomerListStatusFilter>("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<NonNullable<ListCustomersByCriteriaParams["criteria"]>>(
() => ({
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,
};
};

View File

@ -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<CustomerListRow, unknown>[];
@ -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<VisibilityState>;
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 (
<SkeletonDataTable
columns={columns.length}
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
footerProps={{ pageIndex, pageSize, totalItems: totalItems ?? 0 }}
rows={Math.max(6, pageSize)}
showFooter
/>
);
}
return (
<DataTable
columns={columns}
columnVisibility={columnVisibility}
data={items}
enablePagination
enableRowSelection
manualPagination
manualSorting
onColumnVisibilityChange={onColumnVisibilityChange}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
onRowClick={(row, _index) => onRowClick?.(row)}
onSortChange={onSortChange}
//onRowClick={(row) => onRowClick?.(row.id)}
pageIndex={pageIndex}
pageSize={pageSize}
totalItems={total_items}
sort={sort}
totalItems={totalItems}
/>
);
};

View File

@ -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 = (
<div className="h-full min-w-0 overflow-auto w-full">
<div className="flex items-center justify-between gap-16">
<div className="flex h-full min-w-0 flex-col gap-4 overflow-hidden">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<SimpleSearchInput
loading={listCtrl.isFetching}
onSearchChange={listCtrl.setSearchValue}
@ -39,15 +62,23 @@ export const ListCustomersPage = () => {
/>
</div>
<CustomersGrid
columns={columns}
data={listCtrl.data}
loading={listCtrl.isLoading}
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
/>
<div>
<CustomersGrid
columns={columns}
columnVisibility={listCtrl.tablePreferences.columnVisibility}
data={listCtrl.data}
fetching={listCtrl.isFetching}
loading={listCtrl.isLoading}
onColumnVisibilityChange={listCtrl.tablePreferences.setColumnVisibility}
onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize}
onRowClick={(customerId) => panelCtrl.openCustomerPanel(customerId, "view")}
onSortChange={listCtrl.setSort}
pageIndex={listCtrl.pageIndex}
pageSize={listCtrl.pageSize}
sort={listCtrl.sort}
/>
</div>
</div>
);
@ -64,61 +95,60 @@ export const ListCustomersPage = () => {
}
return (
<>
<AppHeader>
<PageHeader
description={t("pages.list.description")}
rightSlot={
<Button
aria-label={t("pages.create.title")}
onClick={() => navigate("/customers/create")}
>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.create.title")}
</Button>
}
title={t("pages.list.title")}
/>
</AppHeader>
<AppContent>
{isPanelOpen ? (
<ResizablePanelGroup
autoSave="list-customers-page"
className="h-full"
orientation="horizontal"
<div className="p-6 space-y-6">
{/* Header */}
<PageHeader
description={t("pages.proformas.list.description")}
rightSlot={
<Button
aria-label={t("pages.proformas.create.title")}
onClick={() => navigate("/proformas/create")}
size={"default"}
>
<ResizablePanel defaultSize="70%" maxSize="75%" minSize="70%">
{listContent}
</ResizablePanel>
<PlusIcon aria-hidden className="mr-2 size-4" />
{t("pages.proformas.create.title")}
</Button>
}
title={t("pages.proformas.list.title")}
/>
<ResizableHandle className="mx-4" withHandle />
{/* Table */}
{isPanelOpen ? (
<ResizablePanelGroup
autoSave="list-customers-page"
className="h-full"
orientation="horizontal"
>
<ResizablePanel defaultSize="70%" maxSize="75%" minSize="70%">
{listContent}
</ResizablePanel>
<ResizablePanel defaultSize="30%" maxSize="30%" minSize="25%">
<div className="h-full">
<CustomerSummaryPanel
className="border bg-background"
customer={panelCtrl.customer}
mode={panelCtrl.panelState.mode}
onEdit={(customer) => navigate(`/customers/${customer.id}/edit`)}
onOpenChange={(open) => {
if (!open) {
panelCtrl.closePanel();
return;
}
<ResizableHandle className="mx-4" withHandle />
panelCtrl.panelState.onOpenChange(true);
}}
open={panelCtrl.panelState.isOpen}
visibility={panelCtrl.panelState.visibility}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
) : (
<div className="flex min-h-0 flex-1 overflow-hidden">{listContent}</div>
)}
</AppContent>
</>
<ResizablePanel defaultSize="30%" maxSize="30%" minSize="25%">
<div className="h-full">
<CustomerSummaryPanel
className="border bg-background"
customer={panelCtrl.customer}
mode={panelCtrl.panelState.mode}
onEdit={(customer) => navigate(`/customers/${customer.id}/edit`)}
onOpenChange={(open) => {
if (!open) {
panelCtrl.closePanel();
return;
}
panelCtrl.panelState.onOpenChange(true);
}}
open={panelCtrl.panelState.isOpen}
visibility={panelCtrl.panelState.visibility}
/>
</div>
</ResizablePanel>
</ResizablePanelGroup>
) : (
<div className="mx-auto w-full space-y-4">{listContent}</div>
)}
</div>
);
};

View File

@ -1,6 +1,5 @@
import type { PropsWithChildren } from "react";
export const CustomerLayout = ({ children }: PropsWithChildren) => {
//return <CustomersProvider>{children}</CustomersProvider>;
return <div>{children}</div>;
return <div className="flex flex-col h-full w-full">{children}</div>;
};