feat(frontend): RemitDrawer component with CAS adjustments + parties + claim payments
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user