// --------------------------------------------------------------------------- // ui / primitives.tsx — EXIT wave 2 (W2-6, contract Y2). // // The shared presentation layer. Y2 fixes these names and neither side invents: // KpiRow · Section · DashHeader · DataTable · ClickableList · Download · // ValidationPanel (+ `fmt` next door) // // WHY THIS IS THE WHOLE POINT OF THE WAVE. Sales is the pilot; Collections and // Procurement are supposed to cost "two entries in a registry" in wave 3. That // is only true if the page renderer is a function of the Y1 envelope and // nothing else — so no primitive here knows the word "sales", and every one of // them takes its content from data. // // TWO OWNER CONSTANTS ARE ENFORCED IN CODE HERE, not left to review: // · **Every count drills to exact rows; a silent [:N] cap is a defect.** // `DataTable` renders "showing N of M" whenever the block declares a cap, // and it renders it from the SERVER's numbers, never from `rows.length` — // a client-side count of a truncated list is exactly the lie being guarded. // · **No emojis** — the two status glyphs are SVG (`./icons`). // // Colour is CSS variables only (`--lp-*`); there is not one literal in this // file or in the `.pg-*` block of index.css. Text, strokes and rules take the // `-deep` weight: the pastels measure 1.38–1.94:1 on white and are legitimately // unreadable as ink ([[loopable-brand-palette]]). // --------------------------------------------------------------------------- import { useMemo } from "react"; import { capDisclosure, cellDescriptor, emptyText } from "./blocks"; import { BLANK, fmt, formatValue } from "./fmt"; import { AlertIcon, CheckIcon, DownloadIcon } from "./icons"; import type { DrillDescriptor, DrillRule, EntityKind, KpiItem, PageControl, PageRow, TableColumn, ValidationCheck, } from "./types"; /** What a drill does when it fires. The shell supplies it; a primitive never * decides what "open" means. */ export type OnOpen = (d: DrillDescriptor) => void; // ------------------------------------------------------------------ DashHeader /** * The page title bar: title, a hairline rule with a gold head, the subtitle, * and the page's controls — mirroring `ui/primitives.py:dash_header` plus the * BU picker that lives in the Streamlit page body. * * ⚠ `controls` render their selection from `value` IN THE RESPONSE (Y1 rule 9), * never from local state. A parameter the server rejected or coerced therefore * cannot look accepted — the picker snaps back to what the server actually did. */ export function DashHeader({ title, asOf, subtitle, controls, onControl, busy, }: { title: string; asOf?: string; subtitle?: string; controls?: PageControl[]; onControl?: (key: string, value: string) => void; busy?: boolean; }) { return (

{title}

{controls && controls.length > 0 ? (
{controls.map((c) => ( ))}
) : null}
{subtitle ?

{subtitle}

: null} {asOf ? (

Data pulled

) : null}
); } // --------------------------------------------------------------------- Section /** A section header with its explanation. The prose is a `note`, shown — not a * tooltip: the Streamlit version hides it behind an ⓘ to save vertical space * that a real page does not have to fight for. */ export function Section({ label, note, children, }: { label: string; note?: string; children?: React.ReactNode; }) { return (

{label}

