From 63093990d2b07d6ea02cf9b7bc09573579b44aa2 Mon Sep 17 00:00:00 2001 From: Kisa Date: Wed, 10 Jun 2026 17:13:16 -0400 Subject: [PATCH] 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 --- CLAUDE.md | 12 +- python-backend/api/main.py | 37 ++- python-backend/core/coverage_calculator.py | 63 ++-- python-backend/core/doc_state_machine.py | 18 +- signal-ui/src/App.jsx | 77 +++-- signal-ui/src/components/Badge.jsx | 9 + signal-ui/src/components/CSVImport.jsx | 295 +++++++++++++++++- .../src/components/ConfirmVisitModal.jsx | 137 +++++--- signal-ui/src/components/DocStatusBar.jsx | 11 +- signal-ui/src/components/EmptyState.jsx | 64 ++++ signal-ui/src/components/WorklistTable.jsx | 117 ++++++- signal-ui/src/lib/coverage.js | 59 ++-- 12 files changed, 723 insertions(+), 176 deletions(-) create mode 100644 signal-ui/src/components/EmptyState.jsx diff --git a/CLAUDE.md b/CLAUDE.md index 4d75064..c88ac8a 100644 --- a/CLAUDE.md +++ b/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 | diff --git a/python-backend/api/main.py b/python-backend/api/main.py index 35b2a29..a089ad9 100644 --- a/python-backend/api/main.py +++ b/python-backend/api/main.py @@ -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") diff --git a/python-backend/core/coverage_calculator.py b/python-backend/core/coverage_calculator.py index 8889882..4cf2478 100644 --- a/python-backend/core/coverage_calculator.py +++ b/python-backend/core/coverage_calculator.py @@ -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 diff --git a/python-backend/core/doc_state_machine.py b/python-backend/core/doc_state_machine.py index 343e2a8..8393bb9 100644 --- a/python-backend/core/doc_state_machine.py +++ b/python-backend/core/doc_state_machine.py @@ -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 diff --git a/signal-ui/src/App.jsx b/signal-ui/src/App.jsx index 05ec988..af4c801 100644 --- a/signal-ui/src/App.jsx +++ b/signal-ui/src/App.jsx @@ -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 (
@@ -85,10 +86,11 @@ function AppInner() { onImportClick={() => csvImportRef.current?.trigger()} /> - {/* Hidden CSV import trigger */} + {/* Hidden CSV import trigger — handles upload, mapping review, and results */} {/* Main */} @@ -112,34 +114,43 @@ function AppInner() { {/* Content */} -
- {/* Stats row */} -
- - - csvImportRef.current?.trigger()} /> + ) : ( +
+ {/* Stats row */} +
+ + + +
+ + {/* Worklist */} + + setRecords(prev => + prev.map(r => r.patient_id === patientId ? { ...r, ...updatedRecord } : r) + ) + } />
- - {/* Worklist */} - -
+ )} diff --git a/signal-ui/src/components/Badge.jsx b/signal-ui/src/components/Badge.jsx index febcb9b..7eba3a4 100644 --- a/signal-ui/src/components/Badge.jsx +++ b/signal-ui/src/components/Badge.jsx @@ -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", diff --git a/signal-ui/src/components/CSVImport.jsx b/signal-ui/src/components/CSVImport.jsx index c61afdd..2e1ce13 100644 --- a/signal-ui/src/components/CSVImport.jsx +++ b/signal-ui/src/components/CSVImport.jsx @@ -1,28 +1,299 @@ -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 ( - + <> + + + {/* Uploading indicator — rendered off-screen, no visible flash for fast responses */} + {uploading && !reviewState && ( +
+
+ Reading your file... +
+
+ )} + + {/* Mapping review overlay */} + {reviewState && ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ Review how your columns were mapped +
+
+ 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. +
+
+ + {/* Table */} +
+ + + + + + {showConfidence && ( + + )} + {reviewState.rows.some((r) => r.exampleValue !== null) && ( + + )} + + + + + {reviewState.rows.map((row, i) => ( + + {/* Original CSV header */} + + + {/* Detected Signal field */} + + + {/* Confidence */} + {showConfidence && ( + + )} + + {/* Example value */} + {reviewState.rows.some((r) => r.exampleValue !== null) && ( + + )} + + {/* Override dropdown */} + + + ))} + +
+ Original CSV header + + Detected Signal field + + Confidence + + Example value + + Override +
+ {row.rawHeader} + + {FIELD_LABELS[row.canonical] ?? row.canonical} + + {confidenceLabel(row.confidence)} + + {row.exampleValue ?? ""} + + +
+ + {/* Unmapped columns notice */} + {reviewState.backendData.mapping_summary.unmapped_columns?.length > 0 && ( +
+ + Columns Signal did not use: + {" "} + {reviewState.backendData.mapping_summary.unmapped_columns.join(", ")} +
+ )} + + {/* Required missing notice */} + {reviewState.backendData.mapping_summary.required_missing?.length > 0 && ( +
+ 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. +
+ )} +
+ + {/* Footer actions */} +
+ + +
+
+
+ )} + ); }); -export default CSVImport; \ No newline at end of file +export default CSVImport; diff --git a/signal-ui/src/components/ConfirmVisitModal.jsx b/signal-ui/src/components/ConfirmVisitModal.jsx index 26cc60b..81c3d6b 100644 --- a/signal-ui/src/components/ConfirmVisitModal.jsx +++ b/signal-ui/src/components/ConfirmVisitModal.jsx @@ -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 (
e.stopPropagation()} > -
- Confirm Qualifying Visit -
-
- Patient ID: {record.patient_id} -
+ {step === "entry" ? ( + <> +
+ Confirm Qualifying Visit +
+
+ Patient ID: {record.patient_id} +
- - 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)]" - /> -
- Contact prescriber office to confirm. Do not use patient-reported date. -
+ + 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)]" + /> +
+ Contact prescriber office to confirm. Do not use patient-reported date. +
- {error && ( -
- {error} -
+ {error && ( +
+ {error} +
+ )} + +
+ + +
+ + ) : ( + <> +
+ One last look +
+ +
+ Patient ID: {record.patient_id} +
+
+ Visit date: {formattedVisitDate} +
+
+ Confirm this is the verified date from the prescriber office. +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+ )} - -
- - -
); diff --git a/signal-ui/src/components/DocStatusBar.jsx b/signal-ui/src/components/DocStatusBar.jsx index 87a8f05..6d0bf29 100644 --- a/signal-ui/src/components/DocStatusBar.jsx +++ b/signal-ui/src/components/DocStatusBar.jsx @@ -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) { diff --git a/signal-ui/src/components/EmptyState.jsx b/signal-ui/src/components/EmptyState.jsx new file mode 100644 index 0000000..fd23654 --- /dev/null +++ b/signal-ui/src/components/EmptyState.jsx @@ -0,0 +1,64 @@ +export default function EmptyState({ onOpenFile }) { + return ( +
+
+

+ Where is your CSV file? +

+

+ Import your order management export to get started. +

+ +
+
+ ); +} diff --git a/signal-ui/src/components/WorklistTable.jsx b/signal-ui/src/components/WorklistTable.jsx index 4946f75..5e6e1cd 100644 --- a/signal-ui/src/components/WorklistTable.jsx +++ b/signal-ui/src/components/WorklistTable.jsx @@ -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 })
+ {/* Working-on banner */} + {activePatientId && ( +
{ + 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 +
+ )} + {/* Table */} @@ -109,29 +159,49 @@ export default function WorklistTable({ records, activeFilter, onFilterChange }) {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 ( - <> + { 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); + } + }} > )} - + ); })} {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 && ( +
+ {toast.message} +
+ )} ); } @@ -268,6 +362,9 @@ function ActionButton({ flag }) { if (flag === "TRANSFER_PENDING") { return ; } + if (flag === "NO_RECENT_SHIPMENT") { + return ; + } if (flag === "RESUPPLY_READY") { return ; } diff --git a/signal-ui/src/lib/coverage.js b/signal-ui/src/lib/coverage.js index 4a63d73..882d034 100644 --- a/signal-ui/src/lib/coverage.js +++ b/signal-ui/src/lib/coverage.js @@ -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,
-
+
{r.patient_id}
{i === 0 && ( @@ -221,7 +291,7 @@ export default function WorklistTable({ records, activeFilter, onFilterChange })