import { useState, useMemo, type ReactNode } from 'react'; import { ChevronUp, ChevronDown, ChevronsUpDown } from 'lucide-react'; export interface Column { key: string; header: string; render?: (row: T) => ReactNode; sortable?: boolean; className?: string; } interface Props { data: T[]; columns: Column[]; keyField: keyof T & string; pageSize?: number; onRowClick?: (row: T) => void; filterFn?: (row: T, query: string) => boolean; emptyMessage?: string; footerRow?: ReactNode; } export function DataTable({ data, columns, keyField, pageSize = 25, onRowClick, filterFn, emptyMessage = 'No data', footerRow, }: Props) { const [sortKey, setSortKey] = useState(null); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const [page, setPage] = useState(0); const [filter, setFilter] = useState(''); const filtered = useMemo(() => { if (!filter || !filterFn) return data; return data.filter((row) => filterFn(row, filter)); }, [data, filter, filterFn]); const sorted = useMemo(() => { if (!sortKey) return filtered; return [...filtered].sort((a, b) => { const av = (a as Record)[sortKey]; const bv = (b as Record)[sortKey]; if (av == null && bv == null) return 0; if (av == null) return 1; if (bv == null) return -1; const cmp = av < bv ? -1 : av > bv ? 1 : 0; return sortDir === 'asc' ? cmp : -cmp; }); }, [filtered, sortKey, sortDir]); const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize)); const paged = sorted.slice(page * pageSize, (page + 1) * pageSize); function toggleSort(key: string) { if (sortKey === key) { setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); } else { setSortKey(key); setSortDir('asc'); } } return (
{filterFn && ( { setFilter(e.target.value); setPage(0); }} className="mb-3 w-full max-w-xs rounded-md border border-surface-700 bg-surface-900 px-3 py-1.5 text-sm text-gray-200 placeholder-gray-500 focus:border-brand-500 focus:outline-none" aria-label="Filter table" /> )}
{columns.map((col) => ( ))} {paged.length === 0 ? ( ) : ( paged.map((row) => ( )[keyField])} className={`border-b border-surface-700/50 ${onRowClick ? 'cursor-pointer hover:bg-surface-800/50' : ''}`} onClick={() => onRowClick?.(row)} > {columns.map((col) => ( ))} )) )} {footerRow && ( {footerRow} )}
col.sortable !== false && toggleSort(col.key)} aria-sort={sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined} > {col.header} {col.sortable !== false && ( sortKey === col.key ? (sortDir === 'asc' ? : ) : )}
{emptyMessage}
{col.render ? col.render(row) : String((row as Record)[col.key] ?? '—')}
{totalPages > 1 && (
{sorted.length} rows
{page + 1} / {totalPages}
)}
); }