feat(sp8): Claim drawer Download 837 button

Three pieces:

- src/lib/download.ts: generic downloadTextFile(filename, mime, text)
  helper. Mirrors csv.ts:downloadCsv but takes an explicit MIME type and
  drops the BOM prepend (which would corrupt the ISA segment).

- src/lib/api.ts: serializeClaim837(id) → {text, filename}. Fetches
  GET /api/claims/{id}/serialize-837, pulls the suggested filename from
  Content-Disposition (falls back to claim-{id}.x12 if the header is
  missing). Throws ApiError on non-2xx so callers can branch on .status.

- ClaimDrawerHeader: Download icon button between the amount and the
  close button. Click → api.serializeClaim837 → downloadTextFile.
  Disabled + 'Downloading 837 file' aria-label while the fetch is in
  flight so the click feels responsive. Optional onError prop surfaces
  fetch failures; defaults to a no-op so existing callers stay clean.

Tests: 3 download.test.ts, 3 api.test.ts, 2 header.test.ts (happy
path + error path). Frontend: 350 passing (+8 from 342).
This commit is contained in:
Tyler
2026-06-20 20:44:39 -06:00
parent 2893676c0b
commit 1764df0cd5
6 changed files with 336 additions and 9 deletions
@@ -7,10 +7,19 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ClaimDrawerHeader } from "./ClaimDrawerHeader";
import * as apiModule from "@/lib/api";
import * as downloadModule from "@/lib/download";
import type { ClaimDetail } from "@/types";
// Mock the api + download helpers so we can assert the wiring without
// hitting the network or the real Blob/anchor machinery. The spies live
// at module scope but `beforeEach` resets their call history so the
// happy-path test doesn't leak into the error-path test.
const serializeSpy = vi.spyOn(apiModule.api, "serializeClaim837");
const downloadSpy = vi.spyOn(downloadModule, "downloadTextFile");
function renderIntoContainer(element: React.ReactElement): {
container: HTMLDivElement;
unmount: () => void;
@@ -104,16 +113,21 @@ function makeDetail(overrides: Partial<ClaimDetail> = {}): ClaimDetail {
function renderHeader(
claim: Partial<ClaimDetail>,
onClose: () => void = vi.fn()
): { container: HTMLDivElement; unmount: () => void; onClose: () => void } {
onClose: () => void = vi.fn(),
onError: (message: string) => void = vi.fn()
): { container: HTMLDivElement; unmount: () => void; onClose: () => void; onError: (m: string) => void } {
const element = (
<ClaimDrawerHeader claim={makeDetail(claim)} onClose={onClose} />
<ClaimDrawerHeader claim={makeDetail(claim)} onClose={onClose} onError={onError} />
);
const { container, unmount } = renderIntoContainer(element);
return { container, unmount, onClose };
return { container, unmount, onClose, onError };
}
describe("ClaimDrawerHeader", () => {
beforeEach(() => {
serializeSpy.mockReset();
downloadSpy.mockReset();
});
it("test_renders_claim_id_label_and_value", () => {
const { container, unmount } = renderHeader({ id: "CLM-42" });
@@ -226,4 +240,64 @@ describe("ClaimDrawerHeader", () => {
unmount();
});
it("test_download_button_fetches_and_saves_file_for_claim_id", async () => {
// Happy path: click → api.serializeClaim837(claim.id) → downloadTextFile
// with the returned text + filename. We don't actually need to wait for
// the download side-effect — we just verify the wiring (right ids,
// right payload, right mime).
serializeSpy.mockResolvedValueOnce({
text: "ISA*00*~IEA*0*1~",
filename: "claim-CLM-1.x12",
});
const { container, unmount } = renderHeader({ id: "CLM-1" });
const btn = container.querySelector(
'[data-testid="header-download-837"]'
) as HTMLButtonElement | null;
expect(btn).not.toBeNull();
await act(async () => {
btn?.click();
// Flush the microtask queue so the awaited handler resolves.
await Promise.resolve();
});
expect(serializeSpy).toHaveBeenCalledTimes(1);
expect(serializeSpy).toHaveBeenCalledWith("CLM-1");
expect(downloadSpy).toHaveBeenCalledTimes(1);
expect(downloadSpy).toHaveBeenCalledWith(
"claim-CLM-1.x12",
"text/x12",
"ISA*00*~IEA*0*1~"
);
unmount();
});
it("test_download_button_routes_api_error_through_onError", async () => {
// When serializeClaim837 throws, the header swallows the error and
// hands the message to the optional onError callback. The download
// helper must NOT be called on the failure path — that's the
// difference between "the file was corrupted in the DB" and "we just
// gave the user a broken empty file".
serializeSpy.mockRejectedValueOnce(new Error("404 Not found"));
const onError = vi.fn();
const { container, unmount } = renderHeader({ id: "CLM-1" }, vi.fn(), onError);
const btn = container.querySelector(
'[data-testid="header-download-837"]'
) as HTMLButtonElement | null;
await act(async () => {
btn?.click();
await Promise.resolve();
});
expect(onError).toHaveBeenCalledTimes(1);
expect(onError).toHaveBeenCalledWith("404 Not found");
expect(downloadSpy).not.toHaveBeenCalled();
unmount();
});
});
@@ -1,6 +1,9 @@
import { X } from "lucide-react";
import { useState } from "react";
import { Download, X } from "lucide-react";
import { Badge, type BadgeProps } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { api } from "@/lib/api";
import { downloadTextFile } from "@/lib/download";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ClaimDetail } from "@/types";
@@ -8,6 +11,13 @@ import type { ClaimDetail } from "@/types";
type ClaimDrawerHeaderProps = {
claim: ClaimDetail;
onClose: () => void;
/**
* Optional toast callback for surfacing download failures (network error,
* 404 if the claim was deleted out from under us, 422 if the stored
* payload can't be regenerated). Defaults to a no-op so consumers that
* don't wire a toast still get the happy-path download.
*/
onError?: (message: string) => void;
};
/**
@@ -44,7 +54,32 @@ function badgeVariantFor(state: string): BadgeProps["variant"] {
* badge color encodes state so the user can read the drawer's status
* at a glance without scrolling.
*/
export function ClaimDrawerHeader({ claim, onClose }: ClaimDrawerHeaderProps) {
export function ClaimDrawerHeader({
claim,
onClose,
onError,
}: ClaimDrawerHeaderProps) {
// Local "is downloading" flag so the button can show feedback while the
// fetch is in flight. The header is rendered above the fold, so a stuck
// spinner is the difference between "the click did something" and "did
// my click even register?"
const [downloading, setDownloading] = useState(false);
async function handleDownload() {
if (downloading) return;
setDownloading(true);
try {
const { text, filename } = await api.serializeClaim837(claim.id);
downloadTextFile(filename, "text/x12", text);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to download 837 file.";
onError?.(message);
} finally {
setDownloading(false);
}
}
return (
<header
className={cn(
@@ -68,8 +103,8 @@ export function ClaimDrawerHeader({ claim, onClose }: ClaimDrawerHeaderProps) {
</span>
</div>
{/* Right: state badge + total amount + close */}
<div className="flex items-start gap-4">
{/* Right: state badge + total amount + download + close */}
<div className="flex items-start gap-2">
<div className="flex flex-col items-end gap-1">
<Badge
variant={badgeVariantFor(claim.state)}
@@ -86,6 +121,17 @@ export function ClaimDrawerHeader({ claim, onClose }: ClaimDrawerHeaderProps) {
{fmt.usdPrecise(claim.billedAmount)}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleDownload}
disabled={downloading}
aria-label={downloading ? "Downloading 837 file" : "Download 837 file"}
title="Download 837 file"
data-testid="header-download-837"
>
<Download className="h-4 w-4" strokeWidth={1.75} />
</Button>
<Button
variant="ghost"
size="icon"