Compare commits

...

2 commits

Author SHA1 Message Date
Kisa
3f2c479f86 state-sync + ledger: Signal readiness-model build complete P1-P6 [CC]
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 06:39:23 -04:00
Kisa
6181de74ed feat: patient-nested worklist — the true-green display (plan 02, P6)
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 <noreply@anthropic.com>
2026-07-07 06:37:04 -04:00
13 changed files with 920 additions and 42 deletions

View file

@ -4,7 +4,7 @@ v:1
ACTIVE: 2026-07-07 [CC]. READINESS-MODEL BACKEND COMPLETE: P1-P5 all SHIPPED + LIVE-VERIFIED (a3af499, d24340c, bd92126, a53728e, f28b5bf; Railway + Vercel deployed; doc_status migration applied to prod). 169 tests + Pi E2E 33/33 green. P6 (frontend nesting) is the ONLY remaining item — BLOCKED on Kisa approving docs/mockups/p6-worklist-mockup-2026-07-07.png (decisions A-D).
NEXT: (Kisa) rule on P6 mockup v2 (docs/mockups/p6-worklist-mockup-v2-2026-07-07.png): Decision B-v2 (generalized purple data-gap family) + Decision E (data-gap patients stay Action Needed with a Data-gaps filter vs a new label; CC recommends the filter) -> then CC builds the frontend. A (nesting) + D (per-line export) LOCKED 2026-07-07; (Kisa) confirm OQ1 plan_type-ingestion decision recorded 2026-07-07 (shipped per umbrella plan; two-line revert if reversed); (Kisa) attorney bundle READY TO SEND (signal/pitch/legal/attorney-bundle-2026-07-07, 5 Word docs + sendable memo): confirm recipient (Clyde Mathes vs Bittinger/Nixon), entity name + formation state, arbitration-vs-courts posture, Partner Concepts carve-out keep/drop, then email; Gaboro re-approach per reactivation plan 00 (unchanged). Queued behind Signal: UMSIS docs-only activation, "63% written off" Draft fix.
NEXT: (Kisa) signed-in click-through of the new worklist (upload a test-data CSV; probe: two-device override isolation, PTN CTA, Clear-to-Ship tab empty without plan_type, confirm-visit fan-out, per-line export opens in Excel); (Kisa) confirm OQ1 + the retained-legacy-stopgap deviation (both logged); (Kisa) attorney bundle send + Gaboro re-approach (her lanes); (CC next session) update pilot guide screenshots + demo narrative for the plan-type mapping step (flag to lead-content), CSV formula-injection sanitization backlog item. Queued behind Signal: UMSIS docs-only activation, '63% written off' Draft fix.
PRIOR ACTIVE: 2026-06-28. Signal paused at clean checkpoint — Insight Engine in active BUILD phase for July 4 deadline. See insight-engine/context/current-state.md and insight-engine/handoff-clock.md for full state. Key progress today: x-sight guided experience designed (Compass) and approved by Kisa, lane-lock handoff clock created, frontend finish readiness assessed (15 ranked items). Claude is building the Compass landing + B1 deploy blocker + finish items. Signal READINESS MODEL still queued at the clean checkpoint from 2026-06-27.
@ -25,6 +25,8 @@ Insight Engine (July 4 deadline = tomorrow) is the active project. Key files:
## Decisions (last
- 2026-07-07 [Kisa/CC]: P6 SHIPPED (6181de7) — all five design decisions approved and built (patient nesting, purple data-gap family, extended legend, per-line export, honest-label data-gap filter). Readiness-model build P1-P6 COMPLETE in one day: 6 ship commits, every item independently audited before ship, adversarial review killed a false-green pre-ship, live-measured verification at each step. Known deviations logged: legacy DOCS_REQUIRED stopgap retained on the ?legacy=1 surface; OQ1 shipped per umbrella plan.
- 2026-07-07 [Kisa/CC]: P6 rulings round 1 — nesting layout APPROVED and per-line export APPROVED (both in KG). Failed-data principle set by Kisa: Signal never acts on rows whose required info was not mapped or provided, and never hides them — engine-dropped rows now surface in skipped_reasons (shipped + deployed same day). Plan Type Needed stays its own state (it selects the rules; not a chaseable document); all other file-level gaps get the purple family treatment pending Decision B-v2/E.
- 2026-07-07 [CC]: Readiness-model backend COMPLETE (P2-P5 shipped same day as P1): citation IDs live (R-registry), patient rollup + nested payload live, device-scoped overrides live (doc_status migration applied to prod; overrides now feed the verdict; the leak is dead, live-measured), plan-type enforcement live (zero greens without a mapped plan type, live-measured; Phase 2c deletion stays Kisa-gated). Every item independently audited SHIP before ship. P6 mockup rendered for Kisa (4 decisions annotated).
@ -63,4 +65,4 @@ Insight Engine (July 4 deadline = tomorrow) is the active project. Key files:
State protocol: read = `git pull --rebase` then read this file (warn if Updated > 24h old); write = edit ACTIVE/NEXT, append one author-stamped decision, then commit + push. Full protocol in the `state-sync` skill. Detailed ship-status (what shipped / didn't / why) = docs/ship-status-ledger.md. TriLane = searchable archive, not live state.
## Updated: 2026-07-07 (CC: readiness-model backend P1-P5 complete, live-verified, deployed; P6 gated on Kisa mockup approval.)
## Updated: 2026-07-07 (CC: Signal readiness-model build COMPLETE P1-P6, deployed, live-verified; Kisa click-through + her attorney/Gaboro lanes are the open items.)

View file

@ -112,3 +112,18 @@ code-auditor SHIP verdict and measured live verification:
Final state: 169 unit/API tests green, Pi E2E 33/33, live drives recorded
above. P6 (frontend nesting) BLOCKED on Kisa's mockup approval:
docs/mockups/p6-worklist-mockup-2026-07-07.png (decisions A-D annotated).
## Shipped: P6 patient-nested worklist (2026-07-07, Claude, commit 6181de7)
The readiness-model build is COMPLETE end to end. The worklist now renders
one row per patient (rollup verdict), expanding into device lines with doc
checklists and rule citations; green is earned only from the verdict. All
five Kisa decisions honored (nesting, purple family, legend, per-line export,
no-sixth-label data-gap filter). Not-processed strip + download; PTN CTA;
device-scoped cycling returns the re-rolled patient via the P4 echo; Confirm
Visit fans out. Legacy table at ?legacy=1 (stopgap intentionally retained
there). Backend: /api/export lines[] (live-verified). Audit: SHIP; MEDIUM
line-identity merge fix + 2 LOWs applied pre-ship. New bundle confirmed
serving (marker strings measured). REMAINING: Kisa's signed-in click-through
(6 probes listed in the audit); backlog: CSV formula-injection sanitization
pass (pre-existing, both export paths).

View file

@ -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.

View file

@ -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).

