// ---------------------------------------------------------------------------
// 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 (
);
}
// -------------------------------------------------------------------- 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
);
}
// ---------------------------------------------------------------- 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 (
{rows.map((r, i) => {
const id = r[idKey];
const label = String(r[labelKey] ?? id ?? BLANK);
const value = valueKey ? formatValue(r[valueKey], valueFmt) : null;
const openable = onOpen && id != null && id !== "";
return (
);
}
// ------------------------------------------------------------- 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`}