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:
+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