feat(release): v0.2.0 — batch 837 export, ClaimCard, theme tokens

Backend:

- New POST /api/batches/{id}/export-837: regenerate X12 837 files
  for a list of claim_ids into a ZIP using HCPF file naming standards,
  with a unique interchange/group control number per export. Wire
  the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
  (NM1*40) blocks so the serializer no longer falls back to
  CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
  batch_id in both JSON and NDJSON response shapes so the frontend
  can hit batch-scoped endpoints without an extra listBatches
  round-trip.
- Filename helpers and the 837 serializer updated to match the new
  HCPF envelope; tests cover batch export, parse batch_id, and the
  serializer's control-number uniqueness guarantee.

Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
  EditorialNote, ExportBar, TickerTape, and a charts/ set
  (BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
  the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
  colors to Tailwind theme tokens (bg-card, text-foreground,
  border/60, etc.) for consistency with the rest of the instrument
  chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
  reworked to compose the new shared components and consume the new
  batch-scoped API surface (notably ExportBar wired into Batches).

Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
  components and the useBatchExport hook.

Rolls up into the v0.2.0 release tag.
This commit is contained in:
Tyler
2026-06-22 11:01:58 -06:00
parent 35298907bc
commit 9bca4b608a
54 changed files with 6224 additions and 3871 deletions
+135
View File
@@ -217,4 +217,139 @@ describe("api GET helpers", () => {
);
await expect(api.serializeClaim837("ghost")).rejects.toBeInstanceOf(ApiError);
});
it("exportBatch837 returns the blob and filename from a 200 ZIP response", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // ZIP magic
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition":
'attachment; filename="batch-abc123-3-claims.zip"',
},
})
);
const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
expect(out.filename).toBe("batch-abc123-3-claims.zip");
expect(out.blob).toBeInstanceOf(Blob);
// POST to the batch-scoped endpoint with claim_ids in the body.
const called = mockFetch.mock.calls[0][0] as string;
expect(called).toContain("/api/batches/abc123/export-837");
const init = mockFetch.mock.calls[0][1] as RequestInit;
expect(init.method).toBe("POST");
const body = JSON.parse(init.body as string);
expect(body).toEqual({ claim_ids: ["CLM-1", "CLM-2", "CLM-3"] });
});
it("exportBatch837 parses the X-Cyclone-Serialize-Errors header into serializeErrors", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
const errs = [
{ claim_id: "CLM-2", reason: "no raw_json" },
{ claim_id: "CLM-3", reason: "raw_json invalid: ..." },
];
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition":
'attachment; filename="batch-abc123-1-claims.zip"',
"x-cyclone-serialize-errors": JSON.stringify(errs),
},
})
);
const out = await api.exportBatch837("abc123", ["CLM-1", "CLM-2", "CLM-3"]);
expect(out.serializeErrors).toEqual(errs);
});
it("exportBatch837 returns an empty serializeErrors array when the header is missing", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: {
"content-type": "application/zip",
"content-disposition": 'attachment; filename="batch-x-1-claims.zip"',
},
})
);
const out = await api.exportBatch837("x", ["CLM-1"]);
expect(out.serializeErrors).toEqual([]);
});
it("exportBatch837 falls back to a default filename when Content-Disposition is missing", async () => {
const zipBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04]);
mockFetch.mockResolvedValueOnce(
new Response(zipBytes, {
status: 200,
headers: { "content-type": "application/zip" },
})
);
const out = await api.exportBatch837("xyz", ["CLM-1"]);
expect(out.filename).toBe("batch-xyz-claims.zip");
});
it("exportBatch837 throws ApiError on 404", async () => {
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({ error: "Not found", detail: "unknown batch: ghost" }),
{ status: 404, headers: { "content-type": "application/json" } }
)
);
await expect(
api.exportBatch837("ghost", ["CLM-1"])
).rejects.toBeInstanceOf(ApiError);
});
it("exportBatch837 throws on empty claim_ids without making a request", async () => {
await expect(api.exportBatch837("any", [])).rejects.toThrow();
expect(mockFetch).not.toHaveBeenCalled();
});
it("getBatchDiff surfaces a clear ApiError when a backend 400 wraps detail in a nested object", async () => {
// The backend can wrap structured errors as
// `{"detail": {"error": "Missing param", "detail": "..."}}`. The
// client must walk into the nested `detail` so the user sees the
// human-readable message rather than a blob of raw JSON.
mockFetch.mockResolvedValueOnce(
new Response(
JSON.stringify({
detail: {
error: "Missing param",
detail: "Both ?a=<batch_id> and ?b=<batch_id> are required.",
},
}),
{ status: 400, headers: { "content-type": "application/json" } }
)
);
let caught: unknown;
try {
await api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "930f25e07eab4ac0908c9770689385e1");
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(ApiError);
expect((caught as ApiError).status).toBe(400);
// The unwrapped message must surface — not the raw JSON.
expect((caught as ApiError).message).toBe(
"Both ?a=<batch_id> and ?b=<batch_id> are required."
);
});
it("getBatchDiff throws ApiError(400) before hitting the network if either id is empty", async () => {
// Defense-in-depth: the hook's `enabled` guard should prevent this
// from ever firing, but if it ever does, the client must refuse to
// make a malformed request and surface a clear local error.
await expect(api.getBatchDiff("", "930f25e07eab4ac0908c9770689385e1")).rejects.toMatchObject({
status: 400,
message: expect.stringContaining("?a=<batch_id>"),
});
await expect(api.getBatchDiff("a8ebd5f564a547908e7c60d6a129621e", "")).rejects.toMatchObject({
status: 400,
message: expect.stringContaining("?b=<batch_id>"),
});
// Network must not be touched.
expect(mockFetch).not.toHaveBeenCalled();
});
});
+96 -5
View File
@@ -175,12 +175,24 @@ async function readErrorBody(res: Response): Promise<string> {
try {
const t = await res.text();
if (!t) return "";
// FastAPI errors are `{ "error": "...", "detail": "..." }`. Surface the
// detail if present, else the raw text.
// FastAPI errors can be flat `{ "error": "...", "detail": "..." }`
// or wrapped as `{ "detail": { "error": "...", "detail": "..." } }`
// (the wrapper our app uses for structured errors). Walk into nested
// `detail` objects until we hit a string field — guards against the
// raw JSON leaking into the user-visible error message.
try {
const obj = JSON.parse(t) as { detail?: unknown; error?: unknown };
if (typeof obj.detail === "string") return obj.detail;
if (typeof obj.error === "string") return obj.error;
let current: unknown = JSON.parse(t);
for (let depth = 0; depth < 5; depth++) {
if (!current || typeof current !== "object") break;
const obj = current as { detail?: unknown; error?: unknown };
if (typeof obj.detail === "string") return obj.detail;
if (typeof obj.error === "string") return obj.error;
if (obj.detail && typeof obj.detail === "object") {
current = obj.detail;
continue;
}
break;
}
} catch {
// not JSON; fall through
}
@@ -558,6 +570,16 @@ async function getRemittance<T = unknown>(id: string): Promise<T> {
*/
async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
if (!isConfigured) throw notConfiguredError();
// Defense-in-depth: even though the `useBatchDiff` hook gates on both
// ids being non-empty, refuse to fire the request if either is
// missing. Surfaces a clear local error instead of letting the
// backend return its generic "Missing param" 400.
if (typeof a !== "string" || a.length === 0) {
throw new ApiError(400, "Missing param: ?a=<batch_id> is required.");
}
if (typeof b !== "string" || b.length === 0) {
throw new ApiError(400, "Missing param: ?b=<batch_id> is required.");
}
const res = await fetch(
joinUrl(
`/api/batch-diff?${qs({ a, b })}`,
@@ -786,6 +808,74 @@ async function getAck(id: number): Promise<Ack & { rawJson: unknown }> {
return { ...mapAck(row), rawJson: row.raw_json };
}
/**
* Download a ZIP of regenerated X12 837 files for a parsed batch.
*
* Drives `POST /api/batches/{batchId}/export-837` with the requested
* claim_ids in the body. The backend returns a binary ZIP whose
* entries follow the HCPF X12 File Naming Standards template
* ``{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (one file per
* successfully serialized claim, with a per-claim millisecond offset
* so every entry has a unique 17-digit timestamp). The ZIP itself is
* named ``batch-{batchId}-{N}-claims.zip`` via Content-Disposition,
* where N is the success count.
*
* Per-claim serialization failures are surfaced via the
* `X-Cyclone-Serialize-Errors` response header (JSON-encoded array of
* `{claim_id, reason}`). The ZIP still contains the successful claims;
* the failures are returned alongside so the UI can show a partial-
* success toast like "Exported 18 · 2 couldn't be regenerated".
*
* Throws `ApiError` on non-2xx — 404 (batch missing) is the most
* likely case. The client-side guard rejects empty `claimIds` without
* making a request.
*/
export interface BatchExportResult {
blob: Blob;
filename: string;
serializeErrors: Array<{ claim_id: string; reason: string }>;
}
async function exportBatch837(
batchId: string,
claimIds: string[],
): Promise<BatchExportResult> {
if (!isConfigured) throw notConfiguredError();
if (claimIds.length === 0) {
throw new Error("claimIds is empty");
}
const res = await fetch(
joinUrl(`/api/batches/${encodeURIComponent(batchId)}/export-837`),
{
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/zip",
},
body: JSON.stringify({ claim_ids: claimIds }),
},
);
if (!res.ok) {
const detail = await readErrorBody(res);
throw new ApiError(res.status, detail || res.statusText);
}
const blob = await res.blob();
const cd = res.headers.get("content-disposition") ?? "";
const match = /filename="?([^";]+)"?/i.exec(cd);
const filename = match?.[1] ?? `batch-${batchId}-claims.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 {
// Malformed header — treat as empty rather than failing the download.
serializeErrors = [];
}
}
return { blob, filename, serializeErrors };
}
export const api = {
isConfigured,
baseUrl: BASE_URL,
@@ -799,6 +889,7 @@ export const api = {
listClaims,
getClaimDetail,
serializeClaim837,
exportBatch837,
listRemittances,
getRemittance,
listProviders,