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>
  );
}