"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 (
);
}
return (
Editing working draft
Changes overwrite the current working version and refresh summary metrics on save.
{error && (
{error}
)}
{tab === "overview" && renderOverviewEditor()}
{tab === "assumptions" && (
)}
{tab === "scope" && (
)}
{tab === "staffing" && (
)}
{(tab === "versions" || tab === "exports") && renderEditorHint()}
);
}