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,215 @@
"use client";
import { useState } from "react";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
interface ApplyEffortRulesProps {
estimateId: string;
canEdit: boolean;
onApplied?: () => void;
}
export function ApplyEffortRules({ estimateId, canEdit, onApplied }: ApplyEffortRulesProps) {
const utils = trpc.useUtils();
const { data: ruleSets, isLoading } = trpc.effortRule.list.useQuery();
const [selectedRuleSetId, setSelectedRuleSetId] = useState<string>("");
const [mode, setMode] = useState<"replace" | "append">("replace");
const [showPreview, setShowPreview] = useState(false);
const previewQuery = trpc.effortRule.preview.useQuery(
{ estimateId, ruleSetId: selectedRuleSetId },
{ enabled: showPreview && Boolean(selectedRuleSetId) },
);
const applyMutation = trpc.effortRule.apply.useMutation({
onSuccess: (result) => {
utils.estimate.getById.invalidate();
setShowPreview(false);
onApplied?.();
if (result.warnings.length > 0) {
alert(`Generated ${result.linesGenerated} demand lines.\n\nWarnings:\n${result.warnings.join("\n")}`);
}
},
});
// Auto-select default rule set
if (!selectedRuleSetId && ruleSets) {
const defaultSet = ruleSets.find((rs) => rs.isDefault) ?? ruleSets[0];
if (defaultSet) {
setSelectedRuleSetId(defaultSet.id);
}
}
if (!canEdit) return null;
if (isLoading) {
return <p className="text-sm text-gray-400">Loading effort rules...</p>;
}
if (!ruleSets || ruleSets.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500">
No effort rule sets defined.{" "}
<a href="/admin/effort-rules" className="text-brand-600 hover:underline">
Create one
</a>{" "}
to auto-generate demand lines from scope items.
</div>
);
}
return (
<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">Generate demand lines from scope</h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Rule set</span>
<select
value={selectedRuleSetId}
onChange={(e) => {
setSelectedRuleSetId(e.target.value);
setShowPreview(false);
}}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
>
{ruleSets.map((rs) => (
<option key={rs.id} value={rs.id}>
{rs.name} ({rs.rules.length} rules){rs.isDefault ? " *" : ""}
</option>
))}
</select>
</label>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Mode</span>
<select
value={mode}
onChange={(e) => setMode(e.target.value as "replace" | "append")}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
>
<option value="replace">Replace existing lines</option>
<option value="append">Append to existing</option>
</select>
</label>
<button
onClick={() => setShowPreview(!showPreview)}
disabled={!selectedRuleSetId}
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
{showPreview ? "Hide preview" : "Preview"}
</button>
<button
onClick={() => {
if (!selectedRuleSetId) return;
const action = mode === "replace" ? "replace all existing demand lines" : "append new demand lines";
if (confirm(`This will ${action}. Continue?`)) {
applyMutation.mutate({ estimateId, ruleSetId: selectedRuleSetId, mode });
}
}}
disabled={!selectedRuleSetId || applyMutation.isPending}
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{applyMutation.isPending ? "Generating..." : "Apply rules"}
</button>
</div>
{applyMutation.error && (
<p className="mt-2 text-sm text-red-600">{applyMutation.error.message}</p>
)}
{/* Preview */}
{showPreview && previewQuery.data && (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap gap-4 text-sm text-gray-600">
<span>{previewQuery.data.scopeItemCount} scope items</span>
<span>{previewQuery.data.ruleCount} rules</span>
<span className="font-semibold text-brand-700">{previewQuery.data.lines.length} demand lines would be generated</span>
{previewQuery.data.unmatchedScopeItems.length > 0 && (
<span className="text-amber-600">{previewQuery.data.unmatchedScopeItems.length} unmatched scope items</span>
)}
</div>
{previewQuery.data.warnings.length > 0 && (
<div className="rounded-xl bg-amber-50 p-3 text-sm text-amber-700">
{previewQuery.data.warnings.map((w, i) => (
<p key={i}>{w}</p>
))}
</div>
)}
{/* Aggregated discipline summary */}
{previewQuery.data.aggregated.length > 0 && (
<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">Discipline</th>
<th className="px-3 py-2 font-medium">Chapter</th>
<th className="px-3 py-2 text-right font-medium">Total hours</th>
<th className="pl-3 py-2 text-right font-medium">Lines</th>
</tr>
</thead>
<tbody>
{previewQuery.data.aggregated.map((agg, i) => (
<tr key={i} className="border-b border-gray-100">
<td className="py-1.5 pr-3 font-medium text-gray-900">{agg.discipline}</td>
<td className="px-3 py-1.5 text-gray-500">{agg.chapter ?? "\u2014"}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">{agg.totalHours.toFixed(1)} h</td>
<td className="pl-3 py-1.5 text-right tabular-nums text-gray-500">{agg.lineCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Detailed lines (collapsible) */}
{previewQuery.data.lines.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
Show all {previewQuery.data.lines.length} generated lines
</summary>
<div className="mt-2 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">Scope item</th>
<th className="px-3 py-2 font-medium">Discipline</th>
<th className="px-3 py-2 font-medium">Chapter</th>
<th className="px-3 py-2 font-medium">Mode</th>
<th className="px-3 py-2 text-right font-medium">Units</th>
<th className="px-3 py-2 text-right font-medium">h/unit</th>
<th className="pl-3 py-2 text-right font-medium">Hours</th>
</tr>
</thead>
<tbody>
{previewQuery.data.lines.map((line, i) => (
<tr key={i} className={clsx("border-b border-gray-100", i % 2 === 0 ? "" : "bg-gray-50")}>
<td className="py-1.5 pr-3 text-gray-900">{line.scopeItemName}</td>
<td className="px-3 py-1.5 text-gray-700">{line.discipline}</td>
<td className="px-3 py-1.5 text-gray-500">{line.chapter ?? "\u2014"}</td>
<td className="px-3 py-1.5 text-gray-500">{line.unitMode}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-600">{line.unitCount}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-600">{line.hoursPerUnit}</td>
<td className="pl-3 py-1.5 text-right tabular-nums font-medium text-gray-900">{line.hours.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</div>
)}
{showPreview && previewQuery.isLoading && (
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
)}
</div>
);
}
@@ -0,0 +1,214 @@
"use client";
import { useState } from "react";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
interface ApplyExperienceMultipliersProps {
estimateId: string;
canEdit: boolean;
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();
const [selectedSetId, setSelectedSetId] = useState<string>("");
const [showPreview, setShowPreview] = useState(false);
const previewQuery = trpc.experienceMultiplier.preview.useQuery(
{ estimateId, multiplierSetId: selectedSetId },
{ enabled: showPreview && Boolean(selectedSetId) },
);
const applyMutation = trpc.experienceMultiplier.apply.useMutation({
onSuccess: (result) => {
utils.estimate.getById.invalidate();
setShowPreview(false);
onApplied?.();
alert(
`Updated ${result.linesUpdated} demand line(s).\n` +
`Hours: ${result.totalOriginalHours}h -> ${result.totalAdjustedHours}h`,
);
},
});
// Auto-select default set
if (!selectedSetId && sets) {
const defaultSet = sets.find((s) => s.isDefault) ?? sets[0];
if (defaultSet) {
setSelectedSetId(defaultSet.id);
}
}
if (!canEdit) return null;
if (isLoading) {
return <p className="text-sm text-gray-400">Loading experience multipliers...</p>;
}
if (!sets || sets.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500">
No experience multiplier sets defined.{" "}
<a href="/admin/experience-multipliers" className="text-brand-600 hover:underline">
Create one
</a>{" "}
to apply rate and effort adjustments.
</div>
);
}
return (
<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">Apply experience multipliers</h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Multiplier set</span>
<select
value={selectedSetId}
onChange={(e) => {
setSelectedSetId(e.target.value);
setShowPreview(false);
}}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900"
>
{sets.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.rules.length} rules){s.isDefault ? " *" : ""}
</option>
))}
</select>
</label>
<button
onClick={() => setShowPreview(!showPreview)}
disabled={!selectedSetId}
className="rounded-2xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
{showPreview ? "Hide preview" : "Preview"}
</button>
<button
onClick={() => {
if (!selectedSetId) return;
if (confirm("This will update cost/bill rates and hours on matching demand lines. Continue?")) {
applyMutation.mutate({ estimateId, multiplierSetId: selectedSetId });
}
}}
disabled={!selectedSetId || applyMutation.isPending}
className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{applyMutation.isPending ? "Applying..." : "Apply multipliers"}
</button>
</div>
{applyMutation.error && (
<p className="mt-2 text-sm text-red-600">{applyMutation.error.message}</p>
)}
{/* Preview */}
{showPreview && previewQuery.data && (
<div className="mt-4 space-y-3">
<div className="flex flex-wrap gap-4 text-sm text-gray-600">
<span>{previewQuery.data.demandLineCount} demand lines</span>
<span>{previewQuery.data.ruleCount} rules</span>
<span className="font-semibold text-brand-700">
{previewQuery.data.linesChanged} line(s) would be adjusted
</span>
</div>
{previewQuery.data.linesChanged > 0 && (
<div className="rounded-xl bg-blue-50 p-3 text-sm text-blue-700">
Total cost: {formatCents(previewQuery.data.totalOriginalCostCents)} {"->"}{" "}
{formatCents(previewQuery.data.totalAdjustedCostCents)}
</div>
)}
{/* Per-line preview */}
{previewQuery.data.previews.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-gray-500 hover:text-gray-700">
Show all {previewQuery.data.previews.length} lines
</summary>
<div className="mt-2 overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Chapter</th>
<th className="px-3 py-2 text-right font-medium">Cost rate</th>
<th className="px-3 py-2 text-right font-medium">Bill rate</th>
<th className="px-3 py-2 text-right font-medium">Hours</th>
<th className="pl-3 py-2 font-medium">Changes</th>
</tr>
</thead>
<tbody>
{previewQuery.data.previews.map((p, i) => (
<tr
key={p.demandLineId}
className={clsx(
"border-b border-gray-100",
p.hasChanges ? "bg-amber-50" : i % 2 === 0 ? "" : "bg-gray-50",
)}
>
<td className="py-1.5 pr-3 text-gray-900">{p.name}</td>
<td className="px-3 py-1.5 text-gray-500">{p.chapter ?? "\u2014"}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
{p.hasChanges && p.adjustedCostRateCents !== p.originalCostRateCents ? (
<>
<span className="line-through text-gray-400">{formatCents(p.originalCostRateCents)}</span>{" "}
{formatCents(p.adjustedCostRateCents)}
</>
) : (
formatCents(p.originalCostRateCents)
)}
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
{p.hasChanges && p.adjustedBillRateCents !== p.originalBillRateCents ? (
<>
<span className="line-through text-gray-400">{formatCents(p.originalBillRateCents)}</span>{" "}
{formatCents(p.adjustedBillRateCents)}
</>
) : (
formatCents(p.originalBillRateCents)
)}
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-gray-700">
{p.hasChanges && p.adjustedHours !== p.originalHours ? (
<>
<span className="line-through text-gray-400">{p.originalHours}h</span>{" "}
{p.adjustedHours}h
</>
) : (
`${p.originalHours}h`
)}
</td>
<td className="pl-3 py-1.5 text-xs text-gray-500">
{p.hasChanges ? p.appliedRules[0] ?? "" : "No change"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</details>
)}
</div>
)}
{showPreview && previewQuery.isLoading && (
<p className="mt-3 text-sm text-gray-400">Computing preview...</p>
)}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,104 @@
import type {
EstimateDemandLineCalculationMetadata,
EstimateDemandLineMetadata,
EstimateDemandLineRateMode,
} from "@planarchy/shared";
interface ResourceRateSnapshotLike {
lcrCents: number;
ucrCents: number;
currency: string;
}
function parseRateMode(value: unknown): EstimateDemandLineRateMode | undefined {
return value === "resource" || value === "manual" ? value : undefined;
}
export function parseDemandLineMetadata(
metadata: Record<string, unknown> | null | undefined,
): EstimateDemandLineMetadata {
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
return {};
}
return metadata as EstimateDemandLineMetadata;
}
export function resolveDemandLineCalculationMetadata(options: {
resourceSnapshot?: ResourceRateSnapshotLike | null | undefined;
metadata?: Record<string, unknown> | null | undefined;
costRateCents: number;
billRateCents: number;
}): EstimateDemandLineCalculationMetadata {
const resourceSnapshot = options.resourceSnapshot;
const parsedMetadata = parseDemandLineMetadata(options.metadata);
const calculation =
typeof parsedMetadata.calculation === "object" &&
parsedMetadata.calculation !== null
? parsedMetadata.calculation
: undefined;
const costRateMode =
parseRateMode(calculation?.costRateMode) ??
(resourceSnapshot && options.costRateCents === resourceSnapshot.lcrCents
? "resource"
: "manual");
const billRateMode =
parseRateMode(calculation?.billRateMode) ??
(resourceSnapshot && options.billRateCents === resourceSnapshot.ucrCents
? "resource"
: "manual");
return {
costRateMode,
billRateMode,
totalMode: "computed",
liveCostRateCents: resourceSnapshot?.lcrCents ?? null,
liveBillRateCents: resourceSnapshot?.ucrCents ?? null,
liveCurrency: resourceSnapshot?.currency ?? null,
};
}
export function buildDemandLineMetadata(
metadata: Record<string, unknown> | null | undefined,
calculation: EstimateDemandLineCalculationMetadata,
): EstimateDemandLineMetadata {
return {
...parseDemandLineMetadata(metadata),
calculation,
};
}
export function getEffectiveDemandLineValues(options: {
resourceSnapshot?: ResourceRateSnapshotLike | null | undefined;
hours: number;
currency?: string | null;
defaultCurrency: string;
costRateCents: number;
billRateCents: number;
costRateMode: EstimateDemandLineRateMode;
billRateMode: EstimateDemandLineRateMode;
}) {
const effectiveCostRateCents =
options.costRateMode === "resource" && options.resourceSnapshot
? options.resourceSnapshot.lcrCents
: options.costRateCents;
const effectiveBillRateCents =
options.billRateMode === "resource" && options.resourceSnapshot
? options.resourceSnapshot.ucrCents
: options.billRateCents;
const currency =
((options.costRateMode === "resource" || options.billRateMode === "resource") &&
options.resourceSnapshot?.currency
? options.resourceSnapshot.currency
: options.currency) ||
options.resourceSnapshot?.currency ||
options.defaultCurrency;
return {
effectiveCostRateCents,
effectiveBillRateCents,
currency,
costTotalCents: Math.round(options.hours * effectiveCostRateCents),
priceTotalCents: Math.round(options.hours * effectiveBillRateCents),
};
}
@@ -0,0 +1,129 @@
"use client";
import type {
EstimateDemandLineMetadata,
EstimateExportArtifactPayload,
EstimateExportFormat,
EstimateStatus,
EstimateVersionStatus,
} from "@planarchy/shared";
export interface EstimateMetricView {
id: string;
key: string;
label: string;
valueDecimal: number;
valueCents?: number | null;
currency?: string | null;
}
export interface EstimateAssumptionView {
id: string;
category: string;
key: string;
label: string;
valueType: string;
value: unknown;
notes?: string | null;
}
export interface EstimateScopeItemView {
id: string;
sequenceNo: number;
scopeType: string;
packageCode?: string | null;
name: string;
description?: string | null;
frameCount?: number | null;
itemCount?: number | null;
unitMode?: string | null;
}
export interface EstimateDemandLineView {
id: string;
scopeItemId?: string | null;
roleId?: string | null;
resourceId?: string | null;
lineType: string;
name: string;
chapter?: string | null;
rateSource?: string | null;
hours: number;
currency: string;
costRateCents: number;
billRateCents: number;
costTotalCents: number;
priceTotalCents: number;
monthlySpread?: Record<string, number>;
metadata: EstimateDemandLineMetadata;
}
export interface EstimateResourceSnapshotView {
id: string;
resourceId?: string | null;
sourceEid?: string | null;
displayName: string;
chapter?: string | null;
roleId?: string | null;
currency: string;
lcrCents: number;
ucrCents: number;
fte?: number | null;
location?: string | null;
country?: string | null;
level?: string | null;
workType?: string | null;
attributes: Record<string, unknown>;
}
export interface EstimateExportView {
id: string;
fileName: string;
format: EstimateExportFormat;
payload?: EstimateExportArtifactPayload | Record<string, unknown> | null;
createdAt: Date;
}
export interface EstimateVersionView {
id: string;
versionNumber: number;
label?: string | null;
status: EstimateVersionStatus;
notes?: string | null;
lockedAt?: Date | null;
updatedAt: Date;
assumptions: EstimateAssumptionView[];
scopeItems: EstimateScopeItemView[];
demandLines: EstimateDemandLineView[];
resourceSnapshots: EstimateResourceSnapshotView[];
metrics: EstimateMetricView[];
exports: EstimateExportView[];
}
export interface EstimateWorkspaceView {
id: string;
name: string;
status: EstimateStatus;
projectId?: string | null;
opportunityId?: string | null;
baseCurrency: string;
updatedAt: Date;
project?: {
id: string;
name: string;
shortCode: string;
startDate?: string | Date | null;
endDate?: string | Date | null;
} | null;
versions: EstimateVersionView[];
}
export type WorkspaceTab =
| "overview"
| "assumptions"
| "scope"
| "staffing"
| "financials"
| "phasing"
| "versions"
| "exports";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,490 @@
"use client";
import { useMemo, useState } from "react";
import { clsx } from "clsx";
import {
compareEstimateVersions,
type VersionCompareInput,
type ChapterSubtotal,
type ResourceSnapshotDiff,
type ScopeItemDiff,
} from "@planarchy/engine";
import type { EstimateVersionView } from "~/components/estimates/EstimateWorkspace.types.js";
function formatMoney(cents: number, currency = "EUR") {
return new Intl.NumberFormat("de-DE", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(cents / 100);
}
function formatDelta(value: number, formatter: (v: number) => string) {
const prefix = value > 0 ? "+" : "";
return `${prefix}${formatter(value)}`;
}
function formatHoursDelta(delta: number) {
const prefix = delta > 0 ? "+" : "";
return `${prefix}${delta.toFixed(1)} h`;
}
function versionToInput(v: EstimateVersionView): VersionCompareInput {
return {
label: v.label ?? null,
versionNumber: v.versionNumber,
demandLines: v.demandLines.map((l) => ({
id: l.id,
name: l.name,
hours: l.hours,
costRateCents: l.costRateCents,
billRateCents: l.billRateCents,
costTotalCents: l.costTotalCents,
priceTotalCents: l.priceTotalCents,
...(l.chapter !== undefined ? { chapter: l.chapter } : {}),
lineType: l.lineType,
})),
assumptions: v.assumptions.map((a) => ({
key: a.key,
label: a.label,
value: a.value,
})),
scopeItems: v.scopeItems.map((s) => ({
id: s.id,
name: s.name,
sequenceNo: s.sequenceNo,
scopeType: s.scopeType,
...(s.packageCode !== undefined ? { packageCode: s.packageCode } : {}),
...(s.description !== undefined ? { description: s.description } : {}),
...(s.frameCount !== undefined ? { frameCount: s.frameCount } : {}),
...(s.itemCount !== undefined ? { itemCount: s.itemCount } : {}),
})),
resourceSnapshots: v.resourceSnapshots.map((r) => ({
id: r.id,
...(r.resourceId !== undefined ? { resourceId: r.resourceId } : {}),
displayName: r.displayName,
...(r.chapter !== undefined ? { chapter: r.chapter } : {}),
currency: r.currency,
lcrCents: r.lcrCents,
ucrCents: r.ucrCents,
...(r.location !== undefined ? { location: r.location } : {}),
...(r.level !== undefined ? { level: r.level } : {}),
})),
};
}
const STATUS_ROW_STYLES = {
added: "bg-emerald-50",
removed: "bg-red-50",
changed: "bg-amber-50",
unchanged: "",
} as const;
const STATUS_BADGE_STYLES = {
added: "bg-emerald-100 text-emerald-700",
removed: "bg-red-100 text-red-700",
changed: "bg-amber-100 text-amber-700",
unchanged: "bg-gray-100 text-gray-500",
} as const;
export function VersionCompare({ versions }: { versions: EstimateVersionView[] }) {
const sorted = useMemo(
() => [...versions].sort((a, b) => b.versionNumber - a.versionNumber),
[versions],
);
const [aId, setAId] = useState<string>(sorted[1]?.id ?? sorted[0]?.id ?? "");
const [bId, setBId] = useState<string>(sorted[0]?.id ?? "");
const [hideUnchanged, setHideUnchanged] = useState(false);
const versionA = sorted.find((v) => v.id === aId);
const versionB = sorted.find((v) => v.id === bId);
const diff = useMemo(() => {
if (!versionA || !versionB || versionA.id === versionB.id) return null;
return compareEstimateVersions(versionToInput(versionA), versionToInput(versionB));
}, [versionA, versionB]);
if (sorted.length < 2) {
return (
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-8 text-center text-sm text-gray-500">
At least two versions are required to compare.
</div>
);
}
const filteredDemandDiffs = diff
? hideUnchanged
? diff.demandLineDiffs.filter((d) => d.status !== "unchanged")
: diff.demandLineDiffs
: [];
const filteredAssumptionDiffs = diff
? hideUnchanged
? diff.assumptionDiffs.filter((d) => d.status !== "unchanged")
: diff.assumptionDiffs
: [];
const filteredScopeDiffs = diff
? hideUnchanged
? diff.scopeItemDiffs.filter((d) => d.status !== "unchanged")
: diff.scopeItemDiffs
: [];
const filteredResourceDiffs = diff
? hideUnchanged
? diff.resourceSnapshotDiffs.filter((d) => d.status !== "unchanged")
: diff.resourceSnapshotDiffs
: [];
return (
<div className="space-y-6">
{/* Version selectors */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-4 text-base font-semibold text-gray-900">Compare versions</h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Base (A)</span>
<select
value={aId}
onChange={(e) => setAId(e.target.value)}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900 focus:border-brand-400 focus:outline-none focus:ring-1 focus:ring-brand-400"
>
{sorted.map((v) => (
<option key={v.id} value={v.id}>
v{v.versionNumber} {v.label ? `\u2014 ${v.label}` : ""} ({v.status})
</option>
))}
</select>
</label>
<span className="pb-2 text-sm text-gray-400">vs</span>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Compare (B)</span>
<select
value={bId}
onChange={(e) => setBId(e.target.value)}
className="rounded-2xl border border-gray-300 bg-gray-50 px-3 py-2 text-sm text-gray-900 focus:border-brand-400 focus:outline-none focus:ring-1 focus:ring-brand-400"
>
{sorted.map((v) => (
<option key={v.id} value={v.id}>
v{v.versionNumber} {v.label ? `\u2014 ${v.label}` : ""} ({v.status})
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 pb-2 text-sm text-gray-600">
<input
type="checkbox"
checked={hideUnchanged}
onChange={(e) => setHideUnchanged(e.target.checked)}
className="rounded border-gray-300"
/>
Hide unchanged
</label>
</div>
{aId === bId && (
<p className="mt-3 text-sm text-amber-600">Select two different versions to compare.</p>
)}
</div>
{diff && (
<>
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4 lg:grid-cols-8">
<SummaryCard
label="Hours"
value={formatHoursDelta(diff.summary.totalHoursDelta)}
positive={diff.summary.totalHoursDelta <= 0}
/>
<SummaryCard
label="Cost"
value={formatDelta(diff.summary.totalCostDelta, (v) => formatMoney(v))}
positive={diff.summary.totalCostDelta <= 0}
/>
<SummaryCard
label="Price"
value={formatDelta(diff.summary.totalPriceDelta, (v) => formatMoney(v))}
positive={diff.summary.totalPriceDelta >= 0}
/>
<SummaryCard
label="Margin"
value={`${diff.summary.marginPercentB.toFixed(1)}% (${diff.summary.marginPercentDelta >= 0 ? "+" : ""}${diff.summary.marginPercentDelta.toFixed(1)}pp)`}
positive={diff.summary.marginPercentDelta >= 0}
/>
<SummaryCard label="Lines +" value={`+${diff.summary.linesAdded}`} positive />
<SummaryCard label="Lines -" value={`-${diff.summary.linesRemoved}`} positive={diff.summary.linesRemoved === 0} />
<SummaryCard label="Lines ~" value={String(diff.summary.linesChanged)} positive={diff.summary.linesChanged === 0} />
<SummaryCard label="Resources ~" value={String(diff.summary.resourceSnapshotsChanged)} positive={diff.summary.resourceSnapshotsChanged === 0} />
</div>
{/* Demand line diffs */}
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Demand lines</h3>
{filteredDemandDiffs.length === 0 ? (
<p className="py-4 text-center text-sm text-gray-400">
{hideUnchanged ? "No changes in demand lines." : "No demand lines to compare."}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 text-right font-medium">Hours (A)</th>
<th className="px-3 py-2 text-right font-medium">Hours (B)</th>
<th className="px-3 py-2 text-right font-medium">Hours delta</th>
<th className="px-3 py-2 text-right font-medium">Cost (A)</th>
<th className="px-3 py-2 text-right font-medium">Cost (B)</th>
<th className="px-3 py-2 text-right font-medium">Cost delta</th>
<th className="px-3 py-2 text-right font-medium">Price (A)</th>
<th className="px-3 py-2 text-right font-medium">Price (B)</th>
<th className="pl-3 py-2 text-right font-medium">Price delta</th>
</tr>
</thead>
<tbody>
{filteredDemandDiffs.map((d, i) => (
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.hours.toFixed(1) ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.hours.toFixed(1) ?? "\u2014"}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.hoursDelta))}>
{d.hoursDelta != null ? formatHoursDelta(d.hoursDelta) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a ? formatMoney(d.a.costTotalCents) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b ? formatMoney(d.b.costTotalCents) : "\u2014"}
</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(d.costDelta))}>
{d.costDelta != null ? formatDelta(d.costDelta, (v) => formatMoney(v)) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.a ? formatMoney(d.a.priceTotalCents) : "\u2014"}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{d.b ? formatMoney(d.b.priceTotalCents) : "\u2014"}
</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(d.priceDelta))}>
{d.priceDelta != null ? formatDelta(d.priceDelta, (v) => formatMoney(v)) : "\u2014"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Assumption diffs */}
{(filteredAssumptionDiffs.length > 0 || !hideUnchanged) && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Assumptions</h3>
{filteredAssumptionDiffs.length === 0 ? (
<p className="py-4 text-center text-sm text-gray-400">
{hideUnchanged ? "No changes in assumptions." : "No assumptions to compare."}
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Assumption</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Value (A)</th>
<th className="pl-3 py-2 font-medium">Value (B)</th>
</tr>
</thead>
<tbody>
{filteredAssumptionDiffs.map((d) => (
<tr key={d.key} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<td className="py-2 pr-3 font-medium text-gray-900">{d.label}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-gray-700">{formatAssumptionValue(d.aValue)}</td>
<td className="pl-3 py-2 text-gray-700">{formatAssumptionValue(d.bValue)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Chapter subtotals */}
{diff.chapterSubtotals.length > 0 && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">By chapter</h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Chapter</th>
<th className="px-3 py-2 text-right font-medium">Hours (A)</th>
<th className="px-3 py-2 text-right font-medium">Hours (B)</th>
<th className="px-3 py-2 text-right font-medium">Hours delta</th>
<th className="px-3 py-2 text-right font-medium">Cost (A)</th>
<th className="px-3 py-2 text-right font-medium">Cost (B)</th>
<th className="pl-3 py-2 text-right font-medium">Cost delta</th>
</tr>
</thead>
<tbody>
{diff.chapterSubtotals.map((ch) => (
<tr key={ch.chapter} className={clsx("border-b border-gray-100", ch.costDelta !== 0 ? "bg-amber-50" : "")}>
<td className="py-2 pr-3 font-medium text-gray-900">{ch.chapter}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursA.toFixed(1)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{ch.hoursB.toFixed(1)}</td>
<td className={clsx("px-3 py-2 text-right tabular-nums", deltaColor(ch.hoursDelta))}>
{formatHoursDelta(ch.hoursDelta)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costA)}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(ch.costB)}</td>
<td className={clsx("pl-3 py-2 text-right tabular-nums", deltaColor(ch.costDelta))}>
{formatDelta(ch.costDelta, (v) => formatMoney(v))}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Scope item diffs */}
{filteredScopeDiffs.length > 0 && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">
Scope items
{(diff.summary.scopeItemsAdded > 0 || diff.summary.scopeItemsRemoved > 0 || diff.summary.scopeItemsChanged > 0) && (
<span className="ml-2 text-sm font-normal text-gray-500">
{diff.summary.scopeItemsAdded > 0 && <span className="text-emerald-600">+{diff.summary.scopeItemsAdded}</span>}
{diff.summary.scopeItemsRemoved > 0 && <span className="ml-2 text-red-600">-{diff.summary.scopeItemsRemoved}</span>}
{diff.summary.scopeItemsChanged > 0 && <span className="ml-2 text-amber-600">~{diff.summary.scopeItemsChanged}</span>}
</span>
)}
</h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Type</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 text-right font-medium">Frames (A)</th>
<th className="px-3 py-2 text-right font-medium">Frames (B)</th>
<th className="px-3 py-2 text-right font-medium">Items (A)</th>
<th className="pl-3 py-2 text-right font-medium">Items (B)</th>
</tr>
</thead>
<tbody>
{filteredScopeDiffs.map((d, i) => (
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<td className="py-2 pr-3 font-medium text-gray-900">{d.name}</td>
<td className="px-3 py-2 text-gray-600">{d.scopeType}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.frameCount ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b?.frameCount ?? "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a?.itemCount ?? "\u2014"}</td>
<td className="pl-3 py-2 text-right tabular-nums text-gray-700">{d.b?.itemCount ?? "\u2014"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Resource snapshot diffs */}
{filteredResourceDiffs.length > 0 && (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<h3 className="mb-3 text-base font-semibold text-gray-900">Resource rates</h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 text-right font-medium">LCR (A)</th>
<th className="px-3 py-2 text-right font-medium">LCR (B)</th>
<th className="px-3 py-2 text-right font-medium">UCR (A)</th>
<th className="px-3 py-2 text-right font-medium">UCR (B)</th>
<th className="px-3 py-2 font-medium">Location (A)</th>
<th className="pl-3 py-2 font-medium">Location (B)</th>
</tr>
</thead>
<tbody>
{filteredResourceDiffs.map((d, i) => (
<tr key={i} className={clsx("border-b border-gray-100", STATUS_ROW_STYLES[d.status])}>
<td className="py-2 pr-3 font-medium text-gray-900">{d.displayName}</td>
<td className="px-3 py-2">
<span className={clsx("rounded-full px-2 py-0.5 text-xs font-medium", STATUS_BADGE_STYLES[d.status])}>
{d.status}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.lcrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.lcrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.a ? formatMoney(d.a.ucrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{d.b ? formatMoney(d.b.ucrCents) : "\u2014"}</td>
<td className="px-3 py-2 text-gray-600">{d.a?.location ?? "\u2014"}</td>
<td className="pl-3 py-2 text-gray-600">{d.b?.location ?? "\u2014"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
);
}
function SummaryCard({
label,
value,
positive,
}: {
label: string;
value: string;
positive: boolean;
}) {
return (
<div className="rounded-3xl border border-gray-200 bg-gray-50 p-4 text-center shadow-sm">
<p className="text-xs font-medium uppercase tracking-wider text-gray-500">{label}</p>
<p className={clsx("mt-1 text-lg font-semibold tabular-nums", positive ? "text-emerald-700" : "text-red-700")}>
{value}
</p>
</div>
);
}
function deltaColor(delta: number | undefined): string {
if (delta == null || delta === 0) return "text-gray-400";
return delta > 0 ? "text-red-600" : "text-emerald-600";
}
function formatAssumptionValue(value: unknown): string {
if (value === undefined) return "\u2014";
if (value === null) return "null";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
@@ -0,0 +1,438 @@
"use client";
import { useMemo, useState } from "react";
import { clsx } from "clsx";
import { trpc } from "~/lib/trpc/client.js";
interface WeeklyPhasingViewProps {
estimateId: string;
canEdit: boolean;
}
type ViewMode = "by_line" | "by_chapter";
type PhasingPattern = "even" | "front_loaded" | "back_loaded";
function getDefaultDateRange(): { start: string; end: string } {
const now = new Date();
const start = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
const endDate = new Date(now.getFullYear(), now.getMonth() + 6, 0);
const end = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, "0")}-${String(endDate.getDate()).padStart(2, "0")}`;
return { start, end };
}
function heatColor(hours: number, maxHours: number): string {
if (hours === 0 || maxHours === 0) return "";
const ratio = Math.min(hours / maxHours, 1);
if (ratio < 0.25) return "bg-blue-50";
if (ratio < 0.5) return "bg-blue-100";
if (ratio < 0.75) return "bg-blue-200";
return "bg-blue-300";
}
export function WeeklyPhasingView({ estimateId, canEdit }: WeeklyPhasingViewProps) {
const defaults = getDefaultDateRange();
const [startDate, setStartDate] = useState(defaults.start);
const [endDate, setEndDate] = useState(defaults.end);
const [pattern, setPattern] = useState<PhasingPattern>("even");
const [viewMode, setViewMode] = useState<ViewMode>("by_line");
const utils = trpc.useUtils();
const phasingQuery = trpc.estimate.getWeeklyPhasing.useQuery(
{ estimateId },
{ staleTime: 30_000 },
);
const generateMutation = trpc.estimate.generateWeeklyPhasing.useMutation({
onSuccess: () => {
void utils.estimate.getWeeklyPhasing.invalidate({ estimateId });
void utils.estimate.getById.invalidate({ id: estimateId });
},
});
const data = phasingQuery.data;
// Compute max hours for heat-map coloring
const maxHours = useMemo(() => {
if (!data?.hasPhasing) return 0;
let max = 0;
for (const line of data.lines) {
for (const h of Object.values(line.weeklyHours)) {
if (h > max) max = h;
}
}
return max;
}, [data]);
// Compute column totals
const columnTotals = useMemo(() => {
if (!data?.hasPhasing) return {};
const totals: Record<string, number> = {};
for (const line of data.lines) {
for (const [weekKey, hours] of Object.entries(line.weeklyHours)) {
totals[weekKey] = Math.round(((totals[weekKey] ?? 0) + hours) * 100) / 100;
}
}
return totals;
}, [data]);
// Compute chapter column totals
const chapterColumnTotals = useMemo(() => {
if (!data?.hasPhasing) return {};
const totals: Record<string, number> = {};
for (const chapterHours of Object.values(data.chapterAggregation)) {
for (const [weekKey, hours] of Object.entries(chapterHours)) {
totals[weekKey] = Math.round(((totals[weekKey] ?? 0) + hours) * 100) / 100;
}
}
return totals;
}, [data]);
// Compute max hours for chapter view
const maxChapterHours = useMemo(() => {
if (!data?.hasPhasing) return 0;
let max = 0;
for (const chapterHours of Object.values(data.chapterAggregation)) {
for (const h of Object.values(chapterHours)) {
if (h > max) max = h;
}
}
return max;
}, [data]);
const handleGenerate = () => {
generateMutation.mutate({
estimateId,
startDate,
endDate,
pattern,
});
};
// Use config dates from existing phasing if available
const effectiveStart = data?.hasPhasing ? data.config.startDate : startDate;
const effectiveEnd = data?.hasPhasing ? data.config.endDate : endDate;
return (
<div className="space-y-6">
{/* Header / Controls */}
<div className="rounded-3xl border border-gray-200 bg-white p-6">
<h3 className="mb-4 text-lg font-semibold text-gray-900">
Weekly Phasing (4Dispo)
</h3>
{canEdit && (
<div className="flex flex-wrap items-end gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Start Date
</label>
<input
type="date"
value={data?.hasPhasing ? effectiveStart : startDate}
onChange={(e) => setStartDate(e.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
End Date
</label>
<input
type="date"
value={data?.hasPhasing ? effectiveEnd : endDate}
onChange={(e) => setEndDate(e.target.value)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Pattern
</label>
<select
value={pattern}
onChange={(e) => setPattern(e.target.value as PhasingPattern)}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm"
>
<option value="even">Even Distribution</option>
<option value="front_loaded">Front Loaded (60/40)</option>
<option value="back_loaded">Back Loaded (40/60)</option>
</select>
</div>
<button
type="button"
onClick={handleGenerate}
disabled={generateMutation.isPending}
className={clsx(
"rounded-lg px-4 py-2 text-sm font-medium text-white",
generateMutation.isPending
? "cursor-not-allowed bg-gray-400"
: "bg-sky-600 hover:bg-sky-700",
)}
>
{generateMutation.isPending ? "Generating..." : "Generate Phasing"}
</button>
</div>
)}
{generateMutation.isError && (
<p className="mt-2 text-sm text-red-600">
{generateMutation.error.message}
</p>
)}
{generateMutation.isSuccess && (
<p className="mt-2 text-sm text-emerald-600">
Phasing generated for {generateMutation.data.linesUpdated} demand
lines.
</p>
)}
</div>
{/* View toggle */}
{data?.hasPhasing && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setViewMode("by_line")}
className={clsx(
"rounded-lg px-3 py-1.5 text-sm font-medium",
viewMode === "by_line"
? "bg-sky-100 text-sky-700"
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
)}
>
By Line
</button>
<button
type="button"
onClick={() => setViewMode("by_chapter")}
className={clsx(
"rounded-lg px-3 py-1.5 text-sm font-medium",
viewMode === "by_chapter"
? "bg-sky-100 text-sky-700"
: "bg-gray-100 text-gray-600 hover:bg-gray-200",
)}
>
By Chapter
</button>
</div>
)}
{/* Phasing Grid */}
{phasingQuery.isLoading && (
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
Loading phasing data...
</div>
)}
{data && !data.hasPhasing && (
<div className="rounded-3xl border border-gray-200 bg-white p-8 text-center text-gray-500">
No weekly phasing generated yet. Use the controls above to generate a
phasing distribution.
</div>
)}
{data?.hasPhasing && viewMode === "by_line" && (
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
Demand Line
</th>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
return (
<th
key={key}
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
>
{week.label}
</th>
);
})}
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
Total
</th>
</tr>
</thead>
<tbody>
{data.lines.map((line) => {
const lineTotal = Object.values(line.weeklyHours).reduce(
(sum, h) => sum + h,
0,
);
return (
<tr
key={line.id}
className="border-b border-gray-100 hover:bg-gray-50/50"
>
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
<div className="truncate max-w-[200px]" title={line.name}>
{line.name}
</div>
{line.chapter && (
<div className="text-xs text-gray-500">
{line.chapter}
</div>
)}
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const hours = line.weeklyHours[key] ?? 0;
return (
<td
key={key}
className={clsx(
"px-3 py-2 text-right tabular-nums",
heatColor(hours, maxHours),
)}
>
{hours > 0 ? hours.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
{lineTotal.toFixed(1)}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
Total
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const total = columnTotals[key] ?? 0;
return (
<td
key={key}
className="px-3 py-3 text-right tabular-nums text-gray-900"
>
{total > 0 ? total.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
{Object.values(columnTotals)
.reduce((sum, h) => sum + h, 0)
.toFixed(1)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
{data?.hasPhasing && viewMode === "by_chapter" && (
<div className="rounded-3xl border border-gray-200 bg-white overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-left font-medium text-gray-700 min-w-[200px]">
Chapter
</th>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
return (
<th
key={key}
className="px-3 py-3 text-right font-medium text-gray-600 min-w-[80px] whitespace-nowrap"
>
{week.label}
</th>
);
})}
<th className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right font-semibold text-gray-700 min-w-[90px]">
Total
</th>
</tr>
</thead>
<tbody>
{Object.entries(data.chapterAggregation)
.sort(([a], [b]) => a.localeCompare(b))
.map(([chapter, weeklyHours]) => {
const chapterTotal = Object.values(weeklyHours).reduce(
(sum, h) => sum + h,
0,
);
return (
<tr
key={chapter}
className="border-b border-gray-100 hover:bg-gray-50/50"
>
<td className="sticky left-0 z-10 bg-white px-4 py-2 font-medium text-gray-900">
{chapter}
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const hours = weeklyHours[key] ?? 0;
return (
<td
key={key}
className={clsx(
"px-3 py-2 text-right tabular-nums",
heatColor(hours, maxChapterHours),
)}
>
{hours > 0 ? hours.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-white px-4 py-2 text-right font-semibold tabular-nums text-gray-900">
{chapterTotal.toFixed(1)}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-gray-300 bg-gray-50 font-semibold">
<td className="sticky left-0 z-10 bg-gray-50 px-4 py-3 text-gray-700">
Total
</td>
{data.weeks.map((week) => {
const key = `${week.year}-W${String(week.weekNumber).padStart(2, "0")}`;
const total = chapterColumnTotals[key] ?? 0;
return (
<td
key={key}
className="px-3 py-3 text-right tabular-nums text-gray-900"
>
{total > 0 ? total.toFixed(1) : "-"}
</td>
);
})}
<td className="sticky right-0 z-10 bg-gray-50 px-4 py-3 text-right tabular-nums text-gray-900">
{Object.values(chapterColumnTotals)
.reduce((sum, h) => sum + h, 0)
.toFixed(1)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
)}
{/* Info about current phasing config */}
{data?.hasPhasing && data.config && (
<div className="rounded-3xl border border-gray-200 bg-white p-4">
<p className="text-sm text-gray-600">
<span className="font-medium">Current phasing:</span>{" "}
{data.config.pattern.replace("_", " ")} distribution from{" "}
{data.config.startDate} to {data.config.endDate} across{" "}
{data.weeks.length} weeks, {data.lines.length} demand lines.
</p>
</div>
)}
</div>
);
}