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

@ -1602,3 +1602,493 @@ button.structure-entry-main:focus-visible {
color: var(--color-ink-soft);
font-size: 0.9rem;
}
/* ── Grobe Leistungsbilanz ──────────────────────────────────────────── */
/* Tabelle im Stil der Büro-Vorlage „Leistungsbilanz – KG400“: kompakte
Zellen-Inputs statt Bootstrap-Formularfelder, Druck als A4 quer. */
.estimate-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
}
.estimate-save-status {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-sm);
font-size: 0.8rem;
}
.estimate-save-badge {
display: inline-block;
padding: 0.2rem 0.55rem;
border-radius: 999px;
font-weight: 600;
border: 1px solid var(--color-border);
color: var(--color-ink-soft);
background: var(--color-bg);
}
.estimate-save-badge.is-saved {
color: var(--color-signal-dark);
border-color: var(--color-signal);
}
.estimate-save-badge.is-busy {
color: var(--color-warn);
border-color: var(--color-warn);
}
.estimate-save-badge.is-error {
color: var(--color-danger);
border-color: var(--color-danger);
background: var(--color-danger-bg);
}
.estimate-save-hint {
color: var(--color-danger);
}
.estimate-header-fields {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm) var(--space-lg);
margin-bottom: var(--space-md);
}
.estimate-header-field {
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 10rem;
}
.estimate-header-field.is-wide {
flex: 1 1 18rem;
}
.estimate-header-label {
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
font-weight: 600;
color: var(--color-ink-soft);
}
.estimate-header-value {
font-weight: 600;
}
.estimate-header-input {
font-size: 0.95rem;
font-weight: 600;
border-color: var(--panel-border);
}
.estimate-sheet-wrap {
overflow-x: auto;
}
.estimate-sheet {
width: 100%;
min-width: 1400px;
table-layout: fixed;
border-collapse: collapse;
font-size: 0.82rem;
background: var(--panel-bg);
color: var(--text-strong);
}
.estimate-comparison {
min-width: 640px;
table-layout: auto;
}
.estimate-sheet th,
.estimate-sheet td {
border: 1px solid var(--panel-border);
padding: 2px 4px;
vertical-align: middle;
}
.estimate-sheet thead th {
background: var(--surface-header-row);
font-size: 0.72rem;
font-weight: 700;
text-align: center;
line-height: 1.2;
}
.estimate-th-hint {
display: block;
font-size: 0.68rem;
font-weight: 400;
color: var(--text-faint);
}
.estimate-col-kg { width: 3rem; }
.estimate-col-supply { width: 4rem; }
.estimate-col-circuit { width: 4.6rem; }
.estimate-col-name { width: auto; }
.estimate-col-quantity { width: 4.8rem; }
.estimate-col-unit { width: 3.8rem; }
.estimate-col-connection { width: 5rem; }
.estimate-col-power { width: 6.4rem; }
.estimate-col-gzf { width: 6.8rem; }
.estimate-col-cosphi { width: 3.8rem; }
.estimate-col-result { width: 4.6rem; }
.estimate-col-sv { width: 3.2rem; }
.estimate-col-remark { width: 9rem; }
.estimate-col-actions { width: 2.4rem; }
.estimate-cell-input,
.estimate-cell-select {
width: 100%;
min-width: 0;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
color: inherit;
font: inherit;
padding: 1px 4px;
line-height: 1.4;
}
.estimate-cell-select {
padding-right: 0;
cursor: pointer;
}
.estimate-cell-input:hover:not(:disabled),
.estimate-cell-select:hover:not(:disabled) {
border-color: var(--panel-border-strong);
}
.estimate-cell-input:focus,
.estimate-cell-select:focus {
outline: none;
border-color: var(--accent-blue);
background: var(--panel-bg);
box-shadow: 0 0 0 2px var(--accent-blue-soft-bg);
}
.estimate-cell-input.is-numeric {
text-align: right;
font-variant-numeric: tabular-nums;
}
.estimate-cell-input.is-invalid,
.estimate-cell-input.is-invalid:focus,
.estimate-cell-input.is-invalid:hover {
border-color: var(--color-danger);
background: var(--color-danger-bg);
}
.estimate-cell-input::placeholder {
color: var(--text-faint);
opacity: 0.7;
}
.estimate-unit-input,
.estimate-gzf-input {
display: flex;
align-items: center;
gap: 2px;
}
.estimate-unit-label {
flex: 0 0 auto;
font-size: 0.7rem;
color: var(--text-faint);
min-width: 1.9rem;
}
.estimate-amev-button {
flex: 0 0 auto;
border: 1px solid var(--panel-border-strong);
border-radius: 3px;
background: var(--panel-bg-subtle);
color: var(--text-subtle);
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.03em;
padding: 1px 3px;
line-height: 1.3;
}
.estimate-amev-button:hover:not(:disabled) {
border-color: var(--accent-blue);
color: var(--accent-blue);
}
.estimate-number {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.estimate-number.is-muted {
color: var(--text-faint);
}
.estimate-number.is-over {
color: var(--color-attention-dark);
}
.estimate-number.is-under {
color: var(--color-signal-dark);
}
.estimate-center {
text-align: center;
}
.estimate-cost-group-cell {
font-weight: 700;
text-align: center;
font-variant-numeric: tabular-nums;
}
.estimate-group-title-row td {
background: var(--surface-component-header);
font-weight: 700;
}
.estimate-group-title {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs) var(--space-lg);
}
.estimate-group-name {
font-size: 0.9rem;
}
.estimate-group-editor {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-weight: 400;
font-size: 0.75rem;
color: var(--text-muted);
}
.estimate-group-editor .estimate-cell-input {
width: 12rem;
}
.estimate-group-actions {
display: flex;
justify-content: flex-end;
gap: 2px;
}
.estimate-group-title-row .estimate-actions-cell {
width: auto;
white-space: nowrap;
}
.estimate-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.15rem 0.3rem;
line-height: 1;
}
.estimate-actions-cell {
text-align: center;
}
.estimate-empty-row td {
color: var(--text-faint);
font-style: italic;
}
.estimate-subtotal-row td {
background: var(--panel-bg-subtle);
font-weight: 700;
}
.estimate-total-label {
text-align: right;
font-weight: 700;
}
.estimate-total-hint {
font-weight: 400;
color: var(--text-faint);
}
.estimate-total-row td {
background: var(--surface-header-row);
font-weight: 600;
}
.estimate-total-row.is-strong td {
font-weight: 800;
border-top: 2px solid var(--panel-border-strong);
}
.estimate-add-group-row td {
background: var(--panel-bg);
padding: var(--space-sm) 4px;
}
.estimate-unassigned-row td {
font-style: italic;
color: var(--text-muted);
}
.estimate-collapse-toggle {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
border: 0;
background: transparent;
color: inherit;
font: inherit;
font-weight: 700;
padding: 0;
}
.estimate-list-table .estimate-number {
font-variant-numeric: tabular-nums;
}
/* Druck-Textwerte: auf dem Bildschirm unsichtbar, im Druck statt Inputs */
.estimate-print-value {
display: none;
}
@page estimate-balance {
size: A4 landscape;
margin: 10mm;
}
@media print {
body:has(.estimate-print-root) .sidebar,
.estimate-print-root .estimate-no-print,
.estimate-print-root .alert,
.estimate-print-root input,
.estimate-print-root select,
.estimate-print-root button {
display: none !important;
}
body:has(.estimate-print-root) {
background: #fff !important;
}
body:has(.estimate-print-root) .app-shell {
display: block;
}
.estimate-print-root {
page: estimate-balance;
padding: 0 !important;
max-width: none;
color: #000;
}
.estimate-print-root .estimate-print-value {
display: inline;
}
.estimate-print-root .page-header {
margin-bottom: 4mm;
}
.estimate-print-root .page-header h1 {
font-size: 14pt;
color: #000;
}
.estimate-print-root .card {
border: 0;
box-shadow: none;
background: transparent;
}
.estimate-print-root .card-body,
.estimate-print-root .card-header {
padding: 0;
background: transparent;
border: 0;
}
.estimate-print-root .estimate-comparison-card {
margin-top: 6mm;
break-inside: avoid;
}
.estimate-sheet-wrap {
overflow: visible;
}
.estimate-sheet {
min-width: 0;
table-layout: auto;
font-size: 7.5pt;
background: #fff;
color: #000;
}
.estimate-sheet th,
.estimate-sheet td {
border-color: #999;
padding: 1px 3px;
}
.estimate-sheet thead {
display: table-header-group;
}
.estimate-sheet thead th,
.estimate-total-row td {
background: #e8e8e8 !important;
}
.estimate-group-title-row td {
background: #d9d9d9 !important;
}
.estimate-subtotal-row td {
background: #f2f2f2 !important;
}
.estimate-group-title-row,
.estimate-subtotal-row,
.estimate-total-row {
break-inside: avoid;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.estimate-group-title-row {
break-after: avoid;
}
/* Totals print once at the end, not repeated on every page. */
.estimate-sheet tfoot {
display: table-row-group;
break-inside: avoid;
}
.estimate-col-actions {
width: 0;
}
.estimate-unit-input,
.estimate-gzf-input {
justify-content: flex-end;
}
.estimate-header-fields {
margin-bottom: 3mm;
}
}

View file

@ -0,0 +1,743 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react";
import {
ArrowLeft,
ChevronDown,
ChevronRight,
Printer,
Redo2,
RefreshCw,
Undo2,
} from "lucide-react";
import {
ApiRequestError,
getEstimateBalance,
getEstimateComparison,
getProject,
getProjectHistory,
isRevisionConflictError,
redoProjectCommand,
undoProjectCommand,
updateEstimateBalance,
} from "../../../../../frontend/utils/api";
import type {
EstimateBalanceDto,
EstimateComparisonDto,
ProjectDto,
ProjectHistoryStateDto,
} from "../../../../../frontend/types";
import type { EstimateGroupSnapshot } from "../../../../../shared/estimate-balance/estimate-balance";
import { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal";
import {
addEstimateGroup,
estimateBalanceFingerprint,
estimateTextLimits,
normalizeEstimateSortOrder,
toSaveableEstimateBalance,
updateEstimateGroup,
updateEstimateRow,
validateEstimateBalance,
} from "../../../../../frontend/estimate-balance/estimate-balance-document";
import { formatIsoDateGerman } from "../../../../../frontend/estimate-balance/estimate-balance-format";
import {
EstimateBalanceSheet,
type EstimateBalanceUpdate,
} from "../../../../../frontend/estimate-balance/estimate-balance-sheet";
import { EstimateComparisonTable } from "../../../../../frontend/estimate-balance/estimate-comparison-table";
import { DraftInput } from "../../../../../frontend/estimate-balance/estimate-draft-input";
import {
EstimateGroupModal,
type EstimateGroupFormValues,
} from "../../../../../frontend/estimate-balance/estimate-group-modal";
const AUTOSAVE_DELAY_MS = 800;
const CONFLICT_MESSAGE = "Die Bilanz wurde zwischenzeitlich geändert und neu geladen.";
type SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid";
type GroupDialog = { group: EstimateGroupSnapshot | null };
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
function isNotFoundError(error: unknown) {
return error instanceof ApiRequestError && error.status === 404;
}
export default function EstimateBalanceEditorPage() {
const params = useParams<{ projectId: string; balanceId: string }>();
const { projectId, balanceId } = params;
const router = useRouter();
const [project, setProject] = useState<ProjectDto | null>(null);
const [draft, setDraft] = useState<EstimateBalanceDto | null>(null);
const [history, setHistory] = useState<ProjectHistoryStateDto | null>(null);
const [comparison, setComparison] = useState<EstimateComparisonDto | null>(null);
const [comparisonError, setComparisonError] = useState<string | null>(null);
const [isComparisonOpen, setIsComparisonOpen] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("saved");
const [saveError, setSaveError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isMissing, setIsMissing] = useState(false);
const [isHistoryBusy, setIsHistoryBusy] = useState(false);
const [invalidFieldKeys, setInvalidFieldKeys] = useState<ReadonlySet<string>>(
() => new Set()
);
const [groupDialog, setGroupDialog] = useState<GroupDialog | null>(null);
const [gzfTarget, setGzfTarget] = useState<{ groupId: string; rowId: string } | null>(
null
);
// Refs carry the latest values into timers, unload handlers and chained
// async saves without re-creating them.
const draftRef = useRef<EstimateBalanceDto | null>(null);
const revisionRef = useRef<number | null>(null);
const savedFingerprintRef = useRef<string | null>(null);
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
const applyServerBalance = useCallback((balance: EstimateBalanceDto) => {
const normalized = normalizeEstimateSortOrder(balance);
savedFingerprintRef.current = estimateBalanceFingerprint(normalized);
draftRef.current = normalized;
setDraft(normalized);
setSaveStatus("saved");
setSaveError(null);
}, []);
const reloadComparison = useCallback(async () => {
try {
setComparison(await getEstimateComparison(projectId, balanceId));
setComparisonError(null);
} catch (err) {
setComparisonError(
errorMessage(err, "Der Vergleich konnte nicht geladen werden.")
);
}
}, [balanceId, projectId]);
/** Reloads project, document and history; replaces the local draft. */
const reloadAll = useCallback(async () => {
const balanceRequest = getEstimateBalance(projectId, balanceId);
// Handled below; avoids an "unhandled rejection" while project/history load.
balanceRequest.catch(() => undefined);
// Project and history are applied even when the balance is missing, so
// Redo stays available after its creation was undone.
const [currentProject, currentHistory] = await Promise.all([
getProject(projectId),
getProjectHistory(projectId).catch(() => null),
]);
setProject(currentProject);
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
setHistory(currentHistory);
const balance = await balanceRequest;
applyServerBalance(balance);
setIsMissing(false);
void reloadComparison();
}, [applyServerBalance, balanceId, projectId, reloadComparison]);
const handleReloadError = useCallback((err: unknown) => {
if (isNotFoundError(err)) {
setIsMissing(true);
setDraft(null);
draftRef.current = null;
return;
}
setError(errorMessage(err, "Die grobe Leistungsbilanz konnte nicht geladen werden."));
}, []);
useEffect(() => {
if (!projectId || !balanceId) {
return;
}
setIsLoading(true);
reloadAll()
.then(() => setError(null))
.catch(handleReloadError)
.finally(() => setIsLoading(false));
}, [balanceId, handleReloadError, projectId, reloadAll]);
const clearSaveTimer = useCallback(() => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
}, []);
/**
* Saves the current draft if it differs from the last saved state. Saves
* never overlap: a save requested while another runs waits for it and then
* saves whatever changed in the meantime. Resolves to false when the draft
* could not be saved.
*/
const saveNow = useCallback(async (): Promise<boolean> => {
clearSaveTimer();
while (saveInFlightRef.current) {
await saveInFlightRef.current;
}
const current = draftRef.current;
const revision = revisionRef.current;
if (!current || revision === null) {
return true;
}
const fingerprint = estimateBalanceFingerprint(current);
if (fingerprint === savedFingerprintRef.current) {
setSaveStatus("saved");
return true;
}
const problems = validateEstimateBalance(current);
if (problems.length) {
setSaveStatus("invalid");
setSaveError(problems.join(" "));
return false;
}
const body = toSaveableEstimateBalance(current);
/**
* Another command changed the project revision. When this document is
* still unchanged on the server (e.g. an edit in a circuit list), the
* draft is kept and saved again; otherwise the server state wins.
*/
async function resolveConflict(): Promise<"retry" | "reloaded"> {
const [currentProject, serverBalance, currentHistory] = await Promise.all([
getProject(projectId),
getEstimateBalance(projectId, balanceId),
getProjectHistory(projectId).catch(() => null),
]);
setProject(currentProject);
setHistory(currentHistory);
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
if (estimateBalanceFingerprint(serverBalance) === savedFingerprintRef.current) {
return "retry";
}
applyServerBalance(serverBalance);
setNotice(CONFLICT_MESSAGE);
void reloadComparison();
return "reloaded";
}
const run = (async () => {
setSaveStatus("saving");
for (let attempt = 0; ; attempt += 1) {
try {
const result = await updateEstimateBalance(
projectId,
body,
attempt === 0 ? revision : revisionRef.current ?? revision
);
if (!("unchanged" in result)) {
revisionRef.current = result.history.currentRevision;
setHistory(result.history);
setProject((currentProject) =>
currentProject
? { ...currentProject, currentRevision: result.history.currentRevision }
: currentProject
);
}
// The sent document is the saved state; the local draft is kept so
// typing is never interrupted by the server's copy.
savedFingerprintRef.current = fingerprint;
setSaveError(null);
const latest = draftRef.current;
if (latest && estimateBalanceFingerprint(latest) !== fingerprint) {
setSaveStatus("pending");
} else {
setSaveStatus("saved");
}
void reloadComparison();
return true;
} catch (err) {
if (isRevisionConflictError(err)) {
try {
if (attempt === 0 && (await resolveConflict()) === "retry") {
continue;
}
if (attempt > 0) {
await reloadAll();
setNotice(CONFLICT_MESSAGE);
}
} catch (reloadError) {
handleReloadError(reloadError);
}
return false;
}
if (isNotFoundError(err)) {
handleReloadError(err);
return false;
}
setSaveStatus("error");
setSaveError(errorMessage(err, "Die Änderungen konnten nicht gespeichert werden."));
return false;
}
}
})();
saveInFlightRef.current = run;
try {
return await run;
} finally {
saveInFlightRef.current = null;
}
}, [
applyServerBalance,
balanceId,
clearSaveTimer,
handleReloadError,
projectId,
reloadAll,
reloadComparison,
]);
// Debounced autosave after every change of the draft.
useEffect(() => {
if (!draft) {
return;
}
if (estimateBalanceFingerprint(draft) === savedFingerprintRef.current) {
return;
}
setSaveStatus((status) => (status === "saving" ? status : "pending"));
clearSaveTimer();
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null;
void saveNow();
}, AUTOSAVE_DELAY_MS);
return clearSaveTimer;
}, [clearSaveTimer, draft, saveNow]);
// Save immediately when the tab is hidden; on unload/unmount send the last
// change with keepalive so it survives the page.
useEffect(() => {
function hasUnsavedDraft() {
const current = draftRef.current;
return Boolean(
current && estimateBalanceFingerprint(current) !== savedFingerprintRef.current
);
}
function sendKeepalive(): boolean {
const current = draftRef.current;
const revision = revisionRef.current;
if (
!current ||
revision === null ||
saveInFlightRef.current ||
validateEstimateBalance(current).length
) {
return false;
}
savedFingerprintRef.current = estimateBalanceFingerprint(current);
void updateEstimateBalance(projectId, toSaveableEstimateBalance(current), revision, {
keepalive: true,
}).catch(() => undefined);
return true;
}
function handleVisibilityChange() {
if (document.visibilityState === "hidden" && hasUnsavedDraft()) {
void saveNow();
}
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!hasUnsavedDraft() || sendKeepalive()) {
return;
}
// A save is still running or the draft is invalid: let the browser ask.
event.preventDefault();
event.returnValue = "";
}
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("beforeunload", handleBeforeUnload);
// In-app navigation (e.g. sidebar) unmounts the editor.
if (hasUnsavedDraft()) {
sendKeepalive();
}
};
}, [projectId, saveNow]);
const handleChange: EstimateBalanceUpdate = useCallback((update) => {
setNotice(null);
setDraft((current) => (current ? update(current) : current));
}, []);
const handleValidityChange = useCallback((fieldKey: string, isValid: boolean) => {
setInvalidFieldKeys((current) => {
if (isValid ? !current.has(fieldKey) : current.has(fieldKey)) {
return current;
}
const next = new Set(current);
if (isValid) {
next.delete(fieldKey);
} else {
next.add(fieldKey);
}
return next;
});
}, []);
async function handleHistory(direction: "undo" | "redo") {
setError(null);
setNotice(null);
setIsHistoryBusy(true);
try {
// Pending edits are saved first so they become their own undo step.
await saveNow();
const revision = revisionRef.current;
if (revision === null) {
return;
}
const result =
direction === "undo"
? await undoProjectCommand(projectId, revision)
: await redoProjectCommand(projectId, revision);
revisionRef.current = result.history.currentRevision;
setHistory(result.history);
await reloadAll();
} catch (err) {
if (isRevisionConflictError(err)) {
await reloadAll().catch(handleReloadError);
setNotice(CONFLICT_MESSAGE);
} else if (isNotFoundError(err)) {
handleReloadError(err);
} else {
setError(
errorMessage(
err,
direction === "undo"
? "Die letzte Änderung konnte nicht rückgängig gemacht werden."
: "Die letzte Änderung konnte nicht wiederholt werden."
)
);
}
} finally {
setIsHistoryBusy(false);
}
}
async function handleBackClick(event: MouseEvent<HTMLAnchorElement>) {
const current = draftRef.current;
if (!current || estimateBalanceFingerprint(current) === savedFingerprintRef.current) {
return;
}
event.preventDefault();
await saveNow();
router.push(`/projects/${projectId}/grobbilanz`);
}
function handleSaveGroup(values: EstimateGroupFormValues) {
const editingGroup = groupDialog?.group ?? null;
handleChange((current) =>
editingGroup
? updateEstimateGroup(current, editingGroup.id, values)
: addEstimateGroup(current, values).balance
);
setGroupDialog(null);
}
function handleSelectGzf(value: number) {
if (gzfTarget) {
handleChange((current) =>
updateEstimateRow(current, gzfTarget.groupId, gzfTarget.rowId, {
simultaneityFactor: value,
})
);
}
setGzfTarget(null);
}
const isEditingDisabled = !draft || isHistoryBusy;
const invalidCount = invalidFieldKeys.size;
const listHref = `/projects/${projectId}/grobbilanz`;
return (
<main className="container-fluid py-4 estimate-print-root">
<div className="page-header estimate-page-header">
<div className="d-flex align-items-start gap-3">
<Link
aria-label="Zurück zur Übersicht der Stände"
className="btn btn-outline-secondary btn-sm structure-back-link estimate-no-print"
href={listHref}
onClick={(event) => void handleBackClick(event)}
title="Zurück zur Übersicht der Stände"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<div className="kicker">{project?.name ?? "Projekt"}</div>
<h1>Grobe Leistungsbilanz{draft ? ` – ${draft.name}` : ""}</h1>
<p className="text-secondary mb-0 estimate-no-print">
Leistungsabschätzung nach Kostengruppen (DIN 276) für LPH 2/3.
</p>
</div>
</div>
<div className="estimate-toolbar estimate-no-print">
<SaveStatusBadge
error={saveError}
invalidCount={invalidCount}
onRetry={() => void saveNow()}
status={saveStatus}
/>
<div className="btn-group btn-group-sm" role="group" aria-label="Verlauf">
<button
aria-label="Rückgängig"
className="btn btn-outline-secondary"
disabled={isHistoryBusy || !history || history.undoDepth === 0}
onClick={() => void handleHistory("undo")}
title="Rückgängig (letzte Änderung im Projekt)"
type="button"
>
<Undo2 aria-hidden="true" size={16} />
</button>
<button
aria-label="Wiederholen"
className="btn btn-outline-secondary"
disabled={isHistoryBusy || !history || history.redoDepth === 0}
onClick={() => void handleHistory("redo")}
title="Wiederholen (im Projekt)"
type="button"
>
<Redo2 aria-hidden="true" size={16} />
</button>
</div>
<button
aria-label="Drucken"
className="btn btn-sm btn-outline-secondary"
disabled={!draft}
onClick={() => window.print()}
title="Drucken"
type="button"
>
<Printer aria-hidden="true" className="me-1" size={16} />
Drucken
</button>
</div>
</div>
{notice ? (
<div className="alert alert-info estimate-no-print" role="status">
{notice}
</div>
) : null}
{error ? (
<div className="alert alert-warning estimate-no-print" role="alert">
{error}
</div>
) : null}
{isLoading ? (
<p className="text-secondary" role="status">
Grobe Leistungsbilanz wird geladen …
</p>
) : null}
{isMissing ? (
<div className="alert alert-warning" role="alert">
Dieser Stand existiert nicht (mehr), z. B. weil er gelöscht oder sein Anlegen
rückgängig gemacht wurde.{" "}
<Link href={listHref}>Zur Übersicht der Stände</Link>
</div>
) : null}
{draft && project ? (
<>
<section className="card shadow-sm mb-3 estimate-sheet-card">
<div className="card-body">
<div className="estimate-header-fields">
<div className="estimate-header-field">
<span className="estimate-header-label">Projekt</span>
<span className="estimate-header-value">{project.name}</span>
</div>
<label className="estimate-header-field is-wide">
<span className="estimate-header-label">Stand</span>
<DraftInput
ariaLabel="Bezeichnung des Stands"
className="estimate-cell-input estimate-header-input"
disabled={isEditingDisabled}
format={(value: string) => value}
maxLength={estimateTextLimits.balanceName}
onCommit={(name) => handleChange((current) => ({ ...current, name }))}
onValidityChange={(isValid) =>
handleValidityChange("balance:name", isValid)
}
parse={(text) =>
text.trim()
? { ok: true, value: text }
: { ok: false, error: "Die Bezeichnung darf nicht leer sein." }
}
value={draft.name}
/>
</label>
<label className="estimate-header-field">
<span className="estimate-header-label">LPH</span>
<input
aria-label="Leistungsphase"
className="estimate-cell-input estimate-header-input"
disabled={isEditingDisabled}
maxLength={estimateTextLimits.workStage}
onChange={(event) =>
handleChange((current) => ({
...current,
workStage: event.target.value,
}))
}
placeholder="z. B. LPH 3"
type="text"
value={draft.workStage ?? ""}
/>
<span className="estimate-print-value">{draft.workStage ?? ""}</span>
</label>
<label className="estimate-header-field">
<span className="estimate-header-label">Datum</span>
<input
aria-label="Datum"
className="estimate-cell-input estimate-header-input"
disabled={isEditingDisabled}
onChange={(event) =>
handleChange((current) => ({
...current,
dateIso: event.target.value || null,
}))
}
type="date"
value={draft.dateIso ?? ""}
/>
<span className="estimate-print-value">
{formatIsoDateGerman(draft.dateIso)}
</span>
</label>
</div>
<EstimateBalanceSheet
balance={draft}
disabled={isEditingDisabled}
onAddGroup={() => setGroupDialog({ group: null })}
onChange={handleChange}
onEditGroup={(group) => setGroupDialog({ group })}
onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })}
onValidityChange={handleValidityChange}
threePhaseVoltageV={project.threePhaseVoltageV}
/>
</div>
</section>
<section
className={
isComparisonOpen
? "card shadow-sm estimate-comparison-card"
: "card shadow-sm estimate-comparison-card estimate-no-print"
}
>
<div className="card-header d-flex justify-content-between align-items-center gap-2">
<button
aria-controls="estimate-comparison-body"
aria-expanded={isComparisonOpen}
className="estimate-collapse-toggle"
onClick={() => setIsComparisonOpen((open) => !open)}
type="button"
>
{isComparisonOpen ? (
<ChevronDown aria-hidden="true" size={16} />
) : (
<ChevronRight aria-hidden="true" size={16} />
)}
Vergleich mit detaillierter Bilanz
</button>
{isComparisonOpen ? (
<button
aria-label="Vergleich neu laden"
className="btn btn-sm btn-outline-secondary structure-icon-button estimate-no-print"
onClick={() => void reloadComparison()}
title="Vergleich neu laden"
type="button"
>
<RefreshCw aria-hidden="true" size={14} />
</button>
) : null}
</div>
{isComparisonOpen ? (
<div className="card-body" id="estimate-comparison-body">
{comparisonError ? (
<div className="alert alert-warning" role="alert">
{comparisonError}
</div>
) : null}
{comparison ? <EstimateComparisonTable comparison={comparison} /> : null}
<p className="text-secondary small mt-2 mb-0">
Detailliert = Summe der Gerätezeilen aller Stromkreislisten (Anzahl ×
Leistung × Geräte-GZF), zugeordnet über die Kostengruppe; ohne
Verteiler-GZF.
</p>
</div>
) : null}
</section>
</>
) : null}
{groupDialog ? (
<EstimateGroupModal
group={groupDialog.group}
onClose={() => setGroupDialog(null)}
onSave={handleSaveGroup}
/>
) : null}
{gzfTarget ? (
<GzfPickerModal onClose={() => setGzfTarget(null)} onSelect={handleSelectGzf} />
) : null}
</main>
);
}
function SaveStatusBadge({
error,
invalidCount,
onRetry,
status,
}: {
error: string | null;
invalidCount: number;
onRetry: () => void;
status: SaveStatus;
}) {
const label =
status === "saving"
? "Wird gespeichert …"
: status === "pending"
? "Ungespeicherte Änderungen"
: status === "error"
? "Nicht gespeichert – Fehler"
: status === "invalid"
? "Nicht gespeichert – ungültige Eingaben"
: "Gespeichert";
const tone =
status === "error" || status === "invalid"
? "is-error"
: status === "saved"
? "is-saved"
: "is-busy";
return (
<div className="estimate-save-status" role="status" aria-live="polite">
<span className={`estimate-save-badge ${tone}`} title={error ?? undefined}>
{label}
</span>
{invalidCount ? (
<span className="estimate-save-hint">
{invalidCount === 1
? "1 ungültige Eingabe wird nicht gespeichert"
: `${invalidCount} ungültige Eingaben werden nicht gespeichert`}
</span>
) : null}
{status === "error" ? (
<button className="btn btn-link btn-sm p-0" onClick={onRetry} type="button">
Erneut versuchen
</button>
) : null}
</div>
);
}

