9bca4b608a
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.
250 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|