Signal/signal-ui/demo/index.html
Kisa 0af32ec983 chore: add .vercel to gitignore; clarify export button label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 12:50:25 -04:00

1102 lines
35 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Signal — 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>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── DARK MODE TOKENS (default) ── */
:root {
--bg-page: #041A1A;
--bg-card: #072E2E;
--bg-elevated: #0A4444;
--bg-sidebar: #072E2E;
--sidebar-border: 1px solid #0F5E5E;
--text-heading: #FFFAF6;
--text-primary: #F0F4F4;
--text-secondary: #5BBBBB;
--text-muted: #5A8080;
--border: #0F5E5E;
--border-subtle: rgba(15, 94, 94, 0.45);
--brand: #2EA3A3;
--brand-hover: #1A8A8A;
--copper: #F07840;
--copper-text: #FFB070;
--copper-glow: 0 0 24px rgba(240,120,64,0.30), 0 2px 10px rgba(4,26,26,0.7);
--priority-row: rgba(224,104,48,0.09);
--card-shadow: 0 2px 10px rgba(4,26,26,0.6);
--row-hover: rgba(46,163,163,0.06);
--font-heading: 'Plus Jakarta Sans', sans-serif;
--font-body: 'Inter', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--sidebar-width: 240px;
}
/* ── LIGHT MODE OVERRIDES ── */
body.light {
--bg-page: #F4F9F9;
--bg-card: #FFFFFF;
--bg-elevated: #E5EEEE;
--bg-sidebar: #072E2E;
--text-heading: #0A3030;
--text-primary: #1C2E2E;
--text-secondary: #5A7E7E;
--text-muted: #7A9E9E;
--border: #C8D8D8;
--border-subtle: rgba(200,216,216,0.7);
--brand: #147A7A;
--brand-hover: #0F5E5E;
--copper: #E06028;
--copper-text: #E06028;
--copper-glow: 0 0 16px rgba(224,96,40,0.20), 0 2px 8px rgba(0,0,0,0.08);
--priority-row: rgba(224,96,40,0.05);
--card-shadow: 0 2px 8px rgba(7,46,46,0.09);
--row-hover: rgba(20,122,122,0.04);
}
body {
font-family: var(--font-body);
background: var(--bg-page);
color: var(--text-primary);
min-height: 100vh;
display: flex;
font-size: 14px;
line-height: 1.5;
transition: background 0.25s, color 0.25s;
}
/* ══════════════════════════════
LAYOUT
══════════════════════════════ */
.app { display: flex; width: 100%; min-height: 100vh; }
/* ── Sidebar ── */
.sidebar {
width: var(--sidebar-width);
background: var(--bg-sidebar);
border-right: var(--sidebar-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
position: fixed;
top: 0; left: 0; bottom: 0;
z-index: 20;
}
.sidebar-header {
padding: 22px 20px 18px;
border-bottom: 1px solid rgba(15,94,94,0.5);
}
.brand-mark {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.brand-icon {
width: 33px; height: 33px;
background: linear-gradient(135deg, #1A8A8A, #147A7A);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-family: var(--font-heading);
font-weight: 700;
font-size: 16px;
color: #FFFAF6;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(20,122,122,0.35);
}
.brand-name {
font-family: var(--font-heading);
font-weight: 700;
font-size: 17px;
color: #FFFAF6;
letter-spacing: -0.01em;
}
.brand-sub {
font-size: 11px;
color: #5BBBBB;
margin-left: 43px;
letter-spacing: 0.01em;
}
/* ── Nav ── */
.nav { padding: 10px 0; flex: 1; overflow-y: auto; }
.nav-label {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.09em;
text-transform: uppercase;
color: #426060;
padding: 10px 20px 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 20px;
font-size: 13.5px;
color: #7A9E9E;
cursor: pointer;
border-left: 3px solid transparent;
text-decoration: none;
transition: all 0.15s;
}
.nav-item:hover { color: #2EA3A3; background: rgba(46,163,163,0.07); }
.nav-item.active {
color: #FFFAF6;
font-weight: 500;
border-left-color: #D97B35;
background: rgba(10,68,68,0.55);
}
.nav-icon { width: 18px; text-align: center; font-size: 14px; flex-shrink: 0; }
.nav-badge {
margin-left: auto;
background: rgba(200,48,48,0.22);
color: #FF8080;
font-size: 10px;
font-weight: 600;
padding: 1px 7px;
border-radius: 20px;
font-family: var(--font-mono);
}
.nav-badge.warn { background: rgba(217,123,53,0.22); color: #E49655; }
.sidebar-footer {
padding: 14px 20px;
border-top: 1px solid rgba(15,94,94,0.5);
}
.user-row { display: flex; align-items: center; gap: 10px; }
.user-avatar {
width: 30px; height: 30px;
background: rgba(46,163,163,0.18);
border: 1px solid rgba(46,163,163,0.3);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 11px; color: #2EA3A3; font-weight: 600;
flex-shrink: 0;
}
.user-name { font-size: 12px; font-weight: 500; color: #FFFAF6; }
.user-role { font-size: 10px; color: #5BBBBB; margin-top: 1px; }
/* ── Main ── */
.main {
margin-left: var(--sidebar-width);
flex: 1;
display: flex;
flex-direction: column;
}
/* ── Topbar ── */
.topbar {
background: var(--bg-card);
border-bottom: 1px solid var(--border);
padding: 0 28px;
height: 60px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0; z-index: 10;
transition: background 0.25s, border-color 0.25s;
}
.topbar-left { display: flex; align-items: center; gap: 16px; }
.page-title {
font-family: var(--font-heading);
font-weight: 700;
font-size: 18px;
color: var(--text-heading);
}
.last-updated {
font-size: 11px;
color: var(--text-muted);
font-family: var(--font-mono);
padding: 3px 8px;
background: var(--bg-elevated);
border-radius: 4px;
}
.topbar-right { display: flex; align-items: center; gap: 10px; }
.mode-toggle {
background: var(--bg-elevated);
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 6px 13px;
border-radius: 6px;
font-size: 12px;
font-family: var(--font-body);
cursor: pointer;
transition: all 0.15s;
}
.mode-toggle:hover { border-color: var(--brand); color: var(--brand); }
.export-btn {
background: var(--brand);
border: none;
color: #FFFAF6;
padding: 7px 16px;
border-radius: 6px;
font-size: 13px;
font-family: var(--font-body);
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.export-btn:hover { background: var(--brand-hover); }
/* ══════════════════════════════
CONTENT
══════════════════════════════ */
.content { padding: 28px; }
/* ── Stat cards ── */
.stats-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px 22px;
box-shadow: var(--card-shadow);
transition: all 0.25s;
}
.stat-card.priority {
border-color: var(--copper);
box-shadow: var(--copper-glow);
}
.stat-label {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 10px;
}
.stat-value {
font-family: var(--font-mono);
font-size: 34px;
font-weight: 500;
color: var(--text-heading);
letter-spacing: -0.02em;
margin-bottom: 2px;
line-height: 1;
}
.stat-value.copper-val { color: var(--copper-text); }
.stat-sub { font-size: 12px; color: var(--text-secondary); margin-top: 6px; }
.stat-delta {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 20px;
margin-top: 10px;
}
.delta-good { color: #4AE899; background: rgba(26,122,78,0.14); }
.delta-warn { color: var(--copper-text); background: rgba(240,120,64,0.14); }
body.light .delta-good { color: #146040; background: #C8EDD8; }
body.light .delta-warn { color: #903A14; background: #FFE4CC; }
/* ══════════════════════════════
WORKLIST TABLE
══════════════════════════════ */
.table-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--card-shadow);
overflow: hidden;
transition: background 0.25s, border-color 0.25s;
}
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 22px;
border-bottom: 1px solid var(--border);
background: var(--bg-elevated);
gap: 16px;
flex-wrap: wrap;
}
.table-title {
font-family: var(--font-heading);
font-weight: 700;
font-size: 15px;
color: var(--text-heading);
}
.table-meta { font-size: 11px; color: var(--text-muted); font-family: var(--font-mono); margin-top: 2px; }
.filters { display: flex; gap: 6px; flex-wrap: wrap; }
.chip {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 4px 12px;
border-radius: 20px;
font-size: 11.5px;
cursor: pointer;
font-family: var(--font-body);
transition: all 0.15s;
}
.chip:hover { border-color: var(--brand); color: var(--brand); }
.chip.on { background: var(--brand); color: #fff; border-color: var(--brand); }
table { width: 100%; border-collapse: collapse; }
thead th {
padding: 10px 22px;
text-align: left;
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-muted);
background: var(--bg-elevated);
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
tbody tr {
border-bottom: 1px solid var(--border-subtle);
transition: background 0.1s;
}
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--row-hover); }
tbody tr.p-row { background: var(--priority-row); }
tbody tr.p-row:hover { background: rgba(203,107,32,0.14); }
td {
padding: 13px 22px;
font-size: 13.5px;
color: var(--text-primary);
vertical-align: middle;
}
.pid {
font-family: var(--font-mono);
font-size: 12.5px;
color: var(--text-secondary);
font-weight: 700;
}
.p-row .pid { color: var(--copper-text); }
.top-tag {
font-size: 9.5px;
font-family: var(--font-mono);
font-weight: 600;
letter-spacing: 0.06em;
color: var(--copper-text);
margin-top: 2px;
}
.device { font-weight: 500; }
.payer { color: var(--text-secondary); font-size: 13px; }
.days { font-family: var(--font-mono); font-weight: 500; }
.days-critical { color: #FF7070; }
.days-warn { color: var(--copper-text); }
.days-ok { color: var(--text-primary); }
.days-expired { color: #FF7070; font-weight: 600; }
.score {
font-family: var(--font-mono);
font-size: 16px;
font-weight: 500;
}
.score-hi { color: var(--copper-text); }
.score-mid { color: var(--text-secondary); }
.score-lo { color: var(--text-muted); }
/* ── Badges ── */
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 500;
white-space: nowrap;
}
.b-active { background: rgba(26,122,78,0.16); color: #4AE899; }
.b-expiring { background: rgba(168,90,24,0.16); color: #F0B464; }
.b-critical { background: rgba(200,48,48,0.16); color: #FF7070; }
.b-pa-req { background: rgba(240,120,64,0.18); color: #FFB070; border: 1px solid rgba(240,120,64,0.3); }
.b-pa-pend { background: rgba(122,94,160,0.16); color: #C0A8E0; }
.b-denied { background: rgba(200,48,48,0.22); color: #FF7070; }
body.light .b-active { background: #C8EDD8; color: #146040; }
body.light .b-expiring { background: #FDECD5; color: #A85A18; }
body.light .b-critical { background: #F8CCCC; color: #A02020; }
body.light .b-pa-req { background: #FFE4CC; color: #903A14; border-color: rgba(144,58,20,0.2); }
body.light .b-pa-pend { background: #E2D8F0; color: #7A5EA0; }
body.light .b-denied { background: #F8CCCC; color: #A02020; }
/* ── Action buttons ── */
.act {
background: transparent;
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 5px 13px;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
font-family: var(--font-body);
white-space: nowrap;
transition: all 0.15s;
}
.act:hover { border-color: var(--brand); color: var(--brand); }
.act-copper {
border-color: var(--copper);
color: var(--copper-text);
}
.act-copper:hover { background: rgba(203,107,32,0.1); }
/* ── Footer note ── */
.footer-note {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 22px;
border-top: 1px solid var(--border-subtle);
font-size: 11px;
color: var(--text-muted);
background: var(--bg-elevated);
}
.phi-note {
display: flex; align-items: center; gap: 6px;
color: var(--text-secondary);
}
/* ── Import toast ── */
.import-toast {
display: none;
position: fixed;
bottom: 24px;
right: 24px;
background: var(--bg-elevated);
border: 1px solid var(--brand);
border-radius: 8px;
padding: 12px 18px;
font-size: 13px;
color: var(--text-primary);
z-index: 100;
box-shadow: var(--card-shadow);
animation: slideIn 0.2s ease;
}
.import-toast.show { display: block; }
@keyframes slideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body>
<div class="app">
<!-- hidden file input for CSV import -->
<input type="file" id="csv-input" accept=".csv" style="display:none" onchange="handleCSVImport(event)" />
<!-- ══ SIDEBAR ══ -->
<aside class="sidebar">
<div class="sidebar-header">
<div class="brand-mark">
<div class="brand-icon">S</div>
<span class="brand-name">Signal</span>
</div>
<div class="brand-sub">by STTIL Solutions</div>
</div>
<nav class="nav">
<div class="nav-label">Worklist</div>
<a class="nav-item active" href="#">
<span class="nav-icon"></span> All Patients
</a>
<a class="nav-item" href="#">
<span class="nav-icon"></span> Resupply Ready
<span class="nav-badge warn" id="badge-expiring">0</span>
</a>
<a class="nav-item" href="#">
<span class="nav-icon"></span> Renewal Due
<span class="nav-badge" id="badge-visit">0</span>
</a>
<a class="nav-item" href="#">
<span class="nav-icon"></span> Supply Lapsed
<span class="nav-badge" id="badge-ooc">0</span>
</a>
<div class="nav-label" style="margin-top:4px;">System</div>
<a class="nav-item" href="#" onclick="document.getElementById('csv-input').click(); return false;">
<span class="nav-icon"></span> Import CSV
</a>
<a class="nav-item" href="#">
<span class="nav-icon"></span> Settings
</a>
</nav>
<div class="sidebar-footer">
<div class="user-row">
<div class="user-avatar">DS</div>
<div>
<div class="user-name">Demo Supplier</div>
<div class="user-role">Billing Staff</div>
</div>
</div>
</div>
</aside>
<!-- ══ MAIN ══ -->
<main class="main">
<header class="topbar">
<div class="topbar-left">
<div class="page-title">Outreach Worklist</div>
<span class="last-updated" id="import-label">Demo data · Apr 23, 2026</span>
</div>
<div class="topbar-right">
<button class="mode-toggle" onclick="document.body.classList.toggle('light')">
◐ Toggle Light/Dark
</button>
<button class="export-btn" id="export-btn" onclick="exportWorkQueue()">↓ Export Work Queue</button>
<a href="mailto:kisa@sttilsolutions.com" class="export-btn" style="text-decoration:none; background:#D97B35;">Request Access →</a>
</div>
</header>
<div class="content">
<!-- ── Stats ── -->
<div class="stats-row">
<div class="stat-card priority" id="stat-card-urgent">
<div class="stat-label" id="stat-urgent-label">⚡ Prescriber Action</div>
<div class="stat-value copper-val" id="stat-urgent-value">6</div>
<div class="stat-sub" id="stat-urgent-sub">supply lapsed or renewal due — prescriber contact required</div>
<div class="stat-delta delta-warn" id="stat-urgent-delta">▲ action required today</div>
</div>
<div class="stat-card">
<div class="stat-label">⚠ Resupply Ready</div>
<div class="stat-value" id="stat-refill-value">14</div>
<div class="stat-sub">patients within resupply window — initiate now</div>
<div class="stat-delta delta-good" id="stat-refill-delta">initiate resupply now</div>
</div>
<div class="stat-card">
<div class="stat-label">✓ Active</div>
<div class="stat-value" id="stat-ok-value">142</div>
<div class="stat-sub">patients with supply on track — no action needed</div>
<div class="stat-delta delta-good">no action needed</div>
</div>
</div>
<!-- ── Worklist Table ── -->
<div class="table-card">
<div class="table-header">
<div>
<div class="table-title">Outreach Worklist</div>
<div class="table-meta" id="table-meta">162 patients · sorted by priority score · Apr 23, 2026</div>
</div>
<div class="filters">
<button class="chip on">All</button>
<button class="chip">Supply Lapsed</button>
<button class="chip">Renewal Due</button>
<button class="chip">Resupply Ready</button>
<button class="chip">Active</button>
</div>
</div>
<table>
<thead>
<tr>
<th>Patient ID</th>
<th>Device</th>
<th>Payer</th>
<th>Days Left</th>
<th>Status</th>
<th>Priority</th>
<th>Action</th>
</tr>
</thead>
<tbody id="worklist-tbody">
<!-- Static demo rows — replaced on CSV import -->
<tr class="p-row">
<td>
<div class="pid">PT-00142</div>
<div class="top-tag">★ TOP PRIORITY</div>
</td>
<td><span class="device">Dexcom G7</span></td>
<td><span class="payer">Medicare Part B</span></td>
<td><span class="days days-critical">4 days</span></td>
<td><span class="badge b-pa-req"><span></span> Renewal Due</span></td>
<td><span class="score score-hi">98</span></td>
<td><button class="act act-copper">Request Renewal →</button></td>
</tr>
<tr>
<td><div class="pid">PT-00387</div></td>
<td><span class="device">FreeStyle Libre 3</span></td>
<td><span class="payer">Medicaid — GA</span></td>
<td><span class="days days-critical">11 days</span></td>
<td><span class="badge b-critical"><span></span> Supply Lapsed</span></td>
<td><span class="score score-hi">87</span></td>
<td><button class="act act-copper">Contact Prescriber</button></td>
</tr>
<tr>
<td><div class="pid">PT-00291</div></td>
<td><span class="device">Dexcom G7</span></td>
<td><span class="payer">Medicare Part B</span></td>
<td><span class="days days-critical">18 days</span></td>
<td><span class="badge b-expiring"><span></span> Resupply Ready</span></td>
<td><span class="score score-hi">74</span></td>
<td><button class="act">Initiate Resupply</button></td>
</tr>
<tr>
<td><div class="pid">PT-00556</div></td>
<td><span class="device">Omnipod 5</span></td>
<td><span class="payer">BCBS — FL</span></td>
<td><span class="days days-warn">22 days</span></td>
<td><span class="badge b-expiring"><span></span> Resupply Ready</span></td>
<td><span class="score score-mid">68</span></td>
<td><button class="act">Initiate Resupply</button></td>
</tr>
<tr>
<td><div class="pid">PT-00103</div></td>
<td><span class="device">FreeStyle Libre 3</span></td>
<td><span class="payer">Medicare Part B</span></td>
<td><span class="days days-warn">31 days</span></td>
<td><span class="badge b-expiring"><span></span> Resupply Ready</span></td>
<td><span class="score score-mid">52</span></td>
<td><button class="act">Initiate Resupply</button></td>
</tr>
<tr>
<td><div class="pid">PT-00478</div></td>
<td><span class="device">Dexcom G7</span></td>
<td><span class="payer">Aetna</span></td>
<td><span class="days days-ok">45 days</span></td>
<td><span class="badge b-active"><span></span> Active</span></td>
<td><span class="score score-mid">41</span></td>
<td><button class="act">View</button></td>
</tr>
<tr>
<td><div class="pid">PT-00612</div></td>
<td><span class="device">FreeStyle Libre 3</span></td>
<td><span class="payer">UnitedHealth</span></td>
<td><span class="days days-ok">62 days</span></td>
<td><span class="badge b-active"><span></span> Active</span></td>
<td><span class="score score-lo">28</span></td>
<td><button class="act">View</button></td>
</tr>
<tr>
<td><div class="pid">PT-00089</div></td>
<td><span class="device">Dexcom G7</span></td>
<td><span class="payer">Medicare Part B</span></td>
<td><span class="days days-expired">Expired</span></td>
<td><span class="badge b-denied"><span></span> Supply Lapsed</span></td>
<td><span class="score score-lo"></span></td>
<td><button class="act act-copper">Contact Prescriber</button></td>
</tr>
</tbody>
</table>
<div class="footer-note">
<span class="phi-note">
🔒 PHI-safe — patient names and DOBs never stored. Crosswalk: patient_id only.
</span>
<span id="footer-count">Showing 8 of 162 · Load more →</span>
</div>
</div>
</div><!-- /content -->
</main>
</div>
<!-- Import toast notification -->
<div class="import-toast" id="import-toast"></div>
<script>
// ── Global state ──
let currentResults = [];
const BACKEND_URL = 'https://signal-api-production-91c2.up.railway.app';
// ── Backend upload (with local fallback) ──
async function uploadToBackend(file) {
const formData = new FormData();
formData.append('file', file);
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();
}
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,
};
}
// ── Coverage calculation rules (mirrored from payer_rules.json) ──
const DEVICE_RULES = {
dexcom_g7: { sensor: 10 },
dexcom_g6: { sensor: 10, transmitter: 90 },
freestyle_libre_2: { sensor: 14 },
freestyle_libre_3: { sensor: 14 },
omnipod_5: { pod: 3, sensor: 14 }
};
const DEVICE_DISPLAY = {
dexcom_g7: 'Dexcom G7',
dexcom_g6: 'Dexcom G6',
freestyle_libre_2: 'FreeStyle Libre 2',
freestyle_libre_3: 'FreeStyle Libre 3',
omnipod_5: 'Omnipod 5'
};
function getPayerConfig(payer) {
const p = payer.toLowerCase();
if (p.includes('medicare')) {
return { visit_renewal_days: 180, refill_window_days: 30 };
}
if (p.includes('medicaid')) {
return { visit_renewal_days: null, refill_window_days: 30 };
}
return { visit_renewal_days: null, refill_window_days: 30 };
}
function getWearDays(device_type, component) {
const device = DEVICE_RULES[device_type];
if (!device) return null;
return device[component] || null;
}
function addDays(dateStr, days) {
const d = new Date(dateStr + 'T00:00:00');
d.setDate(d.getDate() + days);
return d;
}
function today() {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
function daysDiff(a, b) {
return Math.round((a - b) / 86400000);
}
function computePriority(flag, daysUntilEnd) {
if (flag === 'OUT_OF_COVERAGE') return 1000 + Math.abs(daysUntilEnd);
if (flag === 'VISIT_DUE') return 500 + Math.max(0, 90 - daysUntilEnd);
if (flag === 'REFILL_WINDOW') return 200 + Math.max(0, 30 - daysUntilEnd);
return 0;
}
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);
const refillWindow = payerCfg.refill_window_days;
let daysUntilVisit = null;
if (payerCfg.visit_renewal_days) {
const visitDue = addDays(record.shipment_date, payerCfg.visit_renewal_days);
daysUntilVisit = daysDiff(visitDue, now);
}
let flag;
if (daysUntilEnd < 0) {
flag = 'OUT_OF_COVERAGE';
} else if (daysUntilVisit !== null && daysUntilVisit <= 30) {
flag = 'VISIT_DUE';
} else if (daysUntilEnd <= refillWindow) {
flag = 'REFILL_WINDOW';
} else {
flag = 'OK';
}
return {
...record,
flag,
daysUntilEnd,
daysUntilVisit,
priority: computePriority(flag, daysUntilEnd)
};
}
// ── CSV parser ──
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;
});
}
// ── Render helpers ──
function daysLabel(days) {
if (days < 0) return `<span class="days days-expired">Expired ${Math.abs(days)}d ago</span>`;
if (days <= 7) return `<span class="days days-critical">${days} days</span>`;
if (days <= 30) return `<span class="days days-warn">${days} days</span>`;
return `<span class="days days-ok">${days} days</span>`;
}
function flagBadge(flag) {
switch (flag) {
case 'OUT_OF_COVERAGE':
return `<span class="badge b-denied"><span>✕</span> Supply Lapsed</span>`;
case 'VISIT_DUE':
return `<span class="badge b-pa-req"><span>⚡</span> Renewal Due</span>`;
case 'REFILL_WINDOW':
return `<span class="badge b-expiring"><span>⚠</span> Resupply Ready</span>`;
default:
return `<span class="badge b-active"><span>✓</span> Active</span>`;
}
}
function actionBtn(flag) {
switch (flag) {
case 'OUT_OF_COVERAGE':
return `<button class="act act-copper">Contact Prescriber</button>`;
case 'VISIT_DUE':
return `<button class="act act-copper">Request Renewal →</button>`;
case 'REFILL_WINDOW':
return `<button class="act">Initiate Resupply</button>`;
default:
return `<button class="act">View</button>`;
}
}
function scoreClass(priority) {
if (priority >= 500) return 'score-hi';
if (priority >= 200) return 'score-mid';
return 'score-lo';
}
function isHighPriority(flag) {
return flag === 'OUT_OF_COVERAGE' || flag === 'VISIT_DUE';
}
function renderRows(results) {
const tbody = document.getElementById('worklist-tbody');
tbody.innerHTML = results.map((r, i) => {
const priorityRow = isHighPriority(r.flag) ? 'p-row' : '';
const topTag = i === 0 ? '<div class="top-tag">★ TOP PRIORITY</div>' : '';
const device = DEVICE_DISPLAY[r.device_type] || r.device_type;
return `
<tr class="${priorityRow}">
<td>
<div class="pid">${r.patient_id}</div>
${topTag}
</td>
<td><span class="device">${device}</span></td>
<td><span class="payer">${r.payer}</span></td>
<td>${daysLabel(r.daysUntilEnd)}</td>
<td>${flagBadge(r.flag)}</td>
<td><span class="score ${scoreClass(r.priority)}">${r.priority}</span></td>
<td>${actionBtn(r.flag)}</td>
</tr>`;
}).join('');
}
function updateStats(results) {
const ooc = results.filter(r => r.flag === 'OUT_OF_COVERAGE').length;
const visitDue = results.filter(r => r.flag === 'VISIT_DUE').length;
const refill = results.filter(r => r.flag === 'REFILL_WINDOW').length;
const ok = results.filter(r => r.flag === 'OK').length;
const urgent = ooc + visitDue;
document.getElementById('stat-urgent-value').textContent = urgent;
document.getElementById('stat-urgent-sub').textContent =
`${ooc} out of coverage · ${visitDue} visit${visitDue !== 1 ? 's' : ''} due`;
document.getElementById('stat-refill-value').textContent = refill;
document.getElementById('stat-ok-value').textContent = ok;
document.getElementById('badge-ooc').textContent = ooc;
document.getElementById('badge-visit').textContent = visitDue;
document.getElementById('badge-expiring').textContent = refill;
const today = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
document.getElementById('table-meta').textContent =
`${results.length} patients · sorted by priority score · ${today}`;
document.getElementById('footer-count').textContent =
`${results.length} patients · all results shown`;
}
function showToast(msg) {
const t = document.getElementById('import-toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3500);
}
// ── Local CSV processing (fallback) ──
function processLocally(file) {
const reader = new FileReader();
reader.onload = function(e) {
const rows = parseCSV(e.target.result);
const results = [], 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);
currentResults = results;
renderRows(results);
updateStats(results);
const label = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
document.getElementById('import-label').textContent = `Import: ${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);
}
// ── Main import handler — tries backend, falls back to local ──
function handleCSVImport(event) {
const file = event.target.files[0];
if (!file) return;
event.target.value = '';
uploadToBackend(file)
.then(data => {
const results = data.records.map(apiRecordToLocal);
currentResults = results;
renderRows(results);
updateStats(results);
const label = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
document.getElementById('import-label').textContent = `${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);
})
.catch(() => {
// Backend unreachable — process in browser
processLocally(file);
});
}
// ── Export work queue to CSV ──
function flagLabel(flag) {
const labels = {
OUT_OF_COVERAGE: 'Supply Lapsed',
VISIT_DUE: 'Renewal Due',
REFILL_WINDOW: 'Resupply Ready',
OK: 'Active'
};
return labels[flag] || flag;
}
function actionText(flag) {
const actions = {
OUT_OF_COVERAGE: 'Contact Prescriber',
VISIT_DUE: 'Request Renewal',
REFILL_WINDOW: 'Initiate Resupply',
OK: 'No action needed'
};
return actions[flag] || 'Review';
}
function exportWorkQueue() {
if (currentResults.length === 0) {
showToast('No data to export — import a CSV first.');
return;
}
const headers = [
'Patient ID', 'Device', 'Payer', 'Status',
'Priority Score', 'Days Until Coverage End', 'Recommended Action'
];
const rows = currentResults.map(r => [
r.patient_id,
DEVICE_DISPLAY[r.device_type] || r.device_type,
r.payer,
flagLabel(r.flag),
r.priority,
r.daysUntilEnd,
actionText(r.flag)
]);
const csvContent = [headers, ...rows]
.map(row => row.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))
.join('\r\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `signal-work-queue-${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showToast(`Exported ${currentResults.length} patients to CSV`);
}
</script>
</body>
</html>