View file

@ -0,0 +1,292 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { ArrowLeft, Copy, FolderOpen, Plus, Trash2 } from "lucide-react";
import {
createEstimateBalance,
deleteEstimateBalance,
getProject,
listEstimateBalances,
} from "../../../../frontend/utils/api";
import type {
CreateEstimateBalanceInput,
EstimateBalanceDto,
ProjectCommandResultDto,
ProjectDto,
} from "../../../../frontend/types";
import { calculateEstimateBalance } from "../../../../shared/estimate-balance/estimate-balance";
import {
EstimateBalanceCreateModal,
type EstimateBalanceCreateDefaults,
} from "../../../../frontend/estimate-balance/estimate-balance-create-modal";
import { createCopyName } from "../../../../frontend/estimate-balance/estimate-balance-document";
import {
formatIsoDateGerman,
formatKw,
} from "../../../../frontend/estimate-balance/estimate-balance-format";
function countRows(balance: EstimateBalanceDto) {
return balance.groups.reduce((sum, group) => sum + group.rows.length, 0);
}
export default function EstimateBalanceListPage() {
const params = useParams<{ projectId: string }>();
const projectId = params.projectId;
const router = useRouter();
const [project, setProject] = useState<ProjectDto | null>(null);
const [balances, setBalances] = useState<EstimateBalanceDto[]>([]);
const [createDefaults, setCreateDefaults] =
useState<EstimateBalanceCreateDefaults | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const reload = useCallback(async () => {
const [currentProject, loadedBalances] = await Promise.all([
getProject(projectId),
listEstimateBalances(projectId),
]);
setProject(currentProject);
setBalances([...loadedBalances].sort((left, right) => left.sortOrder - right.sortOrder));
}, [projectId]);
useEffect(() => {
if (!projectId) {
return;
}
setIsLoading(true);
reload()
.then(() => setError(null))
.catch((err: unknown) =>
setError(
err instanceof Error
? err.message
: "Grobe Leistungsbilanzen konnten nicht geladen werden."
)
)
.finally(() => setIsLoading(false));
}, [projectId, reload]);
async function runCommand<TResult extends ProjectCommandResultDto>(
execute: (expectedRevision: number) => Promise<TResult>,
fallbackMessage: string
): Promise<TResult | null> {
if (!project) {
return null;
}
setIsSaving(true);
setError(null);
try {
const result = await execute(project.currentRevision);
setProject((current) =>
current ? { ...current, currentRevision: result.history.currentRevision } : current
);
await reload();
return result;
} catch (err) {
setError(err instanceof Error ? err.message : fallbackMessage);
// Refresh revision and list so a stale state does not block the next
// attempt; the command error stays visible.
await reload().catch(() => undefined);
return null;
} finally {
setIsSaving(false);
}
}
async function handleCreate(input: CreateEstimateBalanceInput) {
const result = await runCommand(
(expectedRevision) => createEstimateBalance(projectId, input, expectedRevision),
"Stand konnte nicht angelegt werden."
);
if (result) {
setCreateDefaults(null);
router.push(`/projects/${projectId}/grobbilanz/${result.balance.id}`);
}
}
async function handleDelete(balance: EstimateBalanceDto) {
if (
!window.confirm(
`Stand „${balance.name}“ der groben Leistungsbilanz wirklich löschen?`
)
) {
return;
}
await runCommand(
(expectedRevision) => deleteEstimateBalance(projectId, balance.id, expectedRevision),
"Stand konnte nicht gelöscht werden."
);
}
const isBusy = isSaving || !project;
const voltage = project?.threePhaseVoltageV ?? 400;
return (
<main className="container py-4">
<div className="page-header">
<div className="d-flex align-items-start gap-3">
<Link
aria-label="Zurück zur Projektübersicht"
className="btn btn-outline-secondary btn-sm structure-back-link"
href={`/projects/${projectId}`}
title="Zurück zur Projektübersicht"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<div className="kicker">{project?.name ?? "Projekt"}</div>
<h1>Grobe Leistungsbilanz</h1>
<p className="text-secondary mb-0">
Leistungsabschätzung für Vor- und Entwurfsplanung (LPH 2/3), gegliedert nach
Kostengruppen der DIN 276 – unabhängig von den Stromkreislisten.
</p>
</div>
</div>
<button
className="btn btn-primary"
disabled={isBusy}
onClick={() => setCreateDefaults({})}
type="button"
>
<Plus aria-hidden="true" className="me-1" size={16} />
Neuer Stand
</button>
</div>
{error ? (
<div className="alert alert-warning" role="alert">
{error}
</div>
) : null}
{isLoading ? (
<p className="text-secondary" role="status">
Grobe Leistungsbilanzen werden geladen …
</p>
) : null}
<section aria-labelledby="estimate-list-heading" className="card shadow-sm">
<div className="card-header d-flex justify-content-between align-items-center">
<h2 className="structure-column-title" id="estimate-list-heading">
Stände
<span className="badge text-bg-secondary ms-2">{balances.length}</span>
</h2>
</div>
{balances.length ? (
<div className="table-responsive">
<table className="table table-hover align-middle mb-0 estimate-list-table">
<thead>
<tr>
<th>Stand</th>
<th>LPH</th>
<th>Datum</th>
<th className="text-end">AV kW</th>
<th className="text-end">AV kVA</th>
<th className="text-end">SV kW</th>
<th className="text-end">SV kVA</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{balances.map((balance) => {
const result = calculateEstimateBalance(balance, voltage);
const href = `/projects/${projectId}/grobbilanz/${balance.id}`;
const rowCount = countRows(balance);
return (
<tr key={balance.id}>
<td>
<Link className="fw-semibold" href={href}>
{balance.name}
</Link>
<div className="text-secondary small">
{balance.groups.length} Kostengruppen ·{" "}
{rowCount === 1 ? "1 Zeile" : `${rowCount} Zeilen`}
</div>
</td>
<td>{balance.workStage ?? "–"}</td>
<td>{formatIsoDateGerman(balance.dateIso)}</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.av.kw)}
</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.av.kva)}
</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.sv.kw)}
</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.sv.kva)}
</td>
<td className="text-end">
<div className="d-inline-flex gap-1">
<Link
aria-label={`Stand „${balance.name}“ öffnen`}
className="btn btn-sm btn-outline-primary"
href={href}
title="Öffnen"
>
<FolderOpen aria-hidden="true" className="me-1" size={14} />
Öffnen
</Link>
<button
aria-label={`Stand „${balance.name}“ als neuen Stand kopieren`}
className="btn btn-sm btn-outline-secondary structure-icon-button"
disabled={isBusy}
onClick={() =>
setCreateDefaults({
name: createCopyName(balance.name),
workStage: balance.workStage,
template: "copy",
copyFromBalanceId: balance.id,
})
}
title="Als neuen Stand kopieren"
type="button"
>
<Copy aria-hidden="true" size={14} />
</button>
<button
aria-label={`Stand „${balance.name}“ löschen`}
className="btn btn-sm btn-outline-danger structure-icon-button"
disabled={isBusy}
onClick={() => void handleDelete(balance)}
title="Stand löschen"
type="button"
>
<Trash2 aria-hidden="true" size={14} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<p className="text-secondary small px-3 py-2 mb-0">
Leistungen jeweils inklusive Reserve.
</p>
</div>
) : !isLoading ? (
<div className="card-body">
<p className="structure-empty mb-0">
Noch kein Stand vorhanden. Mit „Neuer Stand“ eine grobe Leistungsbilanz auf
Basis der Vorlage KG 400 anlegen.
</p>
</div>
) : null}
</section>
{createDefaults ? (
<EstimateBalanceCreateModal
balances={balances}
defaults={createDefaults}
isSaving={isSaving}
onClose={() => setCreateDefaults(null)}
onCreate={(input) => void handleCreate(input)}
/>
) : null}
</main>
);
}

