- 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>
554 lines
17 KiB
Markdown
554 lines
17 KiB
Markdown
# 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.
|