// --------------------------------------------------------------------------- // pages / PageSurface.tsx — EXIT wave 2 (W2-7). // // The stateful half: fetch a Y1 envelope, keep the controls honest, hold the // drill panel. `PageView` stays pure so it can be reasoned about (and gated) // without a network; everything that can fail lives here. // // ⚠ CONTROL STATE COMES FROM THE RESPONSE, NOT FROM THE CLICK (Y1 rule 9). The // select is rendered from `controls[].value` in the payload the server just // sent, so a `bu` the server rejected or coerced cannot look accepted — the // picker visibly snaps back. Optimistically holding the clicked value would // show a Royal-only user the word "All" over Royal-only numbers, which is the // single most expensive lie this page could tell. // // ⚠ An in-flight refetch keeps the OLD page on screen, dimmed, rather than // blanking to a spinner. The first `/pages/sales` per scope is genuinely slow // (it runs `sales.validate()`), and a page that empties itself on every BU // change reads as broken long before it reads as loading. // --------------------------------------------------------------------------- import { useCallback, useEffect, useState } from "react"; import { UNAUTHORIZED_EVENT, signal } from "../apiContract"; import type { DrillDescriptor, PageEnvelope } from "../ui/types"; import { DrillPanel } from "./DrillPanel"; import { PageView } from "./PageView"; import { fetchPage } from "./pageApi"; type State = | { phase: "loading" } | { phase: "ready"; page: PageEnvelope } | { phase: "error"; message: string }; export function PageSurface({ pageKey, label }: { pageKey: string; label: string }) { const [state, setState] = useState({ phase: "loading" }); const [params, setParams] = useState>({}); const [busy, setBusy] = useState(false); const [drill, setDrill] = useState(null); // `params` is serialised into the dependency so a NEW object with the same // contents does not refetch — the same reason the shell keys its nav effect // on the username rather than the session object. const paramKey = JSON.stringify(params); useEffect(() => { let dead = false; setBusy(true); void fetchPage(pageKey, JSON.parse(paramKey) as Record).then((r) => { if (dead) return; setBusy(false); if (r.ok) { setState({ phase: "ready", page: r.page }); return; } // 401 is not "this page is broken", it is "the session died under us". // The shell owns that transition; conflating the two would leave a // signed-out user staring at an error panel about Sales. if (r.status === 401) { signal(UNAUTHORIZED_EVENT); return; } setState({ phase: "error", message: r.message }); }); return () => { dead = true; }; }, [pageKey, paramKey]); // Changing a control resets the drill: a panel describing last month's // Fisch revenue, still open over Royal numbers, is a wrong answer that looks // like a right one. const onControl = useCallback((key: string, value: string) => { setDrill(null); setParams((p) => ({ ...p, [key]: value })); }, []); if (state.phase === "loading") { return (
{/* ⚠ THE TITLE STAYS, THE SENTENCE GOES (wave 17 item 3/R6). R6 deletes loading COPY — "Loading live figures from Odoo. The first read of a new scope takes a moment." was an apology for a wait the mark states without excusing. The heading is not loading copy: it is this page's name, already chosen in the rail, and dropping it would make arrival a title POP-IN on a surface that had been anonymous. */}

{label}

); } if (state.phase === "error") { return (

{label}

{state.message}

); } return (
{drill ? setDrill(null)} /> : null}
); }