View file

@ -0,0 +1,43 @@
CREATE TABLE `estimate_balances` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`work_stage` text,
`date_iso` text,
`reserve_av_percent` real DEFAULT 0 NOT NULL,
`reserve_sv_percent` real DEFAULT 0 NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `estimate_balances_project_id_idx` ON `estimate_balances` (`project_id`);--> statement-breakpoint
CREATE TABLE `estimate_groups` (
`id` text PRIMARY KEY NOT NULL,
`balance_id` text NOT NULL,
`cost_group` text DEFAULT '' NOT NULL,
`title` text NOT NULL,
`editor` text,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`balance_id`) REFERENCES `estimate_balances`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `estimate_groups_balance_id_idx` ON `estimate_groups` (`balance_id`);--> statement-breakpoint
CREATE TABLE `estimate_rows` (
`id` text PRIMARY KEY NOT NULL,
`group_id` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`supply` text,
`circuit_ref` text,
`name` text DEFAULT '' NOT NULL,
`quantity` real DEFAULT 0 NOT NULL,
`unit` text DEFAULT 'piece' NOT NULL,
`connection` text,
`power_per_unit_w` real DEFAULT 0 NOT NULL,
`simultaneity_factor` real DEFAULT 1 NOT NULL,
`cos_phi` real DEFAULT 1 NOT NULL,
`safety_supply` integer DEFAULT false NOT NULL,
`remark` text,
FOREIGN KEY (`group_id`) REFERENCES `estimate_groups`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `estimate_rows_group_id_idx` ON `estimate_rows` (`group_id`);

File diff suppressed because it is too large Load diff

View file

@ -71,6 +71,13 @@
"when": 1790419185882,
"tag": "0009_buildings",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1790425784626,
"tag": "0010_estimate_balances",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,132 @@
import { eq } from "drizzle-orm";
import {
assertEstimateBalanceDeleteProjectCommand,
assertEstimateBalanceInsertProjectCommand,
assertEstimateBalanceUpdateProjectCommand,
createEstimateBalanceDeleteProjectCommand,
createEstimateBalanceInsertProjectCommand,
createEstimateBalanceUpdateProjectCommand,
estimateBalanceDeleteCommandType,
estimateBalanceInsertCommandType,
estimateBalanceUpdateCommandType,
normalizeEstimateBalanceSnapshot,
sameEstimateBalance,
type EstimateBalanceProjectCommand,
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type {
EstimateBalanceProjectCommandStore,
ExecuteEstimateBalanceCommandInput,
} from "../../estimate-balance/domain/estimate-balance-project-command.store.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
import type { AppDatabase } from "../database-context.js";
import { estimateBalances } from "../schema/estimate-balances.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import {
deleteEstimateBalance,
insertEstimateBalance,
readEstimateBalances,
} from "./estimate-balance.persistence.js";
export class EstimateBalanceProjectCommandRepository
implements EstimateBalanceProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteEstimateBalanceCommandInput) {
return executeProjectCommandTransaction(this.database, input, (tx) =>
this.applyCommand(tx, input.projectId, input.command)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: EstimateBalanceProjectCommand
): EstimateBalanceProjectCommand {
switch (command.type) {
case estimateBalanceInsertCommandType:
assertEstimateBalanceInsertProjectCommand(command);
return this.insert(database, projectId, command.payload.balance);
case estimateBalanceDeleteCommandType:
assertEstimateBalanceDeleteProjectCommand(command);
return this.delete(database, projectId, command.payload.balance);
case estimateBalanceUpdateCommandType:
assertEstimateBalanceUpdateProjectCommand(command);
return this.update(
database,
projectId,
command.payload.expected,
command.payload.target
);
}
}
private insert(
database: AppDatabase,
projectId: string,
balance: EstimateBalanceSnapshot
) {
if (balance.projectId !== projectId) {
throw new Error("Estimate-balance snapshot does not belong to project.");
}
const project = database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project does not exist.");
}
const existing = database
.select({ id: estimateBalances.id })
.from(estimateBalances)
.where(eq(estimateBalances.id, balance.id))
.get();
if (existing) {
throw new Error("Estimate-balance id already exists.");
}
const normalized = normalizeEstimateBalanceSnapshot(balance);
insertEstimateBalance(database, normalized);
return createEstimateBalanceDeleteProjectCommand(normalized);
}
private delete(
database: AppDatabase,
projectId: string,
expected: EstimateBalanceSnapshot
) {
this.assertPersisted(database, projectId, expected, "deletion");
if (deleteEstimateBalance(database, projectId, expected.id) !== 1) {
throw new Error("Estimate balance could not be deleted.");
}
return createEstimateBalanceInsertProjectCommand(expected);
}
private update(
database: AppDatabase,
projectId: string,
expected: EstimateBalanceSnapshot,
target: EstimateBalanceSnapshot
) {
this.assertPersisted(database, projectId, expected, "update");
// Group and row ids stay stable; replacing the whole document keeps the
// command exact and its inverse trivially the swapped pair.
deleteEstimateBalance(database, projectId, expected.id);
const normalized = normalizeEstimateBalanceSnapshot(target);
insertEstimateBalance(database, normalized);
return createEstimateBalanceUpdateProjectCommand(normalized, expected);
}
private assertPersisted(
database: AppDatabase,
projectId: string,
expected: EstimateBalanceSnapshot,
action: string
) {
const [persisted] = readEstimateBalances(database, projectId, expected.id);
if (!persisted || !sameEstimateBalance(persisted, expected)) {
throw new Error(`Estimate balance changed before ${action}.`);
}
}
}

