"use client"; import { useEffect, useMemo, useRef, useState } from "react"; import { EstimateStatus } from "@capakraken/shared"; import { computeEvenSpread } from "@capakraken/engine"; import { isSpreadsheetFile } from "~/lib/excel.js"; import { parseScopeImport } from "~/lib/scopeImportParser.js"; import { clsx } from "clsx"; import { formatMoney } from "~/lib/format.js"; import { useFocusTrap } from "~/hooks/useFocusTrap.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js"; import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js"; import { trpc } from "~/lib/trpc/client.js"; import { uuid } from "~/lib/uuid.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: uuid(), category: "commercial", key: "", label: "", value: "", }; } function makeScope(sequenceNo = 1): ScopeRow { return { id: uuid(), sequenceNo, scopeType: "SHOT", name: "", description: "", }; } function makeDemand(): DemandRow { return { id: uuid(), 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 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(null); const [opportunityId, setOpportunityId] = useState(""); const [baseCurrency, setBaseCurrency] = useState("EUR"); const [status, setStatus] = useState(EstimateStatus.DRAFT); const [versionLabel, setVersionLabel] = useState("Initial"); const [versionNotes, setVersionNotes] = useState(""); const [assumptions, setAssumptions] = useState([makeAssumption()]); const [scopeItems, setScopeItems] = useState([makeScope(1)]); const [demandLines, setDemandLines] = useState([makeDemand()]); const [error, setError] = useState(null); const [scopeImportWarnings, setScopeImportWarnings] = useState([]); const panelRef = useRef(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) { setAssumptions((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)), ); } function updateScopeItem(id: string, patch: Partial) { setScopeItems((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)), ); } function updateDemandLine(id: string, patch: Partial) { 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) { 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: uuid(), 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(); 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 (

Estimate Wizard

Create a connected estimate

Rates, resource snapshots, and project linkage are pulled from existing CapaKraken data.

{STEP_LABELS.map((label, index) => ( ))}
{step === 0 && (
setName(event.target.value)} className={INPUT_CLS} placeholder="CGI Breakdown Q2 2026" />
setOpportunityId(event.target.value)} className={INPUT_CLS} placeholder="Optional CRM or sales reference" />
setBaseCurrency(event.target.value.toUpperCase())} className={INPUT_CLS} maxLength={3} />
setVersionLabel(event.target.value)} className={INPUT_CLS} placeholder="Initial" />