Add the rough power balance module for early design phases
In LPH 2/3 there are no circuits yet, but the office needs a power balance by DIN 276 cost group, as in the Excel template "Leistungsbilanz - KG400": each trade enters consumers by piece or by area (W/m²) with GLF and cos phi; the balance sums AV and SV per cost group, adds a reserve and derives the maximum current. The module is separate from the circuit structure: own tables (migration 0010), own folder src/estimate-balance and an import-free shared calculation used by API and UI. Writes go through project commands (estimate-balance.insert/.update/.delete with complete documents), so revision checks and Undo/Redo apply; it is not part of project snapshots or transfer yet. A comparison endpoint sums the device rows of all circuit lists by cost group (longest prefix) against the estimate. The UI adds a sidebar entry, a list of Stände (template KG 400, empty or copy) and a spreadsheet-like editor with autosave, AMEV picker for the GLF, totals, reserve, maximum current, comparison table and print layout. The tests reproduce the template example exactly (143.24 kW, 148.48 kVA, 157.56 kW / 163.33 kVA with 10 % reserve, 235.75 A). See docs/estimate-balance.md and issue #7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
80dbc73230
commit
37a70b6b36
37 changed files with 8537 additions and 4 deletions
|
|
@ -1602,3 +1602,493 @@ button.structure-entry-main:focus-visible {
|
|||
color: var(--color-ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ── Grobe Leistungsbilanz ──────────────────────────────────────────── */
|
||||
/* Tabelle im Stil der Büro-Vorlage „Leistungsbilanz – KG400“: kompakte
|
||||
Zellen-Inputs statt Bootstrap-Formularfelder, Druck als A4 quer. */
|
||||
|
||||
.estimate-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.estimate-save-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.estimate-save-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-ink-soft);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.estimate-save-badge.is-saved {
|
||||
color: var(--color-signal-dark);
|
||||
border-color: var(--color-signal);
|
||||
}
|
||||
|
||||
.estimate-save-badge.is-busy {
|
||||
color: var(--color-warn);
|
||||
border-color: var(--color-warn);
|
||||
}
|
||||
|
||||
.estimate-save-badge.is-error {
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger-bg);
|
||||
}
|
||||
|
||||
.estimate-save-hint {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.estimate-header-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm) var(--space-lg);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.estimate-header-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.estimate-header-field.is-wide {
|
||||
flex: 1 1 18rem;
|
||||
}
|
||||
|
||||
.estimate-header-label {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink-soft);
|
||||
}
|
||||
|
||||
.estimate-header-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.estimate-header-input {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
border-color: var(--panel-border);
|
||||
}
|
||||
|
||||
.estimate-sheet-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.estimate-sheet {
|
||||
width: 100%;
|
||||
min-width: 1400px;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
background: var(--panel-bg);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.estimate-comparison {
|
||||
min-width: 640px;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.estimate-sheet th,
|
||||
.estimate-sheet td {
|
||||
border: 1px solid var(--panel-border);
|
||||
padding: 2px 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.estimate-sheet thead th {
|
||||
background: var(--surface-header-row);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.estimate-th-hint {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.estimate-col-kg { width: 3rem; }
|
||||
.estimate-col-supply { width: 4rem; }
|
||||
.estimate-col-circuit { width: 4.6rem; }
|
||||
.estimate-col-name { width: auto; }
|
||||
.estimate-col-quantity { width: 4.8rem; }
|
||||
.estimate-col-unit { width: 3.8rem; }
|
||||
.estimate-col-connection { width: 5rem; }
|
||||
.estimate-col-power { width: 6.4rem; }
|
||||
.estimate-col-gzf { width: 6.8rem; }
|
||||
.estimate-col-cosphi { width: 3.8rem; }
|
||||
.estimate-col-result { width: 4.6rem; }
|
||||
.estimate-col-sv { width: 3.2rem; }
|
||||
.estimate-col-remark { width: 9rem; }
|
||||
.estimate-col-actions { width: 2.4rem; }
|
||||
|
||||
.estimate-cell-input,
|
||||
.estimate-cell-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 1px 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.estimate-cell-select {
|
||||
padding-right: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.estimate-cell-input:hover:not(:disabled),
|
||||
.estimate-cell-select:hover:not(:disabled) {
|
||||
border-color: var(--panel-border-strong);
|
||||
}
|
||||
|
||||
.estimate-cell-input:focus,
|
||||
.estimate-cell-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue);
|
||||
background: var(--panel-bg);
|
||||
box-shadow: 0 0 0 2px var(--accent-blue-soft-bg);
|
||||
}
|
||||
|
||||
.estimate-cell-input.is-numeric {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.estimate-cell-input.is-invalid,
|
||||
.estimate-cell-input.is-invalid:focus,
|
||||
.estimate-cell-input.is-invalid:hover {
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger-bg);
|
||||
}
|
||||
|
||||
.estimate-cell-input::placeholder {
|
||||
color: var(--text-faint);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.estimate-unit-input,
|
||||
.estimate-gzf-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.estimate-unit-label {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-faint);
|
||||
min-width: 1.9rem;
|
||||
}
|
||||
|
||||
.estimate-amev-button {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--panel-border-strong);
|
||||
border-radius: 3px;
|
||||
background: var(--panel-bg-subtle);
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 1px 3px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.estimate-amev-button:hover:not(:disabled) {
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.estimate-number {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.estimate-number.is-muted {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.estimate-number.is-over {
|
||||
color: var(--color-attention-dark);
|
||||
}
|
||||
|
||||
.estimate-number.is-under {
|
||||
color: var(--color-signal-dark);
|
||||
}
|
||||
|
||||
.estimate-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.estimate-cost-group-cell {
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.estimate-group-title-row td {
|
||||
background: var(--surface-component-header);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.estimate-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs) var(--space-lg);
|
||||
}
|
||||
|
||||
.estimate-group-name {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.estimate-group-editor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.estimate-group-editor .estimate-cell-input {
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
.estimate-group-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.estimate-group-title-row .estimate-actions-cell {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.estimate-icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.15rem 0.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.estimate-actions-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.estimate-empty-row td {
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.estimate-subtotal-row td {
|
||||
background: var(--panel-bg-subtle);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.estimate-total-label {
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.estimate-total-hint {
|
||||
font-weight: 400;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.estimate-total-row td {
|
||||
background: var(--surface-header-row);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.estimate-total-row.is-strong td {
|
||||
font-weight: 800;
|
||||
border-top: 2px solid var(--panel-border-strong);
|
||||
}
|
||||
|
||||
.estimate-add-group-row td {
|
||||
background: var(--panel-bg);
|
||||
padding: var(--space-sm) 4px;
|
||||
}
|
||||
|
||||
.estimate-unassigned-row td {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.estimate-collapse-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.estimate-list-table .estimate-number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Druck-Textwerte: auf dem Bildschirm unsichtbar, im Druck statt Inputs */
|
||||
.estimate-print-value {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@page estimate-balance {
|
||||
size: A4 landscape;
|
||||
margin: 10mm;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body:has(.estimate-print-root) .sidebar,
|
||||
.estimate-print-root .estimate-no-print,
|
||||
.estimate-print-root .alert,
|
||||
.estimate-print-root input,
|
||||
.estimate-print-root select,
|
||||
.estimate-print-root button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body:has(.estimate-print-root) {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
body:has(.estimate-print-root) .app-shell {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.estimate-print-root {
|
||||
page: estimate-balance;
|
||||
padding: 0 !important;
|
||||
max-width: none;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.estimate-print-root .estimate-print-value {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.estimate-print-root .page-header {
|
||||
margin-bottom: 4mm;
|
||||
}
|
||||
|
||||
.estimate-print-root .page-header h1 {
|
||||
font-size: 14pt;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.estimate-print-root .card {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.estimate-print-root .card-body,
|
||||
.estimate-print-root .card-header {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.estimate-print-root .estimate-comparison-card {
|
||||
margin-top: 6mm;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.estimate-sheet-wrap {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.estimate-sheet {
|
||||
min-width: 0;
|
||||
table-layout: auto;
|
||||
font-size: 7.5pt;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.estimate-sheet th,
|
||||
.estimate-sheet td {
|
||||
border-color: #999;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
|
||||
.estimate-sheet thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
.estimate-sheet thead th,
|
||||
.estimate-total-row td {
|
||||
background: #e8e8e8 !important;
|
||||
}
|
||||
|
||||
.estimate-group-title-row td {
|
||||
background: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.estimate-subtotal-row td {
|
||||
background: #f2f2f2 !important;
|
||||
}
|
||||
|
||||
.estimate-group-title-row,
|
||||
.estimate-subtotal-row,
|
||||
.estimate-total-row {
|
||||
break-inside: avoid;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.estimate-group-title-row {
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
/* Totals print once at the end, not repeated on every page. */
|
||||
.estimate-sheet tfoot {
|
||||
display: table-row-group;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.estimate-col-actions {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.estimate-unit-input,
|
||||
.estimate-gzf-input {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.estimate-header-fields {
|
||||
margin-bottom: 3mm;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
743
src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx
Normal file
743
src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx
Normal file
|
|
@ -0,0 +1,743 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Printer,
|
||||
Redo2,
|
||||
RefreshCw,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ApiRequestError,
|
||||
getEstimateBalance,
|
||||
getEstimateComparison,
|
||||
getProject,
|
||||
getProjectHistory,
|
||||
isRevisionConflictError,
|
||||
redoProjectCommand,
|
||||
undoProjectCommand,
|
||||
updateEstimateBalance,
|
||||
} from "../../../../../frontend/utils/api";
|
||||
import type {
|
||||
EstimateBalanceDto,
|
||||
EstimateComparisonDto,
|
||||
ProjectDto,
|
||||
ProjectHistoryStateDto,
|
||||
} from "../../../../../frontend/types";
|
||||
import type { EstimateGroupSnapshot } from "../../../../../shared/estimate-balance/estimate-balance";
|
||||
import { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal";
|
||||
import {
|
||||
addEstimateGroup,
|
||||
estimateBalanceFingerprint,
|
||||
estimateTextLimits,
|
||||
normalizeEstimateSortOrder,
|
||||
toSaveableEstimateBalance,
|
||||
updateEstimateGroup,
|
||||
updateEstimateRow,
|
||||
validateEstimateBalance,
|
||||
} from "../../../../../frontend/estimate-balance/estimate-balance-document";
|
||||
import { formatIsoDateGerman } from "../../../../../frontend/estimate-balance/estimate-balance-format";
|
||||
import {
|
||||
EstimateBalanceSheet,
|
||||
type EstimateBalanceUpdate,
|
||||
} from "../../../../../frontend/estimate-balance/estimate-balance-sheet";
|
||||
import { EstimateComparisonTable } from "../../../../../frontend/estimate-balance/estimate-comparison-table";
|
||||
import { DraftInput } from "../../../../../frontend/estimate-balance/estimate-draft-input";
|
||||
import {
|
||||
EstimateGroupModal,
|
||||
type EstimateGroupFormValues,
|
||||
} from "../../../../../frontend/estimate-balance/estimate-group-modal";
|
||||
|
||||
const AUTOSAVE_DELAY_MS = 800;
|
||||
const CONFLICT_MESSAGE = "Die Bilanz wurde zwischenzeitlich geändert und neu geladen.";
|
||||
|
||||
type SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid";
|
||||
|
||||
type GroupDialog = { group: EstimateGroupSnapshot | null };
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown) {
|
||||
return error instanceof ApiRequestError && error.status === 404;
|
||||
}
|
||||
|
||||
export default function EstimateBalanceEditorPage() {
|
||||
const params = useParams<{ projectId: string; balanceId: string }>();
|
||||
const { projectId, balanceId } = params;
|
||||
const router = useRouter();
|
||||
|
||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||
const [draft, setDraft] = useState<EstimateBalanceDto | null>(null);
|
||||
const [history, setHistory] = useState<ProjectHistoryStateDto | null>(null);
|
||||
const [comparison, setComparison] = useState<EstimateComparisonDto | null>(null);
|
||||
const [comparisonError, setComparisonError] = useState<string | null>(null);
|
||||
const [isComparisonOpen, setIsComparisonOpen] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("saved");
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isMissing, setIsMissing] = useState(false);
|
||||
const [isHistoryBusy, setIsHistoryBusy] = useState(false);
|
||||
const [invalidFieldKeys, setInvalidFieldKeys] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
const [groupDialog, setGroupDialog] = useState<GroupDialog | null>(null);
|
||||
const [gzfTarget, setGzfTarget] = useState<{ groupId: string; rowId: string } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Refs carry the latest values into timers, unload handlers and chained
|
||||
// async saves without re-creating them.
|
||||
const draftRef = useRef<EstimateBalanceDto | null>(null);
|
||||
const revisionRef = useRef<number | null>(null);
|
||||
const savedFingerprintRef = useRef<string | null>(null);
|
||||
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
const applyServerBalance = useCallback((balance: EstimateBalanceDto) => {
|
||||
const normalized = normalizeEstimateSortOrder(balance);
|
||||
savedFingerprintRef.current = estimateBalanceFingerprint(normalized);
|
||||
draftRef.current = normalized;
|
||||
setDraft(normalized);
|
||||
setSaveStatus("saved");
|
||||
setSaveError(null);
|
||||
}, []);
|
||||
|
||||
const reloadComparison = useCallback(async () => {
|
||||
try {
|
||||
setComparison(await getEstimateComparison(projectId, balanceId));
|
||||
setComparisonError(null);
|
||||
} catch (err) {
|
||||
setComparisonError(
|
||||
errorMessage(err, "Der Vergleich konnte nicht geladen werden.")
|
||||
);
|
||||
}
|
||||
}, [balanceId, projectId]);
|
||||
|
||||
/** Reloads project, document and history; replaces the local draft. */
|
||||
const reloadAll = useCallback(async () => {
|
||||
const balanceRequest = getEstimateBalance(projectId, balanceId);
|
||||
// Handled below; avoids an "unhandled rejection" while project/history load.
|
||||
balanceRequest.catch(() => undefined);
|
||||
// Project and history are applied even when the balance is missing, so
|
||||
// Redo stays available after its creation was undone.
|
||||
const [currentProject, currentHistory] = await Promise.all([
|
||||
getProject(projectId),
|
||||
getProjectHistory(projectId).catch(() => null),
|
||||
]);
|
||||
setProject(currentProject);
|
||||
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
|
||||
setHistory(currentHistory);
|
||||
const balance = await balanceRequest;
|
||||
applyServerBalance(balance);
|
||||
setIsMissing(false);
|
||||
void reloadComparison();
|
||||
}, [applyServerBalance, balanceId, projectId, reloadComparison]);
|
||||
|
||||
const handleReloadError = useCallback((err: unknown) => {
|
||||
if (isNotFoundError(err)) {
|
||||
setIsMissing(true);
|
||||
setDraft(null);
|
||||
draftRef.current = null;
|
||||
return;
|
||||
}
|
||||
setError(errorMessage(err, "Die grobe Leistungsbilanz konnte nicht geladen werden."));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !balanceId) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
reloadAll()
|
||||
.then(() => setError(null))
|
||||
.catch(handleReloadError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [balanceId, handleReloadError, projectId, reloadAll]);
|
||||
|
||||
const clearSaveTimer = useCallback(() => {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Saves the current draft if it differs from the last saved state. Saves
|
||||
* never overlap: a save requested while another runs waits for it and then
|
||||
* saves whatever changed in the meantime. Resolves to false when the draft
|
||||
* could not be saved.
|
||||
*/
|
||||
const saveNow = useCallback(async (): Promise<boolean> => {
|
||||
clearSaveTimer();
|
||||
while (saveInFlightRef.current) {
|
||||
await saveInFlightRef.current;
|
||||
}
|
||||
const current = draftRef.current;
|
||||
const revision = revisionRef.current;
|
||||
if (!current || revision === null) {
|
||||
return true;
|
||||
}
|
||||
const fingerprint = estimateBalanceFingerprint(current);
|
||||
if (fingerprint === savedFingerprintRef.current) {
|
||||
setSaveStatus("saved");
|
||||
return true;
|
||||
}
|
||||
const problems = validateEstimateBalance(current);
|
||||
if (problems.length) {
|
||||
setSaveStatus("invalid");
|
||||
setSaveError(problems.join(" "));
|
||||
return false;
|
||||
}
|
||||
const body = toSaveableEstimateBalance(current);
|
||||
|
||||
/**
|
||||
* Another command changed the project revision. When this document is
|
||||
* still unchanged on the server (e.g. an edit in a circuit list), the
|
||||
* draft is kept and saved again; otherwise the server state wins.
|
||||
*/
|
||||
async function resolveConflict(): Promise<"retry" | "reloaded"> {
|
||||
const [currentProject, serverBalance, currentHistory] = await Promise.all([
|
||||
getProject(projectId),
|
||||
getEstimateBalance(projectId, balanceId),
|
||||
getProjectHistory(projectId).catch(() => null),
|
||||
]);
|
||||
setProject(currentProject);
|
||||
setHistory(currentHistory);
|
||||
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
|
||||
if (estimateBalanceFingerprint(serverBalance) === savedFingerprintRef.current) {
|
||||
return "retry";
|
||||
}
|
||||
applyServerBalance(serverBalance);
|
||||
setNotice(CONFLICT_MESSAGE);
|
||||
void reloadComparison();
|
||||
return "reloaded";
|
||||
}
|
||||
|
||||
const run = (async () => {
|
||||
setSaveStatus("saving");
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
const result = await updateEstimateBalance(
|
||||
projectId,
|
||||
body,
|
||||
attempt === 0 ? revision : revisionRef.current ?? revision
|
||||
);
|
||||
if (!("unchanged" in result)) {
|
||||
revisionRef.current = result.history.currentRevision;
|
||||
setHistory(result.history);
|
||||
setProject((currentProject) =>
|
||||
currentProject
|
||||
? { ...currentProject, currentRevision: result.history.currentRevision }
|
||||
: currentProject
|
||||
);
|
||||
}
|
||||
// The sent document is the saved state; the local draft is kept so
|
||||
// typing is never interrupted by the server's copy.
|
||||
savedFingerprintRef.current = fingerprint;
|
||||
setSaveError(null);
|
||||
const latest = draftRef.current;
|
||||
if (latest && estimateBalanceFingerprint(latest) !== fingerprint) {
|
||||
setSaveStatus("pending");
|
||||
} else {
|
||||
setSaveStatus("saved");
|
||||
}
|
||||
void reloadComparison();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isRevisionConflictError(err)) {
|
||||
try {
|
||||
if (attempt === 0 && (await resolveConflict()) === "retry") {
|
||||
continue;
|
||||
}
|
||||
if (attempt > 0) {
|
||||
await reloadAll();
|
||||
setNotice(CONFLICT_MESSAGE);
|
||||
}
|
||||
} catch (reloadError) {
|
||||
handleReloadError(reloadError);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (isNotFoundError(err)) {
|
||||
handleReloadError(err);
|
||||
return false;
|
||||
}
|
||||
setSaveStatus("error");
|
||||
setSaveError(errorMessage(err, "Die Änderungen konnten nicht gespeichert werden."));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
saveInFlightRef.current = run;
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
saveInFlightRef.current = null;
|
||||
}
|
||||
}, [
|
||||
applyServerBalance,
|
||||
balanceId,
|
||||
clearSaveTimer,
|
||||
handleReloadError,
|
||||
projectId,
|
||||
reloadAll,
|
||||
reloadComparison,
|
||||
]);
|
||||
|
||||
// Debounced autosave after every change of the draft.
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
if (estimateBalanceFingerprint(draft) === savedFingerprintRef.current) {
|
||||
return;
|
||||
}
|
||||
setSaveStatus((status) => (status === "saving" ? status : "pending"));
|
||||
clearSaveTimer();
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveTimerRef.current = null;
|
||||
void saveNow();
|
||||
}, AUTOSAVE_DELAY_MS);
|
||||
return clearSaveTimer;
|
||||
}, [clearSaveTimer, draft, saveNow]);
|
||||
|
||||
// Save immediately when the tab is hidden; on unload/unmount send the last
|
||||
// change with keepalive so it survives the page.
|
||||
useEffect(() => {
|
||||
function hasUnsavedDraft() {
|
||||
const current = draftRef.current;
|
||||
return Boolean(
|
||||
current && estimateBalanceFingerprint(current) !== savedFingerprintRef.current
|
||||
);
|
||||
}
|
||||
function sendKeepalive(): boolean {
|
||||
const current = draftRef.current;
|
||||
const revision = revisionRef.current;
|
||||
if (
|
||||
!current ||
|
||||
revision === null ||
|
||||
saveInFlightRef.current ||
|
||||
validateEstimateBalance(current).length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
savedFingerprintRef.current = estimateBalanceFingerprint(current);
|
||||
void updateEstimateBalance(projectId, toSaveableEstimateBalance(current), revision, {
|
||||
keepalive: true,
|
||||
}).catch(() => undefined);
|
||||
return true;
|
||||
}
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "hidden" && hasUnsavedDraft()) {
|
||||
void saveNow();
|
||||
}
|
||||
}
|
||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!hasUnsavedDraft() || sendKeepalive()) {
|
||||
return;
|
||||
}
|
||||
// A save is still running or the draft is invalid: let the browser ask.
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
}
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
// In-app navigation (e.g. sidebar) unmounts the editor.
|
||||
if (hasUnsavedDraft()) {
|
||||
sendKeepalive();
|
||||
}
|
||||
};
|
||||
}, [projectId, saveNow]);
|
||||
|
||||
const handleChange: EstimateBalanceUpdate = useCallback((update) => {
|
||||
setNotice(null);
|
||||
setDraft((current) => (current ? update(current) : current));
|
||||
}, []);
|
||||
|
||||
const handleValidityChange = useCallback((fieldKey: string, isValid: boolean) => {
|
||||
setInvalidFieldKeys((current) => {
|
||||
if (isValid ? !current.has(fieldKey) : current.has(fieldKey)) {
|
||||
return current;
|
||||
}
|
||||
const next = new Set(current);
|
||||
if (isValid) {
|
||||
next.delete(fieldKey);
|
||||
} else {
|
||||
next.add(fieldKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function handleHistory(direction: "undo" | "redo") {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setIsHistoryBusy(true);
|
||||
try {
|
||||
// Pending edits are saved first so they become their own undo step.
|
||||
await saveNow();
|
||||
const revision = revisionRef.current;
|
||||
if (revision === null) {
|
||||
return;
|
||||
}
|
||||
const result =
|
||||
direction === "undo"
|
||||
? await undoProjectCommand(projectId, revision)
|
||||
: await redoProjectCommand(projectId, revision);
|
||||
revisionRef.current = result.history.currentRevision;
|
||||
setHistory(result.history);
|
||||
await reloadAll();
|
||||
} catch (err) {
|
||||
if (isRevisionConflictError(err)) {
|
||||
await reloadAll().catch(handleReloadError);
|
||||
setNotice(CONFLICT_MESSAGE);
|
||||
} else if (isNotFoundError(err)) {
|
||||
handleReloadError(err);
|
||||
} else {
|
||||
setError(
|
||||
errorMessage(
|
||||
err,
|
||||
direction === "undo"
|
||||
? "Die letzte Änderung konnte nicht rückgängig gemacht werden."
|
||||
: "Die letzte Änderung konnte nicht wiederholt werden."
|
||||
)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsHistoryBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBackClick(event: MouseEvent<HTMLAnchorElement>) {
|
||||
const current = draftRef.current;
|
||||
if (!current || estimateBalanceFingerprint(current) === savedFingerprintRef.current) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
await saveNow();
|
||||
router.push(`/projects/${projectId}/grobbilanz`);
|
||||
}
|
||||
|
||||
function handleSaveGroup(values: EstimateGroupFormValues) {
|
||||
const editingGroup = groupDialog?.group ?? null;
|
||||
handleChange((current) =>
|
||||
editingGroup
|
||||
? updateEstimateGroup(current, editingGroup.id, values)
|
||||
: addEstimateGroup(current, values).balance
|
||||
);
|
||||
setGroupDialog(null);
|
||||
}
|
||||
|
||||
function handleSelectGzf(value: number) {
|
||||
if (gzfTarget) {
|
||||
handleChange((current) =>
|
||||
updateEstimateRow(current, gzfTarget.groupId, gzfTarget.rowId, {
|
||||
simultaneityFactor: value,
|
||||
})
|
||||
);
|
||||
}
|
||||
setGzfTarget(null);
|
||||
}
|
||||
|
||||
const isEditingDisabled = !draft || isHistoryBusy;
|
||||
const invalidCount = invalidFieldKeys.size;
|
||||
const listHref = `/projects/${projectId}/grobbilanz`;
|
||||
|
||||
return (
|
||||
<main className="container-fluid py-4 estimate-print-root">
|
||||
<div className="page-header estimate-page-header">
|
||||
<div className="d-flex align-items-start gap-3">
|
||||
<Link
|
||||
aria-label="Zurück zur Übersicht der Stände"
|
||||
className="btn btn-outline-secondary btn-sm structure-back-link estimate-no-print"
|
||||
href={listHref}
|
||||
onClick={(event) => void handleBackClick(event)}
|
||||
title="Zurück zur Übersicht der Stände"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" size={18} />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="kicker">{project?.name ?? "Projekt"}</div>
|
||||
<h1>Grobe Leistungsbilanz{draft ? ` – ${draft.name}` : ""}</h1>
|
||||
<p className="text-secondary mb-0 estimate-no-print">
|
||||
Leistungsabschätzung nach Kostengruppen (DIN 276) für LPH 2/3.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="estimate-toolbar estimate-no-print">
|
||||
<SaveStatusBadge
|
||||
error={saveError}
|
||||
invalidCount={invalidCount}
|
||||
onRetry={() => void saveNow()}
|
||||
status={saveStatus}
|
||||
/>
|
||||
<div className="btn-group btn-group-sm" role="group" aria-label="Verlauf">
|
||||
<button
|
||||
aria-label="Rückgängig"
|
||||
className="btn btn-outline-secondary"
|
||||
disabled={isHistoryBusy || !history || history.undoDepth === 0}
|
||||
onClick={() => void handleHistory("undo")}
|
||||
title="Rückgängig (letzte Änderung im Projekt)"
|
||||
type="button"
|
||||
>
|
||||
<Undo2 aria-hidden="true" size={16} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Wiederholen"
|
||||
className="btn btn-outline-secondary"
|
||||
disabled={isHistoryBusy || !history || history.redoDepth === 0}
|
||||
onClick={() => void handleHistory("redo")}
|
||||
title="Wiederholen (im Projekt)"
|
||||
type="button"
|
||||
>
|
||||
<Redo2 aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Drucken"
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
disabled={!draft}
|
||||
onClick={() => window.print()}
|
||||
title="Drucken"
|
||||
type="button"
|
||||
>
|
||||
<Printer aria-hidden="true" className="me-1" size={16} />
|
||||
Drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice ? (
|
||||
<div className="alert alert-info estimate-no-print" role="status">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="alert alert-warning estimate-no-print" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<p className="text-secondary" role="status">
|
||||
Grobe Leistungsbilanz wird geladen …
|
||||
</p>
|
||||
) : null}
|
||||
{isMissing ? (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
Dieser Stand existiert nicht (mehr), z. B. weil er gelöscht oder sein Anlegen
|
||||
rückgängig gemacht wurde.{" "}
|
||||
<Link href={listHref}>Zur Übersicht der Stände</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{draft && project ? (
|
||||
<>
|
||||
<section className="card shadow-sm mb-3 estimate-sheet-card">
|
||||
<div className="card-body">
|
||||
<div className="estimate-header-fields">
|
||||
<div className="estimate-header-field">
|
||||
<span className="estimate-header-label">Projekt</span>
|
||||
<span className="estimate-header-value">{project.name}</span>
|
||||
</div>
|
||||
<label className="estimate-header-field is-wide">
|
||||
<span className="estimate-header-label">Stand</span>
|
||||
<DraftInput
|
||||
ariaLabel="Bezeichnung des Stands"
|
||||
className="estimate-cell-input estimate-header-input"
|
||||
disabled={isEditingDisabled}
|
||||
format={(value: string) => value}
|
||||
maxLength={estimateTextLimits.balanceName}
|
||||
onCommit={(name) => handleChange((current) => ({ ...current, name }))}
|
||||
onValidityChange={(isValid) =>
|
||||
handleValidityChange("balance:name", isValid)
|
||||
}
|
||||
parse={(text) =>
|
||||
text.trim()
|
||||
? { ok: true, value: text }
|
||||
: { ok: false, error: "Die Bezeichnung darf nicht leer sein." }
|
||||
}
|
||||
value={draft.name}
|
||||
/>
|
||||
</label>
|
||||
<label className="estimate-header-field">
|
||||
<span className="estimate-header-label">LPH</span>
|
||||
<input
|
||||
aria-label="Leistungsphase"
|
||||
className="estimate-cell-input estimate-header-input"
|
||||
disabled={isEditingDisabled}
|
||||
maxLength={estimateTextLimits.workStage}
|
||||
onChange={(event) =>
|
||||
handleChange((current) => ({
|
||||
...current,
|
||||
workStage: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="z. B. LPH 3"
|
||||
type="text"
|
||||
value={draft.workStage ?? ""}
|
||||
/>
|
||||
<span className="estimate-print-value">{draft.workStage ?? ""}</span>
|
||||
</label>
|
||||
<label className="estimate-header-field">
|
||||
<span className="estimate-header-label">Datum</span>
|
||||
<input
|
||||
aria-label="Datum"
|
||||
className="estimate-cell-input estimate-header-input"
|
||||
disabled={isEditingDisabled}
|
||||
onChange={(event) =>
|
||||
handleChange((current) => ({
|
||||
...current,
|
||||
dateIso: event.target.value || null,
|
||||
}))
|
||||
}
|
||||
type="date"
|
||||
value={draft.dateIso ?? ""}
|
||||
/>
|
||||
<span className="estimate-print-value">
|
||||
{formatIsoDateGerman(draft.dateIso)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<EstimateBalanceSheet
|
||||
balance={draft}
|
||||
disabled={isEditingDisabled}
|
||||
onAddGroup={() => setGroupDialog({ group: null })}
|
||||
onChange={handleChange}
|
||||
onEditGroup={(group) => setGroupDialog({ group })}
|
||||
onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })}
|
||||
onValidityChange={handleValidityChange}
|
||||
threePhaseVoltageV={project.threePhaseVoltageV}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={
|
||||
isComparisonOpen
|
||||
? "card shadow-sm estimate-comparison-card"
|
||||
: "card shadow-sm estimate-comparison-card estimate-no-print"
|
||||
}
|
||||
>
|
||||
<div className="card-header d-flex justify-content-between align-items-center gap-2">
|
||||
<button
|
||||
aria-controls="estimate-comparison-body"
|
||||
aria-expanded={isComparisonOpen}
|
||||
className="estimate-collapse-toggle"
|
||||
onClick={() => setIsComparisonOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
{isComparisonOpen ? (
|
||||
<ChevronDown aria-hidden="true" size={16} />
|
||||
) : (
|
||||
<ChevronRight aria-hidden="true" size={16} />
|
||||
)}
|
||||
Vergleich mit detaillierter Bilanz
|
||||
</button>
|
||||
{isComparisonOpen ? (
|
||||
<button
|
||||
aria-label="Vergleich neu laden"
|
||||
className="btn btn-sm btn-outline-secondary structure-icon-button estimate-no-print"
|
||||
onClick={() => void reloadComparison()}
|
||||
title="Vergleich neu laden"
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{isComparisonOpen ? (
|
||||
<div className="card-body" id="estimate-comparison-body">
|
||||
{comparisonError ? (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
{comparisonError}
|
||||
</div>
|
||||
) : null}
|
||||
{comparison ? <EstimateComparisonTable comparison={comparison} /> : null}
|
||||
<p className="text-secondary small mt-2 mb-0">
|
||||
Detailliert = Summe der Gerätezeilen aller Stromkreislisten (Anzahl ×
|
||||
Leistung × Geräte-GZF), zugeordnet über die Kostengruppe; ohne
|
||||
Verteiler-GZF.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{groupDialog ? (
|
||||
<EstimateGroupModal
|
||||
group={groupDialog.group}
|
||||
onClose={() => setGroupDialog(null)}
|
||||
onSave={handleSaveGroup}
|
||||
/>
|
||||
) : null}
|
||||
{gzfTarget ? (
|
||||
<GzfPickerModal onClose={() => setGzfTarget(null)} onSelect={handleSelectGzf} />
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveStatusBadge({
|
||||
error,
|
||||
invalidCount,
|
||||
onRetry,
|
||||
status,
|
||||
}: {
|
||||
error: string | null;
|
||||
invalidCount: number;
|
||||
onRetry: () => void;
|
||||
status: SaveStatus;
|
||||
}) {
|
||||
const label =
|
||||
status === "saving"
|
||||
? "Wird gespeichert …"
|
||||
: status === "pending"
|
||||
? "Ungespeicherte Änderungen"
|
||||
: status === "error"
|
||||
? "Nicht gespeichert – Fehler"
|
||||
: status === "invalid"
|
||||
? "Nicht gespeichert – ungültige Eingaben"
|
||||
: "Gespeichert";
|
||||
const tone =
|
||||
status === "error" || status === "invalid"
|
||||
? "is-error"
|
||||
: status === "saved"
|
||||
? "is-saved"
|
||||
: "is-busy";
|
||||
return (
|
||||
<div className="estimate-save-status" role="status" aria-live="polite">
|
||||
<span className={`estimate-save-badge ${tone}`} title={error ?? undefined}>
|
||||
{label}
|
||||
</span>
|
||||
{invalidCount ? (
|
||||
<span className="estimate-save-hint">
|
||||
{invalidCount === 1
|
||||
? "1 ungültige Eingabe wird nicht gespeichert"
|
||||
: `${invalidCount} ungültige Eingaben werden nicht gespeichert`}
|
||||
</span>
|
||||
) : null}
|
||||
{status === "error" ? (
|
||||
<button className="btn btn-link btn-sm p-0" onClick={onRetry} type="button">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
292
src/app/projects/[projectId]/grobbilanz/page.tsx
Normal file
292
src/app/projects/[projectId]/grobbilanz/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/db/migrations/0010_estimate_balances.sql
Normal file
43
src/db/migrations/0010_estimate_balances.sql
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
CREATE TABLE `estimate_balances` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`work_stage` text,
|
||||
`date_iso` text,
|
||||
`reserve_av_percent` real DEFAULT 0 NOT NULL,
|
||||
`reserve_sv_percent` real DEFAULT 0 NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `estimate_balances_project_id_idx` ON `estimate_balances` (`project_id`);--> statement-breakpoint
|
||||
CREATE TABLE `estimate_groups` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`balance_id` text NOT NULL,
|
||||
`cost_group` text DEFAULT '' NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`editor` text,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (`balance_id`) REFERENCES `estimate_balances`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `estimate_groups_balance_id_idx` ON `estimate_groups` (`balance_id`);--> statement-breakpoint
|
||||
CREATE TABLE `estimate_rows` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`group_id` text NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`supply` text,
|
||||
`circuit_ref` text,
|
||||
`name` text DEFAULT '' NOT NULL,
|
||||
`quantity` real DEFAULT 0 NOT NULL,
|
||||
`unit` text DEFAULT 'piece' NOT NULL,
|
||||
`connection` text,
|
||||
`power_per_unit_w` real DEFAULT 0 NOT NULL,
|
||||
`simultaneity_factor` real DEFAULT 1 NOT NULL,
|
||||
`cos_phi` real DEFAULT 1 NOT NULL,
|
||||
`safety_supply` integer DEFAULT false NOT NULL,
|
||||
`remark` text,
|
||||
FOREIGN KEY (`group_id`) REFERENCES `estimate_groups`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `estimate_rows_group_id_idx` ON `estimate_rows` (`group_id`);
|
||||
2931
src/db/migrations/meta/0010_snapshot.json
Normal file
2931
src/db/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -71,6 +71,13 @@
|
|||
"when": 1790419185882,
|
||||
"tag": "0009_buildings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1790425784626,
|
||||
"tag": "0010_estimate_balances",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
src/db/repositories/estimate-balance.persistence.ts
Normal file
121
src/db/repositories/estimate-balance.persistence.ts
Normal 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;
|
||||
}
|
||||
60
src/db/repositories/estimate-balance.repository.ts
Normal file
60
src/db/repositories/estimate-balance.repository.ts
Normal 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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
61
src/db/schema/estimate-balances.ts
Normal file
61
src/db/schema/estimate-balances.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { projects } from "./projects.js";
|
||||
|
||||
// Grobe Leistungsbilanz (LPH 2/3): a separate module without links into the
|
||||
// circuit structure. See docs/estimate-balance.md.
|
||||
export const estimateBalances = sqliteTable(
|
||||
"estimate_balances",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
workStage: text("work_stage"),
|
||||
dateIso: text("date_iso"),
|
||||
reserveAvPercent: real("reserve_av_percent").notNull().default(0),
|
||||
reserveSvPercent: real("reserve_sv_percent").notNull().default(0),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
},
|
||||
(table) => [index("estimate_balances_project_id_idx").on(table.projectId)]
|
||||
);
|
||||
|
||||
export const estimateGroups = sqliteTable(
|
||||
"estimate_groups",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
balanceId: text("balance_id")
|
||||
.notNull()
|
||||
.references(() => estimateBalances.id, { onDelete: "cascade" }),
|
||||
costGroup: text("cost_group").notNull().default(""),
|
||||
title: text("title").notNull(),
|
||||
editor: text("editor"),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
},
|
||||
(table) => [index("estimate_groups_balance_id_idx").on(table.balanceId)]
|
||||
);
|
||||
|
||||
export const estimateRows = sqliteTable(
|
||||
"estimate_rows",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
groupId: text("group_id")
|
||||
.notNull()
|
||||
.references(() => estimateGroups.id, { onDelete: "cascade" }),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
supply: text("supply"),
|
||||
circuitRef: text("circuit_ref"),
|
||||
name: text("name").notNull().default(""),
|
||||
quantity: real("quantity").notNull().default(0),
|
||||
unit: text("unit", { enum: ["piece", "area"] }).notNull().default("piece"),
|
||||
connection: text("connection", { enum: ["fixed", "socket"] }),
|
||||
powerPerUnitW: real("power_per_unit_w").notNull().default(0),
|
||||
simultaneityFactor: real("simultaneity_factor").notNull().default(1),
|
||||
cosPhi: real("cos_phi").notNull().default(1),
|
||||
safetySupply: integer("safety_supply", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
remark: text("remark"),
|
||||
},
|
||||
(table) => [index("estimate_rows_group_id_idx").on(table.groupId)]
|
||||
);
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
33
src/estimate-balance/api/estimate-balance-request.schemas.ts
Normal file
33
src/estimate-balance/api/estimate-balance-request.schemas.ts
Normal 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();
|
||||
320
src/estimate-balance/domain/estimate-balance-command.model.ts
Normal file
320
src/estimate-balance/domain/estimate-balance-command.model.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import crypto from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
|
||||
import {
|
||||
estimateDefaultGroups,
|
||||
type EstimateBalanceSnapshot,
|
||||
type EstimateGroupSnapshot,
|
||||
} from "../../shared/estimate-balance/estimate-balance.js";
|
||||
|
||||
export const estimateBalanceInsertCommandType = "estimate-balance.insert" as const;
|
||||
export const estimateBalanceDeleteCommandType = "estimate-balance.delete" as const;
|
||||
export const estimateBalanceUpdateCommandType = "estimate-balance.update" as const;
|
||||
export const estimateBalanceCommandSchemaVersion = 1 as const;
|
||||
|
||||
const idSchema = z.string().trim().min(1).max(100);
|
||||
const trimmed = (max: number) =>
|
||||
z.string().max(max).refine((value) => value === value.trim(), {
|
||||
message: "must be trimmed",
|
||||
});
|
||||
const nullableText = (max: number) =>
|
||||
trimmed(max)
|
||||
.refine((value) => value.length > 0, { message: "use null instead of empty" })
|
||||
.nullable();
|
||||
const finite = z.number().finite();
|
||||
|
||||
export const estimateRowSnapshotSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
sortOrder: z.number().int().min(0),
|
||||
supply: nullableText(50),
|
||||
circuitRef: nullableText(50),
|
||||
name: trimmed(200),
|
||||
quantity: finite.min(0),
|
||||
unit: z.enum(["piece", "area"]),
|
||||
connection: z.enum(["fixed", "socket"]).nullable(),
|
||||
powerPerUnitW: finite.min(0),
|
||||
simultaneityFactor: finite.min(0).max(1),
|
||||
cosPhi: finite.gt(0).max(1),
|
||||
safetySupply: z.boolean(),
|
||||
remark: nullableText(500),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const estimateGroupSnapshotSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
costGroup: trimmed(20),
|
||||
title: trimmed(200).refine((value) => value.length > 0, {
|
||||
message: "title must not be empty",
|
||||
}),
|
||||
editor: nullableText(100),
|
||||
sortOrder: z.number().int().min(0),
|
||||
rows: z.array(estimateRowSnapshotSchema).max(1000),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const estimateBalanceSnapshotSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
name: trimmed(200).refine((value) => value.length > 0, {
|
||||
message: "name must not be empty",
|
||||
}),
|
||||
workStage: nullableText(50),
|
||||
dateIso: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.refine((value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), {
|
||||
message: "invalid date",
|
||||
})
|
||||
.nullable(),
|
||||
reserveAvPercent: finite.min(0).max(100),
|
||||
reserveSvPercent: finite.min(0).max(100),
|
||||
sortOrder: z.number().int().min(0),
|
||||
groups: z.array(estimateGroupSnapshotSchema).max(200),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((balance, context) => {
|
||||
const ids = new Set<string>();
|
||||
for (const group of balance.groups) {
|
||||
for (const id of [group.id, ...group.rows.map((row) => row.id)]) {
|
||||
if (ids.has(id)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `duplicate id ${id}`,
|
||||
path: ["groups"],
|
||||
});
|
||||
}
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export interface EstimateBalanceInsertProjectCommand
|
||||
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
||||
type: typeof estimateBalanceInsertCommandType;
|
||||
}
|
||||
|
||||
export interface EstimateBalanceDeleteProjectCommand
|
||||
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
||||
type: typeof estimateBalanceDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface EstimateBalanceUpdateProjectCommand
|
||||
extends SerializedProjectCommand<{
|
||||
expected: EstimateBalanceSnapshot;
|
||||
target: EstimateBalanceSnapshot;
|
||||
}> {
|
||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
||||
type: typeof estimateBalanceUpdateCommandType;
|
||||
}
|
||||
|
||||
export type EstimateBalanceProjectCommand =
|
||||
| EstimateBalanceInsertProjectCommand
|
||||
| EstimateBalanceDeleteProjectCommand
|
||||
| EstimateBalanceUpdateProjectCommand;
|
||||
|
||||
/** Parses and normalizes a snapshot (sort orders of groups and rows follow the array order). */
|
||||
export function parseEstimateBalanceSnapshot(value: unknown): EstimateBalanceSnapshot {
|
||||
const parsed = estimateBalanceSnapshotSchema.parse(value);
|
||||
return normalizeEstimateBalanceSnapshot(parsed);
|
||||
}
|
||||
|
||||
export function normalizeEstimateBalanceSnapshot(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
return {
|
||||
...balance,
|
||||
groups: balance.groups.map((group, groupIndex) => ({
|
||||
...group,
|
||||
sortOrder: groupIndex,
|
||||
rows: group.rows.map((row, rowIndex) => ({ ...row, sortOrder: rowIndex })),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable JSON for exact equality checks of two snapshots. */
|
||||
export function canonicalEstimateBalance(balance: EstimateBalanceSnapshot): string {
|
||||
const normalized = normalizeEstimateBalanceSnapshot(balance);
|
||||
return JSON.stringify({
|
||||
id: normalized.id,
|
||||
projectId: normalized.projectId,
|
||||
name: normalized.name,
|
||||
workStage: normalized.workStage,
|
||||
dateIso: normalized.dateIso,
|
||||
reserveAvPercent: normalized.reserveAvPercent,
|
||||
reserveSvPercent: normalized.reserveSvPercent,
|
||||
sortOrder: normalized.sortOrder,
|
||||
groups: normalized.groups.map((group) => ({
|
||||
id: group.id,
|
||||
costGroup: group.costGroup,
|
||||
title: group.title,
|
||||
editor: group.editor,
|
||||
sortOrder: group.sortOrder,
|
||||
rows: group.rows.map((row) => ({
|
||||
id: row.id,
|
||||
sortOrder: row.sortOrder,
|
||||
supply: row.supply,
|
||||
circuitRef: row.circuitRef,
|
||||
name: row.name,
|
||||
quantity: row.quantity,
|
||||
unit: row.unit,
|
||||
connection: row.connection,
|
||||
powerPerUnitW: row.powerPerUnitW,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi,
|
||||
safetySupply: row.safetySupply,
|
||||
remark: row.remark,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export function sameEstimateBalance(
|
||||
left: EstimateBalanceSnapshot,
|
||||
right: EstimateBalanceSnapshot
|
||||
): boolean {
|
||||
return canonicalEstimateBalance(left) === canonicalEstimateBalance(right);
|
||||
}
|
||||
|
||||
export function createEstimateBalanceSnapshot(input: {
|
||||
projectId: string;
|
||||
name: string;
|
||||
workStage: string | null;
|
||||
dateIso: string | null;
|
||||
sortOrder: number;
|
||||
template: "kg400" | "empty" | { copyFrom: EstimateBalanceSnapshot };
|
||||
createId?: () => string;
|
||||
}): EstimateBalanceSnapshot {
|
||||
const createId = input.createId ?? (() => crypto.randomUUID());
|
||||
let groups: EstimateGroupSnapshot[] = [];
|
||||
let reserveAvPercent = 10;
|
||||
let reserveSvPercent = 0;
|
||||
if (input.template === "kg400") {
|
||||
groups = estimateDefaultGroups.map((group, index) => ({
|
||||
id: createId(),
|
||||
costGroup: group.costGroup,
|
||||
title: group.title,
|
||||
editor: null,
|
||||
sortOrder: index,
|
||||
rows: [],
|
||||
}));
|
||||
} else if (input.template !== "empty") {
|
||||
const source = input.template.copyFrom;
|
||||
reserveAvPercent = source.reserveAvPercent;
|
||||
reserveSvPercent = source.reserveSvPercent;
|
||||
groups = source.groups.map((group) => ({
|
||||
...group,
|
||||
id: createId(),
|
||||
rows: group.rows.map((row) => ({ ...row, id: createId() })),
|
||||
}));
|
||||
}
|
||||
return parseEstimateBalanceSnapshot({
|
||||
id: createId(),
|
||||
projectId: input.projectId,
|
||||
name: input.name.trim(),
|
||||
workStage: input.workStage?.trim() || null,
|
||||
dateIso: input.dateIso || null,
|
||||
reserveAvPercent,
|
||||
reserveSvPercent,
|
||||
sortOrder: input.sortOrder,
|
||||
groups,
|
||||
});
|
||||
}
|
||||
|
||||
export function createEstimateBalanceInsertProjectCommand(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceInsertProjectCommand {
|
||||
return {
|
||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
||||
type: estimateBalanceInsertCommandType,
|
||||
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
||||
};
|
||||
}
|
||||
|
||||
export function createEstimateBalanceDeleteProjectCommand(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceDeleteProjectCommand {
|
||||
return {
|
||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
||||
type: estimateBalanceDeleteCommandType,
|
||||
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
||||
};
|
||||
}
|
||||
|
||||
export function createEstimateBalanceUpdateProjectCommand(
|
||||
expected: EstimateBalanceSnapshot,
|
||||
target: EstimateBalanceSnapshot
|
||||
): EstimateBalanceUpdateProjectCommand {
|
||||
const payload = {
|
||||
expected: parseEstimateBalanceSnapshot(expected),
|
||||
target: parseEstimateBalanceSnapshot(target),
|
||||
};
|
||||
assertMatchingUpdate(payload.expected, payload.target);
|
||||
return {
|
||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
||||
type: estimateBalanceUpdateCommandType,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function assertMatchingUpdate(
|
||||
expected: EstimateBalanceSnapshot,
|
||||
target: EstimateBalanceSnapshot
|
||||
) {
|
||||
if (expected.id !== target.id || expected.projectId !== target.projectId) {
|
||||
throw new Error("Estimate-balance update must keep id and project.");
|
||||
}
|
||||
if (sameEstimateBalance(expected, target)) {
|
||||
throw new Error("Estimate-balance update must change the balance.");
|
||||
}
|
||||
}
|
||||
|
||||
function assertCommandShape(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
type: string,
|
||||
keys: string[]
|
||||
): Record<string, unknown> {
|
||||
const payload = command.payload;
|
||||
if (
|
||||
command.schemaVersion !== estimateBalanceCommandSchemaVersion ||
|
||||
command.type !== type ||
|
||||
payload === null ||
|
||||
typeof payload !== "object" ||
|
||||
Array.isArray(payload) ||
|
||||
Object.keys(payload).sort().join(",") !== [...keys].sort().join(",")
|
||||
) {
|
||||
throw new Error("Unsupported estimate-balance command.");
|
||||
}
|
||||
return payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function assertEstimateBalanceInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is EstimateBalanceInsertProjectCommand {
|
||||
const payload = assertCommandShape(command, estimateBalanceInsertCommandType, ["balance"]);
|
||||
parseEstimateBalanceSnapshot(payload.balance);
|
||||
}
|
||||
|
||||
export function assertEstimateBalanceDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is EstimateBalanceDeleteProjectCommand {
|
||||
const payload = assertCommandShape(command, estimateBalanceDeleteCommandType, ["balance"]);
|
||||
parseEstimateBalanceSnapshot(payload.balance);
|
||||
}
|
||||
|
||||
export function assertEstimateBalanceUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is EstimateBalanceUpdateProjectCommand {
|
||||
const payload = assertCommandShape(command, estimateBalanceUpdateCommandType, [
|
||||
"expected",
|
||||
"target",
|
||||
]);
|
||||
assertMatchingUpdate(
|
||||
parseEstimateBalanceSnapshot(payload.expected),
|
||||
parseEstimateBalanceSnapshot(payload.target)
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
162
src/frontend/estimate-balance/estimate-balance-create-modal.tsx
Normal file
162
src/frontend/estimate-balance/estimate-balance-create-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
301
src/frontend/estimate-balance/estimate-balance-document.ts
Normal file
301
src/frontend/estimate-balance/estimate-balance-document.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
// Pure, immutable editing rules for one "Grobe Leistungsbilanz" document.
|
||||
// The editor keeps the complete document as local draft and saves it as a
|
||||
// whole (PUT), so every edit is a function draft → new draft.
|
||||
import type {
|
||||
EstimateBalanceSnapshot,
|
||||
EstimateGroupSnapshot,
|
||||
EstimateRowSnapshot,
|
||||
} from "../../shared/estimate-balance/estimate-balance";
|
||||
import { createClientId } from "../utils/client-id";
|
||||
|
||||
export type MoveDirection = -1 | 1;
|
||||
|
||||
/** Maximum text lengths accepted by the server. */
|
||||
export const estimateTextLimits = {
|
||||
balanceName: 200,
|
||||
workStage: 50,
|
||||
costGroup: 20,
|
||||
groupTitle: 200,
|
||||
groupEditor: 100,
|
||||
supply: 50,
|
||||
circuitRef: 50,
|
||||
rowName: 200,
|
||||
remark: 500,
|
||||
} as const;
|
||||
|
||||
export type EstimateRowPatch = Partial<Omit<EstimateRowSnapshot, "id" | "sortOrder">>;
|
||||
export type EstimateGroupPatch = Partial<Pick<EstimateGroupSnapshot, "costGroup" | "title" | "editor">>;
|
||||
|
||||
export function createEstimateRow(
|
||||
sortOrder: number,
|
||||
createId: () => string = createClientId
|
||||
): EstimateRowSnapshot {
|
||||
return {
|
||||
id: createId(),
|
||||
sortOrder,
|
||||
supply: null,
|
||||
circuitRef: null,
|
||||
name: "",
|
||||
quantity: 1,
|
||||
unit: "piece",
|
||||
connection: null,
|
||||
powerPerUnitW: 0,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 1,
|
||||
safetySupply: false,
|
||||
remark: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEstimateGroup(
|
||||
input: { costGroup: string; title: string; editor?: string | null },
|
||||
sortOrder: number,
|
||||
createId: () => string = createClientId
|
||||
): EstimateGroupSnapshot {
|
||||
return {
|
||||
id: createId(),
|
||||
costGroup: input.costGroup,
|
||||
title: input.title,
|
||||
editor: input.editor ?? null,
|
||||
sortOrder,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
function bySortOrder<T extends { sortOrder: number }>(left: T, right: T) {
|
||||
return left.sortOrder - right.sortOrder;
|
||||
}
|
||||
|
||||
/** Orders groups and rows by sortOrder and renumbers both from 0. */
|
||||
export function normalizeEstimateSortOrder(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
return {
|
||||
...balance,
|
||||
groups: [...balance.groups].sort(bySortOrder).map((group, groupIndex) => ({
|
||||
...group,
|
||||
sortOrder: groupIndex,
|
||||
rows: [...group.rows].sort(bySortOrder).map((row, rowIndex) => ({
|
||||
...row,
|
||||
sortOrder: rowIndex,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function renumber<T extends { sortOrder: number }>(items: T[]): T[] {
|
||||
return items.map((item, index) =>
|
||||
item.sortOrder === index ? item : { ...item, sortOrder: index }
|
||||
);
|
||||
}
|
||||
|
||||
function mapGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
update: (group: EstimateGroupSnapshot) => EstimateGroupSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
let changed = false;
|
||||
const groups = balance.groups.map((group) => {
|
||||
if (group.id !== groupId) {
|
||||
return group;
|
||||
}
|
||||
const next = update(group);
|
||||
changed = next !== group;
|
||||
return next;
|
||||
});
|
||||
// An unknown group or a no-op keeps the identical document.
|
||||
return changed ? { ...balance, groups } : balance;
|
||||
}
|
||||
|
||||
function moveItem<T>(items: T[], index: number, direction: MoveDirection): T[] | null {
|
||||
const target = index + direction;
|
||||
if (index < 0 || target < 0 || target >= items.length) {
|
||||
return null;
|
||||
}
|
||||
const next = [...items];
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Appends a row (or inserts it after `afterRowId`) and returns the new row id. */
|
||||
export function addEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
options: { afterRowId?: string; createId?: () => string } = {}
|
||||
): { balance: EstimateBalanceSnapshot; rowId: string | null } {
|
||||
let rowId: string | null = null;
|
||||
const next = mapGroup(balance, groupId, (group) => {
|
||||
const row = createEstimateRow(0, options.createId);
|
||||
rowId = row.id;
|
||||
const afterIndex = options.afterRowId
|
||||
? group.rows.findIndex((candidate) => candidate.id === options.afterRowId)
|
||||
: -1;
|
||||
const rows = [...group.rows];
|
||||
rows.splice(afterIndex >= 0 ? afterIndex + 1 : rows.length, 0, row);
|
||||
return { ...group, rows: renumber(rows) };
|
||||
});
|
||||
return { balance: next, rowId };
|
||||
}
|
||||
|
||||
export function updateEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
rowId: string,
|
||||
patch: EstimateRowPatch
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => ({
|
||||
...group,
|
||||
rows: group.rows.map((row) => (row.id === rowId ? { ...row, ...patch } : row)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function removeEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
rowId: string
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => ({
|
||||
...group,
|
||||
rows: renumber(group.rows.filter((row) => row.id !== rowId)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function moveEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
rowId: string,
|
||||
direction: MoveDirection
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => {
|
||||
const rows = moveItem(
|
||||
group.rows,
|
||||
group.rows.findIndex((row) => row.id === rowId),
|
||||
direction
|
||||
);
|
||||
return rows ? { ...group, rows: renumber(rows) } : group;
|
||||
});
|
||||
}
|
||||
|
||||
/** Appends a group and returns its id. */
|
||||
export function addEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
input: { costGroup: string; title: string; editor?: string | null },
|
||||
createId: () => string = createClientId
|
||||
): { balance: EstimateBalanceSnapshot; groupId: string } {
|
||||
const group = createEstimateGroup(input, balance.groups.length, createId);
|
||||
return {
|
||||
balance: { ...balance, groups: renumber([...balance.groups, group]) },
|
||||
groupId: group.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
patch: EstimateGroupPatch
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => ({ ...group, ...patch }));
|
||||
}
|
||||
|
||||
export function removeEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string
|
||||
): EstimateBalanceSnapshot {
|
||||
const groups = balance.groups.filter((group) => group.id !== groupId);
|
||||
return groups.length === balance.groups.length
|
||||
? balance
|
||||
: { ...balance, groups: renumber(groups) };
|
||||
}
|
||||
|
||||
export function moveEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
direction: MoveDirection
|
||||
): EstimateBalanceSnapshot {
|
||||
const groups = moveItem(
|
||||
balance.groups,
|
||||
balance.groups.findIndex((group) => group.id === groupId),
|
||||
direction
|
||||
);
|
||||
return groups ? { ...balance, groups: renumber(groups) } : balance;
|
||||
}
|
||||
|
||||
function requiredText(value: string) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalText(value: string | null) {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact document the server accepts: strings trimmed, empty optional
|
||||
* texts as null, sort orders continuous. Only fields of the shared snapshot
|
||||
* type are copied, so no UI state can leak into the request.
|
||||
*/
|
||||
export function toSaveableEstimateBalance(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
const normalized = normalizeEstimateSortOrder(balance);
|
||||
return {
|
||||
id: normalized.id,
|
||||
projectId: normalized.projectId,
|
||||
name: requiredText(normalized.name),
|
||||
workStage: optionalText(normalized.workStage),
|
||||
dateIso: normalized.dateIso || null,
|
||||
reserveAvPercent: normalized.reserveAvPercent,
|
||||
reserveSvPercent: normalized.reserveSvPercent,
|
||||
sortOrder: normalized.sortOrder,
|
||||
groups: normalized.groups.map((group) => ({
|
||||
id: group.id,
|
||||
costGroup: requiredText(group.costGroup),
|
||||
title: requiredText(group.title),
|
||||
editor: optionalText(group.editor),
|
||||
sortOrder: group.sortOrder,
|
||||
rows: group.rows.map((row) => ({
|
||||
id: row.id,
|
||||
sortOrder: row.sortOrder,
|
||||
supply: optionalText(row.supply),
|
||||
circuitRef: optionalText(row.circuitRef),
|
||||
name: requiredText(row.name),
|
||||
quantity: row.quantity,
|
||||
unit: row.unit,
|
||||
connection: row.connection,
|
||||
powerPerUnitW: row.powerPerUnitW,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi,
|
||||
safetySupply: row.safetySupply,
|
||||
remark: optionalText(row.remark),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable comparison key of the saveable document (dirty detection). */
|
||||
export function estimateBalanceFingerprint(balance: EstimateBalanceSnapshot): string {
|
||||
return JSON.stringify(toSaveableEstimateBalance(balance));
|
||||
}
|
||||
|
||||
/**
|
||||
* German problems that would make the server reject the document. The
|
||||
* editor never saves while this list is not empty.
|
||||
*/
|
||||
export function validateEstimateBalance(balance: EstimateBalanceSnapshot): string[] {
|
||||
const problems: string[] = [];
|
||||
const saveable = toSaveableEstimateBalance(balance);
|
||||
if (!saveable.name) {
|
||||
problems.push("Die Bezeichnung des Stands darf nicht leer sein.");
|
||||
}
|
||||
for (const group of saveable.groups) {
|
||||
if (!group.title) {
|
||||
problems.push(`Kostengruppe ${group.costGroup || "ohne Nummer"}: Titel fehlt.`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
export function createCopyName(name: string) {
|
||||
const copyName = `${name.trim()} (Kopie)`;
|
||||
return copyName.slice(0, estimateTextLimits.balanceName);
|
||||
}
|
||||
122
src/frontend/estimate-balance/estimate-balance-format.ts
Normal file
122
src/frontend/estimate-balance/estimate-balance-format.ts
Normal 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}`;
|
||||
}
|
||||
652
src/frontend/estimate-balance/estimate-balance-sheet.tsx
Normal file
652
src/frontend/estimate-balance/estimate-balance-sheet.tsx
Normal file
|
|
@ -0,0 +1,652 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
calculateEstimateBalance,
|
||||
calculateEstimateRow,
|
||||
estimateRowConnectionLabels,
|
||||
estimateRowUnitLabels,
|
||||
type EstimateBalanceSnapshot,
|
||||
type EstimateGroupSnapshot,
|
||||
type EstimatePower,
|
||||
type EstimateRowConnection,
|
||||
type EstimateRowSnapshot,
|
||||
type EstimateRowUnit,
|
||||
} from "../../shared/estimate-balance/estimate-balance";
|
||||
import {
|
||||
addEstimateRow,
|
||||
estimateTextLimits,
|
||||
moveEstimateGroup,
|
||||
removeEstimateGroup,
|
||||
removeEstimateRow,
|
||||
updateEstimateGroup,
|
||||
updateEstimateRow,
|
||||
type EstimateRowPatch,
|
||||
} from "./estimate-balance-document";
|
||||
import {
|
||||
estimateNumberRanges,
|
||||
formatAmpere,
|
||||
formatDecimalInput,
|
||||
formatKw,
|
||||
parseGermanDecimal,
|
||||
type DecimalRange,
|
||||
} from "./estimate-balance-format";
|
||||
import { DraftInput } from "./estimate-draft-input";
|
||||
|
||||
export type EstimateBalanceUpdate = (
|
||||
update: (balance: EstimateBalanceSnapshot) => EstimateBalanceSnapshot
|
||||
) => void;
|
||||
|
||||
interface EstimateBalanceSheetProps {
|
||||
balance: EstimateBalanceSnapshot;
|
||||
disabled: boolean;
|
||||
onAddGroup: () => void;
|
||||
onChange: EstimateBalanceUpdate;
|
||||
onEditGroup: (group: EstimateGroupSnapshot) => void;
|
||||
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||
threePhaseVoltageV: number;
|
||||
}
|
||||
|
||||
// Leading columns up to cos φ; the power columns follow.
|
||||
const LEADING_COLUMN_COUNT = 10;
|
||||
const COLUMN_COUNT = 17;
|
||||
|
||||
function NumberInput({
|
||||
ariaLabel,
|
||||
disabled,
|
||||
fieldKey,
|
||||
onCommit,
|
||||
onValidityChange,
|
||||
range,
|
||||
value,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
disabled: boolean;
|
||||
fieldKey: string;
|
||||
onCommit: (value: number) => void;
|
||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||
range: DecimalRange;
|
||||
value: number;
|
||||
}) {
|
||||
return (
|
||||
<DraftInput
|
||||
ariaLabel={ariaLabel}
|
||||
className="estimate-cell-input is-numeric"
|
||||
disabled={disabled}
|
||||
format={(current: number) => formatDecimalInput(current)}
|
||||
inputMode="decimal"
|
||||
onCommit={onCommit}
|
||||
onValidityChange={(isValid) => onValidityChange(fieldKey, isValid)}
|
||||
parse={(text) => parseGermanDecimal(text, range)}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInput({
|
||||
ariaLabel,
|
||||
disabled,
|
||||
maxLength,
|
||||
onChange,
|
||||
placeholder,
|
||||
value,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
disabled: boolean;
|
||||
maxLength: number;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
value: string | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
aria-label={ariaLabel}
|
||||
className="estimate-cell-input"
|
||||
disabled={disabled}
|
||||
maxLength={maxLength}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
/>
|
||||
<span className="estimate-print-value">{value ?? ""}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
children,
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
variant = "secondary",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "secondary" | "danger" | "primary";
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
className={`btn btn-sm btn-outline-${variant} estimate-icon-button`}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PowerCells({ power, className = "" }: { power: EstimatePower; className?: string }) {
|
||||
return (
|
||||
<>
|
||||
<td className={`estimate-number ${className}`}>{formatKw(power.kw)}</td>
|
||||
<td className={`estimate-number ${className}`}>{formatKw(power.kva)}</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function groupLabel(group: EstimateGroupSnapshot) {
|
||||
return [group.costGroup, group.title].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function EstimateRowLine({
|
||||
disabled,
|
||||
group,
|
||||
onChange,
|
||||
onOpenGzfPicker,
|
||||
onValidityChange,
|
||||
row,
|
||||
rowIndex,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
group: EstimateGroupSnapshot;
|
||||
onChange: EstimateBalanceUpdate;
|
||||
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||
row: EstimateRowSnapshot;
|
||||
rowIndex: number;
|
||||
}) {
|
||||
const result = calculateEstimateRow(row);
|
||||
const rowLabel = `${groupLabel(group)}, Zeile ${rowIndex + 1}`;
|
||||
const fieldKey = (field: string) => `${row.id}:${field}`;
|
||||
|
||||
function patch(rowPatch: EstimateRowPatch) {
|
||||
onChange((balance) => updateEstimateRow(balance, group.id, row.id, rowPatch));
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="estimate-row">
|
||||
<td className="estimate-cost-group-cell" />
|
||||
<td>
|
||||
<TextInput
|
||||
ariaLabel={`Versorgung (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.supply}
|
||||
onChange={(supply) => patch({ supply })}
|
||||
placeholder="ELT"
|
||||
value={row.supply}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextInput
|
||||
ariaLabel={`Stromkreis-Nr. (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.circuitRef}
|
||||
onChange={(circuitRef) => patch({ circuitRef })}
|
||||
value={row.circuitRef}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextInput
|
||||
ariaLabel={`Verbraucher / Fläche (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.rowName}
|
||||
onChange={(name) => patch({ name })}
|
||||
placeholder="Verbraucher oder Fläche"
|
||||
value={row.name}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NumberInput
|
||||
ariaLabel={`Anzahl / Fläche (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("quantity")}
|
||||
onCommit={(quantity) => patch({ quantity })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.quantity}
|
||||
value={row.quantity}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`Einheit (${rowLabel})`}
|
||||
className="estimate-cell-select"
|
||||
disabled={disabled}
|
||||
onChange={(event) => patch({ unit: event.target.value as EstimateRowUnit })}
|
||||
value={row.unit}
|
||||
>
|
||||
{(Object.keys(estimateRowUnitLabels) as EstimateRowUnit[]).map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
{estimateRowUnitLabels[unit]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="estimate-print-value">{estimateRowUnitLabels[row.unit]}</span>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`Anschluss (${rowLabel})`}
|
||||
className="estimate-cell-select"
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
patch({
|
||||
connection: (event.target.value || null) as EstimateRowConnection | null,
|
||||
})
|
||||
}
|
||||
value={row.connection ?? ""}
|
||||
>
|
||||
<option value="">–</option>
|
||||
{(Object.keys(estimateRowConnectionLabels) as EstimateRowConnection[]).map(
|
||||
(connection) => (
|
||||
<option key={connection} value={connection}>
|
||||
{estimateRowConnectionLabels[connection]}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
<span className="estimate-print-value">
|
||||
{row.connection ? estimateRowConnectionLabels[row.connection] : ""}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="estimate-unit-input">
|
||||
<NumberInput
|
||||
ariaLabel={`Einzelleistung in ${row.unit === "area" ? "W/m²" : "W"} (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("powerPerUnitW")}
|
||||
onCommit={(powerPerUnitW) => patch({ powerPerUnitW })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.powerPerUnitW}
|
||||
value={row.powerPerUnitW}
|
||||
/>
|
||||
<span className="estimate-unit-label">{row.unit === "area" ? "W/m²" : "W"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="estimate-gzf-input">
|
||||
<NumberInput
|
||||
ariaLabel={`Gleichzeitigkeitsfaktor (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("simultaneityFactor")}
|
||||
onCommit={(simultaneityFactor) => patch({ simultaneityFactor })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.simultaneityFactor}
|
||||
value={row.simultaneityFactor}
|
||||
/>
|
||||
<button
|
||||
aria-label={`Gleichzeitigkeitsfaktor nach AMEV wählen (${rowLabel})`}
|
||||
className="estimate-amev-button estimate-no-print"
|
||||
disabled={disabled}
|
||||
onClick={() => onOpenGzfPicker(group.id, row.id)}
|
||||
title="Gleichzeitigkeitsfaktor nach AMEV wählen"
|
||||
type="button"
|
||||
>
|
||||
AMEV
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<NumberInput
|
||||
ariaLabel={`cos φ (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("cosPhi")}
|
||||
onCommit={(cosPhi) => patch({ cosPhi })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.cosPhi}
|
||||
value={row.cosPhi}
|
||||
/>
|
||||
</td>
|
||||
<PowerCells power={result.av} />
|
||||
<td className="estimate-center">
|
||||
<input
|
||||
aria-label={`Sicherheitsversorgung (${rowLabel})`}
|
||||
checked={row.safetySupply}
|
||||
className="form-check-input estimate-no-print"
|
||||
disabled={disabled}
|
||||
onChange={(event) => patch({ safetySupply: event.target.checked })}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="estimate-print-value">{row.safetySupply ? "ja" : "nein"}</span>
|
||||
</td>
|
||||
<PowerCells
|
||||
className={row.safetySupply ? "" : "is-muted"}
|
||||
power={result.sv}
|
||||
/>
|
||||
<td>
|
||||
<TextInput
|
||||
ariaLabel={`Anmerkung (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.remark}
|
||||
onChange={(remark) => patch({ remark })}
|
||||
value={row.remark}
|
||||
/>
|
||||
</td>
|
||||
<td className="estimate-actions-cell estimate-no-print">
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Zeile löschen (${rowLabel})`}
|
||||
onClick={() => onChange((balance) => removeEstimateRow(balance, group.id, row.id))}
|
||||
variant="danger"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spreadsheet-like editor in the layout of the office template
|
||||
* "Leistungsbilanz – KG400": one section per cost group with title and
|
||||
* subtotal row, totals, reserve and maximum current at the end.
|
||||
*/
|
||||
export function EstimateBalanceSheet({
|
||||
balance,
|
||||
disabled,
|
||||
onAddGroup,
|
||||
onChange,
|
||||
onEditGroup,
|
||||
onOpenGzfPicker,
|
||||
onValidityChange,
|
||||
threePhaseVoltageV,
|
||||
}: EstimateBalanceSheetProps) {
|
||||
const result = calculateEstimateBalance(balance, threePhaseVoltageV);
|
||||
|
||||
function confirmRemoveGroup(group: EstimateGroupSnapshot) {
|
||||
const rowText =
|
||||
group.rows.length === 1 ? "1 Zeile" : `${group.rows.length} Zeilen`;
|
||||
if (
|
||||
window.confirm(
|
||||
`Kostengruppe „${groupLabel(group)}“ mit ${rowText} wirklich löschen?`
|
||||
)
|
||||
) {
|
||||
onChange((current) => removeEstimateGroup(current, group.id));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="estimate-sheet-wrap">
|
||||
<table className="estimate-sheet">
|
||||
<colgroup>
|
||||
<col className="estimate-col-kg" />
|
||||
<col className="estimate-col-supply" />
|
||||
<col className="estimate-col-circuit" />
|
||||
<col className="estimate-col-name" />
|
||||
<col className="estimate-col-quantity" />
|
||||
<col className="estimate-col-unit" />
|
||||
<col className="estimate-col-connection" />
|
||||
<col className="estimate-col-power" />
|
||||
<col className="estimate-col-gzf" />
|
||||
<col className="estimate-col-cosphi" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-sv" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-remark" />
|
||||
<col className="estimate-col-actions" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowSpan={2}>KG</th>
|
||||
<th rowSpan={2}>
|
||||
Versorgung
|
||||
<span className="estimate-th-hint">ELT/ISP …</span>
|
||||
</th>
|
||||
<th rowSpan={2}>Stromkreis-Nr.</th>
|
||||
<th rowSpan={2}>Verbraucher / Flächen</th>
|
||||
<th colSpan={2}>
|
||||
Anzahl / Fläche
|
||||
<span className="estimate-th-hint">St. / m²</span>
|
||||
</th>
|
||||
<th rowSpan={2}>
|
||||
Anschl.
|
||||
<span className="estimate-th-hint">Fest/Steckd.</span>
|
||||
</th>
|
||||
<th rowSpan={2}>
|
||||
Einzelleist.
|
||||
<span className="estimate-th-hint">W bzw. W/m²</span>
|
||||
</th>
|
||||
<th rowSpan={2}>GL-Faktor</th>
|
||||
<th rowSpan={2}>cos φ</th>
|
||||
<th colSpan={2}>Nötige AV-Leistung</th>
|
||||
<th colSpan={3}>Sicherheitsversorgung (SV)</th>
|
||||
<th rowSpan={2}>Anmerkungen</th>
|
||||
<th aria-label="Aktionen" className="estimate-no-print" rowSpan={2} />
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Wert</th>
|
||||
<th>Einh.</th>
|
||||
<th>kW</th>
|
||||
<th>kVA</th>
|
||||
<th>ja/nein</th>
|
||||
<th>kW</th>
|
||||
<th>kVA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{balance.groups.map((group, groupIndex) => {
|
||||
const groupResult = result.groups[groupIndex]!;
|
||||
const label = groupLabel(group) || "Kostengruppe";
|
||||
return (
|
||||
<tbody className="estimate-group" key={group.id}>
|
||||
<tr className="estimate-group-title-row">
|
||||
<td className="estimate-cost-group-cell">{group.costGroup}</td>
|
||||
<td colSpan={COLUMN_COUNT - 2}>
|
||||
<div className="estimate-group-title">
|
||||
<span className="estimate-group-name">{group.title}</span>
|
||||
<label className="estimate-group-editor">
|
||||
<span>bearbeitet von</span>
|
||||
<input
|
||||
aria-label={`Bearbeitet von (${label})`}
|
||||
className="estimate-cell-input"
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.groupEditor}
|
||||
onChange={(event) =>
|
||||
onChange((current) =>
|
||||
updateEstimateGroup(current, group.id, {
|
||||
editor: event.target.value,
|
||||
})
|
||||
)
|
||||
}
|
||||
placeholder="Gewerk / Name"
|
||||
type="text"
|
||||
value={group.editor ?? ""}
|
||||
/>
|
||||
<span className="estimate-print-value">{group.editor ?? ""}</span>
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td className="estimate-actions-cell estimate-no-print">
|
||||
<div className="estimate-group-actions">
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Zeile hinzufügen (${label})`}
|
||||
onClick={() =>
|
||||
onChange((current) => addEstimateRow(current, group.id).balance)
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
<Plus aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Gruppe bearbeiten (${label})`}
|
||||
onClick={() => onEditGroup(group)}
|
||||
>
|
||||
<Pencil aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled || groupIndex === 0}
|
||||
label={`Gruppe nach oben (${label})`}
|
||||
onClick={() =>
|
||||
onChange((current) => moveEstimateGroup(current, group.id, -1))
|
||||
}
|
||||
>
|
||||
<ArrowUp aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled || groupIndex === balance.groups.length - 1}
|
||||
label={`Gruppe nach unten (${label})`}
|
||||
onClick={() =>
|
||||
onChange((current) => moveEstimateGroup(current, group.id, 1))
|
||||
}
|
||||
>
|
||||
<ArrowDown aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Gruppe löschen (${label})`}
|
||||
onClick={() => confirmRemoveGroup(group)}
|
||||
variant="danger"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{group.rows.map((row, rowIndex) => (
|
||||
<EstimateRowLine
|
||||
disabled={disabled}
|
||||
group={group}
|
||||
key={row.id}
|
||||
onChange={onChange}
|
||||
onOpenGzfPicker={onOpenGzfPicker}
|
||||
onValidityChange={onValidityChange}
|
||||
row={row}
|
||||
rowIndex={rowIndex}
|
||||
/>
|
||||
))}
|
||||
{!group.rows.length ? (
|
||||
<tr className="estimate-empty-row estimate-no-print">
|
||||
<td />
|
||||
<td colSpan={COLUMN_COUNT - 1}>
|
||||
Noch keine Einträge – mit „+“ eine Zeile hinzufügen.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
<tr className="estimate-subtotal-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Summe {label}
|
||||
</td>
|
||||
<PowerCells power={groupResult.av} />
|
||||
<td />
|
||||
<PowerCells power={groupResult.sv} />
|
||||
<td />
|
||||
<td className="estimate-no-print" />
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
})}
|
||||
<tfoot>
|
||||
<tr className="estimate-no-print estimate-add-group-row">
|
||||
<td colSpan={COLUMN_COUNT}>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
disabled={disabled}
|
||||
onClick={onAddGroup}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" className="me-1" size={14} />
|
||||
Kostengruppe hinzufügen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="estimate-total-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Gesamtsumme
|
||||
</td>
|
||||
<PowerCells power={result.total.av} />
|
||||
<td />
|
||||
<PowerCells power={result.total.sv} />
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
<tr className="estimate-total-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT - 1} className="estimate-total-label">
|
||||
Reserveleistung
|
||||
</td>
|
||||
<td>
|
||||
<div className="estimate-unit-input">
|
||||
<NumberInput
|
||||
ariaLabel="Reserve AV in Prozent"
|
||||
disabled={disabled}
|
||||
fieldKey="balance:reserveAvPercent"
|
||||
onCommit={(reserveAvPercent) =>
|
||||
onChange((current) => ({ ...current, reserveAvPercent }))
|
||||
}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.reservePercent}
|
||||
value={balance.reserveAvPercent}
|
||||
/>
|
||||
<span className="estimate-unit-label">%</span>
|
||||
</div>
|
||||
</td>
|
||||
<PowerCells power={result.reserve.av} />
|
||||
<td>
|
||||
<div className="estimate-unit-input">
|
||||
<NumberInput
|
||||
ariaLabel="Reserve SV in Prozent"
|
||||
disabled={disabled}
|
||||
fieldKey="balance:reserveSvPercent"
|
||||
onCommit={(reserveSvPercent) =>
|
||||
onChange((current) => ({ ...current, reserveSvPercent }))
|
||||
}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.reservePercent}
|
||||
value={balance.reserveSvPercent}
|
||||
/>
|
||||
<span className="estimate-unit-label">%</span>
|
||||
</div>
|
||||
</td>
|
||||
<PowerCells power={result.reserve.sv} />
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
<tr className="estimate-total-row is-strong">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Leistung inkl. Reserve
|
||||
</td>
|
||||
<PowerCells power={result.withReserve.av} />
|
||||
<td />
|
||||
<PowerCells power={result.withReserve.sv} />
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
<tr className="estimate-total-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Maximalstrom
|
||||
<span className="estimate-total-hint">
|
||||
{threePhaseVoltageV > 0
|
||||
? ` (3~, U = ${formatDecimalInput(threePhaseVoltageV)} V)`
|
||||
: " (Drehstromspannung im Projekt fehlt)"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="estimate-number" colSpan={2}>
|
||||
{formatAmpere(result.maxCurrentA.av)} A
|
||||
</td>
|
||||
<td />
|
||||
<td className="estimate-number" colSpan={2}>
|
||||
{formatAmpere(result.maxCurrentA.sv)} A
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/frontend/estimate-balance/estimate-comparison-table.tsx
Normal file
88
src/frontend/estimate-balance/estimate-comparison-table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
src/frontend/estimate-balance/estimate-draft-input.tsx
Normal file
107
src/frontend/estimate-balance/estimate-draft-input.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type DraftParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
|
||||
|
||||
interface DraftInputProps<T> {
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
format: (value: T) => string;
|
||||
inputMode?: "decimal" | "text";
|
||||
maxLength?: number;
|
||||
onCommit: (value: T) => void;
|
||||
/** Reports whether the raw text is currently valid (false while invalid). */
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
parse: (text: string) => DraftParseResult<T>;
|
||||
placeholder?: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input that keeps the raw text the user typed. Only a valid text is
|
||||
* committed into the document; an invalid text stays visible with a red
|
||||
* border and is never saved. A value changed from outside (reload, undo)
|
||||
* replaces the text unless the text already represents that value.
|
||||
*/
|
||||
export function DraftInput<T>({
|
||||
ariaLabel,
|
||||
className = "estimate-cell-input",
|
||||
disabled,
|
||||
format,
|
||||
inputMode = "text",
|
||||
maxLength,
|
||||
onCommit,
|
||||
onValidityChange,
|
||||
parse,
|
||||
placeholder,
|
||||
value,
|
||||
}: DraftInputProps<T>) {
|
||||
const [text, setText] = useState(() => format(value));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [syncedValue, setSyncedValue] = useState(value);
|
||||
|
||||
if (!Object.is(syncedValue, value)) {
|
||||
// Adjusting state while rendering (instead of an effect) avoids a frame
|
||||
// with stale text after an external change.
|
||||
setSyncedValue(value);
|
||||
const parsed = parse(text);
|
||||
if (!parsed.ok || !Object.is(parsed.value, value)) {
|
||||
setText(format(value));
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
const validityRef = useRef(onValidityChange);
|
||||
useEffect(() => {
|
||||
validityRef.current = onValidityChange;
|
||||
});
|
||||
const isValid = error === null;
|
||||
useEffect(() => {
|
||||
validityRef.current?.(isValid);
|
||||
}, [isValid]);
|
||||
// A removed input (deleted row) must not leave an invalid marker behind.
|
||||
useEffect(() => () => validityRef.current?.(true), []);
|
||||
|
||||
function handleChange(nextText: string) {
|
||||
setText(nextText);
|
||||
const parsed = parse(nextText);
|
||||
if (!parsed.ok) {
|
||||
setError(parsed.error);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
if (!Object.is(parsed.value, value)) {
|
||||
setSyncedValue(parsed.value);
|
||||
onCommit(parsed.value);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Show the canonical notation again ("0,80" → "0,8") once editing ends.
|
||||
if (error === null) {
|
||||
setText(format(value));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-label={ariaLabel}
|
||||
className={error ? `${className} is-invalid` : className}
|
||||
disabled={disabled}
|
||||
inputMode={inputMode}
|
||||
maxLength={maxLength}
|
||||
onBlur={handleBlur}
|
||||
onChange={(event) => handleChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
title={error ?? undefined}
|
||||
type="text"
|
||||
value={text}
|
||||
/>
|
||||
<span className="estimate-print-value">{error ? format(value) : text}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
src/frontend/estimate-balance/estimate-group-modal.tsx
Normal file
128
src/frontend/estimate-balance/estimate-group-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
180
src/server/controllers/estimate-balance.controller.ts
Normal file
180
src/server/controllers/estimate-balance.controller.ts
Normal 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)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
316
src/shared/estimate-balance/estimate-balance.ts
Normal file
316
src/shared/estimate-balance/estimate-balance.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* Grobe Leistungsbilanz (LPH 2/3): shared data shapes and pure calculation.
|
||||
*
|
||||
* Used by the API (validation, comparison) and by the frontend (live totals),
|
||||
* so this file has no imports: Turbopack resolves frontend imports without
|
||||
* the `.js` suffix the API build needs.
|
||||
*/
|
||||
|
||||
export type EstimateRowUnit = "piece" | "area";
|
||||
export type EstimateRowConnection = "fixed" | "socket";
|
||||
|
||||
export interface EstimateRowSnapshot {
|
||||
id: string;
|
||||
sortOrder: number;
|
||||
/** Versorgung, e.g. "ELT" or "ISP". */
|
||||
supply: string | null;
|
||||
/** Optional circuit reference, free text. */
|
||||
circuitRef: string | null;
|
||||
/** Verbraucher / Fläche. May be empty while a row is being typed. */
|
||||
name: string;
|
||||
/** Anzahl (unit "piece") or area in m² (unit "area"). */
|
||||
quantity: number;
|
||||
unit: EstimateRowUnit;
|
||||
connection: EstimateRowConnection | null;
|
||||
/** W per piece or W/m². */
|
||||
powerPerUnitW: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi: number;
|
||||
/** Also needed on the safety power supply (SV). */
|
||||
safetySupply: boolean;
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
export interface EstimateGroupSnapshot {
|
||||
id: string;
|
||||
/** DIN 276 cost group, e.g. "410" or "434"; empty for "Allgemein". */
|
||||
costGroup: string;
|
||||
title: string;
|
||||
/** Trade or person responsible for the entries ("bearbeitet von"). */
|
||||
editor: string | null;
|
||||
sortOrder: number;
|
||||
rows: EstimateRowSnapshot[];
|
||||
}
|
||||
|
||||
export interface EstimateBalanceSnapshot {
|
||||
id: string;
|
||||
projectId: string;
|
||||
/** Stand, e.g. "Vorplanung" or "Entwurfsplanung". */
|
||||
name: string;
|
||||
workStage: string | null;
|
||||
/** ISO date YYYY-MM-DD. */
|
||||
dateIso: string | null;
|
||||
reserveAvPercent: number;
|
||||
reserveSvPercent: number;
|
||||
sortOrder: number;
|
||||
groups: EstimateGroupSnapshot[];
|
||||
}
|
||||
|
||||
/** Default structure of the office template "Leistungsbilanz - KG400". */
|
||||
export const estimateDefaultGroups: ReadonlyArray<{ costGroup: string; title: string }> = [
|
||||
{ costGroup: "410", title: "Sanitärtechnik" },
|
||||
{ costGroup: "420", title: "Wärmeversorgung" },
|
||||
{ costGroup: "430", title: "Lufttechnische Anlagen" },
|
||||
{ costGroup: "434", title: "Kälteanlagen" },
|
||||
{ costGroup: "440", title: "Starkstromanlagen" },
|
||||
{ costGroup: "450", title: "Schwachstromanlagen" },
|
||||
{ costGroup: "460", title: "Fördertechnik" },
|
||||
{ costGroup: "470", title: "Nutzungsspezifische Anlagen" },
|
||||
{ costGroup: "480", title: "Gebäudeautomation" },
|
||||
{ costGroup: "", title: "Allgemein" },
|
||||
];
|
||||
|
||||
export const estimateRowUnitLabels: Record<EstimateRowUnit, string> = {
|
||||
piece: "St.",
|
||||
area: "m²",
|
||||
};
|
||||
|
||||
export const estimateRowConnectionLabels: Record<EstimateRowConnection, string> = {
|
||||
fixed: "Fest",
|
||||
socket: "Steckd.",
|
||||
};
|
||||
|
||||
export interface EstimatePower {
|
||||
kw: number;
|
||||
kva: number;
|
||||
}
|
||||
|
||||
export interface EstimateRowResult {
|
||||
av: EstimatePower;
|
||||
sv: EstimatePower;
|
||||
}
|
||||
|
||||
export interface EstimateGroupResult {
|
||||
groupId: string;
|
||||
av: EstimatePower;
|
||||
sv: EstimatePower;
|
||||
}
|
||||
|
||||
export interface EstimateBalanceResult {
|
||||
groups: EstimateGroupResult[];
|
||||
total: { av: EstimatePower; sv: EstimatePower };
|
||||
reserve: { av: EstimatePower; sv: EstimatePower };
|
||||
withReserve: { av: EstimatePower; sv: EstimatePower };
|
||||
/** Maximum current of the three-phase supply in A (S / (√3 · U)). */
|
||||
maxCurrentA: { av: number; sv: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds a calculated value for display like the spreadsheet does: binary
|
||||
* floating point stores 40.175 as 40.17499…, so the scaled value is first
|
||||
* cut to 6 decimals and only then rounded half away from zero
|
||||
* (40.175 -> 40.18, -1.825 -> -1.83).
|
||||
*/
|
||||
export function roundEstimateValue(value: number, digits = 2): number {
|
||||
const factor = 10 ** digits;
|
||||
const scaled = Number((Math.abs(value) * factor).toFixed(6));
|
||||
return (Math.sign(value) * Math.round(scaled)) / factor;
|
||||
}
|
||||
|
||||
const zeroPower = (): EstimatePower => ({ kw: 0, kva: 0 });
|
||||
|
||||
function addPower(left: EstimatePower, right: EstimatePower): EstimatePower {
|
||||
return { kw: left.kw + right.kw, kva: left.kva + right.kva };
|
||||
}
|
||||
|
||||
function scalePower(power: EstimatePower, factor: number): EstimatePower {
|
||||
return { kw: power.kw * factor, kva: power.kva * factor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Required power of one row: P = quantity · power per unit · GLF, S = P / cos φ.
|
||||
* AV covers every consumer (normal supply); SV additionally lists the rows
|
||||
* that must also be supplied by the safety power supply.
|
||||
*/
|
||||
export function calculateEstimateRow(
|
||||
row: Pick<
|
||||
EstimateRowSnapshot,
|
||||
"quantity" | "powerPerUnitW" | "simultaneityFactor" | "cosPhi" | "safetySupply"
|
||||
>
|
||||
): EstimateRowResult {
|
||||
const kw = (row.quantity * row.powerPerUnitW * row.simultaneityFactor) / 1000;
|
||||
const kva = row.cosPhi > 0 ? kw / row.cosPhi : 0;
|
||||
const av = { kw, kva };
|
||||
return { av, sv: row.safetySupply ? { ...av } : zeroPower() };
|
||||
}
|
||||
|
||||
export function calculateEstimateBalance(
|
||||
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
|
||||
threePhaseVoltageV: number
|
||||
): EstimateBalanceResult {
|
||||
const groups = balance.groups.map((group) =>
|
||||
group.rows.reduce<EstimateGroupResult>(
|
||||
(sum, row) => {
|
||||
const result = calculateEstimateRow(row);
|
||||
return {
|
||||
groupId: group.id,
|
||||
av: addPower(sum.av, result.av),
|
||||
sv: addPower(sum.sv, result.sv),
|
||||
};
|
||||
},
|
||||
{ groupId: group.id, av: zeroPower(), sv: zeroPower() }
|
||||
)
|
||||
);
|
||||
const total = groups.reduce(
|
||||
(sum, group) => ({ av: addPower(sum.av, group.av), sv: addPower(sum.sv, group.sv) }),
|
||||
{ av: zeroPower(), sv: zeroPower() }
|
||||
);
|
||||
const reserve = {
|
||||
av: scalePower(total.av, balance.reserveAvPercent / 100),
|
||||
sv: scalePower(total.sv, balance.reserveSvPercent / 100),
|
||||
};
|
||||
const withReserve = {
|
||||
av: addPower(total.av, reserve.av),
|
||||
sv: addPower(total.sv, reserve.sv),
|
||||
};
|
||||
const currentFactor =
|
||||
threePhaseVoltageV > 0 ? 1000 / (Math.sqrt(3) * threePhaseVoltageV) : 0;
|
||||
return {
|
||||
groups,
|
||||
total,
|
||||
reserve,
|
||||
withReserve,
|
||||
maxCurrentA: {
|
||||
av: withReserve.av.kva * currentFactor,
|
||||
sv: withReserve.sv.kva * currentFactor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns a detailed cost group (e.g. "444" or "440.1") to the best matching
|
||||
* estimate group: a group code with trailing zeros removed is a prefix
|
||||
* ("430" -> "43", "434" -> "434"); the longest prefix wins. Returns null when
|
||||
* no group matches.
|
||||
*/
|
||||
export function matchEstimateCostGroup(
|
||||
detailedCostGroup: string | null,
|
||||
groupCostGroups: readonly string[]
|
||||
): string | null {
|
||||
const digits = (detailedCostGroup ?? "").replace(/\D/g, "");
|
||||
if (!digits) {
|
||||
return null;
|
||||
}
|
||||
let best: { code: string; length: number } | null = null;
|
||||
for (const code of groupCostGroups) {
|
||||
const codeDigits = code.replace(/\D/g, "");
|
||||
const prefix = codeDigits.replace(/0+$/, "");
|
||||
if (!prefix || !digits.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
if (!best || prefix.length > best.length) {
|
||||
best = { code, length: prefix.length };
|
||||
}
|
||||
}
|
||||
return best?.code ?? null;
|
||||
}
|
||||
|
||||
export interface EstimateComparisonGroup {
|
||||
groupId: string;
|
||||
costGroup: string;
|
||||
title: string;
|
||||
estimatedAv: EstimatePower;
|
||||
detailed: EstimatePower;
|
||||
detailedRowCount: number;
|
||||
deltaKw: number;
|
||||
/** Relative deviation of the detailed value from the estimate, null without estimate. */
|
||||
deltaPercent: number | null;
|
||||
}
|
||||
|
||||
export interface EstimateComparison {
|
||||
groups: EstimateComparisonGroup[];
|
||||
unassigned: EstimatePower & { rowCount: number };
|
||||
total: {
|
||||
estimatedAv: EstimatePower;
|
||||
detailed: EstimatePower;
|
||||
deltaKw: number;
|
||||
deltaPercent: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DetailedDeviceRowPower {
|
||||
costGroup: string | null;
|
||||
/** Total power of the device row in kW (quantity · power · device GZF). */
|
||||
kw: number;
|
||||
cosPhi: number | null;
|
||||
}
|
||||
|
||||
function delta(estimated: number, detailed: number) {
|
||||
return {
|
||||
deltaKw: detailed - estimated,
|
||||
deltaPercent: estimated > 0 ? ((detailed - estimated) / estimated) * 100 : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the estimate per cost group with the detailed device rows of all
|
||||
* circuit lists. The distribution-board simultaneity factor is not applied
|
||||
* on either side; a missing cos φ counts as 1.
|
||||
*/
|
||||
export function compareEstimateWithDetailed(
|
||||
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
|
||||
detailedRows: readonly DetailedDeviceRowPower[]
|
||||
): EstimateComparison {
|
||||
const estimate = calculateEstimateBalance(balance, 0);
|
||||
const codes = balance.groups.map((group) => group.costGroup).filter(Boolean);
|
||||
const detailedByCode = new Map<string, EstimatePower & { rowCount: number }>();
|
||||
const unassigned = { kw: 0, kva: 0, rowCount: 0 };
|
||||
for (const row of detailedRows) {
|
||||
const kva = row.cosPhi && row.cosPhi > 0 ? row.kw / row.cosPhi : row.kw;
|
||||
const code = matchEstimateCostGroup(row.costGroup, codes);
|
||||
const target =
|
||||
code === null
|
||||
? unassigned
|
||||
: detailedByCode.get(code) ??
|
||||
detailedByCode.set(code, { kw: 0, kva: 0, rowCount: 0 }).get(code)!;
|
||||
target.kw += row.kw;
|
||||
target.kva += kva;
|
||||
target.rowCount += 1;
|
||||
}
|
||||
// A cost group code can appear in several groups; the detailed value is
|
||||
// shown once, on the first group with that code.
|
||||
const shownCodes = new Set<string>();
|
||||
const groups = balance.groups.map((group, index) => {
|
||||
const estimatedAv = estimate.groups[index]!.av;
|
||||
const detailedEntry =
|
||||
group.costGroup && !shownCodes.has(group.costGroup)
|
||||
? detailedByCode.get(group.costGroup)
|
||||
: undefined;
|
||||
if (group.costGroup) {
|
||||
shownCodes.add(group.costGroup);
|
||||
}
|
||||
const detailed = { kw: detailedEntry?.kw ?? 0, kva: detailedEntry?.kva ?? 0 };
|
||||
return {
|
||||
groupId: group.id,
|
||||
costGroup: group.costGroup,
|
||||
title: group.title,
|
||||
estimatedAv,
|
||||
detailed,
|
||||
detailedRowCount: detailedEntry?.rowCount ?? 0,
|
||||
...delta(estimatedAv.kw, detailed.kw),
|
||||
};
|
||||
});
|
||||
const detailedTotal = groups.reduce(
|
||||
(sum, group) => addPower(sum, group.detailed),
|
||||
{ kw: unassigned.kw, kva: unassigned.kva }
|
||||
);
|
||||
return {
|
||||
groups,
|
||||
unassigned,
|
||||
total: {
|
||||
estimatedAv: estimate.total.av,
|
||||
detailed: detailedTotal,
|
||||
...delta(estimate.total.av.kw, detailedTotal.kw),
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue