# Tables and Charts

TanStack Table v8 (headless) styled with Mantine, and Recharts for visualizations. Server-side pagination, sorting, and filtering are the default for internal dashboards with non-trivial data.

## Install

Already in the parent [SKILL.md](../SKILL.md#3-install-dependencies):

```bash
bun add @tanstack/react-table recharts
```

## Table Component

```tsx
// src/features/users/components/UsersTable.tsx:1
"use client";

import {
  ActionIcon,
  Badge,
  Group,
  Pagination,
  ScrollArea,
  Skeleton,
  Stack,
  Table,
  Text,
  TextInput,
} from "@mantine/core";
import {
  IconChevronDown,
  IconChevronUp,
  IconSearch,
  IconSelector,
} from "@tabler/icons-react";
import {
  flexRender,
  getCoreRowModel,
  getSortedRowModel,
  useReactTable,
  type ColumnDef,
  type SortingState,
} from "@tanstack/react-table";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { listUsers, type User } from "../api/users.api";
import { usersKeys } from "../hooks/users.keys";

const columns: ColumnDef<User>[] = [
  {
    accessorKey: "nip",
    header: "NIP",
    cell: (info) => <Text fw={500}>{info.getValue<string>()}</Text>,
  },
  {
    accessorKey: "name",
    header: "Name",
  },
  {
    accessorKey: "email",
    header: "Email",
  },
  {
    accessorKey: "isActive",
    header: "Status",
    cell: (info) => {
      const active = info.getValue<boolean>();
      return (
        <Badge color={active ? "green" : "gray"} variant="light">
          {active ? "Active" : "Inactive"}
        </Badge>
      );
    },
  },
  {
    accessorKey: "roles",
    header: "Roles",
    enableSorting: false,
    cell: (info) => {
      const roles = info.getValue<string[]>();
      return (
        <Group gap={4}>
          {roles.map((role) => (
            <Badge key={role} variant="outline">
              {role}
            </Badge>
          ))}
        </Group>
      );
    },
  },
];

export function UsersTable() {
  const [page, setPage] = useState(1);
  const [pageSize] = useState(20);
  const [search, setSearch] = useState("");
  const [sorting, setSorting] = useState<SortingState>([]);

  const sortBy = sorting[0]?.id;
  const sortDir = sorting[0]?.desc ? "desc" : "asc";

  const query = useQuery({
    queryKey: usersKeys.list({ search, page, pageSize, sortBy, sortDir }),
    queryFn: () => listUsers({ search, page, pageSize, sortBy, sortDir }),
    placeholderData: keepPreviousData,
  });

  const tableData = useMemo(() => query.data?.items ?? [], [query.data]);
  const total = query.data?.total ?? 0;
  const totalPages = Math.max(1, Math.ceil(total / pageSize));

  const table = useReactTable({
    data: tableData,
    columns,
    state: { sorting },
    onSortingChange: setSorting,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    manualPagination: true,
    manualSorting: true,
    pageCount: totalPages,
  });

  return (
    <Stack gap="md">
      <TextInput
        placeholder="Search by name, NIP, email"
        leftSection={<IconSearch size={16} />}
        value={search}
        onChange={(e) => {
          setSearch(e.currentTarget.value);
          setPage(1);
        }}
        w={320}
      />

      <ScrollArea>
        <Table striped highlightOnHover withTableBorder verticalSpacing="sm">
          <Table.Thead>
            {table.getHeaderGroups().map((headerGroup) => (
              <Table.Tr key={headerGroup.id}>
                {headerGroup.headers.map((header) => {
                  const canSort = header.column.getCanSort();
                  const sorted = header.column.getIsSorted();
                  return (
                    <Table.Th
                      key={header.id}
                      onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
                      style={canSort ? { cursor: "pointer", userSelect: "none" } : undefined}
                      aria-sort={
                        sorted === "asc"
                          ? "ascending"
                          : sorted === "desc"
                          ? "descending"
                          : "none"
                      }
                    >
                      <Group gap={4}>
                        {flexRender(header.column.columnDef.header, header.getContext())}
                        {canSort && (
                          <ActionIcon variant="subtle" size="xs" aria-hidden>
                            {sorted === "asc" ? (
                              <IconChevronUp size={14} />
                            ) : sorted === "desc" ? (
                              <IconChevronDown size={14} />
                            ) : (
                              <IconSelector size={14} />
                            )}
                          </ActionIcon>
                        )}
                      </Group>
                    </Table.Th>
                  );
                })}
              </Table.Tr>
            ))}
          </Table.Thead>
          <Table.Tbody>
            {query.isLoading && (
              <Table.Tr>
                <Table.Td colSpan={columns.length}>
                  <Skeleton height={32} />
                </Table.Td>
              </Table.Tr>
            )}
            {table.getRowModel().rows.map((row) => (
              <Table.Tr key={row.id}>
                {row.getVisibleCells().map((cell) => (
                  <Table.Td key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </Table.Td>
                ))}
              </Table.Tr>
            ))}
            {!query.isLoading && table.getRowModel().rows.length === 0 && (
              <Table.Tr>
                <Table.Td colSpan={columns.length}>
                  <Text c="dimmed" ta="center" py="md">
                    No users found
                  </Text>
                </Table.Td>
              </Table.Tr>
            )}
          </Table.Tbody>
        </Table>
      </ScrollArea>

      <Group justify="space-between">
        <Text size="sm" c="dimmed">
          {total} {total === 1 ? "user" : "users"}
        </Text>
        <Pagination total={totalPages} value={page} onChange={setPage} />
      </Group>
    </Stack>
  );
}
```

## Column Definitions

Prefer `accessorKey` over `accessor` for simple fields. For computed/derived cells:

```ts
{
  id: "actions",
  header: "",
  cell: ({ row }) => <RowActions user={row.original} />,
}
```

Always set `enableSorting: false` for columns whose sort the backend cannot honor.

## Server-Side Conventions

Backend must accept these query params (matches Go backend-dev `CODING-STANDARDS.md`):

| Param | Type | Notes |
|-------|------|-------|
| `page` | int (1-based) | |
| `pageSize` | int | usually capped server-side |
| `sortBy` | string | column key, snake_case on backend |
| `sortDir` | `asc` \| `desc` | |
| `search` | string | applies to a configured set of fields |

Response shape:

```ts
{ items: T[], total: number, page: number, pageSize: number }
```

## Charts

Recharts with Mantine theme colors:

```tsx
// src/features/analytics/components/UsersByDayChart.tsx:1
"use client";

import { Card } from "@mantine/core";
import {
  Bar,
  BarChart,
  CartesianGrid,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from "recharts";
import { useTheme } from "@mantine/core";

type Point = { date: string; count: number };

export function UsersByDayChart({ data }: { data: Point[] }) {
  const theme = useTheme();

  return (
    <Card>
      <ResponsiveContainer width="100%" height={280}>
        <BarChart data={data}>
          <CartesianGrid strokeDasharray="3 3" stroke={theme.colors.gray[3]} />
          <XAxis dataKey="date" stroke={theme.colors.gray[7]} />
          <YAxis allowDecimals={false} stroke={theme.colors.gray[7]} />
          <Tooltip
            contentStyle={{
              backgroundColor: theme.colors.gray[0],
              border: `1px solid ${theme.colors.gray[3]}`,
            }}
          />
          <Bar dataKey="count" fill={theme.colors.indigo[6]} radius={[4, 4, 0, 0]} />
        </BarChart>
      </ResponsiveContainer>
    </Card>
  );
}
```

For a Mantine-native alternative, use the Nivo or `@mantine/charts` package if the team adopts it. Recharts wins when more chart types are needed (scatter, area, radar).

## Accessibility

- Every chart needs a `title` (visible or `aria-label`) and a brief description.
- Provide an `<Table>` view alongside charts when the underlying data is small.
- Don't rely on color alone — pair bars with labels and patterns when comparing more than two series.

## Performance

- Wrap tables in `<ScrollArea>` to handle wide data without horizontal page scroll.
- `placeholderData: keepPreviousData` (or `keepPreviousData` from v5) prevents layout shift during pagination/sort.
- Memoize `columns` if you build it inside the component — use `useMemo` or hoist to module scope.
- Charts: cap data points (~100 max); downsample before passing to `<BarChart>`.