cd78f72f33
Complete rename of all technical identifiers across the codebase: Package names (11 packages): - @planarchy/* → @capakraken/* in all package.json, tsconfig, imports Import statements: 277 files, 548 occurrences replaced Database & Docker: - PostgreSQL user/db: planarchy → capakraken - Docker volumes: planarchy_pgdata → capakraken_pgdata - Connection strings updated in docker-compose, .env, CI CI/CD: - GitHub Actions workflow: all filter commands updated - Test database credentials updated Infrastructure: - Redis channel: planarchy:sse → capakraken:sse - Logger service name: planarchy-api → capakraken-api - Anonymization seed updated - Start/stop/restart scripts updated Test data: - Seed emails: @planarchy.dev → @capakraken.dev - E2E test credentials: all 11 spec files updated - Email defaults: @planarchy.app → @capakraken.app - localStorage keys: planarchy_* → capakraken_* Documentation: 30+ .md files updated Verification: - pnpm install: workspace resolution works - TypeScript: only pre-existing TS2589 (no new errors) - Engine: 310/310 tests pass - Staffing: 37/37 tests pass Co-Authored-By: claude-flow <ruv@ruv.net>
485 lines
23 KiB
TypeScript
485 lines
23 KiB
TypeScript
"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<string>(sorted[1]?.id ?? sorted[0]?.id ?? "");
|
|
const [bId, setBId] = useState<string>(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 (
|
|
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-8 text-center text-sm text-gray-500">
|
|
At least two versions are required to compare.
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="space-y-6">
|
|
{/* Version selectors */}
|
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
|
<h3 className="mb-4 text-base font-semibold text-gray-900">Compare versions <InfoTooltip content="Select two version snapshots to see what changed in demand lines, scope, assumptions, and resource rates between them." /></h3>
|
|
<div className="flex flex-wrap items-end gap-4">
|
|
<label className="block">
|
|
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Base (A) <InfoTooltip content="The older or reference version to compare from." /></span>
|
|
<select
|
|
value={aId}
|
|
onChange={(e) => setAId(e.target.value)}
|
|
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900 focus:border-brand-400 focus:outline-none focus:ring-1 focus:ring-brand-400"
|
|
>
|
|
{sorted.map((v) => (
|
|
<option key={v.id} value={v.id}>
|
|
v{v.versionNumber} {v.label ? `\u2014 ${v.label}` : ""} ({v.status})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<span className="pb-2 text-sm text-gray-400">vs</span>
|
|
|
|
<label className="block">
|
|
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Compare (B) <InfoTooltip content="The newer or target version to compare against." /></span>
|
|
<select
|
|
value={bId}
|
|
onChange={(e) => setBId(e.target.value)}
|
|
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900 focus:border-brand-400 focus:outline-none focus:ring-1 focus:ring-brand-400"
|
|
>
|
|
{sorted.map((v) => (
|
|
<option key={v.id} value={v.id}>
|
|
v{v.versionNumber} {v.label ? `\u2014 ${v.label}` : ""} ({v.status})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
|
|
<label className="flex items-center gap-2 pb-2 text-sm text-gray-600">
|
|
<input
|
|
type="checkbox"
|
|
checked={hideUnchanged}
|
|
onChange={(e) => setHideUnchanged(e.target.checked)}
|
|
className="rounded border-gray-300"
|
|
/>
|
|
Hide unchanged
|
|
</label>
|
|
</div>
|
|
|
|
{aId === bId && (
|
|
<p className="mt-3 text-sm text-amber-600">Select two different versions to compare.</p>
|
|
)}
|
|
</div>
|
|
|
|
{diff && (
|
|
<>
|
|
{/* Summary cards */}
|
|
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
|
|
<SummaryCard
|
|
label="Hours"
|
|
value={formatHoursDelta(diff.summary.totalHoursDelta)}
|
|
positive={diff.summary.totalHoursDelta <= 0}
|
|
/>
|
|
<SummaryCard
|
|
label="Cost"
|
|
value={formatDelta(diff.summary.totalCostDelta, (v) => formatMoney(v))}
|
|
positive={diff.summary.totalCostDelta <= 0}
|
|
/>
|
|
<SummaryCard
|
|
label="Price"
|
|
value={formatDelta(diff.summary.totalPriceDelta, (v) => formatMoney(v))}
|
|
positive={diff.summary.totalPriceDelta >= 0}
|
|
/>
|
|
<SummaryCard
|
|
label="Margin"
|
|
value={`${diff.summary.marginPercentB.toFixed(1)}% (${diff.summary.marginPercentDelta >= 0 ? "+" : ""}${diff.summary.marginPercentDelta.toFixed(1)}pp)`}
|
|
positive={diff.summary.marginPercentDelta >= 0}
|
|
/>
|
|
<SummaryCard label="Lines +" value={`+${diff.summary.linesAdded}`} positive />
|
|
<SummaryCard label="Lines -" value={`-${diff.summary.linesRemoved}`} positive={diff.summary.linesRemoved === 0} />
|
|
<SummaryCard label="Lines ~" value={String(diff.summary.linesChanged)} positive={diff.summary.linesChanged === 0} />
|
|
<SummaryCard label="Resources ~" value={String(diff.summary.resourceSnapshotsChanged)} positive={diff.summary.resourceSnapshotsChanged === 0} />
|
|
</div>
|
|
|
|
{/* Demand line diffs */}
|
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
|
<h3 className="mb-3 text-base font-semibold text-gray-900">Demand lines</h3>
|
|
{filteredDemandDiffs.length === 0 ? (
|
|
<p className="py-4 text-center text-sm text-gray-400">
|
|
{hideUnchanged ? "No changes in demand lines." : "No demand lines to compare."}
|
|
</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
|
<th className="py-2 pr-3 font-medium">Name</th>
|
|
<th className="px-3 py-2 font-medium">Status</th>
|
|
<th className="px-3 py-2 text-right font-medium">Hours (A)</th>
|
|
<th className="px-3 py-2 text-right font-medium">Hours (B)</th>
|
|
<th className="px-3 py-2 text-right font-medium">Hours delta</th>
|
|
<th className="px-3 py-2 text-right font-medium">Cost (A)</th>
|
|
<th className="px-3 py-2 text-right font-medium">Cost (B)</th>
|
|
<th className="px-3 py-2 text-right font-medium">Cost delta</th>
|
|
<th className="px-3 py-2 text-right font-medium">Price (A)</th>
|
|
<th className="px-3 py-2 text-right font-medium">Price (B)</th>
|
|
<th className="pl-3 py-2 text-right font-medium">Price delta</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredDemandDiffs.map((d, i) => (
|
|
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
|
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
|
{d.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.hours.toFixed(1) ?? "\u2014"}</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.hours.toFixed(1) ?? "\u2014"}</td>
|
|
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.hoursDelta))}>
|
|
{d.hoursDelta != null ? formatHoursDelta(d.hoursDelta) : "\u2014"}
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
|
{d.a ? formatMoney(d.a.costTotalCents) : "\u2014"}
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
|
{d.b ? formatMoney(d.b.costTotalCents) : "\u2014"}
|
|
</td>
|
|
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.costDelta))}>
|
|
{d.costDelta != null ? formatDelta(d.costDelta, (v) => formatMoney(v)) : "\u2014"}
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
|
{d.a ? formatMoney(d.a.priceTotalCents) : "\u2014"}
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
|
|
{d.b ? formatMoney(d.b.priceTotalCents) : "\u2014"}
|
|
</td>
|
|
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(d.priceDelta))}>
|
|
{d.priceDelta != null ? formatDelta(d.priceDelta, (v) => formatMoney(v)) : "\u2014"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Assumption diffs */}
|
|
{(filteredAssumptionDiffs.length > 0 || !hideUnchanged) && (
|
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
|
<h3 className="mb-3 text-base font-semibold text-gray-900">Assumptions</h3>
|
|
{filteredAssumptionDiffs.length === 0 ? (
|
|
<p className="py-4 text-center text-sm text-gray-400">
|
|
{hideUnchanged ? "No changes in assumptions." : "No assumptions to compare."}
|
|
</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
|
<th className="py-2 pr-3 font-medium">Assumption</th>
|
|
<th className="px-3 py-2 font-medium">Status</th>
|
|
<th className="px-3 py-2 font-medium">Value (A)</th>
|
|
<th className="pl-3 py-2 font-medium">Value (B)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredAssumptionDiffs.map((d) => (
|
|
<tr key={d.key} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
|
<td className="py-2 pr-3 font-medium text-gray-900">{d.label}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
|
{d.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-gray-700">{formatAssumptionValue(d.aValue)}</td>
|
|
<td className="pl-3 py-2 text-gray-700">{formatAssumptionValue(d.bValue)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Chapter subtotals */}
|
|
{diff.chapterSubtotals.length > 0 && (
|
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
|
<h3 className="mb-3 text-base font-semibold text-gray-900">By chapter</h3>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
|
<th className="py-2 pr-3 font-medium">Chapter</th>
|
|
<th className="px-3 py-2 text-right font-medium">Hours (A)</th>
|
|
<th className="px-3 py-2 text-right font-medium">Hours (B)</th>
|
|
<th className="px-3 py-2 text-right font-medium">Hours delta</th>
|
|
<th className="px-3 py-2 text-right font-medium">Cost (A)</th>
|
|
<th className="px-3 py-2 text-right font-medium">Cost (B)</th>
|
|
<th className="pl-3 py-2 text-right font-medium">Cost delta</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{diff.chapterSubtotals.map((ch) => (
|
|
<tr key={ch.chapter} className={clsx("border-b border-gray-100", ch.costDelta !== 0 ? "bg-amber-50" : "")}>
|
|
<td className="py-2 pr-3 font-medium text-gray-900">{ch.chapter}</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursA.toFixed(1)}</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursB.toFixed(1)}</td>
|
|
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(ch.hoursDelta))}>
|
|
{formatHoursDelta(ch.hoursDelta)}
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costA)}</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costB)}</td>
|
|
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(ch.costDelta))}>
|
|
{formatDelta(ch.costDelta, (v) => formatMoney(v))}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Scope item diffs */}
|
|
{filteredScopeDiffs.length > 0 && (
|
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
|
<h3 className="mb-3 text-base font-semibold text-gray-900">
|
|
Scope items
|
|
{(diff.summary.scopeItemsAdded > 0 || diff.summary.scopeItemsRemoved > 0 || diff.summary.scopeItemsChanged > 0) && (
|
|
<span className="ml-2 text-sm font-normal text-gray-500">
|
|
{diff.summary.scopeItemsAdded > 0 && <span className="text-emerald-600">+{diff.summary.scopeItemsAdded}</span>}
|
|
{diff.summary.scopeItemsRemoved > 0 && <span className="ml-2 text-red-600">-{diff.summary.scopeItemsRemoved}</span>}
|
|
{diff.summary.scopeItemsChanged > 0 && <span className="ml-2 text-amber-600">~{diff.summary.scopeItemsChanged}</span>}
|
|
</span>
|
|
)}
|
|
</h3>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
|
<th className="py-2 pr-3 font-medium">Name</th>
|
|
<th className="px-3 py-2 font-medium">Type</th>
|
|
<th className="px-3 py-2 font-medium">Status</th>
|
|
<th className="px-3 py-2 text-right font-medium">Frames (A)</th>
|
|
<th className="px-3 py-2 text-right font-medium">Frames (B)</th>
|
|
<th className="px-3 py-2 text-right font-medium">Items (A)</th>
|
|
<th className="pl-3 py-2 text-right font-medium">Items (B)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredScopeDiffs.map((d, i) => (
|
|
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
|
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
|
|
<td className="px-3 py-2 text-gray-600">{d.scopeType}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
|
{d.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.frameCount ?? "\u2014"}</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.frameCount ?? "\u2014"}</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.itemCount ?? "\u2014"}</td>
|
|
<td className="pl-3 py-2 text-right tabular-nums text-gray-700">{d.b?.itemCount ?? "\u2014"}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Resource snapshot diffs */}
|
|
{filteredResourceDiffs.length > 0 && (
|
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
|
<h3 className="mb-3 text-base font-semibold text-gray-900">Resource rates</h3>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-sm">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
|
<th className="py-2 pr-3 font-medium">Name</th>
|
|
<th className="px-3 py-2 font-medium">Status</th>
|
|
<th className="px-3 py-2 text-right font-medium">LCR (A)</th>
|
|
<th className="px-3 py-2 text-right font-medium">LCR (B)</th>
|
|
<th className="px-3 py-2 text-right font-medium">UCR (A)</th>
|
|
<th className="px-3 py-2 text-right font-medium">UCR (B)</th>
|
|
<th className="px-3 py-2 font-medium">Location (A)</th>
|
|
<th className="pl-3 py-2 font-medium">Location (B)</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredResourceDiffs.map((d, i) => (
|
|
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
|
|
<td className="py-2 pr-3 font-medium text-gray-900">{d.displayName}</td>
|
|
<td className="px-3 py-2">
|
|
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
|
|
{d.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.lcrCents) : "\u2014"}</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.lcrCents) : "\u2014"}</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.ucrCents) : "\u2014"}</td>
|
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.ucrCents) : "\u2014"}</td>
|
|
<td className="px-3 py-2 text-gray-600">{d.a?.location ?? "\u2014"}</td>
|
|
<td className="pl-3 py-2 text-gray-600">{d.b?.location ?? "\u2014"}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SummaryCard({
|
|
label,
|
|
value,
|
|
positive,
|
|
}: {
|
|
label: string;
|
|
value: string;
|
|
positive: boolean;
|
|
}) {
|
|
return (
|
|
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-4 text-center shadow-sm">
|
|
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
|
|
<p className={clsx("mt-1 text-lg font-semibold tabular-nums", positive ? "text-emerald-700" : "text-red-700")}>
|
|
{value}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|