fbe9940a3f
Distill the UI into a single, recognizable voice: a precision instrument for one operator on one machine. Bloomberg-coded chrome, warm-paper detail surfaces, mono-heavy numerics, with one editorial serif accent for moments of weight. Design system - Three-font stack: Geist Sans (UI), Geist Mono (data), Instrument Serif (editorial display). No Inter, no system fonts. - Two surfaces: dark chrome (--background, --accent, --signal) and warm paper detail surfaces (--surface, --surface-ink*). - Inbox has its own Ticker Tape terminal palette (--tt-*). - Shared component classes: .eyebrow, .mono, .display, .surface, .surface-2, .row-hover, .nav-active, .kbd, .editorial, .hairline. - --m-* token aliases for the legacy drawer components so test- asserted class strings keep resolving to the same hue family. Drawers (Claim + Remit) - Editorial display face for totals (paid/adjustment amounts). - Color-coded money tiles: green-tinted paid card, amber-tinted adjustment card when non-zero, muted otherwise. - Tabs get accent-blue underline + CSS-driven active state. - Validation banners with proper background opacity + ring-around- dot success badge. - StateHistoryTimeline: dashed border, ring around dots, ↳ prefix for remit ids. - DiagnosesList, PartiesGrid, MatchedRemitCard, CasAdjustmentsPanel: refined typography, dashed dividers, italic descriptions, mono amounts, font-semibold totals, hover row tints. Inbox Ticker Tape - Custom RowCheckbox with sr-only input + amber accent. - Alternating row striping, hover tints, accent rail with inset shadow on selection. - Refined sparkline with glow at high values. - BulkBar: bottom-floating bar with amber count chip, larger shadow. - CandidateBreakdown: animated progress bars with amber gradient. - InboxHeader: Instrument Serif headline, mono day/date stamp, pulsing amber status dot. Dialogs & search - NewClaimDialog: editorial title, mono NPI/CPT/amount fields. - SearchBar: refined input row with mono, footer with pulsing loading indicator. - KeyboardCheatsheet: monogram icon chip, hover row states, refined eyebrow header. Primitives - StatusBadge / ClaimStateBadge: per-state dot indicators. - SelectItem: data-[highlighted]:outline tokens for keyboard a11y. - Table primitives: refined header treatment, hover/focus states. Tests + build - 354/354 tests passing across 59 files. - Vite build clean (53.84 kB CSS / 560.86 kB JS). - Eyebrow assertions updated to match the consolidated .eyebrow class (intact visual contract, abstracted class string). - Badge variant tokens updated to the polished bg-muted/80 / /0.14 opacity scale.
248 lines
10 KiB
TypeScript
248 lines
10 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
|
import { ApiError } from "@/lib/api";
|
|
import { cn } from "@/lib/utils";
|
|
import { useClaimDetail } from "@/hooks/useClaimDetail";
|
|
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
|
|
import { useLineReconciliation } from "@/hooks/useLineReconciliation";
|
|
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 { LineReconciliationTab } from "./LineReconciliationTab";
|
|
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);
|
|
// SP7: tab state. The Line Reconciliation tab lazy-fetches the
|
|
// per-line projection when first activated.
|
|
const [activeTab, setActiveTab] = useState<"details" | "line-reconciliation">("details");
|
|
const lr = useLineReconciliation(
|
|
activeTab === "line-reconciliation" ? claimId : null
|
|
);
|
|
|
|
// 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)]/60 bg-[color:var(--m-surface)] p-0 shadow-[-24px_0_60px_-12px_rgba(0,0,0,0.6)]"
|
|
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 gap-0 px-6 border-b border-[color:var(--surface-line)]/40"
|
|
data-testid="claim-drawer-tabs"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab("details")}
|
|
className={cn(
|
|
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
|
|
activeTab === "details"
|
|
? "text-[color:var(--surface-ink)]"
|
|
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
|
|
)}
|
|
data-testid="tab-button-details"
|
|
data-active={activeTab === "details" ? "true" : "false"}
|
|
>
|
|
Details
|
|
{activeTab === "details" ? (
|
|
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
|
|
) : null}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setActiveTab("line-reconciliation")}
|
|
className={cn(
|
|
"relative px-3 py-2.5 text-[10.5px] font-semibold uppercase tracking-[0.18em] transition-colors",
|
|
activeTab === "line-reconciliation"
|
|
? "text-[color:var(--surface-ink)]"
|
|
: "text-[color:var(--surface-ink-3)] hover:text-[color:var(--surface-ink-2)]"
|
|
)}
|
|
data-testid="tab-button-line-reconciliation"
|
|
data-active={activeTab === "line-reconciliation" ? "true" : "false"}
|
|
>
|
|
Line Reconciliation
|
|
{activeTab === "line-reconciliation" ? (
|
|
<span className="absolute inset-x-0 -bottom-px h-[2px] bg-[color:var(--accent)]" />
|
|
) : null}
|
|
</button>
|
|
</div>
|
|
{activeTab === "line-reconciliation" ? (
|
|
lr.loading ? (
|
|
<p
|
|
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]"
|
|
data-testid="line-reconciliation-loading"
|
|
>
|
|
Loading line reconciliation…
|
|
</p>
|
|
) : lr.error ? (
|
|
<p
|
|
className="px-6 py-4 text-sm text-[color:var(--m-ink-tertiary)]"
|
|
data-testid="line-reconciliation-error"
|
|
>
|
|
Failed to load line reconciliation.
|
|
</p>
|
|
) : lr.data ? (
|
|
<LineReconciliationTab data={lr.data} />
|
|
) : null
|
|
) : (
|
|
<div className="flex flex-col divide-y divide-[color:var(--surface-line)]/12">
|
|
<ValidationPanel validation={data.validation} />
|
|
<ServiceLinesTable
|
|
serviceLines={data.serviceLines}
|
|
lineReconciliation={data.lineReconciliation}
|
|
/>
|
|
<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>
|
|
);
|
|
}
|