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 ? (
+
+ {saving ? "Saving…" : (value || "Unknown")}
+
+ ) : (
+ {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) {
Cancel
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
{
- 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
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
Initiate Resupply ;
}
+ if (flag === "DOCS_REQUIRED") {
+ return
Resolve Docs ;
+ }
return
View ;
}
-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 ? (
+
+ {saving ? "Saving…" : (value || "Unknown")}
+
+ ) : (
+ {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