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 =
|
(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
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
Reference in New Issue
Block a user