9b5cd8549d
- 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>
581 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|