Files
cyclone/src/components/ClaimDrawer/ClaimDrawer.tsx
T
Tyler 9bca4b608a feat(release): v0.2.0 — batch 837 export, ClaimCard, theme tokens
Backend:

- New POST /api/batches/{id}/export-837: regenerate X12 837 files
  for a list of claim_ids into a ZIP using HCPF file naming standards,
  with a unique interchange/group control number per export. Wire
  the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
  (NM1*40) blocks so the serializer no longer falls back to
  CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
  batch_id in both JSON and NDJSON response shapes so the frontend
  can hit batch-scoped endpoints without an extra listBatches
  round-trip.
- Filename helpers and the 837 serializer updated to match the new
  HCPF envelope; tests cover batch export, parse batch_id, and the
  serializer's control-number uniqueness guarantee.

Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
  EditorialNote, ExportBar, TickerTape, and a charts/ set
  (BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
  the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
  colors to Tailwind theme tokens (bg-card, text-foreground,
  border/60, etc.) for consistency with the rest of the instrument
  chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
  reworked to compose the new shared components and consume the new
  batch-scoped API surface (notably ExportBar wired into Batches).

Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
  components and the useBatchExport hook.

Rolls up into the v0.2.0 release tag.
2026-06-22 11:01:58 -06:00

250 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 hairline
// give it the visual identity of a drawer rather than a modal.
// `bg-card` matches the rest of the dark instrument chrome; the
// outer shadow is a soft directional falloff to the left.
// `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-border/60 bg-card 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-border/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-foreground"
: "text-muted-foreground hover:text-foreground"
)}
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-accent shadow-[0_0_8px_hsl(var(--accent)/0.6)]" />
) : 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-foreground"
: "text-muted-foreground hover:text-foreground"
)}
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-accent shadow-[0_0_8px_hsl(var(--accent)/0.6)]" />
) : null}
</button>
</div>
{activeTab === "line-reconciliation" ? (
lr.loading ? (
<p
className="px-6 py-4 text-sm text-muted-foreground"
data-testid="line-reconciliation-loading"
>
Loading line reconciliation
</p>
) : lr.error ? (
<p
className="px-6 py-4 text-sm text-muted-foreground"
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-border/40">
<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>
);
}