Files
cyclone/src/components/RemitDrawer/RemitDrawer.tsx
T

168 lines
6.2 KiB
TypeScript

import { useMemo } from "react";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { ApiError } from "@/lib/api";
import { useRemitDetail } from "@/hooks/useRemitDetail";
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
import { RemitDrawerHeader } from "./RemitDrawerHeader";
import { FinancialSummaryCard } from "./FinancialSummaryCard";
import { PartiesGrid } from "./PartiesGrid";
import { ClaimPaymentsTable } from "./ClaimPaymentsTable";
import { CasAdjustmentsPanel } from "./CasAdjustmentsPanel";
import { RemitDrawerSkeleton } from "./RemitDrawerSkeleton";
import { RemitDrawerError } from "./RemitDrawerError";
type RemitDrawerProps = {
/**
* Currently-open remit id, or `null` when the drawer is closed.
* When `null`, the drawer renders nothing (no DOM) and the keyboard
* listener is disabled — matches the `ClaimDrawer` contract so the
* two drawers behave identically.
*/
remitId: string | null;
/**
* Full ordered list of remit 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 `remitId` for j/k to do
* anything; an unknown id is treated defensively as "navigation
* disabled" rather than throwing.
*/
remits: { 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 remit id. */
onNavigate: (newId: string) => void;
/** Fired when the user presses `?` to toggle the keyboard-help overlay. */
onToggleHelp: () => void;
};
/**
* Root remittance-detail drawer (RemitDrawer).
*
* Twin of `ClaimDrawer` — same dialog shell, same keyboard contract,
* same skeleton/error/loaded tri-state. Composes:
*
* 1. **Data** — `useRemitDetail(remitId)` returns
* `{ data, isLoading, isError, error, refetch }`. The hook
* short-circuits when `remitId === null` so a closed drawer is
* free (no fetch, no cache slot).
*
* 2. **Keyboard** — `useDrawerKeyboard` wires j/k/ArrowDown/ArrowUp/
* Escape/`?` on `window`. Listener is only attached when the
* drawer is open (`enabled: remitId !== null`).
*
* 3. **Layout** — right-anchored side panel via the project's
* `Dialog` primitive, with sections composed from the leaf
* components (header + financial summary + parties + claim
* payments + CAS adjustments). Each section owns its own visual
* language so the layout stays composable.
*
* j/k navigation wraps around at both ends. `useMemo` keeps the
* navigation callbacks stable across renders so the keyboard effect
* doesn't re-subscribe on every parent update.
*
* Error branching mirrors the claim drawer:
* - `ApiError(404)` → "not_found" (no retry, the remit is gone)
* - anything else → "network" (retry available)
*/
export function RemitDrawer({
remitId,
remits,
onClose,
onNavigate,
onToggleHelp,
}: RemitDrawerProps) {
const { data, isLoading, isError, error, refetch } = useRemitDetail(remitId);
// j/k navigation: derive next/prev IDs based on the current remit's
// index in the parent list. Wrap around at both ends. If the current
// remit 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 (remitId === null) {
return { onNext: () => {}, onPrev: () => {} };
}
const idx = remits.findIndex((r) => r.id === remitId);
if (idx === -1 || remits.length === 0) {
return { onNext: () => {}, onPrev: () => {} };
}
return {
onNext: () => {
const nextId = remits[(idx + 1) % remits.length].id;
onNavigate(nextId);
},
onPrev: () => {
// Add `remits.length` before the modulo so the negative index
// (first → wrap to last) wraps correctly without a separate
// branch.
const prevId = remits[(idx - 1 + remits.length) % remits.length].id;
onNavigate(prevId);
},
};
}, [remitId, remits, onNavigate]);
useDrawerKeyboard({
enabled: remitId !== 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 (remitId === null) return null;
const errorKind: "not_found" | "network" | null = isError
? error instanceof ApiError && error.status === 404
? "not_found"
: "network"
: null;
return (
<Dialog
open={remitId !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent
// Right-anchored side panel — mirrors the ClaimDrawer's
// positioning override. The Dialog primitive's default
// centered layout is suppressed so the drawer reads as a
// side-panel, not a modal.
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="remit-drawer"
aria-describedby={undefined}
>
{isLoading ? (
<RemitDrawerSkeleton />
) : errorKind ? (
<RemitDrawerError
kind={errorKind}
onRetry={() => {
void refetch();
}}
onClose={onClose}
/>
) : data ? (
<div
className="flex h-full flex-col overflow-y-auto"
data-testid="remit-drawer-content"
>
<RemitDrawerHeader remit={data} onClose={onClose} />
<div className="flex flex-col divide-y divide-[color:var(--m-border-heavy)]/15">
<FinancialSummaryCard remit={data} />
<PartiesGrid parties={data} fallbackName={data.payerName} />
<ClaimPaymentsTable remit={data} />
{data.adjustments && data.adjustments.length > 0 ? (
<CasAdjustmentsPanel adjustments={data.adjustments} />
) : null}
</div>
</div>
) : null}
</DialogContent>
</Dialog>
);
}