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:
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user