Files
CapaKraken/apps/web/src/components/estimates/EstimateWorkspaceDraftEditor.tsx
T
Hartmut 9b5cd8549d refactor(ui): replace inline INPUT_CLS/BTN_DANGER/action link constants with component classes
- Replace 13 local INPUT_CLS/SELECT_CLS/LABEL_CLS/BTN_DANGER constants with
  app-input, app-select, app-label, app-action-danger-btn component classes
  (CustomFieldFilterBar, RolePresetsEditor, FieldCard, BlueprintFieldCatalog,
  BlueprintFieldEditor, BlueprintsClient, EstimateWizard, EstimateWorkspace-
  DraftEditor, DemandLineEditor, ScopeItemEditor, AssumptionEditor,
  ProjectWizard, BulkEditModal)
- Replace inline text-blue-600/text-red-500 action link strings with
  app-action-edit / app-action-delete in AllocationsClient, ProjectsClient,
  ScenarioPlanner, ProjectDemandsTable, RolesClient, BlueprintsClient,
  CreateTaskModal, RateCardsClient, UsersClient, ManagementLevelsClient

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 07:02:08 +02:00

581 lines
21 KiB
TypeScript

"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 (
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
Draft editing is currently available in Overview, Assumptions, Scope Breakdown, and Staffing.
</div>
);
}
export function EstimateWorkspaceDraftEditor({
estimate,
tab,
onCancel,
onSaved,
}: {
estimate: EstimateWorkspaceView;
tab: WorkspaceTab;
onCancel: () => void;
onSaved: () => void | Promise<void>;
}) {
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<EditableAssumption[]>([]);
const [scopeItems, setScopeItems] = useState<EditableScopeItem[]>([]);
const [demandLines, setDemandLines] = useState<EditableDemandLine[]>([]);
const [error, setError] = useState<string | null>(null);
const [scopeImportWarnings, setScopeImportWarnings] = useState<string[]>([]);
const resourceList = (resourcesQuery.data as ResourceListView | undefined) ?? undefined;
const resourceOptions = useMemo<ResourceOption[]>(() => {
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<string, unknown>)
: {},
}));
}, [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<string, number> }).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<string, number> {
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<typeof snapshot> => 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 (
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),340px]">
<section className="space-y-5 rounded-3xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLS}>Estimate name</span>
<input className={INPUT_CLS} value={name} onChange={(event) => setName(event.target.value)} />
</label>
<label>
<span className={LABEL_CLS}>Opportunity ID</span>
<input className={INPUT_CLS} value={opportunityId} onChange={(event) => setOpportunityId(event.target.value)} />
</label>
<label>
<span className={LABEL_CLS}>Base currency</span>
<input className={INPUT_CLS} maxLength={3} value={baseCurrency} onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())} />
</label>
<label>
<span className={LABEL_CLS}>Version label</span>
<input className={INPUT_CLS} value={versionLabel} onChange={(event) => setVersionLabel(event.target.value)} />
</label>
</div>
<label className="block">
<span className={LABEL_CLS}>Version notes</span>
<textarea
className={`${INPUT_CLS} min-h-32`}
value={versionNotes}
onChange={(event) => setVersionNotes(event.target.value)}
/>
</label>
</section>
<aside className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<p className="text-sm font-semibold text-gray-900">Live draft summary</p>
<div className="mt-4 space-y-3">
<div className="flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3">
<span className="text-xs uppercase tracking-wide text-gray-400">Total Hours</span>
<span className="text-sm font-semibold text-gray-900">{summary.totalHours.toFixed(1)}</span>
</div>
<div className="flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3">
<span className="text-xs uppercase tracking-wide text-gray-400">Total Cost</span>
<span className="text-sm font-semibold text-gray-900">{formatMoney(summary.totalCostCents, baseCurrency)}</span>
</div>
<div className="flex items-center justify-between rounded-2xl bg-gray-50 px-4 py-3">
<span className="text-xs uppercase tracking-wide text-gray-400">Total Price</span>
<span className="text-sm font-semibold text-gray-900">{formatMoney(summary.totalPriceCents, baseCurrency)}</span>
</div>
</div>
</aside>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-brand-200 bg-brand-50 px-5 py-4">
<div>
<p className="text-sm font-semibold text-brand-800">Editing working draft</p>
<p className="text-sm text-brand-700">Changes overwrite the current working version and refresh summary metrics on save.</p>
</div>
<div className="flex gap-2">
<button type="button" className="rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700" onClick={onCancel}>
Cancel
</button>
<button type="button" className="rounded-2xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60" disabled={updateMutation.isPending} onClick={() => void handleSave()}>
{updateMutation.isPending ? "Saving..." : "Save draft"}
</button>
</div>
</div>
{error && (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
{error}
</div>
)}
{tab === "overview" && renderOverviewEditor()}
{tab === "assumptions" && (
<AssumptionEditor
assumptions={assumptions}
onChange={setAssumptions}
/>
)}
{tab === "scope" && (
<ScopeItemEditor
scopeItems={scopeItems}
onChange={setScopeItems}
scopeImportWarnings={scopeImportWarnings}
onScopeImportWarnings={setScopeImportWarnings}
/>
)}
{tab === "staffing" && (
<DemandLineEditor
demandLines={demandLines}
onChange={setDemandLines}
resourceOptions={resourceOptions}
resourceMap={resourceMap}
snapshotByResourceId={snapshotByResourceId}
baseCurrency={baseCurrency}
projectStartDate={projectStartDate}
projectEndDate={projectEndDate}
spreadMonths={spreadMonths}
aggregatedSpread={aggregatedSpread}
/>
)}
{(tab === "versions" || tab === "exports") && renderEditorHint()}
</div>
);
}