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 =
true;
import React, { act } from "react";
import React, { act, useEffect } from "react";
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 { ReconciliationPage } from "./Reconciliation";
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
* `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
* tree). Returns the rendered container so tests can assert against
* 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;
unmount: () => void;
tracker?: { pathname: string; search: string };
} {
const container = document.createElement("div");
document.body.appendChild(container);
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);
act(() => {
root.render(
React.createElement(
QueryClientProvider,
{ client: qc },
element
inner
)
);
});
@@ -61,6 +107,7 @@ function renderIntoContainer(element: React.ReactElement): {
act(() => root.unmount());
container.remove();
},
tracker,
};
}
@@ -94,6 +141,33 @@ describe("ReconciliationPage", () => {
(
api.getRemittance as unknown as ReturnType<typeof vi.fn>
).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 () => {
@@ -138,6 +212,7 @@ describe("ReconciliationPage", () => {
const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage),
{ withRouter: true },
);
// Wait for the loaded two-column view (the "Pair them." headline
@@ -188,7 +263,8 @@ describe("ReconciliationPage", () => {
});
const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage)
React.createElement(ReconciliationPage),
{ withRouter: true },
);
await waitForText("CLM-1");
@@ -206,7 +282,8 @@ describe("ReconciliationPage", () => {
).mockResolvedValue({ claims: [], remittances: [] });
const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage)
React.createElement(ReconciliationPage),
{ withRouter: true },
);
await waitForText("nothing pending");
@@ -216,4 +293,213 @@ describe("ReconciliationPage", () => {
expect(document.body.textContent).not.toContain("Unmatched claims (");
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,
X,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { useReconciliation } from "@/hooks/useReconciliation";
import { ApiError } from "@/lib/api";
@@ -12,7 +13,6 @@ import { Skeleton } from "@/components/ui/skeleton";
import { ErrorState } from "@/components/ui/error-state";
import { Button } from "@/components/ui/button";
import { RemitDrawer } from "@/components/RemitDrawer";
import { DrillableCell } from "@/components/drill/DrillableCell";
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import { cn } from "@/lib/utils";
@@ -34,6 +34,11 @@ export function ReconciliationPage() {
// links land with the drawer open. Selection state stays local
// — the drill is a separate gesture from the match selection.
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 [selectedRemit, setSelectedRemit] = useState<string | null>(null);
@@ -658,11 +663,17 @@ export function ReconciliationPage() {
{claims.map((c) => {
const active = selectedClaim === c.id;
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}
type="button"
onClick={() => setSelectedClaim(c.id)}
aria-pressed={active}
data-testid="recon-claim-card"
className={cn(
"w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors",
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)]"
)}
>
<div
className="display mono text-[13.5px]"
style={{ color: "hsl(var(--surface-ink))" }}
{/* Body — drillable into /claims?claim=ID. We
use a button (not an <a>) because we're
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="text-[12.5px] mt-0.5"
style={{ color: "hsl(var(--surface-ink-2))" }}
<div
className="display mono text-[13.5px]"
style={{ color: "hsl(var(--surface-ink))" }}
>
{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}
</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>
{active ? (
<>
<X className="h-3 w-3" strokeWidth={2.5} />
Unselect
</>
) : (
<>Select for match</>
)}
</button>
</div>
);
})}
</PairColumn>
@@ -743,18 +798,13 @@ export function ReconciliationPage() {
{remittances.map((r) => {
const active = selectedRemit === r.id;
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
key={r.id}
role="button"
tabIndex={0}
aria-pressed={active}
onClick={() => setSelectedRemit(r.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setSelectedRemit(r.id);
}
}}
data-testid="recon-remit-card"
className={cn(
"w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors",
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)]"
)}
>
<div
className="mono text-[13.5px] flex items-center gap-2"
style={{ color: "hsl(var(--surface-ink))" }}
{/* Body — drillable. The whole body is one
<button>; no nested DrillableCell (a <button>
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
the text drills into the RemitDrawer; the
surrounding div onClick (which selects the
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}`}
<div
className="mono text-[13.5px] flex items-center gap-2"
style={{ color: "hsl(var(--surface-ink))" }}
>
<span className="display">{r.payerClaimControlNumber}</span>
</DrillableCell>
{r.isReversal ? (
<span
className="text-[10px] uppercase tracking-[0.18em] mono font-semibold"
style={{ color: "hsl(36 92% 30%)" }}
>
Reversal
<span className="display drillable inline-flex items-center gap-0 rounded-sm">
{r.payerClaimControlNumber}
</span>
) : null}
</div>
<div
className="mono text-[11px] mt-1"
style={{ color: "hsl(var(--surface-ink-3))" }}
{r.isReversal ? (
<span
className="text-[10px] uppercase tracking-[0.18em] mono font-semibold"
style={{ color: "hsl(36 92% 30%)" }}
>
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 · $
{r.adjustmentAmount.toFixed(2)} adj
</div>
{active ? (
<>
<X className="h-3 w-3" strokeWidth={2.5} />
Unselect
</>
) : (
<>Select for match</>
)}
</button>
</div>
);
})}