"use client"; import { useMemo, useState } from "react"; import { clsx } from "clsx"; import { compareEstimateVersions, type VersionCompareInput, type ChapterSubtotal, type ResourceSnapshotDiff, type ScopeItemDiff, } from "@capakraken/engine"; import type { EstimateVersionView } from "~/components/estimates/EstimateWorkspace.types.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { formatMoney } from "~/lib/format.js"; function formatDelta(value: number, formatter: (v: number) => string) { const prefix = value > 0 ? "+" : ""; return `${prefix}${formatter(value)}`; } function formatHoursDelta(delta: number) { const prefix = delta > 0 ? "+" : ""; return `${prefix}${delta.toFixed(1)} h`; } function versionToInput(v: EstimateVersionView): VersionCompareInput { return { label: v.label ?? null, versionNumber: v.versionNumber, demandLines: v.demandLines.map((l) => ({ id: l.id, name: l.name, hours: l.hours, costRateCents: l.costRateCents, billRateCents: l.billRateCents, costTotalCents: l.costTotalCents, priceTotalCents: l.priceTotalCents, ...(l.chapter !== undefined ? { chapter: l.chapter } : {}), lineType: l.lineType, })), assumptions: v.assumptions.map((a) => ({ key: a.key, label: a.label, value: a.value, })), scopeItems: v.scopeItems.map((s) => ({ id: s.id, name: s.name, sequenceNo: s.sequenceNo, scopeType: s.scopeType, ...(s.packageCode !== undefined ? { packageCode: s.packageCode } : {}), ...(s.description !== undefined ? { description: s.description } : {}), ...(s.frameCount !== undefined ? { frameCount: s.frameCount } : {}), ...(s.itemCount !== undefined ? { itemCount: s.itemCount } : {}), })), resourceSnapshots: v.resourceSnapshots.map((r) => ({ id: r.id, ...(r.resourceId !== undefined ? { resourceId: r.resourceId } : {}), displayName: r.displayName, ...(r.chapter !== undefined ? { chapter: r.chapter } : {}), currency: r.currency, lcrCents: r.lcrCents, ucrCents: r.ucrCents, ...(r.location !== undefined ? { location: r.location } : {}), ...(r.level !== undefined ? { level: r.level } : {}), })), }; } const STATUS_ROW_STYLES = { added: "bg-emerald-50", removed: "bg-red-50", changed: "bg-amber-50", unchanged: "", } as const; const STATUS_BADGE_STYLES = { added: "bg-emerald-100 text-emerald-700", removed: "bg-red-100 text-red-700", changed: "bg-amber-100 text-amber-700", unchanged: "bg-gray-100 text-gray-500", } as const; export function VersionCompare({ versions }: { versions: EstimateVersionView[] }) { const sorted = useMemo( () => [...versions].sort((a, b) => b.versionNumber - a.versionNumber), [versions], ); const [aId, setAId] = useState(sorted[1]?.id ?? sorted[0]?.id ?? ""); const [bId, setBId] = useState(sorted[0]?.id ?? ""); const [hideUnchanged, setHideUnchanged] = useState(false); const versionA = sorted.find((v) => v.id === aId); const versionB = sorted.find((v) => v.id === bId); const diff = useMemo(() => { if (!versionA || !versionB || versionA.id === versionB.id) return null; return compareEstimateVersions(versionToInput(versionA), versionToInput(versionB)); }, [versionA, versionB]); if (sorted.length < 2) { return (
At least two versions are required to compare.
); } const filteredDemandDiffs = diff ? hideUnchanged ? diff.demandLineDiffs.filter((d) => d.status !== "unchanged") : diff.demandLineDiffs : []; const filteredAssumptionDiffs = diff ? hideUnchanged ? diff.assumptionDiffs.filter((d) => d.status !== "unchanged") : diff.assumptionDiffs : []; const filteredScopeDiffs = diff ? hideUnchanged ? diff.scopeItemDiffs.filter((d) => d.status !== "unchanged") : diff.scopeItemDiffs : []; const filteredResourceDiffs = diff ? hideUnchanged ? diff.resourceSnapshotDiffs.filter((d) => d.status !== "unchanged") : diff.resourceSnapshotDiffs : []; return (
{/* Version selectors */}

Compare versions

vs
{aId === bId && (

Select two different versions to compare.

)}
{diff && ( <> {/* Summary cards */}
formatMoney(v))} positive={diff.summary.totalCostDelta <= 0} /> formatMoney(v))} positive={diff.summary.totalPriceDelta >= 0} /> = 0 ? "+" : ""}${diff.summary.marginPercentDelta.toFixed(1)}pp)`} positive={diff.summary.marginPercentDelta >= 0} />
{/* Demand line diffs */}

Demand lines

{filteredDemandDiffs.length === 0 ? (

{hideUnchanged ? "No changes in demand lines." : "No demand lines to compare."}

) : (
{filteredDemandDiffs.map((d, i) => ( ))}
Name Status Hours (A) Hours (B) Hours delta Cost (A) Cost (B) Cost delta Price (A) Price (B) Price delta
{d.name} {d.status} {d.a?.hours.toFixed(1) ?? "\u2014"} {d.b?.hours.toFixed(1) ?? "\u2014"} {d.hoursDelta != null ? formatHoursDelta(d.hoursDelta) : "\u2014"} {d.a ? formatMoney(d.a.costTotalCents) : "\u2014"} {d.b ? formatMoney(d.b.costTotalCents) : "\u2014"} {d.costDelta != null ? formatDelta(d.costDelta, (v) => formatMoney(v)) : "\u2014"} {d.a ? formatMoney(d.a.priceTotalCents) : "\u2014"} {d.b ? formatMoney(d.b.priceTotalCents) : "\u2014"} {d.priceDelta != null ? formatDelta(d.priceDelta, (v) => formatMoney(v)) : "\u2014"}
)}
{/* Assumption diffs */} {(filteredAssumptionDiffs.length > 0 || !hideUnchanged) && (

Assumptions

{filteredAssumptionDiffs.length === 0 ? (

{hideUnchanged ? "No changes in assumptions." : "No assumptions to compare."}

) : (
{filteredAssumptionDiffs.map((d) => ( ))}
Assumption Status Value (A) Value (B)
{d.label} {d.status} {formatAssumptionValue(d.aValue)} {formatAssumptionValue(d.bValue)}
)}
)} {/* Chapter subtotals */} {diff.chapterSubtotals.length > 0 && (

By chapter

{diff.chapterSubtotals.map((ch) => ( ))}
Chapter Hours (A) Hours (B) Hours delta Cost (A) Cost (B) Cost delta
{ch.chapter} {ch.hoursA.toFixed(1)} {ch.hoursB.toFixed(1)} {formatHoursDelta(ch.hoursDelta)} {formatMoney(ch.costA)} {formatMoney(ch.costB)} {formatDelta(ch.costDelta, (v) => formatMoney(v))}
)} {/* Scope item diffs */} {filteredScopeDiffs.length > 0 && (

Scope items {(diff.summary.scopeItemsAdded > 0 || diff.summary.scopeItemsRemoved > 0 || diff.summary.scopeItemsChanged > 0) && ( {diff.summary.scopeItemsAdded > 0 && +{diff.summary.scopeItemsAdded}} {diff.summary.scopeItemsRemoved > 0 && -{diff.summary.scopeItemsRemoved}} {diff.summary.scopeItemsChanged > 0 && ~{diff.summary.scopeItemsChanged}} )}

{filteredScopeDiffs.map((d, i) => ( ))}
Name Type Status Frames (A) Frames (B) Items (A) Items (B)
{d.name} {d.scopeType} {d.status} {d.a?.frameCount ?? "\u2014"} {d.b?.frameCount ?? "\u2014"} {d.a?.itemCount ?? "\u2014"} {d.b?.itemCount ?? "\u2014"}
)} {/* Resource snapshot diffs */} {filteredResourceDiffs.length > 0 && (

Resource rates

{filteredResourceDiffs.map((d, i) => ( ))}
Name Status LCR (A) LCR (B) UCR (A) UCR (B) Location (A) Location (B)
{d.displayName} {d.status} {d.a ? formatMoney(d.a.lcrCents) : "\u2014"} {d.b ? formatMoney(d.b.lcrCents) : "\u2014"} {d.a ? formatMoney(d.a.ucrCents) : "\u2014"} {d.b ? formatMoney(d.b.ucrCents) : "\u2014"} {d.a?.location ?? "\u2014"} {d.b?.location ?? "\u2014"}
)} )}
); } function SummaryCard({ label, value, positive, }: { label: string; value: string; positive: boolean; }) { return (

{label}

{value}

); } function deltaColor(delta: number | undefined): string { if (delta == null || delta === 0) return "text-gray-400"; return delta > 0 ? "text-red-600" : "text-emerald-600"; } function formatAssumptionValue(value: unknown): string { if (value === undefined) return "\u2014"; if (value === null) return "null"; if (typeof value === "object") return JSON.stringify(value); return String(value); }