- WorklistTable: consolidate 9 filter tabs to 8 matching 7-state labels (Docs Required, Escalate, Confirm Appt., Begin Outreach, Clear to Ship, New Patient, On Track) + add combined "escalate" filter key for RENEWAL_CRITICAL + VISIT_REQUIRED - Sidebar: fix broken filter keys (REFILL_WINDOW/VISIT_DUE/OUT_OF_COVERAGE never matched any r.flag value) - replace with SUPPLY_LAPSED, escalate, RESUPPLY_READY; update labels to 7-state names - App: rename Sidebar props to match new framework; update stat cards to Needs Attention / Outreach in Progress / Clear to Ship Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
170 lines
No EOL
6 KiB
JavaScript
170 lines
No EOL
6 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 CSVImport from "./components/CSVImport";
|
|
import CSVExport from "./components/CSVExport";
|
|
import ThemeToggle from "./components/ThemeToggle";
|
|
import Toast from "./components/Toast";
|
|
import Privacy from "./Privacy";
|
|
import { showToast } from "./lib/toast";
|
|
import { uploadToBackend, apiRecordToLocal } from "./lib/api";
|
|
import { parseCSV, processBatch } from "./lib/coverage";
|
|
|
|
function AppInner() {
|
|
const { getToken } = useAuth();
|
|
const [records, setRecords] = useState([]);
|
|
const [batchId, setBatchId] = useState(null);
|
|
const [activeFilter, setActiveFilter] = useState("all");
|
|
const [importLabel, setImportLabel] = useState("No data imported");
|
|
const csvImportRef = useRef(null);
|
|
|
|
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").length;
|
|
const okCount = records.filter((r) => r.flag === "ACTIVE").length;
|
|
const escalateCount = visitRequired + renewalCritical;
|
|
const outreachCount = renewalSoon + renewalElevated;
|
|
|
|
const handleResults = useCallback(async (file) => {
|
|
const label = new Date().toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
|
|
const token = await getToken().catch(() => null);
|
|
const data = await uploadToBackend(file, token);
|
|
|
|
if (data) {
|
|
const results = data.records.map(apiRecordToLocal);
|
|
setRecords(results);
|
|
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} skipped`;
|
|
showToast(msg);
|
|
return;
|
|
}
|
|
|
|
// Backend unreachable — process locally
|
|
setBatchId(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} · local processing`);
|
|
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);
|
|
}, [getToken]);
|
|
|
|
return (
|
|
<div className="flex w-full min-h-screen bg-[var(--bg-page)] text-[var(--text-primary)] transition-colors">
|
|
{/* Sidebar */}
|
|
<Sidebar
|
|
supplyLapsedCount={supplyLapsed}
|
|
escalateCount={escalateCount}
|
|
clearToShipCount={refill}
|
|
activeFilter={activeFilter}
|
|
onFilterChange={setActiveFilter}
|
|
onImportClick={() => csvImportRef.current?.trigger()}
|
|
/>
|
|
|
|
{/* Hidden CSV import trigger */}
|
|
<CSVImport
|
|
ref={csvImportRef}
|
|
onResults={handleResults}
|
|
/>
|
|
|
|
{/* 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-muted)] px-2 py-[3px] bg-[var(--bg-elevated)] rounded">
|
|
{importLabel}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-[10px]">
|
|
<ThemeToggle />
|
|
<CSVExport records={records} batchId={batchId} />
|
|
<OrganizationSwitcher hidePersonal />
|
|
<UserButton />
|
|
</div>
|
|
</header>
|
|
|
|
{/* Content */}
|
|
<div className="p-7">
|
|
{/* Stats row */}
|
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
|
<StatCard
|
|
priority
|
|
label="Needs Attention"
|
|
value={supplyLapsed + escalateCount + transferPending}
|
|
sub={`${supplyLapsed} docs required · ${escalateCount} escalate · ${transferPending} new patient`}
|
|
/>
|
|
<StatCard
|
|
label="Outreach in Progress"
|
|
value={outreachCount}
|
|
sub={`${renewalElevated} confirm appt. · ${renewalSoon} begin outreach`}
|
|
/>
|
|
<StatCard
|
|
label="Clear to Ship"
|
|
value={refill}
|
|
sub={`${refill} patients in resupply window · ${okCount} on track`}
|
|
/>
|
|
</div>
|
|
|
|
{/* Worklist */}
|
|
<WorklistTable
|
|
records={records}
|
|
activeFilter={activeFilter}
|
|
onFilterChange={setActiveFilter}
|
|
/>
|
|
</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>
|
|
);
|
|
} |