View file

@ -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",

View file

@ -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() {
</div>
<div className="flex items-center gap-[10px]">
<ThemeToggle />
<CSVExport records={records} batchId={batchId} />
<CSVExport
records={records}
patients={!useLegacyView ? uploadData?.patients : null}
batchId={batchId}
/>
<OrganizationSwitcher hidePersonal />
<UserButton />
</div>
@ -148,17 +161,25 @@ function AppInner() {
/>
</div>
{/* Worklist */}
<WorklistTable
records={records}
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
onVisitConfirmed={(patientId, updatedRecord) =>
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 ? (
<PatientWorklist
data={uploadData}
onImportClick={() => csvImportRef.current?.trigger()}
/>
) : (
<WorklistTable
records={records}
activeFilter={activeFilter}
onFilterChange={setActiveFilter}
onVisitConfirmed={(patientId, updatedRecord) =>
setRecords(prev =>
prev.map(r => r.patient_id === patientId ? { ...r, ...updatedRecord } : r)
)
}
/>
)}
</div>
)}
</main>

View file

@ -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 (
<span className="inline-flex items-center gap-[3px] px-[8px] py-[3px] rounded-full text-[11px] border bg-[rgba(120,120,120,0.07)] text-[var(--text-warm)] border-[var(--border-color)] font-normal whitespace-nowrap">
Informational
</span>
);
}
const cfg = VERDICT_CONFIG[status];
if (!cfg) return null;
return (
<span
className={`inline-flex items-center gap-[3px] px-[8px] py-[3px] rounded-full text-[11px] border ${cfg.bg} ${cfg.text} ${cfg.border} ${cfg.weight} whitespace-nowrap`}
>
<span className="text-[10px]">{cfg.icon}</span>
{status}
</span>
);
}
export default function Badge({ flag }) {
const cfg = FLAG_CONFIG[flag] || {
label: flag,

View file

@ -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) {

View file

@ -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 });

