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