feat(remits): row click opens RemitDrawer (replaces inline CAS expand)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
+67
-125
@@ -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,92 +211,39 @@ 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
|
||||
data-row-index={idx}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
"animate-row-flash",
|
||||
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 }}
|
||||
>
|
||||
<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}
|
||||
</TableCell>
|
||||
<TableCell>{r.payerName}</TableCell>
|
||||
<TableCell className="text-right display mono">
|
||||
{fmt.usdPrecise(r.paidAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display mono text-muted-foreground">
|
||||
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RemitStatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground mono text-[12px]">
|
||||
{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>
|
||||
<TableRow
|
||||
key={`${r.id}-${dataUpdatedAt}`}
|
||||
data-row-index={idx}
|
||||
data-state={isSelected ? "selected" : undefined}
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
"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={() => open(r.id)}
|
||||
>
|
||||
<TableCell className="display mono text-[12.5px]">{r.id}</TableCell>
|
||||
<TableCell className="display mono text-[12.5px] text-muted-foreground">
|
||||
{r.claimId}
|
||||
</TableCell>
|
||||
<TableCell>{r.payerName}</TableCell>
|
||||
<TableCell className="text-right display mono">
|
||||
{fmt.usdPrecise(r.paidAmount)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right display mono text-muted-foreground">
|
||||
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RemitStatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground mono text-[12px]">
|
||||
{fmt.dateShort(r.receivedDate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
|
||||
Reference in New Issue
Block a user