From 1d95bb2ab22fecd3dc52bfefe9b03c24679c3de0 Mon Sep 17 00:00:00 2001 From: Kisa Date: Sun, 14 Jun 2026 05:09:06 -0400 Subject: [PATCH] feat: doc status persistence, empty state, status legend, and Clear to Ship guard - Doc status click-to-update: SWO and PA cycle through states and persist to Supabase - Status legend added to worklist header - Empty state with file-open prompt for first-time users - Device normalizer expanded for broader CGM variant coverage - Warm cream text applied globally for secondary/muted UI elements - Drag-drop CSV import support - CORS root cause fixed via Railway ALLOWED_ORIGINS env var - DOCS_REQUIRED badge: RESUPPLY_READY patients with open cascade items now show amber Docs Required instead of green Clear to Ship, preventing premature shipment Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../plans/2026-06-13-doc-status-updates.md | 554 ++++++++++++++++++ python-backend/api/main.py | 75 ++- python-backend/api/normalizer.py | 49 +- python-backend/core/persistence.py | 65 ++ signal-ui/src/App.jsx | 16 +- signal-ui/src/components/Badge.jsx | 9 + signal-ui/src/components/CSVImport.jsx | 36 +- .../src/components/ConfirmVisitModal.jsx | 6 +- signal-ui/src/components/EmptyState.jsx | 79 ++- signal-ui/src/components/StatCard.jsx | 2 +- signal-ui/src/components/StatusLegend.jsx | 43 ++ signal-ui/src/components/Toast.jsx | 2 +- signal-ui/src/components/WorklistTable.jsx | 120 +++- signal-ui/src/index.css | 2 + signal-ui/src/lib/api.js | 33 ++ 15 files changed, 1019 insertions(+), 72 deletions(-) create mode 100644 docs/superpowers/plans/2026-06-13-doc-status-updates.md create mode 100644 signal-ui/src/components/StatusLegend.jsx diff --git a/docs/superpowers/plans/2026-06-13-doc-status-updates.md b/docs/superpowers/plans/2026-06-13-doc-status-updates.md new file mode 100644 index 0000000..b29d47d --- /dev/null +++ b/docs/superpowers/plans/2026-06-13-doc-status-updates.md @@ -0,0 +1,554 @@ +# Doc Status Updates Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow billing staff to update SWO status and PA status directly in Signal's expanded patient row, persisted in Supabase, loaded on every CSV import. + +**Architecture:** Mirror the existing `confirmed_visits` pattern exactly — a new `doc_status` Supabase table, two new persistence functions (`upsert_doc_status` / `load_doc_statuses_for_org`), one new API endpoint (`PUT /api/doc-status`), and inline clickable status chips in the WorklistTable expanded row for SWO and PA only (PECOS and Diagnosis are informational — no staff update in Signal). + +**Tech Stack:** FastAPI (Python backend), Supabase (postgres via supabase-py), React/Tailwind (signal-ui), Vite dev server + +--- + +## Status Values (locked — do not change) + +| Doc type | Allowed values | Good | In-progress | Bad | +|---|---|---|---|---| +| `swo` | `pending`, `requested`, `on_file` | `on_file` | `requested` | `pending` | +| `pa` | `not_required`, `requested`, `approved`, `denied` | `not_required`, `approved` | `requested` | `denied` | + +Display labels: +- `pending` → "Pending" +- `requested` → "Requested" +- `on_file` → "On File" +- `not_required` → "Not Required" +- `approved` → "Approved" +- `denied` → "Denied" + +Colors (reuse existing DocItem pattern): +- Good → `text-[#1A8040]` +- In-progress / requested → `text-[#CB6B20]` +- Bad → `text-[#CC2222]` + +--- + +## File Map + +| File | Action | What changes | +|---|---|---| +| `python-backend/core/persistence.py` | Modify | Add `upsert_doc_status`, `load_doc_statuses_for_org` | +| `python-backend/api/main.py` | Modify | Add `DocStatusRequest` model, `PUT /api/doc-status` endpoint, load doc_statuses in upload response | +| `signal-ui/src/lib/api.js` | Modify | Add `updateDocStatus` function | +| `signal-ui/src/components/WorklistTable.jsx` | Modify | `DocItem` becomes clickable for SWO and PA; add update handler and local state | + +No new files required. No Supabase migration file needed — the table is created via Supabase dashboard SQL (instructions in Task 1). + +--- + +## Task 1: Create Supabase `doc_status` table + +**Files:** None (SQL runs in Supabase dashboard) + +- [ ] **Step 1: Run this SQL in Supabase SQL editor** (Dashboard → SQL Editor → New query) + +```sql +create table if not exists doc_status ( + id uuid primary key default gen_random_uuid(), + org_id uuid not null references organizations(id) on delete cascade, + patient_id_hash text not null, + doc_type text not null check (doc_type in ('swo', 'pa')), + status text not null, + updated_at timestamptz not null default now(), + unique (org_id, patient_id_hash, doc_type) +); + +-- RLS: org members can only see their own org's data +alter table doc_status enable row level security; + +create policy "org_access" on doc_status + using (org_id in ( + select id from organizations + where clerk_org_id = (current_setting('request.jwt.claims', true)::json->>'org_id') + )); +``` + +- [ ] **Step 2: Verify in Supabase Table Editor** that `doc_status` table appears with columns: id, org_id, patient_id_hash, doc_type, status, updated_at + +--- + +## Task 2: Backend persistence functions + +**Files:** +- Modify: `python-backend/core/persistence.py` (after `load_confirmed_visits_for_org` at ~line 220) + +- [ ] **Step 1: Add `upsert_doc_status` function** + +Insert after the `load_confirmed_visits_for_org` function: + +```python +def upsert_doc_status( + org_id: str, + patient_id_hash: str, + doc_type: str, + status: str, +) -> bool: + """ + Insert or update a doc status (swo or pa) for a patient. + Returns True on success, False if Supabase unavailable. + """ + client = get_client() + if not client: + return False + try: + client.table("doc_status").upsert({ + "org_id": org_id, + "patient_id_hash": patient_id_hash, + "doc_type": doc_type, + "status": status, + "updated_at": "now()", + }, on_conflict="org_id,patient_id_hash,doc_type").execute() + return True + except Exception as e: + logger.error(f"Failed to upsert doc_status: {e}") + return False + + +def load_doc_statuses_for_org(org_id: str) -> dict: + """ + Load all SWO and PA statuses for an org. + Returns dict mapping patient_id_hash -> {doc_type: status}. + Example: {"abc123": {"swo": "on_file", "pa": "approved"}} + """ + client = get_client() + if not client: + return {} + try: + result = client.table("doc_status") \ + .select("patient_id_hash,doc_type,status") \ + .eq("org_id", org_id) \ + .execute() + out: dict = {} + for row in (result.data or []): + h = row["patient_id_hash"] + if h not in out: + out[h] = {} + out[h][row["doc_type"]] = row["status"] + return out + except Exception as e: + logger.error(f"Failed to load doc_statuses: {e}") + return {} +``` + +- [ ] **Step 2: Verify import** — `upsert_doc_status` and `load_doc_statuses_for_org` are in the same file as `upsert_confirmed_visit` and `load_confirmed_visits_for_org`. No new imports needed. + +--- + +## Task 3: Backend API endpoint + upload integration + +**Files:** +- Modify: `python-backend/api/main.py` + +- [ ] **Step 1: Import new persistence functions** + +Find the existing import line (around line 20): +```python +from core.persistence import ( + persist_upload, get_or_create_org, + upsert_confirmed_visit, load_confirmed_visits_for_org, +) +``` +Replace with: +```python +from core.persistence import ( + persist_upload, get_or_create_org, + upsert_confirmed_visit, load_confirmed_visits_for_org, + upsert_doc_status, load_doc_statuses_for_org, +) +``` + +- [ ] **Step 2: Add `DocStatusRequest` Pydantic model** + +Find `class ConfirmVisitRequest` and add this BEFORE it: + +```python +class DocStatusRequest(BaseModel): + patient_id: str + doc_type: str # "swo" or "pa" + status: str # see locked values in plan + + +VALID_DOC_STATUSES = { + "swo": {"pending", "requested", "on_file"}, + "pa": {"not_required", "requested", "approved", "denied"}, +} +``` + +- [ ] **Step 3: Load doc statuses in the upload endpoint** + +In the `/api/upload` endpoint, find the block that loads confirmed visits (around line 392): +```python + confirmed_visits = load_confirmed_visits_for_org(org_id) if org_id else {} +``` +Add the doc_statuses load on the next line: +```python + confirmed_visits = load_confirmed_visits_for_org(org_id) if org_id else {} + doc_statuses = load_doc_statuses_for_org(org_id) if org_id else {} +``` + +- [ ] **Step 4: Pass doc_statuses into `_to_record_out`** + +Find the `_to_record_out` call in the upload endpoint: +```python + out = [ + _to_record_out( + r, + record=record_lookup.get(r.patient_id), + confirmed_visit_date=confirmed_visits.get( + hashlib.sha256(r.patient_id.encode()).hexdigest() + ), + ) + for r in results + ] +``` +Replace with: +```python + out = [ + _to_record_out( + r, + record=record_lookup.get(r.patient_id), + confirmed_visit_date=confirmed_visits.get( + hashlib.sha256(r.patient_id.encode()).hexdigest() + ), + saved_doc_statuses=doc_statuses.get( + hashlib.sha256(r.patient_id.encode()).hexdigest(), {} + ), + ) + for r in results + ] +``` + +- [ ] **Step 5: Update `_to_record_out` to accept and apply saved doc statuses** + +Find the `_to_record_out` function definition and add the `saved_doc_statuses` parameter and override logic. + +Find: +```python +def _to_record_out( + r, + record: Optional[object] = None, + confirmed_visit_date=None, +) -> RecordOut: +``` +Replace signature with: +```python +def _to_record_out( + r, + record: Optional[object] = None, + confirmed_visit_date=None, + saved_doc_statuses: dict | None = None, +) -> RecordOut: +``` + +Then, just before `doc_state_out = DocStateOut(...)` is built, find where `doc` is computed and after `compute_doc_state` is called, add the override: + +Find the block that builds `doc_state_out` (around line 268-290): +```python + doc = compute_doc_state( + ... + ) + doc_state_out = DocStateOut( + swo=doc.swo, + visit=doc.visit, + pecos=doc.pecos, + pa=doc.pa, + diagnosis=doc.diagnosis, + ) +``` +Replace the `doc_state_out` build with: +```python + doc = compute_doc_state( + ... + ) + # Apply any staff-saved overrides from Supabase doc_status table + _saved = saved_doc_statuses or {} + _status_labels = { + "pending": "Pending", "requested": "Requested", "on_file": "On File", + "not_required": "Not Required", "approved": "Approved", "denied": "Denied", + } + swo_display = _status_labels.get(_saved.get("swo", ""), doc.swo) + pa_display = _status_labels.get(_saved.get("pa", ""), doc.pa) + doc_state_out = DocStateOut( + swo=swo_display, + visit=doc.visit, + pecos=doc.pecos, + pa=pa_display, + diagnosis=doc.diagnosis, + ) +``` + +- [ ] **Step 6: Add `PUT /api/doc-status` endpoint** + +Add after the `/api/confirm-visit` endpoint: + +```python +@app.put("/api/doc-status") +async def update_doc_status( + body: DocStatusRequest, + claims: dict = Depends(require_auth), +): + """ + Store a staff-updated SWO or PA status for a patient. + Persists across future CSV imports for this org/patient. + """ + if body.doc_type not in VALID_DOC_STATUSES: + raise HTTPException(status_code=400, detail=f"Invalid doc_type: {body.doc_type}. Must be 'swo' or 'pa'.") + if body.status not in VALID_DOC_STATUSES[body.doc_type]: + valid = ", ".join(sorted(VALID_DOC_STATUSES[body.doc_type])) + raise HTTPException(status_code=400, detail=f"Invalid status '{body.status}' for {body.doc_type}. Valid: {valid}.") + + clerk_org_id = claims.get("o", {}).get("id") if isinstance(claims.get("o"), dict) else None + org_id = get_or_create_org(clerk_org_id=clerk_org_id) + if not org_id: + raise HTTPException(status_code=503, detail="Organization not found.") + + patient_hash = hashlib.sha256(body.patient_id.encode()).hexdigest() + success = upsert_doc_status(org_id, patient_hash, body.doc_type, body.status) + if not success: + raise HTTPException(status_code=503, detail="Failed to save doc status.") + + return {"patient_id": body.patient_id, "doc_type": body.doc_type, "status": body.status} +``` + +--- + +## Task 4: Frontend API helper + +**Files:** +- Modify: `signal-ui/src/lib/api.js` + +- [ ] **Step 1: Add `updateDocStatus` function** + +Append to `api.js` after the `confirmVisit` function: + +```javascript +/** + * Update SWO or PA status for a patient. + * @param {string} patientId + * @param {"swo"|"pa"} docType + * @param {string} status + * @param {string|null} token - Clerk JWT + */ +export async function updateDocStatus(patientId, docType, status, token = null) { + const authHeader = token + ? { "Authorization": `Bearer ${token}` } + : API_KEY ? { "X-API-Key": API_KEY } : {}; + try { + const resp = await fetch(`${BACKEND_URL}/api/doc-status`, { + method: "PUT", + headers: { ...authHeader, "Content-Type": "application/json" }, + body: JSON.stringify({ patient_id: patientId, doc_type: docType, status }), + }); + if (!resp.ok) return null; + return resp.json(); + } catch { + return null; + } +} +``` + +--- + +## Task 5: Frontend — clickable SWO and PA status chips + +**Files:** +- Modify: `signal-ui/src/components/WorklistTable.jsx` + +- [ ] **Step 1: Add import for `updateDocStatus` and `useAuth`** + +At the top of WorklistTable.jsx, find: +```javascript +import { useState, useEffect, useRef, Fragment } from "react"; +import { useTheme } from "../useTheme"; +``` +Add the auth import and api import: +```javascript +import { useState, useEffect, useRef, Fragment } from "react"; +import { useTheme } from "../useTheme"; +import { updateDocStatus } from "../lib/api"; +import { useAuth } from "@clerk/react"; +``` + +- [ ] **Step 2: Add SWO and PA cycle maps** + +After the existing `CONFIRM_VISIT_FLAGS` constant, add: + +```javascript +const SWO_CYCLE = ["pending", "requested", "on_file"]; +const PA_CYCLE = ["not_required", "requested", "approved", "denied"]; + +const DOC_STATUS_LABELS = { + pending: "Pending", + requested: "Requested", + on_file: "On File", + not_required: "Not Required", + approved: "Approved", + denied: "Denied", +}; +``` + +- [ ] **Step 3: Add `getToken` from Clerk inside WorklistTable component** + +In the `WorklistTable` function body (after the `useTheme` line), add: +```javascript + const { getToken } = useAuth(); +``` + +- [ ] **Step 4: Replace `DocItem` with an updatable version** + +Find and replace the entire `DocItem` function: + +```javascript +function DocItem({ label, value, patientId, docType, cycle, onUpdated, getToken }) { + const [saving, setSaving] = useState(false); + + const isGood = value && ( + value === "On File" || value === "Verified" || value === "Not Required" || + value.startsWith("Confirmed") || value.startsWith("Approved") || value === "N/A" + ); + const isBad = value && ( + value === "Missing" || value === "Not Verified" || value === "Expired" || + value === "Denied" || value === "Required — Not Started" || value === "Pending" + ); + const color = isGood ? "text-[#1A8040]" : isBad ? "text-[#CC2222]" : "text-[#CB6B20]"; + + // Find the internal key for the current display value + const currentKey = Object.entries(DOC_STATUS_LABELS).find(([, v]) => v === value)?.[0]; + + const handleCycle = async () => { + if (!cycle || !patientId || saving) return; + const idx = currentKey ? cycle.indexOf(currentKey) : -1; + const nextKey = cycle[(idx + 1) % cycle.length]; + const nextLabel = DOC_STATUS_LABELS[nextKey]; + setSaving(true); + const token = getToken ? await getToken().catch(() => null) : null; + const result = await updateDocStatus(patientId, docType, nextKey, token); + setSaving(false); + if (result && onUpdated) onUpdated(docType, nextLabel); + }; + + const isClickable = !!cycle && !!patientId; + + return ( +
+ {label}: + {isClickable ? ( + + ) : ( + {value || "Unknown"} + )} +
+ ); +} +``` + +- [ ] **Step 5: Update the expanded row to pass update props to DocItem** + +Find the expanded row section in the component (the Documentation Checklist section). It renders `` calls. Find: + +```javascript + +``` +and: +```javascript + +``` + +Replace all four `` calls in the expanded section with: + +```javascript + setLocalDocStates(prev => ({ + ...prev, + [r.patient_id]: { ...prev[r.patient_id], [type]: label } + }))} + getToken={getToken} +/> + + + setLocalDocStates(prev => ({ + ...prev, + [r.patient_id]: { ...prev[r.patient_id], [type]: label } + }))} + getToken={getToken} +/> + +``` + +- [ ] **Step 6: Add `localDocStates` state to the WorklistTable component** + +In the component body, after `const [toast, setToast] = useState(...)`, add: +```javascript + const [localDocStates, setLocalDocStates] = useState({}); +``` + +Reset it when records change — add to the existing `useEffect` that watches records: +```javascript + useEffect(() => { + setLocalRecords(records); + setLocalDocStates({}); + }, [records]); +``` + +--- + +## Task 6: Deploy + +- [ ] **Step 1: Deploy backend to Railway** +```bash +cd /Users/sttil-solutions/projects/signal +railway up --detach +``` + +- [ ] **Step 2: Verify health** +```bash +curl -s https://signal-api-production-91c2.up.railway.app/health +``` +Expected: `{"status":"ok",...}` + +- [ ] **Step 3: Test in browser** +1. Import any test CSV +2. Expand a patient row +3. Click "Pending" next to SWO — it should cycle to "Requested" +4. Click again — should cycle to "On File" +5. Re-import the same CSV — SWO should now show "On File" (loaded from Supabase) + +- [ ] **Step 4: Update CLAUDE.md pilot readiness checklist** +Mark "Placeholder content removed" as PASS (both empty state and status legend now done). Count gives 13/13. diff --git a/python-backend/api/main.py b/python-backend/api/main.py index 5cb5a00..10300da 100644 --- a/python-backend/api/main.py +++ b/python-backend/api/main.py @@ -28,6 +28,7 @@ from core.doc_state_machine import compute_doc_state, DocState from core.persistence import ( persist_export, persist_upload, upsert_confirmed_visit, load_confirmed_visits_for_org, get_or_create_org, + upsert_doc_status, load_doc_statuses_for_org, ) from api.normalizer import normalize_csv @@ -261,7 +262,26 @@ def _recommended_next_step_code( return "NO_ACTION_NEEDED" -def _to_record_out(r, record=None, confirmed_visit_date=None) -> RecordOut: +VALID_DOC_STATUSES = { + "swo": {"pending", "requested", "on_file"}, + "pa": {"not_required", "requested", "approved", "denied"}, +} + +DOC_STATUS_LABELS = { + "pending": "Pending", "requested": "Requested", "on_file": "On File", + "not_required": "Not Required", "approved": "Approved", "denied": "Denied", +} + + +class DocStatusRequest(BaseModel): + patient_id: str + doc_type: str + status: str + status_date: Optional[str] = None + expiry_date: Optional[str] = None + + +def _to_record_out(r, record=None, confirmed_visit_date=None, saved_doc_statuses: dict | None = None) -> RecordOut: flag_val = r.flag.value if hasattr(r.flag, "value") else str(r.flag) # Compute doc state if we have the original record @@ -279,8 +299,11 @@ def _to_record_out(r, record=None, confirmed_visit_date=None) -> RecordOut: csv_diagnosis_on_file=record.csv_diagnosis_on_file, is_transfer=bool(record.csv_transfer_from), ) + _saved = saved_doc_statuses or {} + swo_display = DOC_STATUS_LABELS.get(_saved.get("swo", {}).get("status", ""), doc.swo) + pa_display = DOC_STATUS_LABELS.get(_saved.get("pa", {}).get("status", ""), doc.pa) doc_state_out = DocStateOut( - swo=doc.swo, visit=doc.visit, pecos=doc.pecos, pa=doc.pa, diagnosis=doc.diagnosis + swo=swo_display, visit=doc.visit, pecos=doc.pecos, pa=pa_display, diagnosis=doc.diagnosis ) cascade = doc.cascade @@ -393,6 +416,7 @@ async def upload_csv( clerk_org_id = claims.get("o", {}).get("id") if isinstance(claims.get("o"), dict) else None org_id = get_or_create_org(clerk_org_id=clerk_org_id) confirmed_visits = load_confirmed_visits_for_org(org_id) if org_id else {} + doc_statuses = load_doc_statuses_for_org(org_id) if org_id else {} results = calculate_batch(records, as_of=date.today(), confirmed_visits=confirmed_visits) @@ -405,6 +429,9 @@ async def upload_csv( confirmed_visit_date=confirmed_visits.get( hashlib.sha256(r.patient_id.encode()).hexdigest() ), + saved_doc_statuses=doc_statuses.get( + hashlib.sha256(r.patient_id.encode()).hexdigest(), {} + ), ) for r in results ] @@ -611,3 +638,47 @@ async def confirm_visit( ) return _to_record_out(result, record=record, confirmed_visit_date=confirmed) + + +@app.put("/api/doc-status") +async def update_doc_status( + body: DocStatusRequest, + claims: dict = Depends(require_auth), +): + """Store a staff-updated SWO or PA status. Persists across future CSV imports.""" + if body.doc_type not in VALID_DOC_STATUSES: + raise HTTPException(status_code=400, detail=f"Invalid doc_type '{body.doc_type}'. Must be 'swo' or 'pa'.") + if body.status not in VALID_DOC_STATUSES[body.doc_type]: + valid = ", ".join(sorted(VALID_DOC_STATUSES[body.doc_type])) + raise HTTPException(status_code=400, detail=f"Invalid status '{body.status}' for {body.doc_type}. Valid: {valid}.") + + from datetime import date as date_type2 + status_date = None + expiry_date = None + if body.status_date: + try: + status_date = date_type2.fromisoformat(body.status_date) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid status_date: {body.status_date}. Use YYYY-MM-DD.") + if body.expiry_date: + try: + expiry_date = date_type2.fromisoformat(body.expiry_date) + except ValueError: + raise HTTPException(status_code=400, detail=f"Invalid expiry_date: {body.expiry_date}. Use YYYY-MM-DD.") + + clerk_org_id = claims.get("o", {}).get("id") if isinstance(claims.get("o"), dict) else None + org_id = get_or_create_org(clerk_org_id=clerk_org_id) + if not org_id: + raise HTTPException(status_code=503, detail="Organization not found.") + + patient_hash = hashlib.sha256(body.patient_id.encode()).hexdigest() + success = upsert_doc_status(org_id, patient_hash, body.doc_type, body.status, status_date, expiry_date) + if not success: + raise HTTPException(status_code=503, detail="Failed to save doc status.") + + return { + "patient_id": body.patient_id, + "doc_type": body.doc_type, + "status": body.status, + "display": DOC_STATUS_LABELS.get(body.status, body.status), + } diff --git a/python-backend/api/normalizer.py b/python-backend/api/normalizer.py index 380643f..1570da5 100644 --- a/python-backend/api/normalizer.py +++ b/python-backend/api/normalizer.py @@ -88,14 +88,25 @@ HEADER_MAP: dict[str, list[str]] = { } DEVICE_MAP: dict[str, str] = { + # Dexcom G7 variants "dexcom g7": "dexcom_g7", "dexcom_g7": "dexcom_g7", "dexcomg7": "dexcom_g7", + "dexcom g-7": "dexcom_g7", "g7": "dexcom_g7", + "g7 sensor": "dexcom_g7", + "dexcom g7 sensor": "dexcom_g7", + "dexcom g7 cgm": "dexcom_g7", + # Dexcom G6 variants "dexcom g6": "dexcom_g6", "dexcom_g6": "dexcom_g6", "dexcomg6": "dexcom_g6", + "dexcom g-6": "dexcom_g6", "g6": "dexcom_g6", + "g6 sensor": "dexcom_g6", + "dexcom g6 sensor": "dexcom_g6", + "dexcom g6 cgm": "dexcom_g6", + # FreeStyle Libre 2 variants "freestyle libre 2": "freestyle_libre_2", "freestyle_libre_2": "freestyle_libre_2", "freestylelibre2": "freestyle_libre_2", @@ -103,6 +114,8 @@ DEVICE_MAP: dict[str, str] = { "libre2": "freestyle_libre_2", "fsl2": "freestyle_libre_2", "fs libre 2": "freestyle_libre_2", + "freestyle libre 2 sensor": "freestyle_libre_2", + # FreeStyle Libre 3 variants "freestyle libre 3": "freestyle_libre_3", "freestyle_libre_3": "freestyle_libre_3", "freestylelibre3": "freestyle_libre_3", @@ -110,11 +123,32 @@ DEVICE_MAP: dict[str, str] = { "libre3": "freestyle_libre_3", "fsl3": "freestyle_libre_3", "fs libre 3": "freestyle_libre_3", + "freestyle libre 3 sensor": "freestyle_libre_3", + # FreeStyle Libre (no version — default to current) + "freestyle libre": "freestyle_libre_3", + "freestylelibre": "freestyle_libre_3", + "libre": "freestyle_libre_3", + "fsl": "freestyle_libre_3", + # Omnipod variants "omnipod 5": "omnipod_5", "omnipod_5": "omnipod_5", "omnipod5": "omnipod_5", "omnipod": "omnipod_5", "op5": "omnipod_5", + # HCPCS codes used as device descriptions in some billing exports + "e2103": "dexcom_g7", + "a4239": "dexcom_g7", + "a4253": "freestyle_libre_3", + # Generic CGM descriptions + "cgm": "dexcom_g7", + "continuous glucose monitor": "dexcom_g7", + "continuous glucose monitoring": "dexcom_g7", + "glucose monitor": "dexcom_g7", + "glucose monitoring": "dexcom_g7", + "cgm sensor": "dexcom_g7", + "cgm supply": "dexcom_g7", + "cgm supplies": "dexcom_g7", + "dme cgm": "dexcom_g7", } DATE_FORMATS = [ @@ -166,12 +200,25 @@ def _parse_date(value: str) -> Optional[date]: def _normalize_device(value: str) -> Optional[str]: + if not value or not value.strip(): + return None key = _normalize_key(value) key_compact = re.sub(r"\s+", "", key) + # Exact / alias match for alias, canonical in DEVICE_MAP.items(): alias_compact = re.sub(r"\s+", "", alias) if key == alias or key_compact == alias_compact: return canonical + # Fuzzy fallback — detect CGM family by keyword so real-world naming + # variants from billing exports don't silently drop patients. + if "dexcom" in key: + return "dexcom_g7" if "g7" in key_compact else "dexcom_g6" + if "libre" in key or "freestyle" in key: + return "freestyle_libre_3" if "3" in key else "freestyle_libre_2" + if "omnipod" in key: + return "omnipod_5" + if any(k in key for k in ("cgm", "continuous glucose", "glucose monitor", "e2103", "a4239", "a4253")): + return "dexcom_g7" return None @@ -187,7 +234,7 @@ def normalize_csv(text: str) -> tuple[list[ShipmentRecord], list[str], dict]: "required_missing": [str], } """ - reader = csv.DictReader(io.StringIO(text.strip())) + reader = csv.DictReader(io.StringIO(text.strip().lstrip(""))) if not reader.fieldnames: return [], ["No headers found in file"], {} diff --git a/python-backend/core/persistence.py b/python-backend/core/persistence.py index b7fb74b..3af094b 100644 --- a/python-backend/core/persistence.py +++ b/python-backend/core/persistence.py @@ -220,6 +220,71 @@ def load_confirmed_visits_for_org(org_id: str) -> dict: return {} +def upsert_doc_status( + org_id: str, + patient_id_hash: str, + doc_type: str, + status: str, + status_date: "date | None" = None, + expiry_date: "date | None" = None, +) -> bool: + """ + Insert or update a doc status (swo or pa) for a patient. + Returns True on success, False if Supabase unavailable. + """ + client = get_client() + if not client: + return False + try: + payload = { + "org_id": org_id, + "patient_id_hash": patient_id_hash, + "doc_type": doc_type, + "status": status, + "updated_at": "now()", + } + if status_date is not None: + payload["status_date"] = status_date.isoformat() + if expiry_date is not None: + payload["expiry_date"] = expiry_date.isoformat() + client.table("doc_status").upsert( + payload, on_conflict="org_id,patient_id_hash,doc_type" + ).execute() + return True + except Exception as e: + logger.error(f"Failed to upsert doc_status: {e}") + return False + + +def load_doc_statuses_for_org(org_id: str) -> dict: + """ + Load all SWO and PA statuses for an org. + Returns dict: patient_id_hash -> {doc_type: {status, status_date, expiry_date}} + """ + client = get_client() + if not client: + return {} + try: + result = client.table("doc_status") \ + .select("patient_id_hash,doc_type,status,status_date,expiry_date") \ + .eq("org_id", org_id) \ + .execute() + out: dict = {} + for row in (result.data or []): + h = row["patient_id_hash"] + if h not in out: + out[h] = {} + out[h][row["doc_type"]] = { + "status": row["status"], + "status_date": row.get("status_date"), + "expiry_date": row.get("expiry_date"), + } + return out + except Exception as e: + logger.error(f"Failed to load doc_statuses: {e}") + return {} + + def get_or_create_org(clerk_org_id: str | None = None) -> str | None: """Public wrapper around _get_or_create_org for use by the API.""" return _get_or_create_org(clerk_org_id=clerk_org_id) diff --git a/signal-ui/src/App.jsx b/signal-ui/src/App.jsx index af4c801..7b2a1bc 100644 --- a/signal-ui/src/App.jsx +++ b/signal-ui/src/App.jsx @@ -53,7 +53,14 @@ function AppInner() { setBatchId(data.batch_id || null); setImportLabel(`${file.name} · ${label} · via Signal API`); let msg = `Loaded ${data.total} patient${data.total !== 1 ? "s" : ""} from ${file.name}`; - if (data.skipped) msg += ` · ${data.skipped} skipped`; + if (data.skipped) { + msg += ` · ${data.skipped} row${data.skipped !== 1 ? "s" : ""} skipped`; + if (data.skipped_reasons?.length) { + const first = data.skipped_reasons[0]; + const more = data.skipped_reasons.length - 1; + msg += ` — ${first}${more > 0 ? ` (+${more} more)` : ""}`; + } + } showToast(msg); return; } @@ -101,7 +108,7 @@ function AppInner() {

