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 ( {kind} ); } /** * 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 (
{/* Left accent line — the only "color" element on the card, 2px wide so it reads as a marker, not decoration. */}
{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 ( ); } // --------------------------------------------------------------------------- // 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 (
{/* ================================================================= HERO — dark editorial header ================================================================= */}
Compare{" "} batches. } subtitle={ <> Pick two parsed batches — typically a submitted 837P and its corrected follow-up — and see what was added, removed, or changed between them. } />
{ready ? ( ) : null} {ready ? "Ready to diff" : "Awaiting picks"}
{/* 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. */}
On the wire {a ? "A" : "—"} {b ? "B" : "—"} · {ready ? "ready to diff" : "two picks needed"}
{/* ================================================================= PICKS — single Card wrapping the two BatchPicker cards. Same chrome language as the rest of the app. ================================================================= */}
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.

{batchesError ? ( refetchBatches()} /> ) : (
)}
{/* ================================================================= 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. ================================================================= */}
The diff

What moved.

{ready ? (

Three buckets — added in B, removed from A, changed in place. Unchanged claims are summarized but not listed.

) : null}
{!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 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 ? (
{diff.isFetching ? ( Refreshing… ) : ( <> Comparing{" "} {a} {" "} with{" "} {b} . )}
) : null}
{/* ================================================================= 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. ================================================================= */}
Cyclone · Batch diff
{ready ? "A vs B" : "awaiting picks"} · {batches?.length ?? 0} batch{(batches?.length ?? 0) === 1 ? "" : "es"}{" "} on file
refresh R
{/* 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. */} { // BatchDiff has no cheatsheet surface; `?` is a no-op // here, but the prop is required by the drawer's contract. }} />
); }