From 5c9365ec33b37745c2c8c0e7ce3bb61e05aabf9a Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 21 Jun 2026 00:13:47 -0600 Subject: [PATCH] feat(sp14): 5-lane Inbox UI with Payer-Rejected acknowledge action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap between the SP10 backend (5 lanes) and the SP6 frontend (4 lanes). The Payer-Rejected lane (277CA STC A4/A6/A7) is now rendered alongside Rejected/Candidates/Unmatched/Done, with an Acknowledge bulk action that drops claims from the working surface without erasing the original 277CA rejection event (audit log stays intact, SP11). Backend: * Migration 0010: add payer_rejected_acknowledged_at + payer_rejected_acknowledged_actor columns + partial index. * db.py: surface the two new columns on the Claim model. * inbox_lanes.py: filter acknowledged claims out of the payer_rejected lane; expose the new fields on the row payload for forward-compat (e.g. a future 'Recently acknowledged' view). * api.py: - POST /api/inbox/payer-rejected/acknowledge Bulk-acknowledge. Idempotent. Returns transitioned / already_acked / not_found / not_rejected counts so the UI can show '3 of 5 were already acknowledged' on a noop bulk. Writes a 'claim.payer_rejected_acknowledged' event to the SP11 hash-chained audit log. - GET /api/inbox/export.csv: accept 'payer_rejected' lane. * test_acks.py: bump user_version assertion to 10. * test_lane_filter_acknowledged.py: 4 tests for the lane filter and forward-compat row payload. * test_payer_rejected_acknowledge.py: 6 tests for the endpoint (happy path, idempotency, no-op on non-rejected, missing ids, 400 on empty, audit-log wiring + chain integrity). Frontend: * lib/inbox-api.ts: add payer_rejected to InboxLanes, add acknowledgePayerRejected(), update exportInboxCsvUrl union. * hooks/useInboxLanes.ts: add payer_rejected to initial state. * hooks/useInboxLanes.test.ts: add payer_rejected to mocks. * components/inbox/BulkBar.tsx: add 'payer_rejected' lane with Acknowledge action (no Resubmit, no Dismiss — payer-rejected is not eligible for either). * components/inbox/BulkBar.test.tsx: add payer_rejected test. * pages/Inbox.tsx: render the 5th lane, hook up onAcknowledge, include payer_rejected in the needEyes count. * pages/Inbox.test.tsx: 3 new tests (5-lane render, need-eyes count, acknowledge action hits the right endpoint). * components/inbox/InboxHeader.tsx: doc comment now explains why payer_rejected rolls up into need-eyes. Pre-existing typecheck warnings in BulkBar.test.tsx / InboxRow .test.tsx / Lane.tsx / download.test.ts are unchanged from main — not touched here. Test counts: backend 724 -> 734 (+10). Frontend 350 -> 354 (+4). --- backend/src/cyclone/api.py | 74 +++++++- backend/src/cyclone/db.py | 10 ++ backend/src/cyclone/inbox_lanes.py | 15 +- .../0010_payer_rejected_acknowledged.sql | 22 +++ backend/tests/test_acks.py | 10 +- .../tests/test_lane_filter_acknowledged.py | 105 +++++++++++ .../tests/test_payer_rejected_acknowledge.py | 168 ++++++++++++++++++ src/components/inbox/BulkBar.test.tsx | 28 ++- src/components/inbox/BulkBar.tsx | 39 +++- src/components/inbox/InboxHeader.tsx | 13 ++ src/hooks/useInboxLanes.test.ts | 3 + src/hooks/useInboxLanes.ts | 2 + src/lib/inbox-api.ts | 56 +++++- src/pages/Inbox.test.tsx | 112 +++++++++++- src/pages/Inbox.tsx | 65 ++++++- 15 files changed, 704 insertions(+), 18 deletions(-) create mode 100644 backend/src/cyclone/migrations/0010_payer_rejected_acknowledged.sql create mode 100644 backend/tests/test_lane_filter_acknowledged.py create mode 100644 backend/tests/test_payer_rejected_acknowledge.py diff --git a/backend/src/cyclone/api.py b/backend/src/cyclone/api.py index df95cb6..84037fb 100644 --- a/backend/src/cyclone/api.py +++ b/backend/src/cyclone/api.py @@ -1105,6 +1105,78 @@ def inbox_dismiss_candidates(body: dict): return {"ok": True, "dismissed_count": len(pairs)} +# --------------------------------------------------------------------------- +# SP14: Payer-Rejected acknowledge +# +# Operator hits "Acknowledge" on the Payer-Rejected Inbox lane to clear +# the claim from the working surface. We don't delete the rejection +# (the original payer_rejected_* fields stay for SP11 audit), we just +# set payer_rejected_acknowledged_at so the lane query filters it out. +# +# Idempotent: re-acknowledging an already-acknowledged claim is a noop +# (the timestamp is not bumped). Returns the count actually transitioned +# so the UI can show "3 of 5 were already acknowledged". +# --------------------------------------------------------------------------- +@app.post("/api/inbox/payer-rejected/acknowledge") +def inbox_acknowledge_payer_rejected(body: dict): + """Mark Payer-Rejected claims as acknowledged by the operator.""" + claim_ids = body.get("claim_ids") or [] + actor = body.get("actor") or "operator" + if not isinstance(claim_ids, list) or not claim_ids: + raise HTTPException(400, "claim_ids must be a non-empty list") + if not all(isinstance(c, str) for c in claim_ids): + raise HTTPException(400, "claim_ids must be a list of strings") + + with db.SessionLocal()() as session: + from cyclone.db import Claim + now = datetime.now(timezone.utc) + transitioned = 0 + already_acked = 0 + not_found = 0 + not_rejected = 0 + for cid in claim_ids: + claim = session.get(Claim, cid) + if claim is None: + not_found += 1 + continue + if claim.payer_rejected_at is None: + not_rejected += 1 + continue + if claim.payer_rejected_acknowledged_at is not None: + already_acked += 1 + continue + claim.payer_rejected_acknowledged_at = now + claim.payer_rejected_acknowledged_actor = actor + transitioned += 1 + # SP11: audit event for the acknowledge action. + try: + from cyclone.audit_log import append_event, AuditEvent + append_event(session, AuditEvent( + event_type="claim.payer_rejected_acknowledged", + entity_type="claim", + entity_id=claim.id, + actor=actor, + payload={ + "payer_rejected_status_code": claim.payer_rejected_status_code, + "payer_rejected_by_277ca_id": claim.payer_rejected_by_277ca_id, + }, + )) + except Exception: # noqa: BLE001 + # Audit append is best-effort; don't block the operator's + # acknowledge action on an audit-log failure. + pass + if transitioned: + session.commit() + + return { + "ok": True, + "transitioned": transitioned, + "already_acked": already_acked, + "not_found": not_found, + "not_rejected": not_rejected, + } + + @app.post("/api/inbox/rejected/resubmit") def inbox_resubmit_rejected( request: Request, @@ -1202,7 +1274,7 @@ def inbox_resubmit_rejected( @app.get("/api/inbox/export.csv") def inbox_export_csv(lane: str): """Stream a CSV for a single lane.""" - if lane not in {"rejected", "candidates", "unmatched", "done_today"}: + if lane not in {"rejected", "candidates", "unmatched", "done_today", "payer_rejected"}: raise HTTPException(400, f"unknown lane: {lane}") dismissed_pairs = getattr(app.state, "dismissed_pairs", set()) with db.SessionLocal()() as session: diff --git a/backend/src/cyclone/db.py b/backend/src/cyclone/db.py index 1f57a00..bf5131e 100644 --- a/backend/src/cyclone/db.py +++ b/backend/src/cyclone/db.py @@ -239,6 +239,16 @@ class Claim(Base): payer_rejected_by_277ca_id: Mapped[Optional[str]] = mapped_column( String(64), nullable=True ) + # SP14: when the operator hits "Acknowledge" on the Payer-Rejected + # lane, we set this timestamp. The lane query filters on it being + # NULL so acknowledged claims drop out of the working surface. The + # original payer_rejected_* fields stay intact for audit (SP11). + payer_rejected_acknowledged_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True + ) + payer_rejected_acknowledged_actor: Mapped[Optional[str]] = mapped_column( + String(64), nullable=True + ) resubmit_count: Mapped[int] = mapped_column( Integer, nullable=False, default=0, server_default=text("0") ) diff --git a/backend/src/cyclone/inbox_lanes.py b/backend/src/cyclone/inbox_lanes.py index 1a8a9d4..fcce9be 100644 --- a/backend/src/cyclone/inbox_lanes.py +++ b/backend/src/cyclone/inbox_lanes.py @@ -73,6 +73,12 @@ def _claim_to_row( "payer_rejected_reason": c.payer_rejected_reason, "payer_rejected_status_code": c.payer_rejected_status_code, "payer_rejected_by_277ca_id": c.payer_rejected_by_277ca_id, + # SP14: acknowledgment tracking. Always null on the lane + # (we filter acknowledged claims out) but exposed for + # forward-compat if we later add a "Recently acknowledged" + # inspector view. + "payer_rejected_acknowledged_at": _isoformat(c.payer_rejected_acknowledged_at), + "payer_rejected_acknowledged_actor": c.payer_rejected_acknowledged_actor, } @@ -192,8 +198,15 @@ def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> # by the payer after we submitted a syntactically-valid file). # We don't filter by Claim.state here because the claim may still # be in SUBMITTED state — the payer just hasn't paid it yet. + # + # SP14: filter out claims the operator has already acknowledged. + # The original payer_rejected_* fields stay intact for audit; + # only the working surface (this lane) is filtered. payer_rejected_claims = ( - session.query(Claim).filter(Claim.payer_rejected_at.is_not(None)).all() + session.query(Claim) + .filter(Claim.payer_rejected_at.is_not(None)) + .filter(Claim.payer_rejected_acknowledged_at.is_(None)) + .all() ) pr_matched, pr_total = _line_count_lookup(session, payer_rejected_claims) matched_counts.update(pr_matched) diff --git a/backend/src/cyclone/migrations/0010_payer_rejected_acknowledged.sql b/backend/src/cyclone/migrations/0010_payer_rejected_acknowledged.sql new file mode 100644 index 0000000..906d10a --- /dev/null +++ b/backend/src/cyclone/migrations/0010_payer_rejected_acknowledged.sql @@ -0,0 +1,22 @@ +-- version: 10 +-- SP14: Payer-Rejected lane acknowledge +-- When the operator reviews a payer-rejected claim, they hit the +-- "Acknowledge" bulk action. We mark the claim with the timestamp so +-- the lane query can filter it out (it stays in the DB for audit but +-- doesn't show up in the operator's working surface). +-- +-- Why a separate column instead of clearing payer_rejected_at: +-- * Audit trail: SP11's hash-chained audit_log needs the *original* +-- rejection event intact. Clearing the timestamp would erase the +-- evidence of the payer saying "no" — exactly what auditors want +-- to see. +-- * Reconciliation: future SPs that match payer-rejected claims +-- against appeals (e.g. SP17) can still see the original status +-- code and reason. + +ALTER TABLE claims ADD COLUMN payer_rejected_acknowledged_at TEXT; +ALTER TABLE claims ADD COLUMN payer_rejected_acknowledged_actor TEXT; + +CREATE INDEX idx_claims_payer_rejected_unack + ON claims(payer_rejected_at) + WHERE payer_rejected_acknowledged_at IS NULL; diff --git a/backend/tests/test_acks.py b/backend/tests/test_acks.py index ed75b6e..39f1dfc 100644 --- a/backend/tests/test_acks.py +++ b/backend/tests/test_acks.py @@ -51,18 +51,18 @@ def test_migration_0002_creates_acks_table(): def test_migration_latest_idempotent_on_fresh_db(): """Re-running the migration on the same DB must be a no-op (PRAGMA - user_version already at the latest version — currently 9 after + user_version already at the latest version — currently 10 after 0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007 - providers/payers/clearhouse, SP10's 0008 payer_rejected, and - SP11's 0009 audit_log).""" + providers/payers/clearhouse, SP10's 0008 payer_rejected, + SP11's 0009 audit_log, and SP14's 0010 payer_rejected_acknowledged).""" with db.engine().begin() as c: v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0 - assert v1 == 9 + assert v1 == 10 # A second run should not raise and should not bump the version. db_migrate.run(db.engine()) with db.engine().begin() as c: v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0 - assert v2 == 9 + assert v2 == 10 def test_add_ack_persists_row(): diff --git a/backend/tests/test_lane_filter_acknowledged.py b/backend/tests/test_lane_filter_acknowledged.py new file mode 100644 index 0000000..0af4438 --- /dev/null +++ b/backend/tests/test_lane_filter_acknowledged.py @@ -0,0 +1,105 @@ +"""SP14 — Lane filter drops acknowledged payer-rejected claims. + +The Payer-Rejected Inbox lane must not include claims the operator +has already acknowledged. This is the working-surface UX: acknowledged +claims drop out so the operator only sees new rejections. +""" +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from cyclone import db +from cyclone.db import Batch, Claim, ClaimState +from cyclone.inbox_lanes import compute_lanes + + +@pytest.fixture(autouse=True) +def _setup(tmp_path, monkeypatch): + monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db") + db._reset_for_tests() + db.init_db() + + +def _seed(*, acked: bool = False) -> str: + now = datetime.now(timezone.utc) + cid = "C1" + with db.SessionLocal()() as session: + batch = Batch( + id="b-1", kind="837p", + input_filename="t.x12", parsed_at=now, + ) + session.add(batch) + session.flush() + claim = Claim( + id=cid, batch_id=batch.id, patient_control_number=cid, + state=ClaimState.SUBMITTED, charge_amount=100, + payer_rejected_at=now, + payer_rejected_status_code="A7", + payer_rejected_reason="invalid dx", + ) + if acked: + claim.payer_rejected_acknowledged_at = now + claim.payer_rejected_acknowledged_actor = "operator" + session.add(claim) + session.commit() + return cid + + +def test_unacknowledged_claim_appears_in_lane(): + _seed(acked=False) + with db.SessionLocal()() as session: + lanes = compute_lanes(session, dismissed_pairs=set()) + assert len(lanes.payer_rejected) == 1 + assert lanes.payer_rejected[0]["id"] == "C1" + + +def test_acknowledged_claim_drops_out_of_lane(): + _seed(acked=True) + with db.SessionLocal()() as session: + lanes = compute_lanes(session, dismissed_pairs=set()) + assert lanes.payer_rejected == [] + + +def test_mix_of_acked_and_unacked(): + """Only the unacknowledged one shows up.""" + now = datetime.now(timezone.utc) + with db.SessionLocal()() as session: + batch = Batch( + id="b-1", kind="837p", + input_filename="t.x12", parsed_at=now, + ) + session.add(batch) + session.flush() + for i, acked in enumerate([True, False, True, False]): + claim = Claim( + id=f"C{i}", batch_id=batch.id, + patient_control_number=f"PCN-{i}", + state=ClaimState.SUBMITTED, charge_amount=100, + payer_rejected_at=now, + payer_rejected_status_code="A7", + ) + if acked: + claim.payer_rejected_acknowledged_at = now + session.add(claim) + session.commit() + + with db.SessionLocal()() as session: + lanes = compute_lanes(session, dismissed_pairs=set()) + ids = [r["id"] for r in lanes.payer_rejected] + assert sorted(ids) == ["C1", "C3"] + + +def test_lane_row_carries_ack_fields_for_forward_compat(): + """The row payload still carries the ack fields (all null on the lane) + so future views that want a 'Recently acknowledged' section don't need + a schema change.""" + _seed(acked=False) + with db.SessionLocal()() as session: + lanes = compute_lanes(session, dismissed_pairs=set()) + row = lanes.payer_rejected[0] + assert "payer_rejected_acknowledged_at" in row + assert row["payer_rejected_acknowledged_at"] is None + assert "payer_rejected_acknowledged_actor" in row + assert row["payer_rejected_acknowledged_actor"] is None diff --git a/backend/tests/test_payer_rejected_acknowledge.py b/backend/tests/test_payer_rejected_acknowledge.py new file mode 100644 index 0000000..9cb0cdd --- /dev/null +++ b/backend/tests/test_payer_rejected_acknowledge.py @@ -0,0 +1,168 @@ +"""SP14 — Payer-Rejected acknowledge endpoint tests. + +The endpoint marks payer-rejected claims as acknowledged so the +working-surface lane query filters them out. The original +payer_rejected_* fields stay intact (SP11 audit). +""" +from __future__ import annotations + +import pytest + +from cyclone import db +from cyclone.db import Claim, ClaimState +from cyclone.audit_log import verify_chain + + +@pytest.fixture(autouse=True) +def _setup(tmp_path, monkeypatch): + monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db") + db._reset_for_tests() + db.init_db() + + +def _make_claim(claim_id: str, *, payer_rejected: bool = True) -> None: + """Insert a minimal claim with optional payer_rejected markers.""" + from datetime import datetime, timezone + from cyclone.db import Batch + with db.SessionLocal()() as session: + batch = Batch( + id=f"b-{claim_id}", + kind="837p", + input_filename="t.x12", + parsed_at=datetime.now(timezone.utc), + ) + session.add(batch) + session.flush() + claim = Claim( + id=claim_id, + batch_id=batch.id, + patient_control_number=claim_id, + state=ClaimState.SUBMITTED, + charge_amount=100, + ) + if payer_rejected: + now = datetime.now(timezone.utc) + claim.payer_rejected_at = now + claim.payer_rejected_reason = "invalid diagnosis code" + claim.payer_rejected_status_code = "A7" + session.add(claim) + session.commit() + + +def test_acknowledge_marks_unacknowledged_claim(): + _make_claim("C1", payer_rejected=True) + from fastapi.testclient import TestClient + from cyclone.api import app + with TestClient(app) as client: + r = client.post( + "/api/inbox/payer-rejected/acknowledge", + json={"claim_ids": ["C1"], "actor": "operator"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["ok"] is True + assert body["transitioned"] == 1 + assert body["already_acked"] == 0 + assert body["not_found"] == 0 + assert body["not_rejected"] == 0 + + with db.SessionLocal()() as session: + c = session.get(Claim, "C1") + assert c.payer_rejected_acknowledged_at is not None + assert c.payer_rejected_acknowledged_actor == "operator" + # Original fields stay intact for audit. + assert c.payer_rejected_at is not None + assert c.payer_rejected_status_code == "A7" + assert c.payer_rejected_reason == "invalid diagnosis code" + + +def test_acknowledge_is_idempotent(): + _make_claim("C1", payer_rejected=True) + from fastapi.testclient import TestClient + from cyclone.api import app + with TestClient(app) as client: + r1 = client.post( + "/api/inbox/payer-rejected/acknowledge", + json={"claim_ids": ["C1"]}, + ) + assert r1.json()["transitioned"] == 1 + r2 = client.post( + "/api/inbox/payer-rejected/acknowledge", + json={"claim_ids": ["C1"]}, + ) + assert r2.json()["transitioned"] == 0 + assert r2.json()["already_acked"] == 1 + + +def test_acknowledge_skips_non_payer_rejected_claims(): + _make_claim("C1", payer_rejected=False) + from fastapi.testclient import TestClient + from cyclone.api import app + with TestClient(app) as client: + r = client.post( + "/api/inbox/payer-rejected/acknowledge", + json={"claim_ids": ["C1"]}, + ) + assert r.status_code == 200 + body = r.json() + assert body["transitioned"] == 0 + assert body["not_rejected"] == 1 + + +def test_acknowledge_counts_missing_ids(): + from fastapi.testclient import TestClient + from cyclone.api import app + with TestClient(app) as client: + r = client.post( + "/api/inbox/payer-rejected/acknowledge", + json={"claim_ids": ["nope"]}, + ) + assert r.status_code == 200 + body = r.json() + assert body["transitioned"] == 0 + assert body["not_found"] == 1 + + +def test_acknowledge_empty_claim_ids_400(): + from fastapi.testclient import TestClient + from cyclone.api import app + with TestClient(app) as client: + r = client.post( + "/api/inbox/payer-rejected/acknowledge", + json={"claim_ids": []}, + ) + assert r.status_code == 400 + + +def test_acknowledge_writes_audit_event(): + _make_claim("C1", payer_rejected=True) + from fastapi.testclient import TestClient + from cyclone.api import app + with TestClient(app) as client: + r = client.post( + "/api/inbox/payer-rejected/acknowledge", + json={"claim_ids": ["C1"], "actor": "alice"}, + ) + assert r.status_code == 200 + + # SP11: the chain is intact and includes the new event. + with db.SessionLocal()() as session: + result = verify_chain(session) + assert result.ok, f"chain broken: {result.reason} at {result.first_bad_id}" + + # And the event itself is queryable. + import json as _json + from cyclone.db import AuditLog + with db.SessionLocal()() as session: + events = ( + session.query(AuditLog) + .filter(AuditLog.event_type == "claim.payer_rejected_acknowledged") + .all() + ) + assert len(events) == 1 + e = events[0] + assert e.entity_type == "claim" + assert e.entity_id == "C1" + assert e.actor == "alice" + payload = _json.loads(e.payload_json) if e.payload_json else {} + assert payload["payer_rejected_status_code"] == "A7" diff --git a/src/components/inbox/BulkBar.test.tsx b/src/components/inbox/BulkBar.test.tsx index 40ecfcc..c1c9586 100644 --- a/src/components/inbox/BulkBar.test.tsx +++ b/src/components/inbox/BulkBar.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { afterEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen, fireEvent } from "@testing-library/react"; +import { cleanup, render, fireEvent } from "@testing-library/react"; import { BulkBar } from "./BulkBar"; afterEach(() => cleanup()); @@ -12,6 +12,7 @@ describe("BulkBar", () => { lane="rejected" count={3} onResubmit={vi.fn()} + onAcknowledge={() => {}} onDismiss={vi.fn()} onExport={vi.fn()} />, @@ -28,6 +29,7 @@ describe("BulkBar", () => { lane="rejected" count={2} onResubmit={onResubmit} + onAcknowledge={() => {}} onDismiss={() => {}} onExport={() => {}} />, @@ -39,12 +41,36 @@ describe("BulkBar", () => { expect(onResubmit).toHaveBeenCalledOnce(); }); + it("SP14: payer_rejected lane shows Acknowledge (not Resubmit/Dismiss)", () => { + // Payer-rejected claims are not eligible for resubmit (the payer + // already said no); the operator can only acknowledge them as + // "I've seen this" and export the row set for follow-up. + const onAcknowledge = vi.fn(); + const { container, getByTestId } = render( + {}} + onAcknowledge={onAcknowledge} + onDismiss={() => {}} + onExport={() => {}} + />, + ); + expect(container.textContent).toMatch(/acknowledge/i); + expect(container.textContent).not.toMatch(/re-?submit/i); + expect(container.textContent).not.toMatch(/dismiss/i); + const ack = getByTestId("payer-rejected-acknowledge"); + fireEvent.click(ack); + expect(onAcknowledge).toHaveBeenCalledOnce(); + }); + it("hides when count is zero", () => { const { container } = render( {}} + onAcknowledge={() => {}} onDismiss={() => {}} onExport={() => {}} />, diff --git a/src/components/inbox/BulkBar.tsx b/src/components/inbox/BulkBar.tsx index e07c45d..da03184 100644 --- a/src/components/inbox/BulkBar.tsx +++ b/src/components/inbox/BulkBar.tsx @@ -1,13 +1,40 @@ +// --------------------------------------------------------------------------- +// BulkBar +// +// Per-lane floating action bar. Each Inbox lane has its own BulkBar — +// the bar shows when the lane has a selection and exposes the actions +// appropriate to that lane: +// +// * rejected → Re-submit (move back to SUBMITTED) +// * payer_rejected → Acknowledge (drop from working surface; SP14) +// * candidates → Dismiss (suppress a remit/claim pair) +// * unmatched → (no destructive action; just export) +// * done_today → (read-only; just export) +// +// Every lane gets the Export CSV action so the operator can ship +// anything actionable to a file for off-system follow-up (appeals, +// re-keying, posting to the billing system of record, etc). +// --------------------------------------------------------------------------- + +type LaneKey = + | "rejected" + | "payer_rejected" + | "candidates" + | "unmatched" + | "done_today"; + export function BulkBar({ lane, count, onResubmit, + onAcknowledge, onDismiss, onExport, }: { - lane: "rejected" | "candidates" | "unmatched" | "done_today"; + lane: LaneKey; count: number; onResubmit: () => void; + onAcknowledge: () => void; onDismiss: () => void; onExport: () => void; }) { @@ -52,6 +79,16 @@ export function BulkBar({ Re-submit ({count}) )} + {lane === "payer_rejected" && ( + + )} {lane === "candidates" && (