feat(reconciliation): candidate rows drill to RemitDrawer or ClaimDrawer
This commit is contained in:
@@ -19,6 +19,7 @@ vi.mock("@/lib/api", () => ({
|
|||||||
listUnmatched: vi.fn(),
|
listUnmatched: vi.fn(),
|
||||||
matchRemit: vi.fn(),
|
matchRemit: vi.fn(),
|
||||||
unmatchClaim: vi.fn(),
|
unmatchClaim: vi.fn(),
|
||||||
|
getRemittance: vi.fn(),
|
||||||
},
|
},
|
||||||
ApiError: class ApiError extends Error {
|
ApiError: class ApiError extends Error {
|
||||||
constructor(public status: number, message: string) {
|
constructor(public status: number, message: string) {
|
||||||
@@ -86,6 +87,73 @@ async function waitForText(
|
|||||||
describe("ReconciliationPage", () => {
|
describe("ReconciliationPage", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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 () => {
|
it("renders both columns with unmatched rows when api returns data", async () => {
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { ApiError } from "@/lib/api";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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 { DrillableCell } from "@/components/drill/DrillableCell";
|
||||||
|
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,6 +29,11 @@ import { cn } from "@/lib/utils";
|
|||||||
*/
|
*/
|
||||||
export function ReconciliationPage() {
|
export function ReconciliationPage() {
|
||||||
const { unmatched, match } = useReconciliation();
|
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 [selectedClaim, setSelectedClaim] = useState<string | null>(null);
|
||||||
const [selectedRemit, setSelectedRemit] = useState<string | null>(null);
|
const [selectedRemit, setSelectedRemit] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -735,11 +743,18 @@ export function ReconciliationPage() {
|
|||||||
{remittances.map((r) => {
|
{remittances.map((r) => {
|
||||||
const active = selectedRemit === r.id;
|
const active = selectedRemit === r.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={r.id}
|
key={r.id}
|
||||||
type="button"
|
role="button"
|
||||||
onClick={() => setSelectedRemit(r.id)}
|
tabIndex={0}
|
||||||
aria-pressed={active}
|
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
|
||||||
@@ -751,7 +766,21 @@ export function ReconciliationPage() {
|
|||||||
className="mono text-[13.5px] flex items-center gap-2"
|
className="mono text-[13.5px] flex items-center gap-2"
|
||||||
style={{ color: "hsl(var(--surface-ink))" }}
|
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 ? (
|
{r.isReversal ? (
|
||||||
<span
|
<span
|
||||||
className="text-[10px] uppercase tracking-[0.18em] mono font-semibold"
|
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 · $
|
Status {r.status} · ${r.paidAmount.toFixed(2)} paid · $
|
||||||
{r.adjustmentAmount.toFixed(2)} adj
|
{r.adjustmentAmount.toFixed(2)} adj
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</PairColumn>
|
</PairColumn>
|
||||||
@@ -881,6 +910,22 @@ export function ReconciliationPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user