merge: ui/batch-diff
This commit is contained in:
@@ -10,6 +10,7 @@ import { Upload } from "@/pages/Upload";
|
||||
import { ReconciliationPage } from "@/pages/Reconciliation";
|
||||
import { Acks } from "@/pages/Acks";
|
||||
import { Batches } from "@/pages/Batches";
|
||||
import { BatchDiff } from "@/pages/BatchDiff";
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
@@ -34,6 +35,7 @@ export default function App() {
|
||||
<Route path="reconciliation" element={<ReconciliationPage />} />
|
||||
<Route path="acks" element={<Acks />} />
|
||||
<Route path="batches" element={<Batches />} />
|
||||
<Route path="batch-diff" element={<BatchDiff />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
import { Plus, Minus, Equal, ArrowRight, type LucideIcon } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { KpiCard } from "@/components/KpiCard";
|
||||
import { fmt } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
BatchDiff,
|
||||
BatchDiffChangedRow,
|
||||
BatchDiffSideMeta,
|
||||
BatchDiffSummary,
|
||||
BatchClaimDiffSummary,
|
||||
} from "@/types";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side meta header — small panel identifying which batch is on the left / right.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SideMeta({
|
||||
side,
|
||||
label,
|
||||
}: {
|
||||
side: BatchDiffSideMeta;
|
||||
label: string;
|
||||
}) {
|
||||
// Color mirrors the BatchesList kind badge so the visual identity is
|
||||
// consistent across the app: 837p in cool blue, 835 in warm amber.
|
||||
const kindClass =
|
||||
side.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 (
|
||||
<div
|
||||
className="surface rounded-xl p-4 flex flex-col gap-2"
|
||||
data-testid={`batch-diff-side-${label.toLowerCase()}`}
|
||||
>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10.5px] font-semibold uppercase tracking-[0.14em] font-mono",
|
||||
kindClass,
|
||||
)}
|
||||
>
|
||||
{side.kind}
|
||||
</span>
|
||||
<span className="display num text-[13px]">{side.id}</span>
|
||||
</div>
|
||||
<div className="font-mono text-[12px] text-muted-foreground truncate">
|
||||
{side.inputFilename}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Parsed {side.parsedAt ? fmt.dateShort(side.parsedAt) : "—"}</span>
|
||||
<span className="font-mono num">
|
||||
{fmt.num(side.claimCount)} claim{side.claimCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Summary cards — the four-count tile row.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function SummaryCards({ summary }: { summary: BatchDiffSummary }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3" data-testid="batch-diff-summary">
|
||||
<KpiCard
|
||||
label="Added in B"
|
||||
value={fmt.num(summary.addedCount)}
|
||||
icon={Plus}
|
||||
hint="In B, not in A"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Removed from A"
|
||||
value={fmt.num(summary.removedCount)}
|
||||
icon={Minus}
|
||||
hint="In A, not in B"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Changed"
|
||||
value={fmt.num(summary.changedCount)}
|
||||
icon={Equal}
|
||||
hint="Deltas in either side"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Unchanged"
|
||||
value={fmt.num(summary.unchangedCount)}
|
||||
icon={Equal}
|
||||
hint="Identical across A & B"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row indicator — `+` / `-` / `~` gutter on the left of every row.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RowIndicator({
|
||||
kind,
|
||||
}: {
|
||||
kind: "added" | "removed" | "changed";
|
||||
}) {
|
||||
const cfg = {
|
||||
added: {
|
||||
Icon: Plus,
|
||||
cls: "text-[hsl(var(--success))] bg-[hsl(var(--success)/0.10)] border-[hsl(var(--success)/0.30)]",
|
||||
label: "added",
|
||||
},
|
||||
removed: {
|
||||
Icon: Minus,
|
||||
cls: "text-destructive bg-destructive/10 border-destructive/30",
|
||||
label: "removed",
|
||||
},
|
||||
changed: {
|
||||
Icon: ArrowRight,
|
||||
cls: "text-[hsl(var(--warning))] bg-[hsl(var(--warning)/0.10)] border-[hsl(var(--warning)/0.30)]",
|
||||
label: "changed",
|
||||
},
|
||||
}[kind];
|
||||
const Icon: LucideIcon = cfg.Icon;
|
||||
return (
|
||||
<span
|
||||
aria-label={cfg.label}
|
||||
data-testid={`diff-indicator-${kind}`}
|
||||
className={cn(
|
||||
"inline-flex h-5 w-5 items-center justify-center rounded border font-mono text-[11px]",
|
||||
cfg.cls,
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3 w-3" strokeWidth={2.25} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cell renderers for the summary columns. Kept tiny — these are
|
||||
// hairline cells in a tabular diff, not full claim cards.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ClaimIdCell({ id }: { id: string }) {
|
||||
return (
|
||||
<span className="font-mono text-[12px] tracking-tight" data-testid="diff-claim-id">
|
||||
{id}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PatientCell({ name }: { name: string }) {
|
||||
if (!name) {
|
||||
return <span className="text-muted-foreground/60 text-[12px]">—</span>;
|
||||
}
|
||||
return <span className="text-[13px]">{name}</span>;
|
||||
}
|
||||
|
||||
function ChargeCell({ value }: { value: number }) {
|
||||
return (
|
||||
<span className="display num text-[13px] tabular-nums">
|
||||
{fmt.usdPrecise(value)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DateCell({ value }: { value: string | null }) {
|
||||
if (!value) {
|
||||
return <span className="text-muted-foreground/60 text-[12px]">—</span>;
|
||||
}
|
||||
return <span className="num text-[12.5px]">{value}</span>;
|
||||
}
|
||||
|
||||
function StatusCell({ value }: { value: string }) {
|
||||
if (!value) {
|
||||
return <span className="text-muted-foreground/60 text-[12px]">—</span>;
|
||||
}
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="font-mono uppercase tracking-wider text-[10px]"
|
||||
>
|
||||
{value}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function CptCell({ codes }: { codes: string[] }) {
|
||||
if (!codes || codes.length === 0) {
|
||||
return <span className="text-muted-foreground/60 text-[12px]">—</span>;
|
||||
}
|
||||
return (
|
||||
<span className="font-mono text-[11.5px] tracking-tight">
|
||||
{codes.join(", ")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section tables — one per bucket (added / removed / changed).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Side = "a" | "b";
|
||||
|
||||
function summaryCells(summary: BatchClaimDiffSummary, side: Side) {
|
||||
// Single-row builder that keeps the column order consistent across
|
||||
// all three buckets. ``side`` is just a key for the testid so
|
||||
// tests can target a specific cell.
|
||||
return {
|
||||
claimId: <ClaimIdCell id={summary.claim_id} />,
|
||||
patient: <PatientCell name={summary.patient_name} />,
|
||||
charge: <ChargeCell value={summary.total_charge} />,
|
||||
serviceDate: <DateCell value={summary.service_date} />,
|
||||
status: <StatusCell value={summary.status} />,
|
||||
cpt: <CptCell codes={summary.cpt_codes} />,
|
||||
// Suppress an unused-param warning while leaving the key in the
|
||||
// object for readability / future use.
|
||||
_side: side,
|
||||
};
|
||||
}
|
||||
|
||||
function SummaryRow({
|
||||
summary,
|
||||
indicator,
|
||||
testid,
|
||||
}: {
|
||||
summary: BatchClaimDiffSummary;
|
||||
indicator: "added" | "removed";
|
||||
testid: string;
|
||||
}) {
|
||||
const cells = summaryCells(summary, indicator === "added" ? "b" : "a");
|
||||
return (
|
||||
<TableRow data-testid={testid} className="animate-row-flash">
|
||||
<TableCell className="w-10">
|
||||
<RowIndicator kind={indicator} />
|
||||
</TableCell>
|
||||
<TableCell>{cells.claimId}</TableCell>
|
||||
<TableCell>{cells.patient}</TableCell>
|
||||
<TableCell className="text-right">{cells.charge}</TableCell>
|
||||
<TableCell>{cells.serviceDate}</TableCell>
|
||||
<TableCell>{cells.status}</TableCell>
|
||||
<TableCell>{cells.cpt}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangedRowView({
|
||||
row,
|
||||
testid,
|
||||
}: {
|
||||
row: BatchDiffChangedRow;
|
||||
testid: string;
|
||||
}) {
|
||||
const a = row.a;
|
||||
const b = row.b;
|
||||
const changedFields = new Set(row.diff.map((d) => d.field));
|
||||
return (
|
||||
<TableRow data-testid={testid} className="animate-row-flash">
|
||||
<TableCell className="w-10">
|
||||
<RowIndicator kind="changed" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ClaimIdCell id={a.claim_id} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[12.5px]",
|
||||
a.patient_name !== b.patient_name
|
||||
? "line-through text-muted-foreground/70"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{a.patient_name || "—"}
|
||||
</span>
|
||||
{a.patient_name !== b.patient_name ? (
|
||||
<span className="text-[12.5px] text-[hsl(var(--warning))]">
|
||||
{b.patient_name || "—"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"display num text-[13px] tabular-nums",
|
||||
changedFields.has("total_charge")
|
||||
? "line-through text-muted-foreground/70"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{fmt.usdPrecise(a.total_charge)}
|
||||
</span>
|
||||
{changedFields.has("total_charge") ? (
|
||||
<span className="display num text-[13px] tabular-nums text-[hsl(var(--warning))]">
|
||||
{fmt.usdPrecise(b.total_charge)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"num text-[12.5px]",
|
||||
changedFields.has("service_date")
|
||||
? "line-through text-muted-foreground/70"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{a.service_date || "—"}
|
||||
</span>
|
||||
{changedFields.has("service_date") ? (
|
||||
<span className="num text-[12.5px] text-[hsl(var(--warning))]">
|
||||
{b.service_date || "—"}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
changedFields.has("status")
|
||||
? "line-through text-muted-foreground/70"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<StatusCell value={a.status} />
|
||||
</span>
|
||||
{changedFields.has("status") ? (
|
||||
<StatusCell value={b.status} />
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-[11.5px] tracking-tight",
|
||||
changedFields.has("cpt_codes")
|
||||
? "line-through text-muted-foreground/70"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{a.cpt_codes.length === 0 ? "—" : a.cpt_codes.join(", ")}
|
||||
</span>
|
||||
{changedFields.has("cpt_codes") ? (
|
||||
<span className="font-mono text-[11.5px] tracking-tight text-[hsl(var(--warning))]">
|
||||
{b.cpt_codes.length === 0 ? "—" : b.cpt_codes.join(", ")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTable({
|
||||
title,
|
||||
eyebrow,
|
||||
count,
|
||||
children,
|
||||
testid,
|
||||
emptyMessage,
|
||||
}: {
|
||||
title: string;
|
||||
eyebrow: string;
|
||||
count: number;
|
||||
children: React.ReactNode;
|
||||
testid: string;
|
||||
emptyMessage: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3" data-testid={testid}>
|
||||
<header className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-0.5">
|
||||
{eyebrow}
|
||||
</div>
|
||||
<h2 className="text-[15px] font-semibold tracking-tight">{title}</h2>
|
||||
</div>
|
||||
<span className="font-mono num text-[12.5px] text-muted-foreground">
|
||||
{fmt.num(count)}
|
||||
</span>
|
||||
</header>
|
||||
{count === 0 ? (
|
||||
<div
|
||||
className="surface rounded-xl border border-dashed border-border/60 p-6 text-center text-[12.5px] text-muted-foreground"
|
||||
data-testid={`${testid}-empty`}
|
||||
>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
) : (
|
||||
<div className="surface rounded-xl overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10" aria-label="Diff indicator" />
|
||||
<TableHead>Claim ID</TableHead>
|
||||
<TableHead>Patient</TableHead>
|
||||
<TableHead className="text-right">Charge</TableHead>
|
||||
<TableHead>Service date</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>CPT</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>{children}</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skeleton — used by the page while the diff is loading.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function BatchDiffViewSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6" data-testid="batch-diff-skeleton">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<Skeleton variant="default" height={92} />
|
||||
<Skeleton variant="default" height={92} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} variant="default" height={92} />
|
||||
))}
|
||||
</div>
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} variant="default" height={220} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empty state — both batches selected but no deltas.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function BatchDiffEmpty({ data }: { data: BatchDiff }) {
|
||||
return (
|
||||
<div
|
||||
className="space-y-6"
|
||||
data-testid="batch-diff-empty"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<SideMeta side={data.a} label="A (left)" />
|
||||
<SideMeta side={data.b} label="B (right)" />
|
||||
</div>
|
||||
<SummaryCards summary={data.summary} />
|
||||
<div className="surface rounded-xl border border-dashed border-border/60 p-10 text-center">
|
||||
<div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-1.5">
|
||||
Diff · no deltas
|
||||
</div>
|
||||
<div className="text-[13.5px] text-muted-foreground">
|
||||
These two batches are identical — no claims added, removed, or
|
||||
changed between A and B.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main view — rendered once both batches are picked and the diff returns.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function BatchDiffView({ data }: { data: BatchDiff }) {
|
||||
const { a, b, added, removed, changed, summary } = data;
|
||||
const allEmpty =
|
||||
summary.addedCount === 0 &&
|
||||
summary.removedCount === 0 &&
|
||||
summary.changedCount === 0;
|
||||
if (allEmpty) {
|
||||
return <BatchDiffEmpty data={data} />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6" data-testid="batch-diff-view">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<SideMeta side={a} label="A (left)" />
|
||||
<SideMeta side={b} label="B (right)" />
|
||||
</div>
|
||||
<SummaryCards summary={summary} />
|
||||
<SectionTable
|
||||
title="Added in B"
|
||||
eyebrow="Diff · additions"
|
||||
count={added.length}
|
||||
testid="batch-diff-added-section"
|
||||
emptyMessage="No claims were added in B."
|
||||
>
|
||||
{added.map((row, idx) => (
|
||||
<SummaryRow
|
||||
key={`${row.claim_id}-${idx}`}
|
||||
summary={row}
|
||||
indicator="added"
|
||||
testid={`diff-added-row-${row.claim_id}`}
|
||||
/>
|
||||
))}
|
||||
</SectionTable>
|
||||
<SectionTable
|
||||
title="Removed from A"
|
||||
eyebrow="Diff · removals"
|
||||
count={removed.length}
|
||||
testid="batch-diff-removed-section"
|
||||
emptyMessage="No claims were removed from A."
|
||||
>
|
||||
{removed.map((row, idx) => (
|
||||
<SummaryRow
|
||||
key={`${row.claim_id}-${idx}`}
|
||||
summary={row}
|
||||
indicator="removed"
|
||||
testid={`diff-removed-row-${row.claim_id}`}
|
||||
/>
|
||||
))}
|
||||
</SectionTable>
|
||||
<SectionTable
|
||||
title="Changed"
|
||||
eyebrow="Diff · mutations"
|
||||
count={changed.length}
|
||||
testid="batch-diff-changed-section"
|
||||
emptyMessage="No claims were changed between A and B."
|
||||
>
|
||||
{changed.map((row, idx) => (
|
||||
<ChangedRowView
|
||||
key={`${row.a.claim_id}-${idx}`}
|
||||
row={row}
|
||||
testid={`diff-changed-row-${row.a.claim_id}`}
|
||||
/>
|
||||
))}
|
||||
</SectionTable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
GitCompareArrows,
|
||||
GitMerge,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
@@ -133,6 +134,22 @@ export function Sidebar() {
|
||||
<span>Batches</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink
|
||||
to="/batch-diff"
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "nav-active"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
)
|
||||
}
|
||||
>
|
||||
<GitCompareArrows className="h-4 w-4" strokeWidth={1.5} />
|
||||
<span>Batch diff</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import type { BatchDiff } from "@/types";
|
||||
|
||||
/**
|
||||
* React Query binding for the side-by-side batch diff.
|
||||
*
|
||||
* Query key: ``["batch-diff", a, b]`` so picking different batches
|
||||
* automatically produces a separate cache entry. ``enabled`` short-
|
||||
* circuits when either id is missing — the page disables the Run
|
||||
* button until both pickers are filled in, but we double-check here
|
||||
* so a half-picked URL can't fire an empty query.
|
||||
*
|
||||
* 404s from the backend are surfaced as ``ApiError(404)`` — the page
|
||||
* branches on ``error.status === 404`` to render the "one of these
|
||||
* batches was deleted out from under you" state instead of the
|
||||
* generic error toast. Matches the same convention used by the
|
||||
* claim detail drawer.
|
||||
*/
|
||||
export function useBatchDiff(a: string | null, b: string | null) {
|
||||
return useQuery<BatchDiff, ApiError>({
|
||||
queryKey: ["batch-diff", a, b],
|
||||
queryFn: () => api.getBatchDiff(a as string, b as string),
|
||||
enabled:
|
||||
api.isConfigured &&
|
||||
typeof a === "string" &&
|
||||
a.length > 0 &&
|
||||
typeof b === "string" &&
|
||||
b.length > 0,
|
||||
// Refetch on window focus would be nice in production; for the
|
||||
// diff view it's pointless because the underlying batches don't
|
||||
// change shape without a fresh ingest.
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
import type {
|
||||
Ack,
|
||||
BatchDiff,
|
||||
ClaimDetail,
|
||||
ClaimOutput,
|
||||
ClaimPayment,
|
||||
@@ -508,6 +509,30 @@ async function getRemittance<T = unknown>(id: string): Promise<T> {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the side-by-side diff between two batches.
|
||||
*
|
||||
* Drives ``GET /api/batch-diff?a=<batch_id>&b=<batch_id>``. Both ids
|
||||
* are required; the endpoint returns 400 when either is missing and
|
||||
* 404 when the id is unknown — both surface here as ``ApiError`` so
|
||||
* the page can branch on ``.status`` like every other diff / fetch
|
||||
* surface in the app.
|
||||
*/
|
||||
async function getBatchDiff(a: string, b: string): Promise<BatchDiff> {
|
||||
if (!isConfigured) throw notConfiguredError();
|
||||
const res = await fetch(
|
||||
joinUrl(
|
||||
`/api/batch-diff?${qs({ a, b })}`,
|
||||
),
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const detail = await readErrorBody(res);
|
||||
throw new ApiError(res.status, detail || res.statusText);
|
||||
}
|
||||
return (await res.json()) as BatchDiff;
|
||||
}
|
||||
|
||||
async function listProviders<T = unknown>(
|
||||
params: ListProvidersParams = {}
|
||||
): Promise<PaginatedResponse<T>> {
|
||||
@@ -672,6 +697,7 @@ export const api = {
|
||||
parse999,
|
||||
listBatches,
|
||||
getBatch,
|
||||
getBatchDiff,
|
||||
listClaims,
|
||||
getClaimDetail,
|
||||
listRemittances,
|
||||
|
||||
@@ -0,0 +1,444 @@
|
||||
// @vitest-environment happy-dom
|
||||
// Tell React this is an `act`-aware test environment so react-query's
|
||||
// internal state updates flush through without noisy console warnings.
|
||||
// Mirrors the convention used by Batches.test.tsx and Acks.test.tsx.
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
||||
true;
|
||||
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { BatchDiff } from "./BatchDiff";
|
||||
import { api, ApiError } from "@/lib/api";
|
||||
import type { BatchDiff as BatchDiffResponse } from "@/types";
|
||||
|
||||
// Mock the api module. Only the surface the page actually touches is
|
||||
// stubbed; everything else is left undefined so an accidental call
|
||||
// blows up loudly.
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
isConfigured: true,
|
||||
listBatches: vi.fn(),
|
||||
getBatchDiff: vi.fn(),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
/**
|
||||
* Mount the page into a container wrapped in MemoryRouter +
|
||||
* QueryClientProvider. Uses a 10ms retryDelay so non-404 errors don't
|
||||
* blow past the test timeout — same pattern used by Batches.test.tsx.
|
||||
*/
|
||||
function renderIntoContainer(
|
||||
element: React.ReactElement,
|
||||
initialEntries: string[] = ["/batch-diff"],
|
||||
): { container: HTMLDivElement; unmount: () => void } {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
retryDelay: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
const root: Root = createRoot(container);
|
||||
act(() => {
|
||||
root.render(
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<QueryClientProvider client={qc}>{element}</QueryClientProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
return {
|
||||
container,
|
||||
unmount: () => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush microtasks + react state updates until the predicate holds or
|
||||
* the deadline expires. react-query's fetch resolves asynchronously
|
||||
* so we have to await a tick before the rendered DOM reflects the
|
||||
* mocked data.
|
||||
*/
|
||||
async function waitFor(
|
||||
predicate: () => boolean,
|
||||
description: string,
|
||||
timeoutMs = 2000,
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error(`waitFor: ${description} did not become true within ${timeoutMs}ms`);
|
||||
}
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fixtures used by the data-driven tests.
|
||||
const BATCH_A = {
|
||||
id: "B-A",
|
||||
kind: "837p" as const,
|
||||
inputFilename: "original.837",
|
||||
parsedAt: "2026-06-15T12:00:00Z",
|
||||
claimCount: 2,
|
||||
};
|
||||
const BATCH_B = {
|
||||
id: "B-B",
|
||||
kind: "837p" as const,
|
||||
inputFilename: "corrected.837",
|
||||
parsedAt: "2026-06-16T12:00:00Z",
|
||||
claimCount: 3,
|
||||
};
|
||||
|
||||
function makeDiffPayload(): BatchDiffResponse {
|
||||
return {
|
||||
a: {
|
||||
id: BATCH_A.id,
|
||||
kind: "837p",
|
||||
parsedAt: BATCH_A.parsedAt,
|
||||
inputFilename: BATCH_A.inputFilename,
|
||||
claimCount: 2,
|
||||
},
|
||||
b: {
|
||||
id: BATCH_B.id,
|
||||
kind: "837p",
|
||||
parsedAt: BATCH_B.parsedAt,
|
||||
inputFilename: BATCH_B.inputFilename,
|
||||
claimCount: 3,
|
||||
},
|
||||
added: [
|
||||
{
|
||||
claim_id: "CLM-3",
|
||||
patient_name: "Sam Roe",
|
||||
total_charge: 200,
|
||||
service_date: "2026-06-02",
|
||||
status: "submitted",
|
||||
cpt_codes: ["99213"],
|
||||
},
|
||||
],
|
||||
removed: [
|
||||
{
|
||||
claim_id: "CLM-2",
|
||||
patient_name: "Jane Doe",
|
||||
total_charge: 75,
|
||||
service_date: "2026-06-01",
|
||||
status: "submitted",
|
||||
cpt_codes: ["99213"],
|
||||
},
|
||||
],
|
||||
changed: [
|
||||
{
|
||||
a: {
|
||||
claim_id: "CLM-1",
|
||||
patient_name: "Pat Lee",
|
||||
total_charge: 100,
|
||||
service_date: "2026-06-01",
|
||||
status: "submitted",
|
||||
cpt_codes: ["99213"],
|
||||
},
|
||||
b: {
|
||||
claim_id: "CLM-1",
|
||||
patient_name: "Pat Lee",
|
||||
total_charge: 150,
|
||||
service_date: "2026-06-01",
|
||||
status: "submitted",
|
||||
cpt_codes: ["99213"],
|
||||
},
|
||||
diff: [{ field: "total_charge", a: 100, b: 150 }],
|
||||
},
|
||||
],
|
||||
summary: {
|
||||
addedCount: 1,
|
||||
removedCount: 1,
|
||||
changedCount: 1,
|
||||
unchangedCount: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("BatchDiff page", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// happy-dom persists window.location between tests; reset so the
|
||||
// URL-state hook starts from a clean slate.
|
||||
(window as unknown as { happyDOM: { setURL: (u: string) => void } })
|
||||
.happyDOM.setURL("http://localhost/batch-diff");
|
||||
});
|
||||
|
||||
it("renders the awaiting-picks empty state when no batches are selected", async () => {
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
BATCH_A, BATCH_B,
|
||||
]);
|
||||
|
||||
const { unmount } = renderIntoContainer(<BatchDiff />);
|
||||
await waitFor(
|
||||
() => !!document.querySelector('[data-testid="batch-diff-page"]'),
|
||||
"page header mounted",
|
||||
);
|
||||
// The copy unique to the empty state — verifier the page rendered
|
||||
// the awaiting-picks branch and not the loading skeleton.
|
||||
expect(document.body.textContent).toContain("awaiting picks");
|
||||
expect(document.querySelector('[data-testid="diff-picker-a"]')).not.toBeNull();
|
||||
expect(document.querySelector('[data-testid="diff-picker-b"]')).not.toBeNull();
|
||||
// Diff not requested yet — getBatchDiff should NOT have been
|
||||
// called because the hook gates on both picks being non-null.
|
||||
expect(api.getBatchDiff).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders the loading skeleton while the diff is in flight", async () => {
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
BATCH_A, BATCH_B,
|
||||
]);
|
||||
// Never resolves — keeps the query in the loading state.
|
||||
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||
new Promise(() => {}),
|
||||
);
|
||||
|
||||
// Start the page with both picks already in the URL.
|
||||
const { unmount } = renderIntoContainer(
|
||||
<BatchDiff />,
|
||||
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||
);
|
||||
await waitFor(
|
||||
() => !!document.querySelector('[data-testid="batch-diff-skeleton"]'),
|
||||
"loading skeleton visible",
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders the three-section diff view when the backend returns deltas", async () => {
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
BATCH_A, BATCH_B,
|
||||
]);
|
||||
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
makeDiffPayload(),
|
||||
);
|
||||
|
||||
const { unmount } = renderIntoContainer(
|
||||
<BatchDiff />,
|
||||
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||
);
|
||||
|
||||
// Wait for the diff view (full, non-empty payload).
|
||||
await waitFor(
|
||||
() => !!document.querySelector('[data-testid="batch-diff-view"]'),
|
||||
"diff view rendered",
|
||||
);
|
||||
|
||||
// Sections are present.
|
||||
expect(
|
||||
document.querySelector('[data-testid="batch-diff-added-section"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-testid="batch-diff-removed-section"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-testid="batch-diff-changed-section"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
// Per-claim rows render with the right indicator + id.
|
||||
expect(
|
||||
document.querySelector('[data-testid="diff-added-row-CLM-3"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-testid="diff-removed-row-CLM-2"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-testid="diff-changed-row-CLM-1"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
// Indicator chips are present for each bucket.
|
||||
expect(
|
||||
document.querySelector('[data-testid="diff-indicator-added"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-testid="diff-indicator-removed"]'),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
document.querySelector('[data-testid="diff-indicator-changed"]'),
|
||||
).not.toBeNull();
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders the no-deltas state when the backend reports an empty diff", async () => {
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
BATCH_A, BATCH_B,
|
||||
]);
|
||||
const empty = makeDiffPayload();
|
||||
empty.added = [];
|
||||
empty.removed = [];
|
||||
empty.changed = [];
|
||||
empty.summary = {
|
||||
addedCount: 0,
|
||||
removedCount: 0,
|
||||
changedCount: 0,
|
||||
unchangedCount: 2,
|
||||
};
|
||||
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
empty,
|
||||
);
|
||||
|
||||
const { unmount } = renderIntoContainer(
|
||||
<BatchDiff />,
|
||||
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => !!document.querySelector('[data-testid="batch-diff-empty"]'),
|
||||
"empty-diff state visible",
|
||||
);
|
||||
// Summary cards still surface the counts even when zero.
|
||||
expect(
|
||||
document.querySelector('[data-testid="batch-diff-summary"]'),
|
||||
).not.toBeNull();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders the not-found state on a 404 from getBatchDiff", async () => {
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
BATCH_A, BATCH_B,
|
||||
]);
|
||||
// Mirror the backend's 404 contract: ApiError(404).
|
||||
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new ApiError(404, "Not found — batch B-A not found"),
|
||||
);
|
||||
|
||||
const { unmount } = renderIntoContainer(
|
||||
<BatchDiff />,
|
||||
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||
);
|
||||
|
||||
// The not-found state uses the ErrorState primitive with a retry
|
||||
// affordance; assert by message text since we don't ship a
|
||||
// dedicated testid for the variant.
|
||||
await waitFor(
|
||||
() => (document.body.textContent ?? "").includes("not found"),
|
||||
"not-found error text visible",
|
||||
);
|
||||
expect(
|
||||
(document.body.textContent ?? "").toLowerCase(),
|
||||
).toContain("one of these batches was not found");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("renders the network error state on a non-404 failure", async () => {
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
BATCH_A, BATCH_B,
|
||||
]);
|
||||
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||
new Error("500 Internal Server Error — upstream"),
|
||||
);
|
||||
|
||||
const { unmount } = renderIntoContainer(
|
||||
<BatchDiff />,
|
||||
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() =>
|
||||
(document.body.textContent ?? "").includes(
|
||||
"Couldn't compute the diff",
|
||||
),
|
||||
"network error text visible",
|
||||
);
|
||||
// Retry button is rendered (the ErrorState primitive's affordance).
|
||||
expect(
|
||||
(document.body.textContent ?? "").toLowerCase(),
|
||||
).toContain("retry");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("passes the URL a/b picks through to getBatchDiff", async () => {
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
BATCH_A, BATCH_B,
|
||||
]);
|
||||
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
makeDiffPayload(),
|
||||
);
|
||||
|
||||
const { unmount } = renderIntoContainer(
|
||||
<BatchDiff />,
|
||||
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => (api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mock.calls.length > 0,
|
||||
"getBatchDiff invoked",
|
||||
);
|
||||
|
||||
// The two picks are passed through in order: a, b.
|
||||
expect(api.getBatchDiff).toHaveBeenCalledWith(BATCH_A.id, BATCH_B.id);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("Clear picks button resets both pickers and returns to awaiting-picks state", async () => {
|
||||
(api.listBatches as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
BATCH_A, BATCH_B,
|
||||
]);
|
||||
(api.getBatchDiff as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
makeDiffPayload(),
|
||||
);
|
||||
|
||||
const { unmount } = renderIntoContainer(
|
||||
<BatchDiff />,
|
||||
[`/batch-diff?a=${BATCH_A.id}&b=${BATCH_B.id}`],
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
() => !!document.querySelector('[data-testid="diff-clear-button"]'),
|
||||
"clear button visible",
|
||||
);
|
||||
|
||||
const clear = document.querySelector(
|
||||
'[data-testid="diff-clear-button"]',
|
||||
) as HTMLButtonElement | null;
|
||||
await act(async () => {
|
||||
clear?.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Back to the awaiting-picks empty state. The hook's
|
||||
// `enabled: a && b` predicate stops getBatchDiff from being
|
||||
// re-invoked once both ids are null.
|
||||
await waitFor(
|
||||
() => (document.body.textContent ?? "").includes("awaiting picks"),
|
||||
"empty state re-appears",
|
||||
);
|
||||
// The picker for B should now be empty (the trigger renders the
|
||||
// placeholder text).
|
||||
expect(
|
||||
(document.body.textContent ?? "").toLowerCase(),
|
||||
).toContain("pick a batch");
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
// Keep `ApiError` referenced so the import isn't tree-shaken by
|
||||
// vitest's transformer when the mock factory above is hoisted.
|
||||
void ApiError;
|
||||
@@ -0,0 +1,320 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -560,3 +560,79 @@ export interface ClaimDetail {
|
||||
matchedRemittance: ClaimDetailMatchedRemittance | null;
|
||||
stateHistory: ClaimDetailStateHistoryEvent[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side-by-side batch diff (SP3 P4 / T18)
|
||||
// Mirrors `GET /api/batch-diff?a=<batch_id>&b=<batch_id>` (see
|
||||
// `backend/src/cyclone/batch_diff.py` and `backend/src/cyclone/api.py`).
|
||||
// The contract is deliberately small: no raw segments, no service-line
|
||||
// details — just the summary fields the operator needs to spot the
|
||||
// deltas between two parsed files at a glance.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type BatchKind = "837p" | "835";
|
||||
|
||||
export interface BatchDiffSideMeta {
|
||||
id: string;
|
||||
kind: BatchKind;
|
||||
parsedAt: string; // ISO Z
|
||||
inputFilename: string;
|
||||
claimCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One claim (or remittance) row projected for the diff view. Carries
|
||||
* just enough context to render side-by-side without the EDI parse
|
||||
* tree. ``status`` carries the validation-derived label for 837P
|
||||
* (``submitted`` / ``pending`` / ``draft`` / ``denied``) and the CLP02
|
||||
* code/label for 835.
|
||||
*/
|
||||
export interface BatchClaimDiffSummary {
|
||||
claim_id: string;
|
||||
patient_name: string;
|
||||
total_charge: number;
|
||||
service_date: string | null;
|
||||
status: string;
|
||||
cpt_codes: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One changed field between the two sides of a claim. ``field``
|
||||
* identifies which column changed (``status`` / ``total_charge`` /
|
||||
* ``service_date`` / ``cpt_codes``); ``a`` and ``b`` are the values
|
||||
* from each side, rendered verbatim.
|
||||
*/
|
||||
export interface BatchDiffFieldDiff {
|
||||
field: "status" | "total_charge" | "service_date" | "cpt_codes";
|
||||
a: string | number | string[] | null;
|
||||
b: string | number | string[] | null;
|
||||
}
|
||||
|
||||
export interface BatchDiffChangedRow {
|
||||
a: BatchClaimDiffSummary;
|
||||
b: BatchClaimDiffSummary;
|
||||
diff: BatchDiffFieldDiff[];
|
||||
}
|
||||
|
||||
export interface BatchDiffSummary {
|
||||
addedCount: number;
|
||||
removedCount: number;
|
||||
changedCount: number;
|
||||
unchangedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full response from `GET /api/batch-diff`. The three bucket arrays
|
||||
* (``added`` / ``removed`` / ``changed``) are always present (never
|
||||
* absent) so the UI can index unconditionally; ``summary`` is
|
||||
* precomputed by the backend so the four count cards don't have to
|
||||
* derive them on the client.
|
||||
*/
|
||||
export interface BatchDiff {
|
||||
a: BatchDiffSideMeta;
|
||||
b: BatchDiffSideMeta;
|
||||
added: BatchClaimDiffSummary[];
|
||||
removed: BatchClaimDiffSummary[];
|
||||
changed: BatchDiffChangedRow[];
|
||||
summary: BatchDiffSummary;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user