Add empty state, mapping review, confirmation step, copy rewrites, active patient indicator
- Empty state UI: first-time prompt with file selector - Mapping review step in CSVImport with confidence scores and override dropdowns - Confirmation step in ConfirmVisitModal (one last look before save) - Next-priority toast after visit confirmation - Active patient banner with autosave and one-click return for interruptions - Simplified cascade to 2-step (what is missing, device shipment at risk) - Copy rewrites: status labels, doc checklist text, action messages, cascade language - Pilot readiness updated to 85% (11/13) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d197c529ce
commit
63093990d2
12 changed files with 723 additions and 176 deletions
12
CLAUDE.md
12
CLAUDE.md
|
|
@ -64,20 +64,20 @@ and stays predictable.
|
|||
|
||||
Whitepaper: `/Users/sttil-solutions/Documents/Obsidian_Vault/STTIL-Vault/Projects/2026-05-18-pilot-readiness-whitepaper.md`
|
||||
|
||||
### Checklist Status (as of 2026-06-09) — 65% pilot-ready
|
||||
### Checklist Status (as of 2026-06-10) — 85% pilot-ready (11/13)
|
||||
|
||||
| Checklist Item | Status |
|
||||
|---|---|
|
||||
| App hosted behind stable URL | PASS — https://signal-ui-xi.vercel.app (deployed 2026-05-29) |
|
||||
| Demo login / controlled access working | PASS |
|
||||
| Synthetic sample data loads end to end | NOT VERIFIED as Gaboro org |
|
||||
| Synthetic sample data loads end to end | PASS — verified as Gaboro DME org 2026-06-10 |
|
||||
| 25 CSV variants pass ingestion tests | PASS — 50 new files generated (PA + NJ sets), 100% normalizer pass rate 2026-06-07 |
|
||||
| Import flow: mapping, validation, warnings | PARTIAL — CSVImport exists, full mapping review unconfirmed |
|
||||
| Import flow: mapping, validation, warnings | PASS — mapping review step built 2026-06-10; confidence display, override dropdown, unmapped column notice, required-missing notice all implemented in CSVImport.jsx |
|
||||
| Report generation calculates from backend | PASS |
|
||||
| Each status has reason + recommended action | PASS — visit date proxy bug fixed 2026-06-09; `_resolve_visit_date()` implements full priority chain (CSV column > Supabase confirmed > shipment-30d estimate) |
|
||||
| Export CSV works and opens cleanly | NOT VERIFIED end to end |
|
||||
| Placeholder content removed | PARTIAL — StatCard undefined variables fixed 2026-06-09; Sidebar + sweep in progress |
|
||||
| Browser smoke test passes | NOT RUN |
|
||||
| Export CSV works and opens cleanly | PASS — verified end to end 2026-06-10 |
|
||||
| Placeholder content removed | PARTIAL — StatCard undefined variables fixed 2026-06-09; no empty-state guidance or status legend for first-time users |
|
||||
| Browser smoke test passes | PASS — CSV import, all filter tabs, Confirm Visit, Export all verified 2026-06-10; sidebar count sync fixed |
|
||||
| Data handling rules documented | PASS — privacy-policy.md + data-handling.md written 2026-06-07; minimum necessary fields, CSV disposition policy, retention, and payer_rules.json cadence all documented |
|
||||
| Real PHI blocked | PARTIAL — architecture confirmed; Supabase plan tier (Pro vs Team) not yet verified |
|
||||
| Pilot success metrics written down | PASS |
|
||||
|
|
|
|||
|
|
@ -115,25 +115,27 @@ DEVICE_DISPLAY = {
|
|||
}
|
||||
|
||||
FLAG_LABELS = {
|
||||
"SUPPLY_LAPSED": "Docs Required",
|
||||
"VISIT_REQUIRED": "Escalate",
|
||||
"TRANSFER_PENDING": "New Patient",
|
||||
"RENEWAL_CRITICAL": "Escalate",
|
||||
"RENEWAL_ELEVATED": "Confirm Appointment",
|
||||
"RENEWAL_SOON": "Begin Outreach",
|
||||
"RESUPPLY_READY": "Clear to Ship",
|
||||
"ACTIVE": "On Track",
|
||||
"SUPPLY_LAPSED": "Docs Required",
|
||||
"VISIT_REQUIRED": "Escalate",
|
||||
"TRANSFER_PENDING": "New Patient",
|
||||
"RENEWAL_CRITICAL": "Escalate",
|
||||
"RENEWAL_ELEVATED": "Confirm Appointment",
|
||||
"RENEWAL_SOON": "Begin Outreach",
|
||||
"RESUPPLY_READY": "Clear to Ship",
|
||||
"ACTIVE": "On Track",
|
||||
"NO_RECENT_SHIPMENT": "No Recent Shipment",
|
||||
}
|
||||
|
||||
FLAG_ACTIONS = {
|
||||
"SUPPLY_LAPSED": "Pull patient file — verify documentation is current before initiating any new order",
|
||||
"VISIT_REQUIRED": "Contact prescriber's office and patient directly — ask: 'Was there a visit in the past 6 months to evaluate this patient's CGM dosing needs?' No shipment until visit is confirmed and documented",
|
||||
"TRANSFER_PENDING": "Obtain full documentation set (SWO, PECOS, PA, diagnosis) — Signal cannot assess status until records are on file",
|
||||
"RENEWAL_CRITICAL": "Contact prescriber's office and patient directly — ask: 'Was there a visit in the past 6 months to evaluate CGM dosing needs?' Appointment must be on calendar within 45 days",
|
||||
"RENEWAL_ELEVATED": "Follow up with prescriber's office — ask: 'Was there a visit in the past 6 months to evaluate CGM dosing needs?' Confirm appointment is scheduled",
|
||||
"RENEWAL_SOON": "Contact prescriber's office — ask: 'Was there a visit in the past 6 months to evaluate this patient's CGM dosing needs?' Schedule evaluation now if not yet completed",
|
||||
"RESUPPLY_READY": "Initiate resupply order — patient is within the 30-day refill window",
|
||||
"ACTIVE": "No action needed — documentation current, visit more than 90 days out",
|
||||
"SUPPLY_LAPSED": "Pull patient file — verify documentation is current before initiating any new order",
|
||||
"VISIT_REQUIRED": "Contact prescriber's office and patient directly — ask: 'Was there a visit in the past 6 months to evaluate this patient's CGM dosing needs?' No shipment until visit is confirmed and documented",
|
||||
"TRANSFER_PENDING": "Obtain full documentation set (SWO, PECOS, PA, diagnosis) — Signal cannot assess status until records are on file",
|
||||
"RENEWAL_CRITICAL": "Contact prescriber's office and patient directly — ask: 'Was there a visit in the past 6 months to evaluate CGM dosing needs?' Appointment must be on calendar within 45 days",
|
||||
"RENEWAL_ELEVATED": "Follow up with prescriber's office — ask: 'Was there a visit in the past 6 months to evaluate CGM dosing needs?' Confirm appointment is scheduled",
|
||||
"RENEWAL_SOON": "Contact prescriber's office — ask: 'Was there a visit in the past 6 months to evaluate this patient's CGM dosing needs?' Schedule evaluation now if not yet completed",
|
||||
"RESUPPLY_READY": "Initiate resupply order — patient is within the 30-day refill window",
|
||||
"ACTIVE": "No action needed — documentation current, visit more than 90 days out",
|
||||
"NO_RECENT_SHIPMENT": "Verify status with prescriber office — confirm patient is still active",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -208,6 +210,8 @@ def _build_reason(flag_val: str, days_until_end: int, days_until_visit: Optional
|
|||
return f"Coverage ends in {days_until_end} {unit}. Patient is within resupply window — initiate shipment now."
|
||||
if flag_val == "TRANSFER_PENDING":
|
||||
return "Patient transferred from prior supplier. Verify all documentation before proceeding."
|
||||
if flag_val == "NO_RECENT_SHIPMENT":
|
||||
return "No shipment on file in 12+ months. Contact the prescriber office to confirm the patient is still active and has not transferred care."
|
||||
unit = "day" if days_until_end == 1 else "days"
|
||||
return f"Coverage on track. Resupply window opens in approximately {days_until_end} {unit}."
|
||||
|
||||
|
|
@ -280,6 +284,7 @@ def _compute_stats(records: list[RecordOut]) -> dict:
|
|||
"renewal_soon": flags.count("RENEWAL_SOON"),
|
||||
"resupply_ready": flags.count("RESUPPLY_READY"),
|
||||
"active": flags.count("ACTIVE"),
|
||||
"no_recent_shipment": flags.count("NO_RECENT_SHIPMENT"),
|
||||
"prescriber_action": (
|
||||
flags.count("SUPPLY_LAPSED") + flags.count("VISIT_REQUIRED") +
|
||||
flags.count("RENEWAL_CRITICAL") + flags.count("RENEWAL_ELEVATED")
|
||||
|
|
|
|||
|
|
@ -12,14 +12,16 @@ PHI CONTRACT:
|
|||
signature or data structure in this file.
|
||||
|
||||
Flag types emitted:
|
||||
SUPPLY_LAPSED — supply cycle ended, no new shipment
|
||||
VISIT_REQUIRED — visit date past due, no confirmed new visit
|
||||
TRANSFER_PENDING — transferred patient, all docs need verification
|
||||
RENEWAL_CRITICAL — <= 45 days to next visit due
|
||||
RENEWAL_ELEVATED — <= 60 days to next visit due
|
||||
RENEWAL_SOON — <= 90 days to next visit due
|
||||
RESUPPLY_READY — within refill window, visit not yet urgent
|
||||
ACTIVE — all clear
|
||||
SUPPLY_LAPSED — supply cycle ended, no new shipment
|
||||
VISIT_REQUIRED — visit date past due, no confirmed new visit
|
||||
TRANSFER_PENDING — transferred patient, all docs need verification
|
||||
RENEWAL_CRITICAL — <= 45 days to next visit due
|
||||
RENEWAL_ELEVATED — <= 60 days to next visit due
|
||||
RENEWAL_SOON — <= 90 days to next visit due
|
||||
RESUPPLY_READY — within refill window, visit not yet urgent
|
||||
ACTIVE — all clear
|
||||
NO_RECENT_SHIPMENT — last shipment 12+ months ago, no confirmed active care;
|
||||
verify patient status with prescriber office
|
||||
"""
|
||||
|
||||
import json
|
||||
|
|
@ -38,14 +40,15 @@ PAYER_RULES_PATH = Path(__file__).parent.parent / "config" / "payer_rules.json"
|
|||
|
||||
|
||||
class CoverageFlag(str, Enum):
|
||||
SUPPLY_LAPSED = "SUPPLY_LAPSED" # supply cycle ended, no new shipment
|
||||
VISIT_REQUIRED = "VISIT_REQUIRED" # visit date past due, no confirmed new visit
|
||||
TRANSFER_PENDING = "TRANSFER_PENDING" # transferred patient, all docs need verification
|
||||
RENEWAL_CRITICAL = "RENEWAL_CRITICAL" # <= 45 days to next visit due
|
||||
RENEWAL_ELEVATED = "RENEWAL_ELEVATED" # <= 60 days to next visit due
|
||||
RENEWAL_SOON = "RENEWAL_SOON" # <= 90 days to next visit due
|
||||
RESUPPLY_READY = "RESUPPLY_READY" # within refill window, visit not yet urgent
|
||||
ACTIVE = "ACTIVE" # all clear
|
||||
SUPPLY_LAPSED = "SUPPLY_LAPSED" # supply cycle ended, no new shipment
|
||||
VISIT_REQUIRED = "VISIT_REQUIRED" # visit date past due, no confirmed new visit
|
||||
TRANSFER_PENDING = "TRANSFER_PENDING" # transferred patient, all docs need verification
|
||||
RENEWAL_CRITICAL = "RENEWAL_CRITICAL" # <= 45 days to next visit due
|
||||
RENEWAL_ELEVATED = "RENEWAL_ELEVATED" # <= 60 days to next visit due
|
||||
RENEWAL_SOON = "RENEWAL_SOON" # <= 90 days to next visit due
|
||||
RESUPPLY_READY = "RESUPPLY_READY" # within refill window, visit not yet urgent
|
||||
ACTIVE = "ACTIVE" # all clear
|
||||
NO_RECENT_SHIPMENT = "NO_RECENT_SHIPMENT" # last shipment 12+ months ago, no confirmed active care
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -300,7 +303,31 @@ def calculate_coverage(
|
|||
if flag == CoverageFlag.ACTIVE and days_until_end <= refill_window_days:
|
||||
flag = CoverageFlag.RESUPPLY_READY
|
||||
|
||||
priority = _compute_priority(flag, days_until_visit)
|
||||
# Catch-all: patient shows no active-care signals and last shipment was 12+ months ago.
|
||||
# Only applies when no higher-priority flag has fired (flag is ACTIVE or RESUPPLY_READY).
|
||||
# A confirmed visit date within the last 12 months overrides this check, treating the
|
||||
# patient as still active even if the shipment record is old.
|
||||
NO_RECENT_SHIPMENT_DAYS = 365
|
||||
days_since_shipment = (today - record.shipment_date).days
|
||||
confirmed_visit_within_12m = (
|
||||
confirmed_visit_date is not None
|
||||
and (today - confirmed_visit_date).days < NO_RECENT_SHIPMENT_DAYS
|
||||
)
|
||||
csv_visit_within_12m = (
|
||||
record.csv_visit_date is not None
|
||||
and (today - record.csv_visit_date).days < NO_RECENT_SHIPMENT_DAYS
|
||||
)
|
||||
if (
|
||||
flag in (CoverageFlag.ACTIVE, CoverageFlag.RESUPPLY_READY)
|
||||
and days_since_shipment >= NO_RECENT_SHIPMENT_DAYS
|
||||
and not confirmed_visit_within_12m
|
||||
and not csv_visit_within_12m
|
||||
):
|
||||
flag = CoverageFlag.NO_RECENT_SHIPMENT
|
||||
days_until_visit = None
|
||||
priority = 5 # lowest — drops to bottom of worklist sort
|
||||
else:
|
||||
priority = _compute_priority(flag, days_until_visit)
|
||||
|
||||
return CoverageResult(
|
||||
patient_id=record.patient_id,
|
||||
|
|
@ -344,7 +371,7 @@ def calculate_batch(
|
|||
result = calculate_coverage(record, as_of=as_of, confirmed_visit_date=confirmed_date)
|
||||
results.append(result)
|
||||
except ValueError as exc:
|
||||
logger.warning("Skipping record for patient_id hash — %s", exc)
|
||||
logger.warning("Skipping record for patient_id hash: %s", exc)
|
||||
|
||||
results.sort(key=lambda r: r.priority_score, reverse=True)
|
||||
return results
|
||||
|
|
|
|||
|
|
@ -66,22 +66,18 @@ def _build_cascade(
|
|||
cascade = []
|
||||
|
||||
if not visit_confirmed:
|
||||
cascade.append("Qualifying visit not confirmed")
|
||||
if swo in ("Pending", "Expired"):
|
||||
cascade.append("SWO validity at risk")
|
||||
if payer_type in _PA_REQUIRED and pa in ("Pending", "Required — Not Started"):
|
||||
cascade.append("PA renewal cannot proceed")
|
||||
cascade.append("Next shipment blocked")
|
||||
cascade.append("Visit date not on file")
|
||||
cascade.append("Device shipment at risk")
|
||||
return cascade
|
||||
|
||||
if swo in ("Pending", "Expired"):
|
||||
cascade.append("SWO missing or expired")
|
||||
if payer_type in _PECOS_REQUIRED and pecos not in ("Verified",):
|
||||
cascade.append("PECOS enrollment at risk")
|
||||
cascade.append("Next shipment blocked")
|
||||
cascade.append("SWO not on file")
|
||||
cascade.append("Device shipment at risk")
|
||||
return cascade
|
||||
|
||||
if diagnosis == "Missing":
|
||||
cascade.append("Diagnosis documentation missing — required for all payers")
|
||||
cascade.append("Diagnosis not on file")
|
||||
cascade.append("Device shipment at risk")
|
||||
|
||||
return cascade
|
||||
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import CSVImport from "./components/CSVImport";
|
|||
import CSVExport from "./components/CSVExport";
|
||||
import ThemeToggle from "./components/ThemeToggle";
|
||||
import Toast from "./components/Toast";
|
||||
import EmptyState from "./components/EmptyState";
|
||||
import Privacy from "./Privacy";
|
||||
import { showToast } from "./lib/toast";
|
||||
import { uploadToBackend, apiRecordToLocal } from "./lib/api";
|
||||
import { apiRecordToLocal } from "./lib/api";
|
||||
import { parseCSV, processBatch } from "./lib/coverage";
|
||||
|
||||
function AppInner() {
|
||||
|
|
@ -36,16 +37,16 @@ function AppInner() {
|
|||
const atRiskCount = supplyLapsed + visitRequired + renewalCritical;
|
||||
const actionNeededCount = renewalSoon + renewalElevated + transferPending;
|
||||
|
||||
const handleResults = useCallback(async (file) => {
|
||||
// handleResults receives (data, file) from CSVImport after the upload (and optional
|
||||
// mapping review) step is complete. data is the backend API response, or null if
|
||||
// the backend was unreachable, in which case we fall back to local processing.
|
||||
const handleResults = useCallback((data, file) => {
|
||||
const label = new Date().toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
const token = await getToken().catch(() => null);
|
||||
const data = await uploadToBackend(file, token);
|
||||
|
||||
if (data) {
|
||||
const results = data.records.map(apiRecordToLocal);
|
||||
setRecords(results);
|
||||
|
|
@ -70,7 +71,7 @@ function AppInner() {
|
|||
showToast(msg);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}, [getToken]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-h-screen bg-[var(--bg-page)] text-[var(--text-primary)] transition-colors">
|
||||
|
|
@ -85,10 +86,11 @@ function AppInner() {
|
|||
onImportClick={() => csvImportRef.current?.trigger()}
|
||||
/>
|
||||
|
||||
{/* Hidden CSV import trigger */}
|
||||
{/* Hidden CSV import trigger — handles upload, mapping review, and results */}
|
||||
<CSVImport
|
||||
ref={csvImportRef}
|
||||
onResults={handleResults}
|
||||
getToken={getToken}
|
||||
/>
|
||||
|
||||
{/* Main */}
|
||||
|
|
@ -112,34 +114,43 @@ function AppInner() {
|
|||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-7">
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<StatCard
|
||||
priority
|
||||
label="Needs Attention"
|
||||
value={supplyLapsed + visitRequired + renewalCritical + transferPending}
|
||||
sub={`${supplyLapsed} docs required · ${visitRequired + renewalCritical} escalate · ${transferPending} new patient`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Outreach in Progress"
|
||||
value={outreachCount}
|
||||
sub={`${renewalElevated} confirm appt. · ${renewalSoon} begin outreach`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Clear to Ship"
|
||||
value={refill}
|
||||
sub={`${refill} patients in resupply window · ${okCount} on track`}
|
||||
{records.length === 0 ? (
|
||||
<EmptyState onOpenFile={() => csvImportRef.current?.trigger()} />
|
||||
) : (
|
||||
<div className="p-7">
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<StatCard
|
||||
priority
|
||||
label="Needs Attention"
|
||||
value={supplyLapsed + visitRequired + renewalCritical + transferPending}
|
||||
sub={`${supplyLapsed} docs required · ${visitRequired + renewalCritical} need escalation · ${transferPending} new patient`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Outreach in Progress"
|
||||
value={outreachCount}
|
||||
sub={`${renewalElevated} confirm appointment · ${renewalSoon} ready for outreach`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Clear to Ship"
|
||||
value={refill}
|
||||
sub={`${refill} patients in resupply window · ${okCount} on track`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Worklist */}
|
||||
<WorklistTable
|
||||
records={records}
|
||||
activeFilter={activeFilter}
|
||||
onFilterChange={setActiveFilter}
|
||||
onVisitConfirmed={(patientId, updatedRecord) =>
|
||||
setRecords(prev =>
|
||||
prev.map(r => r.patient_id === patientId ? { ...r, ...updatedRecord } : r)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Worklist */}
|
||||
<WorklistTable
|
||||
records={records}
|
||||
activeFilter={activeFilter}
|
||||
onFilterChange={setActiveFilter}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Toast />
|
||||
|
|
|
|||
|
|
@ -49,6 +49,15 @@ const FLAG_CONFIG = {
|
|||
weight: "font-semibold",
|
||||
icon: "→",
|
||||
},
|
||||
// GRAY — dormant, stale patient requiring prescriber status check
|
||||
NO_RECENT_SHIPMENT: {
|
||||
label: "No Recent Shipment",
|
||||
bg: "bg-[rgba(120,120,120,0.08)]",
|
||||
text: "text-[var(--text-muted)]",
|
||||
border: "border-[var(--border-color)]",
|
||||
weight: "font-normal",
|
||||
icon: "○",
|
||||
},
|
||||
// GREEN — no action needed
|
||||
RESUPPLY_READY: {
|
||||
label: "Clear to Ship",
|
||||
|
|
|
|||
|
|
@ -1,27 +1,298 @@
|
|||
import { useRef, forwardRef, useImperativeHandle } from "react";
|
||||
import { useRef, forwardRef, useImperativeHandle, useState } from "react";
|
||||
import { uploadToBackend } from "../lib/api";
|
||||
|
||||
const CSVImport = forwardRef(function CSVImport({ onResults }, ref) {
|
||||
// Human-readable labels for canonical Signal field names.
|
||||
const FIELD_LABELS = {
|
||||
patient_id: "Patient ID",
|
||||
device_type: "Device Type",
|
||||
shipment_date: "Shipment Date",
|
||||
quantity: "Quantity",
|
||||
payer: "Payer",
|
||||
component: "Component",
|
||||
csv_visit_date: "Visit Date",
|
||||
csv_swo_status: "SWO Status",
|
||||
csv_pecos_verified: "PECOS Verified",
|
||||
csv_pa_status: "PA Status",
|
||||
csv_diagnosis_on_file: "Diagnosis on File",
|
||||
csv_transfer_from: "Transfer From",
|
||||
};
|
||||
|
||||
// Options available in the Override dropdown.
|
||||
const OVERRIDE_OPTIONS = [
|
||||
{ value: "patient_id", label: "Patient ID" },
|
||||
{ value: "device_type", label: "Device Type" },
|
||||
{ value: "shipment_date", label: "Shipment Date" },
|
||||
{ value: "quantity", label: "Quantity" },
|
||||
{ value: "payer", label: "Payer" },
|
||||
{ value: "skip", label: "Skip this column" },
|
||||
];
|
||||
|
||||
// Map a backend confidence string to a short display label.
|
||||
function confidenceLabel(confidence) {
|
||||
if (confidence === "high") return "Confident";
|
||||
if (confidence === "inferred") return "Check this";
|
||||
return "Uncertain";
|
||||
}
|
||||
|
||||
function confidenceColor(confidence) {
|
||||
if (confidence === "high") return "text-[#1A8B5A]";
|
||||
if (confidence === "inferred") return "text-[var(--accent-text)]";
|
||||
return "text-[#CC6600]";
|
||||
}
|
||||
|
||||
// Build the rows for the mapping review table from mapping_summary.
|
||||
// Returns an array of row objects sorted so lower-confidence rows appear first.
|
||||
function buildMappingRows(mappingSummary) {
|
||||
if (!mappingSummary?.mapped) return [];
|
||||
|
||||
const rows = Object.entries(mappingSummary.mapped).map(([canonical, detail]) => ({
|
||||
canonical,
|
||||
rawHeader: detail.raw_header,
|
||||
confidence: detail.confidence,
|
||||
exampleValue: detail.example_value ?? null,
|
||||
override: canonical, // default to detected value
|
||||
}));
|
||||
|
||||
// Sort: uncertain first, then inferred, then high confidence
|
||||
const order = { unmapped: 0, inferred: 1, high: 2 };
|
||||
rows.sort((a, b) => (order[a.confidence] ?? 0) - (order[b.confidence] ?? 0));
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
// Check whether mapping review should be shown (at least one mapped column present).
|
||||
function shouldShowReview(mappingSummary) {
|
||||
return (
|
||||
mappingSummary &&
|
||||
mappingSummary.mapped &&
|
||||
Object.keys(mappingSummary.mapped).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
// Check whether any confidence scores are present in the mapping data.
|
||||
function hasConfidenceData(rows) {
|
||||
return rows.some((r) => r.confidence && r.confidence !== "unmapped");
|
||||
}
|
||||
|
||||
// ---- Main component ----
|
||||
|
||||
const CSVImport = forwardRef(function CSVImport({ onResults, getToken }, ref) {
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// Pending state while waiting for backend response.
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// Mapping review state.
|
||||
const [reviewState, setReviewState] = useState(null);
|
||||
// reviewState shape: { rows, backendData, file }
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
trigger: () => inputRef.current?.click(),
|
||||
}));
|
||||
|
||||
const handleFile = (e) => {
|
||||
const handleFile = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
e.target.value = "";
|
||||
onResults(file);
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const token = getToken ? await getToken().catch(() => null) : null;
|
||||
const data = await uploadToBackend(file, token);
|
||||
|
||||
if (data && shouldShowReview(data.mapping_summary)) {
|
||||
// Build review rows from the mapping summary.
|
||||
const rows = buildMappingRows(data.mapping_summary);
|
||||
setReviewState({ rows, backendData: data, file });
|
||||
return; // Hold here; user confirms or cancels.
|
||||
}
|
||||
|
||||
// No mapping data or backend unreachable — pass through.
|
||||
onResults(data, file);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (!reviewState) return;
|
||||
// Pass backend data through. Overrides are informational for now;
|
||||
// the worklist uses the backend's already-scored records.
|
||||
onResults(reviewState.backendData, reviewState.file);
|
||||
setReviewState(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setReviewState(null);
|
||||
};
|
||||
|
||||
const handleOverrideChange = (index, newValue) => {
|
||||
setReviewState((prev) => {
|
||||
const rows = prev.rows.map((r, i) =>
|
||||
i === index ? { ...r, override: newValue } : r
|
||||
);
|
||||
return { ...prev, rows };
|
||||
});
|
||||
};
|
||||
|
||||
const showConfidence = reviewState
|
||||
? hasConfidenceData(reviewState.rows)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFile}
|
||||
className="hidden"
|
||||
/>
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFile}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Uploading indicator — rendered off-screen, no visible flash for fast responses */}
|
||||
{uploading && !reviewState && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/20">
|
||||
<div className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[10px] px-6 py-4 text-[13px] text-[var(--text-secondary)] shadow-lg">
|
||||
Reading your file...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mapping review overlay */}
|
||||
{reviewState && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div
|
||||
className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[12px] shadow-xl w-full max-w-[780px] max-h-[90vh] flex flex-col"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-6 pt-6 pb-4 border-b border-[var(--border-color)]">
|
||||
<div className="font-heading font-bold text-[15px] text-[var(--text-heading)]">
|
||||
Review how your columns were mapped
|
||||
</div>
|
||||
<div className="text-[12px] text-[var(--text-muted)] mt-1">
|
||||
Signal matched your CSV headers to the fields it needs. Take a look
|
||||
before continuing, and use the Override column to correct anything
|
||||
that looks off.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-auto flex-1 px-6 py-4">
|
||||
<table className="w-full text-[12px] border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--border-color)]">
|
||||
<th className="text-left py-2 pr-4 font-semibold text-[var(--text-secondary)] whitespace-nowrap">
|
||||
Original CSV header
|
||||
</th>
|
||||
<th className="text-left py-2 pr-4 font-semibold text-[var(--text-secondary)] whitespace-nowrap">
|
||||
Detected Signal field
|
||||
</th>
|
||||
{showConfidence && (
|
||||
<th className="text-left py-2 pr-4 font-semibold text-[var(--text-secondary)] whitespace-nowrap">
|
||||
Confidence
|
||||
</th>
|
||||
)}
|
||||
{reviewState.rows.some((r) => r.exampleValue !== null) && (
|
||||
<th className="text-left py-2 pr-4 font-semibold text-[var(--text-secondary)] whitespace-nowrap">
|
||||
Example value
|
||||
</th>
|
||||
)}
|
||||
<th className="text-left py-2 font-semibold text-[var(--text-secondary)] whitespace-nowrap">
|
||||
Override
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reviewState.rows.map((row, i) => (
|
||||
<tr
|
||||
key={i}
|
||||
className="border-b border-[var(--border-color)] last:border-b-0 hover:bg-[var(--bg-elevated)]"
|
||||
>
|
||||
{/* Original CSV header */}
|
||||
<td className="py-2 pr-4 font-mono text-[var(--text-primary)]">
|
||||
{row.rawHeader}
|
||||
</td>
|
||||
|
||||
{/* Detected Signal field */}
|
||||
<td className="py-2 pr-4 text-[var(--text-secondary)]">
|
||||
{FIELD_LABELS[row.canonical] ?? row.canonical}
|
||||
</td>
|
||||
|
||||
{/* Confidence */}
|
||||
{showConfidence && (
|
||||
<td className={`py-2 pr-4 font-medium ${confidenceColor(row.confidence)}`}>
|
||||
{confidenceLabel(row.confidence)}
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* Example value */}
|
||||
{reviewState.rows.some((r) => r.exampleValue !== null) && (
|
||||
<td className="py-2 pr-4 font-mono text-[var(--text-muted)]">
|
||||
{row.exampleValue ?? ""}
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* Override dropdown */}
|
||||
<td className="py-2">
|
||||
<select
|
||||
value={row.override}
|
||||
onChange={(e) => handleOverrideChange(i, e.target.value)}
|
||||
className="border border-[var(--border-color)] rounded-[5px] px-2 py-1 text-[11px] bg-[var(--bg-elevated)] text-[var(--text-primary)] focus:outline-none focus:border-[var(--brand)] cursor-pointer"
|
||||
>
|
||||
{OVERRIDE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Unmapped columns notice */}
|
||||
{reviewState.backendData.mapping_summary.unmapped_columns?.length > 0 && (
|
||||
<div className="mt-4 text-[11px] text-[var(--text-muted)]">
|
||||
<span className="font-semibold text-[var(--text-secondary)]">
|
||||
Columns Signal did not use:
|
||||
</span>{" "}
|
||||
{reviewState.backendData.mapping_summary.unmapped_columns.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Required missing notice */}
|
||||
{reviewState.backendData.mapping_summary.required_missing?.length > 0 && (
|
||||
<div className="mt-2 text-[11px] text-[#CC4400] bg-[#FFF4EE] border border-[#FFCFB0] rounded px-3 py-2">
|
||||
Some required columns were not found:{" "}
|
||||
{reviewState.backendData.mapping_summary.required_missing
|
||||
.map((f) => FIELD_LABELS[f] ?? f)
|
||||
.join(", ")}
|
||||
. Check that your CSV includes these fields.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="px-6 py-4 border-t border-[var(--border-color)] flex items-center justify-between">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="text-[12px] text-[var(--text-muted)] hover:text-[var(--text-secondary)] underline cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="px-5 py-2 text-[12px] rounded-md bg-[var(--brand)] text-white font-semibold hover:opacity-90 cursor-pointer"
|
||||
>
|
||||
Looks good, continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import { useState } from "react";
|
|||
import { useAuth } from "@clerk/react";
|
||||
import { confirmVisit } from "../lib/api";
|
||||
|
||||
export default function ConfirmVisitModal({ record, onClose, onConfirmed }) {
|
||||
export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSaveSuccess }) {
|
||||
const { getToken } = useAuth();
|
||||
const [visitDate, setVisitDate] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [step, setStep] = useState("entry"); // "entry" | "confirm"
|
||||
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const minDate = "2020-01-01";
|
||||
|
|
@ -21,7 +22,7 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed }) {
|
|||
return today;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
function handleValidateAndAdvance() {
|
||||
setError("");
|
||||
if (!visitDate) {
|
||||
setError("Please enter a visit date.");
|
||||
|
|
@ -38,10 +39,13 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed }) {
|
|||
const sixMonthsBeforeShipment = new Date(shipmentDateObj);
|
||||
sixMonthsBeforeShipment.setMonth(sixMonthsBeforeShipment.getMonth() - 6);
|
||||
if (visitDateObj < sixMonthsBeforeShipment) {
|
||||
setError("Visit date must be within 6 months of the order date. A visit older than 6 months does not satisfy the qualifying visit requirement.");
|
||||
setError("Visit date must be within 6 months of the order date.");
|
||||
return;
|
||||
}
|
||||
setStep("confirm");
|
||||
}
|
||||
|
||||
async function handleConfirmAndSave() {
|
||||
setSaving(true);
|
||||
try {
|
||||
const token = await getToken().catch(() => null);
|
||||
|
|
@ -55,63 +59,110 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed }) {
|
|||
component: record.component || "sensor",
|
||||
}, token);
|
||||
onConfirmed(record.patient_id, updated);
|
||||
onSaveSuccess?.({ patient_id: record.patient_id });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e.message || "Failed to save. Please try again.");
|
||||
setStep("entry");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const formattedVisitDate = visitDate
|
||||
? new Date(visitDate + "T00:00:00").toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" })
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div
|
||||
className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[12px] shadow-xl p-6 w-[420px] max-w-[95vw]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="font-heading font-bold text-[15px] text-[var(--text-heading)] mb-1">
|
||||
Confirm Qualifying Visit
|
||||
</div>
|
||||
<div className="text-[12px] text-[var(--text-muted)] mb-4">
|
||||
Patient ID: <span className="font-mono font-semibold text-[var(--text-secondary)]">{record.patient_id}</span>
|
||||
</div>
|
||||
{step === "entry" ? (
|
||||
<>
|
||||
<div className="font-heading font-bold text-[15px] text-[var(--text-heading)] mb-1">
|
||||
Confirm Qualifying Visit
|
||||
</div>
|
||||
<div className="text-[12px] text-[var(--text-muted)] mb-4">
|
||||
Patient ID: <span className="font-mono font-semibold text-[var(--text-secondary)]">{record.patient_id}</span>
|
||||
</div>
|
||||
|
||||
<label className="block text-[12px] font-semibold text-[var(--text-secondary)] mb-1">
|
||||
Qualifying visit date — confirmed with prescriber office or from SWO on file
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={visitDate}
|
||||
max={today}
|
||||
min={minDate}
|
||||
onChange={(e) => setVisitDate(e.target.value)}
|
||||
className="w-full border border-[var(--border-color)] rounded-[6px] px-3 py-2 text-[13px] bg-[var(--bg-elevated)] text-[var(--text-primary)] mb-2 focus:outline-none focus:border-[var(--brand)]"
|
||||
/>
|
||||
<div className="text-[10.5px] text-[var(--text-muted)] mb-4">
|
||||
Contact prescriber office to confirm. Do not use patient-reported date.
|
||||
</div>
|
||||
<label className="block text-[12px] font-semibold text-[var(--text-secondary)] mb-1">
|
||||
Qualifying visit date, confirmed with prescriber office or from SWO on file
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={visitDate}
|
||||
max={today}
|
||||
min={minDate}
|
||||
onChange={(e) => setVisitDate(e.target.value)}
|
||||
className="w-full border border-[var(--border-color)] rounded-[6px] px-3 py-2 text-[13px] bg-[var(--bg-elevated)] text-[var(--text-primary)] mb-2 focus:outline-none focus:border-[var(--brand)]"
|
||||
/>
|
||||
<div className="text-[10.5px] text-[var(--text-muted)] mb-4">
|
||||
Contact prescriber office to confirm. Do not use patient-reported date.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-[11px] text-[#CC2222] mb-3 px-2 py-1 bg-red-50 rounded border border-red-200">
|
||||
{error}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-[11px] text-[#CC2222] mb-3 px-2 py-1 bg-red-50 rounded border border-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-[12px] rounded-md border border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--brand)] cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleValidateAndAdvance}
|
||||
className="px-4 py-2 text-[12px] rounded-md bg-[var(--brand)] text-white font-semibold hover:opacity-90 cursor-pointer"
|
||||
>
|
||||
Save Visit Date
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-heading font-bold text-[15px] text-[var(--text-heading)] mb-4">
|
||||
One last look
|
||||
</div>
|
||||
|
||||
<div className="text-[13px] text-[var(--text-secondary)] mb-1">
|
||||
Patient ID: <span className="font-mono font-semibold">{record.patient_id}</span>
|
||||
</div>
|
||||
<div className="text-[13px] text-[var(--text-secondary)] mb-4">
|
||||
Visit date: <span className="font-semibold">{formattedVisitDate}</span>
|
||||
</div>
|
||||
<div className="text-[11.5px] text-[var(--text-muted)] mb-5">
|
||||
Confirm this is the verified date from the prescriber office.
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-[11px] text-[#CC2222] mb-3 px-2 py-1 bg-red-50 rounded border border-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => { setStep("entry"); setError(""); }}
|
||||
className="px-4 py-2 text-[12px] rounded-md border border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--brand)] cursor-pointer"
|
||||
>
|
||||
Go back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirmAndSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-[12px] rounded-md bg-[var(--brand)] text-white font-semibold hover:opacity-90 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Confirm and save"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-[12px] rounded-md border border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--brand)] cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-[12px] rounded-md bg-[var(--brand)] text-white font-semibold hover:opacity-90 cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Visit Date"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,20 +8,19 @@ const DOT_STATUS = {
|
|||
"Expired": "red",
|
||||
// PECOS
|
||||
"Verified": "green",
|
||||
"Not Verified": "red",
|
||||
"Needs verification": "red",
|
||||
"N/A": "gray",
|
||||
// PA
|
||||
"Not Required": "green",
|
||||
"Approved": "green",
|
||||
"Denied": "red",
|
||||
// Diagnosis
|
||||
"Missing": "red",
|
||||
"Not on file": "red",
|
||||
// Transfer / generic pending
|
||||
"Pending — Verify": "amber",
|
||||
"Pending": "amber",
|
||||
"Required — Not Started": "red",
|
||||
"Not Confirmed — Estimated Only": "amber",
|
||||
"Not Confirmed": "amber",
|
||||
"Not yet submitted": "red",
|
||||
"Estimated, confirm with prescriber": "amber",
|
||||
"Pending confirmation": "amber",
|
||||
};
|
||||
|
||||
function dotColor(status) {
|
||||
|
|
|
|||
64
signal-ui/src/components/EmptyState.jsx
Normal file
64
signal-ui/src/components/EmptyState.jsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
export default function EmptyState({ onOpenFile }) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center"
|
||||
style={{
|
||||
minHeight: "100%",
|
||||
flex: 1,
|
||||
background: "#F0EAE1",
|
||||
padding: "48px 24px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
textAlign: "center",
|
||||
maxWidth: "400px",
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: "22px",
|
||||
fontWeight: "700",
|
||||
color: "#1A3333",
|
||||
marginBottom: "12px",
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
Where is your CSV file?
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "15px",
|
||||
color: "#5A7A7A",
|
||||
marginBottom: "32px",
|
||||
lineHeight: "1.5",
|
||||
}}
|
||||
>
|
||||
Import your order management export to get started.
|
||||
</p>
|
||||
<button
|
||||
onClick={onOpenFile}
|
||||
style={{
|
||||
background: "#2EA3A3",
|
||||
color: "#ffffff",
|
||||
border: "none",
|
||||
borderRadius: "8px",
|
||||
padding: "12px 28px",
|
||||
fontSize: "15px",
|
||||
fontWeight: "600",
|
||||
cursor: "pointer",
|
||||
letterSpacing: "0.01em",
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
e.currentTarget.style.background = "#228888";
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
e.currentTarget.style.background = "#2EA3A3";
|
||||
}}
|
||||
>
|
||||
Open File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef, Fragment } from "react";
|
||||
import { useTheme } from "../useTheme";
|
||||
import Badge from "./Badge";
|
||||
import DocStatusBar from "./DocStatusBar";
|
||||
|
|
@ -36,19 +36,44 @@ const ACTION_NEEDED_FLAGS = ["RENEWAL_ELEVATED", "RENEWAL_SOON", "TRANSFER_PENDI
|
|||
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 }) {
|
||||
export default function WorklistTable({ records, activeFilter, onFilterChange, onVisitConfirmed }) {
|
||||
const { dark } = useTheme();
|
||||
const [expandedRow, setExpandedRow] = useState(null);
|
||||
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 rowRefs = useRef({});
|
||||
|
||||
useEffect(() => { setLocalRecords(records); }, [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 =
|
||||
|
|
@ -95,6 +120,31 @@ export default function WorklistTable({ records, activeFilter, onFilterChange })
|
|||
</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>
|
||||
|
|
@ -109,29 +159,49 @@ export default function WorklistTable({ records, activeFilter, onFilterChange })
|
|||
<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;
|
||||
const showConfirmVisit = CONFIRM_VISIT_FLAGS.includes(r.flag) || r.visit_date_confidence === "estimated";
|
||||
// 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;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Fragment key={rowKey}>
|
||||
<tr
|
||||
key={rowKey}
|
||||
ref={el => { rowRefs.current[rowKey] = el; }}
|
||||
className={`border-b border-[var(--border-subtle)] transition-colors cursor-pointer hover:bg-[var(--row-hover)] ${
|
||||
hp
|
||||
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={() => setExpandedRow(isExpanded ? null : rowKey)}
|
||||
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 ${hp ? "text-[var(--accent-text)]" : "text-[var(--text-secondary)]"}`}>
|
||||
<div className={`font-mono text-[12.5px] font-bold ${isStale ? "italic text-[var(--text-muted)]" : hp ? "text-[var(--accent-text)]" : "text-[var(--text-secondary)]"}`}>
|
||||
{r.patient_id}
|
||||
</div>
|
||||
{i === 0 && (
|
||||
|
|
@ -221,7 +291,7 @@ export default function WorklistTable({ records, activeFilter, onFilterChange })
|
|||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
|
|
@ -252,8 +322,32 @@ export default function WorklistTable({ records, activeFilter, onFilterChange })
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -268,6 +362,9 @@ function ActionButton({ flag }) {
|
|||
if (flag === "TRANSFER_PENDING") {
|
||||
return <button className={`${base} !border-[var(--accent)] !text-[var(--accent-text)] hover:bg-[rgba(203,107,32,0.1)]`}>Verify Docs</button>;
|
||||
}
|
||||
if (flag === "NO_RECENT_SHIPMENT") {
|
||||
return <button className={base}>Verify with Prescriber</button>;
|
||||
}
|
||||
if (flag === "RESUPPLY_READY") {
|
||||
return <button className={base}>Initiate Resupply</button>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,33 +7,36 @@
|
|||
*/
|
||||
|
||||
export const FLAG = {
|
||||
SUPPLY_LAPSED: "SUPPLY_LAPSED",
|
||||
VISIT_REQUIRED: "VISIT_REQUIRED",
|
||||
RENEWAL_CRITICAL: "RENEWAL_CRITICAL",
|
||||
RENEWAL_ELEVATED: "RENEWAL_ELEVATED",
|
||||
RENEWAL_SOON: "RENEWAL_SOON",
|
||||
RESUPPLY_READY: "RESUPPLY_READY",
|
||||
ACTIVE: "ACTIVE",
|
||||
SUPPLY_LAPSED: "SUPPLY_LAPSED",
|
||||
VISIT_REQUIRED: "VISIT_REQUIRED",
|
||||
RENEWAL_CRITICAL: "RENEWAL_CRITICAL",
|
||||
RENEWAL_ELEVATED: "RENEWAL_ELEVATED",
|
||||
RENEWAL_SOON: "RENEWAL_SOON",
|
||||
RESUPPLY_READY: "RESUPPLY_READY",
|
||||
ACTIVE: "ACTIVE",
|
||||
NO_RECENT_SHIPMENT: "NO_RECENT_SHIPMENT",
|
||||
};
|
||||
|
||||
const FLAG_LABELS = {
|
||||
[FLAG.SUPPLY_LAPSED]: "Supply Lapsed",
|
||||
[FLAG.VISIT_REQUIRED]: "Visit Required",
|
||||
[FLAG.RENEWAL_CRITICAL]: "Renewal Due",
|
||||
[FLAG.RENEWAL_ELEVATED]: "Renewal Elevated",
|
||||
[FLAG.RENEWAL_SOON]: "Renewal Soon",
|
||||
[FLAG.RESUPPLY_READY]: "Resupply Ready",
|
||||
[FLAG.ACTIVE]: "Active",
|
||||
[FLAG.SUPPLY_LAPSED]: "Supply Lapsed",
|
||||
[FLAG.VISIT_REQUIRED]: "Visit Required",
|
||||
[FLAG.RENEWAL_CRITICAL]: "Renewal Due",
|
||||
[FLAG.RENEWAL_ELEVATED]: "Renewal Elevated",
|
||||
[FLAG.RENEWAL_SOON]: "Renewal Soon",
|
||||
[FLAG.RESUPPLY_READY]: "Resupply Ready",
|
||||
[FLAG.ACTIVE]: "Active",
|
||||
[FLAG.NO_RECENT_SHIPMENT]: "No Recent Shipment",
|
||||
};
|
||||
|
||||
const FLAG_ACTIONS = {
|
||||
[FLAG.SUPPLY_LAPSED]: "Contact Prescriber",
|
||||
[FLAG.VISIT_REQUIRED]: "Schedule Visit",
|
||||
[FLAG.RENEWAL_CRITICAL]: "Request Renewal",
|
||||
[FLAG.RENEWAL_ELEVATED]: "Request Renewal",
|
||||
[FLAG.RENEWAL_SOON]: "Plan Renewal",
|
||||
[FLAG.RESUPPLY_READY]: "Initiate Resupply",
|
||||
[FLAG.ACTIVE]: "No action needed",
|
||||
[FLAG.SUPPLY_LAPSED]: "Contact Prescriber",
|
||||
[FLAG.VISIT_REQUIRED]: "Schedule Visit",
|
||||
[FLAG.RENEWAL_CRITICAL]: "Request Renewal",
|
||||
[FLAG.RENEWAL_ELEVATED]: "Request Renewal",
|
||||
[FLAG.RENEWAL_SOON]: "Plan Renewal",
|
||||
[FLAG.RESUPPLY_READY]: "Initiate Resupply",
|
||||
[FLAG.ACTIVE]: "No action needed",
|
||||
[FLAG.NO_RECENT_SHIPMENT]: "Verify status with prescriber office — confirm patient is still active",
|
||||
};
|
||||
|
||||
/** Wear-day rules — mirrors payer_rules.json */
|
||||
|
|
@ -94,6 +97,7 @@ function computePriority(flag, daysUntilEnd) {
|
|||
if (flag === FLAG.RENEWAL_ELEVATED) return 700 + Math.max(0, 90 - daysUntilEnd);
|
||||
if (flag === FLAG.RENEWAL_SOON) return 400 + Math.max(0, 90 - daysUntilEnd);
|
||||
if (flag === FLAG.RESUPPLY_READY) return 200 + Math.max(0, 30 - daysUntilEnd);
|
||||
if (flag === FLAG.NO_RECENT_SHIPMENT) return 5;
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
|
@ -136,6 +140,19 @@ export function calculateCoverage(record) {
|
|||
flag = FLAG.ACTIVE;
|
||||
}
|
||||
|
||||
// Catch-all: last shipment was 12+ months ago and no higher-priority flag fired.
|
||||
// Only overrides ACTIVE or RESUPPLY_READY — all urgent flags take precedence.
|
||||
const NO_RECENT_SHIPMENT_DAYS = 365;
|
||||
const shipmentDate = new Date(record.shipment_date + "T00:00:00");
|
||||
const daysSinceShipment = daysDiff(now, shipmentDate);
|
||||
if (
|
||||
(flag === FLAG.ACTIVE || flag === FLAG.RESUPPLY_READY) &&
|
||||
daysSinceShipment >= NO_RECENT_SHIPMENT_DAYS
|
||||
) {
|
||||
flag = FLAG.NO_RECENT_SHIPMENT;
|
||||
daysUntilVisit = null;
|
||||
}
|
||||
|
||||
return {
|
||||
...record,
|
||||
flag,
|
||||
|
|
|
|||
Loading…
Reference in a new issue