Ends the override leak: an SWO/PA save now applies to one device's line, not
every device the patient has. doc_status gains device_type ('' = legacy
patient-scoped row, applies until re-saved); the old unique key is dropped by
introspected name and replaced with the 4-col scope key. Staff overrides now
FEED THE VERDICT (device-scoped, staff wins over the CSV cell) instead of
display-only; visit overrides keep their patient-scoped fan-out via
confirmed_visits. PUT /api/doc-status and /api/confirm-visit accept an
optional echo of the patient's lines and return {lines, rollup_status}
recomputed through the same engine (stateless, no batch rebuild). Frontend
minimal fix: doc-status state and writes keyed patient:device. Independent
audit: SHIP (migration reproduced empirically on both old-key shapes; guard
scoped per its nit). 162 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
540 lines
25 KiB
JavaScript
540 lines
25 KiB
JavaScript
import { useState, useEffect, useRef, Fragment } from "react";
|
|
import { useTheme } from "../useTheme";
|
|
import { useAuth } from "@clerk/react";
|
|
import Badge from "./Badge";
|
|
import DocStatusBar from "./DocStatusBar";
|
|
import ConfirmVisitModal from "./ConfirmVisitModal";
|
|
import StatusLegend from "./StatusLegend";
|
|
import { updateDocStatus } from "../lib/api";
|
|
|
|
function daysLabel(days, flag) {
|
|
if (days === undefined || days === null) return <span className="font-mono font-medium text-[var(--text-warm)]">—</span>;
|
|
if (days < 0)
|
|
return <span className="font-mono font-semibold text-[#FF7070]">Expired {Math.abs(days)}d ago</span>;
|
|
// RESUPPLY_READY: days left means window closing, not a problem — show as teal urgency not red alert
|
|
if (flag === "RESUPPLY_READY") {
|
|
if (days <= 7) return <span className="font-mono font-medium text-[var(--brand)]">{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>;
|
|
}
|
|
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 >= 1000) return "text-[#B00000]";
|
|
if (priority >= 500) return "text-[var(--accent-text)]";
|
|
if (priority >= 200) return "text-[var(--text-secondary)]";
|
|
return "text-[var(--text-warm)]";
|
|
}
|
|
|
|
const FILTERS = [
|
|
{ key: "all", label: "All", color: null, check: false },
|
|
{ key: "at-risk", label: "At Risk", color: "#E53E3E", check: false },
|
|
{ key: "action-needed", label: "Action Needed", color: "#F59E0B", check: false },
|
|
{ key: "RESUPPLY_READY", label: "Clear to Ship", color: "#22C55E", check: true },
|
|
{ key: "ACTIVE", label: "On Track", color: "#2EA3A3", check: false },
|
|
];
|
|
|
|
const SWO_CYCLE = ["pending", "requested", "on_file"];
|
|
const PA_CYCLE = ["not_required", "requested", "approved", "denied"];
|
|
const DOC_STATUS_LABELS = {
|
|
pending: "Pending", requested: "Requested", on_file: "On File",
|
|
not_required: "Not Required", approved: "Approved", denied: "Denied",
|
|
};
|
|
|
|
const AT_RISK_FLAGS = ["SUPPLY_LAPSED", "VISIT_REQUIRED", "RENEWAL_CRITICAL"];
|
|
const ACTION_NEEDED_FLAGS = ["RENEWAL_ELEVATED", "RENEWAL_SOON", "TRANSFER_PENDING"];
|
|
|
|
const HIGH_PRIORITY_FLAGS = ["SUPPLY_LAPSED", "VISIT_REQUIRED", "RENEWAL_CRITICAL", "TRANSFER_PENDING"];
|
|
const CONFIRM_VISIT_FLAGS = ["VISIT_REQUIRED", "RENEWAL_CRITICAL", "RENEWAL_ELEVATED", "RENEWAL_SOON"];
|
|
|
|
export default function WorklistTable({ records, activeFilter, onFilterChange, onVisitConfirmed }) {
|
|
const { dark } = useTheme();
|
|
const { getToken } = useAuth();
|
|
const [expandedRow, setExpandedRow] = useState(() => localStorage.getItem("signal_expanded_row") || null);
|
|
const [activePatientId, setActivePatientId] = useState(null);
|
|
const [confirmingRecord, setConfirmingRecord] = useState(null);
|
|
const [localRecords, setLocalRecords] = useState(records);
|
|
const [toast, setToast] = useState({ visible: false, message: "" });
|
|
const [localDocStates, setLocalDocStates] = useState({});
|
|
const rowRefs = useRef({});
|
|
|
|
useEffect(() => {
|
|
setLocalRecords(records);
|
|
setLocalDocStates({});
|
|
}, [records]);
|
|
|
|
function showToast(message) {
|
|
setToast({ visible: true, message });
|
|
setTimeout(() => setToast({ visible: false, message: "" }), 5000);
|
|
}
|
|
|
|
function handleSaveSuccess({ patient_id: savedPatientId }) {
|
|
setActivePatientId(prev => (prev === savedPatientId ? null : prev));
|
|
|
|
const EXCLUDED_FLAGS = ["ACTIVE", "NO_RECENT_SHIPMENT"];
|
|
const next = localRecords
|
|
.filter(r => !EXCLUDED_FLAGS.includes(r.flag) && r.patient_id !== savedPatientId)
|
|
.sort((a, b) => (b.priority_score || b.priority || 0) - (a.priority_score || a.priority || 0))[0];
|
|
|
|
if (next) {
|
|
const label = next.reason || next.flag || "";
|
|
showToast(`Done. Next: Patient ${next.patient_id}: ${label}`);
|
|
} else {
|
|
showToast("Done. Your list is clear.");
|
|
}
|
|
}
|
|
|
|
function handleVisitConfirmed(patientId, updatedRecord) {
|
|
setLocalRecords(prev =>
|
|
prev.map(r => r.patient_id === patientId ? { ...r, ...updatedRecord } : r)
|
|
.sort((a, b) => (b.priority_score || b.priority || 0) - (a.priority_score || a.priority || 0))
|
|
);
|
|
onVisitConfirmed?.(patientId, updatedRecord);
|
|
}
|
|
|
|
const filtered =
|
|
activeFilter === "all"
|
|
? localRecords
|
|
: activeFilter === "at-risk"
|
|
? localRecords.filter((r) => AT_RISK_FLAGS.includes(r.flag))
|
|
: activeFilter === "action-needed"
|
|
? localRecords.filter((r) => ACTION_NEEDED_FLAGS.includes(r.flag))
|
|
: localRecords.filter((r) => r.flag === activeFilter);
|
|
|
|
const todayStr = new Date().toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
|
|
const total = localRecords.length;
|
|
const tabCounts = {
|
|
all: total,
|
|
"at-risk": localRecords.filter(r => AT_RISK_FLAGS.includes(r.flag)).length,
|
|
"action-needed": localRecords.filter(r => ACTION_NEEDED_FLAGS.includes(r.flag)).length,
|
|
RESUPPLY_READY: localRecords.filter(r => r.flag === "RESUPPLY_READY" && !(r.cascade?.length > 0)).length,
|
|
ACTIVE: localRecords.filter(r => r.flag === "ACTIVE").length,
|
|
};
|
|
|
|
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-warm)] mt-[2px]">
|
|
{localRecords.length} patients · sorted by priority score · {todayStr}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-[6px] flex-wrap">
|
|
{FILTERS.map((f) => {
|
|
const count = tabCounts[f.key] ?? 0;
|
|
const pct = total > 0 && f.key !== "all" ? Math.round((count / total) * 100) : null;
|
|
const isActive = activeFilter === f.key;
|
|
const indicatorColor = isActive ? "white" : f.color;
|
|
return (
|
|
<button
|
|
key={f.key}
|
|
onClick={() => onFilterChange(f.key)}
|
|
className={`px-3 py-[5px] rounded-full text-[11.5px] cursor-pointer font-body transition-all border inline-flex items-center gap-[5px] ${
|
|
isActive
|
|
? "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.color && (
|
|
f.check
|
|
? <span style={{ color: indicatorColor, fontSize: "12px", fontWeight: 800, lineHeight: 1, marginTop: "-1px" }}>✓</span>
|
|
: <span style={{ width: 6, height: 6, borderRadius: "50%", backgroundColor: indicatorColor, display: "inline-block", flexShrink: 0 }} />
|
|
)}
|
|
{f.label}
|
|
{total > 0 && (
|
|
<span style={{ opacity: 0.65, fontSize: "10.5px", fontWeight: 500 }}>
|
|
{f.key === "all" ? count : `${count}${pct !== null ? ` · ${pct}%` : ""}`}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Working-on banner */}
|
|
{activePatientId && (
|
|
<div
|
|
style={{
|
|
backgroundColor: "#2EA3A3",
|
|
color: "#ffffff",
|
|
fontSize: "12px",
|
|
fontWeight: 500,
|
|
padding: "6px 22px",
|
|
cursor: "pointer",
|
|
lineHeight: "1.4",
|
|
}}
|
|
onClick={() => {
|
|
const targetKey = Object.keys(rowRefs.current).find(k => k.startsWith(activePatientId + "-"));
|
|
if (targetKey) {
|
|
setExpandedRow(targetKey);
|
|
localStorage.setItem("signal_expanded_row", targetKey);
|
|
rowRefs.current[targetKey]?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
}
|
|
}}
|
|
>
|
|
Working on Patient {activePatientId}, click to return
|
|
</div>
|
|
)}
|
|
|
|
{/* Table */}
|
|
<table className="w-full border-collapse">
|
|
<thead>
|
|
<tr>
|
|
{["Patient ID", "Device", "Payer", "Days Left", "Status", "Priority", "Action"].map(h => (
|
|
<th key={h} className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-warm)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
|
|
{h}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filtered.map((r, i) => {
|
|
const hp = HIGH_PRIORITY_FLAGS.includes(r.flag);
|
|
const isStale = r.flag === "NO_RECENT_SHIPMENT";
|
|
const rowKey = r.patient_id + "-" + i;
|
|
const isExpanded = expandedRow === rowKey;
|
|
// NO_RECENT_SHIPMENT rows never show Confirm Visit — those patients need prescriber
|
|
// verification first, not a visit date entry. CONFIRM_VISIT_FLAGS does not include
|
|
// NO_RECENT_SHIPMENT, so the estimated-visit fallback check is also gated.
|
|
const showConfirmVisit = !isStale && (CONFIRM_VISIT_FLAGS.includes(r.flag) || r.visit_date_confidence === "estimated");
|
|
// Support both API shape (days_until_coverage_end) and legacy local shape (daysUntilEnd)
|
|
const daysLeft = r.days_until_coverage_end ?? r.daysUntilEnd;
|
|
const priority = r.priority_score ?? r.priority;
|
|
const deviceDisplay = r.device_display || r.device_type;
|
|
// RESUPPLY_READY with open cascade items means timing is fine but docs are not.
|
|
// Show amber "Docs Required" badge instead of green "Clear to Ship".
|
|
const effectiveFlag = (r.flag === "RESUPPLY_READY" && r.cascade?.length > 0)
|
|
? "DOCS_REQUIRED"
|
|
: r.flag;
|
|
|
|
return (
|
|
<Fragment key={rowKey}>
|
|
<tr
|
|
ref={el => { rowRefs.current[rowKey] = el; }}
|
|
className={`border-b border-[var(--border-subtle)] transition-colors cursor-pointer hover:bg-[var(--row-hover)] ${
|
|
isStale
|
|
? "opacity-50"
|
|
: 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)]"
|
|
: ""
|
|
}`}
|
|
onClick={() => {
|
|
if (isExpanded) {
|
|
// Collapsing — clear active if this is the active patient
|
|
setActivePatientId(prev => (prev === r.patient_id ? null : prev));
|
|
setExpandedRow(null);
|
|
localStorage.removeItem("signal_expanded_row");
|
|
} else {
|
|
// Expanding — mark as active if showConfirmVisit
|
|
if (showConfirmVisit) {
|
|
setActivePatientId(r.patient_id);
|
|
}
|
|
setExpandedRow(rowKey);
|
|
localStorage.setItem("signal_expanded_row", rowKey);
|
|
}
|
|
}}
|
|
>
|
|
<td className="px-[22px] py-[13px] align-middle">
|
|
<div className={`font-mono text-[12.5px] font-bold ${isStale ? "italic text-[var(--text-warm)]" : 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>
|
|
)}
|
|
{r.visit_date_confidence === "estimated" && (
|
|
<div className="text-[9px] text-[#CB6B20] mt-[2px]">Estimated visit date</div>
|
|
)}
|
|
</td>
|
|
<td className="px-[22px] py-[13px] text-[13.5px] font-medium text-[var(--text-primary)] align-middle">
|
|
{deviceDisplay}
|
|
</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(daysLeft, r.flag)}
|
|
</td>
|
|
<td className="px-[22px] py-[13px] align-middle">
|
|
<div className="flex items-center gap-[8px]">
|
|
<Badge flag={effectiveFlag} />
|
|
{r.doc_state && <DocStatusBar docState={r.doc_state} />}
|
|
</div>
|
|
{r.reason && (
|
|
<div className="text-[10.5px] text-[var(--text-warm)] mt-[4px] max-w-[260px] leading-[1.4]">
|
|
{r.reason}
|
|
</div>
|
|
)}
|
|
{r.flag === "ACTIVE" && r.next_visit_due_date && (
|
|
<div className="text-[10.5px] text-[var(--text-warm)] mt-[4px]">
|
|
Next visit due: {new Date(r.next_visit_due_date + "T00:00:00").toLocaleDateString("en-US", { month: "short", year: "numeric" })}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-[22px] py-[13px] align-middle">
|
|
<span className={`font-mono text-[16px] font-medium ${scoreClass(priority)}`}>
|
|
{priority}
|
|
</span>
|
|
</td>
|
|
<td className="px-[22px] py-[13px] align-middle" onClick={e => e.stopPropagation()}>
|
|
<ActionCell
|
|
flag={effectiveFlag}
|
|
cascade={r.cascade}
|
|
showConfirmVisit={showConfirmVisit}
|
|
onConfirmVisit={() => setConfirmingRecord(r)}
|
|
onExpand={(e) => {
|
|
e.stopPropagation();
|
|
if (isExpanded) {
|
|
setActivePatientId(prev => (prev === r.patient_id ? null : prev));
|
|
setExpandedRow(null);
|
|
localStorage.removeItem("signal_expanded_row");
|
|
} else {
|
|
if (showConfirmVisit) setActivePatientId(r.patient_id);
|
|
setExpandedRow(rowKey);
|
|
localStorage.setItem("signal_expanded_row", rowKey);
|
|
}
|
|
}}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
|
|
{/* Expanded row — cascade + doc checklist */}
|
|
{isExpanded && (
|
|
<tr key={rowKey + "-expanded"} className="bg-[var(--bg-elevated)]">
|
|
<td colSpan={7} className="px-[32px] py-[16px]">
|
|
{r.cascade && r.cascade.length > 0 && (
|
|
<div className="mb-4">
|
|
<div className="text-[11px] font-semibold text-[#CC2222] uppercase tracking-[0.06em] mb-2">
|
|
Documentation Actions Required
|
|
</div>
|
|
<ul className="list-none m-0 p-0 space-y-[6px]">
|
|
{r.cascade.map((item, idx) => {
|
|
const isDenied = /denied/i.test(item);
|
|
const color = isDenied ? "#CC2222" : "#CB6B20";
|
|
return (
|
|
<li key={idx} className="flex gap-[8px] items-start text-[12.5px] leading-[1.5]" style={{ color }}>
|
|
<span className="shrink-0 font-bold mt-[1px]">•</span>
|
|
<span>{item}</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
{r.doc_state && (
|
|
<div>
|
|
<div className="text-[11px] font-semibold text-[var(--text-warm)] uppercase tracking-[0.06em] mb-2">
|
|
Documentation Checklist
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-y-[6px] gap-x-[20px] text-[12px]">
|
|
<DocItem
|
|
label="SWO"
|
|
value={localDocStates[`${r.patient_id}:${r.device_type}`]?.swo ?? r.doc_state.swo}
|
|
patientId={r.patient_id} deviceType={r.device_type} docType="swo" cycle={SWO_CYCLE}
|
|
getToken={getToken}
|
|
onUpdated={(type, label) => setLocalDocStates(prev => ({
|
|
...prev, [`${r.patient_id}:${r.device_type}`]: { ...prev[`${r.patient_id}:${r.device_type}`], [type]: label }
|
|
}))}
|
|
/>
|
|
<DocItem label="Qualifying Visit" value={r.doc_state.visit} />
|
|
<DocItem label="PECOS Enrollment" value={r.doc_state.pecos} />
|
|
<DocItem
|
|
label="Prior Authorization"
|
|
value={localDocStates[`${r.patient_id}:${r.device_type}`]?.pa ?? r.doc_state.pa}
|
|
patientId={r.patient_id} deviceType={r.device_type} docType="pa" cycle={PA_CYCLE}
|
|
getToken={getToken}
|
|
onUpdated={(type, label) => setLocalDocStates(prev => ({
|
|
...prev, [`${r.patient_id}:${r.device_type}`]: { ...prev[`${r.patient_id}:${r.device_type}`], [type]: label }
|
|
}))}
|
|
/>
|
|
<DocItem label="Diagnosis on File" value={r.doc_state.diagnosis} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!r.doc_state && !r.cascade?.length && (
|
|
<div className="text-[12px] text-[var(--text-warm)]">No documentation details available. Upload a CSV with doc columns to see the full checklist.</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
{filtered.length === 0 && (
|
|
<tr>
|
|
<td colSpan={7} className="px-[22px] py-8 text-center text-[var(--text-warm)]">
|
|
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-warm)] 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"
|
|
? `${localRecords.length} patients · all results shown`
|
|
: `${filtered.length} of ${localRecords.length} · filtered`}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Confirm Visit Modal */}
|
|
{confirmingRecord && (
|
|
<ConfirmVisitModal
|
|
record={confirmingRecord}
|
|
onClose={() => setConfirmingRecord(null)}
|
|
onConfirmed={handleVisitConfirmed}
|
|
onSaveSuccess={handleSaveSuccess}
|
|
/>
|
|
)}
|
|
|
|
{/* Next-priority toast */}
|
|
{toast.visible && (
|
|
<div
|
|
style={{
|
|
position: "fixed",
|
|
bottom: "24px",
|
|
right: "24px",
|
|
backgroundColor: "#2EA3A3",
|
|
color: "#ffffff",
|
|
padding: "10px 16px",
|
|
borderRadius: "8px",
|
|
fontSize: "12.5px",
|
|
fontWeight: 500,
|
|
boxShadow: "0 4px 12px rgba(0,0,0,0.18)",
|
|
zIndex: 9999,
|
|
maxWidth: "340px",
|
|
lineHeight: "1.4",
|
|
}}
|
|
>
|
|
{toast.message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ActionCell({ flag, cascade, showConfirmVisit, onConfirmVisit, onExpand }) {
|
|
const hasCascade = cascade && cascade.length > 0;
|
|
const accentBtn = "bg-transparent border border-[var(--accent)] text-[var(--accent-text)] px-[10px] py-[4px] rounded-md text-[11px] cursor-pointer font-body whitespace-nowrap transition-all hover:bg-[rgba(203,107,32,0.1)] self-start";
|
|
const mutedBtn = "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 === "RESUPPLY_READY") {
|
|
return (
|
|
<div className="flex items-center gap-[5px]" style={{ color: "#1A8040" }}>
|
|
<span style={{ fontSize: "13px", fontWeight: 800 }}>✓</span>
|
|
<span className="text-[11.5px] font-medium">Ready to ship</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (hasCascade) {
|
|
return (
|
|
<div className="flex flex-col gap-[4px]">
|
|
<ul className="list-none m-0 p-0 space-y-[2px]">
|
|
{cascade.map((item, idx) => {
|
|
const shortLabel = item.split(" — ")[0];
|
|
const isDenied = /denied/i.test(item);
|
|
return (
|
|
<li key={idx} className="flex gap-[5px] items-start text-[11px] leading-[1.35]"
|
|
style={{ color: isDenied ? "#CC2222" : "#CB6B20" }}>
|
|
<span className="shrink-0 mt-[1px]">•</span>
|
|
<span>{shortLabel}</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
{showConfirmVisit ? (
|
|
<button onClick={onConfirmVisit} className={accentBtn}>Confirm Visit →</button>
|
|
) : (
|
|
<button onClick={onExpand} className={accentBtn}>Update →</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (showConfirmVisit) {
|
|
return (
|
|
<button onClick={onConfirmVisit} className="bg-transparent border border-[var(--accent)] text-[var(--accent-text)] px-[13px] py-[5px] rounded-md text-xs cursor-pointer font-body whitespace-nowrap transition-all hover:bg-[rgba(203,107,32,0.1)]">
|
|
Confirm Visit →
|
|
</button>
|
|
);
|
|
}
|
|
|
|
if (flag === "TRANSFER_PENDING") {
|
|
return <button onClick={onExpand} className={`${mutedBtn} !border-[var(--accent)] !text-[var(--accent-text)] hover:bg-[rgba(203,107,32,0.1)]`}>Verify Docs →</button>;
|
|
}
|
|
if (flag === "NO_RECENT_SHIPMENT" || flag === "SUPPLY_LAPSED") {
|
|
return <button onClick={onExpand} className={mutedBtn}>Update →</button>;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function DocItem({ label, value, patientId, deviceType = "", docType, cycle, getToken, onUpdated }) {
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const isGood = value && (
|
|
value === "On File" || value === "Verified" || value === "Not Required" ||
|
|
value.startsWith("Confirmed") || value.startsWith("Approved") || value === "N/A"
|
|
);
|
|
const isBad = value && (
|
|
value === "Missing" || value === "Not Verified" || value === "Expired" ||
|
|
value === "Denied" || value === "Required — Not Started" || value === "Pending"
|
|
);
|
|
const color = isGood ? "text-[#1A8040]" : isBad ? "text-[#CC2222]" : "text-[#CB6B20]";
|
|
|
|
const currentKey = Object.entries(DOC_STATUS_LABELS).find(([, v]) => v === value)?.[0];
|
|
|
|
const handleCycle = async () => {
|
|
if (!cycle || !patientId || saving) return;
|
|
const idx = currentKey ? cycle.indexOf(currentKey) : -1;
|
|
const nextKey = cycle[(idx + 1) % cycle.length];
|
|
const nextLabel = DOC_STATUS_LABELS[nextKey];
|
|
setSaving(true);
|
|
const token = getToken ? await getToken().catch(() => null) : null;
|
|
const result = await updateDocStatus(patientId, docType, nextKey, null, null, token, deviceType);
|
|
setSaving(false);
|
|
if (result && onUpdated) onUpdated(docType, nextLabel);
|
|
};
|
|
|
|
return (
|
|
<div className="flex items-start gap-[6px]">
|
|
<span className="text-[var(--text-warm)] w-[140px] shrink-0">{label}:</span>
|
|
{cycle ? (
|
|
<button
|
|
onClick={handleCycle}
|
|
disabled={saving}
|
|
title={saving ? "Saving…" : `Click to update ${label} status`}
|
|
className={`font-medium ${color} hover:underline underline-offset-2 cursor-pointer disabled:opacity-50 bg-transparent border-none p-0 text-left`}
|
|
style={{ fontSize: "inherit", fontFamily: "inherit" }}
|
|
>
|
|
{saving ? "Saving…" : (value || "Unknown")}
|
|
</button>
|
|
) : (
|
|
<span className={`font-medium ${color}`}>{value || "Unknown"}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|