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 (
|
|
)}
{columns.map((column) => {
const active = sort?.key === column.key;
const SortIcon = !active ? ChevronsUpDown : sort.direction === 'asc' ? ArrowUp : ArrowDown;
return (
{column.sortable ? ( ) : ( column.header )} | ); })}
|---|---|
| e.stopPropagation()}>
|
)}
{columns.map((column) => (
{column.cell ? column.cell(row) : column.accessor ? column.accessor(row) : row?.[column.key] ?? '—'} | ))}