Guardar preferencias y returnTo en grid de listados

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
David Arranz 2026-05-14 19:36:25 +02:00
parent c98bc6cafc
commit 6e53e5bd58
15 changed files with 329 additions and 71 deletions

View File

@ -1,6 +1,7 @@
export * from "./use-datasource";
export * from "./use-debounce";
export * from "./use-hook-form";
export * from "./use-return-to-navigate";
export * from "./use-rhf-error-focus";
export * from "./use-unsaved-changes-notifier";
export * from "./use-url-param-id";

View File

@ -0,0 +1 @@
export * from "./use-return-to-navigation";

View File

@ -0,0 +1,28 @@
import * as React from "react";
import { useLocation, useNavigate, useSearchParams } from "react-router-dom";
interface UseReturnToNavigationParams {
fallbackPath: string;
}
export const useReturnToNavigation = ({ fallbackPath }: UseReturnToNavigationParams) => {
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const currentReturnTo = React.useMemo(() => {
return `${location.pathname}${location.search}`;
}, [location.pathname, location.search]);
const returnTo = searchParams.get("returnTo") || fallbackPath;
const navigateBack = React.useCallback(() => {
navigate(returnTo);
}, [navigate, returnTo]);
return {
currentReturnTo,
returnTo,
navigateBack,
};
};

View File

