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 ( { if (!open) onClose(); }} > {isLoading ? ( ) : errorKind ? ( { void refetch(); }} onClose={onClose} /> ) : data ? (
{data.matchedRemittance ? ( ) : null}
) : null}
); }