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:
Kisa 2026-06-10 17:13:16 -04:00
parent d197c529ce
commit 63093990d2
12 changed files with 723 additions and 176 deletions

View file

@ -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 |

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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 />

View file

@ -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",

View file

@ -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>
)}
</>
);
});

View file

@ -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>
);

View file

@ -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) {

View 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>
);
}

View file

@ -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>;
}

View file

@ -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,