diff --git a/src/components/ClaimDrawer/MatchedRemitCard.test.tsx b/src/components/ClaimDrawer/MatchedRemitCard.test.tsx new file mode 100644 index 0000000..bf72a1d --- /dev/null +++ b/src/components/ClaimDrawer/MatchedRemitCard.test.tsx @@ -0,0 +1,179 @@ +// @vitest-environment happy-dom +// Pure prop-driven component; match the act() convention used by the +// other ClaimDrawer test files (ClaimDrawerHeader / ClaimDrawerSkeleton / +// ClaimDrawerError / ValidationPanel / ServiceLinesTable / DiagnosesList +// / PartiesGrid / RawSegmentsPanel). +(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 { MatchedRemitCard } from "./MatchedRemitCard"; +import type { ClaimDetailMatchedRemittance } from "@/types"; + +function renderIntoContainer(element: React.ReactElement): { + container: HTMLDivElement; + unmount: () => void; +} { + const container = document.createElement("div"); + document.body.appendChild(container); + const root: Root = createRoot(container); + act(() => { + root.render(element); + }); + return { + container, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +function makeMatchedRemittance( + overrides: Partial = {} +): ClaimDetailMatchedRemittance { + return { + id: "REM-2026-0001", + totalPaid: 142.5, + status: "received", + receivedAt: "2026-06-12T10:00:00Z", + ...overrides, + }; +} + +describe("MatchedRemitCard", () => { + it("test_null_prop_returns_no_dom", () => { + const { container, unmount } = renderIntoContainer( + + ); + + // No DOM at all — the component returns null before rendering. + expect(container.innerHTML).toBe(""); + + unmount(); + }); + + it("test_populated_prop_renders_section_label", () => { + const { container, unmount } = renderIntoContainer( + + ); + + const label = container.querySelector('[data-testid="section-label"]'); + expect(label).not.toBeNull(); + + // `uppercase` is applied via Tailwind CSS, so textContent gives the + // source-case string "Matched Remittance"; assert case-insensitively + // and confirm the visual transform via the className sniff below. + const text = (label?.textContent ?? "").toLowerCase(); + expect(text).toContain("matched remittance"); + + // Eyebrow style (uppercase + tracking) — sniff the className. + const cls = label?.className ?? ""; + expect(cls).toContain("uppercase"); + expect(cls).toContain("tracking-[0.18em]"); + + unmount(); + }); + + it("test_renders_id_totalPaid_status_and_receivedAt", () => { + const { container, unmount } = renderIntoContainer( + + ); + + // Remit ID rendered in mono. + const idEl = container.querySelector('[data-testid="matched-remit-id"]'); + expect(idEl).not.toBeNull(); + expect(idEl?.textContent ?? "").toContain("REM-2026-9999"); + + // totalPaid formatted via fmt.usdPrecise → "$250.00". + const totalPaidEl = container.querySelector( + '[data-testid="matched-remit-total-paid"]' + ); + expect(totalPaidEl).not.toBeNull(); + expect(totalPaidEl?.textContent ?? "").toContain("$250.00"); + + // Status badge. + const statusEl = container.querySelector( + '[data-testid="matched-remit-status"]' + ); + expect(statusEl).not.toBeNull(); + expect(statusEl?.textContent ?? "").toContain("reconciled"); + + // receivedAt formatted via fmt.date — the date contains the year at + // minimum, and `new Date("ISO Z")` is UTC-stable. + const receivedEl = container.querySelector( + '[data-testid="matched-remit-received"]' + ); + expect(receivedEl).not.toBeNull(); + expect(receivedEl?.textContent ?? "").toContain("2026"); + + unmount(); + }); + + it("test_status_badge_uses_danger_variant_for_rejected_status", () => { + // The remittance status field is an unconstrained string (per + // `ClaimDetailMatchedRemittance`), so unknown statuses should fall + // back to the muted variant rather than throwing. + const { container, unmount } = renderIntoContainer( + + ); + + const statusEl = container.querySelector( + '[data-testid="matched-remit-status"]' + ); + expect(statusEl).not.toBeNull(); + // Muted variant (no color) — the badge still renders without error. + expect(statusEl?.textContent ?? "").toContain("weird"); + + unmount(); + }); + + it("test_view_remittance_link_sets_window_location_href", () => { + // Spy on the `href` setter so the click doesn't actually navigate + // away in the test runner, and so we can assert what was assigned. + const setHrefSpy = vi.spyOn(window.location, "href", "set"); + setHrefSpy.mockImplementation(() => { + // No-op — happy-dom would otherwise try to update the URL. + }); + + try { + const { container, unmount } = renderIntoContainer( + + ); + + const link = container.querySelector( + '[data-testid="view-remittance-link"]' + ); + expect(link).not.toBeNull(); + // The button should advertise the destination via the visible label. + expect(link?.textContent ?? "").toContain("View remittance"); + + act(() => { + link?.dispatchEvent( + new MouseEvent("click", { bubbles: true, cancelable: true }) + ); + }); + + // The click handler should have assigned to window.location.href + // exactly once, with the remittance-detail deep link. + expect(setHrefSpy).toHaveBeenCalledTimes(1); + expect(setHrefSpy).toHaveBeenCalledWith("/remittances?id=REM-XYZ"); + + unmount(); + } finally { + setHrefSpy.mockRestore(); + } + }); +}); diff --git a/src/components/ClaimDrawer/MatchedRemitCard.tsx b/src/components/ClaimDrawer/MatchedRemitCard.tsx new file mode 100644 index 0000000..f725393 --- /dev/null +++ b/src/components/ClaimDrawer/MatchedRemitCard.tsx @@ -0,0 +1,114 @@ +import { ArrowRight } from "lucide-react"; +import { Badge, type BadgeProps } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { fmt } from "@/lib/format"; +import type { ClaimDetail } from "@/types"; + +type MatchedRemitCardProps = { + matchedRemittance: ClaimDetail["matchedRemittance"]; +}; + +/** + * Remittance status → Badge variant. Mirrors the list-endpoint mapping + * in ``RemittanceStatus`` (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. + */ +const STATUS_VARIANT: Record = { + received: "secondary", + posted: "default", + reconciled: "success", +}; + +function statusVariantFor(status: string): BadgeProps["variant"] { + return STATUS_VARIANT[status] ?? "muted"; +} + +/** + * Matched-remittance card for the claim detail drawer (SP4). + * + * Renders only when the claim has been paired with an ERA (the + * ``matchedRemittance`` field is non-null). Layout: a single card with + * remit id + status badge + received date on the left, big mono + * ``totalPaid`` on the right, plus a "View remittance →" link that + * deep-links to ``/remittances?id={id}`` (v1: full page navigation via + * ``window.location.href``; a router-based nav handler is a v2 concern). + */ +export function MatchedRemitCard({ matchedRemittance }: MatchedRemitCardProps) { + if (matchedRemittance === null) return null; + + const { id, totalPaid, status, receivedAt } = matchedRemittance; + + const handleViewRemittance = () => { + window.location.href = `/remittances?id=${id}`; + }; + + return ( +
+

+ Matched Remittance +

+ +
+ {/* Left: remit id + status badge + received date */} +
+ + {id} + +
+ + {status} + + + Received {fmt.date(receivedAt)} + +
+
+ + {/* Right: total paid + view-remittance link */} +
+ + {fmt.usdPrecise(totalPaid)} + + +
+
+
+ ); +}