feat: add reason strings per patient, fix export headers, add signal-ui source
- Add _build_reason() to backend — per-patient reason strings with specific day counts (e.g. "Supply lapsed 70 days ago. Prescriber contact required.") - Add reason field to RecordOut model and backend /api/export CSV - Fix export column headers: Coverage End Date → Resupply End Date, Days Until Coverage End → Days Until Resupply End - Pass reason through apiRecordToLocal in frontend api.js - Display reason as muted sub-line under status badge in WorklistTable - Add reason column to client-side CSVExport - Add signal-ui React source to repo (was untracked) - CLAUDE.md: add Billing and CMS integrations to Phase 2 deferred table - research: restore Section 14 stat verification (May 23 recovery) Deployed to Railway production — health check confirmed live. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0af32ec983
commit
a424ac9d13
27 changed files with 3105 additions and 2 deletions
|
|
@ -58,6 +58,8 @@ Two-curve graph showing supplier staff time over months:
|
|||
| Item | Phase | Reason deferred |
|
||||
|------|-------|----------------|
|
||||
| Dexcom OAuth API integration | Phase 2 | Requires vendor agreement + PHI scope expansion |
|
||||
| Billing system API integration (Brightree, Bonafide, Fastrack, etc.) | Phase 2 | Build after pilot reveals which systems suppliers use; vendor agreements required; PHI scope expands significantly |
|
||||
| CMS and contact management integration (Salesforce, etc.) | Phase 2 | Pilot feedback will reveal if suppliers have CMS and what they use; do not assume |
|
||||
| Prescriber fax automation | Phase 2 | Workflow complexity; Level 1 manual outreach sufficient |
|
||||
| Patient-facing SMS (Twilio, not SignalWire) | Phase 3 | PHI transmission to third-party; requires BAA + consent layer |
|
||||
| Consortium strategy (Level 2/3) | 18–24 months | Depends on 15+ paying Level 1 suppliers first |
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ class RecordOut(BaseModel):
|
|||
next_visit_due_date: Optional[str] = None
|
||||
action: str
|
||||
status_label: str
|
||||
reason: str
|
||||
|
||||
|
||||
class UploadResponse(BaseModel):
|
||||
|
|
@ -77,6 +78,27 @@ class UploadResponse(BaseModel):
|
|||
stats: dict
|
||||
|
||||
|
||||
def _build_reason(flag_val: str, days_until_end: int, days_until_visit: Optional[int]) -> str:
|
||||
if flag_val == "OUT_OF_COVERAGE":
|
||||
ago = abs(days_until_end)
|
||||
unit = "day" if ago == 1 else "days"
|
||||
return f"Supply lapsed {ago} {unit} ago. Prescriber contact required before next shipment."
|
||||
if flag_val == "VISIT_DUE":
|
||||
if days_until_visit is not None and days_until_visit <= 0:
|
||||
overdue = abs(days_until_visit)
|
||||
unit = "day" if overdue == 1 else "days"
|
||||
return f"Qualifying visit overdue by {overdue} {unit}. Confirm documentation immediately."
|
||||
if days_until_visit is not None:
|
||||
unit = "day" if days_until_visit == 1 else "days"
|
||||
return f"Qualifying visit due in {days_until_visit} {unit}. Confirm visit documentation before resupply."
|
||||
return "Qualifying visit renewal required. Confirm documentation before resupply."
|
||||
if flag_val == "REFILL_WINDOW":
|
||||
unit = "day" if days_until_end == 1 else "days"
|
||||
return f"Coverage ends in {days_until_end} {unit}. Patient is within resupply window — initiate shipment now."
|
||||
unit = "day" if days_until_end == 1 else "days"
|
||||
return f"Coverage on track. Resupply window opens in approximately {days_until_end} {unit}."
|
||||
|
||||
|
||||
def _to_record_out(r) -> RecordOut:
|
||||
flag_val = r.flag.value if hasattr(r.flag, "value") else str(r.flag)
|
||||
return RecordOut(
|
||||
|
|
@ -93,6 +115,7 @@ def _to_record_out(r) -> RecordOut:
|
|||
next_visit_due_date=r.next_visit_due_date.isoformat() if r.next_visit_due_date else None,
|
||||
action=FLAG_ACTIONS.get(flag_val, "Review"),
|
||||
status_label=FLAG_LABELS.get(flag_val, flag_val),
|
||||
reason=_build_reason(flag_val, r.days_until_coverage_end, r.days_until_visit_due),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -158,10 +181,11 @@ async def export_work_queue(records: list[RecordOut]):
|
|||
"Payer",
|
||||
"Status",
|
||||
"Priority Score",
|
||||
"Days Until Coverage End",
|
||||
"Days Until Resupply End",
|
||||
"Next Visit Due",
|
||||
"Recommended Action",
|
||||
"Coverage End Date",
|
||||
"Resupply End Date",
|
||||
"Reason",
|
||||
])
|
||||
for r in records:
|
||||
writer.writerow([
|
||||
|
|
@ -174,6 +198,7 @@ async def export_work_queue(records: list[RecordOut]):
|
|||
r.next_visit_due_date or "",
|
||||
r.action,
|
||||
r.coverage_end_date,
|
||||
r.reason,
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
|
|
|
|||
|
|
@ -901,6 +901,42 @@ SIGNAL CGM VALUE STACK
|
|||
|
||||
---
|
||||
|
||||
## 14. Verified Stat Index — May 2026
|
||||
|
||||
*Stat verification completed May 23, 2026. Use this table before citing any Signal-related statistic. The "usable" column reflects what is citable with a direct source URL.*
|
||||
|
||||
### What Is Verified With Direct Citations
|
||||
|
||||
| Stat | Source | What It Measures | Usable for Signal |
|
||||
|------|--------|-----------------|-------------------|
|
||||
| 32.8% error rate on glucose monitor claims | CERT 2019 annual report | Random sample of PAID claims reviewed post-payment | Yes — locked stat. "Nearly 1 in 3." |
|
||||
| 68.6% of those errors from insufficient documentation | CERT 2019 annual report | Share of error-rate claims with doc problems | Yes — locked. "Over two-thirds from docs." |
|
||||
| 25.2% CGM improper payment rate | CMS 2024 MLN compliance page (direct URL) | Claims paid that had documentation problems — post-payment audit exposure | Yes — audit exposure narrative |
|
||||
| 67.6% absent documentation | CMS 2024 MLN compliance page (direct URL) | Share of those improper payments with no docs at all | Yes — use for whitepaper/gate framing |
|
||||
| **30.86% pre-pay review error rate** | **CGS MAC Jurisdiction B Q2 2024** | **Claims reviewed before or at payment — near submission** | **Best source for denial prevention pitch** |
|
||||
| 18.52% TPE error rate | MAC B/C 2025 | Claims reviewed at audit | Strong supporting evidence |
|
||||
| $1.9B DMEPOS improper payments FY2024 | OIG (URL exists) | All DMEPOS categories, not CGM specifically | Market context only |
|
||||
| DMEPOS 22.5% vs 7.38% overall Medicare | Post-payment comparison | DMEPOS vs. all Medicare improper payment rate | Market context |
|
||||
| 63.9% MA appeal success at Level 2 | KFF 2024 (direct URL) | Medicare Advantage only — not FFS | MA context only, qualify if used |
|
||||
|
||||
### What Is Derived or Not Directly Citable
|
||||
|
||||
| Stat | Problem | What to Use Instead |
|
||||
|------|---------|-------------------|
|
||||
| "94.2% of CGM denials are documentation failures" | Derived sum of two CMS MLN line items — not stated directly by CMS | CERT 2019: "over two-thirds from docs" |
|
||||
| "35–45% of CGM claims denied" | Scenario-based, not universal | "First-pass denial rates vary significantly by supplier documentation maturity" |
|
||||
| "63% of denied CGM claims are written off permanently" | Derived model, not citable | Do not use as a standalone stat |
|
||||
|
||||
### Why CGS MAC Pre-Pay Wins for Signal's Pitch
|
||||
|
||||
The CGS Jurisdiction B pre-pay review measures claims being stopped and reviewed near submission — not years later in a post-payment audit. Every top-10 denial reason in the CGS data is a documentation failure. This is the only publicly available source that measures documentation risk at the point Signal addresses it: before supplies ship and before the claim is filed.
|
||||
|
||||
The CERT and CMS MLN stats measure what happened after the fact. The CGS pre-pay stat measures the same problem Signal solves, at the same point in the workflow Signal operates.
|
||||
|
||||
**Use CGS MAC pre-pay for denial prevention framing. Use CERT 2019 for LinkedIn and public-facing stats.**
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
Research compiled April 2026 from:
|
||||
|
|
|
|||
2
signal-ui/.gitignore
vendored
Normal file
2
signal-ui/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
21
signal-ui/eslint.config.js
Normal file
21
signal-ui/eslint.config.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
},
|
||||
])
|
||||
19
signal-ui/index.html
Normal file
19
signal-ui/index.html
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Signal CGM — Outreach Worklist</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@600;700&family=Inter:wght@400;500&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
/* Prevent flash of light mode before ThemeProvider hydrates */
|
||||
html.dark { background: #041A1A; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
signal-ui/package.json
Normal file
29
signal-ui/package.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "signal-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
1707
signal-ui/pnpm-lock.yaml
Normal file
1707
signal-ui/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
137
signal-ui/src/App.jsx
Normal file
137
signal-ui/src/App.jsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { useState, useCallback, useRef } from "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 [records, setRecords] = useState([]);
|
||||
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((file) => {
|
||||
const label = new Date().toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
uploadToBackend(file).then((data) => {
|
||||
if (data) {
|
||||
const results = data.records.map(apiRecordToLocal);
|
||||
setRecords(results);
|
||||
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
|
||||
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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
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} />
|
||||
</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() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AppInner />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
3
signal-ui/src/ThemeContext.js
Normal file
3
signal-ui/src/ThemeContext.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { createContext } from "react";
|
||||
|
||||
export const ThemeContext = createContext(null);
|
||||
27
signal-ui/src/ThemeProvider.jsx
Normal file
27
signal-ui/src/ThemeProvider.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
import { ThemeContext } from "./ThemeContext";
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [dark, setDark] = useState(() => {
|
||||
const stored = localStorage.getItem("signal-theme");
|
||||
if (stored) return stored === "dark";
|
||||
return true;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (dark) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
localStorage.setItem("signal-theme", dark ? "dark" : "light");
|
||||
}, [dark]);
|
||||
|
||||
const toggle = useCallback(() => setDark((prev) => !prev), []);
|
||||
|
||||
return (
|
||||
<ThemeContext value={{ dark, toggle }}>
|
||||
{children}
|
||||
</ThemeContext>
|
||||
);
|
||||
}
|
||||
48
signal-ui/src/components/Badge.jsx
Normal file
48
signal-ui/src/components/Badge.jsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { getFlagLabel, getFlagAction } from "../lib/coverage";
|
||||
|
||||
function Badge({ flag, dark }) {
|
||||
const style = badgeStyles(flag, dark);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-[5px] px-[9px] py-[3px] rounded-full text-[11.5px] font-medium whitespace-nowrap ${style}`}
|
||||
title={getFlagAction(flag)}
|
||||
>
|
||||
<span>{style.icon}</span>
|
||||
{getFlagLabel(flag)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function badgeStyles(flag, dark) {
|
||||
if (dark) return darkBadge(flag);
|
||||
return lightBadge(flag);
|
||||
}
|
||||
|
||||
function darkBadge(flag) {
|
||||
switch (flag) {
|
||||
case "OUT_OF_COVERAGE":
|
||||
return { icon: "✕", className: "bg-[rgba(200,48,48,0.22)] text-[#FF7070]" };
|
||||
case "VISIT_DUE":
|
||||
return { icon: "⚡", className: "bg-[rgba(240,120,64,0.18)] text-[#FFB070] border border-[rgba(240,120,64,0.3)]" };
|
||||
case "REFILL_WINDOW":
|
||||
return { icon: "⚠", className: "bg-[rgba(168,90,24,0.16)] text-[#F0B464]" };
|
||||
default:
|
||||
return { icon: "✓", className: "bg-[rgba(26,122,78,0.16)] text-[#4AE899]" };
|
||||
}
|
||||
}
|
||||
|
||||
function lightBadge(flag) {
|
||||
switch (flag) {
|
||||
case "OUT_OF_COVERAGE":
|
||||
return { icon: "✕", className: "bg-[#F8CCCC] text-[#A02020]" };
|
||||
case "VISIT_DUE":
|
||||
return { icon: "⚡", className: "bg-[#FFE4CC] text-[#903A14] border border-[rgba(144,58,20,0.2)]" };
|
||||
case "REFILL_WINDOW":
|
||||
return { icon: "⚠", className: "bg-[#FDECD5] text-[#A85A18]" };
|
||||
default:
|
||||
return { icon: "✓", className: "bg-[#C8EDD8] text-[#146040]" };
|
||||
}
|
||||
}
|
||||
|
||||
export default Badge;
|
||||
59
signal-ui/src/components/CSVExport.jsx
Normal file
59
signal-ui/src/components/CSVExport.jsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { getFlagLabel, getFlagAction, getDeviceDisplay } from "../lib/coverage";
|
||||
|
||||
export default function CSVExport({ records }) {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const exportCSV = () => {
|
||||
if (!records || records.length === 0) return;
|
||||
|
||||
const headers = [
|
||||
"Patient ID",
|
||||
"Device",
|
||||
"Payer",
|
||||
"Status",
|
||||
"Priority Score",
|
||||
"Days Until Resupply End",
|
||||
"Recommended Action",
|
||||
"Resupply End Date",
|
||||
"Reason",
|
||||
];
|
||||
|
||||
const rows = records.map((r) => [
|
||||
r.patient_id,
|
||||
getDeviceDisplay(r.device_type),
|
||||
r.payer,
|
||||
getFlagLabel(r.flag),
|
||||
r.priority,
|
||||
r.daysUntilEnd,
|
||||
getFlagAction(r.flag),
|
||||
r.coverageEndDate || "",
|
||||
r.reason || "",
|
||||
]);
|
||||
|
||||
const csv = [headers, ...rows]
|
||||
.map((row) =>
|
||||
row.map((v) => `"${String(v).replace(/"/g, '""')}"`).join(",")
|
||||
)
|
||||
.join("\r\n");
|
||||
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `signal-work-queue-${today}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={exportCSV}
|
||||
className="bg-[var(--brand)] text-warm-white px-4 py-[7px] rounded-md text-[13px] font-medium cursor-pointer transition-colors hover:bg-[var(--brand-hover)] font-body"
|
||||
disabled={!records || records.length === 0}
|
||||
>
|
||||
↓ Export Work Queue
|
||||
</button>
|
||||
);
|
||||
}
|
||||
28
signal-ui/src/components/CSVImport.jsx
Normal file
28
signal-ui/src/components/CSVImport.jsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { useRef, forwardRef, useImperativeHandle } from "react";
|
||||
|
||||
const CSVImport = forwardRef(function CSVImport({ onResults }, ref) {
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
trigger: () => inputRef.current?.click(),
|
||||
}));
|
||||
|
||||
const handleFile = (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = "";
|
||||
onResults(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFile}
|
||||
className="hidden"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default CSVImport;
|
||||
132
signal-ui/src/components/Sidebar.jsx
Normal file
132
signal-ui/src/components/Sidebar.jsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
export default function Sidebar({
|
||||
oocCount,
|
||||
visitDueCount,
|
||||
refillCount,
|
||||
activeFilter,
|
||||
onFilterChange,
|
||||
onImportClick,
|
||||
}) {
|
||||
const navItems = [
|
||||
{
|
||||
label: "All Patients",
|
||||
key: "all",
|
||||
icon: "≡",
|
||||
badge: null,
|
||||
},
|
||||
{
|
||||
label: "Resupply Ready",
|
||||
key: "REFILL_WINDOW",
|
||||
icon: "⚠",
|
||||
badge: refillCount,
|
||||
badgeWarn: true,
|
||||
},
|
||||
{
|
||||
label: "Renewal Due",
|
||||
key: "VISIT_DUE",
|
||||
icon: "⚡",
|
||||
badge: visitDueCount,
|
||||
},
|
||||
{
|
||||
label: "Supply Lapsed",
|
||||
key: "OUT_OF_COVERAGE",
|
||||
icon: "✕",
|
||||
badge: oocCount,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<aside className="w-[240px] bg-teal-900 border-r border-teal-700 flex flex-col shrink-0 fixed top-0 left-0 bottom-0 z-20">
|
||||
{/* Header */}
|
||||
<div className="px-5 pt-[22px] pb-[18px] border-b border-[rgba(15,94,94,0.5)]">
|
||||
<div className="flex items-center gap-[10px] mb-1">
|
||||
<div className="w-[33px] h-[33px] bg-gradient-to-br from-[#1A8A8A] to-[#147A7A] rounded-lg flex items-center justify-center font-heading font-bold text-[16px] text-warm-white shrink-0 shadow-[0_2px_8px_rgba(20,122,122,0.35)]">
|
||||
S
|
||||
</div>
|
||||
<span className="font-heading font-bold text-[17px] text-warm-white tracking-[-0.01em]">
|
||||
Signal
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-teal-300 ml-[43px] tracking-[0.01em]">
|
||||
by STTIL Solutions
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="py-[10px] flex-1 overflow-y-auto">
|
||||
<div className="text-[10px] font-medium tracking-[0.09em] uppercase text-[#426060] px-5 pt-[10px] pb-1">
|
||||
Worklist
|
||||
</div>
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
key={item.key}
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onFilterChange(item.key);
|
||||
}}
|
||||
className={`flex items-center gap-[10px] px-5 py-[9px] text-[13.5px] no-underline transition-all cursor-pointer border-l-[3px] ${
|
||||
activeFilter === item.key
|
||||
? "text-warm-white font-medium border-l-[var(--accent)] bg-[rgba(10,68,68,0.55)]"
|
||||
: "text-[#7A9E9E] border-l-transparent hover:text-teal-400 hover:bg-[rgba(46,163,163,0.07)]"
|
||||
}`}
|
||||
>
|
||||
<span className="w-[18px] text-center text-[14px] shrink-0">
|
||||
{item.icon}
|
||||
</span>
|
||||
{item.label}
|
||||
{item.badge != null && item.badge > 0 && (
|
||||
<span
|
||||
className={`ml-auto text-[10px] font-semibold px-[7px] py-[1px] rounded-[20px] font-mono ${
|
||||
item.badgeWarn
|
||||
? "bg-[rgba(217,123,53,0.22)] text-[#E49655]"
|
||||
: "bg-[rgba(200,48,48,0.22)] text-[#FF8080]"
|
||||
}`}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
<div className="text-[10px] font-medium tracking-[0.09em] uppercase text-[#426060] px-5 pt-4 pb-1">
|
||||
System
|
||||
</div>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onImportClick();
|
||||
}}
|
||||
className="flex items-center gap-[10px] px-5 py-[9px] text-[13.5px] text-[#7A9E9E] no-underline transition-all cursor-pointer border-l-[3px] border-l-transparent hover:text-teal-400 hover:bg-[rgba(46,163,163,0.07)]"
|
||||
>
|
||||
<span className="w-[18px] text-center text-[14px] shrink-0">↑</span>
|
||||
Import CSV
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => e.preventDefault()}
|
||||
className="flex items-center gap-[10px] px-5 py-[9px] text-[13.5px] text-[#7A9E9E] no-underline transition-all cursor-pointer border-l-[3px] border-l-transparent hover:text-teal-400 hover:bg-[rgba(46,163,163,0.07)]"
|
||||
>
|
||||
<span className="w-[18px] text-center text-[14px] shrink-0">⚙</span>
|
||||
Settings
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-[14px] border-t border-[rgba(15,94,94,0.5)]">
|
||||
<div className="flex items-center gap-[10px]">
|
||||
<div className="w-[30px] h-[30px] bg-[rgba(46,163,163,0.18)] border border-[rgba(46,163,163,0.3)] rounded-full flex items-center justify-center text-[11px] text-teal-400 font-semibold shrink-0">
|
||||
DS
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-warm-white">
|
||||
Demo Supplier
|
||||
</div>
|
||||
<div className="text-[10px] text-teal-300 mt-[1px]">
|
||||
Billing Staff
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
32
signal-ui/src/components/StatCard.jsx
Normal file
32
signal-ui/src/components/StatCard.jsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export default function StatCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
priority = false,
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-[10px] p-5 border transition-all duration-200 ${
|
||||
priority
|
||||
? "border-[var(--accent)] shadow-[var(--accent-glow)] bg-[var(--bg-card)]"
|
||||
: "border-[var(--border-color)] shadow-[var(--shadow-card-md)] bg-[var(--bg-card)]"
|
||||
}`}
|
||||
>
|
||||
<div className="text-[11px] font-medium tracking-[0.06em] uppercase text-[var(--text-muted)] mb-[10px]">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={`font-mono text-[34px] font-medium tracking-[-0.02em] leading-none mb-[2px] ${
|
||||
priority ? "text-[var(--accent-text)]" : "text-[var(--text-heading)]"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
{sub && (
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-[6px]">
|
||||
{sub}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
signal-ui/src/components/ThemeToggle.jsx
Normal file
14
signal-ui/src/components/ThemeToggle.jsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { useTheme } from "../useTheme";
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const { dark, toggle } = useTheme();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="bg-[var(--bg-elevated)] border border-[var(--border-color)] text-[var(--text-secondary)] px-[13px] py-[6px] rounded-md text-xs font-body cursor-pointer transition-all hover:border-[var(--brand)] hover:text-[var(--brand)]"
|
||||
>
|
||||
◐ {dark ? "Dark" : "Light"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
24
signal-ui/src/components/Toast.jsx
Normal file
24
signal-ui/src/components/Toast.jsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useState, useEffect } from "react";
|
||||
|
||||
export default function Toast() {
|
||||
const [message, setMessage] = useState("");
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
setMessage(e.detail.message);
|
||||
setVisible(true);
|
||||
setTimeout(() => setVisible(false), 3500);
|
||||
};
|
||||
window.addEventListener("signal-toast", handler);
|
||||
return () => window.removeEventListener("signal-toast", handler);
|
||||
}, []);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 bg-[var(--bg-elevated)] border border-[var(--brand)] rounded-lg px-[18px] py-3 text-[13px] text-[var(--text-primary)] z-[100] shadow-[var(--shadow-card-md)]">
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
206
signal-ui/src/components/WorklistTable.jsx
Normal file
206
signal-ui/src/components/WorklistTable.jsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { useTheme } from "../useTheme";
|
||||
import Badge from "./Badge";
|
||||
import { getDeviceDisplay } from "../lib/coverage";
|
||||
|
||||
function daysLabel(days) {
|
||||
if (days < 0)
|
||||
return <span className="font-mono font-semibold text-[#FF7070]">Expired {Math.abs(days)}d ago</span>;
|
||||
if (days <= 7)
|
||||
return <span className="font-mono font-medium text-[#FF7070]">{days} days</span>;
|
||||
if (days <= 30)
|
||||
return <span className="font-mono font-medium text-[var(--accent-text)]">{days} days</span>;
|
||||
return <span className="font-mono font-medium text-[var(--text-primary)]">{days} days</span>;
|
||||
}
|
||||
|
||||
function scoreClass(priority) {
|
||||
if (priority >= 500) return "text-[var(--accent-text)]";
|
||||
if (priority >= 200) return "text-[var(--text-secondary)]";
|
||||
return "text-[var(--text-muted)]";
|
||||
}
|
||||
|
||||
function isHighPriority(flag) {
|
||||
return flag === "OUT_OF_COVERAGE" || flag === "VISIT_DUE";
|
||||
}
|
||||
|
||||
const FILTERS = [
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "OUT_OF_COVERAGE", label: "Supply Lapsed" },
|
||||
{ key: "VISIT_DUE", label: "Renewal Due" },
|
||||
{ key: "REFILL_WINDOW", label: "Resupply Ready" },
|
||||
{ key: "OK", label: "Active" },
|
||||
];
|
||||
|
||||
export default function WorklistTable({ records, activeFilter, onFilterChange }) {
|
||||
const { dark } = useTheme();
|
||||
|
||||
const filtered =
|
||||
activeFilter === "all"
|
||||
? records
|
||||
: records.filter((r) => r.flag === activeFilter);
|
||||
|
||||
const todayStr = new Date().toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[10px] shadow-[var(--shadow-card-md)] overflow-hidden transition-colors">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-[22px] py-4 border-b border-[var(--border-color)] bg-[var(--bg-elevated)] gap-4 flex-wrap">
|
||||
<div>
|
||||
<div className="font-heading font-bold text-[15px] text-[var(--text-heading)]">
|
||||
Outreach Worklist
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-[var(--text-muted)] mt-[2px]">
|
||||
{records.length} patients · sorted by priority score · {todayStr}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-[6px] flex-wrap">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.key}
|
||||
onClick={() => onFilterChange(f.key)}
|
||||
className={`px-3 py-1 rounded-full text-[11.5px] cursor-pointer font-body transition-all border ${
|
||||
activeFilter === f.key
|
||||
? "bg-[var(--brand)] text-white border-[var(--brand)]"
|
||||
: "bg-transparent border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--brand)] hover:text-[var(--brand)]"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-muted)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
|
||||
Patient ID
|
||||
</th>
|
||||
<th className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-muted)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
|
||||
Device
|
||||
</th>
|
||||
<th className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-muted)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
|
||||
Payer
|
||||
</th>
|
||||
<th className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-muted)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
|
||||
Days Left
|
||||
</th>
|
||||
<th className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-muted)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-muted)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
|
||||
Priority
|
||||
</th>
|
||||
<th className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-muted)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
|
||||
Action
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((r, i) => {
|
||||
const hp = isHighPriority(r.flag);
|
||||
return (
|
||||
<tr
|
||||
key={r.patient_id + "-" + i}
|
||||
className={`border-b border-[var(--border-subtle)] last:border-b-0 transition-colors hover:bg-[var(--row-hover)] ${
|
||||
hp
|
||||
? dark
|
||||
? "bg-[rgba(224,104,48,0.09)] hover:bg-[rgba(203,107,32,0.14)]"
|
||||
: "bg-[rgba(224,96,40,0.05)] hover:bg-[rgba(203,107,32,0.14)]"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<td className="px-[22px] py-[13px] align-middle">
|
||||
<div
|
||||
className={`font-mono text-[12.5px] font-bold ${
|
||||
hp
|
||||
? "text-[var(--accent-text)]"
|
||||
: "text-[var(--text-secondary)]"
|
||||
}`}
|
||||
>
|
||||
{r.patient_id}
|
||||
</div>
|
||||
{i === 0 && (
|
||||
<div className="font-mono text-[9.5px] font-semibold tracking-[0.06em] text-[var(--accent-text)] mt-[2px]">
|
||||
★ TOP PRIORITY
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-[22px] py-[13px] text-[13.5px] font-medium text-[var(--text-primary)] align-middle">
|
||||
{getDeviceDisplay(r.device_type)}
|
||||
</td>
|
||||
<td className="px-[22px] py-[13px] text-[13px] text-[var(--text-secondary)] align-middle">
|
||||
{r.payer}
|
||||
</td>
|
||||
<td className="px-[22px] py-[13px] align-middle">
|
||||
{daysLabel(r.daysUntilEnd)}
|
||||
</td>
|
||||
<td className="px-[22px] py-[13px] align-middle">
|
||||
<Badge flag={r.flag} dark={dark} />
|
||||
{r.reason && (
|
||||
<div className="text-[10.5px] text-[var(--text-muted)] mt-[4px] max-w-[220px] leading-[1.4]">
|
||||
{r.reason}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-[22px] py-[13px] align-middle">
|
||||
<span
|
||||
className={`font-mono text-[16px] font-medium ${scoreClass(
|
||||
r.priority
|
||||
)}`}
|
||||
>
|
||||
{r.priority}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-[22px] py-[13px] align-middle">
|
||||
<ActionButton flag={r.flag} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={7}
|
||||
className="px-[22px] py-8 text-center text-[var(--text-muted)]"
|
||||
>
|
||||
No patients match this filter.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-[22px] py-3 border-t border-[var(--border-subtle)] text-[11px] text-[var(--text-muted)] bg-[var(--bg-elevated)]">
|
||||
<span className="flex items-center gap-[6px] text-[var(--text-secondary)]">
|
||||
🔒 PHI-safe — patient names and DOBs never stored. Crosswalk: patient_id only.
|
||||
</span>
|
||||
<span>
|
||||
{activeFilter === "all"
|
||||
? `${records.length} patients · all results shown`
|
||||
: `${filtered.length} of ${records.length} · filtered`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({ flag }) {
|
||||
const base =
|
||||
"bg-transparent border border-[var(--border-color)] text-[var(--text-secondary)] px-[13px] py-[5px] rounded-md text-xs cursor-pointer font-body whitespace-nowrap transition-all hover:border-[var(--brand)] hover:text-[var(--brand)]";
|
||||
|
||||
if (flag === "OUT_OF_COVERAGE" || flag === "VISIT_DUE") {
|
||||
const label =
|
||||
flag === "OUT_OF_COVERAGE" ? "Contact Prescriber" : "Request Renewal →";
|
||||
return <button className={`${base} !border-[var(--accent)] !text-[var(--accent-text)] hover:bg-[rgba(203,107,32,0.1)]`}>{label}</button>;
|
||||
}
|
||||
if (flag === "REFILL_WINDOW") {
|
||||
return <button className={base}>Initiate Resupply</button>;
|
||||
}
|
||||
return <button className={base}>View</button>;
|
||||
}
|
||||
168
signal-ui/src/index.css
Normal file
168
signal-ui/src/index.css
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* ── Signal Design Tokens — v1.0 ──
|
||||
Source: docs/design-tokens-v1.json + docs/sttil-brand-system-v1.md
|
||||
Accent family: tangerine (warm gold on dark, restrained copper on light)
|
||||
*/
|
||||
|
||||
@theme {
|
||||
/* ── Color: Teal (dominant system color) ── */
|
||||
--color-teal-50: #EEF8F8;
|
||||
--color-teal-100: #CBE9E9;
|
||||
--color-teal-200: #97D3D3;
|
||||
--color-teal-300: #5BBBBB;
|
||||
--color-teal-400: #2EA3A3;
|
||||
--color-teal-500: #1A8A8A;
|
||||
--color-teal-600: #147A7A;
|
||||
--color-teal-700: #0F5E5E;
|
||||
--color-teal-800: #0A4444;
|
||||
--color-teal-900: #072E2E;
|
||||
--color-teal-950: #041A1A;
|
||||
|
||||
/* ── Color: Tangerine (accent) ── */
|
||||
--color-tng-50: #FFF4EE;
|
||||
--color-tng-100: #FFE4CC;
|
||||
--color-tng-200: #FFC090;
|
||||
--color-tng-300: #FFB070;
|
||||
--color-tng-400: #F07840;
|
||||
--color-tng-500: #E06028;
|
||||
--color-tng-600: #C04E1C;
|
||||
--color-tng-700: #903A14;
|
||||
--color-tng-800: #6A2A0C;
|
||||
--color-tng-900: #3E1808;
|
||||
|
||||
/* ── Color: Neutral (teal-toned) ── */
|
||||
--color-neutral-0: #FFFFFF;
|
||||
--color-neutral-50: #F4F9F9;
|
||||
--color-neutral-100: #E5EEEE;
|
||||
--color-neutral-200: #C8D8D8;
|
||||
--color-neutral-300: #A3BEBE;
|
||||
--color-neutral-400: #7A9E9E;
|
||||
--color-neutral-500: #5A7E7E;
|
||||
--color-neutral-600: #426060;
|
||||
--color-neutral-700: #2E4444;
|
||||
--color-neutral-800: #1C2C2C;
|
||||
--color-neutral-900: #111A1A;
|
||||
|
||||
/* ── Color: Semantic ── */
|
||||
--color-success-100: #C8EDD8;
|
||||
--color-success-500: #1A7A4E;
|
||||
--color-success-600: #146040;
|
||||
--color-warning-100: #FDECD5;
|
||||
--color-warning-400: #D97B35;
|
||||
--color-warning-600: #A85A18;
|
||||
--color-error-100: #F8CCCC;
|
||||
--color-error-500: #C83030;
|
||||
--color-error-600: #A02020;
|
||||
--color-info-100: #C8E0EE;
|
||||
--color-info-500: #1A6A9A;
|
||||
--color-purple-100: #E2D8F0;
|
||||
--color-purple-500: #7A5EA0;
|
||||
|
||||
/* ── Color: Mode-specific surfaces ── */
|
||||
--color-warm-white: #FFFAF6;
|
||||
--color-page-dark: #041A1A;
|
||||
--color-card-dark: #072E2E;
|
||||
--color-elevated-dark: #0A4444;
|
||||
--color-border-dark: #0F5E5E;
|
||||
|
||||
/* ── Typography ── */
|
||||
--font-heading: 'Plus Jakarta Sans', sans-serif;
|
||||
--font-body: 'Inter', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.25rem;
|
||||
--text-2xl: 1.5rem;
|
||||
--text-3xl: 1.875rem;
|
||||
--text-4xl: 2.25rem;
|
||||
--text-5xl: 3rem;
|
||||
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
--leading-tight: 1.15;
|
||||
--leading-snug: 1.25;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.6;
|
||||
|
||||
--tracking-tight: -0.02em;
|
||||
--tracking-normal: 0em;
|
||||
--tracking-wide: 0.05em;
|
||||
--tracking-wider: 0.08em;
|
||||
--tracking-widest: 0.1em;
|
||||
|
||||
/* ── Border radius ── */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-2xl: 1.5rem;
|
||||
|
||||
/* ── Shadows ── */
|
||||
--shadow-card-sm: 0 1px 3px rgba(7,46,46,0.10);
|
||||
--shadow-card-md: 0 2px 10px rgba(4,26,26,0.14);
|
||||
--shadow-card-lg: 0 4px 20px rgba(4,26,26,0.18);
|
||||
--shadow-copper-dark: 0 0 24px rgba(240,120,64,0.30), 0 2px 10px rgba(4,26,26,0.70);
|
||||
--shadow-copper-light: 0 0 16px rgba(224,96,40,0.20), 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
/* ── Base layer ── */
|
||||
@layer base {
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-weight: var(--font-weight-regular);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Light mode (default) ── */
|
||||
:root {
|
||||
--bg-page: var(--color-neutral-50);
|
||||
--bg-card: var(--color-neutral-0);
|
||||
--bg-elevated: var(--color-neutral-100);
|
||||
--text-heading: #0A3030;
|
||||
--text-primary: var(--color-neutral-800);
|
||||
--text-secondary:var(--color-neutral-500);
|
||||
--text-muted: var(--color-neutral-400);
|
||||
--border-color: var(--color-neutral-200);
|
||||
--border-subtle: rgba(200,216,216,0.7);
|
||||
--brand: var(--color-teal-600);
|
||||
--brand-hover: var(--color-teal-700);
|
||||
--accent: var(--color-tng-500);
|
||||
--accent-text: var(--color-tng-500);
|
||||
--accent-glow: var(--shadow-copper-light);
|
||||
--row-hover: rgba(20,122,122,0.04);
|
||||
}
|
||||
|
||||
/* ── Dark mode ── */
|
||||
.dark {
|
||||
--bg-page: var(--color-page-dark);
|
||||
--bg-card: var(--color-card-dark);
|
||||
--bg-elevated: var(--color-elevated-dark);
|
||||
--text-heading: var(--color-warm-white);
|
||||
--text-primary: #F0F4F4;
|
||||
--text-secondary:var(--color-teal-300);
|
||||
--text-muted: #5A8080;
|
||||
--border-color: var(--color-border-dark);
|
||||
--border-subtle: rgba(15,94,94,0.45);
|
||||
--brand: var(--color-teal-400);
|
||||
--brand-hover: #1A8A8A;
|
||||
--accent: var(--color-tng-400);
|
||||
--accent-text: var(--color-tng-300);
|
||||
--accent-glow: var(--shadow-copper-dark);
|
||||
--row-hover: rgba(46,163,163,0.06);
|
||||
}
|
||||
48
signal-ui/src/lib/api.js
Normal file
48
signal-ui/src/lib/api.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Signal API helper — tries the backend, returns null on failure so the
|
||||
* app can fall back to client-side coverage calculation.
|
||||
*/
|
||||
|
||||
const BACKEND_URL = "https://signal-api-production-91c2.up.railway.app";
|
||||
|
||||
/**
|
||||
* Upload a CSV file to the backend scoring endpoint.
|
||||
* @param {File} file
|
||||
* @returns {Promise<object|null>} API response or null on failure
|
||||
*/
|
||||
export async function uploadToBackend(file) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/api/upload`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
throw new Error(err.detail?.message || `API error ${resp.status}`);
|
||||
}
|
||||
return resp.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert backend record shape to local record shape.
|
||||
*/
|
||||
export function apiRecordToLocal(r) {
|
||||
return {
|
||||
patient_id: r.patient_id,
|
||||
device_type: r.device_type,
|
||||
payer: r.payer,
|
||||
component: r.component,
|
||||
flag: r.flag,
|
||||
daysUntilEnd: r.days_until_coverage_end,
|
||||
daysUntilVisit: r.days_until_visit_due,
|
||||
priority: r.priority_score,
|
||||
coverageEndDate: r.coverage_end_date,
|
||||
nextVisitDueDate: r.next_visit_due_date,
|
||||
reason: r.reason || "",
|
||||
};
|
||||
}
|
||||
165
signal-ui/src/lib/coverage.js
Normal file
165
signal-ui/src/lib/coverage.js
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
/**
|
||||
* Client-side CGM coverage calculator — mirrors python-backend/core/coverage_calculator.py.
|
||||
* Used as fallback when the Signal API is unreachable.
|
||||
*
|
||||
* PHI CONTRACT: This module receives only patient_id, device_type, shipment_date,
|
||||
* quantity, payer, component. No names, SSNs, DOBs, or contact fields.
|
||||
*/
|
||||
|
||||
export const FLAG = {
|
||||
OUT_OF_COVERAGE: "OUT_OF_COVERAGE",
|
||||
VISIT_DUE: "VISIT_DUE",
|
||||
REFILL_WINDOW: "REFILL_WINDOW",
|
||||
OK: "OK",
|
||||
};
|
||||
|
||||
const FLAG_LABELS = {
|
||||
[FLAG.OUT_OF_COVERAGE]: "Supply Lapsed",
|
||||
[FLAG.VISIT_DUE]: "Renewal Due",
|
||||
[FLAG.REFILL_WINDOW]: "Resupply Ready",
|
||||
[FLAG.OK]: "Active",
|
||||
};
|
||||
|
||||
const FLAG_ACTIONS = {
|
||||
[FLAG.OUT_OF_COVERAGE]: "Contact Prescriber",
|
||||
[FLAG.VISIT_DUE]: "Request Renewal",
|
||||
[FLAG.REFILL_WINDOW]: "Initiate Resupply",
|
||||
[FLAG.OK]: "No action needed",
|
||||
};
|
||||
|
||||
/** Wear-day rules — mirrors payer_rules.json */
|
||||
const DEVICE_RULES = {
|
||||
dexcom_g7: { display: "Dexcom G7", sensor: 10 },
|
||||
dexcom_g6: { display: "Dexcom G6", sensor: 10, transmitter: 90 },
|
||||
freestyle_libre_2: { display: "FreeStyle Libre 2", sensor: 14 },
|
||||
freestyle_libre_3: { display: "FreeStyle Libre 3", sensor: 14 },
|
||||
omnipod_5: { display: "Omnipod 5", pod: 3, sensor: 14 },
|
||||
};
|
||||
|
||||
export function getDeviceDisplay(deviceType) {
|
||||
return DEVICE_RULES[deviceType]?.display ?? deviceType;
|
||||
}
|
||||
|
||||
export function getFlagLabel(flag) {
|
||||
return FLAG_LABELS[flag] ?? flag;
|
||||
}
|
||||
|
||||
export function getFlagAction(flag) {
|
||||
return FLAG_ACTIONS[flag] ?? "Review";
|
||||
}
|
||||
|
||||
function today() {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function daysDiff(a, b) {
|
||||
return Math.round((a.getTime() - b.getTime()) / 86400000);
|
||||
}
|
||||
|
||||
function addDays(dateStr, days) {
|
||||
const d = new Date(dateStr + "T00:00:00");
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
function getWearDays(deviceType, component) {
|
||||
const device = DEVICE_RULES[deviceType];
|
||||
if (!device) return null;
|
||||
return device[component] ?? null;
|
||||
}
|
||||
|
||||
function getPayerConfig(payer) {
|
||||
const p = payer.toLowerCase();
|
||||
if (p.includes("medicare")) {
|
||||
return { visitRenewalDays: 180, refillWindowDays: 30 };
|
||||
}
|
||||
return { visitRenewalDays: null, refillWindowDays: 30 };
|
||||
}
|
||||
|
||||
function computePriority(flag, daysUntilEnd) {
|
||||
if (flag === FLAG.OUT_OF_COVERAGE) return 1000 + Math.abs(daysUntilEnd);
|
||||
if (flag === FLAG.VISIT_DUE) return 500 + Math.max(0, 90 - daysUntilEnd);
|
||||
if (flag === FLAG.REFILL_WINDOW) return 200 + Math.max(0, 30 - daysUntilEnd);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate coverage for a single record.
|
||||
* @param {{ patient_id, device_type, shipment_date, quantity, payer, component }} record
|
||||
* @returns {{ flag, daysUntilEnd, daysUntilVisit, priority }} or null on error
|
||||
*/
|
||||
export function calculateCoverage(record) {
|
||||
const wearDays = getWearDays(record.device_type, record.component);
|
||||
if (wearDays === null) return null;
|
||||
|
||||
const now = today();
|
||||
const totalWear = wearDays * parseInt(record.quantity, 10);
|
||||
const coverageEnd = addDays(record.shipment_date, totalWear);
|
||||
const daysUntilEnd = daysDiff(coverageEnd, now);
|
||||
|
||||
const payerCfg = getPayerConfig(record.payer);
|
||||
|
||||
let daysUntilVisit = null;
|
||||
if (payerCfg.visitRenewalDays) {
|
||||
const visitDue = addDays(record.shipment_date, payerCfg.visitRenewalDays);
|
||||
daysUntilVisit = daysDiff(visitDue, now);
|
||||
}
|
||||
|
||||
let flag;
|
||||
if (daysUntilEnd < 0) {
|
||||
flag = FLAG.OUT_OF_COVERAGE;
|
||||
} else if (daysUntilVisit !== null && daysUntilVisit <= 30) {
|
||||
flag = FLAG.VISIT_DUE;
|
||||
} else if (daysUntilEnd <= payerCfg.refillWindowDays) {
|
||||
flag = FLAG.REFILL_WINDOW;
|
||||
} else {
|
||||
flag = FLAG.OK;
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
flag,
|
||||
daysUntilEnd,
|
||||
daysUntilVisit,
|
||||
priority: computePriority(flag, daysUntilEnd),
|
||||
coverageEndDate: coverageEnd.toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse raw CSV text into record objects.
|
||||
*/
|
||||
export function parseCSV(text) {
|
||||
const lines = text.trim().split(/\r?\n/);
|
||||
if (lines.length < 2) return [];
|
||||
const headers = lines[0].split(",").map((h) => h.trim().toLowerCase());
|
||||
return lines
|
||||
.slice(1)
|
||||
.filter((l) => l.trim())
|
||||
.map((line) => {
|
||||
const cols = line.split(",").map((c) => c.trim());
|
||||
const row = {};
|
||||
headers.forEach((h, i) => (row[h] = cols[i] || ""));
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch of parsed CSV rows and return sorted results + skip list.
|
||||
*/
|
||||
export function processBatch(rows) {
|
||||
const results = [];
|
||||
const skipped = [];
|
||||
for (const row of rows) {
|
||||
const result = calculateCoverage(row);
|
||||
if (result) {
|
||||
results.push(result);
|
||||
} else {
|
||||
skipped.push(row.patient_id || "?");
|
||||
}
|
||||
}
|
||||
results.sort((a, b) => b.priority - a.priority);
|
||||
return { results, skipped };
|
||||
}
|
||||
6
signal-ui/src/lib/toast.js
Normal file
6
signal-ui/src/lib/toast.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/** Fire a toast notification from anywhere in the app. */
|
||||
export function showToast(message) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("signal-toast", { detail: { message } })
|
||||
);
|
||||
}
|
||||
10
signal-ui/src/main.jsx
Normal file
10
signal-ui/src/main.jsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App.jsx";
|
||||
import "./index.css";
|
||||
|
||||
createRoot(document.getElementById("root")).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
8
signal-ui/src/useTheme.js
Normal file
8
signal-ui/src/useTheme.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { useContext } from "react";
|
||||
import { ThemeContext } from "./ThemeContext.js";
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
|
||||
return ctx;
|
||||
}
|
||||
136
signal-ui/tokens/tokens.json
Normal file
136
signal-ui/tokens/tokens.json
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
{
|
||||
"$schema": "https://design-tokens.github.io/community-group/format/",
|
||||
"$metadata": {
|
||||
"version": "1.0.0",
|
||||
"updated": "2026-04-23",
|
||||
"scope": "STTIL Solutions — master token set (Signal CGM product expression)",
|
||||
"accent-family": "tangerine",
|
||||
"source": "docs/design-tokens-v1.json"
|
||||
},
|
||||
|
||||
"color": {
|
||||
"teal": {
|
||||
"50": { "$value": "#EEF8F8", "$type": "color" },
|
||||
"100": { "$value": "#CBE9E9", "$type": "color" },
|
||||
"200": { "$value": "#97D3D3", "$type": "color" },
|
||||
"300": { "$value": "#5BBBBB", "$type": "color" },
|
||||
"400": { "$value": "#2EA3A3", "$type": "color" },
|
||||
"500": { "$value": "#1A8A8A", "$type": "color" },
|
||||
"600": { "$value": "#147A7A", "$type": "color" },
|
||||
"700": { "$value": "#0F5E5E", "$type": "color" },
|
||||
"800": { "$value": "#0A4444", "$type": "color" },
|
||||
"900": { "$value": "#072E2E", "$type": "color" },
|
||||
"950": { "$value": "#041A1A", "$type": "color" }
|
||||
},
|
||||
"tangerine": {
|
||||
"50": { "$value": "#FFF4EE", "$type": "color" },
|
||||
"100": { "$value": "#FFE4CC", "$type": "color" },
|
||||
"200": { "$value": "#FFC090", "$type": "color" },
|
||||
"300": { "$value": "#FFB070", "$type": "color" },
|
||||
"400": { "$value": "#F07840", "$type": "color" },
|
||||
"500": { "$value": "#E06028", "$type": "color" },
|
||||
"600": { "$value": "#C04E1C", "$type": "color" },
|
||||
"700": { "$value": "#903A14", "$type": "color" },
|
||||
"800": { "$value": "#6A2A0C", "$type": "color" },
|
||||
"900": { "$value": "#3E1808", "$type": "color" }
|
||||
},
|
||||
"neutral": {
|
||||
"0": { "$value": "#FFFFFF", "$type": "color" },
|
||||
"50": { "$value": "#F4F9F9", "$type": "color" },
|
||||
"100": { "$value": "#E5EEEE", "$type": "color" },
|
||||
"200": { "$value": "#C8D8D8", "$type": "color" },
|
||||
"300": { "$value": "#A3BEBE", "$type": "color" },
|
||||
"400": { "$value": "#7A9E9E", "$type": "color" },
|
||||
"500": { "$value": "#5A7E7E", "$type": "color" },
|
||||
"600": { "$value": "#426060", "$type": "color" },
|
||||
"700": { "$value": "#2E4444", "$type": "color" },
|
||||
"800": { "$value": "#1C2C2C", "$type": "color" },
|
||||
"900": { "$value": "#111A1A", "$type": "color" }
|
||||
},
|
||||
"semantic": {
|
||||
"success": {
|
||||
"100": { "$value": "#C8EDD8", "$type": "color" },
|
||||
"500": { "$value": "#1A7A4E", "$type": "color" },
|
||||
"600": { "$value": "#146040", "$type": "color" }
|
||||
},
|
||||
"warning": {
|
||||
"100": { "$value": "#FDECD5", "$type": "color" },
|
||||
"400": { "$value": "#D97B35", "$type": "color" },
|
||||
"600": { "$value": "#A85A18", "$type": "color" }
|
||||
},
|
||||
"error": {
|
||||
"100": { "$value": "#F8CCCC", "$type": "color" },
|
||||
"500": { "$value": "#C83030", "$type": "color" },
|
||||
"600": { "$value": "#A02020", "$type": "color" }
|
||||
},
|
||||
"info": {
|
||||
"100": { "$value": "#C8E0EE", "$type": "color" },
|
||||
"500": { "$value": "#1A6A9A", "$type": "color" }
|
||||
},
|
||||
"purple": {
|
||||
"100": { "$value": "#E2D8F0", "$type": "color" },
|
||||
"500": { "$value": "#7A5EA0", "$type": "color" }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"typography": {
|
||||
"fontFamily": {
|
||||
"heading": { "$value": "'Plus Jakarta Sans', sans-serif", "$type": "fontFamily" },
|
||||
"body": { "$value": "'Inter', sans-serif", "$type": "fontFamily" },
|
||||
"mono": { "$value": "'JetBrains Mono', monospace", "$type": "fontFamily" }
|
||||
},
|
||||
"fontSize": {
|
||||
"xs": { "$value": "0.75rem" },
|
||||
"sm": { "$value": "0.875rem" },
|
||||
"base": { "$value": "1rem" },
|
||||
"lg": { "$value": "1.125rem" },
|
||||
"xl": { "$value": "1.25rem" },
|
||||
"2xl": { "$value": "1.5rem" },
|
||||
"3xl": { "$value": "1.875rem" },
|
||||
"4xl": { "$value": "2.25rem" },
|
||||
"5xl": { "$value": "3rem" }
|
||||
},
|
||||
"fontWeight": {
|
||||
"regular": { "$value": 400 },
|
||||
"medium": { "$value": 500 },
|
||||
"semibold": { "$value": 600 },
|
||||
"bold": { "$value": 700 }
|
||||
}
|
||||
},
|
||||
|
||||
"spacing": {
|
||||
"0": { "$value": "0rem" },
|
||||
"1": { "$value": "0.25rem" },
|
||||
"2": { "$value": "0.5rem" },
|
||||
"3": { "$value": "0.75rem" },
|
||||
"4": { "$value": "1rem" },
|
||||
"5": { "$value": "1.25rem" },
|
||||
"6": { "$value": "1.5rem" },
|
||||
"8": { "$value": "2rem" },
|
||||
"10": { "$value": "2.5rem" },
|
||||
"12": { "$value": "3rem" },
|
||||
"16": { "$value": "4rem" },
|
||||
"20": { "$value": "5rem" },
|
||||
"24": { "$value": "6rem" }
|
||||
},
|
||||
|
||||
"borderRadius": {
|
||||
"none": { "$value": "0" },
|
||||
"sm": { "$value": "0.25rem" },
|
||||
"md": { "$value": "0.5rem" },
|
||||
"lg": { "$value": "0.75rem" },
|
||||
"xl": { "$value": "1rem" },
|
||||
"2xl": { "$value": "1.5rem" },
|
||||
"full": { "$value": "9999px" }
|
||||
},
|
||||
|
||||
"shadow": {
|
||||
"sm": { "$value": "0 1px 3px rgba(7,46,46,0.10)" },
|
||||
"md": { "$value": "0 2px 10px rgba(4,26,26,0.14)" },
|
||||
"lg": { "$value": "0 4px 20px rgba(4,26,26,0.18)" },
|
||||
"xl": { "$value": "0 8px 32px rgba(4,26,26,0.22)" },
|
||||
"tangerine-glow-dark": { "$value": "0 0 24px rgba(240,120,64,0.30), 0 2px 10px rgba(4,26,26,0.70)" },
|
||||
"tangerine-glow-light": { "$value": "0 0 16px rgba(224,96,40,0.20), 0 2px 8px rgba(0,0,0,0.08)" }
|
||||
}
|
||||
}
|
||||
11
signal-ui/vite.config.js
Normal file
11
signal-ui/vite.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
},
|
||||
})
|
||||
Loading…
Reference in a new issue