refactor: complete v2 refactoring plan (Phases 1-5)

Phase 1 — Quick Wins: centralize formatMoney/formatCents, extract
findUniqueOrThrow helper (19 routers), shared Prisma select constants,
useInvalidatePlanningViews hook, status badge consolidation, composite
DB indexes.

Phase 2 — Timeline Split: extract TimelineContext, TimelineResourcePanel,
TimelineProjectPanel; split 28-dep useMemo into 3 focused memos.
TimelineView.tsx reduced from 1,903 to 538 lines.

Phase 3 — Query Performance: server-side filtering for getEntriesView,
remove availability from timeline resource select, SSE event debouncing
(50ms batch window).

Phase 4 — Estimate Workspace: extract 7 tab components and 3 editor
components. EstimateWorkspaceClient 1,298→306 lines,
EstimateWorkspaceDraftEditor 1,205→581 lines.

Phase 5 — Package Cleanup: split commit-dispo-import-batch (1,112→573
lines), extract shared pagination helper with 11 tests.

All tests pass: 209 API, 254 engine, 67 application.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-14 23:03:42 +01:00
parent 4dabb9d4ce
commit ad0855902b
65 changed files with 7108 additions and 4740 deletions
@@ -2,6 +2,7 @@
import { useState } from "react";
import { clsx } from "clsx";
import { formatCents } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
interface ApplyExperienceMultipliersProps {
@@ -10,13 +11,6 @@ interface ApplyExperienceMultipliersProps {
onApplied?: () => void;
}
function formatCents(cents: number): string {
return (cents / 100).toLocaleString("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: ApplyExperienceMultipliersProps) {
const utils = trpc.useUtils();
const { data: sets, isLoading } = trpc.experienceMultiplier.list.useQuery();
@@ -29,7 +23,7 @@ export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: A
{ enabled: showPreview && Boolean(selectedSetId) },
);
const applyMutation = trpc.experienceMultiplier.apply.useMutation({
const applyMutation = trpc.experienceMultiplier.applyRules.useMutation({
onSuccess: (result) => {
utils.estimate.getById.invalidate();
setShowPreview(false);
@@ -6,6 +6,7 @@ import { computeEvenSpread } from "@planarchy/engine";
import { isSpreadsheetFile } from "~/lib/excel.js";
import { parseScopeImport } from "~/lib/scopeImportParser.js";
import { clsx } from "clsx";
import { formatMoney } from "~/lib/format.js";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
@@ -120,14 +121,6 @@ function toHours(value: string) {
return parsed;
}
function formatMoney(cents: number, currency = "EUR") {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(cents / 100);
}
function slugify(value: string) {
return value
.trim()
@@ -1,6 +1,7 @@
"use client";
import type {
CommercialTerms,
EstimateDemandLineMetadata,
EstimateExportArtifactPayload,
EstimateExportFormat,
@@ -98,6 +99,7 @@ export interface EstimateVersionView {
resourceSnapshots: EstimateResourceSnapshotView[];
metrics: EstimateMetricView[];
exports: EstimateExportView[];
commercialTerms?: CommercialTerms | null;
}
export interface EstimateWorkspaceView {
File diff suppressed because it is too large Load Diff
@@ -19,62 +19,22 @@ import type {
EstimateWorkspaceView,
WorkspaceTab,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { isSpreadsheetFile } from "~/lib/excel.js";
import { parseScopeImport } from "~/lib/scopeImportParser.js";
import {
AssumptionEditor,
type EditableAssumption,
} from "~/components/estimates/editors/AssumptionEditor.js";
import {
ScopeItemEditor,
type EditableScopeItem,
} from "~/components/estimates/editors/ScopeItemEditor.js";
import {
DemandLineEditor,
type EditableDemandLine,
type ResourceOption,
} from "~/components/estimates/editors/DemandLineEditor.js";
import { formatMoney } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
interface EditableAssumption {
id?: string;
category: string;
key: string;
label: string;
valueType: string;
value: string;
notes: string;
}
interface EditableScopeItem {
id?: string;
sequenceNo: string;
scopeType: string;
packageCode: string;
name: string;
description: string;
}
interface EditableDemandLine {
id?: string;
scopeItemId?: string;
roleId?: string;
resourceId?: string;
lineType: string;
name: string;
chapter: string;
hours: string;
currency: string;
costRate: string;
billRate: string;
costRateMode: EstimateDemandLineRateMode;
billRateMode: EstimateDemandLineRateMode;
metadata: Record<string, unknown>;
/** Locked monthly hours overrides, keyed by "YYYY-MM" */
lockedMonths: Record<string, number>;
/** Whether the monthly spread section is expanded */
spreadExpanded: boolean;
}
interface ResourceOption {
id: string;
eid: string;
displayName: string;
chapter?: string | null;
roleId?: string | null;
lcrCents: number;
ucrCents: number;
currency: string;
dynamicFields?: Record<string, unknown>;
}
interface ResourceListView {
resources: ResourceOption[];
}
@@ -83,14 +43,6 @@ const INPUT_CLS =
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
function formatMoney(cents: number, currency = "EUR") {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(cents / 100);
}
function toNumber(value: string) {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
@@ -130,44 +82,6 @@ function stringifyAssumptionValue(value: unknown) {
return String(value);
}
function makeAssumption(): EditableAssumption {
return {
category: "commercial",
key: "",
label: "",
valueType: "string",
value: "",
notes: "",
};
}
function makeScopeItem(sequenceNo: number): EditableScopeItem {
return {
sequenceNo: String(sequenceNo),
scopeType: "SHOT",
packageCode: "",
name: "",
description: "",
};
}
function makeDemandLine(): EditableDemandLine {
return {
lineType: "LABOR",
name: "",
chapter: "",
hours: "8",
currency: "EUR",
costRate: "0",
billRate: "0",
costRateMode: "manual",
billRateMode: "manual",
metadata: {},
lockedMonths: {},
spreadExpanded: false,
};
}
function renderEditorHint() {
return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
@@ -356,8 +270,6 @@ export function EstimateWorkspaceDraftEditor({
return renderEditorHint();
}
const editableVersion = workingVersion;
function getLineResourceSnapshot(line: EditableDemandLine) {
if (!line.resourceId) {
return null;
@@ -379,117 +291,6 @@ export function EstimateWorkspaceDraftEditor({
});
}
function updateDemandLine(index: number, updater: (line: EditableDemandLine) => EditableDemandLine) {
setDemandLines((current) =>
current.map((entry, entryIndex) => (entryIndex === index ? updater(entry) : entry)),
);
}
function applyResourceSelection(index: number, nextResourceId: string) {
updateDemandLine(index, (line) => {
if (!nextResourceId) {
const { resourceId: _resourceId, ...unlinkedLine } = line;
return {
...unlinkedLine,
costRateMode: "manual",
billRateMode: "manual",
};
}
const resource = resourceMap.get(nextResourceId);
if (!resource) {
return line;
}
return {
...line,
resourceId: resource.id,
...(resource.roleId ? { roleId: resource.roleId } : {}),
chapter: resource.chapter ?? line.chapter,
currency: resource.currency,
costRate: (resource.lcrCents / 100).toFixed(2),
billRate: (resource.ucrCents / 100).toFixed(2),
costRateMode: "resource",
billRateMode: "resource",
name: line.name.trim() ? line.name : resource.displayName,
};
});
}
function syncDemandLineRates(index: number) {
updateDemandLine(index, (line) => {
if (!line.resourceId) {
return line;
}
const resource = resourceMap.get(line.resourceId);
if (!resource) {
return line;
}
return {
...line,
chapter: resource.chapter ?? line.chapter,
currency: resource.currency,
costRate: (resource.lcrCents / 100).toFixed(2),
billRate: (resource.ucrCents / 100).toFixed(2),
costRateMode: "resource",
billRateMode: "resource",
};
});
}
function setDemandLineRateMode(
index: number,
rateField: "costRateMode" | "billRateMode",
nextMode: EstimateDemandLineRateMode,
) {
updateDemandLine(index, (line) => {
const resourceSnapshot = getLineResourceSnapshot(line);
if (!resourceSnapshot || nextMode === "manual") {
return {
...line,
[rateField]: "manual",
};
}
return {
...line,
[rateField]: "resource",
...(rateField === "costRateMode"
? { costRate: (resourceSnapshot.lcrCents / 100).toFixed(2) }
: { billRate: (resourceSnapshot.ucrCents / 100).toFixed(2) }),
...(resourceSnapshot.currency ? { currency: resourceSnapshot.currency } : {}),
};
});
}
function syncAllLiveLinkedLines() {
setDemandLines((current) =>
current.map((line) => {
const resourceSnapshot = getLineResourceSnapshot(line);
if (!resourceSnapshot) {
return line;
}
return {
...line,
currency: resourceSnapshot.currency,
costRate:
line.costRateMode === "resource"
? (resourceSnapshot.lcrCents / 100).toFixed(2)
: line.costRate,
billRate:
line.billRateMode === "resource"
? (resourceSnapshot.ucrCents / 100).toFixed(2)
: line.billRate,
};
}),
);
}
const projectStartDate = estimate.project?.startDate
? new Date(estimate.project.startDate)
: null;
@@ -520,41 +321,14 @@ export function EstimateWorkspaceDraftEditor({
}).spread;
}
const spreadMonths = useMemo(
() =>
hasProjectDates
? getEstimateMonthRange(projectStartDate, projectEndDate)
: [],
[hasProjectDates, projectStartDate, projectEndDate],
);
const spreadMonths =
hasProjectDates
? getEstimateMonthRange(projectStartDate, projectEndDate)
: [];
const aggregatedSpread = useMemo(() => {
if (!hasProjectDates) return {};
return summarizeMonthlySpread(demandLines.map(computeLineSpread));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasProjectDates, demandLines, projectStartDate, projectEndDate]);
function toggleMonthLock(lineIndex: number, monthKey: string, currentValue: number) {
updateDemandLine(lineIndex, (line) => {
const isLocked = monthKey in line.lockedMonths;
if (isLocked) {
const { [monthKey]: _removed, ...rest } = line.lockedMonths;
return { ...line, lockedMonths: rest };
}
return {
...line,
lockedMonths: { ...line.lockedMonths, [monthKey]: currentValue },
};
});
}
function setLockedMonthValue(lineIndex: number, monthKey: string, value: string) {
const numValue = Math.max(0, toNumber(value));
updateDemandLine(lineIndex, (line) => ({
...line,
lockedMonths: { ...line.lockedMonths, [monthKey]: Math.round(numValue * 10) / 10 },
}));
}
const aggregatedSpread = hasProjectDates
? summarizeMonthlySpread(demandLines.map(computeLineSpread))
: {};
async function handleSave() {
setError(null);
@@ -749,429 +523,6 @@ export function EstimateWorkspaceDraftEditor({
);
}
function renderAssumptionsEditor() {
return (
<div className="space-y-4">
{assumptions.map((assumption, index) => (
<div key={assumption.id ?? `new-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<label>
<span className={LABEL_CLS}>Category</span>
<input className={INPUT_CLS} value={assumption.category} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, category: event.target.value } : item))} />
</label>
<label>
<span className={LABEL_CLS}>Key</span>
<input className={INPUT_CLS} value={assumption.key} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, key: event.target.value } : item))} />
</label>
<label>
<span className={LABEL_CLS}>Label</span>
<input className={INPUT_CLS} value={assumption.label} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value } : item))} />
</label>
<label>
<span className={LABEL_CLS}>Type</span>
<input className={INPUT_CLS} value={assumption.valueType} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, valueType: event.target.value } : item))} />
</label>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr),220px]">
<label className="block">
<span className={LABEL_CLS}>Value</span>
<textarea className={`${INPUT_CLS} min-h-24`} value={assumption.value} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} />
</label>
<label className="block">
<span className={LABEL_CLS}>Notes</span>
<textarea className={`${INPUT_CLS} min-h-24`} value={assumption.notes} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, notes: event.target.value } : item))} />
</label>
</div>
<div className="mt-4 flex justify-end">
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => setAssumptions((current) => current.filter((_, itemIndex) => itemIndex !== index))}>
Remove assumption
</button>
</div>
</div>
))}
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => setAssumptions((current) => [...current, makeAssumption()])}>
Add assumption
</button>
</div>
);
}
async function handleScopeImport(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file || !isSpreadsheetFile(file)) return;
event.target.value = "";
try {
const result = await parseScopeImport(file);
setScopeImportWarnings(result.warnings);
if (result.rows.length > 0) {
const imported: EditableScopeItem[] = result.rows.map((row) => ({
sequenceNo: String(row.sequenceNo),
scopeType: row.scopeType,
packageCode: row.packageCode,
name: row.name,
description: row.description,
}));
setScopeItems((current) => [...current, ...imported]);
}
} catch {
setScopeImportWarnings(["Failed to parse the uploaded file."]);
}
}
function renderScopeEditor() {
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<label className="cursor-pointer rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50">
Import scope from XLSX
<input type="file" accept=".xlsx,.xls,.csv" className="hidden" onChange={(event) => void handleScopeImport(event)} />
</label>
{scopeImportWarnings.length > 0 && (
<div className="text-xs text-amber-700">
{scopeImportWarnings.map((warning, index) => (
<p key={index}>{warning}</p>
))}
</div>
)}
</div>
{scopeItems.map((item, index) => (
<div key={item.id ?? `scope-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="grid gap-4 md:grid-cols-3">
<label>
<span className={LABEL_CLS}>Sequence</span>
<input className={INPUT_CLS} value={item.sequenceNo} onChange={(event) => setScopeItems((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, sequenceNo: event.target.value } : entry))} />
</label>
<label>
<span className={LABEL_CLS}>Scope type</span>
<input className={INPUT_CLS} value={item.scopeType} onChange={(event) => setScopeItems((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, scopeType: event.target.value } : entry))} />
</label>
<label>
<span className={LABEL_CLS}>Package code</span>
<input className={INPUT_CLS} value={item.packageCode} onChange={(event) => setScopeItems((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, packageCode: event.target.value } : entry))} />
</label>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,0.9fr),minmax(0,1.1fr)]">
<label>
<span className={LABEL_CLS}>Name</span>
<input className={INPUT_CLS} value={item.name} onChange={(event) => setScopeItems((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: event.target.value } : entry))} />
</label>
<label>
<span className={LABEL_CLS}>Description</span>
<textarea className={`${INPUT_CLS} min-h-24`} value={item.description} onChange={(event) => setScopeItems((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, description: event.target.value } : entry))} />
</label>
</div>
<div className="mt-4 flex justify-end">
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => setScopeItems((current) => current.filter((_, entryIndex) => entryIndex !== index))}>
Remove scope item
</button>
</div>
</div>
))}
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => setScopeItems((current) => [...current, makeScopeItem(current.length + 1)])}>
Add scope item
</button>
</div>
);
}
function renderStaffingEditor() {
return (
<div className="space-y-4">
<div className="flex justify-end">
<button
type="button"
className="rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700"
onClick={syncAllLiveLinkedLines}
>
Recalculate live-linked lines
</button>
</div>
{demandLines.map((line, index) => {
const linkedResource = line.resourceId ? getLineResourceSnapshot(line) : null;
const effectiveValues = getLineEffectiveValues(line);
const costDeltaCents =
linkedResource != null ? toCents(line.costRate) - linkedResource.lcrCents : 0;
const billDeltaCents =
linkedResource != null ? toCents(line.billRate) - linkedResource.ucrCents : 0;
return (
<div key={line.id ?? `line-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">Resource link</p>
<p className="mt-1 text-sm text-gray-700">
{linkedResource
? `${linkedResource.displayName} (${("eid" in linkedResource ? linkedResource.eid : linkedResource.sourceEid) ?? "snapshot"})`
: "This demand line is currently unlinked."}
</p>
</div>
{linkedResource && (
<div className="flex items-center gap-2">
<span
className={
line.costRateMode === "resource" && line.billRateMode === "resource"
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700"
}
>
{line.costRateMode === "resource" && line.billRateMode === "resource"
? "Live rates synced"
: "Manual override active"}
</span>
<button
type="button"
className="rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700"
onClick={() => syncDemandLineRates(index)}
>
Apply current resource rates
</button>
</div>
)}
</div>
<div className="mb-4 grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLS}>Linked resource</span>
<select
className={INPUT_CLS}
value={line.resourceId ?? ""}
onChange={(event) => applyResourceSelection(index, event.target.value)}
>
<option value="">Unlinked</option>
{resourceOptions.map((resource) => (
<option key={resource.id} value={resource.id}>
{resource.displayName} ({resource.eid})
</option>
))}
</select>
</label>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Snapshot behavior</p>
<p className="mt-1 text-sm text-gray-700">
Linked resources refresh from live Planarchy rates when a rate is set to live mode. Manual overrides are persisted on the demand line.
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<label>
<span className={LABEL_CLS}>Name</span>
<input className={INPUT_CLS} value={line.name} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, name: event.target.value }))} />
</label>
<label>
<span className={LABEL_CLS}>Line type</span>
<input className={INPUT_CLS} value={line.lineType} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, lineType: event.target.value }))} />
</label>
<label>
<span className={LABEL_CLS}>Chapter</span>
<input className={INPUT_CLS} value={line.chapter} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, chapter: event.target.value }))} />
</label>
<label>
<span className={LABEL_CLS}>Hours</span>
<input className={INPUT_CLS} value={line.hours} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, hours: event.target.value }))} />
</label>
<label>
<span className={LABEL_CLS}>Currency</span>
<input className={INPUT_CLS} maxLength={3} value={line.currency} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, currency: event.target.value.toUpperCase() }))} />
</label>
<label>
<span className={LABEL_CLS}>Cost rate</span>
<div className="space-y-2">
<select
className={INPUT_CLS}
value={line.costRateMode}
onChange={(event) =>
setDemandLineRateMode(index, "costRateMode", event.target.value as EstimateDemandLineRateMode)
}
>
{getLineResourceSnapshot(line) && <option value="resource">Use live resource rate</option>}
<option value="manual">Manual override</option>
</select>
<input
className={INPUT_CLS}
value={line.costRate}
onChange={(event) =>
updateDemandLine(index, (entry) => ({
...entry,
costRateMode: "manual",
costRate: event.target.value,
}))
}
/>
{linkedResource && (
<p className="text-xs text-gray-500">
Live snapshot {formatMoney(linkedResource.lcrCents, linkedResource.currency)}
{line.costRateMode === "manual" && costDeltaCents !== 0
? ` (${costDeltaCents > 0 ? "+" : ""}${formatMoney(costDeltaCents, linkedResource.currency)} delta)`
: ""}
</p>
)}
</div>
</label>
<label>
<span className={LABEL_CLS}>Bill rate</span>
<div className="space-y-2">
<select
className={INPUT_CLS}
value={line.billRateMode}
onChange={(event) =>
setDemandLineRateMode(index, "billRateMode", event.target.value as EstimateDemandLineRateMode)
}
>
{getLineResourceSnapshot(line) && <option value="resource">Use live resource rate</option>}
<option value="manual">Manual override</option>
</select>
<input
className={INPUT_CLS}
value={line.billRate}
onChange={(event) =>
updateDemandLine(index, (entry) => ({
...entry,
billRateMode: "manual",
billRate: event.target.value,
}))
}
/>
{linkedResource && (
<p className="text-xs text-gray-500">
Live snapshot {formatMoney(linkedResource.ucrCents, linkedResource.currency)}
{line.billRateMode === "manual" && billDeltaCents !== 0
? ` (${billDeltaCents > 0 ? "+" : ""}${formatMoney(billDeltaCents, linkedResource.currency)} delta)`
: ""}
</p>
)}
</div>
</label>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total</p>
<p className="mt-1 text-sm font-semibold text-gray-900">
{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}
</p>
<p className="mt-3 text-xs uppercase tracking-wide text-gray-400">Price total</p>
<p className="mt-1 text-sm font-semibold text-gray-900">
{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}
</p>
</div>
</div>
{hasProjectDates && spreadMonths.length > 0 && (
<div className="mt-4">
<button
type="button"
className="flex items-center gap-1.5 text-xs font-medium text-gray-600"
onClick={() => updateDemandLine(index, (entry) => ({ ...entry, spreadExpanded: !entry.spreadExpanded }))}
>
<span className={`inline-block transition-transform ${line.spreadExpanded ? "rotate-90" : ""}`}>&#9654;</span>
Monthly phasing ({spreadMonths.length} months)
</button>
{line.spreadExpanded && (() => {
const lineSpread = computeLineSpread(line);
return (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">Month</th>
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">Hours</th>
<th className="px-2 py-1.5 text-center text-xs font-semibold uppercase tracking-wide text-gray-400">Lock</th>
</tr>
</thead>
<tbody>
{spreadMonths.map((monthKey) => {
const isLocked = monthKey in line.lockedMonths;
const value = lineSpread[monthKey] ?? 0;
return (
<tr key={monthKey} className="border-b border-gray-100">
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
<td className="px-2 py-1.5 text-right">
{isLocked ? (
<input
className="w-20 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-right text-sm text-gray-900"
value={line.lockedMonths[monthKey]}
onChange={(event) => setLockedMonthValue(index, monthKey, event.target.value)}
/>
) : (
<span className="text-gray-700">{value.toFixed(1)}</span>
)}
</td>
<td className="px-2 py-1.5 text-center">
<button
type="button"
className={`rounded px-2 py-0.5 text-xs font-medium ${isLocked ? "bg-amber-100 text-amber-700" : "bg-gray-100 text-gray-500"}`}
onClick={() => toggleMonthLock(index, monthKey, value)}
>
{isLocked ? "Locked" : "Auto"}
</button>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-gray-300">
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">Total</td>
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
{Object.values(lineSpread).reduce((a, b) => a + b, 0).toFixed(1)}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
);
})()}
</div>
)}
<div className="mt-4 flex justify-end">
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => setDemandLines((current) => current.filter((_, entryIndex) => entryIndex !== index))}>
Remove demand line
</button>
</div>
</div>
);
})}
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => setDemandLines((current) => [...current, makeDemandLine()])}>
Add demand line
</button>
{hasProjectDates && spreadMonths.length > 0 && demandLines.length > 0 && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="mb-3 text-sm font-semibold text-gray-900">Aggregated monthly phasing</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">Month</th>
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">Total hours</th>
</tr>
</thead>
<tbody>
{spreadMonths.map((monthKey) => (
<tr key={monthKey} className="border-b border-gray-100">
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
<td className="px-2 py-1.5 text-right text-gray-900">{(aggregatedSpread[monthKey] ?? 0).toFixed(1)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-gray-300">
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">Grand total</td>
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
{Object.values(aggregatedSpread).reduce((a, b) => a + b, 0).toFixed(1)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
</div>
);
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-brand-200 bg-brand-50 px-5 py-4">
@@ -1196,9 +547,34 @@ export function EstimateWorkspaceDraftEditor({
)}
{tab === "overview" && renderOverviewEditor()}
{tab === "assumptions" && renderAssumptionsEditor()}
{tab === "scope" && renderScopeEditor()}
{tab === "staffing" && renderStaffingEditor()}
{tab === "assumptions" && (
<AssumptionEditor
assumptions={assumptions}
onChange={setAssumptions}
/>
)}
{tab === "scope" && (
<ScopeItemEditor
scopeItems={scopeItems}
onChange={setScopeItems}
scopeImportWarnings={scopeImportWarnings}
onScopeImportWarnings={setScopeImportWarnings}
/>
)}
{tab === "staffing" && (
<DemandLineEditor
demandLines={demandLines}
onChange={setDemandLines}
resourceOptions={resourceOptions}
resourceMap={resourceMap}
snapshotByResourceId={snapshotByResourceId}
baseCurrency={baseCurrency}
projectStartDate={projectStartDate}
projectEndDate={projectEndDate}
spreadMonths={spreadMonths}
aggregatedSpread={aggregatedSpread}
/>
)}
{(tab === "versions" || tab === "exports") && renderEditorHint()}
</div>
);
@@ -10,14 +10,7 @@ import {
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);
}
import { formatMoney } from "~/lib/format.js";
function formatDelta(value: number, formatter: (v: number) => string) {
const prefix = value > 0 ? "+" : "";
@@ -0,0 +1,79 @@
"use client";
const INPUT_CLS =
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
export interface EditableAssumption {
id?: string;
category: string;
key: string;
label: string;
valueType: string;
value: string;
notes: string;
}
export function makeAssumption(): EditableAssumption {
return {
category: "commercial",
key: "",
label: "",
valueType: "string",
value: "",
notes: "",
};
}
export interface AssumptionEditorProps {
assumptions: EditableAssumption[];
onChange: (updater: (current: EditableAssumption[]) => EditableAssumption[]) => void;
}
export function AssumptionEditor({ assumptions, onChange }: AssumptionEditorProps) {
return (
<div className="space-y-4">
{assumptions.map((assumption, index) => (
<div key={assumption.id ?? `new-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<label>
<span className={LABEL_CLS}>Category</span>
<input className={INPUT_CLS} value={assumption.category} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, category: event.target.value } : item))} />
</label>
<label>
<span className={LABEL_CLS}>Key</span>
<input className={INPUT_CLS} value={assumption.key} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, key: event.target.value } : item))} />
</label>
<label>
<span className={LABEL_CLS}>Label</span>
<input className={INPUT_CLS} value={assumption.label} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value } : item))} />
</label>
<label>
<span className={LABEL_CLS}>Type</span>
<input className={INPUT_CLS} value={assumption.valueType} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, valueType: event.target.value } : item))} />
</label>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr),220px]">
<label className="block">
<span className={LABEL_CLS}>Value</span>
<textarea className={`${INPUT_CLS} min-h-24`} value={assumption.value} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} />
</label>
<label className="block">
<span className={LABEL_CLS}>Notes</span>
<textarea className={`${INPUT_CLS} min-h-24`} value={assumption.notes} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, notes: event.target.value } : item))} />
</label>
</div>
<div className="mt-4 flex justify-end">
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => onChange((current) => current.filter((_, itemIndex) => itemIndex !== index))}>
Remove assumption
</button>
</div>
</div>
))}
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => onChange((current) => [...current, makeAssumption()])}>
Add assumption
</button>
</div>
);
}
@@ -0,0 +1,574 @@
"use client";
import { useMemo } from "react";
import type { EstimateDemandLineRateMode } from "@planarchy/shared";
import {
computeEvenSpread,
rebalanceSpread,
} from "@planarchy/engine";
import {
getEffectiveDemandLineValues,
} from "~/components/estimates/EstimateWorkspace.calculations.js";
import type { EstimateResourceSnapshotView } from "~/components/estimates/EstimateWorkspace.types.js";
import { formatMoney } from "~/lib/format.js";
const INPUT_CLS =
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
function toNumber(value: string) {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function toCents(value: string) {
return Math.round(toNumber(value) * 100);
}
export interface EditableDemandLine {
id?: string;
scopeItemId?: string;
roleId?: string;
resourceId?: string;
lineType: string;
name: string;
chapter: string;
hours: string;
currency: string;
costRate: string;
billRate: string;
costRateMode: EstimateDemandLineRateMode;
billRateMode: EstimateDemandLineRateMode;
metadata: Record<string, unknown>;
/** Locked monthly hours overrides, keyed by "YYYY-MM" */
lockedMonths: Record<string, number>;
/** Whether the monthly spread section is expanded */
spreadExpanded: boolean;
}
export function makeDemandLine(): EditableDemandLine {
return {
lineType: "LABOR",
name: "",
chapter: "",
hours: "8",
currency: "EUR",
costRate: "0",
billRate: "0",
costRateMode: "manual",
billRateMode: "manual",
metadata: {},
lockedMonths: {},
spreadExpanded: false,
};
}
export interface ResourceOption {
id: string;
eid: string;
displayName: string;
chapter?: string | null;
roleId?: string | null;
lcrCents: number;
ucrCents: number;
currency: string;
dynamicFields?: Record<string, unknown>;
}
export interface DemandLineEditorProps {
demandLines: EditableDemandLine[];
onChange: (updater: (current: EditableDemandLine[]) => EditableDemandLine[]) => void;
resourceOptions: ResourceOption[];
resourceMap: Map<string, ResourceOption>;
snapshotByResourceId: Map<string, EstimateResourceSnapshotView>;
baseCurrency: string;
projectStartDate: Date | null;
projectEndDate: Date | null;
spreadMonths: string[];
aggregatedSpread: Record<string, number>;
}
type ResourceSnapshotLike = ResourceOption | EstimateResourceSnapshotView;
export function DemandLineEditor({
demandLines,
onChange,
resourceOptions,
resourceMap,
snapshotByResourceId,
baseCurrency,
projectStartDate,
projectEndDate,
spreadMonths,
aggregatedSpread,
}: DemandLineEditorProps) {
const hasProjectDates = projectStartDate !== null && projectEndDate !== null;
function getLineResourceSnapshot(line: EditableDemandLine): ResourceSnapshotLike | null {
if (!line.resourceId) {
return null;
}
return resourceMap.get(line.resourceId) ?? snapshotByResourceId.get(line.resourceId) ?? null;
}
function getLineEffectiveValues(line: EditableDemandLine) {
return getEffectiveDemandLineValues({
resourceSnapshot: getLineResourceSnapshot(line),
hours: toNumber(line.hours),
currency: line.currency,
defaultCurrency: baseCurrency,
costRateCents: toCents(line.costRate),
billRateCents: toCents(line.billRate),
costRateMode: line.costRateMode,
billRateMode: line.billRateMode,
});
}
function updateDemandLine(index: number, updater: (line: EditableDemandLine) => EditableDemandLine) {
onChange((current) =>
current.map((entry, entryIndex) => (entryIndex === index ? updater(entry) : entry)),
);
}
function applyResourceSelection(index: number, nextResourceId: string) {
updateDemandLine(index, (line) => {
if (!nextResourceId) {
const { resourceId: _resourceId, ...unlinkedLine } = line;
return {
...unlinkedLine,
costRateMode: "manual",
billRateMode: "manual",
};
}
const resource = resourceMap.get(nextResourceId);
if (!resource) {
return line;
}
return {
...line,
resourceId: resource.id,
...(resource.roleId ? { roleId: resource.roleId } : {}),
chapter: resource.chapter ?? line.chapter,
currency: resource.currency,
costRate: (resource.lcrCents / 100).toFixed(2),
billRate: (resource.ucrCents / 100).toFixed(2),
costRateMode: "resource",
billRateMode: "resource",
name: line.name.trim() ? line.name : resource.displayName,
};
});
}
function syncDemandLineRates(index: number) {
updateDemandLine(index, (line) => {
if (!line.resourceId) {
return line;
}
const resource = resourceMap.get(line.resourceId);
if (!resource) {
return line;
}
return {
...line,
chapter: resource.chapter ?? line.chapter,
currency: resource.currency,
costRate: (resource.lcrCents / 100).toFixed(2),
billRate: (resource.ucrCents / 100).toFixed(2),
costRateMode: "resource",
billRateMode: "resource",
};
});
}
function setDemandLineRateMode(
index: number,
rateField: "costRateMode" | "billRateMode",
nextMode: EstimateDemandLineRateMode,
) {
updateDemandLine(index, (line) => {
const resourceSnapshot = getLineResourceSnapshot(line);
if (!resourceSnapshot || nextMode === "manual") {
return {
...line,
[rateField]: "manual",
};
}
return {
...line,
[rateField]: "resource",
...(rateField === "costRateMode"
? { costRate: (resourceSnapshot.lcrCents / 100).toFixed(2) }
: { billRate: (resourceSnapshot.ucrCents / 100).toFixed(2) }),
...(resourceSnapshot.currency ? { currency: resourceSnapshot.currency } : {}),
};
});
}
function syncAllLiveLinkedLines() {
onChange((current) =>
current.map((line) => {
const resourceSnapshot = getLineResourceSnapshot(line);
if (!resourceSnapshot) {
return line;
}
return {
...line,
currency: resourceSnapshot.currency,
costRate:
line.costRateMode === "resource"
? (resourceSnapshot.lcrCents / 100).toFixed(2)
: line.costRate,
billRate:
line.billRateMode === "resource"
? (resourceSnapshot.ucrCents / 100).toFixed(2)
: line.billRate,
};
}),
);
}
function computeLineSpread(line: EditableDemandLine): Record<string, number> {
if (!hasProjectDates) return {};
const hours = toNumber(line.hours);
if (hours <= 0) return {};
const lockedKeys = Object.keys(line.lockedMonths);
if (lockedKeys.length > 0) {
return rebalanceSpread({
totalHours: hours,
startDate: projectStartDate,
endDate: projectEndDate,
lockedMonths: line.lockedMonths,
}).spread;
}
return computeEvenSpread({
totalHours: hours,
startDate: projectStartDate,
endDate: projectEndDate,
}).spread;
}
function toggleMonthLock(lineIndex: number, monthKey: string, currentValue: number) {
updateDemandLine(lineIndex, (line) => {
const isLocked = monthKey in line.lockedMonths;
if (isLocked) {
const { [monthKey]: _removed, ...rest } = line.lockedMonths;
return { ...line, lockedMonths: rest };
}
return {
...line,
lockedMonths: { ...line.lockedMonths, [monthKey]: currentValue },
};
});
}
function setLockedMonthValue(lineIndex: number, monthKey: string, value: string) {
const numValue = Math.max(0, toNumber(value));
updateDemandLine(lineIndex, (line) => ({
...line,
lockedMonths: { ...line.lockedMonths, [monthKey]: Math.round(numValue * 10) / 10 },
}));
}
return (
<div className="space-y-4">
<div className="flex justify-end">
<button
type="button"
className="rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700"
onClick={syncAllLiveLinkedLines}
>
Recalculate live-linked lines
</button>
</div>
{demandLines.map((line, index) => {
const linkedResource = line.resourceId ? getLineResourceSnapshot(line) : null;
const effectiveValues = getLineEffectiveValues(line);
const costDeltaCents =
linkedResource != null ? toCents(line.costRate) - linkedResource.lcrCents : 0;
const billDeltaCents =
linkedResource != null ? toCents(line.billRate) - linkedResource.ucrCents : 0;
return (
<div key={line.id ?? `line-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">Resource link</p>
<p className="mt-1 text-sm text-gray-700">
{linkedResource
? `${linkedResource.displayName} (${("eid" in linkedResource ? linkedResource.eid : (linkedResource as EstimateResourceSnapshotView).sourceEid) ?? "snapshot"})`
: "This demand line is currently unlinked."}
</p>
</div>
{linkedResource && (
<div className="flex items-center gap-2">
<span
className={
line.costRateMode === "resource" && line.billRateMode === "resource"
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700"
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700"
}
>
{line.costRateMode === "resource" && line.billRateMode === "resource"
? "Live rates synced"
: "Manual override active"}
</span>
<button
type="button"
className="rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700"
onClick={() => syncDemandLineRates(index)}
>
Apply current resource rates
</button>
</div>
)}
</div>
<div className="mb-4 grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLS}>Linked resource</span>
<select
className={INPUT_CLS}
value={line.resourceId ?? ""}
onChange={(event) => applyResourceSelection(index, event.target.value)}
>
<option value="">Unlinked</option>
{resourceOptions.map((resource) => (
<option key={resource.id} value={resource.id}>
{resource.displayName} ({resource.eid})
</option>
))}
</select>
</label>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Snapshot behavior</p>
<p className="mt-1 text-sm text-gray-700">
Linked resources refresh from live Planarchy rates when a rate is set to live mode. Manual overrides are persisted on the demand line.
</p>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<label>
<span className={LABEL_CLS}>Name</span>
<input className={INPUT_CLS} value={line.name} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, name: event.target.value }))} />
</label>
<label>
<span className={LABEL_CLS}>Line type</span>
<input className={INPUT_CLS} value={line.lineType} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, lineType: event.target.value }))} />
</label>
<label>
<span className={LABEL_CLS}>Chapter</span>
<input className={INPUT_CLS} value={line.chapter} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, chapter: event.target.value }))} />
</label>
<label>
<span className={LABEL_CLS}>Hours</span>
<input className={INPUT_CLS} value={line.hours} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, hours: event.target.value }))} />
</label>
<label>
<span className={LABEL_CLS}>Currency</span>
<input className={INPUT_CLS} maxLength={3} value={line.currency} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, currency: event.target.value.toUpperCase() }))} />
</label>
<label>
<span className={LABEL_CLS}>Cost rate</span>
<div className="space-y-2">
<select
className={INPUT_CLS}
value={line.costRateMode}
onChange={(event) =>
setDemandLineRateMode(index, "costRateMode", event.target.value as EstimateDemandLineRateMode)
}
>
{getLineResourceSnapshot(line) && <option value="resource">Use live resource rate</option>}
<option value="manual">Manual override</option>
</select>
<input
className={INPUT_CLS}
value={line.costRate}
onChange={(event) =>
updateDemandLine(index, (entry) => ({
...entry,
costRateMode: "manual",
costRate: event.target.value,
}))
}
/>
{linkedResource && (
<p className="text-xs text-gray-500">
Live snapshot {formatMoney(linkedResource.lcrCents, linkedResource.currency)}
{line.costRateMode === "manual" && costDeltaCents !== 0
? ` (${costDeltaCents > 0 ? "+" : ""}${formatMoney(costDeltaCents, linkedResource.currency)} delta)`
: ""}
</p>
)}
</div>
</label>
<label>
<span className={LABEL_CLS}>Bill rate</span>
<div className="space-y-2">
<select
className={INPUT_CLS}
value={line.billRateMode}
onChange={(event) =>
setDemandLineRateMode(index, "billRateMode", event.target.value as EstimateDemandLineRateMode)
}
>
{getLineResourceSnapshot(line) && <option value="resource">Use live resource rate</option>}
<option value="manual">Manual override</option>
</select>
<input
className={INPUT_CLS}
value={line.billRate}
onChange={(event) =>
updateDemandLine(index, (entry) => ({
...entry,
billRateMode: "manual",
billRate: event.target.value,
}))
}
/>
{linkedResource && (
<p className="text-xs text-gray-500">
Live snapshot {formatMoney(linkedResource.ucrCents, linkedResource.currency)}
{line.billRateMode === "manual" && billDeltaCents !== 0
? ` (${billDeltaCents > 0 ? "+" : ""}${formatMoney(billDeltaCents, linkedResource.currency)} delta)`
: ""}
</p>
)}
</div>
</label>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total</p>
<p className="mt-1 text-sm font-semibold text-gray-900">
{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}
</p>
<p className="mt-3 text-xs uppercase tracking-wide text-gray-400">Price total</p>
<p className="mt-1 text-sm font-semibold text-gray-900">
{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}
</p>
</div>
</div>
{hasProjectDates && spreadMonths.length > 0 && (
<div className="mt-4">
<button
type="button"
className="flex items-center gap-1.5 text-xs font-medium text-gray-600"
onClick={() => updateDemandLine(index, (entry) => ({ ...entry, spreadExpanded: !entry.spreadExpanded }))}
>
<span className={`inline-block transition-transform ${line.spreadExpanded ? "rotate-90" : ""}`}>&#9654;</span>
Monthly phasing ({spreadMonths.length} months)
</button>
{line.spreadExpanded && (() => {
const lineSpread = computeLineSpread(line);
return (
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">Month</th>
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">Hours</th>
<th className="px-2 py-1.5 text-center text-xs font-semibold uppercase tracking-wide text-gray-400">Lock</th>
</tr>
</thead>
<tbody>
{spreadMonths.map((monthKey) => {
const isLocked = monthKey in line.lockedMonths;
const value = lineSpread[monthKey] ?? 0;
return (
<tr key={monthKey} className="border-b border-gray-100">
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
<td className="px-2 py-1.5 text-right">
{isLocked ? (
<input
className="w-20 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-right text-sm text-gray-900"
value={line.lockedMonths[monthKey]}
onChange={(event) => setLockedMonthValue(index, monthKey, event.target.value)}
/>
) : (
<span className="text-gray-700">{value.toFixed(1)}</span>
)}
</td>
<td className="px-2 py-1.5 text-center">
<button
type="button"
className={`rounded px-2 py-0.5 text-xs font-medium ${isLocked ? "bg-amber-100 text-amber-700" : "bg-gray-100 text-gray-500"}`}
onClick={() => toggleMonthLock(index, monthKey, value)}
>
{isLocked ? "Locked" : "Auto"}
</button>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-gray-300">
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">Total</td>
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
{Object.values(lineSpread).reduce((a, b) => a + b, 0).toFixed(1)}
</td>
<td />
</tr>
</tfoot>
</table>
</div>
);
})()}
</div>
)}
<div className="mt-4 flex justify-end">
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => onChange((current) => current.filter((_, entryIndex) => entryIndex !== index))}>
Remove demand line
</button>
</div>
</div>
);
})}
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => onChange((current) => [...current, makeDemandLine()])}>
Add demand line
</button>
{hasProjectDates && spreadMonths.length > 0 && demandLines.length > 0 && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="mb-3 text-sm font-semibold text-gray-900">Aggregated monthly phasing</p>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">Month</th>
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">Total hours</th>
</tr>
</thead>
<tbody>
{spreadMonths.map((monthKey) => (
<tr key={monthKey} className="border-b border-gray-100">
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
<td className="px-2 py-1.5 text-right text-gray-900">{(aggregatedSpread[monthKey] ?? 0).toFixed(1)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-gray-300">
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">Grand total</td>
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
{Object.values(aggregatedSpread).reduce((a, b) => a + b, 0).toFixed(1)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,119 @@
"use client";
import { isSpreadsheetFile } from "~/lib/excel.js";
import { parseScopeImport } from "~/lib/scopeImportParser.js";
const INPUT_CLS =
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
export interface EditableScopeItem {
id?: string;
sequenceNo: string;
scopeType: string;
packageCode: string;
name: string;
description: string;
}
export function makeScopeItem(sequenceNo: number): EditableScopeItem {
return {
sequenceNo: String(sequenceNo),
scopeType: "SHOT",
packageCode: "",
name: "",
description: "",
};
}
export interface ScopeItemEditorProps {
scopeItems: EditableScopeItem[];
onChange: (updater: (current: EditableScopeItem[]) => EditableScopeItem[]) => void;
scopeImportWarnings: string[];
onScopeImportWarnings: (warnings: string[]) => void;
}
export function ScopeItemEditor({
scopeItems,
onChange,
scopeImportWarnings,
onScopeImportWarnings,
}: ScopeItemEditorProps) {
async function handleScopeImport(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file || !isSpreadsheetFile(file)) return;
event.target.value = "";
try {
const result = await parseScopeImport(file);
onScopeImportWarnings(result.warnings);
if (result.rows.length > 0) {
const imported: EditableScopeItem[] = result.rows.map((row) => ({
sequenceNo: String(row.sequenceNo),
scopeType: row.scopeType,
packageCode: row.packageCode,
name: row.name,
description: row.description,
}));
onChange((current) => [...current, ...imported]);
}
} catch {
onScopeImportWarnings(["Failed to parse the uploaded file."]);
}
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<label className="cursor-pointer rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50">
Import scope from XLSX
<input type="file" accept=".xlsx,.xls,.csv" className="hidden" onChange={(event) => void handleScopeImport(event)} />
</label>
{scopeImportWarnings.length > 0 && (
<div className="text-xs text-amber-700">
{scopeImportWarnings.map((warning, index) => (
<p key={index}>{warning}</p>
))}
</div>
)}
</div>
{scopeItems.map((item, index) => (
<div key={item.id ?? `scope-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="grid gap-4 md:grid-cols-3">
<label>
<span className={LABEL_CLS}>Sequence</span>
<input className={INPUT_CLS} value={item.sequenceNo} onChange={(event) => onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, sequenceNo: event.target.value } : entry))} />
</label>
<label>
<span className={LABEL_CLS}>Scope type</span>
<input className={INPUT_CLS} value={item.scopeType} onChange={(event) => onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, scopeType: event.target.value } : entry))} />
</label>
<label>
<span className={LABEL_CLS}>Package code</span>
<input className={INPUT_CLS} value={item.packageCode} onChange={(event) => onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, packageCode: event.target.value } : entry))} />
</label>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,0.9fr),minmax(0,1.1fr)]">
<label>
<span className={LABEL_CLS}>Name</span>
<input className={INPUT_CLS} value={item.name} onChange={(event) => onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: event.target.value } : entry))} />
</label>
<label>
<span className={LABEL_CLS}>Description</span>
<textarea className={`${INPUT_CLS} min-h-24`} value={item.description} onChange={(event) => onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, description: event.target.value } : entry))} />
</label>
</div>
<div className="mt-4 flex justify-end">
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => onChange((current) => current.filter((_, entryIndex) => entryIndex !== index))}>
Remove scope item
</button>
</div>
</div>
))}
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => onChange((current) => [...current, makeScopeItem(current.length + 1)])}>
Add scope item
</button>
</div>
);
}
@@ -0,0 +1,51 @@
"use client";
import type {
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
{children}
</div>
);
}
export function AssumptionsTab({ estimate }: { estimate: EstimateWorkspaceView }) {
const versions = estimate.versions as EstimateVersionView[];
const latestVersion = versions[0] ?? null;
const assumptions = latestVersion?.assumptions ?? [];
if (assumptions.length === 0) {
return <EmptyState>No assumptions captured for the current version yet.</EmptyState>;
}
return (
<div className="rounded-3xl border border-gray-200 bg-white shadow-sm">
<div className="border-b border-gray-100 px-6 py-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Commercial and delivery assumptions</h2>
</div>
<div className="divide-y divide-gray-100">
{assumptions.map((assumption) => (
<div key={assumption.id} className="grid gap-3 px-6 py-4 md:grid-cols-[160px,1fr,1fr]">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Category</p>
<p className="mt-1 text-sm font-medium text-gray-900">{assumption.category}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Label</p>
<p className="mt-1 text-sm text-gray-800">{assumption.label}</p>
<p className="mt-1 text-xs text-gray-400">{assumption.key}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Value</p>
<p className="mt-1 text-sm text-gray-800">{String(assumption.value)}</p>
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,226 @@
"use client";
import {
type EstimateExportArtifactPayload,
EstimateExportFormat,
} from "@planarchy/shared";
import type {
EstimateExportView,
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const EXPORT_FORMATS: EstimateExportFormat[] = [
EstimateExportFormat.XLSX,
EstimateExportFormat.CSV,
EstimateExportFormat.JSON,
EstimateExportFormat.SAP,
EstimateExportFormat.MMP,
];
function formatBytes(value: number | null | undefined) {
const bytes = value ?? 0;
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function isEstimateExportArtifactPayload(
payload: EstimateExportView["payload"],
): payload is EstimateExportArtifactPayload {
return (
typeof payload === "object" &&
payload !== null &&
typeof payload.content === "string" &&
typeof payload.mimeType === "string" &&
typeof payload.encoding === "string" &&
typeof payload.fileExtension === "string" &&
typeof payload.generatedAt === "string" &&
typeof payload.byteLength === "number" &&
typeof payload.summary === "object" &&
payload.summary !== null
);
}
function decodeBase64(value: string) {
const binary = atob(value);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return bytes;
}
function downloadEstimateExport(estimateExport: EstimateExportView) {
const payload = estimateExport.payload;
if (!isEstimateExportArtifactPayload(payload)) {
return;
}
const blob =
payload.encoding === "base64"
? new Blob([decodeBase64(payload.content)], { type: payload.mimeType })
: new Blob([payload.content], { type: payload.mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = estimateExport.fileName;
document.body.append(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
export interface ExportsTabProps {
estimate: EstimateWorkspaceView;
canEdit: boolean;
onCreateExport: (versionId: string, format: EstimateExportFormat) => void;
isCreatingExport: boolean;
}
export function ExportsTab({
estimate,
canEdit,
onCreateExport,
isCreatingExport,
}: ExportsTabProps) {
const versions = estimate.versions as EstimateVersionView[];
const latestVersion = versions[0] ?? null;
const exports = latestVersion?.exports ?? [];
return (
<div className="space-y-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Export delivery</h2>
<p className="mt-2 text-sm text-gray-500">
Generate format-specific artifacts from the current version and download them directly from the stored serializer payload.
</p>
</div>
{latestVersion && canEdit && (
<div className="flex flex-wrap gap-2">
{EXPORT_FORMATS.map((format) => (
<button
key={format}
type="button"
onClick={() => onCreateExport(latestVersion.id, format)}
disabled={isCreatingExport}
className="rounded-2xl border border-brand-200 bg-white px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
>
{isCreatingExport ? "Generating..." : `Create ${format}`}
</button>
))}
</div>
)}
</div>
</div>
<div className="rounded-3xl border border-gray-200 bg-white shadow-sm">
<div className="border-b border-gray-100 px-6 py-4">
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Generated exports</h2>
</div>
{exports.length === 0 ? (
<div className="px-6 py-8">
<p className="text-sm text-gray-400">No exports have been generated for the current version yet.</p>
</div>
) : (
<div className="divide-y divide-gray-100">
{exports.map((estimateExport) => {
const payload = isEstimateExportArtifactPayload(estimateExport.payload)
? estimateExport.payload
: null;
return (
<div key={estimateExport.id} className="px-6 py-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-gray-900">{estimateExport.fileName}</p>
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-gray-600">
{estimateExport.format}
</span>
{payload?.sheetNames?.length ? (
<span className="rounded-full bg-sky-50 px-2.5 py-1 text-[11px] font-semibold text-sky-700">
{payload.sheetNames.length} sheets
</span>
) : null}
</div>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500">
<span>{formatDateLong(estimateExport.createdAt)}</span>
{payload ? <span>{formatBytes(payload.byteLength)}</span> : null}
{payload?.rowCount != null ? <span>{payload.rowCount} rows</span> : null}
{payload?.lineCount != null ? <span>{payload.lineCount} lines</span> : null}
</div>
{payload ? (
<div className="mt-3 grid gap-2 text-xs text-gray-600 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Hours</p>
<p className="mt-1 font-semibold text-gray-900">
{payload.summary.totalHours.toFixed(1)}
</p>
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Cost</p>
<p className="mt-1 font-semibold text-gray-900">
{formatMoney(
payload.summary.totalCostCents,
payload.summary.baseCurrency,
)}
</p>
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Price</p>
<p className="mt-1 font-semibold text-gray-900">
{formatMoney(
payload.summary.totalPriceCents,
payload.summary.baseCurrency,
)}
</p>
</div>
<div className="rounded-2xl bg-gray-50 px-3 py-2">
<p className="uppercase tracking-wide text-gray-400">Margin</p>
<p className="mt-1 font-semibold text-gray-900">
{payload.summary.marginPercent.toFixed(0)}%
</p>
</div>
</div>
) : (
<p className="mt-3 text-xs text-amber-700">
Legacy export record detected. Regenerate it to get downloadable serializer output.
</p>
)}
</div>
{payload ? (
<button
type="button"
onClick={() => downloadEstimateExport(estimateExport)}
className="rounded-2xl border border-brand-200 bg-white px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50"
>
Download
</button>
) : null}
</div>
{payload?.previewText ? (
<pre className="mt-4 overflow-x-auto rounded-2xl bg-gray-950/95 p-4 text-xs text-gray-100">
{payload.previewText}
</pre>
) : null}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,239 @@
"use client";
import { clsx } from "clsx";
import { CommercialTermsEditor } from "~/components/estimates/CommercialTermsEditor.js";
import type {
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { formatMoney } from "~/lib/format.js";
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
{children}
</div>
);
}
export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspaceView; canEdit: boolean }) {
const versions = estimate.versions as EstimateVersionView[];
const latestVersion = versions[0] ?? null;
const demandLines = latestVersion?.demandLines ?? [];
if (demandLines.length === 0) {
return <EmptyState>No demand lines available to generate financial summaries.</EmptyState>;
}
const totals = demandLines.reduce(
(acc, line) => ({
hours: acc.hours + line.hours,
costCents: acc.costCents + line.costTotalCents,
priceCents: acc.priceCents + line.priceTotalCents,
}),
{ hours: 0, costCents: 0, priceCents: 0 },
);
const marginCents = totals.priceCents - totals.costCents;
const marginPercent = totals.priceCents > 0 ? (marginCents / totals.priceCents) * 100 : 0;
const avgCostRate = totals.hours > 0 ? totals.costCents / totals.hours : 0;
const avgBillRate = totals.hours > 0 ? totals.priceCents / totals.hours : 0;
// Group by chapter
const chapterMap = new Map<string, { hours: number; costCents: number; priceCents: number; count: number }>();
for (const line of demandLines) {
const chapter = line.chapter?.trim() || "Unassigned";
const existing = chapterMap.get(chapter) ?? { hours: 0, costCents: 0, priceCents: 0, count: 0 };
existing.hours += line.hours;
existing.costCents += line.costTotalCents;
existing.priceCents += line.priceTotalCents;
existing.count += 1;
chapterMap.set(chapter, existing);
}
const chapterBreakdown = [...chapterMap.entries()]
.sort(([, a], [, b]) => b.priceCents - a.priceCents);
// Monthly cost/price phasing
const spreads = demandLines.filter(
(line): line is typeof line & { monthlySpread: Record<string, number> } =>
line.monthlySpread != null && Object.keys(line.monthlySpread).length > 0,
);
const monthlyFinancials = new Map<string, { hours: number; costCents: number; priceCents: number }>();
for (const line of spreads) {
const costRate = line.hours > 0 ? line.costTotalCents / line.hours : 0;
const billRate = line.hours > 0 ? line.priceTotalCents / line.hours : 0;
for (const [month, hours] of Object.entries(line.monthlySpread)) {
const existing = monthlyFinancials.get(month) ?? { hours: 0, costCents: 0, priceCents: 0 };
existing.hours += hours;
existing.costCents += Math.round(hours * costRate);
existing.priceCents += Math.round(hours * billRate);
monthlyFinancials.set(month, existing);
}
}
const sortedMonths = [...monthlyFinancials.keys()].sort();
return (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(totals.costCents, estimate.baseCurrency)}</p>
<p className="mt-1 text-xs text-gray-500">Avg {formatMoney(Math.round(avgCostRate), estimate.baseCurrency)}/h</p>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(totals.priceCents, estimate.baseCurrency)}</p>
<p className="mt-1 text-xs text-gray-500">Avg {formatMoney(Math.round(avgBillRate), estimate.baseCurrency)}/h</p>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Margin</p>
<p className={clsx("mt-2 text-2xl font-semibold", marginCents >= 0 ? "text-emerald-700" : "text-red-700")}>
{formatMoney(marginCents, estimate.baseCurrency)}
</p>
<p className="mt-1 text-xs text-gray-500">{marginPercent.toFixed(1)}% of price</p>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{totals.hours.toFixed(1)} h</p>
<p className="mt-1 text-xs text-gray-500">{demandLines.length} demand lines</p>
</div>
</div>
{/* Margin waterfall: Cost -> Margin -> Price */}
<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">Cost to price bridge</h3>
<div className="flex items-end gap-1 h-32">
{(() => {
const maxVal = Math.max(totals.costCents, totals.priceCents, 1);
const costH = (totals.costCents / maxVal) * 100;
const marginH = (Math.abs(marginCents) / maxVal) * 100;
const priceH = (totals.priceCents / maxVal) * 100;
return (
<>
<div className="flex-1 flex flex-col items-center gap-1">
<div className="w-full rounded-t-xl bg-gray-300" style={{ height: `${costH}%` }} />
<span className="text-xs font-medium text-gray-600">Cost</span>
<span className="text-xs text-gray-500">{formatMoney(totals.costCents, estimate.baseCurrency)}</span>
</div>
<div className="flex-1 flex flex-col items-center gap-1">
<div
className={clsx("w-full rounded-t-xl", marginCents >= 0 ? "bg-emerald-400" : "bg-red-400")}
style={{ height: `${marginH}%` }}
/>
<span className="text-xs font-medium text-gray-600">Margin</span>
<span className="text-xs text-gray-500">{formatMoney(marginCents, estimate.baseCurrency)}</span>
</div>
<div className="flex-1 flex flex-col items-center gap-1">
<div className="w-full rounded-t-xl bg-brand-500" style={{ height: `${priceH}%` }} />
<span className="text-xs font-medium text-gray-600">Price</span>
<span className="text-xs text-gray-500">{formatMoney(totals.priceCents, estimate.baseCurrency)}</span>
</div>
</>
);
})()}
</div>
</div>
{/* Chapter breakdown */}
<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">Breakdown 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">Lines</th>
<th className="px-3 py-2 text-right font-medium">Hours</th>
<th className="px-3 py-2 text-right font-medium">Cost</th>
<th className="px-3 py-2 text-right font-medium">Price</th>
<th className="px-3 py-2 text-right font-medium">Margin</th>
<th className="pl-3 py-2 text-right font-medium">Margin %</th>
</tr>
</thead>
<tbody>
{chapterBreakdown.map(([chapter, data]) => {
const chapterMargin = data.priceCents - data.costCents;
const chapterMarginPct = data.priceCents > 0 ? (chapterMargin / data.priceCents) * 100 : 0;
return (
<tr key={chapter} className="border-b border-gray-100">
<td className="py-2 pr-3 font-medium text-gray-900">{chapter}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-600">{data.count}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{data.hours.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", chapterMargin >= 0 ? "text-emerald-700" : "text-red-700")}>
{formatMoney(chapterMargin, estimate.baseCurrency)}
</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", chapterMarginPct >= 0 ? "text-emerald-700" : "text-red-700")}>
{chapterMarginPct.toFixed(1)}%
</td>
</tr>
);
})}
<tr className="border-t-2 border-gray-300 font-semibold">
<td className="py-2 pr-3 text-gray-900">Total</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{demandLines.length}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{totals.hours.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{formatMoney(totals.costCents, estimate.baseCurrency)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{formatMoney(totals.priceCents, estimate.baseCurrency)}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", marginCents >= 0 ? "text-emerald-700" : "text-red-700")}>
{formatMoney(marginCents, estimate.baseCurrency)}
</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", marginPercent >= 0 ? "text-emerald-700" : "text-red-700")}>
{marginPercent.toFixed(1)}%
</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Monthly cost/price phasing */}
{sortedMonths.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">Monthly financial phasing</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">Month</th>
<th className="px-3 py-2 text-right font-medium">Hours</th>
<th className="px-3 py-2 text-right font-medium">Cost</th>
<th className="px-3 py-2 text-right font-medium">Price</th>
<th className="pl-3 py-2 text-right font-medium">Margin</th>
</tr>
</thead>
<tbody>
{sortedMonths.map((month) => {
const data = monthlyFinancials.get(month)!;
const mMargin = data.priceCents - data.costCents;
return (
<tr key={month} className="border-b border-gray-100">
<td className="py-2 pr-3 font-medium text-gray-900">{month}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{data.hours.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", mMargin >= 0 ? "text-emerald-700" : "text-red-700")}>
{formatMoney(mMargin, estimate.baseCurrency)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
{/* Commercial Terms */}
<CommercialTermsEditor
estimateId={estimate.id}
baseCostCents={totals.costCents}
basePriceCents={totals.priceCents}
baseCurrency={estimate.baseCurrency}
canEdit={canEdit}
/>
</div>
);
}
@@ -0,0 +1,174 @@
"use client";
import { clsx } from "clsx";
import { EstimateStatus, EstimateVersionStatus } from "@planarchy/shared";
import type {
EstimateMetricView,
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const STATUS_STYLES: Record<EstimateStatus, string> = {
DRAFT: "bg-slate-100 text-slate-700",
IN_REVIEW: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
ARCHIVED: "bg-zinc-200 text-zinc-700",
};
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
WORKING: "bg-sky-100 text-sky-700",
BASELINE: "bg-violet-100 text-violet-700",
SUBMITTED: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
SUPERSEDED: "bg-zinc-200 text-zinc-700",
};
function formatMetricValue(metric: EstimateMetricView) {
if (metric.valueCents != null) {
return formatMoney(metric.valueCents, metric.currency ?? "EUR");
}
if (metric.key === "margin_percent") {
return `${metric.valueDecimal.toFixed(0)}%`;
}
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 }).format(metric.valueDecimal);
}
export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
const versions = estimate.versions as EstimateVersionView[];
const latestVersion = versions[0] ?? null;
const latestMetrics = latestVersion?.metrics ?? [];
return (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),340px]">
<section className="space-y-6">
<div className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="flex flex-wrap items-center gap-2">
<span className={clsx("rounded-full px-3 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}>
{estimate.status.replace("_", " ")}
</span>
{estimate.project && (
<span className="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-600">
{estimate.project.shortCode}
</span>
)}
</div>
<div className="mt-5 grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity</p>
<p className="mt-1 text-sm text-gray-800">{estimate.opportunityId ?? "Not set"}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Base currency</p>
<p className="mt-1 text-sm text-gray-800">{estimate.baseCurrency}</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Latest version</p>
<p className="mt-1 text-sm text-gray-800">
{latestVersion ? `v${latestVersion.versionNumber}${latestVersion.label ? ` - ${latestVersion.label}` : ""}` : "No version"}
</p>
</div>
<div>
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
<p className="mt-1 text-sm text-gray-800">{formatDateLong(estimate.updatedAt)}</p>
</div>
</div>
{latestVersion?.notes && (
<div className="mt-5 rounded-2xl border border-gray-100 bg-gray-50 p-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Version notes</p>
<p className="mt-2 text-sm text-gray-700">{latestVersion.notes}</p>
</div>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Scope items</p>
<span className="text-xs text-gray-400">{latestVersion?.scopeItems.length ?? 0}</span>
</div>
<div className="mt-4 space-y-2">
{(latestVersion?.scopeItems ?? []).slice(0, 4).map((item) => (
<div key={item.id} className="rounded-2xl border border-gray-100 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-gray-900">{item.name}</p>
<span className="text-xs text-gray-400">{item.scopeType}</span>
</div>
</div>
))}
{(latestVersion?.scopeItems.length ?? 0) === 0 && <p className="text-sm text-gray-400">No scope rows captured yet.</p>}
</div>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-gray-900">Demand lines</p>
<span className="text-xs text-gray-400">{latestVersion?.demandLines.length ?? 0}</span>
</div>
<div className="mt-4 space-y-2">
{(latestVersion?.demandLines ?? []).slice(0, 4).map((line) => (
<div key={line.id} className="rounded-2xl border border-gray-100 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-gray-900">{line.name}</p>
<span className="text-xs text-gray-500">{line.hours.toFixed(1)} h</span>
</div>
</div>
))}
{(latestVersion?.demandLines.length ?? 0) === 0 && <p className="text-sm text-gray-400">No demand lines captured yet.</p>}
</div>
</div>
</div>
</section>
<aside className="space-y-4">
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-sm font-semibold text-gray-900">Summary metrics</p>
<div className="mt-4 space-y-3">
{latestMetrics.length === 0 ? (
<p className="text-sm text-gray-400">No derived metrics available yet.</p>
) : (
latestMetrics.map((metric) => (
<div key={metric.id} className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
<span className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</span>
<span className="text-sm font-semibold text-gray-900">{formatMetricValue(metric)}</span>
</div>
))
)}
</div>
</div>
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-sm font-semibold text-gray-900">Version context</p>
<div className="mt-4 space-y-3">
{latestVersion ? (
<>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Status</span>
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[latestVersion.status])}>
{latestVersion.status}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Assumptions</span>
<span className="text-sm font-medium text-gray-900">{latestVersion.assumptions.length}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Snapshots</span>
<span className="text-sm font-medium text-gray-900">{latestVersion.resourceSnapshots.length}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-gray-500">Exports</span>
<span className="text-sm font-medium text-gray-900">{latestVersion.exports.length}</span>
</div>
</>
) : (
<p className="text-sm text-gray-400">No version available.</p>
)}
</div>
</div>
</aside>
</div>
);
}
@@ -0,0 +1,52 @@
"use client";
import type {
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
{children}
</div>
);
}
export function ScopeTab({ estimate }: { estimate: EstimateWorkspaceView }) {
const versions = estimate.versions as EstimateVersionView[];
const latestVersion = versions[0] ?? null;
const scopeItems = latestVersion?.scopeItems ?? [];
if (scopeItems.length === 0) {
return <EmptyState>No scope rows captured for the current version yet.</EmptyState>;
}
return (
<div className="space-y-3">
{scopeItems.map((item) => (
<div key={item.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600">
#{item.sequenceNo}
</span>
<span className="rounded-full bg-brand-50 px-2.5 py-1 text-xs font-medium text-brand-700">
{item.scopeType}
</span>
</div>
<h3 className="mt-3 text-lg font-semibold text-gray-900">{item.name}</h3>
{item.description && <p className="mt-2 text-sm text-gray-600">{item.description}</p>}
</div>
<div className="grid gap-2 text-right text-xs text-gray-400">
{item.frameCount != null && <span>{item.frameCount} frames</span>}
{item.itemCount != null && <span>{item.itemCount} units</span>}
{item.unitMode && <span>{item.unitMode}</span>}
</div>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,185 @@
"use client";
import { EstimateVersionStatus } from "@planarchy/shared";
import { summarizeMonthlySpread } from "@planarchy/engine";
import { clsx } from "clsx";
import {
getEffectiveDemandLineValues,
resolveDemandLineCalculationMetadata,
} from "~/components/estimates/EstimateWorkspace.calculations.js";
import { ApplyEffortRules } from "~/components/estimates/ApplyEffortRules.js";
import { ApplyExperienceMultipliers } from "~/components/estimates/ApplyExperienceMultipliers.js";
import type {
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { formatMoney } from "~/lib/format.js";
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
{children}
</div>
);
}
export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspaceView; canEdit: boolean }) {
const versions = estimate.versions as EstimateVersionView[];
const latestVersion = versions[0] ?? null;
const demandLines = latestVersion?.demandLines ?? [];
const snapshots = latestVersion?.resourceSnapshots ?? [];
const isWorking = latestVersion?.status === EstimateVersionStatus.WORKING;
return (
<div className="space-y-3">
{isWorking && (
<>
<ApplyEffortRules
estimateId={estimate.id}
canEdit={canEdit}
/>
<ApplyExperienceMultipliers
estimateId={estimate.id}
canEdit={canEdit}
/>
</>
)}
{demandLines.length === 0 && (
<EmptyState>No staffing demand lines captured for the current version yet.</EmptyState>
)}
{demandLines.map((line) => {
const linkedSnapshot = line.resourceId
? snapshots.find((snapshot) => snapshot.resourceId === line.resourceId) ?? null
: null;
const calculation = resolveDemandLineCalculationMetadata({
resourceSnapshot: linkedSnapshot,
metadata: line.metadata,
costRateCents: line.costRateCents,
billRateCents: line.billRateCents,
});
const effectiveValues = getEffectiveDemandLineValues({
resourceSnapshot: linkedSnapshot,
hours: line.hours,
currency: line.currency,
defaultCurrency: line.currency,
costRateCents: line.costRateCents,
billRateCents: line.billRateCents,
costRateMode: calculation.costRateMode,
billRateMode: calculation.billRateMode,
});
return (
<div key={line.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h3 className="text-lg font-semibold text-gray-900">{line.name}</h3>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500">
<span>{line.lineType}</span>
{line.chapter && <span>{line.chapter}</span>}
{line.rateSource && <span>{line.rateSource}</span>}
{linkedSnapshot && <span>Linked: {linkedSnapshot.displayName}</span>}
<span
className={clsx(
"rounded-full px-2.5 py-1 font-medium",
calculation.costRateMode === "resource"
? "bg-emerald-50 text-emerald-700"
: "bg-amber-50 text-amber-700",
)}
>
Cost {calculation.costRateMode === "resource" ? "live" : "manual"}
</span>
<span
className={clsx(
"rounded-full px-2.5 py-1 font-medium",
calculation.billRateMode === "resource"
? "bg-emerald-50 text-emerald-700"
: "bg-amber-50 text-amber-700",
)}
>
Sell {calculation.billRateMode === "resource" ? "live" : "manual"}
</span>
</div>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-gray-900">{line.hours.toFixed(1)} h</p>
<p className="mt-1 text-xs text-gray-500">{effectiveValues.currency}</p>
</div>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-4">
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost rate</p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(line.costRateCents, line.currency)}</p>
{linkedSnapshot && calculation.costRateMode === "manual" && (
<p className="mt-1 text-xs text-gray-500">
Live snapshot {formatMoney(linkedSnapshot.lcrCents, linkedSnapshot.currency)}
</p>
)}
</div>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Sell rate</p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(line.billRateCents, line.currency)}</p>
{linkedSnapshot && calculation.billRateMode === "manual" && (
<p className="mt-1 text-xs text-gray-500">
Live snapshot {formatMoney(linkedSnapshot.ucrCents, linkedSnapshot.currency)}
</p>
)}
</div>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total</p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}</p>
</div>
<div className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Price total</p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}</p>
</div>
</div>
{line.monthlySpread && Object.keys(line.monthlySpread).length > 0 && (
<div className="mt-4">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Monthly phasing</p>
<div className="flex flex-wrap gap-2">
{Object.entries(line.monthlySpread)
.sort(([a], [b]) => a.localeCompare(b))
.map(([month, hours]) => (
<div key={month} className="rounded-xl bg-gray-50 px-3 py-1.5 text-xs">
<span className="text-gray-500">{month}</span>
<span className="ml-1.5 font-medium text-gray-900">{hours.toFixed(1)} h</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
{(() => {
const spreads = demandLines
.map((line) => line.monthlySpread)
.filter((spread): spread is Record<string, number> => spread != null && Object.keys(spread).length > 0);
if (spreads.length === 0) return null;
const aggregated = summarizeMonthlySpread(spreads);
const months = Object.keys(aggregated).sort();
if (months.length === 0) return null;
return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="mb-3 text-sm font-semibold text-gray-900">Aggregated monthly phasing</p>
<div className="flex flex-wrap gap-2">
{months.map((month) => (
<div key={month} className="rounded-xl bg-gray-50 px-3 py-2 text-sm">
<span className="text-gray-500">{month}</span>
<span className="ml-2 font-semibold text-gray-900">{(aggregated[month] ?? 0).toFixed(1)} h</span>
</div>
))}
</div>
<div className="mt-3 text-right text-sm font-semibold text-gray-700">
Total: {Object.values(aggregated).reduce((a, b) => a + b, 0).toFixed(1)} h
</div>
</div>
);
})()}
</div>
);
}
@@ -0,0 +1,179 @@
"use client";
import { EstimateVersionStatus } from "@planarchy/shared";
import { clsx } from "clsx";
import { VersionCompare } from "~/components/estimates/VersionCompare.js";
import type {
EstimateMetricView,
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
WORKING: "bg-sky-100 text-sky-700",
BASELINE: "bg-violet-100 text-violet-700",
SUBMITTED: "bg-amber-100 text-amber-700",
APPROVED: "bg-emerald-100 text-emerald-700",
SUPERSEDED: "bg-zinc-200 text-zinc-700",
};
function formatMetricValue(metric: EstimateMetricView) {
if (metric.valueCents != null) {
return formatMoney(metric.valueCents, metric.currency ?? "EUR");
}
if (metric.key === "margin_percent") {
return `${metric.valueDecimal.toFixed(0)}%`;
}
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 }).format(metric.valueDecimal);
}
function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
{children}
</div>
);
}
export interface VersionsTabProps {
estimate: EstimateWorkspaceView;
canEdit: boolean;
hasLinkedProject: boolean;
onSubmitVersion: (versionId: string) => void;
onApproveVersion: (versionId: string) => void;
onCreateRevision: (versionId: string) => void;
onCreatePlanningHandoff: (versionId: string) => void;
isSubmitting: boolean;
isApproving: boolean;
isCreatingRevision: boolean;
isCreatingPlanningHandoff: boolean;
}
export function VersionsTab({
estimate,
canEdit,
hasLinkedProject,
onSubmitVersion,
onApproveVersion,
onCreateRevision,
onCreatePlanningHandoff,
isSubmitting,
isApproving,
isCreatingRevision,
isCreatingPlanningHandoff,
}: VersionsTabProps) {
const versions = estimate.versions as EstimateVersionView[];
const hasWorkingVersion = versions.some(
(version) => version.status === EstimateVersionStatus.WORKING,
);
if (versions.length === 0) {
return <EmptyState>No versions available for this estimate yet.</EmptyState>;
}
return (
<div className="space-y-3">
{versions.map((version) => (
<div key={version.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-gray-900">v{version.versionNumber}</span>
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[version.status])}>
{version.status}
</span>
</div>
<p className="mt-2 text-sm text-gray-600">{version.label ?? "Unlabeled version"}</p>
{version.notes && <p className="mt-2 text-sm text-gray-500">{version.notes}</p>}
</div>
<div className="text-right text-sm text-gray-500">
<p>Updated {formatDateLong(version.updatedAt)}</p>
{version.lockedAt && (
<p className="mt-1">Locked {formatDateLong(version.lockedAt)}</p>
)}
<p className="mt-1">{version.demandLines.length} lines</p>
</div>
</div>
{canEdit && (
<div className="mt-4 flex flex-wrap gap-2">
{version.status === EstimateVersionStatus.WORKING && (
<button
type="button"
onClick={() => onSubmitVersion(version.id)}
disabled={isSubmitting || isApproving || isCreatingRevision}
className="rounded-2xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm font-semibold text-amber-800 transition hover:border-amber-300 hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? "Submitting..." : "Submit for review"}
</button>
)}
{version.status === EstimateVersionStatus.SUBMITTED && (
<button
type="button"
onClick={() => onApproveVersion(version.id)}
disabled={isSubmitting || isApproving || isCreatingRevision}
className="rounded-2xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800 transition hover:border-emerald-300 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
>
{isApproving ? "Approving..." : "Approve version"}
</button>
)}
{version.status !== EstimateVersionStatus.WORKING && !hasWorkingVersion && (
<button
type="button"
onClick={() => onCreateRevision(version.id)}
disabled={isSubmitting || isApproving || isCreatingRevision || isCreatingPlanningHandoff}
className="rounded-2xl border border-brand-200 bg-white px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
>
{isCreatingRevision ? "Creating revision..." : "Create working revision"}
</button>
)}
{version.status === EstimateVersionStatus.APPROVED && (
<button
type="button"
onClick={() => onCreatePlanningHandoff(version.id)}
disabled={
isSubmitting ||
isApproving ||
isCreatingRevision ||
isCreatingPlanningHandoff ||
!hasLinkedProject
}
className="rounded-2xl border border-sky-200 bg-sky-50 px-3 py-2 text-sm font-semibold text-sky-800 transition hover:border-sky-300 hover:bg-sky-100 disabled:cursor-not-allowed disabled:opacity-60"
>
{isCreatingPlanningHandoff
? "Creating planning allocations..."
: hasLinkedProject
? "Create planning allocations"
: "Link project to hand off"}
</button>
)}
</div>
)}
{version.status === EstimateVersionStatus.APPROVED && !hasLinkedProject && (
<p className="mt-3 text-sm text-amber-700">
Link this estimate to a project before handing approved demand into planning.
</p>
)}
{version.metrics.length > 0 && (
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
{version.metrics.map((metric) => (
<div key={metric.id} className="rounded-2xl bg-gray-50 px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</p>
<p className="mt-1 text-sm font-semibold text-gray-900">{formatMetricValue(metric)}</p>
</div>
))}
</div>
)}
</div>
))}
{versions.length >= 2 && <VersionCompare versions={versions} />}
</div>
);
}