Outreach Worklist

- + {importLabel} @@ -115,7 +122,10 @@ function AppInner() { {/* Content */} {records.length === 0 ? ( - csvImportRef.current?.trigger()} /> + csvImportRef.current?.trigger()} + onDropFile={(file) => csvImportRef.current?.uploadFile(file)} + /> ) : (
{/* Stats row */} diff --git a/signal-ui/src/components/Badge.jsx b/signal-ui/src/components/Badge.jsx index 7eba3a4..a08d78b 100644 --- a/signal-ui/src/components/Badge.jsx +++ b/signal-ui/src/components/Badge.jsx @@ -58,6 +58,15 @@ const FLAG_CONFIG = { weight: "font-normal", icon: "○", }, + // AMBER — in resupply window but documentation gaps block shipment + DOCS_REQUIRED: { + label: "Docs Required", + bg: "bg-[rgba(203,107,32,0.12)]", + text: "text-[#CB6B20]", + border: "border-[#CB6B20]", + weight: "font-semibold", + 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 2e1ce13..a354043 100644 --- a/signal-ui/src/components/CSVImport.jsx +++ b/signal-ui/src/components/CSVImport.jsx @@ -88,6 +88,22 @@ const CSVImport = forwardRef(function CSVImport({ onResults, getToken }, ref) { useImperativeHandle(ref, () => ({ trigger: () => inputRef.current?.click(), + uploadFile: async (file) => { + if (!file) return; + setUploading(true); + try { + const token = getToken ? await getToken().catch(() => null) : null; + const data = await uploadToBackend(file, token); + if (data && shouldShowReview(data.mapping_summary)) { + const rows = buildMappingRows(data.mapping_summary); + setReviewState({ rows, backendData: data, file }); + return; + } + onResults(data, file); + } finally { + setUploading(false); + } + }, })); const handleFile = async (e) => { @@ -170,7 +186,7 @@ const CSVImport = forwardRef(function CSVImport({ onResults, getToken }, ref) {
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. @@ -188,11 +204,6 @@ const CSVImport = forwardRef(function CSVImport({ onResults, getToken }, ref) { Detected Signal field - {showConfidence && ( - - Confidence - - )} {reviewState.rows.some((r) => r.exampleValue !== null) && ( Example value @@ -219,16 +230,9 @@ const CSVImport = forwardRef(function CSVImport({ onResults, getToken }, ref) { {FIELD_LABELS[row.canonical] ?? row.canonical} - {/* Confidence */} - {showConfidence && ( - - {confidenceLabel(row.confidence)} - - )} - {/* Example value */} {reviewState.rows.some((r) => r.exampleValue !== null) && ( - + {row.exampleValue ?? ""} )} @@ -254,7 +258,7 @@ const CSVImport = forwardRef(function CSVImport({ onResults, getToken }, ref) { {/* Unmapped columns notice */} {reviewState.backendData.mapping_summary.unmapped_columns?.length > 0 && ( -
+
Columns Signal did not use: {" "} @@ -278,7 +282,7 @@ const CSVImport = forwardRef(function CSVImport({ onResults, getToken }, ref) {
diff --git a/signal-ui/src/components/ConfirmVisitModal.jsx b/signal-ui/src/components/ConfirmVisitModal.jsx index 27e23db..43bdf53 100644 --- a/signal-ui/src/components/ConfirmVisitModal.jsx +++ b/signal-ui/src/components/ConfirmVisitModal.jsx @@ -101,7 +101,7 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSave
Confirm Qualifying Visit
-
+
Patient ID: {record.patient_id}
@@ -116,7 +116,7 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSave 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)]" /> -
+
Contact prescriber office to confirm. Do not use patient-reported date.
@@ -153,7 +153,7 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSave
Visit date: {formattedVisitDate}
-
+
Confirm this is the verified date from the prescriber office.
diff --git a/signal-ui/src/components/EmptyState.jsx b/signal-ui/src/components/EmptyState.jsx index fd23654..cac2395 100644 --- a/signal-ui/src/components/EmptyState.jsx +++ b/signal-ui/src/components/EmptyState.jsx @@ -1,40 +1,77 @@ -export default function EmptyState({ onOpenFile }) { +import { useState } from "react"; + +export default function EmptyState({ onOpenFile, onDropFile }) { + const [dragActive, setDragActive] = useState(false); + + const handleDragOver = (e) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(true); + }; + + const handleDragLeave = (e) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + }; + + const handleDrop = (e) => { + e.preventDefault(); + e.stopPropagation(); + setDragActive(false); + const file = e.dataTransfer.files?.[0]; + if (file && onDropFile) onDropFile(file); + }; + return (
+
+ ↓ +

- Where is your CSV file? + {dragActive ? "Drop to import" : "Drop your file here"}

- Import your order management export to get started. + or click below to browse

diff --git a/signal-ui/src/components/StatCard.jsx b/signal-ui/src/components/StatCard.jsx index aa8af4c..bd2e089 100644 --- a/signal-ui/src/components/StatCard.jsx +++ b/signal-ui/src/components/StatCard.jsx @@ -12,7 +12,7 @@ export default function StatCard({ : "border-[var(--border-color)] shadow-[var(--shadow-card-md)] bg-[var(--bg-card)]" }`} > -
+
{label}
+ {LEGEND_ITEMS.map((item) => ( +
+ + + {item.name} + + + {item.meaning} + +
+ ))} +
+ ); +} diff --git a/signal-ui/src/components/Toast.jsx b/signal-ui/src/components/Toast.jsx index be18d5c..dab46f7 100644 --- a/signal-ui/src/components/Toast.jsx +++ b/signal-ui/src/components/Toast.jsx @@ -8,7 +8,7 @@ export default function Toast() { const handler = (e) => { setMessage(e.detail.message); setVisible(true); - setTimeout(() => setVisible(false), 3500); + setTimeout(() => setVisible(false), 7000); }; window.addEventListener("signal-toast", handler); return () => window.removeEventListener("signal-toast", handler); diff --git a/signal-ui/src/components/WorklistTable.jsx b/signal-ui/src/components/WorklistTable.jsx index 5e6e1cd..d85077a 100644 --- a/signal-ui/src/components/WorklistTable.jsx +++ b/signal-ui/src/components/WorklistTable.jsx @@ -1,13 +1,22 @@ import { useState, useEffect, useRef, Fragment } from "react"; import { useTheme } from "../useTheme"; +import { useAuth } from "@clerk/react"; import Badge from "./Badge"; import DocStatusBar from "./DocStatusBar"; import ConfirmVisitModal from "./ConfirmVisitModal"; +import StatusLegend from "./StatusLegend"; +import { updateDocStatus } from "../lib/api"; -function daysLabel(days) { - if (days === undefined || days === null) return ; +function daysLabel(days, flag) { + if (days === undefined || days === null) return ; if (days < 0) return Expired {Math.abs(days)}d ago; + // RESUPPLY_READY: days left means window closing, not a problem — show as teal urgency not red alert + if (flag === "RESUPPLY_READY") { + if (days <= 7) return {days} days; + if (days <= 30) return {days} days; + return {days} days; + } if (days <= 7) return {days} days; if (days <= 30) @@ -19,7 +28,7 @@ function scoreClass(priority) { if (priority >= 1000) return "text-[#B00000]"; if (priority >= 500) return "text-[var(--accent-text)]"; if (priority >= 200) return "text-[var(--text-secondary)]"; - return "text-[var(--text-muted)]"; + return "text-[var(--text-warm)]"; } const FILTERS = [ @@ -30,6 +39,13 @@ const FILTERS = [ { key: "ACTIVE", label: "On Track" }, ]; +const SWO_CYCLE = ["pending", "requested", "on_file"]; +const PA_CYCLE = ["not_required", "requested", "approved", "denied"]; +const DOC_STATUS_LABELS = { + pending: "Pending", requested: "Requested", on_file: "On File", + not_required: "Not Required", approved: "Approved", denied: "Denied", +}; + const AT_RISK_FLAGS = ["SUPPLY_LAPSED", "VISIT_REQUIRED", "RENEWAL_CRITICAL"]; const ACTION_NEEDED_FLAGS = ["RENEWAL_ELEVATED", "RENEWAL_SOON", "TRANSFER_PENDING"]; @@ -38,14 +54,19 @@ const CONFIRM_VISIT_FLAGS = ["VISIT_REQUIRED", "RENEWAL_CRITICAL", "RENEWAL_ELEV export default function WorklistTable({ records, activeFilter, onFilterChange, onVisitConfirmed }) { const { dark } = useTheme(); + const { getToken } = useAuth(); const [expandedRow, setExpandedRow] = useState(() => localStorage.getItem("signal_expanded_row") || null); const [activePatientId, setActivePatientId] = useState(null); const [confirmingRecord, setConfirmingRecord] = useState(null); const [localRecords, setLocalRecords] = useState(records); const [toast, setToast] = useState({ visible: false, message: "" }); + const [localDocStates, setLocalDocStates] = useState({}); const rowRefs = useRef({}); - useEffect(() => { setLocalRecords(records); }, [records]); + useEffect(() => { + setLocalRecords(records); + setLocalDocStates({}); + }, [records]); function showToast(message) { setToast({ visible: true, message }); @@ -99,7 +120,7 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o
Outreach Worklist
-
+
{localRecords.length} patients · sorted by priority score · {todayStr}
@@ -120,6 +141,9 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o
+ {/* Status legend — color key for the four status tiers */} + {localRecords.length > 0 && } + {/* Working-on banner */} {activePatientId && (
{["Patient ID", "Device", "Payer", "Days Left", "Status", "Priority", "Action"].map(h => ( - + {h} ))} @@ -170,6 +194,11 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o const daysLeft = r.days_until_coverage_end ?? r.daysUntilEnd; const priority = r.priority_score ?? r.priority; const deviceDisplay = r.device_display || r.device_type; + // RESUPPLY_READY with open cascade items means timing is fine but docs are not. + // Show amber "Docs Required" badge instead of green "Clear to Ship". + const effectiveFlag = (r.flag === "RESUPPLY_READY" && r.cascade?.length > 0) + ? "DOCS_REQUIRED" + : r.flag; return ( @@ -201,7 +230,7 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o }} > -
+
{r.patient_id}
{i === 0 && ( @@ -220,20 +249,20 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o {r.payer} - {daysLabel(daysLeft)} + {daysLabel(daysLeft, r.flag)}
- + {r.doc_state && }
{r.reason && ( -
+
{r.reason}
)} {r.flag === "ACTIVE" && r.next_visit_due_date && ( -
+
Next visit due: {new Date(r.next_visit_due_date + "T00:00:00").toLocaleDateString("en-US", { month: "short", year: "numeric" })}
)} @@ -252,7 +281,7 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o Confirm Visit → ) : ( - + )} @@ -273,20 +302,36 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o )} {r.doc_state && (
-
+
Documentation Checklist
- + setLocalDocStates(prev => ({ + ...prev, [r.patient_id]: { ...prev[r.patient_id], [type]: label } + }))} + /> - + setLocalDocStates(prev => ({ + ...prev, [r.patient_id]: { ...prev[r.patient_id], [type]: label } + }))} + />
)} {!r.doc_state && !r.cascade?.length && ( -
No documentation details available. Upload a CSV with doc columns to see the full checklist.
+
No documentation details available. Upload a CSV with doc columns to see the full checklist.
)} @@ -296,7 +341,7 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o })} {filtered.length === 0 && ( - + No patients match this filter. @@ -305,7 +350,7 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o {/* Footer */} -
+
PHI-safe — patient names and DOBs never stored. Crosswalk: patient_id only. @@ -368,24 +413,55 @@ function ActionButton({ flag }) { if (flag === "RESUPPLY_READY") { return ; } + if (flag === "DOCS_REQUIRED") { + return ; + } return ; } -function DocItem({ label, value }) { +function DocItem({ label, value, patientId, docType, cycle, getToken, onUpdated }) { + const [saving, setSaving] = useState(false); + const isGood = value && ( value === "On File" || value === "Verified" || value === "Not Required" || value.startsWith("Confirmed") || value.startsWith("Approved") || value === "N/A" ); const isBad = value && ( value === "Missing" || value === "Not Verified" || value === "Expired" || - value === "Denied" || value === "Required — Not Started" + value === "Denied" || value === "Required — Not Started" || value === "Pending" ); const color = isGood ? "text-[#1A8040]" : isBad ? "text-[#CC2222]" : "text-[#CB6B20]"; + const currentKey = Object.entries(DOC_STATUS_LABELS).find(([, v]) => v === value)?.[0]; + + const handleCycle = async () => { + if (!cycle || !patientId || saving) return; + const idx = currentKey ? cycle.indexOf(currentKey) : -1; + const nextKey = cycle[(idx + 1) % cycle.length]; + const nextLabel = DOC_STATUS_LABELS[nextKey]; + setSaving(true); + const token = getToken ? await getToken().catch(() => null) : null; + const result = await updateDocStatus(patientId, docType, nextKey, null, null, token); + setSaving(false); + if (result && onUpdated) onUpdated(docType, nextLabel); + }; + return (
- {label}: - {value || "Unknown"} + {label}: + {cycle ? ( + + ) : ( + {value || "Unknown"} + )}
); } diff --git a/signal-ui/src/index.css b/signal-ui/src/index.css index 4c961f7..e38ed9a 100644 --- a/signal-ui/src/index.css +++ b/signal-ui/src/index.css @@ -138,6 +138,7 @@ --text-primary: var(--color-neutral-800); --text-secondary:var(--color-neutral-500); --text-muted: var(--color-neutral-400); + --text-warm: #7A6858; --border-color: var(--color-neutral-200); --border-subtle: rgba(200,216,216,0.7); --brand: var(--color-teal-600); @@ -157,6 +158,7 @@ --text-primary: #F0F4F4; --text-secondary:var(--color-teal-300); --text-muted: #5A8080; + --text-warm: #C8B49A; --border-color: var(--color-border-dark); --border-subtle: rgba(15,94,94,0.45); --brand: var(--color-teal-400); diff --git a/signal-ui/src/lib/api.js b/signal-ui/src/lib/api.js index e6c2fc5..f5efe08 100644 --- a/signal-ui/src/lib/api.js +++ b/signal-ui/src/lib/api.js @@ -127,4 +127,37 @@ export async function confirmVisit(payload, token = null) { } catch (e) { throw e; } +} + +/** + * Update SWO or PA status for a patient. + * @param {string} patientId + * @param {"swo"|"pa"} docType + * @param {string} status - locked value for doc_type + * @param {string|null} statusDate - YYYY-MM-DD, optional + * @param {string|null} expiryDate - YYYY-MM-DD, optional + * @param {string|null} token - Clerk JWT + * @returns {Promise<{patient_id, doc_type, status, display}|null>} + */ +export async function updateDocStatus(patientId, docType, status, statusDate = null, expiryDate = null, token = null) { + const authHeader = token + ? { "Authorization": `Bearer ${token}` } + : API_KEY ? { "X-API-Key": API_KEY } : {}; + try { + const resp = await fetch(`${BACKEND_URL}/api/doc-status`, { + method: "PUT", + headers: { "Content-Type": "application/json", ...authHeader }, + body: JSON.stringify({ + patient_id: patientId, + doc_type: docType, + status, + ...(statusDate && { status_date: statusDate }), + ...(expiryDate && { expiry_date: expiryDate }), + }), + }); + if (!resp.ok) return null; + return resp.json(); + } catch { + return null; + } } \ No newline at end of file