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( +
".
+ 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