Add the rough power balance module for early design phases

In LPH 2/3 there are no circuits yet, but the office needs a power
balance by DIN 276 cost group, as in the Excel template "Leistungsbilanz -
KG400": each trade enters consumers by piece or by area (W/m²) with GLF
and cos phi; the balance sums AV and SV per cost group, adds a reserve and
derives the maximum current.

The module is separate from the circuit structure: own tables
(migration 0010), own folder src/estimate-balance and an import-free
shared calculation used by API and UI. Writes go through project commands
(estimate-balance.insert/.update/.delete with complete documents), so
revision checks and Undo/Redo apply; it is not part of project snapshots
or transfer yet. A comparison endpoint sums the device rows of all circuit
lists by cost group (longest prefix) against the estimate.

The UI adds a sidebar entry, a list of Stände (template KG 400, empty or
copy) and a spreadsheet-like editor with autosave, AMEV picker for the
GLF, totals, reserve, maximum current, comparison table and print layout.
The tests reproduce the template example exactly (143.24 kW, 148.48 kVA,
157.56 kW / 163.33 kVA with 10 % reserve, 235.75 A). See
docs/estimate-balance.md and issue #7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-26 14:50:48 +02:00
parent 80dbc73230
commit 37a70b6b36
37 changed files with 8537 additions and 4 deletions

View file

