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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
// useBatchExport — owns the per-claim selection state and the
|
||||
// "click Export → POST → download ZIP" flow for the Upload page's
|
||||
// streaming view.
|
||||
//
|
||||
// Extracted from Upload.tsx in SP9 (June 2026) so the logic is unit-
|
||||
// testable in isolation from the page (and so the page itself stays
|
||||
// focused on layout). All state is local to the hook — selection is
|
||||
// not persisted across navigations, and the captured `batchId` is the
|
||||
// most-recently-completed parse's server-side id.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
import { downloadBlob } from "@/lib/download";
|
||||
|
||||
/** Minimal shape of a streamed claim this hook needs to read the id. */
|
||||
export interface BatchExportItem {
|
||||
kind: "837p" | "835";
|
||||
data: { claim_id?: string; payer_claim_control_number?: string };
|
||||
}
|
||||
|
||||
export interface UseBatchExportResult {
|
||||
/** Currently-selected 837P claim ids. */
|
||||
selectedClaimIds: Set<string>;
|
||||
/** True while an export POST is in flight. */
|
||||
exporting: boolean;
|
||||
/** Total 837P claims currently in the stream. */
|
||||
total837InStream: number;
|
||||
/** Toggle a single claim's selection. */
|
||||
onToggleSelect: (claimId: string) => void;
|
||||
/** Toggle between "all selected" and "none selected" across the stream. */
|
||||
onToggleAll: () => void;
|
||||
/** Fire the export. Returns the promise so tests can await it. */
|
||||
onExport: () => Promise<void>;
|
||||
/**
|
||||
* Called by the parent after a successful parse to record the
|
||||
* server-side batch id. Captured into a ref so a re-parse that
|
||||
* completes mid-export can't swap in a different id under the
|
||||
* export promise.
|
||||
*/
|
||||
recordBatchId: (batchId: string | null) => void;
|
||||
}
|
||||
|
||||
export function useBatchExport(items: BatchExportItem[]): UseBatchExportResult {
|
||||
const [selectedClaimIds, setSelectedClaimIds] = useState<Set<string>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const lastBatchIdRef = useRef<string | null>(null);
|
||||
// Tracks every claim id that has ever been in the stream. Used by the
|
||||
// auto-select effect to distinguish "new claim, default-select it"
|
||||
// from "user previously deselected this claim — leave it alone".
|
||||
// Without this, a re-render with the same items list would re-add
|
||||
// every claim that the user explicitly deselected.
|
||||
const seenClaimIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Auto-select newly streamed 837P claims.
|
||||
//
|
||||
// Pre-compute the new claim ids OUTSIDE the setState callback so
|
||||
// the ref is mutated exactly once per items.length change and the
|
||||
// setState callback only runs when there's actually something new
|
||||
// to add. The previous inline pattern (mutating the ref inside the
|
||||
// setState callback) failed under React StrictMode's effect
|
||||
// double-invoke: the second invoke saw an empty prev (the first
|
||||
// invoke's queued update was dropped) AND a ref that already
|
||||
// contained the ids, so the second invoke added nothing — and the
|
||||
// selection ended up empty even though the stream populated.
|
||||
useEffect(() => {
|
||||
const newIds: string[] = [];
|
||||
for (const item of items) {
|
||||
if (item.kind === "837p" && item.data.claim_id) {
|
||||
const id = item.data.claim_id;
|
||||
// Only add if we've never seen this id before (so a
|
||||
// re-render with the same items doesn't re-add anything the
|
||||
// user just deselected) AND it's not already selected (no
|
||||
// need to add it again).
|
||||
if (!seenClaimIdsRef.current.has(id)) {
|
||||
seenClaimIdsRef.current.add(id);
|
||||
newIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newIds.length === 0) return;
|
||||
setSelectedClaimIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const id of newIds) next.add(id);
|
||||
return next;
|
||||
});
|
||||
// Key on items.length so a re-render with the same set of items
|
||||
// (just new array identity) doesn't re-run the effect.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [items.length]);
|
||||
|
||||
const total837InStream = useMemo(
|
||||
() => items.filter((i) => i.kind === "837p").length,
|
||||
[items],
|
||||
);
|
||||
|
||||
function onToggleSelect(claimId: string) {
|
||||
setSelectedClaimIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(claimId)) {
|
||||
next.delete(claimId);
|
||||
} else {
|
||||
next.add(claimId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function onToggleAll() {
|
||||
const streamed837Ids = items
|
||||
.filter((i): i is BatchExportItem & { data: { claim_id: string } } =>
|
||||
i.kind === "837p" && !!i.data.claim_id,
|
||||
)
|
||||
.map((i) => i.data.claim_id);
|
||||
if (
|
||||
streamed837Ids.length > 0 &&
|
||||
streamed837Ids.every((id) => selectedClaimIds.has(id))
|
||||
) {
|
||||
setSelectedClaimIds(new Set());
|
||||
} else {
|
||||
setSelectedClaimIds(new Set(streamed837Ids));
|
||||
}
|
||||
}
|
||||
|
||||
function recordBatchId(batchId: string | null) {
|
||||
lastBatchIdRef.current = batchId;
|
||||
}
|
||||
|
||||
async function onExport() {
|
||||
const batchId = lastBatchIdRef.current;
|
||||
if (!batchId) {
|
||||
toast.error("No batch to export — parse a file first.");
|
||||
return;
|
||||
}
|
||||
const ids = Array.from(selectedClaimIds);
|
||||
if (ids.length === 0) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const { blob, filename, serializeErrors } =
|
||||
await api.exportBatch837(batchId, ids);
|
||||
downloadBlob(filename, blob);
|
||||
if (serializeErrors.length === 0) {
|
||||
toast.success(`Exported ${ids.length} claims as X12`, {
|
||||
description: filename,
|
||||
});
|
||||
} else {
|
||||
toast.warning(
|
||||
`Exported ${ids.length - serializeErrors.length} · ${serializeErrors.length} couldn't be regenerated`,
|
||||
{
|
||||
description: serializeErrors
|
||||
.map((e) => `${e.claim_id}: ${e.reason}`)
|
||||
.join("\n"),
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
selectedClaimIds,
|
||||
exporting,
|
||||
total837InStream,
|
||||
onToggleSelect,
|
||||
onToggleAll,
|
||||
onExport,
|
||||
recordBatchId,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user