| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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"; |
|
|
| |
| |
| export type OnOpen = (d: DrillDescriptor) => void; |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 ( |
| <header className="pg-header"> |
| <div className="pg-title-row"> |
| <h1 className="pg-title">{title}</h1> |
| {controls && controls.length > 0 ? ( |
| <div className="pg-controls"> |
| {controls.map((c) => ( |
| <label key={c.key} className="pg-control"> |
| <span className="pg-control-label">{c.label}</span> |
| <select |
| className="pg-select" |
| value={String(c.value)} |
| disabled={busy || !onControl} |
| onChange={(e) => onControl?.(c.key, e.target.value)} |
| > |
| {/* ⚠ Every option carries an explicit `value`. A <select> |
| whose selected option omits it renders the FIRST option |
| instead — a control that silently lies about the state it |
| is displaying ([[cg-condition-builder-items]]). */} |
| {c.options.map((o) => ( |
| <option key={String(o.value)} value={String(o.value)}> |
| {o.label} |
| </option> |
| ))} |
| </select> |
| </label> |
| ))} |
| </div> |
| ) : null} |
| </div> |
| <div className="pg-rule" /> |
| {subtitle ? <p className="pg-subtitle">{subtitle}</p> : null} |
| {asOf ? ( |
| <p className="pg-asof"> |
| Data pulled <time dateTime={asOf}>{fmt.date(asOf)}</time> |
| </p> |
| ) : null} |
| </header> |
| ); |
| } |
| |
| // --------------------------------------------------------------------- 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 ( |
| <section className="pg-section"> |
| <h2 className="pg-section-label">{label}</h2> |
| {note ? <p className="pg-section-note">{note}</p> : null} |
| {children} |
| </section> |
| ); |
| } |
| |
| // ---------------------------------------------------------------------- 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 ( |
| <div className="pg-kpis"> |
| {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 = ( |
| <> |
| <span className="pg-kpi-label">{m.label}</span> |
| <span className="pg-kpi-value">{value}</span> |
| {deltaText || m.delta_label ? ( |
| <span className={`pg-kpi-delta is-${dir}`}> |
| {deltaText} |
| {deltaText && m.delta_label ? " " : ""} |
| {m.delta_label ?? ""} |
| </span> |
| ) : null} |
| </> |
| ); |
| const key = m.key ?? `${m.label}-${i}`; |
| return m.drill && onOpen ? ( |
| <button |
| key={key} |
| type="button" |
| className="pg-kpi is-clickable" |
| onClick={() => onOpen(m.drill as DrillDescriptor)} |
| > |
| {body} |
| </button> |
| ) : ( |
| <div key={key} className="pg-kpi"> |
| {body} |
| </div> |
| ); |
| })} |
| </div> |
| ); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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 ( |
| <a className="pg-download" href={href} download={`${filename}.csv`}> |
| <DownloadIcon /> |
| <span>Download CSV</span> |
| {note ? <span className="pg-download-note">{note}</span> : null} |
| </a> |
| ); |
| } |
| |
| // ------------------------------------------------------------------- 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 <p className="pg-empty">{emptyText(empty)}</p>; |
| } |
| |
| return ( |
| <div className="pg-table-wrap"> |
| <table className="pg-table"> |
| <thead> |
| <tr> |
| {columns.map((c) => ( |
| <th |
| key={c.key} |
| className={c.align === "right" ? "is-right" : c.align === "center" ? "is-center" : ""} |
| scope="col" |
| > |
| {c.label} |
| </th> |
| ))} |
| </tr> |
| </thead> |
| <tbody> |
| {rows.map((r, i) => { |
| const d = cellDescriptor(r, drill); |
| return ( |
| <tr key={String(r[columns[0]?.key] ?? i)} className={d && onOpen ? "is-clickable" : ""}> |
| {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 ( |
| <td key={c.key} className={cls}> |
| {ci === 0 && d && onOpen ? ( |
| <button type="button" className="pg-cell-link" onClick={() => onOpen(d)}> |
| {text} |
| </button> |
| ) : ( |
| text |
| )} |
| </td> |
| ); |
| })} |
| </tr> |
| ); |
| })} |
| </tbody> |
| </table> |
| <div className="pg-table-foot"> |
| {cap ? ( |
| <span className={cap.capped ? "pg-shown is-capped" : "pg-shown"}>{cap.text}</span> |
| ) : null} |
| {download ? ( |
| <Download |
| rows={rows} |
| columns={columns} |
| filename={download.filename} |
| note={capped ? "the rows shown" : undefined} |
| /> |
| ) : null} |
| </div> |
| </div> |
| ); |
| } |
| |
| // ---------------------------------------------------------------- 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 <p className="pg-empty">{emptyText(empty)}</p>; |
| const cap = capDisclosure(shown, total); |
| return ( |
| <div className="pg-list"> |
| <ul className="pg-list-items"> |
| {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 ( |
| <li key={String(id ?? i)} className="pg-list-item"> |
| {openable ? ( |
| <button |
| type="button" |
| className="pg-cell-link" |
| onClick={() => |
| onOpen({ kind, id: id as string | number, label }) |
| } |
| > |
| {label} |
| </button> |
| ) : ( |
| <span>{label}</span> |
| )} |
| {value != null ? <span className="pg-list-value">{value}</span> : null} |
| </li> |
| ); |
| })} |
| </ul> |
| {cap ? <span className={cap.capped ? "pg-shown is-capped" : "pg-shown"}>{cap.text}</span> : null} |
| </div> |
| ); |
| } |
| |
| // ------------------------------------------------------------- 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 ( |
| <details className="pg-validation" open={failed > 0}> |
| <summary className={failed ? "is-failed" : "is-ok"}> |
| {failed ? <AlertIcon /> : <CheckIcon />} |
| <span> |
| {failed |
| ? `${fmt.int(failed)} of ${fmt.int(checks.length)} reconciliation checks did not tie` |
| : `All ${fmt.int(checks.length)} reconciliation checks tie to Odoo`} |
| </span> |
| </summary> |
| <table className="pg-table pg-validation-table"> |
| <thead> |
| <tr> |
| <th scope="col">Check</th> |
| <th scope="col" className="is-right">Expected</th> |
| <th scope="col" className="is-right">Actual</th> |
| <th scope="col">Note</th> |
| </tr> |
| </thead> |
| <tbody> |
| {checks.map((c) => ( |
| <tr key={c.name} className={c.ok ? "" : "is-failed"}> |
| <td>{c.name}</td> |
| <td className="is-right">{formatValue(c.expected, c.fmt)}</td> |
| <td className="is-right">{formatValue(c.actual, c.fmt)}</td> |
| <td>{c.note ?? ""}</td> |
| </tr> |
| ))} |
| </tbody> |
| </table> |
| </details> |
| ); |
| } |
| |