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) <noreply@anthropic.com>
This commit is contained in:
Kisa 2026-06-14 05:09:06 -04:00
parent 1733d83bba
commit 1d95bb2ab2
15 changed files with 1019 additions and 72 deletions

View file

@ -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 (
<div className="flex items-start gap-[6px]">
<span className="text-[var(--text-warm)] w-[140px] shrink-0">{label}:</span>
{isClickable ? (
<button
onClick={handleCycle}
disabled={saving}
title={saving ? "Saving…" : `Click to update ${label} status`}
className={`font-medium ${color} underline-offset-2 hover:underline cursor-pointer disabled:opacity-50 bg-transparent border-none p-0 font-inherit text-left`}
style={{ fontSize: "inherit" }}
>
{saving ? "Saving…" : (value || "Unknown")}
</button>
) : (
<span className={`font-medium ${color}`}>{value || "Unknown"}</span>
)}
</div>
);
}
```
- [ ] **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 `<DocItem>` calls. Find:
```javascript
<DocItem label="SWO" value={r.doc_state?.swo} />
```
and:
```javascript
<DocItem label="Prior Authorization" value={r.doc_state?.pa} />
```
Replace all four `<DocItem>` calls in the expanded section with:
```javascript
<DocItem
label="SWO"
value={localDocStates[r.patient_id]?.swo ?? r.doc_state?.swo}
patientId={r.patient_id}
docType="swo"
cycle={SWO_CYCLE}
onUpdated={(type, label) => setLocalDocStates(prev => ({
...prev,
[r.patient_id]: { ...prev[r.patient_id], [type]: label }
}))}
getToken={getToken}
/>
<DocItem
label="Qualifying Visit"
value={r.doc_state?.visit}
/>
<DocItem
label="PECOS Enrollment"
value={r.doc_state?.pecos}
/>
<DocItem
label="Prior Authorization"
value={localDocStates[r.patient_id]?.pa ?? r.doc_state?.pa}
patientId={r.patient_id}
docType="pa"
cycle={PA_CYCLE}
onUpdated={(type, label) => setLocalDocStates(prev => ({
...prev,
[r.patient_id]: { ...prev[r.patient_id], [type]: label }
}))}
getToken={getToken}
/>
<DocItem
label="Diagnosis on File"
value={r.doc_state?.diagnosis}
/>
```
- [ ] **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.

View file

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

View file

@ -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"], {}

View file

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

View file

@ -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() {
<h1 className="font-heading font-bold text-lg text-[var(--text-heading)]">
Outreach Worklist
</h1>
<span className="font-mono text-[11px] text-[var(--text-muted)] px-2 py-[3px] bg-[var(--bg-elevated)] rounded">
<span className="font-mono text-[11px] text-[var(--text-warm)] px-2 py-[3px] bg-[var(--bg-elevated)] rounded">
{importLabel}
</span>
</div>
@ -115,7 +122,10 @@ function AppInner() {
{/* Content */}
{records.length === 0 ? (
<EmptyState onOpenFile={() => csvImportRef.current?.trigger()} />
<EmptyState
onOpenFile={() => csvImportRef.current?.trigger()}
onDropFile={(file) => csvImportRef.current?.uploadFile(file)}
/>
) : (
<div className="p-7">
{/* Stats row */}

View file

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

View file

@ -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) {
<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">
<div className="text-[12px] text-[var(--text-warm)] 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.
@ -188,11 +204,6 @@ const CSVImport = forwardRef(function CSVImport({ onResults, getToken }, ref) {
<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
@ -219,16 +230,9 @@ const CSVImport = forwardRef(function CSVImport({ onResults, getToken }, ref) {
{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)]">
<td className="py-2 pr-4 font-mono text-[var(--text-warm)]">
{row.exampleValue ?? ""}
</td>
)}
@ -254,7 +258,7 @@ const CSVImport = forwardRef(function CSVImport({ onResults, getToken }, ref) {
{/* Unmapped columns notice */}
{reviewState.backendData.mapping_summary.unmapped_columns?.length > 0 && (
<div className="mt-4 text-[11px] text-[var(--text-muted)]">
<div className="mt-4 text-[11px] text-[var(--text-warm)]">
<span className="font-semibold text-[var(--text-secondary)]">
Columns Signal did not use:
</span>{" "}
@ -278,7 +282,7 @@ const CSVImport = forwardRef(function CSVImport({ onResults, getToken }, ref) {
<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"
className="text-[12px] text-[var(--text-warm)] hover:text-[var(--text-secondary)] underline cursor-pointer"
>
Cancel
</button>

View file

@ -101,7 +101,7 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSave
<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">
<div className="text-[12px] text-[var(--text-warm)] mb-4">
Patient ID: <span className="font-mono font-semibold text-[var(--text-secondary)]">{record.patient_id}</span>
</div>
@ -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)]"
/>
<div className="text-[10.5px] text-[var(--text-muted)] mb-4">
<div className="text-[10.5px] text-[var(--text-warm)] mb-4">
Contact prescriber office to confirm. Do not use patient-reported date.
</div>
@ -153,7 +153,7 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSave
<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">
<div className="text-[11.5px] text-[var(--text-warm)] mb-5">
Confirm this is the verified date from the prescriber office.
</div>

View file

@ -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 (
<div
className="flex flex-col items-center justify-center"
style={{
minHeight: "100%",
flex: 1,
background: "#F0EAE1",
padding: "48px 24px",
}}
style={{ minHeight: "100%", flex: 1, padding: "48px 24px" }}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div
style={{
textAlign: "center",
maxWidth: "400px",
maxWidth: "420px",
border: `2px dashed ${dragActive ? "#2EA3A3" : "#C8D8D8"}`,
borderRadius: "16px",
padding: "48px 40px",
background: dragActive ? "rgba(46,163,163,0.05)" : "transparent",
transition: "border-color 0.15s, background 0.15s",
}}
>
<div
style={{
fontSize: "32px",
marginBottom: "16px",
opacity: dragActive ? 1 : 0.4,
transition: "opacity 0.15s",
}}
>
</div>
<h2
style={{
fontSize: "22px",
fontSize: "18px",
fontWeight: "700",
color: "#1A3333",
marginBottom: "12px",
marginBottom: "8px",
letterSpacing: "-0.01em",
}}
>
Where is your CSV file?
{dragActive ? "Drop to import" : "Drop your file here"}
</h2>
<p
style={{
fontSize: "15px",
fontSize: "13px",
color: "#5A7A7A",
marginBottom: "32px",
marginBottom: "28px",
lineHeight: "1.5",
}}
>
Import your order management export to get started.
or click below to browse
</p>
<button
onClick={onOpenFile}
@ -43,20 +80,16 @@ export default function EmptyState({ onOpenFile }) {
color: "#ffffff",
border: "none",
borderRadius: "8px",
padding: "12px 28px",
fontSize: "15px",
padding: "10px 28px",
fontSize: "14px",
fontWeight: "600",
cursor: "pointer",
letterSpacing: "0.01em",
}}
onMouseOver={(e) => {
e.currentTarget.style.background = "#228888";
}}
onMouseOut={(e) => {
e.currentTarget.style.background = "#2EA3A3";
}}
onMouseOver={(e) => { e.currentTarget.style.background = "#228888"; }}
onMouseOut={(e) => { e.currentTarget.style.background = "#2EA3A3"; }}
>
Open File
Import CSV
</button>
</div>
</div>

View file

@ -12,7 +12,7 @@ export default function StatCard({
: "border-[var(--border-color)] shadow-[var(--shadow-card-md)] bg-[var(--bg-card)]"
}`}
>
<div className="text-[11px] font-medium tracking-[0.06em] uppercase text-[var(--text-muted)] mb-[10px]">
<div className="text-[11px] font-medium tracking-[0.06em] uppercase text-[var(--text-warm)] mb-[10px]">
{label}
</div>
<div

