chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -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);
}