{note ?

{note}

: null} {children}
); } // ---------------------------------------------------------------------- KpiRow /** * The scorecard. A card with a `drill` is a real button; one without is inert * and must not look otherwise — a card that invites a click and does nothing is * the cheapest way to lose a user's trust in the whole page. * * The delta is the subtle part. Y1 rule 4: when a period has no revenue the * server sends `delta: null` with a `delta_label` and `delta_dir:"off"`, so the * card reads "no orders yet" instead of an alarming −100%. `delta_dir` tints; * it never replaces the sign, which comes from the number itself. */ export function KpiRow({ items, onOpen }: { items: KpiItem[]; onOpen?: OnOpen }) { if (!items.length) return null; return (
{items.map((m, i) => { const value = formatValue(m.value, m.fmt); const hasDelta = m.delta != null; const deltaText = hasDelta ? formatValue(m.delta, m.delta_fmt ?? "pct", { signed: true }) : ""; const dir = m.delta_dir ?? (hasDelta ? "flat" : "off"); const body = ( <> {m.label} {value} {deltaText || m.delta_label ? ( {deltaText} {deltaText && m.delta_label ? " " : ""} {m.delta_label ?? ""} ) : null} ); const key = m.key ?? `${m.label}-${i}`; return m.drill && onOpen ? ( ) : (
{body}
); })}
); } // -------------------------------------------------------------------- Download /** CSV of exactly what the table is showing. Built from the same `columns` the * table rendered, in the same order, with the same formatting — a download * that disagrees with the screen is worse than no download. * * ⚠ Values are formatted, then quoted. RFC-4180 quoting (double the quote, * wrap anything with a comma/quote/newline) — an unquoted `$1,204` splits one * money column into two. */ export function Download({ rows, columns, filename, note, }: { rows: PageRow[]; columns: TableColumn[]; filename: string; note?: string; }) { const href = useMemo(() => { const esc = (s: string) => /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; const head = columns.map((c) => esc(c.label)).join(","); const body = rows.map((r) => columns.map((c) => esc(formatValue(r[c.key], c.fmt))).join(",") ); // The BOM is what makes Excel read UTF-8 instead of the system codepage. const csv = "" + [head, ...body].join("\r\n"); return URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" })); }, [rows, columns]); return ( Download CSV {note ? {note} : null} ); } // ------------------------------------------------------------------- DataTable /** * The table block. * * ⛔ THE CAP DISCLOSURE IS NOT DECORATION. `shown`/`total` come from the SERVER * and are rendered verbatim. Deriving "showing N of M" from `rows.length` would * make the disclosure agree with the truncated list by construction — it would * read correct on a lie. Owner rule [[no-unverifiable-aggregates]]: a number a * user cannot open to exact rows is a defect, and a count that cannot be wrong * is not a check. */ export function DataTable({ columns, rows, drill, shown, total, onOpen, empty, download, }: { columns: TableColumn[]; rows: PageRow[]; drill?: DrillRule; shown?: number; total?: number; onOpen?: OnOpen; empty?: string; download?: { filename: string }; }) { const cap = capDisclosure(shown, total); const capped = cap?.capped ?? false; if (!rows.length) { // An empty state SAYS WHAT EMPTY MEANS. "No data" is the sentence that // sends somebody to check whether the page is broken. return

{emptyText(empty)}

; } return (
{columns.map((c) => ( ))} {rows.map((r, i) => { const d = cellDescriptor(r, drill); return ( {columns.map((c, ci) => { const text = formatValue(r[c.key], c.fmt); const cls = c.align === "right" ? "is-right" : c.align === "center" ? "is-center" : ""; // Only the FIRST column becomes the affordance — a whole row // of links reads as eight destinations, not one. return ( ); })} ); })}
{c.label}
{ci === 0 && d && onOpen ? ( ) : ( text )}
{cap ? ( {cap.text} ) : null} {download ? ( ) : null}
); } // ---------------------------------------------------------------- ClickableList /** * A compact list of entities, each opening its panel — the drawer affordance * from `ui/primitives.py:clickable_list`, for places a full table is too much. * * `kind` is the ENTITY KIND (Y6: `sku` is what Sales needs), and the emitted * descriptor is the same `EntityDescriptor` a table's block-level rule produces * — one grammar, so the shell has one handler and not one per surface. */ export function ClickableList({ rows, kind, onOpen, idKey = "id", labelKey = "label", valueKey, valueFmt, shown, total, empty, }: { rows: PageRow[]; kind: EntityKind; onOpen?: OnOpen; idKey?: string; labelKey?: string; valueKey?: string; valueFmt?: string; shown?: number; total?: number; empty?: string; }) { if (!rows.length) return

{emptyText(empty)}

; const cap = capDisclosure(shown, total); return (
{cap ? {cap.text} : null}
); } // ------------------------------------------------------------- ValidationPanel /** * Every headline number reconciled to an independent aggregate — the platform's * standing rule ("a number that doesn't tie to Odoo does not ship") made * visible to the person reading the page rather than only to `validate.py`. * * A FAILING check is shown, never hidden: the panel exists to be believed, and * a panel that only ever shows green is decoration. */ export function ValidationPanel({ checks }: { checks: ValidationCheck[] }) { if (!checks.length) return null; const failed = checks.filter((c) => !c.ok).length; return (
0}> {failed ? : } {failed ? `${fmt.int(failed)} of ${fmt.int(checks.length)} reconciliation checks did not tie` : `All ${fmt.int(checks.length)} reconciliation checks tie to Odoo`} {checks.map((c) => ( ))}
Check Expected Actual Note
{c.name} {formatValue(c.expected, c.fmt)} {formatValue(c.actual, c.fmt)} {c.note ?? ""}
); }