forked from jappel/leistungsbilanz-ts
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue