feat(upload): streamed claim cards offer drill to persisted entity
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
// @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, 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();
|
||||
});
|
||||
});
|
||||
+75
-2
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
@@ -138,10 +139,22 @@ function StatPill({
|
||||
);
|
||||
}
|
||||
|
||||
function ClaimCard837({ claim }: { claim: ClaimOutput }) {
|
||||
export function ClaimCard837({ claim }: { claim: ClaimOutput }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const passed = claim.validation.passed;
|
||||
const hasWarnings = claim.validation.warnings.length > 0;
|
||||
// SP21 Phase 5 Task 5.7: a "See claim in detail →" link drills to
|
||||
// /claims?claim=ID — but ONLY when this streamed claim_id has
|
||||
// actually been persisted to a parsed batch. The Upload page
|
||||
// streams claims as the parser emits them; until the user clicks
|
||||
// "Save batch" the claim isn't visible to ClaimDrawer.
|
||||
const parsedBatches = useAppStore((s) => s.parsedBatches);
|
||||
const persistedClaimIds = useMemo(
|
||||
() => new Set(parsedBatches.flatMap((b) => b.claimIds)),
|
||||
[parsedBatches],
|
||||
);
|
||||
const canDrill = persistedClaimIds.has(claim.claim_id);
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg overflow-hidden border"
|
||||
@@ -331,6 +344,31 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* SP21 Phase 5 Task 5.7: drill to the persisted claim. Only
|
||||
renders when this streamed claim_id has been saved into
|
||||
a parsed batch (so ClaimDrawer can find it). Before
|
||||
"Save batch" the claim is streaming-only and the link
|
||||
would 404. */}
|
||||
{canDrill ? (
|
||||
<div className="pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/claims?claim=${encodeURIComponent(claim.claim_id)}`,
|
||||
)
|
||||
}
|
||||
data-testid="upload-claim-drill"
|
||||
aria-label={`See claim ${claim.claim_id} in detail`}
|
||||
className="inline-flex items-center gap-1.5 text-[12px] mono font-semibold cursor-pointer rounded-sm px-1 -mx-1 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
See claim in detail
|
||||
<ArrowRight className="h-3 w-3" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -385,9 +423,19 @@ function ServiceLine837Row({ line }: { line: ServiceLine }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ClaimCard835({ claim }: { claim: ClaimPayment }) {
|
||||
export function ClaimCard835({ claim }: { claim: ClaimPayment }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const passed = claim.service_payments.length > 0;
|
||||
// SP21 Phase 5 Task 5.7: same drill gate as ClaimCard837. Only show
|
||||
// "See claim in detail" once the claim_id is in the persisted
|
||||
// batches (otherwise ClaimDrawer would 404).
|
||||
const parsedBatches = useAppStore((s) => s.parsedBatches);
|
||||
const persistedClaimIds = useMemo(
|
||||
() => new Set(parsedBatches.flatMap((b) => b.claimIds)),
|
||||
[parsedBatches],
|
||||
);
|
||||
const canDrill = persistedClaimIds.has(claim.payer_claim_control_number);
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg overflow-hidden border"
|
||||
@@ -518,6 +566,31 @@ function ClaimCard835({ claim }: { claim: ClaimPayment }) {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* SP21 Phase 5 Task 5.7: drill to the persisted remit. The
|
||||
835 cards have no separate "claim" (they're the payment
|
||||
side), so the link goes to /remittances?remit=PCN which
|
||||
opens the RemitDrawer for this payment. Same persisted-
|
||||
gate: only show when the PCN is in a saved batch. */}
|
||||
{canDrill ? (
|
||||
<div className="pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/remittances?remit=${encodeURIComponent(claim.payer_claim_control_number)}`,
|
||||
)
|
||||
}
|
||||
data-testid="upload-remit-drill"
|
||||
aria-label={`See remittance ${claim.payer_claim_control_number} in detail`}
|
||||
className="inline-flex items-center gap-1.5 text-[12px] mono font-semibold cursor-pointer rounded-sm px-1 -mx-1 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
|
||||
style={{ color: "hsl(var(--surface-ink))" }}
|
||||
>
|
||||
See claim in detail
|
||||
<ArrowRight className="h-3 w-3" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user