leistungsbilanz-ts/src/frontend/estimate-balance/estimate-balance-document.ts
Grovy311 72189d23a5 Offer supply suggestions, drop the circuit column, center cells
The Versorgung field suggests ELT and MSR (plus values already used in
the balance) through a datalist and still accepts free text. The circuit
number column is not needed in early design phases; it is removed from
the data model, the regenerated migration 0010, the editor and the
print. All cells are centered except "Verbraucher / Flächen", group
titles and total labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:56:53 +02:00

298 lines
8.8 KiB
TypeScript

// 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<Omit<EstimateRowSnapshot, "id" | "sortOrder">>;
export type EstimateGroupPatch = Partial<Pick<EstimateGroupSnapshot, "costGroup" | "title" | "editor">>;
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<T extends { sortOrder: number }>(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<T extends { sortOrder: number }>(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<T>(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);
}