/* DataTable — paginated, sortable, optionally expandable list table. * * Backed by @tanstack/react-table v8 in **manual** mode: the parent owns * data fetching, sort + search dispatch, and the page/pageSize state. * The table itself just renders what it's given and emits user-input * callbacks. * * External API is intentionally stable from the previous custom * implementation: * * …} // optional * onSearch={(q) => …} // optional * onRowClick={(row) => …} // optional * isLoading={false} // optional * * // NEW since the TanStack rewrite: * getRowCanExpand={(row) => boolean} // optional, default false * renderSubRow={(row) => <…>} // optional; required when expandable * /> * * The Column shape is the same as before (key/label/sortable/render). * Internally we translate to ColumnDef so existing consumers keep working * without code changes. */ 'use client'; import { useMemo, useState } from 'react'; import { type ColumnDef, type ExpandedState, type Row, type SortingState, flexRender, getCoreRowModel, getExpandedRowModel, useReactTable, } from '@tanstack/react-table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Skeleton } from '@/components/ui/skeleton'; import { EmptyState } from '@/components/ui/empty-state'; import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Download, Loader2, Search, } from 'lucide-react'; import { cn } from '@/lib/utils'; export interface Column { key: string; label: string; sortable?: boolean; render?: (value: any, row: TData) => React.ReactNode; } export interface DataTableProps { columns: Column[]; data: TData[]; totalCount: number; page: number; pageSize: number; onPageChange: (page: number) => void; onSort?: (column: string, direction: 'asc' | 'desc') => void; onSearch?: (query: string) => void; onRowClick?: (row: TData) => void; isLoading?: boolean; /** Per-row gate for expansion. Return true to enable a chevron toggle. */ getRowCanExpand?: (row: TData) => boolean; /** Renders the expanded sub-row body when a row is open. */ renderSubRow?: (row: TData) => React.ReactNode; /** Empty-state slot. Defaults to a neutral "No results" message. */ emptyTitle?: string; emptyDescription?: string; /** Pin the first column when the table scrolls horizontally. Default true. */ stickyFirstColumn?: boolean; /** When true, render an "Export CSV" button. Exports the current page only * unless `onExportAll` is provided (which receives all records server-side). */ exportable?: boolean; /** Filename prefix for the CSV download (e.g. "tickets" → "tickets-2026-05-03.csv"). */ exportFilename?: string; /** Optional async callback for full-result export — called instead of building * the CSV from `data`. Should resolve with the full row set. */ onExportAll?: () => Promise; } export default function DataTable({ columns, data, totalCount, page, pageSize, onPageChange, onSort, onSearch, onRowClick, isLoading = false, getRowCanExpand, renderSubRow, emptyTitle = 'No data found', emptyDescription = 'Try adjusting your search or filters.', stickyFirstColumn = true, exportable = false, exportFilename = 'export', onExportAll, }: DataTableProps) { const [searchQuery, setSearchQuery] = useState(''); const [sorting, setSorting] = useState([]); const [expanded, setExpanded] = useState({}); const expandable = !!renderSubRow; const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); // Translate the legacy Column shape into TanStack ColumnDef. const tanstackColumns = useMemo[]>(() => { const cols: ColumnDef[] = []; // Lead expansion column when expansion is enabled. if (expandable) { cols.push({ id: '__expand', header: () => null, cell: ({ row }) => row.getCanExpand() ? ( ) : null, size: 32, }); } for (const c of columns) { cols.push({ id: c.key, accessorKey: c.key, enableSorting: !!c.sortable, header: c.label, cell: ({ row, getValue }) => c.render ? c.render(getValue(), row.original) : (getValue() as React.ReactNode), }); } return cols; }, [columns, expandable]); const table = useReactTable({ data, columns: tanstackColumns, state: { sorting, expanded }, onSortingChange: (updater) => { const next = typeof updater === 'function' ? updater(sorting) : updater; setSorting(next); // Defer to caller for actual data fetch. if (onSort && next.length > 0) { onSort(next[0].id, next[0].desc ? 'desc' : 'asc'); } }, onExpandedChange: setExpanded, getRowCanExpand: getRowCanExpand ? (row) => getRowCanExpand(row.original) : () => expandable, getCoreRowModel: getCoreRowModel(), getExpandedRowModel: getExpandedRowModel(), manualPagination: true, manualSorting: true, pageCount: totalPages, }); const handleSearch = () => { onSearch?.(searchQuery); }; const [exporting, setExporting] = useState(false); async function handleExport() { setExporting(true); try { const rows = onExportAll ? await onExportAll() : data; const csv = toCsv(columns, rows); const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const today = new Date().toISOString().slice(0, 10); const a = document.createElement('a'); a.href = url; a.download = `${exportFilename}-${today}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } finally { setExporting(false); } } return (
{(onSearch || exportable) && (
{onSearch && ( <>
setSearchQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} className="pl-10" />
)} {exportable && ( )}
)}
{table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const sortable = header.column.getCanSort(); const sortDir = header.column.getIsSorted(); return ( {header.isPlaceholder ? null : sortable ? ( ) : ( flexRender(header.column.columnDef.header, header.getContext()) )} ); })} ))} {isLoading ? ( renderLoadingRows(table) ) : table.getRowModel().rows.length === 0 ? ( ) : ( table.getRowModel().rows.map((row) => ( )) )}
Showing {totalCount === 0 ? 0 : Math.min((page - 1) * pageSize + 1, totalCount)} {' '} to {Math.min(page * pageSize, totalCount)} {' '} of {totalCount} results
Page {page} of {totalPages}
); } function ExpandableRow({ row, onRowClick, renderSubRow, colSpan, }: { row: Row; onRowClick?: (row: TData) => void; renderSubRow?: (row: TData) => React.ReactNode; colSpan: number; }) { const clickable = !!onRowClick; return ( <> onRowClick?.(row.original)} > {row.getVisibleCells().map((cell) => ( {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} {row.getIsExpanded() && renderSubRow && ( {renderSubRow(row.original)} )} ); } function SortIcon({ dir }: { dir: 'asc' | 'desc' | null }) { if (dir === 'asc') return ; if (dir === 'desc') return ; return ; } function renderLoadingRows(table: ReturnType>) { const cols = table.getAllLeafColumns().length; return Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: cols }).map((_, j) => ( ))} )); } /** * Build a CSV string from columns + rows. Cell values come from * `row[col.key]` (raw, not column.render — render returns ReactNode * which doesn't serialize). Quotes are doubled, fields containing * comma / quote / newline are wrapped in quotes; everything else is * left bare. Booleans render true/false, null/undefined render empty. */ function toCsv(columns: Column[], rows: T[]): string { const escape = (v: unknown): string => { if (v === null || v === undefined) return ''; const s = typeof v === 'object' ? JSON.stringify(v) : String(v); return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; const header = columns.map((c) => escape(c.label)).join(','); const body = rows .map((row) => columns.map((c) => escape((row as Record)[c.key])).join(','), ) .join('\n'); // BOM so Excel reads UTF-8 correctly. return '' + header + '\n' + body + '\n'; }