9bca4b608a
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.
271 lines
8.0 KiB
TypeScript
271 lines
8.0 KiB
TypeScript
// @vitest-environment happy-dom
|
|
// Tell React this is an `act`-aware test environment so react-query's
|
|
// internal state updates flush through without noisy console warnings.
|
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
|
true;
|
|
|
|
import React, { act, useEffect } from "react";
|
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
import { MemoryRouter, useLocation } from "react-router-dom";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { ClaimCard837 } from "@/components/ClaimCard837";
|
|
import { ClaimCard835 } from "./Upload";
|
|
import { useAppStore } from "@/store";
|
|
import type { ClaimOutput, ClaimPayment, ParsedBatch } from "@/types";
|
|
|
|
// Fixtures — kept tiny, just enough to exercise the drill logic.
|
|
const CLAIM_837: ClaimOutput = {
|
|
claim_id: "CLM-PERSISTED",
|
|
subscriber: {
|
|
first_name: "Jane",
|
|
last_name: "Doe",
|
|
member_id: "MEM-1",
|
|
},
|
|
payer: { name: "Test Payer", id: "P1" },
|
|
billing_provider: { npi: "1234567890" },
|
|
claim: {
|
|
total_charge: 100,
|
|
place_of_service: "11",
|
|
frequency_code: "1",
|
|
prior_auth: null,
|
|
},
|
|
service_lines: [
|
|
{
|
|
line_number: 1,
|
|
procedure: { qualifier: "HC", code: "99213", modifiers: [] },
|
|
charge: "100",
|
|
units: "1",
|
|
unit_type: "UN",
|
|
service_date: "2026-06-01",
|
|
},
|
|
],
|
|
diagnoses: [{ qualifier: "ABK", code: "J20.9" }],
|
|
validation: { passed: true, errors: [], warnings: [] },
|
|
};
|
|
|
|
const CLAIM_835: ClaimPayment = {
|
|
payer_claim_control_number: "PCN-PERSISTED",
|
|
status_code: "1",
|
|
status_label: "Processed as Primary",
|
|
claim_filing_indicator: "CI",
|
|
facility_type: "11",
|
|
frequency_code: "1",
|
|
total_charge: "100",
|
|
total_paid: "80",
|
|
patient_responsibility: "20",
|
|
service_payments: [
|
|
{
|
|
line_number: 1,
|
|
procedure_qualifier: "HC",
|
|
procedure_code: "99213",
|
|
modifiers: [],
|
|
service_date: "2026-06-01",
|
|
units: "1",
|
|
unit_type: "UN",
|
|
charge: "100",
|
|
payment: "80",
|
|
adjustments: [],
|
|
},
|
|
],
|
|
original_claim_id: null,
|
|
};
|
|
|
|
// Mount the card inside a MemoryRouter so the useNavigate call has
|
|
// a router context (without this, clicking the drill link would
|
|
// throw "useNavigate may be used only in a Router").
|
|
function renderCard(
|
|
element: React.ReactElement,
|
|
initialEntries: string[] = ["/upload"],
|
|
): {
|
|
container: HTMLDivElement;
|
|
unmount: () => void;
|
|
tracker: { pathname: string; search: string };
|
|
} {
|
|
const container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
const tracker = { pathname: "/upload", search: "" };
|
|
const Tracker = () => {
|
|
const loc = useLocation();
|
|
useEffect(() => {
|
|
tracker.pathname = loc.pathname;
|
|
tracker.search = loc.search;
|
|
}, [loc.pathname, loc.search]);
|
|
return null;
|
|
};
|
|
const root: Root = createRoot(container);
|
|
act(() => {
|
|
root.render(
|
|
React.createElement(
|
|
MemoryRouter,
|
|
{ initialEntries },
|
|
React.createElement(Tracker, null),
|
|
element,
|
|
),
|
|
);
|
|
});
|
|
return {
|
|
container,
|
|
unmount: () => {
|
|
act(() => root.unmount());
|
|
container.remove();
|
|
},
|
|
tracker,
|
|
};
|
|
}
|
|
|
|
describe("ClaimCard837 / ClaimCard835 drill link (SP21 Phase 5 Task 5.7)", () => {
|
|
beforeEach(() => {
|
|
// Reset parsedBatches to empty by default — individual tests
|
|
// populate it as needed.
|
|
useAppStore.setState({ parsedBatches: [] });
|
|
});
|
|
|
|
afterEach(() => {
|
|
useAppStore.setState({ parsedBatches: [] });
|
|
});
|
|
|
|
it("ClaimCard837: renders the drill link when the claim_id is in a persisted batch", async () => {
|
|
// Pre-populate the store with a parsed batch that contains this
|
|
// claim id, then expand the card and verify the link is present.
|
|
const persisted: ParsedBatch = {
|
|
id: "batch-1",
|
|
kind: "837p",
|
|
inputFilename: "test.837",
|
|
parsedAt: "2026-06-21T12:00:00Z",
|
|
claimCount: 1,
|
|
passed: 1,
|
|
failed: 0,
|
|
claimIds: ["CLM-PERSISTED"],
|
|
summary: { total_claims: 1, passed: 1, failed: 0 },
|
|
};
|
|
useAppStore.setState({ parsedBatches: [persisted] });
|
|
|
|
const { container, unmount } = renderCard(
|
|
React.createElement(ClaimCard837, { claim: CLAIM_837 }),
|
|
);
|
|
|
|
// Expand the card.
|
|
const header = container.querySelector("button[aria-expanded]");
|
|
await act(async () => {
|
|
(header as HTMLButtonElement).click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// The drill link should now be visible.
|
|
const link = container.querySelector('[data-testid="upload-claim-drill"]');
|
|
expect(link).not.toBeNull();
|
|
expect(link?.textContent).toContain("See claim in detail");
|
|
unmount();
|
|
});
|
|
|
|
it("ClaimCard837: does NOT render the drill link when the claim_id is not persisted", async () => {
|
|
// Streaming-only claim — the parsedBatches slice is empty, so
|
|
// the link must be absent (clicking would 404 ClaimDrawer).
|
|
const { container, unmount } = renderCard(
|
|
React.createElement(ClaimCard837, { claim: CLAIM_837 }),
|
|
);
|
|
|
|
const header = container.querySelector("button[aria-expanded]");
|
|
await act(async () => {
|
|
(header as HTMLButtonElement).click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(
|
|
container.querySelector('[data-testid="upload-claim-drill"]'),
|
|
).toBeNull();
|
|
unmount();
|
|
});
|
|
|
|
it("ClaimCard837: clicking the drill link navigates to /claims?claim=ID", async () => {
|
|
const persisted: ParsedBatch = {
|
|
id: "batch-1",
|
|
kind: "837p",
|
|
inputFilename: "test.837",
|
|
parsedAt: "2026-06-21T12:00:00Z",
|
|
claimCount: 1,
|
|
passed: 1,
|
|
failed: 0,
|
|
claimIds: ["CLM-PERSISTED"],
|
|
summary: { total_claims: 1, passed: 1, failed: 0 },
|
|
};
|
|
useAppStore.setState({ parsedBatches: [persisted] });
|
|
|
|
const { container, unmount, tracker } = renderCard(
|
|
React.createElement(ClaimCard837, { claim: CLAIM_837 }),
|
|
);
|
|
|
|
const header = container.querySelector("button[aria-expanded]");
|
|
await act(async () => {
|
|
(header as HTMLButtonElement).click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const link = container.querySelector(
|
|
'[data-testid="upload-claim-drill"]',
|
|
) as HTMLButtonElement;
|
|
await act(async () => {
|
|
link.click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(tracker.pathname).toBe("/claims");
|
|
expect(tracker.search).toBe("?claim=CLM-PERSISTED");
|
|
unmount();
|
|
});
|
|
|
|
it("ClaimCard835: renders the drill link when the PCN is persisted", async () => {
|
|
const persisted: ParsedBatch = {
|
|
id: "batch-1",
|
|
kind: "835",
|
|
inputFilename: "test.835",
|
|
parsedAt: "2026-06-21T12:00:00Z",
|
|
claimCount: 1,
|
|
passed: 1,
|
|
failed: 0,
|
|
claimIds: ["PCN-PERSISTED"],
|
|
summary: { total_claims: 1, passed: 1, failed: 0 },
|
|
};
|
|
useAppStore.setState({ parsedBatches: [persisted] });
|
|
|
|
const { container, unmount, tracker } = renderCard(
|
|
React.createElement(ClaimCard835, { claim: CLAIM_835 }),
|
|
);
|
|
|
|
const header = container.querySelector("button[aria-expanded]");
|
|
await act(async () => {
|
|
(header as HTMLButtonElement).click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
const link = container.querySelector('[data-testid="upload-remit-drill"]');
|
|
expect(link).not.toBeNull();
|
|
await act(async () => {
|
|
(link as HTMLButtonElement).click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// 835 cards drill to /remittances?remit=PCN (not /claims), since
|
|
// the RemitDrawer is the right surface for the payment side.
|
|
expect(tracker.pathname).toBe("/remittances");
|
|
expect(tracker.search).toBe("?remit=PCN-PERSISTED");
|
|
unmount();
|
|
});
|
|
|
|
it("ClaimCard835: does NOT render the drill link when the PCN is not persisted", async () => {
|
|
const { container, unmount } = renderCard(
|
|
React.createElement(ClaimCard835, { claim: CLAIM_835 }),
|
|
);
|
|
|
|
const header = container.querySelector("button[aria-expanded]");
|
|
await act(async () => {
|
|
(header as HTMLButtonElement).click();
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(
|
|
container.querySelector('[data-testid="upload-remit-drill"]'),
|
|
).toBeNull();
|
|
unmount();
|
|
});
|
|
}); |