Integrate rough estimate balance
This commit is contained in:
commit
573487666c
45 changed files with 9378 additions and 58 deletions
|
|
@ -2002,3 +2002,710 @@ button.structure-entry-main:focus-visible {
|
|||
color: var(--color-ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ── Grobe Leistungsbilanz ──────────────────────────────────────────── */
|
||||
/* Tabelle im Stil der Büro-Vorlage „Leistungsbilanz – KG400“: kompakte
|
||||
Zellen-Inputs statt Bootstrap-Formularfelder, Druck als A4 quer. */
|
||||
|
||||
.estimate-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.estimate-save-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.estimate-save-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-ink-soft);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.estimate-save-badge.is-saved {
|
||||
color: var(--color-signal-dark);
|
||||
border-color: var(--color-signal);
|
||||
}
|
||||
|
||||
.estimate-save-badge.is-busy {
|
||||
color: var(--color-warn);
|
||||
border-color: var(--color-warn);
|
||||
}
|
||||
|
||||
.estimate-save-badge.is-error {
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger-bg);
|
||||
}
|
||||
|
||||
.estimate-save-hint {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.estimate-header-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm) var(--space-lg);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.estimate-header-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.estimate-header-field.is-wide {
|
||||
flex: 1 1 18rem;
|
||||
}
|
||||
|
||||
.estimate-header-label {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink-soft);
|
||||
}
|
||||
|
||||
.estimate-header-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.estimate-header-input {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
border-color: var(--panel-border);
|
||||
}
|
||||
|
||||
.estimate-sheet-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.estimate-sheet {
|
||||
width: 100%;
|
||||
min-width: 1400px;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
background: var(--panel-bg);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.estimate-comparison {
|
||||
min-width: 640px;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.estimate-sheet th,
|
||||
.estimate-sheet td {
|
||||
border: 1px solid var(--panel-border);
|
||||
padding: 2px 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.estimate-sheet thead th {
|
||||
background: var(--surface-header-row);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.estimate-th-hint {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.estimate-col-kg { width: 3rem; }
|
||||
.estimate-col-supply { width: 4rem; }
|
||||
.estimate-col-name { width: auto; }
|
||||
.estimate-col-quantity { width: 4.8rem; }
|
||||
.estimate-col-unit { width: 3.8rem; }
|
||||
.estimate-col-connection { width: 5rem; }
|
||||
.estimate-col-power { width: 6.4rem; }
|
||||
.estimate-col-gzf { width: 6.8rem; }
|
||||
.estimate-col-cosphi { width: 3.8rem; }
|
||||
.estimate-col-result { width: 4.6rem; }
|
||||
.estimate-col-sv { width: 3.2rem; }
|
||||
.estimate-col-remark { width: 9rem; }
|
||||
.estimate-col-actions { width: 2.4rem; }
|
||||
|
||||
.estimate-cell-input,
|
||||
.estimate-cell-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 1px 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.estimate-cell-select {
|
||||
padding-right: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.estimate-cell-input:hover:not(:disabled),
|
||||
.estimate-cell-select:hover:not(:disabled) {
|
||||
border-color: var(--panel-border-strong);
|
||||
}
|
||||
|
||||
.estimate-cell-input:focus,
|
||||
.estimate-cell-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue);
|
||||
background: var(--panel-bg);
|
||||
box-shadow: 0 0 0 2px var(--accent-blue-soft-bg);
|
||||
}
|
||||
|
||||
.estimate-cell-input.is-numeric {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.estimate-cell-input.is-invalid,
|
||||
.estimate-cell-input.is-invalid:focus,
|
||||
.estimate-cell-input.is-invalid:hover {
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger-bg);
|
||||
}
|
||||
|
||||
.estimate-cell-input::placeholder {
|
||||
color: var(--text-faint);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.estimate-unit-input,
|
||||
.estimate-gzf-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.estimate-unit-label {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-faint);
|
||||
min-width: 1.9rem;
|
||||
}
|
||||
|
||||
.estimate-amev-button {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--panel-border-strong);
|
||||
border-radius: 3px;
|
||||
background: var(--panel-bg-subtle);
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 1px 3px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.estimate-amev-button:hover:not(:disabled) {
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.estimate-number {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.estimate-number.is-muted {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.estimate-number.is-over {
|
||||
color: var(--color-attention-dark);
|
||||
}
|
||||
|
||||
.estimate-number.is-under {
|
||||
color: var(--color-signal-dark);
|
||||
}
|
||||
|
||||
.estimate-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.estimate-cost-group-cell {
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.estimate-group-title-row td {
|
||||
background: var(--surface-component-header);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.estimate-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs) var(--space-lg);
|
||||
}
|
||||
|
||||
.estimate-group-name {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.estimate-group-editor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.estimate-group-editor .estimate-cell-input {
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
.estimate-group-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.estimate-group-title-row .estimate-actions-cell {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.estimate-icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.15rem 0.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.estimate-actions-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.estimate-empty-row td {
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.estimate-subtotal-row td {
|
||||
background: var(--panel-bg-subtle);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.estimate-total-label {
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.estimate-total-hint {
|
||||
font-weight: 400;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.estimate-total-row td {
|
||||
background: var(--surface-header-row);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.estimate-total-row.is-strong td {
|
||||
font-weight: 800;
|
||||
border-top: 2px solid var(--panel-border-strong);
|
||||
}
|
||||
|
||||
.estimate-add-group-row td {
|
||||
background: var(--panel-bg);
|
||||
padding: var(--space-sm) 4px;
|
||||
}
|
||||
|
||||
.estimate-unassigned-row td {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.estimate-collapse-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.estimate-list-table .estimate-number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Druck-Textwerte: auf dem Bildschirm unsichtbar, im Druck statt Inputs */
|
||||
.estimate-print-value {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Ausrichtung: alle Zellen zentriert, nur „Verbraucher / Flächen“, die
|
||||
Gruppentitel und die Summenbeschriftungen stehen links bzw. rechts. */
|
||||
.estimate-sheet td,
|
||||
.estimate-sheet td.estimate-number,
|
||||
.estimate-sheet .estimate-cell-input,
|
||||
.estimate-sheet .estimate-cell-input.is-numeric {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.estimate-sheet .estimate-cell-select {
|
||||
text-align: center;
|
||||
text-align-last: center;
|
||||
}
|
||||
|
||||
.estimate-sheet .estimate-unit-input,
|
||||
.estimate-sheet .estimate-gzf-input {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.estimate-sheet td.estimate-name-cell,
|
||||
.estimate-sheet td.estimate-name-cell .estimate-cell-input,
|
||||
.estimate-sheet .estimate-group-title-row td:not(.estimate-cost-group-cell),
|
||||
.estimate-comparison td:nth-child(2) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.estimate-sheet td.estimate-total-label {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Druck im kbp-CD der Seitenansicht (src/frontend/print/print-document.css):
|
||||
gleiche Farben, Schrift, Titelblock und Fußzeile. Die Fußzeile mit
|
||||
Seitenzahlen setzt EstimatePrintHeader als @page-Regel mit Projektdaten. */
|
||||
.estimate-print-titleblock {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@page estimate-balance {
|
||||
size: A4 landscape;
|
||||
margin: 10mm 10mm 13mm;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body:has(.estimate-print-root) .sidebar,
|
||||
.estimate-print-root .estimate-no-print,
|
||||
.estimate-print-root .estimate-page-header,
|
||||
.estimate-print-root .estimate-header-fields,
|
||||
.estimate-print-root .alert,
|
||||
.estimate-print-root input,
|
||||
.estimate-print-root select,
|
||||
.estimate-print-root button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html:has(.estimate-print-root),
|
||||
body:has(.estimate-print-root),
|
||||
body:has(.estimate-print-root) .app-shell,
|
||||
body:has(.estimate-print-root) .app-shell-content {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
/* Der Dunkelmodus setzt color-scheme: dark; Chrome färbt damit auch den
|
||||
Seitenrand dunkel. Im Druck gilt immer das helle Schema. */
|
||||
html:has(.estimate-print-root) {
|
||||
color-scheme: light !important;
|
||||
}
|
||||
|
||||
.estimate-print-root .estimate-group-editor.is-empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Verbraucher bekommt den Platz; Anmerkungen bleiben schmal. */
|
||||
.estimate-sheet .estimate-col-name {
|
||||
width: 52mm;
|
||||
}
|
||||
|
||||
.estimate-sheet .estimate-col-remark {
|
||||
width: 24mm;
|
||||
}
|
||||
|
||||
.estimate-sheet .estimate-col-supply {
|
||||
width: 13mm;
|
||||
}
|
||||
|
||||
body:has(.estimate-print-root) .app-shell {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.estimate-print-root {
|
||||
--ep-blue: #476088;
|
||||
--ep-lilac: #9198c4;
|
||||
--ep-line: #d6dae5;
|
||||
--ep-tint: #f5f6fa;
|
||||
--ep-text: #2b2b2b;
|
||||
--ep-muted: #6b7280;
|
||||
--ep-accent: #c9782a;
|
||||
page: estimate-balance;
|
||||
padding: 0 !important;
|
||||
max-width: none;
|
||||
color: var(--ep-text);
|
||||
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.estimate-print-root * {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.estimate-print-root .estimate-print-value {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
/* Titelblock wie der Plankopf der Seitenansicht */
|
||||
.estimate-print-titleblock {
|
||||
display: grid;
|
||||
grid-template-columns: 30mm 1fr auto auto;
|
||||
margin-bottom: 4mm;
|
||||
border: 1.5px solid var(--ep-blue);
|
||||
background: #fff;
|
||||
font-size: 8.5pt;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.estimate-print-titleblock > * {
|
||||
padding: 2.5mm 3.5mm;
|
||||
border-left: 1px solid var(--ep-line);
|
||||
}
|
||||
|
||||
.estimate-print-titleblock > :first-child {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.estimate-print-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 16mm;
|
||||
color: var(--ep-blue);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.estimate-print-logo img {
|
||||
max-width: 100%;
|
||||
max-height: 10mm;
|
||||
}
|
||||
|
||||
.estimate-print-kicker {
|
||||
color: var(--ep-muted);
|
||||
font-size: 7pt;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.estimate-print-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 1mm 4mm;
|
||||
margin-top: 1mm;
|
||||
}
|
||||
|
||||
.estimate-print-stand {
|
||||
color: var(--ep-blue);
|
||||
font-size: 16pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.estimate-print-project {
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.estimate-print-meta {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
gap: 0.8mm 4mm;
|
||||
align-content: center;
|
||||
margin: 0;
|
||||
font-size: 7.5pt;
|
||||
}
|
||||
|
||||
.estimate-print-meta div {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.estimate-print-meta dt {
|
||||
color: var(--ep-muted);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.estimate-print-meta dd {
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.estimate-print-meta.is-tinted {
|
||||
background: var(--ep-tint);
|
||||
}
|
||||
|
||||
/* Karten und Ränder der Bildschirmansicht entfallen */
|
||||
.estimate-print-root .card {
|
||||
border: 0;
|
||||
box-shadow: none !important;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.estimate-print-root .card-body,
|
||||
.estimate-print-root .card-header {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.estimate-print-root .estimate-comparison-card {
|
||||
margin-top: 6mm;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.estimate-print-root .estimate-comparison-card .card-header {
|
||||
margin-bottom: 2mm;
|
||||
color: var(--ep-blue);
|
||||
font-size: 10pt;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.estimate-sheet-wrap {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Tabelle */
|
||||
.estimate-sheet {
|
||||
min-width: 0;
|
||||
table-layout: auto;
|
||||
font-size: 7.4pt;
|
||||
line-height: 1.2;
|
||||
background: #fff;
|
||||
color: var(--ep-text);
|
||||
}
|
||||
|
||||
.estimate-sheet th,
|
||||
.estimate-sheet td {
|
||||
border: 1px solid var(--ep-line) !important;
|
||||
padding: 0.7mm 1.5mm;
|
||||
background: #fff;
|
||||
color: var(--ep-text);
|
||||
}
|
||||
|
||||
.estimate-sheet thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
.estimate-sheet thead th {
|
||||
background: var(--ep-blue) !important;
|
||||
border-color: var(--ep-blue) !important;
|
||||
color: #fff !important;
|
||||
font-weight: bold;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.estimate-sheet thead .estimate-th-hint {
|
||||
color: #dfe3f0 !important;
|
||||
}
|
||||
|
||||
.estimate-group-title-row td {
|
||||
background: var(--ep-tint) !important;
|
||||
border-top: 1.5px solid var(--ep-lilac) !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.estimate-group-title-row .estimate-cost-group-cell {
|
||||
color: var(--ep-blue) !important;
|
||||
}
|
||||
|
||||
.estimate-group-title-row .estimate-group-editor {
|
||||
color: var(--ep-muted);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.estimate-subtotal-row td {
|
||||
border-bottom: 1.5px solid var(--ep-line) !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.estimate-subtotal-row .estimate-total-label {
|
||||
color: var(--ep-muted);
|
||||
}
|
||||
|
||||
.estimate-total-row td {
|
||||
background: var(--ep-tint) !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.estimate-total-row.is-strong td {
|
||||
background: var(--ep-blue) !important;
|
||||
border-color: var(--ep-blue) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.estimate-total-row .estimate-total-hint {
|
||||
color: var(--ep-muted);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.estimate-total-row.is-strong .estimate-total-hint {
|
||||
color: #dfe3f0;
|
||||
}
|
||||
|
||||
.estimate-comparison .is-over {
|
||||
color: var(--ep-accent) !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.estimate-comparison .is-under {
|
||||
color: var(--ep-blue) !important;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.estimate-group-title-row,
|
||||
.estimate-subtotal-row,
|
||||
.estimate-total-row,
|
||||
.estimate-row {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.estimate-group-title-row {
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
/* Summen einmal am Ende, nicht auf jeder Seite */
|
||||
.estimate-sheet tfoot {
|
||||
display: table-row-group;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.estimate-col-actions {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.estimate-unit-input,
|
||||
.estimate-gzf-input {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
745
src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx
Normal file
745
src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx
Normal file
|
|
@ -0,0 +1,745 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Printer,
|
||||
Redo2,
|
||||
RefreshCw,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ApiRequestError,
|
||||
getEstimateBalance,
|
||||
getEstimateComparison,
|
||||
getProject,
|
||||
getProjectHistory,
|
||||
isRevisionConflictError,
|
||||
redoProjectCommand,
|
||||
undoProjectCommand,
|
||||
updateEstimateBalance,
|
||||
} from "../../../../../frontend/utils/api";
|
||||
import type {
|
||||
EstimateBalanceDto,
|
||||
EstimateComparisonDto,
|
||||
ProjectDto,
|
||||
ProjectHistoryStateDto,
|
||||
} from "../../../../../frontend/types";
|
||||
import type { EstimateGroupSnapshot } from "../../../../../shared/estimate-balance/estimate-balance";
|
||||
import { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal";
|
||||
import {
|
||||
addEstimateGroup,
|
||||
estimateBalanceFingerprint,
|
||||
estimateTextLimits,
|
||||
normalizeEstimateSortOrder,
|
||||
toSaveableEstimateBalance,
|
||||
updateEstimateGroup,
|
||||
updateEstimateRow,
|
||||
validateEstimateBalance,
|
||||
} from "../../../../../frontend/estimate-balance/estimate-balance-document";
|
||||
import { formatIsoDateGerman } from "../../../../../frontend/estimate-balance/estimate-balance-format";
|
||||
import {
|
||||
EstimateBalanceSheet,
|
||||
type EstimateBalanceUpdate,
|
||||
} from "../../../../../frontend/estimate-balance/estimate-balance-sheet";
|
||||
import { EstimateComparisonTable } from "../../../../../frontend/estimate-balance/estimate-comparison-table";
|
||||
import { DraftInput } from "../../../../../frontend/estimate-balance/estimate-draft-input";
|
||||
import { EstimatePrintHeader } from "../../../../../frontend/estimate-balance/estimate-print-header";
|
||||
import {
|
||||
EstimateGroupModal,
|
||||
type EstimateGroupFormValues,
|
||||
} from "../../../../../frontend/estimate-balance/estimate-group-modal";
|
||||
|
||||
const AUTOSAVE_DELAY_MS = 800;
|
||||
const CONFLICT_MESSAGE = "Die Bilanz wurde zwischenzeitlich geändert und neu geladen.";
|
||||
|
||||
type SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid";
|
||||
|
||||
type GroupDialog = { group: EstimateGroupSnapshot | null };
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown) {
|
||||
return error instanceof ApiRequestError && error.status === 404;
|
||||
}
|
||||
|
||||
export default function EstimateBalanceEditorPage() {
|
||||
const params = useParams<{ projectId: string; balanceId: string }>();
|
||||
const { projectId, balanceId } = params;
|
||||
const router = useRouter();
|
||||
|
||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||
const [draft, setDraft] = useState<EstimateBalanceDto | null>(null);
|
||||
const [history, setHistory] = useState<ProjectHistoryStateDto | null>(null);
|
||||
const [comparison, setComparison] = useState<EstimateComparisonDto | null>(null);
|
||||
const [comparisonError, setComparisonError] = useState<string | null>(null);
|
||||
const [isComparisonOpen, setIsComparisonOpen] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("saved");
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isMissing, setIsMissing] = useState(false);
|
||||
const [isHistoryBusy, setIsHistoryBusy] = useState(false);
|
||||
const [invalidFieldKeys, setInvalidFieldKeys] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
const [groupDialog, setGroupDialog] = useState<GroupDialog | null>(null);
|
||||
const [gzfTarget, setGzfTarget] = useState<{ groupId: string; rowId: string } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Refs carry the latest values into timers, unload handlers and chained
|
||||
// async saves without re-creating them.
|
||||
const draftRef = useRef<EstimateBalanceDto | null>(null);
|
||||
const revisionRef = useRef<number | null>(null);
|
||||
const savedFingerprintRef = useRef<string | null>(null);
|
||||
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
const applyServerBalance = useCallback((balance: EstimateBalanceDto) => {
|
||||
const normalized = normalizeEstimateSortOrder(balance);
|
||||
savedFingerprintRef.current = estimateBalanceFingerprint(normalized);
|
||||
draftRef.current = normalized;
|
||||
setDraft(normalized);
|
||||
setSaveStatus("saved");
|
||||
setSaveError(null);
|
||||
}, []);
|
||||
|
||||
const reloadComparison = useCallback(async () => {
|
||||
try {
|
||||
setComparison(await getEstimateComparison(projectId, balanceId));
|
||||
setComparisonError(null);
|
||||
} catch (err) {
|
||||
setComparisonError(
|
||||
errorMessage(err, "Der Vergleich konnte nicht geladen werden.")
|
||||
);
|
||||
}
|
||||
}, [balanceId, projectId]);
|
||||
|
||||
/** Reloads project, document and history; replaces the local draft. */
|
||||
const reloadAll = useCallback(async () => {
|
||||
const balanceRequest = getEstimateBalance(projectId, balanceId);
|
||||
// Handled below; avoids an "unhandled rejection" while project/history load.
|
||||
balanceRequest.catch(() => undefined);
|
||||
// Project and history are applied even when the balance is missing, so
|
||||
// Redo stays available after its creation was undone.
|
||||
const [currentProject, currentHistory] = await Promise.all([
|
||||
getProject(projectId),
|
||||
getProjectHistory(projectId).catch(() => null),
|
||||
]);
|
||||
setProject(currentProject);
|
||||
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
|
||||
setHistory(currentHistory);
|
||||
const balance = await balanceRequest;
|
||||
applyServerBalance(balance);
|
||||
setIsMissing(false);
|
||||
void reloadComparison();
|
||||
}, [applyServerBalance, balanceId, projectId, reloadComparison]);
|
||||
|
||||
const handleReloadError = useCallback((err: unknown) => {
|
||||
if (isNotFoundError(err)) {
|
||||
setIsMissing(true);
|
||||
setDraft(null);
|
||||
draftRef.current = null;
|
||||
return;
|
||||
}
|
||||
setError(errorMessage(err, "Die grobe Leistungsbilanz konnte nicht geladen werden."));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !balanceId) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
reloadAll()
|
||||
.then(() => setError(null))
|
||||
.catch(handleReloadError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [balanceId, handleReloadError, projectId, reloadAll]);
|
||||
|
||||
const clearSaveTimer = useCallback(() => {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Saves the current draft if it differs from the last saved state. Saves
|
||||
* never overlap: a save requested while another runs waits for it and then
|
||||
* saves whatever changed in the meantime. Resolves to false when the draft
|
||||
* could not be saved.
|
||||
*/
|
||||
const saveNow = useCallback(async (): Promise<boolean> => {
|
||||
clearSaveTimer();
|
||||
while (saveInFlightRef.current) {
|
||||
await saveInFlightRef.current;
|
||||
}
|
||||
const current = draftRef.current;
|
||||
const revision = revisionRef.current;
|
||||
if (!current || revision === null) {
|
||||
return true;
|
||||
}
|
||||
const fingerprint = estimateBalanceFingerprint(current);
|
||||
if (fingerprint === savedFingerprintRef.current) {
|
||||
setSaveStatus("saved");
|
||||
return true;
|
||||
}
|
||||
const problems = validateEstimateBalance(current);
|
||||
if (problems.length) {
|
||||
setSaveStatus("invalid");
|
||||
setSaveError(problems.join(" "));
|
||||
return false;
|
||||
}
|
||||
const body = toSaveableEstimateBalance(current);
|
||||
|
||||
/**
|
||||
* Another command changed the project revision. When this document is
|
||||
* still unchanged on the server (e.g. an edit in a circuit list), the
|
||||
* draft is kept and saved again; otherwise the server state wins.
|
||||
*/
|
||||
async function resolveConflict(): Promise<"retry" | "reloaded"> {
|
||||
const [currentProject, serverBalance, currentHistory] = await Promise.all([
|
||||
getProject(projectId),
|
||||
getEstimateBalance(projectId, balanceId),
|
||||
getProjectHistory(projectId).catch(() => null),
|
||||
]);
|
||||
setProject(currentProject);
|
||||
setHistory(currentHistory);
|
||||
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
|
||||
if (estimateBalanceFingerprint(serverBalance) === savedFingerprintRef.current) {
|
||||
return "retry";
|
||||
}
|
||||
applyServerBalance(serverBalance);
|
||||
setNotice(CONFLICT_MESSAGE);
|
||||
void reloadComparison();
|
||||
return "reloaded";
|
||||
}
|
||||
|
||||
const run = (async () => {
|
||||
setSaveStatus("saving");
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
const result = await updateEstimateBalance(
|
||||
projectId,
|
||||
body,
|
||||
attempt === 0 ? revision : revisionRef.current ?? revision
|
||||
);
|
||||
if (!("unchanged" in result)) {
|
||||
revisionRef.current = result.history.currentRevision;
|
||||
setHistory(result.history);
|
||||
setProject((currentProject) =>
|
||||
currentProject
|
||||
? { ...currentProject, currentRevision: result.history.currentRevision }
|
||||
: currentProject
|
||||
);
|
||||
}
|
||||
// The sent document is the saved state; the local draft is kept so
|
||||
// typing is never interrupted by the server's copy.
|
||||
savedFingerprintRef.current = fingerprint;
|
||||
setSaveError(null);
|
||||
const latest = draftRef.current;
|
||||
if (latest && estimateBalanceFingerprint(latest) !== fingerprint) {
|
||||
setSaveStatus("pending");
|
||||
} else {
|
||||
setSaveStatus("saved");
|
||||
}
|
||||
void reloadComparison();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isRevisionConflictError(err)) {
|
||||
try {
|
||||
if (attempt === 0 && (await resolveConflict()) === "retry") {
|
||||
continue;
|
||||
}
|
||||
if (attempt > 0) {
|
||||
await reloadAll();
|
||||
setNotice(CONFLICT_MESSAGE);
|
||||
}
|
||||
} catch (reloadError) {
|
||||
handleReloadError(reloadError);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (isNotFoundError(err)) {
|
||||
handleReloadError(err);
|
||||
return false;
|
||||
}
|
||||
setSaveStatus("error");
|
||||
setSaveError(errorMessage(err, "Die Änderungen konnten nicht gespeichert werden."));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
saveInFlightRef.current = run;
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
saveInFlightRef.current = null;
|
||||
}
|
||||
}, [
|
||||
applyServerBalance,
|
||||
balanceId,
|
||||
clearSaveTimer,
|
||||
handleReloadError,
|
||||
projectId,
|
||||
reloadAll,
|
||||
reloadComparison,
|
||||
]);
|
||||
|
||||
// Debounced autosave after every change of the draft.
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
if (estimateBalanceFingerprint(draft) === savedFingerprintRef.current) {
|
||||
return;
|
||||
}
|
||||
setSaveStatus((status) => (status === "saving" ? status : "pending"));
|
||||
clearSaveTimer();
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveTimerRef.current = null;
|
||||
void saveNow();
|
||||
}, AUTOSAVE_DELAY_MS);
|
||||
return clearSaveTimer;
|
||||
}, [clearSaveTimer, draft, saveNow]);
|
||||
|
||||
// Save immediately when the tab is hidden; on unload/unmount send the last
|
||||
// change with keepalive so it survives the page.
|
||||
useEffect(() => {
|
||||
function hasUnsavedDraft() {
|
||||
const current = draftRef.current;
|
||||
return Boolean(
|
||||
current && estimateBalanceFingerprint(current) !== savedFingerprintRef.current
|
||||
);
|
||||
}
|
||||
function sendKeepalive(): boolean {
|
||||
const current = draftRef.current;
|
||||
const revision = revisionRef.current;
|
||||
if (
|
||||
!current ||
|
||||
revision === null ||
|
||||
saveInFlightRef.current ||
|
||||
validateEstimateBalance(current).length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
savedFingerprintRef.current = estimateBalanceFingerprint(current);
|
||||
void updateEstimateBalance(projectId, toSaveableEstimateBalance(current), revision, {
|
||||
keepalive: true,
|
||||
}).catch(() => undefined);
|
||||
return true;
|
||||
}
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "hidden" && hasUnsavedDraft()) {
|
||||
void saveNow();
|
||||
}
|
||||
}
|
||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!hasUnsavedDraft() || sendKeepalive()) {
|
||||
return;
|
||||
}
|
||||
// A save is still running or the draft is invalid: let the browser ask.
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
}
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
// In-app navigation (e.g. sidebar) unmounts the editor.
|
||||
if (hasUnsavedDraft()) {
|
||||
sendKeepalive();
|
||||
}
|
||||
};
|
||||
}, [projectId, saveNow]);
|
||||
|
||||
const handleChange: EstimateBalanceUpdate = useCallback((update) => {
|
||||
setNotice(null);
|
||||
setDraft((current) => (current ? update(current) : current));
|
||||
}, []);
|
||||
|
||||
const handleValidityChange = useCallback((fieldKey: string, isValid: boolean) => {
|
||||
setInvalidFieldKeys((current) => {
|
||||
if (isValid ? !current.has(fieldKey) : current.has(fieldKey)) {
|
||||
return current;
|
||||
}
|
||||
const next = new Set(current);
|
||||
if (isValid) {
|
||||
next.delete(fieldKey);
|
||||
} else {
|
||||
next.add(fieldKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function handleHistory(direction: "undo" | "redo") {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setIsHistoryBusy(true);
|
||||
try {
|
||||
// Pending edits are saved first so they become their own undo step.
|
||||
await saveNow();
|
||||
const revision = revisionRef.current;
|
||||
if (revision === null) {
|
||||
return;
|
||||
}
|
||||
const result =
|
||||
direction === "undo"
|
||||
? await undoProjectCommand(projectId, revision)
|
||||
: await redoProjectCommand(projectId, revision);
|
||||
revisionRef.current = result.history.currentRevision;
|
||||
setHistory(result.history);
|
||||
await reloadAll();
|
||||
} catch (err) {
|
||||
if (isRevisionConflictError(err)) {
|
||||
await reloadAll().catch(handleReloadError);
|
||||
setNotice(CONFLICT_MESSAGE);
|
||||
} else if (isNotFoundError(err)) {
|
||||
handleReloadError(err);
|
||||
} else {
|
||||
setError(
|
||||
errorMessage(
|
||||
err,
|
||||
direction === "undo"
|
||||
? "Die letzte Änderung konnte nicht rückgängig gemacht werden."
|
||||
: "Die letzte Änderung konnte nicht wiederholt werden."
|
||||
)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsHistoryBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBackClick(event: MouseEvent<HTMLAnchorElement>) {
|
||||
const current = draftRef.current;
|
||||
if (!current || estimateBalanceFingerprint(current) === savedFingerprintRef.current) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
await saveNow();
|
||||
router.push(`/projects/${projectId}/grobbilanz`);
|
||||
}
|
||||
|
||||
function handleSaveGroup(values: EstimateGroupFormValues) {
|
||||
const editingGroup = groupDialog?.group ?? null;
|
||||
handleChange((current) =>
|
||||
editingGroup
|
||||
? updateEstimateGroup(current, editingGroup.id, values)
|
||||
: addEstimateGroup(current, values).balance
|
||||
);
|
||||
setGroupDialog(null);
|
||||
}
|
||||
|
||||
function handleSelectGzf(value: number) {
|
||||
if (gzfTarget) {
|
||||
handleChange((current) =>
|
||||
updateEstimateRow(current, gzfTarget.groupId, gzfTarget.rowId, {
|
||||
simultaneityFactor: value,
|
||||
})
|
||||
);
|
||||
}
|
||||
setGzfTarget(null);
|
||||
}
|
||||
|
||||
const isEditingDisabled = !draft || isHistoryBusy;
|
||||
const invalidCount = invalidFieldKeys.size;
|
||||
const listHref = `/projects/${projectId}/grobbilanz`;
|
||||
|
||||
return (
|
||||
<main className="container-fluid py-4 estimate-print-root">
|
||||
{project && draft ? <EstimatePrintHeader balance={draft} project={project} /> : null}
|
||||
<div className="page-header estimate-page-header">
|
||||
<div className="d-flex align-items-start gap-3">
|
||||
<Link
|
||||
aria-label="Zurück zur Übersicht der Stände"
|
||||
className="btn btn-outline-secondary btn-sm structure-back-link estimate-no-print"
|
||||
href={listHref}
|
||||
onClick={(event) => void handleBackClick(event)}
|
||||
title="Zurück zur Übersicht der Stände"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" size={18} />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="kicker">{project?.name ?? "Projekt"}</div>
|
||||
<h1>Grobe Leistungsbilanz{draft ? ` – ${draft.name}` : ""}</h1>
|
||||
<p className="text-secondary mb-0 estimate-no-print">
|
||||
Leistungsabschätzung nach Kostengruppen (DIN 276) für LPH 2/3.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="estimate-toolbar estimate-no-print">
|
||||
<SaveStatusBadge
|
||||
error={saveError}
|
||||
invalidCount={invalidCount}
|
||||
onRetry={() => void saveNow()}
|
||||
status={saveStatus}
|
||||
/>
|
||||
<div className="btn-group btn-group-sm" role="group" aria-label="Verlauf">
|
||||
<button
|
||||
aria-label="Rückgängig"
|
||||
className="btn btn-outline-secondary"
|
||||
disabled={isHistoryBusy || !history || history.undoDepth === 0}
|
||||
onClick={() => void handleHistory("undo")}
|
||||
title="Rückgängig (letzte Änderung im Projekt)"
|
||||
type="button"
|
||||
>
|
||||
<Undo2 aria-hidden="true" size={16} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Wiederholen"
|
||||
className="btn btn-outline-secondary"
|
||||
disabled={isHistoryBusy || !history || history.redoDepth === 0}
|
||||
onClick={() => void handleHistory("redo")}
|
||||
title="Wiederholen (im Projekt)"
|
||||
type="button"
|
||||
>
|
||||
<Redo2 aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Drucken"
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
disabled={!draft}
|
||||
onClick={() => window.print()}
|
||||
title="Drucken"
|
||||
type="button"
|
||||
>
|
||||
<Printer aria-hidden="true" className="me-1" size={16} />
|
||||
Drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice ? (
|
||||
<div className="alert alert-info estimate-no-print" role="status">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="alert alert-warning estimate-no-print" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<p className="text-secondary" role="status">
|
||||
Grobe Leistungsbilanz wird geladen …
|
||||
</p>
|
||||
) : null}
|
||||
{isMissing ? (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
Dieser Stand existiert nicht (mehr), z. B. weil er gelöscht oder sein Anlegen
|
||||
rückgängig gemacht wurde.{" "}
|
||||
<Link href={listHref}>Zur Übersicht der Stände</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{draft && project ? (
|
||||
<>
|
||||
<section className="card shadow-sm mb-3 estimate-sheet-card">
|
||||
<div className="card-body">
|
||||
<div className="estimate-header-fields">
|
||||
<div className="estimate-header-field">
|
||||
<span className="estimate-header-label">Projekt</span>
|
||||
<span className="estimate-header-value">{project.name}</span>
|
||||
</div>
|
||||
<label className="estimate-header-field is-wide">
|
||||
<span className="estimate-header-label">Stand</span>
|
||||
<DraftInput
|
||||
ariaLabel="Bezeichnung des Stands"
|
||||
className="estimate-cell-input estimate-header-input"
|
||||
disabled={isEditingDisabled}
|
||||
format={(value: string) => value}
|
||||
maxLength={estimateTextLimits.balanceName}
|
||||
onCommit={(name) => handleChange((current) => ({ ...current, name }))}
|
||||
onValidityChange={(isValid) =>
|
||||
handleValidityChange("balance:name", isValid)
|
||||
}
|
||||
parse={(text) =>
|
||||
text.trim()
|
||||
? { ok: true, value: text }
|
||||
: { ok: false, error: "Die Bezeichnung darf nicht leer sein." }
|
||||
}
|
||||
value={draft.name}
|
||||
/>
|
||||
</label>
|
||||
<label className="estimate-header-field">
|
||||
<span className="estimate-header-label">LPH</span>
|
||||
<input
|
||||
aria-label="Leistungsphase"
|
||||
className="estimate-cell-input estimate-header-input"
|
||||
disabled={isEditingDisabled}
|
||||
maxLength={estimateTextLimits.workStage}
|
||||
onChange={(event) =>
|
||||
handleChange((current) => ({
|
||||
...current,
|
||||
workStage: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="z. B. LPH 3"
|
||||
type="text"
|
||||
value={draft.workStage ?? ""}
|
||||
/>
|
||||
<span className="estimate-print-value">{draft.workStage ?? ""}</span>
|
||||
</label>
|
||||
<label className="estimate-header-field">
|
||||
<span className="estimate-header-label">Datum</span>
|
||||
<input
|
||||
aria-label="Datum"
|
||||
className="estimate-cell-input estimate-header-input"
|
||||
disabled={isEditingDisabled}
|
||||
onChange={(event) =>
|
||||
handleChange((current) => ({
|
||||
...current,
|
||||
dateIso: event.target.value || null,
|
||||
}))
|
||||
}
|
||||
type="date"
|
||||
value={draft.dateIso ?? ""}
|
||||
/>
|
||||
<span className="estimate-print-value">
|
||||
{formatIsoDateGerman(draft.dateIso)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<EstimateBalanceSheet
|
||||
balance={draft}
|
||||
disabled={isEditingDisabled}
|
||||
onAddGroup={() => setGroupDialog({ group: null })}
|
||||
onChange={handleChange}
|
||||
onEditGroup={(group) => setGroupDialog({ group })}
|
||||
onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })}
|
||||
onValidityChange={handleValidityChange}
|
||||
threePhaseVoltageV={project.threePhaseVoltageV}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={
|
||||
isComparisonOpen
|
||||
? "card shadow-sm estimate-comparison-card"
|
||||
: "card shadow-sm estimate-comparison-card estimate-no-print"
|
||||
}
|
||||
>
|
||||
<div className="card-header d-flex justify-content-between align-items-center gap-2">
|
||||
<button
|
||||
aria-controls="estimate-comparison-body"
|
||||
aria-expanded={isComparisonOpen}
|
||||
className="estimate-collapse-toggle"
|
||||
onClick={() => setIsComparisonOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
{isComparisonOpen ? (
|
||||
<ChevronDown aria-hidden="true" size={16} />
|
||||
) : (
|
||||
<ChevronRight aria-hidden="true" size={16} />
|
||||
)}
|
||||
Vergleich mit detaillierter Bilanz
|
||||
</button>
|
||||
{isComparisonOpen ? (
|
||||
<button
|
||||
aria-label="Vergleich neu laden"
|
||||
className="btn btn-sm btn-outline-secondary structure-icon-button estimate-no-print"
|
||||
onClick={() => void reloadComparison()}
|
||||
title="Vergleich neu laden"
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{isComparisonOpen ? (
|
||||
<div className="card-body" id="estimate-comparison-body">
|
||||
{comparisonError ? (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
{comparisonError}
|
||||
</div>
|
||||
) : null}
|
||||
{comparison ? <EstimateComparisonTable comparison={comparison} /> : null}
|
||||
<p className="text-secondary small mt-2 mb-0">
|
||||
Detailliert = Summe der Gerätezeilen aller Stromkreislisten (Anzahl ×
|
||||
Leistung × Geräte-GZF), zugeordnet über die Kostengruppe; ohne
|
||||
Verteiler-GZF.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{groupDialog ? (
|
||||
<EstimateGroupModal
|
||||
group={groupDialog.group}
|
||||
onClose={() => setGroupDialog(null)}
|
||||
onSave={handleSaveGroup}
|
||||
/>
|
||||
) : null}
|
||||
{gzfTarget ? (
|
||||
<GzfPickerModal onClose={() => setGzfTarget(null)} onSelect={handleSelectGzf} />
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveStatusBadge({
|
||||
error,
|
||||
invalidCount,
|
||||
onRetry,
|
||||
status,
|
||||
}: {
|
||||
error: string | null;
|
||||
invalidCount: number;
|
||||
onRetry: () => void;
|
||||
status: SaveStatus;
|
||||
}) {
|
||||
const label =
|
||||
status === "saving"
|
||||
? "Wird gespeichert …"
|
||||
: status === "pending"
|
||||
? "Ungespeicherte Änderungen"
|
||||
: status === "error"
|
||||
? "Nicht gespeichert – Fehler"
|
||||
: status === "invalid"
|
||||
? "Nicht gespeichert – ungültige Eingaben"
|
||||
: "Gespeichert";
|
||||
const tone =
|
||||
status === "error" || status === "invalid"
|
||||
? "is-error"
|
||||
: status === "saved"
|
||||
? "is-saved"
|
||||
: "is-busy";
|
||||
return (
|
||||
<div className="estimate-save-status" role="status" aria-live="polite">
|
||||
<span className={`estimate-save-badge ${tone}`} title={error ?? undefined}>
|
||||
{label}
|
||||
</span>
|
||||
{invalidCount ? (
|
||||
<span className="estimate-save-hint">
|
||||
{invalidCount === 1
|
||||
? "1 ungültige Eingabe wird nicht gespeichert"
|
||||
: `${invalidCount} ungültige Eingaben werden nicht gespeichert`}
|
||||
</span>
|
||||
) : null}
|
||||
{status === "error" ? (
|
||||
<button className="btn btn-link btn-sm p-0" onClick={onRetry} type="button">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
42
src/db/migrations/0010_estimate_balances.sql
Normal file
42
src/db/migrations/0010_estimate_balances.sql
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
CREATE TABLE `estimate_balances` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`work_stage` text,
|
||||
`date_iso` text,
|
||||
`reserve_av_percent` real DEFAULT 0 NOT NULL,
|
||||
`reserve_sv_percent` real DEFAULT 0 NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `estimate_balances_project_id_idx` ON `estimate_balances` (`project_id`);--> statement-breakpoint
|
||||
CREATE TABLE `estimate_groups` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`balance_id` text NOT NULL,
|
||||
`cost_group` text DEFAULT '' NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`editor` text,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (`balance_id`) REFERENCES `estimate_balances`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `estimate_groups_balance_id_idx` ON `estimate_groups` (`balance_id`);--> statement-breakpoint
|
||||
CREATE TABLE `estimate_rows` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`group_id` text NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`supply` text,
|
||||
`name` text DEFAULT '' NOT NULL,
|
||||
`quantity` real DEFAULT 0 NOT NULL,
|
||||
`unit` text DEFAULT 'piece' NOT NULL,
|
||||
`connection` text,
|
||||
`power_per_unit_w` real DEFAULT 0 NOT NULL,
|
||||
`simultaneity_factor` real DEFAULT 1 NOT NULL,
|
||||
`cos_phi` real DEFAULT 1 NOT NULL,
|
||||
`safety_supply` integer DEFAULT false NOT NULL,
|
||||
`remark` text,
|
||||
FOREIGN KEY (`group_id`) REFERENCES `estimate_groups`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `estimate_rows_group_id_idx` ON `estimate_rows` (`group_id`);
|
||||
2924
src/db/migrations/meta/0010_snapshot.json
Normal file
2924
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": 1790438092269,
|
||||
"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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ import { ProjectStateConflictError } from "../../domain/errors/project-state-con
|
|||
import {
|
||||
assertProjectStateRestoreCommand,
|
||||
createProjectStateRestoreCommand,
|
||||
createProjectTransferRestoreCommand,
|
||||
projectStateRestoreCommandSchemaVersion,
|
||||
} from "../../domain/models/project-state-restore-command.model.js";
|
||||
import {
|
||||
parseProjectStateSnapshot,
|
||||
|
|
@ -13,6 +15,8 @@ import type {
|
|||
ProjectStateRestoreCommandStore,
|
||||
} from "../../domain/ports/project-state-restore-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { canonicalEstimateBalance } from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
||||
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
|
|
@ -32,6 +36,11 @@ import { externalImportBatches } from "../schema/external-import-batches.js";
|
|||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||
import { estimateBalances } from "../schema/estimate-balances.js";
|
||||
import {
|
||||
insertEstimateBalance,
|
||||
readEstimateBalances,
|
||||
} from "./estimate-balance.persistence.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import {
|
||||
readProjectStateSnapshot,
|
||||
|
|
@ -66,10 +75,39 @@ export class ProjectStateRestoreCommandRepository
|
|||
if (!current) {
|
||||
throw new Error("Project not found.");
|
||||
}
|
||||
const currentEstimateBalances = readEstimateBalances(tx, input.projectId);
|
||||
const transferCommand =
|
||||
input.command.schemaVersion === projectStateRestoreCommandSchemaVersion
|
||||
? input.command
|
||||
: null;
|
||||
if (
|
||||
current.payloadSha256 !==
|
||||
input.command.payload.expectedStateSha256 &&
|
||||
!matchesHistoryRestoreTarget(tx, input, current.state)
|
||||
!matchesHistoryRestoreTarget(
|
||||
tx,
|
||||
input,
|
||||
current.state,
|
||||
currentEstimateBalances
|
||||
)
|
||||
) {
|
||||
throw new ProjectStateConflictError(
|
||||
input.projectId,
|
||||
input.command.payload.expectedStateSha256,
|
||||
current.payloadSha256
|
||||
);
|
||||
}
|
||||
if (
|
||||
transferCommand &&
|
||||
canonicalEstimateBalances(currentEstimateBalances) !==
|
||||
canonicalEstimateBalances(
|
||||
transferCommand.payload.expectedEstimateBalances
|
||||
) &&
|
||||
!matchesHistoryRestoreTarget(
|
||||
tx,
|
||||
input,
|
||||
current.state,
|
||||
currentEstimateBalances
|
||||
)
|
||||
) {
|
||||
throw new ProjectStateConflictError(
|
||||
input.projectId,
|
||||
|
|
@ -82,14 +120,28 @@ export class ProjectStateRestoreCommandRepository
|
|||
input.command.payload.targetState
|
||||
);
|
||||
replaceProjectState(tx, targetState);
|
||||
if (transferCommand) {
|
||||
replaceEstimateBalances(
|
||||
tx,
|
||||
input.projectId,
|
||||
transferCommand.payload.targetEstimateBalances
|
||||
);
|
||||
}
|
||||
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
|
||||
if (!persistedTarget) {
|
||||
throw new Error("Restored project could not be loaded.");
|
||||
}
|
||||
const inverse = createProjectStateRestoreCommand(
|
||||
persistedTarget.payloadSha256,
|
||||
current.state
|
||||
);
|
||||
const inverse = transferCommand
|
||||
? createProjectTransferRestoreCommand(
|
||||
persistedTarget.payloadSha256,
|
||||
current.state,
|
||||
transferCommand.payload.targetEstimateBalances,
|
||||
currentEstimateBalances
|
||||
)
|
||||
: createProjectStateRestoreCommand(
|
||||
persistedTarget.payloadSha256,
|
||||
current.state
|
||||
);
|
||||
|
||||
return inverse;
|
||||
}
|
||||
|
|
@ -98,7 +150,8 @@ export class ProjectStateRestoreCommandRepository
|
|||
function matchesHistoryRestoreTarget(
|
||||
database: AppDatabase,
|
||||
input: ExecuteProjectStateRestoreCommandInput,
|
||||
currentState: ProjectStateSnapshot
|
||||
currentState: ProjectStateSnapshot,
|
||||
currentEstimateBalances: EstimateBalanceSnapshot[]
|
||||
) {
|
||||
if (
|
||||
(input.source !== "undo" && input.source !== "redo") ||
|
||||
|
|
@ -122,25 +175,45 @@ function matchesHistoryRestoreTarget(
|
|||
input.source === "undo"
|
||||
? changeSet.forwardPayloadJson
|
||||
: changeSet.inversePayloadJson;
|
||||
const command = JSON.parse(serialized) as {
|
||||
type?: unknown;
|
||||
payload?: { targetState?: unknown };
|
||||
};
|
||||
if (command.type !== "project.restore-state") {
|
||||
const command = JSON.parse(serialized) as unknown;
|
||||
assertProjectStateRestoreCommand(command);
|
||||
const expectedState = parseProjectStateSnapshot(command.payload.targetState);
|
||||
const stateMatches =
|
||||
canonicalProjectState(expectedState) ===
|
||||
canonicalProjectState(currentState);
|
||||
if (!stateMatches) {
|
||||
return false;
|
||||
}
|
||||
const expectedState = parseProjectStateSnapshot(
|
||||
command.payload?.targetState
|
||||
);
|
||||
return (
|
||||
canonicalProjectState(expectedState) ===
|
||||
canonicalProjectState(currentState)
|
||||
);
|
||||
return command.schemaVersion !== projectStateRestoreCommandSchemaVersion ||
|
||||
canonicalEstimateBalances(command.payload.targetEstimateBalances) ===
|
||||
canonicalEstimateBalances(currentEstimateBalances);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalEstimateBalances(balances: EstimateBalanceSnapshot[]) {
|
||||
return JSON.stringify(
|
||||
[...balances]
|
||||
.sort((left, right) => left.id.localeCompare(right.id))
|
||||
.map((balance) => canonicalEstimateBalance(balance))
|
||||
);
|
||||
}
|
||||
|
||||
function replaceEstimateBalances(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
balances: EstimateBalanceSnapshot[]
|
||||
) {
|
||||
database
|
||||
.delete(estimateBalances)
|
||||
.where(eq(estimateBalances.projectId, projectId))
|
||||
.run();
|
||||
for (const balance of balances) {
|
||||
insertEstimateBalance(database, balance);
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalProjectState(state: ProjectStateSnapshot) {
|
||||
return JSON.stringify({
|
||||
...state,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import { eq } from "drizzle-orm";
|
|||
import {
|
||||
createProjectTransferEnvelope,
|
||||
parseProjectTransferEnvelope,
|
||||
remapEstimateBalances,
|
||||
remapProjectState,
|
||||
serializeProjectTransferPayload,
|
||||
} from "../../domain/models/project-transfer.model.js";
|
||||
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
||||
import { createProjectTransferRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
||||
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
|
|
@ -26,6 +28,10 @@ import { externalImportBatches } from "../schema/external-import-batches.js";
|
|||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||
import {
|
||||
insertEstimateBalance,
|
||||
readEstimateBalances,
|
||||
} from "./estimate-balance.persistence.js";
|
||||
import {
|
||||
hashProjectStatePayload,
|
||||
readProjectStateSnapshot,
|
||||
|
|
@ -40,10 +46,15 @@ export class ProjectTransferRepository implements ProjectTransferStore {
|
|||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
const estimateBalances = readEstimateBalances(this.database, projectId);
|
||||
const payloadSha256 = hashProjectStatePayload(
|
||||
serializeProjectTransferPayload(snapshot.state, estimateBalances)
|
||||
);
|
||||
return createProjectTransferEnvelope(
|
||||
new Date().toISOString(),
|
||||
snapshot.payloadSha256,
|
||||
snapshot.state
|
||||
payloadSha256,
|
||||
snapshot.state,
|
||||
estimateBalances
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -61,10 +72,21 @@ export class ProjectTransferRepository implements ProjectTransferStore {
|
|||
projectId,
|
||||
() => crypto.randomUUID()
|
||||
);
|
||||
const currentEstimateBalances = readEstimateBalances(this.database, projectId);
|
||||
const targetEstimateBalances =
|
||||
transfer.projectState.project.id === projectId
|
||||
? transfer.estimateBalances
|
||||
: remapEstimateBalances(
|
||||
transfer.estimateBalances,
|
||||
projectId,
|
||||
() => crypto.randomUUID()
|
||||
);
|
||||
return {
|
||||
command: createProjectStateRestoreCommand(
|
||||
command: createProjectTransferRestoreCommand(
|
||||
current.payloadSha256,
|
||||
targetState
|
||||
targetState,
|
||||
currentEstimateBalances,
|
||||
targetEstimateBalances
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
@ -78,20 +100,39 @@ export class ProjectTransferRepository implements ProjectTransferStore {
|
|||
() => crypto.randomUUID(),
|
||||
`${transfer.projectState.project.name} (Kopie)`
|
||||
);
|
||||
this.database.transaction((tx) => insertProjectState(tx, state));
|
||||
const estimateBalances = remapEstimateBalances(
|
||||
transfer.estimateBalances,
|
||||
projectId,
|
||||
() => crypto.randomUUID()
|
||||
);
|
||||
this.database.transaction((tx) => {
|
||||
insertProjectState(tx, state);
|
||||
for (const balance of estimateBalances) {
|
||||
insertEstimateBalance(tx, balance);
|
||||
}
|
||||
});
|
||||
return { projectId, name: state.project.name };
|
||||
}
|
||||
|
||||
private parseVerified(value: unknown) {
|
||||
const rawPayloadSha256 =
|
||||
isPlainObject(value) &&
|
||||
isPlainObject(value.projectState)
|
||||
? hashProjectStatePayload(JSON.stringify(value.projectState))
|
||||
: null;
|
||||
const isLegacy = isPlainObject(value) && value.formatVersion === 1;
|
||||
const rawPayloadSha256 = isPlainObject(value) && isPlainObject(value.projectState)
|
||||
? hashProjectStatePayload(
|
||||
isLegacy
|
||||
? JSON.stringify(value.projectState)
|
||||
: JSON.stringify({
|
||||
projectState: value.projectState,
|
||||
estimateBalances: value.estimateBalances,
|
||||
})
|
||||
)
|
||||
: null;
|
||||
const transfer = parseProjectTransferEnvelope(value);
|
||||
const payloadJson = serializeProjectStateSnapshot(
|
||||
transfer.projectState
|
||||
);
|
||||
const payloadJson = isLegacy
|
||||
? serializeProjectStateSnapshot(transfer.projectState)
|
||||
: serializeProjectTransferPayload(
|
||||
transfer.projectState,
|
||||
transfer.estimateBalances
|
||||
);
|
||||
if (
|
||||
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
|
||||
rawPayloadSha256 !== transfer.payloadSha256
|
||||
|
|
|
|||
60
src/db/schema/estimate-balances.ts
Normal file
60
src/db/schema/estimate-balances.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { projects } from "./projects.js";
|
||||
|
||||
// Grobe Leistungsbilanz (LPH 2/3): a separate module without links into the
|
||||
// circuit structure. See docs/estimate-balance.md.
|
||||
export const estimateBalances = sqliteTable(
|
||||
"estimate_balances",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
workStage: text("work_stage"),
|
||||
dateIso: text("date_iso"),
|
||||
reserveAvPercent: real("reserve_av_percent").notNull().default(0),
|
||||
reserveSvPercent: real("reserve_sv_percent").notNull().default(0),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
},
|
||||
(table) => [index("estimate_balances_project_id_idx").on(table.projectId)]
|
||||
);
|
||||
|
||||
export const estimateGroups = sqliteTable(
|
||||
"estimate_groups",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
balanceId: text("balance_id")
|
||||
.notNull()
|
||||
.references(() => estimateBalances.id, { onDelete: "cascade" }),
|
||||
costGroup: text("cost_group").notNull().default(""),
|
||||
title: text("title").notNull(),
|
||||
editor: text("editor"),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
},
|
||||
(table) => [index("estimate_groups_balance_id_idx").on(table.balanceId)]
|
||||
);
|
||||
|
||||
export const estimateRows = sqliteTable(
|
||||
"estimate_rows",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
groupId: text("group_id")
|
||||
.notNull()
|
||||
.references(() => estimateGroups.id, { onDelete: "cascade" }),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
supply: text("supply"),
|
||||
name: text("name").notNull().default(""),
|
||||
quantity: real("quantity").notNull().default(0),
|
||||
unit: text("unit", { enum: ["piece", "area"] }).notNull().default("piece"),
|
||||
connection: text("connection", { enum: ["fixed", "socket"] }),
|
||||
powerPerUnitW: real("power_per_unit_w").notNull().default(0),
|
||||
simultaneityFactor: real("simultaneity_factor").notNull().default(1),
|
||||
cosPhi: real("cos_phi").notNull().default(1),
|
||||
safetySupply: integer("safety_supply", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
remark: text("remark"),
|
||||
},
|
||||
(table) => [index("estimate_rows_group_id_idx").on(table.groupId)]
|
||||
);
|
||||
|
|
@ -3,32 +3,80 @@ import {
|
|||
type ProjectStateSnapshot,
|
||||
} from "./project-state-snapshot.model.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
import {
|
||||
parseEstimateBalanceSnapshot,
|
||||
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
||||
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
||||
|
||||
export const projectStateRestoreCommandType = "project.restore-state";
|
||||
export const projectStateRestoreCommandSchemaVersion = 1 as const;
|
||||
export const projectStateRestoreCommandSchemaVersion = 2 as const;
|
||||
const snapshotRestoreCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface ProjectStateRestoreCommandPayload {
|
||||
expectedStateSha256: string;
|
||||
targetState: ProjectStateSnapshot;
|
||||
}
|
||||
|
||||
export type ProjectStateRestoreCommand = SerializedProjectCommand<
|
||||
export type SnapshotProjectStateRestoreCommand = SerializedProjectCommand<
|
||||
ProjectStateRestoreCommandPayload
|
||||
> & {
|
||||
schemaVersion: typeof snapshotRestoreCommandSchemaVersion;
|
||||
type: typeof projectStateRestoreCommandType;
|
||||
};
|
||||
|
||||
export interface ProjectTransferRestoreCommandPayload
|
||||
extends ProjectStateRestoreCommandPayload {
|
||||
expectedEstimateBalances: EstimateBalanceSnapshot[];
|
||||
targetEstimateBalances: EstimateBalanceSnapshot[];
|
||||
}
|
||||
|
||||
export type ProjectTransferRestoreCommand = SerializedProjectCommand<
|
||||
ProjectTransferRestoreCommandPayload
|
||||
> & {
|
||||
schemaVersion: typeof projectStateRestoreCommandSchemaVersion;
|
||||
type: typeof projectStateRestoreCommandType;
|
||||
};
|
||||
|
||||
export type ProjectStateRestoreCommand =
|
||||
| SnapshotProjectStateRestoreCommand
|
||||
| ProjectTransferRestoreCommand;
|
||||
|
||||
export function createProjectStateRestoreCommand(
|
||||
expectedStateSha256: string,
|
||||
targetState: ProjectStateSnapshot
|
||||
): ProjectStateRestoreCommand {
|
||||
const command: ProjectStateRestoreCommand = {
|
||||
const command: SnapshotProjectStateRestoreCommand = {
|
||||
schemaVersion: snapshotRestoreCommandSchemaVersion,
|
||||
type: projectStateRestoreCommandType,
|
||||
payload: {
|
||||
expectedStateSha256,
|
||||
targetState: parseProjectStateSnapshot(targetState),
|
||||
},
|
||||
};
|
||||
assertProjectStateRestoreCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function createProjectTransferRestoreCommand(
|
||||
expectedStateSha256: string,
|
||||
targetState: ProjectStateSnapshot,
|
||||
expectedEstimateBalances: EstimateBalanceSnapshot[],
|
||||
targetEstimateBalances: EstimateBalanceSnapshot[]
|
||||
): ProjectTransferRestoreCommand {
|
||||
const command: ProjectTransferRestoreCommand = {
|
||||
schemaVersion: projectStateRestoreCommandSchemaVersion,
|
||||
type: projectStateRestoreCommandType,
|
||||
payload: {
|
||||
expectedStateSha256,
|
||||
targetState: parseProjectStateSnapshot(targetState),
|
||||
expectedEstimateBalances: parseEstimateBalances(
|
||||
expectedEstimateBalances,
|
||||
targetState.project.id
|
||||
),
|
||||
targetEstimateBalances: parseEstimateBalances(
|
||||
targetEstimateBalances,
|
||||
targetState.project.id
|
||||
),
|
||||
},
|
||||
};
|
||||
assertProjectStateRestoreCommand(command);
|
||||
|
|
@ -42,10 +90,7 @@ export function assertProjectStateRestoreCommand(
|
|||
throw new Error("Project state restore command must be an object.");
|
||||
}
|
||||
const command = value as Partial<ProjectStateRestoreCommand>;
|
||||
if (
|
||||
command.type !== projectStateRestoreCommandType ||
|
||||
command.schemaVersion !== projectStateRestoreCommandSchemaVersion
|
||||
) {
|
||||
if (command.type !== projectStateRestoreCommandType) {
|
||||
throw new Error("Unsupported project state restore command.");
|
||||
}
|
||||
if (!command.payload || typeof command.payload !== "object") {
|
||||
|
|
@ -58,5 +103,43 @@ export function assertProjectStateRestoreCommand(
|
|||
) {
|
||||
throw new Error("Expected project state checksum is invalid.");
|
||||
}
|
||||
parseProjectStateSnapshot(payload.targetState);
|
||||
const targetState = parseProjectStateSnapshot(payload.targetState);
|
||||
if (command.schemaVersion === snapshotRestoreCommandSchemaVersion) {
|
||||
if (Object.keys(payload).length !== 2) {
|
||||
throw new Error("Snapshot restore payload contains unknown fields.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (command.schemaVersion !== projectStateRestoreCommandSchemaVersion) {
|
||||
throw new Error("Unsupported project state restore command.");
|
||||
}
|
||||
const transferPayload = payload as Partial<ProjectTransferRestoreCommandPayload>;
|
||||
parseEstimateBalances(
|
||||
transferPayload.expectedEstimateBalances,
|
||||
targetState.project.id
|
||||
);
|
||||
parseEstimateBalances(
|
||||
transferPayload.targetEstimateBalances,
|
||||
targetState.project.id
|
||||
);
|
||||
if (Object.keys(payload).length !== 4) {
|
||||
throw new Error("Project transfer restore payload contains unknown fields.");
|
||||
}
|
||||
}
|
||||
|
||||
function parseEstimateBalances(
|
||||
value: unknown,
|
||||
projectId: string
|
||||
): EstimateBalanceSnapshot[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error("Project transfer estimate balances must be an array.");
|
||||
}
|
||||
const balances = value.map(parseEstimateBalanceSnapshot);
|
||||
if (balances.some((balance) => balance.projectId !== projectId)) {
|
||||
throw new Error("Project transfer estimate balance belongs to another project.");
|
||||
}
|
||||
if (new Set(balances.map((balance) => balance.id)).size !== balances.length) {
|
||||
throw new Error("Project transfer contains duplicate estimate balances.");
|
||||
}
|
||||
return balances;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ import {
|
|||
projectStateSnapshotSchemaVersion,
|
||||
type ProjectStateSnapshot,
|
||||
} from "./project-state-snapshot.model.js";
|
||||
import { parseEstimateBalanceSnapshot } from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
||||
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
||||
|
||||
export const projectTransferFormat = "leistungsbilanz.project" as const;
|
||||
export const projectTransferFormatVersion = 1 as const;
|
||||
export const projectTransferFormatVersion = 2 as const;
|
||||
const legacyProjectTransferFormatVersion = 1 as const;
|
||||
|
||||
export interface ProjectTransferEnvelope {
|
||||
format: typeof projectTransferFormat;
|
||||
|
|
@ -13,12 +16,14 @@ export interface ProjectTransferEnvelope {
|
|||
exportedAtIso: string;
|
||||
payloadSha256: string;
|
||||
projectState: ProjectStateSnapshot;
|
||||
estimateBalances: EstimateBalanceSnapshot[];
|
||||
}
|
||||
|
||||
export function createProjectTransferEnvelope(
|
||||
exportedAtIso: string,
|
||||
payloadSha256: string,
|
||||
projectState: ProjectStateSnapshot
|
||||
projectState: ProjectStateSnapshot,
|
||||
estimateBalances: EstimateBalanceSnapshot[]
|
||||
): ProjectTransferEnvelope {
|
||||
return parseProjectTransferEnvelope({
|
||||
format: projectTransferFormat,
|
||||
|
|
@ -26,6 +31,7 @@ export function createProjectTransferEnvelope(
|
|||
exportedAtIso,
|
||||
payloadSha256,
|
||||
projectState,
|
||||
estimateBalances,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -37,7 +43,8 @@ export function parseProjectTransferEnvelope(
|
|||
}
|
||||
if (
|
||||
value.format !== projectTransferFormat ||
|
||||
value.formatVersion !== projectTransferFormatVersion
|
||||
(value.formatVersion !== legacyProjectTransferFormatVersion &&
|
||||
value.formatVersion !== projectTransferFormatVersion)
|
||||
) {
|
||||
throw new Error("Project transfer format is not supported.");
|
||||
}
|
||||
|
|
@ -57,7 +64,13 @@ export function parseProjectTransferEnvelope(
|
|||
if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
||||
throw new Error("Project transfer snapshot version is not supported.");
|
||||
}
|
||||
if (Object.keys(value).length !== 5) {
|
||||
const estimateBalances =
|
||||
value.formatVersion === legacyProjectTransferFormatVersion
|
||||
? []
|
||||
: parseTransferEstimateBalances(value.estimateBalances, projectState.project.id);
|
||||
const expectedFieldCount =
|
||||
value.formatVersion === legacyProjectTransferFormatVersion ? 5 : 6;
|
||||
if (Object.keys(value).length !== expectedFieldCount) {
|
||||
throw new Error("Project transfer file contains unknown fields.");
|
||||
}
|
||||
return {
|
||||
|
|
@ -66,9 +79,43 @@ export function parseProjectTransferEnvelope(
|
|||
exportedAtIso: value.exportedAtIso,
|
||||
payloadSha256: value.payloadSha256,
|
||||
projectState,
|
||||
estimateBalances,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeProjectTransferPayload(
|
||||
projectState: ProjectStateSnapshot,
|
||||
estimateBalances: EstimateBalanceSnapshot[]
|
||||
) {
|
||||
const parsedState = parseProjectStateSnapshot(projectState);
|
||||
return JSON.stringify({
|
||||
projectState: parsedState,
|
||||
estimateBalances: parseTransferEstimateBalances(
|
||||
estimateBalances,
|
||||
parsedState.project.id
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function remapEstimateBalances(
|
||||
source: EstimateBalanceSnapshot[],
|
||||
targetProjectId: string,
|
||||
createId: () => string
|
||||
): EstimateBalanceSnapshot[] {
|
||||
return source.map((balance) =>
|
||||
parseEstimateBalanceSnapshot({
|
||||
...balance,
|
||||
id: createId(),
|
||||
projectId: targetProjectId,
|
||||
groups: balance.groups.map((group) => ({
|
||||
...group,
|
||||
id: createId(),
|
||||
rows: group.rows.map((row) => ({ ...row, id: createId() })),
|
||||
})),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function remapProjectState(
|
||||
source: ProjectStateSnapshot,
|
||||
targetProjectId: string,
|
||||
|
|
@ -282,6 +329,37 @@ function requiredId(ids: ReadonlyMap<string, string>, sourceId: string) {
|
|||
return targetId;
|
||||
}
|
||||
|
||||
function parseTransferEstimateBalances(
|
||||
value: unknown,
|
||||
projectId: string
|
||||
): EstimateBalanceSnapshot[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error("Project transfer estimate balances must be an array.");
|
||||
}
|
||||
const balances = value.map(parseEstimateBalanceSnapshot);
|
||||
if (balances.some((balance) => balance.projectId !== projectId)) {
|
||||
throw new Error("Project transfer estimate balance belongs to another project.");
|
||||
}
|
||||
assertUniqueIds(balances.map((balance) => balance.id), "estimate balance");
|
||||
assertUniqueIds(
|
||||
balances.flatMap((balance) => balance.groups.map((group) => group.id)),
|
||||
"estimate group"
|
||||
);
|
||||
assertUniqueIds(
|
||||
balances.flatMap((balance) =>
|
||||
balance.groups.flatMap((group) => group.rows.map((row) => row.id))
|
||||
),
|
||||
"estimate row"
|
||||
);
|
||||
return balances;
|
||||
}
|
||||
|
||||
function assertUniqueIds(ids: string[], label: string) {
|
||||
if (new Set(ids).size !== ids.length) {
|
||||
throw new Error(`Project transfer contains a duplicate ${label} id.`);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
318
src/estimate-balance/domain/estimate-balance-command.model.ts
Normal file
318
src/estimate-balance/domain/estimate-balance-command.model.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import crypto from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
|
||||
import {
|
||||
estimateDefaultGroups,
|
||||
type EstimateBalanceSnapshot,
|
||||
type EstimateGroupSnapshot,
|
||||
} from "../../shared/estimate-balance/estimate-balance.js";
|
||||
|
||||
export const estimateBalanceInsertCommandType = "estimate-balance.insert" as const;
|
||||
export const estimateBalanceDeleteCommandType = "estimate-balance.delete" as const;
|
||||
export const estimateBalanceUpdateCommandType = "estimate-balance.update" as const;
|
||||
export const estimateBalanceCommandSchemaVersion = 1 as const;
|
||||
|
||||
const idSchema = z.string().trim().min(1).max(100);
|
||||
const trimmed = (max: number) =>
|
||||
z.string().max(max).refine((value) => value === value.trim(), {
|
||||
message: "must be trimmed",
|
||||
});
|
||||
const nullableText = (max: number) =>
|
||||
trimmed(max)
|
||||
.refine((value) => value.length > 0, { message: "use null instead of empty" })
|
||||
.nullable();
|
||||
const finite = z.number().finite();
|
||||
|
||||
export const estimateRowSnapshotSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
sortOrder: z.number().int().min(0),
|
||||
supply: nullableText(50),
|
||||
name: trimmed(200),
|
||||
quantity: finite.min(0),
|
||||
unit: z.enum(["piece", "area"]),
|
||||
connection: z.enum(["fixed", "socket"]).nullable(),
|
||||
powerPerUnitW: finite.min(0),
|
||||
simultaneityFactor: finite.min(0).max(1),
|
||||
cosPhi: finite.gt(0).max(1),
|
||||
safetySupply: z.boolean(),
|
||||
remark: nullableText(500),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const estimateGroupSnapshotSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
costGroup: trimmed(20),
|
||||
title: trimmed(200).refine((value) => value.length > 0, {
|
||||
message: "title must not be empty",
|
||||
}),
|
||||
editor: nullableText(100),
|
||||
sortOrder: z.number().int().min(0),
|
||||
rows: z.array(estimateRowSnapshotSchema).max(1000),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const estimateBalanceSnapshotSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
name: trimmed(200).refine((value) => value.length > 0, {
|
||||
message: "name must not be empty",
|
||||
}),
|
||||
workStage: nullableText(50),
|
||||
dateIso: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.refine((value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), {
|
||||
message: "invalid date",
|
||||
})
|
||||
.nullable(),
|
||||
reserveAvPercent: finite.min(0).max(100),
|
||||
reserveSvPercent: finite.min(0).max(100),
|
||||
sortOrder: z.number().int().min(0),
|
||||
groups: z.array(estimateGroupSnapshotSchema).max(200),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((balance, context) => {
|
||||
const ids = new Set<string>();
|
||||
for (const group of balance.groups) {
|
||||
for (const id of [group.id, ...group.rows.map((row) => row.id)]) {
|
||||
if (ids.has(id)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `duplicate id ${id}`,
|
||||
path: ["groups"],
|
||||
});
|
||||
}
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export interface EstimateBalanceInsertProjectCommand
|
||||
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
||||
type: typeof estimateBalanceInsertCommandType;
|
||||
}
|
||||
|
||||
export interface EstimateBalanceDeleteProjectCommand
|
||||
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
||||
type: typeof estimateBalanceDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface EstimateBalanceUpdateProjectCommand
|
||||
extends SerializedProjectCommand<{
|
||||
expected: EstimateBalanceSnapshot;
|
||||
target: EstimateBalanceSnapshot;
|
||||
}> {
|
||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
||||
type: typeof estimateBalanceUpdateCommandType;
|
||||
}
|
||||
|
||||
export type EstimateBalanceProjectCommand =
|
||||
| EstimateBalanceInsertProjectCommand
|
||||
| EstimateBalanceDeleteProjectCommand
|
||||
| EstimateBalanceUpdateProjectCommand;
|
||||
|
||||
/** Parses and normalizes a snapshot (sort orders of groups and rows follow the array order). */
|
||||
export function parseEstimateBalanceSnapshot(value: unknown): EstimateBalanceSnapshot {
|
||||
const parsed = estimateBalanceSnapshotSchema.parse(value);
|
||||
return normalizeEstimateBalanceSnapshot(parsed);
|
||||
}
|
||||
|
||||
export function normalizeEstimateBalanceSnapshot(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
return {
|
||||
...balance,
|
||||
groups: balance.groups.map((group, groupIndex) => ({
|
||||
...group,
|
||||
sortOrder: groupIndex,
|
||||
rows: group.rows.map((row, rowIndex) => ({ ...row, sortOrder: rowIndex })),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable JSON for exact equality checks of two snapshots. */
|
||||
export function canonicalEstimateBalance(balance: EstimateBalanceSnapshot): string {
|
||||
const normalized = normalizeEstimateBalanceSnapshot(balance);
|
||||
return JSON.stringify({
|
||||
id: normalized.id,
|
||||
projectId: normalized.projectId,
|
||||
name: normalized.name,
|
||||
workStage: normalized.workStage,
|
||||
dateIso: normalized.dateIso,
|
||||
reserveAvPercent: normalized.reserveAvPercent,
|
||||
reserveSvPercent: normalized.reserveSvPercent,
|
||||
sortOrder: normalized.sortOrder,
|
||||
groups: normalized.groups.map((group) => ({
|
||||
id: group.id,
|
||||
costGroup: group.costGroup,
|
||||
title: group.title,
|
||||
editor: group.editor,
|
||||
sortOrder: group.sortOrder,
|
||||
rows: group.rows.map((row) => ({
|
||||
id: row.id,
|
||||
sortOrder: row.sortOrder,
|
||||
supply: row.supply,
|
||||
name: row.name,
|
||||
quantity: row.quantity,
|
||||
unit: row.unit,
|
||||
connection: row.connection,
|
||||
powerPerUnitW: row.powerPerUnitW,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi,
|
||||
safetySupply: row.safetySupply,
|
||||
remark: row.remark,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export function sameEstimateBalance(
|
||||
left: EstimateBalanceSnapshot,
|
||||
right: EstimateBalanceSnapshot
|
||||
): boolean {
|
||||
return canonicalEstimateBalance(left) === canonicalEstimateBalance(right);
|
||||
}
|
||||
|
||||
export function createEstimateBalanceSnapshot(input: {
|
||||
projectId: string;
|
||||
name: string;
|
||||
workStage: string | null;
|
||||
dateIso: string | null;
|
||||
sortOrder: number;
|
||||
template: "kg400" | "empty" | { copyFrom: EstimateBalanceSnapshot };
|
||||
createId?: () => string;
|
||||
}): EstimateBalanceSnapshot {
|
||||
const createId = input.createId ?? (() => crypto.randomUUID());
|
||||
let groups: EstimateGroupSnapshot[] = [];
|
||||
let reserveAvPercent = 10;
|
||||
let reserveSvPercent = 0;
|
||||
if (input.template === "kg400") {
|
||||
groups = estimateDefaultGroups.map((group, index) => ({
|
||||
id: createId(),
|
||||
costGroup: group.costGroup,
|
||||
title: group.title,
|
||||
editor: null,
|
||||
sortOrder: index,
|
||||
rows: [],
|
||||
}));
|
||||
} else if (input.template !== "empty") {
|
||||
const source = input.template.copyFrom;
|
||||
reserveAvPercent = source.reserveAvPercent;
|
||||
reserveSvPercent = source.reserveSvPercent;
|
||||
groups = source.groups.map((group) => ({
|
||||
...group,
|
||||
id: createId(),
|
||||
rows: group.rows.map((row) => ({ ...row, id: createId() })),
|
||||
}));
|
||||
}
|
||||
return parseEstimateBalanceSnapshot({
|
||||
id: createId(),
|
||||
projectId: input.projectId,
|
||||
name: input.name.trim(),
|
||||
workStage: input.workStage?.trim() || null,
|
||||
dateIso: input.dateIso || null,
|
||||
reserveAvPercent,
|
||||
reserveSvPercent,
|
||||
sortOrder: input.sortOrder,
|
||||
groups,
|
||||
});
|
||||
}
|
||||
|
||||
export function createEstimateBalanceInsertProjectCommand(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceInsertProjectCommand {
|
||||
return {
|
||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
||||
type: estimateBalanceInsertCommandType,
|
||||
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
||||
};
|
||||
}
|
||||
|
||||
export function createEstimateBalanceDeleteProjectCommand(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceDeleteProjectCommand {
|
||||
return {
|
||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
||||
type: estimateBalanceDeleteCommandType,
|
||||
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
||||
};
|
||||
}
|
||||
|
||||
export function createEstimateBalanceUpdateProjectCommand(
|
||||
expected: EstimateBalanceSnapshot,
|
||||
target: EstimateBalanceSnapshot
|
||||
): EstimateBalanceUpdateProjectCommand {
|
||||
const payload = {
|
||||
expected: parseEstimateBalanceSnapshot(expected),
|
||||
target: parseEstimateBalanceSnapshot(target),
|
||||
};
|
||||
assertMatchingUpdate(payload.expected, payload.target);
|
||||
return {
|
||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
||||
type: estimateBalanceUpdateCommandType,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function assertMatchingUpdate(
|
||||
expected: EstimateBalanceSnapshot,
|
||||
target: EstimateBalanceSnapshot
|
||||
) {
|
||||
if (expected.id !== target.id || expected.projectId !== target.projectId) {
|
||||
throw new Error("Estimate-balance update must keep id and project.");
|
||||
}
|
||||
if (sameEstimateBalance(expected, target)) {
|
||||
throw new Error("Estimate-balance update must change the balance.");
|
||||
}
|
||||
}
|
||||
|
||||
function assertCommandShape(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
type: string,
|
||||
keys: string[]
|
||||
): Record<string, unknown> {
|
||||
const payload = command.payload;
|
||||
if (
|
||||
command.schemaVersion !== estimateBalanceCommandSchemaVersion ||
|
||||
command.type !== type ||
|
||||
payload === null ||
|
||||
typeof payload !== "object" ||
|
||||
Array.isArray(payload) ||
|
||||
Object.keys(payload).sort().join(",") !== [...keys].sort().join(",")
|
||||
) {
|
||||
throw new Error("Unsupported estimate-balance command.");
|
||||
}
|
||||
return payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function assertEstimateBalanceInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is EstimateBalanceInsertProjectCommand {
|
||||
const payload = assertCommandShape(command, estimateBalanceInsertCommandType, ["balance"]);
|
||||
parseEstimateBalanceSnapshot(payload.balance);
|
||||
}
|
||||
|
||||
export function assertEstimateBalanceDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is EstimateBalanceDeleteProjectCommand {
|
||||
const payload = assertCommandShape(command, estimateBalanceDeleteCommandType, ["balance"]);
|
||||
parseEstimateBalanceSnapshot(payload.balance);
|
||||
}
|
||||
|
||||
export function assertEstimateBalanceUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is EstimateBalanceUpdateProjectCommand {
|
||||
const payload = assertCommandShape(command, estimateBalanceUpdateCommandType, [
|
||||
"expected",
|
||||
"target",
|
||||
]);
|
||||
assertMatchingUpdate(
|
||||
parseEstimateBalanceSnapshot(payload.expected),
|
||||
parseEstimateBalanceSnapshot(payload.target)
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
298
src/frontend/estimate-balance/estimate-balance-document.ts
Normal file
298
src/frontend/estimate-balance/estimate-balance-document.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
// Pure, immutable editing rules for one "Grobe Leistungsbilanz" document.
|
||||
// The editor keeps the complete document as local draft and saves it as a
|
||||
// whole (PUT), so every edit is a function draft → new draft.
|
||||
import type {
|
||||
EstimateBalanceSnapshot,
|
||||
EstimateGroupSnapshot,
|
||||
EstimateRowSnapshot,
|
||||
} from "../../shared/estimate-balance/estimate-balance";
|
||||
import { createClientId } from "../utils/client-id";
|
||||
|
||||
export type MoveDirection = -1 | 1;
|
||||
|
||||
/** Maximum text lengths accepted by the server. */
|
||||
export const estimateTextLimits = {
|
||||
balanceName: 200,
|
||||
workStage: 50,
|
||||
costGroup: 20,
|
||||
groupTitle: 200,
|
||||
groupEditor: 100,
|
||||
supply: 50,
|
||||
rowName: 200,
|
||||
remark: 500,
|
||||
} as const;
|
||||
|
||||
export type EstimateRowPatch = Partial<Omit<EstimateRowSnapshot, "id" | "sortOrder">>;
|
||||
export type EstimateGroupPatch = Partial<Pick<EstimateGroupSnapshot, "costGroup" | "title" | "editor">>;
|
||||
|
||||
export function createEstimateRow(
|
||||
sortOrder: number,
|
||||
createId: () => string = createClientId
|
||||
): EstimateRowSnapshot {
|
||||
return {
|
||||
id: createId(),
|
||||
sortOrder,
|
||||
supply: null,
|
||||
name: "",
|
||||
quantity: 1,
|
||||
unit: "piece",
|
||||
connection: null,
|
||||
powerPerUnitW: 0,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 1,
|
||||
safetySupply: false,
|
||||
remark: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEstimateGroup(
|
||||
input: { costGroup: string; title: string; editor?: string | null },
|
||||
sortOrder: number,
|
||||
createId: () => string = createClientId
|
||||
): EstimateGroupSnapshot {
|
||||
return {
|
||||
id: createId(),
|
||||
costGroup: input.costGroup,
|
||||
title: input.title,
|
||||
editor: input.editor ?? null,
|
||||
sortOrder,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
function bySortOrder<T extends { sortOrder: number }>(left: T, right: T) {
|
||||
return left.sortOrder - right.sortOrder;
|
||||
}
|
||||
|
||||
/** Orders groups and rows by sortOrder and renumbers both from 0. */
|
||||
export function normalizeEstimateSortOrder(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
return {
|
||||
...balance,
|
||||
groups: [...balance.groups].sort(bySortOrder).map((group, groupIndex) => ({
|
||||
...group,
|
||||
sortOrder: groupIndex,
|
||||
rows: [...group.rows].sort(bySortOrder).map((row, rowIndex) => ({
|
||||
...row,
|
||||
sortOrder: rowIndex,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function renumber<T extends { sortOrder: number }>(items: T[]): T[] {
|
||||
return items.map((item, index) =>
|
||||
item.sortOrder === index ? item : { ...item, sortOrder: index }
|
||||
);
|
||||
}
|
||||
|
||||
function mapGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
update: (group: EstimateGroupSnapshot) => EstimateGroupSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
let changed = false;
|
||||
const groups = balance.groups.map((group) => {
|
||||
if (group.id !== groupId) {
|
||||
return group;
|
||||
}
|
||||
const next = update(group);
|
||||
changed = next !== group;
|
||||
return next;
|
||||
});
|
||||
// An unknown group or a no-op keeps the identical document.
|
||||
return changed ? { ...balance, groups } : balance;
|
||||
}
|
||||
|
||||
function moveItem<T>(items: T[], index: number, direction: MoveDirection): T[] | null {
|
||||
const target = index + direction;
|
||||
if (index < 0 || target < 0 || target >= items.length) {
|
||||
return null;
|
||||
}
|
||||
const next = [...items];
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Appends a row (or inserts it after `afterRowId`) and returns the new row id. */
|
||||
export function addEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
options: { afterRowId?: string; createId?: () => string } = {}
|
||||
): { balance: EstimateBalanceSnapshot; rowId: string | null } {
|
||||
let rowId: string | null = null;
|
||||
const next = mapGroup(balance, groupId, (group) => {
|
||||
const row = createEstimateRow(0, options.createId);
|
||||
rowId = row.id;
|
||||
const afterIndex = options.afterRowId
|
||||
? group.rows.findIndex((candidate) => candidate.id === options.afterRowId)
|
||||
: -1;
|
||||
const rows = [...group.rows];
|
||||
rows.splice(afterIndex >= 0 ? afterIndex + 1 : rows.length, 0, row);
|
||||
return { ...group, rows: renumber(rows) };
|
||||
});
|
||||
return { balance: next, rowId };
|
||||
}
|
||||
|
||||
export function updateEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
rowId: string,
|
||||
patch: EstimateRowPatch
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => ({
|
||||
...group,
|
||||
rows: group.rows.map((row) => (row.id === rowId ? { ...row, ...patch } : row)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function removeEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
rowId: string
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => ({
|
||||
...group,
|
||||
rows: renumber(group.rows.filter((row) => row.id !== rowId)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function moveEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
rowId: string,
|
||||
direction: MoveDirection
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => {
|
||||
const rows = moveItem(
|
||||
group.rows,
|
||||
group.rows.findIndex((row) => row.id === rowId),
|
||||
direction
|
||||
);
|
||||
return rows ? { ...group, rows: renumber(rows) } : group;
|
||||
});
|
||||
}
|
||||
|
||||
/** Appends a group and returns its id. */
|
||||
export function addEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
input: { costGroup: string; title: string; editor?: string | null },
|
||||
createId: () => string = createClientId
|
||||
): { balance: EstimateBalanceSnapshot; groupId: string } {
|
||||
const group = createEstimateGroup(input, balance.groups.length, createId);
|
||||
return {
|
||||
balance: { ...balance, groups: renumber([...balance.groups, group]) },
|
||||
groupId: group.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
patch: EstimateGroupPatch
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => ({ ...group, ...patch }));
|
||||
}
|
||||
|
||||
export function removeEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string
|
||||
): EstimateBalanceSnapshot {
|
||||
const groups = balance.groups.filter((group) => group.id !== groupId);
|
||||
return groups.length === balance.groups.length
|
||||
? balance
|
||||
: { ...balance, groups: renumber(groups) };
|
||||
}
|
||||
|
||||
export function moveEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
direction: MoveDirection
|
||||
): EstimateBalanceSnapshot {
|
||||
const groups = moveItem(
|
||||
balance.groups,
|
||||
balance.groups.findIndex((group) => group.id === groupId),
|
||||
direction
|
||||
);
|
||||
return groups ? { ...balance, groups: renumber(groups) } : balance;
|
||||
}
|
||||
|
||||
function requiredText(value: string) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalText(value: string | null) {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact document the server accepts: strings trimmed, empty optional
|
||||
* texts as null, sort orders continuous. Only fields of the shared snapshot
|
||||
* type are copied, so no UI state can leak into the request.
|
||||
*/
|
||||
export function toSaveableEstimateBalance(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
const normalized = normalizeEstimateSortOrder(balance);
|
||||
return {
|
||||
id: normalized.id,
|
||||
projectId: normalized.projectId,
|
||||
name: requiredText(normalized.name),
|
||||
workStage: optionalText(normalized.workStage),
|
||||
dateIso: normalized.dateIso || null,
|
||||
reserveAvPercent: normalized.reserveAvPercent,
|
||||
reserveSvPercent: normalized.reserveSvPercent,
|
||||
sortOrder: normalized.sortOrder,
|
||||
groups: normalized.groups.map((group) => ({
|
||||
id: group.id,
|
||||
costGroup: requiredText(group.costGroup),
|
||||
title: requiredText(group.title),
|
||||
editor: optionalText(group.editor),
|
||||
sortOrder: group.sortOrder,
|
||||
rows: group.rows.map((row) => ({
|
||||
id: row.id,
|
||||
sortOrder: row.sortOrder,
|
||||
supply: optionalText(row.supply),
|
||||
name: requiredText(row.name),
|
||||
quantity: row.quantity,
|
||||
unit: row.unit,
|
||||
connection: row.connection,
|
||||
powerPerUnitW: row.powerPerUnitW,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi,
|
||||
safetySupply: row.safetySupply,
|
||||
remark: optionalText(row.remark),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable comparison key of the saveable document (dirty detection). */
|
||||
export function estimateBalanceFingerprint(balance: EstimateBalanceSnapshot): string {
|
||||
return JSON.stringify(toSaveableEstimateBalance(balance));
|
||||
}
|
||||
|
||||
/**
|
||||
* German problems that would make the server reject the document. The
|
||||
* editor never saves while this list is not empty.
|
||||
*/
|
||||
export function validateEstimateBalance(balance: EstimateBalanceSnapshot): string[] {
|
||||
const problems: string[] = [];
|
||||
const saveable = toSaveableEstimateBalance(balance);
|
||||
if (!saveable.name) {
|
||||
problems.push("Die Bezeichnung des Stands darf nicht leer sein.");
|
||||
}
|
||||
for (const group of saveable.groups) {
|
||||
if (!group.title) {
|
||||
problems.push(`Kostengruppe ${group.costGroup || "ohne Nummer"}: Titel fehlt.`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
export function createCopyName(name: string) {
|
||||
const copyName = `${name.trim()} (Kopie)`;
|
||||
return copyName.slice(0, estimateTextLimits.balanceName);
|
||||
}
|
||||
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}`;
|
||||
}
|
||||
669
src/frontend/estimate-balance/estimate-balance-sheet.tsx
Normal file
669
src/frontend/estimate-balance/estimate-balance-sheet.tsx
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
calculateEstimateBalance,
|
||||
calculateEstimateRow,
|
||||
estimateRowConnectionLabels,
|
||||
estimateRowUnitLabels,
|
||||
type EstimateBalanceSnapshot,
|
||||
type EstimateGroupSnapshot,
|
||||
type EstimatePower,
|
||||
type EstimateRowConnection,
|
||||
type EstimateRowSnapshot,
|
||||
type EstimateRowUnit,
|
||||
} from "../../shared/estimate-balance/estimate-balance";
|
||||
import {
|
||||
addEstimateRow,
|
||||
estimateTextLimits,
|
||||
moveEstimateGroup,
|
||||
removeEstimateGroup,
|
||||
removeEstimateRow,
|
||||
updateEstimateGroup,
|
||||
updateEstimateRow,
|
||||
type EstimateRowPatch,
|
||||
} from "./estimate-balance-document";
|
||||
import {
|
||||
estimateNumberRanges,
|
||||
formatAmpere,
|
||||
formatDecimalInput,
|
||||
formatKw,
|
||||
parseGermanDecimal,
|
||||
type DecimalRange,
|
||||
formatGermanNumber,
|
||||
} from "./estimate-balance-format";
|
||||
import { DraftInput } from "./estimate-draft-input";
|
||||
|
||||
export type EstimateBalanceUpdate = (
|
||||
update: (balance: EstimateBalanceSnapshot) => EstimateBalanceSnapshot
|
||||
) => void;
|
||||
|
||||
interface EstimateBalanceSheetProps {
|
||||
balance: EstimateBalanceSnapshot;
|
||||
disabled: boolean;
|
||||
onAddGroup: () => void;
|
||||
onChange: EstimateBalanceUpdate;
|
||||
onEditGroup: (group: EstimateGroupSnapshot) => void;
|
||||
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||
threePhaseVoltageV: number;
|
||||
}
|
||||
|
||||
// Leading columns up to cos φ; the power columns follow.
|
||||
const LEADING_COLUMN_COUNT = 9;
|
||||
const COLUMN_COUNT = 16;
|
||||
|
||||
/** Suggested supply values; free text stays possible. */
|
||||
const SUPPLY_SUGGESTIONS = ["ELT", "MSR"];
|
||||
const SUPPLY_LIST_ID = "estimate-supply-options";
|
||||
|
||||
function NumberInput({
|
||||
ariaLabel,
|
||||
disabled,
|
||||
fieldKey,
|
||||
onCommit,
|
||||
onValidityChange,
|
||||
range,
|
||||
value,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
disabled: boolean;
|
||||
fieldKey: string;
|
||||
onCommit: (value: number) => void;
|
||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||
range: DecimalRange;
|
||||
value: number;
|
||||
}) {
|
||||
return (
|
||||
<DraftInput
|
||||
ariaLabel={ariaLabel}
|
||||
className="estimate-cell-input is-numeric"
|
||||
disabled={disabled}
|
||||
format={(current: number) => formatDecimalInput(current)}
|
||||
inputMode="decimal"
|
||||
printFormat={(current: number) => formatGermanNumber(current, 0, 3)}
|
||||
onCommit={onCommit}
|
||||
onValidityChange={(isValid) => onValidityChange(fieldKey, isValid)}
|
||||
parse={(text) => parseGermanDecimal(text, range)}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function supplyOptions(balance: EstimateBalanceSnapshot): string[] {
|
||||
const used = balance.groups.flatMap((group) =>
|
||||
group.rows.map((row) => row.supply?.trim() ?? "")
|
||||
);
|
||||
return [...new Set([...SUPPLY_SUGGESTIONS, ...used.filter(Boolean)])];
|
||||
}
|
||||
|
||||
function TextInput({
|
||||
ariaLabel,
|
||||
disabled,
|
||||
list,
|
||||
maxLength,
|
||||
onChange,
|
||||
placeholder,
|
||||
value,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
disabled: boolean;
|
||||
list?: string;
|
||||
maxLength: number;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
value: string | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
aria-label={ariaLabel}
|
||||
className="estimate-cell-input"
|
||||
disabled={disabled}
|
||||
list={list}
|
||||
maxLength={maxLength}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
/>
|
||||
<span className="estimate-print-value">{value ?? ""}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
children,
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
variant = "secondary",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "secondary" | "danger" | "primary";
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
className={`btn btn-sm btn-outline-${variant} estimate-icon-button`}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PowerCells({ power, className = "" }: { power: EstimatePower; className?: string }) {
|
||||
return (
|
||||
<>
|
||||
<td className={`estimate-number ${className}`}>{formatKw(power.kw)}</td>
|
||||
<td className={`estimate-number ${className}`}>{formatKw(power.kva)}</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function groupLabel(group: EstimateGroupSnapshot) {
|
||||
return [group.costGroup, group.title].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function EstimateRowLine({
|
||||
disabled,
|
||||
group,
|
||||
onChange,
|
||||
onOpenGzfPicker,
|
||||
onValidityChange,
|
||||
row,
|
||||
rowIndex,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
group: EstimateGroupSnapshot;
|
||||
onChange: EstimateBalanceUpdate;
|
||||
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||
row: EstimateRowSnapshot;
|
||||
rowIndex: number;
|
||||
}) {
|
||||
const result = calculateEstimateRow(row);
|
||||
const rowLabel = `${groupLabel(group)}, Zeile ${rowIndex + 1}`;
|
||||
const fieldKey = (field: string) => `${row.id}:${field}`;
|
||||
|
||||
function patch(rowPatch: EstimateRowPatch) {
|
||||
onChange((balance) => updateEstimateRow(balance, group.id, row.id, rowPatch));
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="estimate-row">
|
||||
<td className="estimate-cost-group-cell" />
|
||||
<td>
|
||||
<TextInput
|
||||
ariaLabel={`Versorgung (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
list={SUPPLY_LIST_ID}
|
||||
maxLength={estimateTextLimits.supply}
|
||||
onChange={(supply) => patch({ supply })}
|
||||
placeholder="ELT"
|
||||
value={row.supply}
|
||||
/>
|
||||
</td>
|
||||
<td className="estimate-name-cell">
|
||||
<TextInput
|
||||
ariaLabel={`Verbraucher / Fläche (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.rowName}
|
||||
onChange={(name) => patch({ name })}
|
||||
placeholder="Verbraucher oder Fläche"
|
||||
value={row.name}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NumberInput
|
||||
ariaLabel={`Anzahl / Fläche (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("quantity")}
|
||||
onCommit={(quantity) => patch({ quantity })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.quantity}
|
||||
value={row.quantity}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`Einheit (${rowLabel})`}
|
||||
className="estimate-cell-select"
|
||||
disabled={disabled}
|
||||
onChange={(event) => patch({ unit: event.target.value as EstimateRowUnit })}
|
||||
value={row.unit}
|
||||
>
|
||||
{(Object.keys(estimateRowUnitLabels) as EstimateRowUnit[]).map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
{estimateRowUnitLabels[unit]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="estimate-print-value">{estimateRowUnitLabels[row.unit]}</span>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`Anschluss (${rowLabel})`}
|
||||
className="estimate-cell-select"
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
patch({
|
||||
connection: (event.target.value || null) as EstimateRowConnection | null,
|
||||
})
|
||||
}
|
||||
value={row.connection ?? ""}
|
||||
>
|
||||
<option value="">–</option>
|
||||
{(Object.keys(estimateRowConnectionLabels) as EstimateRowConnection[]).map(
|
||||
(connection) => (
|
||||
<option key={connection} value={connection}>
|
||||
{estimateRowConnectionLabels[connection]}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
<span className="estimate-print-value">
|
||||
{row.connection ? estimateRowConnectionLabels[row.connection] : ""}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="estimate-unit-input">
|
||||
<NumberInput
|
||||
ariaLabel={`Einzelleistung in ${row.unit === "area" ? "W/m²" : "W"} (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("powerPerUnitW")}
|
||||
onCommit={(powerPerUnitW) => patch({ powerPerUnitW })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.powerPerUnitW}
|
||||
value={row.powerPerUnitW}
|
||||
/>
|
||||
<span className="estimate-unit-label">{row.unit === "area" ? "W/m²" : "W"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="estimate-gzf-input">
|
||||
<NumberInput
|
||||
ariaLabel={`Gleichzeitigkeitsfaktor (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("simultaneityFactor")}
|
||||
onCommit={(simultaneityFactor) => patch({ simultaneityFactor })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.simultaneityFactor}
|
||||
value={row.simultaneityFactor}
|
||||
/>
|
||||
<button
|
||||
aria-label={`Gleichzeitigkeitsfaktor nach AMEV wählen (${rowLabel})`}
|
||||
className="estimate-amev-button estimate-no-print"
|
||||
disabled={disabled}
|
||||
onClick={() => onOpenGzfPicker(group.id, row.id)}
|
||||
title="Gleichzeitigkeitsfaktor nach AMEV wählen"
|
||||
type="button"
|
||||
>
|
||||
AMEV
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<NumberInput
|
||||
ariaLabel={`cos φ (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("cosPhi")}
|
||||
onCommit={(cosPhi) => patch({ cosPhi })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.cosPhi}
|
||||
value={row.cosPhi}
|
||||
/>
|
||||
</td>
|
||||
<PowerCells power={result.av} />
|
||||
<td className="estimate-center">
|
||||
<input
|
||||
aria-label={`Sicherheitsversorgung (${rowLabel})`}
|
||||
checked={row.safetySupply}
|
||||
className="form-check-input estimate-no-print"
|
||||
disabled={disabled}
|
||||
onChange={(event) => patch({ safetySupply: event.target.checked })}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="estimate-print-value">{row.safetySupply ? "ja" : "nein"}</span>
|
||||
</td>
|
||||
<PowerCells
|
||||
className={row.safetySupply ? "" : "is-muted"}
|
||||
power={result.sv}
|
||||
/>
|
||||
<td>
|
||||
<TextInput
|
||||
ariaLabel={`Anmerkung (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.remark}
|
||||
onChange={(remark) => patch({ remark })}
|
||||
value={row.remark}
|
||||
/>
|
||||
</td>
|
||||
<td className="estimate-actions-cell estimate-no-print">
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Zeile löschen (${rowLabel})`}
|
||||
onClick={() => onChange((balance) => removeEstimateRow(balance, group.id, row.id))}
|
||||
variant="danger"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spreadsheet-like editor in the layout of the office template
|
||||
* "Leistungsbilanz – KG400": one section per cost group with title and
|
||||
* subtotal row, totals, reserve and maximum current at the end.
|
||||
*/
|
||||
export function EstimateBalanceSheet({
|
||||
balance,
|
||||
disabled,
|
||||
onAddGroup,
|
||||
onChange,
|
||||
onEditGroup,
|
||||
onOpenGzfPicker,
|
||||
onValidityChange,
|
||||
threePhaseVoltageV,
|
||||
}: EstimateBalanceSheetProps) {
|
||||
const result = calculateEstimateBalance(balance, threePhaseVoltageV);
|
||||
|
||||
function confirmRemoveGroup(group: EstimateGroupSnapshot) {
|
||||
const rowText =
|
||||
group.rows.length === 1 ? "1 Zeile" : `${group.rows.length} Zeilen`;
|
||||
if (
|
||||
window.confirm(
|
||||
`Kostengruppe „${groupLabel(group)}“ mit ${rowText} wirklich löschen?`
|
||||
)
|
||||
) {
|
||||
onChange((current) => removeEstimateGroup(current, group.id));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="estimate-sheet-wrap">
|
||||
<datalist id={SUPPLY_LIST_ID}>
|
||||
{supplyOptions(balance).map((supply) => (
|
||||
<option key={supply} value={supply} />
|
||||
))}
|
||||
</datalist>
|
||||
<table className="estimate-sheet">
|
||||
<colgroup>
|
||||
<col className="estimate-col-kg" />
|
||||
<col className="estimate-col-supply" />
|
||||
<col className="estimate-col-name" />
|
||||
<col className="estimate-col-quantity" />
|
||||
<col className="estimate-col-unit" />
|
||||
<col className="estimate-col-connection" />
|
||||
<col className="estimate-col-power" />
|
||||
<col className="estimate-col-gzf" />
|
||||
<col className="estimate-col-cosphi" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-sv" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-remark" />
|
||||
<col className="estimate-col-actions" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowSpan={2}>KG</th>
|
||||
<th rowSpan={2}>
|
||||
Versorgung
|
||||
<span className="estimate-th-hint">ELT/MSR …</span>
|
||||
</th>
|
||||
<th rowSpan={2}>Verbraucher / Flächen</th>
|
||||
<th colSpan={2}>
|
||||
Anzahl / Fläche
|
||||
<span className="estimate-th-hint">St. / m²</span>
|
||||
</th>
|
||||
<th rowSpan={2}>
|
||||
Anschl.
|
||||
<span className="estimate-th-hint">Fest/Steckd.</span>
|
||||
</th>
|
||||
<th rowSpan={2}>
|
||||
Einzelleist.
|
||||
<span className="estimate-th-hint">W bzw. W/m²</span>
|
||||
</th>
|
||||
<th rowSpan={2}>GL-Faktor</th>
|
||||
<th rowSpan={2}>cos φ</th>
|
||||
<th colSpan={2}>Nötige AV-Leistung</th>
|
||||
<th colSpan={3}>Sicherheitsversorgung (SV)</th>
|
||||
<th rowSpan={2}>Anmerkungen</th>
|
||||
<th aria-label="Aktionen" className="estimate-no-print" rowSpan={2} />
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Wert</th>
|
||||
<th>Einh.</th>
|
||||
<th>kW</th>
|
||||
<th>kVA</th>
|
||||
<th>ja/nein</th>
|
||||
<th>kW</th>
|
||||
<th>kVA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{balance.groups.map((group, groupIndex) => {
|
||||
const groupResult = result.groups[groupIndex]!;
|
||||
const label = groupLabel(group) || "Kostengruppe";
|
||||
return (
|
||||
<tbody className="estimate-group" key={group.id}>
|
||||
<tr className="estimate-group-title-row">
|
||||
<td className="estimate-cost-group-cell">{group.costGroup}</td>
|
||||
<td colSpan={COLUMN_COUNT - 2}>
|
||||
<div className="estimate-group-title">
|
||||
<span className="estimate-group-name">{group.title}</span>
|
||||
<label
|
||||
className={
|
||||
group.editor?.trim()
|
||||
? "estimate-group-editor"
|
||||
: "estimate-group-editor is-empty"
|
||||
}
|
||||
>
|
||||
<span>bearbeitet von</span>
|
||||
<input
|
||||
aria-label={`Bearbeitet von (${label})`}
|
||||
className="estimate-cell-input"
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.groupEditor}
|
||||
onChange={(event) =>
|
||||
onChange((current) =>
|
||||
updateEstimateGroup(current, group.id, {
|
||||
editor: event.target.value,
|
||||
})
|
||||
)
|
||||
}
|
||||
placeholder="Gewerk / Name"
|
||||
type="text"
|
||||
value={group.editor ?? ""}
|
||||
/>
|
||||
<span className="estimate-print-value">{group.editor ?? ""}</span>
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td className="estimate-actions-cell estimate-no-print">
|
||||
<div className="estimate-group-actions">
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Zeile hinzufügen (${label})`}
|
||||
onClick={() =>
|
||||
onChange((current) => addEstimateRow(current, group.id).balance)
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
<Plus aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Gruppe bearbeiten (${label})`}
|
||||
onClick={() => onEditGroup(group)}
|
||||
>
|
||||
<Pencil aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled || groupIndex === 0}
|
||||
label={`Gruppe nach oben (${label})`}
|
||||
onClick={() =>
|
||||
onChange((current) => moveEstimateGroup(current, group.id, -1))
|
||||
}
|
||||
>
|
||||
<ArrowUp aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled || groupIndex === balance.groups.length - 1}
|
||||
label={`Gruppe nach unten (${label})`}
|
||||
onClick={() =>
|
||||
onChange((current) => moveEstimateGroup(current, group.id, 1))
|
||||
}
|
||||
>
|
||||
<ArrowDown aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Gruppe löschen (${label})`}
|
||||
onClick={() => confirmRemoveGroup(group)}
|
||||
variant="danger"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{group.rows.map((row, rowIndex) => (
|
||||
<EstimateRowLine
|
||||
disabled={disabled}
|
||||
group={group}
|
||||
key={row.id}
|
||||
onChange={onChange}
|
||||
onOpenGzfPicker={onOpenGzfPicker}
|
||||
onValidityChange={onValidityChange}
|
||||
row={row}
|
||||
rowIndex={rowIndex}
|
||||
/>
|
||||
))}
|
||||
{!group.rows.length ? (
|
||||
<tr className="estimate-empty-row estimate-no-print">
|
||||
<td />
|
||||
<td colSpan={COLUMN_COUNT - 1}>
|
||||
Noch keine Einträge – mit „+“ eine Zeile hinzufügen.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
<tr className="estimate-subtotal-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Summe {label}
|
||||
</td>
|
||||
<PowerCells power={groupResult.av} />
|
||||
<td />
|
||||
<PowerCells power={groupResult.sv} />
|
||||
<td />
|
||||
<td className="estimate-no-print" />
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
})}
|
||||
<tfoot>
|
||||
<tr className="estimate-no-print estimate-add-group-row">
|
||||
<td colSpan={COLUMN_COUNT}>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
disabled={disabled}
|
||||
onClick={onAddGroup}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" className="me-1" size={14} />
|
||||
Kostengruppe hinzufügen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="estimate-total-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Gesamtsumme
|
||||
</td>
|
||||
<PowerCells power={result.total.av} />
|
||||
<td />
|
||||
<PowerCells power={result.total.sv} />
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
<tr className="estimate-total-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT - 1} className="estimate-total-label">
|
||||
Reserveleistung
|
||||
</td>
|
||||
<td>
|
||||
<div className="estimate-unit-input">
|
||||
<NumberInput
|
||||
ariaLabel="Reserve AV in Prozent"
|
||||
disabled={disabled}
|
||||
fieldKey="balance:reserveAvPercent"
|
||||
onCommit={(reserveAvPercent) =>
|
||||
onChange((current) => ({ ...current, reserveAvPercent }))
|
||||
}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.reservePercent}
|
||||
value={balance.reserveAvPercent}
|
||||
/>
|
||||
<span className="estimate-unit-label">%</span>
|
||||
</div>
|
||||
</td>
|
||||
<PowerCells power={result.reserve.av} />
|
||||
<td>
|
||||
<div className="estimate-unit-input">
|
||||
<NumberInput
|
||||
ariaLabel="Reserve SV in Prozent"
|
||||
disabled={disabled}
|
||||
fieldKey="balance:reserveSvPercent"
|
||||
onCommit={(reserveSvPercent) =>
|
||||
onChange((current) => ({ ...current, reserveSvPercent }))
|
||||
}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.reservePercent}
|
||||
value={balance.reserveSvPercent}
|
||||
/>
|
||||
<span className="estimate-unit-label">%</span>
|
||||
</div>
|
||||
</td>
|
||||
<PowerCells power={result.reserve.sv} />
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
<tr className="estimate-total-row is-strong">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Leistung inkl. Reserve
|
||||
</td>
|
||||
<PowerCells power={result.withReserve.av} />
|
||||
<td />
|
||||
<PowerCells power={result.withReserve.sv} />
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
<tr className="estimate-total-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Maximalstrom
|
||||
<span className="estimate-total-hint">
|
||||
{threePhaseVoltageV > 0
|
||||
? ` (3~, U = ${formatDecimalInput(threePhaseVoltageV)} V)`
|
||||
: " (Drehstromspannung im Projekt fehlt)"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="estimate-number" colSpan={2}>
|
||||
{formatAmpere(result.maxCurrentA.av)} A
|
||||
</td>
|
||||
<td />
|
||||
<td className="estimate-number" colSpan={2}>
|
||||
{formatAmpere(result.maxCurrentA.sv)} A
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
112
src/frontend/estimate-balance/estimate-draft-input.tsx
Normal file
112
src/frontend/estimate-balance/estimate-draft-input.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type DraftParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
|
||||
|
||||
interface DraftInputProps<T> {
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
format: (value: T) => string;
|
||||
/** Printed text; defaults to the input text. */
|
||||
printFormat?: (value: T) => string;
|
||||
inputMode?: "decimal" | "text";
|
||||
maxLength?: number;
|
||||
onCommit: (value: T) => void;
|
||||
/** Reports whether the raw text is currently valid (false while invalid). */
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
parse: (text: string) => DraftParseResult<T>;
|
||||
placeholder?: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input that keeps the raw text the user typed. Only a valid text is
|
||||
* committed into the document; an invalid text stays visible with a red
|
||||
* border and is never saved. A value changed from outside (reload, undo)
|
||||
* replaces the text unless the text already represents that value.
|
||||
*/
|
||||
export function DraftInput<T>({
|
||||
ariaLabel,
|
||||
className = "estimate-cell-input",
|
||||
disabled,
|
||||
format,
|
||||
printFormat,
|
||||
inputMode = "text",
|
||||
maxLength,
|
||||
onCommit,
|
||||
onValidityChange,
|
||||
parse,
|
||||
placeholder,
|
||||
value,
|
||||
}: DraftInputProps<T>) {
|
||||
const [text, setText] = useState(() => format(value));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [syncedValue, setSyncedValue] = useState(value);
|
||||
|
||||
if (!Object.is(syncedValue, value)) {
|
||||
// Adjusting state while rendering (instead of an effect) avoids a frame
|
||||
// with stale text after an external change.
|
||||
setSyncedValue(value);
|
||||
const parsed = parse(text);
|
||||
if (!parsed.ok || !Object.is(parsed.value, value)) {
|
||||
setText(format(value));
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
const validityRef = useRef(onValidityChange);
|
||||
useEffect(() => {
|
||||
validityRef.current = onValidityChange;
|
||||
});
|
||||
const isValid = error === null;
|
||||
useEffect(() => {
|
||||
validityRef.current?.(isValid);
|
||||
}, [isValid]);
|
||||
// A removed input (deleted row) must not leave an invalid marker behind.
|
||||
useEffect(() => () => validityRef.current?.(true), []);
|
||||
|
||||
function handleChange(nextText: string) {
|
||||
setText(nextText);
|
||||
const parsed = parse(nextText);
|
||||
if (!parsed.ok) {
|
||||
setError(parsed.error);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
if (!Object.is(parsed.value, value)) {
|
||||
setSyncedValue(parsed.value);
|
||||
onCommit(parsed.value);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Show the canonical notation again ("0,80" → "0,8") once editing ends.
|
||||
if (error === null) {
|
||||
setText(format(value));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-label={ariaLabel}
|
||||
className={error ? `${className} is-invalid` : className}
|
||||
disabled={disabled}
|
||||
inputMode={inputMode}
|
||||
maxLength={maxLength}
|
||||
onBlur={handleBlur}
|
||||
onChange={(event) => handleChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
title={error ?? undefined}
|
||||
type="text"
|
||||
value={text}
|
||||
/>
|
||||
<span className="estimate-print-value">
|
||||
{printFormat ? printFormat(value) : error ? format(value) : text}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
104
src/frontend/estimate-balance/estimate-print-header.tsx
Normal file
104
src/frontend/estimate-balance/estimate-print-header.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { documentLogoUrl, getDocumentBranding } from "../utils/api";
|
||||
import type { EstimateBalanceDto, OfficeProfileDto, ProjectDto } from "../types";
|
||||
import { formatIsoDateGerman } from "./estimate-balance-format";
|
||||
|
||||
interface EstimatePrintHeaderProps {
|
||||
project: ProjectDto;
|
||||
balance: EstimateBalanceDto;
|
||||
}
|
||||
|
||||
function cssString(value: string) {
|
||||
return JSON.stringify(value.replace(/[<>\r\n]/g, " "));
|
||||
}
|
||||
|
||||
/**
|
||||
* Print-only title block and page footer of the rough power balance in the
|
||||
* kbp corporate design of the print view (Seitenansicht): logo, blue title,
|
||||
* meta data, "Seite x von y". Hidden on screen.
|
||||
*/
|
||||
export function EstimatePrintHeader({ project, balance }: EstimatePrintHeaderProps) {
|
||||
const [office, setOffice] = useState<OfficeProfileDto | null>(null);
|
||||
const [hasLogo, setHasLogo] = useState(false);
|
||||
const [logoVersion, setLogoVersion] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
getDocumentBranding()
|
||||
.then((branding) => {
|
||||
setOffice(branding.office);
|
||||
setHasLogo(Boolean(branding.logo));
|
||||
setLogoVersion(branding.logo ? branding.logo.updatedAtIso : "");
|
||||
})
|
||||
.catch(() => {
|
||||
setOffice(null);
|
||||
setHasLogo(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const stand = [balance.name, balance.workStage].filter(Boolean).join(" · ");
|
||||
const date = balance.dateIso ? formatIsoDateGerman(balance.dateIso) : null;
|
||||
const footer = `
|
||||
@page estimate-balance {
|
||||
size: A4 landscape;
|
||||
margin: 10mm 10mm 13mm;
|
||||
@bottom-left { content: ${cssString(`${project.name} · Grobe Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
|
||||
@bottom-center { content: ${cssString(`Stand ${stand}${date ? ` · ${date}` : ""}`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
|
||||
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 7.5pt Arial, sans-serif; color: #6b7280; }
|
||||
}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{footer}</style>
|
||||
<header className="estimate-print-titleblock" aria-hidden="true">
|
||||
<div className="estimate-print-logo">
|
||||
{hasLogo ? (
|
||||
<img alt="" src={`${documentLogoUrl}?v=${encodeURIComponent(logoVersion)}`} />
|
||||
) : (
|
||||
<span>{office?.officeName ?? ""}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="estimate-print-title">
|
||||
<div className="estimate-print-kicker">Grobe Leistungsbilanz · KG 400</div>
|
||||
<div className="estimate-print-heading">
|
||||
<span className="estimate-print-stand">{balance.name}</span>
|
||||
<span className="estimate-print-project">{project.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="estimate-print-meta">
|
||||
<div>
|
||||
<dt>Projekt-Nr.</dt>
|
||||
<dd>{project.internalProjectNumber ?? "–"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Bauherr</dt>
|
||||
<dd>{project.buildingOwner ?? "–"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Anschrift</dt>
|
||||
<dd>{project.siteAddress?.split("\n").join(", ") ?? "–"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<dl className="estimate-print-meta is-tinted">
|
||||
<div>
|
||||
<dt>Leistungsphase</dt>
|
||||
<dd>{balance.workStage ?? "–"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Datum</dt>
|
||||
<dd>{date ?? "–"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Bearbeitung</dt>
|
||||
<dd>{project.projectEditor ?? "–"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Büro</dt>
|
||||
<dd>{office?.officeName ?? "–"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</header>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
314
src/shared/estimate-balance/estimate-balance.ts
Normal file
314
src/shared/estimate-balance/estimate-balance.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
/**
|
||||
* Grobe Leistungsbilanz (LPH 2/3): shared data shapes and pure calculation.
|
||||
*
|
||||
* Used by the API (validation, comparison) and by the frontend (live totals),
|
||||
* so this file has no imports: Turbopack resolves frontend imports without
|
||||
* the `.js` suffix the API build needs.
|
||||
*/
|
||||
|
||||
export type EstimateRowUnit = "piece" | "area";
|
||||
export type EstimateRowConnection = "fixed" | "socket";
|
||||
|
||||
export interface EstimateRowSnapshot {
|
||||
id: string;
|
||||
sortOrder: number;
|
||||
/** Versorgung, e.g. "ELT" or "MSR" (free text). */
|
||||
supply: string | null;
|
||||
/** Verbraucher / Fläche. May be empty while a row is being typed. */
|
||||
name: string;
|
||||
/** Anzahl (unit "piece") or area in m² (unit "area"). */
|
||||
quantity: number;
|
||||
unit: EstimateRowUnit;
|
||||
connection: EstimateRowConnection | null;
|
||||
/** W per piece or W/m². */
|
||||
powerPerUnitW: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi: number;
|
||||
/** Also needed on the safety power supply (SV). */
|
||||
safetySupply: boolean;
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
export interface EstimateGroupSnapshot {
|
||||
id: string;
|
||||
/** DIN 276 cost group, e.g. "410" or "434"; empty for "Allgemein". */
|
||||
costGroup: string;
|
||||
title: string;
|
||||
/** Trade or person responsible for the entries ("bearbeitet von"). */
|
||||
editor: string | null;
|
||||
sortOrder: number;
|
||||
rows: EstimateRowSnapshot[];
|
||||
}
|
||||
|
||||
export interface EstimateBalanceSnapshot {
|
||||
id: string;
|
||||
projectId: string;
|
||||
/** Stand, e.g. "Vorplanung" or "Entwurfsplanung". */
|
||||
name: string;
|
||||
workStage: string | null;
|
||||
/** ISO date YYYY-MM-DD. */
|
||||
dateIso: string | null;
|
||||
reserveAvPercent: number;
|
||||
reserveSvPercent: number;
|
||||
sortOrder: number;
|
||||
groups: EstimateGroupSnapshot[];
|
||||
}
|
||||
|
||||
/** Default structure of the office template "Leistungsbilanz - KG400". */
|
||||
export const estimateDefaultGroups: ReadonlyArray<{ costGroup: string; title: string }> = [
|
||||
{ costGroup: "410", title: "Sanitärtechnik" },
|
||||
{ costGroup: "420", title: "Wärmeversorgung" },
|
||||
{ costGroup: "430", title: "Lufttechnische Anlagen" },
|
||||
{ costGroup: "434", title: "Kälteanlagen" },
|
||||
{ costGroup: "440", title: "Starkstromanlagen" },
|
||||
{ costGroup: "450", title: "Schwachstromanlagen" },
|
||||
{ costGroup: "460", title: "Fördertechnik" },
|
||||
{ costGroup: "470", title: "Nutzungsspezifische Anlagen" },
|
||||
{ costGroup: "480", title: "Gebäudeautomation" },
|
||||
{ costGroup: "", title: "Allgemein" },
|
||||
];
|
||||
|
||||
export const estimateRowUnitLabels: Record<EstimateRowUnit, string> = {
|
||||
piece: "St.",
|
||||
area: "m²",
|
||||
};
|
||||
|
||||
export const estimateRowConnectionLabels: Record<EstimateRowConnection, string> = {
|
||||
fixed: "Fest",
|
||||
socket: "Steckd.",
|
||||
};
|
||||
|
||||
export interface EstimatePower {
|
||||
kw: number;
|
||||
kva: number;
|
||||
}
|
||||
|
||||
export interface EstimateRowResult {
|
||||
av: EstimatePower;
|
||||
sv: EstimatePower;
|
||||
}
|
||||
|
||||
export interface EstimateGroupResult {
|
||||
groupId: string;
|
||||
av: EstimatePower;
|
||||
sv: EstimatePower;
|
||||
}
|
||||
|
||||
export interface EstimateBalanceResult {
|
||||
groups: EstimateGroupResult[];
|
||||
total: { av: EstimatePower; sv: EstimatePower };
|
||||
reserve: { av: EstimatePower; sv: EstimatePower };
|
||||
withReserve: { av: EstimatePower; sv: EstimatePower };
|
||||
/** Maximum current of the three-phase supply in A (S / (√3 · U)). */
|
||||
maxCurrentA: { av: number; sv: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds a calculated value for display like the spreadsheet does: binary
|
||||
* floating point stores 40.175 as 40.17499…, so the scaled value is first
|
||||
* cut to 6 decimals and only then rounded half away from zero
|
||||
* (40.175 -> 40.18, -1.825 -> -1.83).
|
||||
*/
|
||||
export function roundEstimateValue(value: number, digits = 2): number {
|
||||
const factor = 10 ** digits;
|
||||
const scaled = Number((Math.abs(value) * factor).toFixed(6));
|
||||
return (Math.sign(value) * Math.round(scaled)) / factor;
|
||||
}
|
||||
|
||||
const zeroPower = (): EstimatePower => ({ kw: 0, kva: 0 });
|
||||
|
||||
function addPower(left: EstimatePower, right: EstimatePower): EstimatePower {
|
||||
return { kw: left.kw + right.kw, kva: left.kva + right.kva };
|
||||
}
|
||||
|
||||
function scalePower(power: EstimatePower, factor: number): EstimatePower {
|
||||
return { kw: power.kw * factor, kva: power.kva * factor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Required power of one row: P = quantity · power per unit · GLF, S = P / cos φ.
|
||||
* AV covers every consumer (normal supply); SV additionally lists the rows
|
||||
* that must also be supplied by the safety power supply.
|
||||
*/
|
||||
export function calculateEstimateRow(
|
||||
row: Pick<
|
||||
EstimateRowSnapshot,
|
||||
"quantity" | "powerPerUnitW" | "simultaneityFactor" | "cosPhi" | "safetySupply"
|
||||
>
|
||||
): EstimateRowResult {
|
||||
const kw = (row.quantity * row.powerPerUnitW * row.simultaneityFactor) / 1000;
|
||||
const kva = row.cosPhi > 0 ? kw / row.cosPhi : 0;
|
||||
const av = { kw, kva };
|
||||
return { av, sv: row.safetySupply ? { ...av } : zeroPower() };
|
||||
}
|
||||
|
||||
export function calculateEstimateBalance(
|
||||
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
|
||||
threePhaseVoltageV: number
|
||||
): EstimateBalanceResult {
|
||||
const groups = balance.groups.map((group) =>
|
||||
group.rows.reduce<EstimateGroupResult>(
|
||||
(sum, row) => {
|
||||
const result = calculateEstimateRow(row);
|
||||
return {
|
||||
groupId: group.id,
|
||||
av: addPower(sum.av, result.av),
|
||||
sv: addPower(sum.sv, result.sv),
|
||||
};
|
||||
},
|
||||
{ groupId: group.id, av: zeroPower(), sv: zeroPower() }
|
||||
)
|
||||
);
|
||||
const total = groups.reduce(
|
||||
(sum, group) => ({ av: addPower(sum.av, group.av), sv: addPower(sum.sv, group.sv) }),
|
||||
{ av: zeroPower(), sv: zeroPower() }
|
||||
);
|
||||
const reserve = {
|
||||
av: scalePower(total.av, balance.reserveAvPercent / 100),
|
||||
sv: scalePower(total.sv, balance.reserveSvPercent / 100),
|
||||
};
|
||||
const withReserve = {
|
||||
av: addPower(total.av, reserve.av),
|
||||
sv: addPower(total.sv, reserve.sv),
|
||||
};
|
||||
const currentFactor =
|
||||
threePhaseVoltageV > 0 ? 1000 / (Math.sqrt(3) * threePhaseVoltageV) : 0;
|
||||
return {
|
||||
groups,
|
||||
total,
|
||||
reserve,
|
||||
withReserve,
|
||||
maxCurrentA: {
|
||||
av: withReserve.av.kva * currentFactor,
|
||||
sv: withReserve.sv.kva * currentFactor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns a detailed cost group (e.g. "444" or "440.1") to the best matching
|
||||
* estimate group: a group code with trailing zeros removed is a prefix
|
||||
* ("430" -> "43", "434" -> "434"); the longest prefix wins. Returns null when
|
||||
* no group matches.
|
||||
*/
|
||||
export function matchEstimateCostGroup(
|
||||
detailedCostGroup: string | null,
|
||||
groupCostGroups: readonly string[]
|
||||
): string | null {
|
||||
const digits = (detailedCostGroup ?? "").replace(/\D/g, "");
|
||||
if (!digits) {
|
||||
return null;
|
||||
}
|
||||
let best: { code: string; length: number } | null = null;
|
||||
for (const code of groupCostGroups) {
|
||||
const codeDigits = code.replace(/\D/g, "");
|
||||
const prefix = codeDigits.replace(/0+$/, "");
|
||||
if (!prefix || !digits.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
if (!best || prefix.length > best.length) {
|
||||
best = { code, length: prefix.length };
|
||||
}
|
||||
}
|
||||
return best?.code ?? null;
|
||||
}
|
||||
|
||||
export interface EstimateComparisonGroup {
|
||||
groupId: string;
|
||||
costGroup: string;
|
||||
title: string;
|
||||
estimatedAv: EstimatePower;
|
||||
detailed: EstimatePower;
|
||||
detailedRowCount: number;
|
||||
deltaKw: number;
|
||||
/** Relative deviation of the detailed value from the estimate, null without estimate. */
|
||||
deltaPercent: number | null;
|
||||
}
|
||||
|
||||
export interface EstimateComparison {
|
||||
groups: EstimateComparisonGroup[];
|
||||
unassigned: EstimatePower & { rowCount: number };
|
||||
total: {
|
||||
estimatedAv: EstimatePower;
|
||||
detailed: EstimatePower;
|
||||
deltaKw: number;
|
||||
deltaPercent: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DetailedDeviceRowPower {
|
||||
costGroup: string | null;
|
||||
/** Total power of the device row in kW (quantity · power · device GZF). */
|
||||
kw: number;
|
||||
cosPhi: number | null;
|
||||
}
|
||||
|
||||
function delta(estimated: number, detailed: number) {
|
||||
return {
|
||||
deltaKw: detailed - estimated,
|
||||
deltaPercent: estimated > 0 ? ((detailed - estimated) / estimated) * 100 : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the estimate per cost group with the detailed device rows of all
|
||||
* circuit lists. The distribution-board simultaneity factor is not applied
|
||||
* on either side; a missing cos φ counts as 1.
|
||||
*/
|
||||
export function compareEstimateWithDetailed(
|
||||
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
|
||||
detailedRows: readonly DetailedDeviceRowPower[]
|
||||
): EstimateComparison {
|
||||
const estimate = calculateEstimateBalance(balance, 0);
|
||||
const codes = balance.groups.map((group) => group.costGroup).filter(Boolean);
|
||||
const detailedByCode = new Map<string, EstimatePower & { rowCount: number }>();
|
||||
const unassigned = { kw: 0, kva: 0, rowCount: 0 };
|
||||
for (const row of detailedRows) {
|
||||
const kva = row.cosPhi && row.cosPhi > 0 ? row.kw / row.cosPhi : row.kw;
|
||||
const code = matchEstimateCostGroup(row.costGroup, codes);
|
||||
const target =
|
||||
code === null
|
||||
? unassigned
|
||||
: detailedByCode.get(code) ??
|
||||
detailedByCode.set(code, { kw: 0, kva: 0, rowCount: 0 }).get(code)!;
|
||||
target.kw += row.kw;
|
||||
target.kva += kva;
|
||||
target.rowCount += 1;
|
||||
}
|
||||
// A cost group code can appear in several groups; the detailed value is
|
||||
// shown once, on the first group with that code.
|
||||
const shownCodes = new Set<string>();
|
||||
const groups = balance.groups.map((group, index) => {
|
||||
const estimatedAv = estimate.groups[index]!.av;
|
||||
const detailedEntry =
|
||||
group.costGroup && !shownCodes.has(group.costGroup)
|
||||
? detailedByCode.get(group.costGroup)
|
||||
: undefined;
|
||||
if (group.costGroup) {
|
||||
shownCodes.add(group.costGroup);
|
||||
}
|
||||
const detailed = { kw: detailedEntry?.kw ?? 0, kva: detailedEntry?.kva ?? 0 };
|
||||
return {
|
||||
groupId: group.id,
|
||||
costGroup: group.costGroup,
|
||||
title: group.title,
|
||||
estimatedAv,
|
||||
detailed,
|
||||
detailedRowCount: detailedEntry?.rowCount ?? 0,
|
||||
...delta(estimatedAv.kw, detailed.kw),
|
||||
};
|
||||
});
|
||||
const detailedTotal = groups.reduce(
|
||||
(sum, group) => addPower(sum, group.detailed),
|
||||
{ kw: unassigned.kw, kva: unassigned.kva }
|
||||
);
|
||||
return {
|
||||
groups,
|
||||
unassigned,
|
||||
total: {
|
||||
estimatedAv: estimate.total.av,
|
||||
detailed: detailedTotal,
|
||||
...delta(estimate.total.av.kw, detailedTotal.kw),
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue