feat: FastAPI backend + full deployment stack (Railway + Vercel)
- FastAPI backend: /health, /api/upload (CSV parse + score), /api/export (work queue CSV) - CSV normalizer: tolerates 10+ header aliases per field, 8 date formats, all 5 devices, all major payers - Python coverage_calculator wired as the authoritative scoring engine - Frontend: backend-first upload with local fallback, export CSV wired, J. Sullivan placeholder removed - Dockerfile + railway.toml for Railway deploy - vercel.json for static frontend deploy - Railway MCP installed for future sessions Backend live: https://signal-api-production-91c2.up.railway.app Frontend live: https://signal-ui-xi.vercel.app Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
d6eef34509
commit
e3afd9038c
10 changed files with 571 additions and 23 deletions
15
Dockerfile
Normal file
15
Dockerfile
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY python-backend/ ./python-backend/
|
||||||
|
|
||||||
|
ENV PORT=8000
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Run from within python-backend so 'api' and 'core' are importable as top-level packages
|
||||||
|
CMD ["sh", "-c", "cd /app/python-backend && uvicorn api.main:app --host 0.0.0.0 --port ${PORT}"]
|
||||||
0
python-backend/__init__.py
Normal file
0
python-backend/__init__.py
Normal file
0
python-backend/api/__init__.py
Normal file
0
python-backend/api/__init__.py
Normal file
187
python-backend/api/main.py
Normal file
187
python-backend/api/main.py
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
"""Signal API — FastAPI backend for CSV ingestion, scoring, and export."""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import FastAPI, File, HTTPException, UploadFile
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
# Ensure python-backend root is on path (works both locally and in Docker)
|
||||||
|
_backend_root = Path(__file__).parent.parent
|
||||||
|
if str(_backend_root) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_backend_root))
|
||||||
|
|
||||||
|
from core.coverage_calculator import ShipmentRecord, calculate_batch
|
||||||
|
from api.normalizer import normalize_csv
|
||||||
|
|
||||||
|
app = FastAPI(title="Signal API", version="1.0.0", docs_url="/docs")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
DEVICE_DISPLAY = {
|
||||||
|
"dexcom_g7": "Dexcom G7",
|
||||||
|
"dexcom_g6": "Dexcom G6",
|
||||||
|
"freestyle_libre_2": "FreeStyle Libre 2",
|
||||||
|
"freestyle_libre_3": "FreeStyle Libre 3",
|
||||||
|
"omnipod_5": "Omnipod 5",
|
||||||
|
}
|
||||||
|
|
||||||
|
FLAG_LABELS = {
|
||||||
|
"OUT_OF_COVERAGE": "Supply Lapsed",
|
||||||
|
"VISIT_DUE": "Renewal Due",
|
||||||
|
"REFILL_WINDOW": "Resupply Ready",
|
||||||
|
"OK": "Active",
|
||||||
|
}
|
||||||
|
|
||||||
|
FLAG_ACTIONS = {
|
||||||
|
"OUT_OF_COVERAGE": "Contact Prescriber",
|
||||||
|
"VISIT_DUE": "Request Renewal",
|
||||||
|
"REFILL_WINDOW": "Initiate Resupply",
|
||||||
|
"OK": "No action needed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RecordOut(BaseModel):
|
||||||
|
patient_id: str
|
||||||
|
device_type: str
|
||||||
|
device_display: str
|
||||||
|
payer: str
|
||||||
|
component: str
|
||||||
|
days_until_coverage_end: int
|
||||||
|
days_until_visit_due: Optional[int] = None
|
||||||
|
flag: str
|
||||||
|
priority_score: int
|
||||||
|
coverage_end_date: str
|
||||||
|
next_visit_due_date: Optional[str] = None
|
||||||
|
action: str
|
||||||
|
status_label: str
|
||||||
|
|
||||||
|
|
||||||
|
class UploadResponse(BaseModel):
|
||||||
|
records: list[RecordOut]
|
||||||
|
total: int
|
||||||
|
skipped: int
|
||||||
|
skipped_reasons: list[str]
|
||||||
|
stats: dict
|
||||||
|
|
||||||
|
|
||||||
|
def _to_record_out(r) -> RecordOut:
|
||||||
|
flag_val = r.flag.value if hasattr(r.flag, "value") else str(r.flag)
|
||||||
|
return RecordOut(
|
||||||
|
patient_id=r.patient_id,
|
||||||
|
device_type=r.device_type,
|
||||||
|
device_display=DEVICE_DISPLAY.get(r.device_type, r.device_type),
|
||||||
|
payer=r.payer,
|
||||||
|
component=r.component,
|
||||||
|
days_until_coverage_end=r.days_until_coverage_end,
|
||||||
|
days_until_visit_due=r.days_until_visit_due,
|
||||||
|
flag=flag_val,
|
||||||
|
priority_score=r.priority_score,
|
||||||
|
coverage_end_date=r.coverage_end_date.isoformat(),
|
||||||
|
next_visit_due_date=r.next_visit_due_date.isoformat() if r.next_visit_due_date else None,
|
||||||
|
action=FLAG_ACTIONS.get(flag_val, "Review"),
|
||||||
|
status_label=FLAG_LABELS.get(flag_val, flag_val),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_stats(records: list[RecordOut]) -> dict:
|
||||||
|
flags = [r.flag for r in records]
|
||||||
|
return {
|
||||||
|
"total": len(records),
|
||||||
|
"supply_lapsed": flags.count("OUT_OF_COVERAGE"),
|
||||||
|
"renewal_due": flags.count("VISIT_DUE"),
|
||||||
|
"resupply_ready": flags.count("REFILL_WINDOW"),
|
||||||
|
"active": flags.count("OK"),
|
||||||
|
"prescriber_action": flags.count("OUT_OF_COVERAGE") + flags.count("VISIT_DUE"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok", "service": "signal-api", "version": "1.0.0"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/upload", response_model=UploadResponse)
|
||||||
|
async def upload_csv(file: UploadFile = File(...)):
|
||||||
|
if not (file.filename or "").endswith(".csv"):
|
||||||
|
raise HTTPException(status_code=400, detail="File must be a .csv")
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
try:
|
||||||
|
text = content.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
text = content.decode("latin-1")
|
||||||
|
|
||||||
|
records, skipped_reasons = normalize_csv(text)
|
||||||
|
|
||||||
|
if not records:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={
|
||||||
|
"message": "No processable rows found in the uploaded file.",
|
||||||
|
"skipped": skipped_reasons[:10],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
results = calculate_batch(records, as_of=date.today())
|
||||||
|
out = [_to_record_out(r) for r in results]
|
||||||
|
|
||||||
|
return UploadResponse(
|
||||||
|
records=out,
|
||||||
|
total=len(out),
|
||||||
|
skipped=len(skipped_reasons),
|
||||||
|
skipped_reasons=skipped_reasons[:20],
|
||||||
|
stats=_compute_stats(out),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/export")
|
||||||
|
async def export_work_queue(records: list[RecordOut]):
|
||||||
|
"""Generate a downloadable work-queue CSV from a list of scored records."""
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output)
|
||||||
|
writer.writerow([
|
||||||
|
"Patient ID",
|
||||||
|
"Device",
|
||||||
|
"Payer",
|
||||||
|
"Status",
|
||||||
|
"Priority Score",
|
||||||
|
"Days Until Coverage End",
|
||||||
|
"Next Visit Due",
|
||||||
|
"Recommended Action",
|
||||||
|
"Coverage End Date",
|
||||||
|
])
|
||||||
|
for r in records:
|
||||||
|
writer.writerow([
|
||||||
|
r.patient_id,
|
||||||
|
r.device_display,
|
||||||
|
r.payer,
|
||||||
|
r.status_label,
|
||||||
|
r.priority_score,
|
||||||
|
r.days_until_coverage_end,
|
||||||
|
r.next_visit_due_date or "",
|
||||||
|
r.action,
|
||||||
|
r.coverage_end_date,
|
||||||
|
])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
today = date.today().isoformat()
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(output.getvalue().encode("utf-8")),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename=signal-work-queue-{today}.csv"
|
||||||
|
},
|
||||||
|
)
|
||||||
221
python-backend/api/normalizer.py
Normal file
221
python-backend/api/normalizer.py
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
"""
|
||||||
|
CSV header normalization for Signal.
|
||||||
|
|
||||||
|
Maps messy supplier CSV exports to canonical ShipmentRecord fields.
|
||||||
|
Tolerates header drift, alternative column names, and common date formats.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
from core.coverage_calculator import ShipmentRecord
|
||||||
|
|
||||||
|
HEADER_MAP: dict[str, list[str]] = {
|
||||||
|
"patient_id": [
|
||||||
|
"patient_id", "patientid", "patient id", "pt_id", "pt id",
|
||||||
|
"mrn", "account_number", "account number", "account_no",
|
||||||
|
"patient_account", "acct_no", "id", "patient",
|
||||||
|
],
|
||||||
|
"device_type": [
|
||||||
|
"device_type", "device type", "device", "devicetype",
|
||||||
|
"product_type", "product type", "product", "item",
|
||||||
|
"item_description", "item description", "hcpcs_description",
|
||||||
|
"description", "product_name",
|
||||||
|
],
|
||||||
|
"shipment_date": [
|
||||||
|
"shipment_date", "shipment date", "ship_date", "ship date",
|
||||||
|
"dispense_date", "dispense date", "service_date", "service date",
|
||||||
|
"order_date", "order date", "date_of_service", "dos",
|
||||||
|
"fill_date", "fill date", "last_ship_date", "last ship date",
|
||||||
|
],
|
||||||
|
"quantity": [
|
||||||
|
"quantity", "qty", "units", "count", "qty_dispensed",
|
||||||
|
"units_dispensed", "quantity_dispensed", "qty_shipped",
|
||||||
|
],
|
||||||
|
"payer": [
|
||||||
|
"payer", "insurance", "insurance_name", "insurance name",
|
||||||
|
"plan", "plan_name", "plan name", "payer_name", "payer name",
|
||||||
|
"primary_payer", "primary payer", "ins_name", "carrier",
|
||||||
|
],
|
||||||
|
"component": [
|
||||||
|
"component", "item_type", "component_type", "type", "supply_type",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
DEVICE_MAP: dict[str, str] = {
|
||||||
|
"dexcom g7": "dexcom_g7",
|
||||||
|
"dexcom_g7": "dexcom_g7",
|
||||||
|
"dexcomg7": "dexcom_g7",
|
||||||
|
"g7": "dexcom_g7",
|
||||||
|
"dexcom g6": "dexcom_g6",
|
||||||
|
"dexcom_g6": "dexcom_g6",
|
||||||
|
"dexcomg6": "dexcom_g6",
|
||||||
|
"g6": "dexcom_g6",
|
||||||
|
"freestyle libre 2": "freestyle_libre_2",
|
||||||
|
"freestyle_libre_2": "freestyle_libre_2",
|
||||||
|
"freestylelibre2": "freestyle_libre_2",
|
||||||
|
"libre 2": "freestyle_libre_2",
|
||||||
|
"libre2": "freestyle_libre_2",
|
||||||
|
"fsl2": "freestyle_libre_2",
|
||||||
|
"fs libre 2": "freestyle_libre_2",
|
||||||
|
"freestyle libre 3": "freestyle_libre_3",
|
||||||
|
"freestyle_libre_3": "freestyle_libre_3",
|
||||||
|
"freestylelibre3": "freestyle_libre_3",
|
||||||
|
"libre 3": "freestyle_libre_3",
|
||||||
|
"libre3": "freestyle_libre_3",
|
||||||
|
"fsl3": "freestyle_libre_3",
|
||||||
|
"fs libre 3": "freestyle_libre_3",
|
||||||
|
"omnipod 5": "omnipod_5",
|
||||||
|
"omnipod_5": "omnipod_5",
|
||||||
|
"omnipod5": "omnipod_5",
|
||||||
|
"omnipod": "omnipod_5",
|
||||||
|
"op5": "omnipod_5",
|
||||||
|
}
|
||||||
|
|
||||||
|
PAYER_MAP: dict[str, str] = {
|
||||||
|
"medicare part b": "medicare",
|
||||||
|
"medicare part a": "medicare",
|
||||||
|
"medicare advantage": "commercial",
|
||||||
|
"medicare": "medicare",
|
||||||
|
"cms": "medicare",
|
||||||
|
"medicaid": "medicaid",
|
||||||
|
"mcd": "medicaid",
|
||||||
|
"molina": "medicaid",
|
||||||
|
"centene": "medicaid",
|
||||||
|
"wellcare": "medicaid",
|
||||||
|
"bcbs": "commercial",
|
||||||
|
"blue cross": "commercial",
|
||||||
|
"blue shield": "commercial",
|
||||||
|
"aetna": "commercial",
|
||||||
|
"cigna": "commercial",
|
||||||
|
"unitedhealthcare": "commercial",
|
||||||
|
"united health": "commercial",
|
||||||
|
"uhc": "commercial",
|
||||||
|
"humana": "commercial",
|
||||||
|
"anthem": "commercial",
|
||||||
|
"united": "commercial",
|
||||||
|
}
|
||||||
|
|
||||||
|
DATE_FORMATS = [
|
||||||
|
"%Y-%m-%d",
|
||||||
|
"%m/%d/%Y",
|
||||||
|
"%m-%d-%Y",
|
||||||
|
"%d/%m/%Y",
|
||||||
|
"%m/%d/%y",
|
||||||
|
"%Y%m%d",
|
||||||
|
"%d-%b-%Y",
|
||||||
|
"%b %d, %Y",
|
||||||
|
"%B %d, %Y",
|
||||||
|
"%m/%d/%Y %H:%M:%S",
|
||||||
|
"%Y-%m-%dT%H:%M:%S",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_key(s: str) -> str:
|
||||||
|
return s.strip().lower().replace("-", " ").replace("_", " ")
|
||||||
|
|
||||||
|
|
||||||
|
def _map_header(raw: str) -> Optional[str]:
|
||||||
|
key = _normalize_key(raw)
|
||||||
|
for canonical, aliases in HEADER_MAP.items():
|
||||||
|
if key in [_normalize_key(a) for a in aliases]:
|
||||||
|
return canonical
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: str) -> Optional[date]:
|
||||||
|
value = value.strip()
|
||||||
|
for fmt in DATE_FORMATS:
|
||||||
|
try:
|
||||||
|
return datetime.strptime(value, fmt).date()
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_device(value: str) -> Optional[str]:
|
||||||
|
key = _normalize_key(value)
|
||||||
|
key_compact = re.sub(r"\s+", "", key)
|
||||||
|
for alias, canonical in DEVICE_MAP.items():
|
||||||
|
alias_compact = re.sub(r"\s+", "", alias)
|
||||||
|
if key == alias or key_compact == alias_compact:
|
||||||
|
return canonical
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_payer(value: str) -> str:
|
||||||
|
key = _normalize_key(value)
|
||||||
|
# Longest-match first (payer_map keys are already ordered longest first for medicare)
|
||||||
|
for alias, canonical in PAYER_MAP.items():
|
||||||
|
if alias in key:
|
||||||
|
return canonical
|
||||||
|
return "commercial"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_csv(text: str) -> tuple[list[ShipmentRecord], list[str]]:
|
||||||
|
"""
|
||||||
|
Parse raw CSV text and return (records, skipped_reasons).
|
||||||
|
Tolerates header drift and normalizes device/payer/date values.
|
||||||
|
"""
|
||||||
|
reader = csv.DictReader(io.StringIO(text.strip()))
|
||||||
|
if not reader.fieldnames:
|
||||||
|
return [], ["No headers found in file"]
|
||||||
|
|
||||||
|
column_map: dict[str, str] = {}
|
||||||
|
for raw_header in reader.fieldnames:
|
||||||
|
canonical = _map_header(raw_header)
|
||||||
|
if canonical:
|
||||||
|
column_map[raw_header] = canonical
|
||||||
|
|
||||||
|
records: list[ShipmentRecord] = []
|
||||||
|
skipped: list[str] = []
|
||||||
|
|
||||||
|
for i, row in enumerate(reader, start=2):
|
||||||
|
mapped: dict[str, str] = {}
|
||||||
|
for raw_h, canonical in column_map.items():
|
||||||
|
mapped[canonical] = (row.get(raw_h) or "").strip()
|
||||||
|
|
||||||
|
patient_id = mapped.get("patient_id", "").strip()
|
||||||
|
if not patient_id:
|
||||||
|
skipped.append(f"Row {i}: missing patient_id")
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_device = mapped.get("device_type", "")
|
||||||
|
device_type = _normalize_device(raw_device)
|
||||||
|
if not device_type:
|
||||||
|
skipped.append(f"Row {i} ({patient_id}): unrecognized device '{raw_device}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_date = mapped.get("shipment_date", "")
|
||||||
|
shipment_date = _parse_date(raw_date)
|
||||||
|
if not shipment_date:
|
||||||
|
skipped.append(f"Row {i} ({patient_id}): unparseable date '{raw_date}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw_qty = mapped.get("quantity", "1")
|
||||||
|
try:
|
||||||
|
quantity = max(1, int(float(raw_qty)))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
quantity = 1
|
||||||
|
|
||||||
|
payer = _normalize_payer(mapped.get("payer", ""))
|
||||||
|
component = (mapped.get("component", "sensor") or "sensor").lower().strip()
|
||||||
|
if component not in ("sensor", "transmitter", "pod"):
|
||||||
|
component = "sensor"
|
||||||
|
|
||||||
|
records.append(ShipmentRecord(
|
||||||
|
patient_id=patient_id,
|
||||||
|
device_type=device_type,
|
||||||
|
shipment_date=shipment_date,
|
||||||
|
quantity=quantity,
|
||||||
|
payer=payer,
|
||||||
|
component=component,
|
||||||
|
))
|
||||||
|
|
||||||
|
return records, skipped
|
||||||
0
python-backend/core/__init__.py
Normal file
0
python-backend/core/__init__.py
Normal file
9
railway.toml
Normal file
9
railway.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
[build]
|
||||||
|
builder = "DOCKERFILE"
|
||||||
|
dockerfilePath = "Dockerfile"
|
||||||
|
|
||||||
|
[deploy]
|
||||||
|
healthcheckPath = "/health"
|
||||||
|
healthcheckTimeout = 30
|
||||||
|
restartPolicyType = "ON_FAILURE"
|
||||||
|
restartPolicyMaxRetries = 3
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
fastapi>=0.111.0
|
||||||
|
uvicorn[standard]>=0.29.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
pydantic>=2.0.0
|
||||||
|
|
@ -576,10 +576,10 @@
|
||||||
|
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div class="user-row">
|
<div class="user-row">
|
||||||
<div class="user-avatar">JS</div>
|
<div class="user-avatar">DS</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="user-name">J. Sullivan</div>
|
<div class="user-name">Demo Supplier</div>
|
||||||
<div class="user-role">Billing Manager</div>
|
<div class="user-role">Billing Staff</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -597,7 +597,7 @@
|
||||||
<button class="mode-toggle" onclick="document.body.classList.toggle('light')">
|
<button class="mode-toggle" onclick="document.body.classList.toggle('light')">
|
||||||
◐ Toggle Light/Dark
|
◐ Toggle Light/Dark
|
||||||
</button>
|
</button>
|
||||||
<button class="export-btn">↓ Export CSV</button>
|
<button class="export-btn" onclick="exportWorkQueue()">↓ Export CSV</button>
|
||||||
<a href="mailto:kisa@sttilsolutions.com" class="export-btn" style="text-decoration:none; background:#D97B35;">Request Access →</a>
|
<a href="mailto:kisa@sttilsolutions.com" class="export-btn" style="text-decoration:none; background:#D97B35;">Request Access →</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -759,6 +759,35 @@
|
||||||
<div class="import-toast" id="import-toast"></div>
|
<div class="import-toast" id="import-toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
// ── Global state ──
|
||||||
|
let currentResults = [];
|
||||||
|
const BACKEND_URL = 'https://signal-api-production-91c2.up.railway.app';
|
||||||
|
|
||||||
|
// ── Backend upload (with local fallback) ──
|
||||||
|
async function uploadToBackend(file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
const resp = await fetch(`${BACKEND_URL}/api/upload`, { method: 'POST', body: formData });
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail?.message || `API error ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiRecordToLocal(r) {
|
||||||
|
return {
|
||||||
|
patient_id: r.patient_id,
|
||||||
|
device_type: r.device_type,
|
||||||
|
payer: r.payer,
|
||||||
|
component: r.component,
|
||||||
|
flag: r.flag,
|
||||||
|
daysUntilEnd: r.days_until_coverage_end,
|
||||||
|
daysUntilVisit: r.days_until_visit_due,
|
||||||
|
priority: r.priority_score,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── Coverage calculation rules (mirrored from payer_rules.json) ──
|
// ── Coverage calculation rules (mirrored from payer_rules.json) ──
|
||||||
const DEVICE_RULES = {
|
const DEVICE_RULES = {
|
||||||
dexcom_g7: { sensor: 10 },
|
dexcom_g7: { sensor: 10 },
|
||||||
|
|
@ -964,41 +993,109 @@ function showToast(msg) {
|
||||||
setTimeout(() => t.classList.remove('show'), 3500);
|
setTimeout(() => t.classList.remove('show'), 3500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main import handler ──
|
// ── Local CSV processing (fallback) ──
|
||||||
function handleCSVImport(event) {
|
function processLocally(file) {
|
||||||
const file = event.target.files[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = function(e) {
|
reader.onload = function(e) {
|
||||||
const rows = parseCSV(e.target.result);
|
const rows = parseCSV(e.target.result);
|
||||||
const results = [];
|
const results = [], skipped = [];
|
||||||
const skipped = [];
|
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const result = calculateCoverage(row);
|
const result = calculateCoverage(row);
|
||||||
if (result) {
|
if (result) { results.push(result); } else { skipped.push(row.patient_id || '?'); }
|
||||||
results.push(result);
|
|
||||||
} else {
|
|
||||||
skipped.push(row.patient_id || '?');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
results.sort((a, b) => b.priority - a.priority);
|
results.sort((a, b) => b.priority - a.priority);
|
||||||
|
currentResults = results;
|
||||||
renderRows(results);
|
renderRows(results);
|
||||||
updateStats(results);
|
updateStats(results);
|
||||||
|
|
||||||
const label = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
const label = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
document.getElementById('import-label').textContent = `Import: ${file.name} · ${label}`;
|
document.getElementById('import-label').textContent = `Import: ${file.name} · ${label}`;
|
||||||
|
|
||||||
let msg = `Loaded ${results.length} patient${results.length !== 1 ? 's' : ''} from ${file.name}`;
|
let msg = `Loaded ${results.length} patient${results.length !== 1 ? 's' : ''} from ${file.name}`;
|
||||||
if (skipped.length) msg += ` · ${skipped.length} skipped (unknown device)`;
|
if (skipped.length) msg += ` · ${skipped.length} skipped`;
|
||||||
showToast(msg);
|
showToast(msg);
|
||||||
};
|
};
|
||||||
|
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main import handler — tries backend, falls back to local ──
|
||||||
|
function handleCSVImport(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
|
|
||||||
|
uploadToBackend(file)
|
||||||
|
.then(data => {
|
||||||
|
const results = data.records.map(apiRecordToLocal);
|
||||||
|
currentResults = results;
|
||||||
|
renderRows(results);
|
||||||
|
updateStats(results);
|
||||||
|
const label = new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
document.getElementById('import-label').textContent = `${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`;
|
||||||
|
showToast(msg);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Backend unreachable — process in browser
|
||||||
|
processLocally(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export work queue to CSV ──
|
||||||
|
function flagLabel(flag) {
|
||||||
|
const labels = {
|
||||||
|
OUT_OF_COVERAGE: 'Supply Lapsed',
|
||||||
|
VISIT_DUE: 'Renewal Due',
|
||||||
|
REFILL_WINDOW: 'Resupply Ready',
|
||||||
|
OK: 'Active'
|
||||||
|
};
|
||||||
|
return labels[flag] || flag;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionText(flag) {
|
||||||
|
const actions = {
|
||||||
|
OUT_OF_COVERAGE: 'Contact Prescriber',
|
||||||
|
VISIT_DUE: 'Request Renewal',
|
||||||
|
REFILL_WINDOW: 'Initiate Resupply',
|
||||||
|
OK: 'No action needed'
|
||||||
|
};
|
||||||
|
return actions[flag] || 'Review';
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportWorkQueue() {
|
||||||
|
if (currentResults.length === 0) {
|
||||||
|
showToast('No data to export — import a CSV first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
'Patient ID', 'Device', 'Payer', 'Status',
|
||||||
|
'Priority Score', 'Days Until Coverage End', 'Recommended Action'
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = currentResults.map(r => [
|
||||||
|
r.patient_id,
|
||||||
|
DEVICE_DISPLAY[r.device_type] || r.device_type,
|
||||||
|
r.payer,
|
||||||
|
flagLabel(r.flag),
|
||||||
|
r.priority,
|
||||||
|
r.daysUntilEnd,
|
||||||
|
actionText(r.flag)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const csvContent = [headers, ...rows]
|
||||||
|
.map(row => row.map(v => `"${String(v).replace(/"/g, '""')}"`).join(','))
|
||||||
|
.join('\r\n');
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `signal-work-queue-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showToast(`Exported ${currentResults.length} patients to CSV`);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
15
vercel.json
Normal file
15
vercel.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"builds": [
|
||||||
|
{
|
||||||
|
"src": "signal-ui/demo/index.html",
|
||||||
|
"use": "@vercel/static"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"routes": [
|
||||||
|
{
|
||||||
|
"src": "/(.*)",
|
||||||
|
"dest": "signal-ui/demo/$1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue