feat(batch-diff): side-by-side claim diff between two batches

This commit is contained in:
Tyler
2026-06-20 17:17:09 -06:00
parent 4cd52c3084
commit 7b394fff1a
12 changed files with 2556 additions and 0 deletions
+547
View File
@@ -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>
);
}