Signal/signal-ui/src/App.jsx
Kisa 6181de74ed feat: patient-nested worklist — the true-green display (plan 02, P6)
The worklist now shows one row per patient carrying the rollup verdict,
expanding into device coverage lines with doc checklists and rule citations.
Green is earned only from the readiness verdict. Kisa-approved design
(mockup v2, decisions A/B/C/D/E locked 2026-07-07): purple Plan Type Needed
badge + Map-the-plan-type CTA; ◇ column-not-mapped chips; not-processed
strip with download; Data-gaps filter (honest labels, no sixth status);
extended legend; per-line work-queue export (backend lines[] support,
audit-logged). Tabs count patients. SWO/PA cycling is device-scoped and
returns the re-rolled patient through the P4 echo contract; Confirm Visit
fans out across the patient's echoed lines. Legacy table remains at
?legacy=1 (its DOCS_REQUIRED stopgap intentionally kept while it exists —
literal deviation from the plan's remove-in-same-change line, logged).
Independent audit: SHIP; its MEDIUM (line-identity merge collision) and two
LOWs fixed in-commit. 171 backend tests green; frontend builds clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 06:37:04 -04:00

217 lines
No EOL
8.4 KiB
JavaScript

import { useState, useCallback, useRef } from "react";
import { SignIn, UserButton, OrganizationSwitcher, useAuth } from "@clerk/react";
import { ThemeProvider } from "./ThemeProvider";
import Sidebar from "./components/Sidebar";
import StatCard from "./components/StatCard";
import WorklistTable from "./components/WorklistTable";
import PatientWorklist from "./components/PatientWorklist";
import CSVImport from "./components/CSVImport";
import CSVExport from "./components/CSVExport";
import ThemeToggle from "./components/ThemeToggle";
import Toast from "./components/Toast";
import EmptyState from "./components/EmptyState";
import Privacy from "./Privacy";
import { showToast } from "./lib/toast";
import { apiRecordToLocal } from "./lib/api";
import { parseCSV, processBatch } from "./lib/coverage";
function AppInner() {
const { getToken } = useAuth();
const [records, setRecords] = useState([]);
const [uploadData, setUploadData] = useState(null); // full API payload (P6)
const [batchId, setBatchId] = useState(null);
const [activeFilter, setActiveFilter] = useState("all");
const [importLabel, setImportLabel] = useState("No data imported");
const csvImportRef = useRef(null);
// The nested patient view is the door (P6, Kisa-approved); ?legacy=1 keeps
// the old row-keyed table reachable during the transition (Phase 2b).
const useLegacyView =
new URLSearchParams(window.location.search).get("legacy") === "1";
const supplyLapsed = records.filter((r) => r.flag === "SUPPLY_LAPSED").length;
const visitRequired = records.filter((r) => r.flag === "VISIT_REQUIRED").length;
const renewalCritical = records.filter((r) => r.flag === "RENEWAL_CRITICAL").length;
const renewalElevated = records.filter((r) => r.flag === "RENEWAL_ELEVATED").length;
const renewalSoon = records.filter((r) => r.flag === "RENEWAL_SOON").length;
const transferPending = records.filter((r) => r.flag === "TRANSFER_PENDING").length;
const refill = records.filter((r) => r.flag === "RESUPPLY_READY" && !(r.cascade?.length > 0)).length;
const okCount = records.filter((r) => r.flag === "ACTIVE").length;
// escalateCount = VISIT_REQUIRED (qualifying visit past due) + RENEWAL_CRITICAL (<=45 days)
const escalateCount = visitRequired + renewalCritical;
// outreachCount = RENEWAL_ELEVATED (<=60d) + RENEWAL_SOON (<=90d)
const outreachCount = renewalElevated + renewalSoon;
const atRiskCount = supplyLapsed + visitRequired + renewalCritical;
const actionNeededCount = renewalSoon + renewalElevated + transferPending;
// handleResults receives (data, file) from CSVImport after the upload (and optional
// mapping review) step is complete. data is the backend API response, or null if
// the backend was unreachable, in which case we fall back to local processing.
const handleResults = useCallback((data, file) => {
const label = new Date().toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
if (data) {
const results = data.records.map(apiRecordToLocal);
setRecords(results);
setUploadData(data);
setBatchId(data.batch_id || null);
setImportLabel(`${file.name} · ${label} · via Signal API`);
let msg = `Loaded ${data.total} patient${data.total !== 1 ? "s" : ""} from ${file.name}`;
if (data.skipped) {
msg += ` · ${data.skipped} row${data.skipped !== 1 ? "s" : ""} skipped`;
if (data.skipped_reasons?.length) {
const first = data.skipped_reasons[0];
const more = data.skipped_reasons.length - 1;
msg += `${first}${more > 0 ? ` (+${more} more)` : ""}`;
}
}
showToast(msg);
return;
}
// Backend unreachable — process locally (legacy table renders; the
// nested view needs the API's patients payload)
setBatchId(null);
setUploadData(null);
const reader = new FileReader();
reader.onload = (e) => {
const rows = parseCSV(e.target.result);
const { results, skipped } = processBatch(rows);
setRecords(results);
setImportLabel(`${file.name} · ${label}`);
let msg = `Loaded ${results.length} patient${results.length !== 1 ? "s" : ""} from ${file.name}`;
if (skipped.length) msg += ` · ${skipped.length} skipped`;
showToast(msg);
};
reader.readAsText(file);
}, []);
return (
<div className="flex w-full min-h-screen bg-[var(--bg-page)] text-[var(--text-primary)] transition-colors">
{/* Sidebar */}
<Sidebar
atRiskCount={atRiskCount}
actionNeededCount={actionNeededCount}
clearToShipCount={refill}
onTrackCount={okCount}
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
onImportClick={() => csvImportRef.current?.trigger()}
/>
{/* Hidden CSV import trigger — handles upload, mapping review, and results */}
<CSVImport
ref={csvImportRef}
onResults={handleResults}
getToken={getToken}
/>
{/* Main */}
<main className="ml-[240px] flex-1 flex flex-col">
{/* Topbar */}
<header className="bg-[var(--bg-card)] border-b border-[var(--border-color)] px-7 h-[60px] flex items-center justify-between sticky top-0 z-10 transition-colors">
<div className="flex items-center gap-4">
<h1 className="font-heading font-bold text-lg text-[var(--text-heading)]">
Outreach Worklist
</h1>
<span className="font-mono text-[11px] text-[var(--text-warm)] px-2 py-[3px] bg-[var(--bg-elevated)] rounded">
{importLabel}
</span>
</div>
<div className="flex items-center gap-[10px]">
<ThemeToggle />
<CSVExport
records={records}
patients={!useLegacyView ? uploadData?.patients : null}
batchId={batchId}
/>
<OrganizationSwitcher hidePersonal />
<UserButton />
</div>
</header>
{/* Content */}
{records.length === 0 ? (
<EmptyState
onOpenFile={() => csvImportRef.current?.trigger()}
onDropFile={(file) => csvImportRef.current?.uploadFile(file)}
/>
) : (
<div className="p-7">
{/* Stats row */}
<div className="grid grid-cols-3 gap-4 mb-6">
<StatCard
priority
label="Needs Attention"
value={supplyLapsed + visitRequired + renewalCritical + transferPending}
sub={`${supplyLapsed} docs required · ${visitRequired + renewalCritical} need escalation · ${transferPending} new patient`}
/>
<StatCard
label="Outreach in Progress"
value={outreachCount}
sub={`${renewalElevated} confirm appointment · ${renewalSoon} ready for outreach`}
/>
<StatCard
label="Clear to Ship"
value={refill}
sub={`${refill} patients in resupply window · ${okCount} on track`}
/>
</div>
{/* Worklist — nested patient view (P6) unless ?legacy=1 or the
payload lacks patients (local fallback processing) */}
{!useLegacyView && uploadData?.patients?.length ? (
<PatientWorklist
data={uploadData}
onImportClick={() => csvImportRef.current?.trigger()}
/>
) : (
<WorklistTable
records={records}
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
onVisitConfirmed={(patientId, updatedRecord) =>
setRecords(prev =>
prev.map(r => r.patient_id === patientId ? { ...r, ...updatedRecord } : r)
)
}
/>
)}
</div>
)}
</main>
<Toast />
</div>
);
}
export default function App() {
const { isSignedIn, isLoaded } = useAuth();
// Public routes — accessible without authentication
if (window.location.pathname === "/privacy") {
return <Privacy />;
}
if (!isLoaded) {
return <div className="flex items-center justify-center min-h-screen bg-[#F0EAE1]" />;
}
if (!isSignedIn) {
return (
<div className="flex items-center justify-center min-h-screen bg-[#F0EAE1]">
<SignIn routing="hash" />
</div>
);
}
return (
<ThemeProvider>
<AppInner />
</ThemeProvider>
);
}