Files
Nexus/apps/web/src/components/estimates/EstimateWorkspaceDraftEditor.tsx
T
Hartmut 4a5edeef3e
CI / Unit Tests (pull_request) Successful in 5m46s
CI / Lint (pull_request) Failing after 3m49s
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Failing after 35s
CI / Architecture Guardrails (pull_request) Failing after 2m14s
CI / Typecheck (pull_request) Successful in 4m22s
CI / Build (pull_request) Has been skipped
CI / Release Images (pull_request) Has been skipped
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI
- @capakraken/* → @nexus/* across 12 packages (root + 11 workspaces),
  1551 import lines migrated via codemod
- User-visible brand strings renamed (emails, page titles, PWA
  manifest, mobile header, MFA backup-codes header, tooltips, signin
  page, invite page, weekly digest, install prompt)
- TOTP issuer "CapaKraken" → "Nexus" (existing secrets still valid;
  re-enrollment relabels them in users' authenticator apps)
- Function rename: assertCapaKrakenDbTarget → assertNexusDbTarget
- LocalStorage migration shim in apps/web/src/app/layout.tsx copies
  capakraken_* → nexus_* on first load (guarded by nexus_migrated_v1
  sentinel; runs once per browser, then never again)
- Service-worker cache name capakraken-v2 → nexus-v2 with one-time
  caches.delete('capakraken-v2') from the same shim
- Email-domain fixtures @capakraken.{dev,app} → @nexus.{dev,app} in
  seed data, e2e specs, SMTP default fallback
- Dockerfile.dev / Dockerfile.prod / all .github/workflows/*.yml
  pnpm --filter @capakraken/* → @nexus/*
- README, CLAUDE.md, LEARNINGS.md, all docs/*.md, .env.example,
  tooling/deploy/.env.production.example brand sweep

Phase 1 deliberately leaves untouched (handled in Phase 3 cutover):
- PostgreSQL DB name "capakraken" and POSTGRES_USER "capakraken"
- Volume names capakraken_pgdata etc.
- Compose project name "capakraken" / "capakraken-prod"
- db-target-guard default expectedDatabase
- env-var CAPAKRAKEN_EXPECTED_DB_NAME
- Container DNS names in docker-compose.ci.yml

Quality gates green: pnpm typecheck (7/7), pnpm test:unit (7/7),
pnpm lint (0 errors), check:exports/imports/architecture all pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 15:10:44 +02:00

608 lines
22 KiB
TypeScript

"use client";
import { useEffect, useMemo, useState } from "react";
import type { EstimateDemandLineRateMode } from "@nexus/shared";
import {
computeEvenSpread,
getEstimateMonthRange,
rebalanceSpread,
summarizeMonthlySpread,
} from "@nexus/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[];
}
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="app-label">Estimate name</span>
<input
className="app-input"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
<label>
<span className="app-label">Opportunity ID</span>
<input
className="app-input"
value={opportunityId}
onChange={(event) => setOpportunityId(event.target.value)}
/>
</label>
<label>
<span className="app-label">Base currency</span>
<input
className="app-input"
maxLength={3}
value={baseCurrency}
onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())}
/>
</label>
<label>
<span className="app-label">Version label</span>
<input
className="app-input"
value={versionLabel}
onChange={(event) => setVersionLabel(event.target.value)}
/>
</label>
</div>
<label className="block">
<span className="app-label">Version notes</span>
<textarea
className="app-input 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>
);
}