feat(sp29): inbox rejected-row 999 ack chips + per-row Resubmit
- 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
This commit is contained in:
@@ -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 <id>.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*~...");
|
||||
});
|
||||
});
|
||||
|
||||
+24
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user