diff --git a/pitch/legal/STTIL-BAA-template-v1.md b/pitch/legal/STTIL-BAA-template-v1.md index 701797d..9e1fae7 100644 --- a/pitch/legal/STTIL-BAA-template-v1.md +++ b/pitch/legal/STTIL-BAA-template-v1.md @@ -1,7 +1,7 @@ # BUSINESS ASSOCIATE AGREEMENT (Template v1) > **DRAFT, for attorney review before use. Not legal advice.** -> Prepared 2026-07-07. Generalized from the a prior supplier-specific BAA into a reusable STTIL Solutions template. This is a FORWARD-LOOKING instrument. See "Status and posture" immediately below before reading the operative terms. +> Prepared 2026-07-07. Generalized from a prior supplier-specific BAA into a reusable STTIL Solutions template. This is a FORWARD-LOOKING instrument. See "Status and posture" immediately below before reading the operative terms. --- @@ -20,7 +20,7 @@ Counsel should review this template as the instrument STTIL would execute at the **Effective Date:** __________ , 20__ **Between:** -- **KJF Professional Services LLC d/b/a STTIL Solutions** ("Business Associate"), a [STATE, confirm formation state with counsel] limited liability company, Kisa Fenn, Managing Member +- **KJF Professional Services LLC d/b/a STTIL Solutions** ("Business Associate"), a Florida limited liability company, Arkisia (Kisa) Fenn, Managing Member - **[COVERED ENTITY FULL LEGAL NAME]** ("Covered Entity"), [SIGNER NAME], [SIGNER TITLE] Each a "Party" and together the "Parties." @@ -177,7 +177,7 @@ Nothing in this Agreement confers any right, remedy, or claim upon any third par ### 5.4 Governing Law -This Agreement is governed by the laws of the State of [STATE, confirm formation state with counsel], without regard to conflict of law principles. +This Agreement is governed by the laws of the State of Florida, without regard to conflict of law principles. ### 5.5 Entire Agreement @@ -189,7 +189,7 @@ This Agreement, together with the LOI and the mutual NDA between the Parties, co **KJF Professional Services LLC d/b/a STTIL Solutions (Business Associate)** By: ___________________________ -Name: Kisa Fenn +Name: Arkisia (Kisa) Fenn Title: Managing Member Date: _________________________ Email: [KISA EMAIL] @@ -213,9 +213,9 @@ Each load-bearing factual claim is labeled GROUNDED (cited source) or GRAFTED (a 3. **Accounting of disclosures scope (GRAFTED as applied).** Confirm the scope of Section 2.8 as applied to a software service that processes de-identified or minimum-necessary data only. -4. **State-specific requirements (GRAFTED).** Confirm which state's health-data statutes (beyond HIPAA) apply once formation state and Covered Entity location are known. A prior supplier-specific version governed under Pennsylvania; this template uses a placeholder. Confirm no additional state-law obligations attach. +4. **State-specific requirements (GRAFTED).** Formation state is Florida (founder-confirmed 2026-07-07). Confirm which state's health-data statutes (beyond HIPAA) apply given Florida formation and each Covered Entity's location. A prior supplier-specific version governed under Pennsylvania; this template now uses Florida. Confirm no additional state-law obligations attach. -5. **Governing law and entity alignment (GROUNDED that documents disagree).** The v1 LOI and NDA said Delaware; the a prior supplier BAA said Pennsylvania; an executed letter of intent states Florida. Entity name is now "KJF Professional Services LLC d/b/a STTIL Solutions." Confirm the correct legal name and formation state and align Section 5.4 with the LOI and NDA. +5. **Governing law and entity alignment (GROUNDED, founder-confirmed).** Entity is "KJF Professional Services LLC d/b/a STTIL Solutions", a Florida limited liability company (founder-confirmed 2026-07-07; older drafts said Delaware and Pennsylvania). Confirm Section 5.4's Florida governing law aligns with the LOI and NDA as drafted. 6. **Forward-looking status (GROUNDED).** Real PHI is blocked/mock today (Signal current-state.md, 2026-07-07). Confirm this template is the correct instrument to execute at the point real PHI enters the system, and advise whether any interim data-handling addendum is needed for a pilot that stays inside the de-identified minimum-necessary field set before that point. diff --git a/pitch/legal/STTIL-attorney-review-cover-memo-2026-07-07.md b/pitch/legal/STTIL-attorney-review-cover-memo-2026-07-07.md index e00f91e..17cc4da 100644 --- a/pitch/legal/STTIL-attorney-review-cover-memo-2026-07-07.md +++ b/pitch/legal/STTIL-attorney-review-cover-memo-2026-07-07.md @@ -1,14 +1,14 @@ > **DRAFT, for attorney review before use. Not legal advice.** > Cover memo prepared 2026-07-07. Bundles five items for a single review pass. -**To:** [ATTORNEY NAME, CONFIRM RECIPIENT before sending. Session records dated 2026-06-05 name Clyde Mathes as attorney of record; the Signal compliance checklist rows name Bittinger Law / Nixon Law Group. These conflict. Reconcile before this memo goes out.] +**To:** Clyde Mathes **From:** Kisa Fenn, Founder and CEO, KJF Professional Services LLC d/b/a STTIL Solutions **Date:** [DATE] **Re:** Single-pass review of three template agreements plus two focused legal questions --- -Hello [ATTORNEY NAME], +Hello Clyde, I would like to engage you for one bundled review pass covering five items. I have grouped them deliberately so you can scope the whole thing at once rather than in pieces. An associate handling the bulk of this is completely fine with me. What I need first is a turnaround estimate and a fee estimate so I can plan around it. @@ -26,9 +26,9 @@ I have written my own focused questions under each item. Please treat these as a ### Overarching question (applies to all three templates) -**Q1. Confirm our legal name, our state of formation, and align governing law across every document.** Our entity is KJF Professional Services LLC d/b/a STTIL Solutions. Our documents currently disagree on the state: two older templates say Delaware, an executed letter of intent says Florida, and an older business associate agreement says Pennsylvania. I have replaced the state with a placeholder everywhere so nothing is asserted incorrectly. Please tell me the correct legal name and formation state, and set governing law and dispute-resolution venue consistently across the LOI, NDA, and BAA. +**Q1. Confirm Florida governing law across every document.** Our entity is KJF Professional Services LLC d/b/a STTIL Solutions, a Florida limited liability company. I have confirmed the name and formation state; older drafts disagreed (Delaware, Pennsylvania), so all three templates now state Florida consistently. Please verify the registration and confirm Florida is the right governing-law and venue choice for us in each document. -**Q2. Standardize my signing title.** The LOI and NDA sign as "Founder and CEO" while the BAA signs as "Managing Member." Tell me which title is correct for an LLC signer and I will use it across all three. +**Q2. Standardize my signing title and name.** The LOI and NDA sign as "Founder and CEO" while the BAA signs as "Managing Member." Tell me which title is correct for an LLC signer and I will use it across all three. Also confirm the name format: my legal name is Arkisia Fenn (I go by Kisa), and I signed our executed letter of intent as Arkisia Fenn. --- @@ -37,7 +37,7 @@ I have written my own focused questions under each item. Please treat these as a 1. The BAA cross-reference in Section 5.3 turns on whether we are a Business Associate at all. That is the BAA question below; please keep the two consistent. 2. Section 8.3 (we retain all rights to any modification or enhancement made during a pilot, regardless of who requested it) and Section 8.4 (a narrow carve-out for a partner's pre-documented pre-existing concepts). Confirm both are enforceable and that 8.4 does not erode 8.3. 3. Section 12.3, a new mutual exclusion of consequential damages for a no-cost pilot. Confirm it is adequate and enforceable. -4. Section 14, a dispute-resolution path (30-day good-faith discussion, then arbitration, with the American Arbitration Association (AAA) as fallback, and a court carve-out for injunctive relief). We added this after an out-of-state counterparty objected to exclusive Delaware court jurisdiction. Tell me whether to keep arbitration here and whether to match it in the NDA, which currently uses courts. +4. Section 14, our dispute-resolution path (30-day good-faith discussion, then arbitration, with the American Arbitration Association (AAA) as fallback, and a court carve-out for injunctive relief). I have set arbitration as our default posture, and the NDA now mirrors it in Article 10.2; the structure follows an agreement we have already signed once. Confirm both clauses as drafted. 5. Sections 6.1 and 13.3, confirming that a successful pilot does not obligate us to offer a commercial agreement. Confirm this closes any promissory-estoppel exposure. ### 2. Mutual NDA template (v3) @@ -68,7 +68,7 @@ The memo is at `signal/docs/compliance/fda-cds-exemption-memo-DRAFT.md`. I am no **Confidentiality note:** the partner named here is bound to us by a confidentiality clause that bars public mention of the relationship. I am sharing the name with you only because you are our counsel and I need the facts to get sound advice. Please keep it inside privilege. -We have an executed 180-day discovery letter of intent with Pathogens Artificial Intelligence Inc., which does business as LabABLE, a diagnostic-laboratory software platform run by Dr. Mehul Patel's team. During discovery we discussed a reciprocal integration concept, informally called "kit in kit": LabABLE could be made available as a component inside our Signal platform, and Signal could be made available as a component inside the LabABLE platform. +We have an executed 180-day discovery letter of intent with Pathogens Artificial Intelligence Inc., a Delaware corporation doing business as LabABLE, a diagnostic-laboratory software platform whose team includes Dr. Mehul Patel (the LOI was signed by co-founder Daniel Rojas). During discovery we discussed a reciprocal integration concept, informally called "kit in kit": LabABLE could be made available as a component inside our Signal platform, and Signal could be made available as a component inside the LabABLE platform. Both of our customer bases serve providers who bill federal healthcare programs (our suppliers bill Medicare for DMEPOS; laboratories bill Medicare Part B). My question: @@ -87,9 +87,9 @@ This must be resolved before we execute any further agreement with them, so plea Thank you. I am happy to get on a call to walk through any of it. -Kisa Fenn +Kisa (Arkisia) Fenn KJF Professional Services LLC d/b/a STTIL Solutions -[KISA EMAIL] / [PHONE] +kisasttil@gmail.com / [PHONE] --- @@ -97,8 +97,8 @@ KJF Professional Services LLC d/b/a STTIL Solutions Load-bearing facts in this memo, labeled GROUNDED (cited source) or GRAFTED (assumption to confirm): -- Recipient is unresolved. GROUNDED that 2026-06-05 records name Clyde Mathes and the Signal compliance checklist names Bittinger/Nixon; the two conflict (Signal CLAUDE.md; FDA memo routing note). Confirm before sending. GRAFTED which is correct. -- labABLE LOI executed 2026-06-12, 180-day discovery term. GROUNDED (KG Signal; executed PDF `signal/pitch/legal/labABLE-LOI-v3-EXECUTED-2026-06-12.pdf`). Verify signer names and clause numbers against the executed PDF before any citation; the "kit-in-kit" description here is from the 2026-06-06 markup, not the final executed text. +- Recipient RESOLVED: Clyde Mathes, confirmed by Kisa 2026-07-07 (the Bittinger/Nixon names in the compliance checklist were candidate firms). +- labABLE LOI VERIFIED against the executed PDF 2026-07-07 (`signal/pitch/legal/labABLE-LOI-v3-EXECUTED-2026-06-12.pdf`): signed 6/12/2026 by Daniel Rojas (Co-Founder, Pathogens Artificial Intelligence Inc.) and Arkisia Fenn (Founder); binding sections 3 through 11; 180-day term from Effective Date; governed by Delaware law on LabABLE's paper; Section 7 publicity clause bars public mention without written consent. The kit-in-kit concept does NOT appear in the executed text; it is a discussed concept only, which is exactly how this memo frames it. - labABLE legal entity is Pathogens Artificial Intelligence Inc. d/b/a LabABLE. GROUNDED (labABLE LOI markup 2026-06-06). - AWS BAA signed and active 2026-05-27, RDS and S3 provisioned and parked. GROUNDED (KG Signal; Signal current-state.md). - Real PHI blocked/mock today. GROUNDED (Signal current-state.md 2026-07-07). diff --git a/python-backend/api/main.py b/python-backend/api/main.py index 0f1592b..69d9ee9 100644 --- a/python-backend/api/main.py +++ b/python-backend/api/main.py @@ -925,9 +925,34 @@ async def upload_csv( ) +class LineExportRow(BaseModel): + """One device coverage line for the per-line work-queue export + (granularity locked by Kisa 2026-07-07).""" + + patient_id: str + device_display: str = "" + payer: str = "" + plan_type: Optional[str] = None + rollup_status: Optional[str] = None + line_status: Optional[str] = None + timing: str = "" + order_numbers: str = "" + hcpcs_codes: str = "" + total_quantity: int = 0 + shipment_date: str = "" + swo: str = "" + visit: str = "" + pecos: str = "" + pa: str = "" + diagnosis: str = "" + + class ExportRequest(BaseModel): - records: list[RecordOut] + records: list[RecordOut] = [] batch_id: str | None = None + # Per-line export (P6): when present, the CSV is one row per device + # coverage line instead of one row per legacy record. + lines: Optional[list[LineExportRow]] = None @app.post("/api/export") @@ -935,10 +960,81 @@ async def export_work_queue( body: ExportRequest, claims: dict = Depends(require_auth), ): - """Generate a downloadable work-queue CSV from a list of scored records.""" + """Generate a downloadable work-queue CSV from a list of scored records, + or one row per device coverage line when `lines` is provided (P6).""" records = body.records output = io.StringIO() writer = csv.writer(output) + + if body.lines is not None: + writer.writerow( + [ + "Patient ID", + "Device", + "Payer", + "Plan Type", + "Patient Status", + "Line Status", + "Timing", + "Order Numbers", + "HCPCS", + "Quantity", + "Last Shipment", + "SWO", + "Visit", + "PECOS", + "PA", + "Diagnosis", + ] + ) + for l in body.lines: + writer.writerow( + [ + l.patient_id, + l.device_display, + l.payer, + l.plan_type or "Plan type needed", + l.rollup_status or "", + l.line_status or "", + l.timing, + l.order_numbers, + l.hcpcs_codes, + l.total_quantity, + l.shipment_date, + l.swo, + l.visit, + l.pecos, + l.pa, + l.diagnosis, + ] + ) + output.seek(0) + today = date.today().isoformat() + export_filename = f"signal-work-queue-{today}.csv" + from core.supabase_client import get_client + + log_event( + action=AuditAction.WORKLIST_EXPORT, + resource_id=export_filename, + user_id=claims.get("sub", "demo_user"), + outcome="success", + ip_address="0.0.0.0", + detail=f"{len(body.lines)} coverage lines exported", + db_conn=get_client(), + ) + persist_export( + batch_id=body.batch_id, + filename=export_filename, + row_count=len(body.lines), + ) + return StreamingResponse( + io.BytesIO(output.getvalue().encode("utf-8")), + media_type="text/csv", + headers={ + "Content-Disposition": f"attachment; filename={export_filename}" + }, + ) + writer.writerow( [ "Order Number", diff --git a/signal-ui/src/App.jsx b/signal-ui/src/App.jsx index 685ba1e..230364b 100644 --- a/signal-ui/src/App.jsx +++ b/signal-ui/src/App.jsx @@ -4,6 +4,7 @@ import { ThemeProvider } from "./ThemeProvider"; import Sidebar from "./components/Sidebar"; import StatCard from "./components/StatCard"; import WorklistTable from "./components/WorklistTable"; +import PatientWorklist from "./components/PatientWorklist"; import CSVImport from "./components/CSVImport"; import CSVExport from "./components/CSVExport"; import ThemeToggle from "./components/ThemeToggle"; @@ -17,10 +18,15 @@ import { parseCSV, processBatch } from "./lib/coverage"; function AppInner() { const { getToken } = useAuth(); const [records, setRecords] = useState([]); + const [uploadData, setUploadData] = useState(null); // full API payload (P6) const [batchId, setBatchId] = useState(null); const [activeFilter, setActiveFilter] = useState("all"); const [importLabel, setImportLabel] = useState("No data imported"); const csvImportRef = useRef(null); + // The nested patient view is the door (P6, Kisa-approved); ?legacy=1 keeps + // the old row-keyed table reachable during the transition (Phase 2b). + const useLegacyView = + new URLSearchParams(window.location.search).get("legacy") === "1"; const supplyLapsed = records.filter((r) => r.flag === "SUPPLY_LAPSED").length; const visitRequired = records.filter((r) => r.flag === "VISIT_REQUIRED").length; @@ -50,6 +56,7 @@ function AppInner() { if (data) { const results = data.records.map(apiRecordToLocal); setRecords(results); + setUploadData(data); 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}`; @@ -65,8 +72,10 @@ function AppInner() { return; } - // Backend unreachable — process locally + // Backend unreachable — process locally (legacy table renders; the + // nested view needs the API's patients payload) setBatchId(null); + setUploadData(null); const reader = new FileReader(); reader.onload = (e) => { const rows = parseCSV(e.target.result); @@ -114,7 +123,11 @@ function AppInner() {
- +
@@ -148,17 +161,25 @@ function AppInner() { /> - {/* Worklist */} - - setRecords(prev => - prev.map(r => r.patient_id === patientId ? { ...r, ...updatedRecord } : r) - ) - } - /> + {/* Worklist — nested patient view (P6) unless ?legacy=1 or the + payload lacks patients (local fallback processing) */} + {!useLegacyView && uploadData?.patients?.length ? ( + csvImportRef.current?.trigger()} + /> + ) : ( + + setRecords(prev => + prev.map(r => r.patient_id === patientId ? { ...r, ...updatedRecord } : r) + ) + } + /> + )} )} diff --git a/signal-ui/src/components/Badge.jsx b/signal-ui/src/components/Badge.jsx index 36d15a9..74b44fb 100644 --- a/signal-ui/src/components/Badge.jsx +++ b/signal-ui/src/components/Badge.jsx @@ -86,6 +86,52 @@ const FLAG_CONFIG = { }, }; +// P6 verdict badges — the FIVE sanctioned line/rollup statuses, verbatim. +// Colors per the Kisa-approved mockup (2026-07-07): stoplight for the four +// locked labels; purple for Plan Type Needed (configuration, not urgency). +const VERDICT_CONFIG = { + "At Risk": { + bg: "bg-[rgba(204,34,34,0.10)]", text: "text-[#CC2222]", + border: "border-[#CC2222]", weight: "font-bold", icon: "⚠", + }, + "Action Needed": { + bg: "bg-[rgba(203,107,32,0.12)]", text: "text-[#CB6B20]", + border: "border-[#CB6B20]", weight: "font-semibold", icon: "→", + }, + "On Track": { + bg: "bg-[rgba(46,163,163,0.10)]", text: "text-[#147A7A]", + border: "border-[#2EA3A3]", weight: "font-normal", icon: "●", + }, + "Clear to Ship": { + bg: "bg-[rgba(34,197,94,0.12)]", text: "text-[#166534]", + border: "border-[#22C55E]", weight: "font-bold", icon: "✓", + }, + "Plan Type Needed": { + bg: "bg-[rgba(122,94,160,0.10)]", text: "text-[#6B4F9A]", + border: "border-[#9B8EC4]", weight: "font-semibold", icon: "◆", + }, +}; + +export function VerdictBadge({ status }) { + if (!status) { + return ( + + Informational + + ); + } + const cfg = VERDICT_CONFIG[status]; + if (!cfg) return null; + return ( + + {cfg.icon} + {status} + + ); +} + export default function Badge({ flag }) { const cfg = FLAG_CONFIG[flag] || { label: flag, diff --git a/signal-ui/src/components/CSVExport.jsx b/signal-ui/src/components/CSVExport.jsx index 150a709..69b3270 100644 --- a/signal-ui/src/components/CSVExport.jsx +++ b/signal-ui/src/components/CSVExport.jsx @@ -1,7 +1,40 @@ import { useAuth } from "@clerk/react"; -import { exportFromBackend } from "../lib/api"; +import { exportFromBackend, exportLinesFromBackend } from "../lib/api"; import { getFlagLabel, getFlagAction, getDeviceDisplay } from "../lib/coverage"; +// One export row per device coverage line (P6 granularity, locked 2026-07-07). +function patientsToLineRows(patients) { + const rows = []; + for (const p of patients) { + for (const line of p.lines || []) { + if (!line.gradeable) continue; // non-CGM lines carry no readiness actions + const by = {}; + (line.doc_items || []).forEach((i) => { + by[i.doc_type] = i.value || (i.required_state === "NOT_REQUIRED" ? "Not required" : "Not on file"); + }); + rows.push({ + patient_id: p.patient_id, + device_display: line.device_display, + payer: line.payer || "", + plan_type: line.plan_type, + rollup_status: p.rollup_status, + line_status: line.line_status, + timing: line.timing_flag || "", + order_numbers: (line.order_numbers || []).join(", "), + hcpcs_codes: (line.hcpcs_codes || []).join(", "), + total_quantity: line.total_quantity || 0, + shipment_date: line.shipment_date || "", + swo: by.swo || "", + visit: by.visit || "", + pecos: by.pecos || "", + pa: by.pa || "", + diagnosis: by.diagnosis || "", + }); + } + } + return rows; +} + function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -13,15 +46,30 @@ function downloadBlob(blob, filename) { URL.revokeObjectURL(url); } -export default function CSVExport({ records, batchId }) { +export default function CSVExport({ records, patients = null, batchId }) { const { getToken } = useAuth(); const today = new Date().toISOString().slice(0, 10); const filename = `signal-work-queue-${today}.csv`; const exportCSV = async () => { - if (!records || records.length === 0) return; + if ((!records || records.length === 0) && !(patients && patients.length)) return; - if (batchId) { + // Nested view: one row per device coverage line, exported through the + // backend so the WORM audit trail records it. + if (patients && patients.length) { + const token = await getToken().catch(() => null); + try { + const blob = await exportLinesFromBackend(patientsToLineRows(patients), batchId, token); + if (blob) { + downloadBlob(blob, filename); + return; + } + } catch { + // fall through to the legacy paths below + } + } + + if (batchId && records && records.length) { const token = await getToken().catch(() => null); const blob = await exportFromBackend(records, batchId, token); if (blob) { diff --git a/signal-ui/src/components/ConfirmVisitModal.jsx b/signal-ui/src/components/ConfirmVisitModal.jsx index 43bdf53..472263f 100644 --- a/signal-ui/src/components/ConfirmVisitModal.jsx +++ b/signal-ui/src/components/ConfirmVisitModal.jsx @@ -74,6 +74,11 @@ export default function ConfirmVisitModal({ record, onClose, onConfirmed, onSave csv_transfer_from: record.csv_transfer_from || null, order_number: record.order_number || null, hcpcs: record.hcpcs || null, + // P6 nested view: plan type + the patient's echoed lines so the + // response carries the re-rolled patient (visit fans out to all + // lines). Legacy records lack these — behavior unchanged. + ...(record.plan_type ? { plan_type: record.plan_type } : {}), + ...(record._echo_lines ? { lines: record._echo_lines } : {}), }, token); onConfirmed(record.patient_id, updated); onSaveSuccess?.({ patient_id: record.patient_id }); diff --git a/signal-ui/src/components/PatientWorklist.jsx b/signal-ui/src/components/PatientWorklist.jsx new file mode 100644 index 0000000..dfec7d1 --- /dev/null +++ b/signal-ui/src/components/PatientWorklist.jsx @@ -0,0 +1,568 @@ +import { useState, useEffect, Fragment } from "react"; +import { useAuth } from "@clerk/react"; +import { VerdictBadge } from "./Badge"; +import StatusLegend from "./StatusLegend"; +import ConfirmVisitModal from "./ConfirmVisitModal"; +import { updateDocStatus } from "../lib/api"; + +// P6 patient-nested worklist (Kisa-approved mockup, 2026-07-07). +// One row per patient carrying the rollup verdict; expands into device +// coverage lines with their doc checklists and rule citations. Green is +// earned only from the readiness verdict. Tabs count PATIENTS. +// The five status labels are locked; "Data gap" is a filter + chip, never +// a sixth label (Kisa Decision E). + +const DOC_LABELS = { + swo: "SWO", + visit: "Qualifying Visit", + pecos: "PECOS Enrollment", + pa: "Prior Authorization", + diagnosis: "Diagnosis on File", +}; + +// doc_type -> the canonical CSV field whose mapping decides whether an +// absent item is a FILE gap (column never mapped) or a PATIENT gap. +const DOC_COLUMN = { + swo: "csv_swo_status", + visit: "csv_visit_date", + pecos: "csv_pecos_verified", + pa: "csv_pa_status", + diagnosis: "csv_diagnosis_on_file", +}; + +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", +}; + +// Timing hints are descriptive phrases, never status labels (locked sets). +const TIMING_HINTS = { + SUPPLY_LAPSED: "Supply lapsed", + VISIT_REQUIRED: "Visit overdue", + RENEWAL_CRITICAL: "Visit renewal critical", + RENEWAL_ELEVATED: "Visit renewal due soon", + RENEWAL_SOON: "Visit renewal approaching", + RESUPPLY_READY: "In resupply window", + ACTIVE: "Timing on track", + NO_RECENT_SHIPMENT: "No recent shipment", + TRANSFER_PENDING: "Transfer pending", +}; + +const TABS = [ + { key: "all", label: "All Patients", color: "#5A7E7E" }, + { key: "At Risk", label: "At Risk", color: "#CC2222" }, + { key: "Action Needed", label: "Action Needed", color: "#CB6B20" }, + { key: "On Track", label: "On Track", color: "#2EA3A3" }, + { key: "Clear to Ship", label: "Clear to Ship", color: "#22C55E", check: true }, + { key: "Plan Type Needed", label: "Plan Type Needed", color: "#9B8EC4" }, +]; + +function itemIsFileGap(item, mappedFields) { + // REQUIRED only: NOT_EVALUATED items (plan type unknown) belong to the + // Plan Type Needed treatment, never double-flagged as data gaps. + return ( + item.input_state === "ABSENT" && + item.required_state === "REQUIRED" && + !(DOC_COLUMN[item.doc_type] in mappedFields) + ); +} + +function patientFileGaps(p, mappedFields) { + const gaps = new Set(); + for (const line of p.lines || []) { + for (const item of line.doc_items || []) { + if (itemIsFileGap(item, mappedFields)) gaps.add(item.doc_type); + } + } + return [...gaps]; +} + +function lineToEcho(line) { + const by = {}; + (line.doc_items || []).forEach((i) => { by[i.doc_type] = i.value; }); + return { + device_type: line.device_type, + gradeable: line.gradeable, + plan_type: line.plan_type, + shipment_date: line.shipment_date || null, + csv_swo_status: by.swo || null, + csv_visit_date: by.visit || null, + csv_pecos_verified: by.pecos || null, + csv_pa_status: by.pa || null, + csv_diagnosis_on_file: by.diagnosis || null, + }; +} + +function lineToModalRecord(p, line) { + const by = {}; + (line.doc_items || []).forEach((i) => { by[i.doc_type] = i.value; }); + return { + patient_id: p.patient_id, + last_shipment_date: line.shipment_date, + payer: line.payer || "", + device_type: line.device_type, + quantity: line.total_quantity || 1, + plan_type: line.plan_type, + csv_visit_date: by.visit || null, + csv_swo_status: by.swo || null, + csv_pecos_verified: by.pecos || null, + csv_pa_status: by.pa || null, + csv_diagnosis_on_file: by.diagnosis || null, + _echo_lines: (p.lines || []).map(lineToEcho), + }; +} + +// Merge a P4 recompute response ({lines, rollup_status}) into local state. +// Line identity is (device_type, plan_type) — a patient can hold the same +// device under two plan types (typed + unknown), so device alone is not a key. +function mergeRecompute(p, recomputed) { + if (!recomputed?.lines) return p; + const lineKey = (l) => `${l.device_type}:${l.plan_type ?? ""}`; + const byKey = {}; + recomputed.lines.forEach((l) => { byKey[lineKey(l)] = l; }); + return { + ...p, + rollup_status: recomputed.rollup_status ?? p.rollup_status, + lines: p.lines.map((line) => { + const upd = byKey[lineKey(line)]; + return upd + ? { ...line, line_status: upd.line_status, doc_items: upd.doc_items ?? line.doc_items } + : line; + }), + }; +} + +export default function PatientWorklist({ data, onImportClick }) { + const { getToken } = useAuth(); + const [patients, setPatients] = useState(data.patients || []); + const [tab, setTab] = useState("all"); + const [refine, setRefine] = useState(null); // null | "gaps" | "outreach" + const [expanded, setExpanded] = useState(null); // patient_id + const [confirming, setConfirming] = useState(null); // {record, patientId} + const [showAllSkipped, setShowAllSkipped] = useState(false); + + useEffect(() => { + setPatients(data.patients || []); + setExpanded(null); + }, [data]); + + const mappedFields = data.mapping_summary?.mapped || {}; + const skippedReasons = data.skipped_reasons || []; + const skippedCount = data.skipped || 0; + + const gradeable = patients.filter((p) => p.rollup_status); + const gapPatients = patients.filter( + (p) => patientFileGaps(p, mappedFields).length > 0 + ); + const outreachPatients = patients.filter( + (p) => + ["At Risk", "Action Needed"].includes(p.rollup_status) && + patientFileGaps(p, mappedFields).length === 0 + ); + + // Single derivation for all counts: always from the local patients state. + const counts = { all: patients.length }; + TABS.slice(1).forEach((t) => { + counts[t.key] = gradeable.filter((p) => p.rollup_status === t.key).length; + }); + + let visible = + tab === "all" ? patients : patients.filter((p) => p.rollup_status === tab); + if (refine === "gaps") { + visible = visible.filter((p) => patientFileGaps(p, mappedFields).length > 0); + } else if (refine === "outreach") { + visible = visible.filter((p) => outreachPatients.includes(p)); + } + + function downloadSkipped() { + const blob = new Blob( + ["Signal — rows not processed\n\n" + skippedReasons.join("\n") + "\n"], + { type: "text/plain;charset=utf-8;" } + ); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `signal-not-processed-${new Date().toISOString().slice(0, 10)}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + function applyRecompute(patientId, recomputed) { + setPatients((prev) => + prev.map((p) => (p.patient_id === patientId ? mergeRecompute(p, recomputed) : p)) + ); + } + + const ungraded = patients.length - gradeable.length; + + return ( +
+ {/* Header + patient tabs */} +
+
+
+ Outreach Worklist +
+
+ {patients.length} patients · {gradeable.length} gradeable + {ungraded > 0 && ` · ${ungraded} informational only`} + {skippedCount > 0 && ` · ${skippedCount} rows not processed`} +
+
+
+ {TABS.map((t) => { + const count = counts[t.key] ?? 0; + const pct = + t.key !== "all" && gradeable.length > 0 + ? Math.round((count / gradeable.length) * 100) + : null; + const isActive = tab === t.key; + const indicatorColor = isActive ? "white" : t.color; + return ( + + ); + })} +
+
+ + {/* Refine pills — separates fix-the-file work from outreach work */} +
+ Refine: + + + + Data-gap patients keep their honest status; this separates fix-the-file work from call-the-prescriber work + +
+ + {/* Not-processed strip — rows Signal never scores and never hides */} + {skippedCount > 0 && ( +
+
+ + {skippedCount} row{skippedCount !== 1 ? "s" : ""} not processed + {" "} + — Signal never scores a row whose required information was not provided, and never hides one: +
    + {(showAllSkipped ? skippedReasons : skippedReasons.slice(0, 3)).map((r, i) => ( +
  • {r}
  • + ))} +
+ {skippedReasons.length > 3 && !showAllSkipped && ( + + )} +
+ +
+ )} + + + + {/* Patient table */} + + + + {["", "Patient", "Device(s)", "Payer", "Status", "Last Resupply", "Actions"].map((h, i) => ( + + ))} + + + + {visible.map((p) => { + const isExpanded = expanded === p.patient_id; + const gaps = patientFileGaps(p, mappedFields); + const isPTN = p.rollup_status === "Plan Type Needed"; + const capped = p.plan_type_needed && !isPTN; + const lastShip = (p.lines || []) + .map((l) => l.shipment_date) + .filter(Boolean) + .sort() + .pop(); + return ( + + setExpanded(isExpanded ? null : p.patient_id)} + > + + + + + + + + + + {isExpanded && + (p.lines || []).map((line) => ( + + + + ))} + + ); + })} + {visible.length === 0 && ( + + + + )} + +
+ {h} +
+ {isExpanded ? "▼" : "▶"} + + + {p.patient_id} + + + {(p.lines || []).map((l) => l.device_display).join(", ")} + + {(p.lines || [])[0]?.payer || "—"} + + + {capped && ( +
+ Capped: includes a Plan Type Needed line +
+ )} + {gaps.length > 0 && !isPTN && ( +
+ Data gap — {gaps.map((g) => DOC_LABELS[g]).join(", ")} column not provided in the file +
+ )} + {!p.rollup_status && ( +
+ Non-CGM device — displayed, not graded, excluded from counts +
+ )} +
+ {lastShip || "—"} + e.stopPropagation()}> + {isPTN && ( + + )} + {gaps.length > 0 && !isPTN && ( + + ◇ Fix the file, not the phone + + )} +
+ applyRecompute(p.patient_id, rec)} + onConfirmVisit={() => + setConfirming({ record: lineToModalRecord(p, line), patientId: p.patient_id }) + } + /> +
+ No patients match this filter. +
+ + {/* Footer */} +
+ + PHI-safe — patient names and DOBs never stored. Crosswalk: patient_id only. + + + {visible.length} of {patients.length} patients shown + +
+ + {confirming && ( + setConfirming(null)} + onConfirmed={(patientId, updated) => applyRecompute(patientId, updated)} + /> + )} +
+ ); +} + +function DeviceLine({ patient, line, mappedFields, getToken, onRecomputed, onConfirmVisit }) { + const items = line.doc_items || []; + const visitItem = items.find((i) => i.doc_type === "visit"); + const showConfirmVisit = line.gradeable && visitItem && !visitItem.satisfied; + return ( +
+
+ {line.device_display} + + {line.plan_type || "Plan type needed"} + + + {line.timing_flag && ( + + {TIMING_HINTS[line.timing_flag] || line.timing_flag} + + )} + + {line.order_numbers?.length ? `Orders: ${line.order_numbers.join(", ")} · ` : ""} + {line.hcpcs_codes?.length ? `HCPCS: ${line.hcpcs_codes.join(", ")} · ` : ""} + qty {line.total_quantity} + {line.prior_shipment_count > 0 && ` · ${line.prior_shipment_count} prior shipment${line.prior_shipment_count !== 1 ? "s" : ""}`} + +
+ {line.gradeable && items.length > 0 ? ( +
+ {items.map((item) => ( + + ))} + {showConfirmVisit && ( + + )} +
+ ) : ( +
+ Non-CGM device — no readiness actions. +
+ )} +
+ ); +} + +function ChecklistItem({ patient, line, item, fileGap, getToken, onRecomputed }) { + const [saving, setSaving] = useState(false); + const cyclable = item.doc_type === "swo" ? SWO_CYCLE : item.doc_type === "pa" ? PA_CYCLE : null; + + const icon = fileGap + ? { glyph: "◇", cls: "text-[#6B4F9A]" } + : item.satisfied + ? item.required_state === "NOT_REQUIRED" && item.input_state === "ABSENT" + ? { glyph: "—", cls: "text-[var(--text-warm)]" } + : { glyph: "✓", cls: "text-[#166534]" } + : item.quality === "BAD" + ? { glyph: "✗", cls: "text-[#CC2222]" } + : { glyph: "✗", cls: "text-[#CB6B20]" }; + + const valueText = fileGap + ? "Column not mapped" + : item.value || + (item.required_state === "NOT_REQUIRED" ? "Not required" : "Not on file"); + + async function handleCycle() { + if (!cyclable || saving || fileGap) return; + const currentKey = Object.entries(DOC_STATUS_LABELS).find(([, v]) => v === item.value)?.[0]; + const idx = currentKey ? cyclable.indexOf(currentKey) : -1; + const nextKey = cyclable[(idx + 1) % cyclable.length]; + setSaving(true); + try { + const token = getToken ? await getToken().catch(() => null) : null; + const result = await updateDocStatus( + patient.patient_id, + item.doc_type, + nextKey, + null, + null, + token, + line.device_type, + (patient.lines || []).map(lineToEcho) + ); + if (result?.lines) onRecomputed(result); + } finally { + setSaving(false); + } + } + + return ( +
+ {icon.glyph} +
+
+ {DOC_LABELS[item.doc_type]} +
+ {cyclable && !fileGap ? ( + + ) : ( +
+ {valueText} +
+ )} +
+ {item.rule_id ? `${item.rule_id}` : ""} + {fileGap ? " · resolves at re-import or mapping review" : ""} +
+
+
+ ); +} diff --git a/signal-ui/src/components/StatusLegend.jsx b/signal-ui/src/components/StatusLegend.jsx index 61e1423..6cb86f4 100644 --- a/signal-ui/src/components/StatusLegend.jsx +++ b/signal-ui/src/components/StatusLegend.jsx @@ -5,13 +5,23 @@ const LEGEND_ITEMS = [ { color: "#22C55E", name: "Clear to Ship", meaning: "In resupply window. Ready to go.", check: true }, ]; -export default function StatusLegend() { +// P6 additions (Kisa-approved 2026-07-07): the readiness states the nested +// worklist introduces. Legacy surfaces keep the original four. +const EXTENDED_ITEMS = [ + { color: "#9B8EC4", name: "Plan Type Needed", meaning: "Map the plan type to grade documentation." }, + { color: "#9B8EC4", name: "Data gap", meaning: "A required column was not in the file. Fix the import, not the patient.", diamond: true }, +]; + +export default function StatusLegend({ extended = false }) { + const items = extended ? [...LEGEND_ITEMS, ...EXTENDED_ITEMS] : LEGEND_ITEMS; return (
- {LEGEND_ITEMS.map((item) => ( + {items.map((item) => (
{item.check ? + : item.diamond + ? : } diff --git a/signal-ui/src/lib/api.js b/signal-ui/src/lib/api.js index daea4c2..677a4e3 100644 --- a/signal-ui/src/lib/api.js +++ b/signal-ui/src/lib/api.js @@ -124,13 +124,34 @@ export async function confirmVisit(payload, token = null) { throw new Error(err.detail || `HTTP ${res.status}`); } const raw = await res.json(); - // Normalize to local shape - return apiRecordToLocal(raw); + // Normalize to local shape, preserving the P4 recompute payload (the + // re-rolled patient) when the request echoed the patient's lines. + const local = apiRecordToLocal(raw); + if (raw.rollup_status !== undefined) local.rollup_status = raw.rollup_status; + if (raw.lines) local.lines = raw.lines; + return local; } catch (e) { throw e; } } +/** + * Export the work queue as one CSV row per device coverage line (P6, + * granularity locked 2026-07-07). Falls through to the caller on failure. + */ +export async function exportLinesFromBackend(lines, batchId = null, token = null) { + const authHeader = token + ? { "Authorization": `Bearer ${token}` } + : {}; + const resp = await fetch(`${BACKEND_URL}/api/export`, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeader }, + body: JSON.stringify({ records: [], lines, batch_id: batchId }), + }); + if (!resp.ok) throw new Error(`API error ${resp.status}`); + return resp.blob(); +} + /** * Update SWO or PA status for a patient. * @param {string} patientId @@ -141,7 +162,7 @@ export async function confirmVisit(payload, token = null) { * @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, deviceType = "") { +export async function updateDocStatus(patientId, docType, status, statusDate = null, expiryDate = null, token = null, deviceType = "", lines = null) { const authHeader = token ? { "Authorization": `Bearer ${token}` } : {}; @@ -156,6 +177,9 @@ export async function updateDocStatus(patientId, docType, status, statusDate = n // Device scope (P4): an SWO/PA update applies to this device's line // only, never to every device the patient has. ...(deviceType && { device_type: deviceType }), + // Echo the patient's lines so the response carries the recomputed + // {lines, rollup_status} from the same engine (P4 contract). + ...(lines && { lines }), ...(statusDate && { status_date: statusDate }), ...(expiryDate && { expiry_date: expiryDate }), }), diff --git a/tests/test_worklist_readiness.py b/tests/test_worklist_readiness.py index bc541d3..c0fc8fe 100644 --- a/tests/test_worklist_readiness.py +++ b/tests/test_worklist_readiness.py @@ -722,3 +722,46 @@ def test_unrecognized_plan_value_warning_hashes_patient(caplog): hits = [r for r in caplog.records if "Unrecognized plan_type" in r.getMessage()] assert len(hits) == 1 assert "PT-RAW-SECRET" not in hits[0].getMessage() + + +def test_export_per_line_rows(): + """P6 (Kisa-locked): the work-queue export writes one row per coverage + line when lines are provided; legacy record export unchanged otherwise.""" + resp = client.post( + "/api/export", + json={ + "lines": [ + { + "patient_id": "PT-EXP-1", + "device_display": "Dexcom G7", + "payer": "Medicare Part B", + "plan_type": "medicare", + "rollup_status": "Action Needed", + "line_status": "Clear to Ship", + "timing": "In resupply window", + "order_numbers": "ORD-1, ORD-2", + "hcpcs_codes": "A4239", + "total_quantity": 3, + "shipment_date": "2026-07-02", + "swo": "On File", + "visit": "2026-06-14", + "pecos": "Enrolled", + "pa": "Not required", + "diagnosis": "E11.65", + } + ] + }, + ) + assert resp.status_code == 200 + body = resp.text + lines = body.strip().splitlines() + assert lines[0].startswith("Patient ID,Device,Payer,Plan Type") + assert len(lines) == 2 + assert '"ORD-1, ORD-2"' in lines[1] # joined order numbers stay one cell + assert "Clear to Ship" in lines[1] + + +def test_export_legacy_records_shape_unchanged(): + resp = client.post("/api/export", json={"records": [], "batch_id": None}) + assert resp.status_code == 200 + assert resp.text.strip().splitlines()[0].startswith("Order Number,HCPCS,Patient ID")