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/docs/superpowers/plans/2026-07-02-cyclone-999-rejected-drill.md b/docs/superpowers/plans/2026-07-02-cyclone-999-rejected-drill.md new file mode 100644 index 0000000..0027e41 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-cyclone-999-rejected-drill.md @@ -0,0 +1,460 @@ +# SP29 — Inbox 999-rejected claim drill implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Surface the SP28-linkable 999 AK2 set-response evidence inline on each `rejected`-lane row in the Inbox and add a per-row `Resubmit` button that downloads the single-claim corrected 837. The operator can identify and act on each rejected claim without opening the drawer or running the bulk modal. + +**Architecture:** Add a `claim_acks` summary field to the rejected-lane row payload from `/api/inbox/lanes` (one batched query for the whole lane — no N+1). Extend `InboxClaimRow` in `inbox-api.ts` with the new field. Extend `InboxRow` to render up to 3 AK2 chips inline plus a per-row Resubmit button that calls the existing `serializeClaim837` client and writes via `downloadTextFile`. No new endpoints, no new dependencies. + +**Tech Stack:** Python 3.11+, FastAPI, SQLAlchemy 2.x, SQLite (encrypted via SQLCipher), React 18 + TypeScript + Vite, TanStack Query, Zustand, Radix UI primitives. + +**Spec:** [`docs/superpowers/specs/2026-07-02-cyclone-999-rejected-drill-design.md`](../specs/2026-07-02-cyclone-999-rejected-drill-design.md) + +--- + +## File structure + +``` +backend/ +├── src/cyclone/ +│ └── inbox_lanes.py # ~ attach claim_acks summary to rejected rows +└── tests/ + └── test_inbox_lanes.py # + 1 test for the new field (extend if file exists) + +src/ +├── lib/ +│ └── inbox-api.ts # + InboxClaimRow.claim_acks type +├── components/inbox/ +│ ├── InboxRow.tsx # + AK2 chip sub-row + per-row Resubmit button +│ └── InboxRow.test.tsx # + 1 test for chips +└── pages/ + ├── Inbox.tsx # + onResubmitOne handler threading + └── Inbox.test.tsx # + 1 test for per-row download +``` + +--- + +## Phase 1 — Backend: lane row payload + +### Task 1: Attach `claim_acks` summary to each rejected-lane row + +- [ ] **Step 1.1: Read `backend/src/cyclone/inbox_lanes.py` and locate the rejected-lane loop.** + + Around `inbox_lanes.py:180-195` — the `rejected_claims = (...)` query, the `_line_count_lookup(...)` call, and the `_claim_to_row(...)` loop. The new `claim_acks` attach should sit AFTER the loop (so it operates on the dict rows), pulling from a single batched SQL query keyed by `claim_id IN (...)`. + +- [ ] **Step 1.2: Add a batched query helper inside `compute_lanes`.** + + After the rejected loop, before the function returns, add: + + ```python + rejected_ids = [r["id"] for r in lanes.rejected] + rejected_ack_summary = _ack_summary_for_claims(session, rejected_ids) + for row in lanes.rejected: + row["claim_acks"] = rejected_ack_summary.get(row["id"]) # None when no 999 acks linked + ``` + + The helper lives at module scope (above `compute_lanes`): + + ```python + def _ack_summary_for_claims(session: Session, claim_ids: list[str]) -> dict[str, dict]: + """Attach a `claim_acks` summary to every rejected-lane row. + + SP29: per rejected claim, summarize the linked 999 acks so the + Inbox row can render AK2 chips + a per-row Resubmit button + without an extra fetch. Newest 5 acks + total + rejected count. + + Returns: + {claim_id: {"total": int, "rejected": int, "items": [...]}, ...} + (claims with zero 999 acks are NOT in the returned dict, so + the caller can map.get(...) and treat absence as null.) + """ + if not claim_ids: + return {} + from cyclone.db import ClaimAck # late import — mirrors DB module shape + # Fetch newest 5 linked 999 acks per claim in one round-trip + from sqlalchemy import select + # Subquery to pick the 5 newest per claim + # Simpler: fetch ALL then trim in Python (claim_id count is low — lanes page + # only carries rejected claims, typically a few dozen) + 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] in rejected_codes) + 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 + ``` + + **Performance note:** the lanes page only carries rejected claims (a few dozen at most on this codebase; the prod count of `rejected` lane rows can be confirmed with a count query but is small by construction). A bounded fetch + Python trim is fine for v1. If a future SP finds lane payloads blowing up, add `claim_acks(claim_id, ack_kind, linked_at)` composite index in a follow-up migration. + +- [ ] **Step 1.3: Wire the helper into `compute_lanes`.** + + Place the two lines from Step 1.2 immediately after the rejected-lane loop, before any return statement. Do not touch the `payer_rejected` loop — that lane has its own `payer_rejected_*` fields and doesn't need `claim_acks` (its rejections come from 277CA, not 999). + +- [ ] **Step 1.4: Add a test in `backend/tests/test_inbox_lanes.py`.** + + ```python + def test_inbox_lanes_attaches_claim_acks_summary_for_rejected(): + # Seed: 1 rejected claim, with 3 linked 999 acks (2 accepted, 1 rejected) + # Call compute_lanes (or /api/inbox/lanes via TestClient) + # Assert: rejected[0]["claim_acks"]["total"] == 3 + # Assert: rejected[0]["claim_acks"]["rejected"] == 1 + # Assert: items has len <= 5 and is sorted by linked_at DESC + # Assert: items contain ack_id + set_control_number + set_accept_reject_code + ``` + + If `test_inbox_lanes.py` doesn't exist, create it and put the test there. The test should: + - Create a `Claim` with `state=ClaimState.REJECTED` + - Create 3 `Ack` rows (no specific kind — they're stored in the `acks` table) + - Create 3 `ClaimAck` rows linking the claim to those acks via the `claim_acks` table + - Set 2 of the claim_acks rows' `set_accept_reject_code` to `A` and 1 to `R` + - Call `/api/inbox/lanes` via TestClient + - Assert the `rejected[0].claim_acks` shape matches + +- [ ] **Step 1.5: Run the new test — should PASS.** + + ```bash + cd backend && .venv/bin/pytest tests/test_inbox_lanes.py -v + ``` + +--- + +## Phase 2 — Frontend types + render + +### Task 2: Extend `InboxClaimRow` type + +- [ ] **Step 2.1: Edit `src/lib/inbox-api.ts` (around line 54 where `InboxClaimRow` is declared).** + + Add the new optional field: + + ```typescript + export type InboxClaimAckItem = { + ack_id: number; + set_control_number: string; + set_accept_reject_code: string; + ak2_index: number; + linked_at: string; + }; + + export type InboxClaimRow = { + // ... existing fields ... + /** + * 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. + */ + claim_acks?: { + total: number; + rejected: number; + items: InboxClaimAckItem[]; + } | null; + }; + ``` + +### Task 3: Extend `InboxRow` with chip column + per-row Resubmit button + +- [ ] **Step 3.1: Locate the row render in `src/components/inbox/InboxRow.tsx`.** + + Around the 7-column flexbox (lines 73-119). The new chips live in a sub-row beneath the existing row content; the per-row Resubmit button slots in at the right edge of the row. + +- [ ] **Step 3.2: Add an `onResubmitOne?: (claimId: string) => void` prop.** + + Update the function signature to accept the new optional callback. + +- [ ] **Step 3.3: Render the chips sub-row for `rejected` claim rows.** + + Pseudocode: + + ```tsx + {row.kind === "claim" && row.state === "rejected" && ( +
".
+ 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