@ -1,5 +1,8 @@
import { useDebounce } from "@erp/core/hooks";
import { useMemo, useState } from "react";
import { 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 ListProformasByCriteriaParams,
@ -19,10 +22,33 @@ const EMPTY_PROFORMAS_LIST: ProformaList = {
};
export const useListProformasController = () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<ProformaListStatusFilter>("all");
const [searchParams, setSearchParams] = useSearchParams();
const tablePreferences = useDataTablePreferences({
storageKey: "proformas:list:grid",
defaultPageSize: 5,
defaultColumnVisibility: {
reference: true,
recipientName: true,
status: true,
totalAmountFmt: true,
invoiceDate: true,
},
});
const { pageSize: preferencesPageSize, setPageSize: setPageSizePreference } = tablePreferences;
// Parse page from URL (1-based) or default to 1
const urlPage = NumberHelper.parsePositiveInteger(searchParams.get("page"), 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);
@ -41,42 +67,71 @@ export const useListProformasController = () => {
const query = useProformasListQuery({ criteria });
const setStatusFilterValue = (value: string) => {
const setStatusFilterValue = useCallback(
(value: string) => {
const nextValue = (value || "all") as ProformaListStatusFilter;
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);
// Reset page to 1 when status filter changes
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set("page", "1");
return params;
});
return nextValue;
});
};
},
[setSearchParams]
);
const setSearchValue = (value: string) => {
const setSearchValue = useCallback(
(value: string) => {
const nextValue = value.trim().replace(/\s+/g, " ");
setSearch((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);
// Reset page to 1 when search changes
setSearchParams((prev) => {
const params = new URLSearchParams(prev);
params.set("page", "1");
return params;
});
return nextValue;
});
};
},
[setSearchParams]
);
const setPageSizeValue = (value: number) => {
setPageSize((prev) => {
if (prev === value) return prev;
// Sólo si el tamaño de página realmente cambia,
// reseteamos la página a 0 para evitar inconsistencias
setPageIndex(0);
return value;
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", "1");
params.set("pageSize", String(value));
return params;
});
setPageSizePreference(value);
},
[pageSize, setSearchParams, setPageSizePreference]
);
return {
data: query.data ?? EMPTY_PROFORMAS_LIST,
@ -88,9 +143,11 @@ export const useListProformasController = () => {
refetch: query.refetch,
tablePreferences,
pageIndex,
pageSize,
setPageIndex,
setPageIndex: setPageIndexValue,
setPageSize: setPageSizeValue,
search,

View File

@ -1,5 +1,5 @@
import { DataTable, SkeletonDataTable } from "@repo/rdx-ui/components";
import type { ColumnDef } from "@tanstack/react-table";
import type { ColumnDef, OnChangeFn, VisibilityState } from "@tanstack/react-table";
import { useTranslation } from "../../../../../i18n";
import type { ProformaList, ProformaListRow } from "../../../../shared";
@ -16,6 +16,9 @@ interface ProformasGridProps {
onPageChange: (pageIndex: number) => void;
onPageSizeChange: (size: number) => void;
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
onRowClick?: (proformaId: string) => void;
}
@ -28,6 +31,8 @@ export const ProformasGrid = ({
pageSize,
onPageChange,
onPageSizeChange,
columnVisibility,
onColumnVisibilityChange,
onRowClick,
}: ProformasGridProps) => {
const { t } = useTranslation();
@ -47,10 +52,12 @@ export const ProformasGrid = ({
return (
<DataTable
columns={columns}
columnVisibility={columnVisibility}
data={items}
enablePagination
enableRowSelection
manualPagination
onColumnVisibilityChange={onColumnVisibilityChange}
onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange}
//onRowClick={(row) => onRowClick?.(row.id)}

View File

@ -1,4 +1,5 @@
import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components";
import { useReturnToNavigation } from "@erp/core/hooks";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import {
Button,
@ -12,7 +13,7 @@ import {
SelectValue,
} from "@repo/shadcn-ui/components";
import { FilterIcon, PlusIcon } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { createSearchParams, useLocation, useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
import { ChangeProformaStatusDialog } from "../../../change-status";
@ -29,12 +30,26 @@ import { ProformaStatusBadge } from "../components";
export const ListProformasPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { currentReturnTo } = useReturnToNavigation({
fallbackPath: "/proformas",
});
const { listCtrl, panelCtrl, deleteDialogCtrl, issueDialogCtrl, changeStatusDialogCtrl } =
useListProformasPageController();
const handleEditClick = (proformaId: string) => {
navigate({
pathname: `/proformas/${proformaId}/edit`,
search: createSearchParams({
returnTo: currentReturnTo,
}).toString(),
});
};
const columns = useProformasGridColumns({
onEditClick: (proforma) => navigate(`/proformas/${proforma.id}/edit`),
onEditClick: (proforma) => handleEditClick(proforma.id),
onIssueClick: (proformaRow) =>
issueDialogCtrl.openDialog(prepareIssueProformaTarget(proformaRow)),
onDeleteClick: (proformaRow: ProformaListRow) =>
@ -75,9 +90,11 @@ export const ListProformasPage = () => {
<div>
<ProformasGrid
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={(proformaId) => panelCtrl.openProformaPanel(proformaId, "view")}
@ -86,26 +103,33 @@ export const ListProformasPage = () => {
/>
{/* Explicación técnica */}
<div className="mt-8 rounded border border-border bg-card p-3 sm:p-4 text-xs sm:text-sm text-muted-foreground space-y-2">
<div className="mt-8 rounded border border-border bg-card p-3 sm:p-4 text-xs sm:text-sm text-muted-foreground space-y-4">
<p className="font-semibold text-foreground">Estado de proforma</p>
<ul className="list-disc list-inside space-y-1">
<ul className="list-disc list-inside space-y-4">
<li>
<ProformaStatusBadge status="draft" />
<strong className="text-foreground">Borrador:</strong> Un{" "}
<code className="rounded bg-muted px-1 text-xs">div</code> absoluto con{" "}
<code className="rounded bg-muted px-1 text-xs">pointer-events: none</code> y{" "}
<code className="rounded bg-muted px-1 text-xs">linear-gradient</code> se superpone
encima del scroll.
<strong className="text-foreground">Borrador:</strong> Una proforma sin terminar y
pendiente de modificaciones.
</li>
<li>
<strong className="text-foreground">Columna sticky:</strong> La celda de acciones usa{" "}
<code className="rounded bg-muted px-1 text-xs">sticky right-0 z-20</code>.
<ProformaStatusBadge status="sent" />
<strong className="text-foreground">Enviada:</strong> Se ha enviado la proforma al
cliente para su aprobación.
</li>
<li>
<strong className="text-foreground">Responsive:</strong> Columnas ocultas en móviles (
<code className="rounded bg-muted px-1 text-xs">hidden sm:</code>,{" "}
<code className="rounded bg-muted px-1 text-xs">hidden md:</code>,{" "}
<code className="rounded bg-muted px-1 text-xs">hidden lg:</code>).
<ProformaStatusBadge status="approved" />
<strong className="text-foreground">Aprobada:</strong> La proforma ha sido aprobada
por el cliente y puede pasar a factura.
</li>
<li>
<ProformaStatusBadge status="rejected" />
<strong className="text-foreground">Rechazada:</strong> La proforma ha sido rechazada
por el cliente. Puede quedarse así o volver a "borrador".
</li>
<li>
<ProformaStatusBadge status="issued" />
<strong className="text-foreground">Facturada:</strong> La proforma ya ha sido pasada
a factura.
</li>
</ul>
</div>
@ -162,7 +186,7 @@ export const ListProformasPage = () => {
<ProformaSummaryPanel
className="border bg-background"
mode={panelCtrl.panelState.mode}
onEdit={(proforma) => navigate(`/proformas/${proforma.id}/edit`)}
onEdit={(proforma) => handleEditClick(proforma.id)}
onOpenChange={(open) => {
if (!open) {
panelCtrl.closePanel();

View File

@ -1,13 +1,17 @@
import { useUrlParamId } from "@erp/core/hooks";
import { useCustomerSelectionFlow } from "@erp/customers/common";
import { useSearchParams } from "react-router-dom";
import { useUpdateProformaController } from "./use-update-proforma-controller";
export const useUpdateProformaPageController = () => {
const proformaId = useUrlParamId();
const [searchParams] = useSearchParams();
const updateCtrl = useUpdateProformaController(proformaId);
const returnTo = searchParams.get("returnTo") ?? "/proformas";
const selectCustomerCtrl = useCustomerSelectionFlow({
defaultLanguageCode: updateCtrl.form.watch("languageCode"),
defaultCurrencyCode: updateCtrl.form.watch("currencyCode"),
@ -19,5 +23,6 @@ export const useUpdateProformaPageController = () => {
return {
updateCtrl,
selectCustomerCtrl,
returnTo,
};
};

View File

@ -1,11 +1,14 @@
import { SpainTaxCatalogProvider } from "@erp/core";
import { ErrorAlert, NotFoundCard, PageHeader } from "@erp/core/components";
import { FormCommitButtonGroup, UnsavedChangesProvider } from "@erp/core/hooks";
import {
FormCommitButtonGroup,
UnsavedChangesProvider,
useReturnToNavigation,
} from "@erp/core/hooks";
import { SelectCustomerDialog } from "@erp/customers";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { useMemo } from "react";
import { FormProvider } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../../../i18n";
import { useUpdateProformaPageController } from "../../controllers/use-update-proforma-page-controller";
@ -14,10 +17,13 @@ import { ProformaUpdateEditorForm } from "../editors";
export const ProformaUpdatePage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const taxCatalog = useMemo(() => SpainTaxCatalogProvider(), []);
const { updateCtrl, selectCustomerCtrl } = useUpdateProformaPageController();
const { updateCtrl, selectCustomerCtrl, returnTo } = useUpdateProformaPageController();
const { navigateBack } = useReturnToNavigation({
fallbackPath: returnTo,
});
if (updateCtrl.isLoading) {
return <ProformaUpdateSkeleton />;
@ -36,7 +42,7 @@ export const ProformaUpdatePage = () => {
/>
<div className="flex items-center justify-end">
<BackHistoryButton />
<BackHistoryButton onClick={() => navigateBack()} />
</div>
</AppContent>
);
@ -61,14 +67,15 @@ export const ProformaUpdatePage = () => {
<AppHeader className="mx-auto max-w-5xl space-y-4">
<PageHeader
description={t("pages.proformas.update.description")}
onBackClick={() => navigate("/proformas/list")}
onBackClick={() => navigateBack()}
rightSlot={
<FormCommitButtonGroup
cancel={{
to: "/proformas/list",
onCancel: () => navigateBack(),
}}
disabled={updateCtrl.isUpdating}
isLoading={updateCtrl.isUpdating}
onBack={() => navigateBack()}
onReset={updateCtrl.form.formState.isDirty ? updateCtrl.resetForm : undefined}
submit={{
formId: updateCtrl.formId,

View File

@ -4,12 +4,17 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../locales/i18n.ts";
export const BackHistoryButton = () => {
export const BackHistoryButton = ({ onClick }: { onClick?: () => void }) => {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<Button className="h-7 w-7" onClick={() => navigate(-1)} size="icon" variant="outline">
<Button
className="h-7 w-7"
onClick={() => navigate(-1) || onClick}
size="icon"
variant="outline"
>
<ChevronLeftIcon className="w-4 h-4" />
<span className="sr-only">{t("common.back")}</span>
</Button>

View File

@ -11,6 +11,7 @@ import {
type ColumnDef,
type ColumnFiltersState,
type ColumnSizingState,
type OnChangeFn,
type Row,
type SortingState,
type Table,
@ -71,6 +72,8 @@ export interface DataTableProps<TData, TValue> {
// Configuración
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
readOnly?: boolean;
enablePagination?: boolean;
pageSize?: number;
@ -86,7 +89,11 @@ export interface DataTableProps<TData, TValue> {
onPageSizeChange?: (pageSize: number) => void;
// Acción al hacer click en una fila
onRowClick?: (row: TData, index: number, event: React.MouseEvent<HTMLTableRowElement>) => void;
onRowClick?: (
row: TData,
index: number,
event: React.MouseEvent<HTMLTableRowElement> | React.KeyboardEvent<HTMLTableRowElement>
) => void;
}
export function DataTable<TData, TValue>({
@ -94,7 +101,9 @@ export function DataTable<TData, TValue>({
data,
meta,
columnVisibility: inititalcolumnVisibility = {},
columnVisibility,
onColumnVisibilityChange,
readOnly = false,
enablePagination = true,
pageSize = 10,
@ -114,11 +123,17 @@ export function DataTable<TData, TValue>({
const [rowSelection, setRowSelection] = React.useState({});
const [sorting, setSorting] = React.useState<SortingState>([]);
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>(inititalcolumnVisibility);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
const [colSizes, setColSizes] = React.useState<ColumnSizingState>({});
const [internalColumnVisibility, setInternalColumnVisibility] = React.useState<VisibilityState>(
{}
);
const resolvedColumnVisibility = columnVisibility ?? internalColumnVisibility;
const handleColumnVisibilityChange = onColumnVisibilityChange ?? setInternalColumnVisibility;
// Configuración TanStack
const table = useReactTable({
data,
@ -137,7 +152,7 @@ export function DataTable<TData, TValue>({
state: {
columnSizing: colSizes,
sorting,
columnVisibility,
columnVisibility: resolvedColumnVisibility,
rowSelection,
columnFilters,
pagination: { pageIndex, pageSize },
@ -165,7 +180,7 @@ export function DataTable<TData, TValue>({
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onColumnVisibilityChange: handleColumnVisibilityChange,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
@ -229,10 +244,16 @@ export function DataTable<TData, TValue>({
data-state={row.getIsSelected() && "selected"}
key={row.id}
onClick={(e) => onRowClick?.(row.original, rowIndex, e)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ")
onRowClick?.(row.original, rowIndex, e as any);
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") {
return;
}
event.preventDefault();
onRowClick?.(row.original, rowIndex, event);
}}
role={onRowClick ? "button" : undefined}
tabIndex={onRowClick ? 0 : undefined}
>
{row.getVisibleCells().map((cell) => {
/*

View File

@ -0,0 +1 @@
export * from "./use-data-table-preferences.ts";

View File

@ -0,0 +1,91 @@
import type { OnChangeFn, VisibilityState } from "@tanstack/react-table";
import * as React from "react";
export interface DataTablePreferences {
pageSize: number;
columnVisibility: VisibilityState;
}
interface UseDataTablePreferencesParams {
storageKey: string;
defaultPageSize: number;
defaultColumnVisibility?: VisibilityState;
}
export const useDataTablePreferences = ({
storageKey,
defaultPageSize,
defaultColumnVisibility = {},
}: UseDataTablePreferencesParams) => {
const [preferences, setPreferences] = React.useState<DataTablePreferences>(() => {
try {
const rawValue = window.localStorage.getItem(storageKey);
if (!rawValue) {
return {
pageSize: defaultPageSize,
columnVisibility: defaultColumnVisibility,
};
}
const parsed = JSON.parse(rawValue) as Partial<DataTablePreferences>;
return {
pageSize: parsed.pageSize ?? defaultPageSize,
columnVisibility: {
...defaultColumnVisibility,
...parsed.columnVisibility,
},
};
} catch {
return {
pageSize: defaultPageSize,
columnVisibility: defaultColumnVisibility,
};
}
});
const persist = React.useCallback(
(next: DataTablePreferences) => {
window.localStorage.setItem(storageKey, JSON.stringify(next));
},
[storageKey]
);
const setPageSize = React.useCallback(
(pageSize: number) => {
setPreferences((previous) => {
const next = { ...previous, pageSize };
persist(next);
return next;
});
},
[persist]
);
const setColumnVisibility = React.useCallback<OnChangeFn<VisibilityState>>(
(updater) => {
setPreferences((previous) => {
const nextColumnVisibility =
typeof updater === "function" ? updater(previous.columnVisibility) : updater;
const next = {
...previous,
columnVisibility: nextColumnVisibility,
};
persist(next);
return next;
});
},
[persist]
);
return {
pageSize: preferences.pageSize,
columnVisibility: preferences.columnVisibility,
setPageSize,
setColumnVisibility,
};
};

View File

@ -1,5 +1,4 @@
export * from "./data-table-column-header.tsx";
export * from "./data-table.tsx";
export * from "./data-table-column-header.tsx";
export * from "./hooks/index.ts";
export * from "./skeleton-data-table.tsx";
export * from "./with-row-selection.tsx";

View File

@ -51,7 +51,18 @@ const roundToScale = (n: number, scale = 2) => {
// Stepping teclado con redondeo a escala
const stepNumber = (base: number, step = 0.01, scale = 2) => roundToScale(base + step, scale);
const parsePositiveInteger = (value: string | null, fallback: number): number => {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 1) {
return fallback;
}
return parsed;
};
export const NumberHelper = {
parsePositiveInteger,
toSafeNumber,
formatNumber,
parseLocaleNumber,