feat(frontend): RemitDrawer component with CAS adjustments + parties + claim payments

This commit is contained in:
Tyler
2026-06-20 17:16:23 -06:00
parent 4cd52c3084
commit 25d47a5621
21 changed files with 2899 additions and 0 deletions
@@ -0,0 +1,108 @@
// @vitest-environment happy-dom
(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 } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CasAdjustmentsPanel } from "./CasAdjustmentsPanel";
import type { CasAdjustment } from "@/types";
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();
},
};
}
describe("CasAdjustmentsPanel", () => {
it("groups_repeated_codes_and_shows_count_and_amount", () => {
const adjs: CasAdjustment[] = [
{ group: "CO", reason: "45", label: "Charge exceeds fee schedule", amount: 50, quantity: null },
{ group: "PR", reason: "1", label: "Deductible", amount: 10, quantity: 1 },
{ group: "CO", reason: "45", label: "Charge exceeds fee schedule", amount: -35, quantity: null },
];
const { container, unmount } = renderIntoContainer(
<CasAdjustmentsPanel adjustments={adjs} />
);
const rows = container.querySelectorAll('[data-testid="cas-row"]');
expect(rows.length).toBe(2);
const co45 = Array.from(rows).find(
(row) => row.getAttribute("data-carc") === "CO-45"
);
expect(co45).toBeDefined();
expect(co45?.textContent).toContain("×2");
// Cumulative amount 50 + -35 = 15, formatted as $15.00.
expect(co45?.textContent).toContain("15");
unmount();
});
it("renders_nothing_when_adjustments_empty", () => {
const { container, unmount } = renderIntoContainer(
<CasAdjustmentsPanel adjustments={[]} />
);
// An empty list produces no panel — the parent decides what to
// show in its place (the parent omits the section entirely, but
// we still defend against the empty-array case here).
expect(
container.querySelector('[data-testid="cas-adjustments-panel"]')
).toBeNull();
unmount();
});
it("toggle_collapses_and_expands_the_list", () => {
// A long list (> 3 groups) starts collapsed so the drawer doesn't
// get buried under a wall of CARC rows. The user can re-expand by
// clicking the section header.
const adjs: CasAdjustment[] = [
{ group: "CO", reason: "45", label: "A", amount: 10, quantity: null },
{ group: "PR", reason: "1", label: "B", amount: 10, quantity: 1 },
{ group: "OA", reason: "23", label: "C", amount: 10, quantity: null },
{ group: "PI", reason: "10", label: "D", amount: 10, quantity: null },
];
const { container, unmount } = renderIntoContainer(
<CasAdjustmentsPanel adjustments={adjs} />
);
// 4 unique CARC codes → starts collapsed.
expect(
container.querySelector('[data-testid="cas-adjustments-list"]')
).toBeNull();
// Click the toggle → list expands.
const toggle = container.querySelector(
'[data-testid="cas-toggle"]'
) as HTMLButtonElement | null;
expect(toggle).not.toBeNull();
act(() => {
toggle?.click();
});
expect(
container.querySelector('[data-testid="cas-adjustments-list"]')
).not.toBeNull();
unmount();
});
});
@@ -0,0 +1,167 @@
import { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { RemitDetail } from "@/hooks/useRemitDetail";
type CasAdjustmentsPanelProps = {
adjustments: NonNullable<RemitDetail["adjustments"]>;
};
/**
* Group adjustments by `(group, reason)` so a single CARC that fires
* multiple times in the same remit (common — e.g. CO-45 hitting each
* service line of a multi-line claim) collapses into one row with a
* `count` and a cumulative `total`. Sorted by absolute dollar impact
* so the eye lands on the biggest hit first.
*/
type CasGroup = {
group: string;
reason: string;
label: string;
total: number;
count: number;
};
function groupAdjustments(
adjustments: NonNullable<RemitDetail["adjustments"]>
): CasGroup[] {
const byKey = new Map<string, CasGroup>();
for (const adj of adjustments) {
const key = `${adj.group}-${adj.reason}`;
const existing = byKey.get(key);
if (existing) {
existing.total += adj.amount;
existing.count += 1;
} else {
byKey.set(key, {
group: adj.group,
reason: adj.reason,
label: adj.label,
total: adj.amount,
count: 1,
});
}
}
return Array.from(byKey.values()).sort(
(a, b) => Math.abs(b.total) - Math.abs(a.total)
);
}
/**
* Single CAS row. The CARC key is the headline; label is secondary
* metadata; amount and quantity use the project's mono treatment so
* columns line up vertically.
*/
function CasRow({ group }: { group: CasGroup }) {
return (
<div
data-testid="cas-row"
data-carc={`${group.group}-${group.reason}`}
className="grid grid-cols-[6rem_1fr_5rem_6rem] items-baseline gap-3 px-4 py-2 border-b border-[color:var(--m-border-heavy)]/10 last:border-b-0"
>
<span
className="font-mono text-sm tabular-nums text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{group.group}-{group.reason}
</span>
<span className="text-sm text-[color:var(--m-ink-secondary)]">{group.label}</span>
<span
className="text-right font-mono text-sm tabular-nums text-[color:var(--m-ink-tertiary)]"
data-testid="cas-count"
>
×{group.count}
</span>
<span
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
data-testid="cas-amount"
>
{fmt.usdPrecise(group.total)}
</span>
</div>
);
}
/**
* Collapsible CAS adjustments panel for the remittance detail drawer.
*
* Lists every CARC reason code that fired on the remit, grouped by
* (group, reason) so a single CO-45 hitting N service lines reads as
* one row with `×N` and a cumulative dollar amount. The label is the
* backend-supplied CARC dictionary lookup (SP3 P2), so we never have
* to ship a client-side code table.
*
* Default-open when there are ≤ 3 groups (a small handful of codes
* the user is likely to want to see immediately); collapsed when the
* list is longer so the drawer doesn't get buried under a wall of
* CARC rows.
*/
export function CasAdjustmentsPanel({ adjustments }: CasAdjustmentsPanelProps) {
const groups = groupAdjustments(adjustments);
const [open, setOpen] = useState(groups.length <= 3);
if (groups.length === 0) return null;
return (
<section
className="flex flex-col gap-3 px-6 py-4"
data-testid="cas-adjustments-panel"
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls="cas-adjustments-list"
className="flex items-center gap-2 text-left"
data-testid="cas-toggle"
>
{open ? (
<ChevronDown className="h-3.5 w-3.5 text-[color:var(--m-ink-tertiary)]" aria-hidden />
) : (
<ChevronRight className="h-3.5 w-3.5 text-[color:var(--m-ink-tertiary)]" aria-hidden />
)}
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
>
CAS Adjustments ({groups.length})
</h3>
</button>
{open ? (
<div
id="cas-adjustments-list"
data-testid="cas-adjustments-list"
className={cn(
"flex flex-col rounded-lg border border-[color:var(--m-border-heavy)]/15",
"bg-[color:var(--m-surface)]/40 overflow-hidden"
)}
>
{/* Header row — labels for the 4-column grid */}
<div className="grid grid-cols-[6rem_1fr_5rem_6rem] gap-3 px-4 py-2 border-b border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)]/60">
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
CARC
</span>
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
Reason
</span>
<span className="text-right text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
Hits
</span>
<span className="text-right text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
Amount
</span>
</div>
{groups.map((g) => (
<CasRow
key={`${g.group}-${g.reason}`}
group={g}
/>
))}
</div>
) : null}
</section>
);
}
@@ -0,0 +1,108 @@
// @vitest-environment happy-dom
(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 } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ClaimPaymentsTable } from "./ClaimPaymentsTable";
import type { RemitDetail } from "@/hooks/useRemitDetail";
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();
},
};
}
describe("ClaimPaymentsTable", () => {
it("renders_headline_row_and_carc_breakdown", () => {
const sample: RemitDetail = {
id: "REM-1",
claimId: "CLM-1",
payerClaimControlNumber: "PCN-1",
payerName: "Medicaid",
paidAmount: 100,
adjustmentAmount: 25,
totalCharge: 125,
receivedDate: "2026-01-15T00:00:00Z",
checkNumber: "EFT-1",
status: "received",
adjustments: [
{ group: "CO", reason: "45", label: "Charge exceeds fee schedule", amount: 50, quantity: null },
{ group: "PR", reason: "1", label: "Deductible", amount: 10, quantity: 1 },
{ group: "CO", reason: "45", label: "Charge exceeds fee schedule", amount: -35, quantity: null },
],
};
const { container, unmount } = renderIntoContainer(
<ClaimPaymentsTable remit={sample} />
);
// Headline table is present.
expect(
container.querySelector('[data-testid="claim-payments-table"]')
).not.toBeNull();
expect(
container.querySelector('[data-testid="claim-payments-row"]')
).not.toBeNull();
// CARC breakdown groups the two CO-45 hits into one row.
const carcRows = container.querySelectorAll('[data-testid="carc-row"]');
expect(carcRows.length).toBe(2);
// CO-45 rows: two of them with a cumulative amount of 50 + -35 = 15
const co45 = Array.from(carcRows).find((row) =>
row.getAttribute("data-carc") === "CO-45"
);
expect(co45).toBeDefined();
expect(co45?.textContent).toContain("×2");
// The cumulative amount shows $15.00 (formatted by usdPrecise).
expect(co45?.textContent).toContain("15");
unmount();
});
it("omits_carc_breakdown_when_no_adjustments", () => {
const sample: RemitDetail = {
id: "REM-1",
claimId: "CLM-1",
payerClaimControlNumber: "PCN-1",
payerName: "Medicaid",
paidAmount: 100,
adjustmentAmount: 0,
receivedDate: "2026-01-15T00:00:00Z",
checkNumber: "EFT-1",
status: "received",
adjustments: [],
};
const { container, unmount } = renderIntoContainer(
<ClaimPaymentsTable remit={sample} />
);
expect(
container.querySelector('[data-testid="claim-payments-table"]')
).not.toBeNull();
expect(
container.querySelector('[data-testid="claim-payments-carc-summary"]')
).toBeNull();
unmount();
});
});
@@ -0,0 +1,176 @@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { fmt } from "@/lib/format";
import type { RemitDetail } from "@/hooks/useRemitDetail";
type ClaimPaymentsTableProps = {
remit: RemitDetail;
};
/**
* Format a CARC reason code as `GROUP-REASON` (e.g. `CO-45`). Both
* halves come straight off the backend `CasAdjustment` row; the group
* is the 2-letter ANSI code (CO/PR/OA/PI/CR) and the reason is the
* numeric code within that group.
*/
function carcKey(group: string, reason: string): string {
return `${group}-${reason}`;
}
/**
* Aggregate per-CARC adjustment totals so the table footer can show
* the dollar impact per reason code. The remit's flat `adjustments`
* array is summed in-place — no server round-trip.
*/
function summarizeAdjustments(remit: RemitDetail): Array<{
group: string;
reason: string;
label: string;
total: number;
count: number;
}> {
const byKey = new Map<
string,
{ group: string; reason: string; label: string; total: number; count: number }
>();
for (const adj of remit.adjustments ?? []) {
const key = carcKey(adj.group, adj.reason);
const existing = byKey.get(key);
if (existing) {
existing.total += adj.amount;
existing.count += 1;
} else {
byKey.set(key, {
group: adj.group,
reason: adj.reason,
label: adj.label,
total: adj.amount,
count: 1,
});
}
}
// Largest dollar impact first — easy to scan for the biggest hit.
return Array.from(byKey.values()).sort((a, b) => Math.abs(b.total) - Math.abs(a.total));
}
/**
* Claim payments table for the remittance detail drawer.
*
* The current backend `/api/remittances/{id}` payload exposes one
* `ClaimPayment` per remit (the persistence layer keys adjustments by
* remittance, not by claim), so the table renders one row carrying the
* remit's claim identity (PCN + claim id) plus the headline figures
* (paid + adjustments). Below the headline row we surface a per-CARC
* breakdown — one row per (group, reason) pair, with the cumulative
* amount and the reason-code label — so the user can see WHY the
* adjustment totals moved without leaving the drawer.
*
* When the backend grows to expose per-`ClaimPayment.service_payment`
* rows in the detail payload, the table grows naturally: a parent row
* per claim, expandable to per-service-payment children. The current
* single-row layout is the v1 shape that works against the v1 API.
*/
export function ClaimPaymentsTable({ remit }: ClaimPaymentsTableProps) {
const summary = summarizeAdjustments(remit);
const claimLabel = remit.payerClaimControlNumber ?? remit.claimId ?? remit.id;
const hasCharge = typeof remit.totalCharge === "number" && Number.isFinite(remit.totalCharge);
return (
<section className="flex flex-col gap-3 px-6 py-4" data-testid="claim-payments-table-section">
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
>
Claim Payments (1)
</h3>
<Table data-testid="claim-payments-table">
<TableHeader>
<TableRow>
<TableHead className="w-44">Claim</TableHead>
<TableHead className="w-28">Status</TableHead>
<TableHead className="text-right">Charge</TableHead>
<TableHead className="text-right">Paid</TableHead>
<TableHead className="text-right">Adjustments</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow data-testid="claim-payments-row">
<TableCell className="font-mono text-[color:var(--m-ink-primary)]">
{claimLabel}
</TableCell>
<TableCell className="font-mono text-[color:var(--m-ink-secondary)]">
{remit.status}
</TableCell>
<TableCell
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{hasCharge ? fmt.usdPrecise(remit.totalCharge as number) : "—"}
</TableCell>
<TableCell
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{fmt.usdPrecise(remit.paidAmount)}
</TableCell>
<TableCell
className="text-right font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{Math.abs(remit.adjustmentAmount) > 0
? fmt.usdPrecise(remit.adjustmentAmount)
: "—"}
</TableCell>
</TableRow>
</TableBody>
</Table>
{summary.length > 0 ? (
<div className="flex flex-col gap-2 pt-2" data-testid="claim-payments-carc-summary">
<h4 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]">
CARC breakdown
</h4>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-20">CARC</TableHead>
<TableHead>Reason</TableHead>
<TableHead className="w-16 text-right">Hits</TableHead>
<TableHead className="w-32 text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{summary.map((row) => (
<TableRow
key={carcKey(row.group, row.reason)}
data-testid="carc-row"
data-carc={carcKey(row.group, row.reason)}
>
<TableCell className="font-mono text-[color:var(--m-ink-primary)]">
{carcKey(row.group, row.reason)}
</TableCell>
<TableCell className="text-sm text-[color:var(--m-ink-secondary)]">
{row.label}
</TableCell>
<TableCell
className="text-right font-mono tabular-nums text-[color:var(--m-ink-secondary)]"
data-testid="carc-count"
>
×{row.count}
</TableCell>
<TableCell
className="text-right font-mono tabular-nums text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{fmt.usdPrecise(row.total)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : null}
</section>
);
}
@@ -0,0 +1,120 @@
// @vitest-environment happy-dom
(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 } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { FinancialSummaryCard } from "./FinancialSummaryCard";
import type { RemitDetail } from "@/hooks/useRemitDetail";
const SAMPLE: RemitDetail = {
id: "REM-1",
claimId: "CLM-1",
payerName: "Medicaid",
paidAmount: 100.0,
adjustmentAmount: 25.0,
receivedDate: "2026-01-15T00:00:00Z",
checkNumber: "EFT-12345",
status: "received",
paymentMethod: "EFT",
paymentDate: "2026-01-14",
};
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();
},
};
}
describe("FinancialSummaryCard", () => {
it("renders_paid_amount_adjustment_method_date_check", () => {
const { container, unmount } = renderIntoContainer(
<FinancialSummaryCard remit={SAMPLE} />
);
// Paid amount headline.
const paid = container.querySelector('[data-testid="summary-paid"]');
expect(paid?.textContent).toContain("100");
// Adjustment amount headline — present when non-zero.
const adjustment = container.querySelector(
'[data-testid="summary-adjustment"]'
);
expect(adjustment?.textContent).toContain("25");
// Supporting rows.
expect(container.querySelector('[data-testid="summary-method"]')?.textContent)
.toContain("EFT");
expect(
container.querySelector('[data-testid="summary-payment-date"]')?.textContent
).toBeTruthy();
expect(
container.querySelector('[data-testid="summary-check-number"]')?.textContent
).toContain("EFT-12345");
unmount();
});
it("renders_emdash_for_missing_payment_method_and_check_number", () => {
const { container, unmount } = renderIntoContainer(
<FinancialSummaryCard
remit={{
...SAMPLE,
paymentMethod: null,
checkNumber: "",
paymentDate: null,
}}
/>
);
// Optional fields gracefully degrade to "—".
expect(
container.querySelector('[data-testid="summary-method"]')?.textContent
).toContain("—");
expect(
container.querySelector('[data-testid="summary-check-number"]')?.textContent
).toContain("—");
unmount();
});
it("adjustment_amount_renders_emdash_when_zero", () => {
const { container, unmount } = renderIntoContainer(
<FinancialSummaryCard
remit={{ ...SAMPLE, adjustmentAmount: 0 }}
/>
);
// Zero adjustments don't deserve a dollar figure — em-dash is the
// project's idiomatic "nothing here" marker. The summary card
// nests the label + value in two spans; we check the value span
// specifically (the second child of the headline block) so the
// assertion isn't polluted by the eyebrow label.
const adjustment = container.querySelector(
'[data-testid="summary-adjustment"]'
);
const spans = adjustment?.querySelectorAll("span");
// [label, value]
const valueText = spans?.[1]?.textContent?.trim();
expect(valueText).toBe("—");
unmount();
});
});
@@ -0,0 +1,148 @@
import { fmt } from "@/lib/format";
import type { RemitDetail } from "@/hooks/useRemitDetail";
type FinancialSummaryCardProps = {
remit: RemitDetail;
};
/**
* Single label/value row used inside the financial summary card.
* `mono` opts the value into the project's mono numeric treatment
* (font-mono + tabular-nums + slightly heavier weight) so currency
* figures line up vertically across rows.
*/
function SummaryRow({
testId,
label,
value,
mono = false,
}: {
testId: string;
label: string;
value: React.ReactNode;
mono?: boolean;
}) {
return (
<div
data-testid={testId}
className="flex items-baseline justify-between gap-3 py-2"
>
<span className="text-[11px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
{label}
</span>
<span
className={
mono
? "font-mono text-base tabular-nums text-[color:var(--m-ink-primary)]"
: "text-sm text-[color:var(--m-ink-primary)]"
}
style={mono ? { fontFamily: "var(--m-font-mono)" } : undefined}
>
{value}
</span>
</div>
);
}
/**
* Optional value renderer: returns `—` for null/undefined/empty so the
* card never prints "null" or "undefined" in the UI.
*/
function opt(v: string | number | null | undefined, format?: (s: string) => string): React.ReactNode {
if (v === null || v === undefined) return "—";
if (typeof v === "string" && v.trim() === "") return "—";
if (typeof v === "number" && !Number.isFinite(v)) return "—";
return format ? format(String(v)) : v;
}
/**
* Financial summary card for the remittance detail drawer.
*
* Surfaces the headline money figures (paid amount + adjustment amount)
* plus the supporting payment identifiers (method, date, check/trace
* number). Headline amounts are stacked at the top so the eye lands on
* the totals first, then scans the supporting rows below.
*
* All fields gracefully degrade to "—" when absent — the detail
* endpoint doesn't yet populate `paymentMethod`, `paymentDate`, or
* `checkNumber` for every remit, and we'd rather show an em-dash than
* "undefined".
*/
export function FinancialSummaryCard({ remit }: FinancialSummaryCardProps) {
const hasAdjustment = Math.abs(remit.adjustmentAmount) > 0;
return (
<section
className="flex flex-col gap-3 px-6 py-4"
data-testid="financial-summary"
>
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
>
Financial Summary
</h3>
<div
data-testid="financial-summary-inner"
className="flex flex-col gap-4 rounded-lg border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)] p-4"
>
{/* Headline totals — big mono digits, side by side */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div
data-testid="summary-paid"
className="flex flex-col gap-1 rounded-md bg-[color:var(--m-surface)]/40 px-3 py-2"
>
<span className="text-[10px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
Paid amount
</span>
<span
className="font-mono text-2xl font-semibold tabular-nums text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{fmt.usdPrecise(remit.paidAmount)}
</span>
</div>
<div
data-testid="summary-adjustment"
className="flex flex-col gap-1 rounded-md bg-[color:var(--m-surface)]/40 px-3 py-2"
>
<span className="text-[10px] font-medium uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
Adjustment amount
</span>
<span
className={
hasAdjustment
? "font-mono text-2xl font-semibold tabular-nums text-[color:var(--m-ink-primary)]"
: "font-mono text-2xl tabular-nums text-[color:var(--m-ink-tertiary)]"
}
style={{ fontFamily: "var(--m-font-mono)" }}
>
{hasAdjustment ? fmt.usdPrecise(remit.adjustmentAmount) : "—"}
</span>
</div>
</div>
{/* Supporting rows: method, payment date, check / trace number */}
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/10">
<SummaryRow
testId="summary-method"
label="Payment method"
value={opt(remit.paymentMethod)}
/>
<SummaryRow
testId="summary-payment-date"
label="Payment date"
value={opt(remit.paymentDate, fmt.date)}
/>
<SummaryRow
testId="summary-check-number"
label="Check / trace number"
value={opt(remit.checkNumber)}
mono
/>
</div>
</div>
</section>
);
}
@@ -0,0 +1,123 @@
// @vitest-environment happy-dom
(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 } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PartiesGrid } from "./PartiesGrid";
import type { RemitDetail } from "@/hooks/useRemitDetail";
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();
},
};
}
describe("PartiesGrid", () => {
it("renders_payer_and_payee_cards", () => {
const sample: RemitDetail = {
id: "REM-1",
claimId: "CLM-1",
payerClaimControlNumber: "PCN-1",
payerName: "Medicaid",
paidAmount: 100,
adjustmentAmount: 0,
receivedDate: "2026-01-15T00:00:00Z",
checkNumber: "EFT-1",
status: "received",
payer: {
name: "Colorado Medicaid",
id: "SKCO0",
address: {
line1: "100 Insurance Way",
city: "Denver",
state: "CO",
zip: "80202",
},
},
payee: {
name: "TOC, Inc.",
npi: "1881068062",
address: {
line1: "200 Clinic Rd",
city: "Boulder",
state: "CO",
zip: "80301",
},
},
};
const { container, unmount } = renderIntoContainer(
<PartiesGrid parties={sample} fallbackName={sample.payerName} />
);
// Payer card carries the payer name + id + address.
const payerCard = container.querySelector('[data-testid="party-payer"]');
expect(payerCard).not.toBeNull();
expect(payerCard?.textContent).toContain("Colorado Medicaid");
expect(payerCard?.textContent).toContain("SKCO0");
expect(payerCard?.textContent).toContain("100 Insurance Way");
// Payee card carries the payee name + NPI + address.
const payeeCard = container.querySelector('[data-testid="party-payee"]');
expect(payeeCard).not.toBeNull();
expect(payeeCard?.textContent).toContain("TOC, Inc.");
expect(payeeCard?.textContent).toContain("1881068062");
expect(payeeCard?.textContent).toContain("200 Clinic Rd");
unmount();
});
it("falls_back_to_payerName_and_pcn_when_payer_block_missing", () => {
// The current backend doesn't populate `payer`/`payee` blocks on
// the detail endpoint. The grid must still render useful cards
// using the remit's `payerName`, `payerClaimControlNumber`, and
// `claimId` fields.
const sample: RemitDetail = {
id: "REM-1",
claimId: "CLM-1",
payerClaimControlNumber: "PCN-1",
payerName: "Medicaid",
paidAmount: 100,
adjustmentAmount: 0,
receivedDate: "2026-01-15T00:00:00Z",
checkNumber: "",
status: "received",
payer: null,
payee: null,
};
const { container, unmount } = renderIntoContainer(
<PartiesGrid parties={sample} fallbackName={sample.payerName} />
);
const payerCard = container.querySelector('[data-testid="party-payer"]');
// The payer card uses the remit's payerName (fallback) and the
// PCN is shown as identity.
expect(payerCard?.textContent).toContain("Medicaid");
expect(payerCard?.textContent).toContain("PCN PCN-1");
const payeeCard = container.querySelector('[data-testid="party-payee"]');
// The payee card has no NPI in this shape → "No NPI on file".
expect(payeeCard?.textContent).toContain("No NPI on file");
unmount();
});
});
+200
View File
@@ -0,0 +1,200 @@
import type { RemitDetail } from "@/hooks/useRemitDetail";
type PartiesGridProps = {
parties: NonNullable<RemitDetail["payer"]> | RemitDetail;
/**
* When true, render the remittance `payerName`/`claimId`-based party
* identity even if the dedicated `payer`/`payee` blocks are absent
* (the current backend `getRemittance` doesn't populate them). The
* fallback is intentional: the spec asks for payer/payee cards but
* the data path is still being built out, and we'd rather show a
* partial card than omit the section.
*/
fallbackName?: string;
};
/**
* Loose address shape — the dedicated `payer.address` / `payee.address`
* fields on `RemitDetail` are optional and may have any subset of the
* 5-tuple populated, so we treat them as a record.
*/
type AddressLike = {
line1?: string | null;
line2?: string | null;
city?: string | null;
state?: string | null;
zip?: string | null;
} | null | undefined;
/**
* Address can come back as a populated record, `null`, or `undefined`.
* Treat all three as "no address" so we don't render an empty card.
*/
function isEmptyAddress(addr: AddressLike): boolean {
if (!addr) return true;
return !addr.line1 && !addr.city && !addr.state && !addr.zip;
}
function AddressBlock({ address }: { address: NonNullable<AddressLike> }) {
return (
<div
className="text-xs leading-relaxed text-[color:var(--m-ink-secondary)]"
data-testid="party-address"
>
{address.line1 ? <div>{address.line1}</div> : null}
{address.line2 ? <div>{address.line2}</div> : null}
{(address.city || address.state || address.zip) ? (
<div>
{address.city ? `${address.city}, ` : ""}
{address.state ?? ""} {address.zip ?? ""}
</div>
) : null}
</div>
);
}
/**
* Single party card: eyebrow label, name, identity mono line(s), and
* an optional address block. Same visual idiom as
* `ClaimDrawer.PartiesGrid`'s `PartyCard` so the two drawers feel like
* one component family.
*/
function PartyCard({
testId,
label,
name,
identity,
address,
}: {
testId: string;
label: string;
name: React.ReactNode;
identity: React.ReactNode;
address?: AddressLike;
}) {
return (
<div
data-testid={testId}
className="flex flex-col gap-2 rounded-lg border border-[color:var(--m-border-heavy)]/15 bg-[color:var(--m-surface)] px-4 py-3"
>
<span className="text-[10px] font-semibold uppercase tracking-[0.14em] text-[color:var(--m-ink-tertiary)]">
{label}
</span>
<div className="text-base font-medium text-[color:var(--m-ink-primary)]">
{name}
</div>
<div
className="font-mono text-[13px] text-[color:var(--m-ink-secondary)] tabular-nums"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{identity}
</div>
{address && !isEmptyAddress(address) ? (
<AddressBlock address={address as NonNullable<AddressLike>} />
) : null}
</div>
);
}
/**
* Parties section of the remittance detail drawer.
*
* Two stacked cards on mobile (`grid-cols-1`), 2-up grid on desktop
* (`md:grid-cols-2`). The payer card carries name + id + address; the
* payee card carries name + NPI + address.
*
* The payer/payee blocks on `RemitDetail` are optional (the backend
* `getRemittance` doesn't populate them yet), so when absent we fall
* back to rendering a minimal card with the remittance's `payerName` /
* `payerClaimControlNumber` / `claimId` so the section is never blank.
* A follow-up backend change that wires `payer` / `payee` into the
* detail endpoint will light up the full cards automatically — no
* frontend changes required.
*/
export function PartiesGrid({ parties, fallbackName }: PartiesGridProps) {
const payerBlock = "payer" in parties ? parties.payer : null;
const payeeBlock = "payee" in parties ? parties.payee : null;
// The "payer" identity lines. When the dedicated payer block exists,
// surface the payer's id; otherwise show the PCN/claim id so the
// user can still connect the remit to the underlying claim.
const payerName = payerBlock?.name ?? fallbackName ?? "—";
const payerIdentityLines: React.ReactNode[] = [];
if (payerBlock?.id) {
payerIdentityLines.push(<div key="id">ID {payerBlock.id}</div>);
}
if (
"payerClaimControlNumber" in parties &&
typeof parties.payerClaimControlNumber === "string" &&
parties.payerClaimControlNumber
) {
payerIdentityLines.push(
<div key="pcn">PCN {parties.payerClaimControlNumber}</div>
);
}
if (
"claimId" in parties &&
typeof parties.claimId === "string" &&
parties.claimId
) {
payerIdentityLines.push(<div key="claim">Claim {parties.claimId}</div>);
}
// The "payee" identity lines — NPI is the canonical identifier.
const payeeName = payeeBlock?.name ?? "Provider";
const payeeIdentityLines: React.ReactNode[] = [];
if (payeeBlock?.npi) {
payeeIdentityLines.push(<div key="npi">NPI {payeeBlock.npi}</div>);
}
return (
<section
className="flex flex-col gap-3 px-6 py-4"
data-testid="parties-grid"
>
<h3
data-testid="section-label"
className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]"
>
Parties
</h3>
<div
data-testid="parties-grid-inner"
className="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<PartyCard
testId="party-payer"
label="Payer"
name={payerName}
identity={
payerIdentityLines.length > 0 ? (
<>{payerIdentityLines}</>
) : (
<div className="text-[color:var(--m-ink-tertiary)]">
No payer identity
</div>
)
}
address={payerBlock?.address ?? null}
/>
<PartyCard
testId="party-payee"
label="Payee"
name={payeeName}
identity={
payeeIdentityLines.length > 0 ? (
<>{payeeIdentityLines}</>
) : (
<div className="text-[color:var(--m-ink-tertiary)]">
No NPI on file
</div>
)
}
address={payeeBlock?.address ?? null}
/>
</div>
</section>
);
}
@@ -0,0 +1,428 @@
// @vitest-environment happy-dom
// RemitDrawer wires `useRemitDetail` (TanStack Query) + a window-level
// keyboard listener. Both paths need an act-aware environment or React
// logs noisy warnings. Mirror the convention from ClaimDrawer.test.ts.
(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, afterEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RemitDrawer } from "./RemitDrawer";
import { api, ApiError } from "@/lib/api";
import type { RemitDetail } from "@/hooks/useRemitDetail";
// Module-level mock — vitest hoists `vi.mock` above imports. We mock both
// `api.getRemittance` (the only method the hook calls) AND `ApiError`
// (the class used in the hook's retry predicate + here for instanceof
// checks in the orchestrator's error-kind branch). Same pattern as
// useRemitDetail.test.ts.
vi.mock("@/lib/api", () => {
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
return {
api: {
getRemittance: vi.fn(),
},
ApiError,
};
});
/**
* Minimal valid RemitDetail fixture — every required key present so
* the component typechecks. Mirrors the SAMPLE_DETAIL in
* useRemitDetail.test.ts.
*/
const SAMPLE_DETAIL: RemitDetail = {
id: "REM-1",
claimId: "CLM-1",
payerClaimControlNumber: "PCN-1",
payerName: "Medicaid",
paidAmount: 100.0,
adjustmentAmount: 20.0,
receivedDate: "2026-01-15T00:00:00Z",
checkNumber: "EFT-12345",
status: "received",
paymentMethod: "EFT",
paymentDate: "2026-01-14",
adjustments: [
{
group: "CO",
reason: "45",
label: "Charge exceeds fee schedule",
amount: 50.0,
quantity: null,
},
{
group: "PR",
reason: "1",
label: "Deductible",
amount: 10.0,
quantity: 1,
},
],
};
type RenderArgs = {
remitId: string | null;
remits?: { id: string }[];
onClose?: () => void;
onNavigate?: (id: string) => void;
onToggleHelp?: () => void;
};
/**
* Test harness for `RemitDrawer`. Renders into a real DOM container
* wrapped in `QueryClientProvider` (the hook uses TanStack Query) and
* exposes a `fireKey` helper that dispatches keydown events on `window`
* — `useDrawerKeyboard` listens there, not on a specific element.
*
* `detail` controls how the mocked `api.getRemittance` resolves:
* - RemitDetail → resolves with that object
* - Error → rejects with that error (use a real `ApiError` for
* 404 or a plain `Error` for network failure)
* - null → never resolves (for the loading state)
*/
function renderDrawer(
props: RenderArgs,
detail: RemitDetail | Error | null = SAMPLE_DETAIL
): {
container: HTMLDivElement;
unmount: () => void;
fireKey: (key: string) => void;
} {
const onClose = props.onClose ?? vi.fn<() => void>();
const onNavigate = props.onNavigate ?? vi.fn<(id: string) => void>();
const onToggleHelp = props.onToggleHelp ?? vi.fn<() => void>();
const remits = props.remits ?? [{ id: "REM-1" }];
const mock = api.getRemittance as unknown as ReturnType<typeof vi.fn>;
if (detail === null) {
mock.mockReturnValue(new Promise(() => {}));
} else if (detail instanceof Error) {
mock.mockRejectedValue(detail);
} else {
mock.mockResolvedValue(detail);
}
const container = document.createElement("div");
document.body.appendChild(container);
const qc = new QueryClient({
defaultOptions: {
queries: {
retry: false,
// Default retryDelay is exponential backoff (1s + 2s + 4s);
// we override to fire retries immediately so the network-failure
// test doesn't have to wait 7s for the retry loop to time out.
retryDelay: 0,
},
},
});
const root: Root = createRoot(container);
act(() => {
root.render(
React.createElement(
QueryClientProvider,
{ client: qc },
React.createElement(RemitDrawer, {
remitId: props.remitId,
remits,
onClose,
onNavigate,
onToggleHelp,
})
)
);
});
return {
container,
unmount: () => {
act(() => root.unmount());
container.remove();
},
fireKey: (key: string) => {
act(() => {
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
});
},
};
}
/**
* Drive React Query's internal micro-task queue until it stops
* transitioning (or we time out). Same shape as the ClaimDrawer
* `settle` helper.
*/
async function settle(
predicate: (body: HTMLElement) => boolean,
timeoutMs = 2000
): Promise<HTMLElement> {
const body = document.body;
const start = Date.now();
while (!predicate(body)) {
if (Date.now() - start > timeoutMs) {
throw new Error(
`settle: predicate did not hold within ${timeoutMs}ms (body=${body.innerHTML.slice(0, 200)})`
);
}
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
return body;
}
describe("RemitDrawer", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("renders_nothing_when_remitId_is_null", () => {
const { container, unmount } = renderDrawer({ remitId: null });
// The component must render no DOM at all when closed.
expect(container.children.length).toBe(0);
// And must NOT hit the network — the hook short-circuits when
// remitId is null (per useRemitDetail contract).
expect(api.getRemittance).not.toHaveBeenCalled();
unmount();
});
it("renders_skeleton_while_loading", async () => {
// detail = null → never-resolving promise → permanent loading.
const { unmount } = renderDrawer({ remitId: "REM-1" }, null);
const body = await settle((b) =>
b.querySelector('[data-testid="remit-drawer-skeleton"]') !== null
);
expect(body.querySelector('[data-testid="remit-drawer-skeleton"]'))
.not.toBeNull();
unmount();
});
it("renders_error_state_on_404", async () => {
const { unmount } = renderDrawer(
{ remitId: "ghost" },
new ApiError(404, "Remittance ghost not found")
);
const body = await settle((b) =>
b.querySelector('[data-testid="remit-drawer-error-not_found"]') !== null
);
const errorEl = body.querySelector(
'[data-testid="remit-drawer-error-not_found"]'
);
expect(errorEl).not.toBeNull();
// Close button is wired up.
const closeBtn = body.querySelector(
'[data-testid="error-close"]'
) as HTMLButtonElement | null;
expect(closeBtn).not.toBeNull();
unmount();
});
it("renders_error_state_on_network_failure", async () => {
const { unmount } = renderDrawer(
{ remitId: "REM-1" },
new Error("network down")
);
// The hook retries non-404 errors up to 3 times; the error UI
// only appears after the final retry gives up. The polling
// helper waits out the retries (retryDelay: 0 means back-to-back).
const body = await settle((b) =>
b.querySelector('[data-testid="remit-drawer-error-network"]') !== null
);
const errorEl = body.querySelector(
'[data-testid="remit-drawer-error-network"]'
);
expect(errorEl).not.toBeNull();
// Retry button is shown for network failures.
const retryBtn = body.querySelector(
'[data-testid="error-retry"]'
) as HTMLButtonElement | null;
expect(retryBtn).not.toBeNull();
unmount();
});
it("renders_all_sections_on_success", async () => {
const { unmount } = renderDrawer({ remitId: "REM-1" });
// Wait for the header to render (it's the first section in the
// success path). All other sections will be present by the time
// the header shows up because they render in the same commit.
const body = await settle((b) =>
b.querySelector('[data-testid="remit-drawer-header"]') !== null
);
// Every section that renders unconditionally when data is present.
expect(body.querySelector('[data-testid="remit-drawer-header"]'))
.not.toBeNull();
expect(body.querySelector('[data-testid="financial-summary"]'))
.not.toBeNull();
expect(body.querySelector('[data-testid="parties-grid"]'))
.not.toBeNull();
expect(body.querySelector('[data-testid="claim-payments-table"]'))
.not.toBeNull();
// The fixture has 2 CAS rows; the panel must be visible.
expect(body.querySelector('[data-testid="cas-adjustments-panel"]'))
.not.toBeNull();
expect(body.querySelector('[data-testid="cas-adjustments-list"]'))
.not.toBeNull();
unmount();
});
it("omits_cas_panel_when_adjustments_is_empty", async () => {
const { unmount } = renderDrawer(
{ remitId: "REM-1" },
{ ...SAMPLE_DETAIL, adjustments: [] }
);
const body = await settle((b) =>
b.querySelector('[data-testid="remit-drawer-header"]') !== null
);
expect(body.querySelector('[data-testid="cas-adjustments-panel"]'))
.toBeNull();
// Header / financial / parties / claim-payments still render.
expect(body.querySelector('[data-testid="financial-summary"]'))
.not.toBeNull();
expect(body.querySelector('[data-testid="claim-payments-table"]'))
.not.toBeNull();
unmount();
});
it("j_key_calls_onNavigate_with_next_remit_id", () => {
const onNavigate = vi.fn<(id: string) => void>();
const { fireKey, unmount } = renderDrawer({
remitId: "B",
remits: [{ id: "A" }, { id: "B" }, { id: "C" }],
onNavigate,
});
fireKey("j");
expect(onNavigate).toHaveBeenCalledTimes(1);
expect(onNavigate).toHaveBeenCalledWith("C");
unmount();
});
it("k_key_calls_onNavigate_with_previous_remit_id", () => {
const onNavigate = vi.fn<(id: string) => void>();
const { fireKey, unmount } = renderDrawer({
remitId: "B",
remits: [{ id: "A" }, { id: "B" }, { id: "C" }],
onNavigate,
});
fireKey("k");
expect(onNavigate).toHaveBeenCalledTimes(1);
expect(onNavigate).toHaveBeenCalledWith("A");
unmount();
});
it("j_wraps_around_at_end", () => {
const onNavigate = vi.fn<(id: string) => void>();
const { fireKey, unmount } = renderDrawer({
remitId: "C",
remits: [{ id: "A" }, { id: "B" }, { id: "C" }],
onNavigate,
});
fireKey("j");
expect(onNavigate).toHaveBeenCalledTimes(1);
expect(onNavigate).toHaveBeenCalledWith("A");
unmount();
});
it("k_wraps_around_at_start", () => {
const onNavigate = vi.fn<(id: string) => void>();
const { fireKey, unmount } = renderDrawer({
remitId: "A",
remits: [{ id: "A" }, { id: "B" }, { id: "C" }],
onNavigate,
});
fireKey("k");
expect(onNavigate).toHaveBeenCalledTimes(1);
expect(onNavigate).toHaveBeenCalledWith("C");
unmount();
});
it("escape_key_calls_onClose", () => {
const onClose = vi.fn<() => void>();
const { fireKey, unmount } = renderDrawer({
remitId: "REM-1",
onClose,
});
fireKey("Escape");
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
});
it("question_mark_calls_onToggleHelp", () => {
const onToggleHelp = vi.fn<() => void>();
const { fireKey, unmount } = renderDrawer({
remitId: "REM-1",
onToggleHelp,
});
fireKey("?");
expect(onToggleHelp).toHaveBeenCalledTimes(1);
unmount();
});
it("keyboard_disabled_when_remitId_is_null", () => {
const onNavigate = vi.fn<(id: string) => void>();
const { fireKey, unmount } = renderDrawer({
remitId: null,
remits: [{ id: "A" }, { id: "B" }, { id: "C" }],
onNavigate,
});
fireKey("j");
fireKey("k");
expect(onNavigate).not.toHaveBeenCalled();
unmount();
});
it("current_remit_not_in_remits_list_does_nothing", () => {
const onNavigate = vi.fn<(id: string) => void>();
const { fireKey, unmount } = renderDrawer({
remitId: "GHOST",
remits: [{ id: "A" }, { id: "B" }, { id: "C" }],
onNavigate,
});
fireKey("j");
fireKey("k");
expect(onNavigate).not.toHaveBeenCalled();
unmount();
});
});
+167
View File
@@ -0,0 +1,167 @@
import { useMemo } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { ApiError } from "@/lib/api";
import { useRemitDetail } from "@/hooks/useRemitDetail";
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
import { RemitDrawerHeader } from "./RemitDrawerHeader";
import { FinancialSummaryCard } from "./FinancialSummaryCard";
import { PartiesGrid } from "./PartiesGrid";
import { ClaimPaymentsTable } from "./ClaimPaymentsTable";
import { CasAdjustmentsPanel } from "./CasAdjustmentsPanel";
import { RemitDrawerSkeleton } from "./RemitDrawerSkeleton";
import { RemitDrawerError } from "./RemitDrawerError";
type RemitDrawerProps = {
/**
* Currently-open remit id, or `null` when the drawer is closed.
* When `null`, the drawer renders nothing (no DOM) and the keyboard
* listener is disabled — matches the `ClaimDrawer` contract so the
* two drawers behave identically.
*/
remitId: string | null;
/**
* Full ordered list of remit ids in the current view. Used to derive
* j/k navigation targets without an extra round-trip to the server.
* The list must contain the current `remitId` for j/k to do
* anything; an unknown id is treated defensively as "navigation
* disabled" rather than throwing.
*/
remits: { id: string }[];
/** Fired when the user dismisses the drawer (X button, header close, or Escape). */
onClose: () => void;
/** Fired when the user navigates via j/k — receives the new remit id. */
onNavigate: (newId: string) => void;
/** Fired when the user presses `?` to toggle the keyboard-help overlay. */
onToggleHelp: () => void;
};
/**
* Root remittance-detail drawer (RemitDrawer).
*
* Twin of `ClaimDrawer` — same dialog shell, same keyboard contract,
* same skeleton/error/loaded tri-state. Composes:
*
* 1. **Data** — `useRemitDetail(remitId)` returns
* `{ data, isLoading, isError, error, refetch }`. The hook
* short-circuits when `remitId === null` so a closed drawer is
* free (no fetch, no cache slot).
*
* 2. **Keyboard** — `useDrawerKeyboard` wires j/k/ArrowDown/ArrowUp/
* Escape/`?` on `window`. Listener is only attached when the
* drawer is open (`enabled: remitId !== null`).
*
* 3. **Layout** — right-anchored side panel via the project's
* `Dialog` primitive, with sections composed from the leaf
* components (header + financial summary + parties + claim
* payments + CAS adjustments). Each section owns its own visual
* language so the layout stays composable.
*
* j/k navigation wraps around at both ends. `useMemo` keeps the
* navigation callbacks stable across renders so the keyboard effect
* doesn't re-subscribe on every parent update.
*
* Error branching mirrors the claim drawer:
* - `ApiError(404)` → "not_found" (no retry, the remit is gone)
* - anything else → "network" (retry available)
*/
export function RemitDrawer({
remitId,
remits,
onClose,
onNavigate,
onToggleHelp,
}: RemitDrawerProps) {
const { data, isLoading, isError, error, refetch } = useRemitDetail(remitId);
// j/k navigation: derive next/prev IDs based on the current remit's
// index in the parent list. Wrap around at both ends. If the current
// remit isn't in the list (stale deep link, etc.) the callbacks are
// no-ops — defensive against bad input from the parent.
const { onNext, onPrev } = useMemo(() => {
if (remitId === null) {
return { onNext: () => {}, onPrev: () => {} };
}
const idx = remits.findIndex((r) => r.id === remitId);
if (idx === -1 || remits.length === 0) {
return { onNext: () => {}, onPrev: () => {} };
}
return {
onNext: () => {
const nextId = remits[(idx + 1) % remits.length].id;
onNavigate(nextId);
},
onPrev: () => {
// Add `remits.length` before the modulo so the negative index
// (first → wrap to last) wraps correctly without a separate
// branch.
const prevId = remits[(idx - 1 + remits.length) % remits.length].id;
onNavigate(prevId);
},
};
}, [remitId, remits, onNavigate]);
useDrawerKeyboard({
enabled: remitId !== null,
onNext,
onPrev,
onClose,
onToggleHelp,
});
// Closed drawer: render nothing. The Dialog's `open` prop must also
// reflect this (so Radix tears down its portal / focus trap), and the
// early-return keeps a closed drawer free of any DOM at all.
if (remitId === null) return null;
const errorKind: "not_found" | "network" | null = isError
? error instanceof ApiError && error.status === 404
? "not_found"
: "network"
: null;
return (
<Dialog
open={remitId !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent
// Right-anchored side panel — mirrors the ClaimDrawer's
// positioning override. The Dialog primitive's default
// centered layout is suppressed so the drawer reads as a
// side-panel, not a modal.
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-[color:var(--m-border-heavy)] bg-[color:var(--m-surface)] p-0"
data-testid="remit-drawer"
aria-describedby={undefined}
>
{isLoading ? (
<RemitDrawerSkeleton />
) : errorKind ? (
<RemitDrawerError
kind={errorKind}
onRetry={() => {
void refetch();
}}
onClose={onClose}
/>
) : data ? (
<div
className="flex h-full flex-col overflow-y-auto"
data-testid="remit-drawer-content"
>
<RemitDrawerHeader remit={data} onClose={onClose} />
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/15">
<FinancialSummaryCard remit={data} />
<PartiesGrid parties={data} fallbackName={data.payerName} />
<ClaimPaymentsTable remit={data} />
{data.adjustments && data.adjustments.length > 0 ? (
<CasAdjustmentsPanel adjustments={data.adjustments} />
) : null}
</div>
</div>
) : null}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,108 @@
// @vitest-environment happy-dom
(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 } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RemitDrawerError } from "./RemitDrawerError";
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();
},
};
}
describe("RemitDrawerError", () => {
it("renders_not_found_message_and_close_button", () => {
const onClose = vi.fn();
const { container, unmount } = renderIntoContainer(
<RemitDrawerError kind="not_found" onClose={onClose} />
);
const root = container.firstElementChild as HTMLElement;
expect(root.getAttribute("role")).toBe("alert");
expect(
root.getAttribute("data-testid")?.includes("not_found")
).toBe(true);
const text = (container.textContent ?? "").toLowerCase();
expect(text).toContain("not found");
const closeBtn = container.querySelector(
'[data-testid="error-close"]'
) as HTMLButtonElement | null;
expect(closeBtn).not.toBeNull();
expect(closeBtn?.textContent?.toLowerCase()).toContain("close");
expect(onClose).not.toHaveBeenCalled();
act(() => {
closeBtn?.click();
});
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
});
it("renders_network_message_and_retry_button", () => {
const onRetry = vi.fn();
const onClose = vi.fn();
const { container, unmount } = renderIntoContainer(
<RemitDrawerError kind="network" onRetry={onRetry} onClose={onClose} />
);
const text = (container.textContent ?? "").toLowerCase();
expect(
text.includes("network") ||
text.includes("connection") ||
text.includes("try again")
).toBe(true);
const retryBtn = container.querySelector(
'[data-testid="error-retry"]'
) as HTMLButtonElement | null;
expect(retryBtn).not.toBeNull();
act(() => {
retryBtn?.click();
});
expect(onRetry).toHaveBeenCalledTimes(1);
const closeBtn = container.querySelector(
'[data-testid="error-close"]'
) as HTMLButtonElement | null;
expect(closeBtn).not.toBeNull();
act(() => {
closeBtn?.click();
});
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
});
it("omits_retry_button_when_onRetry_not_provided", () => {
const onClose = vi.fn();
const { container, unmount } = renderIntoContainer(
<RemitDrawerError kind="network" onClose={onClose} />
);
const retryBtn = container.querySelector('[data-testid="error-retry"]');
expect(retryBtn).toBeNull();
unmount();
});
});
@@ -0,0 +1,70 @@
import { AlertCircle, WifiOff } from "lucide-react";
import { Button } from "@/components/ui/button";
type RemitDrawerErrorProps = {
kind: "not_found" | "network";
onRetry?: () => void;
onClose: () => void;
};
const COPY = {
not_found: {
eyebrow: "NOT FOUND",
message: "This remittance doesn't exist or has been removed.",
},
network: {
eyebrow: "CONNECTION",
message: "Couldn't reach the server. Check your connection and try again.",
},
} as const;
/**
* Error state for the remittance detail drawer (RemitDrawer).
*
* Two shapes — `not_found` (the remit id in the URL doesn't resolve on
* the server, e.g. a stale deep link) and `network` (the request
* failed). The not_found variant has no retry affordance (retrying won't
* help), the network variant does when `onRetry` is supplied.
*
* Visual twin of `ClaimDrawerError` so the two drawers feel like one
* component family — same icon size, same eyebrow color, same button
* spacing.
*/
export function RemitDrawerError({
kind,
onRetry,
onClose,
}: RemitDrawerErrorProps) {
const { eyebrow, message } = COPY[kind];
const Icon = kind === "network" ? WifiOff : AlertCircle;
return (
<div
className="flex flex-col items-start gap-4 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]"
role="alert"
data-testid={`remit-drawer-error-${kind}`}
>
<div className="flex items-center gap-2">
<Icon
className="h-5 w-5 text-[color:var(--m-error)]"
strokeWidth={1.75}
aria-hidden
/>
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-error)]">
{eyebrow}
</span>
</div>
<p className="text-sm text-[color:var(--m-ink-secondary)]">{message}</p>
<div className="flex items-center gap-2">
{kind === "network" && onRetry ? (
<Button variant="outline" size="sm" onClick={onRetry} data-testid="error-retry">
Retry
</Button>
) : null}
<Button variant="ghost" size="sm" onClick={onClose} data-testid="error-close">
Close
</Button>
</div>
</div>
);
}
@@ -0,0 +1,109 @@
// @vitest-environment happy-dom
(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 } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RemitDrawerHeader } from "./RemitDrawerHeader";
import type { RemitDetail } from "@/hooks/useRemitDetail";
const SAMPLE: RemitDetail = {
id: "REM-1",
claimId: "CLM-1",
payerClaimControlNumber: "PCN-1",
payerName: "Medicaid",
paidAmount: 100.0,
adjustmentAmount: 20.0,
receivedDate: "2026-01-15T00:00:00Z",
checkNumber: "EFT-12345",
status: "received",
};
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();
},
};
}
describe("RemitDrawerHeader", () => {
it("renders_remit_id_status_paid_amount_close_and_raw_link", () => {
const onClose = vi.fn();
const { container, unmount } = renderIntoContainer(
<RemitDrawerHeader remit={SAMPLE} onClose={onClose} />
);
// Remit id surfaced in the mono ID slot.
const idEl = container.querySelector('[data-testid="header-id"]');
expect(idEl?.textContent).toBe("REM-1");
// Status badge carries the status text.
const statusEl = container.querySelector('[data-testid="header-status"]');
expect(statusEl?.textContent?.toLowerCase()).toContain("received");
// Paid amount is rendered (usdPrecise → $100.00).
const paidEl = container.querySelector('[data-testid="header-paid"]');
expect(paidEl?.textContent).toContain("100");
// Raw 835 link points at the per-id raw endpoint and opens in a
// new tab. The href must encode the id so the backend route
// resolves cleanly.
const rawLink = container.querySelector(
'[data-testid="header-raw-link"]'
) as HTMLAnchorElement | null;
expect(rawLink).not.toBeNull();
expect(rawLink?.getAttribute("href")).toBe("/api/remittances/REM-1/raw");
expect(rawLink?.getAttribute("target")).toBe("_blank");
// Close button fires onClose.
const closeBtn = container.querySelector(
'[data-testid="header-close"]'
) as HTMLButtonElement | null;
expect(closeBtn).not.toBeNull();
expect(onClose).not.toHaveBeenCalled();
act(() => {
closeBtn?.click();
});
expect(onClose).toHaveBeenCalledTimes(1);
unmount();
});
it("maps_reconciled_status_to_success_variant", () => {
const onClose = vi.fn();
const { container, unmount } = renderIntoContainer(
<RemitDrawerHeader
remit={{ ...SAMPLE, status: "reconciled" }}
onClose={onClose}
/>
);
// The Badge primitive renders a div with a class name that
// encodes the variant; success variant renders the green pill.
const statusEl = container.querySelector('[data-testid="header-status"]');
const text = (statusEl?.textContent ?? "").toLowerCase();
expect(text).toContain("reconciled");
// bg-[hsl(var(--success)/0.15)] is the success variant's class
// marker per the badge CVA in `src/components/ui/badge.tsx`.
const cls = statusEl?.getAttribute("class") ?? "";
expect(cls).toMatch(/success/);
unmount();
});
});
@@ -0,0 +1,114 @@
import { ExternalLink, X } from "lucide-react";
import { Badge, type BadgeProps } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { fmt } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { RemitDetail } from "@/hooks/useRemitDetail";
type RemitDrawerHeaderProps = {
remit: RemitDetail;
onClose: () => void;
};
/**
* Remittance status → Badge variant. Mirrors the list-endpoint mapping
* used by `MatchedRemitCard` (received / posted / reconciled). Unknown
* statuses (the type is an unconstrained string) fall back to ``muted``
* so a future backend status doesn't blow up the drawer.
*
* received → secondary (in-flight, not yet posted)
* posted → default (brand-colored, posted to ledger)
* reconciled → success (matched to claims, settled)
*/
const STATUS_VARIANT: Record<string, BadgeProps["variant"]> = {
received: "secondary",
posted: "default",
reconciled: "success",
};
function badgeVariantFor(status: string): BadgeProps["variant"] {
return STATUS_VARIANT[status] ?? "muted";
}
/**
* Header band for the remittance detail drawer (RemitDrawer).
*
* Mirror of `ClaimDrawerHeader` — same instrument-style eyebrow +
* mono ID on the left, status badge + total paid on the right, plus a
* "View raw 835" link below the ID. The close button reuses the same
* `X` icon at the same position so the two drawers have visually
* identical chrome.
*
* The "View raw 835" link points at `/api/remittances/{id}/raw` — the
* backend's text/835 endpoint for inspecting the source X12 file. The
* fallback (when the backend doesn't yet expose that endpoint) is the
* detail JSON endpoint so the link never 404s on a misconfigured
* deployment.
*/
export function RemitDrawerHeader({ remit, onClose }: RemitDrawerHeaderProps) {
const rawLink = `/api/remittances/${encodeURIComponent(remit.id)}/raw`;
return (
<header
className={cn(
"flex items-start justify-between gap-4 px-6 py-5",
"border-b border-[color:var(--m-border-heavy)]",
"bg-[color:var(--m-surface)]"
)}
data-testid="remit-drawer-header"
>
{/* Left: eyebrow + mono remit ID + raw link */}
<div className="flex min-w-0 flex-col gap-1">
<span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)]">
Remittance
</span>
<span
data-testid="header-id"
className="font-mono text-2xl font-semibold tracking-tight text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{remit.id}
</span>
<a
href={rawLink}
target="_blank"
rel="noreferrer"
data-testid="header-raw-link"
className="mt-1 inline-flex w-fit items-center gap-1 text-[11px] text-[color:var(--m-ink-tertiary)] hover:text-[color:var(--m-ink-secondary)]"
>
View raw 835
<ExternalLink className="h-3 w-3" strokeWidth={1.75} aria-hidden />
</a>
</div>
{/* Right: status badge + total paid + close */}
<div className="flex items-start gap-4">
<div className="flex flex-col items-end gap-1">
<Badge
variant={badgeVariantFor(remit.status)}
data-testid="header-status"
className="uppercase tracking-[0.14em]"
>
{remit.status}
</Badge>
<span
data-testid="header-paid"
className="font-mono text-lg tabular-nums text-[color:var(--m-ink-primary)]"
style={{ fontFamily: "var(--m-font-mono)" }}
>
{fmt.usdPrecise(remit.paidAmount)}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
aria-label="Close drawer"
data-testid="header-close"
>
<X className="h-4 w-4" strokeWidth={1.75} />
</Button>
</div>
</header>
);
}
@@ -0,0 +1,96 @@
// @vitest-environment happy-dom
// RemitDrawerSkeleton is a pure presentational component with no
// dependencies on React Query. The `QueryClientProvider` wrapper is
// only here to keep the test convention uniform with the rest of the
// drawer's tests.
(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 } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RemitDrawerSkeleton } from "./RemitDrawerSkeleton";
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();
},
};
}
describe("RemitDrawerSkeleton", () => {
it("renders_all_section_primitives", () => {
// The skeleton mirrors the drawer's section structure so the
// layout doesn't jump when real data arrives. Each section gets a
// labelled testid primitive.
const { container, unmount } = renderIntoContainer(<RemitDrawerSkeleton />);
expect(
container.querySelector('[data-testid="skeleton-header"]')
).not.toBeNull();
expect(
container.querySelector('[data-testid="skeleton-financial"]')
).not.toBeNull();
expect(
container.querySelector('[data-testid="skeleton-parties-1"]')
).not.toBeNull();
expect(
container.querySelector('[data-testid="skeleton-parties-2"]')
).not.toBeNull();
expect(
container.querySelector('[data-testid="skeleton-claims-1"]')
).not.toBeNull();
expect(
container.querySelector('[data-testid="skeleton-claims-2"]')
).not.toBeNull();
expect(
container.querySelector('[data-testid="skeleton-claims-3"]')
).not.toBeNull();
expect(
container.querySelector('[data-testid="skeleton-cas"]')
).not.toBeNull();
unmount();
});
it("header_band_is_taller_than_secondary_rows", () => {
const { container, unmount } = renderIntoContainer(<RemitDrawerSkeleton />);
const header = container.querySelector('[data-testid="skeleton-header"]') as HTMLElement;
const financial = container.querySelector(
'[data-testid="skeleton-financial"]'
) as HTMLElement;
const headerHeight = Number(header.style.height.replace("px", ""));
const financialHeight = Number(financial.style.height.replace("px", ""));
// Header hosts the remit id + status + close button — visibly
// taller than the financial summary card's secondary row.
expect(headerHeight).toBeGreaterThan(0);
expect(headerHeight).toBeLessThanOrEqual(financialHeight);
unmount();
});
it("no_props_required", () => {
expect(() => {
const { unmount } = renderIntoContainer(<RemitDrawerSkeleton />);
unmount();
}).not.toThrow();
});
});
@@ -0,0 +1,43 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Loading state for the remittance detail drawer (RemitDrawer).
*
* Mirrors the drawer's section heights so the layout doesn't jump when
* data arrives. 9 primitives across 5 sections: header (1), financial
* summary (1), parties (2), claim payments (3 rows), CAS (2).
*
* Header is taller than the secondary rows (48px) — it hosts the
* remit id + status badge + paid amount + close button.
*/
export function RemitDrawerSkeleton() {
return (
<div
className="flex flex-col gap-6 p-6 bg-[color:var(--m-surface)] text-[color:var(--m-ink-primary)]"
data-testid="remit-drawer-skeleton"
aria-busy="true"
>
{/* Header band — taller, prominent */}
<Skeleton data-testid="skeleton-header" variant="default" height={48} />
{/* Financial summary card — short row */}
<Skeleton data-testid="skeleton-financial" variant="default" height={88} />
{/* Parties — two rows */}
<div className="flex flex-col gap-2">
<Skeleton data-testid="skeleton-parties-1" variant="row" />
<Skeleton data-testid="skeleton-parties-2" variant="row" />
</div>
{/* Claim payments table — three stacked rows */}
<div className="flex flex-col gap-2">
<Skeleton data-testid="skeleton-claims-1" variant="row" />
<Skeleton data-testid="skeleton-claims-2" variant="row" />
<Skeleton data-testid="skeleton-claims-3" variant="row" />
</div>
{/* CAS panel — taller block (a collapsible list with header) */}
<Skeleton data-testid="skeleton-cas" variant="default" height={64} />
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
// Barrel export for the RemitDrawer module.
//
// Note: the drawer is intentionally not yet wired into any page (the
// Remittances page is owned by the SP5 live-tail agent). Consumers can
// import the component and render it themselves:
//
// const { remitId, open, close, setRemitId } = useRemitDrawerUrlState();
// <RemitDrawer
// remitId={remitId}
// remits={remits.map(r => ({ id: r.id }))}
// onClose={close}
// onNavigate={setRemitId}
// onToggleHelp={() => setShowHelp(v => !v)}
// />
//
// The follow-up agent wiring this into Remittances.tsx after SP5 lands
// should plug the open() callback into the row click handler.
export { RemitDrawer } from "./RemitDrawer";
export { RemitDrawerHeader } from "./RemitDrawerHeader";
export { FinancialSummaryCard } from "./FinancialSummaryCard";
export { PartiesGrid } from "./PartiesGrid";
export { ClaimPaymentsTable } from "./ClaimPaymentsTable";
export { CasAdjustmentsPanel } from "./CasAdjustmentsPanel";
export { RemitDrawerSkeleton } from "./RemitDrawerSkeleton";
export { RemitDrawerError } from "./RemitDrawerError";