View file

@ -0,0 +1,43 @@
const LEGEND_ITEMS = [
{
color: "#FF7070",
name: "At Risk",
meaning: "Supply or documentation gap. Act now.",
},
{
color: "#CB6B20",
name: "Action Needed",
meaning: "Deadline approaching. Confirm before window closes.",
},
{
color: "#2EA3A3",
name: "On Track",
meaning: "Documentation current.",
},
{
color: "#2E7D32",
name: "Clear to Ship",
meaning: "In resupply window. Ready to go.",
},
];
export default function StatusLegend() {
return (
<div className="flex items-center gap-x-5 gap-y-2 flex-wrap px-[22px] py-[10px] border-b border-[var(--border-subtle)] bg-[var(--bg-elevated)] font-body">
{LEGEND_ITEMS.map((item) => (
<div key={item.name} className="flex items-center gap-[7px] whitespace-nowrap">
<span
className="inline-block rounded-full shrink-0"
style={{ width: "8px", height: "8px", backgroundColor: item.color }}
/>
<span className="text-[11.5px] font-semibold text-[var(--text-secondary)]">
{item.name}
</span>
<span className="text-[11px] text-[var(--text-warm)]">
{item.meaning}
</span>
</div>
))}
</div>
);
}

View file

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

View file

@ -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 <span className="font-mono font-medium text-[var(--text-muted)]"></span>;
function daysLabel(days, flag) {
if (days === undefined || days === null) return <span className="font-mono font-medium text-[var(--text-warm)]"></span>;
if (days < 0)
return <span className="font-mono font-semibold text-[#FF7070]">Expired {Math.abs(days)}d ago</span>;
// 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 <span className="font-mono font-medium text-[var(--brand)]">{days} days</span>;
if (days <= 30) return <span className="font-mono font-medium text-[var(--accent-text)]">{days} days</span>;
return <span className="font-mono font-medium text-[var(--text-primary)]">{days} days</span>;
}
if (days <= 7)
return <span className="font-mono font-medium text-[#FF7070]">{days} days</span>;
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
<div className="font-heading font-bold text-[15px] text-[var(--text-heading)]">
Outreach Worklist
</div>
<div className="font-mono text-[11px] text-[var(--text-muted)] mt-[2px]">
<div className="font-mono text-[11px] text-[var(--text-warm)] mt-[2px]">
{localRecords.length} patients · sorted by priority score · {todayStr}
</div>
</div>
@ -120,6 +141,9 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o
</div>
</div>
{/* Status legend — color key for the four status tiers */}
{localRecords.length > 0 && <StatusLegend />}
{/* Working-on banner */}
{activePatientId && (
<div
@ -150,7 +174,7 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o
<thead>
<tr>
{["Patient ID", "Device", "Payer", "Days Left", "Status", "Priority", "Action"].map(h => (
<th key={h} className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-muted)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
<th key={h} className="px-[22px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-warm)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
{h}
</th>
))}
@ -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 (
<Fragment key={rowKey}>
@ -201,7 +230,7 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o
}}
>
<td className="px-[22px] py-[13px] align-middle">
<div className={`font-mono text-[12.5px] font-bold ${isStale ? "italic text-[var(--text-muted)]" : hp ? "text-[var(--accent-text)]" : "text-[var(--text-secondary)]"}`}>
<div className={`font-mono text-[12.5px] font-bold ${isStale ? "italic text-[var(--text-warm)]" : hp ? "text-[var(--accent-text)]" : "text-[var(--text-secondary)]"}`}>
{r.patient_id}
</div>
{i === 0 && (
@ -220,20 +249,20 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o
{r.payer}
</td>
<td className="px-[22px] py-[13px] align-middle">
{daysLabel(daysLeft)}
{daysLabel(daysLeft, r.flag)}
</td>
<td className="px-[22px] py-[13px] align-middle">
<div className="flex items-center gap-[8px]">
<Badge flag={r.flag} />
<Badge flag={effectiveFlag} />
{r.doc_state && <DocStatusBar docState={r.doc_state} />}
</div>
{r.reason && (
<div className="text-[10.5px] text-[var(--text-muted)] mt-[4px] max-w-[260px] leading-[1.4]">
<div className="text-[10.5px] text-[var(--text-warm)] mt-[4px] max-w-[260px] leading-[1.4]">
{r.reason}
</div>
)}
{r.flag === "ACTIVE" && r.next_visit_due_date && (
<div className="text-[10.5px] text-[var(--text-muted)] mt-[4px]">
<div className="text-[10.5px] text-[var(--text-warm)] mt-[4px]">
Next visit due: {new Date(r.next_visit_due_date + "T00:00:00").toLocaleDateString("en-US", { month: "short", year: "numeric" })}
</div>
)}
@ -252,7 +281,7 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o
Confirm Visit
</button>
) : (
<ActionButton flag={r.flag} />
<ActionButton flag={effectiveFlag} />
)}
</td>
</tr>
@ -273,20 +302,36 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o
)}
{r.doc_state && (
<div>
<div className="text-[11px] font-semibold text-[var(--text-muted)] uppercase tracking-[0.06em] mb-2">
<div className="text-[11px] font-semibold text-[var(--text-warm)] uppercase tracking-[0.06em] mb-2">
Documentation Checklist
</div>
<div className="grid grid-cols-2 gap-y-[6px] gap-x-[20px] text-[12px]">
<DocItem label="SWO" value={r.doc_state.swo} />
<DocItem
label="SWO"
value={localDocStates[r.patient_id]?.swo ?? r.doc_state.swo}
patientId={r.patient_id} docType="swo" cycle={SWO_CYCLE}
getToken={getToken}
onUpdated={(type, label) => setLocalDocStates(prev => ({
...prev, [r.patient_id]: { ...prev[r.patient_id], [type]: label }
}))}
/>
<DocItem label="Qualifying Visit" value={r.doc_state.visit} />
<DocItem label="PECOS Enrollment" value={r.doc_state.pecos} />
<DocItem label="Prior Authorization" value={r.doc_state.pa} />
<DocItem
label="Prior Authorization"
value={localDocStates[r.patient_id]?.pa ?? r.doc_state.pa}
patientId={r.patient_id} docType="pa" cycle={PA_CYCLE}
getToken={getToken}
onUpdated={(type, label) => setLocalDocStates(prev => ({
...prev, [r.patient_id]: { ...prev[r.patient_id], [type]: label }
}))}
/>
<DocItem label="Diagnosis on File" value={r.doc_state.diagnosis} />
</div>
</div>
)}
{!r.doc_state && !r.cascade?.length && (
<div className="text-[12px] text-[var(--text-muted)]">No documentation details available. Upload a CSV with doc columns to see the full checklist.</div>
<div className="text-[12px] text-[var(--text-warm)]">No documentation details available. Upload a CSV with doc columns to see the full checklist.</div>
)}
</td>
</tr>
@ -296,7 +341,7 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o
})}
{filtered.length === 0 && (
<tr>
<td colSpan={7} className="px-[22px] py-8 text-center text-[var(--text-muted)]">
<td colSpan={7} className="px-[22px] py-8 text-center text-[var(--text-warm)]">
No patients match this filter.
</td>
</tr>
@ -305,7 +350,7 @@ export default function WorklistTable({ records, activeFilter, onFilterChange, o
</table>
{/* Footer */}
<div className="flex items-center justify-between px-[22px] py-3 border-t border-[var(--border-subtle)] text-[11px] text-[var(--text-muted)] bg-[var(--bg-elevated)]">
<div className="flex items-center justify-between px-[22px] py-3 border-t border-[var(--border-subtle)] text-[11px] text-[var(--text-warm)] bg-[var(--bg-elevated)]">
<span className="flex items-center gap-[6px] text-[var(--text-secondary)]">
PHI-safe patient names and DOBs never stored. Crosswalk: patient_id only.
</span>
@ -368,24 +413,55 @@ function ActionButton({ flag }) {
if (flag === "RESUPPLY_READY") {
return <button className={base}>Initiate Resupply</button>;
}
if (flag === "DOCS_REQUIRED") {
return <button className={`${base} !border-[var(--accent)] !text-[var(--accent-text)] hover:bg-[rgba(203,107,32,0.1)]`}>Resolve Docs</button>;
}
return <button className={base}>View</button>;
}
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 (
<div className="flex items-start gap-[6px]">
<span className="text-[var(--text-muted)] w-[140px] shrink-0">{label}:</span>
<span className={`font-medium ${color}`}>{value || "Unknown"}</span>
<span className="text-[var(--text-warm)] w-[140px] shrink-0">{label}:</span>
{cycle ? (
<button
onClick={handleCycle}
disabled={saving}
title={saving ? "Saving…" : `Click to update ${label} status`}
className={`font-medium ${color} hover:underline underline-offset-2 cursor-pointer disabled:opacity-50 bg-transparent border-none p-0 text-left`}
style={{ fontSize: "inherit", fontFamily: "inherit" }}
>
{saving ? "Saving…" : (value || "Unknown")}
</button>
) : (
<span className={`font-medium ${color}`}>{value || "Unknown"}</span>
)}
</div>
);
}

View file

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

View file

@ -128,3 +128,36 @@ export async function confirmVisit(payload, token = null) {
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;
}
}