"use client"; import { useMemo } from "react"; import type { EstimateDemandLineRateMode } from "@planarchy/shared"; import { computeEvenSpread, rebalanceSpread, } from "@planarchy/engine"; import { getEffectiveDemandLineValues, } from "~/components/estimates/EstimateWorkspace.calculations.js"; import type { EstimateResourceSnapshotView } from "~/components/estimates/EstimateWorkspace.types.js"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { formatMoney } from "~/lib/format.js"; 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 toNumber(value: string) { const parsed = Number.parseFloat(value); return Number.isFinite(parsed) ? parsed : 0; } function toCents(value: string) { return Math.round(toNumber(value) * 100); } export 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; /** Locked monthly hours overrides, keyed by "YYYY-MM" */ lockedMonths: Record; /** Whether the monthly spread section is expanded */ spreadExpanded: boolean; } export function makeDemandLine(): EditableDemandLine { return { lineType: "LABOR", name: "", chapter: "", hours: "8", currency: "EUR", costRate: "0", billRate: "0", costRateMode: "manual", billRateMode: "manual", metadata: {}, lockedMonths: {}, spreadExpanded: false, }; } export interface ResourceOption { id: string; eid: string; displayName: string; chapter?: string | null; roleId?: string | null; lcrCents: number; ucrCents: number; currency: string; dynamicFields?: Record; } export interface DemandLineEditorProps { demandLines: EditableDemandLine[]; onChange: (updater: (current: EditableDemandLine[]) => EditableDemandLine[]) => void; resourceOptions: ResourceOption[]; resourceMap: Map; snapshotByResourceId: Map; baseCurrency: string; projectStartDate: Date | null; projectEndDate: Date | null; spreadMonths: string[]; aggregatedSpread: Record; } type ResourceSnapshotLike = ResourceOption | EstimateResourceSnapshotView; export function DemandLineEditor({ demandLines, onChange, resourceOptions, resourceMap, snapshotByResourceId, baseCurrency, projectStartDate, projectEndDate, spreadMonths, aggregatedSpread, }: DemandLineEditorProps) { const hasProjectDates = projectStartDate !== null && projectEndDate !== null; function getLineResourceSnapshot(line: EditableDemandLine): ResourceSnapshotLike | null { 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, }); } function updateDemandLine(index: number, updater: (line: EditableDemandLine) => EditableDemandLine) { onChange((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() { onChange((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, }; }), ); } function computeLineSpread(line: EditableDemandLine): Record { if (!hasProjectDates) return {}; const hours = toNumber(line.hours); if (hours <= 0) return {}; const lockedKeys = Object.keys(line.lockedMonths); if (lockedKeys.length > 0) { return rebalanceSpread({ totalHours: hours, startDate: projectStartDate, endDate: projectEndDate, lockedMonths: line.lockedMonths, }).spread; } return computeEvenSpread({ totalHours: hours, startDate: projectStartDate, endDate: projectEndDate, }).spread; } 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 }, })); } return (
{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 (

Resource link

{linkedResource ? `${linkedResource.displayName} (${("eid" in linkedResource ? linkedResource.eid : (linkedResource as EstimateResourceSnapshotView).sourceEid) ?? "snapshot"})` : "This demand line is currently unlinked."}

{linkedResource && (
{line.costRateMode === "resource" && line.billRateMode === "resource" ? "Live rates synced" : "Manual override active"}
)}

Snapshot behavior

Linked resources refresh from live plANARCHY rates when a rate is set to live mode. Manual overrides are persisted on the demand line.

Cost total

{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}

Price total

{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}

{hasProjectDates && spreadMonths.length > 0 && (
{line.spreadExpanded && (() => { const lineSpread = computeLineSpread(line); return (
{spreadMonths.map((monthKey) => { const isLocked = monthKey in line.lockedMonths; const value = lineSpread[monthKey] ?? 0; return ( ); })}
Month Hours Lock
{monthKey} {isLocked ? ( setLockedMonthValue(index, monthKey, event.target.value)} /> ) : ( {value.toFixed(1)} )}
Total {Object.values(lineSpread).reduce((a, b) => a + b, 0).toFixed(1)}
); })()}
)}
); })} {hasProjectDates && spreadMonths.length > 0 && demandLines.length > 0 && (

Aggregated monthly phasing

{spreadMonths.map((monthKey) => ( ))}
Month Total hours
{monthKey} {(aggregatedSpread[monthKey] ?? 0).toFixed(1)}
Grand total {Object.values(aggregatedSpread).reduce((a, b) => a + b, 0).toFixed(1)}
)}
); }