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 { BatchDiffView, BatchDiffViewSkeleton, } from "@/components/BatchDiffView"; import { useBatches } from "@/hooks/useBatches"; import { useBatchDiff } from "@/hooks/useBatchDiff"; 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. Identical contract to the one in // `BatchesList.tsx`; duplicated here because each lives inside a // different page surface (the picker is part of this page, the row // badge belongs to Batches). // --------------------------------------------------------------------------- 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 ( {kind} ); } 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; }) { // Filter out the value already chosen on the *other* side so the // operator can't accidentally pick the same batch twice (which // produces a meaningless diff). Disabled rather than hidden so the // selection stays transparent. const visible = useMemo( () => items.filter((it) => it.id !== excludeId), [items, excludeId], ); const selected = items.find((it) => it.id === value); const accent = side === "A" ? "hsl(212 100% 45%)" : "hsl(36 92% 50%)"; const tint = side === "A" ? "hsl(212 85% 95%)" : "hsl(36 82% 92%)"; return (
{side}
{label}
{selected ? ( {selected.claimCount}{" "} claims ) : null}
); } // --------------------------------------------------------------------------- // 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 ( ); } // --------------------------------------------------------------------------- // Section header — § N + small italic vertical-rl label. Reused by // both the picks and diff sections so the folio is uniform. // --------------------------------------------------------------------------- function Folio({ section, label, topClass = "top-7" }: { section: string; label: string; topClass?: string; }) { return (
{section}
{label}
); } // --------------------------------------------------------------------------- // 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(); 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; // ----------------------------------------------------------------- // Stagger choreography // ----------------------------------------------------------------- const heroDelay = 0; const foldDelay = 220; const titleDelay = 320; const picksDelay = 460; const diffDelay = 600; // --- render ------------------------------------------------------- return (
{/* ================================================================= HERO — DARK EDITORIAL HEADER ================================================================= */}
{/* Ghost "DIFF" watermark — a print-shop stamp behind the title. */}
Diff · Sheet 01 · A vs B

Compare{" "} batches.

837P · 835 · first vs second
Pick a batch for each side

Pick two parsed batches — typically a submitted 837P and its corrected follow-up — and see what was added, removed, or changed between them.

{/* ================================================================= FOLD — TORN PAGE ================================================================= */}
{Array.from({ length: 48 }, (_, i) => ( ))}
A → B
{/* ================================================================= PAPER PLANE ================================================================= */}
{/* Paper grain */}
\")", backgroundSize: "160px 160px", mixBlendMode: "multiply", }} /> {/* Title block */}
Sheet 01 · Compare two batches

Compare them.

Two picks, three buckets. What the second batch added, what the first batch dropped, and which claims moved between them.
On the wire
{a ? "A" : "—"} {b ? "B" : "—"}
{ready ? "ready to diff" : "two picks needed"}
{/* § 01 The picks */}
The picks

One on each side.

A is the "before" — B is the "after". A picked batch can't be picked again on the other side.
{/* § 02 The diff */}
The diff

What moved.

{ready ? (
Three buckets — added in B, removed from A, changed in place. Unchanged claims are summarized but not listed.
) : null}
{batchesError ? (
refetchBatches()} />
) : !ready ? (
} eyebrow="Batch diff · awaiting picks" message="Choose one batch for A and one for B to compute the diff." />
) : loadingBatches ? ( ) : errKind === "not_found" ? ( ) : diff.isError ? (
diff.refetch()} />
) : diff.isLoading || !diff.data ? ( ) : ( )}
{/* Action footer — always visible when ready so the operator can clear or force-refresh without scrolling back to the picker. */} {ready ? (
{diff.isFetching ? ( Refreshing… ) : ( <> Comparing{" "} {a} {" "} with{" "} {b} . )}
) : null} {/* Footer */}
End of sheet 01 Cyclone · Batch diff {ready ? "A vs B" : "awaiting picks"}
); }