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(),
|
listRemittances: vi.fn(),
|
||||||
getRemittance: 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
|
// 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;
|
) 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"`. */
|
/** True iff exactly one row carries `data-state="selected"`. */
|
||||||
function hasExactlyOneSelectedRow(): boolean {
|
function hasExactlyOneSelectedRow(): boolean {
|
||||||
const selected = document.querySelectorAll(
|
const selected = document.querySelectorAll(
|
||||||
@@ -191,6 +207,11 @@ const SAMPLE_REMITS = [
|
|||||||
describe("Remittances", () => {
|
describe("Remittances", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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
|
// Singleton tail-store: clear the remittances slice between tests
|
||||||
// so a tail-arrival case (if added later) doesn't see rows from a
|
// so a tail-arrival case (if added later) doesn't see rows from a
|
||||||
// previous test.
|
// previous test.
|
||||||
@@ -203,18 +224,26 @@ describe("Remittances", () => {
|
|||||||
returned: SAMPLE_REMITS.length,
|
returned: SAMPLE_REMITS.length,
|
||||||
has_more: false,
|
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));
|
const { unmount } = renderIntoContainer(React.createElement(Remittances));
|
||||||
await waitForText("PCN-1");
|
await waitForText("PCN-1");
|
||||||
|
|
||||||
// The chevron + "Adjustments" header should not yet be visible because
|
// No drawer in the DOM yet — the URL has no `?remit=`.
|
||||||
// the row hasn't been expanded yet.
|
expect(document.body.querySelector('[data-testid="remit-drawer"]')).toBeNull();
|
||||||
expect(document.body.textContent).not.toContain("Adjustments (2)");
|
|
||||||
|
|
||||||
// Expand the row by clicking on the remit ID cell. We click the parent
|
// Click the row containing PCN-1.
|
||||||
// row by selecting the cell containing "PCN-1" and bubbling up.
|
|
||||||
const cell = Array.from(document.querySelectorAll("td")).find(
|
const cell = Array.from(document.querySelectorAll("td")).find(
|
||||||
(td) => td.textContent === "PCN-1"
|
(td) => td.textContent === "PCN-1"
|
||||||
);
|
);
|
||||||
@@ -223,16 +252,36 @@ describe("Remittances", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
(row as HTMLTableRowElement).click();
|
(row as HTMLTableRowElement).click();
|
||||||
});
|
});
|
||||||
await waitForText("Adjustments (2)");
|
|
||||||
|
|
||||||
// Both CAS labels must surface (not the raw codes alone).
|
// The drawer must mount into document.body via Radix's portal.
|
||||||
expect(document.body.textContent).toContain(
|
await settle(
|
||||||
"Charge exceeds fee schedule/maximum allowable"
|
() => document.body.querySelector('[data-testid="remit-drawer"]') !== null
|
||||||
);
|
);
|
||||||
expect(document.body.textContent).toContain("Deductible amount");
|
expect(
|
||||||
// Group/reason pills show the CARC code alongside.
|
document.body.querySelector('[data-testid="remit-drawer"]')
|
||||||
expect(document.body.textContent).toContain("CO-45");
|
).not.toBeNull();
|
||||||
expect(document.body.textContent).toContain("PR-1");
|
|
||||||
|
// 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();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+67
-125
@@ -1,5 +1,4 @@
|
|||||||
import { Fragment, useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { ChevronDown, ChevronRight, Receipt } from "lucide-react";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -17,13 +16,15 @@ import { Pagination } from "@/components/ui/pagination";
|
|||||||
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
|
import { KeyboardCheatsheet } from "@/components/KeyboardCheatsheet";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
import { TailStatusPill } from "@/components/TailStatusPill";
|
import { TailStatusPill } from "@/components/TailStatusPill";
|
||||||
|
import { RemitDrawer } from "@/components/RemitDrawer";
|
||||||
import { useRemittances } from "@/hooks/useRemittances";
|
import { useRemittances } from "@/hooks/useRemittances";
|
||||||
|
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
||||||
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
import { useRowKeyboard } from "@/hooks/useRowKeyboard";
|
||||||
import { useTailStream } from "@/hooks/useTailStream";
|
import { useTailStream } from "@/hooks/useTailStream";
|
||||||
import { useMergedTail } from "@/hooks/useMergedTail";
|
import { useMergedTail } from "@/hooks/useMergedTail";
|
||||||
import { fmt } from "@/lib/format";
|
import { fmt } from "@/lib/format";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { CasAdjustment, Remittance, RemittanceStatus } from "@/types";
|
import type { Remittance, RemittanceStatus } from "@/types";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
@@ -33,39 +34,20 @@ const STATUS_OPTIONS: FilterChipOption[] = [
|
|||||||
{ value: "reconciled", label: "Reconciled" },
|
{ 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() {
|
export function Remittances() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [status, setStatus] = useState<RemittanceStatus | null>(null);
|
const [status, setStatus] = useState<RemittanceStatus | null>(null);
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
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({
|
const { data, isLoading, isError, error, refetch, dataUpdatedAt } = useRemittances({
|
||||||
sort: "receivedDate",
|
sort: "receivedDate",
|
||||||
order: "desc",
|
order: "desc",
|
||||||
@@ -93,15 +75,6 @@ export function Remittances() {
|
|||||||
{ paid: 0, adjustments: 0 }
|
{ 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(() => {
|
const moveNext = useCallback(() => {
|
||||||
setSelectedIndex((i) => {
|
setSelectedIndex((i) => {
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
@@ -119,19 +92,42 @@ export function Remittances() {
|
|||||||
}, [items.length]);
|
}, [items.length]);
|
||||||
|
|
||||||
useRowKeyboard({
|
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,
|
onNext: moveNext,
|
||||||
onPrev: movePrev,
|
onPrev: movePrev,
|
||||||
onClose: () => setHelpOpen(false),
|
onClose: () => setHelpOpen(false),
|
||||||
onToggleHelp: () => setHelpOpen((v) => !v),
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<KeyboardCheatsheet
|
<KeyboardCheatsheet
|
||||||
open={helpOpen}
|
open={helpOpen}
|
||||||
onClose={() => setHelpOpen(false)}
|
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">
|
<div className="space-y-6 lg:space-y-8 animate-fade-in">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="Remittances"
|
eyebrow="Remittances"
|
||||||
@@ -204,7 +200,6 @@ export function Remittances() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-8" aria-label="Expand" />
|
|
||||||
<TableHead>Remit</TableHead>
|
<TableHead>Remit</TableHead>
|
||||||
<TableHead>Claim</TableHead>
|
<TableHead>Claim</TableHead>
|
||||||
<TableHead>Payer</TableHead>
|
<TableHead>Payer</TableHead>
|
||||||
@@ -216,92 +211,39 @@ export function Remittances() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.map((r, idx) => {
|
{items.map((r, idx) => {
|
||||||
const isOpen = expanded.has(r.id);
|
|
||||||
const hasAdjustments =
|
|
||||||
!!r.adjustments && r.adjustments.length > 0;
|
|
||||||
const isSelected = selectedIndex === idx;
|
const isSelected = selectedIndex === idx;
|
||||||
return (
|
return (
|
||||||
<Fragment key={`${r.id}-${dataUpdatedAt}`}>
|
<TableRow
|
||||||
<TableRow
|
key={`${r.id}-${dataUpdatedAt}`}
|
||||||
data-row-index={idx}
|
data-row-index={idx}
|
||||||
data-state={isSelected ? "selected" : undefined}
|
data-state={isSelected ? "selected" : undefined}
|
||||||
aria-selected={isSelected}
|
aria-selected={isSelected}
|
||||||
className={cn(
|
className={cn(
|
||||||
"animate-row-flash",
|
"animate-row-flash cursor-pointer drillable",
|
||||||
isSelected && [
|
isSelected && [
|
||||||
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
|
"bg-accent/10 ring-1 ring-inset ring-accent/40 shadow-[inset_2px_0_0_0_hsl(var(--accent))]",
|
||||||
],
|
],
|
||||||
)}
|
)}
|
||||||
onClick={() =>
|
onClick={() => open(r.id)}
|
||||||
hasAdjustments ? toggleExpand(r.id) : undefined
|
>
|
||||||
}
|
<TableCell className="display mono text-[12.5px]">{r.id}</TableCell>
|
||||||
aria-expanded={hasAdjustments ? isOpen : undefined}
|
<TableCell className="display mono text-[12.5px] text-muted-foreground">
|
||||||
style={{ cursor: hasAdjustments ? "pointer" : undefined }}
|
{r.claimId}
|
||||||
>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell>{r.payerName}</TableCell>
|
||||||
{hasAdjustments ? (
|
<TableCell className="text-right display mono">
|
||||||
isOpen ? (
|
{fmt.usdPrecise(r.paidAmount)}
|
||||||
<ChevronDown
|
</TableCell>
|
||||||
className="h-3.5 w-3.5"
|
<TableCell className="text-right display mono text-muted-foreground">
|
||||||
strokeWidth={1.75}
|
{r.adjustmentAmount > 0 ? fmt.usdPrecise(r.adjustmentAmount) : "—"}
|
||||||
aria-hidden
|
</TableCell>
|
||||||
/>
|
<TableCell>
|
||||||
) : (
|
<RemitStatusBadge status={r.status} />
|
||||||
<ChevronRight
|
</TableCell>
|
||||||
className="h-3.5 w-3.5"
|
<TableCell className="text-muted-foreground mono text-[12px]">
|
||||||
strokeWidth={1.75}
|
{fmt.dateShort(r.receivedDate)}
|
||||||
aria-hidden
|
</TableCell>
|
||||||
/>
|
</TableRow>
|
||||||
)
|
|
||||||
) : 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>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
Reference in New Issue
Block a user