Facturas de cliente

This commit is contained in:
David Arranz 2025-10-19 00:31:30 +02:00
parent af2997465e
commit 94be2d066f
11 changed files with 430 additions and 215 deletions

View File

@ -31,7 +31,8 @@
}, },
"suspicious": { "suspicious": {
"noImplicitAnyLet": "info", "noImplicitAnyLet": "info",
"noExplicitAny": "info" "noExplicitAny": "info",
"noArrayIndexKey": "info"
}, },
"style": { "style": {
"useImportType": "off", "useImportType": "off",

View File

@ -14,7 +14,11 @@
"actions": "Actions", "actions": "Actions",
"rows_selected": "{{count}} fila(s) seleccionadas.", "rows_selected": "{{count}} fila(s) seleccionadas.",
"rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas." "rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas.",
"search_placeholder": "Type for search...",
"search": "Search",
"clear": "Clear"
}, },
"catalog": { "catalog": {

View File

@ -14,7 +14,11 @@
"actions": "Acciones", "actions": "Acciones",
"rows_selected": "{{count}} fila(s) seleccionadas.", "rows_selected": "{{count}} fila(s) seleccionadas.",
"rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas." "rows_selected_of_total": "{{count}} de {{total}} fila(s) seleccionadas.",
"search_placeholder": "Escribe aquí para buscar...",
"search": "Buscar",
"clear": "Limpiar"
}, },
"catalog": { "catalog": {
"status": { "status": {

View File

@ -3,8 +3,17 @@ import { useDataSource } from "@erp/core/hooks";
import { DefaultError, QueryKey, useQuery } from "@tanstack/react-query"; import { DefaultError, QueryKey, useQuery } from "@tanstack/react-query";
import { CustomerInvoicesPage } from '../schemas'; import { CustomerInvoicesPage } from '../schemas';
export const CUSTOMER_INVOICES_QUERY_KEY = (criteria?: CriteriaDTO): QueryKey => export const CUSTOMER_INVOICES_QUERY_KEY = (criteria: CriteriaDTO): QueryKey => [
["customer_invoices", criteria] as const; "customer_invoices", {
pageNumber: criteria.pageNumber ?? 0,
pageSize: criteria.pageSize ?? 10,
q: criteria.q ?? "",
filters: criteria.filters ?? [],
orderBy: criteria.orderBy ?? "",
order: criteria.order ?? "",
},
];
type InvoicesQueryOptions = { type InvoicesQueryOptions = {
enabled?: boolean; enabled?: boolean;
@ -21,8 +30,8 @@ export const useInvoicesQuery = (options?: InvoicesQueryOptions) => {
queryKey: CUSTOMER_INVOICES_QUERY_KEY(criteria), queryKey: CUSTOMER_INVOICES_QUERY_KEY(criteria),
queryFn: async ({ signal }) => { queryFn: async ({ signal }) => {
return await dataSource.getList<CustomerInvoicesPage>("customer-invoices", { return await dataSource.getList<CustomerInvoicesPage>("customer-invoices", {
...criteria,
signal, signal,
...criteria,
}); });
}, },
enabled, enabled,

View File

@ -2,9 +2,9 @@ import type { CellKeyDownEvent, RowClickedEvent } from "ag-grid-community";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { DataTable, LoadingOverlay } from '@repo/rdx-ui/components'; import { DataTable, SkeletonDataTable } from '@repo/rdx-ui/components';
import { Button, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@repo/shadcn-ui/components'; import { Button, InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Spinner } from '@repo/shadcn-ui/components';
import { FileDownIcon, FilterIcon, SearchIcon } from 'lucide-react'; import { FileDownIcon, FilterIcon, SearchIcon, XIcon } from 'lucide-react';
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useTranslation } from "../../i18n"; import { useTranslation } from "../../i18n";
import { InvoiceSummaryFormData, InvoicesPageFormData } from '../../schemas'; import { InvoiceSummaryFormData, InvoicesPageFormData } from '../../schemas';
@ -14,8 +14,13 @@ export type InvoiceUpdateCompProps = {
invoicesPage: InvoicesPageFormData; invoicesPage: InvoicesPageFormData;
loading?: boolean; loading?: boolean;
onPageChange?: (pageIndex: number) => void; pageIndex: number;
pageSize: number;
onPageChange?: (pageNumber: number) => void;
onPageSizeChange?: (pageSize: number) => void; onPageSizeChange?: (pageSize: number) => void;
searchValue: string;
onSearchChange: (value: string) => void;
} }
@ -23,8 +28,11 @@ export type InvoiceUpdateCompProps = {
export const InvoicesListGrid = ({ export const InvoicesListGrid = ({
invoicesPage, invoicesPage,
loading, loading,
pageIndex,
pageSize,
onPageChange, onPageChange,
onPageSizeChange, onPageSizeChange,
searchValue, onSearchChange,
}: InvoiceUpdateCompProps) => { }: InvoiceUpdateCompProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@ -33,8 +41,7 @@ export const InvoicesListGrid = ({
const [statusFilter, setStatusFilter] = useState("todas"); const [statusFilter, setStatusFilter] = useState("todas");
const columns = useInvoicesListColumns(); const columns = useInvoicesListColumns();
const { items, page, per_page, total_pages, total_items } = invoicesPage; const { items, total_items } = invoicesPage;
// Navegación accesible (click o teclado) // Navegación accesible (click o teclado)
const goToRow = useCallback( const goToRow = useCallback(
@ -77,6 +84,15 @@ export const InvoicesListGrid = ({
[goToRow] [goToRow]
); );
// Handlers de búsqueda
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => onSearchChange(e.target.value);
const handleClear = () => onSearchChange("");
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
// Envío inmediato: forzar “salto” del debounce
onSearchChange((e.target as HTMLInputElement).value);
}
};
const handleRowClick = useCallback( const handleRowClick = useCallback(
(invoice: InvoiceSummaryFormData, _index: number, e: React.MouseEvent) => { (invoice: InvoiceSummaryFormData, _index: number, e: React.MouseEvent) => {
@ -87,20 +103,53 @@ export const InvoicesListGrid = ({
[navigate] [navigate]
); );
if (loading) {
return (
<div className="flex flex-col gap-4">
<SkeletonDataTable
columns={columns.length}
rows={Math.max(6, pageSize)}
showFooter
footerProps={{ pageIndex, pageSize, totalItems: total_items ?? 0 }}
/>
</div>
);
}
// Render principal // Render principal
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{/* Barra de filtros */} {/* Barra de filtros */}
<div className="flex flex-col sm:flex-row gap-4 mb-6"> <div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="relative flex-1"> <div className="relative flex-1" role="search" aria-label={t("pages.list.searchPlaceholder")}>
<SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
placeholder="Buscar por número o cliente..." <InputGroup className='bg-background' data-disabled={loading}>
value={searchTerm} <InputGroupInput
onChange={(e) => setSearchTerm(e.target.value)} placeholder={t("common.search_placeholder")}
className="pl-10 " value={searchValue}
/> onChange={handleInputChange}
onKeyDown={handleKeyDown}
inputMode="search"
autoComplete="off"
spellCheck={false}
disabled={loading}
/>
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupAddon align="inline-end">
{loading && <Spinner />}
{!searchValue && !loading && <InputGroupButton variant='secondary' className='cursor-pointer'>
{t("common.search")}
</InputGroupButton>}
{searchValue && !loading && <InputGroupButton variant='secondary' className='cursor-pointer' aria-label={t("common.clear")} onClick={handleClear}>
<XIcon className="size-4" aria-hidden />
<span className="sr-only">{t("common.search")}</span>
</InputGroupButton>}
</InputGroupAddon>
</InputGroup>
</div> </div>
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-48 bg-white border-gray-200 shadow-sm"> <SelectTrigger className="w-full sm:w-48 bg-white border-gray-200 shadow-sm">
@ -120,27 +169,22 @@ export const InvoicesListGrid = ({
</Button> </Button>
</div> </div>
<div className="overflow-hidden"> <div className="overflow-hidden">
<DataTable <DataTable
columns={columns} columns={columns}
data={items} data={items}
readOnly readOnly
enableRowSelection enableRowSelection
enablePagination enablePagination
manualPagination manualPagination
pageIndex={page - 1} pageIndex={pageIndex} // DataTable usa 0-based
pageSize={per_page} pageSize={pageSize}
totalItems={total_items} totalItems={total_items}
onPageChange={onPageChange} onPageChange={onPageChange}
onPageSizeChange={onPageSizeChange} onPageSizeChange={onPageSizeChange}
onRowClick={handleRowClick} onRowClick={handleRowClick}
/> />
{loading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/50">
<LoadingOverlay />
</div>
)}
</div> </div>
</div> </div>
); );

View File

@ -1,5 +1,5 @@
import { ErrorAlert } from '@erp/customers/components'; import { ErrorAlert } from '@erp/customers/components';
import { AppBreadcrumb, AppContent, AppHeader, BackHistoryButton } from "@repo/rdx-ui/components"; import { AppBreadcrumb, AppContent, AppHeader, BackHistoryButton, useDebounce } from "@repo/rdx-ui/components";
import { Button } from "@repo/shadcn-ui/components"; import { Button } from "@repo/shadcn-ui/components";
import { FilePenIcon, PlusIcon } from "lucide-react"; import { FilePenIcon, PlusIcon } from "lucide-react";
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
@ -13,8 +13,20 @@ export const InvoiceListPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [pageIndex, setOageIndex] = useState(0); const [pageIndex, setPageIndex] = useState(5);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
const [search, setSearch] = useState("");
const debouncedQ = useDebounce(search, 300);
const criteria = useMemo(
() => ({
q: debouncedQ || "",
pageSize,
pageNumber: pageIndex,
}),
[pageSize, pageIndex, debouncedQ]
);
const { const {
data, data,
@ -22,10 +34,7 @@ export const InvoiceListPage = () => {
isError, isError,
error, error,
} = useInvoicesQuery({ } = useInvoicesQuery({
criteria: { criteria
pageSize,
pageNumber: pageIndex
}
}); });
const invoicesPageData = useMemo(() => { const invoicesPageData = useMemo(() => {
@ -38,12 +47,19 @@ export const InvoiceListPage = () => {
const handlePageChange = (newPageIndex: number) => { const handlePageChange = (newPageIndex: number) => {
// TanStack usa pageIndex 0-based → API usa 0-based también // TanStack usa pageIndex 0-based → API usa 0-based también
setOageIndex(newPageIndex); setPageIndex(newPageIndex);
}; };
const handlePageSizeChange = (newSize: number) => { const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize); setPageSize(newSize);
setOageIndex(0); setPageIndex(0);
};
const handleSearchChange = (value: string) => {
// Normalización ligera: recorta y colapsa espacios internos
const cleaned = value.trim().replace(/\s+/g, " ");
setSearch(cleaned);
setPageIndex(0);
}; };
if (isError || !invoicesPageData) { if (isError || !invoicesPageData) {
@ -70,22 +86,6 @@ export const InvoiceListPage = () => {
/> />
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-4xl font-bold bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text text-transparent mb-2">
Facturas
</h1>
<p className="text-gray-600">Gestiona y consulta todas tus facturas de cliente</p>
</div>
<Button onClick={() => navigate(-1)} className="bg-gradient-to-r from-blue-600 to-violet-600 hover:from-blue-700 hover:to-violet-700 text-white shadow-lg shadow-blue-500/30">
<PlusIcon className="mr-2 h-4 w-4" />
Nueva Factura
</Button>
</div>
</AppHeader> </AppHeader>
<AppContent> <AppContent>
<div className='flex items-center justify-between space-y-6'> <div className='flex items-center justify-between space-y-6'>
@ -108,8 +108,12 @@ export const InvoiceListPage = () => {
<InvoicesListGrid <InvoicesListGrid
invoicesPage={invoicesPageData} invoicesPage={invoicesPageData}
loading={isLoading} loading={isLoading}
pageIndex={pageIndex}
pageSize={pageSize}
onPageChange={handlePageChange} onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange} onPageSizeChange={handlePageSizeChange}
searchValue={search}
onSearchChange={handleSearchChange}
/> />
</div> </div>
</AppContent> </AppContent>

View File

@ -21,14 +21,19 @@ import { DataTableMeta } from './data-table.tsx';
interface DataTablePaginationProps<TData> { interface DataTablePaginationProps<TData> {
table: Table<TData>; table: Table<TData>;
onPageChange?: (pageIndex: number) => void;
onPageSizeChange?: (pageSize: number) => void;
className?: string; className?: string;
} }
export function DataTablePagination<TData>({ table, className }: DataTablePaginationProps<TData>) { export function DataTablePagination<TData>({
table, onPageChange, onPageSizeChange, className }: DataTablePaginationProps<TData>) {
const { t } = useTranslation(); const { t } = useTranslation();
const { pageIndex: rawIndex, pageSize: rawSize } = table.getState().pagination; const { pageIndex: rawIndex, pageSize: rawSize } = table.getState().pagination;
const totalRows = (table.options.meta as DataTableMeta<TData>)?.totalItems ?? table.getFilteredRowModel().rows.length; const meta = table.options.meta as DataTableMeta<TData>;
const totalRows = meta?.totalItems ?? table.getFilteredRowModel().rows.length;
// Normalización segura // Normalización segura
const pageIndex = Number.isFinite(rawIndex) && rawIndex >= 0 ? rawIndex : 0; const pageIndex = Number.isFinite(rawIndex) && rawIndex >= 0 ? rawIndex : 0;
@ -41,23 +46,28 @@ export function DataTablePagination<TData>({ table, className }: DataTablePagina
const start = totalRows > 0 ? pageIndex * pageSize + 1 : 0; const start = totalRows > 0 ? pageIndex * pageSize + 1 : 0;
const end = totalRows > 0 ? Math.min(start + pageSize - 1, totalRows) : 0; const end = totalRows > 0 ? Math.min(start + pageSize - 1, totalRows) : 0;
// Handlers de navegación controlada
const notify = (next: Partial<{ pageIndex: number; pageSize: number }>) =>
table.options.onPaginationChange?.({ pageIndex, pageSize, ...next });
const gotoPage = (index: number) => onPageChange ? onPageChange(index) : notify({ pageIndex: index });
const handlePageSizeChange = (size: string) => onPageSizeChange ?
onPageSizeChange(Number(size)) :
notify({ pageSize: Number(size) });
const gotoPreviousPage = () => gotoPage(pageIndex - 1);
const gotoNextPage = () => gotoPage(pageIndex + 1);
const gotoFirstPage = () => gotoPage(0);
const gotoLastPage = () => gotoPage(pageCount - 1);
return ( return (
<div className={cn( <div className={cn("flex items-center justify-between", className)}>
"flex items-center justify-between",
className
)}>
{/* Información izquierda */} {/* Información izquierda */}
<div className="flex flex-col sm:flex-row items-center gap-4 flex-1 text-sm text-muted-foreground"> <div className="flex flex-col sm:flex-row items-center gap-4 flex-1 text-sm text-muted-foreground">
{/* Rango visible */}
<span aria-live="polite"> <span aria-live="polite">
{t("components.datatable.pagination.showing_range", { {t("components.datatable.pagination.showing_range", { start, end, total: totalRows })}
start,
end,
total: totalRows,
})}
</span> </span>
{/* Selección de filas */}
{hasSelected && ( {hasSelected && (
<span aria-live="polite"> <span aria-live="polite">
{t("components.datatable.pagination.rows_selected", { {t("components.datatable.pagination.rows_selected", {
@ -68,13 +78,8 @@ export function DataTablePagination<TData>({ table, className }: DataTablePagina
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground text-nowrap"> <span>{t("components.datatable.pagination.rows_per_page")}</span>
{t("components.datatable.pagination.rows_per_page")} <Select value={String(pageSize)} onValueChange={handlePageSizeChange}>
</span>
<Select
value={String(pageSize)}
onValueChange={(value) => table.setPageSize(Number(value))}
>
<SelectTrigger className="w-20 h-8 bg-white border-gray-200"> <SelectTrigger className="w-20 h-8 bg-white border-gray-200">
<SelectValue placeholder={String(pageSize)} /> <SelectValue placeholder={String(pageSize)} />
</SelectTrigger> </SelectTrigger>
@ -86,6 +91,7 @@ export function DataTablePagination<TData>({ table, className }: DataTablePagina
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
{pageIndex + 1} / {pageCount}
</div> </div>
</div> </div>
@ -93,63 +99,61 @@ export function DataTablePagination<TData>({ table, className }: DataTablePagina
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Pagination> <Pagination>
<PaginationContent> <PaginationContent>
{/* Primera página */}
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
aria-label={t("components.datatable.pagination.goto_first_page")} aria-label={t("components.datatable.pagination.goto_first_page")}
onClick={() => table.setPageIndex(0)} onClick={() => {
isActive={!table.getCanPreviousPage()} if (pageIndex > 0) gotoFirstPage();
}}
isActive={pageIndex > 0}
size="sm" size="sm"
className="px-2.5" className="px-2.5 cursor-pointer"
> >
<ChevronsLeftIcon className="size-4" /> <ChevronsLeftIcon className="size-4" />
</PaginationLink> </PaginationLink>
</PaginationItem> </PaginationItem>
{/* Anterior */}
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
aria-label={t("components.datatable.pagination.goto_previous_page")} aria-label={t("components.datatable.pagination.goto_previous_page")}
onClick={() => table.previousPage()} onClick={() => {
isActive={!table.getCanPreviousPage()} if (pageIndex > 0) gotoPreviousPage();
}}
isActive={pageIndex > 0}
size="sm" size="sm"
className="px-2.5" className="px-2.5 cursor-pointer"
> >
<ChevronLeftIcon className="size-4" /> <ChevronLeftIcon className="size-4" />
</PaginationLink> </PaginationLink>
</PaginationItem> </PaginationItem>
<span <span className="text-sm text-muted-foreground px-2" aria-live="polite">
className="text-sm text-muted-foreground px-2" {t("components.datatable.pagination.page_of", { page: pageIndex + 1, of: pageCount })}
aria-live="polite"
>
{t("components.datatable.pagination.page_of", {
page: pageIndex + 1,
of: pageCount || 1,
})}
</span> </span>
{/* Siguiente */}
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
aria-label={t("components.datatable.pagination.goto_next_page")} aria-label={t("components.datatable.pagination.goto_next_page")}
onClick={() => table.nextPage()} onClick={() => {
isActive={!table.getCanNextPage()} if (pageIndex < pageCount - 1) gotoNextPage();
}}
isActive={pageIndex < pageCount - 1}
size="sm" size="sm"
className="px-2.5" className="px-2.5 cursor-pointer"
> >
<ChevronRightIcon className="size-4" /> <ChevronRightIcon className="size-4" />
</PaginationLink> </PaginationLink>
</PaginationItem> </PaginationItem>
{/* Última página */}
<PaginationItem> <PaginationItem>
<PaginationLink <PaginationLink
aria-label={t("components.datatable.pagination.goto_last_page")} aria-label={t("components.datatable.pagination.goto_last_page")}
onClick={() => table.setPageIndex(pageCount - 1)} onClick={() => {
isActive={!table.getCanNextPage()} if (pageIndex < pageCount - 1) gotoLastPage();
}}
isActive={pageIndex < pageCount - 1}
size="sm" size="sm"
className="px-2.5" className="px-2.5 cursor-pointer"
> >
<ChevronsRightIcon className="size-4" /> <ChevronsRightIcon className="size-4" />
</PaginationLink> </PaginationLink>

View File

@ -101,7 +101,7 @@ export function DataTable<TData, TValue>({
readOnly = false, readOnly = false,
enablePagination = true, enablePagination = true,
pageSize = 25, pageSize = 10,
enableRowSelection = false, enableRowSelection = false,
EditorComponent, EditorComponent,
@ -145,10 +145,7 @@ export function DataTable<TData, TValue>({
columnVisibility, columnVisibility,
rowSelection, rowSelection,
columnFilters, columnFilters,
pagination: { pagination: { pageIndex, pageSize },
pageIndex,
pageSize,
},
}, },
manualPagination, manualPagination,
@ -156,13 +153,14 @@ export function DataTable<TData, TValue>({
? Math.ceil((totalItems ?? data.length) / (pageSize ?? 25)) ? Math.ceil((totalItems ?? data.length) / (pageSize ?? 25))
: undefined, : undefined,
// Propagar cambios al padre
onPaginationChange: (updater) => { onPaginationChange: (updater) => {
const next = const next = typeof updater === "function"
typeof updater === "function" ? updater({ pageIndex, pageSize })
? updater({ pageIndex, pageSize }) : updater;
: updater;
if (next.pageIndex !== undefined) onPageChange?.(next.pageIndex); if (typeof next.pageIndex === "number") onPageChange?.(next.pageIndex);
if (next.pageSize !== undefined) onPageSizeChange?.(next.pageSize); if (typeof next.pageSize === "number") onPageSizeChange?.(next.pageSize);
}, },
enableRowSelection, enableRowSelection,
@ -183,126 +181,129 @@ export function DataTable<TData, TValue>({
// Render principal // Render principal
return ( return (
<div className="flex flex-col gap-0"> <div
<DataTableToolbar table={table} showViewOptions={!readOnly} /> className="overflow-hidden transition-[max-height] duration-300 ease-in-out"
style={{ maxHeight: `${table.getRowModel().rows.length * 56}px` }} // 56≈altura fila
>
<div className="flex flex-col gap-0">
<DataTableToolbar table={table} showViewOptions={!readOnly} />
<div className="overflow-hidden rounded-md border"> <div className="overflow-hidden rounded-md border">
<TableComp className="w-full text-sm"> <TableComp className="w-full text-sm">
{/* CABECERA */} {/* CABECERA */}
<TableHeader className="sticky top-0 z-10"> <TableHeader className="sticky top-0 z-10">
{table.getHeaderGroups().map((hg) => ( {table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}> <TableRow key={hg.id}>
{hg.headers.map((h) => { {hg.headers.map((h) => {
const w = h.getSize(); const w = h.getSize();
const minW = h.column.columnDef.minSize; const minW = h.column.columnDef.minSize;
const maxW = h.column.columnDef.maxSize; const maxW = h.column.columnDef.maxSize;
return (
<TableHead
key={h.id}
colSpan={h.colSpan}
style={{
width: w ? `${w}px` : undefined,
minWidth: typeof minW === "number" ? `${minW}px` : undefined,
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
}}
>
<div className={"text-xs text-muted-foreground text-nowrap cursor-default"}>
{h.isPlaceholder
? null
: flexRender(h.column.columnDef.header, h.getContext())}
</div>
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
{/* CUERPO */}
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row, rowIndex) => (
<TableRow
key={row.id}
role="button"
tabIndex={0}
data-state={row.getIsSelected() && "selected"}
className={`group bg-background ${readOnly ? "cursor-default" : "cursor-pointer"}`}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onRowClick?.(row.original, rowIndex, e as any);
}}
onDoubleClick={
!readOnly && !onRowClick ? () => setEditIndex(rowIndex) : undefined
} >
{row.getVisibleCells().map((cell) => {
const w = cell.column.getSize();
const minW = cell.column.columnDef.minSize;
const maxW = cell.column.columnDef.maxSize;
return ( return (
<TableCell <TableHead
key={cell.id} key={h.id}
className="align-top" colSpan={h.colSpan}
style={{ style={{
width: w ? `${w}px` : undefined, width: w ? `${w}px` : undefined,
minWidth: typeof minW === "number" ? `${minW}px` : undefined, minWidth: typeof minW === "number" ? `${minW}px` : undefined,
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined, maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
}} }}
> >
{flexRender(cell.column.columnDef.cell, cell.getContext())} <div className={"text-xs text-muted-foreground text-nowrap cursor-default"}>
</TableCell> {h.isPlaceholder
? null
: flexRender(h.column.columnDef.header, h.getContext())}
</div>
</TableHead>
); );
})} })}
</TableRow> </TableRow>
)) ))}
) : ( </TableHeader>
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
{t("components.datatable.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
{/* Paginación */}
{enablePagination && (
<TableFooter>
<TableRow>
<TableCell colSpan={100}>
<DataTablePagination table={table} />
</TableCell>
</TableRow>
</TableFooter>)
}
</TableComp> {/* CUERPO */}
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row, rowIndex) => (
<TableRow
key={row.id}
role="button"
tabIndex={0}
data-state={row.getIsSelected() && "selected"}
className={`group bg-background ${readOnly ? "cursor-default" : "cursor-pointer"}`}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onRowClick?.(row.original, rowIndex, e as any);
}}
onDoubleClick={
!readOnly && !onRowClick ? () => setEditIndex(rowIndex) : undefined
} >
{row.getVisibleCells().map((cell) => {
const w = cell.column.getSize();
const minW = cell.column.columnDef.minSize;
const maxW = cell.column.columnDef.maxSize;
return (
<TableCell
key={cell.id}
className="align-top"
style={{
width: w ? `${w}px` : undefined,
minWidth: typeof minW === "number" ? `${minW}px` : undefined,
maxWidth: typeof maxW === "number" ? `${maxW}px` : undefined,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center text-muted-foreground">
{t("components.datatable.empty")}
</TableCell>
</TableRow>
)}
</TableBody>
{/* Paginación */}
{enablePagination && (
<TableFooter>
<TableRow>
<TableCell colSpan={100}>
<DataTablePagination table={table} onPageChange={onPageChange} onPageSizeChange={onPageSizeChange} />
</TableCell>
</TableRow>
</TableFooter>)
}
</TableComp>
</div>
{/* Editor modal */}
{EditorComponent && editIndex !== null && (
<Dialog open onOpenChange={handleCloseEditor}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t("components.datatable.editor.title")}</DialogTitle>
<DialogDescription>{t("components.datatable.editor.subtitle")}</DialogDescription>
</DialogHeader>
<div className="mt-4">
<EditorComponent
row={data[editIndex]}
index={editIndex}
onClose={handleCloseEditor}
/>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={handleCloseEditor}>
{t("common.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div> </div>
{/* Editor modal */}
{EditorComponent && editIndex !== null && (
<Dialog open onOpenChange={handleCloseEditor}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t("components.datatable.editor.title")}</DialogTitle>
<DialogDescription>{t("components.datatable.editor.subtitle")}</DialogDescription>
</DialogHeader>
<div className="mt-4">
<EditorComponent
row={data[editIndex]}
index={editIndex}
onClose={handleCloseEditor}
/>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={handleCloseEditor}>
{t("common.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div> </div>
) )
} }

View File

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

View File

@ -0,0 +1,69 @@
// SkeletonTableFooter.tsx
/** Skeleton del footer de paginación, imita DataTablePagination */
export function SkeletonDataTableFooter({
pageIndex = 0,
pageSize = 10,
totalItems = 0,
}: {
pageIndex?: number; // 0-based
pageSize?: number;
totalItems?: number;
}) {
// Cálculo de rango (1-based visual)
const start = totalItems > 0 ? pageIndex * pageSize + 1 : 0;
const end = totalItems > 0 ? Math.min(start + pageSize - 1, totalItems) : 0;
return (
<div
role="status"
aria-busy="true"
className="flex items-center justify-between border-t border-border px-4 py-3 bg-background"
>
{/* Izquierda: rango visible */}
<div className="flex flex-col sm:flex-row items-center gap-4 flex-1 text-sm text-muted-foreground">
<span aria-live="polite">
{/* Texto real + shimmer leve para coherencia visual */}
{`Mostrando ${start}${end} de ${totalItems} registros`}
</span>
{/* 'Filas por página' + trigger del select simulado */}
<div className="flex items-center gap-2">
<span>Filas por página</span>
<div
className="h-8 w-20 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none"
aria-hidden
/>
</div>
</div>
{/* Derecha: controles de paginación simulados */}
<div className="flex items-center gap-2">
{/* Primera */}
<div
className="h-8 w-8 rounded-md bg-foreground/10 animate-pulse motion-reduce:animate-none"
aria-hidden
/>
{/* Anterior */}
<div
className="h-8 w-8 rounded-md bg-foreground/10 animate-pulse motion-reduce:animate-none"
aria-hidden
/>
{/* Indicador de página */}
<div className="h-5 w-28 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none" />
{/* Siguiente */}
<div
className="h-8 w-8 rounded-md bg-foreground/10 animate-pulse motion-reduce:animate-none"
aria-hidden
/>
{/* Última */}
<div
className="h-8 w-8 rounded-md bg-foreground/10 animate-pulse motion-reduce:animate-none"
aria-hidden
/>
</div>
<span className="sr-only">Loading pagination</span>
</div>
);
}

View File

@ -0,0 +1,74 @@
import {
TableBody,
TableCell,
Table as TableComp,
TableHead,
TableHeader,
TableRow,
} from "@repo/shadcn-ui/components";
// SkeletonTable.tsx
import * as React from "react";
import { SkeletonDataTableFooter } from './skeleton-data-table-footer.tsx';
export type SkeletonTableProps = {
columns?: number;
rows?: number;
stickyHeader?: boolean;
showFooter?: boolean;
footerProps?: { pageIndex?: number; pageSize?: number; totalItems?: number };
}
// Componente Skeleton de tabla genérico
export const SkeletonDataTable = ({
columns = 6,
rows = 10,
stickyHeader = true,
showFooter = true,
footerProps,
}: SkeletonTableProps) => {
// Genera arrays para mapear
const cols = React.useMemo(() => Array.from({ length: columns }), [columns]);
const rws = React.useMemo(() => Array.from({ length: rows }), [rows]);
return (
<div className="overflow-hidden rounded-md border bg-background" role="status" aria-busy="true">
<TableComp className="w-full text-sm">
<TableHeader className={stickyHeader ? "sticky top-0 z-10 bg-muted" : ""}>
<TableRow>
{cols.map((_, i) => (
<TableHead key={`sk-th-${i}`}>
<div className="h-4 w-24 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none" />
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{rws.map((_, r) => (
<TableRow key={`sk-tr-${r}`}>
{cols.map((_, c) => (
<TableCell key={`sk-td-${r}-${c}`} className="align-top">
<div
className={[
"h-4 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none",
c === 0 ? "w-36" : "w-full",
"my-2",
].join(" ")}
style={{ maxWidth: c % 3 === 0 ? "14rem" : c % 3 === 1 ? "10rem" : "100%" }}
/>
{c > 0 && (
<div className="mt-2 h-3 w-1/2 rounded bg-foreground/10 animate-pulse motion-reduce:animate-none" />
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</TableComp>
{showFooter && <SkeletonDataTableFooter {...footerProps} />}
<span className="sr-only">Loading table</span>
</div>
);
}