"use client"; import React, { useState, useCallback, useMemo, useRef, useEffect } from "react"; import { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, getPaginationRowModel, flexRender, type ColumnDef, type SortingState, type ColumnFiltersState, type VisibilityState, type Row, type OnChangeFn, type PaginationState, } from "@tanstack/react-table"; import { DndContext, closestCenter, type DragEndEvent, PointerSensor, useSensor, useSensors, } from "@dnd-kit/core"; import { SortableContext, horizontalListSortingStrategy, useSortable, arrayMove, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { rankItem } from "@tanstack/match-sorter-utils"; /* ─── Types ─── */ export type RowAction = { label: string; onClick?: (row: TData) => void; icon?: React.ReactNode; variant?: "default" | "destructive"; }; export type DataTableProps = { columns: ColumnDef[]; data: TData[]; loading?: boolean; // search searchPlaceholder?: string; enableGlobalFilter?: boolean; // sorting enableSorting?: boolean; // row selection enableRowSelection?: boolean; rowSelection?: Record; onRowSelectionChange?: OnChangeFn>; bulkActions?: React.ReactNode; // column features enableColumnReordering?: boolean; onColumnReorder?: (newOrder: string[]) => void; initialColumnVisibility?: VisibilityState; // pagination pageSize?: number; // actions onRefresh?: () => void; onAdd?: () => void; addButtonLabel?: string; onRowClick?: (row: TData, index: number) => void; rowActions?: (row: TData) => RowAction[]; // toolbar toolbarExtra?: React.ReactNode; title?: string; titleIcon?: React.ReactNode; // sticky stickyFirstColumn?: boolean; // server-side pagination serverPagination?: { totalCount: number; page: number; pageSize: number; onPageChange: (page: number) => void; onPageSizeChange: (size: number) => void; }; // server-side search callback (replaces client-side fuzzy filter) onServerSearch?: (query: string) => void; }; /* ─── Fuzzy filter ─── */ function fuzzyFilter( row: Row, columnId: string, filterValue: string, ) { const result = rankItem(row.getValue(columnId), filterValue); return result.passed; } /* ─── Sortable header cell (DnD) ─── */ function SortableHeader({ id, children, style, className, }: { id: string; children: React.ReactNode; style?: React.CSSProperties; className?: string; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id }); const dragStyle: React.CSSProperties = { ...style, transform: CSS.Translate.toString(transform), transition, opacity: isDragging ? 0.5 : 1, cursor: "grab", }; return ( {children} ); } /* ─── Sort icon ─── */ function SortIcon({ direction }: { direction: "asc" | "desc" | false }) { if (!direction) { return ( ); } return ( {direction === "asc" ? : } ); } /* ─── Main component ─── */ export function DataTable({ columns, data, loading = false, searchPlaceholder = "Search...", enableGlobalFilter = true, enableSorting = true, enableRowSelection = false, rowSelection: externalRowSelection, onRowSelectionChange, bulkActions, enableColumnReordering = false, onColumnReorder, initialColumnVisibility, pageSize: defaultPageSize = 100, onRefresh, onAdd, addButtonLabel = "+ Add", onRowClick, rowActions, toolbarExtra, title, titleIcon, stickyFirstColumn: stickyFirstProp = true, serverPagination, onServerSearch, }: DataTableProps) { const [sorting, setSorting] = useState([]); const [globalFilter, setGlobalFilter] = useState(""); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState(initialColumnVisibility ?? {}); // Sync column visibility when the prop changes (e.g. loading a saved view) useEffect(() => { if (initialColumnVisibility) { setColumnVisibility(initialColumnVisibility); } }, [initialColumnVisibility]); const [internalRowSelection, setInternalRowSelection] = useState>({}); const [showColumnsMenu, setShowColumnsMenu] = useState(false); const [stickyFirstColumn, setStickyFirstColumn] = useState(stickyFirstProp); const [isScrolled, setIsScrolled] = useState(false); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: defaultPageSize }); const columnsMenuRef = useRef(null); const scrollContainerRef = useRef(null); const rowSelectionState = externalRowSelection !== undefined ? externalRowSelection : internalRowSelection; // Extract column ID from ColumnDef const getColumnId = useCallback((c: ColumnDef): string => { if ("id" in c && typeof c.id === "string") {return c.id;} if ("accessorKey" in c && typeof c.accessorKey === "string") {return c.accessorKey;} return ""; }, []); // Column order for DnD — include "select" at start and "actions" at end // so TanStack doesn't push them to the end of the table const buildColumnOrder = useCallback( (dataCols: ColumnDef[]) => { const dataOrder = dataCols.map(getColumnId); const order: string[] = []; if (enableRowSelection) {order.push("select");} order.push(...dataOrder); if (rowActions) {order.push("actions");} return order; }, [getColumnId, enableRowSelection, rowActions], ); const [columnOrder, setColumnOrder] = useState(() => buildColumnOrder(columns), ); // Update column order when columns change useEffect(() => { setColumnOrder(buildColumnOrder(columns)); }, [columns, buildColumnOrder]); // DnD sensors const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), ); const handleDragEnd = useCallback( (event: DragEndEvent) => { const { active, over } = event; if (over && active.id !== over.id) { setColumnOrder((old) => { const oldIndex = old.indexOf(active.id as string); const newIndex = old.indexOf(over.id as string); const newOrder = arrayMove(old, oldIndex, newIndex); onColumnReorder?.(newOrder); return newOrder; }); } }, [onColumnReorder], ); // Scroll tracking for sticky column shadow const handleScroll = useCallback((e: React.UIEvent) => { setIsScrolled(e.currentTarget.scrollLeft > 0); }, []); // Close columns menu on click outside useEffect(() => { function handleClick(e: MouseEvent) { if (columnsMenuRef.current && !columnsMenuRef.current.contains(e.target as Node)) { setShowColumnsMenu(false); } } if (showColumnsMenu) { document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); } }, [showColumnsMenu]); // Build selection column const selectionColumn: ColumnDef | null = enableRowSelection ? { id: "select", header: ({ table }) => ( ), cell: ({ row }) => ( e.stopPropagation()} className="w-3.5 h-3.5 rounded accent-[var(--color-accent)] cursor-pointer" /> ), size: 40, enableSorting: false, enableHiding: false, } : null; // Build actions column const actionsColumn: ColumnDef | null = rowActions ? { id: "actions", header: () => null, cell: ({ row }) => ( ), size: 48, enableSorting: false, enableHiding: false, } : null; const allColumns = useMemo(() => { const cols: ColumnDef[] = []; if (selectionColumn) {cols.push(selectionColumn as ColumnDef);} cols.push(...columns); if (actionsColumn) {cols.push(actionsColumn as ColumnDef);} return cols; }, [columns, selectionColumn, actionsColumn]); // Server-side pagination state derived from props const serverPaginationState = serverPagination ? { pageIndex: serverPagination.page - 1, pageSize: serverPagination.pageSize } : undefined; const table = useReactTable({ data, columns: allColumns, state: { sorting, globalFilter, columnFilters, columnVisibility, rowSelection: rowSelectionState, columnOrder: enableColumnReordering ? columnOrder : undefined, pagination: serverPaginationState ?? pagination, }, onSortingChange: setSorting, onGlobalFilterChange: setGlobalFilter, onColumnFiltersChange: setColumnFilters, onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: (updater) => { if (onRowSelectionChange) { onRowSelectionChange(updater); } else { setInternalRowSelection(updater); } }, onPaginationChange: serverPagination ? (updater) => { const newVal = typeof updater === "function" ? updater(serverPaginationState!) : updater; if (newVal.pageSize !== serverPagination.pageSize) { serverPagination.onPageSizeChange(newVal.pageSize); } else if (newVal.pageIndex !== serverPagination.page - 1) { serverPagination.onPageChange(newVal.pageIndex + 1); } } : setPagination, getCoreRowModel: getCoreRowModel(), getSortedRowModel: enableSorting ? getSortedRowModel() : undefined, getFilteredRowModel: serverPagination ? undefined : getFilteredRowModel(), getPaginationRowModel: serverPagination ? undefined : getPaginationRowModel(), ...(serverPagination ? { manualPagination: true, pageCount: Math.ceil(serverPagination.totalCount / serverPagination.pageSize), } : {}), enableRowSelection, enableSorting, globalFilterFn: fuzzyFilter, columnResizeMode: "onChange", }); const selectedCount = Object.keys(rowSelectionState).filter((k) => rowSelectionState[k]).length; const visibleColumns = table.getVisibleFlatColumns().filter((c) => c.id !== "select" && c.id !== "actions"); // ─── Render ─── return (
{/* Toolbar */}
{title && (
{titleIcon} {title}
)} {/* Search */} {enableGlobalFilter && (
{ setGlobalFilter(e.target.value); onServerSearch?.(e.target.value); }} placeholder={searchPlaceholder} className="w-full pl-9 pr-3 py-1.5 text-xs rounded-full outline-none" style={{ background: "var(--color-surface-hover)", color: "var(--color-text)", border: "1px solid var(--color-border)", }} /> {globalFilter && ( )}
)} {/* Bulk actions */} {selectedCount > 0 && bulkActions && (
{selectedCount} selected {bulkActions}
)}
{toolbarExtra} {/* Refresh */} {onRefresh && ( )} {/* Columns menu */}
{showColumnsMenu && (
{/* Sticky first col toggle */} {visibleColumns.length === 0 ? (
No toggleable columns
) : ( table.getAllLeafColumns() .filter((c) => c.id !== "select" && c.id !== "actions" && c.getCanHide()) .map((column) => ( )) )}
)}
{/* Add button */} {onAdd && ( )}
{/* Table */}
{loading ? ( ) : data.length === 0 ? (

No data

) : ( {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header, colIdx) => { const isFirstData = colIdx === (enableRowSelection ? 1 : 0); const isSticky = stickyFirstColumn && isFirstData; const isSelectCol = header.id === "select"; const isActionsCol = header.id === "actions"; const canSort = header.column.getCanSort(); const headerStyle: React.CSSProperties = { borderColor: "var(--color-border)", background: "var(--color-surface)", position: "sticky", top: 0, zIndex: isSticky || isSelectCol ? 31 : 30, ...(isSticky ? { left: enableRowSelection ? 40 : 0, boxShadow: isScrolled ? "4px 0 8px -2px rgba(0,0,0,0.08)" : "none" } : {}), ...(isSelectCol ? { left: 0, position: "sticky", zIndex: 31, width: 40 } : {}), width: header.getSize(), }; const content = header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()); if (enableColumnReordering && !isSelectCol && !isActionsCol) { return ( {content} {canSort && } ); } return ( ); })} ))} {table.getRowModel().rows.map((row, rowIdx) => { const isSelected = row.getIsSelected(); return ( onRowClick?.(row.original, rowIdx)} onMouseEnter={(e) => { if (!isSelected) {(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";} }} onMouseLeave={(e) => { if (!isSelected) {(e.currentTarget as HTMLElement).style.background = rowIdx % 2 === 0 ? "transparent" : "var(--color-surface)";} }} > {row.getVisibleCells().map((cell, colIdx) => { const isFirstData = colIdx === (enableRowSelection ? 1 : 0); const isSticky = stickyFirstColumn && isFirstData; const isSelectCol = cell.column.id === "select"; const cellStyle: React.CSSProperties = { borderColor: "var(--color-border)", ...(isSticky ? { position: "sticky" as const, left: enableRowSelection ? 40 : 0, zIndex: 20, background: isSelected ? "var(--color-accent-light)" : "var(--color-bg)", boxShadow: isScrolled ? "4px 0 8px -2px rgba(0,0,0,0.08)" : "none", } : {}), ...(isSelectCol ? { position: "sticky" as const, left: 0, zIndex: 20, background: isSelected ? "var(--color-accent-light)" : "var(--color-bg)", width: 40, } : {}), }; return ( ); })} ); })}
{content} {canSort && }
{flexRender(cell.column.columnDef.cell, cell.getContext())}
)}
{/* Pagination footer */} {!loading && data.length > 0 && (
{serverPagination ? `Showing ${(serverPagination.page - 1) * serverPagination.pageSize + 1}–${Math.min(serverPagination.page * serverPagination.pageSize, serverPagination.totalCount)} of ${serverPagination.totalCount} results` : `Showing ${table.getRowModel().rows.length} of ${data.length} results`} {selectedCount > 0 && ` (${selectedCount} selected)`}
Rows per page Page {serverPagination ? serverPagination.page : pagination.pageIndex + 1} of {table.getPageCount()}
{serverPagination ? ( <> serverPagination.onPageChange(1)} disabled={serverPagination.page <= 1} label="«" /> serverPagination.onPageChange(serverPagination.page - 1)} disabled={serverPagination.page <= 1} label="‹" /> serverPagination.onPageChange(serverPagination.page + 1)} disabled={serverPagination.page >= Math.ceil(serverPagination.totalCount / serverPagination.pageSize)} label="›" /> serverPagination.onPageChange(Math.ceil(serverPagination.totalCount / serverPagination.pageSize))} disabled={serverPagination.page >= Math.ceil(serverPagination.totalCount / serverPagination.pageSize)} label="»" /> ) : ( <> setPagination((p) => ({ ...p, pageIndex: 0 }))} disabled={!table.getCanPreviousPage()} label="«" /> table.previousPage()} disabled={!table.getCanPreviousPage()} label="‹" /> table.nextPage()} disabled={!table.getCanNextPage()} label="›" /> setPagination((p) => ({ ...p, pageIndex: table.getPageCount() - 1 }))} disabled={!table.getCanNextPage()} label="»" /> )}
)}
); } /* ─── Sub-components ─── */ function PaginationButton({ onClick, disabled, label }: { onClick: () => void; disabled: boolean; label: string }) { return ( {open && (
{actions.map((action, i) => ( ))}
)}
); } function LoadingSkeleton({ columnCount }: { columnCount: number }) { return (
{Array.from({ length: 12 }).map((_, i) => (
{Array.from({ length: Math.min(columnCount, 6) }).map((_col, j) => (
))}
))}
); }