Integrate rough estimate balance

This commit is contained in:
Julian Appel 2026-09-27 12:37:59 +02:00
commit 573487666c
45 changed files with 9378 additions and 58 deletions

View file

@ -2002,3 +2002,710 @@ 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-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;
}
/* Ausrichtung: alle Zellen zentriert, nur „Verbraucher / Flächen“, die
Gruppentitel und die Summenbeschriftungen stehen links bzw. rechts. */
.estimate-sheet td,
.estimate-sheet td.estimate-number,
.estimate-sheet .estimate-cell-input,
.estimate-sheet .estimate-cell-input.is-numeric {
text-align: center;
}
.estimate-sheet .estimate-cell-select {
text-align: center;
text-align-last: center;
}
.estimate-sheet .estimate-unit-input,
.estimate-sheet .estimate-gzf-input {
justify-content: center;
}
.estimate-sheet td.estimate-name-cell,
.estimate-sheet td.estimate-name-cell .estimate-cell-input,
.estimate-sheet .estimate-group-title-row td:not(.estimate-cost-group-cell),
.estimate-comparison td:nth-child(2) {
text-align: left;
}
.estimate-sheet td.estimate-total-label {
text-align: right;
}
/* Druck im kbp-CD der Seitenansicht (src/frontend/print/print-document.css):
gleiche Farben, Schrift, Titelblock und Fußzeile. Die Fußzeile mit
Seitenzahlen setzt EstimatePrintHeader als @page-Regel mit Projektdaten. */
.estimate-print-titleblock {
display: none;
}
@page estimate-balance {
size: A4 landscape;
margin: 10mm 10mm 13mm;
}
@media print {
body:has(.estimate-print-root) .sidebar,
.estimate-print-root .estimate-no-print,
.estimate-print-root .estimate-page-header,
.estimate-print-root .estimate-header-fields,
.estimate-print-root .alert,
.estimate-print-root input,
.estimate-print-root select,
.estimate-print-root button {
display: none !important;
}
html:has(.estimate-print-root),
body:has(.estimate-print-root),
body:has(.estimate-print-root) .app-shell,
body:has(.estimate-print-root) .app-shell-content {
background: #fff !important;
}
/* Der Dunkelmodus setzt color-scheme: dark; Chrome färbt damit auch den
Seitenrand dunkel. Im Druck gilt immer das helle Schema. */
html:has(.estimate-print-root) {
color-scheme: light !important;
}
.estimate-print-root .estimate-group-editor.is-empty {
display: none;
}
/* Verbraucher bekommt den Platz; Anmerkungen bleiben schmal. */
.estimate-sheet .estimate-col-name {
width: 52mm;
}
.estimate-sheet .estimate-col-remark {
width: 24mm;
}
.estimate-sheet .estimate-col-supply {
width: 13mm;
}
body:has(.estimate-print-root) .app-shell {
display: block;
}
.estimate-print-root {
--ep-blue: #476088;
--ep-lilac: #9198c4;
--ep-line: #d6dae5;
--ep-tint: #f5f6fa;
--ep-text: #2b2b2b;
--ep-muted: #6b7280;
--ep-accent: #c9782a;
page: estimate-balance;
padding: 0 !important;
max-width: none;
color: var(--ep-text);
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
color-scheme: light;
}
.estimate-print-root * {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.estimate-print-root .estimate-print-value {
display: inline;
}
/* Titelblock wie der Plankopf der Seitenansicht */
.estimate-print-titleblock {
display: grid;
grid-template-columns: 30mm 1fr auto auto;
margin-bottom: 4mm;
border: 1.5px solid var(--ep-blue);
background: #fff;
font-size: 8.5pt;
break-inside: avoid;
}
.estimate-print-titleblock > * {
padding: 2.5mm 3.5mm;
border-left: 1px solid var(--ep-line);
}
.estimate-print-titleblock > :first-child {
border-left: none;
}
.estimate-print-logo {
display: flex;
align-items: center;
justify-content: center;
min-height: 16mm;
color: var(--ep-blue);
font-weight: bold;
text-align: center;
}
.estimate-print-logo img {
max-width: 100%;
max-height: 10mm;
}
.estimate-print-kicker {
color: var(--ep-muted);
font-size: 7pt;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.estimate-print-heading {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 1mm 4mm;
margin-top: 1mm;
}
.estimate-print-stand {
color: var(--ep-blue);
font-size: 16pt;
font-weight: bold;
}
.estimate-print-project {
font-size: 12pt;
font-weight: bold;
}
.estimate-print-meta {
display: grid;
grid-template-columns: auto auto;
gap: 0.8mm 4mm;
align-content: center;
margin: 0;
font-size: 7.5pt;
}
.estimate-print-meta div {
display: contents;
}
.estimate-print-meta dt {
color: var(--ep-muted);
font-weight: normal;
}
.estimate-print-meta dd {
margin: 0;
font-weight: bold;
}
.estimate-print-meta.is-tinted {
background: var(--ep-tint);
}
/* Karten und Ränder der Bildschirmansicht entfallen */
.estimate-print-root .card {
border: 0;
box-shadow: none !important;
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-print-root .estimate-comparison-card .card-header {
margin-bottom: 2mm;
color: var(--ep-blue);
font-size: 10pt;
font-weight: bold;
}
.estimate-sheet-wrap {
overflow: visible;
}
/* Tabelle */
.estimate-sheet {
min-width: 0;
table-layout: auto;
font-size: 7.4pt;
line-height: 1.2;
background: #fff;
color: var(--ep-text);
}
.estimate-sheet th,
.estimate-sheet td {
border: 1px solid var(--ep-line) !important;
padding: 0.7mm 1.5mm;
background: #fff;
color: var(--ep-text);
}
.estimate-sheet thead {
display: table-header-group;
}
.estimate-sheet thead th {
background: var(--ep-blue) !important;
border-color: var(--ep-blue) !important;
color: #fff !important;
font-weight: bold;
vertical-align: bottom;
}
.estimate-sheet thead .estimate-th-hint {
color: #dfe3f0 !important;
}
.estimate-group-title-row td {
background: var(--ep-tint) !important;
border-top: 1.5px solid var(--ep-lilac) !important;
font-weight: bold;
}
.estimate-group-title-row .estimate-cost-group-cell {
color: var(--ep-blue) !important;
}
.estimate-group-title-row .estimate-group-editor {
color: var(--ep-muted);
font-weight: normal;
}
.estimate-subtotal-row td {
border-bottom: 1.5px solid var(--ep-line) !important;
font-weight: bold;
}
.estimate-subtotal-row .estimate-total-label {
color: var(--ep-muted);
}
.estimate-total-row td {
background: var(--ep-tint) !important;
font-weight: bold;
}
.estimate-total-row.is-strong td {
background: var(--ep-blue) !important;
border-color: var(--ep-blue) !important;
color: #fff !important;
}
.estimate-total-row .estimate-total-hint {
color: var(--ep-muted);
font-weight: normal;
}
.estimate-total-row.is-strong .estimate-total-hint {
color: #dfe3f0;
}
.estimate-comparison .is-over {
color: var(--ep-accent) !important;
font-weight: bold;
}
.estimate-comparison .is-under {
color: var(--ep-blue) !important;
font-weight: bold;
}
.estimate-group-title-row,
.estimate-subtotal-row,
.estimate-total-row,
.estimate-row {
break-inside: avoid;
}
.estimate-group-title-row {
break-after: avoid;
}
/* Summen einmal am Ende, nicht auf jeder Seite */
.estimate-sheet tfoot {
display: table-row-group;
break-inside: avoid;
}
.estimate-col-actions {
width: 0;
}
.estimate-unit-input,
.estimate-gzf-input {
justify-content: center;
}
}

View file

@ -0,0 +1,745 @@
"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 { EstimatePrintHeader } from "../../../../../frontend/estimate-balance/estimate-print-header";
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">
{project && draft ? <EstimatePrintHeader balance={draft} project={project} /> : null}
<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,42 @@
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,
`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": 1790438092269,
"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

@ -3,6 +3,8 @@ import { ProjectStateConflictError } from "../../domain/errors/project-state-con
import {
assertProjectStateRestoreCommand,
createProjectStateRestoreCommand,
createProjectTransferRestoreCommand,
projectStateRestoreCommandSchemaVersion,
} from "../../domain/models/project-state-restore-command.model.js";
import {
parseProjectStateSnapshot,
@ -13,6 +15,8 @@ import type {
ProjectStateRestoreCommandStore,
} from "../../domain/ports/project-state-restore-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { canonicalEstimateBalance } from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
@ -32,6 +36,11 @@ import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { estimateBalances } from "../schema/estimate-balances.js";
import {
insertEstimateBalance,
readEstimateBalances,
} from "./estimate-balance.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import {
readProjectStateSnapshot,
@ -66,10 +75,39 @@ export class ProjectStateRestoreCommandRepository
if (!current) {
throw new Error("Project not found.");
}
const currentEstimateBalances = readEstimateBalances(tx, input.projectId);
const transferCommand =
input.command.schemaVersion === projectStateRestoreCommandSchemaVersion
? input.command
: null;
if (
current.payloadSha256 !==
input.command.payload.expectedStateSha256 &&
!matchesHistoryRestoreTarget(tx, input, current.state)
!matchesHistoryRestoreTarget(
tx,
input,
current.state,
currentEstimateBalances
)
) {
throw new ProjectStateConflictError(
input.projectId,
input.command.payload.expectedStateSha256,
current.payloadSha256
);
}
if (
transferCommand &&
canonicalEstimateBalances(currentEstimateBalances) !==
canonicalEstimateBalances(
transferCommand.payload.expectedEstimateBalances
) &&
!matchesHistoryRestoreTarget(
tx,
input,
current.state,
currentEstimateBalances
)
) {
throw new ProjectStateConflictError(
input.projectId,
@ -82,14 +120,28 @@ export class ProjectStateRestoreCommandRepository
input.command.payload.targetState
);
replaceProjectState(tx, targetState);
if (transferCommand) {
replaceEstimateBalances(
tx,
input.projectId,
transferCommand.payload.targetEstimateBalances
);
}
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
if (!persistedTarget) {
throw new Error("Restored project could not be loaded.");
}
const inverse = createProjectStateRestoreCommand(
persistedTarget.payloadSha256,
current.state
);
const inverse = transferCommand
? createProjectTransferRestoreCommand(
persistedTarget.payloadSha256,
current.state,
transferCommand.payload.targetEstimateBalances,
currentEstimateBalances
)
: createProjectStateRestoreCommand(
persistedTarget.payloadSha256,
current.state
);
return inverse;
}
@ -98,7 +150,8 @@ export class ProjectStateRestoreCommandRepository
function matchesHistoryRestoreTarget(
database: AppDatabase,
input: ExecuteProjectStateRestoreCommandInput,
currentState: ProjectStateSnapshot
currentState: ProjectStateSnapshot,
currentEstimateBalances: EstimateBalanceSnapshot[]
) {
if (
(input.source !== "undo" && input.source !== "redo") ||
@ -122,25 +175,45 @@ function matchesHistoryRestoreTarget(
input.source === "undo"
? changeSet.forwardPayloadJson
: changeSet.inversePayloadJson;
const command = JSON.parse(serialized) as {
type?: unknown;
payload?: { targetState?: unknown };
};
if (command.type !== "project.restore-state") {
const command = JSON.parse(serialized) as unknown;
assertProjectStateRestoreCommand(command);
const expectedState = parseProjectStateSnapshot(command.payload.targetState);
const stateMatches =
canonicalProjectState(expectedState) ===
canonicalProjectState(currentState);
if (!stateMatches) {
return false;
}
const expectedState = parseProjectStateSnapshot(
command.payload?.targetState
);
return (
canonicalProjectState(expectedState) ===
canonicalProjectState(currentState)
);
return command.schemaVersion !== projectStateRestoreCommandSchemaVersion ||
canonicalEstimateBalances(command.payload.targetEstimateBalances) ===
canonicalEstimateBalances(currentEstimateBalances);
} catch {
return false;
}
}
function canonicalEstimateBalances(balances: EstimateBalanceSnapshot[]) {
return JSON.stringify(
[...balances]
.sort((left, right) => left.id.localeCompare(right.id))
.map((balance) => canonicalEstimateBalance(balance))
);
}
function replaceEstimateBalances(
database: AppDatabase,
projectId: string,
balances: EstimateBalanceSnapshot[]
) {
database
.delete(estimateBalances)
.where(eq(estimateBalances.projectId, projectId))
.run();
for (const balance of balances) {
insertEstimateBalance(database, balance);
}
}
function canonicalProjectState(state: ProjectStateSnapshot) {
return JSON.stringify({
...state,

View file

@ -3,9 +3,11 @@ import { eq } from "drizzle-orm";
import {
createProjectTransferEnvelope,
parseProjectTransferEnvelope,
remapEstimateBalances,
remapProjectState,
serializeProjectTransferPayload,
} from "../../domain/models/project-transfer.model.js";
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import { createProjectTransferRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
@ -26,6 +28,10 @@ import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import {
insertEstimateBalance,
readEstimateBalances,
} from "./estimate-balance.persistence.js";
import {
hashProjectStatePayload,
readProjectStateSnapshot,
@ -40,10 +46,15 @@ export class ProjectTransferRepository implements ProjectTransferStore {
if (!snapshot) {
return null;
}
const estimateBalances = readEstimateBalances(this.database, projectId);
const payloadSha256 = hashProjectStatePayload(
serializeProjectTransferPayload(snapshot.state, estimateBalances)
);
return createProjectTransferEnvelope(
new Date().toISOString(),
snapshot.payloadSha256,
snapshot.state
payloadSha256,
snapshot.state,
estimateBalances
);
}
@ -61,10 +72,21 @@ export class ProjectTransferRepository implements ProjectTransferStore {
projectId,
() => crypto.randomUUID()
);
const currentEstimateBalances = readEstimateBalances(this.database, projectId);
const targetEstimateBalances =
transfer.projectState.project.id === projectId
? transfer.estimateBalances
: remapEstimateBalances(
transfer.estimateBalances,
projectId,
() => crypto.randomUUID()
);
return {
command: createProjectStateRestoreCommand(
command: createProjectTransferRestoreCommand(
current.payloadSha256,
targetState
targetState,
currentEstimateBalances,
targetEstimateBalances
),
};
}
@ -78,20 +100,39 @@ export class ProjectTransferRepository implements ProjectTransferStore {
() => crypto.randomUUID(),
`${transfer.projectState.project.name} (Kopie)`
);
this.database.transaction((tx) => insertProjectState(tx, state));
const estimateBalances = remapEstimateBalances(
transfer.estimateBalances,
projectId,
() => crypto.randomUUID()
);
this.database.transaction((tx) => {
insertProjectState(tx, state);
for (const balance of estimateBalances) {
insertEstimateBalance(tx, balance);
}
});
return { projectId, name: state.project.name };
}
private parseVerified(value: unknown) {
const rawPayloadSha256 =
isPlainObject(value) &&
isPlainObject(value.projectState)
? hashProjectStatePayload(JSON.stringify(value.projectState))
: null;
const isLegacy = isPlainObject(value) && value.formatVersion === 1;
const rawPayloadSha256 = isPlainObject(value) && isPlainObject(value.projectState)
? hashProjectStatePayload(
isLegacy
? JSON.stringify(value.projectState)
: JSON.stringify({
projectState: value.projectState,
estimateBalances: value.estimateBalances,
})
)
: null;
const transfer = parseProjectTransferEnvelope(value);
const payloadJson = serializeProjectStateSnapshot(
transfer.projectState
);
const payloadJson = isLegacy
? serializeProjectStateSnapshot(transfer.projectState)
: serializeProjectTransferPayload(
transfer.projectState,
transfer.estimateBalances
);
if (
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
rawPayloadSha256 !== transfer.payloadSha256

View file

@ -0,0 +1,60 @@
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"),
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

@ -3,32 +3,80 @@ import {
type ProjectStateSnapshot,
} from "./project-state-snapshot.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import {
parseEstimateBalanceSnapshot,
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
export const projectStateRestoreCommandType = "project.restore-state";
export const projectStateRestoreCommandSchemaVersion = 1 as const;
export const projectStateRestoreCommandSchemaVersion = 2 as const;
const snapshotRestoreCommandSchemaVersion = 1 as const;
export interface ProjectStateRestoreCommandPayload {
expectedStateSha256: string;
targetState: ProjectStateSnapshot;
}
export type ProjectStateRestoreCommand = SerializedProjectCommand<
export type SnapshotProjectStateRestoreCommand = SerializedProjectCommand<
ProjectStateRestoreCommandPayload
> & {
schemaVersion: typeof snapshotRestoreCommandSchemaVersion;
type: typeof projectStateRestoreCommandType;
};
export interface ProjectTransferRestoreCommandPayload
extends ProjectStateRestoreCommandPayload {
expectedEstimateBalances: EstimateBalanceSnapshot[];
targetEstimateBalances: EstimateBalanceSnapshot[];
}
export type ProjectTransferRestoreCommand = SerializedProjectCommand<
ProjectTransferRestoreCommandPayload
> & {
schemaVersion: typeof projectStateRestoreCommandSchemaVersion;
type: typeof projectStateRestoreCommandType;
};
export type ProjectStateRestoreCommand =
| SnapshotProjectStateRestoreCommand
| ProjectTransferRestoreCommand;
export function createProjectStateRestoreCommand(
expectedStateSha256: string,
targetState: ProjectStateSnapshot
): ProjectStateRestoreCommand {
const command: ProjectStateRestoreCommand = {
const command: SnapshotProjectStateRestoreCommand = {
schemaVersion: snapshotRestoreCommandSchemaVersion,
type: projectStateRestoreCommandType,
payload: {
expectedStateSha256,
targetState: parseProjectStateSnapshot(targetState),
},
};
assertProjectStateRestoreCommand(command);
return command;
}
export function createProjectTransferRestoreCommand(
expectedStateSha256: string,
targetState: ProjectStateSnapshot,
expectedEstimateBalances: EstimateBalanceSnapshot[],
targetEstimateBalances: EstimateBalanceSnapshot[]
): ProjectTransferRestoreCommand {
const command: ProjectTransferRestoreCommand = {
schemaVersion: projectStateRestoreCommandSchemaVersion,
type: projectStateRestoreCommandType,
payload: {
expectedStateSha256,
targetState: parseProjectStateSnapshot(targetState),
expectedEstimateBalances: parseEstimateBalances(
expectedEstimateBalances,
targetState.project.id
),
targetEstimateBalances: parseEstimateBalances(
targetEstimateBalances,
targetState.project.id
),
},
};
assertProjectStateRestoreCommand(command);
@ -42,10 +90,7 @@ export function assertProjectStateRestoreCommand(
throw new Error("Project state restore command must be an object.");
}
const command = value as Partial<ProjectStateRestoreCommand>;
if (
command.type !== projectStateRestoreCommandType ||
command.schemaVersion !== projectStateRestoreCommandSchemaVersion
) {
if (command.type !== projectStateRestoreCommandType) {
throw new Error("Unsupported project state restore command.");
}
if (!command.payload || typeof command.payload !== "object") {
@ -58,5 +103,43 @@ export function assertProjectStateRestoreCommand(
) {
throw new Error("Expected project state checksum is invalid.");
}
parseProjectStateSnapshot(payload.targetState);
const targetState = parseProjectStateSnapshot(payload.targetState);
if (command.schemaVersion === snapshotRestoreCommandSchemaVersion) {
if (Object.keys(payload).length !== 2) {
throw new Error("Snapshot restore payload contains unknown fields.");
}
return;
}
if (command.schemaVersion !== projectStateRestoreCommandSchemaVersion) {
throw new Error("Unsupported project state restore command.");
}
const transferPayload = payload as Partial<ProjectTransferRestoreCommandPayload>;
parseEstimateBalances(
transferPayload.expectedEstimateBalances,
targetState.project.id
);
parseEstimateBalances(
transferPayload.targetEstimateBalances,
targetState.project.id
);
if (Object.keys(payload).length !== 4) {
throw new Error("Project transfer restore payload contains unknown fields.");
}
}
function parseEstimateBalances(
value: unknown,
projectId: string
): EstimateBalanceSnapshot[] {
if (!Array.isArray(value)) {
throw new Error("Project transfer estimate balances must be an array.");
}
const balances = value.map(parseEstimateBalanceSnapshot);
if (balances.some((balance) => balance.projectId !== projectId)) {
throw new Error("Project transfer estimate balance belongs to another project.");
}
if (new Set(balances.map((balance) => balance.id)).size !== balances.length) {
throw new Error("Project transfer contains duplicate estimate balances.");
}
return balances;
}

View file

@ -3,9 +3,12 @@ import {
projectStateSnapshotSchemaVersion,
type ProjectStateSnapshot,
} from "./project-state-snapshot.model.js";
import { parseEstimateBalanceSnapshot } from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
export const projectTransferFormat = "leistungsbilanz.project" as const;
export const projectTransferFormatVersion = 1 as const;
export const projectTransferFormatVersion = 2 as const;
const legacyProjectTransferFormatVersion = 1 as const;
export interface ProjectTransferEnvelope {
format: typeof projectTransferFormat;
@ -13,12 +16,14 @@ export interface ProjectTransferEnvelope {
exportedAtIso: string;
payloadSha256: string;
projectState: ProjectStateSnapshot;
estimateBalances: EstimateBalanceSnapshot[];
}
export function createProjectTransferEnvelope(
exportedAtIso: string,
payloadSha256: string,
projectState: ProjectStateSnapshot
projectState: ProjectStateSnapshot,
estimateBalances: EstimateBalanceSnapshot[]
): ProjectTransferEnvelope {
return parseProjectTransferEnvelope({
format: projectTransferFormat,
@ -26,6 +31,7 @@ export function createProjectTransferEnvelope(
exportedAtIso,
payloadSha256,
projectState,
estimateBalances,
});
}
@ -37,7 +43,8 @@ export function parseProjectTransferEnvelope(
}
if (
value.format !== projectTransferFormat ||
value.formatVersion !== projectTransferFormatVersion
(value.formatVersion !== legacyProjectTransferFormatVersion &&
value.formatVersion !== projectTransferFormatVersion)
) {
throw new Error("Project transfer format is not supported.");
}
@ -57,7 +64,13 @@ export function parseProjectTransferEnvelope(
if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) {
throw new Error("Project transfer snapshot version is not supported.");
}
if (Object.keys(value).length !== 5) {
const estimateBalances =
value.formatVersion === legacyProjectTransferFormatVersion
? []
: parseTransferEstimateBalances(value.estimateBalances, projectState.project.id);
const expectedFieldCount =
value.formatVersion === legacyProjectTransferFormatVersion ? 5 : 6;
if (Object.keys(value).length !== expectedFieldCount) {
throw new Error("Project transfer file contains unknown fields.");
}
return {
@ -66,9 +79,43 @@ export function parseProjectTransferEnvelope(
exportedAtIso: value.exportedAtIso,
payloadSha256: value.payloadSha256,
projectState,
estimateBalances,
};
}
export function serializeProjectTransferPayload(
projectState: ProjectStateSnapshot,
estimateBalances: EstimateBalanceSnapshot[]
) {
const parsedState = parseProjectStateSnapshot(projectState);
return JSON.stringify({
projectState: parsedState,
estimateBalances: parseTransferEstimateBalances(
estimateBalances,
parsedState.project.id
),
});
}
export function remapEstimateBalances(
source: EstimateBalanceSnapshot[],
targetProjectId: string,
createId: () => string
): EstimateBalanceSnapshot[] {
return source.map((balance) =>
parseEstimateBalanceSnapshot({
...balance,
id: createId(),
projectId: targetProjectId,
groups: balance.groups.map((group) => ({
...group,
id: createId(),
rows: group.rows.map((row) => ({ ...row, id: createId() })),
})),
})
);
}
export function remapProjectState(
source: ProjectStateSnapshot,
targetProjectId: string,
@ -282,6 +329,37 @@ function requiredId(ids: ReadonlyMap<string, string>, sourceId: string) {
return targetId;
}
function parseTransferEstimateBalances(
value: unknown,
projectId: string
): EstimateBalanceSnapshot[] {
if (!Array.isArray(value)) {
throw new Error("Project transfer estimate balances must be an array.");
}
const balances = value.map(parseEstimateBalanceSnapshot);
if (balances.some((balance) => balance.projectId !== projectId)) {
throw new Error("Project transfer estimate balance belongs to another project.");
}
assertUniqueIds(balances.map((balance) => balance.id), "estimate balance");
assertUniqueIds(
balances.flatMap((balance) => balance.groups.map((group) => group.id)),
"estimate group"
);
assertUniqueIds(
balances.flatMap((balance) =>
balance.groups.flatMap((group) => group.rows.map((row) => row.id))
),
"estimate row"
);
return balances;
}
function assertUniqueIds(ids: string[], label: string) {
if (new Set(ids).size !== ids.length) {
throw new Error(`Project transfer contains a duplicate ${label} id.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

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

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,669 @@
"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,
formatGermanNumber,
} 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 = 9;
const COLUMN_COUNT = 16;
/** Suggested supply values; free text stays possible. */
const SUPPLY_SUGGESTIONS = ["ELT", "MSR"];
const SUPPLY_LIST_ID = "estimate-supply-options";
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"
printFormat={(current: number) => formatGermanNumber(current, 0, 3)}
onCommit={onCommit}
onValidityChange={(isValid) => onValidityChange(fieldKey, isValid)}
parse={(text) => parseGermanDecimal(text, range)}
value={value}
/>
);
}
function supplyOptions(balance: EstimateBalanceSnapshot): string[] {
const used = balance.groups.flatMap((group) =>
group.rows.map((row) => row.supply?.trim() ?? "")
);
return [...new Set([...SUPPLY_SUGGESTIONS, ...used.filter(Boolean)])];
}
function TextInput({
ariaLabel,
disabled,
list,
maxLength,
onChange,
placeholder,
value,
}: {
ariaLabel: string;
disabled: boolean;
list?: string;
maxLength: number;
onChange: (value: string) => void;
placeholder?: string;
value: string | null;
}) {
return (
<>
<input
aria-label={ariaLabel}
className="estimate-cell-input"
disabled={disabled}
list={list}
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}
list={SUPPLY_LIST_ID}
maxLength={estimateTextLimits.supply}
onChange={(supply) => patch({ supply })}
placeholder="ELT"
value={row.supply}
/>
</td>
<td className="estimate-name-cell">
<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">
<datalist id={SUPPLY_LIST_ID}>
{supplyOptions(balance).map((supply) => (
<option key={supply} value={supply} />
))}
</datalist>
<table className="estimate-sheet">
<colgroup>
<col className="estimate-col-kg" />
<col className="estimate-col-supply" />
<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/MSR …</span>
</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={
group.editor?.trim()
? "estimate-group-editor"
: "estimate-group-editor is-empty"
}
>
<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,112 @@
"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;
/** Printed text; defaults to the input text. */
printFormat?: (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,
printFormat,
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">
{printFormat ? printFormat(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

@ -0,0 +1,104 @@
"use client";
import { useEffect, useState } from "react";
import { documentLogoUrl, getDocumentBranding } from "../utils/api";
import type { EstimateBalanceDto, OfficeProfileDto, ProjectDto } from "../types";
import { formatIsoDateGerman } from "./estimate-balance-format";
interface EstimatePrintHeaderProps {
project: ProjectDto;
balance: EstimateBalanceDto;
}
function cssString(value: string) {
return JSON.stringify(value.replace(/[<>\r\n]/g, " "));
}
/**
* Print-only title block and page footer of the rough power balance in the
* kbp corporate design of the print view (Seitenansicht): logo, blue title,
* meta data, "Seite x von y". Hidden on screen.
*/
export function EstimatePrintHeader({ project, balance }: EstimatePrintHeaderProps) {
const [office, setOffice] = useState<OfficeProfileDto | null>(null);
const [hasLogo, setHasLogo] = useState(false);
const [logoVersion, setLogoVersion] = useState("");
useEffect(() => {
getDocumentBranding()
.then((branding) => {
setOffice(branding.office);
setHasLogo(Boolean(branding.logo));
setLogoVersion(branding.logo ? branding.logo.updatedAtIso : "");
})
.catch(() => {
setOffice(null);
setHasLogo(false);
});
}, []);
const stand = [balance.name, balance.workStage].filter(Boolean).join(" · ");
const date = balance.dateIso ? formatIsoDateGerman(balance.dateIso) : null;
const footer = `
@page estimate-balance {
size: A4 landscape;
margin: 10mm 10mm 13mm;
@bottom-left { content: ${cssString(`${project.name} · Grobe Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
@bottom-center { content: ${cssString(`Stand ${stand}${date ? ` · ${date}` : ""}`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 7.5pt Arial, sans-serif; color: #6b7280; }
}`;
return (
<>
<style>{footer}</style>
<header className="estimate-print-titleblock" aria-hidden="true">
<div className="estimate-print-logo">
{hasLogo ? (
<img alt="" src={`${documentLogoUrl}?v=${encodeURIComponent(logoVersion)}`} />
) : (
<span>{office?.officeName ?? ""}</span>
)}
</div>
<div className="estimate-print-title">
<div className="estimate-print-kicker">Grobe Leistungsbilanz · KG 400</div>
<div className="estimate-print-heading">
<span className="estimate-print-stand">{balance.name}</span>
<span className="estimate-print-project">{project.name}</span>
</div>
</div>
<dl className="estimate-print-meta">
<div>
<dt>Projekt-Nr.</dt>
<dd>{project.internalProjectNumber ?? "–"}</dd>
</div>
<div>
<dt>Bauherr</dt>
<dd>{project.buildingOwner ?? "–"}</dd>
</div>
<div>
<dt>Anschrift</dt>
<dd>{project.siteAddress?.split("\n").join(", ") ?? "–"}</dd>
</div>
</dl>
<dl className="estimate-print-meta is-tinted">
<div>
<dt>Leistungsphase</dt>
<dd>{balance.workStage ?? "–"}</dd>
</div>
<div>
<dt>Datum</dt>
<dd>{date ?? "–"}</dd>
</div>
<div>
<dt>Bearbeitung</dt>
<dd>{project.projectEditor ?? "–"}</dd>
</div>
<div>
<dt>Büro</dt>
<dd>{office?.officeName ?? "–"}</dd>
</div>
</dl>
</header>
</>
);
}

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,314 @@
/**
* 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 "MSR" (free text). */
supply: 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),
},
};
}