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 { useMemo, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -138,10 +139,22 @@ function StatPill({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ClaimCard837({ claim }: { claim: ClaimOutput }) {
|
export function ClaimCard837({ claim }: { claim: ClaimOutput }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const passed = claim.validation.passed;
|
const passed = claim.validation.passed;
|
||||||
const hasWarnings = claim.validation.warnings.length > 0;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg overflow-hidden border"
|
className="rounded-lg overflow-hidden border"
|
||||||
@@ -331,6 +344,31 @@ function ClaimCard837({ claim }: { claim: ClaimOutput }) {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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 [open, setOpen] = useState(false);
|
||||||
const passed = claim.service_payments.length > 0;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg overflow-hidden border"
|
className="rounded-lg overflow-hidden border"
|
||||||
@@ -518,6 +566,31 @@ function ClaimCard835({ claim }: { claim: ClaimPayment }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user