The worklist now shows one row per patient carrying the rollup verdict, expanding into device coverage lines with doc checklists and rule citations. Green is earned only from the readiness verdict. Kisa-approved design (mockup v2, decisions A/B/C/D/E locked 2026-07-07): purple Plan Type Needed badge + Map-the-plan-type CTA; ◇ column-not-mapped chips; not-processed strip with download; Data-gaps filter (honest labels, no sixth status); extended legend; per-line work-queue export (backend lines[] support, audit-logged). Tabs count patients. SWO/PA cycling is device-scoped and returns the re-rolled patient through the P4 echo contract; Confirm Visit fans out across the patient's echoed lines. Legacy table remains at ?legacy=1 (its DOCS_REQUIRED stopgap intentionally kept while it exists — literal deviation from the plan's remove-in-same-change line, logged). Independent audit: SHIP; its MEDIUM (line-identity merge collision) and two LOWs fixed in-commit. 171 backend tests green; frontend builds clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
568 lines
24 KiB
JavaScript
568 lines
24 KiB
JavaScript
import { useState, useEffect, Fragment } from "react";
|
|
import { useAuth } from "@clerk/react";
|
|
import { VerdictBadge } from "./Badge";
|
|
import StatusLegend from "./StatusLegend";
|
|
import ConfirmVisitModal from "./ConfirmVisitModal";
|
|
import { updateDocStatus } from "../lib/api";
|
|
|
|
// P6 patient-nested worklist (Kisa-approved mockup, 2026-07-07).
|
|
// One row per patient carrying the rollup verdict; expands into device
|
|
// coverage lines with their doc checklists and rule citations. Green is
|
|
// earned only from the readiness verdict. Tabs count PATIENTS.
|
|
// The five status labels are locked; "Data gap" is a filter + chip, never
|
|
// a sixth label (Kisa Decision E).
|
|
|
|
const DOC_LABELS = {
|
|
swo: "SWO",
|
|
visit: "Qualifying Visit",
|
|
pecos: "PECOS Enrollment",
|
|
pa: "Prior Authorization",
|
|
diagnosis: "Diagnosis on File",
|
|
};
|
|
|
|
// doc_type -> the canonical CSV field whose mapping decides whether an
|
|
// absent item is a FILE gap (column never mapped) or a PATIENT gap.
|
|
const DOC_COLUMN = {
|
|
swo: "csv_swo_status",
|
|
visit: "csv_visit_date",
|
|
pecos: "csv_pecos_verified",
|
|
pa: "csv_pa_status",
|
|
diagnosis: "csv_diagnosis_on_file",
|
|
};
|
|
|
|
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",
|
|
};
|
|
|
|
// Timing hints are descriptive phrases, never status labels (locked sets).
|
|
const TIMING_HINTS = {
|
|
SUPPLY_LAPSED: "Supply lapsed",
|
|
VISIT_REQUIRED: "Visit overdue",
|
|
RENEWAL_CRITICAL: "Visit renewal critical",
|
|
RENEWAL_ELEVATED: "Visit renewal due soon",
|
|
RENEWAL_SOON: "Visit renewal approaching",
|
|
RESUPPLY_READY: "In resupply window",
|
|
ACTIVE: "Timing on track",
|
|
NO_RECENT_SHIPMENT: "No recent shipment",
|
|
TRANSFER_PENDING: "Transfer pending",
|
|
};
|
|
|
|
const TABS = [
|
|
{ key: "all", label: "All Patients", color: "#5A7E7E" },
|
|
{ key: "At Risk", label: "At Risk", color: "#CC2222" },
|
|
{ key: "Action Needed", label: "Action Needed", color: "#CB6B20" },
|
|
{ key: "On Track", label: "On Track", color: "#2EA3A3" },
|
|
{ key: "Clear to Ship", label: "Clear to Ship", color: "#22C55E", check: true },
|
|
{ key: "Plan Type Needed", label: "Plan Type Needed", color: "#9B8EC4" },
|
|
];
|
|
|
|
function itemIsFileGap(item, mappedFields) {
|
|
// REQUIRED only: NOT_EVALUATED items (plan type unknown) belong to the
|
|
// Plan Type Needed treatment, never double-flagged as data gaps.
|
|
return (
|
|
item.input_state === "ABSENT" &&
|
|
item.required_state === "REQUIRED" &&
|
|
!(DOC_COLUMN[item.doc_type] in mappedFields)
|
|
);
|
|
}
|
|
|
|
function patientFileGaps(p, mappedFields) {
|
|
const gaps = new Set();
|
|
for (const line of p.lines || []) {
|
|
for (const item of line.doc_items || []) {
|
|
if (itemIsFileGap(item, mappedFields)) gaps.add(item.doc_type);
|
|
}
|
|
}
|
|
return [...gaps];
|
|
}
|
|
|
|
function lineToEcho(line) {
|
|
const by = {};
|
|
(line.doc_items || []).forEach((i) => { by[i.doc_type] = i.value; });
|
|
return {
|
|
device_type: line.device_type,
|
|
gradeable: line.gradeable,
|
|
plan_type: line.plan_type,
|
|
shipment_date: line.shipment_date || null,
|
|
csv_swo_status: by.swo || null,
|
|
csv_visit_date: by.visit || null,
|
|
csv_pecos_verified: by.pecos || null,
|
|
csv_pa_status: by.pa || null,
|
|
csv_diagnosis_on_file: by.diagnosis || null,
|
|
};
|
|
}
|
|
|
|
function lineToModalRecord(p, line) {
|
|
const by = {};
|
|
(line.doc_items || []).forEach((i) => { by[i.doc_type] = i.value; });
|
|
return {
|
|
patient_id: p.patient_id,
|
|
last_shipment_date: line.shipment_date,
|
|
payer: line.payer || "",
|
|
device_type: line.device_type,
|
|
quantity: line.total_quantity || 1,
|
|
plan_type: line.plan_type,
|
|
csv_visit_date: by.visit || null,
|
|
csv_swo_status: by.swo || null,
|
|
csv_pecos_verified: by.pecos || null,
|
|
csv_pa_status: by.pa || null,
|
|
csv_diagnosis_on_file: by.diagnosis || null,
|
|
_echo_lines: (p.lines || []).map(lineToEcho),
|
|
};
|
|
}
|
|
|
|
// Merge a P4 recompute response ({lines, rollup_status}) into local state.
|
|
// Line identity is (device_type, plan_type) — a patient can hold the same
|
|
// device under two plan types (typed + unknown), so device alone is not a key.
|
|
function mergeRecompute(p, recomputed) {
|
|
if (!recomputed?.lines) return p;
|
|
const lineKey = (l) => `${l.device_type}:${l.plan_type ?? ""}`;
|
|
const byKey = {};
|
|
recomputed.lines.forEach((l) => { byKey[lineKey(l)] = l; });
|
|
return {
|
|
...p,
|
|
rollup_status: recomputed.rollup_status ?? p.rollup_status,
|
|
lines: p.lines.map((line) => {
|
|
const upd = byKey[lineKey(line)];
|
|
return upd
|
|
? { ...line, line_status: upd.line_status, doc_items: upd.doc_items ?? line.doc_items }
|
|
: line;
|
|
}),
|
|
};
|
|
}
|
|
|
|
export default function PatientWorklist({ data, onImportClick }) {
|
|
const { getToken } = useAuth();
|
|
const [patients, setPatients] = useState(data.patients || []);
|
|
const [tab, setTab] = useState("all");
|
|
const [refine, setRefine] = useState(null); // null | "gaps" | "outreach"
|
|
const [expanded, setExpanded] = useState(null); // patient_id
|
|
const [confirming, setConfirming] = useState(null); // {record, patientId}
|
|
const [showAllSkipped, setShowAllSkipped] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setPatients(data.patients || []);
|
|
setExpanded(null);
|
|
}, [data]);
|
|
|
|
const mappedFields = data.mapping_summary?.mapped || {};
|
|
const skippedReasons = data.skipped_reasons || [];
|
|
const skippedCount = data.skipped || 0;
|
|
|
|
const gradeable = patients.filter((p) => p.rollup_status);
|
|
const gapPatients = patients.filter(
|
|
(p) => patientFileGaps(p, mappedFields).length > 0
|
|
);
|
|
const outreachPatients = patients.filter(
|
|
(p) =>
|
|
["At Risk", "Action Needed"].includes(p.rollup_status) &&
|
|
patientFileGaps(p, mappedFields).length === 0
|
|
);
|
|
|
|
// Single derivation for all counts: always from the local patients state.
|
|
const counts = { all: patients.length };
|
|
TABS.slice(1).forEach((t) => {
|
|
counts[t.key] = gradeable.filter((p) => p.rollup_status === t.key).length;
|
|
});
|
|
|
|
let visible =
|
|
tab === "all" ? patients : patients.filter((p) => p.rollup_status === tab);
|
|
if (refine === "gaps") {
|
|
visible = visible.filter((p) => patientFileGaps(p, mappedFields).length > 0);
|
|
} else if (refine === "outreach") {
|
|
visible = visible.filter((p) => outreachPatients.includes(p));
|
|
}
|
|
|
|
function downloadSkipped() {
|
|
const blob = new Blob(
|
|
["Signal — rows not processed\n\n" + skippedReasons.join("\n") + "\n"],
|
|
{ type: "text/plain;charset=utf-8;" }
|
|
);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `signal-not-processed-${new Date().toISOString().slice(0, 10)}.txt`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function applyRecompute(patientId, recomputed) {
|
|
setPatients((prev) =>
|
|
prev.map((p) => (p.patient_id === patientId ? mergeRecompute(p, recomputed) : p))
|
|
);
|
|
}
|
|
|
|
const ungraded = patients.length - gradeable.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 + patient tabs */}
|
|
<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]">
|
|
{patients.length} patients · {gradeable.length} gradeable
|
|
{ungraded > 0 && ` · ${ungraded} informational only`}
|
|
{skippedCount > 0 && ` · ${skippedCount} rows not processed`}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-[6px] flex-wrap">
|
|
{TABS.map((t) => {
|
|
const count = counts[t.key] ?? 0;
|
|
const pct =
|
|
t.key !== "all" && gradeable.length > 0
|
|
? Math.round((count / gradeable.length) * 100)
|
|
: null;
|
|
const isActive = tab === t.key;
|
|
const indicatorColor = isActive ? "white" : t.color;
|
|
return (
|
|
<button
|
|
key={t.key}
|
|
onClick={() => setTab(t.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)]"
|
|
}`}
|
|
>
|
|
{t.check ? (
|
|
<span style={{ color: indicatorColor, fontSize: "12px", fontWeight: 800, lineHeight: 1 }}>✓</span>
|
|
) : (
|
|
<span style={{ width: 6, height: 6, borderRadius: "50%", backgroundColor: indicatorColor, display: "inline-block", flexShrink: 0 }} />
|
|
)}
|
|
{t.label}
|
|
<span style={{ opacity: 0.65, fontSize: "10.5px", fontWeight: 500 }}>
|
|
{count}
|
|
{pct !== null ? ` · ${pct}%` : ""}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Refine pills — separates fix-the-file work from outreach work */}
|
|
<div className="flex items-center gap-[10px] px-[22px] py-[8px] border-b border-[var(--border-subtle)] text-[11px] text-[var(--text-warm)]">
|
|
Refine:
|
|
<button
|
|
onClick={() => setRefine(refine === "gaps" ? null : "gaps")}
|
|
className={`inline-flex items-center gap-[5px] rounded-full px-[11px] py-[2px] text-[11px] font-semibold border cursor-pointer ${
|
|
refine === "gaps"
|
|
? "bg-[#6B4F9A] text-white border-[#6B4F9A]"
|
|
: "bg-[rgba(122,94,160,0.10)] text-[#6B4F9A] border-[#9B8EC4]"
|
|
}`}
|
|
>
|
|
◇ Data gaps <b>{gapPatients.length}</b>
|
|
</button>
|
|
<button
|
|
onClick={() => setRefine(refine === "outreach" ? null : "outreach")}
|
|
className={`inline-flex items-center gap-[5px] rounded-full px-[11px] py-[2px] text-[11px] font-semibold border cursor-pointer ${
|
|
refine === "outreach"
|
|
? "bg-[var(--brand)] text-white border-[var(--brand)]"
|
|
: "border-[var(--border-color)] text-[var(--text-secondary)]"
|
|
}`}
|
|
>
|
|
Outreach only <b>{outreachPatients.length}</b>
|
|
</button>
|
|
<span className="ml-auto italic text-[10.5px]">
|
|
Data-gap patients keep their honest status; this separates fix-the-file work from call-the-prescriber work
|
|
</span>
|
|
</div>
|
|
|
|
{/* Not-processed strip — rows Signal never scores and never hides */}
|
|
{skippedCount > 0 && (
|
|
<div className="flex items-start gap-3 px-[22px] py-[10px] border-b border-[var(--border-subtle)] bg-[rgba(203,107,32,0.05)] text-[12px] text-[var(--text-secondary)]">
|
|
<div className="flex-1">
|
|
<b className="text-[var(--text-heading)]">
|
|
{skippedCount} row{skippedCount !== 1 ? "s" : ""} not processed
|
|
</b>{" "}
|
|
— Signal never scores a row whose required information was not provided, and never hides one:
|
|
<ul className="list-disc ml-5 mt-1">
|
|
{(showAllSkipped ? skippedReasons : skippedReasons.slice(0, 3)).map((r, i) => (
|
|
<li key={i}>{r}</li>
|
|
))}
|
|
</ul>
|
|
{skippedReasons.length > 3 && !showAllSkipped && (
|
|
<button className="text-[var(--brand)] font-semibold mt-1 cursor-pointer bg-transparent border-none p-0" onClick={() => setShowAllSkipped(true)}>
|
|
Show all {skippedReasons.length}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<button onClick={downloadSkipped} className="text-[var(--brand)] font-semibold whitespace-nowrap cursor-pointer bg-transparent border-none p-0">
|
|
Download list ↓
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<StatusLegend extended />
|
|
|
|
{/* Patient table */}
|
|
<table className="w-full border-collapse">
|
|
<thead>
|
|
<tr>
|
|
{["", "Patient", "Device(s)", "Payer", "Status", "Last Resupply", "Actions"].map((h, i) => (
|
|
<th key={i} className="px-[18px] 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>
|
|
{visible.map((p) => {
|
|
const isExpanded = expanded === p.patient_id;
|
|
const gaps = patientFileGaps(p, mappedFields);
|
|
const isPTN = p.rollup_status === "Plan Type Needed";
|
|
const capped = p.plan_type_needed && !isPTN;
|
|
const lastShip = (p.lines || [])
|
|
.map((l) => l.shipment_date)
|
|
.filter(Boolean)
|
|
.sort()
|
|
.pop();
|
|
return (
|
|
<Fragment key={p.patient_id}>
|
|
<tr
|
|
data-patient-row={p.patient_id}
|
|
className={`border-b border-[var(--border-subtle)] cursor-pointer transition-colors hover:bg-[var(--row-hover)] ${
|
|
!p.rollup_status ? "opacity-60" : ""
|
|
} ${isExpanded ? "bg-[rgba(20,122,122,0.04)]" : ""}`}
|
|
onClick={() => setExpanded(isExpanded ? null : p.patient_id)}
|
|
>
|
|
<td className="pl-[18px] py-[13px] w-[26px] text-[9px] text-[var(--text-warm)] align-middle">
|
|
{isExpanded ? "▼" : "▶"}
|
|
</td>
|
|
<td className="px-[18px] py-[13px] align-middle">
|
|
<span className="font-mono text-[12.5px] font-bold text-[var(--brand)]">
|
|
{p.patient_id}
|
|
</span>
|
|
</td>
|
|
<td className="px-[18px] py-[13px] text-[13px] text-[var(--text-primary)] align-middle">
|
|
{(p.lines || []).map((l) => l.device_display).join(", ")}
|
|
</td>
|
|
<td className="px-[18px] py-[13px] text-[12.5px] text-[var(--text-secondary)] align-middle">
|
|
{(p.lines || [])[0]?.payer || "—"}
|
|
</td>
|
|
<td className="px-[18px] py-[13px] align-middle">
|
|
<VerdictBadge status={p.rollup_status} />
|
|
{capped && (
|
|
<div className="text-[10px] italic text-[var(--text-warm)] mt-[3px]">
|
|
Capped: includes a Plan Type Needed line
|
|
</div>
|
|
)}
|
|
{gaps.length > 0 && !isPTN && (
|
|
<div className="text-[10px] italic text-[#6B4F9A] mt-[3px]">
|
|
Data gap — {gaps.map((g) => DOC_LABELS[g]).join(", ")} column not provided in the file
|
|
</div>
|
|
)}
|
|
{!p.rollup_status && (
|
|
<div className="text-[10px] italic text-[var(--text-warm)] mt-[3px]">
|
|
Non-CGM device — displayed, not graded, excluded from counts
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-[18px] py-[13px] font-mono text-[12px] text-[var(--text-warm)] align-middle">
|
|
{lastShip || "—"}
|
|
</td>
|
|
<td className="px-[18px] py-[13px] align-middle" onClick={(e) => e.stopPropagation()}>
|
|
{isPTN && (
|
|
<button
|
|
onClick={onImportClick}
|
|
className="inline-flex items-center gap-[5px] text-[11px] font-semibold text-[#6B4F9A] bg-[rgba(122,94,160,0.10)] border border-[#9B8EC4] rounded-[5px] px-[11px] py-[4px] cursor-pointer"
|
|
>
|
|
◆ Map the plan type
|
|
</button>
|
|
)}
|
|
{gaps.length > 0 && !isPTN && (
|
|
<span className="inline-flex items-center gap-[4px] text-[10px] font-bold text-[#6B4F9A] bg-[rgba(122,94,160,0.10)] border border-[#9B8EC4] rounded-[4px] px-[7px] py-[1px]">
|
|
◇ Fix the file, not the phone
|
|
</span>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
|
|
{isExpanded &&
|
|
(p.lines || []).map((line) => (
|
|
<tr key={`${p.patient_id}:${line.device_type}:${line.plan_type}`} className="bg-[var(--bg-elevated)] border-b border-[var(--border-subtle)]">
|
|
<td colSpan={7} className="py-0">
|
|
<DeviceLine
|
|
patient={p}
|
|
line={line}
|
|
mappedFields={mappedFields}
|
|
getToken={getToken}
|
|
onRecomputed={(rec) => applyRecompute(p.patient_id, rec)}
|
|
onConfirmVisit={() =>
|
|
setConfirming({ record: lineToModalRecord(p, line), patientId: p.patient_id })
|
|
}
|
|
/>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</Fragment>
|
|
);
|
|
})}
|
|
{visible.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="text-[var(--text-secondary)]">
|
|
PHI-safe — patient names and DOBs never stored. Crosswalk: patient_id only.
|
|
</span>
|
|
<span>
|
|
{visible.length} of {patients.length} patients shown
|
|
</span>
|
|
</div>
|
|
|
|
{confirming && (
|
|
<ConfirmVisitModal
|
|
record={confirming.record}
|
|
onClose={() => setConfirming(null)}
|
|
onConfirmed={(patientId, updated) => applyRecompute(patientId, updated)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DeviceLine({ patient, line, mappedFields, getToken, onRecomputed, onConfirmVisit }) {
|
|
const items = line.doc_items || [];
|
|
const visitItem = items.find((i) => i.doc_type === "visit");
|
|
const showConfirmVisit = line.gradeable && visitItem && !visitItem.satisfied;
|
|
return (
|
|
<div className="pl-[56px] pr-[22px] py-[11px] border-l-4 border-[rgba(20,122,122,0.16)] ml-[18px]">
|
|
<div className="flex items-center gap-[10px] mb-[8px]">
|
|
<span className="text-[12px] font-bold text-[var(--text-primary)]">{line.device_display}</span>
|
|
<span className="text-[10.5px] text-[var(--text-warm)] bg-[var(--bg-card)] border border-[var(--border-color)] rounded px-[7px]">
|
|
{line.plan_type || "Plan type needed"}
|
|
</span>
|
|
<VerdictBadge status={line.line_status} />
|
|
{line.timing_flag && (
|
|
<span className="text-[10.5px] text-[var(--text-warm)] italic">
|
|
{TIMING_HINTS[line.timing_flag] || line.timing_flag}
|
|
</span>
|
|
)}
|
|
<span className="ml-auto font-mono text-[10px] text-[var(--text-warm)]">
|
|
{line.order_numbers?.length ? `Orders: ${line.order_numbers.join(", ")} · ` : ""}
|
|
{line.hcpcs_codes?.length ? `HCPCS: ${line.hcpcs_codes.join(", ")} · ` : ""}
|
|
qty {line.total_quantity}
|
|
{line.prior_shipment_count > 0 && ` · ${line.prior_shipment_count} prior shipment${line.prior_shipment_count !== 1 ? "s" : ""}`}
|
|
</span>
|
|
</div>
|
|
{line.gradeable && items.length > 0 ? (
|
|
<div className="flex flex-wrap gap-y-[6px]">
|
|
{items.map((item) => (
|
|
<ChecklistItem
|
|
key={item.doc_type}
|
|
patient={patient}
|
|
line={line}
|
|
item={item}
|
|
fileGap={itemIsFileGap(item, mappedFields)}
|
|
getToken={getToken}
|
|
onRecomputed={onRecomputed}
|
|
/>
|
|
))}
|
|
{showConfirmVisit && (
|
|
<button
|
|
onClick={onConfirmVisit}
|
|
className="self-start ml-auto bg-transparent border border-[var(--accent)] text-[var(--accent-text)] px-[10px] py-[3px] rounded-md text-[11px] cursor-pointer whitespace-nowrap hover:bg-[rgba(203,107,32,0.1)]"
|
|
>
|
|
Confirm Visit →
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="text-[11px] italic text-[var(--text-warm)]">
|
|
Non-CGM device — no readiness actions.
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChecklistItem({ patient, line, item, fileGap, getToken, onRecomputed }) {
|
|
const [saving, setSaving] = useState(false);
|
|
const cyclable = item.doc_type === "swo" ? SWO_CYCLE : item.doc_type === "pa" ? PA_CYCLE : null;
|
|
|
|
const icon = fileGap
|
|
? { glyph: "◇", cls: "text-[#6B4F9A]" }
|
|
: item.satisfied
|
|
? item.required_state === "NOT_REQUIRED" && item.input_state === "ABSENT"
|
|
? { glyph: "—", cls: "text-[var(--text-warm)]" }
|
|
: { glyph: "✓", cls: "text-[#166534]" }
|
|
: item.quality === "BAD"
|
|
? { glyph: "✗", cls: "text-[#CC2222]" }
|
|
: { glyph: "✗", cls: "text-[#CB6B20]" };
|
|
|
|
const valueText = fileGap
|
|
? "Column not mapped"
|
|
: item.value ||
|
|
(item.required_state === "NOT_REQUIRED" ? "Not required" : "Not on file");
|
|
|
|
async function handleCycle() {
|
|
if (!cyclable || saving || fileGap) return;
|
|
const currentKey = Object.entries(DOC_STATUS_LABELS).find(([, v]) => v === item.value)?.[0];
|
|
const idx = currentKey ? cyclable.indexOf(currentKey) : -1;
|
|
const nextKey = cyclable[(idx + 1) % cyclable.length];
|
|
setSaving(true);
|
|
try {
|
|
const token = getToken ? await getToken().catch(() => null) : null;
|
|
const result = await updateDocStatus(
|
|
patient.patient_id,
|
|
item.doc_type,
|
|
nextKey,
|
|
null,
|
|
null,
|
|
token,
|
|
line.device_type,
|
|
(patient.lines || []).map(lineToEcho)
|
|
);
|
|
if (result?.lines) onRecomputed(result);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex items-start gap-[6px] basis-1/5 min-w-[170px] pr-[12px]">
|
|
<span className={`text-[12px] font-extrabold w-[14px] text-center ${icon.cls}`}>{icon.glyph}</span>
|
|
<div>
|
|
<div className="text-[11px] font-semibold text-[var(--text-primary)]">
|
|
{DOC_LABELS[item.doc_type]}
|
|
</div>
|
|
{cyclable && !fileGap ? (
|
|
<button
|
|
onClick={handleCycle}
|
|
disabled={saving}
|
|
title={`Click to update ${DOC_LABELS[item.doc_type]} status (this device only)`}
|
|
className={`text-[11px] font-medium bg-transparent border-none p-0 text-left cursor-pointer hover:underline underline-offset-2 disabled:opacity-50 ${
|
|
item.satisfied ? "text-[#166534]" : "text-[#CB6B20]"
|
|
}`}
|
|
>
|
|
{saving ? "Saving…" : valueText}
|
|
</button>
|
|
) : (
|
|
<div className={`text-[11px] ${fileGap ? "text-[#6B4F9A] font-semibold" : item.satisfied ? "text-[var(--text-secondary)]" : "text-[#CB6B20] font-medium"}`}>
|
|
{valueText}
|
|
</div>
|
|
)}
|
|
<div className="text-[9.5px] text-[var(--text-warm)]">
|
|
{item.rule_id ? `${item.rule_id}` : ""}
|
|
{fileGap ? " · resolves at re-import or mapping review" : ""}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|