# Pattern — Kanban Board

## When to use

Pipeline / workflow visualization where entities move through a fixed set of
STAGES (qualification → proposal → negotiation → won / lost). Drag-and-drop
moves items between columns; columns with no OUTGOING transition in the BR
Flow graph are TERMINAL (no drag out).

> **The generated implementation is NOT a separate page.** scaffold-component
> renders the board as the `kanban` viewMode of the entity's LIST page
> (`?view=kanban`, Table ⇄ Kanban SegmentedControl) from the pagespec's
> first-order `kanban` block (`lib/page-spec-kanban.ts`, folded by
> `create-prd/cli/derive-kanban-spec`): the FilterBar, search, segments and
> URL state are the list's own; the BR Flow matrix is inlined as
> `ALLOWED_TRANSITIONS` (drops outside it are refused — the backend `move`
> guard compiles the same edges); column order/visibility persist through
> `useKanbanColumnPrefs` (header drag + ColumnPicker). This pattern file is
> the STYLE reference for that branch and for bespoke `@customised` boards.

## Anatomy (the generated board branch)

```
ListPage (one route — the board is a branch of the list render)
  stats row / segments / FilterBar   — SHARED with the table representation
  viewMode toggle                    — SegmentedControl (table | cards | kanban)
  board toolbar                      — ColumnPicker (hide/show columns)
  move-error banner                  — --error-bg, dismissable (failed move)
  truncated banner                   — --warning-bg (totalCount > fetched)
  columns flex                       — flex gap-3 overflow-x-auto (ONE line)
    column                           — w-64 shrink-0, border-t-2 tone token
      header                         — draggable (reorder), label + count
      card list                      — draggable cards (matrix + permission gated)
    unassigned bucket                — dashed --warning border (schema drift)
```

## Canonical example (compliant)

```tsx
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { PermissionGuard, Slot } from '@atlashub/smartstack';
import { PageTemplate } from '@/components/ui/PageTemplate';
import { opportunitiesApi } from '@/services/api/prospectionApi';
import type {
  OpportunityListDto,
  OpportunityStatus,
  OpportunityFilters,
  OpportunityStageDto,
} from '@/types/prospection';

const COLUMNS: OpportunityStatus[] = [
  'qualification', 'proposition', 'negociation', 'gagnee', 'perdue', 'abandonnee',
];
const TERMINAL: ReadonlySet<OpportunityStatus> = new Set(['gagnee', 'perdue', 'abandonnee']);

export function PipelinePage() {
  const { t } = useTranslation('prospection');
  const navigate = useNavigate();
  const [items, setItems] = useState<OpportunityListDto[]>([]);
  const [stages, setStages] = useState<OpportunityStageDto[]>([]);
  const [draggedId, setDraggedId] = useState<string | null>(null);

  useEffect(() => {
    void opportunitiesApi.getStages().then(setStages);
    void opportunitiesApi.getAll().then(setItems);
  }, []);

  const handleDrop = async (target: OpportunityStatus) => {
    if (!draggedId) return;
    const opp = items.find((o) => o.id === draggedId);
    if (!opp || opp.status === target) return;
    const stage = stages.find((s) => s.name === target);
    if (!stage) return;
    await opportunitiesApi.moveStage(draggedId, { toStageId: stage.id });
    setItems((prev) => prev.map((o) => o.id === draggedId ? { ...o, status: target, stageName: stage.name } : o));
    setDraggedId(null);
  };

  const byStatus = (s: OpportunityStatus) => items.filter((o) => o.status === s);

  return (
    <PermissionGuard permission="crm.prospection.read">
      <PageTemplate
        title={t('pipeline.title')}
        actions={
          <div className="flex items-center gap-2">
            <button
              type="button"
              onClick={() => navigate('/crm/prospection/pipeline/list')}
              className="rounded-[var(--radius-button)] border border-[var(--border-color)] px-3 py-1.5 text-sm text-[var(--text-primary)] hover:bg-[var(--bg-hover)]"
            >
              {t('pipeline.viewList')}
            </button>
            <PermissionGuard permission="crm.prospection.create">
              <button
                type="button"
                onClick={() => navigate('/crm/prospection/pipeline/new')}
                className="rounded-[var(--radius-button)] bg-[var(--color-accent-500)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-accent-600)] transition-colors"
              >
                + {t('pipeline.newOpportunity')}
              </button>
            </PermissionGuard>
          </div>
        }
      >
        <Slot name="opportunity.header.actions" />

        <div className="flex flex-1 overflow-auto gap-3 p-4">
          {COLUMNS.map((status) => {
            const colItems = byStatus(status);
            const isTerminal = TERMINAL.has(status);
            return (
              <div
                key={status}
                className="flex flex-col w-56 shrink-0 rounded-[var(--radius-card)] border border-[var(--border-color)] bg-[var(--bg-hover)]"
                onDragOver={(e) => { if (!isTerminal) e.preventDefault(); }}
                onDrop={() => { void handleDrop(status); }}
              >
                <div className="border-b border-[var(--border-color)] px-3 py-2">
                  <p className="text-sm font-semibold text-[var(--text-primary)]">
                    {t(`pipeline.status.${status}`)}
                  </p>
                  <p className="text-xs text-[var(--text-secondary)]">{colItems.length}</p>
                </div>

                <div className="flex-1 overflow-auto p-2 space-y-2">
                  {colItems.length === 0 ? (
                    <p className="py-4 text-center text-xs text-[var(--text-secondary)]">
                      {t('common.noResults')}
                    </p>
                  ) : (
                    colItems.map((opp) => (
                      <button
                        key={opp.id}
                        type="button"
                        draggable={!isTerminal}
                        onDragStart={() => setDraggedId(opp.id)}
                        onDragEnd={() => setDraggedId(null)}
                        onClick={() => navigate(`/crm/prospection/pipeline/${opp.id}`)}
                        className="w-full text-left rounded-[var(--radius-badge)] border border-[var(--border-color)] bg-[var(--bg-card)] p-3 shadow-sm cursor-pointer hover:shadow-md transition-shadow"
                      >
                        <div className="flex items-start justify-between gap-2">
                          <p className="text-sm font-medium text-[var(--text-primary)] truncate flex-1">
                            {opp.title}
                          </p>
                          {opp.isAtRisk && (
                            <span className="shrink-0 rounded-[var(--radius-badge)] bg-[var(--error-bg)] px-1.5 py-0.5 text-xs text-[var(--error-text)]">
                              {t('pipeline.atRisk')}
                            </span>
                          )}
                        </div>
                        <p className="mt-1 text-xs text-[var(--text-secondary)]">
                          {opp.estimatedAmount.toLocaleString()} {opp.currency}
                        </p>
                      </button>
                    ))
                  )}
                </div>
              </div>
            );
          })}
        </div>
      </PageTemplate>
    </PermissionGuard>
  );
}
```

