Signal/signal-ui/src/components/WorklistTable.jsx
Kisa a424ac9d13 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>
2026-05-26 09:45:02 -04:00

206 lines
No EOL
8.9 KiB
JavaScript

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>;
}