From 6181de74ed43b98cebba850890a217111d901b0f Mon Sep 17 00:00:00 2001 From: Kisa Date: Tue, 7 Jul 2026 06:37:04 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20patient-nested=20worklist=20=E2=80=94?= =?UTF-8?q?=20the=20true-green=20display=20(plan=2002,=20P6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The worklist now shows one row per patient carrying the rollup verdict, expanding into device coverage lines with doc checklists and rule citations. Green is earned only from the readiness verdict. Kisa-approved design (mockup v2, decisions A/B/C/D/E locked 2026-07-07): purple Plan Type Needed badge + Map-the-plan-type CTA; ◇ column-not-mapped chips; not-processed strip with download; Data-gaps filter (honest labels, no sixth status); extended legend; per-line work-queue export (backend lines[] support, audit-logged). Tabs count patients. SWO/PA cycling is device-scoped and returns the re-rolled patient through the P4 echo contract; Confirm Visit fans out across the patient's echoed lines. Legacy table remains at ?legacy=1 (its DOCS_REQUIRED stopgap intentionally kept while it exists — literal deviation from the plan's remove-in-same-change line, logged). Independent audit: SHIP; its MEDIUM (line-identity merge collision) and two LOWs fixed in-commit. 171 backend tests green; frontend builds clean. Co-Authored-By: Claude Fable 5 --- pitch/legal/STTIL-BAA-template-v1.md | 12 +- ...L-attorney-review-cover-memo-2026-07-07.md | 20 +- python-backend/api/main.py | 100 ++- signal-ui/src/App.jsx | 47 +- signal-ui/src/components/Badge.jsx | 46 ++ signal-ui/src/components/CSVExport.jsx | 56 +- .../src/components/ConfirmVisitModal.jsx | 5 + signal-ui/src/components/PatientWorklist.jsx | 568 ++++++++++++++++++ signal-ui/src/components/StatusLegend.jsx | 14 +- signal-ui/src/lib/api.js | 30 +- tests/test_worklist_readiness.py | 43 ++ 11 files changed, 901 insertions(+), 40 deletions(-) create mode 100644 signal-ui/src/components/PatientWorklist.jsx 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")