309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
ColumnDef,
|
|
ColumnFiltersState,
|
|
ColumnSizingState,
|
|
Row,
|
|
SortingState,
|
|
Table,
|
|
TableMeta,
|
|
VisibilityState,
|
|
flexRender,
|
|
getCoreRowModel,
|
|
getFacetedRowModel,
|
|
getFacetedUniqueValues,
|
|
getFilteredRowModel,
|
|
getPaginationRowModel,
|
|
getSortedRowModel,
|
|
useReactTable
|
|
} from "@tanstack/react-table"
|
|
import * as React from "react"
|
|
|
|
import {
|
|
Button,
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
TableBody,
|
|
TableCell,
|
|
Table as TableComp,
|
|
TableFooter,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow
|
|
} from '@repo/shadcn-ui/components'
|
|
import { DataTablePagination } from './data-table-pagination.tsx'
|
|
import { DataTableToolbar } from "./data-table-toolbar.tsx"
|
|
|
|
import { useTranslation } from "../../locales/i18n.ts"
|
|
|
|
export type DataTableOps<TData> = {
|
|
onAdd?: (table: Table<TData>) => void;
|
|
}
|
|
|
|
export type DataTableRowOps<TData> = {
|
|
duplicate?(index: number, table: Table<TData>): void;
|
|
remove?(index: number, table: Table<TData>): void;
|
|
move?(from: number, to: number, table: Table<TData>): void;
|
|
canMoveUp?(index: number, table: Table<TData>): boolean;
|
|
canMoveDown?(index: number, lastIndex: number, table: Table<TData>): boolean;
|
|
};
|
|
|
|
export type DataTableBulkRowOps<TData> = {
|
|
duplicateSelected?: (indexes: number[], table: Table<TData>) => void;
|
|
removeSelected?: (indexes: number[], table: Table<TData>) => void;
|
|
moveSelectedUp?: (indexes: number[], table: Table<TData>) => void;
|
|
moveSelectedDown?: (indexes: number[], table: Table<TData>) => void;
|
|
};
|
|
|
|
export type DataTableMeta<TData> = TableMeta<TData> & {
|
|
totalItems?: number; // para paginación server-side
|
|
readOnly?: boolean;
|
|
|
|
tableOps?: DataTableOps<TData>
|
|
rowOps?: DataTableRowOps<TData>
|
|
bulkOps?: DataTableBulkRowOps<TData>
|
|
}
|
|
|
|
export interface DataTableProps<TData, TValue> {
|
|
columns: ColumnDef<TData, TValue>[]
|
|
data: TData[]
|
|
meta?: DataTableMeta<TData>
|
|
|
|
// Configuración
|
|
readOnly?: boolean
|
|
enablePagination?: boolean
|
|
pageSize?: number
|
|
enableRowSelection?: boolean
|
|
EditorComponent?: React.ComponentType<{ row: TData; index: number; onClose: () => void }>
|
|
|
|
getRowId?: (originalRow: TData, index: number, parent?: Row<TData>) => string;
|
|
|
|
// Soporte para paginación server-side
|
|
manualPagination?: boolean;
|
|
pageIndex?: number; // 0-based
|
|
totalItems?: number;
|
|
onPageChange?: (pageIndex: number) => void;
|
|
onPageSizeChange?: (pageSize: number) => void;
|
|
|
|
// Acción al hacer click en una fila
|
|
onRowClick?: (row: TData, index: number, event: React.MouseEvent<HTMLTableRowElement>) => void;
|
|
}
|
|
|
|
export function DataTable<TData, TValue>({
|
|
columns,
|
|
data,
|
|
meta,
|
|
|
|
readOnly = false,
|
|
enablePagination = true,
|
|
pageSize = 10,
|
|
enableRowSelection = false,
|
|
EditorComponent,
|
|
|
|
getRowId,
|
|
|
|
manualPagination,
|
|
pageIndex = 0,
|
|
totalItems,
|
|
onPageChange,
|
|
onPageSizeChange,
|
|
|
|
onRowClick,
|
|
}: DataTableProps<TData, TValue>) {
|
|
const { t } = useTranslation();
|
|
|
|
const [rowSelection, setRowSelection] = React.useState({});
|
|
const [sorting, setSorting] = React.useState<SortingState>([]);
|
|
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
|
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]);
|
|
const [colSizes, setColSizes] = React.useState<ColumnSizingState>({});
|
|
const [editIndex, setEditIndex] = React.useState<number | null>(null);
|
|
|
|
// Configuración TanStack
|
|
const table = useReactTable({
|
|
data,
|
|
columns,
|
|
columnResizeMode: "onChange",
|
|
onColumnSizingChange: setColSizes,
|
|
meta: { ...meta, totalItems, readOnly },
|
|
|
|
getRowId:
|
|
getRowId ??
|
|
((originalRow: TData, i: number) =>
|
|
typeof (originalRow as any).id !== "undefined"
|
|
? String((originalRow as any).id)
|
|
: String(i)),
|
|
|
|
state: {
|
|
columnSizing: colSizes,
|
|
sorting,
|
|
columnVisibility,
|
|
rowSelection,
|
|
columnFilters,
|
|
pagination: { pageIndex, pageSize },
|
|
},
|
|
|
|
manualPagination,
|
|
pageCount: manualPagination
|
|
? Math.ceil((totalItems ?? data.length) / (pageSize ?? 25))
|
|
: undefined,
|
|
|
|
// Propagar cambios al padre
|
|
onPaginationChange: (updater) => {
|
|
const next = typeof updater === "function"
|
|
? updater({ pageIndex, pageSize })
|
|
: updater;
|
|
|
|
if (typeof next.pageIndex === "number") onPageChange?.(next.pageIndex);
|
|
if (typeof next.pageSize === "number") onPageSizeChange?.(next.pageSize);
|
|
},
|
|
|
|
enableRowSelection,
|
|
onRowSelectionChange: setRowSelection,
|
|
onSortingChange: setSorting,
|
|
onColumnFiltersChange: setColumnFilters,
|
|
onColumnVisibilityChange: setColumnVisibility,
|
|
|
|
getCoreRowModel: getCoreRowModel(),
|
|
getFilteredRowModel: getFilteredRowModel(),
|
|
getPaginationRowModel: manualPagination ? undefined : getPaginationRowModel(),
|
|
getSortedRowModel: getSortedRowModel(),
|
|
getFacetedRowModel: getFacetedRowModel(),
|
|
getFacetedUniqueValues: getFacetedUniqueValues(),
|
|
})
|
|
|
|
const handleCloseEditor = React.useCallback(() => setEditIndex(null), [])
|
|
|
|
// Render principal
|
|
return (
|
|
<div
|
|
className="transition-[max-height] duration-300 ease-in-out"
|
|
>
|
|
<div className="flex flex-col gap-0">
|
|
<DataTableToolbar table={table} showViewOptions={!readOnly} />
|
|
|
|
<div className="overflow-hidden rounded-md border">
|
|
<TableComp className="w-full text-sm">
|
|
{/* CABECERA */}
|
|
<TableHeader className="sticky top-0 z-10 bg-muted">
|
|
{table.getHeaderGroups().map((hg) => (
|
|
<TableRow key={hg.id}>
|
|
{hg.headers.map((h) => {
|
|
const w = h.getSize();
|
|
const minW = h.column.columnDef.minSize;
|
|
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);
|
|
}}
|
|
onClick={(e) => onRowClick?.(row.original, rowIndex, e)}
|
|
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>
|
|
)
|
|
} |