@ -0,0 +1,652 @@
"use client";
import type { ReactNode } from "react";
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
import {
calculateEstimateBalance,
calculateEstimateRow,
estimateRowConnectionLabels,
estimateRowUnitLabels,
type EstimateBalanceSnapshot,
type EstimateGroupSnapshot,
type EstimatePower,
type EstimateRowConnection,
type EstimateRowSnapshot,
type EstimateRowUnit,
} from "../../shared/estimate-balance/estimate-balance";
import {
addEstimateRow,
estimateTextLimits,
moveEstimateGroup,
removeEstimateGroup,
removeEstimateRow,
updateEstimateGroup,
updateEstimateRow,
type EstimateRowPatch,
} from "./estimate-balance-document";
import {
estimateNumberRanges,
formatAmpere,
formatDecimalInput,
formatKw,
parseGermanDecimal,
type DecimalRange,
} from "./estimate-balance-format";
import { DraftInput } from "./estimate-draft-input";
export type EstimateBalanceUpdate = (
update: (balance: EstimateBalanceSnapshot) => EstimateBalanceSnapshot
) => void;
interface EstimateBalanceSheetProps {
balance: EstimateBalanceSnapshot;
disabled: boolean;
onAddGroup: () => void;
onChange: EstimateBalanceUpdate;
onEditGroup: (group: EstimateGroupSnapshot) => void;
onOpenGzfPicker: (groupId: string, rowId: string) => void;
onValidityChange: (fieldKey: string, isValid: boolean) => void;
threePhaseVoltageV: number;
}
// Leading columns up to cos φ; the power columns follow.
const LEADING_COLUMN_COUNT = 10;
const COLUMN_COUNT = 17;
function NumberInput({
ariaLabel,
disabled,
fieldKey,
onCommit,
onValidityChange,
range,
value,
}: {
ariaLabel: string;
disabled: boolean;
fieldKey: string;
onCommit: (value: number) => void;
onValidityChange: (fieldKey: string, isValid: boolean) => void;
range: DecimalRange;
value: number;
}) {
return (
<DraftInput
ariaLabel={ariaLabel}
className="estimate-cell-input is-numeric"
disabled={disabled}
format={(current: number) => formatDecimalInput(current)}
inputMode="decimal"
onCommit={onCommit}
onValidityChange={(isValid) => onValidityChange(fieldKey, isValid)}
parse={(text) => parseGermanDecimal(text, range)}
value={value}
/>
);
}
function TextInput({
ariaLabel,
disabled,
maxLength,
onChange,
placeholder,
value,
}: {
ariaLabel: string;
disabled: boolean;
maxLength: number;
onChange: (value: string) => void;
placeholder?: string;
value: string | null;
}) {
return (
<>
<input
aria-label={ariaLabel}
className="estimate-cell-input"
disabled={disabled}
maxLength={maxLength}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
type="text"
value={value ?? ""}
/>
<span className="estimate-print-value">{value ?? ""}</span>
</>
);
}
function IconButton({
children,
disabled,
label,
onClick,
variant = "secondary",
}: {
children: ReactNode;
disabled: boolean;
label: string;
onClick: () => void;
variant?: "secondary" | "danger" | "primary";
}) {
return (
<button
aria-label={label}
className={`btn btn-sm btn-outline-${variant} estimate-icon-button`}
disabled={disabled}
onClick={onClick}
title={label}
type="button"
>
{children}
</button>
);
}
function PowerCells({ power, className = "" }: { power: EstimatePower; className?: string }) {
return (
<>
<td className={`estimate-number ${className}`}>{formatKw(power.kw)}</td>
<td className={`estimate-number ${className}`}>{formatKw(power.kva)}</td>
</>
);
}
function groupLabel(group: EstimateGroupSnapshot) {
return [group.costGroup, group.title].filter(Boolean).join(" ");
}
function EstimateRowLine({
disabled,
group,
onChange,
onOpenGzfPicker,
onValidityChange,
row,
rowIndex,
}: {
disabled: boolean;
group: EstimateGroupSnapshot;
onChange: EstimateBalanceUpdate;
onOpenGzfPicker: (groupId: string, rowId: string) => void;
onValidityChange: (fieldKey: string, isValid: boolean) => void;
row: EstimateRowSnapshot;
rowIndex: number;
}) {
const result = calculateEstimateRow(row);
const rowLabel = `${groupLabel(group)}, Zeile ${rowIndex + 1}`;
const fieldKey = (field: string) => `${row.id}:${field}`;
function patch(rowPatch: EstimateRowPatch) {
onChange((balance) => updateEstimateRow(balance, group.id, row.id, rowPatch));
}
return (
<tr className="estimate-row">
<td className="estimate-cost-group-cell" />
<td>
<TextInput
ariaLabel={`Versorgung (${rowLabel})`}
disabled={disabled}
maxLength={estimateTextLimits.supply}
onChange={(supply) => patch({ supply })}
placeholder="ELT"
value={row.supply}
/>
</td>
<td>
<TextInput
ariaLabel={`Stromkreis-Nr. (${rowLabel})`}
disabled={disabled}
maxLength={estimateTextLimits.circuitRef}
onChange={(circuitRef) => patch({ circuitRef })}
value={row.circuitRef}
/>
</td>
<td>
<TextInput
ariaLabel={`Verbraucher / Fläche (${rowLabel})`}
disabled={disabled}
maxLength={estimateTextLimits.rowName}
onChange={(name) => patch({ name })}
placeholder="Verbraucher oder Fläche"
value={row.name}
/>
</td>
<td>
<NumberInput
ariaLabel={`Anzahl / Fläche (${rowLabel})`}
disabled={disabled}
fieldKey={fieldKey("quantity")}
onCommit={(quantity) => patch({ quantity })}
onValidityChange={onValidityChange}
range={estimateNumberRanges.quantity}
value={row.quantity}
/>
</td>
<td>
<select
aria-label={`Einheit (${rowLabel})`}
className="estimate-cell-select"
disabled={disabled}
onChange={(event) => patch({ unit: event.target.value as EstimateRowUnit })}
value={row.unit}
>
{(Object.keys(estimateRowUnitLabels) as EstimateRowUnit[]).map((unit) => (
<option key={unit} value={unit}>
{estimateRowUnitLabels[unit]}
</option>
))}
</select>
<span className="estimate-print-value">{estimateRowUnitLabels[row.unit]}</span>
</td>
<td>
<select
aria-label={`Anschluss (${rowLabel})`}
className="estimate-cell-select"
disabled={disabled}
onChange={(event) =>
patch({
connection: (event.target.value || null) as EstimateRowConnection | null,
})
}
value={row.connection ?? ""}
>
<option value="">–</option>
{(Object.keys(estimateRowConnectionLabels) as EstimateRowConnection[]).map(
(connection) => (
<option key={connection} value={connection}>
{estimateRowConnectionLabels[connection]}
</option>
)
)}
</select>
<span className="estimate-print-value">
{row.connection ? estimateRowConnectionLabels[row.connection] : ""}
</span>
</td>
<td>
<div className="estimate-unit-input">
<NumberInput
ariaLabel={`Einzelleistung in ${row.unit === "area" ? "W/m²" : "W"} (${rowLabel})`}
disabled={disabled}
fieldKey={fieldKey("powerPerUnitW")}
onCommit={(powerPerUnitW) => patch({ powerPerUnitW })}
onValidityChange={onValidityChange}
range={estimateNumberRanges.powerPerUnitW}
value={row.powerPerUnitW}
/>
<span className="estimate-unit-label">{row.unit === "area" ? "W/m²" : "W"}</span>
</div>
</td>
<td>
<div className="estimate-gzf-input">
<NumberInput
ariaLabel={`Gleichzeitigkeitsfaktor (${rowLabel})`}
disabled={disabled}
fieldKey={fieldKey("simultaneityFactor")}
onCommit={(simultaneityFactor) => patch({ simultaneityFactor })}
onValidityChange={onValidityChange}
range={estimateNumberRanges.simultaneityFactor}
value={row.simultaneityFactor}
/>
<button
aria-label={`Gleichzeitigkeitsfaktor nach AMEV wählen (${rowLabel})`}
className="estimate-amev-button estimate-no-print"
disabled={disabled}
onClick={() => onOpenGzfPicker(group.id, row.id)}
title="Gleichzeitigkeitsfaktor nach AMEV wählen"
type="button"
>
AMEV
</button>
</div>
</td>
<td>
<NumberInput
ariaLabel={`cos φ (${rowLabel})`}
disabled={disabled}
fieldKey={fieldKey("cosPhi")}
onCommit={(cosPhi) => patch({ cosPhi })}
onValidityChange={onValidityChange}
range={estimateNumberRanges.cosPhi}
value={row.cosPhi}
/>
</td>
<PowerCells power={result.av} />
<td className="estimate-center">
<input
aria-label={`Sicherheitsversorgung (${rowLabel})`}
checked={row.safetySupply}
className="form-check-input estimate-no-print"
disabled={disabled}
onChange={(event) => patch({ safetySupply: event.target.checked })}
type="checkbox"
/>
<span className="estimate-print-value">{row.safetySupply ? "ja" : "nein"}</span>
</td>
<PowerCells
className={row.safetySupply ? "" : "is-muted"}
power={result.sv}
/>
<td>
<TextInput
ariaLabel={`Anmerkung (${rowLabel})`}
disabled={disabled}
maxLength={estimateTextLimits.remark}
onChange={(remark) => patch({ remark })}
value={row.remark}
/>
</td>
<td className="estimate-actions-cell estimate-no-print">
<IconButton
disabled={disabled}
label={`Zeile löschen (${rowLabel})`}
onClick={() => onChange((balance) => removeEstimateRow(balance, group.id, row.id))}
variant="danger"
>
<Trash2 aria-hidden="true" size={13} />
</IconButton>
</td>
</tr>
);
}
/**
* Spreadsheet-like editor in the layout of the office template
* "Leistungsbilanz – KG400": one section per cost group with title and
* subtotal row, totals, reserve and maximum current at the end.
*/
export function EstimateBalanceSheet({
balance,
disabled,
onAddGroup,
onChange,
onEditGroup,
onOpenGzfPicker,
onValidityChange,
threePhaseVoltageV,
}: EstimateBalanceSheetProps) {
const result = calculateEstimateBalance(balance, threePhaseVoltageV);
function confirmRemoveGroup(group: EstimateGroupSnapshot) {
const rowText =
group.rows.length === 1 ? "1 Zeile" : `${group.rows.length} Zeilen`;
if (
window.confirm(
`Kostengruppe „${groupLabel(group)}“ mit ${rowText} wirklich löschen?`
)
) {
onChange((current) => removeEstimateGroup(current, group.id));
}
}
return (
<div className="estimate-sheet-wrap">
<table className="estimate-sheet">
<colgroup>
<col className="estimate-col-kg" />
<col className="estimate-col-supply" />
<col className="estimate-col-circuit" />
<col className="estimate-col-name" />
<col className="estimate-col-quantity" />
<col className="estimate-col-unit" />
<col className="estimate-col-connection" />
<col className="estimate-col-power" />
<col className="estimate-col-gzf" />
<col className="estimate-col-cosphi" />
<col className="estimate-col-result" />
<col className="estimate-col-result" />
<col className="estimate-col-sv" />
<col className="estimate-col-result" />
<col className="estimate-col-result" />
<col className="estimate-col-remark" />
<col className="estimate-col-actions" />
</colgroup>
<thead>
<tr>
<th rowSpan={2}>KG</th>
<th rowSpan={2}>
Versorgung
<span className="estimate-th-hint">ELT/ISP …</span>
</th>
<th rowSpan={2}>Stromkreis-Nr.</th>
<th rowSpan={2}>Verbraucher / Flächen</th>
<th colSpan={2}>
Anzahl / Fläche
<span className="estimate-th-hint">St. / m²</span>
</th>
<th rowSpan={2}>
Anschl.
<span className="estimate-th-hint">Fest/Steckd.</span>
</th>
<th rowSpan={2}>
Einzelleist.
<span className="estimate-th-hint">W bzw. W/m²</span>
</th>
<th rowSpan={2}>GL-Faktor</th>
<th rowSpan={2}>cos φ</th>
<th colSpan={2}>Nötige AV-Leistung</th>
<th colSpan={3}>Sicherheitsversorgung (SV)</th>
<th rowSpan={2}>Anmerkungen</th>
<th aria-label="Aktionen" className="estimate-no-print" rowSpan={2} />
</tr>
<tr>
<th>Wert</th>
<th>Einh.</th>
<th>kW</th>
<th>kVA</th>
<th>ja/nein</th>
<th>kW</th>
<th>kVA</th>
</tr>
</thead>
{balance.groups.map((group, groupIndex) => {
const groupResult = result.groups[groupIndex]!;
const label = groupLabel(group) || "Kostengruppe";
return (
<tbody className="estimate-group" key={group.id}>
<tr className="estimate-group-title-row">
<td className="estimate-cost-group-cell">{group.costGroup}</td>
<td colSpan={COLUMN_COUNT - 2}>
<div className="estimate-group-title">
<span className="estimate-group-name">{group.title}</span>
<label className="estimate-group-editor">
<span>bearbeitet von</span>
<input
aria-label={`Bearbeitet von (${label})`}
className="estimate-cell-input"
disabled={disabled}
maxLength={estimateTextLimits.groupEditor}
onChange={(event) =>
onChange((current) =>
updateEstimateGroup(current, group.id, {
editor: event.target.value,
})
)
}
placeholder="Gewerk / Name"
type="text"
value={group.editor ?? ""}
/>
<span className="estimate-print-value">{group.editor ?? ""}</span>
</label>
</div>
</td>
<td className="estimate-actions-cell estimate-no-print">
<div className="estimate-group-actions">
<IconButton
disabled={disabled}
label={`Zeile hinzufügen (${label})`}
onClick={() =>
onChange((current) => addEstimateRow(current, group.id).balance)
}
variant="primary"
>
<Plus aria-hidden="true" size={13} />
</IconButton>
<IconButton
disabled={disabled}
label={`Gruppe bearbeiten (${label})`}
onClick={() => onEditGroup(group)}
>
<Pencil aria-hidden="true" size={13} />
</IconButton>
<IconButton
disabled={disabled || groupIndex === 0}
label={`Gruppe nach oben (${label})`}
onClick={() =>
onChange((current) => moveEstimateGroup(current, group.id, -1))
}
>
<ArrowUp aria-hidden="true" size={13} />
</IconButton>
<IconButton
disabled={disabled || groupIndex === balance.groups.length - 1}
label={`Gruppe nach unten (${label})`}
onClick={() =>
onChange((current) => moveEstimateGroup(current, group.id, 1))
}
>
<ArrowDown aria-hidden="true" size={13} />
</IconButton>
<IconButton
disabled={disabled}
label={`Gruppe löschen (${label})`}
onClick={() => confirmRemoveGroup(group)}
variant="danger"
>
<Trash2 aria-hidden="true" size={13} />
</IconButton>
</div>
</td>
</tr>
{group.rows.map((row, rowIndex) => (
<EstimateRowLine
disabled={disabled}
group={group}
key={row.id}
onChange={onChange}
onOpenGzfPicker={onOpenGzfPicker}
onValidityChange={onValidityChange}
row={row}
rowIndex={rowIndex}
/>
))}
{!group.rows.length ? (
<tr className="estimate-empty-row estimate-no-print">
<td />
<td colSpan={COLUMN_COUNT - 1}>
Noch keine Einträge – mit „+“ eine Zeile hinzufügen.
</td>
</tr>
) : null}
<tr className="estimate-subtotal-row">
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
Summe {label}
</td>
<PowerCells power={groupResult.av} />
<td />
<PowerCells power={groupResult.sv} />
<td />
<td className="estimate-no-print" />
</tr>
</tbody>
);
})}
<tfoot>
<tr className="estimate-no-print estimate-add-group-row">
<td colSpan={COLUMN_COUNT}>
<button
className="btn btn-sm btn-outline-primary"
disabled={disabled}
onClick={onAddGroup}
type="button"
>
<Plus aria-hidden="true" className="me-1" size={14} />
Kostengruppe hinzufügen
</button>
</td>
</tr>
<tr className="estimate-total-row">
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
Gesamtsumme
</td>
<PowerCells power={result.total.av} />
<td />
<PowerCells power={result.total.sv} />
<td colSpan={2} />
</tr>
<tr className="estimate-total-row">
<td colSpan={LEADING_COLUMN_COUNT - 1} className="estimate-total-label">
Reserveleistung
</td>
<td>
<div className="estimate-unit-input">
<NumberInput
ariaLabel="Reserve AV in Prozent"
disabled={disabled}
fieldKey="balance:reserveAvPercent"
onCommit={(reserveAvPercent) =>
onChange((current) => ({ ...current, reserveAvPercent }))
}
onValidityChange={onValidityChange}
range={estimateNumberRanges.reservePercent}
value={balance.reserveAvPercent}
/>
<span className="estimate-unit-label">%</span>
</div>
</td>
<PowerCells power={result.reserve.av} />
<td>
<div className="estimate-unit-input">
<NumberInput
ariaLabel="Reserve SV in Prozent"
disabled={disabled}
fieldKey="balance:reserveSvPercent"
onCommit={(reserveSvPercent) =>
onChange((current) => ({ ...current, reserveSvPercent }))
}
onValidityChange={onValidityChange}
range={estimateNumberRanges.reservePercent}
value={balance.reserveSvPercent}
/>
<span className="estimate-unit-label">%</span>
</div>
</td>
<PowerCells power={result.reserve.sv} />
<td colSpan={2} />
</tr>
<tr className="estimate-total-row is-strong">
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
Leistung inkl. Reserve
</td>
<PowerCells power={result.withReserve.av} />
<td />
<PowerCells power={result.withReserve.sv} />
<td colSpan={2} />
</tr>
<tr className="estimate-total-row">
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
Maximalstrom
<span className="estimate-total-hint">
{threePhaseVoltageV > 0
? ` (3~, U = ${formatDecimalInput(threePhaseVoltageV)} V)`
: " (Drehstromspannung im Projekt fehlt)"}
</span>
</td>
<td className="estimate-number" colSpan={2}>
{formatAmpere(result.maxCurrentA.av)} A
</td>
<td />
<td className="estimate-number" colSpan={2}>
{formatAmpere(result.maxCurrentA.sv)} A
</td>
<td colSpan={2} />
</tr>
</tfoot>
</table>
</div>
);
}