# Pattern — `{Entity}ListPage.tsx`

## When to use

Any page that renders a flat list of entities: contacts, leads, tickets, orders,
products. Columns are known and finite (≤10). Search + pagination are expected.

For grid-of-cards (gallery), use `entity-card.md`. For pipeline stages,
use `kanban-board.md`.

## Anatomy

```
PermissionGuard (read)
  PageTemplate (title, icon, actions)
    Slot             — {entity}.header.actions
    error banner     — token-based (R7)
    filter toolbar?  — ONE bordered box: global search input (1st control) + faceted
                       filter fields. Emitted ONLY when the pagespec declares filters.
    Slot             — {entity}.table.columns.before
    isLoading ?      — Loader2 animate-spin
      ResponsiveDataTable<T> — local @/components/ui (responsive columns + truncation
                       tooltip). With filters → controlled search (searchTerm /
                       onSearchChange); the toolbar owns the input. Without filters →
                       the table's built-in `searchable` input.
    Slot             — {entity}.table.columns.after
```

> The global search is **never** a separate bare input between the filter box and
> the table. When filters exist it sits as the first control inside the toolbar
> (controlled search); otherwise it is the DataTable's own built-in `searchable`.

## Canonical example (compliant)

```tsx
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Loader2, Plus, Pencil, Trash2, List } from 'lucide-react';
import { PermissionGuard, Slot } from '@atlashub/smartstack';
import { ResponsiveDataTable, type ResponsiveColumn } from '@/components/ui/ResponsiveDataTable';
import { PageTemplate } from '@/components/ui/PageTemplate';
import { useContacts, useDeleteContact } from '../hooks/useContact';
import type { Contact } from '../types';

export function ContactListPage() {
  const { t } = useTranslation('contact');
  const navigate = useNavigate();
  const { data, isLoading, error } = useContacts();
  const deleteMutation = useDeleteContact();

  // Responsive contract: code + label stay visible at every width (no
  // minBreakpoint); extra columns appear as the screen widens; `truncate`
  // clips long values + shows the full text in a tooltip on hover.
  const columns: ResponsiveColumn<Contact>[] = [
    { key: 'code', label: t('list.columns.code'), sortable: true, truncate: true },
    { key: 'lastName', label: t('list.columns.lastName'), sortable: true, truncate: true },
    { key: 'email', label: t('list.columns.email'), sortable: true, truncate: true, minBreakpoint: 'md' },
    {
      key: '__actions',
      label: t('list.actions'),
      align: 'right',
      render: (item) => (
        <div className="flex items-center justify-end gap-1">
          <PermissionGuard permission="crm.contacts.update">
            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); navigate(`./${item.id}/edit`); }}
              className="p-1.5 rounded-[var(--radius-badge)] hover:bg-[var(--bg-hover)] text-[var(--text-muted)] hover:text-[var(--text-primary)]"
              title={t('list.edit')}
            >
              <Pencil className="w-4 h-4" />
            </button>
          </PermissionGuard>
          <PermissionGuard permission="crm.contacts.delete">
            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); void deleteMutation.mutateAsync(item.id); }}
              className="p-1.5 rounded-[var(--radius-badge)] hover:bg-[var(--error-bg)] text-[var(--text-muted)] hover:text-[var(--error-text)]"
              title={t('list.delete')}
            >
              <Trash2 className="w-4 h-4" />
            </button>
          </PermissionGuard>
        </div>
      ),
    },
  ];

  return (
    <PermissionGuard permission="crm.contacts.read">
      <PageTemplate
        title={t('list.title')}
        subtitle={t('list.subtitle')}
        icon={<List className="w-6 h-6" />}
        actions={
          <PermissionGuard permission="crm.contacts.create">
            <button
              type="button"
              onClick={() => navigate('./new/edit')}
              className="flex items-center gap-1.5 px-3 py-2 rounded-[var(--radius-button)] bg-[var(--color-accent-500)] text-white text-sm font-medium hover:bg-[var(--color-accent-600)] transition-colors"
            >
              <Plus className="w-4 h-4" />
              {t('list.create')}
            </button>
          </PermissionGuard>
        }
      >
        <Slot name="contact.header.actions" />

        {error && (
          <div className="mb-4 p-3 rounded-[var(--radius-card)] bg-[var(--error-bg)] border border-[var(--error-border)] text-sm text-[var(--error-text)]">
            {t('list.error')}: {String(error)}
          </div>
        )}

        <Slot name="contact.table.columns.before" />

        {isLoading ? (
          <div className="flex items-center justify-center py-12 text-[var(--text-muted)]">
            <Loader2 className="w-5 h-5 animate-spin mr-2" />
            {t('list.loading')}
          </div>
        ) : (
          <ResponsiveDataTable<Contact>
            data={data?.items ?? []}
            columns={columns}
            searchable
            searchPlaceholder={t('list.search')}
            pagination={{ pageSize: 20, showSizeSelector: true }}
            getRowKey={(item) => item.id}
            emptyMessage={t('list.empty')}
            onRowClick={(item) => navigate(`./${item.id}`)}
          />
        )}

        <Slot name="contact.table.columns.after" />
      </PageTemplate>
    </PermissionGuard>
  );
}
```

## With faceted filters (search lives inside the toolbar)

When the pagespec declares `config.filters`, the global search becomes the FIRST
control inside the single bordered filter toolbar, and the table runs in
**controlled-search** mode (no built-in input). `filtered` applies the faceted
filters; the `<DataTable>` applies `searchTerm` on top — no duplicated matching.

