diff --git a/src/components/RemitDrawer/CasAdjustmentsPanel.test.tsx b/src/components/RemitDrawer/CasAdjustmentsPanel.test.tsx new file mode 100644 index 0000000..2c20ccd --- /dev/null +++ b/src/components/RemitDrawer/CasAdjustmentsPanel.test.tsx @@ -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( + + ); + + 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( + + ); + + // 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( + + ); + + // 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(); + }); +}); diff --git a/src/components/RemitDrawer/CasAdjustmentsPanel.tsx b/src/components/RemitDrawer/CasAdjustmentsPanel.tsx new file mode 100644 index 0000000..169f6b4 --- /dev/null +++ b/src/components/RemitDrawer/CasAdjustmentsPanel.tsx @@ -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; +}; + +/** + * 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 +): CasGroup[] { + const byKey = new Map(); + 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 ( +
+ + {group.group}-{group.reason} + + {group.label} + + ×{group.count} + + + {fmt.usdPrecise(group.total)} + +
+ ); +} + +/** + * 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 ( +
+ + + {open ? ( +
+ {/* Header row — labels for the 4-column grid */} +
+ + CARC + + + Reason + + + Hits + + + Amount + +
+ {groups.map((g) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/RemitDrawer/ClaimPaymentsTable.test.tsx b/src/components/RemitDrawer/ClaimPaymentsTable.test.tsx new file mode 100644 index 0000000..de33e1a --- /dev/null +++ b/src/components/RemitDrawer/ClaimPaymentsTable.test.tsx @@ -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( + + ); + + // 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( + + ); + + expect( + container.querySelector('[data-testid="claim-payments-table"]') + ).not.toBeNull(); + expect( + container.querySelector('[data-testid="claim-payments-carc-summary"]') + ).toBeNull(); + + unmount(); + }); +}); diff --git a/src/components/RemitDrawer/ClaimPaymentsTable.tsx b/src/components/RemitDrawer/ClaimPaymentsTable.tsx new file mode 100644 index 0000000..a64ae8c --- /dev/null +++ b/src/components/RemitDrawer/ClaimPaymentsTable.tsx @@ -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 ( +
+

+ Claim Payments (1) +

+ + + + + Claim + Status + Charge + Paid + Adjustments + + + + + + {claimLabel} + + + {remit.status} + + + {hasCharge ? fmt.usdPrecise(remit.totalCharge as number) : "—"} + + + {fmt.usdPrecise(remit.paidAmount)} + + + {Math.abs(remit.adjustmentAmount) > 0 + ? fmt.usdPrecise(remit.adjustmentAmount) + : "—"} + + + +
+ + {summary.length > 0 ? ( +
+

+ CARC breakdown +

+ + + + CARC + Reason + Hits + Amount + + + + {summary.map((row) => ( + + + {carcKey(row.group, row.reason)} + + + {row.label} + + + ×{row.count} + + + {fmt.usdPrecise(row.total)} + + + ))} + +
+
+ ) : null} +
+ ); +} diff --git a/src/components/RemitDrawer/FinancialSummaryCard.test.tsx b/src/components/RemitDrawer/FinancialSummaryCard.test.tsx new file mode 100644 index 0000000..a78fe16 --- /dev/null +++ b/src/components/RemitDrawer/FinancialSummaryCard.test.tsx @@ -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( + + ); + + // 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( + + ); + + // 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( + + ); + + // 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(); + }); +}); diff --git a/src/components/RemitDrawer/FinancialSummaryCard.tsx b/src/components/RemitDrawer/FinancialSummaryCard.tsx new file mode 100644 index 0000000..cf0b716 --- /dev/null +++ b/src/components/RemitDrawer/FinancialSummaryCard.tsx @@ -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 ( +
+ + {label} + + + {value} + +
+ ); +} + +/** + * 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 ( +
+

+ Financial Summary +

+ +
+ {/* Headline totals — big mono digits, side by side */} +
+
+ + Paid amount + + + {fmt.usdPrecise(remit.paidAmount)} + +
+
+ + Adjustment amount + + + {hasAdjustment ? fmt.usdPrecise(remit.adjustmentAmount) : "—"} + +
+
+ + {/* Supporting rows: method, payment date, check / trace number */} +
+ + + +
+
+
+ ); +} diff --git a/src/components/RemitDrawer/PartiesGrid.test.tsx b/src/components/RemitDrawer/PartiesGrid.test.tsx new file mode 100644 index 0000000..12a949e --- /dev/null +++ b/src/components/RemitDrawer/PartiesGrid.test.tsx @@ -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( + + ); + + // 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( + + ); + + 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(); + }); +}); diff --git a/src/components/RemitDrawer/PartiesGrid.tsx b/src/components/RemitDrawer/PartiesGrid.tsx new file mode 100644 index 0000000..112e272 --- /dev/null +++ b/src/components/RemitDrawer/PartiesGrid.tsx @@ -0,0 +1,200 @@ +import type { RemitDetail } from "@/hooks/useRemitDetail"; + +type PartiesGridProps = { + parties: NonNullable | 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 }) { + return ( +
+ {address.line1 ?
{address.line1}
: null} + {address.line2 ?
{address.line2}
: null} + {(address.city || address.state || address.zip) ? ( +
+ {address.city ? `${address.city}, ` : ""} + {address.state ?? ""} {address.zip ?? ""} +
+ ) : null} +
+ ); +} + +/** + * 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 ( +
+ + {label} + +
+ {name} +
+
+ {identity} +
+ {address && !isEmptyAddress(address) ? ( + } /> + ) : null} +
+ ); +} + +/** + * 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(
ID {payerBlock.id}
); + } + if ( + "payerClaimControlNumber" in parties && + typeof parties.payerClaimControlNumber === "string" && + parties.payerClaimControlNumber + ) { + payerIdentityLines.push( +
PCN {parties.payerClaimControlNumber}
+ ); + } + if ( + "claimId" in parties && + typeof parties.claimId === "string" && + parties.claimId + ) { + payerIdentityLines.push(
Claim {parties.claimId}
); + } + + // The "payee" identity lines — NPI is the canonical identifier. + const payeeName = payeeBlock?.name ?? "Provider"; + const payeeIdentityLines: React.ReactNode[] = []; + if (payeeBlock?.npi) { + payeeIdentityLines.push(
NPI {payeeBlock.npi}
); + } + + return ( +
+

+ Parties +

+ +
+ 0 ? ( + <>{payerIdentityLines} + ) : ( +
+ No payer identity +
+ ) + } + address={payerBlock?.address ?? null} + /> + + 0 ? ( + <>{payeeIdentityLines} + ) : ( +
+ No NPI on file +
+ ) + } + address={payeeBlock?.address ?? null} + /> +
+
+ ); +} diff --git a/src/components/RemitDrawer/RemitDrawer.test.tsx b/src/components/RemitDrawer/RemitDrawer.test.tsx new file mode 100644 index 0000000..d42abca --- /dev/null +++ b/src/components/RemitDrawer/RemitDrawer.test.tsx @@ -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; + 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 { + 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(); + }); +}); diff --git a/src/components/RemitDrawer/RemitDrawer.tsx b/src/components/RemitDrawer/RemitDrawer.tsx new file mode 100644 index 0000000..6ba5c3e --- /dev/null +++ b/src/components/RemitDrawer/RemitDrawer.tsx @@ -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 ( + { + if (!open) onClose(); + }} + > + + {isLoading ? ( + + ) : errorKind ? ( + { + void refetch(); + }} + onClose={onClose} + /> + ) : data ? ( +
+ +
+ + + + {data.adjustments && data.adjustments.length > 0 ? ( + + ) : null} +
+
+ ) : null} +
+
+ ); +} diff --git a/src/components/RemitDrawer/RemitDrawerError.test.tsx b/src/components/RemitDrawer/RemitDrawerError.test.tsx new file mode 100644 index 0000000..e79832b --- /dev/null +++ b/src/components/RemitDrawer/RemitDrawerError.test.tsx @@ -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( + + ); + + 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( + + ); + + 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( + + ); + + const retryBtn = container.querySelector('[data-testid="error-retry"]'); + expect(retryBtn).toBeNull(); + + unmount(); + }); +}); diff --git a/src/components/RemitDrawer/RemitDrawerError.tsx b/src/components/RemitDrawer/RemitDrawerError.tsx new file mode 100644 index 0000000..8cd8f1d --- /dev/null +++ b/src/components/RemitDrawer/RemitDrawerError.tsx @@ -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 ( +
+
+ + + {eyebrow} + +
+

{message}

+
+ {kind === "network" && onRetry ? ( + + ) : null} + +
+
+ ); +} diff --git a/src/components/RemitDrawer/RemitDrawerHeader.test.tsx b/src/components/RemitDrawer/RemitDrawerHeader.test.tsx new file mode 100644 index 0000000..f34df9d --- /dev/null +++ b/src/components/RemitDrawer/RemitDrawerHeader.test.tsx @@ -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( + + ); + + // 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( + + ); + + // 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(); + }); +}); diff --git a/src/components/RemitDrawer/RemitDrawerHeader.tsx b/src/components/RemitDrawer/RemitDrawerHeader.tsx new file mode 100644 index 0000000..9dd869a --- /dev/null +++ b/src/components/RemitDrawer/RemitDrawerHeader.tsx @@ -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 = { + 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 ( +
+ {/* Left: eyebrow + mono remit ID + raw link */} +
+ + Remittance + + + {remit.id} + + + View raw 835 + + +
+ + {/* Right: status badge + total paid + close */} +
+
+ + {remit.status} + + + {fmt.usdPrecise(remit.paidAmount)} + +
+ +
+
+ ); +} diff --git a/src/components/RemitDrawer/RemitDrawerSkeleton.test.tsx b/src/components/RemitDrawer/RemitDrawerSkeleton.test.tsx new file mode 100644 index 0000000..301c622 --- /dev/null +++ b/src/components/RemitDrawer/RemitDrawerSkeleton.test.tsx @@ -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(); + + 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(); + + 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(); + unmount(); + }).not.toThrow(); + }); +}); diff --git a/src/components/RemitDrawer/RemitDrawerSkeleton.tsx b/src/components/RemitDrawer/RemitDrawerSkeleton.tsx new file mode 100644 index 0000000..745fab6 --- /dev/null +++ b/src/components/RemitDrawer/RemitDrawerSkeleton.tsx @@ -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 ( +
+ {/* Header band — taller, prominent */} + + + {/* Financial summary card — short row */} + + + {/* Parties — two rows */} +
+ + +
+ + {/* Claim payments table — three stacked rows */} +
+ + + +
+ + {/* CAS panel — taller block (a collapsible list with header) */} + +
+ ); +} diff --git a/src/components/RemitDrawer/index.ts b/src/components/RemitDrawer/index.ts new file mode 100644 index 0000000..6b03396 --- /dev/null +++ b/src/components/RemitDrawer/index.ts @@ -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(); +// ({ 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"; diff --git a/src/hooks/useRemitDetail.test.ts b/src/hooks/useRemitDetail.test.ts new file mode 100644 index 0000000..d429ed2 --- /dev/null +++ b/src/hooks/useRemitDetail.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment happy-dom +// Mirror the IS_REACT_ACT_ENVIRONMENT setup from useClaimDetail.test.ts so +// React doesn't log act() warnings about the createRoot render/unmount. +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useRemitDetail } from "./useRemitDetail"; +import { api, ApiError } from "@/lib/api"; +import type { Remittance } from "@/types"; + +// 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 retry predicate) so that `instanceof ApiError` +// in the hook and `new ApiError(404, ...)` in the test resolve to the +// same class at runtime. +vi.mock("@/lib/api", () => { + class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + } + } + return { + api: { + getRemittance: vi.fn(), + }, + ApiError, + }; +}); + +/** + * Minimal renderHook shim — same pattern as `useClaimDetail.test.ts`. + * The project doesn't ship `@testing-library/react`, so we wire a Probe + * component into a real `createRoot` and read its return value through + * a shared `result` object. + */ +function renderHook( + useCallback: () => TResult +): { + result: { current: TResult | undefined }; + unmount: () => void; +} { + const result: { current: TResult | undefined } = { current: undefined }; + const container = document.createElement("div"); + document.body.appendChild(container); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + function Probe() { + result.current = useCallback(); + return null; + } + + const root: Root = createRoot(container); + act(() => { + root.render( + React.createElement(QueryClientProvider, { client: qc }, React.createElement(Probe)) + ); + }); + + return { + result, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +/** Poll the result until `predicate` is true or we time out. */ +async function waitForState( + predicate: () => boolean, + timeoutMs = 1000 +): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + throw new Error(`waitForState: predicate did not hold within ${timeoutMs}ms`); + } + await act(async () => { + await Promise.resolve(); + }); + } +} + +// Minimal valid Remittance — every required key present on the base +// `Remittance` interface. Mirrors the shape used by the page-level +// tests in `Remittances.test.tsx` so the type-checks line up with the +// rest of the codebase. +const SAMPLE_DETAIL: Remittance = { + id: "REM-1", + claimId: "CLM-1", + payerName: "Medicaid", + paidAmount: 100.0, + adjustmentAmount: 20.0, + receivedDate: "2026-01-15T00:00:00Z", + checkNumber: "EFT-12345", + status: "received", +}; + +describe("useRemitDetail", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns the empty drawer state and does not fetch when id is null", () => { + // The drawer is closed → no network call, no loading, no error. + // The hook must short-circuit BEFORE the queryFn is ever invoked. + const { result, unmount } = renderHook(() => useRemitDetail(null)); + + expect(result.current).toBeDefined(); + expect(result.current?.data).toBeNull(); + expect(result.current?.isLoading).toBe(false); + expect(result.current?.isError).toBe(false); + expect(result.current?.error).toBeNull(); + expect(typeof result.current?.refetch).toBe("function"); + expect(api.getRemittance).not.toHaveBeenCalled(); + + unmount(); + }); + + it("fetches the remit and returns the parsed body for a valid id", async () => { + (api.getRemittance as unknown as ReturnType).mockResolvedValue( + SAMPLE_DETAIL + ); + + const { result, unmount } = renderHook(() => useRemitDetail("REM-1")); + + await waitForState( + () => + result.current?.data !== null && + result.current?.data !== undefined && + result.current?.isLoading === false + ); + + expect(result.current?.data).toEqual(SAMPLE_DETAIL); + expect(result.current?.isError).toBe(false); + expect(result.current?.error).toBeNull(); + expect(api.getRemittance).toHaveBeenCalledTimes(1); + expect(api.getRemittance).toHaveBeenCalledWith("REM-1"); + + unmount(); + }); + + it("surfaces a 404 ApiError as isError=true with the original error object", async () => { + // The hook's retry predicate must give up immediately on 404 — the + // drawer needs to render the "remit doesn't exist" state rather than + // masking it with three back-to-back retries. + (api.getRemittance as unknown as ReturnType).mockRejectedValue( + new ApiError(404, "Remittance ghost not found") + ); + + const { result, unmount } = renderHook(() => useRemitDetail("ghost")); + + await waitForState(() => result.current?.isError === true); + + expect(result.current?.data).toBeNull(); + expect(result.current?.isLoading).toBe(false); + expect(result.current?.isError).toBe(true); + expect(result.current?.error).toBeInstanceOf(ApiError); + expect((result.current?.error as ApiError).status).toBe(404); + // 404s must not retry — exactly one fetch attempt. + expect(api.getRemittance).toHaveBeenCalledTimes(1); + + unmount(); + }); +}); diff --git a/src/hooks/useRemitDetail.ts b/src/hooks/useRemitDetail.ts new file mode 100644 index 0000000..ebb45a8 --- /dev/null +++ b/src/hooks/useRemitDetail.ts @@ -0,0 +1,103 @@ +import { useQuery } from "@tanstack/react-query"; +import { api, ApiError } from "@/lib/api"; +import type { Remittance } from "@/types"; + +/** + * UI-facing remittance detail shape returned by `GET /api/remittances/{id}`. + * + * The backend mapper (`cyclone.store.to_ui_remittance_with_adjustments`) + * returns the standard `Remittance` fields plus the labeled `adjustments` + * array. We extend the base type locally with the additional 835-derived + * fields the backend emits — `payerClaimControlNumber` and `totalCharge` + * (which aren't part of the v1 `Remittance` interface but ARE part of the + * backend's `/api/remittances/{id}` payload). All newly-added fields are + * optional so the component degrades gracefully when the backend hasn't + * populated them (e.g. earlier rows without those columns populated). + * + * `payer` and `payee` blocks are also optional — the current detail + * endpoint does not surface these yet, but the PartiesGrid renders them + * when present so a future backend version lights up the cards without a + * frontend change. + */ +export interface RemitDetail extends Remittance { + payerClaimControlNumber?: string; + totalCharge?: number; + paymentMethod?: string | null; + paymentDate?: string | null; + payer?: { + name?: string | null; + id?: string | null; + address?: { + line1?: string; + line2?: string | null; + city?: string; + state?: string; + zip?: string; + } | null; + } | null; + payee?: { + name?: string | null; + npi?: string | null; + address?: { + line1?: string; + line2?: string | null; + city?: string; + state?: string; + zip?: string; + } | null; + } | null; +} + +/** + * Per-remittance detail drawer query (RemitDrawer). + * + * Mirror of `useClaimDetail` — same return shape, same retry semantics. + * Returns `{ data, isLoading, isError, error, refetch }`: + * - `id === null` (drawer closed): the query is disabled and the hook + * short-circuits to the empty drawer state so a closed drawer doesn't + * burn a network request or a TanStack cache slot. + * - `id` is set: fetches `GET /api/remittances/{id}` via + * `api.getRemittance`. Cached 30 s so j/k navigation across remits + * reuses prior fetches within the drawer session. + * - On 404: the hook's retry predicate short-circuits (no retries) so + * the drawer's not-found state appears immediately rather than being + * masked by three back-to-back retry attempts. + */ +export function useRemitDetail(id: string | null): { + data: RemitDetail | null; + isLoading: boolean; + isError: boolean; + error: Error | null; + refetch: () => void; +} { + const q = useQuery({ + queryKey: ["remit-detail", id], + queryFn: () => api.getRemittance(id as string), + enabled: id !== null, + staleTime: 30 * 1000, + retry: (failureCount, error) => { + if (error instanceof ApiError && error.status === 404) return false; + return failureCount < 3; + }, + }); + + if (id === null) { + return { + data: null, + isLoading: false, + isError: false, + error: null, + refetch: () => {}, + }; + } + + return { + data: q.data ?? null, + isLoading: q.isLoading, + isError: q.isError, + error: q.error, + refetch: () => { + void q.refetch(); + }, + }; +} diff --git a/src/hooks/useRemitDrawerUrlState.test.ts b/src/hooks/useRemitDrawerUrlState.test.ts new file mode 100644 index 0000000..2950fc0 --- /dev/null +++ b/src/hooks/useRemitDrawerUrlState.test.ts @@ -0,0 +1,216 @@ +// @vitest-environment happy-dom +// Mirror the IS_REACT_ACT_ENVIRONMENT setup from useDrawerUrlState.test.ts +// so React doesn't log act() warnings about the createRoot render/unmount +// and the popstate-driven state updates. +(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 { useRemitDrawerUrlState } from "./useRemitDrawerUrlState"; + +/** + * Minimal renderHook shim — same pattern as the other hook tests. + */ +function renderHook(setup: () => TResult): { + result: { current: TResult | undefined }; + unmount: () => void; +} { + const result: { current: TResult | undefined } = { current: undefined }; + const container = document.createElement("div"); + document.body.appendChild(container); + + function Probe() { + result.current = setup(); + return null; + } + + const root: Root = createRoot(container); + act(() => { + root.render(React.createElement(Probe)); + }); + + return { + result, + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +/** + * Point happy-dom's URL at a known value. happy-dom v20 doesn't expose a + * writable `window.location.search`, but `window.happyDOM.setURL` updates + * the URL the window reports without triggering a navigation — exactly + * what we want for mounting the hook at `/remittances?remit=REM-1` etc. + */ +function setLocation(url: string): void { + (window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url); +} + +describe("useRemitDrawerUrlState", () => { + type PushState = (state: unknown, unused: string, url?: string | URL | null) => void; + let pushStateMock: ReturnType>; + let replaceStateMock: ReturnType>; + + beforeEach(() => { + pushStateMock = vi.fn(); + replaceStateMock = vi.fn(); + + vi.stubGlobal("history", { + pushState: pushStateMock, + replaceState: replaceStateMock, + state: null, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + setLocation("http://localhost/"); + }); + + it("reads the ?remit= param from window.location.search on mount", () => { + setLocation("http://localhost/remittances?remit=REM-1"); + + const { result, unmount } = renderHook(() => useRemitDrawerUrlState()); + + expect(result.current?.remitId).toBe("REM-1"); + expect(typeof result.current?.open).toBe("function"); + expect(typeof result.current?.close).toBe("function"); + expect(typeof result.current?.setRemitId).toBe("function"); + + unmount(); + }); + + it("returns null remitId when no ?remit= param is set", () => { + setLocation("http://localhost/remittances"); + + const { result, unmount } = renderHook(() => useRemitDrawerUrlState()); + + expect(result.current?.remitId).toBeNull(); + + unmount(); + }); + + it("returns null remitId when ?remit= is present but empty", () => { + setLocation("http://localhost/remittances?remit="); + + const { result, unmount } = renderHook(() => useRemitDrawerUrlState()); + + expect(result.current?.remitId).toBeNull(); + + unmount(); + }); + + it("open(id) pushes a new history entry containing ?remit=id", () => { + setLocation("http://localhost/remittances"); + + const { result, unmount } = renderHook(() => useRemitDrawerUrlState()); + + act(() => { + result.current?.open("REM-2"); + }); + + expect(pushStateMock).toHaveBeenCalledTimes(1); + expect(replaceStateMock).not.toHaveBeenCalled(); + const urlArg = pushStateMock.mock.calls[0][2] as string; + expect(urlArg).toContain("?remit=REM-2"); + + unmount(); + }); + + it("setRemitId(id) replaces the current history entry (no new entry) and does NOT pushState", () => { + setLocation("http://localhost/remittances?remit=REM-1"); + + const { result, unmount } = renderHook(() => useRemitDrawerUrlState()); + + act(() => { + result.current?.setRemitId("REM-3"); + }); + + expect(replaceStateMock).toHaveBeenCalledTimes(1); + expect(pushStateMock).not.toHaveBeenCalled(); + const urlArg = replaceStateMock.mock.calls[0][2] as string; + expect(urlArg).toContain("?remit=REM-3"); + + unmount(); + }); + + it("open() and close() preserve other query params (only ?remit= is touched)", () => { + setLocation("http://localhost/remittances?page=2&remit=REM-1"); + + const { result, unmount } = renderHook(() => useRemitDrawerUrlState()); + + act(() => { + result.current?.open("REM-2"); + }); + const openUrl = pushStateMock.mock.calls[0][2] as string; + expect(openUrl).toContain("page=2"); + expect(openUrl).toMatch(/[?&]remit=REM-2/); + + act(() => { + result.current?.close(); + }); + const closeUrl = pushStateMock.mock.calls[1][2] as string; + expect(closeUrl).toContain("page=2"); + expect(closeUrl).not.toContain("remit="); + + unmount(); + }); + + it("close() pushes a new history entry with the ?remit= param stripped", () => { + setLocation("http://localhost/remittances?remit=REM-1"); + + const { result, unmount } = renderHook(() => useRemitDrawerUrlState()); + + act(() => { + result.current?.close(); + }); + + expect(pushStateMock).toHaveBeenCalledTimes(1); + expect(replaceStateMock).not.toHaveBeenCalled(); + const urlArg = pushStateMock.mock.calls[0][2] as string; + expect(urlArg).not.toContain("?remit="); + expect(result.current?.remitId).toBeNull(); + + unmount(); + }); + + it("updates remitId in response to popstate (browser back/forward)", async () => { + setLocation("http://localhost/remittances?remit=REM-1"); + + const { result, unmount } = renderHook(() => useRemitDrawerUrlState()); + + expect(result.current?.remitId).toBe("REM-1"); + + setLocation("http://localhost/remittances?remit=REM-2"); + await act(async () => { + window.dispatchEvent(new PopStateEvent("popstate")); + await Promise.resolve(); + }); + + expect(result.current?.remitId).toBe("REM-2"); + + unmount(); + }); + + it("does not collide with the existing ?claim= param (orthogonal keys)", () => { + // The remits drawer and the claims drawer are independent — both + // can be open simultaneously in the future, and the hooks must not + // stomp each other's URL state. Opening a remit must leave + // ?claim= intact, and vice-versa. + setLocation("http://localhost/?claim=CLM-1"); + + const { result, unmount } = renderHook(() => useRemitDrawerUrlState()); + + act(() => { + result.current?.open("REM-1"); + }); + const openUrl = pushStateMock.mock.calls[0][2] as string; + expect(openUrl).toContain("claim=CLM-1"); + expect(openUrl).toMatch(/[?&]remit=REM-1/); + + unmount(); + }); +}); diff --git a/src/hooks/useRemitDrawerUrlState.ts b/src/hooks/useRemitDrawerUrlState.ts new file mode 100644 index 0000000..3cf7c08 --- /dev/null +++ b/src/hooks/useRemitDrawerUrlState.ts @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useState } from "react"; + +/** + * Read the current `?remit=…` query param off `window.location.search`. + * Returns `null` when the param is absent or empty. + * + * `URLSearchParams` is the standard, locale-free way to parse query + * strings in the browser. Using it (rather than hand-rolled string + * slicing) means we correctly handle multiple params and percent-encoded + * characters in remit IDs without surprises. + * + * Param name is `?remit=` (not `?remittance=`) — chosen to mirror the + * shorter `?claim=` convention used by `useDrawerUrlState` (one-word + * token, alphabetical brevity, no collision with the existing + * `MatchedRemitCard` which uses the path `/remittances?id={id}`). + */ +function readRemitId(): string | null { + const params = new URLSearchParams(window.location.search); + const value = params.get("remit"); + return value === "" ? null : value; +} + +/** + * Build the URL we want to push/replace into history. + * + * - `remitId === null` → drop the `?remit=` param, preserving any + * other params (e.g. `?page=2&remit=…` keeps `page=2`). + * - `remitId !== null` → set the param to the new id, also preserving + * any other params. + * + * We return `pathname + search + hash` (a relative URL) rather than the + * full href — `history.pushState` accepts a relative URL and rewriting + * only the relative form keeps the document's origin stable. + */ +function buildUrl(remitId: string | null): string { + const url = new URL(window.location.href); + if (remitId === null) { + url.searchParams.delete("remit"); + } else { + url.searchParams.set("remit", remitId); + } + return url.pathname + url.search + url.hash; +} + +/** + * Per-remittance detail drawer URL state (RemitDrawer). + * + * Mirrors `useDrawerUrlState` (SP4) but for the remits drawer — reads + * `?remit=` from the URL on mount and keeps the value in sync with + * history as the drawer is opened, navigated (j/k), and closed. + * + * - `remitId`: the id parsed from the URL (or `null` when the param + * is absent). React state so consumers re-render on changes. + * - `open(id)`: pushes a NEW history entry with `?remit={id}` — so the + * browser Back button returns to the previous page (e.g. the + * remits list) and not just to the previously-open remit. + * - `setRemitId(id)`: REPLACES the current history entry — used by the + * j/k nav handler so j/k moves through the list without polluting + * history with one entry per keystroke. + * - `close()`: pushes a NEW entry that strips the param, so Back from + * the closed drawer returns to whatever page the user was on before + * opening the drawer. + * + * The hook subscribes to `popstate` so that browser Back/Forward + * (which fire popstate rather than our own pushState) propagate into + * the React state. Without this, hitting Back would change the URL but + * leave the drawer open on the stale id. + */ +export function useRemitDrawerUrlState(): { + remitId: string | null; + open: (id: string) => void; + close: () => void; + setRemitId: (id: string) => void; +} { + const [remitId, setRemitIdState] = useState(() => readRemitId()); + + const open = useCallback((id: string) => { + window.history.pushState(null, "", buildUrl(id)); + setRemitIdState(id); + }, []); + + const setRemitId = useCallback((id: string) => { + window.history.replaceState(null, "", buildUrl(id)); + setRemitIdState(id); + }, []); + + const close = useCallback(() => { + window.history.pushState(null, "", buildUrl(null)); + setRemitIdState(null); + }, []); + + useEffect(() => { + const onPopState = () => { + setRemitIdState(readRemitId()); + }; + window.addEventListener("popstate", onPopState); + return () => window.removeEventListener("popstate", onPopState); + }, []); + + return { remitId, open, close, setRemitId }; +}