diff --git a/src/pages/Remittances.test.tsx b/src/pages/Remittances.test.tsx index 1d3e9bf..fe1e964 100644 --- a/src/pages/Remittances.test.tsx +++ b/src/pages/Remittances.test.tsx @@ -22,6 +22,11 @@ vi.mock("@/lib/api", () => ({ listRemittances: vi.fn(), getRemittance: vi.fn(), }, + ApiError: class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + } + }, })); // Mock the live-tail hook so the page renders the pill in the settled @@ -128,6 +133,17 @@ function rowAt(idx: number): HTMLTableRowElement | null { ) as HTMLTableRowElement | null; } +/** + * 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 page at `/remittances?remit=PCN-1` etc. + * Same helper used by Claims.test.tsx and useRemitDrawerUrlState.test.ts. + */ +function setLocation(url: string): void { + (window as unknown as { happyDOM: { setURL: (u: string) => void } }).happyDOM.setURL(url); +} + /** True iff exactly one row carries `data-state="selected"`. */ function hasExactlyOneSelectedRow(): boolean { const selected = document.querySelectorAll( @@ -191,6 +207,11 @@ const SAMPLE_REMITS = [ describe("Remittances", () => { beforeEach(() => { vi.clearAllMocks(); + // Reset URL to the bare remittances page between tests so a + // `?remit=` leaked from a prior test (via pushState) doesn't + // bleed into the next. happy-dom's URL survives across tests in + // the same file unless explicitly reset. + setLocation("http://localhost/remittances"); // Singleton tail-store: clear the remittances slice between tests // so a tail-arrival case (if added later) doesn't see rows from a // previous test. @@ -203,18 +224,26 @@ describe("Remittances", () => { returned: SAMPLE_REMITS.length, has_more: false, }); + // Default for the per-remit detail fetch — the drawer fetches + // this whenever `?remit=` is in the URL. Return a never-resolving + // promise so the drawer stays in the loading state; the smoke + // tests only assert the drawer mounts, not the loaded data. + ( + api.getRemittance as unknown as ReturnType + ).mockReturnValue(new Promise(() => {})); }); - it("renders a CAS adjustment label inside the expanded detail row", async () => { + it("clicking a row opens the RemitDrawer (no more inline expand)", async () => { + // SP21 Phase 4 Task 4.3: the inline CAS expansion is gone — the + // whole row now drills into the RemitDrawer via `?remit=ID`. The + // CAS panel is now inside the drawer, not in a second . const { unmount } = renderIntoContainer(React.createElement(Remittances)); await waitForText("PCN-1"); - // The chevron + "Adjustments" header should not yet be visible because - // the row hasn't been expanded yet. - expect(document.body.textContent).not.toContain("Adjustments (2)"); + // No drawer in the DOM yet — the URL has no `?remit=`. + expect(document.body.querySelector('[data-testid="remit-drawer"]')).toBeNull(); - // Expand the row by clicking on the remit ID cell. We click the parent - // row by selecting the cell containing "PCN-1" and bubbling up. + // Click the row containing PCN-1. const cell = Array.from(document.querySelectorAll("td")).find( (td) => td.textContent === "PCN-1" ); @@ -223,16 +252,36 @@ describe("Remittances", () => { await act(async () => { (row as HTMLTableRowElement).click(); }); - await waitForText("Adjustments (2)"); - // Both CAS labels must surface (not the raw codes alone). - expect(document.body.textContent).toContain( - "Charge exceeds fee schedule/maximum allowable" + // The drawer must mount into document.body via Radix's portal. + await settle( + () => document.body.querySelector('[data-testid="remit-drawer"]') !== null ); - expect(document.body.textContent).toContain("Deductible amount"); - // Group/reason pills show the CARC code alongside. - expect(document.body.textContent).toContain("CO-45"); - expect(document.body.textContent).toContain("PR-1"); + expect( + document.body.querySelector('[data-testid="remit-drawer"]') + ).not.toBeNull(); + + // URL must reflect the open remit. + expect(window.location.search).toContain("remit=PCN-1"); + + unmount(); + }); + + it("deep-link ?remit=ID opens the drawer on mount", async () => { + // Pre-set the URL so the hook reads PCN-1 off `window.location.search` + // during its `useState` initializer — no click needed. + setLocation("http://localhost/remittances?remit=PCN-1"); + + const { unmount } = renderIntoContainer(React.createElement(Remittances)); + await settle( + () => document.body.querySelector('[data-testid="remit-drawer"]') !== null + ); + + // Drawer should appear immediately, without user interaction. + expect( + document.body.querySelector('[data-testid="remit-drawer"]') + ).not.toBeNull(); + unmount(); }); diff --git a/src/pages/Remittances.tsx b/src/pages/Remittances.tsx index b845ce8..81860a4 100644 --- a/src/pages/Remittances.tsx +++ b/src/pages/Remittances.tsx @@ -1,5 +1,4 @@ -import { Fragment, useCallback, useMemo, useState } from "react"; -import { ChevronDown, ChevronRight, Receipt } from "lucide-react"; +import { useCallback, useMemo, useState } from "react"; import { Table, TableBody, @@ -17,13 +16,15 @@ import { Pagination } from "@/components/ui/pagination"; import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet"; import { PageHeader } from "@/components/PageHeader"; import { TailStatusPill } from "@/components/TailStatusPill"; +import { RemitDrawer } from "@/components/RemitDrawer"; import { useRemittances } from "@/hooks/useRemittances"; +import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState"; import { useRowKeyboard } from "@/hooks/useRowKeyboard"; import { useTailStream } from "@/hooks/useTailStream"; import { useMergedTail } from "@/hooks/useMergedTail"; import { fmt } from "@/lib/format"; import { cn } from "@/lib/utils"; -import type { CasAdjustment, Remittance, RemittanceStatus } from "@/types"; +import type { Remittance, RemittanceStatus } from "@/types"; const PAGE_SIZE = 25; @@ -33,39 +34,20 @@ const STATUS_OPTIONS: FilterChipOption[] = [ { value: "reconciled", label: "Reconciled" }, ]; -/** - * One persisted CAS row, rendered as a "code — label" pair plus the - * dollar amount. Lives inside the expanded detail row of a remit so - * the operator can see exactly why the payer adjusted the claim. - */ -function AdjustmentRow({ adj }: { adj: CasAdjustment }) { - return ( -
-
-
- {adj.group}-{adj.reason} - {adj.quantity !== null ? ( - - qty {adj.quantity} - - ) : null} -
-
{adj.label}
-
-
- {fmt.usdPrecise(adj.amount)} -
-
- ); -} - export function Remittances() { const [page, setPage] = useState(1); const [status, setStatus] = useState(null); - const [expanded, setExpanded] = useState>(() => new Set()); const [selectedIndex, setSelectedIndex] = useState(null); const [helpOpen, setHelpOpen] = useState(false); + // SP21 Phase 4 Task 4.3: row click → RemitDrawer. The drawer is + // URL-driven (`?remit=ID`) so deep links restore the open remit + // on reload — same pattern as the ClaimDrawer on /claims. + // `remits` is the j/k navigation list (the current page of rows). + // `setRemitId` (REPLACE history, not push) is what j/k uses so a + // single keypress doesn't add a history entry. + const { remitId, open, close, setRemitId } = useRemitDrawerUrlState(); + const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useRemittances({ sort: "receivedDate", order: "desc", @@ -93,15 +75,6 @@ export function Remittances() { { paid: 0, adjustments: 0 } ); - const toggleExpand = (id: string) => { - setExpanded((prev) => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); - }; - const moveNext = useCallback(() => { setSelectedIndex((i) => { if (items.length === 0) return null; @@ -119,19 +92,42 @@ export function Remittances() { }, [items.length]); useRowKeyboard({ - enabled: !helpOpen && items.length > 0, + // Page-level j/k only fires when the drawer is closed — once + // `?remit=` is set, the drawer's own `useDrawerKeyboard` listener + // owns the j/k keys (with its own wrap-around semantics over + // `remits`). Letting the page-level listener stay active here + // would mean a single `j` keypress both advances the drawer's + // remittance AND bumps the page-level selectedIndex — exactly + // the "double navigation" surprise we want to avoid. + enabled: !helpOpen && items.length > 0 && remitId === null, onNext: moveNext, onPrev: movePrev, onClose: () => setHelpOpen(false), onToggleHelp: () => setHelpOpen((v) => !v), }); + // j/k navigation through the remits list. The drawer's own keyboard + // handler (useDrawerKeyboard, only attached while the drawer is + // open) uses the same keys with its own wrap-around semantics, so + // page-level nav only fires when the drawer is closed. + const drawerRemits = useMemo( + () => items.map((r) => ({ id: r.id })), + [items], + ); + return ( <> setHelpOpen(false)} /> + setHelpOpen((v) => !v)} + />
- Remit Claim Payer @@ -216,92 +211,39 @@ export function Remittances() { {items.map((r, idx) => { - const isOpen = expanded.has(r.id); - const hasAdjustments = - !!r.adjustments && r.adjustments.length > 0; const isSelected = selectedIndex === idx; return ( - - - hasAdjustments ? toggleExpand(r.id) : undefined - } - aria-expanded={hasAdjustments ? isOpen : undefined} - style={{ cursor: hasAdjustments ? "pointer" : undefined }} - > - - {hasAdjustments ? ( - isOpen ? ( - - ) : ( - - ) - ) : null} - - {r.id} - - {r.claimId} - - {r.payerName} - - {fmt.usdPrecise(r.paidAmount)} - - - {r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"} - - - - - - {fmt.dateShort(r.receivedDate)} - - - {isOpen && hasAdjustments ? ( - - - -
- -
- Adjustments ({r.adjustments!.length}) -
-
-
- {r.adjustments!.map((adj, i) => ( - - ))} -
-
-
- ) : null} -
+ open(r.id)} + > + {r.id} + + {r.claimId} + + {r.payerName} + + {fmt.usdPrecise(r.paidAmount)} + + + {r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"} + + + + + + {fmt.dateShort(r.receivedDate)} + + ); })}