refactor: complete v2 refactoring plan (Phases 1-5)
Phase 1 — Quick Wins: centralize formatMoney/formatCents, extract findUniqueOrThrow helper (19 routers), shared Prisma select constants, useInvalidatePlanningViews hook, status badge consolidation, composite DB indexes. Phase 2 — Timeline Split: extract TimelineContext, TimelineResourcePanel, TimelineProjectPanel; split 28-dep useMemo into 3 focused memos. TimelineView.tsx reduced from 1,903 to 538 lines. Phase 3 — Query Performance: server-side filtering for getEntriesView, remove availability from timeline resource select, SSE event debouncing (50ms batch window). Phase 4 — Estimate Workspace: extract 7 tab components and 3 editor components. EstimateWorkspaceClient 1,298→306 lines, EstimateWorkspaceDraftEditor 1,205→581 lines. Phase 5 — Package Cleanup: split commit-dispo-import-batch (1,112→573 lines), extract shared pagination helper with 11 tests. All tests pass: 209 API, 254 engine, 67 application. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -19,62 +19,22 @@ import type {
|
||||
EstimateWorkspaceView,
|
||||
WorkspaceTab,
|
||||
} from "~/components/estimates/EstimateWorkspace.types.js";
|
||||
import { isSpreadsheetFile } from "~/lib/excel.js";
|
||||
import { parseScopeImport } from "~/lib/scopeImportParser.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 EditableAssumption {
|
||||
id?: string;
|
||||
category: string;
|
||||
key: string;
|
||||
label: string;
|
||||
valueType: string;
|
||||
value: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface EditableScopeItem {
|
||||
id?: string;
|
||||
sequenceNo: string;
|
||||
scopeType: string;
|
||||
packageCode: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface EditableDemandLine {
|
||||
id?: string;
|
||||
scopeItemId?: string;
|
||||
roleId?: string;
|
||||
resourceId?: string;
|
||||
lineType: string;
|
||||
name: string;
|
||||
chapter: string;
|
||||
hours: string;
|
||||
currency: string;
|
||||
costRate: string;
|
||||
billRate: string;
|
||||
costRateMode: EstimateDemandLineRateMode;
|
||||
billRateMode: EstimateDemandLineRateMode;
|
||||
metadata: Record<string, unknown>;
|
||||
/** Locked monthly hours overrides, keyed by "YYYY-MM" */
|
||||
lockedMonths: Record<string, number>;
|
||||
/** Whether the monthly spread section is expanded */
|
||||
spreadExpanded: boolean;
|
||||
}
|
||||
|
||||
interface ResourceOption {
|
||||
id: string;
|
||||
eid: string;
|
||||
displayName: string;
|
||||
chapter?: string | null;
|
||||
roleId?: string | null;
|
||||
lcrCents: number;
|
||||
ucrCents: number;
|
||||
currency: string;
|
||||
dynamicFields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface ResourceListView {
|
||||
resources: ResourceOption[];
|
||||
}
|
||||
@@ -83,14 +43,6 @@ const INPUT_CLS =
|
||||
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
|
||||
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
|
||||
|
||||
function formatMoney(cents: number, currency = "EUR") {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
|
||||
function toNumber(value: string) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
@@ -130,44 +82,6 @@ function stringifyAssumptionValue(value: unknown) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function makeAssumption(): EditableAssumption {
|
||||
return {
|
||||
category: "commercial",
|
||||
key: "",
|
||||
label: "",
|
||||
valueType: "string",
|
||||
value: "",
|
||||
notes: "",
|
||||
};
|
||||
}
|
||||
|
||||
function makeScopeItem(sequenceNo: number): EditableScopeItem {
|
||||
return {
|
||||
sequenceNo: String(sequenceNo),
|
||||
scopeType: "SHOT",
|
||||
packageCode: "",
|
||||
name: "",
|
||||
description: "",
|
||||
};
|
||||
}
|
||||
|
||||
function makeDemandLine(): EditableDemandLine {
|
||||
return {
|
||||
lineType: "LABOR",
|
||||
name: "",
|
||||
chapter: "",
|
||||
hours: "8",
|
||||
currency: "EUR",
|
||||
costRate: "0",
|
||||
billRate: "0",
|
||||
costRateMode: "manual",
|
||||
billRateMode: "manual",
|
||||
metadata: {},
|
||||
lockedMonths: {},
|
||||
spreadExpanded: false,
|
||||
};
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -356,8 +270,6 @@ export function EstimateWorkspaceDraftEditor({
|
||||
return renderEditorHint();
|
||||
}
|
||||
|
||||
const editableVersion = workingVersion;
|
||||
|
||||
function getLineResourceSnapshot(line: EditableDemandLine) {
|
||||
if (!line.resourceId) {
|
||||
return null;
|
||||
@@ -379,117 +291,6 @@ export function EstimateWorkspaceDraftEditor({
|
||||
});
|
||||
}
|
||||
|
||||
function updateDemandLine(index: number, updater: (line: EditableDemandLine) => EditableDemandLine) {
|
||||
setDemandLines((current) =>
|
||||
current.map((entry, entryIndex) => (entryIndex === index ? updater(entry) : entry)),
|
||||
);
|
||||
}
|
||||
|
||||
function applyResourceSelection(index: number, nextResourceId: string) {
|
||||
updateDemandLine(index, (line) => {
|
||||
if (!nextResourceId) {
|
||||
const { resourceId: _resourceId, ...unlinkedLine } = line;
|
||||
return {
|
||||
...unlinkedLine,
|
||||
costRateMode: "manual",
|
||||
billRateMode: "manual",
|
||||
};
|
||||
}
|
||||
|
||||
const resource = resourceMap.get(nextResourceId);
|
||||
if (!resource) {
|
||||
return line;
|
||||
}
|
||||
|
||||
return {
|
||||
...line,
|
||||
resourceId: resource.id,
|
||||
...(resource.roleId ? { roleId: resource.roleId } : {}),
|
||||
chapter: resource.chapter ?? line.chapter,
|
||||
currency: resource.currency,
|
||||
costRate: (resource.lcrCents / 100).toFixed(2),
|
||||
billRate: (resource.ucrCents / 100).toFixed(2),
|
||||
costRateMode: "resource",
|
||||
billRateMode: "resource",
|
||||
name: line.name.trim() ? line.name : resource.displayName,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function syncDemandLineRates(index: number) {
|
||||
updateDemandLine(index, (line) => {
|
||||
if (!line.resourceId) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const resource = resourceMap.get(line.resourceId);
|
||||
if (!resource) {
|
||||
return line;
|
||||
}
|
||||
|
||||
return {
|
||||
...line,
|
||||
chapter: resource.chapter ?? line.chapter,
|
||||
currency: resource.currency,
|
||||
costRate: (resource.lcrCents / 100).toFixed(2),
|
||||
billRate: (resource.ucrCents / 100).toFixed(2),
|
||||
costRateMode: "resource",
|
||||
billRateMode: "resource",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function setDemandLineRateMode(
|
||||
index: number,
|
||||
rateField: "costRateMode" | "billRateMode",
|
||||
nextMode: EstimateDemandLineRateMode,
|
||||
) {
|
||||
updateDemandLine(index, (line) => {
|
||||
const resourceSnapshot = getLineResourceSnapshot(line);
|
||||
|
||||
if (!resourceSnapshot || nextMode === "manual") {
|
||||
return {
|
||||
...line,
|
||||
[rateField]: "manual",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...line,
|
||||
[rateField]: "resource",
|
||||
...(rateField === "costRateMode"
|
||||
? { costRate: (resourceSnapshot.lcrCents / 100).toFixed(2) }
|
||||
: { billRate: (resourceSnapshot.ucrCents / 100).toFixed(2) }),
|
||||
...(resourceSnapshot.currency ? { currency: resourceSnapshot.currency } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function syncAllLiveLinkedLines() {
|
||||
setDemandLines((current) =>
|
||||
current.map((line) => {
|
||||
const resourceSnapshot = getLineResourceSnapshot(line);
|
||||
|
||||
if (!resourceSnapshot) {
|
||||
return line;
|
||||
}
|
||||
|
||||
return {
|
||||
...line,
|
||||
currency: resourceSnapshot.currency,
|
||||
costRate:
|
||||
line.costRateMode === "resource"
|
||||
? (resourceSnapshot.lcrCents / 100).toFixed(2)
|
||||
: line.costRate,
|
||||
billRate:
|
||||
line.billRateMode === "resource"
|
||||
? (resourceSnapshot.ucrCents / 100).toFixed(2)
|
||||
: line.billRate,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const projectStartDate = estimate.project?.startDate
|
||||
? new Date(estimate.project.startDate)
|
||||
: null;
|
||||
@@ -520,41 +321,14 @@ export function EstimateWorkspaceDraftEditor({
|
||||
}).spread;
|
||||
}
|
||||
|
||||
const spreadMonths = useMemo(
|
||||
() =>
|
||||
hasProjectDates
|
||||
? getEstimateMonthRange(projectStartDate, projectEndDate)
|
||||
: [],
|
||||
[hasProjectDates, projectStartDate, projectEndDate],
|
||||
);
|
||||
const spreadMonths =
|
||||
hasProjectDates
|
||||
? getEstimateMonthRange(projectStartDate, projectEndDate)
|
||||
: [];
|
||||
|
||||
const aggregatedSpread = useMemo(() => {
|
||||
if (!hasProjectDates) return {};
|
||||
return summarizeMonthlySpread(demandLines.map(computeLineSpread));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasProjectDates, demandLines, projectStartDate, projectEndDate]);
|
||||
|
||||
function toggleMonthLock(lineIndex: number, monthKey: string, currentValue: number) {
|
||||
updateDemandLine(lineIndex, (line) => {
|
||||
const isLocked = monthKey in line.lockedMonths;
|
||||
if (isLocked) {
|
||||
const { [monthKey]: _removed, ...rest } = line.lockedMonths;
|
||||
return { ...line, lockedMonths: rest };
|
||||
}
|
||||
return {
|
||||
...line,
|
||||
lockedMonths: { ...line.lockedMonths, [monthKey]: currentValue },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function setLockedMonthValue(lineIndex: number, monthKey: string, value: string) {
|
||||
const numValue = Math.max(0, toNumber(value));
|
||||
updateDemandLine(lineIndex, (line) => ({
|
||||
...line,
|
||||
lockedMonths: { ...line.lockedMonths, [monthKey]: Math.round(numValue * 10) / 10 },
|
||||
}));
|
||||
}
|
||||
const aggregatedSpread = hasProjectDates
|
||||
? summarizeMonthlySpread(demandLines.map(computeLineSpread))
|
||||
: {};
|
||||
|
||||
async function handleSave() {
|
||||
setError(null);
|
||||
@@ -749,429 +523,6 @@ export function EstimateWorkspaceDraftEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function renderAssumptionsEditor() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{assumptions.map((assumption, index) => (
|
||||
<div key={assumption.id ?? `new-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Category</span>
|
||||
<input className={INPUT_CLS} value={assumption.category} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, category: event.target.value } : item))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Key</span>
|
||||
<input className={INPUT_CLS} value={assumption.key} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, key: event.target.value } : item))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Label</span>
|
||||
<input className={INPUT_CLS} value={assumption.label} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value } : item))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Type</span>
|
||||
<input className={INPUT_CLS} value={assumption.valueType} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, valueType: event.target.value } : item))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr),220px]">
|
||||
<label className="block">
|
||||
<span className={LABEL_CLS}>Value</span>
|
||||
<textarea className={`${INPUT_CLS} min-h-24`} value={assumption.value} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className={LABEL_CLS}>Notes</span>
|
||||
<textarea className={`${INPUT_CLS} min-h-24`} value={assumption.notes} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, notes: event.target.value } : item))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => setAssumptions((current) => current.filter((_, itemIndex) => itemIndex !== index))}>
|
||||
Remove assumption
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => setAssumptions((current) => [...current, makeAssumption()])}>
|
||||
Add assumption
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleScopeImport(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file || !isSpreadsheetFile(file)) return;
|
||||
event.target.value = "";
|
||||
|
||||
try {
|
||||
const result = await parseScopeImport(file);
|
||||
setScopeImportWarnings(result.warnings);
|
||||
if (result.rows.length > 0) {
|
||||
const imported: EditableScopeItem[] = result.rows.map((row) => ({
|
||||
sequenceNo: String(row.sequenceNo),
|
||||
scopeType: row.scopeType,
|
||||
packageCode: row.packageCode,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
}));
|
||||
setScopeItems((current) => [...current, ...imported]);
|
||||
}
|
||||
} catch {
|
||||
setScopeImportWarnings(["Failed to parse the uploaded file."]);
|
||||
}
|
||||
}
|
||||
|
||||
function renderScopeEditor() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="cursor-pointer rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50">
|
||||
Import scope from XLSX
|
||||
<input type="file" accept=".xlsx,.xls,.csv" className="hidden" onChange={(event) => void handleScopeImport(event)} />
|
||||
</label>
|
||||
{scopeImportWarnings.length > 0 && (
|
||||
<div className="text-xs text-amber-700">
|
||||
{scopeImportWarnings.map((warning, index) => (
|
||||
<p key={index}>{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{scopeItems.map((item, index) => (
|
||||
<div key={item.id ?? `scope-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Sequence</span>
|
||||
<input className={INPUT_CLS} value={item.sequenceNo} onChange={(event) => setScopeItems((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, sequenceNo: event.target.value } : entry))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Scope type</span>
|
||||
<input className={INPUT_CLS} value={item.scopeType} onChange={(event) => setScopeItems((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, scopeType: event.target.value } : entry))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Package code</span>
|
||||
<input className={INPUT_CLS} value={item.packageCode} onChange={(event) => setScopeItems((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, packageCode: event.target.value } : entry))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,0.9fr),minmax(0,1.1fr)]">
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Name</span>
|
||||
<input className={INPUT_CLS} value={item.name} onChange={(event) => setScopeItems((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: event.target.value } : entry))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Description</span>
|
||||
<textarea className={`${INPUT_CLS} min-h-24`} value={item.description} onChange={(event) => setScopeItems((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, description: event.target.value } : entry))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => setScopeItems((current) => current.filter((_, entryIndex) => entryIndex !== index))}>
|
||||
Remove scope item
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => setScopeItems((current) => [...current, makeScopeItem(current.length + 1)])}>
|
||||
Add scope item
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderStaffingEditor() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700"
|
||||
onClick={syncAllLiveLinkedLines}
|
||||
>
|
||||
Recalculate live-linked lines
|
||||
</button>
|
||||
</div>
|
||||
{demandLines.map((line, index) => {
|
||||
const linkedResource = line.resourceId ? getLineResourceSnapshot(line) : null;
|
||||
const effectiveValues = getLineEffectiveValues(line);
|
||||
const costDeltaCents =
|
||||
linkedResource != null ? toCents(line.costRate) - linkedResource.lcrCents : 0;
|
||||
const billDeltaCents =
|
||||
linkedResource != null ? toCents(line.billRate) - linkedResource.ucrCents : 0;
|
||||
|
||||
return (
|
||||
<div key={line.id ?? `line-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">Resource link</p>
|
||||
<p className="mt-1 text-sm text-gray-700">
|
||||
{linkedResource
|
||||
? `${linkedResource.displayName} (${("eid" in linkedResource ? linkedResource.eid : linkedResource.sourceEid) ?? "snapshot"})`
|
||||
: "This demand line is currently unlinked."}
|
||||
</p>
|
||||
</div>
|
||||
{linkedResource && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={
|
||||
line.costRateMode === "resource" && line.billRateMode === "resource"
|
||||
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700"
|
||||
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||
}
|
||||
>
|
||||
{line.costRateMode === "resource" && line.billRateMode === "resource"
|
||||
? "Live rates synced"
|
||||
: "Manual override active"}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700"
|
||||
onClick={() => syncDemandLineRates(index)}
|
||||
>
|
||||
Apply current resource rates
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-4 grid gap-4 md:grid-cols-2">
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Linked resource</span>
|
||||
<select
|
||||
className={INPUT_CLS}
|
||||
value={line.resourceId ?? ""}
|
||||
onChange={(event) => applyResourceSelection(index, event.target.value)}
|
||||
>
|
||||
<option value="">Unlinked</option>
|
||||
{resourceOptions.map((resource) => (
|
||||
<option key={resource.id} value={resource.id}>
|
||||
{resource.displayName} ({resource.eid})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="rounded-2xl bg-gray-50 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Snapshot behavior</p>
|
||||
<p className="mt-1 text-sm text-gray-700">
|
||||
Linked resources refresh from live Planarchy rates when a rate is set to live mode. Manual overrides are persisted on the demand line.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Name</span>
|
||||
<input className={INPUT_CLS} value={line.name} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, name: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Line type</span>
|
||||
<input className={INPUT_CLS} value={line.lineType} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, lineType: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Chapter</span>
|
||||
<input className={INPUT_CLS} value={line.chapter} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, chapter: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Hours</span>
|
||||
<input className={INPUT_CLS} value={line.hours} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, hours: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Currency</span>
|
||||
<input className={INPUT_CLS} maxLength={3} value={line.currency} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, currency: event.target.value.toUpperCase() }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Cost rate</span>
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className={INPUT_CLS}
|
||||
value={line.costRateMode}
|
||||
onChange={(event) =>
|
||||
setDemandLineRateMode(index, "costRateMode", event.target.value as EstimateDemandLineRateMode)
|
||||
}
|
||||
>
|
||||
{getLineResourceSnapshot(line) && <option value="resource">Use live resource rate</option>}
|
||||
<option value="manual">Manual override</option>
|
||||
</select>
|
||||
<input
|
||||
className={INPUT_CLS}
|
||||
value={line.costRate}
|
||||
onChange={(event) =>
|
||||
updateDemandLine(index, (entry) => ({
|
||||
...entry,
|
||||
costRateMode: "manual",
|
||||
costRate: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{linkedResource && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Live snapshot {formatMoney(linkedResource.lcrCents, linkedResource.currency)}
|
||||
{line.costRateMode === "manual" && costDeltaCents !== 0
|
||||
? ` (${costDeltaCents > 0 ? "+" : ""}${formatMoney(costDeltaCents, linkedResource.currency)} delta)`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span className={LABEL_CLS}>Bill rate</span>
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
className={INPUT_CLS}
|
||||
value={line.billRateMode}
|
||||
onChange={(event) =>
|
||||
setDemandLineRateMode(index, "billRateMode", event.target.value as EstimateDemandLineRateMode)
|
||||
}
|
||||
>
|
||||
{getLineResourceSnapshot(line) && <option value="resource">Use live resource rate</option>}
|
||||
<option value="manual">Manual override</option>
|
||||
</select>
|
||||
<input
|
||||
className={INPUT_CLS}
|
||||
value={line.billRate}
|
||||
onChange={(event) =>
|
||||
updateDemandLine(index, (entry) => ({
|
||||
...entry,
|
||||
billRateMode: "manual",
|
||||
billRate: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{linkedResource && (
|
||||
<p className="text-xs text-gray-500">
|
||||
Live snapshot {formatMoney(linkedResource.ucrCents, linkedResource.currency)}
|
||||
{line.billRateMode === "manual" && billDeltaCents !== 0
|
||||
? ` (${billDeltaCents > 0 ? "+" : ""}${formatMoney(billDeltaCents, linkedResource.currency)} delta)`
|
||||
: ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
<div className="rounded-2xl bg-gray-50 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total</p>
|
||||
<p className="mt-1 text-sm font-semibold text-gray-900">
|
||||
{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}
|
||||
</p>
|
||||
<p className="mt-3 text-xs uppercase tracking-wide text-gray-400">Price total</p>
|
||||
<p className="mt-1 text-sm font-semibold text-gray-900">
|
||||
{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{hasProjectDates && spreadMonths.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-gray-600"
|
||||
onClick={() => updateDemandLine(index, (entry) => ({ ...entry, spreadExpanded: !entry.spreadExpanded }))}
|
||||
>
|
||||
<span className={`inline-block transition-transform ${line.spreadExpanded ? "rotate-90" : ""}`}>▶</span>
|
||||
Monthly phasing ({spreadMonths.length} months)
|
||||
</button>
|
||||
{line.spreadExpanded && (() => {
|
||||
const lineSpread = computeLineSpread(line);
|
||||
return (
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">Month</th>
|
||||
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">Hours</th>
|
||||
<th className="px-2 py-1.5 text-center text-xs font-semibold uppercase tracking-wide text-gray-400">Lock</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{spreadMonths.map((monthKey) => {
|
||||
const isLocked = monthKey in line.lockedMonths;
|
||||
const value = lineSpread[monthKey] ?? 0;
|
||||
return (
|
||||
<tr key={monthKey} className="border-b border-gray-100">
|
||||
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
|
||||
<td className="px-2 py-1.5 text-right">
|
||||
{isLocked ? (
|
||||
<input
|
||||
className="w-20 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-right text-sm text-gray-900"
|
||||
value={line.lockedMonths[monthKey]}
|
||||
onChange={(event) => setLockedMonthValue(index, monthKey, event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-700">{value.toFixed(1)}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<button
|
||||
type="button"
|
||||
className={`rounded px-2 py-0.5 text-xs font-medium ${isLocked ? "bg-amber-100 text-amber-700" : "bg-gray-100 text-gray-500"}`}
|
||||
onClick={() => toggleMonthLock(index, monthKey, value)}
|
||||
>
|
||||
{isLocked ? "Locked" : "Auto"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-gray-300">
|
||||
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">Total</td>
|
||||
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
|
||||
{Object.values(lineSpread).reduce((a, b) => a + b, 0).toFixed(1)}
|
||||
</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => setDemandLines((current) => current.filter((_, entryIndex) => entryIndex !== index))}>
|
||||
Remove demand line
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => setDemandLines((current) => [...current, makeDemandLine()])}>
|
||||
Add demand line
|
||||
</button>
|
||||
|
||||
{hasProjectDates && spreadMonths.length > 0 && demandLines.length > 0 && (
|
||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||
<p className="mb-3 text-sm font-semibold text-gray-900">Aggregated monthly phasing</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">Month</th>
|
||||
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">Total hours</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{spreadMonths.map((monthKey) => (
|
||||
<tr key={monthKey} className="border-b border-gray-100">
|
||||
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
|
||||
<td className="px-2 py-1.5 text-right text-gray-900">{(aggregatedSpread[monthKey] ?? 0).toFixed(1)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-gray-300">
|
||||
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">Grand total</td>
|
||||
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
|
||||
{Object.values(aggregatedSpread).reduce((a, b) => a + b, 0).toFixed(1)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
@@ -1196,9 +547,34 @@ export function EstimateWorkspaceDraftEditor({
|
||||
)}
|
||||
|
||||
{tab === "overview" && renderOverviewEditor()}
|
||||
{tab === "assumptions" && renderAssumptionsEditor()}
|
||||
{tab === "scope" && renderScopeEditor()}
|
||||
{tab === "staffing" && renderStaffingEditor()}
|
||||
{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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user