```tsx
const [search, setSearch] = useState('');
const [filters, setFilters] = useState<Record<string, string>>({ /* … */ });

// …inside <PageTemplate>, before the table:
<div className="mb-4 flex flex-wrap items-end gap-3 p-3 rounded-[var(--radius-card)] border border-[var(--border-color)] bg-[var(--bg-card)]">
  <div className="relative flex-1 min-w-[240px] max-w-md">
    <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)] pointer-events-none" />
    <input type="text" value={search} onChange={(e) => setSearch(e.target.value)}
           placeholder={t('list.search')} className="input w-full pl-10 pr-10" />
    {search && (
      <button type="button" onClick={() => setSearch('')}
              className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--text-secondary)] hover:text-[var(--text-primary)]">
        <X className="w-4 h-4" />
      </button>
    )}
  </div>
  {/* …faceted filter fields: text inputs, EnumSelect, DateInput… */}
</div>

<ResponsiveDataTable<Contact>
  data={filtered}
  columns={columns}
  searchTerm={search}
  onSearchChange={setSearch}
  pagination={{ pageSize: 20, showSizeSelector: true }}
  getRowKey={(item) => item.id}
  emptyMessage={t('list.empty')}
  onRowClick={(item) => navigate(`./${item.id}`)}
/>
```

This mode additionally imports `Search, X` from `lucide-react`.

## Rules this pattern enforces

- **R1** — `<PageTemplate>` wrap
- **R3** — primary CTA uses `bg-[var(--color-accent-500)] text-white hover:bg-[var(--color-accent-600)]`
- **R4** — every user-facing string flows through `useTranslation('entity')` + `t('key')`
- **R5** — icons only from `lucide-react`
- **R6** — loading state = `<Loader2 className="animate-spin" />`
- **R8 / R27** — `<ResponsiveDataTable>` (local `@/components/ui/ResponsiveDataTable`, wraps `<DataTable>`), NOT raw `<table>`
- **R10** — permission keys `{module}.{section}.{action}` (no appCode prefix)
- **R11** — Slot injection points: `{entity}.header.actions`, `{entity}.table.columns.before/after`
- **R13** — status badges use `bg-[var(--<status>-bg)]` anatomy
- **R16** — radius tokens (`rounded-[var(--radius-button|card|badge)]`)
- **R19** — no palette (`bg-gray-*`, `text-blue-*`) nor absolute (`bg-white`, `bg-black`) colors
- **R20** — borders always paired with `border-[var(--border-color)]`

## Import contract

| Name | From | Notes |
|---|---|---|
| `PermissionGuard`, `Slot` | `@atlashub/smartstack` | npm package |
| `formatDate`, `formatDateTime` (**when date columns exist**) | `@atlashub/smartstack` | platform display-settings rendering (General : DateFormat / TimeFormat / DefaultTimezone) |
| `ResponsiveDataTable`, `ResponsiveColumn` | `@/components/ui/ResponsiveDataTable` | local wrapper (scaffold-ui-primitives) — responsive cols + truncation tooltip |
| `PageTemplate` | `@/components/ui/PageTemplate` | local baseline file |
| Icons (Loader2, Plus, Pencil, Trash2, List; + Search, X **when filters exist**) | `lucide-react` | no other icon lib |
| `useTranslation` | `react-i18next` | |
| `useNavigate` | `react-router-dom` | |

**`ResponsiveColumn` + `ResponsiveDataTable`** are scaffolded locally by
`scaffold-ui-primitives` into `@/components/ui/ResponsiveDataTable`. The wrapper
renders the local `<DataTable>`, keeps the default columns (code, label, actions)
visible at every width while extra columns appear as the screen widens
(`minBreakpoint` per column), and clips overflowing cells with an ellipsis +
tooltip (`truncate`). `ResponsiveColumn<T>` extends `DataTableColumn<T>` with those
two optional fields.

**Date columns** — a pagespec column carrying `formatHint: "date"|"datetime"`, or a
field whose entity type is `date`/`datetime`, gets a
`render: (item) => formatDate(item.x)` / `formatDateTime(item.x)` so the value
follows the platform display settings instead of printing the raw ISO string. The
scaffolded `DataTable`/`ResponsiveDataTable` additionally apply the same formatting
as a **cell fallback** (`formatCellValue`) for full-ISO strings in columns without a
`render`.

**Controlled search** — `<DataTable>`/`<ResponsiveDataTable>` accept optional
`searchTerm?: string` + `onSearchChange?: (v: string) => void`. When provided, the
table filters by that term but renders **no** input of its own (the page's filter
toolbar owns the box). This mirrors the controlled-sort props (`sortKey` /
`onSortChange`). Omit them (and pass `searchable`) for the built-in search.

## Do NOT

- Raw `<table>` / `<thead>` / `<tbody>` — use `<ResponsiveDataTable>`
- `bg-white` → `bg-[var(--bg-card)]`
- `bg-gray-50` → `bg-[var(--bg-hover)]` or `bg-[var(--bg-secondary)]`
- `border` alone → `border border-[var(--border-color)]`
- `text-blue-700` → `text-[var(--info-text)]`
- `className="bg-[#6366f1]"` → use the accent token
- Hardcoded user-facing strings — always `t('key')`
- `<div onClick>` for buttons — use `<button type="button">`
- A second bare search input between the filter box and the table — when filters
  exist, the global search is the toolbar's controlled `searchTerm`/`onSearchChange`
