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-datasource";
export * from "./use-debounce"; export * from "./use-debounce";
export * from "./use-hook-form"; export * from "./use-hook-form";
export * from "./use-return-to-navigate";
export * from "./use-rhf-error-focus"; export * from "./use-rhf-error-focus";
export * from "./use-unsaved-changes-notifier"; export * from "./use-unsaved-changes-notifier";
export * from "./use-url-param-id"; 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 { 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 { import {
type ListProformasByCriteriaParams, type ListProformasByCriteriaParams,
@ -19,10 +22,33 @@ const EMPTY_PROFORMAS_LIST: ProformaList = {
}; };
export const useListProformasController = () => { export const useListProformasController = () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<ProformaListStatusFilter>("all"); 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); const debouncedSearch = useDebounce(search, 300);
@ -41,42 +67,71 @@ export const useListProformasController = () => {
const query = useProformasListQuery({ criteria }); const query = useProformasListQuery({ criteria });
const setStatusFilterValue = (value: string) => { const setStatusFilterValue = useCallback(
(value: string) => {
const nextValue = (value || "all") as ProformaListStatusFilter; const nextValue = (value || "all") as ProformaListStatusFilter;
setStatusFilter((prev) => { setStatusFilter((prev) => {
if (prev === nextValue) return prev; if (prev === nextValue) return prev;
// Sólo si la búsqueda realmente cambia, // Reset page to 1 when status filter changes
// reseteamos la página a 0 para evitar inconsistencias setSearchParams((prev) => {
setPageIndex(0); const params = new URLSearchParams(prev);
params.set("page", "1");
return params;
});
return nextValue; return nextValue;
}); });
}; },
[setSearchParams]
);
const setSearchValue = (value: string) => { const setSearchValue = useCallback(
(value: string) => {
const nextValue = value.trim().replace(/\s+/g, " "); const nextValue = value.trim().replace(/\s+/g, " ");
setSearch((prev) => { setSearch((prev) => {
if (prev === nextValue) return prev; if (prev === nextValue) return prev;
// Sólo si la búsqueda realmente cambia, // Reset page to 1 when search changes
// reseteamos la página a 0 para evitar inconsistencias setSearchParams((prev) => {
setPageIndex(0); const params = new URLSearchParams(prev);
params.set("page", "1");
return params;
});
return nextValue; return nextValue;
}); });
}; },
[setSearchParams]
);
const setPageSizeValue = (value: number) => { const setPageIndexValue = useCallback(
setPageSize((prev) => { (newPageIndex: number) => {
if (prev === value) return prev; const newPage = newPageIndex + 1; // Convert to 1-based for URL
setSearchParams((prev) => {
// Sólo si el tamaño de página realmente cambia, const params = new URLSearchParams(prev);
// reseteamos la página a 0 para evitar inconsistencias params.set("page", String(newPage));
setPageIndex(0); return params;
return value;
}); });
}; },
[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 { return {
data: query.data ?? EMPTY_PROFORMAS_LIST, data: query.data ?? EMPTY_PROFORMAS_LIST,
@ -88,9 +143,11 @@ export const useListProformasController = () => {
refetch: query.refetch, refetch: query.refetch,
tablePreferences,
pageIndex, pageIndex,
pageSize, pageSize,
setPageIndex, setPageIndex: setPageIndexValue,
setPageSize: setPageSizeValue, setPageSize: setPageSizeValue,
search, search,

View File

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

View File

@ -1,4 +1,5 @@
import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components"; import { ErrorAlert, PageHeader, SimpleSearchInput } from "@erp/core/components";
import { useReturnToNavigation } from "@erp/core/hooks";
import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components"; import { AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components";
import { import {
Button, Button,
@ -12,7 +13,7 @@ import {
SelectValue, SelectValue,
} from "@repo/shadcn-ui/components"; } from "@repo/shadcn-ui/components";
import { FilterIcon, PlusIcon } from "lucide-react"; 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 { useTranslation } from "../../../../i18n";
import { ChangeProformaStatusDialog } from "../../../change-status"; import { ChangeProformaStatusDialog } from "../../../change-status";
@ -29,12 +30,26 @@ import { ProformaStatusBadge } from "../components";
export const ListProformasPage = () => { export const ListProformasPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const { currentReturnTo } = useReturnToNavigation({
fallbackPath: "/proformas",
});
const { listCtrl, panelCtrl, deleteDialogCtrl, issueDialogCtrl, changeStatusDialogCtrl } = const { listCtrl, panelCtrl, deleteDialogCtrl, issueDialogCtrl, changeStatusDialogCtrl } =
useListProformasPageController(); useListProformasPageController();
const handleEditClick = (proformaId: string) => {
navigate({
pathname: `/proformas/${proformaId}/edit`,
search: createSearchParams({
returnTo: currentReturnTo,
}).toString(),
});
};
const columns = useProformasGridColumns({ const columns = useProformasGridColumns({
onEditClick: (proforma) => navigate(`/proformas/${proforma.id}/edit`), onEditClick: (proforma) => handleEditClick(proforma.id),
onIssueClick: (proformaRow) => onIssueClick: (proformaRow) =>
issueDialogCtrl.openDialog(prepareIssueProformaTarget(proformaRow)), issueDialogCtrl.openDialog(prepareIssueProformaTarget(proformaRow)),
onDeleteClick: (proformaRow: ProformaListRow) => onDeleteClick: (proformaRow: ProformaListRow) =>
@ -75,9 +90,11 @@ export const ListProformasPage = () => {
<div> <div>
<ProformasGrid <ProformasGrid
columns={columns} columns={columns}
columnVisibility={listCtrl.tablePreferences.columnVisibility}
data={listCtrl.data} data={listCtrl.data}
fetching={listCtrl.isFetching} fetching={listCtrl.isFetching}
loading={listCtrl.isLoading} loading={listCtrl.isLoading}
onColumnVisibilityChange={listCtrl.tablePreferences.setColumnVisibility}
onPageChange={listCtrl.setPageIndex} onPageChange={listCtrl.setPageIndex}
onPageSizeChange={listCtrl.setPageSize} onPageSizeChange={listCtrl.setPageSize}
onRowClick={(proformaId) => panelCtrl.openProformaPanel(proformaId, "view")} onRowClick={(proformaId) => panelCtrl.openProformaPanel(proformaId, "view")}
@ -86,26 +103,33 @@ export const ListProformasPage = () => {
/> />
{/* Explicación técnica */} {/* 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> <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> <li>
<ProformaStatusBadge status="draft" /> <ProformaStatusBadge status="draft" />
<strong className="text-foreground">Borrador:</strong> Un{" "} <strong className="text-foreground">Borrador:</strong> Una proforma sin terminar y
<code className="rounded bg-muted px-1 text-xs">div</code> absoluto con{" "} pendiente de modificaciones.
<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.
</li> </li>
<li> <li>
<strong className="text-foreground">Columna sticky:</strong> La celda de acciones usa{" "} <ProformaStatusBadge status="sent" />
<code className="rounded bg-muted px-1 text-xs">sticky right-0 z-20</code>. <strong className="text-foreground">Enviada:</strong> Se ha enviado la proforma al
cliente para su aprobación.
</li> </li>
<li> <li>
<strong className="text-foreground">Responsive:</strong> Columnas ocultas en móviles ( <ProformaStatusBadge status="approved" />
<code className="rounded bg-muted px-1 text-xs">hidden sm:</code>,{" "} <strong className="text-foreground">Aprobada:</strong> La proforma ha sido aprobada
<code className="rounded bg-muted px-1 text-xs">hidden md:</code>,{" "} por el cliente y puede pasar a factura.
<code className="rounded bg-muted px-1 text-xs">hidden lg:</code>). </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> </li>
</ul> </ul>
</div> </div>
@ -162,7 +186,7 @@ export const ListProformasPage = () => {
<ProformaSummaryPanel <ProformaSummaryPanel
className="border bg-background" className="border bg-background"
mode={panelCtrl.panelState.mode} mode={panelCtrl.panelState.mode}
onEdit={(proforma) => navigate(`/proformas/${proforma.id}/edit`)} onEdit={(proforma) => handleEditClick(proforma.id)}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) { if (!open) {
panelCtrl.closePanel(); panelCtrl.closePanel();

View File

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

View File

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

View File

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

View File

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