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. */}
);
}
// ---------------------------------------------------------------------------
// 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
================================================================= */}
{watermark}
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.
>
}
/>
{/* 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."
/>
{/* 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 ? (
) : 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.
}}
/>