feat(reconciliation): card body drillable, select button split

This commit is contained in:
Tyler
2026-06-21 17:41:58 -06:00
parent 4a8ce1a524
commit ac87ed4908
2 changed files with 434 additions and 71 deletions
+292 -6
View File
@@ -4,9 +4,10 @@
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true; true;
import React, { act } from "react"; import React, { act, useEffect } from "react";
import { createRoot, type Root } from "react-dom/client"; import { createRoot, type Root } from "react-dom/client";
import { describe, expect, it, vi, beforeEach } from "vitest"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { MemoryRouter, useLocation } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReconciliationPage } from "./Reconciliation"; import { ReconciliationPage } from "./Reconciliation";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
@@ -28,6 +29,23 @@ vi.mock("@/lib/api", () => ({
}, },
})); }));
/**
* Tracks the current MemoryRouter location so tests can assert on
* navigation without poking at window.location (MemoryRouter doesn't
* touch it). Mounted as a sibling under the same router.
*/
function LocationTracker({
on,
}: {
on: (pathname: string, search: string) => void;
}) {
const loc = useLocation();
useEffect(() => {
on(loc.pathname, loc.search);
}, [loc.pathname, loc.search, on]);
return null;
}
/** /**
* Minimal `render` helper using react-dom/client + act(). Mirrors the * Minimal `render` helper using react-dom/client + act(). Mirrors the
* `renderHook` helper in `useReconciliation.test.ts` — see that file's * `renderHook` helper in `useReconciliation.test.ts` — see that file's
@@ -35,22 +53,50 @@ vi.mock("@/lib/api", () => ({
* yet, and adding one just for these tests would inflate the dev-deps * yet, and adding one just for these tests would inflate the dev-deps
* tree). Returns the rendered container so tests can assert against * tree). Returns the rendered container so tests can assert against
* the live DOM via `container.textContent`. * the live DOM via `container.textContent`.
*
* SP21 Phase 5 Task 5.5: optionally wraps with a MemoryRouter so the
* `useNavigate()` call (claim drill to /claims?claim=ID) has a router
* context. Without this, `useNavigate()` would throw in tests that
* trigger a claim-card click. Tests that don't need a router can keep
* using the default (no router) path.
*/ */
function renderIntoContainer(element: React.ReactElement): { function renderIntoContainer(
element: React.ReactElement,
options: { withRouter?: boolean; initialEntries?: string[] } = {},
): {
container: HTMLDivElement; container: HTMLDivElement;
unmount: () => void; unmount: () => void;
tracker?: { pathname: string; search: string };
} { } {
const container = document.createElement("div"); const container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
let tracker: { pathname: string; search: string } | undefined;
const track = (pathname: string, search: string) => {
if (!tracker) tracker = { pathname, search };
tracker.pathname = pathname;
tracker.search = search;
};
const inner = options.withRouter
? React.createElement(
MemoryRouter,
{
initialEntries: options.initialEntries ?? ["/reconciliation"],
},
React.createElement(LocationTracker, { on: track }),
element,
)
: element;
const root: Root = createRoot(container); const root: Root = createRoot(container);
act(() => { act(() => {
root.render( root.render(
React.createElement( React.createElement(
QueryClientProvider, QueryClientProvider,
{ client: qc }, { client: qc },
element inner
) )
); );
}); });
@@ -61,6 +107,7 @@ function renderIntoContainer(element: React.ReactElement): {
act(() => root.unmount()); act(() => root.unmount());
container.remove(); container.remove();
}, },
tracker,
}; };
} }
@@ -94,6 +141,33 @@ describe("ReconciliationPage", () => {
( (
api.getRemittance as unknown as ReturnType<typeof vi.fn> api.getRemittance as unknown as ReturnType<typeof vi.fn>
).mockReturnValue(new Promise(() => {})); ).mockReturnValue(new Promise(() => {}));
// SP21 Phase 5 Task 5.5: reset window.location to a clean
// /reconciliation URL so `useRemitDrawerUrlState`'s mount-time
// read doesn't see a `?remit=REM-DRILL` from the previous
// test's `open()` history push. Without this, a test that
// asserts the drawer isn't open still finds the drawer
// mounted on initial render (driven by history state, not by
// a click).
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
.happyDOM.setURL("http://localhost/reconciliation");
// SP21 Phase 5 Task 5.5: the previous test may have left a Radix
// portal in document.body. Clear them before each test so a
// "drawer not in DOM" assertion isn't polluted by a stale portal.
document.body
.querySelectorAll('[role="dialog"]')
.forEach((node) => node.remove());
});
afterEach(() => {
// SP21 Phase 5 Task 5.5: the RemitDrawer portals into
// document.body. Each test renders + unmounts, but happy-dom's
// document.body persists across tests within the file, so we
// explicitly remove any lingering Radix portals here. Without
// this, a test that asserts `drawer not in DOM` would still
// find a leftover portal from the previous test.
document.body
.querySelectorAll('[role="dialog"]')
.forEach((node) => node.remove());
}); });
it("SP21 Task 4.6: deep-link ?remit=ID opens the RemitDrawer on mount", async () => { it("SP21 Task 4.6: deep-link ?remit=ID opens the RemitDrawer on mount", async () => {
@@ -138,6 +212,7 @@ describe("ReconciliationPage", () => {
const { unmount } = renderIntoContainer( const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage), React.createElement(ReconciliationPage),
{ withRouter: true },
); );
// Wait for the loaded two-column view (the "Pair them." headline // Wait for the loaded two-column view (the "Pair them." headline
@@ -188,7 +263,8 @@ describe("ReconciliationPage", () => {
}); });
const { unmount } = renderIntoContainer( const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage) React.createElement(ReconciliationPage),
{ withRouter: true },
); );
await waitForText("CLM-1"); await waitForText("CLM-1");
@@ -206,7 +282,8 @@ describe("ReconciliationPage", () => {
).mockResolvedValue({ claims: [], remittances: [] }); ).mockResolvedValue({ claims: [], remittances: [] });
const { unmount } = renderIntoContainer( const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage) React.createElement(ReconciliationPage),
{ withRouter: true },
); );
await waitForText("nothing pending"); await waitForText("nothing pending");
@@ -216,4 +293,213 @@ describe("ReconciliationPage", () => {
expect(document.body.textContent).not.toContain("Unmatched claims ("); expect(document.body.textContent).not.toContain("Unmatched claims (");
unmount(); unmount();
}); });
it("SP21 Task 5.5: clicking the claim card body drills to /claims?claim=ID", async () => {
// Task 5.5 splits the gesture: clicking the card body drills into
// the ClaimDrawer via /claims?claim=ID, while a separate
// "Select for match" button toggles the row's selection. The
// card is no longer a single <button>; the body region is a
// button with its own onClick that calls navigate().
(
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
claims: [
{
id: "CLM-DRILL",
patientName: "Patient Drill",
billedAmount: 250,
providerNpi: "1234567890",
serviceDate: "2026-06-19",
payerId: "P1",
state: "submitted",
},
],
remittances: [],
});
const { unmount, tracker } = renderIntoContainer(
React.createElement(ReconciliationPage),
{ withRouter: true, initialEntries: ["/reconciliation"] },
);
await waitForText("CLM-DRILL");
// Click the claim card body (the body region is the inner <button>
// that contains the id). It's not the "Select for match" toggle
// — the body region has aria-label="View claim CLM-DRILL in detail".
const bodyBtn = document.body.querySelector(
'button[aria-label="View claim CLM-DRILL in detail"]',
) as HTMLButtonElement | null;
expect(bodyBtn).not.toBeNull();
await act(async () => {
bodyBtn!.click();
await Promise.resolve();
});
expect(tracker?.pathname).toBe("/claims");
expect(tracker?.search).toBe("?claim=CLM-DRILL");
unmount();
});
it("SP21 Task 5.5: clicking the 'Select for match' button toggles selection without drilling", async () => {
// The select button calls e.stopPropagation() before setSelectedClaim,
// so the row body click handler doesn't fire. Verify the URL stays
// on /reconciliation and the "Match selected" button becomes enabled
// (or at least that selection state advances).
(
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
claims: [
{
id: "CLM-SEL",
patientName: "Patient Select",
billedAmount: 175,
providerNpi: "1234567890",
serviceDate: "2026-06-19",
payerId: "P1",
state: "submitted",
},
],
remittances: [
{
id: "REM-SEL",
payerClaimControlNumber: "PCN-SEL",
status: "received",
paidAmount: 175,
adjustmentAmount: 0,
receivedDate: "2026-06-19",
isReversal: false,
totalCharge: 175,
serviceDate: "2026-06-19",
batchId: "b1",
},
],
});
const { unmount, tracker } = renderIntoContainer(
React.createElement(ReconciliationPage),
{ withRouter: true },
);
await waitForText("CLM-SEL");
// Click the claim "Select for match" toggle. It has
// data-testid="recon-claim-select-CLM-SEL" and should NOT drill.
const selectBtn = document.body.querySelector(
'[data-testid="recon-claim-select-CLM-SEL"]',
) as HTMLButtonElement | null;
expect(selectBtn).not.toBeNull();
expect(selectBtn!.textContent).toContain("Select for match");
await act(async () => {
selectBtn!.click();
await Promise.resolve();
});
// URL stays on /reconciliation — selection didn't drill.
expect(tracker?.pathname).toBe("/reconciliation");
// The toggle button now reads "Unselect" (clicked once).
const updatedBtn = document.body.querySelector(
'[data-testid="recon-claim-select-CLM-SEL"]',
) as HTMLButtonElement | null;
expect(updatedBtn?.textContent).toContain("Unselect");
unmount();
});
it("SP21 Task 5.5: clicking the remit card body opens the RemitDrawer", async () => {
// Task 5.5 also restructures the remits column — the body is no
// longer role="button" with a select-onClick handler. The body
// drills into the RemitDrawer via open(r.id); selection moves to
// a dedicated "Select for match" toggle.
(
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
claims: [],
remittances: [
{
id: "REM-DRILL",
payerClaimControlNumber: "PCN-DRILL",
status: "received",
paidAmount: 100,
adjustmentAmount: 0,
receivedDate: "2026-06-19",
isReversal: false,
totalCharge: 100,
serviceDate: "2026-06-19",
batchId: "b1",
},
],
});
const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage),
{ withRouter: true },
);
await waitForText("PCN-DRILL");
// Click the remit card body (inner <button> with aria-label).
const bodyBtn = document.body.querySelector(
'button[aria-label="View remittance PCN-DRILL in detail"]',
) as HTMLButtonElement | null;
expect(bodyBtn).not.toBeNull();
await act(async () => {
bodyBtn!.click();
await Promise.resolve();
});
// RemitDrawer should be mounted in document.body.
expect(
document.body.querySelector('[data-testid="remit-drawer"]'),
).not.toBeNull();
unmount();
});
it("SP21 Task 5.5: clicking the remit 'Select for match' button toggles selection without opening drawer", async () => {
// The remit select toggle has e.stopPropagation() — clicking it
// shouldn't open the RemitDrawer (which used to be a side effect
// when the outer card was role="button" with onClick=select).
(
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
claims: [],
remittances: [
{
id: "REM-SEL",
payerClaimControlNumber: "PCN-SEL2",
status: "received",
paidAmount: 100,
adjustmentAmount: 0,
receivedDate: "2026-06-19",
isReversal: false,
totalCharge: 100,
serviceDate: "2026-06-19",
batchId: "b1",
},
],
});
const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage),
{ withRouter: true },
);
await waitForText("PCN-SEL2");
const selectBtn = document.body.querySelector(
'[data-testid="recon-remit-select-REM-SEL"]',
) as HTMLButtonElement | null;
expect(selectBtn).not.toBeNull();
expect(selectBtn!.textContent).toContain("Select for match");
await act(async () => {
selectBtn!.click();
await Promise.resolve();
});
// No drawer.
expect(
document.body.querySelector('[data-testid="remit-drawer"]'),
).toBeNull();
// Toggle flipped.
const updated = document.body.querySelector(
'[data-testid="recon-remit-select-REM-SEL"]',
) as HTMLButtonElement | null;
expect(updated?.textContent).toContain("Unselect");
unmount();
});
}); });
+142 -65
View File
@@ -5,6 +5,7 @@ import {
GitMerge, GitMerge,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { useReconciliation } from "@/hooks/useReconciliation"; import { useReconciliation } from "@/hooks/useReconciliation";
import { ApiError } from "@/lib/api"; import { ApiError } from "@/lib/api";
@@ -12,7 +13,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { ErrorState } from "@/components/ui/error-state"; import { ErrorState } from "@/components/ui/error-state";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { RemitDrawer } from "@/components/RemitDrawer"; import { RemitDrawer } from "@/components/RemitDrawer";
import { DrillableCell } from "@/components/drill/DrillableCell";
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState"; import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -34,6 +34,11 @@ export function ReconciliationPage() {
// links land with the drawer open. Selection state stays local // links land with the drawer open. Selection state stays local
// — the drill is a separate gesture from the match selection. // — the drill is a separate gesture from the match selection.
const { remitId, open, close } = useRemitDrawerUrlState(); const { remitId, open, close } = useRemitDrawerUrlState();
// SP21 Phase 5 Task 5.5: claim drill from the card body navigates
// to /claims?claim=ID so the ClaimDrawer mounts in-place on the
// /claims route. Selection for matching stays local — the drill
// gesture is separate from the match selection gesture.
const navigate = useNavigate();
const [selectedClaim, setSelectedClaim] = useState<string | null>(null); const [selectedClaim, setSelectedClaim] = useState<string | null>(null);
const [selectedRemit, setSelectedRemit] = useState<string | null>(null); const [selectedRemit, setSelectedRemit] = useState<string | null>(null);
@@ -658,11 +663,17 @@ export function ReconciliationPage() {
{claims.map((c) => { {claims.map((c) => {
const active = selectedClaim === c.id; const active = selectedClaim === c.id;
return ( return (
<button // SP21 Phase 5 Task 5.5: card body drills into the
// ClaimDrawer; selection for matching lives on a
// dedicated button inside the card. The card is no
// longer a single <button> — it's a <div> with a
// clickable body region (cursor-pointer) and a
// separate "Select for match" toggle. We split the
// gestures so the operator can review the claim
// without accidentally queuing it for pairing.
<div
key={c.id} key={c.id}
type="button" data-testid="recon-claim-card"
onClick={() => setSelectedClaim(c.id)}
aria-pressed={active}
className={cn( className={cn(
"w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors", "w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors",
active active
@@ -670,27 +681,71 @@ export function ReconciliationPage() {
: "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]" : "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]"
)} )}
> >
<div {/* Body — drillable into /claims?claim=ID. We
className="display mono text-[13.5px]" use a button (not an <a>) because we're
style={{ color: "hsl(var(--surface-ink))" }} staying on the SPA; the ClaimDrawer mounts
when Claims.tsx reads ?claim= off the URL.
e.stopPropagation in DrillableCell prevents
the click from reaching the select button
below. */}
<button
type="button"
onClick={() =>
navigate(
`/claims?claim=${encodeURIComponent(c.id)}`,
)
}
aria-label={`View claim ${c.id} in detail`}
className="w-full text-left cursor-pointer rounded-sm p-0 m-0 border-0 bg-transparent"
> >
{c.id} <div
</div> className="display mono text-[13.5px]"
<div style={{ color: "hsl(var(--surface-ink))" }}
className="text-[12.5px] mt-0.5" >
style={{ color: "hsl(var(--surface-ink-2))" }} {c.id}
</div>
<div
className="text-[12.5px] mt-0.5"
style={{ color: "hsl(var(--surface-ink-2))" }}
>
{c.patientName}
</div>
<div
className="mono text-[11px] mt-1"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
{c.serviceDate ?? "—"} · $
{c.billedAmount.toFixed(2)} · NPI{" "}
{c.providerNpi ?? "—"}
</div>
</button>
{/* Selection toggle — separate gesture so a drill
doesn't auto-select the row for matching. */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedClaim(active ? null : c.id);
}}
aria-pressed={active}
data-testid={`recon-claim-select-${c.id}`}
className={cn(
"mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm text-[10.5px] mono uppercase tracking-[0.16em] font-semibold transition-colors",
active
? "bg-[hsl(212_100%_45%)] text-white"
: "bg-[hsl(36_22%_92%)] text-[hsl(var(--surface-ink-2))] hover:bg-[hsl(212_85%_92%)]",
)}
> >
{c.patientName} {active ? (
</div> <>
<div <X className="h-3 w-3" strokeWidth={2.5} />
className="mono text-[11px] mt-1" Unselect
style={{ color: "hsl(var(--surface-ink-3))" }} </>
> ) : (
{c.serviceDate ?? "—"} · $ <>Select for match</>
{c.billedAmount.toFixed(2)} · NPI{" "} )}
{c.providerNpi ?? "—"} </button>
</div> </div>
</button>
); );
})} })}
</PairColumn> </PairColumn>
@@ -743,18 +798,13 @@ export function ReconciliationPage() {
{remittances.map((r) => { {remittances.map((r) => {
const active = selectedRemit === r.id; const active = selectedRemit === r.id;
return ( return (
// SP21 Phase 5 Task 5.5: card body drills into the
// RemitDrawer; selection for matching lives on a
// dedicated button. Same gesture-split as the
// claims column — body = drill, button = select.
<div <div
key={r.id} key={r.id}
role="button" data-testid="recon-remit-card"
tabIndex={0}
aria-pressed={active}
onClick={() => setSelectedRemit(r.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedRemit(r.id);
}
}}
className={cn( className={cn(
"w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors", "w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors",
active active
@@ -762,41 +812,68 @@ export function ReconciliationPage() {
: "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]" : "border-[hsl(30_14%_14%/_0.14)] bg-[hsl(36_22%_98%)] hover:bg-[hsl(36_22%_94%)] hover:border-[hsl(30_14%_14%/_0.25)]"
)} )}
> >
<div {/* Body — drillable. The whole body is one
className="mono text-[13.5px] flex items-center gap-2" <button>; no nested DrillableCell (a <button>
style={{ color: "hsl(var(--surface-ink))" }} inside a <button> is invalid HTML and warns
in the console). The PCN still gets the
chevron affordance via the `drillable` class
added directly to the inner span. */}
<button
type="button"
onClick={() => open(r.id)}
aria-label={`View remittance ${r.payerClaimControlNumber} in detail`}
className="w-full text-left cursor-pointer rounded-sm p-0 m-0 border-0 bg-transparent"
> >
{/* DrillableCell wraps the PCN text — clicking <div
the text drills into the RemitDrawer; the className="mono text-[13.5px] flex items-center gap-2"
surrounding div onClick (which selects the style={{ color: "hsl(var(--surface-ink))" }}
row for the match action) is suppressed
because DrillableCell calls
e.stopPropagation() in its onClick. The
DrillableCell is its own <button>, so the
outer row had to move off <button> to
avoid invalid nested-button HTML. */}
<DrillableCell
onClick={() => open(r.id)}
ariaLabel={`View remittance ${r.payerClaimControlNumber}`}
> >
<span className="display">{r.payerClaimControlNumber}</span> <span className="display drillable inline-flex items-center gap-0 rounded-sm">
</DrillableCell> {r.payerClaimControlNumber}
{r.isReversal ? (
<span
className="text-[10px] uppercase tracking-[0.18em] mono font-semibold"
style={{ color: "hsl(36 92% 30%)" }}
>
Reversal
</span> </span>
) : null} {r.isReversal ? (
</div> <span
<div className="text-[10px] uppercase tracking-[0.18em] mono font-semibold"
className="mono text-[11px] mt-1" style={{ color: "hsl(36 92% 30%)" }}
style={{ color: "hsl(var(--surface-ink-3))" }} >
Reversal
</span>
) : null}
</div>
<div
className="mono text-[11px] mt-1"
style={{ color: "hsl(var(--surface-ink-3))" }}
>
Status {r.status} · ${r.paidAmount.toFixed(2)} paid · $
{r.adjustmentAmount.toFixed(2)} adj
</div>
</button>
{/* Selection toggle — separate gesture from
drill. Same pattern as the claims column. */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setSelectedRemit(active ? null : r.id);
}}
aria-pressed={active}
data-testid={`recon-remit-select-${r.id}`}
className={cn(
"mt-2 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm text-[10.5px] mono uppercase tracking-[0.16em] font-semibold transition-colors",
active
? "bg-[hsl(36_92%_50%)] text-[hsl(30_14%_14%)]"
: "bg-[hsl(36_22%_92%)] text-[hsl(var(--surface-ink-2))] hover:bg-[hsl(36_82%_88%)]",
)}
> >
Status {r.status} · ${r.paidAmount.toFixed(2)} paid · $ {active ? (
{r.adjustmentAmount.toFixed(2)} adj <>
</div> <X className="h-3 w-3" strokeWidth={2.5} />
Unselect
</>
) : (
<>Select for match</>
)}
</button>
</div> </div>
); );
})} })}