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>
217 lines
No EOL
8.4 KiB
JavaScript
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>
|
|
);
|
|
} |