feat(frontend): RemitDrawer component with CAS adjustments + parties + claim payments
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user