const { useState, useEffect, useCallback, useRef } = React; /** * BH Content Creator — Clinical Review Queue * Production reviewer interface. v1.0 · 2026-08-15 * * Talks to the API in api/. Set API_BASE, serve behind the same origin or a * CORS allowlist. This is not an artifact demo — it will show a connection * error until the API is running. * * WHAT THIS SCREEN IS * * Not a CMS. The reviewer's job is adjudicating assertions and then binding * their licence to a specific text. The nearest honest analogue is a document * execution page: enumerated exhibits, an explicit ruling on each, then a * signature that names what it covers. * * There IS an edit affordance, deliberately narrow: anyone holding draft.edit * (owner, operator, clinical director, compliance — never viewer) can revise * the body text in place. Saving recomputes the content hash on the server; * if the page was signed, that signature is voided on the record and the page * returns to the queue. A reviewer may sign their own revision; the record * states that they were also the editor. * * Three consequences run through the layout: * * 1. Claims lead, prose supports. The ledger is the left column and the * draft is reference material on the right. A queue that opens on body * copy invites proofreading, which is not the job. * * 2. The signature block is always visible and always states what is missing. * It shows the reviewer's own licence and expiry, the content hash, and * the number of unruled claims. It stays inert with a reason rather than * going grey silently. * * 3. The content hash is rendered, in mono, as a seal. It is the answer to * "what exactly did they read," and putting it on screen makes the binding * legible instead of implied. * * There is no approve-all control, and adding one would defeat the queue. */ const API_BASE = ""; // ─── Design tokens ────────────────────────────────────────────────────────── // Palette: cold document ink on cool paper, with an oxblood reserved solely for // the act of signing. Oxblood appears nowhere else in the interface — it means // "this binds you," and spending it on a secondary button would dilute the one // signal that matters. const CSS = ` @import url('https://fonts.googleapis.com/css2?family=Source+Serif+4:opsz,wght@8..60,400;8..60,600;8..60,700&family=Public+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap'); *, *::before, *::after { box-sizing: border-box; } body { margin: 0; } .rq { --ink: #12191F; --ink-soft: #48565F; --ink-faint: #7C8B95; --paper: #EEF1F4; --card: #FFFFFF; --rule: #D6DDE4; --rule-soft: #E7ECF0; --seal: #8C2F39; --seal-dim: #8C2F390F; --sourced: #1E6B4F; --sourced-dim:#1E6B4F14; --judgment: #8A5B0B; --judgment-dim:#8A5B0B14; --blocked: #8C2F39; --focus: #1B3A5C; font-family: 'Public Sans', system-ui, sans-serif; color: var(--ink); background: var(--paper); min-height: 100vh; font-size: 15px; line-height: 1.55; -webkit-font-smoothing: antialiased; } .rq :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } @media (prefers-reduced-motion: reduce) { .rq *, .rq *::before, .rq *::after { animation: none !important; transition: none !important; } } .mono { font-family: 'IBM Plex Mono', ui-monospace, monospace; } .serif { font-family: 'Source Serif 4', Georgia, serif; } /* ── Masthead ── */ .rq-head { background: var(--ink); color: #EEF1F4; padding: 0 22px; height: 54px; display: flex; align-items: center; gap: 18px; position: sticky; top: 0; z-index: 30; } .rq-mark { font-family: 'Source Serif 4', serif; font-weight: 600; font-size: 15px; letter-spacing: -0.01em; } .rq-mark span { color: #8FA3AF; font-weight: 400; } .rq-facility { font-size: 12px; color: #8FA3AF; border-left: 1px solid #33424C; padding-left: 18px; } .rq-head-right { margin-left: auto; display: flex; align-items: center; gap: 14px; font-size: 12px; } .rq-who { text-align: right; line-height: 1.3; } .rq-who b { display: block; font-weight: 600; font-size: 12.5px; } .rq-who span { color: #8FA3AF; font-size: 11px; } /* ── Capacity strip ── */ .rq-capacity { background: #1B242C; color: #A9B8C2; padding: 6px 22px; font-size: 11.5px; display: flex; gap: 20px; align-items: center; border-bottom: 1px solid #2A343D; } .rq-capacity b { color: #EEF1F4; font-weight: 600; } .rq-capacity .warn { color: #E0A458; } /* ── Shell ── */ .rq-shell { display: grid; grid-template-columns: 268px 1fr; min-height: calc(100vh - 54px); } /* ── Worklist ── */ .rq-list { background: var(--card); border-right: 1px solid var(--rule); overflow-y: auto; } .rq-list-head { padding: 14px 18px 10px; border-bottom: 1px solid var(--rule); position: sticky; top: 0; background: var(--card); } .rq-list-head h2 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.09em; color: var(--ink-faint); margin: 0 0 3px; font-weight: 600; } .rq-list-head p { margin: 0; font-size: 13px; color: var(--ink-soft); } .rq-item { display: block; width: 100%; text-align: left; padding: 13px 18px; border: 0; border-bottom: 1px solid var(--rule-soft); border-left: 3px solid transparent; background: transparent; cursor: pointer; font: inherit; } .rq-item:hover { background: #F7F9FB; } .rq-item[aria-current="true"] { background: #F2F6F9; border-left-color: var(--ink); } .rq-item-type { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-faint); font-weight: 600; } .rq-item-title { font-family: 'Source Serif 4', serif; font-size: 15px; font-weight: 600; margin: 2px 0 4px; line-height: 1.3; } .rq-item-meta { font-size: 11.5px; color: var(--ink-faint); display: flex; gap: 10px; } .rq-item-meta .stale { color: var(--judgment); font-weight: 600; } /* ── Packet ── */ .rq-packet { display: grid; grid-template-rows: auto 1fr auto; min-width: 0; } .rq-packet-head { background: var(--card); border-bottom: 1px solid var(--rule); padding: 18px 26px 14px; } .rq-eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: 0.09em; color: var(--ink-faint); font-weight: 600; } .rq-title { font-family: 'Source Serif 4', serif; font-size: 25px; font-weight: 600; margin: 4px 0 8px; line-height: 1.2; letter-spacing: -0.01em; } .rq-facts { display: flex; flex-wrap: wrap; gap: 8px 20px; font-size: 12px; color: var(--ink-soft); } .rq-facts b { font-weight: 600; color: var(--ink); } .rq-cols { display: grid; grid-template-columns: minmax(0,1.05fr) minmax(0,1fr); gap: 0; min-height: 0; } .rq-ledger { padding: 20px 26px 40px; overflow-y: auto; border-right: 1px solid var(--rule); } .rq-draft { padding: 20px 26px 40px; overflow-y: auto; background: #F7F9FB; } .rq-sec { font-size: 11px; text-transform: uppercase; letter-spacing: 0.09em; color: var(--ink-faint); font-weight: 600; margin: 0 0 12px; } /* ── Claims ledger ── Numbered because these ARE an enumerated evidentiary record — exhibit numbers, not decoration. The reviewer refers to "claim 3" when they request changes, and the number has to mean something. */ .rq-claim { background: var(--card); border: 1px solid var(--rule); border-radius: 3px; padding: 13px 15px; margin-bottom: 10px; display: grid; grid-template-columns: 26px 1fr; gap: 12px; } .rq-claim.ruled { border-color: var(--rule-soft); background: #FCFDFD; } .rq-claim-n { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--ink-faint); padding-top: 2px; } .rq-claim-text { font-family: 'Source Serif 4', serif; font-size: 15.5px; line-height: 1.45; margin: 0 0 8px; } .rq-claim-ask { font-size: 12.5px; color: var(--ink-soft); margin: 0 0 10px; } .rq-tag { display: inline-block; font-size: 10px; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; padding: 2px 7px; border-radius: 2px; margin-right: 6px; } .tag-sourced { background: var(--sourced-dim); color: var(--sourced); } .tag-judgment { background: var(--judgment-dim); color: var(--judgment); } .tag-blocked { background: var(--seal-dim); color: var(--blocked); } .rq-source { font-size: 12px; color: var(--ink-soft); margin: 0 0 10px; } .rq-source a { color: var(--focus); } .rq-source .q { font-family: 'Source Serif 4', serif; font-style: italic; } .rq-rulings { display: flex; gap: 6px; } .rq-rule-btn { font: inherit; font-size: 12px; font-weight: 600; padding: 5px 11px; border-radius: 2px; cursor: pointer; border: 1px solid var(--rule); background: var(--card); color: var(--ink-soft); } .rq-rule-btn:hover { border-color: var(--ink-soft); } .rq-rule-btn[aria-pressed="true"].accept { background: var(--sourced); border-color: var(--sourced); color: #fff; } .rq-rule-btn[aria-pressed="true"].reject { background: var(--seal); border-color: var(--seal); color: #fff; } /* ── Draft body ── */ .rq-body { font-family: 'Source Serif 4', serif; font-size: 16px; line-height: 1.62; color: #1B242C; } .rq-body h2 { font-size: 17px; font-weight: 700; margin: 22px 0 7px; } .rq-body p { margin: 0 0 13px; } /* ── Signature block — the signature element of this interface ── Always visible, always states what is missing. It shows the reviewer their own licence and the hash of the exact text they are binding it to. */ .rq-sign { background: var(--card); border-top: 2px solid var(--ink); padding: 14px 26px; display: flex; align-items: center; gap: 22px; position: sticky; bottom: 0; } .rq-sign-id { min-width: 0; } .rq-sign-id b { font-size: 13.5px; font-weight: 600; display: block; } .rq-sign-id span { font-size: 11.5px; color: var(--ink-soft); } .rq-seal { border-left: 1px solid var(--rule); padding-left: 22px; font-size: 11px; color: var(--ink-faint); line-height: 1.4; } .rq-seal .h { font-family: 'IBM Plex Mono', monospace; font-size: 12.5px; color: var(--ink); letter-spacing: 0.02em; } .rq-sign-state { margin-left: auto; text-align: right; font-size: 12px; color: var(--ink-soft); max-width: 260px; } .rq-sign-state.blocked { color: var(--judgment); } .rq-btn { font: inherit; font-weight: 600; font-size: 14px; padding: 11px 22px; border-radius: 2px; cursor: pointer; border: 1px solid transparent; } .rq-btn-sign { background: var(--seal); color: #fff; } .rq-btn-sign:hover:not(:disabled) { background: #7A2831; } .rq-btn-sign:disabled { background: #C9D1D8; color: #7C8B95; cursor: not-allowed; } .rq-btn-ghost { background: transparent; border-color: var(--rule); color: var(--ink-soft); } .rq-btn-ghost:hover { border-color: var(--ink-soft); color: var(--ink); } /* ── Notices ── */ .rq-notice { padding: 11px 15px; border-radius: 3px; font-size: 13px; margin-bottom: 14px; border: 1px solid; } .notice-warn { background: var(--judgment-dim); border-color: #D8B87A; color: #6B4708; } .notice-stop { background: var(--seal-dim); border-color: #D7A2A8; color: #6E242C; } .notice-info { background: #EDF3F8; border-color: #B9CEDE; color: #244258; } .rq-notice b { font-weight: 600; } /* ── Elevation dialog ── */ .rq-scrim { position: fixed; inset: 0; background: rgba(18,25,31,0.55); display: grid; place-items: center; z-index: 60; padding: 20px; } .rq-dialog { background: var(--card); border-radius: 4px; padding: 26px; width: 100%; max-width: 430px; box-shadow: 0 18px 50px rgba(18,25,31,0.28); } .rq-dialog h2 { font-family: 'Source Serif 4', serif; font-size: 21px; margin: 0 0 8px; font-weight: 600; } .rq-dialog p { font-size: 13.5px; color: var(--ink-soft); margin: 0 0 16px; } .rq-field { display: block; margin-bottom: 14px; } .rq-field span { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-faint); font-weight: 600; margin-bottom: 5px; } .rq-input { width: 100%; font: inherit; font-size: 14px; padding: 9px 11px; border: 1px solid var(--rule); border-radius: 2px; background: #FBFCFD; } .rq-input:focus { border-color: var(--focus); } .rq-dialog-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 18px; } .rq-err { font-size: 13px; color: var(--blocked); margin: 0 0 12px; } /* ── Sign-in ── */ .rq-signin { min-height: 100vh; display: grid; place-items: center; padding: 20px; } .rq-signin-card { background: var(--card); border: 1px solid var(--rule); border-radius: 4px; padding: 34px; width: 100%; max-width: 400px; } .rq-signin-card h1 { font-family: 'Source Serif 4', serif; font-size: 24px; margin: 0 0 4px; font-weight: 600; } .rq-signin-card > p { font-size: 13px; color: var(--ink-soft); margin: 0 0 22px; } /* ── Empty ── */ .rq-empty { display: grid; place-items: center; padding: 70px 26px; text-align: center; } .rq-empty p { max-width: 380px; color: var(--ink-soft); font-size: 14px; } .rq-empty h2 { font-family: 'Source Serif 4', serif; font-size: 21px; font-weight: 600; margin: 0 0 6px; } /* Readiness */ .rq-ready { display: flex; gap: 22px; align-items: center; margin-top: 14px; padding: 12px 14px; border: 1px solid var(--rule); border-radius: 6px; background: var(--card); } .rq-ready-num { min-width: 92px; } .rq-ready-num b { font-family: 'Source Serif 4', serif; font-size: 30px; font-weight: 700; color: var(--ink); line-height: 1; } .rq-ready-num span { font-size: 13px; color: var(--ink-faint); margin-left: 2px; } .rq-ready-cap { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-faint); font-weight: 600; margin-top: 4px; } .rq-ready-dims { flex: 1; display: grid; grid-template-columns: 1fr 1fr; gap: 6px 22px; } .rq-dim { display: grid; grid-template-columns: 120px 1fr 32px; gap: 10px; align-items: center; font-size: 12px; color: var(--ink-soft); } .rq-dim.weakest .rq-dim-label { color: var(--ink); font-weight: 600; } .rq-dim-bar { height: 6px; background: var(--rule-soft); border-radius: 3px; overflow: hidden; } .rq-dim-bar i { display: block; height: 100%; background: var(--sourced); } .rq-dim.weakest .rq-dim-bar i { background: #B7791F; } .rq-dim-val { text-align: right; color: var(--ink); } /* Record */ .rq-record { margin-top: 26px; padding-top: 18px; border-top: 1px solid var(--rule); } .rq-rec { padding: 10px 12px; border: 1px solid var(--rule); border-radius: 6px; margin-bottom: 8px; background: var(--card); font-size: 12px; color: var(--ink-soft); } .rq-rec-head { display: flex; justify-content: space-between; gap: 12px; color: var(--ink); } .rq-rec-head b { font-weight: 600; } .rq-rec-who { margin-top: 3px; } .rq-rec-hash { margin-top: 3px; color: var(--ink-faint); font-size: 11px; } .rq-rec-note { margin-top: 5px; font-style: italic; } .rq-rec.rec-approved { border-left: 3px solid var(--sourced); } .rq-rec.rec-voided { border-left: 3px solid var(--seal); background: var(--seal-dim); } .rq-rec.rec-voided .rq-rec-head b { color: var(--seal); } .tag-editor { background: #B7791F1A; color: #7A4F0E; margin-left: 8px; } /* Editing */ .rq-draft-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; } .rq-draft-head .rq-sec { margin: 0; } .rq-edit-actions { display: inline-flex; gap: 8px; } .rq-btn-sm { padding: 6px 12px; font-size: 12px; } .rq-btn-edit { background: var(--ink); color: #fff; border-color: var(--ink); } .rq-btn-edit:disabled { opacity: 0.55; } .rq-textarea { width: 100%; font-family: 'Source Serif 4', serif; font-size: 16px; line-height: 1.62; color: #1B242C; padding: 10px 12px; border: 1px solid var(--rule); border-radius: 6px; background: var(--card); resize: vertical; margin: 0 0 13px; } .rq-textarea:focus { outline: 2px solid var(--focus); outline-offset: 1px; } .rq-list-head-2 { margin-top: 14px; border-top: 1px solid var(--rule); } @media (max-width: 1080px) { .rq-shell { grid-template-columns: 1fr; } .rq-list { border-right: 0; border-bottom: 1px solid var(--rule); max-height: 210px; } .rq-cols { grid-template-columns: 1fr; } .rq-ledger { border-right: 0; border-bottom: 1px solid var(--rule); } .rq-sign { flex-wrap: wrap; gap: 12px; } .rq-sign-state { margin-left: 0; text-align: left; max-width: none; } } `; // ─── API client ───────────────────────────────────────────────────────────── // One place that knows what each status code means. The interface reacts to // `action` rather than re-deriving intent from the status everywhere. function useApi(token, onSignOut, onNeedElevation) { return useCallback(async (path, { method = "GET", body } = {}) => { const res = await fetch(`${API_BASE}${path}`, { method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body ? { "Content-Type": "application/json" } : {}), }, body: body ? JSON.stringify(body) : undefined, }); const data = await res.json().catch(() => ({})); if (res.ok) return data; // A missing elevation is NOT a sign-out. Prompt in place and keep their spot. if (data.action === "prompt_password") { onNeedElevation?.(); const err = new Error(data.error); err.code = data.code; err.needsElevation = true; throw err; } if (data.action === "sign_in") { onSignOut?.(data.error); } const err = new Error(data.error || `Request failed (${res.status})`); err.code = data.code; err.status = res.status; throw err; }, [token, onSignOut, onNeedElevation]); } // ─── Sign in ──────────────────────────────────────────────────────────────── function SignIn({ onSignedIn }) { // An org-scoped invitation link can carry the organization id (?org=…) so a // reviewer is not asked to type a UUID. Nothing else is read from the URL. const [orgId, setOrgId] = useState(() => { try { return new URLSearchParams(window.location.search).get("org") || ""; } catch { return ""; } }); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const submit = async () => { setBusy(true); setError(null); try { const res = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ orgId, email, password }), }); const data = await res.json(); if (!res.ok) { setError(data.error || "Sign in failed"); setBusy(false); return; } onSignedIn(data); } catch { setError("Cannot reach the review service. Check that the API is running."); } setBusy(false); }; return (

