feat(remits): row click opens RemitDrawer (replaces inline CAS expand)

This commit is contained in:
Tyler
2026-06-21 17:03:04 -06:00
parent b606e8c9a2
commit 7427838292
2 changed files with 130 additions and 139 deletions
+63 -14
View File
@@ -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<typeof vi.fn>
).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 <tr>.
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();
});
+39 -97
View File
@@ -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 (
<div className="flex items-start justify-between gap-4 py-2 border-b border-border/30 last:border-0">
<div className="min-w-0 flex-1">
<div className="mono text-[10.5px] text-muted-foreground">
{adj.group}-{adj.reason}
{adj.quantity !== null ? (
<span className="ml-2 text-muted-foreground/60">
qty {adj.quantity}
</span>
) : null}
</div>
<div className="text-[12.5px] text-foreground/90 truncate">{adj.label}</div>
</div>
<div className="display mono text-[12.5px] tabular-nums whitespace-nowrap text-muted-foreground">
{fmt.usdPrecise(adj.amount)}
</div>
</div>
);
}
export function Remittances() {
const [page, setPage] = useState(1);
const [status, setStatus] = useState<RemittanceStatus | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [selectedIndex, setSelectedIndex] = useState<number | null>(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 (
<>
<KeyboardCheatsheet
open={helpOpen}
onClose={() => setHelpOpen(false)}
/>
<RemitDrawer
remitId={remitId}
remits={drawerRemits}
onClose={close}
onNavigate={setRemitId}
onToggleHelp={() => setHelpOpen((v) => !v)}
/>
<div className="space-y-6 lg:space-y-8 animate-fade-in">
<PageHeader
eyebrow="Remittances"
@@ -204,7 +200,6 @@ export function Remittances() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" aria-label="Expand" />
<TableHead>Remit</TableHead>
<TableHead>Claim</TableHead>
<TableHead>Payer</TableHead>
@@ -216,45 +211,21 @@ export function Remittances() {
</TableHeader>
<TableBody>
{items.map((r, idx) => {
const isOpen = expanded.has(r.id);
const hasAdjustments =
!!r.adjustments && r.adjustments.length > 0;
const isSelected = selectedIndex === idx;
return (
<Fragment key={`${r.id}-${dataUpdatedAt}`}>
<TableRow
key={`${r.id}-${dataUpdatedAt}`}
data-row-index={idx}
data-state={isSelected ? "selected" : undefined}
aria-selected={isSelected}
className={cn(
"animate-row-flash",
"animate-row-flash cursor-pointer drillable",
isSelected && [
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
],
)}
onClick={() =>
hasAdjustments ? toggleExpand(r.id) : undefined
}
aria-expanded={hasAdjustments ? isOpen : undefined}
style={{ cursor: hasAdjustments ? "pointer" : undefined }}
onClick={() => open(r.id)}
>
<TableCell className="text-muted-foreground">
{hasAdjustments ? (
isOpen ? (
<ChevronDown
className="h-3.5 w-3.5"
strokeWidth={1.75}
aria-hidden
/>
) : (
<ChevronRight
className="h-3.5 w-3.5"
strokeWidth={1.75}
aria-hidden
/>
)
) : null}
</TableCell>
<TableCell className="display mono text-[12.5px]">{r.id}</TableCell>
<TableCell className="display mono text-[12.5px] text-muted-foreground">
{r.claimId}
@@ -273,35 +244,6 @@ export function Remittances() {
{fmt.dateShort(r.receivedDate)}
</TableCell>
</TableRow>
{isOpen && hasAdjustments ? (
<TableRow
key={`${r.id}-${dataUpdatedAt}-detail`}
className="bg-muted/20 hover:bg-muted/20"
>
<TableCell />
<TableCell colSpan={7} className="py-3">
<div className="flex items-center gap-2 mb-2">
<Receipt
className="h-3.5 w-3.5 text-muted-foreground"
strokeWidth={1.5}
aria-hidden
/>
<div className="eyebrow">
Adjustments ({r.adjustments!.length})
</div>
</div>
<div className="pl-5">
{r.adjustments!.map((adj, i) => (
<AdjustmentRow
key={`${adj.group}-${adj.reason}-${i}`}
adj={adj}
/>
))}
</div>
</TableCell>
</TableRow>
) : null}
</Fragment>
);
})}
</TableBody>