chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,490 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import {
|
||||
compareEstimateVersions,
|
||||
type VersionCompareInput,
|
||||
type ChapterSubtotal,
|
||||
type ResourceSnapshotDiff,
|
||||
type ScopeItemDiff,
|
||||
} from "@planarchy/engine";
|
||||
import type { EstimateVersionView } from "~/components/estimates/EstimateWorkspace.types.js";
|
||||
|
||||
function formatMoney(cents: number, currency = "EUR") {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
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</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)</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)</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);
|
||||
}
|
||||
Reference in New Issue
Block a user