548 lines
17 KiB
TypeScript
548 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|