feat(frontend): ClaimDrawer root orchestrator with skeleton/error/data states
This commit is contained in:
@@ -0,0 +1,465 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
// ClaimDrawer wires `useClaimDetail` (TanStack Query) + a window-level
|
||||||
|
// keyboard listener. Both paths need an act-aware environment or React
|
||||||
|
// logs noisy warnings. Mirror the convention from useClaimDetail.test.ts.
|
||||||
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|
||||||
|
import React, { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { ClaimDrawer } from "./ClaimDrawer";
|
||||||
|
import { api, ApiError } from "@/lib/api";
|
||||||
|
import type { ClaimDetail } from "@/types";
|
||||||
|
|
||||||
|
// Module-level mock — vitest hoists `vi.mock` above imports. We mock both
|
||||||
|
// `api.getClaimDetail` (the only method the hook calls) AND `ApiError`
|
||||||
|
// (the class used in the hook's retry predicate + here for instanceof
|
||||||
|
// checks in the orchestrator's error-kind branch). Same pattern as
|
||||||
|
// useClaimDetail.test.ts.
|
||||||
|
vi.mock("@/lib/api", () => {
|
||||||
|
class ApiError extends Error {
|
||||||
|
constructor(public status: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
api: {
|
||||||
|
getClaimDetail: vi.fn(),
|
||||||
|
},
|
||||||
|
ApiError,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal valid ClaimDetail fixture — every required key present so the
|
||||||
|
* component typechecks. Mirrors the SAMPLE_DETAIL in
|
||||||
|
* useClaimDetail.test.ts; same shape the api.getClaimDetail test uses.
|
||||||
|
*/
|
||||||
|
const SAMPLE_DETAIL: ClaimDetail = {
|
||||||
|
id: "CLM-1",
|
||||||
|
batchId: "B-1",
|
||||||
|
state: "submitted",
|
||||||
|
stateLabel: "Submitted",
|
||||||
|
billedAmount: 123.45,
|
||||||
|
patientName: "Jane Doe",
|
||||||
|
providerNpi: "1234567890",
|
||||||
|
providerName: "Acme Clinic",
|
||||||
|
payerName: "Medicaid",
|
||||||
|
payerId: "MEDCO",
|
||||||
|
submissionDate: "2026-01-15T00:00:00Z",
|
||||||
|
serviceDateFrom: "2026-01-10",
|
||||||
|
serviceDateTo: "2026-01-10",
|
||||||
|
parsedAt: "2026-01-15T00:00:01Z",
|
||||||
|
diagnoses: [{ code: "E11.9", qualifier: "ABK" }],
|
||||||
|
serviceLines: [
|
||||||
|
{
|
||||||
|
lineNumber: 1,
|
||||||
|
procedureQualifier: "HC",
|
||||||
|
procedureCode: "99213",
|
||||||
|
modifiers: [],
|
||||||
|
charge: 100.0,
|
||||||
|
units: 1,
|
||||||
|
unitType: "UN",
|
||||||
|
serviceDate: "2026-01-10",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
parties: {
|
||||||
|
billingProvider: {
|
||||||
|
name: "Acme Clinic",
|
||||||
|
npi: "1234567890",
|
||||||
|
taxId: "12-3456789",
|
||||||
|
address: {
|
||||||
|
line1: "123 Main St",
|
||||||
|
line2: null,
|
||||||
|
city: "Denver",
|
||||||
|
state: "CO",
|
||||||
|
zip: "80202",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subscriber: {
|
||||||
|
firstName: "Jane",
|
||||||
|
lastName: "Doe",
|
||||||
|
memberId: "M-1",
|
||||||
|
dob: null,
|
||||||
|
gender: "F",
|
||||||
|
},
|
||||||
|
payer: { name: "Medicaid", id: "MEDCO" },
|
||||||
|
},
|
||||||
|
validation: { passed: true, errors: [], warnings: [] },
|
||||||
|
rawSegments: [["ISA*00*"]],
|
||||||
|
matchedRemittance: null,
|
||||||
|
stateHistory: [
|
||||||
|
{
|
||||||
|
kind: "claim_submitted",
|
||||||
|
ts: "2026-01-15T00:00:00Z",
|
||||||
|
batchId: "B-1",
|
||||||
|
remittanceId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
type RenderArgs = {
|
||||||
|
claimId: string | null;
|
||||||
|
claims?: { id: string }[];
|
||||||
|
onClose?: () => void;
|
||||||
|
onNavigate?: (id: string) => void;
|
||||||
|
onToggleHelp?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test harness for `ClaimDrawer`. Renders into a real DOM container
|
||||||
|
* wrapped in `QueryClientProvider` (the hook uses TanStack Query) and
|
||||||
|
* exposes a `fireKey` helper that dispatches keydown events on `window`
|
||||||
|
* — `useDrawerKeyboard` listens there, not on a specific element.
|
||||||
|
*
|
||||||
|
* `detail` controls how the mocked `api.getClaimDetail` resolves:
|
||||||
|
* - ClaimDetail → resolves with that object
|
||||||
|
* - Error → rejects with that error (use a real `ApiError` for
|
||||||
|
* 404 or a plain `Error` for network failure)
|
||||||
|
* - null → never resolves (for the loading state)
|
||||||
|
*/
|
||||||
|
function renderDrawer(
|
||||||
|
props: RenderArgs,
|
||||||
|
detail: ClaimDetail | Error | null = SAMPLE_DETAIL
|
||||||
|
): {
|
||||||
|
container: HTMLDivElement;
|
||||||
|
unmount: () => void;
|
||||||
|
fireKey: (key: string) => void;
|
||||||
|
} {
|
||||||
|
const onClose = props.onClose ?? vi.fn<() => void>();
|
||||||
|
const onNavigate = props.onNavigate ?? vi.fn<(id: string) => void>();
|
||||||
|
const onToggleHelp = props.onToggleHelp ?? vi.fn<() => void>();
|
||||||
|
const claims = props.claims ?? [{ id: "CLM-1" }];
|
||||||
|
|
||||||
|
const mock = api.getClaimDetail as unknown as ReturnType<typeof vi.fn>;
|
||||||
|
if (detail === null) {
|
||||||
|
// Never-resolving promise — leaves the query in the loading state.
|
||||||
|
mock.mockReturnValue(new Promise(() => {}));
|
||||||
|
} else if (detail instanceof Error) {
|
||||||
|
mock.mockRejectedValue(detail);
|
||||||
|
} else {
|
||||||
|
mock.mockResolvedValue(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
// The hook's retry predicate still retries non-404 errors up
|
||||||
|
// to 3 times. Default retryDelay is exponential backoff
|
||||||
|
// (1s + 2s + 4s ≈ 7s) which is way too slow for unit tests.
|
||||||
|
// We override it to fire retries immediately.
|
||||||
|
retryDelay: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const root: Root = createRoot(container);
|
||||||
|
act(() => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(
|
||||||
|
QueryClientProvider,
|
||||||
|
{ client: qc },
|
||||||
|
React.createElement(ClaimDrawer, {
|
||||||
|
claimId: props.claimId,
|
||||||
|
claims,
|
||||||
|
onClose,
|
||||||
|
onNavigate,
|
||||||
|
onToggleHelp,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
unmount: () => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
},
|
||||||
|
fireKey: (key: string) => {
|
||||||
|
// useDrawerKeyboard listens on `window`; dispatch a real
|
||||||
|
// KeyboardEvent so the listener picks it up.
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", { key, bubbles: true }));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive React Query's internal micro-task queue until it stops
|
||||||
|
* transitioning (or we time out). TanStack's queries resolve on
|
||||||
|
* promise micro-tasks, then re-render, then settle — a single
|
||||||
|
* `await new Promise(r => setTimeout(r, 0))` isn't always enough
|
||||||
|
* for the hook's retry loop (3 retries on a plain Error) or for
|
||||||
|
* the success path to propagate through React's commit phase.
|
||||||
|
*
|
||||||
|
* Each tick flushes a micro-task and a macro-task. We exit early
|
||||||
|
* as soon as `body` matches `predicate`, so the common case (a
|
||||||
|
* single successful resolve) returns fast.
|
||||||
|
*
|
||||||
|
* The render harness sets `retryDelay: 0` on the QueryClient so
|
||||||
|
* retries fire back-to-back — no exponential-backoff wait. Default
|
||||||
|
* timeout here is therefore modest (2s).
|
||||||
|
*/
|
||||||
|
async function settle(
|
||||||
|
predicate: (body: HTMLElement) => boolean,
|
||||||
|
timeoutMs = 2000
|
||||||
|
): Promise<HTMLElement> {
|
||||||
|
const body = document.body;
|
||||||
|
const start = Date.now();
|
||||||
|
while (!predicate(body)) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error(
|
||||||
|
`settle: predicate did not hold within ${timeoutMs}ms (body=${body.innerHTML.slice(0, 200)})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ClaimDrawer", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Defensive: clear any pending listeners between tests.
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_nothing_when_claimId_is_null", () => {
|
||||||
|
const { container, unmount } = renderDrawer({ claimId: null });
|
||||||
|
|
||||||
|
// The component must render no DOM at all when closed.
|
||||||
|
expect(container.children.length).toBe(0);
|
||||||
|
// And must NOT hit the network — the hook short-circuits when
|
||||||
|
// claimId is null (per useClaimDetail contract).
|
||||||
|
expect(api.getClaimDetail).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_skeleton_while_loading", async () => {
|
||||||
|
// detail = null → never-resolving promise → permanent loading.
|
||||||
|
const { unmount } = renderDrawer({ claimId: "CLM-1" }, null);
|
||||||
|
|
||||||
|
// Radix's Dialog portals content into document.body, so we query
|
||||||
|
// there rather than the React container we rendered into.
|
||||||
|
const body = await settle((b) =>
|
||||||
|
b.querySelector('[data-testid="claim-drawer-skeleton"]') !== null
|
||||||
|
);
|
||||||
|
expect(body.querySelector('[data-testid="claim-drawer-skeleton"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_error_state_on_404", async () => {
|
||||||
|
const { unmount } = renderDrawer(
|
||||||
|
{ claimId: "ghost" },
|
||||||
|
new ApiError(404, "Claim ghost not found")
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = await settle((b) =>
|
||||||
|
b.querySelector('[data-testid="claim-drawer-error-not_found"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorEl = body.querySelector(
|
||||||
|
'[data-testid="claim-drawer-error-not_found"]'
|
||||||
|
);
|
||||||
|
expect(errorEl).not.toBeNull();
|
||||||
|
|
||||||
|
// Close button is wired up.
|
||||||
|
const closeBtn = body.querySelector(
|
||||||
|
'[data-testid="error-close"]'
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(closeBtn).not.toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_error_state_on_network_failure", async () => {
|
||||||
|
const { unmount } = renderDrawer(
|
||||||
|
{ claimId: "CLM-1" },
|
||||||
|
new Error("network down")
|
||||||
|
);
|
||||||
|
|
||||||
|
// The hook retries non-404 errors up to 3 times; the error UI
|
||||||
|
// only appears after the final retry gives up. The polling
|
||||||
|
// helper waits out the retries.
|
||||||
|
const body = await settle((b) =>
|
||||||
|
b.querySelector('[data-testid="claim-drawer-error-network"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
const errorEl = body.querySelector(
|
||||||
|
'[data-testid="claim-drawer-error-network"]'
|
||||||
|
);
|
||||||
|
expect(errorEl).not.toBeNull();
|
||||||
|
|
||||||
|
// Retry button is shown for network failures.
|
||||||
|
const retryBtn = body.querySelector(
|
||||||
|
'[data-testid="error-retry"]'
|
||||||
|
) as HTMLButtonElement | null;
|
||||||
|
expect(retryBtn).not.toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_renders_all_sections_on_success", async () => {
|
||||||
|
const { unmount } = renderDrawer({ claimId: "CLM-1" });
|
||||||
|
|
||||||
|
// Wait for the header to render (it's the first section in the
|
||||||
|
// success path). All other sections will be present by the time
|
||||||
|
// the header shows up because they render in the same commit.
|
||||||
|
const body = await settle((b) =>
|
||||||
|
b.querySelector('[data-testid="claim-drawer-header"]') !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
// All 7 sections that render unconditionally (matched remit is null
|
||||||
|
// in the fixture). `validation-passed` is the testid for the happy
|
||||||
|
// path (validation.passed=true, no errors/warnings); the
|
||||||
|
// `validation-panel` testid is reserved for the failed state.
|
||||||
|
expect(body.querySelector('[data-testid="claim-drawer-header"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(body.querySelector('[data-testid="validation-passed"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(body.querySelector('[data-testid="service-lines-table"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(body.querySelector('[data-testid="diagnoses-list"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(body.querySelector('[data-testid="parties-grid"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(body.querySelector('[data-testid="raw-segments-panel"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
expect(body.querySelector('[data-testid="state-history-section"]'))
|
||||||
|
.not.toBeNull();
|
||||||
|
// matchedRemittance is null in the fixture → card not rendered.
|
||||||
|
expect(body.querySelector('[data-testid="matched-remit-card"]'))
|
||||||
|
.toBeNull();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_j_key_calls_onNavigate_with_next_claim_id", () => {
|
||||||
|
const onNavigate = vi.fn<(id: string) => void>();
|
||||||
|
const { fireKey, unmount } = renderDrawer({
|
||||||
|
claimId: "B",
|
||||||
|
claims: [{ id: "A" }, { id: "B" }, { id: "C" }],
|
||||||
|
onNavigate,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireKey("j");
|
||||||
|
expect(onNavigate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onNavigate).toHaveBeenCalledWith("C");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_k_key_calls_onNavigate_with_previous_claim_id", () => {
|
||||||
|
const onNavigate = vi.fn<(id: string) => void>();
|
||||||
|
const { fireKey, unmount } = renderDrawer({
|
||||||
|
claimId: "B",
|
||||||
|
claims: [{ id: "A" }, { id: "B" }, { id: "C" }],
|
||||||
|
onNavigate,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireKey("k");
|
||||||
|
expect(onNavigate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onNavigate).toHaveBeenCalledWith("A");
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_j_wraps_around_at_end", () => {
|
||||||
|
const onNavigate = vi.fn<(id: string) => void>();
|
||||||
|
const { fireKey, unmount } = renderDrawer({
|
||||||
|
claimId: "C", // last
|
||||||
|
claims: [{ id: "A" }, { id: "B" }, { id: "C" }],
|
||||||
|
onNavigate,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireKey("j");
|
||||||
|
expect(onNavigate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onNavigate).toHaveBeenCalledWith("A"); // wraps to first
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_k_wraps_around_at_start", () => {
|
||||||
|
const onNavigate = vi.fn<(id: string) => void>();
|
||||||
|
const { fireKey, unmount } = renderDrawer({
|
||||||
|
claimId: "A", // first
|
||||||
|
claims: [{ id: "A" }, { id: "B" }, { id: "C" }],
|
||||||
|
onNavigate,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireKey("k");
|
||||||
|
expect(onNavigate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onNavigate).toHaveBeenCalledWith("C"); // wraps to last
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_escape_key_calls_onClose", () => {
|
||||||
|
const onClose = vi.fn<() => void>();
|
||||||
|
const { fireKey, unmount } = renderDrawer({
|
||||||
|
claimId: "CLM-1",
|
||||||
|
onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireKey("Escape");
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_question_mark_calls_onToggleHelp", () => {
|
||||||
|
const onToggleHelp = vi.fn<() => void>();
|
||||||
|
const { fireKey, unmount } = renderDrawer({
|
||||||
|
claimId: "CLM-1",
|
||||||
|
onToggleHelp,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireKey("?");
|
||||||
|
expect(onToggleHelp).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_keyboard_disabled_when_claimId_is_null", () => {
|
||||||
|
const onNavigate = vi.fn<(id: string) => void>();
|
||||||
|
const { fireKey, unmount } = renderDrawer({
|
||||||
|
claimId: null,
|
||||||
|
claims: [{ id: "A" }, { id: "B" }, { id: "C" }],
|
||||||
|
onNavigate,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireKey("j");
|
||||||
|
fireKey("k");
|
||||||
|
expect(onNavigate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("test_current_claim_not_in_claims_list_does_nothing", () => {
|
||||||
|
const onNavigate = vi.fn<(id: string) => void>();
|
||||||
|
const { fireKey, unmount } = renderDrawer({
|
||||||
|
claimId: "GHOST", // not in claims list
|
||||||
|
claims: [{ id: "A" }, { id: "B" }, { id: "C" }],
|
||||||
|
onNavigate,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireKey("j");
|
||||||
|
fireKey("k");
|
||||||
|
expect(onNavigate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
||||||
|
import { ApiError } from "@/lib/api";
|
||||||
|
import { useClaimDetail } from "@/hooks/useClaimDetail";
|
||||||
|
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
|
||||||
|
import { ClaimDrawerHeader } from "./ClaimDrawerHeader";
|
||||||
|
import { ValidationPanel } from "./ValidationPanel";
|
||||||
|
import { ServiceLinesTable } from "./ServiceLinesTable";
|
||||||
|
import { DiagnosesList } from "./DiagnosesList";
|
||||||
|
import { PartiesGrid } from "./PartiesGrid";
|
||||||
|
import { RawSegmentsPanel } from "./RawSegmentsPanel";
|
||||||
|
import { MatchedRemitCard } from "./MatchedRemitCard";
|
||||||
|
import { StateHistoryTimeline } from "./StateHistoryTimeline";
|
||||||
|
import { ClaimDrawerSkeleton } from "./ClaimDrawerSkeleton";
|
||||||
|
import { ClaimDrawerError } from "./ClaimDrawerError";
|
||||||
|
|
||||||
|
type ClaimDrawerProps = {
|
||||||
|
/**
|
||||||
|
* Currently-open claim id, or `null` when the drawer is closed.
|
||||||
|
* When `null`, the drawer renders nothing (no DOM) and the keyboard
|
||||||
|
* listener is disabled — per spec §3.4.
|
||||||
|
*/
|
||||||
|
claimId: string | null;
|
||||||
|
/**
|
||||||
|
* Full ordered list of claim ids in the current view. Used to derive
|
||||||
|
* j/k navigation targets without an extra round-trip to the server.
|
||||||
|
* The list must contain the current `claimId` for j/k to do
|
||||||
|
* anything; an unknown id is treated defensively as "navigation
|
||||||
|
* disabled" rather than throwing.
|
||||||
|
*/
|
||||||
|
claims: { id: string }[];
|
||||||
|
/** Fired when the user dismisses the drawer (X button, header close, or Escape). */
|
||||||
|
onClose: () => void;
|
||||||
|
/** Fired when the user navigates via j/k — receives the new claim id. */
|
||||||
|
onNavigate: (newId: string) => void;
|
||||||
|
/** Fired when the user presses `?` to toggle the keyboard-help overlay. */
|
||||||
|
onToggleHelp: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root claim-detail drawer (SP4).
|
||||||
|
*
|
||||||
|
* Orchestrates three concerns and delegates the rest:
|
||||||
|
*
|
||||||
|
* 1. **Data** — `useClaimDetail(claimId)` returns `{ data, isLoading,
|
||||||
|
* isError, error, refetch }`. The hook short-circuits when
|
||||||
|
* `claimId === null` (no fetch, no loading state) so a closed
|
||||||
|
* drawer is free.
|
||||||
|
*
|
||||||
|
* 2. **Keyboard** — `useDrawerKeyboard` wires j/k/ArrowDown/ArrowUp/
|
||||||
|
* Escape/`?` on `window`. The listener is only attached when the
|
||||||
|
* drawer is open (`enabled: claimId !== null`).
|
||||||
|
*
|
||||||
|
* 3. **Layout** — the dialog shell is the project's `Dialog` primitive
|
||||||
|
* repositioned to the right edge (side-panel style). It exists
|
||||||
|
* mostly for Radix's focus management + portal; the actual
|
||||||
|
* drawer's structure (header + scrollable sections) is composed
|
||||||
|
* from the leaf section components, each of which owns its own
|
||||||
|
* visual language.
|
||||||
|
*
|
||||||
|
* j/k navigation wraps around at both ends (j from last → first; k
|
||||||
|
* from first → last). `useMemo` keeps the navigation callbacks stable
|
||||||
|
* across renders so the keyboard effect doesn't re-subscribe on every
|
||||||
|
* parent update.
|
||||||
|
*/
|
||||||
|
export function ClaimDrawer({
|
||||||
|
claimId,
|
||||||
|
claims,
|
||||||
|
onClose,
|
||||||
|
onNavigate,
|
||||||
|
onToggleHelp,
|
||||||
|
}: ClaimDrawerProps) {
|
||||||
|
const { data, isLoading, isError, error, refetch } = useClaimDetail(claimId);
|
||||||
|
|
||||||
|
// j/k navigation: derive next/prev IDs based on the current claim's
|
||||||
|
// index in the parent list. Wrap around at both ends. If the current
|
||||||
|
// claim isn't in the list (stale deep link, etc.) the callbacks are
|
||||||
|
// no-ops — defensive against bad input from the parent.
|
||||||
|
const { onNext, onPrev } = useMemo(() => {
|
||||||
|
if (claimId === null) {
|
||||||
|
return { onNext: () => {}, onPrev: () => {} };
|
||||||
|
}
|
||||||
|
const idx = claims.findIndex((c) => c.id === claimId);
|
||||||
|
if (idx === -1 || claims.length === 0) {
|
||||||
|
return { onNext: () => {}, onPrev: () => {} };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
onNext: () => {
|
||||||
|
const nextId = claims[(idx + 1) % claims.length].id;
|
||||||
|
onNavigate(nextId);
|
||||||
|
},
|
||||||
|
onPrev: () => {
|
||||||
|
// Add `claims.length` before the modulo so the negative index
|
||||||
|
// (first → wrap to last) wraps correctly without a separate
|
||||||
|
// branch.
|
||||||
|
const prevId = claims[(idx - 1 + claims.length) % claims.length].id;
|
||||||
|
onNavigate(prevId);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [claimId, claims, onNavigate]);
|
||||||
|
|
||||||
|
useDrawerKeyboard({
|
||||||
|
enabled: claimId !== null,
|
||||||
|
onNext,
|
||||||
|
onPrev,
|
||||||
|
onClose,
|
||||||
|
onToggleHelp,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Closed drawer: render nothing. The Dialog's `open` prop must also
|
||||||
|
// reflect this (so Radix tears down its portal / focus trap), and the
|
||||||
|
// early-return keeps a closed drawer free of any DOM at all.
|
||||||
|
if (claimId === null) return null;
|
||||||
|
|
||||||
|
// Branch the error into the two shapes the spec calls out:
|
||||||
|
// - ApiError(404) → "not_found" (no retry, the claim is gone)
|
||||||
|
// - anything else → "network" (retry available)
|
||||||
|
const errorKind: "not_found" | "network" | null = isError
|
||||||
|
? error instanceof ApiError && error.status === 404
|
||||||
|
? "not_found"
|
||||||
|
: "network"
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={claimId !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
// Right-anchored side panel. We override the Dialog primitive's
|
||||||
|
// default centered positioning (`left-1/2 top-1/2 -translate-x-1/2
|
||||||
|
// -translate-y-1/2`) by re-asserting `right-0 top-0 translate-x-0
|
||||||
|
// translate-y-0`. `h-full` + `rounded-none` + the left border
|
||||||
|
// give it the visual identity of a drawer rather than a modal.
|
||||||
|
// `aria-describedby={undefined}` suppresses the Radix warning
|
||||||
|
// about a missing description — the drawer content is its own
|
||||||
|
// description and there's nothing useful to point at.
|
||||||
|
className="fixed right-0 top-0 h-full w-full max-w-2xl translate-x-0 translate-y-0 rounded-none border-l border-[color:var(--m-border-heavy)] bg-[color:var(--m-surface)] p-0"
|
||||||
|
data-testid="claim-drawer"
|
||||||
|
aria-describedby={undefined}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<ClaimDrawerSkeleton />
|
||||||
|
) : errorKind ? (
|
||||||
|
<ClaimDrawerError
|
||||||
|
kind={errorKind}
|
||||||
|
onRetry={() => {
|
||||||
|
void refetch();
|
||||||
|
}}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
) : data ? (
|
||||||
|
<div
|
||||||
|
className="flex h-full flex-col overflow-y-auto"
|
||||||
|
data-testid="claim-drawer-content"
|
||||||
|
>
|
||||||
|
<ClaimDrawerHeader claim={data} onClose={onClose} />
|
||||||
|
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/15">
|
||||||
|
<ValidationPanel validation={data.validation} />
|
||||||
|
<ServiceLinesTable serviceLines={data.serviceLines} />
|
||||||
|
<DiagnosesList diagnoses={data.diagnoses} />
|
||||||
|
<PartiesGrid parties={data.parties} />
|
||||||
|
{data.matchedRemittance ? (
|
||||||
|
<MatchedRemitCard matchedRemittance={data.matchedRemittance} />
|
||||||
|
) : null}
|
||||||
|
<RawSegmentsPanel rawSegments={data.rawSegments} />
|
||||||
|
<StateHistoryTimeline history={data.stateHistory} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
// Barrel export for the ClaimDrawer module (SP4).
|
// Barrel export for the ClaimDrawer module (SP4).
|
||||||
//
|
|
||||||
// The root ClaimDrawer component is exported separately (added in T20)
|
|
||||||
// once that file exists — listing it here now would break typecheck.
|
|
||||||
|
|
||||||
|
export { ClaimDrawer } from "./ClaimDrawer";
|
||||||
export { ClaimDrawerHeader } from "./ClaimDrawerHeader";
|
export { ClaimDrawerHeader } from "./ClaimDrawerHeader";
|
||||||
export { ValidationPanel } from "./ValidationPanel";
|
export { ValidationPanel } from "./ValidationPanel";
|
||||||
export { ServiceLinesTable } from "./ServiceLinesTable";
|
export { ServiceLinesTable } from "./ServiceLinesTable";
|
||||||
|
|||||||
Reference in New Issue
Block a user