View file

@ -0,0 +1,121 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
import type { AppDatabase } from "../database-context.js";
import {
estimateBalances,
estimateGroups,
estimateRows,
} from "../schema/estimate-balances.js";
/** Reads complete balance documents of a project, ordered like the UI shows them. */
export function readEstimateBalances(
database: AppDatabase,
projectId: string,
balanceId?: string
): EstimateBalanceSnapshot[] {
const balances = database
.select()
.from(estimateBalances)
.where(
balanceId
? and(
eq(estimateBalances.projectId, projectId),
eq(estimateBalances.id, balanceId)
)
: eq(estimateBalances.projectId, projectId)
)
.orderBy(asc(estimateBalances.sortOrder), asc(estimateBalances.id))
.all();
if (!balances.length) {
return [];
}
const groups = database
.select()
.from(estimateGroups)
.where(inArray(estimateGroups.balanceId, balances.map((balance) => balance.id)))
.orderBy(asc(estimateGroups.sortOrder), asc(estimateGroups.id))
.all();
const rows = groups.length
? database
.select()
.from(estimateRows)
.where(inArray(estimateRows.groupId, groups.map((group) => group.id)))
.orderBy(asc(estimateRows.sortOrder), asc(estimateRows.id))
.all()
: [];
return balances.map((balance) => ({
id: balance.id,
projectId: balance.projectId,
name: balance.name,
workStage: balance.workStage,
dateIso: balance.dateIso,
reserveAvPercent: balance.reserveAvPercent,
reserveSvPercent: balance.reserveSvPercent,
sortOrder: balance.sortOrder,
groups: groups
.filter((group) => group.balanceId === balance.id)
.map((group) => ({
id: group.id,
costGroup: group.costGroup,
title: group.title,
editor: group.editor,
sortOrder: group.sortOrder,
rows: rows
.filter((row) => row.groupId === group.id)
.map(({ groupId: _groupId, ...row }) => row),
})),
}));
}
/** Writes a complete balance document (the caller removed an older version first). */
export function insertEstimateBalance(
database: AppDatabase,
balance: EstimateBalanceSnapshot
) {
database
.insert(estimateBalances)
.values({
id: balance.id,
projectId: balance.projectId,
name: balance.name,
workStage: balance.workStage,
dateIso: balance.dateIso,
reserveAvPercent: balance.reserveAvPercent,
reserveSvPercent: balance.reserveSvPercent,
sortOrder: balance.sortOrder,
})
.run();
for (const group of balance.groups) {
database
.insert(estimateGroups)
.values({
id: group.id,
balanceId: balance.id,
costGroup: group.costGroup,
title: group.title,
editor: group.editor,
sortOrder: group.sortOrder,
})
.run();
if (group.rows.length) {
database
.insert(estimateRows)
.values(group.rows.map((row) => ({ ...row, groupId: group.id })))
.run();
}
}
}
/** Removes a balance with its groups and rows (foreign keys cascade). */
export function deleteEstimateBalance(
database: AppDatabase,
projectId: string,
balanceId: string
) {
return database
.delete(estimateBalances)
.where(
and(eq(estimateBalances.projectId, projectId), eq(estimateBalances.id, balanceId))
)
.run().changes;
}

View file

@ -0,0 +1,60 @@
import { asc, eq } from "drizzle-orm";
import type {
DetailedDeviceRowPower,
EstimateBalanceSnapshot,
} from "../../shared/estimate-balance/estimate-balance.js";
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { estimateBalances } from "../schema/estimate-balances.js";
import { readEstimateBalances } from "./estimate-balance.persistence.js";
/** Read side of the estimate-balance module; writes go through commands. */
export class EstimateBalanceRepository {
constructor(private readonly database: AppDatabase) {}
listByProject(projectId: string): EstimateBalanceSnapshot[] {
return readEstimateBalances(this.database, projectId);
}
findById(projectId: string, balanceId: string): EstimateBalanceSnapshot | null {
return readEstimateBalances(this.database, projectId, balanceId)[0] ?? null;
}
nextSortOrder(projectId: string): number {
const rows = this.database
.select({ sortOrder: estimateBalances.sortOrder })
.from(estimateBalances)
.where(eq(estimateBalances.projectId, projectId))
.orderBy(asc(estimateBalances.sortOrder))
.all();
return rows.length ? Math.max(...rows.map((row) => row.sortOrder)) + 1 : 0;
}
/**
* Device rows of all circuit lists of the project with their total power
* (quantity · power per unit · device GZF, like the circuit tree).
*/
listDetailedDeviceRowPower(projectId: string): DetailedDeviceRowPower[] {
return this.database
.select({
costGroup: circuitDeviceRows.costGroup,
quantity: circuitDeviceRows.quantity,
powerPerUnit: circuitDeviceRows.powerPerUnit,
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
cosPhi: circuitDeviceRows.cosPhi,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.where(eq(circuitLists.projectId, projectId))
.all()
.map((row) => ({
costGroup: row.costGroup,
kw: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
cosPhi: row.cosPhi,
}));
}
}

View file

@ -0,0 +1,61 @@
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { projects } from "./projects.js";
// Grobe Leistungsbilanz (LPH 2/3): a separate module without links into the
// circuit structure. See docs/estimate-balance.md.
export const estimateBalances = sqliteTable(
"estimate_balances",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
workStage: text("work_stage"),
dateIso: text("date_iso"),
reserveAvPercent: real("reserve_av_percent").notNull().default(0),
reserveSvPercent: real("reserve_sv_percent").notNull().default(0),
sortOrder: integer("sort_order").notNull().default(0),
},
(table) => [index("estimate_balances_project_id_idx").on(table.projectId)]
);
export const estimateGroups = sqliteTable(
"estimate_groups",
{
id: text("id").primaryKey(),
balanceId: text("balance_id")
.notNull()
.references(() => estimateBalances.id, { onDelete: "cascade" }),
costGroup: text("cost_group").notNull().default(""),
title: text("title").notNull(),
editor: text("editor"),
sortOrder: integer("sort_order").notNull().default(0),
},
(table) => [index("estimate_groups_balance_id_idx").on(table.balanceId)]
);
export const estimateRows = sqliteTable(
"estimate_rows",
{
id: text("id").primaryKey(),
groupId: text("group_id")
.notNull()
.references(() => estimateGroups.id, { onDelete: "cascade" }),
sortOrder: integer("sort_order").notNull().default(0),
supply: text("supply"),
circuitRef: text("circuit_ref"),
name: text("name").notNull().default(""),
quantity: real("quantity").notNull().default(0),
unit: text("unit", { enum: ["piece", "area"] }).notNull().default("piece"),
connection: text("connection", { enum: ["fixed", "socket"] }),
powerPerUnitW: real("power_per_unit_w").notNull().default(0),
simultaneityFactor: real("simultaneity_factor").notNull().default(1),
cosPhi: real("cos_phi").notNull().default(1),
safetySupply: integer("safety_supply", { mode: "boolean" })
.notNull()
.default(false),
remark: text("remark"),
},
(table) => [index("estimate_rows_group_id_idx").on(table.groupId)]
);

View file

@ -194,6 +194,15 @@ import {
externalObjectDeleteCreatedCircuitCommandType,
} from "../models/external-object-new-circuit-project-command.model.js";
import type { ExternalObjectNewCircuitProjectCommandStore } from "../ports/external-object-new-circuit-project-command.store.js";
import {
assertEstimateBalanceDeleteProjectCommand,
assertEstimateBalanceInsertProjectCommand,
assertEstimateBalanceUpdateProjectCommand,
estimateBalanceDeleteCommandType,
estimateBalanceInsertCommandType,
estimateBalanceUpdateCommandType,
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceProjectCommandStore } from "../../estimate-balance/domain/estimate-balance-project-command.store.js";
interface DispatchProjectCommandInput {
projectId: string;
@ -233,7 +242,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore
) {}
executeUser(
@ -654,6 +664,24 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case estimateBalanceInsertCommandType:
case estimateBalanceDeleteCommandType:
case estimateBalanceUpdateCommandType: {
if (input.command.type === estimateBalanceInsertCommandType) {
assertEstimateBalanceInsertProjectCommand(input.command);
} else if (input.command.type === estimateBalanceDeleteCommandType) {
assertEstimateBalanceDeleteProjectCommand(input.command);
} else {
assertEstimateBalanceUpdateProjectCommand(input.command);
}
if (!this.estimateBalanceStore) {
throw new Error("Estimate-balance store is not available.");
}
return this.estimateBalanceStore.execute({
...input,
command: input.command,
}).revision;
}
case projectStateRestoreCommandType: {
assertProjectStateRestoreCommand(input.command);
return this.projectStateRestoreStore.execute({

View file

@ -0,0 +1,33 @@
import { z } from "zod";
import { expectedProjectRevisionSchema } from "../../shared/validation/project-command.schemas.js";
export const createEstimateBalanceRequestSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200),
workStage: z.string().trim().max(50).nullable().optional(),
dateIso: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.nullable()
.optional(),
template: z.enum(["kg400", "empty", "copy"]),
copyFromBalanceId: z.string().trim().min(1).optional(),
})
.strict()
.refine((input) => input.template !== "copy" || Boolean(input.copyFromBalanceId), {
message: "copyFromBalanceId is required for template copy",
path: ["copyFromBalanceId"],
});
export const updateEstimateBalanceRequestSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
// Validated in detail by parseEstimateBalanceSnapshot.
balance: z.unknown(),
})
.strict();
export const deleteEstimateBalanceRequestSchema = z
.object({ expectedRevision: expectedProjectRevisionSchema })
.strict();