## Rules this pattern enforces

- **R1** — `<PageTemplate>`
- **R3** — primary CTA accent (new opportunity button)
- **R4** — `useTranslation('entity')` + `t('pipeline.status.<status>')`
- **R8** — NOT applicable (kanban is NOT a list — raw `<table>` exception)
- **R10** — permission format
- **R11** — Slot `{entity}.header.actions`
- **R16** — radius tokens on columns + cards
- **R17** — cards use `<button>` not `<div onClick>` — keyboard accessibility
- **R19** — `bg-[var(--bg-hover)]` for column, `bg-[var(--bg-card)]` for card (NO `bg-white`)
- **R20** — every border paired with `border-[var(--border-color)]`

## Terminal-column contract

Columns representing final states (`won` / `lost` / `abandoned`) have a
distinct drop behaviour: `onDragOver` must NOT call `preventDefault` — this
rejects drops from the browser. Cards inside terminal columns have
`draggable={false}`. The pattern above uses a `TERMINAL` set for clarity.

## Import contract

| Name | From |
|---|---|
| `PermissionGuard`, `Slot` | `@atlashub/smartstack` |
| `PageTemplate` | `@/components/ui/PageTemplate` |
| `useTranslation` | `react-i18next` |
| `useNavigate` | `react-router-dom` |

## Do NOT

- Use `<div onClick>` for the cards — use `<button type="button">` (R17).
- `bg-white` for the card → `bg-[var(--bg-card)]`.
- `border` alone on column/card → always paired with `border-[var(--border-color)]`.
- Emoji ⚠ for the at-risk badge — use an i18n string or lucide icon.
- Allow drag-to-terminal without a confirmation step if the PRD says moves
  to terminal states require justification (rule-dependent — check the
  business rules slice before scaffolding).
- Render a separate `<h1>` inside PageTemplate's children — title is already
  emitted by `<PageTemplate title={…}>`.
