merge: ui/batch-diff
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user