feat(reconciliation): card body drillable, select button split
This commit is contained in:
@@ -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
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user