Clinical review

Sign in to review and approve pages before they publish.

{error &&

{error}

}
); } // ─── Elevation dialog ─────────────────────────────────────────────────────── function ElevationDialog({ onConfirm, onCancel, title, error, busy }) { const ref = useRef(null); const [password, setPassword] = useState(""); useEffect(() => { ref.current?.focus(); }, []); return (
e.key === "Escape" && onCancel()}>

Confirm your identity

You are about to attach your licence to {title}. Enter your password to sign. This confirms the signature is yours and not an open browser session.

{error &&

{error}

}
); } // ─── Claims ledger ────────────────────────────────────────────────────────── function ClaimsLedger({ claims, rulings, onRule }) { return ( <>

Claims to adjudicate · {claims.length}

{claims.map((c, i) => { const ruled = rulings[i]; const tag = c.resolution === "PASS" ? ["tag-sourced", "Sourced"] : c.resolution === "BLOCK" ? ["tag-blocked", "Blocked"] : ["tag-judgment", "Needs judgment"]; return (
{String(i + 1).padStart(2, "0")}

{c.claim}

{tag[1]} {c.class === "facility" ? "About us" : "External"}

{c.ask}

{c.boundTo && (

Checked against your profile field {c.boundTo}

)} {c.url && (

{c.source} — view source {c.quote && <>
“{c.quote}”}

)}
); })} ); } // ─── Readiness ────────────────────────────────────────────────────────────── // The composite is shown WITH its four dimensions. A single number invites // trust-and-skim; the weakest dimension is what a reviewer should look at. function Readiness({ readiness }) { if (!readiness || typeof readiness.composite !== "number") return null; const dims = readiness.dimensions || {}; return (
{readiness.composite}/100
{readiness.capped ? "Capped: a dimension scored zero" : "Publish readiness"}
{Object.entries(dims).map(([k, d]) => (
{d.label} {d.value}
))}
); } // ─── Record ───────────────────────────────────────────────────────────────── // The evidentiary chain for this draft, oldest first. Every row names the hash // it covered, so a voided approval is visibly a signature against text that no // longer exists — not an erased one. function Record({ history }) { if (!history || !history.length) return null; const label = { approved: "Signed", voided: "Signature voided", changes_requested: "Sent back", declined: "Declined" }; return (

