// Pure, immutable editing rules for one "Grobe Leistungsbilanz" document. // The editor keeps the complete document as local draft and saves it as a // whole (PUT), so every edit is a function draft → new draft. import type { EstimateBalanceSnapshot, EstimateGroupSnapshot, EstimateRowSnapshot, } from "../../shared/estimate-balance/estimate-balance"; import { createClientId } from "../utils/client-id"; export type MoveDirection = -1 | 1; /** Maximum text lengths accepted by the server. */ export const estimateTextLimits = { balanceName: 200, workStage: 50, costGroup: 20, groupTitle: 200, groupEditor: 100, supply: 50, rowName: 200, remark: 500, } as const; export type EstimateRowPatch = Partial>; export type EstimateGroupPatch = Partial>; export function createEstimateRow( sortOrder: number, createId: () => string = createClientId ): EstimateRowSnapshot { return { id: createId(), sortOrder, supply: null, name: "", quantity: 1, unit: "piece", connection: null, powerPerUnitW: 0, simultaneityFactor: 1, cosPhi: 1, safetySupply: false, remark: null, }; } export function createEstimateGroup( input: { costGroup: string; title: string; editor?: string | null }, sortOrder: number, createId: () => string = createClientId ): EstimateGroupSnapshot { return { id: createId(), costGroup: input.costGroup, title: input.title, editor: input.editor ?? null, sortOrder, rows: [], }; } function bySortOrder(left: T, right: T) { return left.sortOrder - right.sortOrder; } /** Orders groups and rows by sortOrder and renumbers both from 0. */ export function normalizeEstimateSortOrder( balance: EstimateBalanceSnapshot ): EstimateBalanceSnapshot { return { ...balance, groups: [...balance.groups].sort(bySortOrder).map((group, groupIndex) => ({ ...group, sortOrder: groupIndex, rows: [...group.rows].sort(bySortOrder).map((row, rowIndex) => ({ ...row, sortOrder: rowIndex, })), })), }; } function renumber(items: T[]): T[] { return items.map((item, index) => item.sortOrder === index ? item : { ...item, sortOrder: index } ); } function mapGroup( balance: EstimateBalanceSnapshot, groupId: string, update: (group: EstimateGroupSnapshot) => EstimateGroupSnapshot ): EstimateBalanceSnapshot { let changed = false; const groups = balance.groups.map((group) => { if (group.id !== groupId) { return group; } const next = update(group); changed = next !== group; return next; }); // An unknown group or a no-op keeps the identical document. return changed ? { ...balance, groups } : balance; } function moveItem(items: T[], index: number, direction: MoveDirection): T[] | null { const target = index + direction; if (index < 0 || target < 0 || target >= items.length) { return null; } const next = [...items]; [next[index], next[target]] = [next[target]!, next[index]!]; return next; } /** Appends a row (or inserts it after `afterRowId`) and returns the new row id. */ export function addEstimateRow( balance: EstimateBalanceSnapshot, groupId: string, options: { afterRowId?: string; createId?: () => string } = {} ): { balance: EstimateBalanceSnapshot; rowId: string | null } { let rowId: string | null = null; const next = mapGroup(balance, groupId, (group) => { const row = createEstimateRow(0, options.createId); rowId = row.id; const afterIndex = options.afterRowId ? group.rows.findIndex((candidate) => candidate.id === options.afterRowId) : -1; const rows = [...group.rows]; rows.splice(afterIndex >= 0 ? afterIndex + 1 : rows.length, 0, row); return { ...group, rows: renumber(rows) }; }); return { balance: next, rowId }; } export function updateEstimateRow( balance: EstimateBalanceSnapshot, groupId: string, rowId: string, patch: EstimateRowPatch ): EstimateBalanceSnapshot { return mapGroup(balance, groupId, (group) => ({ ...group, rows: group.rows.map((row) => (row.id === rowId ? { ...row, ...patch } : row)), })); } export function removeEstimateRow( balance: EstimateBalanceSnapshot, groupId: string, rowId: string ): EstimateBalanceSnapshot { return mapGroup(balance, groupId, (group) => ({ ...group, rows: renumber(group.rows.filter((row) => row.id !== rowId)), })); } export function moveEstimateRow( balance: EstimateBalanceSnapshot, groupId: string, rowId: string, direction: MoveDirection ): EstimateBalanceSnapshot { return mapGroup(balance, groupId, (group) => { const rows = moveItem( group.rows, group.rows.findIndex((row) => row.id === rowId), direction ); return rows ? { ...group, rows: renumber(rows) } : group; }); } /** Appends a group and returns its id. */ export function addEstimateGroup( balance: EstimateBalanceSnapshot, input: { costGroup: string; title: string; editor?: string | null }, createId: () => string = createClientId ): { balance: EstimateBalanceSnapshot; groupId: string } { const group = createEstimateGroup(input, balance.groups.length, createId); return { balance: { ...balance, groups: renumber([...balance.groups, group]) }, groupId: group.id, }; } export function updateEstimateGroup( balance: EstimateBalanceSnapshot, groupId: string, patch: EstimateGroupPatch ): EstimateBalanceSnapshot { return mapGroup(balance, groupId, (group) => ({ ...group, ...patch })); } export function removeEstimateGroup( balance: EstimateBalanceSnapshot, groupId: string ): EstimateBalanceSnapshot { const groups = balance.groups.filter((group) => group.id !== groupId); return groups.length === balance.groups.length ? balance : { ...balance, groups: renumber(groups) }; } export function moveEstimateGroup( balance: EstimateBalanceSnapshot, groupId: string, direction: MoveDirection ): EstimateBalanceSnapshot { const groups = moveItem( balance.groups, balance.groups.findIndex((group) => group.id === groupId), direction ); return groups ? { ...balance, groups: renumber(groups) } : balance; } function requiredText(value: string) { return value.trim(); } function optionalText(value: string | null) { const trimmed = value?.trim() ?? ""; return trimmed ? trimmed : null; } /** * The exact document the server accepts: strings trimmed, empty optional * texts as null, sort orders continuous. Only fields of the shared snapshot * type are copied, so no UI state can leak into the request. */ export function toSaveableEstimateBalance( balance: EstimateBalanceSnapshot ): EstimateBalanceSnapshot { const normalized = normalizeEstimateSortOrder(balance); return { id: normalized.id, projectId: normalized.projectId, name: requiredText(normalized.name), workStage: optionalText(normalized.workStage), dateIso: normalized.dateIso || null, reserveAvPercent: normalized.reserveAvPercent, reserveSvPercent: normalized.reserveSvPercent, sortOrder: normalized.sortOrder, groups: normalized.groups.map((group) => ({ id: group.id, costGroup: requiredText(group.costGroup), title: requiredText(group.title), editor: optionalText(group.editor), sortOrder: group.sortOrder, rows: group.rows.map((row) => ({ id: row.id, sortOrder: row.sortOrder, supply: optionalText(row.supply), name: requiredText(row.name), quantity: row.quantity, unit: row.unit, connection: row.connection, powerPerUnitW: row.powerPerUnitW, simultaneityFactor: row.simultaneityFactor, cosPhi: row.cosPhi, safetySupply: row.safetySupply, remark: optionalText(row.remark), })), })), }; } /** Stable comparison key of the saveable document (dirty detection). */ export function estimateBalanceFingerprint(balance: EstimateBalanceSnapshot): string { return JSON.stringify(toSaveableEstimateBalance(balance)); } /** * German problems that would make the server reject the document. The * editor never saves while this list is not empty. */ export function validateEstimateBalance(balance: EstimateBalanceSnapshot): string[] { const problems: string[] = []; const saveable = toSaveableEstimateBalance(balance); if (!saveable.name) { problems.push("Die Bezeichnung des Stands darf nicht leer sein."); } for (const group of saveable.groups) { if (!group.title) { problems.push(`Kostengruppe ${group.costGroup || "ohne Nummer"}: Titel fehlt.`); } } return problems; } export function createCopyName(name: string) { const copyName = `${name.trim()} (Kopie)`; return copyName.slice(0, estimateTextLimits.balanceName); }