View file

@ -0,0 +1,320 @@
import crypto from "node:crypto";
import { z } from "zod";
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
import {
estimateDefaultGroups,
type EstimateBalanceSnapshot,
type EstimateGroupSnapshot,
} from "../../shared/estimate-balance/estimate-balance.js";
export const estimateBalanceInsertCommandType = "estimate-balance.insert" as const;
export const estimateBalanceDeleteCommandType = "estimate-balance.delete" as const;
export const estimateBalanceUpdateCommandType = "estimate-balance.update" as const;
export const estimateBalanceCommandSchemaVersion = 1 as const;
const idSchema = z.string().trim().min(1).max(100);
const trimmed = (max: number) =>
z.string().max(max).refine((value) => value === value.trim(), {
message: "must be trimmed",
});
const nullableText = (max: number) =>
trimmed(max)
.refine((value) => value.length > 0, { message: "use null instead of empty" })
.nullable();
const finite = z.number().finite();
export const estimateRowSnapshotSchema = z
.object({
id: idSchema,
sortOrder: z.number().int().min(0),
supply: nullableText(50),
circuitRef: nullableText(50),
name: trimmed(200),
quantity: finite.min(0),
unit: z.enum(["piece", "area"]),
connection: z.enum(["fixed", "socket"]).nullable(),
powerPerUnitW: finite.min(0),
simultaneityFactor: finite.min(0).max(1),
cosPhi: finite.gt(0).max(1),
safetySupply: z.boolean(),
remark: nullableText(500),
})
.strict();
export const estimateGroupSnapshotSchema = z
.object({
id: idSchema,
costGroup: trimmed(20),
title: trimmed(200).refine((value) => value.length > 0, {
message: "title must not be empty",
}),
editor: nullableText(100),
sortOrder: z.number().int().min(0),
rows: z.array(estimateRowSnapshotSchema).max(1000),
})
.strict();
export const estimateBalanceSnapshotSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: trimmed(200).refine((value) => value.length > 0, {
message: "name must not be empty",
}),
workStage: nullableText(50),
dateIso: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.refine((value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), {
message: "invalid date",
})
.nullable(),
reserveAvPercent: finite.min(0).max(100),
reserveSvPercent: finite.min(0).max(100),
sortOrder: z.number().int().min(0),
groups: z.array(estimateGroupSnapshotSchema).max(200),
})
.strict()
.superRefine((balance, context) => {
const ids = new Set<string>();
for (const group of balance.groups) {
for (const id of [group.id, ...group.rows.map((row) => row.id)]) {
if (ids.has(id)) {
context.addIssue({
code: "custom",
message: `duplicate id ${id}`,
path: ["groups"],
});
}
ids.add(id);
}
}
});
export interface EstimateBalanceInsertProjectCommand
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
type: typeof estimateBalanceInsertCommandType;
}
export interface EstimateBalanceDeleteProjectCommand
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
type: typeof estimateBalanceDeleteCommandType;
}
export interface EstimateBalanceUpdateProjectCommand
extends SerializedProjectCommand<{
expected: EstimateBalanceSnapshot;
target: EstimateBalanceSnapshot;
}> {
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
type: typeof estimateBalanceUpdateCommandType;
}
export type EstimateBalanceProjectCommand =
| EstimateBalanceInsertProjectCommand
| EstimateBalanceDeleteProjectCommand
| EstimateBalanceUpdateProjectCommand;
/** Parses and normalizes a snapshot (sort orders of groups and rows follow the array order). */
export function parseEstimateBalanceSnapshot(value: unknown): EstimateBalanceSnapshot {
const parsed = estimateBalanceSnapshotSchema.parse(value);
return normalizeEstimateBalanceSnapshot(parsed);
}
export function normalizeEstimateBalanceSnapshot(
balance: EstimateBalanceSnapshot
): EstimateBalanceSnapshot {
return {
...balance,
groups: balance.groups.map((group, groupIndex) => ({
...group,
sortOrder: groupIndex,
rows: group.rows.map((row, rowIndex) => ({ ...row, sortOrder: rowIndex })),
})),
};
}
/** Stable JSON for exact equality checks of two snapshots. */
export function canonicalEstimateBalance(balance: EstimateBalanceSnapshot): string {
const normalized = normalizeEstimateBalanceSnapshot(balance);
return JSON.stringify({
id: normalized.id,
projectId: normalized.projectId,
name: normalized.name,
workStage: normalized.workStage,
dateIso: normalized.dateIso,
reserveAvPercent: normalized.reserveAvPercent,
reserveSvPercent: normalized.reserveSvPercent,
sortOrder: normalized.sortOrder,
groups: normalized.groups.map((group) => ({
id: group.id,
costGroup: group.costGroup,
title: group.title,
editor: group.editor,
sortOrder: group.sortOrder,
rows: group.rows.map((row) => ({
id: row.id,
sortOrder: row.sortOrder,
supply: row.supply,
circuitRef: row.circuitRef,
name: row.name,
quantity: row.quantity,
unit: row.unit,
connection: row.connection,
powerPerUnitW: row.powerPerUnitW,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
safetySupply: row.safetySupply,
remark: row.remark,
})),
})),
});
}
export function sameEstimateBalance(
left: EstimateBalanceSnapshot,
right: EstimateBalanceSnapshot
): boolean {
return canonicalEstimateBalance(left) === canonicalEstimateBalance(right);
}
export function createEstimateBalanceSnapshot(input: {
projectId: string;
name: string;
workStage: string | null;
dateIso: string | null;
sortOrder: number;
template: "kg400" | "empty" | { copyFrom: EstimateBalanceSnapshot };
createId?: () => string;
}): EstimateBalanceSnapshot {
const createId = input.createId ?? (() => crypto.randomUUID());
let groups: EstimateGroupSnapshot[] = [];
let reserveAvPercent = 10;
let reserveSvPercent = 0;
if (input.template === "kg400") {
groups = estimateDefaultGroups.map((group, index) => ({
id: createId(),
costGroup: group.costGroup,
title: group.title,
editor: null,
sortOrder: index,
rows: [],
}));
} else if (input.template !== "empty") {
const source = input.template.copyFrom;
reserveAvPercent = source.reserveAvPercent;
reserveSvPercent = source.reserveSvPercent;
groups = source.groups.map((group) => ({
...group,
id: createId(),
rows: group.rows.map((row) => ({ ...row, id: createId() })),
}));
}
return parseEstimateBalanceSnapshot({
id: createId(),
projectId: input.projectId,
name: input.name.trim(),
workStage: input.workStage?.trim() || null,
dateIso: input.dateIso || null,
reserveAvPercent,
reserveSvPercent,
sortOrder: input.sortOrder,
groups,
});
}
export function createEstimateBalanceInsertProjectCommand(
balance: EstimateBalanceSnapshot
): EstimateBalanceInsertProjectCommand {
return {
schemaVersion: estimateBalanceCommandSchemaVersion,
type: estimateBalanceInsertCommandType,
payload: { balance: parseEstimateBalanceSnapshot(balance) },
};
}
export function createEstimateBalanceDeleteProjectCommand(
balance: EstimateBalanceSnapshot
): EstimateBalanceDeleteProjectCommand {
return {
schemaVersion: estimateBalanceCommandSchemaVersion,
type: estimateBalanceDeleteCommandType,
payload: { balance: parseEstimateBalanceSnapshot(balance) },
};
}
export function createEstimateBalanceUpdateProjectCommand(
expected: EstimateBalanceSnapshot,
target: EstimateBalanceSnapshot
): EstimateBalanceUpdateProjectCommand {
const payload = {
expected: parseEstimateBalanceSnapshot(expected),
target: parseEstimateBalanceSnapshot(target),
};
assertMatchingUpdate(payload.expected, payload.target);
return {
schemaVersion: estimateBalanceCommandSchemaVersion,
type: estimateBalanceUpdateCommandType,
payload,
};
}
function assertMatchingUpdate(
expected: EstimateBalanceSnapshot,
target: EstimateBalanceSnapshot
) {
if (expected.id !== target.id || expected.projectId !== target.projectId) {
throw new Error("Estimate-balance update must keep id and project.");
}
if (sameEstimateBalance(expected, target)) {
throw new Error("Estimate-balance update must change the balance.");
}
}
function assertCommandShape(
command: SerializedProjectCommand<unknown>,
type: string,
keys: string[]
): Record<string, unknown> {
const payload = command.payload;
if (
command.schemaVersion !== estimateBalanceCommandSchemaVersion ||
command.type !== type ||
payload === null ||
typeof payload !== "object" ||
Array.isArray(payload) ||
Object.keys(payload).sort().join(",") !== [...keys].sort().join(",")
) {
throw new Error("Unsupported estimate-balance command.");
}
return payload as Record<string, unknown>;
}
export function assertEstimateBalanceInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is EstimateBalanceInsertProjectCommand {
const payload = assertCommandShape(command, estimateBalanceInsertCommandType, ["balance"]);
parseEstimateBalanceSnapshot(payload.balance);
}
export function assertEstimateBalanceDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is EstimateBalanceDeleteProjectCommand {
const payload = assertCommandShape(command, estimateBalanceDeleteCommandType, ["balance"]);
parseEstimateBalanceSnapshot(payload.balance);
}
export function assertEstimateBalanceUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is EstimateBalanceUpdateProjectCommand {
const payload = assertCommandShape(command, estimateBalanceUpdateCommandType, [
"expected",
"target",
]);
assertMatchingUpdate(
parseEstimateBalanceSnapshot(payload.expected),
parseEstimateBalanceSnapshot(payload.target)
);
}

View file

@ -0,0 +1,24 @@
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "../../domain/ports/project-revision.store.js";
import type { EstimateBalanceProjectCommand } from "./estimate-balance-command.model.js";
export interface ExecuteEstimateBalanceCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: EstimateBalanceProjectCommand;
}
export interface ExecutedEstimateBalanceCommand {
revision: AppendedProjectRevision;
inverse: EstimateBalanceProjectCommand;
}
export interface EstimateBalanceProjectCommandStore {
execute(input: ExecuteEstimateBalanceCommandInput): ExecutedEstimateBalanceCommand;
}

View file

@ -17,6 +17,7 @@ const PROJECT_SECTIONS = [
// Separate project sub-routes (/projects/[id]/<segment>).
const PROJECT_ROUTES = [
{ segment: "struktur", label: "Gebäudestruktur", icon: "▥" },
{ segment: "grobbilanz", label: "Grobe Leistungsbilanz", icon: "Σ" },
] as const;
// Matches the project overview and all of its sub-routes (group 2 = rest).

View file

@ -0,0 +1,162 @@
"use client";
import { type FormEvent, useState } from "react";
import { FormModal } from "../components/form-modal";
import type {
CreateEstimateBalanceInput,
EstimateBalanceDto,
EstimateBalanceTemplate,
} from "../types";
import { estimateTextLimits } from "./estimate-balance-document";
import { getTodayIsoDate } from "./estimate-balance-format";
export interface EstimateBalanceCreateDefaults {
name?: string;
workStage?: string | null;
template?: EstimateBalanceTemplate;
copyFromBalanceId?: string;
}
interface EstimateBalanceCreateModalProps {
balances: EstimateBalanceDto[];
defaults: EstimateBalanceCreateDefaults;
isSaving: boolean;
onClose: () => void;
onCreate: (input: CreateEstimateBalanceInput) => void;
}
/** "Neuer Stand": name, LPH, date and the starting content. */
export function EstimateBalanceCreateModal({
balances,
defaults,
isSaving,
onClose,
onCreate,
}: EstimateBalanceCreateModalProps) {
const [name, setName] = useState(defaults.name ?? "");
const [workStage, setWorkStage] = useState(defaults.workStage ?? "");
const [dateIso, setDateIso] = useState(getTodayIsoDate());
const [template, setTemplate] = useState<EstimateBalanceTemplate>(
defaults.template ?? "kg400"
);
const [copyFromBalanceId, setCopyFromBalanceId] = useState(
defaults.copyFromBalanceId ?? balances[0]?.id ?? ""
);
const isCopyInvalid = template === "copy" && !copyFromBalanceId;
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!name.trim() || isCopyInvalid) {
return;
}
onCreate({
name: name.trim(),
workStage: workStage.trim() || null,
dateIso: dateIso || null,
template,
...(template === "copy" ? { copyFromBalanceId } : {}),
});
}
return (
<FormModal
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!name.trim() || isCopyInvalid}
submitLabel="Stand anlegen"
title="Neuer Stand"
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="estimate-create-name">
Bezeichnung
</label>
<input
autoFocus
className="form-control"
id="estimate-create-name"
maxLength={estimateTextLimits.balanceName}
onChange={(event) => setName(event.target.value)}
placeholder="z. B. Entwurfsplanung"
required
value={name}
/>
</div>
<div className="col-6 col-md-3">
<label className="form-label" htmlFor="estimate-create-work-stage">
LPH
</label>
<input
className="form-control"
id="estimate-create-work-stage"
maxLength={estimateTextLimits.workStage}
onChange={(event) => setWorkStage(event.target.value)}
placeholder="z. B. LPH 3"
value={workStage}
/>
</div>
<div className="col-6 col-md-3">
<label className="form-label" htmlFor="estimate-create-date">
Datum
</label>
<input
className="form-control"
id="estimate-create-date"
onChange={(event) => setDateIso(event.target.value)}
type="date"
value={dateIso}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="estimate-create-template">
Inhalt
</label>
<select
className="form-select"
id="estimate-create-template"
onChange={(event) =>
setTemplate(event.target.value as EstimateBalanceTemplate)
}
value={template}
>
<option value="kg400">Vorlage KG 400</option>
<option value="empty">Leer</option>
<option disabled={!balances.length} value="copy">
Kopie von …
</option>
</select>
</div>
{template === "copy" ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="estimate-create-copy-from">
Kopie von Stand
</label>
<select
className="form-select"
id="estimate-create-copy-from"
onChange={(event) => setCopyFromBalanceId(event.target.value)}
required
value={copyFromBalanceId}
>
{balances.map((balance) => (
<option key={balance.id} value={balance.id}>
{balance.workStage ? `${balance.name} (${balance.workStage})` : balance.name}
</option>
))}
</select>
</div>
) : null}
<div className="col-12">
<p className="text-secondary small mb-0">
{template === "kg400"
? "Legt die Kostengruppen 410 bis 480 und „Allgemein“ ohne Einträge an."
: template === "empty"
? "Legt einen Stand ohne Kostengruppen an."
: "Übernimmt alle Kostengruppen und Zeilen des gewählten Stands."}
</p>
</div>
</div>
</FormModal>
);
}

