"use client"; import { useEffect, useMemo, useState } from "react"; import type { EstimateDemandLineRateMode } from "@capakraken/shared"; import { computeEvenSpread, getEstimateMonthRange, rebalanceSpread, summarizeMonthlySpread, } from "@capakraken/engine"; import { buildDemandLineMetadata, getEffectiveDemandLineValues, resolveDemandLineCalculationMetadata, } from "~/components/estimates/EstimateWorkspace.calculations.js"; import type { EstimateResourceSnapshotView, EstimateVersionView, EstimateWorkspaceView, WorkspaceTab, } from "~/components/estimates/EstimateWorkspace.types.js"; import { AssumptionEditor, type EditableAssumption, } from "~/components/estimates/editors/AssumptionEditor.js"; import { ScopeItemEditor, type EditableScopeItem, } from "~/components/estimates/editors/ScopeItemEditor.js"; import { DemandLineEditor, type EditableDemandLine, type ResourceOption, } from "~/components/estimates/editors/DemandLineEditor.js"; import { formatMoney } from "~/lib/format.js"; import { trpc } from "~/lib/trpc/client.js"; interface ResourceListView { resources: ResourceOption[]; } const INPUT_CLS = "app-input"; const LABEL_CLS = "app-label"; function toNumber(value: string) { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : 0; } function toCents(value: string) { return Math.round(toNumber(value) * 100); } function parseAssumptionValue(valueType: string, rawValue: string) { if (valueType === "number") { return toNumber(rawValue); } if (valueType === "boolean") { return rawValue.trim().toLowerCase() === "true"; } if (valueType === "json") { try { return rawValue.trim() ? JSON.parse(rawValue) : {}; } catch { return rawValue; } } return rawValue; } function stringifyAssumptionValue(value: unknown) { if (typeof value === "string") { return value; } if (value == null) { return ""; } if (typeof value === "object") { return JSON.stringify(value); } return String(value); } function renderEditorHint() { return (
Draft editing is currently available in Overview, Assumptions, Scope Breakdown, and Staffing.
); } export function EstimateWorkspaceDraftEditor({ estimate, tab, onCancel, onSaved, }: { estimate: EstimateWorkspaceView; tab: WorkspaceTab; onCancel: () => void; onSaved: () => void | Promise; }) { const utils = trpc.useUtils(); const versions = estimate.versions as EstimateVersionView[]; const resourcesQuery = trpc.resource.listStaff.useQuery( { isActive: true, limit: 200 }, { staleTime: 15_000 }, ); const workingVersion = versions.find((version) => version.status === "WORKING") ?? versions[0] ?? null; const [name, setName] = useState(estimate.name); const [opportunityId, setOpportunityId] = useState(estimate.opportunityId ?? ""); const [baseCurrency, setBaseCurrency] = useState(estimate.baseCurrency); const [versionLabel, setVersionLabel] = useState(workingVersion?.label ?? ""); const [versionNotes, setVersionNotes] = useState(workingVersion?.notes ?? ""); const [assumptions, setAssumptions] = useState([]); const [scopeItems, setScopeItems] = useState([]); const [demandLines, setDemandLines] = useState([]); const [error, setError] = useState(null); const [scopeImportWarnings, setScopeImportWarnings] = useState([]); const resourceList = (resourcesQuery.data as ResourceListView | undefined) ?? undefined; const resourceOptions = useMemo(() => { const resources = resourceList?.resources ?? []; return resources.map((resource) => ({ id: resource.id, eid: resource.eid, displayName: resource.displayName, chapter: resource.chapter ?? null, roleId: resource.roleId ?? null, lcrCents: resource.lcrCents, ucrCents: resource.ucrCents, currency: resource.currency, dynamicFields: typeof resource.dynamicFields === "object" && resource.dynamicFields !== null && !Array.isArray(resource.dynamicFields) ? (resource.dynamicFields as Record) : {}, })); }, [resourceList]); const resourceMap = useMemo( () => new Map(resourceOptions.map((resource) => [resource.id, resource])), [resourceOptions], ); const snapshotByResourceId = useMemo( () => new Map( (workingVersion?.resourceSnapshots ?? []) .filter( ( snapshot, ): snapshot is EstimateResourceSnapshotView & { resourceId: string } => typeof snapshot.resourceId === "string" && snapshot.resourceId.length > 0, ) .map((snapshot) => [snapshot.resourceId, snapshot]), ), [workingVersion], ); useEffect(() => { setName(estimate.name); setOpportunityId(estimate.opportunityId ?? ""); setBaseCurrency(estimate.baseCurrency); setVersionLabel(workingVersion?.label ?? ""); setVersionNotes(workingVersion?.notes ?? ""); setAssumptions( (workingVersion?.assumptions ?? []).map((assumption) => ({ id: assumption.id, category: assumption.category, key: assumption.key, label: assumption.label, valueType: assumption.valueType, value: stringifyAssumptionValue(assumption.value), notes: assumption.notes ?? "", })), ); setScopeItems( (workingVersion?.scopeItems ?? []).map((item) => ({ id: item.id, sequenceNo: String(item.sequenceNo), scopeType: item.scopeType, packageCode: item.packageCode ?? "", name: item.name, description: item.description ?? "", })), ); setDemandLines( (workingVersion?.demandLines ?? []).map((line) => { const resourceSnapshot = line.resourceId != null ? snapshotByResourceId.get(line.resourceId) : null; const calculation = resolveDemandLineCalculationMetadata({ resourceSnapshot, metadata: line.metadata, costRateCents: line.costRateCents, billRateCents: line.billRateCents, }); const existingSpread = (line as { monthlySpread?: Record }).monthlySpread ?? {}; return { id: line.id, ...(line.scopeItemId ? { scopeItemId: line.scopeItemId } : {}), ...(line.roleId ? { roleId: line.roleId } : {}), ...(line.resourceId ? { resourceId: line.resourceId } : {}), lineType: line.lineType, name: line.name, chapter: line.chapter ?? "", hours: line.hours.toFixed(1), currency: line.currency, costRate: (line.costRateCents / 100).toFixed(2), billRate: (line.billRateCents / 100).toFixed(2), costRateMode: calculation.costRateMode, billRateMode: calculation.billRateMode, metadata: line.metadata ?? {}, lockedMonths: existingSpread, spreadExpanded: Object.keys(existingSpread).length > 0, }; }), ); setError(null); }, [estimate, snapshotByResourceId, workingVersion]); const summary = useMemo(() => { return demandLines.reduce( (accumulator, line) => { const hours = toNumber(line.hours); const resourceSnapshot = line.resourceId != null ? resourceMap.get(line.resourceId) ?? snapshotByResourceId.get(line.resourceId) ?? null : null; const effectiveValues = getEffectiveDemandLineValues({ resourceSnapshot, hours, currency: line.currency, defaultCurrency: baseCurrency, costRateCents: toCents(line.costRate), billRateCents: toCents(line.billRate), costRateMode: line.costRateMode, billRateMode: line.billRateMode, }); return { totalHours: accumulator.totalHours + hours, totalCostCents: accumulator.totalCostCents + effectiveValues.costTotalCents, totalPriceCents: accumulator.totalPriceCents + effectiveValues.priceTotalCents, }; }, { totalHours: 0, totalCostCents: 0, totalPriceCents: 0 }, ); }, [baseCurrency, demandLines, resourceMap, snapshotByResourceId]); const updateMutation = trpc.estimate.updateDraft.useMutation({ onSuccess: async () => { await Promise.all([ utils.estimate.list.invalidate(), utils.estimate.getById.invalidate({ id: estimate.id }), ]); await onSaved(); }, onError: (mutationError) => { setError(mutationError.message); }, }); if (!workingVersion) { return renderEditorHint(); } function getLineResourceSnapshot(line: EditableDemandLine) { if (!line.resourceId) { return null; } return resourceMap.get(line.resourceId) ?? snapshotByResourceId.get(line.resourceId) ?? null; } function getLineEffectiveValues(line: EditableDemandLine) { return getEffectiveDemandLineValues({ resourceSnapshot: getLineResourceSnapshot(line), hours: toNumber(line.hours), currency: line.currency, defaultCurrency: baseCurrency, costRateCents: toCents(line.costRate), billRateCents: toCents(line.billRate), costRateMode: line.costRateMode, billRateMode: line.billRateMode, }); } const projectStartDate = estimate.project?.startDate ? new Date(estimate.project.startDate) : null; const projectEndDate = estimate.project?.endDate ? new Date(estimate.project.endDate) : null; const hasProjectDates = projectStartDate !== null && projectEndDate !== null; function computeLineSpread(line: EditableDemandLine): Record { if (!hasProjectDates) return {}; const hours = toNumber(line.hours); if (hours <= 0) return {}; const lockedKeys = Object.keys(line.lockedMonths); if (lockedKeys.length > 0) { return rebalanceSpread({ totalHours: hours, startDate: projectStartDate, endDate: projectEndDate, lockedMonths: line.lockedMonths, }).spread; } return computeEvenSpread({ totalHours: hours, startDate: projectStartDate, endDate: projectEndDate, }).spread; } const spreadMonths = hasProjectDates ? getEstimateMonthRange(projectStartDate, projectEndDate) : []; const aggregatedSpread = hasProjectDates ? summarizeMonthlySpread(demandLines.map(computeLineSpread)) : {}; async function handleSave() { setError(null); const sanitizedAssumptions = assumptions .filter((assumption) => assumption.key.trim() && assumption.label.trim()) .map((assumption, index) => ({ ...(assumption.id ? { id: assumption.id } : {}), category: assumption.category.trim() || "commercial", key: assumption.key.trim(), label: assumption.label.trim(), valueType: assumption.valueType.trim() || "string", value: parseAssumptionValue(assumption.valueType, assumption.value), sortOrder: index, ...(assumption.notes.trim() ? { notes: assumption.notes.trim() } : {}), })); const sanitizedScopeItems = scopeItems .filter((item) => item.name.trim()) .map((item, index) => ({ ...(item.id ? { id: item.id } : {}), sequenceNo: Math.max(1, Math.round(toNumber(item.sequenceNo) || index + 1)), scopeType: item.scopeType.trim() || "SHOT", ...(item.packageCode.trim() ? { packageCode: item.packageCode.trim() } : {}), name: item.name.trim(), ...(item.description.trim() ? { description: item.description.trim() } : {}), technicalSpec: {}, sortOrder: index, metadata: {}, })); const sanitizedDemandLines = demandLines .filter((line) => line.name.trim()) .map((line) => { const hours = toNumber(line.hours); const resourceSnapshot = getLineResourceSnapshot(line); const effectiveValues = getLineEffectiveValues(line); const calculation = resolveDemandLineCalculationMetadata({ resourceSnapshot, metadata: line.metadata, costRateCents: effectiveValues.effectiveCostRateCents, billRateCents: effectiveValues.effectiveBillRateCents, }); return { ...(line.id ? { id: line.id } : {}), ...(line.scopeItemId ? { scopeItemId: line.scopeItemId } : {}), ...(line.roleId ? { roleId: line.roleId } : {}), ...(line.resourceId ? { resourceId: line.resourceId } : {}), lineType: line.lineType.trim() || "LABOR", name: line.name.trim(), ...(line.chapter.trim() ? { chapter: line.chapter.trim() } : {}), hours, costRateCents: effectiveValues.effectiveCostRateCents, billRateCents: effectiveValues.effectiveBillRateCents, currency: effectiveValues.currency, costTotalCents: effectiveValues.costTotalCents, priceTotalCents: effectiveValues.priceTotalCents, monthlySpread: computeLineSpread(line), staffingAttributes: {}, metadata: buildDemandLineMetadata(line.metadata, { ...calculation, costRateMode: line.costRateMode, billRateMode: line.billRateMode, }), }; }); const linkedResourceIds = [ ...new Set( sanitizedDemandLines .map((line) => line.resourceId) .filter((resourceId): resourceId is string => typeof resourceId === "string" && resourceId.length > 0), ), ]; const sanitizedResourceSnapshots = linkedResourceIds .map((resourceId) => { const liveResource = resourceMap.get(resourceId); const existingSnapshot = snapshotByResourceId.get(resourceId); if (!liveResource && !existingSnapshot) { return null; } if (liveResource) { return { ...(existingSnapshot?.id ? { id: existingSnapshot.id } : {}), resourceId: liveResource.id, sourceEid: liveResource.eid, displayName: liveResource.displayName, ...(liveResource.chapter ? { chapter: liveResource.chapter } : {}), ...(liveResource.roleId ? { roleId: liveResource.roleId } : {}), currency: liveResource.currency, lcrCents: liveResource.lcrCents, ucrCents: liveResource.ucrCents, attributes: liveResource.dynamicFields ?? {}, }; } return { ...(existingSnapshot?.id ? { id: existingSnapshot.id } : {}), resourceId, ...(existingSnapshot?.sourceEid ? { sourceEid: existingSnapshot.sourceEid } : {}), displayName: existingSnapshot?.displayName ?? "Linked resource", ...(existingSnapshot?.chapter ? { chapter: existingSnapshot.chapter } : {}), ...(existingSnapshot?.roleId ? { roleId: existingSnapshot.roleId } : {}), currency: existingSnapshot?.currency ?? baseCurrency, lcrCents: existingSnapshot?.lcrCents ?? 0, ucrCents: existingSnapshot?.ucrCents ?? 0, ...(existingSnapshot?.fte != null ? { fte: existingSnapshot.fte } : {}), ...(existingSnapshot?.location ? { location: existingSnapshot.location } : {}), ...(existingSnapshot?.country ? { country: existingSnapshot.country } : {}), ...(existingSnapshot?.level ? { level: existingSnapshot.level } : {}), ...(existingSnapshot?.workType ? { workType: existingSnapshot.workType } : {}), attributes: typeof existingSnapshot?.attributes === "object" && existingSnapshot.attributes !== null && !Array.isArray(existingSnapshot.attributes) ? existingSnapshot.attributes : {}, }; }) .filter((snapshot): snapshot is NonNullable => snapshot !== null); await updateMutation.mutateAsync({ id: estimate.id, ...(estimate.projectId ? { projectId: estimate.projectId } : {}), name: name.trim(), ...(opportunityId.trim() ? { opportunityId: opportunityId.trim() } : {}), baseCurrency: baseCurrency.trim() || "EUR", ...(versionLabel.trim() ? { versionLabel: versionLabel.trim() } : {}), ...(versionNotes.trim() ? { versionNotes: versionNotes.trim() } : {}), assumptions: sanitizedAssumptions, scopeItems: sanitizedScopeItems, demandLines: sanitizedDemandLines, resourceSnapshots: sanitizedResourceSnapshots, metrics: [], }); } function renderOverviewEditor() { return (