/** The column board — records as cards in stage columns, moved by drag or menu. */ import { useMemo, useState } from "react"; import { Avatar } from "@lotics/ui/avatar"; import { Board, BoardCard, BoardColumn } from "@lotics/ui/board"; import { Box } from "@lotics/ui/box"; import { Button } from "@lotics/ui/button"; import { colors } from "@lotics/ui/colors"; import { daysUntil, deadlineAnnotation } from "@lotics/ui/deadline"; import { Drawer, DrawerScrollArea } from "@lotics/ui/drawer"; import { DetailRow, DetailTable } from "@lotics/ui/detail_row"; import { FilterChip } from "@lotics/ui/filter_chip"; import { MemberChip } from "@lotics/ui/member_chip"; import { Status } from "@lotics/ui/status"; import { OptionList } from "@lotics/ui/option_list"; import { Stack } from "@lotics/ui/stack"; import { TotalsLine } from "@lotics/ui/totals_line"; import { Text } from "@lotics/ui/text"; import { TextInput } from "@lotics/ui/text_input"; /** * THE column board — a surface whose subject is ADVANCING work between piles. * * ## Why this is not a grid of the same work * * They look adjacent and they are not. A `DataGrid` is bands of ROWS with a * field in every cell, for a reader who is COMPARING work and correcting it — a * deadline here, an owner there. Nothing moves; the grid is a spreadsheet with * opinions. * * This shape exists for the opposite act. The columns ARE the stages, the pile * sizes are themselves the report, and the only thing the reader does is send a * card onward. Reach for it when the question is *"what is stuck, and what moves * next?"* rather than *"which of these is wrong?"*. * * Getting this backwards is not hypothetical: an app hand-rolled a 280px column * because a reviewer read a template's NAME and told its author the kit already * shipped a board. It did not. Read a template's source, never its name. * * ## What a board owes its reader * * - **The move is reachable by KEYBOARD.** Every card carries a control whose * items name their destinations, so advancing work never requires a pointer. * Drag is layered on the same declared list — the grip drags, the drop * hit-tests the column under the pointer and is refused unless the card * already declared it, so the two paths cannot disagree about what is legal. * - **A column states its size as a PROP.** Never formatted into the label: the * count is what makes a pile a report, and a number inside a heading is a * second copy that goes stale. * - **The BOARD scrolls sideways, never the page.** A wide shape earns its place * by owning its own overflow; a page that scrolls horizontally has lost its * left edge. * - **A card is a DOOR.** Pressing it opens the record, exactly as a register * row does — the board is another way into the same surface, never a second * one. What a card SHOWS is the minimum for deciding whether to move it. */ type Stage = "intake" | "review" | "approved" | "issued"; interface Doc { id: string; title: string; ref: string; owner: string; due: string | null; stage: Stage; } const STAGES: { key: Stage; label: string; color: "zinc" | "amber" | "blue" | "emerald" }[] = [ { key: "intake", label: "Received", color: "zinc" }, { key: "review", label: "In review", color: "amber" }, { key: "approved", label: "Approved", color: "blue" }, { key: "issued", label: "Issued", color: "emerald" }, ]; const SEED: Doc[] = [ { id: "d1", title: "Carrier rate agreement 2026", ref: "DOC-2026-0041", owner: "Alice Rowan", due: "2026-08-24", stage: "intake" }, { id: "d2", title: "Warehouse lease — Bay 4", ref: "DOC-2026-0042", owner: "Martin Vogel", due: "2026-09-02", stage: "intake" }, { id: "d3", title: "Customs power of attorney", ref: "DOC-2026-0043", owner: "Ruth Adeyemi", due: null, stage: "intake" }, { id: "d4", title: "Insurance renewal — fleet", ref: "DOC-2026-0038", owner: "Alice Rowan", due: "2026-08-28", stage: "review" }, { id: "d5", title: "Subcontractor NDA", ref: "DOC-2026-0039", owner: "Lena Ferrer", due: "2026-09-15", stage: "review" }, { id: "d6", title: "Port handling addendum", ref: "DOC-2026-0035", owner: "Martin Vogel", due: "2026-09-08", stage: "approved" }, { id: "d7", title: "Annual safety declaration", ref: "DOC-2026-0031", owner: "Ruth Adeyemi", due: "2026-08-19", stage: "issued" }, { id: "d8", title: "Depot access permit", ref: "DOC-2026-0029", owner: "Lena Ferrer", due: "2026-10-01", stage: "issued" }, ]; const OWNERS = [...new Set(SEED.map((d) => d.owner))].map((o) => ({ value: o, label: o })); /** The template's "now", so the countdowns are stable in a gallery. */ const NOW = new Date("2026-08-30T00:00:00Z"); const DEADLINE_WORDS = { overdue: (d: number) => `${d} day${d === 1 ? "" : "s"} overdue`, today: "Due today", tomorrow: "Due tomorrow", inDays: (d: number) => `${d} days left`, }; /** One helper, so a card and the record it opens cannot word the same date * differently — the annotation carries its own tone with it. */ const dueProps = (iso: string, done: boolean) => done ? { color: "muted" as const, children: "Issued" } : (() => { const a = deadlineAnnotation(daysUntil(new Date(iso), NOW), DEADLINE_WORDS); const text = a.error ?? a.warning ?? a.description ?? ""; const color: "danger" | "warning" | "muted" = a.error ? "danger" : a.warning ? "warning" : "muted"; return { color, children: text }; })(); export function TplBoard() { const [docs, setDocs] = useState(SEED); const [query, setQuery] = useState(""); const [owners, setOwners] = useState([]); const [open, setOpen] = useState(null); const shown = useMemo(() => { const q = query.trim().toLowerCase(); return docs.filter( (d) => (!q || d.title.toLowerCase().includes(q) || d.ref.toLowerCase().includes(q)) && (owners.length === 0 || owners.includes(d.owner)), ); }, [docs, query, owners]); const move = (id: string, to: string) => setDocs((all) => all.map((d) => (d.id === id ? { ...d, stage: to as Stage } : d))); const record = docs.find((d) => d.id === open) ?? null; const overdue = shown.filter((d) => d.due != null && d.due < "2026-08-30" && d.stage !== "issued").length; return ( <> {/* The CANVAS — a full-bleed scroller on the page ground, its content inset by the canvas padding. `flexShrink: 0` on the content is what keeps a scroller scrolling: a flex child shrinks to its parent by default, and a content box squeezed to the viewport has nothing left to scroll. */} Document approvals {/* Search leftmost, then the facets — the same toolbar order a register uses, because the board is a VIEW of the same population and the reader should not relearn where the search box lives. */} 0 ? {owners.slice(0, 4).map((o) => )} : undefined} onClear={() => setOwners([])} clearLabel="Clear owner filter" > } /> {/* What is AT STAKE, not how many rows there are: the columns already state their own sizes, so repeating the count here would be one fact printed twice a few pixels apart. */} d.stage === "intake" || d.stage === "review").length }, ...(overdue > 0 ? [{ label: "past their date", value: overdue, tone: "warning" as const }] : []), ]} /> {STAGES.map((s) => { const inStage = shown.filter((d) => d.stage === s.key); return ( } count={inStage.length} emptyMessage="Nothing at this stage" > {inStage.map((d) => ( setOpen(d.id)} /* Every stage but this one. Declaring them on the card is what makes the keyboard menu and the drop target agree — the drag resolves against this same list. */ moves={STAGES.filter((x) => x.key !== s.key).map((x) => ({ key: x.key, label: x.label }))} onMove={(to) => move(d.id, to)} > {/* The date and its countdown in ONE run, in the kit's deadline tone — a card is read at a glance, and a separate badge for "late" would say twice what the annotation already says once. */} {d.due ? ( ) : ( No date )} ))} ); })} {/* A card is a door into the SAME record surface a register would open — never a second detail view built for the board. */} !v && setOpen(null)} title={record?.title}> {record ? ( {record.ref} s.key === record.stage)?.label ?? record.stage, color: STAGES.find((s) => s.key === record.stage)?.color ?? "zinc" }} /> {record.due ? : No date} {/* The same act the card offers, in reach of the person who opened it — a reader who drilled in should not have to go back out to advance the thing they just read. */} {STAGES.filter((s) => s.key !== record.stage).map((s) => (