forked from jappel/leistungsbilanz-ts
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>
298 lines
8.8 KiB
TypeScript
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);
|
|
}
|