'use client'; import { useState, useMemo } from 'react'; import { EmptyState } from './empty-state'; import { TableSkeleton } from './skeleton'; interface Column { key: string; label: string; sortable?: boolean; align?: 'left' | 'right' | 'center'; render: (item: T) => React.ReactNode; } interface FilterOption { label: string; value: string; } interface QuickFilter { key: string; label: string; options: FilterOption[]; } interface DataTableProps { columns: Column[]; data: T[]; loading?: boolean; emptyTitle?: string; emptyDescription?: string; keyExtractor: (item: T) => string; searchPlaceholder?: string; searchValue?: string; onSearchChange?: (value: string) => void; quickFilters?: QuickFilter[]; activeFilters?: Record; onFilterChange?: (key: string, value: string) => void; pageSize?: number; onRowClick?: (item: T) => void; maxHeight?: string; headerActions?: React.ReactNode; } const PAGE_SIZES = [10, 20, 50, 100]; export function DataTable({ columns, data, loading, emptyTitle = 'No data', emptyDescription, keyExtractor, searchPlaceholder, searchValue, onSearchChange, quickFilters, activeFilters, onFilterChange, pageSize: initialPageSize = 20, onRowClick, maxHeight, headerActions, }: DataTableProps) { const [sortKey, setSortKey] = useState(null); const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc'); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(initialPageSize); function handleSort(key: string) { if (sortKey === key) setSortDir(sortDir === 'asc' ? 'desc' : 'asc'); else { setSortKey(key); setSortDir('asc'); } setPage(1); } const sortedData = useMemo(() => { if (!sortKey) return data; return [...data].sort((a, b) => { const aVal = (a as any)[sortKey]; const bVal = (b as any)[sortKey]; if (aVal == null) return 1; if (bVal == null) return -1; const cmp = typeof aVal === 'string' ? aVal.localeCompare(bVal) : aVal - bVal; return sortDir === 'asc' ? cmp : -cmp; }); }, [data, sortKey, sortDir]); const totalPages = Math.ceil(sortedData.length / perPage); const paginatedData = sortedData.slice((page - 1) * perPage, page * perPage); const startItem = sortedData.length === 0 ? 0 : (page - 1) * perPage + 1; const endItem = Math.min(page * perPage, sortedData.length); // Reset page when data changes if (page > totalPages && totalPages > 0) setPage(totalPages); function getPageNumbers(): (number | '...')[] { if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1); const pages: (number | '...')[] = [1]; if (page > 3) pages.push('...'); for (let i = Math.max(2, page - 1); i <= Math.min(totalPages - 1, page + 1); i++) pages.push(i); if (page < totalPages - 2) pages.push('...'); if (totalPages > 1) pages.push(totalPages); return pages; } if (loading) return ; return (
{/* Search + Quick Filters + Header Actions row */}
{onSearchChange && (
{ onSearchChange(e.target.value); setPage(1); }} placeholder={searchPlaceholder || 'Search...'} aria-label={searchPlaceholder || 'Search'} className="w-64 pl-9 pr-3 py-2 rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-sm text-surface-900 dark:text-surface-200 placeholder:text-surface-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 transition-all duration-200" />
)} {/* Quick filter chips */} {quickFilters && onFilterChange && quickFilters.map((filter) => (
{filter.label}: {filter.options.map((opt) => ( ))}
))}
{headerActions &&
{headerActions}
}
{sortedData.length === 0 ? (
} />
) : ( <>
{/* Scrollable table body with sticky header */}
{columns.map((col) => ( ))} {paginatedData.map((item) => ( onRowClick(item) : undefined} > {columns.map((col) => ( ))} ))}
handleSort(col.key) : undefined} aria-sort={sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined}> {col.label} {col.sortable && sortKey === col.key && ( {sortDir === 'asc' ? : } )}
{col.render(item)}
{/* Pagination controls — always visible */}
Showing {startItem}-{endItem} of {sortedData.length}
{totalPages > 1 && ( )}
)}
); }