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:
@@ -0,0 +1,288 @@
|
||||
// @vitest-environment happy-dom
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
|
||||
import React, { act } from "react";
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
|
||||
// Mock the api + download + toast surfaces so the hook can be exercised
|
||||
// in isolation. Each test sets up its own expectations on these.
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
exportBatch837: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/download", () => ({
|
||||
downloadBlob: vi.fn(),
|
||||
}));
|
||||
vi.mock("sonner", () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { useBatchExport, type BatchExportItem } from "./useBatchExport";
|
||||
import { api } from "@/lib/api";
|
||||
import { downloadBlob } from "@/lib/download";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const mockExportBatch837 = vi.mocked(api.exportBatch837);
|
||||
const mockDownloadBlob = vi.mocked(downloadBlob);
|
||||
const mockToastSuccess = vi.mocked(toast.success);
|
||||
const mockToastWarning = vi.mocked(toast.warning);
|
||||
const mockToastError = vi.mocked(toast.error);
|
||||
|
||||
function makeItem(claimId: string): BatchExportItem {
|
||||
return { kind: "837p", data: { claim_id: claimId } };
|
||||
}
|
||||
function make835Item(id: string): BatchExportItem {
|
||||
return { kind: "835", data: { payer_claim_control_number: id } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the hook with a fixed initial list and capture its return
|
||||
* value. The `rerenderWith` helper re-creates the harness with a new
|
||||
* initial list — that's how we simulate "new claims streamed in"
|
||||
* without poking at internal useState setters from outside the
|
||||
* component.
|
||||
*/
|
||||
function setup(initial: BatchExportItem[]) {
|
||||
let latest: ReturnType<typeof useBatchExport> | null = null;
|
||||
const container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
|
||||
function Harness({ items }: { items: BatchExportItem[] }) {
|
||||
latest = useBatchExport(items);
|
||||
return null;
|
||||
}
|
||||
|
||||
const root: Root = createRoot(container);
|
||||
function render(items: BatchExportItem[]) {
|
||||
act(() => {
|
||||
root.render(React.createElement(Harness, { items }));
|
||||
});
|
||||
}
|
||||
render(initial);
|
||||
|
||||
return {
|
||||
get latest(): ReturnType<typeof useBatchExport> {
|
||||
if (!latest) throw new Error("hook result not yet captured");
|
||||
return latest;
|
||||
},
|
||||
/** Re-render with a new items list (e.g. to simulate streaming). */
|
||||
rerenderWith(items: BatchExportItem[]) {
|
||||
render(items);
|
||||
},
|
||||
unmount() {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("useBatchExport", () => {
|
||||
beforeEach(() => {
|
||||
mockExportBatch837.mockReset();
|
||||
mockDownloadBlob.mockReset();
|
||||
mockToastSuccess.mockReset();
|
||||
mockToastWarning.mockReset();
|
||||
mockToastError.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("auto-selects all 837P claims passed in initially", () => {
|
||||
const h = setup([makeItem("CLM-1"), makeItem("CLM-2"), makeItem("CLM-3")]);
|
||||
expect(h.latest.selectedClaimIds.size).toBe(3);
|
||||
expect(h.latest.total837InStream).toBe(3);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("ignores 835 items when counting / selecting", () => {
|
||||
const h = setup([makeItem("CLM-1"), make835Item("PCN-1")]);
|
||||
expect(h.latest.selectedClaimIds.size).toBe(1);
|
||||
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(true);
|
||||
expect(h.latest.total837InStream).toBe(1);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("auto-selects a new 837P claim that streams in", () => {
|
||||
const h = setup([makeItem("CLM-1")]);
|
||||
expect(h.latest.selectedClaimIds.size).toBe(1);
|
||||
h.rerenderWith([makeItem("CLM-1"), makeItem("CLM-2")]);
|
||||
expect(h.latest.selectedClaimIds.size).toBe(2);
|
||||
expect(h.latest.selectedClaimIds.has("CLM-2")).toBe(true);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("does not deselect existing claims when a new one streams in", () => {
|
||||
const h = setup([makeItem("CLM-1")]);
|
||||
act(() => {
|
||||
h.latest.onToggleSelect("CLM-1"); // deselect
|
||||
});
|
||||
expect(h.latest.selectedClaimIds.size).toBe(0);
|
||||
h.rerenderWith([makeItem("CLM-1"), makeItem("CLM-2")]);
|
||||
// CLM-1 is still deselected (user's choice is preserved), CLM-2
|
||||
// is auto-selected.
|
||||
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(false);
|
||||
expect(h.latest.selectedClaimIds.has("CLM-2")).toBe(true);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onToggleSelect adds and removes", () => {
|
||||
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
|
||||
act(() => {
|
||||
h.latest.onToggleSelect("CLM-1"); // toggle off
|
||||
});
|
||||
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(false);
|
||||
act(() => {
|
||||
h.latest.onToggleSelect("CLM-1"); // toggle back on
|
||||
});
|
||||
expect(h.latest.selectedClaimIds.has("CLM-1")).toBe(true);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onToggleAll deselects when everything is selected", () => {
|
||||
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
|
||||
expect(h.latest.selectedClaimIds.size).toBe(2);
|
||||
act(() => {
|
||||
h.latest.onToggleAll();
|
||||
});
|
||||
expect(h.latest.selectedClaimIds.size).toBe(0);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onToggleAll selects everything when partial / none are selected", () => {
|
||||
const h = setup([makeItem("CLM-1"), makeItem("CLM-2"), makeItem("CLM-3")]);
|
||||
act(() => {
|
||||
h.latest.onToggleSelect("CLM-1"); // partial: only CLM-2 and CLM-3
|
||||
});
|
||||
act(() => {
|
||||
h.latest.onToggleSelect("CLM-2");
|
||||
});
|
||||
act(() => {
|
||||
h.latest.onToggleAll();
|
||||
});
|
||||
expect(h.latest.selectedClaimIds.size).toBe(3);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onExport does nothing when no batch id is recorded", async () => {
|
||||
const h = setup([makeItem("CLM-1")]);
|
||||
await h.latest.onExport();
|
||||
expect(mockExportBatch837).not.toHaveBeenCalled();
|
||||
expect(mockToastError).toHaveBeenCalled();
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onExport does nothing when selection is empty (after recordBatchId)", async () => {
|
||||
const h = setup([makeItem("CLM-1")]);
|
||||
h.latest.recordBatchId("server-uuid-123");
|
||||
act(() => {
|
||||
h.latest.onToggleSelect("CLM-1"); // deselect
|
||||
});
|
||||
await h.latest.onExport();
|
||||
expect(mockExportBatch837).not.toHaveBeenCalled();
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onExport calls api.exportBatch837 with the recorded batch id and selected ids, then downloadBlob + success toast", async () => {
|
||||
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
|
||||
h.latest.recordBatchId("server-uuid-456");
|
||||
const exportedBlob = new Blob([new Uint8Array([1, 2, 3])], {
|
||||
type: "application/zip",
|
||||
});
|
||||
mockExportBatch837.mockResolvedValueOnce({
|
||||
blob: exportedBlob,
|
||||
filename: "batch-server-uuid-456-2-claims.zip",
|
||||
serializeErrors: [],
|
||||
});
|
||||
await h.latest.onExport();
|
||||
expect(mockExportBatch837).toHaveBeenCalledWith(
|
||||
"server-uuid-456",
|
||||
expect.arrayContaining(["CLM-1", "CLM-2"]),
|
||||
);
|
||||
expect(mockDownloadBlob).toHaveBeenCalledTimes(1);
|
||||
// Regression: downloadBlob's signature is (filename, blob). An earlier
|
||||
// build had the args swapped, which made URL.createObjectURL receive
|
||||
// the filename string and throw "Overload resolution failed" — pin
|
||||
// the order here so a swap can't sneak back in.
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith(
|
||||
"batch-server-uuid-456-2-claims.zip",
|
||||
exportedBlob,
|
||||
);
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
"Exported 2 claims as X12",
|
||||
expect.objectContaining({ description: "batch-server-uuid-456-2-claims.zip" }),
|
||||
);
|
||||
expect(mockToastWarning).not.toHaveBeenCalled();
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onExport shows a warning toast with serialize-error details on partial failure", async () => {
|
||||
const h = setup([makeItem("CLM-1"), makeItem("CLM-2")]);
|
||||
h.latest.recordBatchId("server-uuid-789");
|
||||
mockExportBatch837.mockResolvedValueOnce({
|
||||
blob: new Blob([new Uint8Array([1])]),
|
||||
filename: "batch-server-uuid-789-1-claims.zip",
|
||||
serializeErrors: [{ claim_id: "CLM-2", reason: "no raw_json" }],
|
||||
});
|
||||
await h.latest.onExport();
|
||||
expect(mockDownloadBlob).toHaveBeenCalledTimes(1);
|
||||
expect(mockToastSuccess).not.toHaveBeenCalled();
|
||||
expect(mockToastWarning).toHaveBeenCalledWith(
|
||||
"Exported 1 · 1 couldn't be regenerated",
|
||||
expect.objectContaining({
|
||||
description: expect.stringContaining("CLM-2: no raw_json"),
|
||||
}),
|
||||
);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("onExport shows an error toast and resets exporting=false on network failure", async () => {
|
||||
const h = setup([makeItem("CLM-1")]);
|
||||
h.latest.recordBatchId("server-uuid-abc");
|
||||
mockExportBatch837.mockRejectedValueOnce(new Error("network down"));
|
||||
await h.latest.onExport();
|
||||
expect(mockToastError).toHaveBeenCalledWith("network down");
|
||||
expect(h.latest.exporting).toBe(false);
|
||||
h.unmount();
|
||||
});
|
||||
|
||||
it("exporting is true while the export is in flight, false after", async () => {
|
||||
const h = setup([makeItem("CLM-1")]);
|
||||
h.latest.recordBatchId("server-uuid-xyz");
|
||||
|
||||
let resolveExport: (v: unknown) => void = () => {};
|
||||
mockExportBatch837.mockReturnValueOnce(
|
||||
new Promise((res) => {
|
||||
resolveExport = res;
|
||||
}) as ReturnType<typeof api.exportBatch837>,
|
||||
);
|
||||
|
||||
const exportPromise = h.latest.onExport();
|
||||
// Yield once so the exporting=true state has flushed.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(h.latest.exporting).toBe(true);
|
||||
|
||||
resolveExport({
|
||||
blob: new Blob(),
|
||||
filename: "x.zip",
|
||||
serializeErrors: [],
|
||||
});
|
||||
// The state update in onExport's `finally` block happens on a
|
||||
// microtask after the promise resolves; wrapping the await in
|
||||
// act() flushes it.
|
||||
await act(async () => {
|
||||
await exportPromise;
|
||||
});
|
||||
expect(h.latest.exporting).toBe(false);
|
||||
h.unmount();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user