321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
import { useCallback, useMemo } from "react";
|
|
import { useSearchParams } from "react-router-dom";
|
|
import { GitCompareArrows, CircleDashed, AlertCircle } 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,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Batch picker — one Select dropdown for each side. Keeps the same
|
|
// kind-colored badge inline so the operator can spot which batch is
|
|
// which at a glance.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
function BatchPicker({
|
|
label,
|
|
value,
|
|
onChange,
|
|
items,
|
|
excludeId,
|
|
testid,
|
|
}: {
|
|
label: string;
|
|
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);
|
|
return (
|
|
<div className="space-y-1.5" data-testid={testid}>
|
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
|
{label}
|
|
</div>
|
|
<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-muted-foreground text-[12px] truncate">
|
|
· {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>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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();
|
|
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;
|
|
|
|
// --- render -------------------------------------------------------
|
|
return (
|
|
<div className="space-y-6 md:space-y-8 animate-fade-in" data-testid="batch-diff-page">
|
|
<header>
|
|
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-2 flex items-center gap-2">
|
|
<span className="inline-block h-px w-6 bg-border" />
|
|
Diff
|
|
</div>
|
|
<h1 className="text-[22px] sm:text-[26px] font-semibold tracking-tight">
|
|
Batch diff
|
|
</h1>
|
|
<p className="text-muted-foreground mt-1.5 text-[14px]">
|
|
Pick two parsed batches — typically a submitted 837P and its
|
|
corrected follow-up — and see what was added, removed, or changed
|
|
between them.
|
|
</p>
|
|
</header>
|
|
|
|
<div className="surface rounded-xl p-4 grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
|
|
<BatchPicker
|
|
label="A · left"
|
|
value={a}
|
|
onChange={setA}
|
|
items={batches ?? []}
|
|
excludeId={b}
|
|
testid="diff-picker-a"
|
|
/>
|
|
<BatchPicker
|
|
label="B · right"
|
|
value={b}
|
|
onChange={setB}
|
|
items={batches ?? []}
|
|
excludeId={a}
|
|
testid="diff-picker-b"
|
|
/>
|
|
</div>
|
|
|
|
{batchesError ? (
|
|
<ErrorState
|
|
message="Couldn't load batches from the backend."
|
|
detail={
|
|
batchesErrorMsg instanceof Error
|
|
? batchesErrorMsg.message
|
|
: String(batchesErrorMsg)
|
|
}
|
|
onRetry={() => refetchBatches()}
|
|
/>
|
|
) : null}
|
|
|
|
{!ready ? (
|
|
<div className="surface rounded-xl">
|
|
<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} />
|
|
)}
|
|
|
|
{/* Action footer — always visible so the operator can clear or
|
|
force-refresh without scrolling back to the picker. */}
|
|
{ready ? (
|
|
<div className="flex items-center justify-end gap-2 pt-2">
|
|
{diff.isFetching ? (
|
|
<span className="text-[12px] text-muted-foreground inline-flex items-center gap-1.5">
|
|
<CircleDashed className="h-3 w-3 animate-spin" strokeWidth={1.75} />
|
|
Refreshing…
|
|
</span>
|
|
) : null}
|
|
<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>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|