diff --git a/src/lib/download.test.ts b/src/lib/download.test.ts index bde47bc..238bb51 100644 --- a/src/lib/download.test.ts +++ b/src/lib/download.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { downloadTextFile } from "./download"; +import { downloadBlob, downloadTextFile } from "./download"; // Capture the real appendChild before any spy replaces it, so the // happy-dom implementation is preserved when the appendChild spy calls @@ -94,4 +94,34 @@ describe("downloadTextFile", () => { (globalThis as { document?: Document }).document = originalDocument; } }); + + it("test_download_blob_uses_supplied_blob_and_filename", async () => { + // The blob variant exists for binary payloads (ZIP, PDF, ...) where + // the caller already has a Blob and just wants the browser to save + // it. The anchor's href must point at the supplied blob's object + // URL and the download attribute must be the filename. + const captured: HTMLAnchorElement[] = []; + const appendSpy = vi + .spyOn(document.body, "appendChild") + .mockImplementation((node) => { + if (node instanceof HTMLAnchorElement) captured.push(node); + return originalAppendChild.call(document.body, node); + }); + + const blob = new Blob(["PK\u0003\u0004fake-zip"], { + type: "application/zip", + }); + downloadBlob("resubmit-2-claims.zip", blob); + + expect(captured).toHaveLength(1); + const a = captured[0]; + expect(a.getAttribute("download")).toBe("resubmit-2-claims.zip"); + expect(a.getAttribute("href")).toBe("blob:test-url"); + expect(a.style.display).toBe("none"); + + expect(appendSpy).toHaveBeenCalledTimes(1); + expect(createSpy).toHaveBeenCalledTimes(1); + await Promise.resolve(); + expect(revokeSpy).toHaveBeenCalledWith("blob:test-url"); + }); }); diff --git a/src/lib/download.ts b/src/lib/download.ts index 1a8bb43..a946d55 100644 --- a/src/lib/download.ts +++ b/src/lib/download.ts @@ -1,12 +1,18 @@ /** - * Generic browser-side download helper for plain-text files. + * Generic browser-side download helpers. * - * Mirrors `downloadCsv` in `./csv.ts` but takes an explicit MIME type so the - * same code path can serve X12 (text/x12), JSON (application/json), or any - * other text payload without growing a one-off helper per format. + * - `downloadTextFile` for plain-text payloads (X12, JSON, CSV, ...). + * Mirrors `downloadCsv` in `./csv.ts` but takes an explicit MIME type + * so the same code path can serve any text format without growing a + * one-off helper per format. * - * The implementation is identical to `downloadCsv` minus the BOM prepending - * (we want byte-faithful X12 — prepending a BOM corrupts the ISA segment). + * - `downloadBlob` for binary payloads (the resubmit ZIP, future PDF + * exports, etc.) where the caller already has a `Blob` and just needs + * it handed to the browser. + * + * Both implementations skip the BOM prepend that `downloadCsv` uses (we + * want byte-faithful X12 and ZIP — a BOM would corrupt the ISA segment + * and confuse ZIP readers respectively). * * Safe to call from SSR/Node — guards against a missing `document`. */ @@ -15,8 +21,11 @@ export function downloadTextFile( mimeType: string, text: string, ): void { + downloadBlob(filename, new Blob([text], { type: `${mimeType};charset=utf-8` })); +} + +export function downloadBlob(filename: string, blob: Blob): void { if (typeof document === "undefined" || typeof URL === "undefined") return; - const blob = new Blob([text], { type: `${mimeType};charset=utf-8` }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; diff --git a/src/lib/inbox-api.ts b/src/lib/inbox-api.ts index 7d0d9f0..da4770a 100644 --- a/src/lib/inbox-api.ts +++ b/src/lib/inbox-api.ts @@ -124,6 +124,55 @@ export async function resubmitRejected( }); } +/** + * Resubmit rejected claims AND ask the backend for a downloadable bundle + * of regenerated 837 files (one .x12 per successfully resubmitted claim). + * + * Returns the raw `Blob` plus the filename the backend suggested in its + * `Content-Disposition` header (e.g. `resubmit-3-claims.zip`). Callers + * usually pipe both into `downloadTextFile`'s sibling helper for binary + * downloads — see `downloadBlob` in `@/lib/download`. + * + * The endpoint never fails because of a partial bundle — per-claim + * serialization errors are surfaced via the `X-Cyclone-Serialize-Errors` + * response header (JSON-encoded array) so the UI can show "10 + * resubmitted, 2 couldn't be regenerated" without parsing the zip. The + * returned blob still contains the claims that did serialize. + */ +export async function resubmitRejectedWithDownload( + claimIds: string[], +): Promise<{ blob: Blob; filename: string; serializeErrors: Array<{ claim_id: string; reason: string }> }> { + if (!isConfigured) throw notConfiguredError(); + const res = await fetch( + joinUrl("/api/inbox/rejected/resubmit?download=true"), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ claim_ids: claimIds }), + }, + ); + if (!res.ok) { + const detail = await readErrorBody(res); + throw new Error( + `${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`, + ); + } + const blob = await res.blob(); + const cd = res.headers.get("content-disposition") ?? ""; + const match = /filename="?([^";]+)"?/i.exec(cd); + const filename = match?.[1] ?? "resubmit-bundle.zip"; + const errHeader = res.headers.get("x-cyclone-serialize-errors"); + let serializeErrors: Array<{ claim_id: string; reason: string }> = []; + if (errHeader) { + try { + serializeErrors = JSON.parse(errHeader); + } catch { + serializeErrors = []; + } + } + return { blob, filename, serializeErrors }; +} + export function exportInboxCsvUrl( lane: "rejected" | "candidates" | "unmatched" | "done_today", ): string { diff --git a/src/pages/Inbox.tsx b/src/pages/Inbox.tsx index 6b0c9d2..539681e 100644 --- a/src/pages/Inbox.tsx +++ b/src/pages/Inbox.tsx @@ -15,7 +15,9 @@ import { exportInboxCsvUrl, dismissCandidates, resubmitRejected, + resubmitRejectedWithDownload, } from "@/lib/inbox-api"; +import { downloadBlob } from "@/lib/download"; type LaneKey = "rejected" | "candidates" | "unmatched" | "done_today"; @@ -36,13 +38,77 @@ export default function Inbox() { setSelected((prev) => ({ ...prev, [lane]: ids })); } - async function onResubmit() { - if (selected.rejected.length === 0) return; - await resubmitRejected(selected.rejected); + // Resubmit-bundle download modal state (SP8). When the user selects N>1 + // rejected claims and hits Resubmit, we surface a confirm modal that + // lets them choose "Resubmit only" (just move the state, no download) + // vs "Resubmit + Download" (move the state AND hand them a zip of the + // regenerated 837 files). Single-claim resubmit skips the modal — + // there's nothing to bundle, the download is implicit in the action. + const [bundleModalOpen, setBundleModalOpen] = useState(false); + const [bundleSubmitting, setBundleSubmitting] = useState(false); + + async function performResubmit(ids: string[], withDownload: boolean) { + if (withDownload) { + const { blob, filename, serializeErrors } = + await resubmitRejectedWithDownload(ids); + downloadBlob(filename, blob); + // Surface per-claim serialization failures (rare — usually means + // raw_json is corrupted for one of the claims). Fail-soft: don't + // throw, the user still got the zip with the claims that did work. + if (serializeErrors.length > 0) { + console.warn( + "resubmit bundle: skipped", + serializeErrors.length, + "claims (couldn't regenerate 837):", + serializeErrors, + ); + } + } else { + await resubmitRejected(ids); + } setSelected((prev) => ({ ...prev, rejected: [] })); await refetch(); } + async function onResubmit() { + const ids = selected.rejected; + if (ids.length === 0) return; + if (ids.length === 1) { + // No bundle to download — single file, just do it. The user can + // always grab the .x12 from the claim drawer afterward. + setBundleSubmitting(true); + try { + await performResubmit(ids, false); + } finally { + setBundleSubmitting(false); + } + return; + } + setBundleModalOpen(true); + } + + async function onResubmitOnly() { + const ids = selected.rejected; + setBundleModalOpen(false); + setBundleSubmitting(true); + try { + await performResubmit(ids, false); + } finally { + setBundleSubmitting(false); + } + } + + async function onResubmitAndDownload() { + const ids = selected.rejected; + setBundleModalOpen(false); + setBundleSubmitting(true); + try { + await performResubmit(ids, true); + } finally { + setBundleSubmitting(false); + } + } + async function onDismiss() { if (selected.candidates.length === 0) return; // Pair each selected remit with its first candidate claim (the row's @@ -157,6 +223,82 @@ export default function Inbox() { onDismiss={() => {}} onExport={() => onExport("done_today")} /> + + {/* Resubmit bundle modal — SP8. Shown when N>1 rejected claims are + selected; lets the user choose to also download a zip of the + regenerated 837 files. Single-claim resubmit skips this. */} + {bundleModalOpen && ( +
+
+

+ Resubmit {selected.rejected.length} claims +

+

+ Move these claims back to submitted? + You can also download a bundle of regenerated 837 files + (one .x12 per claim) — useful if you're + about to ship them to the clearinghouse. +

+
+ + +
+
+
+ )} + + {/* Subtle progress overlay while a bulk resubmit is in flight, so + the user knows the click registered even when the network is slow. + Single-claim resubmit also benefits — no visible feedback before + this and the inbox can sit there for a few hundred ms. */} + {bundleSubmitting && ( + ); }