View file

@ -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 (
<div className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[10px] shadow-[var(--shadow-card-md)] overflow-hidden transition-colors">
{/* Header + patient tabs */}
<div className="flex items-center justify-between px-[22px] py-4 border-b border-[var(--border-color)] bg-[var(--bg-elevated)] gap-4 flex-wrap">
<div>
<div className="font-heading font-bold text-[15px] text-[var(--text-heading)]">
Outreach Worklist
</div>
<div className="font-mono text-[11px] text-[var(--text-warm)] mt-[2px]">
{patients.length} patients · {gradeable.length} gradeable
{ungraded > 0 && ` · ${ungraded} informational only`}
{skippedCount > 0 && ` · ${skippedCount} rows not processed`}
</div>
</div>
<div className="flex gap-[6px] flex-wrap">
{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 (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-3 py-[5px] rounded-full text-[11.5px] cursor-pointer font-body transition-all border inline-flex items-center gap-[5px] ${
isActive
? "bg-[var(--brand)] text-white border-[var(--brand)]"
: "bg-transparent border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--brand)] hover:text-[var(--brand)]"
}`}
>
{t.check ? (
<span style={{ color: indicatorColor, fontSize: "12px", fontWeight: 800, lineHeight: 1 }}></span>
) : (
<span style={{ width: 6, height: 6, borderRadius: "50%", backgroundColor: indicatorColor, display: "inline-block", flexShrink: 0 }} />
)}
{t.label}
<span style={{ opacity: 0.65, fontSize: "10.5px", fontWeight: 500 }}>
{count}
{pct !== null ? ` · ${pct}%` : ""}
</span>
</button>
);
})}
</div>
</div>
{/* Refine pills — separates fix-the-file work from outreach work */}
<div className="flex items-center gap-[10px] px-[22px] py-[8px] border-b border-[var(--border-subtle)] text-[11px] text-[var(--text-warm)]">
Refine:
<button
onClick={() => setRefine(refine === "gaps" ? null : "gaps")}
className={`inline-flex items-center gap-[5px] rounded-full px-[11px] py-[2px] text-[11px] font-semibold border cursor-pointer ${
refine === "gaps"
? "bg-[#6B4F9A] text-white border-[#6B4F9A]"
: "bg-[rgba(122,94,160,0.10)] text-[#6B4F9A] border-[#9B8EC4]"
}`}
>
Data gaps <b>{gapPatients.length}</b>
</button>
<button
onClick={() => setRefine(refine === "outreach" ? null : "outreach")}
className={`inline-flex items-center gap-[5px] rounded-full px-[11px] py-[2px] text-[11px] font-semibold border cursor-pointer ${
refine === "outreach"
? "bg-[var(--brand)] text-white border-[var(--brand)]"
: "border-[var(--border-color)] text-[var(--text-secondary)]"
}`}
>
Outreach only <b>{outreachPatients.length}</b>
</button>
<span className="ml-auto italic text-[10.5px]">
Data-gap patients keep their honest status; this separates fix-the-file work from call-the-prescriber work
</span>
</div>
{/* Not-processed strip — rows Signal never scores and never hides */}
{skippedCount > 0 && (
<div className="flex items-start gap-3 px-[22px] py-[10px] border-b border-[var(--border-subtle)] bg-[rgba(203,107,32,0.05)] text-[12px] text-[var(--text-secondary)]">
<div className="flex-1">
<b className="text-[var(--text-heading)]">
{skippedCount} row{skippedCount !== 1 ? "s" : ""} not processed
</b>{" "}
Signal never scores a row whose required information was not provided, and never hides one:
<ul className="list-disc ml-5 mt-1">
{(showAllSkipped ? skippedReasons : skippedReasons.slice(0, 3)).map((r, i) => (
<li key={i}>{r}</li>
))}
</ul>
{skippedReasons.length > 3 && !showAllSkipped && (
<button className="text-[var(--brand)] font-semibold mt-1 cursor-pointer bg-transparent border-none p-0" onClick={() => setShowAllSkipped(true)}>
Show all {skippedReasons.length}
</button>
)}
</div>
<button onClick={downloadSkipped} className="text-[var(--brand)] font-semibold whitespace-nowrap cursor-pointer bg-transparent border-none p-0">
Download list
</button>
</div>
)}
<StatusLegend extended />
{/* Patient table */}
<table className="w-full border-collapse">
<thead>
<tr>
{["", "Patient", "Device(s)", "Payer", "Status", "Last Resupply", "Actions"].map((h, i) => (
<th key={i} className="px-[18px] py-[10px] text-left text-[10.5px] font-semibold tracking-[0.06em] uppercase text-[var(--text-warm)] bg-[var(--bg-elevated)] border-b border-[var(--border-color)] whitespace-nowrap">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{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 (
<Fragment key={p.patient_id}>
<tr
data-patient-row={p.patient_id}
className={`border-b border-[var(--border-subtle)] cursor-pointer transition-colors hover:bg-[var(--row-hover)] ${
!p.rollup_status ? "opacity-60" : ""
} ${isExpanded ? "bg-[rgba(20,122,122,0.04)]" : ""}`}
onClick={() => setExpanded(isExpanded ? null : p.patient_id)}
>
<td className="pl-[18px] py-[13px] w-[26px] text-[9px] text-[var(--text-warm)] align-middle">
{isExpanded ? "▼" : "▶"}
</td>
<td className="px-[18px] py-[13px] align-middle">
<span className="font-mono text-[12.5px] font-bold text-[var(--brand)]">
{p.patient_id}
</span>
</td>
<td className="px-[18px] py-[13px] text-[13px] text-[var(--text-primary)] align-middle">
{(p.lines || []).map((l) => l.device_display).join(", ")}
</td>
<td className="px-[18px] py-[13px] text-[12.5px] text-[var(--text-secondary)] align-middle">
{(p.lines || [])[0]?.payer || "—"}
</td>
<td className="px-[18px] py-[13px] align-middle">
<VerdictBadge status={p.rollup_status} />
{capped && (
<div className="text-[10px] italic text-[var(--text-warm)] mt-[3px]">
Capped: includes a Plan Type Needed line
</div>
)}
{gaps.length > 0 && !isPTN && (
<div className="text-[10px] italic text-[#6B4F9A] mt-[3px]">
Data gap {gaps.map((g) => DOC_LABELS[g]).join(", ")} column not provided in the file
</div>
)}
{!p.rollup_status && (
<div className="text-[10px] italic text-[var(--text-warm)] mt-[3px]">
Non-CGM device displayed, not graded, excluded from counts
</div>
)}
</td>
<td className="px-[18px] py-[13px] font-mono text-[12px] text-[var(--text-warm)] align-middle">
{lastShip || "—"}
</td>
<td className="px-[18px] py-[13px] align-middle" onClick={(e) => e.stopPropagation()}>
{isPTN && (
<button
onClick={onImportClick}
className="inline-flex items-center gap-[5px] text-[11px] font-semibold text-[#6B4F9A] bg-[rgba(122,94,160,0.10)] border border-[#9B8EC4] rounded-[5px] px-[11px] py-[4px] cursor-pointer"
>
Map the plan type
</button>
)}
{gaps.length > 0 && !isPTN && (
<span className="inline-flex items-center gap-[4px] text-[10px] font-bold text-[#6B4F9A] bg-[rgba(122,94,160,0.10)] border border-[#9B8EC4] rounded-[4px] px-[7px] py-[1px]">
Fix the file, not the phone
</span>
)}
</td>
</tr>
{isExpanded &&
(p.lines || []).map((line) => (
<tr key={`${p.patient_id}:${line.device_type}:${line.plan_type}`} className="bg-[var(--bg-elevated)] border-b border-[var(--border-subtle)]">
<td colSpan={7} className="py-0">
<DeviceLine
patient={p}
line={line}
mappedFields={mappedFields}
getToken={getToken}
onRecomputed={(rec) => applyRecompute(p.patient_id, rec)}
onConfirmVisit={() =>
setConfirming({ record: lineToModalRecord(p, line), patientId: p.patient_id })
}
/>
</td>
</tr>
))}
</Fragment>
);
})}
{visible.length === 0 && (
<tr>
<td colSpan={7} className="px-[22px] py-8 text-center text-[var(--text-warm)]">
No patients match this filter.
</td>
</tr>
)}
</tbody>
</table>
{/* Footer */}
<div className="flex items-center justify-between px-[22px] py-3 border-t border-[var(--border-subtle)] text-[11px] text-[var(--text-warm)] bg-[var(--bg-elevated)]">
<span className="text-[var(--text-secondary)]">
PHI-safe patient names and DOBs never stored. Crosswalk: patient_id only.
</span>
<span>
{visible.length} of {patients.length} patients shown
</span>
</div>
{confirming && (
<ConfirmVisitModal
record={confirming.record}
onClose={() => setConfirming(null)}
onConfirmed={(patientId, updated) => applyRecompute(patientId, updated)}
/>
)}
</div>
);
}
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 (
<div className="pl-[56px] pr-[22px] py-[11px] border-l-4 border-[rgba(20,122,122,0.16)] ml-[18px]">
<div className="flex items-center gap-[10px] mb-[8px]">
<span className="text-[12px] font-bold text-[var(--text-primary)]">{line.device_display}</span>
<span className="text-[10.5px] text-[var(--text-warm)] bg-[var(--bg-card)] border border-[var(--border-color)] rounded px-[7px]">
{line.plan_type || "Plan type needed"}
</span>
<VerdictBadge status={line.line_status} />
{line.timing_flag && (
<span className="text-[10.5px] text-[var(--text-warm)] italic">
{TIMING_HINTS[line.timing_flag] || line.timing_flag}
</span>
)}
<span className="ml-auto font-mono text-[10px] text-[var(--text-warm)]">
{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" : ""}`}
</span>
</div>
{line.gradeable && items.length > 0 ? (
<div className="flex flex-wrap gap-y-[6px]">
{items.map((item) => (
<ChecklistItem
key={item.doc_type}
patient={patient}
line={line}
item={item}
fileGap={itemIsFileGap(item, mappedFields)}
getToken={getToken}
onRecomputed={onRecomputed}
/>
))}
{showConfirmVisit && (
<button
onClick={onConfirmVisit}
className="self-start ml-auto bg-transparent border border-[var(--accent)] text-[var(--accent-text)] px-[10px] py-[3px] rounded-md text-[11px] cursor-pointer whitespace-nowrap hover:bg-[rgba(203,107,32,0.1)]"
>
Confirm Visit
</button>
)}
</div>
) : (
<div className="text-[11px] italic text-[var(--text-warm)]">
Non-CGM device no readiness actions.
</div>
)}
</div>
);
}
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 (
<div className="flex items-start gap-[6px] basis-1/5 min-w-[170px] pr-[12px]">
<span className={`text-[12px] font-extrabold w-[14px] text-center ${icon.cls}`}>{icon.glyph}</span>
<div>
<div className="text-[11px] font-semibold text-[var(--text-primary)]">
{DOC_LABELS[item.doc_type]}
</div>
{cyclable && !fileGap ? (
<button
onClick={handleCycle}
disabled={saving}
title={`Click to update ${DOC_LABELS[item.doc_type]} status (this device only)`}
className={`text-[11px] font-medium bg-transparent border-none p-0 text-left cursor-pointer hover:underline underline-offset-2 disabled:opacity-50 ${
item.satisfied ? "text-[#166534]" : "text-[#CB6B20]"
}`}
>
{saving ? "Saving…" : valueText}
</button>
) : (
<div className={`text-[11px] ${fileGap ? "text-[#6B4F9A] font-semibold" : item.satisfied ? "text-[var(--text-secondary)]" : "text-[#CB6B20] font-medium"}`}>
{valueText}
</div>
)}
<div className="text-[9.5px] text-[var(--text-warm)]">
{item.rule_id ? `${item.rule_id}` : ""}
{fileGap ? " · resolves at re-import or mapping review" : ""}
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className="flex items-center gap-x-5 gap-y-2 flex-wrap px-[22px] py-[10px] border-b border-[var(--border-subtle)] bg-[var(--bg-elevated)] font-body">
{LEGEND_ITEMS.map((item) => (
{items.map((item) => (
<div key={item.name} className="flex items-center gap-[7px] whitespace-nowrap">
{item.check
? <span style={{ color: item.color, fontSize: "13px", fontWeight: 800, lineHeight: 1, width: "8px", textAlign: "center" }}></span>
: item.diamond
? <span style={{ color: item.color, fontSize: "11px", fontWeight: 800, lineHeight: 1, width: "8px", textAlign: "center" }}></span>
: <span className="inline-block rounded-full shrink-0" style={{ width: "8px", height: "8px", backgroundColor: item.color }} />
}
<span className="text-[11.5px] font-semibold text-[var(--text-secondary)]">

View file

@ -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 }),
}),

View file

@ -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")