From ffacfd8665265e645852ae84d2b2702447d51afb Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 13:16:38 -0600 Subject: [PATCH] feat(sp29): inbox rejected-row 999 ack chips + per-row Resubmit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend: _ack_summary_for_claims helper attaches claim_acks payload to inbox rejected-lane rows (3 tests) - frontend: InboxRow renders newest 3 AK2 chips + '+N more' overflow under the rejection reason, with '999 not linked' marker when an ack couldn't be linked to a claim - frontend: per-row Resubmit button downloads a single corrected 837 via api.serializeClaim837 (no bulk modal, no zip — one click, one .x12 file) - frontend: 2 new tests for the chip rendering and the per-row download flow --- backend/src/cyclone/inbox_lanes.py | 87 +++++++++++++++++ backend/tests/test_inbox_lanes.py | 110 +++++++++++++++++++++- src/components/inbox/InboxRow.test.tsx | 111 ++++++++++++++++++++++ src/components/inbox/InboxRow.tsx | 124 ++++++++++++++++++++++++- src/components/inbox/Lane.tsx | 16 +++- src/lib/inbox-api.ts | 22 +++++ src/pages/Inbox.test.tsx | 109 ++++++++++++++++++++++ src/pages/Inbox.tsx | 25 ++++- 8 files changed, 596 insertions(+), 8 deletions(-) diff --git a/backend/src/cyclone/inbox_lanes.py b/backend/src/cyclone/inbox_lanes.py index fcce9be..5d47ef7 100644 --- a/backend/src/cyclone/inbox_lanes.py +++ b/backend/src/cyclone/inbox_lanes.py @@ -175,6 +175,82 @@ def _line_count_lookup(session: Session, claims: list[Claim]) -> tuple[dict, dic return matched_counts, total_lines_by_claim +def _ack_summary_for_claims( + session: Session, claim_ids: list[str], +) -> dict[str, dict]: + """Build a {claim_id: {total, rejected, items: [...]}} map for 999 acks. + + SP29: the Inbox `rejected` lane needs to render AK2 evidence + inline per row, plus a per-row Resubmit button. The data lives + in ``claim_acks`` (SP28) — we don't want to N+1 fetch per row, + so the whole rejected-claim set is summarized in one batched + query here. + + Filters to ``ack_kind='999'`` because the rejected lane is the + 999 envelope reject lane; the 277CA STC A4/A6/A7 evidence flows + through the ``payer_rejected_*`` fields on a separate lane and + isn't part of this scope (see SP29 spec D4 / scope). + + Returns: + ``{claim_id: {"total": int, "rejected": int, "items": [...]}, ...}`` + Claims with zero linked 999 acks are NOT in the returned + dict — the caller maps via ``.get(cid)`` and treats absence + as "no 999 acks linked" (renders as ``null`` in the + payload, ``999 not linked`` in the UI). + + Args: + session: SQLAlchemy session the caller owns. + claim_ids: list of claim.id values to summarize. Typically + the rejected-lane claim ids. Empty list → empty dict. + """ + if not claim_ids: + return {} + from cyclone.db import ClaimAck # late import — DB model registered + rows = ( + session.query( + ClaimAck.claim_id, + ClaimAck.ack_id, + ClaimAck.set_control_number, + ClaimAck.set_accept_reject_code, + ClaimAck.ak2_index, + ClaimAck.linked_at, + ) + .filter( + ClaimAck.claim_id.in_(claim_ids), + ClaimAck.ack_kind == "999", + ) + .order_by(ClaimAck.linked_at.desc(), ClaimAck.id.desc()) + .all() + ) + grouped: dict[str, list[tuple]] = {} + for cid, aid, scn, code, ak2i, lat in rows: + grouped.setdefault(cid, []).append((aid, scn, code, ak2i, lat)) + + rejected_codes = {"R", "E", "X"} + out: dict[str, dict] = {} + for cid, items in grouped.items(): + total = len(items) + rejected_count = sum(1 for it in items if (it[2] or "") in rejected_codes) + # Keep 5 most recent items for the chip column. The full count + # is in ``total`` so the UI can show ``+N more`` honestly. + trimmed = items[:5] + out[cid] = { + "total": total, + "rejected": rejected_count, + "items": [ + { + "ack_id": aid, + "set_control_number": scn, + "set_accept_reject_code": code or "", + "ak2_index": ak2i, + "linked_at": _isoformat(lat), + } + for (aid, scn, code, ak2i, lat) in trimmed + ], + } + return out + + def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> Lanes: lanes = Lanes() dismissed = set(dismissed_pairs) @@ -192,6 +268,17 @@ def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> ), )) + # SP29: attach the 999 ack-evidence summary (total / rejected / + # 5 most recent AK2 set_responses) to every rejected row so the + # Inbox can render AK2 chips inline + a per-row Resubmit button + # without an extra round-trip. One batched query, keyed off the + # rejected-claim id set. + rejected_ack_summary = _ack_summary_for_claims( + session, [r["id"] for r in lanes.rejected] + ) + for row in lanes.rejected: + row["claim_acks"] = rejected_ack_summary.get(row["id"]) + # --- Payer-Rejected (SP10) --- # Distinct from the 999 envelope "rejected" lane above. A claim # lands here when a 277CA STC category code is A4/A6/A7 (rejected diff --git a/backend/tests/test_inbox_lanes.py b/backend/tests/test_inbox_lanes.py index 776d895..e140cfb 100644 --- a/backend/tests/test_inbox_lanes.py +++ b/backend/tests/test_inbox_lanes.py @@ -12,7 +12,7 @@ import pytest from cyclone import db from cyclone.db import ( - Base, Batch, Claim, ClaimState, Remittance, init_db, + Base, Batch, Claim, Ack, ClaimAck, ClaimState, Remittance, init_db, ) from cyclone.inbox_lanes import compute_lanes @@ -142,3 +142,111 @@ def test_done_today_includes_recent_terminal_states(): ids = {r["id"] for r in lanes.done_today} assert "C1" in ids assert "C2" not in ids + + +# --------------------------------------------------------------------------- +# SP29: rejected-lane rows must carry the per-claim 999 ack-evidence summary +# so the Inbox can render AK2 chips inline + a per-row Resubmit button +# without an extra round-trip. +# --------------------------------------------------------------------------- + +def _add_999_ack(*, ack_id: int, parsed_at=None) -> None: + """Persist a minimal Ack row (the 999 envelope).""" + with db.SessionLocal()() as s: + if s.get(Ack, ack_id) is not None: + return + s.add(Ack( + id=ack_id, source_batch_id="B-1", + accepted_count=0, rejected_count=0, received_count=1, + ack_code="A", + parsed_at=parsed_at or datetime.now(timezone.utc), + raw_json={"set_responses": []}, + )) + s.commit() + + +def _link_claim_ack( + *, ack_id: int, claim_id: str, ak2_index: int, + set_accept_reject_code: str, + linked_at=None, set_control_number: str = "991102989", +) -> None: + """Persist a single ClaimAck row linking an Ack to a Claim.""" + with db.SessionLocal()() as s: + s.add(ClaimAck( + claim_id=claim_id, ack_id=ack_id, ack_kind="999", + ak2_index=ak2_index, + set_control_number=set_control_number, + set_accept_reject_code=set_accept_reject_code, + linked_at=linked_at or datetime.now(timezone.utc), + linked_by="auto", + )) + s.commit() + + +def test_inbox_lanes_attaches_claim_acks_summary_for_rejected(): + """A rejected-lane row gets a `claim_acks` field with total / + rejected counts + 5 most recent AK2 set_responses. Newest first. + """ + _add_claim(claim_id="REJ-1", state=ClaimState.REJECTED) + # 3 linked 999 acks: 2 accepted, 1 rejected + _add_999_ack(ack_id=1) + _add_999_ack(ack_id=2) + _add_999_ack(ack_id=3) + t0 = datetime.now(timezone.utc) + _link_claim_ack(ack_id=1, claim_id="REJ-1", ak2_index=0, + set_accept_reject_code="A", + linked_at=t0 - timedelta(minutes=10)) + _link_claim_ack(ack_id=2, claim_id="REJ-1", ak2_index=1, + set_accept_reject_code="R", + linked_at=t0 - timedelta(minutes=5)) + _link_claim_ack(ack_id=3, claim_id="REJ-1", ak2_index=2, + set_accept_reject_code="A", + linked_at=t0 - timedelta(minutes=1)) + + with db.SessionLocal()() as s: + lanes = compute_lanes(s, dismissed_pairs=set()) + + rows = [r for r in lanes.rejected if r["id"] == "REJ-1"] + assert len(rows) == 1 + row = rows[0] + assert "claim_acks" in row + summary = row["claim_acks"] + assert summary is not None + assert summary["total"] == 3 + assert summary["rejected"] == 1 + assert len(summary["items"]) == 3 + # Newest first (descending linked_at) + assert summary["items"][0]["set_accept_reject_code"] == "A" # ack 3 (most recent) + assert summary["items"][1]["set_accept_reject_code"] == "R" # ack 2 (middle) + assert summary["items"][2]["set_accept_reject_code"] == "A" # ack 1 (oldest) + # The R code is the one rejected entry + codes = [it["set_accept_reject_code"] for it in summary["items"]] + assert codes.count("R") == 1 + # ak2_index is preserved + assert summary["items"][0]["ak2_index"] == 2 + assert summary["items"][1]["ak2_index"] == 1 + + +def test_inbox_lanes_claim_acks_is_null_when_no_links(): + """A rejected claim with zero linked 999 acks renders + `claim_acks: null` (the UI shows '999 not linked').""" + _add_claim(claim_id="REJ-2", state=ClaimState.REJECTED) + with db.SessionLocal()() as s: + lanes = compute_lanes(s, dismissed_pairs=set()) + rows = [r for r in lanes.rejected if r["id"] == "REJ-2"] + assert len(rows) == 1 + assert rows[0]["claim_acks"] is None + + +def test_inbox_lanes_claim_acks_does_not_attach_to_other_lanes(): + """The ack summary is only on `rejected`, not `payer_rejected` / + `unmatched` / `done_today` — those lanes have their own evidence + shape (payer_rejected_*, no linked 999s by construction).""" + _add_claim(claim_id="SUB-1", state=ClaimState.SUBMITTED) + _add_claim(claim_id="DEN-1", state=ClaimState.DENIED) + with db.SessionLocal()() as s: + lanes = compute_lanes(s, dismissed_pairs=set()) + for r in lanes.unmatched: + assert "claim_acks" not in r + for r in lanes.done_today: + assert "claim_acks" not in r diff --git a/src/components/inbox/InboxRow.test.tsx b/src/components/inbox/InboxRow.test.tsx index 67c735c..7785e29 100644 --- a/src/components/inbox/InboxRow.test.tsx +++ b/src/components/inbox/InboxRow.test.tsx @@ -87,4 +87,115 @@ describe("InboxRow", () => { ); expect(container.textContent).toContain("92"); }); + + // --------------------------------------------------------------------- + // SP29: per-AK2 chip evidence + per-row Resubmit gesture for the + // rejected-lane drill. The page renders 999 AK2 set-response codes + // inline on the row so the operator can spot "this batch had R/E/X + // rejects" without opening the drawer. The per-row Resubmit button + // downloads a single corrected 837. + // --------------------------------------------------------------------- + it("SP29: rejected row renders inline 999 ack evidence chips + Resubmit button", () => { + // 4 linked 999 AK2 set-responses — 3 will render as chips, the + // 4th triggers the "+N more" overflow indicator. + const rejectedRow: InboxClaimRow = { + ...baseClaim, + id: "REJ1", + claim_acks: { + total: 4, + rejected: 2, + items: [ + { + ack_id: 11, + set_control_number: "991102989", + set_accept_reject_code: "A", + ak2_index: 0, + linked_at: "2026-07-02T10:03:00Z", + }, + { + ack_id: 12, + set_control_number: "991102989", + set_accept_reject_code: "R", + ak2_index: 1, + linked_at: "2026-07-02T10:02:00Z", + }, + { + ack_id: 13, + set_control_number: "991102989", + set_accept_reject_code: "E", + ak2_index: 2, + linked_at: "2026-07-02T10:01:00Z", + }, + ], + }, + }; + const onResubmitOne = vi.fn(); + const { container } = render( + + + {}} + onResubmitOne={onResubmitOne} + /> + +
, + ); + + // Chip visible — the operator can read at a glance that the most + // recent AK2 from this batch was an Accept, then a Reject, then + // an Error. The "999 · A" / "999 · R" / "999 · E" text comes from + // rendering "·". + expect(container.textContent).toContain("991102989·A"); + expect(container.textContent).toContain("991102989·R"); + expect(container.textContent).toContain("991102989·E"); + + // Overflow "+1 more" — total=4 but only 3 chips fit. + expect(container.textContent).toContain("+1 more"); + + // Resubmit button rendered with the per-row testid. + const btn = screen.getByTestId("resubmit-REJ1") as HTMLButtonElement; + expect(btn).toBeTruthy(); + expect(btn.textContent).toBe("Resubmit"); + + // Click fires the per-row handler with the claim id; click does + // NOT bubble to onClick (the row-onClick is for drilldown, the + // button is a separate download gesture). + fireEvent.click(btn); + expect(onResubmitOne).toHaveBeenCalledTimes(1); + expect(onResubmitOne).toHaveBeenCalledWith("REJ1"); + }); + + it("SP29: rejected row with no linked 999 acks shows the '999 not linked' orphan marker", () => { + // A rejected claim whose 999 ack didn't match any ST02 in the + // batch — the operator needs to know the drill lacked ack + // evidence. The "999 not linked" italic line is the marker. + const rejectedRow: InboxClaimRow = { + ...baseClaim, + id: "REJ-ORPH", + claim_acks: { + total: 0, + rejected: 0, + items: [], + }, + }; + const onResubmitOne = vi.fn(); + const { container } = render( + + + {}} + onResubmitOne={onResubmitOne} + /> + +
, + ); + expect(container.textContent).toContain("999 not linked"); + // The Resubmit button still renders so the operator can fix the + // claim without first manually linking the orphan. + expect(screen.getByTestId("resubmit-REJ-ORPH")).toBeTruthy(); + }); }); diff --git a/src/components/inbox/InboxRow.tsx b/src/components/inbox/InboxRow.tsx index 7bb2c52..9791f84 100644 --- a/src/components/inbox/InboxRow.tsx +++ b/src/components/inbox/InboxRow.tsx @@ -10,6 +10,8 @@ const ACCENT_VAR: Record = { muted: "var(--tt-muted)", }; +const REJECT_CODES = new Set(["R", "E", "X"]); + function fmtMoney(n: number | null | undefined): string { if (n == null) return "—"; return n.toLocaleString("en-US", { style: "currency", currency: "USD" }); @@ -55,12 +57,54 @@ function Sparkline({ breakdown }: { breakdown: ScoreBreakdown | null }) { ); } -type Props = - | { row: InboxClaimRow; accent: Accent; onClick: () => void } - | { row: InboxCandidateRow; accent: Accent; onClick: () => void }; +// --------------------------------------------------------------------------- +// SP29: per-AK2 chip (compact evidence row for the rejected-lane drill) +// --------------------------------------------------------------------------- +function AckCodeChip({ + setControlNumber, + code, +}: { + setControlNumber: string; + code: string; +}) { + const isReject = REJECT_CODES.has(code); + return ( + + {setControlNumber} + · + {code || "—"} + + ); +} -export function InboxRow({ row, accent, onClick }: Props) { +type Props = { + row: InboxClaimRow | InboxCandidateRow; + accent: Accent; + onClick: () => void; + /** SP29: per-row Resubmit gesture. Only meaningful for rejected + * claim rows; the component no-ops on remit rows or other states. */ + onResubmitOne?: (claimId: string) => void; +}; + +export function InboxRow({ row, accent, onClick, onResubmitOne }: Props) { const isCandidate = row.kind === "remit"; + const isRejected = + !isCandidate && (row as InboxClaimRow).state === "rejected"; const id = isCandidate ? (row as InboxCandidateRow).payer_claim_control_number : row.id; const payer = (isCandidate ? (row as InboxCandidateRow).payer_id : (row as InboxClaimRow).payer_id) ?? ""; const charge = fmtMoney(row.charge_amount); @@ -68,6 +112,7 @@ export function InboxRow({ row, accent, onClick }: Props) { const rejectedAt = !isCandidate ? (row as InboxClaimRow).rejected_at : null; const topScore = isCandidate && row.candidates.length > 0 ? row.candidates[0].score : null; const filled = isCandidate && row.candidates.length > 0 ? row.candidates[0].breakdown : null; + const claimAcks = !isCandidate ? (row as InboxClaimRow).claim_acks : null; return ( - {reason ?? (topScore != null ? `score ${topScore}` : "")} +
+ {reason ?? (topScore != null ? `score ${topScore}` : "")} + {/* SP29: rejected-lane AK2 chip evidence (newest 3 + overflow). */} + {isRejected && claimAcks && claimAcks.items.length > 0 && ( + + {claimAcks.items.slice(0, 3).map((it) => ( + + ))} + {claimAcks.total > 3 && ( + + +{claimAcks.total - 3} more + + )} + + )} + {/* SP29: rejected claim but no linked 999 acks (orphan). */} + {isRejected && (!claimAcks || claimAcks.items.length === 0) && ( + + 999 not linked + + )} +
+ {/* SP29: per-row Resubmit button (rejected-lane only). */} + {isRejected && onResubmitOne ? ( + e.stopPropagation()} + > + + + ) : ( + + )} ); } diff --git a/src/components/inbox/Lane.tsx b/src/components/inbox/Lane.tsx index 365603d..9fcbcd4 100644 --- a/src/components/inbox/Lane.tsx +++ b/src/components/inbox/Lane.tsx @@ -75,12 +75,16 @@ export function Lane({ rows, onRowClick, onSelectionChange, + onResubmitOne, }: { name: string; accent: Accent; rows: LaneRow[]; onRowClick: (row: LaneRow) => void; onSelectionChange?: (ids: string[]) => void; + /** SP29: per-row Resubmit gesture for the rejected-lane drill. + * Only claim rows render the button; remit rows ignore it. */ + onResubmitOne?: (claimId: string) => void; }) { const [selected, setSelected] = useState>(new Set()); @@ -188,7 +192,17 @@ export function Lane({ onToggle={() => toggle(id)} />
- onRowClick(row)} /> + onRowClick(row)} + // SP29: only meaningful on claim rows. Remit rows + // narrow the union to the `onResubmitOne?: never` + // branch so this is a no-op for them. + {...(row.kind === "claim" && onResubmitOne + ? { onResubmitOne } + : {})} + />
); diff --git a/src/lib/inbox-api.ts b/src/lib/inbox-api.ts index 9e72895..042c344 100644 --- a/src/lib/inbox-api.ts +++ b/src/lib/inbox-api.ts @@ -54,6 +54,28 @@ export type InboxClaimRow = { payer_rejected_by_277ca_id?: string | null; payer_rejected_acknowledged_at?: string | null; payer_rejected_acknowledged_actor?: string | null; + // SP29: present on `rejected`-lane rows (999 envelope rejects). + // Newest 5 AK2 set-responses for this claim plus a `total` / + // `rejected` summary. `null` when the claim has zero linked + // 999 acks. Filtered to ack_kind='999' only — the + // `payer_rejected` lane carries 277CA evidence separately. + claim_acks?: InboxClaimAckSummary | null; +}; + +/** + * SP29: per-claim 999 ack-evidence summary for the Inbox + * `rejected`-lane drill. Newest-first; up to 5 items. + */ +export type InboxClaimAckSummary = { + total: number; + rejected: number; + items: Array<{ + ack_id: number; + set_control_number: string; + set_accept_reject_code: string; + ak2_index: number; + linked_at: string; + }>; }; export type InboxCandidateRow = { diff --git a/src/pages/Inbox.test.tsx b/src/pages/Inbox.test.tsx index ffe6b28..0794ef2 100644 --- a/src/pages/Inbox.test.tsx +++ b/src/pages/Inbox.test.tsx @@ -6,6 +6,7 @@ import { MemoryRouter, useLocation, useNavigate } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import Inbox from "./Inbox"; import * as inboxApi from "@/lib/inbox-api"; +import * as apiModule from "@/lib/api"; import * as downloadModule from "@/lib/download"; // The Inbox's write-affordance BulkBars (resubmit / acknowledge / @@ -769,4 +770,112 @@ describe("Inbox page", () => { expect(options[0]?.getAttribute("data-claim-id")).toBe("CLM-A"); expect(options[1]?.getAttribute("data-claim-id")).toBe("CLM-B"); }); + + // ------------------------------------------------------------------------- + // SP29: per-row Resubmit button on rejected-lane claim rows downloads + // the single-claim corrected 837. The bulk resubmit path (multi-select) + // is exercised above in the "SP8 multi-select resubmit" test; this + // covers the dedicated single-claim gesture and the operator's + // most-click flow ("I see one reject in the lane, I click Resubmit + // next to it"). + // ------------------------------------------------------------------------- + it("SP29: per-row Resubmit button downloads the single-claim 837", async () => { + // Lane returns one rejected claim. The page is responsible for + // calling api.serializeClaim837(id) and handing the returned + // text to downloadTextFile as .x12. + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(async (url: string) => { + if (url.includes("/api/inbox/lanes")) { + return { + ok: true, + json: async () => ({ + rejected: [ + { + id: "REJ1", + kind: "claim", + patient_control_number: "REJ1", + charge_amount: 175, + payer_id: "P1", + provider_npi: "1234567890", + state: "rejected", + rejection_reason: "999 AK9 R", + rejected_at: "2026-07-02T09:00:00Z", + service_date_from: null, + claim_acks: { + total: 2, + rejected: 1, + items: [ + { + ack_id: 50, + set_control_number: "991102989", + set_accept_reject_code: "R", + ak2_index: 0, + linked_at: "2026-07-02T09:00:00Z", + }, + { + ack_id: 51, + set_control_number: "991102989", + set_accept_reject_code: "A", + ak2_index: 1, + linked_at: "2026-07-02T08:59:00Z", + }, + ], + }, + }, + ], + payer_rejected: [], + candidates: [], + unmatched: [], + done_today: [], + }), + }; + } + return { ok: true, json: async () => ({}) }; + }), + ); + + // Spy on the api call AND the download sink. The page calls + // `api.serializeClaim837(id)` (namespace import from @/lib/api), + // and downloadTextFile is on the download module. + const serializeSpy = vi + .spyOn(apiModule.api, "serializeClaim837") + .mockResolvedValue({ + text: "ISA*00*~...", + filename: "claim-REJ1.x12", + }); + const downloadSpy = vi + .spyOn(downloadModule, "downloadTextFile") + .mockImplementation(() => {}); + + const view = renderInbox(); + await waitFor(() => { + expect(view.container.textContent).toContain("REJ1"); + }); + + // Click the per-row Resubmit button — its testid includes the + // claim id so the operator can target one row at a time. + const btn = view.container.querySelector( + '[data-testid="resubmit-REJ1"]', + ) as HTMLButtonElement | null; + expect(btn).not.toBeNull(); + expect(btn!.textContent).toBe("Resubmit"); + + await act(async () => { + fireEvent.click(btn!); + }); + + // The api was called with the row's claim id, and the resulting + // .x12 was handed to downloadTextFile with text/x12 mime so the + // browser saves it as a download. + await waitFor(() => { + expect(serializeSpy).toHaveBeenCalledTimes(1); + expect(serializeSpy).toHaveBeenCalledWith("REJ1"); + }); + expect(downloadSpy).toHaveBeenCalledTimes(1); + const [filename, mime, text] = downloadSpy.mock.calls[0]; + expect(filename).toBe("claim-REJ1.x12"); + expect(mime).toBe("text/x12"); + expect(text).toBe("ISA*00*~..."); + }); }); diff --git a/src/pages/Inbox.tsx b/src/pages/Inbox.tsx index 33eefcf..015eec5 100644 --- a/src/pages/Inbox.tsx +++ b/src/pages/Inbox.tsx @@ -30,7 +30,8 @@ import { resubmitRejectedWithDownload, acknowledgePayerRejected, } from "@/lib/inbox-api"; -import { downloadBlob } from "@/lib/download"; +import { api } from "@/lib/api"; +import { downloadBlob, downloadTextFile } from "@/lib/download"; import { RoleGate } from "@/auth/RoleGate"; type LaneKey = @@ -107,6 +108,24 @@ export default function Inbox() { await refetch(); } + // SP29: per-row Resubmit gesture for the rejected lane. Downloads + // the single-claim corrected 837 (read-only — see spec §D1). The + // operator reconciles via their own workflow, then resubmits via + // the bulk path or the existing single-claim modal flow. + async function performResubmitOne(claimId: string) { + try { + const { text, filename } = await api.serializeClaim837(claimId); + downloadTextFile(filename, "text/x12", text); + } catch (err) { + console.error("resubmit single failed:", claimId, err); + // Surface the failure — the Inbox doesn't have a toast yet, so + // we fall back to an alert. v2 swaps in a sonner toast. + window.alert( + `Failed to download 837 for ${claimId}: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + async function onResubmit() { const ids = selected.rejected; if (ids.length === 0) return; @@ -298,6 +317,10 @@ export default function Inbox() { } }} onSelectionChange={(ids) => setLaneSelected("rejected", ids)} + // SP29: per-row Resubmit button. Downloads the single-claim + // corrected 837 (read-only). InboxRow renders the button only + // on rejected claim rows; remit rows ignore the prop. + onResubmitOne={(claimId) => void performResubmitOne(claimId)} /> {/* SP14: payer-rejected (277CA STC A4/A6/A7). Distinct accent