import * as React from 'react'; import { ArrowDown, ArrowUp, ChevronsUpDown, Inbox } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Surface } from '@/components/ds/Surface'; import { EmptyState } from '@/components/ds/EmptyState'; import { SkeletonTable } from '@/components/ds/Skeleton'; import { LoadingOverlay } from '@/components/ds/LoadingState'; import { Pagination } from '@/components/ds/Pagination'; import { Checkbox } from '@/components/ui/checkbox'; import { usePreferences } from '@/lib/krowHooks'; /** * DataTable — the app's one table. * * Columns are declared as data: * * { key, header, accessor?, sortValue?, cell?, align?, width?, sortable?, hideBelow? } * * Sorting, selection and pagination are handled internally by default, but each * can be lifted by passing the corresponding value + handler — so the same * component serves a simple static list and a server-paginated view. * * `hideBelow: 'md'` drops a column on small screens, which is what keeps the * table usable on mobile without a horizontal scroll for every view. */ export function DataTable({ columns = [], rows = [], /** Stable row identity. Falls back to `row.id`, then the index. */ getRowId = (row, i) => row?.id ?? i, loading = false, /** Dims existing rows instead of replacing them — for refetches. */ refreshing = false, /* Sorting — omit `sort` to let the table own it */ sort: controlledSort, onSortChange, defaultSort, /* Selection — pass `selectedIds` to enable */ selectedIds, onSelectionChange, /* Pagination — pass `pageSize` to enable */ pageSize: initialPageSize, page: controlledPage, onPageChange, totalItems, pageSizeOptions, onRowClick, emptyState, /** True when filters are active, so the empty copy can say so. */ isFiltered = false, onClearFilters, caption, className, rowClassName, stickyHeader = false, }) { /* ── Sorting ─────────────────────────────────────────────────────────── */ /* Row height follows the account's density preference. Only the vertical padding changes — same columns, same type, same behaviour. */ const { compactDensity: compact } = usePreferences(); const cellPad = compact ? 'py-1.5' : 'py-3'; const [internalSort, setInternalSort] = React.useState(defaultSort ?? null); const sort = controlledSort !== undefined ? controlledSort : internalSort; const setSort = (next) => { if (onSortChange) onSortChange(next); if (controlledSort === undefined) setInternalSort(next); }; const toggleSort = (column) => { if (sort?.key !== column.key) return setSort({ key: column.key, direction: 'asc' }); if (sort.direction === 'asc') return setSort({ key: column.key, direction: 'desc' }); // Third click clears sorting, so the original order is reachable. return setSort(null); }; const valueFor = (column, row) => { if (column.sortValue) return column.sortValue(row); if (column.accessor) return column.accessor(row); return row?.[column.key]; }; const sortedRows = React.useMemo(() => { // A controlled `sort` means the parent already ordered the rows. if (!sort || controlledSort !== undefined) return rows; const column = columns.find((c) => c.key === sort.key); if (!column) return rows; const factor = sort.direction === 'desc' ? -1 : 1; return [...rows].sort((a, b) => { const av = valueFor(column, a); const bv = valueFor(column, b); if (av == null && bv == null) return 0; if (av == null) return 1; if (bv == null) return -1; if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * factor; return String(av).localeCompare(String(bv)) * factor; }); }, [rows, sort, columns, controlledSort]); /* ── Pagination ──────────────────────────────────────────────────────── */ const paginated = initialPageSize != null; const [internalPage, setInternalPage] = React.useState(1); const [pageSize, setPageSize] = React.useState(initialPageSize ?? 25); const page = controlledPage !== undefined ? controlledPage : internalPage; const setPage = (next) => { if (onPageChange) onPageChange(next); if (controlledPage === undefined) setInternalPage(next); }; // Snap back to page 1 whenever the result set changes underneath us, // otherwise filtering to 3 rows while on page 4 shows an empty table. const rowCount = rows.length; React.useEffect(() => { if (controlledPage === undefined) setInternalPage(1); }, [rowCount, sort, controlledPage]); const serverPaginated = totalItems != null && onPageChange != null; const pageCount = paginated ? Math.max(1, Math.ceil((totalItems ?? sortedRows.length) / pageSize)) : 1; const visibleRows = React.useMemo(() => { if (!paginated || serverPaginated) return sortedRows; const start = (page - 1) * pageSize; return sortedRows.slice(start, start + pageSize); }, [sortedRows, paginated, serverPaginated, page, pageSize]); /* ── Selection ───────────────────────────────────────────────────────── */ const selectable = selectedIds !== undefined; const selected = React.useMemo(() => new Set(selectedIds || []), [selectedIds]); const pageIds = visibleRows.map((row, i) => getRowId(row, i)); const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selected.has(id)); const someOnPageSelected = pageIds.some((id) => selected.has(id)); const toggleAll = () => { if (allOnPageSelected) { onSelectionChange?.(selectedIds.filter((id) => !pageIds.includes(id))); } else { onSelectionChange?.([...new Set([...selectedIds, ...pageIds])]); } }; const toggleRow = (id) => { onSelectionChange?.( selected.has(id) ? selectedIds.filter((s) => s !== id) : [...selectedIds, id] ); }; /* ── Render ──────────────────────────────────────────────────────────── */ const hideBelowClass = { sm: 'hidden sm:table-cell', md: 'hidden md:table-cell', lg: 'hidden lg:table-cell' }; const alignClass = { right: 'text-right', center: 'text-center' }; if (loading) { return ( ); } if (sortedRows.length === 0) { return ( {emptyState || ( )} ); } return (
{caption && } {selectable && ( )} {columns.map((column) => { const active = sort?.key === column.key; const SortIcon = !active ? ChevronsUpDown : sort.direction === 'asc' ? ArrowUp : ArrowDown; return ( ); })} {visibleRows.map((row, i) => { const id = getRowId(row, i); const isSelected = selected.has(id); return ( onRowClick(row) : undefined} className={cn( 'border-b border-border last:border-0 transition-colors duration-fast', onRowClick && 'cursor-pointer', isSelected ? 'bg-krow-blue-tint' : 'hover:bg-surface-subtle', typeof rowClassName === 'function' ? rowClassName(row) : rowClassName )} > {selectable && ( )} {columns.map((column) => ( ))} ); })}
{caption}
{column.sortable ? ( ) : ( column.header )}
e.stopPropagation()}> toggleRow(id)} aria-label={`Select row ${i + 1}`} /> {column.cell ? column.cell(row) : column.accessor ? column.accessor(row) : row?.[column.key] ?? '—'}
{paginated && ( { setPageSize(size); setPage(1); }} pageSizeOptions={pageSizeOptions} /> )}
); }