9bca4b608a
Backend:
- New POST /api/batches/{id}/export-837: regenerate X12 837 files
for a list of claim_ids into a ZIP using HCPF file naming standards,
with a unique interchange/group control number per export. Wire
the clearhouse Loop 1000A (NM1*41 + PER) and per-payer receiver
(NM1*40) blocks so the serializer no longer falls back to
CYCLONE / RECEIVER placeholders.
- /api/parse-837 and /api/parse-835 now surface the server-side
batch_id in both JSON and NDJSON response shapes so the frontend
can hit batch-scoped endpoints without an extra listBatches
round-trip.
- Filename helpers and the 837 serializer updated to match the new
HCPF envelope; tests cover batch export, parse batch_id, and the
serializer's control-number uniqueness guarantee.
Frontend:
- New shared components: ClaimCard, ClaimCard837, DominantKpiCard,
EditorialNote, ExportBar, TickerTape, and a charts/ set
(BarChart, HBarChart, SegmentedBar, AgingBars).
- New useBatchExport hook driving ExportBar's download flow against
the new endpoint.
- ClaimDrawer, Lane, and Layout migrated from raw CSS-variable
colors to Tailwind theme tokens (bg-card, text-foreground,
border/60, etc.) for consistency with the rest of the instrument
chrome; the active tab indicator gains a subtle accent glow.
- Upload, Inbox, Batches, BatchDiff, Reconciliation, and Acks pages
reworked to compose the new shared components and consume the new
batch-scoped API surface (notably ExportBar wired into Batches).
Tooling / Docs:
- Add audit-uiux.mjs and a docs/goodclaim.x12 sample fixture.
- Update ClaimDrawer testids and add coverage for the new
components and the useBatchExport hook.
Rolls up into the v0.2.0 release tag.
646 lines
24 KiB
TypeScript
646 lines
24 KiB
TypeScript
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 (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div
|
|
data-testid={testid}
|
|
className="relative rounded-xl border border-border/60 bg-card/40 overflow-hidden"
|
|
>
|
|
{/* Left accent line — the only "color" element on the card, 2px
|
|
wide so it reads as a marker, not decoration. */}
|
|
<div
|
|
aria-hidden
|
|
className={cn("absolute left-0 top-4 bottom-4 w-px", accentClass)}
|
|
/>
|
|
<div className="flex items-center justify-between px-4 pt-3 pb-2">
|
|
<div className="flex items-center gap-2.5">
|
|
<div
|
|
aria-hidden
|
|
className={cn(
|
|
"h-5 w-5 rounded-md flex items-center justify-center mono font-semibold text-[11px]",
|
|
sideBadgeClass,
|
|
)}
|
|
>
|
|
{side}
|
|
</div>
|
|
<span className="mono text-[10.5px] uppercase tracking-[0.18em] font-semibold text-muted-foreground">
|
|
{label}
|
|
</span>
|
|
</div>
|
|
{selected ? (
|
|
<span className="display mono tabular-nums text-[12.5px] text-foreground">
|
|
{selected.claimCount}{" "}
|
|
<span className="mono not-italic uppercase tracking-[0.14em] text-[10px] text-muted-foreground/60">
|
|
claims
|
|
</span>
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<div className="px-4 pb-4">
|
|
<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-[12px] truncate text-muted-foreground">
|
|
· {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>
|
|
</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();
|
|
// 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 (
|
|
<div
|
|
className="space-y-6 lg:space-y-10 animate-fade-in"
|
|
data-testid="batch-diff-page"
|
|
>
|
|
{/* =================================================================
|
|
HERO — dark editorial header
|
|
================================================================= */}
|
|
<section
|
|
className="relative animate-fade-in overflow-hidden"
|
|
style={{ animationDelay: `${heroDelay}ms` }}
|
|
>
|
|
<div
|
|
aria-hidden="true"
|
|
className="pointer-events-none select-none absolute -right-4 top-1/2 -translate-y-1/2 whitespace-nowrap display text-foreground"
|
|
style={{
|
|
fontSize: "clamp(72px, 12vw, 140px)",
|
|
letterSpacing: "-0.04em",
|
|
opacity: 0.045,
|
|
lineHeight: 1,
|
|
}}
|
|
>
|
|
{watermark}
|
|
</div>
|
|
|
|
<div className="relative z-10 flex items-end justify-between gap-4 sm:gap-6 flex-wrap">
|
|
<div className="min-w-0">
|
|
<PageHeader
|
|
eyebrow="Batch diff · A vs B"
|
|
title={
|
|
<>
|
|
Compare{" "}
|
|
<span className="italic text-muted-foreground/85">
|
|
batches.
|
|
</span>
|
|
</>
|
|
}
|
|
subtitle={
|
|
<>
|
|
Pick two parsed batches — typically a submitted 837P and its
|
|
corrected follow-up — and see what was added, removed, or
|
|
changed between them.
|
|
</>
|
|
}
|
|
/>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
"inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-[11px] mono uppercase tracking-[0.12em] backdrop-blur",
|
|
ready
|
|
? "border-[hsl(var(--success)/0.4)] bg-[hsl(var(--success)/0.08)] text-[hsl(var(--success))]"
|
|
: "border-border/60 bg-card/60 text-muted-foreground"
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
"relative inline-flex h-1.5 w-1.5 rounded-full",
|
|
ready ? "bg-[hsl(var(--success))]" : "bg-muted-foreground/60"
|
|
)}
|
|
>
|
|
{ready ? (
|
|
<span className="absolute inline-flex h-full w-full rounded-full bg-[hsl(var(--success))] opacity-60 animate-ping" />
|
|
) : null}
|
|
</span>
|
|
<GitCompareArrows className="h-3.5 w-3.5" strokeWidth={1.75} />
|
|
{ready ? "Ready to diff" : "Awaiting picks"}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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. */}
|
|
<div className="relative z-10 mt-5 flex items-center gap-3 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
|
|
<span>On the wire</span>
|
|
<span
|
|
className={cn(
|
|
"display mono not-italic text-[14px]",
|
|
a ? "text-accent" : "text-muted-foreground/40"
|
|
)}
|
|
>
|
|
{a ? "A" : "—"}
|
|
</span>
|
|
<ArrowRight
|
|
className="h-3.5 w-3.5"
|
|
strokeWidth={1.5}
|
|
style={{ color: "hsl(var(--muted-foreground) / 0.4)" }}
|
|
/>
|
|
<span
|
|
className={cn(
|
|
"display mono not-italic text-[14px]",
|
|
b ? "text-[hsl(var(--warning))]" : "text-muted-foreground/40"
|
|
)}
|
|
>
|
|
{b ? "B" : "—"}
|
|
</span>
|
|
<span className="text-muted-foreground/30" aria-hidden>
|
|
·
|
|
</span>
|
|
<span>{ready ? "ready to diff" : "two picks needed"}</span>
|
|
</div>
|
|
</section>
|
|
|
|
{/* =================================================================
|
|
PICKS — single Card wrapping the two BatchPicker cards.
|
|
Same chrome language as the rest of the app.
|
|
================================================================= */}
|
|
<section
|
|
aria-label="The picks"
|
|
className="animate-fade-in-up"
|
|
style={{ animationDelay: `${picksDelay}ms` }}
|
|
>
|
|
<Card>
|
|
<CardContent className="p-6 lg:p-7 space-y-5">
|
|
<div className="flex items-end justify-between gap-6 flex-wrap">
|
|
<div className="min-w-0">
|
|
<div className="eyebrow flex items-center gap-2 mb-2">
|
|
<span className="inline-block h-px w-6 bg-foreground/20" />
|
|
The picks
|
|
</div>
|
|
<h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
|
|
One on each <span className="italic">side.</span>
|
|
</h2>
|
|
</div>
|
|
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
|
|
A is the “before” — B is the “after”.
|
|
A picked batch can’t be picked again on the other side.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="pt-5 border-t border-border/40">
|
|
{batchesError ? (
|
|
<ErrorState
|
|
message="Couldn't load batches from the backend."
|
|
detail={
|
|
batchesErrorMsg instanceof Error
|
|
? batchesErrorMsg.message
|
|
: String(batchesErrorMsg)
|
|
}
|
|
onRetry={() => refetchBatches()}
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<BatchPicker
|
|
label="A · left"
|
|
side="A"
|
|
value={a}
|
|
onChange={setA}
|
|
items={batches ?? []}
|
|
excludeId={b}
|
|
testid="diff-picker-a"
|
|
/>
|
|
<BatchPicker
|
|
label="B · right"
|
|
side="B"
|
|
value={b}
|
|
onChange={setB}
|
|
items={batches ?? []}
|
|
excludeId={a}
|
|
testid="diff-picker-b"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</section>
|
|
|
|
{/* =================================================================
|
|
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.
|
|
================================================================= */}
|
|
<section
|
|
aria-label="The diff"
|
|
className="animate-fade-in-up"
|
|
style={{ animationDelay: `${diffDelay}ms` }}
|
|
>
|
|
<Card>
|
|
<CardContent className="p-6 lg:p-7 space-y-5">
|
|
<div className="flex items-end justify-between gap-6 flex-wrap">
|
|
<div className="min-w-0">
|
|
<div className="eyebrow flex items-center gap-2 mb-2">
|
|
<span className="inline-block h-px w-6 bg-foreground/20" />
|
|
The diff
|
|
</div>
|
|
<h2 className="display text-[26px] leading-[1.05] tracking-[-0.02em]">
|
|
What <span className="italic">moved.</span>
|
|
</h2>
|
|
</div>
|
|
{ready ? (
|
|
<p className="text-[12.5px] text-muted-foreground/80 max-w-sm">
|
|
Three buckets — added in B, removed from A, changed in place.
|
|
Unchanged claims are summarized but not listed.
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="pt-5 border-t border-border/40">
|
|
{!ready ? (
|
|
<div
|
|
className="rounded-md border border-dashed border-border/60 bg-card/40"
|
|
data-testid="batch-diff-awaiting"
|
|
>
|
|
<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} />
|
|
)}
|
|
</div>
|
|
|
|
{/* 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 ? (
|
|
<div className="pt-5 border-t border-border/40 flex items-center justify-between gap-3 flex-wrap">
|
|
<div className="text-[12.5px] text-muted-foreground/80 min-w-0">
|
|
{diff.isFetching ? (
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<CircleDashed
|
|
className="h-3 w-3 animate-spin"
|
|
strokeWidth={1.75}
|
|
/>
|
|
Refreshing…
|
|
</span>
|
|
) : (
|
|
<>
|
|
Comparing{" "}
|
|
<span className="display mono not-italic text-foreground">
|
|
{a}
|
|
</span>{" "}
|
|
with{" "}
|
|
<span className="display mono not-italic text-foreground">
|
|
{b}
|
|
</span>
|
|
.
|
|
</>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<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>
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
</section>
|
|
|
|
{/* =================================================================
|
|
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.
|
|
================================================================= */}
|
|
<section
|
|
aria-label="Sheet status"
|
|
className="animate-fade-in-up flex items-center justify-between gap-4 flex-wrap pt-5 border-t border-border/40"
|
|
style={{ animationDelay: `${footerDelay}ms` }}
|
|
>
|
|
<div className="flex items-center gap-2 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-[hsl(var(--accent))]" />
|
|
Cyclone · Batch diff
|
|
</div>
|
|
<div className="flex items-center gap-3 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
|
|
<span>{ready ? "A vs B" : "awaiting picks"}</span>
|
|
<span className="text-muted-foreground/30" aria-hidden>
|
|
·
|
|
</span>
|
|
<span>
|
|
{batches?.length ?? 0} batch{(batches?.length ?? 0) === 1 ? "" : "es"}{" "}
|
|
on file
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 mono text-[10.5px] uppercase tracking-[0.18em] text-muted-foreground/60">
|
|
<span>refresh</span>
|
|
<kbd className="rounded-sm border border-border/60 bg-card/40 px-1.5 py-0.5 not-italic text-foreground/80">
|
|
R
|
|
</kbd>
|
|
</div>
|
|
</section>
|
|
|
|
{/* 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. */}
|
|
<RemitDrawer
|
|
remitId={remitId}
|
|
remits={[]}
|
|
onClose={close}
|
|
onNavigate={open}
|
|
onToggleHelp={() => {
|
|
// BatchDiff has no cheatsheet surface; `?` is a no-op
|
|
// here, but the prop is required by the drawer's contract.
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|