View file

@ -0,0 +1,301 @@
// 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,
circuitRef: 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,
circuitRef: 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),
circuitRef: optionalText(row.circuitRef),
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);
}

View file

@ -0,0 +1,122 @@
// Pure parsing and formatting rules of the "Grobe Leistungsbilanz" editor.
// Numbers are entered and shown in German notation. As in the circuit list
// editor, only "," is accepted as decimal separator: a "." is ambiguous
// (decimal point or thousands separator) and is rejected instead of guessed.
import { roundEstimateValue } from "../../shared/estimate-balance/estimate-balance";
export type DecimalParseResult =
| { ok: true; value: number }
| { ok: false; error: string };
export interface DecimalRange {
min?: number;
/** Excludes `min` itself (e.g. cos φ > 0). */
minExclusive?: boolean;
max?: number;
}
const DECIMAL_PATTERN = /^[+-]?(\d+(,\d*)?|,\d+)$/;
function formatBound(value: number) {
return value.toLocaleString("de-DE", { maximumFractionDigits: 6, useGrouping: false });
}
export function parseGermanDecimal(text: string, range: DecimalRange = {}): DecimalParseResult {
const trimmed = text.trim();
if (!trimmed) {
return { ok: false, error: "Bitte einen Wert eingeben." };
}
if (trimmed.includes(".")) {
return { ok: false, error: "Dezimalstellen mit Komma eingeben (z. B. 0,8)." };
}
if (!DECIMAL_PATTERN.test(trimmed)) {
return { ok: false, error: "Ungültiger Zahlenwert." };
}
const value = Number(trimmed.replace(",", "."));
if (!Number.isFinite(value)) {
return { ok: false, error: "Ungültiger Zahlenwert." };
}
if (range.min !== undefined) {
if (range.minExclusive ? value <= range.min : value < range.min) {
return {
ok: false,
error: range.minExclusive
? `Der Wert muss größer als ${formatBound(range.min)} sein.`
: `Der Wert darf nicht kleiner als ${formatBound(range.min)} sein.`,
};
}
}
if (range.max !== undefined && value > range.max) {
return { ok: false, error: `Der Wert darf nicht größer als ${formatBound(range.max)} sein.` };
}
return { ok: true, value };
}
/** Value ranges accepted by the server for the numeric document fields. */
export const estimateNumberRanges = {
quantity: { min: 0 },
powerPerUnitW: { min: 0 },
simultaneityFactor: { min: 0, max: 1 },
cosPhi: { min: 0, minExclusive: true, max: 1 },
reservePercent: { min: 0, max: 100 },
} satisfies Record<string, DecimalRange>;
/**
* Text of a numeric input field: German comma, no thousands separator, so the
* shown text can be parsed again unchanged.
*/
export function formatDecimalInput(value: number, maximumFractionDigits = 6): string {
return value.toLocaleString("de-DE", { maximumFractionDigits, useGrouping: false });
}
/**
* Read-only display of a calculated value with thousands separator ("1.200",
* "30,24"). The value is rounded half away from zero first, like Excel does
* (40,175 → 40,18 instead of the binary-rounded 40,17).
*/
export function formatGermanNumber(
value: number,
minimumFractionDigits: number,
maximumFractionDigits = minimumFractionDigits
): string {
const rounded = roundEstimateValue(value, maximumFractionDigits);
// "+ 0" turns -0 into 0 and avoids "-0,00".
return (rounded + 0).toLocaleString("de-DE", {
minimumFractionDigits,
maximumFractionDigits,
});
}
export function formatKw(value: number) {
return formatGermanNumber(value, 2);
}
export function formatAmpere(value: number) {
return formatGermanNumber(value, 2);
}
export function formatSignedKw(value: number) {
const text = formatKw(value);
return value > 0 && text !== "0,00" ? `+${text}` : text;
}
export function formatDeltaPercent(value: number | null) {
if (value === null) {
return "–";
}
const text = formatGermanNumber(value, 1);
return `${value > 0 && text !== "0,0" ? "+" : ""}${text} %`;
}
/** "2026-09-26" → "26.09.2026"; empty/invalid → "–". */
export function formatIsoDateGerman(dateIso: string | null): string {
const match = dateIso?.match(/^(\d{4})-(\d{2})-(\d{2})$/);
return match ? `${match[3]}.${match[2]}.${match[1]}` : "–";
}
/** Local calendar date as YYYY-MM-DD (not UTC, so late evenings stay today). */
export function getTodayIsoDate(now: Date = new Date()): string {
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${now.getFullYear()}-${month}-${day}`;
}

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>
);
}

View file

@ -0,0 +1,88 @@
"use client";
import {
roundEstimateValue,
type EstimateComparison,
} from "../../shared/estimate-balance/estimate-balance";
import { formatDeltaPercent, formatKw, formatSignedKw } from "./estimate-balance-format";
function deltaClass(deltaKw: number) {
if (roundEstimateValue(deltaKw, 2) === 0) {
return "estimate-number";
}
return deltaKw > 0 ? "estimate-number is-over" : "estimate-number is-under";
}
/** Estimated AV power per cost group vs. the detailed circuit-list rows. */
export function EstimateComparisonTable({ comparison }: { comparison: EstimateComparison }) {
return (
<div className="estimate-sheet-wrap">
<table className="estimate-sheet estimate-comparison">
<thead>
<tr>
<th>KG</th>
<th>Titel</th>
<th>geschätzt AV kW</th>
<th>detailliert kW</th>
<th>Δ kW</th>
<th>Δ %</th>
</tr>
</thead>
<tbody>
{comparison.groups.map((group) => (
<tr key={group.groupId}>
<td className="estimate-cost-group-cell">{group.costGroup}</td>
<td>
{group.title}
{group.detailedRowCount ? (
<span className="estimate-th-hint">
{group.detailedRowCount === 1
? "1 Gerätezeile"
: `${group.detailedRowCount} Gerätezeilen`}
</span>
) : null}
</td>
<td className="estimate-number">{formatKw(group.estimatedAv.kw)}</td>
<td className="estimate-number">{formatKw(group.detailed.kw)}</td>
<td className={deltaClass(group.deltaKw)}>{formatSignedKw(group.deltaKw)}</td>
<td className={deltaClass(group.deltaKw)}>
{formatDeltaPercent(group.deltaPercent)}
</td>
</tr>
))}
<tr className="estimate-unassigned-row">
<td />
<td>
ohne Kostengruppe / nicht zugeordnet
{comparison.unassigned.rowCount ? (
<span className="estimate-th-hint">
{comparison.unassigned.rowCount === 1
? "1 Gerätezeile"
: `${comparison.unassigned.rowCount} Gerätezeilen`}
</span>
) : null}
</td>
<td className="estimate-number">–</td>
<td className="estimate-number">{formatKw(comparison.unassigned.kw)}</td>
<td className="estimate-number">–</td>
<td className="estimate-number">–</td>
</tr>
</tbody>
<tfoot>
<tr className="estimate-total-row is-strong">
<td />
<td className="estimate-total-label">Summe</td>
<td className="estimate-number">{formatKw(comparison.total.estimatedAv.kw)}</td>
<td className="estimate-number">{formatKw(comparison.total.detailed.kw)}</td>
<td className={deltaClass(comparison.total.deltaKw)}>
{formatSignedKw(comparison.total.deltaKw)}
</td>
<td className={deltaClass(comparison.total.deltaKw)}>
{formatDeltaPercent(comparison.total.deltaPercent)}
</td>
</tr>
</tfoot>
</table>
</div>
);
}

View file

@ -0,0 +1,107 @@
"use client";
import { useEffect, useRef, useState } from "react";
export type DraftParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
interface DraftInputProps<T> {
ariaLabel: string;
className?: string;
disabled?: boolean;
format: (value: T) => string;
inputMode?: "decimal" | "text";
maxLength?: number;
onCommit: (value: T) => void;
/** Reports whether the raw text is currently valid (false while invalid). */
onValidityChange?: (isValid: boolean) => void;
parse: (text: string) => DraftParseResult<T>;
placeholder?: string;
value: T;
}
/**
* Input that keeps the raw text the user typed. Only a valid text is
* committed into the document; an invalid text stays visible with a red
* border and is never saved. A value changed from outside (reload, undo)
* replaces the text unless the text already represents that value.
*/
export function DraftInput<T>({
ariaLabel,
className = "estimate-cell-input",
disabled,
format,
inputMode = "text",
maxLength,
onCommit,
onValidityChange,
parse,
placeholder,
value,
}: DraftInputProps<T>) {
const [text, setText] = useState(() => format(value));
const [error, setError] = useState<string | null>(null);
const [syncedValue, setSyncedValue] = useState(value);
if (!Object.is(syncedValue, value)) {
// Adjusting state while rendering (instead of an effect) avoids a frame
// with stale text after an external change.
setSyncedValue(value);
const parsed = parse(text);
if (!parsed.ok || !Object.is(parsed.value, value)) {
setText(format(value));
setError(null);
}
}
const validityRef = useRef(onValidityChange);
useEffect(() => {
validityRef.current = onValidityChange;
});
const isValid = error === null;
useEffect(() => {
validityRef.current?.(isValid);
}, [isValid]);
// A removed input (deleted row) must not leave an invalid marker behind.
useEffect(() => () => validityRef.current?.(true), []);
function handleChange(nextText: string) {
setText(nextText);
const parsed = parse(nextText);
if (!parsed.ok) {
setError(parsed.error);
return;
}
setError(null);
if (!Object.is(parsed.value, value)) {
setSyncedValue(parsed.value);
onCommit(parsed.value);
}
}
function handleBlur() {
// Show the canonical notation again ("0,80" → "0,8") once editing ends.
if (error === null) {
setText(format(value));
}
}
return (
<>
<input
aria-invalid={error ? true : undefined}
aria-label={ariaLabel}
className={error ? `${className} is-invalid` : className}
disabled={disabled}
inputMode={inputMode}
maxLength={maxLength}
onBlur={handleBlur}
onChange={(event) => handleChange(event.target.value)}
placeholder={placeholder}
title={error ?? undefined}
type="text"
value={text}
/>
<span className="estimate-print-value">{error ? format(value) : text}</span>
</>
);
}

View file

@ -0,0 +1,128 @@
"use client";
import { type FormEvent, useState } from "react";
import { FormModal } from "../components/form-modal";
import {
estimateDefaultGroups,
type EstimateGroupSnapshot,
} from "../../shared/estimate-balance/estimate-balance";
import { estimateTextLimits } from "./estimate-balance-document";
export interface EstimateGroupFormValues {
costGroup: string;
title: string;
editor: string | null;
}
interface EstimateGroupModalProps {
group: EstimateGroupSnapshot | null;
onClose: () => void;
onSave: (values: EstimateGroupFormValues) => void;
}
/** Creates or edits the KG number, title and "bearbeitet von" of one group. */
export function EstimateGroupModal({ group, onClose, onSave }: EstimateGroupModalProps) {
const [costGroup, setCostGroup] = useState(group?.costGroup ?? "");
const [title, setTitle] = useState(group?.title ?? "");
const [editor, setEditor] = useState(group?.editor ?? "");
function applyTemplate(index: string) {
const template = estimateDefaultGroups[Number(index)];
if (template) {
setCostGroup(template.costGroup);
setTitle(template.title);
}
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!title.trim()) {
return;
}
onSave({
costGroup: costGroup.trim(),
title: title.trim(),
editor: editor.trim() || null,
});
}
return (
<FormModal
isSaving={false}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!title.trim()}
submitLabel={group ? "Übernehmen" : "Kostengruppe hinzufügen"}
title={group ? "Kostengruppe bearbeiten" : "Kostengruppe hinzufügen"}
>
<div className="row g-3">
{!group ? (
<div className="col-12">
<label className="form-label" htmlFor="estimate-group-template">
Aus Vorlage KG 400 übernehmen
</label>
<select
className="form-select"
defaultValue=""
id="estimate-group-template"
onChange={(event) => applyTemplate(event.target.value)}
>
<option value="">– Vorlage wählen –</option>
{estimateDefaultGroups.map((template, index) => (
<option key={`${template.costGroup}-${template.title}`} value={index}>
{[template.costGroup, template.title].filter(Boolean).join(" ")}
</option>
))}
</select>
</div>
) : null}
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="estimate-group-cost-group">
KG (DIN 276)
</label>
<input
className="form-control"
id="estimate-group-cost-group"
maxLength={estimateTextLimits.costGroup}
onChange={(event) => setCostGroup(event.target.value)}
placeholder="z. B. 440"
value={costGroup}
/>
</div>
<div className="col-12 col-md-9">
<label className="form-label" htmlFor="estimate-group-title">
Titel
</label>
<input
className="form-control"
id="estimate-group-title"
maxLength={estimateTextLimits.groupTitle}
onChange={(event) => setTitle(event.target.value)}
placeholder="z. B. Starkstromanlagen"
required
value={title}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="estimate-group-editor">
Bearbeitet von
</label>
<input
className="form-control"
id="estimate-group-editor"
maxLength={estimateTextLimits.groupEditor}
onChange={(event) => setEditor(event.target.value)}
placeholder="Gewerk oder Name (optional)"
value={editor}
/>
</div>
<div className="col-12">
<p className="text-secondary small mb-0">
Ohne KG-Nummer (z. B. „Allgemein“) wird die Gruppe im Vergleich mit der
detaillierten Bilanz keiner Kostengruppe zugeordnet.
</p>
</div>
</div>
</FormModal>
);
}

View file

@ -535,3 +535,34 @@ export interface CreateCircuitDeviceRowInputDto {
}
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
// ── Grobe Leistungsbilanz (LPH 2/3) ─────────────────────────────────────
// The document shapes are shared with the API; see
// src/shared/estimate-balance/estimate-balance.ts.
import type {
EstimateBalanceSnapshot,
EstimateComparison,
} from "../shared/estimate-balance/estimate-balance";
export type EstimateBalanceDto = EstimateBalanceSnapshot;
export type EstimateComparisonDto = EstimateComparison;
export type EstimateBalanceTemplate = "kg400" | "empty" | "copy";
export interface CreateEstimateBalanceInput {
name: string;
workStage?: string | null;
dateIso?: string | null;
template: EstimateBalanceTemplate;
/** Required for template "copy". */
copyFromBalanceId?: string;
}
export interface EstimateBalanceCommandResultDto extends ProjectCommandResultDto {
balance: EstimateBalanceDto;
}
/** PUT result: either a new revision or an unchanged document. */
export type EstimateBalanceUpdateResultDto =
| EstimateBalanceCommandResultDto
| { unchanged: true; balance: EstimateBalanceDto };

View file

@ -38,6 +38,11 @@ import type {
ExternalInitialImportPlanDto,
ApplyExternalInitialImportInput,
ExternalInitialImportResultDto,
CreateEstimateBalanceInput,
EstimateBalanceCommandResultDto,
EstimateBalanceDto,
EstimateBalanceUpdateResultDto,
EstimateComparisonDto,
} from "../types";
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts";
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
@ -87,6 +92,30 @@ import type {
CircuitGroupSubtreeSnapshot,
} from "../../domain/models/circuit-group-subtree-snapshot.model";
/**
* Error of a failed API request. The message stays the server's German
* error text; status and optional error code allow callers to react to
* specific failures such as a revision conflict (409).
*/
export class ApiRequestError extends Error {
readonly status: number;
readonly code: string | null;
constructor(message: string, status: number, code: string | null = null) {
super(message);
this.name = "ApiRequestError";
this.status = status;
this.code = code;
}
}
export function isRevisionConflictError(error: unknown) {
return (
error instanceof ApiRequestError &&
(error.status === 409 || error.code === "PROJECT_REVISION_CONFLICT")
);
}
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
...init,
@ -99,15 +128,23 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
if (!response.ok) {
let details = await response.text();
let code: string | null = null;
try {
const parsed = JSON.parse(details) as { error?: unknown };
const parsed = JSON.parse(details) as { error?: unknown; code?: unknown };
if (typeof parsed.error === "string") {
details = parsed.error;
}
if (typeof parsed.code === "string") {
code = parsed.code;
}
} catch {
// Keep non-JSON error responses unchanged.
}
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
throw new ApiRequestError(
details || `Anfrage fehlgeschlagen (Status ${response.status})`,
response.status,
code
);
}
if (response.status === 204) {
@ -1135,3 +1172,66 @@ export function disconnectProjectDeviceRows(
}
);
}
// ── Grobe Leistungsbilanz (LPH 2/3) ─────────────────────────────────────
function estimateBalancesUrl(projectId: string, balanceId?: string) {
const base = `/api/projects/${projectId}/estimate-balances`;
return balanceId ? `${base}/${balanceId}` : base;
}
export function listEstimateBalances(projectId: string) {
return request<EstimateBalanceDto[]>(estimateBalancesUrl(projectId));
}
export function getEstimateBalance(projectId: string, balanceId: string) {
return request<EstimateBalanceDto>(estimateBalancesUrl(projectId, balanceId));
}
export function createEstimateBalance(
projectId: string,
input: CreateEstimateBalanceInput,
expectedRevision: number
) {
return request<EstimateBalanceCommandResultDto>(estimateBalancesUrl(projectId), {
method: "POST",
body: JSON.stringify({ ...input, expectedRevision }),
});
}
/**
* Saves the complete target document. `keepalive` lets the request outlive
* the page, e.g. for the last autosave while the tab is being closed.
*/
export function updateEstimateBalance(
projectId: string,
balance: EstimateBalanceDto,
expectedRevision: number,
options: { keepalive?: boolean } = {}
) {
return request<EstimateBalanceUpdateResultDto>(
estimateBalancesUrl(projectId, balance.id),
{
method: "PUT",
body: JSON.stringify({ expectedRevision, balance }),
...(options.keepalive ? { keepalive: true } : {}),
}
);
}
export function deleteEstimateBalance(
projectId: string,
balanceId: string,
expectedRevision: number
) {
return request<ProjectCommandResultDto>(estimateBalancesUrl(projectId, balanceId), {
method: "DELETE",
body: JSON.stringify({ expectedRevision }),
});
}
export function getEstimateComparison(projectId: string, balanceId: string) {
return request<EstimateComparisonDto>(
`${estimateBalancesUrl(projectId, balanceId)}/comparison`
);
}

View file

@ -68,6 +68,9 @@ const commandTypeLabels: Record<string, string> = {
"external-object.unassign-and-delete-created-row":
"Externes Objekt aus erzeugter Gerätezeile gelöst",
"external-object.update-row-assignment": "Externe Objektzuordnung geändert",
"estimate-balance.insert": "Grobe Leistungsbilanz angelegt",
"estimate-balance.update": "Grobe Leistungsbilanz bearbeitet",
"estimate-balance.delete": "Grobe Leistungsbilanz entfernt",
};
export function getProjectRevisionSourceLabel(

View file

@ -7,6 +7,7 @@ import { CircuitProtectionDeviceRepository } from "../../db/repositories/circuit
import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js";
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
import { BuildingRepository } from "../../db/repositories/building.repository.js";
import { EstimateBalanceRepository } from "../../db/repositories/estimate-balance.repository.js";
import { FloorRepository } from "../../db/repositories/floor.repository.js";
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
@ -28,6 +29,7 @@ export const distributionBoardComponentRepository =
export const distributionBoardRepository =
new DistributionBoardRepository(db);
export const buildingRepository = new BuildingRepository(db);
export const estimateBalanceRepository = new EstimateBalanceRepository(db);
export const floorRepository = new FloorRepository(db);
export const globalDeviceRepository = new GlobalDeviceRepository(db);
export const projectDeviceRepository = new ProjectDeviceRepository(db);

View file

@ -27,6 +27,7 @@ import { ExternalInitialImportProjectCommandRepository } from "../../db/reposito
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
import { EstimateBalanceProjectCommandRepository } from "../../db/repositories/estimate-balance-project-command.repository.js";
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
export const circuitDeviceRowProjectCommandStore =
@ -80,6 +81,8 @@ export const externalObjectNewRowProjectCommandStore =
new ExternalObjectNewRowProjectCommandRepository(db);
export const externalObjectNewCircuitProjectCommandStore =
new ExternalObjectNewCircuitProjectCommandRepository(db);
export const estimateBalanceProjectCommandStore =
new EstimateBalanceProjectCommandRepository(db);
export const projectCommandService = new ProjectCommandService(
circuitProjectCommandStore,
circuitDeviceRowProjectCommandStore,
@ -107,5 +110,6 @@ export const projectCommandService = new ProjectCommandService(
externalInitialImportProjectCommandStore,
externalObjectRowAssignmentProjectCommandStore,
externalObjectNewRowProjectCommandStore,
externalObjectNewCircuitProjectCommandStore
externalObjectNewCircuitProjectCommandStore,
estimateBalanceProjectCommandStore
);

View file

@ -0,0 +1,180 @@
import type { Request, Response } from "express";
import { ZodError } from "zod";
import {
createEstimateBalanceDeleteProjectCommand,
createEstimateBalanceInsertProjectCommand,
createEstimateBalanceSnapshot,
createEstimateBalanceUpdateProjectCommand,
parseEstimateBalanceSnapshot,
sameEstimateBalance,
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
import {
createEstimateBalanceRequestSchema,
deleteEstimateBalanceRequestSchema,
updateEstimateBalanceRequestSchema,
} from "../../estimate-balance/api/estimate-balance-request.schemas.js";
import { compareEstimateWithDetailed } from "../../shared/estimate-balance/estimate-balance.js";
import { estimateBalanceRepository } from "../composition/application-repositories.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
function params(req: Request) {
const { projectId, balanceId } = req.params;
return {
projectId: typeof projectId === "string" ? projectId : null,
balanceId: typeof balanceId === "string" ? balanceId : null,
};
}
export function listEstimateBalances(req: Request, res: Response) {
const { projectId } = params(req);
if (!projectId) {
return res.status(400).json({ error: "Ungültige Projekt-ID." });
}
return res.json(estimateBalanceRepository.listByProject(projectId));
}
export function getEstimateBalance(req: Request, res: Response) {
const { projectId, balanceId } = params(req);
if (!projectId || !balanceId) {
return res.status(400).json({ error: "Ungültige Parameter." });
}
const balance = estimateBalanceRepository.findById(projectId, balanceId);
if (!balance) {
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
}
return res.json(balance);
}
export function createEstimateBalance(req: Request, res: Response) {
const { projectId } = params(req);
if (!projectId) {
return res.status(400).json({ error: "Ungültige Projekt-ID." });
}
const parsed = createEstimateBalanceRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
let template: Parameters<typeof createEstimateBalanceSnapshot>[0]["template"] =
parsed.data.template === "copy" ? "empty" : parsed.data.template;
if (parsed.data.template === "copy") {
const source = estimateBalanceRepository.findById(
projectId,
parsed.data.copyFromBalanceId!
);
if (!source) {
return res
.status(404)
.json({ error: "Der zu kopierende Stand wurde nicht gefunden." });
}
template = { copyFrom: source };
}
try {
const balance = createEstimateBalanceSnapshot({
projectId,
name: parsed.data.name,
workStage: parsed.data.workStage ?? null,
dateIso: parsed.data.dateIso ?? null,
sortOrder: estimateBalanceRepository.nextSortOrder(projectId),
template,
});
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Grobe Leistungsbilanz anlegen",
command: createEstimateBalanceInsertProjectCommand(balance),
});
return res.status(201).json({ ...result, balance });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export function updateEstimateBalance(req: Request, res: Response) {
const { projectId, balanceId } = params(req);
if (!projectId || !balanceId) {
return res.status(400).json({ error: "Ungültige Parameter." });
}
const parsed = updateEstimateBalanceRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
let target;
try {
target = parseEstimateBalanceSnapshot(parsed.data.balance);
} catch (error) {
return res.status(400).json({
error: "Die grobe Leistungsbilanz enthält ungültige Werte.",
issues: error instanceof ZodError ? error.issues : undefined,
});
}
if (target.id !== balanceId || target.projectId !== projectId) {
return res
.status(400)
.json({ error: "Die Bilanz gehört nicht zu diesem Projekt oder dieser Adresse." });
}
const current = estimateBalanceRepository.findById(projectId, balanceId);
if (!current) {
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
}
if (sameEstimateBalance(current, target)) {
return res.json({ unchanged: true, balance: current });
}
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Grobe Leistungsbilanz bearbeiten",
command: createEstimateBalanceUpdateProjectCommand(current, target),
});
return res.json({
...result,
balance: estimateBalanceRepository.findById(projectId, balanceId),
});
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export function deleteEstimateBalance(req: Request, res: Response) {
const { projectId, balanceId } = params(req);
if (!projectId || !balanceId) {
return res.status(400).json({ error: "Ungültige Parameter." });
}
const parsed = deleteEstimateBalanceRequestSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const current = estimateBalanceRepository.findById(projectId, balanceId);
if (!current) {
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
}
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Grobe Leistungsbilanz löschen",
command: createEstimateBalanceDeleteProjectCommand(current),
});
return res.json(result);
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export function getEstimateComparison(req: Request, res: Response) {
const { projectId, balanceId } = params(req);
if (!projectId || !balanceId) {
return res.status(400).json({ error: "Ungültige Parameter." });
}
const balance = estimateBalanceRepository.findById(projectId, balanceId);
if (!balance) {
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
}
return res.json(
compareEstimateWithDetailed(
balance,
estimateBalanceRepository.listDetailedDeviceRowPower(projectId)
)
);
}

View file

@ -21,6 +21,14 @@ import {
} from "../controllers/building.controller.js";
import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js";
import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js";
import {
createEstimateBalance,
deleteEstimateBalance,
getEstimateBalance,
getEstimateComparison,
listEstimateBalances,
updateEstimateBalance,
} from "../controllers/estimate-balance.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js";
import {
@ -118,3 +126,12 @@ projectRouter.get("/:projectId/rooms", listRoomsByProject);
projectRouter.post("/:projectId/rooms", createRoom);
projectRouter.put("/:projectId/rooms/:roomId", updateRoom);
projectRouter.delete("/:projectId/rooms/:roomId", deleteRoom);
projectRouter.get("/:projectId/estimate-balances", listEstimateBalances);
projectRouter.post("/:projectId/estimate-balances", createEstimateBalance);
projectRouter.get("/:projectId/estimate-balances/:balanceId", getEstimateBalance);
projectRouter.put("/:projectId/estimate-balances/:balanceId", updateEstimateBalance);
projectRouter.delete("/:projectId/estimate-balances/:balanceId", deleteEstimateBalance);
projectRouter.get(
"/:projectId/estimate-balances/:balanceId/comparison",
getEstimateComparison
);

View file

@ -0,0 +1,316 @@
/**
* Grobe Leistungsbilanz (LPH 2/3): shared data shapes and pure calculation.
*
* Used by the API (validation, comparison) and by the frontend (live totals),
* so this file has no imports: Turbopack resolves frontend imports without
* the `.js` suffix the API build needs.
*/
export type EstimateRowUnit = "piece" | "area";
export type EstimateRowConnection = "fixed" | "socket";
export interface EstimateRowSnapshot {
id: string;
sortOrder: number;
/** Versorgung, e.g. "ELT" or "ISP". */
supply: string | null;
/** Optional circuit reference, free text. */
circuitRef: string | null;
/** Verbraucher / Fläche. May be empty while a row is being typed. */
name: string;
/** Anzahl (unit "piece") or area in m² (unit "area"). */
quantity: number;
unit: EstimateRowUnit;
connection: EstimateRowConnection | null;
/** W per piece or W/m². */
powerPerUnitW: number;
simultaneityFactor: number;
cosPhi: number;
/** Also needed on the safety power supply (SV). */
safetySupply: boolean;
remark: string | null;
}
export interface EstimateGroupSnapshot {
id: string;
/** DIN 276 cost group, e.g. "410" or "434"; empty for "Allgemein". */
costGroup: string;
title: string;
/** Trade or person responsible for the entries ("bearbeitet von"). */
editor: string | null;
sortOrder: number;
rows: EstimateRowSnapshot[];
}
export interface EstimateBalanceSnapshot {
id: string;
projectId: string;
/** Stand, e.g. "Vorplanung" or "Entwurfsplanung". */
name: string;
workStage: string | null;
/** ISO date YYYY-MM-DD. */
dateIso: string | null;
reserveAvPercent: number;
reserveSvPercent: number;
sortOrder: number;
groups: EstimateGroupSnapshot[];
}
/** Default structure of the office template "Leistungsbilanz - KG400". */
export const estimateDefaultGroups: ReadonlyArray<{ costGroup: string; title: string }> = [
{ costGroup: "410", title: "Sanitärtechnik" },
{ costGroup: "420", title: "Wärmeversorgung" },
{ costGroup: "430", title: "Lufttechnische Anlagen" },
{ costGroup: "434", title: "Kälteanlagen" },
{ costGroup: "440", title: "Starkstromanlagen" },
{ costGroup: "450", title: "Schwachstromanlagen" },
{ costGroup: "460", title: "Fördertechnik" },
{ costGroup: "470", title: "Nutzungsspezifische Anlagen" },
{ costGroup: "480", title: "Gebäudeautomation" },
{ costGroup: "", title: "Allgemein" },
];
export const estimateRowUnitLabels: Record<EstimateRowUnit, string> = {
piece: "St.",
area: "m²",
};
export const estimateRowConnectionLabels: Record<EstimateRowConnection, string> = {
fixed: "Fest",
socket: "Steckd.",
};
export interface EstimatePower {
kw: number;
kva: number;
}
export interface EstimateRowResult {
av: EstimatePower;
sv: EstimatePower;
}
export interface EstimateGroupResult {
groupId: string;
av: EstimatePower;
sv: EstimatePower;
}
export interface EstimateBalanceResult {
groups: EstimateGroupResult[];
total: { av: EstimatePower; sv: EstimatePower };
reserve: { av: EstimatePower; sv: EstimatePower };
withReserve: { av: EstimatePower; sv: EstimatePower };
/** Maximum current of the three-phase supply in A (S / (√3 · U)). */
maxCurrentA: { av: number; sv: number };
}
/**
* Rounds a calculated value for display like the spreadsheet does: binary
* floating point stores 40.175 as 40.17499…, so the scaled value is first
* cut to 6 decimals and only then rounded half away from zero
* (40.175 -> 40.18, -1.825 -> -1.83).
*/
export function roundEstimateValue(value: number, digits = 2): number {
const factor = 10 ** digits;
const scaled = Number((Math.abs(value) * factor).toFixed(6));
return (Math.sign(value) * Math.round(scaled)) / factor;
}
const zeroPower = (): EstimatePower => ({ kw: 0, kva: 0 });
function addPower(left: EstimatePower, right: EstimatePower): EstimatePower {
return { kw: left.kw + right.kw, kva: left.kva + right.kva };
}
function scalePower(power: EstimatePower, factor: number): EstimatePower {
return { kw: power.kw * factor, kva: power.kva * factor };
}
/**
* Required power of one row: P = quantity · power per unit · GLF, S = P / cos φ.
* AV covers every consumer (normal supply); SV additionally lists the rows
* that must also be supplied by the safety power supply.
*/
export function calculateEstimateRow(
row: Pick<
EstimateRowSnapshot,
"quantity" | "powerPerUnitW" | "simultaneityFactor" | "cosPhi" | "safetySupply"
>
): EstimateRowResult {
const kw = (row.quantity * row.powerPerUnitW * row.simultaneityFactor) / 1000;
const kva = row.cosPhi > 0 ? kw / row.cosPhi : 0;
const av = { kw, kva };
return { av, sv: row.safetySupply ? { ...av } : zeroPower() };
}
export function calculateEstimateBalance(
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
threePhaseVoltageV: number
): EstimateBalanceResult {
const groups = balance.groups.map((group) =>
group.rows.reduce<EstimateGroupResult>(
(sum, row) => {
const result = calculateEstimateRow(row);
return {
groupId: group.id,
av: addPower(sum.av, result.av),
sv: addPower(sum.sv, result.sv),
};
},
{ groupId: group.id, av: zeroPower(), sv: zeroPower() }
)
);
const total = groups.reduce(
(sum, group) => ({ av: addPower(sum.av, group.av), sv: addPower(sum.sv, group.sv) }),
{ av: zeroPower(), sv: zeroPower() }
);
const reserve = {
av: scalePower(total.av, balance.reserveAvPercent / 100),
sv: scalePower(total.sv, balance.reserveSvPercent / 100),
};
const withReserve = {
av: addPower(total.av, reserve.av),
sv: addPower(total.sv, reserve.sv),
};
const currentFactor =
threePhaseVoltageV > 0 ? 1000 / (Math.sqrt(3) * threePhaseVoltageV) : 0;
return {
groups,
total,
reserve,
withReserve,
maxCurrentA: {
av: withReserve.av.kva * currentFactor,
sv: withReserve.sv.kva * currentFactor,
},
};
}
/**
* Assigns a detailed cost group (e.g. "444" or "440.1") to the best matching
* estimate group: a group code with trailing zeros removed is a prefix
* ("430" -> "43", "434" -> "434"); the longest prefix wins. Returns null when
* no group matches.
*/
export function matchEstimateCostGroup(
detailedCostGroup: string | null,
groupCostGroups: readonly string[]
): string | null {
const digits = (detailedCostGroup ?? "").replace(/\D/g, "");
if (!digits) {
return null;
}
let best: { code: string; length: number } | null = null;
for (const code of groupCostGroups) {
const codeDigits = code.replace(/\D/g, "");
const prefix = codeDigits.replace(/0+$/, "");
if (!prefix || !digits.startsWith(prefix)) {
continue;
}
if (!best || prefix.length > best.length) {
best = { code, length: prefix.length };
}
}
return best?.code ?? null;
}
export interface EstimateComparisonGroup {
groupId: string;
costGroup: string;
title: string;
estimatedAv: EstimatePower;
detailed: EstimatePower;
detailedRowCount: number;
deltaKw: number;
/** Relative deviation of the detailed value from the estimate, null without estimate. */
deltaPercent: number | null;
}
export interface EstimateComparison {
groups: EstimateComparisonGroup[];
unassigned: EstimatePower & { rowCount: number };
total: {
estimatedAv: EstimatePower;
detailed: EstimatePower;
deltaKw: number;
deltaPercent: number | null;
};
}
export interface DetailedDeviceRowPower {
costGroup: string | null;
/** Total power of the device row in kW (quantity · power · device GZF). */
kw: number;
cosPhi: number | null;
}
function delta(estimated: number, detailed: number) {
return {
deltaKw: detailed - estimated,
deltaPercent: estimated > 0 ? ((detailed - estimated) / estimated) * 100 : null,
};
}
/**
* Compares the estimate per cost group with the detailed device rows of all
* circuit lists. The distribution-board simultaneity factor is not applied
* on either side; a missing cos φ counts as 1.
*/
export function compareEstimateWithDetailed(
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
detailedRows: readonly DetailedDeviceRowPower[]
): EstimateComparison {
const estimate = calculateEstimateBalance(balance, 0);
const codes = balance.groups.map((group) => group.costGroup).filter(Boolean);
const detailedByCode = new Map<string, EstimatePower & { rowCount: number }>();
const unassigned = { kw: 0, kva: 0, rowCount: 0 };
for (const row of detailedRows) {
const kva = row.cosPhi && row.cosPhi > 0 ? row.kw / row.cosPhi : row.kw;
const code = matchEstimateCostGroup(row.costGroup, codes);
const target =
code === null
? unassigned
: detailedByCode.get(code) ??
detailedByCode.set(code, { kw: 0, kva: 0, rowCount: 0 }).get(code)!;
target.kw += row.kw;
target.kva += kva;
target.rowCount += 1;
}
// A cost group code can appear in several groups; the detailed value is
// shown once, on the first group with that code.
const shownCodes = new Set<string>();
const groups = balance.groups.map((group, index) => {
const estimatedAv = estimate.groups[index]!.av;
const detailedEntry =
group.costGroup && !shownCodes.has(group.costGroup)
? detailedByCode.get(group.costGroup)
: undefined;
if (group.costGroup) {
shownCodes.add(group.costGroup);
}
const detailed = { kw: detailedEntry?.kw ?? 0, kva: detailedEntry?.kva ?? 0 };
return {
groupId: group.id,
costGroup: group.costGroup,
title: group.title,
estimatedAv,
detailed,
detailedRowCount: detailedEntry?.rowCount ?? 0,
...delta(estimatedAv.kw, detailed.kw),
};
});
const detailedTotal = groups.reduce(
(sum, group) => addPower(sum, group.detailed),
{ kw: unassigned.kw, kva: unassigned.kva }
);
return {
groups,
unassigned,
total: {
estimatedAv: estimate.total.av,
detailed: detailedTotal,
...delta(estimate.total.av.kw, detailedTotal.kw),
},
};
}