"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"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuCheckboxItem, } from "../ui/dropdown-menu"; import { cn } from "@/lib/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; onColumnVisibilityChanged?: (visibility: VisibilityState) => void; // 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, onColumnVisibilityChanged, 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(() => { setColumnVisibility(initialColumnVisibility ?? {}); }, [initialColumnVisibility]); const [internalRowSelection, setInternalRowSelection] = useState>({}); const [stickyFirstColumn, setStickyFirstColumn] = useState(stickyFirstProp); const [isScrolled, setIsScrolled] = useState(false); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: defaultPageSize }); 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); }, []); // 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: (updater) => { const next = typeof updater === "function" ? updater(columnVisibility) : updater; setColumnVisibility(next); onColumnVisibilityChanged?.(next); }, 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 h-full text-xs bg-transparent outline-none border-0 p-0" style={{ color: "var(--color-text)" }} /> {globalFilter && ( )}
)} {/* Bulk actions */} {selectedCount > 0 && bulkActions && (
{selectedCount} selected {bulkActions}
)}
{toolbarExtra} {/* Columns menu */} Columns setStickyFirstColumn((v) => !v)} > Freeze first column {visibleColumns.length === 0 ? (
No toggleable columns
) : ( table.getAllLeafColumns() .filter((c) => c.id !== "select" && c.id !== "actions" && c.getCanHide()) .map((column) => ( column.toggleVisibility(!column.getIsVisible())} > {typeof column.columnDef.header === "string" ? column.columnDef.header : String((column.columnDef.meta as Record | undefined)?.label ?? column.id)} )) )}
{/* Refresh button */} {onRefresh && ( )} {/* Add button */} {onAdd && ( )}
{/* Table */}
{loading ? ( ) : data.length === 0 ? (

No results found

{globalFilter ? "Try adjusting your search or filter criteria." : "No data available yet. Create your first entry to get started."}

) : ( {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 isSorted = header.column.getIsSorted(); const isLastCol = colIdx === headerGroup.headers.length - 1; const headerStyle: React.CSSProperties = { background: "var(--color-surface)", position: "sticky", top: 0, zIndex: isSticky || isSelectCol ? 31 : 30, ...(isSticky ? { left: enableRowSelection ? 40 : 0, boxShadow: isScrolled ? "4px 0 12px -2px rgba(0,0,0,0.15), 2px 0 4px -1px 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()); const thClassName = cn( "h-11 text-left align-middle font-medium text-xs uppercase tracking-wider whitespace-nowrap p-0 group select-none relative box-border", !isLastCol && "border-r", isSticky && isScrolled && "border-r-2!", ); const innerClassName = cn( "flex items-center gap-1 h-full px-4 transition-colors", canSort && "cursor-pointer hover:bg-[var(--color-surface-hover)]", isSorted && "bg-[var(--color-surface-hover)]", ); if (enableColumnReordering && !isSelectCol && !isActionsCol) { return ( {content} {canSort && } {isSticky && isScrolled && (
)} ); } return (
); })} ))} {table.getRowModel().rows.map((row, rowIdx) => { const isSelected = row.getIsSelected(); const visibleCells = row.getVisibleCells(); 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 ? "var(--color-surface)" : "var(--color-bg)";} }} > {visibleCells.map((cell, colIdx) => { const isFirstData = colIdx === (enableRowSelection ? 1 : 0); const isSticky = stickyFirstColumn && isFirstData; const isSelectCol = cell.column.id === "select"; const isLastCol = colIdx === visibleCells.length - 1; const rowBg = isSelected ? "var(--color-accent-light)" : rowIdx % 2 === 0 ? "var(--color-surface)" : "var(--color-bg)"; const cellStyle: React.CSSProperties = { borderColor: "var(--color-border)", ...(isSticky ? { position: "sticky" as const, left: enableRowSelection ? 40 : 0, zIndex: 20, background: rowBg, boxShadow: isScrolled ? "4px 0 12px -2px rgba(0,0,0,0.12), 2px 0 4px -1px rgba(0,0,0,0.06)" : "none", } : {}), ...(isSelectCol ? { position: "sticky" as const, left: 0, zIndex: 20, background: rowBg, width: 40, } : {}), }; return ( ); })} ); })}
{content} {canSort && } {isSticky && isScrolled && (
)}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{isSticky && isScrolled && (
)}
)}
{/* 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 (