chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,837 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { EstimateStatus } from "@planarchy/shared";
import { computeEvenSpread } from "@planarchy/engine";
import { isSpreadsheetFile } from "~/lib/excel.js";
import { parseScopeImport } from "~/lib/scopeImportParser.js";
import { clsx } from "clsx";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
import { trpc } from "~/lib/trpc/client.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 SELECT_CLS = INPUT_CLS;
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
const STEP_LABELS = ["Setup", "Assumptions", "Scope", "Staffing", "Review"];
interface AssumptionRow {
id: string;
category: string;
key: string;
label: string;
value: string;
}
interface ScopeRow {
id: string;
sequenceNo: number;
scopeType: string;
name: string;
description: string;
}
interface DemandRow {
id: string;
name: string;
roleId: string | null;
resourceId: string | null;
hours: string;
chapter: string;
costRate: string;
billRate: string;
currency: string;
}
interface ProjectOption {
id: string;
shortCode: string;
name: string;
startDate?: string | Date | null;
endDate?: string | Date | null;
}
interface RoleOption {
id: string;
name: string;
}
interface ResourceOption {
id: string;
eid: string;
displayName: string;
chapter: string | null;
currency: string;
lcrCents: number;
ucrCents: number;
roleId: string | null;
federalState: string | null;
}
function makeAssumption(): AssumptionRow {
return {
id: crypto.randomUUID(),
category: "commercial",
key: "",
label: "",
value: "",
};
}
function makeScope(sequenceNo = 1): ScopeRow {
return {
id: crypto.randomUUID(),
sequenceNo,
scopeType: "SHOT",
name: "",
description: "",
};
}
function makeDemand(): DemandRow {
return {
id: crypto.randomUUID(),
name: "",
roleId: null,
resourceId: null,
hours: "8",
chapter: "",
costRate: "",
billRate: "",
currency: "EUR",
};
}
function toCents(value: string) {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return 0;
}
return Math.round(parsed * 100);
}
function toHours(value: string) {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed) || parsed < 0) {
return 0;
}
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()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
export function EstimateWizard({ onClose }: { onClose: () => void }) {
const [step, setStep] = useState(0);
const [name, setName] = useState("");
const [projectId, setProjectId] = useState<string | null>(null);
const [opportunityId, setOpportunityId] = useState("");
const [baseCurrency, setBaseCurrency] = useState("EUR");
const [status, setStatus] = useState<EstimateStatus>(EstimateStatus.DRAFT);
const [versionLabel, setVersionLabel] = useState("Initial");
const [versionNotes, setVersionNotes] = useState("");
const [assumptions, setAssumptions] = useState<AssumptionRow[]>([makeAssumption()]);
const [scopeItems, setScopeItems] = useState<ScopeRow[]>([makeScope(1)]);
const [demandLines, setDemandLines] = useState<DemandRow[]>([makeDemand()]);
const [error, setError] = useState<string | null>(null);
const [scopeImportWarnings, setScopeImportWarnings] = useState<string[]>([]);
const panelRef = useRef<HTMLDivElement>(null);
useFocusTrap(panelRef, true);
const utils = trpc.useUtils();
const projectsQuery = trpc.project.list.useQuery({ limit: 200 }, { staleTime: 60_000 });
const rolesQuery = trpc.role.list.useQuery({ isActive: true }, { staleTime: 60_000 });
const resourcesQuery = trpc.resource.list.useQuery(
{ limit: 500, includeRoles: true, isActive: true },
{ staleTime: 60_000 },
);
const createMutation = trpc.estimate.create.useMutation({
onSuccess: async () => {
await utils.estimate.list.invalidate();
onClose();
},
onError: (mutationError) => {
setError(mutationError.message);
},
});
const projectRows = (projectsQuery.data?.projects ?? []) as unknown as ProjectOption[];
const roleRows = (rolesQuery.data ?? []) as unknown as RoleOption[];
const resourceRows = (resourcesQuery.data?.resources ?? []) as unknown as ResourceOption[];
const projects: ProjectOption[] = projectRows.map((project) => ({
id: project.id,
shortCode: project.shortCode,
name: project.name,
}));
const roles: RoleOption[] = roleRows.map((role) => ({
id: role.id,
name: role.name,
}));
const resources: ResourceOption[] = resourceRows.map((resource) => ({
id: resource.id,
eid: resource.eid,
displayName: resource.displayName,
chapter: resource.chapter,
currency: resource.currency,
lcrCents: resource.lcrCents,
ucrCents: resource.ucrCents,
roleId: resource.roleId,
federalState: resource.federalState,
}));
const selectedProject = projectId
? projects.find((project) => project.id === projectId) ?? null
: null;
const summary = useMemo(() => {
return demandLines.reduce(
(accumulator, line) => {
const hours = toHours(line.hours);
const costTotalCents = Math.round(hours * toCents(line.costRate));
const priceTotalCents = Math.round(hours * toCents(line.billRate));
return {
totalHours: accumulator.totalHours + hours,
totalCostCents: accumulator.totalCostCents + costTotalCents,
totalPriceCents: accumulator.totalPriceCents + priceTotalCents,
};
},
{ totalHours: 0, totalCostCents: 0, totalPriceCents: 0 },
);
}, [demandLines]);
const marginCents = summary.totalPriceCents - summary.totalCostCents;
const marginPercent = summary.totalPriceCents > 0
? Math.round((marginCents / summary.totalPriceCents) * 100)
: 0;
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
function updateAssumption(id: string, patch: Partial<AssumptionRow>) {
setAssumptions((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
}
function updateScopeItem(id: string, patch: Partial<ScopeRow>) {
setScopeItems((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
}
function updateDemandLine(id: string, patch: Partial<DemandRow>) {
setDemandLines((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
}
function applyResource(resourceId: string | null, demandLineId: string) {
const resource = resourceId
? resources.find((item) => item.id === resourceId) ?? null
: null;
updateDemandLine(demandLineId, {
resourceId,
name: resource?.displayName ?? "",
chapter: resource?.chapter ?? "",
currency: resource?.currency ?? baseCurrency,
costRate: resource ? (resource.lcrCents / 100).toFixed(2) : "",
billRate: resource ? (resource.ucrCents / 100).toFixed(2) : "",
roleId: resource?.roleId ?? null,
});
}
async function handleScopeImport(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
event.target.value = "";
if (!isSpreadsheetFile(file)) {
setScopeImportWarnings(["Unsupported file type. Please upload .xlsx, .xls, or .csv."]);
return;
}
try {
const result = await parseScopeImport(file);
setScopeImportWarnings(result.warnings);
if (result.rows.length > 0) {
const imported: ScopeRow[] = result.rows.map((row) => ({
id: crypto.randomUUID(),
sequenceNo: row.sequenceNo,
scopeType: row.scopeType,
name: row.name,
description: row.description,
}));
setScopeItems((current) => {
const nonEmpty = current.filter((item) => item.name.trim());
return [...nonEmpty, ...imported];
});
}
} catch {
setScopeImportWarnings(["Failed to parse the file. Please check the format."]);
}
}
function validateStep(targetStep: number) {
if (targetStep === 1 && !name.trim()) {
setError("Estimate name is required.");
return false;
}
setError(null);
return true;
}
function goNext() {
const nextStep = Math.min(step + 1, STEP_LABELS.length - 1);
if (!validateStep(nextStep)) return;
setStep(nextStep);
}
function goBack() {
setStep((current) => Math.max(current - 1, 0));
}
function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!name.trim()) {
setError("Estimate name is required.");
setStep(0);
return;
}
const normalizedDemandLines = demandLines
.map((line, index) => {
const resource = line.resourceId
? resources.find((item) => item.id === line.resourceId) ?? null
: null;
const role = line.roleId
? roles.find((item) => item.id === line.roleId) ?? null
: null;
const hours = toHours(line.hours);
const costRateCents = toCents(line.costRate);
const billRateCents = toCents(line.billRate);
const displayName = line.name.trim() || resource?.displayName || role?.name || `Line ${index + 1}`;
return {
resourceId: line.resourceId ?? undefined,
roleId: line.roleId ?? undefined,
lineType: "LABOR",
name: displayName,
chapter: line.chapter || resource?.chapter || undefined,
hours,
days: hours > 0 ? Number((hours / 8).toFixed(2)) : undefined,
rateSource: resource ? "RESOURCE" : role ? "ROLE" : "MANUAL",
costRateCents,
billRateCents,
currency: line.currency || resource?.currency || baseCurrency,
costTotalCents: Math.round(hours * costRateCents),
priceTotalCents: Math.round(hours * billRateCents),
monthlySpread:
selectedProject?.startDate && selectedProject?.endDate && hours > 0
? computeEvenSpread({
totalHours: hours,
startDate: new Date(selectedProject.startDate),
endDate: new Date(selectedProject.endDate),
}).spread
: {},
staffingAttributes: {
linkedResource: resource ? true : false,
linkedRole: role ? true : false,
},
metadata: {},
};
})
.filter((line) => line.hours > 0);
const normalizedScopeItems = scopeItems
.map((item, index) => ({
sequenceNo: index + 1,
scopeType: item.scopeType.trim() || "SHOT",
name: item.name.trim(),
description: item.description.trim() || undefined,
technicalSpec: {},
sortOrder: index,
metadata: {},
}))
.filter((item) => item.name.length > 0);
const normalizedAssumptions = assumptions
.map((assumption, index) => ({
category: assumption.category.trim() || "general",
key: assumption.key.trim() || slugify(assumption.label) || `assumption_${index + 1}`,
label: assumption.label.trim(),
valueType: "text",
value: assumption.value.trim(),
sortOrder: index,
}))
.filter((assumption) => assumption.label.length > 0 && String(assumption.value).length > 0);
const seenResources = new Set<string>();
const resourceSnapshots = normalizedDemandLines.flatMap((line) => {
if (!line.resourceId) return [];
if (seenResources.has(line.resourceId)) return [];
seenResources.add(line.resourceId);
const resource = resources.find((item) => item.id === line.resourceId) ?? null;
if (!resource) return [];
return [
{
resourceId: resource.id,
sourceEid: resource.eid,
displayName: resource.displayName,
chapter: resource.chapter ?? undefined,
roleId: resource.roleId ?? undefined,
currency: resource.currency,
lcrCents: resource.lcrCents,
ucrCents: resource.ucrCents,
location: resource.federalState ?? undefined,
attributes: {},
},
];
});
createMutation.mutate({
projectId: projectId ?? undefined,
name: name.trim(),
opportunityId: opportunityId.trim() || undefined,
baseCurrency,
status,
versionLabel: versionLabel.trim() || undefined,
versionNotes: versionNotes.trim() || undefined,
assumptions: normalizedAssumptions,
scopeItems: normalizedScopeItems,
demandLines: normalizedDemandLines,
resourceSnapshots,
metrics: [],
});
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-gray-950/45 p-4">
<div ref={panelRef} className="flex max-h-[92vh] w-full max-w-6xl flex-col overflow-hidden rounded-[32px] bg-white shadow-2xl">
<div className="border-b border-gray-100 px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Wizard</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Create a connected estimate</h2>
<p className="mt-1 text-sm text-gray-500">
Rates, resource snapshots, and project linkage are pulled from existing Planarchy data.
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-gray-200 px-3 py-2 text-sm text-gray-500 transition hover:border-gray-300 hover:text-gray-700"
>
Close
</button>
</div>
<div className="mt-5 grid gap-2 md:grid-cols-5">
{STEP_LABELS.map((label, index) => (
<button
key={label}
type="button"
onClick={() => {
if (index <= step || validateStep(index)) {
setStep(index);
}
}}
className={clsx(
"rounded-2xl px-4 py-3 text-left transition",
index === step
? "bg-brand-600 text-white"
: index < step
? "bg-brand-50 text-brand-700"
: "bg-gray-50 text-gray-400",
)}
>
<span className="block text-xs uppercase tracking-wide">Step {index + 1}</span>
<span className="mt-1 block text-sm font-semibold">{label}</span>
</button>
))}
</div>
</div>
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="grid min-h-0 flex-1 gap-0 lg:grid-cols-[minmax(0,1.15fr),360px]">
<div className="min-h-0 overflow-y-auto px-6 py-6">
{step === 0 && (
<div className="space-y-5">
<div className="grid gap-5 md:grid-cols-2">
<div>
<label className={LABEL_CLS}>Estimate Name</label>
<input value={name} onChange={(event) => setName(event.target.value)} className={INPUT_CLS} placeholder="CGI Breakdown Q2 2026" />
</div>
<div>
<label className={LABEL_CLS}>Linked Project</label>
<ProjectCombobox value={projectId} onChange={setProjectId} placeholder="Link to project" />
</div>
<div>
<label className={LABEL_CLS}>Opportunity ID</label>
<input value={opportunityId} onChange={(event) => setOpportunityId(event.target.value)} className={INPUT_CLS} placeholder="Optional CRM or sales reference" />
</div>
<div>
<label className={LABEL_CLS}>Estimate Status</label>
<select value={status} onChange={(event) => setStatus(event.target.value as EstimateStatus)} className={SELECT_CLS}>
{Object.values(EstimateStatus).map((value) => (
<option key={value} value={value}>
{value.replace("_", " ")}
</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLS}>Base Currency</label>
<input value={baseCurrency} onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())} className={INPUT_CLS} maxLength={3} />
</div>
<div>
<label className={LABEL_CLS}>Version Label</label>
<input value={versionLabel} onChange={(event) => setVersionLabel(event.target.value)} className={INPUT_CLS} placeholder="Initial" />
</div>
</div>
<div>
<label className={LABEL_CLS}>Version Notes</label>
<textarea
value={versionNotes}
onChange={(event) => setVersionNotes(event.target.value)}
rows={5}
className={INPUT_CLS}
placeholder="Document assumptions, exclusions, or client comments."
/>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 p-5">
<p className="text-sm font-semibold text-gray-900">Live connection preview</p>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Project source</p>
<p className="mt-1 text-sm text-gray-700">
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "Not linked yet"}
</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Live catalogs</p>
<p className="mt-1 text-sm text-gray-700">
{roles.length} roles, {resources.length} active resources available
</p>
</div>
</div>
</div>
</div>
)}
{step === 1 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Commercial and delivery assumptions</h3>
<p className="text-sm text-gray-500">These rows replace free-form spreadsheet notes with structured data.</p>
</div>
<button type="button" onClick={() => setAssumptions((current) => [...current, makeAssumption()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Add assumption
</button>
</div>
<div className="space-y-3">
{assumptions.map((row) => (
<div key={row.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[140px,1fr,1fr,1.2fr,auto]">
<input value={row.category} onChange={(event) => updateAssumption(row.id, { category: event.target.value })} className={INPUT_CLS} placeholder="Category" />
<input value={row.label} onChange={(event) => updateAssumption(row.id, { label: event.target.value })} className={INPUT_CLS} placeholder="Label" />
<input value={row.key} onChange={(event) => updateAssumption(row.id, { key: event.target.value })} className={INPUT_CLS} placeholder="Key (optional)" />
<input value={row.value} onChange={(event) => updateAssumption(row.id, { value: event.target.value })} className={INPUT_CLS} placeholder="Value" />
<button type="button" onClick={() => setAssumptions((current) => current.filter((item) => item.id !== row.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
Remove
</button>
</div>
))}
</div>
</div>
)}
{step === 2 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Scope breakdown</h3>
<p className="text-sm text-gray-500">Create structured work packages that can later evolve into versioned estimate scope.</p>
</div>
<div className="flex gap-2">
<label className="cursor-pointer rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Import XLSX
<input type="file" accept=".xlsx,.xls,.csv" onChange={handleScopeImport} className="hidden" />
</label>
<button type="button" onClick={() => setScopeItems((current) => [...current, makeScope(current.length + 1)])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Add scope row
</button>
</div>
</div>
{scopeImportWarnings.length > 0 && (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-700">
{scopeImportWarnings.map((warning, index) => (
<p key={index}>{warning}</p>
))}
</div>
)}
<div className="space-y-3">
{scopeItems.map((item, index) => (
<div key={item.id} className="grid gap-3 rounded-2xl border border-gray-100 p-4 md:grid-cols-[90px,120px,1fr,1.2fr,auto]">
<input value={String(index + 1)} readOnly className={clsx(INPUT_CLS, "bg-gray-50 text-gray-500")} />
<input value={item.scopeType} onChange={(event) => updateScopeItem(item.id, { scopeType: event.target.value })} className={INPUT_CLS} placeholder="Type" />
<input value={item.name} onChange={(event) => updateScopeItem(item.id, { name: event.target.value })} className={INPUT_CLS} placeholder="Name" />
<input value={item.description} onChange={(event) => updateScopeItem(item.id, { description: event.target.value })} className={INPUT_CLS} placeholder="Description" />
<button type="button" onClick={() => setScopeItems((current) => current.filter((row) => row.id !== item.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
Remove
</button>
</div>
))}
</div>
</div>
)}
{step === 3 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Staffing and rate lines</h3>
<p className="text-sm text-gray-500">Selecting a resource pre-fills cost rate, sell rate, chapter, and role from live data.</p>
</div>
<button type="button" onClick={() => setDemandLines((current) => [...current, makeDemand()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
Add staffing line
</button>
</div>
<div className="space-y-4">
{demandLines.map((line) => {
const resource = line.resourceId
? resources.find((item) => item.id === line.resourceId) ?? null
: null;
return (
<div key={line.id} className="rounded-3xl border border-gray-100 p-4">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<label className={LABEL_CLS}>Resource</label>
<ResourceCombobox value={line.resourceId} onChange={(resourceId) => applyResource(resourceId, line.id)} placeholder="Search resource" />
</div>
<div>
<label className={LABEL_CLS}>Role</label>
<select value={line.roleId ?? ""} onChange={(event) => updateDemandLine(line.id, { roleId: event.target.value || null })} className={SELECT_CLS}>
<option value="">Unassigned</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</div>
<div>
<label className={LABEL_CLS}>Line Name</label>
<input value={line.name} onChange={(event) => updateDemandLine(line.id, { name: event.target.value })} className={INPUT_CLS} placeholder="Compositing, lighting, PM, ..." />
</div>
<div>
<label className={LABEL_CLS}>Chapter</label>
<input value={line.chapter} onChange={(event) => updateDemandLine(line.id, { chapter: event.target.value })} className={INPUT_CLS} placeholder="Auto-filled from resource when linked" />
</div>
<div>
<label className={LABEL_CLS}>Hours</label>
<input value={line.hours} onChange={(event) => updateDemandLine(line.id, { hours: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
</div>
<div>
<label className={LABEL_CLS}>Currency</label>
<input value={line.currency} onChange={(event) => updateDemandLine(line.id, { currency: event.target.value.toUpperCase() })} className={INPUT_CLS} maxLength={3} />
</div>
<div>
<label className={LABEL_CLS}>Cost Rate / h</label>
<input value={line.costRate} onChange={(event) => updateDemandLine(line.id, { costRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
</div>
<div>
<label className={LABEL_CLS}>Sell Rate / h</label>
<input value={line.billRate} onChange={(event) => updateDemandLine(line.id, { billRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
</div>
</div>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
<div className="text-sm text-gray-600">
{resource ? `Linked to ${resource.displayName} (${resource.eid})` : "Manual line"}
</div>
<div className="flex flex-wrap gap-4 text-sm">
<span className="font-medium text-gray-700">
Cost {formatMoney(Math.round(toHours(line.hours) * toCents(line.costRate)), line.currency)}
</span>
<span className="font-medium text-gray-700">
Price {formatMoney(Math.round(toHours(line.hours) * toCents(line.billRate)), line.currency)}
</span>
</div>
<button type="button" onClick={() => setDemandLines((current) => current.filter((item) => item.id !== line.id))} className="rounded-xl border border-transparent px-3 py-2 text-sm text-red-500 transition hover:border-red-100 hover:bg-red-50">
Remove
</button>
</div>
</div>
);
})}
</div>
</div>
)}
{step === 4 && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900">Review</h3>
<p className="text-sm text-gray-500">The summary metrics below are recalculated from the demand rows and persisted on create.</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{summary.totalHours.toFixed(1)}</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<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(summary.totalCostCents, baseCurrency)}</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<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(summary.totalPriceCents, baseCurrency)}</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Margin</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(marginCents, baseCurrency)} ({marginPercent}%)
</p>
</div>
</div>
<div className="grid gap-5 lg:grid-cols-2">
<div className="rounded-3xl border border-gray-100 p-5">
<p className="text-sm font-semibold text-gray-900">Estimate envelope</p>
<dl className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex justify-between gap-4">
<dt>Name</dt>
<dd className="text-right text-gray-900">{name || "Untitled"}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Project</dt>
<dd className="text-right text-gray-900">{selectedProject ? `${selectedProject.shortCode}` : "Standalone"}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Status</dt>
<dd className="text-right text-gray-900">{status.replace("_", " ")}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Version</dt>
<dd className="text-right text-gray-900">{versionLabel || "Initial"}</dd>
</div>
</dl>
</div>
<div className="rounded-3xl border border-gray-100 p-5">
<p className="text-sm font-semibold text-gray-900">Connected records</p>
<dl className="mt-4 space-y-2 text-sm text-gray-600">
<div className="flex justify-between gap-4">
<dt>Assumptions</dt>
<dd className="text-right text-gray-900">{assumptions.filter((row) => row.label.trim()).length}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Scope items</dt>
<dd className="text-right text-gray-900">{scopeItems.filter((row) => row.name.trim()).length}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Demand lines</dt>
<dd className="text-right text-gray-900">{demandLines.filter((row) => toHours(row.hours) > 0).length}</dd>
</div>
<div className="flex justify-between gap-4">
<dt>Resource snapshots</dt>
<dd className="text-right text-gray-900">{new Set(demandLines.map((row) => row.resourceId).filter(Boolean)).size}</dd>
</div>
</dl>
</div>
</div>
</div>
)}
</div>
<aside className="border-t border-gray-100 bg-gray-50 px-6 py-6 lg:border-l lg:border-t-0">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-gray-500">Dynamic summary</p>
<div className="mt-4 space-y-3">
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Project link</p>
<p className="mt-1 text-sm text-gray-800">
{selectedProject ? `${selectedProject.shortCode} - ${selectedProject.name}` : "No linked project"}
</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Resource-linked demand</p>
<p className="mt-1 text-sm text-gray-800">
{demandLines.filter((line) => line.resourceId).length} of {demandLines.length} rows tied to live resources
</p>
</div>
<div className="rounded-2xl bg-white px-4 py-3">
<p className="text-xs uppercase tracking-wide text-gray-400">Calculated totals</p>
<p className="mt-1 text-sm text-gray-800">{summary.totalHours.toFixed(1)} h</p>
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalCostCents, baseCurrency)} cost</p>
<p className="mt-1 text-sm text-gray-800">{formatMoney(summary.totalPriceCents, baseCurrency)} price</p>
</div>
</div>
{error && (
<div className="mt-4 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
</aside>
</div>
<div className="flex items-center justify-between border-t border-gray-100 px-6 py-4">
<button type="button" onClick={step === 0 ? onClose : goBack} className="rounded-xl border border-gray-200 px-4 py-2 text-sm font-medium text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
{step === 0 ? "Cancel" : "Back"}
</button>
{step < STEP_LABELS.length - 1 ? (
<button type="button" onClick={goNext} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700">
Next
</button>
) : (
<button type="submit" disabled={createMutation.isPending} className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:cursor-not-allowed disabled:opacity-60">
{createMutation.isPending ? "Creating..." : "Create Estimate"}
</button>
)}
</div>
</form>
</div>
</div>
);
}