Files
cyclone/src/pages/BatchDiff.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

646 lines
24 KiB
TypeScript

import { useCallback, useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import {
AlertCircle,
ArrowRight,
CircleDashed,
GitCompareArrows,
} from "lucide-react";
import { ApiError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { EmptyState } from "@/components/ui/empty-state";
import { ErrorState } from "@/components/ui/error-state";
import { Card, CardContent } from "@/components/ui/card";
import { PageHeader } from "@/components/PageHeader";
import {
BatchDiffView,
BatchDiffViewSkeleton,
} from "@/components/BatchDiffView";
import { RemitDrawer } from "@/components/RemitDrawer";
import { useBatches } from "@/hooks/useBatches";
import { useBatchDiff } from "@/hooks/useBatchDiff";
import { useRemitDrawerUrlState } from "@/hooks/useRemitDrawerUrlState";
import type { BatchSummary as ApiBatchSummary } from "@/lib/api";
import { cn } from "@/lib/utils";
// ---------------------------------------------------------------------------
// Inline URL state — uses react-router's useSearchParams so the page
// participates in the standard routing lifecycle (MemoryRouter in
// tests, BrowserRouter in production). `replace: true` keeps the
// picker tweaks from polluting the back-button stack.
// ---------------------------------------------------------------------------
function readIdsFromParams(params: URLSearchParams): {
a: string | null;
b: string | null;
} {
const a = params.get("a");
const b = params.get("b");
return {
a: a && a.length > 0 ? a : null,
b: b && b.length > 0 ? b : null,
};
}
// ---------------------------------------------------------------------------
// Kind badge — small inline label so the operator can spot which batch
// is which at a glance. Re-themed for the dark surface; the
// data-testid contract from `BatchesList.KindBadge` is preserved
// (prefixed `diff-picker-` so the BatchDiff tests can still assert
// on it).
// ---------------------------------------------------------------------------
function KindBadge({ kind }: { kind: ApiBatchSummary["kind"] }) {
const cls =
kind === "837p"
? "text-sky-300 border-sky-400/30 bg-sky-400/10"
: "text-amber-300 border-amber-400/30 bg-amber-400/10";
return (
<span
data-testid={`diff-picker-kind-${kind}`}
className={cn(
"inline-flex items-center gap-1 rounded-full border px-1.5 py-px text-[9.5px] font-semibold uppercase tracking-[0.14em] font-mono",
cls,
)}
>
{kind}
</span>
);
}
/**
* Side A / Side B picker card. Dark-theme chrome with a hairline-width
* left accent line in the side color (electric blue for A, amber for B).
* Excludes the value already chosen on the other side so the operator
* can't pick the same batch twice.
*/
function BatchPicker({
label,
side,
value,
onChange,
items,
excludeId,
testid,
}: {
label: string;
side: "A" | "B";
value: string | null;
onChange: (id: string) => void;
items: ApiBatchSummary[];
excludeId: string | null;
testid: string;
}) {
const visible = useMemo(
() => items.filter((it) => it.id !== excludeId),
[items, excludeId],
);
const selected = items.find((it) => it.id === value);
// A is electric blue (project --accent). B is amber (project --warning).
const accentClass =
side === "A"
? "bg-accent"
: "bg-[hsl(var(--warning))]";
const sideBadgeClass =
side === "A"
? "bg-accent/15 text-accent"
: "bg-[hsl(var(--warning)/0.15)] text-[hsl(var(--warning))]";
return (
<div
data-testid={testid}
className="relative rounded-xl border border-border/60 bg-card/40 overflow-hidden"
>
{/* Left accent line — the only "color" element on the card, 2px
wide so it reads as a marker, not decoration. */}
<div
aria-hidden
className={cn("absolute left-0 top-4 bottom-4 w-px", accentClass)}
/>
<div className="flex items-center justify-between px-4 pt-3 pb-2">
<div className="flex items-center gap-2.5">
<div
aria-hidden
className={cn(
"h-5 w-5 rounded-md flex items-center justify-center mono font-semibold text-[11px]",
sideBadgeClass,
)}
>
{side}
</div>
<span className="mono text-[10.5px] uppercase tracking-[0.18em] font-semibold text-muted-foreground">
{label}
</span>
</div>
{selected ? (
<span className="display mono tabular-nums text-[12.5px] text-foreground">
{selected.claimCount}{" "}
<span className="mono not-italic uppercase tracking-[0.14em] text-[10px] text-muted-foreground/60">
claims
</span>
</span>
) : null}
</div>
<div className="px-4 pb-4">
<Select value={value ?? ""} onValueChange={(v) => onChange(v)}>
<SelectTrigger>
{selected ? (
<span className="flex items-center gap-2 truncate">
<KindBadge kind={selected.kind} />
<span className="font-mono num text-[12.5px]">{selected.id}</span>
<span className="text-[12px] truncate text-muted-foreground">
· {selected.inputFilename}
</span>
</span>
) : (
<SelectValue placeholder="Pick a batch…" />
)}
</SelectTrigger>
<SelectContent>
{visible.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">
No other batches available.
</div>
) : (
visible.map((b) => (
<SelectItem
key={b.id}
value={b.id}
data-testid={`diff-picker-option-${b.id}`}
>
<span className="flex items-center gap-2">
<KindBadge kind={b.kind} />
<span className="font-mono num text-[12px]">{b.id}</span>
<span className="text-muted-foreground text-[12px] truncate">
· {b.inputFilename}
</span>
</span>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Not-found branch — surfaces when the backend reports 404 for one of
// the picks. Most likely cause: a bookmark referencing a now-deleted
// batch, or another agent deleting between the picker load and the
// diff fetch.
// ---------------------------------------------------------------------------
function NotFoundState({
a,
b,
onReset,
}: {
a: string;
b: string;
onReset: () => void;
}) {
return (
<ErrorState
message="One of these batches was not found."
detail={`The backend couldn't find batch ${a} or ${b}. It may have been deleted between picker load and diff fetch.`}
onRetry={onReset}
/>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export function BatchDiff() {
// URL → state via react-router. Keeps MemoryRouter (tests) and
// BrowserRouter (production) symmetric: both update the
// useSearchParams hook on every navigation.
const [searchParams, setSearchParams] = useSearchParams();
// SP21 Phase 4 Task 4.5: mount the RemitDrawer so a deep link to
// /batch-diff?remit=REM-1 opens the drawer in-place. The BatchDiff
// data model (BatchClaimDiffSummary) is a claim-level projection —
// there are no per-remit IDs in the diff payload to wrap, so no
// click-to-drill is wired here. The drawer still mounts so the
// `?remit=` URL contract is honored when the user lands on this
// page with the param set (e.g. from an external link).
const { remitId, open, close } = useRemitDrawerUrlState();
const { a, b } = useMemo(
() => readIdsFromParams(searchParams),
[searchParams],
);
const updateIds = useCallback(
(next: { a: string | null; b: string | null }) => {
const params = new URLSearchParams(searchParams);
if (next.a === null) params.delete("a");
else params.set("a", next.a);
if (next.b === null) params.delete("b");
else params.set("b", next.b);
setSearchParams(params, { replace: true });
},
[searchParams, setSearchParams],
);
const setA = useCallback(
(id: string) => updateIds({ a: id, b }),
[b, updateIds],
);
const setB = useCallback(
(id: string) => updateIds({ a, b: id }),
[a, updateIds],
);
const reset = useCallback(() => updateIds({ a: null, b: null }), [updateIds]);
const {
data: batches,
isLoading: loadingBatches,
isError: batchesError,
error: batchesErrorMsg,
refetch: refetchBatches,
} = useBatches(100);
// Only fire the diff once both pickers have a value. The hook also
// gates on this internally (defense-in-depth), so a half-picked URL
// never hits the backend.
const diff = useBatchDiff(a, b);
const ready = a !== null && b !== null;
// --- error / status derivation ------------------------------------
const errKind: "network" | "not_found" | null = diff.error
? diff.error instanceof ApiError && diff.error.status === 404
? "not_found"
: "network"
: null;
// The ghost watermark carries the picker state, scaled like the
// other pages. When both picks are in: "A→B", otherwise the count
// of available batches, otherwise "DIFF".
const watermark = ready
? `${a}${b}`
: batches && batches.length > 0
? batches.length.toLocaleString()
: "DIFF";
// -----------------------------------------------------------------
// Stagger choreography — hero lands first, picks at 140ms,
// diff at 240ms, footer at 320ms. Total < 500ms.
// -----------------------------------------------------------------
const heroDelay = 0;
const picksDelay = 140;
const diffDelay = 240;
const footerDelay = 320;
// --- render -------------------------------------------------------
return (
<div
className="space-y-6 lg:space-y-10 animate-fade-in"
data-testid="batch-diff-page"
>
{/* =================================================================
HERO — dark editorial header
================================================================= */}
<section
className="relative animate-fade-in overflow-hidden"
style={{ animationDelay: `${heroDelay}ms` }}
>
<div
aria-hidden="true"
className="pointer-events-none select-none absolute -right-4 top-1/2 -translate-y-1/2 whitespace-nowrap display text-foreground"
style={{
fontSize: "clamp(72px, 12vw, 140px)",
letterSpacing: "-0.04em",
opacity: 0.045,
lineHeight: 1,
}}
>
{watermark}
</div>
<div className="relative z-10 flex items-end justify-between gap-4 sm:gap-6 flex-wrap">
<div className="min-w-0">
<PageHeader
eyebrow="Batch diff · A vs B"
title={
<>
Compare{" "}
<span className="italic text-muted-foreground/85">
batches.
</span>
</>
}
subtitle={
<>
Pick two parsed batches typically a submitted 837P and its
corrected follow-up and see what was added, removed, or
changed between them.
</>
}
/>
</div>
<div
className={cn(
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[11px] mono uppercase tracking-[0.12em] backdrop-blur",
ready
? "border-[hsl(var(--success)/0.4)] bg-[hsl(var(--success)/0.08)] text-[hsl(var(--success))]"
: "border-border/60 bg-card/60 text-muted-foreground"
)}
>
<span
className={cn(
"relative inline-flex h-1.5 w-1.5 rounded-full",
ready ? "bg-[hsl(var(--success))]" : "bg-muted-foreground/60"
)}
>
{ready ? (
<span className="absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--success))] opacity-60 animate-ping" />
) : null}
</span>
<GitCompareArrows className="h-3.5 w-3.5" strokeWidth={1.75} />
{ready ? "Ready to diff" : "Awaiting picks"}
</div>
</div>
{/* On the wire — small inline status row showing A → B,
mirrors the watermark visually and tells the operator at a
glance which picks are armed. */}
<div className="relative z-10 mt-5 flex items-center gap-3 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span>On the wire</span>
<span
className={cn(
"display mono not-italic text-[14px]",
a ? "text-accent" : "text-muted-foreground/40"
)}
>
{a ? "A" : "—"}
</span>
<ArrowRight
className="h-3.5 w-3.5"
strokeWidth={1.5}
style={{ color: "hsl(var(--muted-foreground) / 0.4)" }}
/>
<span
className={cn(
"display mono not-italic text-[14px]",
b ? "text-[hsl(var(--warning))]" : "text-muted-foreground/40"
)}
>
{b ? "B" : "—"}
</span>
<span className="text-muted-foreground/30" aria-hidden>
·
</span>
<span>{ready ? "ready to diff" : "two picks needed"}</span>
</div>
</section>
{/* =================================================================
PICKS — single Card wrapping the two BatchPicker cards.
Same chrome language as the rest of the app.
================================================================= */}
<section
aria-label="The picks"
className="animate-fade-in-up"
style={{ animationDelay: `${picksDelay}ms` }}
>
<Card>
<CardContent className="p-6 lg:p-7 space-y-5">
<div className="flex items-end justify-between gap-6 flex-wrap">
<div className="min-w-0">
<div className="eyebrow flex items-center gap-2 mb-2">
<span className="inline-block h-px w-6 bg-foreground/20" />
The picks
</div>
<h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
One on each <span className="italic">side.</span>
</h2>
</div>
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
A is the &ldquo;before&rdquo; B is the &ldquo;after&rdquo;.
A picked batch can&rsquo;t be picked again on the other side.
</p>
</div>
<div className="pt-5 border-t border-border/40">
{batchesError ? (
<ErrorState
message="Couldn't load batches from the backend."
detail={
batchesErrorMsg instanceof Error
? batchesErrorMsg.message
: String(batchesErrorMsg)
}
onRetry={() => refetchBatches()}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<BatchPicker
label="A · left"
side="A"
value={a}
onChange={setA}
items={batches ?? []}
excludeId={b}
testid="diff-picker-a"
/>
<BatchPicker
label="B · right"
side="B"
value={b}
onChange={setB}
items={batches ?? []}
excludeId={a}
testid="diff-picker-b"
/>
</div>
)}
</div>
</CardContent>
</Card>
</section>
{/* =================================================================
DIFF — single Card wrapping the BatchDiffView. Same chrome
language as the rest of the app. Branches on `ready` to
pick between the awaiting-picks empty state, the loading
skeleton, the not-found error, the network error, and the
actual diff view.
================================================================= */}
<section
aria-label="The diff"
className="animate-fade-in-up"
style={{ animationDelay: `${diffDelay}ms` }}
>
<Card>
<CardContent className="p-6 lg:p-7 space-y-5">
<div className="flex items-end justify-between gap-6 flex-wrap">
<div className="min-w-0">
<div className="eyebrow flex items-center gap-2 mb-2">
<span className="inline-block h-px w-6 bg-foreground/20" />
The diff
</div>
<h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
What <span className="italic">moved.</span>
</h2>
</div>
{ready ? (
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
Three buckets added in B, removed from A, changed in place.
Unchanged claims are summarized but not listed.
</p>
) : null}
</div>
<div className="pt-5 border-t border-border/40">
{!ready ? (
<div
className="rounded-md border border-dashed border-border/60 bg-card/40"
data-testid="batch-diff-awaiting"
>
<EmptyState
icon={
<GitCompareArrows
className="h-4 w-4"
strokeWidth={1.5}
/>
}
eyebrow="Batch diff · awaiting picks"
message="Choose one batch for A and one for B to compute the diff."
/>
</div>
) : loadingBatches ? (
<BatchDiffViewSkeleton />
) : errKind === "not_found" ? (
<NotFoundState a={a as string} b={b as string} onReset={reset} />
) : diff.isError ? (
<ErrorState
message="Couldn't compute the diff between these two batches."
detail={
diff.error instanceof Error
? diff.error.message
: String(diff.error)
}
onRetry={() => diff.refetch()}
/>
) : diff.isLoading || !diff.data ? (
<BatchDiffViewSkeleton />
) : (
<BatchDiffView data={diff.data} />
)}
</div>
{/* Action row — refresh + clear, only when ready. Lives
inside the Card so it sits at the bottom of the diff
surface, not as a separate paper footer. */}
{ready ? (
<div className="pt-5 border-t border-border/40 flex items-center justify-between gap-3 flex-wrap">
<div className="text-[12.5px] text-muted-foreground/80 min-w-0">
{diff.isFetching ? (
<span className="inline-flex items-center gap-1.5">
<CircleDashed
className="h-3 w-3 animate-spin"
strokeWidth={1.75}
/>
Refreshing
</span>
) : (
<>
Comparing{" "}
<span className="display mono not-italic text-foreground">
{a}
</span>{" "}
with{" "}
<span className="display mono not-italic text-foreground">
{b}
</span>
.
</>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => diff.refetch()}
data-testid="diff-refresh-button"
>
Refresh
</Button>
<Button
variant="ghost"
size="sm"
onClick={reset}
data-testid="diff-clear-button"
>
<AlertCircle className="h-3.5 w-3.5 mr-1" strokeWidth={1.75} />
Clear picks
</Button>
</div>
</div>
) : null}
</CardContent>
</Card>
</section>
{/* =================================================================
FOOTER — single hairline-separated status row. Replaces the
warm-paper "End of sheet 01" treatment with a quiet
instrument-style status line in the same rhythm as the
other pages.
================================================================= */}
<section
aria-label="Sheet status"
className="animate-fade-in-up flex items-center justify-between gap-4 flex-wrap pt-5 border-t border-border/40"
style={{ animationDelay: `${footerDelay}ms` }}
>
<div className="flex items-center gap-2 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span className="h-1.5 w-1.5 rounded-full bg-[hsl(var(--accent))]" />
Cyclone · Batch diff
</div>
<div className="flex items-center gap-3 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span>{ready ? "A vs B" : "awaiting picks"}</span>
<span className="text-muted-foreground/30" aria-hidden>
·
</span>
<span>
{batches?.length ?? 0} batch{(batches?.length ?? 0) === 1 ? "" : "es"}{" "}
on file
</span>
</div>
<div className="flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
<span>refresh</span>
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80">
R
</kbd>
</div>
</section>
{/* SP21 Phase 4 Task 4.5: RemitDrawer mount. There are no
per-remit rows in the diff payload (the page is a
claim-level projection), so this drawer is for deep-link
support only — clicking a claim row does not open it.
When the user lands on /batch-diff?remit=REM-1, the drawer
opens in-place. The `remits` list is empty so j/k is a
no-op while the drawer is open. */}
<RemitDrawer
remitId={remitId}
remits={[]}
onClose={close}
onNavigate={open}
onToggleHelp={() => {
// BatchDiff has no cheatsheet surface; `?` is a no-op
// here, but the prop is required by the drawer's contract.
}}
/>
</div>
);
}