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
+37
View File
@@ -476,6 +476,42 @@ async function getClaimDetail(id: string): Promise<ClaimDetail> {
return (await res.json()) as ClaimDetail;
}
/**
* Serialize a claim back to an X12 837P file via the backend's outbound
* serializer (SP8).
*
* Returns both the regenerated X12 text and the filename the backend
* suggested (from the `Content-Disposition: attachment; filename=...`
* header). Callers usually pipe both into `downloadTextFile` so the user
* gets a byte-faithful file named after the claim id.
*
* Throws `ApiError` on non-2xx — 404 (claim missing) and 422 (stored
* `raw_json` unparseable / serializer rejected the payload) are both
* reachable, and the caller may want to branch on `.status` to surface a
* useful message ("claim data is corrupted — open the batch file
* manually" vs. "this claim no longer exists").
*/
async function serializeClaim837(
id: string,
): Promise<{ text: string; filename: string }> {
if (!isConfigured) throw notConfiguredError();
const res = await fetch(
joinUrl(`/api/claims/${encodeURIComponent(id)}/serialize-837`),
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
const text = await res.text();
// Filename comes from Content-Disposition (set by the backend). Fall back
// to a sensible default if the header is missing or malformed so the UI
// always has something to hand to `downloadTextFile`.
const cd = res.headers.get("content-disposition") ?? "";
const match = /filename="?([^";]+)"?/i.exec(cd);
const filename = match?.[1] ?? `claim-${id}.x12`;
return { text, filename };
}
async function listRemittances<T = unknown>(
params: ListRemittancesParams
): Promise<PaginatedResponse<T>> {
@@ -700,6 +736,7 @@ export const api = {
getBatchDiff,
listClaims,
getClaimDetail,
serializeClaim837,
listRemittances,
getRemittance,
listProviders,