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 (
);
}
// ---------------------------------------------------------------------------
// 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
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
================================================================= */}