feat(parsers+api+ui): expose CARC-labeled CAS adjustments (SP3 P2)

This commit is contained in:
Tyler
2026-06-20 07:44:41 -06:00
parent 76eb33e029
commit 2a853857b1
10 changed files with 927 additions and 26 deletions
+149
View File
@@ -0,0 +1,149 @@
// @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 } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Remittances } from "./Remittances";
import { api } from "@/lib/api";
// Module-level mock: vitest hoists `vi.mock` calls above imports. We only
// stub the method this page touches via the `useRemittances` hook.
// `isConfigured: true` is critical — without it, `useRemittances` falls
// back to the empty zustand store instead of calling our mocked API.
vi.mock("@/lib/api", () => ({
api: {
isConfigured: true,
listRemittances: vi.fn(),
getRemittance: vi.fn(),
},
}));
/**
* Minimal `render` helper using react-dom/client + act(). Mirrors the
* `renderIntoContainer` helper in `Reconciliation.test.tsx` — see that
* file's comment for the rationale (project doesn't ship a DOM test
* library yet, and adding one just for these tests would inflate the
* dev-deps tree). Returns the rendered container so tests can assert
* against the live DOM via `document.body.textContent`.
*/
function renderIntoContainer(element: React.ReactElement): {
container: HTMLDivElement;
unmount: () => void;
} {
const container = document.createElement("div");
document.body.appendChild(container);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const root: Root = createRoot(container);
act(() => {
root.render(
React.createElement(QueryClientProvider, { client: qc }, element)
);
});
return {
container,
unmount: () => {
act(() => root.unmount());
container.remove();
},
};
}
/**
* Flush microtasks + react state updates until the predicate holds or we
* time out. react-query's fetch resolves asynchronously, so we have to
* await a tick before the rendered DOM reflects the mocked data.
*/
async function waitForText(
text: string,
timeoutMs = 2000
): Promise<void> {
const start = Date.now();
while (!document.body.textContent?.includes(text)) {
if (Date.now() - start > timeoutMs) {
throw new Error(`waitForText: "${text}" did not appear within ${timeoutMs}ms`);
}
await act(async () => {
await Promise.resolve();
});
}
}
describe("Remittances", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders a CAS adjustment label inside the expanded detail row", async () => {
(
api.listRemittances as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
items: [
{
id: "PCN-1",
claimId: "CLM-1",
payerName: "Colorado Medicaid",
paidAmount: 100,
adjustmentAmount: 60,
receivedDate: "2026-06-19T12:00:00Z",
checkNumber: "",
status: "received",
adjustments: [
{
group: "CO",
reason: "45",
label: "Charge exceeds fee schedule/maximum allowable",
amount: 50,
quantity: null,
},
{
group: "PR",
reason: "1",
label: "Deductible amount",
amount: 10,
quantity: 1,
},
],
},
],
total: 1,
returned: 1,
has_more: false,
});
const { unmount } = renderIntoContainer(React.createElement(Remittances));
await waitForText("PCN-1");
// The chevron + "Adjustments" header should not yet be visible because
// the row hasn't been expanded yet.
expect(document.body.textContent).not.toContain("Adjustments (2)");
// Expand the row by clicking on the remit ID cell. We click the parent
// row by selecting the cell containing "PCN-1" and bubbling up.
const cell = Array.from(document.querySelectorAll("td")).find(
(td) => td.textContent === "PCN-1"
);
const row = cell?.closest("tr");
expect(row).toBeTruthy();
await act(async () => {
(row as HTMLTableRowElement).click();
});
await waitForText("Adjustments (2)");
// Both CAS labels must surface (not the raw codes alone).
expect(document.body.textContent).toContain(
"Charge exceeds fee schedule/maximum allowable"
);
expect(document.body.textContent).toContain("Deductible amount");
// Group/reason pills show the CARC code alongside.
expect(document.body.textContent).toContain("CO-45");
expect(document.body.textContent).toContain("PR-1");
unmount();
});
});
+121 -24
View File
@@ -1,4 +1,5 @@
import { useState } from "react";
import { Fragment, useState } from "react";
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
import {
Table,
TableBody,
@@ -16,7 +17,7 @@ import { Pagination } from "@/components/ui/pagination";
import { useRemittances } from "@/hooks/useRemittances";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { RemittanceStatus } from "@/types";
import type { CasAdjustment, RemittanceStatus } from "@/types";
const PAGE_SIZE = 25;
@@ -26,9 +27,36 @@ const STATUS_OPTIONS: FilterChipOption[] = [
{ value: "reconciled", label: "Reconciled" },
];
/**
* One persisted CAS row, rendered as a "code — label" pair plus the
* dollar amount. Lives inside the expanded detail row of a remit so
* the operator can see exactly why the payer adjusted the claim.
*/
function AdjustmentRow({ adj }: { adj: CasAdjustment }) {
return (
<div className="flex items-start justify-between gap-4 py-1.5 border-b border-border/40 last:border-0">
<div className="min-w-0 flex-1">
<div className="font-mono text-[11px] text-muted-foreground">
{adj.group}-{adj.reason}
{adj.quantity !== null ? (
<span className="ml-2 text-muted-foreground/70">
qty {adj.quantity}
</span>
) : null}
</div>
<div className="text-[13px] text-foreground/90 truncate">{adj.label}</div>
</div>
<div className="display num text-[13px] tabular-nums whitespace-nowrap text-muted-foreground">
{fmt.usdPrecise(adj.amount)}
</div>
</div>
);
}
export function Remittances() {
const [page, setPage] = useState(1);
const [status, setStatus] = useState<RemittanceStatus | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useRemittances({
sort: "receivedDate",
@@ -46,6 +74,15 @@ export function Remittances() {
{ paid: 0, adjustments: 0 }
);
const toggleExpand = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<div className="space-y-8 animate-fade-in">
<header>
@@ -117,6 +154,7 @@ export function Remittances() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" aria-label="Expand" />
<TableHead>Remit</TableHead>
<TableHead>Claim</TableHead>
<TableHead>Payer</TableHead>
@@ -127,27 +165,86 @@ export function Remittances() {
</TableRow>
</TableHeader>
<TableBody>
{items.map((r) => (
<TableRow key={`${r.id}-${dataUpdatedAt}`} className={cn("animate-row-flash")}>
<TableCell className="display num text-[13px]">{r.id}</TableCell>
<TableCell className="display num text-[13px] text-muted-foreground">
{r.claimId}
</TableCell>
<TableCell>{r.payerName}</TableCell>
<TableCell className="text-right display num">
{fmt.usdPrecise(r.paidAmount)}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
</TableCell>
<TableCell>
<RemitStatusBadge status={r.status} />
</TableCell>
<TableCell className="text-muted-foreground num text-[13px]">
{fmt.dateShort(r.receivedDate)}
</TableCell>
</TableRow>
))}
{items.map((r) => {
const isOpen = expanded.has(r.id);
const hasAdjustments =
!!r.adjustments && r.adjustments.length > 0;
return (
<Fragment key={`${r.id}-${dataUpdatedAt}`}>
<TableRow
className={cn("animate-row-flash")}
onClick={() =>
hasAdjustments ? toggleExpand(r.id) : undefined
}
aria-expanded={hasAdjustments ? isOpen : undefined}
style={{ cursor: hasAdjustments ? "pointer" : undefined }}
>
<TableCell className="text-muted-foreground">
{hasAdjustments ? (
isOpen ? (
<ChevronDown
className="h-3.5 w-3.5"
strokeWidth={1.75}
aria-hidden
/>
) : (
<ChevronRight
className="h-3.5 w-3.5"
strokeWidth={1.75}
aria-hidden
/>
)
) : null}
</TableCell>
<TableCell className="display num text-[13px]">{r.id}</TableCell>
<TableCell className="display num text-[13px] text-muted-foreground">
{r.claimId}
</TableCell>
<TableCell>{r.payerName}</TableCell>
<TableCell className="text-right display num">
{fmt.usdPrecise(r.paidAmount)}
</TableCell>
<TableCell className="text-right display num text-muted-foreground">
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
</TableCell>
<TableCell>
<RemitStatusBadge status={r.status} />
</TableCell>
<TableCell className="text-muted-foreground num text-[13px]">
{fmt.dateShort(r.receivedDate)}
</TableCell>
</TableRow>
{isOpen && hasAdjustments ? (
<TableRow
key={`${r.id}-${dataUpdatedAt}-detail`}
className="bg-muted/30 hover:bg-muted/30"
>
<TableCell />
<TableCell colSpan={7} className="py-3">
<div className="flex items-center gap-2 mb-2">
<Receipt
className="h-3.5 w-3.5 text-muted-foreground"
strokeWidth={1.5}
aria-hidden
/>
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Adjustments ({r.adjustments!.length})
</div>
</div>
<div className="pl-5">
{r.adjustments!.map((adj, i) => (
<AdjustmentRow
key={`${adj.group}-${adj.reason}-${i}`}
adj={adj}
/>
))}
</div>
</TableCell>
</TableRow>
) : null}
</Fragment>
);
})}
</TableBody>
</Table>
<div className="px-4 pb-4">
@@ -163,4 +260,4 @@ export function Remittances() {
</div>
</div>
);
}
}