- 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>
206 lines
No EOL
8.9 KiB
JavaScript
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>;
|
|
} |