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:
parent
1733d83bba
commit
1d95bb2ab2
15 changed files with 1019 additions and 72 deletions
554
docs/superpowers/plans/2026-06-13-doc-status-updates.md
Normal file
554
docs/superpowers/plans/2026-06-13-doc-status-updates.md
Normal 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.
|
||||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"], {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
43
signal-ui/src/components/StatusLegend.jsx
Normal file
43
signal-ui/src/components/StatusLegend.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue