dodil/erp-ui
src/components/data-table/DataTable.tsx
/**
* DataTable — the list every ERP screen starts from.
*
* Columns are described by KIND (text, number, money, date, status) so formatting and alignment are the
* same in every module: money right-aligned in tabular figures and sorted EXACTLY (as decimals, never as
* floats), dates formatted one way, statuses as badges. `cell` is the escape hatch for anything else.
*
* Built on TanStack Table 9 (sorting, pagination) and Radix Themes' Table. Loading, error and empty are
* states of the table itself, so no screen forgets one.
*/
import {
createColumnHelper,
createPaginatedRowModel,
createSortedRowModel,
rowPaginationFeature,
rowSortingFeature,
sortFns,
tableFeatures,
useTable,
type ColumnDef,
type RowData,
type SortingState,
} from "@tanstack/react-table";
import { Button, Flex, Spinner, Table, Text } from "@radix-ui/themes";
import { useMemo, useState, type ReactNode } from "react";
import "./data-table.css";
import { compareDecimal, formatAmount, formatDate } from "../../lib/format";
import { StatusBadge, type Tone } from "../status-badge/StatusBadge";
export type ColumnKind = "text" | "number" | "money" | "date" | "status";
export interface Column<T extends RowData> {
key: keyof T & string;
header: string;
kind?: ColumnKind;
/** money: the field holding the currency, shown beside the amount when the table mixes currencies. */
currencyKey?: keyof T & string;
sortable?: boolean;
/** status: colours for this brick's own states. */
tones?: Record<string, Tone>;
/** Anything the kinds do not cover. Receives the row; returns what to show. */
cell?: (row: T) => ReactNode;
}
export interface DataTableProps<T extends RowData> {
rows: T[] | undefined;
columns: Column<T>[];
/** A stable id per row — the record's natural key. */
rowKey: (row: T) => string;
onRowClick?: (row: T) => void;
loading?: boolean;
/** A message to show instead of the rows — the API's own reason. */
error?: string | null;
empty?: ReactNode;
pageSize?: number;
/** Accessible name for the table, e.g. "Partners". */
label: string;
}
const features = tableFeatures({
rowSortingFeature,
rowPaginationFeature,
sortedRowModel: createSortedRowModel(),
paginatedRowModel: createPaginatedRowModel(),
sortFns,
});
const ALIGN: Record<ColumnKind, "left" | "right"> = {
text: "left", status: "left", date: "left", number: "right", money: "right",
};
export function DataTable<T extends RowData>({
rows, columns, rowKey, onRowClick, loading, error, empty, pageSize = 25, label,
}: DataTableProps<T>) {
const [sorting, setSorting] = useState<SortingState>([]);
const helper = useMemo(() => createColumnHelper<typeof features, T>(), []);
const defs = useMemo(
() =>
columns.map((c) => {
const kind = c.kind ?? "text";
return helper.accessor((row: T) => row[c.key] as unknown, {
id: c.key,
header: c.header,
enableSorting: c.sortable ?? true,
sortFn: (a, b) => {
const x = a.original[c.key] as unknown, y = b.original[c.key] as unknown;
if (kind === "money" || kind === "number") return compareDecimal(x as string, y as string);
return String(x ?? "").localeCompare(String(y ?? ""), undefined, { numeric: true });
},
});
}) as ColumnDef<typeof features, T, unknown>[],
[columns, helper],
);
const table = useTable({
features,
columns: defs,
data: rows ?? [],
state: { sorting },
onSortingChange: setSorting,
initialState: { pagination: { pageIndex: 0, pageSize } },
getRowId: (row: T) => rowKey(row),
});
const byId = new Map(columns.map((c) => [c.key, c] as const));
const show = (row: T, c: Column<T>): ReactNode => {
if (c.cell) return c.cell(row);
const v = row[c.key] as unknown;
switch (c.kind ?? "text") {
case "money": {
const cur = c.currencyKey ? (row[c.currencyKey] as unknown as string) : "";
return `${formatAmount(v as string)}${cur ? ` ${cur}` : ""}`;
}
case "number":
return formatAmount(v as string, 0);
case "date":
return formatDate(v as string);
case "status":
return v ? <StatusBadge status={String(v)} tones={c.tones} /> : "—";
default:
return v === null || v === undefined || v === "" ? "—" : String(v);
}
};
if (error) {
return <Text as="p" color="red" role="alert">{error}</Text>;
}
const pageRows = table.getRowModel().rows;
const pages = table.getPageCount();
return (
<Flex direction="column" gap="3">
<Table.Root variant="surface" aria-label={label} aria-busy={loading || undefined}>
<Table.Header>
{table.getHeaderGroups().map((g) => (
<Table.Row key={g.id}>
{g.headers.map((h) => {
const c = byId.get(h.column.id as keyof T & string)!;
const sorted = h.column.getIsSorted();
const sortable = h.column.getCanSort();
return (
<Table.ColumnHeaderCell
key={h.id}
align={ALIGN[c.kind ?? "text"]}
aria-sort={sorted === "asc" ? "ascending" : sorted === "desc" ? "descending" : undefined}
>
{sortable ? (
<button type="button" className="erp-sort" onClick={h.column.getToggleSortingHandler()}>
{c.header}
<span aria-hidden="true">{sorted === "asc" ? " ↑" : sorted === "desc" ? " ↓" : ""}</span>
</button>
) : (
c.header
)}
</Table.ColumnHeaderCell>
);
})}
</Table.Row>
))}
</Table.Header>
<Table.Body>
{loading && !rows?.length ? (
<Table.Row>
<Table.Cell colSpan={columns.length}>
<Flex align="center" gap="2"><Spinner /> <Text color="gray">Loading…</Text></Flex>
</Table.Cell>
</Table.Row>
) : pageRows.length === 0 ? (
<Table.Row>
<Table.Cell colSpan={columns.length}>
<Text color="gray">{empty ?? "Nothing here yet."}</Text>
</Table.Cell>
</Table.Row>
) : (
pageRows.map((r) => (
<Table.Row
key={r.id}
onClick={onRowClick ? () => onRowClick(r.original) : undefined}
onKeyDown={onRowClick ? (e) => { if (e.key === "Enter") onRowClick(r.original); } : undefined}
tabIndex={onRowClick ? 0 : undefined}
style={onRowClick ? { cursor: "pointer" } : undefined}
data-row-id={r.id}
>
{columns.map((c) => (
<Table.Cell
key={c.key}
align={ALIGN[c.kind ?? "text"]}
style={c.kind === "money" || c.kind === "number" ? { fontVariantNumeric: "tabular-nums" } : undefined}
>
{show(r.original, c)}
</Table.Cell>
))}
</Table.Row>
))
)}
</Table.Body>
</Table.Root>
{pages > 1 && (
<Flex align="center" justify="end" gap="2">
<Text size="2" color="gray">
Page {table.state.pagination.pageIndex + 1} of {pages}
</Text>
<Button size="1" variant="soft" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
Previous
</Button>
<Button size="1" variant="soft" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
Next
</Button>
</Flex>
)}
</Flex>
);
}