Lets users switch between orgs (e.g. STTIL Solutions, Gaboro DME) directly from the top bar without going into account settings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
159 lines
No EOL
5.4 KiB
JavaScript
159 lines
No EOL
5.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 CSVImport from "./components/CSVImport";
|
|
import CSVExport from "./components/CSVExport";
|
|
import ThemeToggle from "./components/ThemeToggle";
|
|
import Toast from "./components/Toast";
|
|
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 ooc = records.filter((r) => r.flag === "OUT_OF_COVERAGE").length;
|
|
const visitDue = records.filter((r) => r.flag === "VISIT_DUE").length;
|
|
const refill = records.filter((r) => r.flag === "REFILL_WINDOW").length;
|
|
const okCount = records.filter((r) => r.flag === "OK").length;
|
|
const urgent = ooc + visitDue;
|
|
|
|
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
|
|
oocCount={ooc}
|
|
visitDueCount={visitDue}
|
|
refillCount={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="⚡ Prescriber Action"
|
|
value={urgent}
|
|
sub={`${ooc} out of coverage · ${visitDue} visit${visitDue !== 1 ? "s" : ""} due — prescriber contact required`}
|
|
/>
|
|
<StatCard
|
|
label="⚠ Resupply Ready"
|
|
value={refill}
|
|
sub="patients within resupply window — initiate now"
|
|
/>
|
|
<StatCard
|
|
label="✓ Active"
|
|
value={okCount}
|
|
sub="patients with supply on track — no action needed"
|
|
/>
|
|
</div>
|
|
|
|
{/* Worklist */}
|
|
<WorklistTable
|
|
records={records}
|
|
activeFilter={activeFilter}
|
|
onFilterChange={setActiveFilter}
|
|
/>
|
|
</div>
|
|
</main>
|
|
|
|
<Toast />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function App() {
|
|
const { isSignedIn, isLoaded } = useAuth();
|
|
|
|
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>
|
|
);
|
|
} |