Record · {history.length}

{history.map((h, i) => (
{label[h.action] || h.action} {new Date(h.at).toLocaleString()}
{h.action === "voided" ? "system" : `${h.by}${h.credentials ? `, ${h.credentials}` : ""}${h.licence ? ` · licence ${h.licence}` : ""}`} {h.signerWasEditor && Signer also edited this revision}
hash {h.contentHash}
{h.note &&
{h.note}
}
))}
); } // ─── Main ─────────────────────────────────────────────────────────────────── function ReviewQueue() { const [session, setSession] = useState(null); const [me, setMe] = useState(null); const [capacity, setCapacity] = useState(null); const [items, setItems] = useState([]); const [resolved, setResolved] = useState([]); const [activeId, setActiveId] = useState(null); const [packet, setPacket] = useState(null); const [rulings, setRulings] = useState({}); const [elevating, setElevating] = useState(false); const [elevError, setElevError] = useState(null); const [busy, setBusy] = useState(false); const [banner, setBanner] = useState(null); // Editing. `draftBody` is the working copy of the body while an edit is open. const [editing, setEditing] = useState(false); const [draftBody, setDraftBody] = useState(null); const signOut = useCallback((reason) => { setSession(null); setMe(null); setPacket(null); setItems([]); setResolved([]); if (reason) setBanner({ kind: "info", text: reason }); }, []); // Stable callbacks, deliberately: useApi memoizes on these, and an inline // arrow here would give every render a new client, re-arm every effect that // depends on it, and cancel the previous load before it could set state. const needElevation = useCallback(() => setElevating(true), []); const api = useApi(session?.token, signOut, needElevation); const loadQueue = useCallback(async (facilityId) => { const [queue, ent] = await Promise.all([ api(`/api/facilities/${facilityId}/queue`), api("/api/entitlements").catch(() => null), ]); setItems(queue.items); setResolved(queue.resolved || []); setCapacity(ent); return queue; }, [api]); const loadPacket = useCallback(async (id) => { const p = await api(`/api/review/${id}`); setPacket(p); return p; }, [api]); // Load identity and worklist useEffect(() => { if (!session) return; let cancelled = false; (async () => { try { const meRes = await api("/api/me"); if (cancelled) return; setMe(meRes); const facilityId = meRes.facilities[0]; if (!facilityId) { setBanner({ kind: "warn", text: "No facility is assigned to your account. Ask your administrator to add one." }); return; } const queue = await loadQueue(facilityId); if (cancelled) return; setActiveId((cur) => cur || queue.items[0]?.id || null); } catch (e) { if (!cancelled) setBanner({ kind: "stop", text: e.message }); } })(); return () => { cancelled = true; }; }, [session, api, loadQueue]); // Load the active packet useEffect(() => { if (!activeId || !session) { setPacket(null); return; } let cancelled = false; (async () => { try { const p = await api(`/api/review/${activeId}`); if (cancelled) return; setPacket(p); setRulings({}); setEditing(false); setDraftBody(null); } catch (e) { if (!cancelled) setBanner({ kind: "stop", text: e.message }); } })(); return () => { cancelled = true; }; }, [activeId, session, api]); const attest = async (action, elevationPassword) => { if (!packet) return; setBusy(true); setElevError(null); try { if (elevationPassword) { const res = await fetch(`${API_BASE}/api/auth/elevate`, { method: "POST", headers: { Authorization: `Bearer ${session.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ password: elevationPassword }), }); if (!res.ok) { const d = await res.json().catch(() => ({})); setElevError(d.error || "That password was not accepted."); setBusy(false); return; } } const r = await api(`/api/review/${packet.id}/attest`, { method: "POST", body: { action, contentHash: packet.contentHash }, }); setElevating(false); // Keep the page on screen after signing. The reviewer should see the // signature land on the record — and see it voided if the text moves. await Promise.all([loadQueue(me.facilities[0]), loadPacket(packet.id)]); setBanner({ kind: "info", text: action === "approved" ? `Page signed. Your licence is recorded against hash ${r.contentHash}.` : "Sent back for changes. The page returns to the queue once it is revised.", }); } catch (e) { if (e.needsElevation) { setBusy(false); return; } // dialog is already open setBanner({ kind: "stop", text: e.message }); setElevating(false); } setBusy(false); }; const beginEdit = () => { setDraftBody((packet.content?.body || []).map((b) => ({ ...b }))); setEditing(true); }; const saveEdit = async () => { if (!packet || !draftBody) return; setBusy(true); try { const content = { ...packet.content, body: draftBody }; const r = await api(`/api/review/${packet.id}/edit`, { method: "POST", body: { content } }); setEditing(false); setDraftBody(null); setRulings({}); await Promise.all([loadQueue(me.facilities[0]), loadPacket(packet.id)]); setBanner({ kind: r.voided ? "warn" : "info", text: r.message }); } catch (e) { setBanner({ kind: "stop", text: e.message }); } setBusy(false); }; if (!session) { return (<>
); } const total = packet?.claims?.length || 0; const ruled = Object.values(rulings).filter(Boolean).length; const rejected = Object.values(rulings).filter((r) => r === "reject").length; const allRuled = total > 0 && ruled === total; const alreadySigned = packet?.state === "approved"; const lastRecord = packet?.history?.[packet.history.length - 1]; const recordLeads = alreadySigned || lastRecord?.action === "voided"; const canSign = packet?.preflight?.canSign && allRuled && rejected === 0 && !alreadySigned && !editing; let signState; if (!packet) signState = "Select a page from the queue."; else if (alreadySigned) signState = "Signed. Any edit to the text voids this signature and returns the page to the queue."; else if (editing) signState = "Finish or cancel the edit before signing."; else if (!packet.preflight.canSign) signState = packet.preflight.reason; else if (!allRuled) signState = `${total - ruled} of ${total} claims still need a ruling.`; else if (rejected > 0) signState = `${rejected} claim${rejected > 1 ? "s" : ""} marked for change. Send it back instead of signing.`; else if (packet.editedBySelf) signState = "Every claim ruled. You authored this revision; the record will say so."; else signState = "Every claim ruled. Ready to sign."; const ListItem = ({ i }) => ( ); return ( <>
BH Content Creator · Clinical review
{items.length} awaiting review
{me?.name} {me?.credentials || me?.role} {me?.licence ? `· ${me.licence}` : ""}
{capacity && (
{capacity.usage.newPages} of {capacity.usage.newPagesLimit} pages generated this period Plan {capacity.entitlements.tier} {capacity.usage.newPagesRemaining === 0 && ( Page limit reached — review continues as normal. )}
)}
{!packet ? (

{items.length ? "Select a page" : "Queue is clear"}

{items.length ? "Choose a page from the queue to review its claims." : "Nothing is waiting for your signature. New drafts appear here as they are generated."}

) : ( <>
{(packet.pageType || "").replace(/_/g, " ")} · {(packet.state || "").replace(/_/g, " ")}

{packet.title}

Target query {packet.targetQuery} Routed to {(packet.route || []).join(" or ").replace(/_/g, " ")} {packet.dualAttestation && Two signatures required} {packet.editedBy && Current revision edited by {packet.editedBy}}
{banner && (
{banner.text}
)} {packet.preflight?.warning && (
{packet.preflight.warning}
)} {!packet.preflight?.canSign && (
You cannot sign this page. {packet.preflight.reason}
)} {/* Claims lead — unless the page's signature is the news. A page that is signed, or whose signature was just voided, should show the record before the exhibits. */} {recordLeads && } setRulings((r) => ({ ...r, [i]: v }))} /> {!recordLeads && }

{editing ? "Editing the page text" : "The page as it will publish"}

{packet.canEdit && !editing && ( )} {editing && ( )}
{editing && (
{alreadySigned ? "This page is signed. Saving any change to the text voids that signature and returns the page to the queue." : "Saving changes the content hash. A reviewer holding the current packet will be refused on sign and must reload."}
)}
{!editing && (packet.content?.body || []).map((b, i) => (
{b.heading &&

{b.heading}

}

{b.content}

))} {editing && (draftBody || []).map((b, i) => (
{b.heading &&

{b.heading}

}