feat(reconciliation): candidate rows drill to RemitDrawer or ClaimDrawer

This commit is contained in:
Tyler
2026-06-21 17:15:09 -06:00
parent 12c2913ba1
commit 2eb61f16ff
2 changed files with 118 additions and 5 deletions
+68
View File
@@ -19,6 +19,7 @@ vi.mock("@/lib/api", () => ({
listUnmatched: vi.fn(),
matchRemit: vi.fn(),
unmatchClaim: vi.fn(),
getRemittance: vi.fn(),
},
ApiError: class ApiError extends Error {
constructor(public status: number, message: string) {
@@ -86,6 +87,73 @@ async function waitForText(
describe("ReconciliationPage", () => {
beforeEach(() => {
vi.clearAllMocks();
// Default for the per-remit detail fetch — the drawer fetches
// this whenever `?remit=` is in the URL. Return a never-resolving
// promise so the drawer stays in the loading state; the smoke
// test only asserts the drawer mounts.
(
api.getRemittance as unknown as ReturnType<typeof vi.fn>
).mockReturnValue(new Promise(() => {}));
});
it("SP21 Task 4.6: deep-link ?remit=ID opens the RemitDrawer on mount", async () => {
// Non-empty unmatched payload so the page renders the two-column
// matching surface (the "Pair them." branch). The pre-existing
// empty-state branch has a flaky happy-dom/race that's unrelated
// to Phase 4 — using non-empty data sidesteps that bug and still
// proves the deep-link → drawer mount works. We pre-set
// `window.location` (which `useRemitDrawerUrlState` reads on
// mount) so `?remit=REM-7` resolves to a truthy `remitId`.
(
api.listUnmatched as unknown as ReturnType<typeof vi.fn>
).mockResolvedValue({
claims: [
{
id: "CLM-1",
patientName: "Patient A",
billedAmount: 100,
providerNpi: "1234567890",
serviceDate: "2026-06-01",
payerId: "P1",
state: "submitted",
},
],
remittances: [
{
id: "REM-7",
payerClaimControlNumber: "PCN-A",
status: "received",
paidAmount: 100,
adjustmentAmount: 0,
receivedDate: "2026-06-01",
isReversal: false,
totalCharge: 100,
serviceDate: "2026-06-01",
batchId: "b1",
},
],
});
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
.happyDOM.setURL("http://localhost/reconciliation?remit=REM-7");
const { unmount } = renderIntoContainer(
React.createElement(ReconciliationPage),
);
// Wait for the loaded two-column view (the "Pair them." headline
// is the clearest signal that listUnmatched has resolved and the
// page is past the loading + empty branches), then assert the
// drawer is mounted.
await waitForText("Pair them.");
expect(
document.body.querySelector('[data-testid="remit-drawer"]'),
).not.toBeNull();
unmount();
// Reset URL so the next test sees a clean /reconciliation URL.
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
.happyDOM.setURL("http://localhost/reconciliation");
});
it("renders both columns with unmatched rows when api returns data", async () => {
+50 -5
View File
@@ -11,6 +11,9 @@ import { ApiError } from "@/lib/api";
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";
/**
@@ -26,6 +29,11 @@ import { cn } from "@/lib/utils";
*/
export function ReconciliationPage() {
const { unmatched, match } = useReconciliation();
// SP21 Phase 4 Task 4.6: drill into the RemitDrawer from the
// remits column. The hook reads `?remit=` from the URL so deep
// 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();
const [selectedClaim, setSelectedClaim] = useState<string | null>(null);
const [selectedRemit, setSelectedRemit] = useState<string | null>(null);
@@ -735,11 +743,18 @@ export function ReconciliationPage() {
{remittances.map((r) => {
const active = selectedRemit === r.id;
return (
<button
<div
key={r.id}
type="button"
onClick={() => setSelectedRemit(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);
}
}}
className={cn(
"w-full text-left p-3.5 min-h-[44px] rounded-md border transition-colors",
active
@@ -751,7 +766,21 @@ export function ReconciliationPage() {
className="mono text-[13.5px] flex items-center gap-2"
style={{ color: "hsl(var(--surface-ink))" }}
>
<span className="display">{r.payerClaimControlNumber}</span>
{/* 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}`}
>
<span className="display">{r.payerClaimControlNumber}</span>
</DrillableCell>
{r.isReversal ? (
<span
className="text-[10px] uppercase tracking-[0.18em] mono font-semibold"
@@ -768,7 +797,7 @@ export function ReconciliationPage() {
Status {r.status} · ${r.paidAmount.toFixed(2)} paid · $
{r.adjustmentAmount.toFixed(2)} adj
</div>
</button>
</div>
);
})}
</PairColumn>
@@ -881,6 +910,22 @@ export function ReconciliationPage() {
</span>
</div>
</div>
{/* SP21 Phase 4 Task 4.6: RemitDrawer mount. The remits column
drills into the parent remit via the PCN text (DrillableCell
+ open). The drawer portals into document.body, so the
surrounding paper plane stays put while the drawer is open.
`remits` is empty (we don't keep a list of all remits on
this page), so j/k is a no-op while the drawer is open. */}
<RemitDrawer
remitId={remitId}
remits={[]}
onClose={close}
onNavigate={open}
onToggleHelp={() => {
// Reconciliation has no cheatsheet; `?` is a no-op here.
}}
/>
</div>
);
}