forked from jappel/leistungsbilanz-ts
Add the rough power balance module for early design phases
In LPH 2/3 there are no circuits yet, but the office needs a power balance by DIN 276 cost group, as in the Excel template "Leistungsbilanz - KG400": each trade enters consumers by piece or by area (W/m²) with GLF and cos phi; the balance sums AV and SV per cost group, adds a reserve and derives the maximum current. The module is separate from the circuit structure: own tables (migration 0010), own folder src/estimate-balance and an import-free shared calculation used by API and UI. Writes go through project commands (estimate-balance.insert/.update/.delete with complete documents), so revision checks and Undo/Redo apply; it is not part of project snapshots or transfer yet. A comparison endpoint sums the device rows of all circuit lists by cost group (longest prefix) against the estimate. The UI adds a sidebar entry, a list of Stände (template KG 400, empty or copy) and a spreadsheet-like editor with autosave, AMEV picker for the GLF, totals, reserve, maximum current, comparison table and print layout. The tests reproduce the template example exactly (143.24 kW, 148.48 kVA, 157.56 kW / 163.33 kVA with 10 % reserve, 235.75 A). See docs/estimate-balance.md and issue #7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
80dbc73230
commit
37a70b6b36
37 changed files with 8537 additions and 4 deletions
21
AGENTS.md
21
AGENTS.md
|
|
@ -576,6 +576,27 @@ Future sizing will need:
|
|||
|
||||
Users must be able to override sizing suggestions.
|
||||
|
||||
## Estimate Balance Module (Grobe Leistungsbilanz)
|
||||
|
||||
`src/estimate-balance` plus `src/shared/estimate-balance` is a separate module
|
||||
for the early-phase power balance by DIN 276 cost group (see
|
||||
`docs/estimate-balance.md`). Rules:
|
||||
|
||||
- It has its own tables (`estimate_*`) and never writes circuit, board or
|
||||
project-device data; it only reads device rows for the comparison.
|
||||
- Writes use the project command mechanism (`estimate-balance.insert/.update/
|
||||
.delete` with complete documents), so revision checks and Undo/Redo apply.
|
||||
- The calculation and comparison live in the import-free
|
||||
`src/shared/estimate-balance/estimate-balance.ts` and are shared by API and UI;
|
||||
displayed values use `roundEstimateValue`.
|
||||
- It is not part of project snapshots or project transfer yet (open question
|
||||
in issue #7); do not add it there without a snapshot schema version.
|
||||
- UI: `/projects/:id/grobbilanz` (Stände) and `/grobbilanz/:balanceId` (editor,
|
||||
`src/frontend/estimate-balance/`). The editor autosaves the whole document
|
||||
debounced (one Undo step per save), keeps invalid raw input local, and on a
|
||||
409 reloads only if the balance itself changed; otherwise it re-saves with
|
||||
the new revision. `request()` throws `ApiRequestError` (status, code).
|
||||
|
||||
## Persistence and Migration Rules
|
||||
|
||||
- SQLite is the currently supported database.
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
|
|||
Verteilerübersicht, Verteilerpläne, Büro-Logo
|
||||
- [Gebäudestruktur](building-structure.md) – Gebäude → Etagen → Räume,
|
||||
Befehle, Snapshot-Schema 7, eigene Seite
|
||||
- [Grobe Leistungsbilanz](estimate-balance.md) – LPH 2/3 nach Kostengruppen,
|
||||
Rechnung, Vergleich mit der detaillierten Bilanz
|
||||
- [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte
|
||||
- [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) –
|
||||
bewusst noch nicht implementierte oder begrenzte Funktionen
|
||||
|
|
|
|||
|
|
@ -214,6 +214,12 @@ A stale `expectedRevision` returns HTTP `409` with
|
|||
`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry
|
||||
returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
||||
|
||||
## Estimate Balance (Grobe Leistungsbilanz)
|
||||
|
||||
Separate module under `/projects/:projectId/estimate-balances`; complete
|
||||
documents are written through `estimate-balance.insert/.update/.delete` project
|
||||
commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
|
||||
|
||||
## Circuit-First Endpoints
|
||||
|
||||
### Distribution Board Setup
|
||||
|
|
|
|||
108
docs/estimate-balance.md
Normal file
108
docs/estimate-balance.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# Grobe Leistungsbilanz (LPH 2/3)
|
||||
|
||||
Eigenes Modul für die Leistungsbilanz der Vor- und Entwurfsplanung, gegliedert
|
||||
nach Kostengruppen (DIN 276, KG 400). Es gibt dort noch keine Verteilungen und
|
||||
Stromkreise: Jedes Gewerk trägt unter seiner Kostengruppe Verbraucher als
|
||||
Stückzahl mit Einzelleistung oder als Fläche mit W/m² ein. Aufbau und Rechnung
|
||||
folgen der bisherigen Excel-Vorlage „Leistungsbilanz – KG400“. Abstimmung:
|
||||
Issue #7.
|
||||
|
||||
Das Modul ist **nicht** mit der Stromkreisstruktur verknüpft. Es liest die
|
||||
Gerätezeilen nur für den Vergleich „geschätzt ↔ detailliert“ und schreibt
|
||||
nichts in Stromkreislisten, Verteilungen oder Projektgeräte.
|
||||
|
||||
## Aufbau
|
||||
|
||||
```
|
||||
src/shared/estimate-balance/estimate-balance.ts Datenform, Rechnung, Vergleich (rein, ohne Imports)
|
||||
src/estimate-balance/domain/estimate-balance-command.model.ts Snapshot-Schema (Zod), Befehle
|
||||
src/estimate-balance/domain/estimate-balance-project-command.store.ts Store-Port
|
||||
src/estimate-balance/api/estimate-balance-request.schemas.ts Request-Schemas
|
||||
src/db/schema/estimate-balances.ts Tabellen (Migration 0010)
|
||||
src/db/repositories/estimate-balance*.ts Lesen, Schreiben, Befehls-Store
|
||||
src/server/controllers/estimate-balance.controller.ts API
|
||||
src/app/projects/[projectId]/grobbilanz/ Übersicht der Stände und Editor
|
||||
src/frontend/estimate-balance/ Editor-Komponenten und Helfer
|
||||
```
|
||||
|
||||
Die gemeinsame Datei unter `src/shared/estimate-balance` hat keine Imports, weil
|
||||
das Frontend sie direkt nutzt (Turbopack löst die `.js`-Suffixe der API nicht auf).
|
||||
|
||||
## Datenmodell
|
||||
|
||||
| Tabelle | Inhalt |
|
||||
|---|---|
|
||||
| `estimate_balances` | ein **Stand** je Projekt (z. B. Vorplanung, Entwurfsplanung): Name, LPH, Datum, Reserve AV/SV in %, Sortierung |
|
||||
| `estimate_groups` | Kostengruppe (`"410"` … `"480"`, `""` für „Allgemein“), Titel, „bearbeitet von“, Sortierung |
|
||||
| `estimate_rows` | Versorgung, Stromkreis-Nr. (Freitext), Verbraucher/Fläche, Anzahl bzw. Fläche, Einheit (`piece` = St., `area` = m²), Anschluss (fest/Steckdose), Einzelleistung in W bzw. W/m², GLF, cos φ, SV ja/nein, Anmerkung |
|
||||
|
||||
Neue Stände entstehen aus der Vorlage KG 400 (410 Sanitär, 420 Wärme, 430 Luft,
|
||||
434 Kälte, 440 Starkstrom, 450 Schwachstrom, 460 Förder, 470 Nutzungsspezifisch,
|
||||
480 GA, Allgemein; Reserve AV 10 %), leer oder als Kopie eines bestehenden
|
||||
Stands mit neuen IDs. So lässt sich ein Stand einfrieren und später vergleichen.
|
||||
|
||||
## Rechnung
|
||||
|
||||
- Zeile: P = Anzahl bzw. Fläche × Einzelleistung × GLF, S = P / cos φ.
|
||||
- **AV** enthält alle Verbraucher (Normalversorgung). **SV** listet zusätzlich
|
||||
die Zeilen mit „SV = ja“, die auch aus der Sicherheitsstromversorgung versorgt
|
||||
werden müssen. Das ist die Lesart der Vorlage; die Regel liegt in
|
||||
`calculateEstimateRow` und lässt sich dort zentral ändern.
|
||||
- Summen je Kostengruppe, Gesamtsumme, Reserve in % (AV und SV getrennt),
|
||||
Leistung inklusive Reserve und Maximalstrom I = S / (√3 · U) mit der
|
||||
Drehstromspannung aus den Projekteinstellungen.
|
||||
- Anzeige wie in der Tabellenkalkulation gerundet (`roundEstimateValue`,
|
||||
40,175 → 40,18). Der Test `tests/estimate-balance.test.ts` rechnet das
|
||||
Beispiel der Vorlage vollständig nach (143,24 kW, 148,48 kVA, mit 10 %
|
||||
Reserve 157,56 kW / 163,33 kVA, 235,75 A).
|
||||
|
||||
## Vergleich geschätzt ↔ detailliert
|
||||
|
||||
`GET …/comparison` summiert alle Gerätezeilen aller Stromkreislisten des
|
||||
Projekts (Anzahl × Leistung je Stück × Geräte-GZF, wie der Stromkreisbaum) und
|
||||
ordnet sie über das Feld **Kostengruppe** der Gerätezeile zu. Maßgeblich ist das
|
||||
längste passende Präfix: `434` → Gruppe 434, `431` → Gruppe 430, `444` → 440.
|
||||
Zeilen ohne passende Kostengruppe erscheinen als „nicht zugeordnet“. Der
|
||||
Verteiler-GZF wird auf keiner Seite angewendet; ein fehlendes cos φ zählt als 1.
|
||||
Verglichen wird die AV-Leistung (kW) mit Differenz absolut und in %.
|
||||
|
||||
## Befehle, Undo/Redo
|
||||
|
||||
Alle Änderungen laufen über den bestehenden Projekt-Befehlsmechanismus mit
|
||||
Revision, Inverse und persistentem Undo/Redo:
|
||||
|
||||
- `estimate-balance.insert` / `estimate-balance.delete` mit dem vollständigen
|
||||
Stand (Gruppen und Zeilen)
|
||||
- `estimate-balance.update` mit vollständigem `expected`/`target`-Dokument; der
|
||||
Store prüft, dass der gespeicherte Stand genau `expected` entspricht, und
|
||||
ersetzt ihn atomar. Die Inverse ist das vertauschte Paar.
|
||||
|
||||
Der Editor speichert das ganze Dokument verzögert (Autosave); jeder
|
||||
Speichervorgang ist ein Undo-Schritt. Unveränderte Dokumente erzeugen keine
|
||||
Revision (`{ unchanged: true }`).
|
||||
|
||||
## Bewusst nicht enthalten (Fragen an Julian, Issue #7)
|
||||
|
||||
- Die grobe Bilanz ist **nicht** Teil von Projekt-Snapshot, Restore und
|
||||
Projekttransfer; das Snapshot-Schema bleibt unverändert. Ein Restore lässt
|
||||
die Stände unverändert, eine Projektkopie übernimmt sie nicht.
|
||||
- Keine Rechte je Gewerk; „bearbeitet von“ ist ein Freitext je Kostengruppe.
|
||||
- Kostengruppen der Gerätezeilen sind nicht normiert; der Vergleich arbeitet
|
||||
mit Präfixen.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/projects/:projectId/estimate-balances`
|
||||
- `POST /api/projects/:projectId/estimate-balances` –
|
||||
`{ "expectedRevision": 7, "name": "Entwurfsplanung", "workStage": "LPH 3", "dateIso": "2026-09-26", "template": "kg400" }`
|
||||
(`template`: `kg400` | `empty` | `copy` mit `copyFromBalanceId`)
|
||||
- `GET /api/projects/:projectId/estimate-balances/:balanceId`
|
||||
- `PUT /api/projects/:projectId/estimate-balances/:balanceId` –
|
||||
`{ "expectedRevision": 8, "balance": { …vollständiges Dokument… } }`
|
||||
- `DELETE /api/projects/:projectId/estimate-balances/:balanceId` – `{ "expectedRevision": 9 }`
|
||||
- `GET /api/projects/:projectId/estimate-balances/:balanceId/comparison`
|
||||
|
||||
Validierung: Name 1–200 Zeichen, getrimmte Texte, Anzahl/Fläche und
|
||||
Einzelleistung ≥ 0, GLF 0–1, cos φ > 0 bis 1, Reserve 0–100 %, Datum
|
||||
`YYYY-MM-DD`, eindeutige Gruppen- und Zeilen-IDs, unbekannte Felder werden
|
||||
abgelehnt.
|
||||
|
|
@ -6,6 +6,9 @@ const db = new Database(dbPath, { readonly: true });
|
|||
|
||||
const requiredTables = [
|
||||
"buildings",
|
||||
"estimate_balances",
|
||||
"estimate_groups",
|
||||
"estimate_rows",
|
||||
"circuit_sections",
|
||||
"circuits",
|
||||
"circuit_device_rows",
|
||||
|
|
|
|||
|
|
@ -1602,3 +1602,493 @@ button.structure-entry-main:focus-visible {
|
|||
color: var(--color-ink-soft);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* ── Grobe Leistungsbilanz ──────────────────────────────────────────── */
|
||||
/* Tabelle im Stil der Büro-Vorlage „Leistungsbilanz – KG400“: kompakte
|
||||
Zellen-Inputs statt Bootstrap-Formularfelder, Druck als A4 quer. */
|
||||
|
||||
.estimate-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.estimate-save-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.estimate-save-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-ink-soft);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.estimate-save-badge.is-saved {
|
||||
color: var(--color-signal-dark);
|
||||
border-color: var(--color-signal);
|
||||
}
|
||||
|
||||
.estimate-save-badge.is-busy {
|
||||
color: var(--color-warn);
|
||||
border-color: var(--color-warn);
|
||||
}
|
||||
|
||||
.estimate-save-badge.is-error {
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger-bg);
|
||||
}
|
||||
|
||||
.estimate-save-hint {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.estimate-header-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm) var(--space-lg);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.estimate-header-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
.estimate-header-field.is-wide {
|
||||
flex: 1 1 18rem;
|
||||
}
|
||||
|
||||
.estimate-header-label {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
color: var(--color-ink-soft);
|
||||
}
|
||||
|
||||
.estimate-header-value {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.estimate-header-input {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
border-color: var(--panel-border);
|
||||
}
|
||||
|
||||
.estimate-sheet-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.estimate-sheet {
|
||||
width: 100%;
|
||||
min-width: 1400px;
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
background: var(--panel-bg);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.estimate-comparison {
|
||||
min-width: 640px;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.estimate-sheet th,
|
||||
.estimate-sheet td {
|
||||
border: 1px solid var(--panel-border);
|
||||
padding: 2px 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.estimate-sheet thead th {
|
||||
background: var(--surface-header-row);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.estimate-th-hint {
|
||||
display: block;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.estimate-col-kg { width: 3rem; }
|
||||
.estimate-col-supply { width: 4rem; }
|
||||
.estimate-col-circuit { width: 4.6rem; }
|
||||
.estimate-col-name { width: auto; }
|
||||
.estimate-col-quantity { width: 4.8rem; }
|
||||
.estimate-col-unit { width: 3.8rem; }
|
||||
.estimate-col-connection { width: 5rem; }
|
||||
.estimate-col-power { width: 6.4rem; }
|
||||
.estimate-col-gzf { width: 6.8rem; }
|
||||
.estimate-col-cosphi { width: 3.8rem; }
|
||||
.estimate-col-result { width: 4.6rem; }
|
||||
.estimate-col-sv { width: 3.2rem; }
|
||||
.estimate-col-remark { width: 9rem; }
|
||||
.estimate-col-actions { width: 2.4rem; }
|
||||
|
||||
.estimate-cell-input,
|
||||
.estimate-cell-select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 1px 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.estimate-cell-select {
|
||||
padding-right: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.estimate-cell-input:hover:not(:disabled),
|
||||
.estimate-cell-select:hover:not(:disabled) {
|
||||
border-color: var(--panel-border-strong);
|
||||
}
|
||||
|
||||
.estimate-cell-input:focus,
|
||||
.estimate-cell-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue);
|
||||
background: var(--panel-bg);
|
||||
box-shadow: 0 0 0 2px var(--accent-blue-soft-bg);
|
||||
}
|
||||
|
||||
.estimate-cell-input.is-numeric {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.estimate-cell-input.is-invalid,
|
||||
.estimate-cell-input.is-invalid:focus,
|
||||
.estimate-cell-input.is-invalid:hover {
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger-bg);
|
||||
}
|
||||
|
||||
.estimate-cell-input::placeholder {
|
||||
color: var(--text-faint);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.estimate-unit-input,
|
||||
.estimate-gzf-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.estimate-unit-label {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-faint);
|
||||
min-width: 1.9rem;
|
||||
}
|
||||
|
||||
.estimate-amev-button {
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--panel-border-strong);
|
||||
border-radius: 3px;
|
||||
background: var(--panel-bg-subtle);
|
||||
color: var(--text-subtle);
|
||||
font-size: 0.6rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 1px 3px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.estimate-amev-button:hover:not(:disabled) {
|
||||
border-color: var(--accent-blue);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.estimate-number {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.estimate-number.is-muted {
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.estimate-number.is-over {
|
||||
color: var(--color-attention-dark);
|
||||
}
|
||||
|
||||
.estimate-number.is-under {
|
||||
color: var(--color-signal-dark);
|
||||
}
|
||||
|
||||
.estimate-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.estimate-cost-group-cell {
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.estimate-group-title-row td {
|
||||
background: var(--surface-component-header);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.estimate-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs) var(--space-lg);
|
||||
}
|
||||
|
||||
.estimate-group-name {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.estimate-group-editor {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-weight: 400;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.estimate-group-editor .estimate-cell-input {
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
.estimate-group-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.estimate-group-title-row .estimate-actions-cell {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.estimate-icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.15rem 0.3rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.estimate-actions-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.estimate-empty-row td {
|
||||
color: var(--text-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.estimate-subtotal-row td {
|
||||
background: var(--panel-bg-subtle);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.estimate-total-label {
|
||||
text-align: right;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.estimate-total-hint {
|
||||
font-weight: 400;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.estimate-total-row td {
|
||||
background: var(--surface-header-row);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.estimate-total-row.is-strong td {
|
||||
font-weight: 800;
|
||||
border-top: 2px solid var(--panel-border-strong);
|
||||
}
|
||||
|
||||
.estimate-add-group-row td {
|
||||
background: var(--panel-bg);
|
||||
padding: var(--space-sm) 4px;
|
||||
}
|
||||
|
||||
.estimate-unassigned-row td {
|
||||
font-style: italic;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.estimate-collapse-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.estimate-list-table .estimate-number {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Druck-Textwerte: auf dem Bildschirm unsichtbar, im Druck statt Inputs */
|
||||
.estimate-print-value {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@page estimate-balance {
|
||||
size: A4 landscape;
|
||||
margin: 10mm;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body:has(.estimate-print-root) .sidebar,
|
||||
.estimate-print-root .estimate-no-print,
|
||||
.estimate-print-root .alert,
|
||||
.estimate-print-root input,
|
||||
.estimate-print-root select,
|
||||
.estimate-print-root button {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body:has(.estimate-print-root) {
|
||||
background: #fff !important;
|
||||
}
|
||||
|
||||
body:has(.estimate-print-root) .app-shell {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.estimate-print-root {
|
||||
page: estimate-balance;
|
||||
padding: 0 !important;
|
||||
max-width: none;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.estimate-print-root .estimate-print-value {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.estimate-print-root .page-header {
|
||||
margin-bottom: 4mm;
|
||||
}
|
||||
|
||||
.estimate-print-root .page-header h1 {
|
||||
font-size: 14pt;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.estimate-print-root .card {
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.estimate-print-root .card-body,
|
||||
.estimate-print-root .card-header {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.estimate-print-root .estimate-comparison-card {
|
||||
margin-top: 6mm;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.estimate-sheet-wrap {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.estimate-sheet {
|
||||
min-width: 0;
|
||||
table-layout: auto;
|
||||
font-size: 7.5pt;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.estimate-sheet th,
|
||||
.estimate-sheet td {
|
||||
border-color: #999;
|
||||
padding: 1px 3px;
|
||||
}
|
||||
|
||||
.estimate-sheet thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
.estimate-sheet thead th,
|
||||
.estimate-total-row td {
|
||||
background: #e8e8e8 !important;
|
||||
}
|
||||
|
||||
.estimate-group-title-row td {
|
||||
background: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.estimate-subtotal-row td {
|
||||
background: #f2f2f2 !important;
|
||||
}
|
||||
|
||||
.estimate-group-title-row,
|
||||
.estimate-subtotal-row,
|
||||
.estimate-total-row {
|
||||
break-inside: avoid;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
.estimate-group-title-row {
|
||||
break-after: avoid;
|
||||
}
|
||||
|
||||
/* Totals print once at the end, not repeated on every page. */
|
||||
.estimate-sheet tfoot {
|
||||
display: table-row-group;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.estimate-col-actions {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.estimate-unit-input,
|
||||
.estimate-gzf-input {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.estimate-header-fields {
|
||||
margin-bottom: 3mm;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
743
src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx
Normal file
743
src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx
Normal file
|
|
@ -0,0 +1,743 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Printer,
|
||||
Redo2,
|
||||
RefreshCw,
|
||||
Undo2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
ApiRequestError,
|
||||
getEstimateBalance,
|
||||
getEstimateComparison,
|
||||
getProject,
|
||||
getProjectHistory,
|
||||
isRevisionConflictError,
|
||||
redoProjectCommand,
|
||||
undoProjectCommand,
|
||||
updateEstimateBalance,
|
||||
} from "../../../../../frontend/utils/api";
|
||||
import type {
|
||||
EstimateBalanceDto,
|
||||
EstimateComparisonDto,
|
||||
ProjectDto,
|
||||
ProjectHistoryStateDto,
|
||||
} from "../../../../../frontend/types";
|
||||
import type { EstimateGroupSnapshot } from "../../../../../shared/estimate-balance/estimate-balance";
|
||||
import { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal";
|
||||
import {
|
||||
addEstimateGroup,
|
||||
estimateBalanceFingerprint,
|
||||
estimateTextLimits,
|
||||
normalizeEstimateSortOrder,
|
||||
toSaveableEstimateBalance,
|
||||
updateEstimateGroup,
|
||||
updateEstimateRow,
|
||||
validateEstimateBalance,
|
||||
} from "../../../../../frontend/estimate-balance/estimate-balance-document";
|
||||
import { formatIsoDateGerman } from "../../../../../frontend/estimate-balance/estimate-balance-format";
|
||||
import {
|
||||
EstimateBalanceSheet,
|
||||
type EstimateBalanceUpdate,
|
||||
} from "../../../../../frontend/estimate-balance/estimate-balance-sheet";
|
||||
import { EstimateComparisonTable } from "../../../../../frontend/estimate-balance/estimate-comparison-table";
|
||||
import { DraftInput } from "../../../../../frontend/estimate-balance/estimate-draft-input";
|
||||
import {
|
||||
EstimateGroupModal,
|
||||
type EstimateGroupFormValues,
|
||||
} from "../../../../../frontend/estimate-balance/estimate-group-modal";
|
||||
|
||||
const AUTOSAVE_DELAY_MS = 800;
|
||||
const CONFLICT_MESSAGE = "Die Bilanz wurde zwischenzeitlich geändert und neu geladen.";
|
||||
|
||||
type SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid";
|
||||
|
||||
type GroupDialog = { group: EstimateGroupSnapshot | null };
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message ? error.message : fallback;
|
||||
}
|
||||
|
||||
function isNotFoundError(error: unknown) {
|
||||
return error instanceof ApiRequestError && error.status === 404;
|
||||
}
|
||||
|
||||
export default function EstimateBalanceEditorPage() {
|
||||
const params = useParams<{ projectId: string; balanceId: string }>();
|
||||
const { projectId, balanceId } = params;
|
||||
const router = useRouter();
|
||||
|
||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||
const [draft, setDraft] = useState<EstimateBalanceDto | null>(null);
|
||||
const [history, setHistory] = useState<ProjectHistoryStateDto | null>(null);
|
||||
const [comparison, setComparison] = useState<EstimateComparisonDto | null>(null);
|
||||
const [comparisonError, setComparisonError] = useState<string | null>(null);
|
||||
const [isComparisonOpen, setIsComparisonOpen] = useState(false);
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("saved");
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isMissing, setIsMissing] = useState(false);
|
||||
const [isHistoryBusy, setIsHistoryBusy] = useState(false);
|
||||
const [invalidFieldKeys, setInvalidFieldKeys] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
);
|
||||
const [groupDialog, setGroupDialog] = useState<GroupDialog | null>(null);
|
||||
const [gzfTarget, setGzfTarget] = useState<{ groupId: string; rowId: string } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Refs carry the latest values into timers, unload handlers and chained
|
||||
// async saves without re-creating them.
|
||||
const draftRef = useRef<EstimateBalanceDto | null>(null);
|
||||
const revisionRef = useRef<number | null>(null);
|
||||
const savedFingerprintRef = useRef<string | null>(null);
|
||||
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
const applyServerBalance = useCallback((balance: EstimateBalanceDto) => {
|
||||
const normalized = normalizeEstimateSortOrder(balance);
|
||||
savedFingerprintRef.current = estimateBalanceFingerprint(normalized);
|
||||
draftRef.current = normalized;
|
||||
setDraft(normalized);
|
||||
setSaveStatus("saved");
|
||||
setSaveError(null);
|
||||
}, []);
|
||||
|
||||
const reloadComparison = useCallback(async () => {
|
||||
try {
|
||||
setComparison(await getEstimateComparison(projectId, balanceId));
|
||||
setComparisonError(null);
|
||||
} catch (err) {
|
||||
setComparisonError(
|
||||
errorMessage(err, "Der Vergleich konnte nicht geladen werden.")
|
||||
);
|
||||
}
|
||||
}, [balanceId, projectId]);
|
||||
|
||||
/** Reloads project, document and history; replaces the local draft. */
|
||||
const reloadAll = useCallback(async () => {
|
||||
const balanceRequest = getEstimateBalance(projectId, balanceId);
|
||||
// Handled below; avoids an "unhandled rejection" while project/history load.
|
||||
balanceRequest.catch(() => undefined);
|
||||
// Project and history are applied even when the balance is missing, so
|
||||
// Redo stays available after its creation was undone.
|
||||
const [currentProject, currentHistory] = await Promise.all([
|
||||
getProject(projectId),
|
||||
getProjectHistory(projectId).catch(() => null),
|
||||
]);
|
||||
setProject(currentProject);
|
||||
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
|
||||
setHistory(currentHistory);
|
||||
const balance = await balanceRequest;
|
||||
applyServerBalance(balance);
|
||||
setIsMissing(false);
|
||||
void reloadComparison();
|
||||
}, [applyServerBalance, balanceId, projectId, reloadComparison]);
|
||||
|
||||
const handleReloadError = useCallback((err: unknown) => {
|
||||
if (isNotFoundError(err)) {
|
||||
setIsMissing(true);
|
||||
setDraft(null);
|
||||
draftRef.current = null;
|
||||
return;
|
||||
}
|
||||
setError(errorMessage(err, "Die grobe Leistungsbilanz konnte nicht geladen werden."));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !balanceId) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
reloadAll()
|
||||
.then(() => setError(null))
|
||||
.catch(handleReloadError)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [balanceId, handleReloadError, projectId, reloadAll]);
|
||||
|
||||
const clearSaveTimer = useCallback(() => {
|
||||
if (saveTimerRef.current) {
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Saves the current draft if it differs from the last saved state. Saves
|
||||
* never overlap: a save requested while another runs waits for it and then
|
||||
* saves whatever changed in the meantime. Resolves to false when the draft
|
||||
* could not be saved.
|
||||
*/
|
||||
const saveNow = useCallback(async (): Promise<boolean> => {
|
||||
clearSaveTimer();
|
||||
while (saveInFlightRef.current) {
|
||||
await saveInFlightRef.current;
|
||||
}
|
||||
const current = draftRef.current;
|
||||
const revision = revisionRef.current;
|
||||
if (!current || revision === null) {
|
||||
return true;
|
||||
}
|
||||
const fingerprint = estimateBalanceFingerprint(current);
|
||||
if (fingerprint === savedFingerprintRef.current) {
|
||||
setSaveStatus("saved");
|
||||
return true;
|
||||
}
|
||||
const problems = validateEstimateBalance(current);
|
||||
if (problems.length) {
|
||||
setSaveStatus("invalid");
|
||||
setSaveError(problems.join(" "));
|
||||
return false;
|
||||
}
|
||||
const body = toSaveableEstimateBalance(current);
|
||||
|
||||
/**
|
||||
* Another command changed the project revision. When this document is
|
||||
* still unchanged on the server (e.g. an edit in a circuit list), the
|
||||
* draft is kept and saved again; otherwise the server state wins.
|
||||
*/
|
||||
async function resolveConflict(): Promise<"retry" | "reloaded"> {
|
||||
const [currentProject, serverBalance, currentHistory] = await Promise.all([
|
||||
getProject(projectId),
|
||||
getEstimateBalance(projectId, balanceId),
|
||||
getProjectHistory(projectId).catch(() => null),
|
||||
]);
|
||||
setProject(currentProject);
|
||||
setHistory(currentHistory);
|
||||
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
|
||||
if (estimateBalanceFingerprint(serverBalance) === savedFingerprintRef.current) {
|
||||
return "retry";
|
||||
}
|
||||
applyServerBalance(serverBalance);
|
||||
setNotice(CONFLICT_MESSAGE);
|
||||
void reloadComparison();
|
||||
return "reloaded";
|
||||
}
|
||||
|
||||
const run = (async () => {
|
||||
setSaveStatus("saving");
|
||||
for (let attempt = 0; ; attempt += 1) {
|
||||
try {
|
||||
const result = await updateEstimateBalance(
|
||||
projectId,
|
||||
body,
|
||||
attempt === 0 ? revision : revisionRef.current ?? revision
|
||||
);
|
||||
if (!("unchanged" in result)) {
|
||||
revisionRef.current = result.history.currentRevision;
|
||||
setHistory(result.history);
|
||||
setProject((currentProject) =>
|
||||
currentProject
|
||||
? { ...currentProject, currentRevision: result.history.currentRevision }
|
||||
: currentProject
|
||||
);
|
||||
}
|
||||
// The sent document is the saved state; the local draft is kept so
|
||||
// typing is never interrupted by the server's copy.
|
||||
savedFingerprintRef.current = fingerprint;
|
||||
setSaveError(null);
|
||||
const latest = draftRef.current;
|
||||
if (latest && estimateBalanceFingerprint(latest) !== fingerprint) {
|
||||
setSaveStatus("pending");
|
||||
} else {
|
||||
setSaveStatus("saved");
|
||||
}
|
||||
void reloadComparison();
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isRevisionConflictError(err)) {
|
||||
try {
|
||||
if (attempt === 0 && (await resolveConflict()) === "retry") {
|
||||
continue;
|
||||
}
|
||||
if (attempt > 0) {
|
||||
await reloadAll();
|
||||
setNotice(CONFLICT_MESSAGE);
|
||||
}
|
||||
} catch (reloadError) {
|
||||
handleReloadError(reloadError);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (isNotFoundError(err)) {
|
||||
handleReloadError(err);
|
||||
return false;
|
||||
}
|
||||
setSaveStatus("error");
|
||||
setSaveError(errorMessage(err, "Die Änderungen konnten nicht gespeichert werden."));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
saveInFlightRef.current = run;
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
saveInFlightRef.current = null;
|
||||
}
|
||||
}, [
|
||||
applyServerBalance,
|
||||
balanceId,
|
||||
clearSaveTimer,
|
||||
handleReloadError,
|
||||
projectId,
|
||||
reloadAll,
|
||||
reloadComparison,
|
||||
]);
|
||||
|
||||
// Debounced autosave after every change of the draft.
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
if (estimateBalanceFingerprint(draft) === savedFingerprintRef.current) {
|
||||
return;
|
||||
}
|
||||
setSaveStatus((status) => (status === "saving" ? status : "pending"));
|
||||
clearSaveTimer();
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveTimerRef.current = null;
|
||||
void saveNow();
|
||||
}, AUTOSAVE_DELAY_MS);
|
||||
return clearSaveTimer;
|
||||
}, [clearSaveTimer, draft, saveNow]);
|
||||
|
||||
// Save immediately when the tab is hidden; on unload/unmount send the last
|
||||
// change with keepalive so it survives the page.
|
||||
useEffect(() => {
|
||||
function hasUnsavedDraft() {
|
||||
const current = draftRef.current;
|
||||
return Boolean(
|
||||
current && estimateBalanceFingerprint(current) !== savedFingerprintRef.current
|
||||
);
|
||||
}
|
||||
function sendKeepalive(): boolean {
|
||||
const current = draftRef.current;
|
||||
const revision = revisionRef.current;
|
||||
if (
|
||||
!current ||
|
||||
revision === null ||
|
||||
saveInFlightRef.current ||
|
||||
validateEstimateBalance(current).length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
savedFingerprintRef.current = estimateBalanceFingerprint(current);
|
||||
void updateEstimateBalance(projectId, toSaveableEstimateBalance(current), revision, {
|
||||
keepalive: true,
|
||||
}).catch(() => undefined);
|
||||
return true;
|
||||
}
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "hidden" && hasUnsavedDraft()) {
|
||||
void saveNow();
|
||||
}
|
||||
}
|
||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!hasUnsavedDraft() || sendKeepalive()) {
|
||||
return;
|
||||
}
|
||||
// A save is still running or the draft is invalid: let the browser ask.
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
}
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
// In-app navigation (e.g. sidebar) unmounts the editor.
|
||||
if (hasUnsavedDraft()) {
|
||||
sendKeepalive();
|
||||
}
|
||||
};
|
||||
}, [projectId, saveNow]);
|
||||
|
||||
const handleChange: EstimateBalanceUpdate = useCallback((update) => {
|
||||
setNotice(null);
|
||||
setDraft((current) => (current ? update(current) : current));
|
||||
}, []);
|
||||
|
||||
const handleValidityChange = useCallback((fieldKey: string, isValid: boolean) => {
|
||||
setInvalidFieldKeys((current) => {
|
||||
if (isValid ? !current.has(fieldKey) : current.has(fieldKey)) {
|
||||
return current;
|
||||
}
|
||||
const next = new Set(current);
|
||||
if (isValid) {
|
||||
next.delete(fieldKey);
|
||||
} else {
|
||||
next.add(fieldKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function handleHistory(direction: "undo" | "redo") {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setIsHistoryBusy(true);
|
||||
try {
|
||||
// Pending edits are saved first so they become their own undo step.
|
||||
await saveNow();
|
||||
const revision = revisionRef.current;
|
||||
if (revision === null) {
|
||||
return;
|
||||
}
|
||||
const result =
|
||||
direction === "undo"
|
||||
? await undoProjectCommand(projectId, revision)
|
||||
: await redoProjectCommand(projectId, revision);
|
||||
revisionRef.current = result.history.currentRevision;
|
||||
setHistory(result.history);
|
||||
await reloadAll();
|
||||
} catch (err) {
|
||||
if (isRevisionConflictError(err)) {
|
||||
await reloadAll().catch(handleReloadError);
|
||||
setNotice(CONFLICT_MESSAGE);
|
||||
} else if (isNotFoundError(err)) {
|
||||
handleReloadError(err);
|
||||
} else {
|
||||
setError(
|
||||
errorMessage(
|
||||
err,
|
||||
direction === "undo"
|
||||
? "Die letzte Änderung konnte nicht rückgängig gemacht werden."
|
||||
: "Die letzte Änderung konnte nicht wiederholt werden."
|
||||
)
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsHistoryBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBackClick(event: MouseEvent<HTMLAnchorElement>) {
|
||||
const current = draftRef.current;
|
||||
if (!current || estimateBalanceFingerprint(current) === savedFingerprintRef.current) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
await saveNow();
|
||||
router.push(`/projects/${projectId}/grobbilanz`);
|
||||
}
|
||||
|
||||
function handleSaveGroup(values: EstimateGroupFormValues) {
|
||||
const editingGroup = groupDialog?.group ?? null;
|
||||
handleChange((current) =>
|
||||
editingGroup
|
||||
? updateEstimateGroup(current, editingGroup.id, values)
|
||||
: addEstimateGroup(current, values).balance
|
||||
);
|
||||
setGroupDialog(null);
|
||||
}
|
||||
|
||||
function handleSelectGzf(value: number) {
|
||||
if (gzfTarget) {
|
||||
handleChange((current) =>
|
||||
updateEstimateRow(current, gzfTarget.groupId, gzfTarget.rowId, {
|
||||
simultaneityFactor: value,
|
||||
})
|
||||
);
|
||||
}
|
||||
setGzfTarget(null);
|
||||
}
|
||||
|
||||
const isEditingDisabled = !draft || isHistoryBusy;
|
||||
const invalidCount = invalidFieldKeys.size;
|
||||
const listHref = `/projects/${projectId}/grobbilanz`;
|
||||
|
||||
return (
|
||||
<main className="container-fluid py-4 estimate-print-root">
|
||||
<div className="page-header estimate-page-header">
|
||||
<div className="d-flex align-items-start gap-3">
|
||||
<Link
|
||||
aria-label="Zurück zur Übersicht der Stände"
|
||||
className="btn btn-outline-secondary btn-sm structure-back-link estimate-no-print"
|
||||
href={listHref}
|
||||
onClick={(event) => void handleBackClick(event)}
|
||||
title="Zurück zur Übersicht der Stände"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" size={18} />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="kicker">{project?.name ?? "Projekt"}</div>
|
||||
<h1>Grobe Leistungsbilanz{draft ? ` – ${draft.name}` : ""}</h1>
|
||||
<p className="text-secondary mb-0 estimate-no-print">
|
||||
Leistungsabschätzung nach Kostengruppen (DIN 276) für LPH 2/3.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="estimate-toolbar estimate-no-print">
|
||||
<SaveStatusBadge
|
||||
error={saveError}
|
||||
invalidCount={invalidCount}
|
||||
onRetry={() => void saveNow()}
|
||||
status={saveStatus}
|
||||
/>
|
||||
<div className="btn-group btn-group-sm" role="group" aria-label="Verlauf">
|
||||
<button
|
||||
aria-label="Rückgängig"
|
||||
className="btn btn-outline-secondary"
|
||||
disabled={isHistoryBusy || !history || history.undoDepth === 0}
|
||||
onClick={() => void handleHistory("undo")}
|
||||
title="Rückgängig (letzte Änderung im Projekt)"
|
||||
type="button"
|
||||
>
|
||||
<Undo2 aria-hidden="true" size={16} />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Wiederholen"
|
||||
className="btn btn-outline-secondary"
|
||||
disabled={isHistoryBusy || !history || history.redoDepth === 0}
|
||||
onClick={() => void handleHistory("redo")}
|
||||
title="Wiederholen (im Projekt)"
|
||||
type="button"
|
||||
>
|
||||
<Redo2 aria-hidden="true" size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Drucken"
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
disabled={!draft}
|
||||
onClick={() => window.print()}
|
||||
title="Drucken"
|
||||
type="button"
|
||||
>
|
||||
<Printer aria-hidden="true" className="me-1" size={16} />
|
||||
Drucken
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notice ? (
|
||||
<div className="alert alert-info estimate-no-print" role="status">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="alert alert-warning estimate-no-print" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<p className="text-secondary" role="status">
|
||||
Grobe Leistungsbilanz wird geladen …
|
||||
</p>
|
||||
) : null}
|
||||
{isMissing ? (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
Dieser Stand existiert nicht (mehr), z. B. weil er gelöscht oder sein Anlegen
|
||||
rückgängig gemacht wurde.{" "}
|
||||
<Link href={listHref}>Zur Übersicht der Stände</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{draft && project ? (
|
||||
<>
|
||||
<section className="card shadow-sm mb-3 estimate-sheet-card">
|
||||
<div className="card-body">
|
||||
<div className="estimate-header-fields">
|
||||
<div className="estimate-header-field">
|
||||
<span className="estimate-header-label">Projekt</span>
|
||||
<span className="estimate-header-value">{project.name}</span>
|
||||
</div>
|
||||
<label className="estimate-header-field is-wide">
|
||||
<span className="estimate-header-label">Stand</span>
|
||||
<DraftInput
|
||||
ariaLabel="Bezeichnung des Stands"
|
||||
className="estimate-cell-input estimate-header-input"
|
||||
disabled={isEditingDisabled}
|
||||
format={(value: string) => value}
|
||||
maxLength={estimateTextLimits.balanceName}
|
||||
onCommit={(name) => handleChange((current) => ({ ...current, name }))}
|
||||
onValidityChange={(isValid) =>
|
||||
handleValidityChange("balance:name", isValid)
|
||||
}
|
||||
parse={(text) =>
|
||||
text.trim()
|
||||
? { ok: true, value: text }
|
||||
: { ok: false, error: "Die Bezeichnung darf nicht leer sein." }
|
||||
}
|
||||
value={draft.name}
|
||||
/>
|
||||
</label>
|
||||
<label className="estimate-header-field">
|
||||
<span className="estimate-header-label">LPH</span>
|
||||
<input
|
||||
aria-label="Leistungsphase"
|
||||
className="estimate-cell-input estimate-header-input"
|
||||
disabled={isEditingDisabled}
|
||||
maxLength={estimateTextLimits.workStage}
|
||||
onChange={(event) =>
|
||||
handleChange((current) => ({
|
||||
...current,
|
||||
workStage: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="z. B. LPH 3"
|
||||
type="text"
|
||||
value={draft.workStage ?? ""}
|
||||
/>
|
||||
<span className="estimate-print-value">{draft.workStage ?? ""}</span>
|
||||
</label>
|
||||
<label className="estimate-header-field">
|
||||
<span className="estimate-header-label">Datum</span>
|
||||
<input
|
||||
aria-label="Datum"
|
||||
className="estimate-cell-input estimate-header-input"
|
||||
disabled={isEditingDisabled}
|
||||
onChange={(event) =>
|
||||
handleChange((current) => ({
|
||||
...current,
|
||||
dateIso: event.target.value || null,
|
||||
}))
|
||||
}
|
||||
type="date"
|
||||
value={draft.dateIso ?? ""}
|
||||
/>
|
||||
<span className="estimate-print-value">
|
||||
{formatIsoDateGerman(draft.dateIso)}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<EstimateBalanceSheet
|
||||
balance={draft}
|
||||
disabled={isEditingDisabled}
|
||||
onAddGroup={() => setGroupDialog({ group: null })}
|
||||
onChange={handleChange}
|
||||
onEditGroup={(group) => setGroupDialog({ group })}
|
||||
onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })}
|
||||
onValidityChange={handleValidityChange}
|
||||
threePhaseVoltageV={project.threePhaseVoltageV}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={
|
||||
isComparisonOpen
|
||||
? "card shadow-sm estimate-comparison-card"
|
||||
: "card shadow-sm estimate-comparison-card estimate-no-print"
|
||||
}
|
||||
>
|
||||
<div className="card-header d-flex justify-content-between align-items-center gap-2">
|
||||
<button
|
||||
aria-controls="estimate-comparison-body"
|
||||
aria-expanded={isComparisonOpen}
|
||||
className="estimate-collapse-toggle"
|
||||
onClick={() => setIsComparisonOpen((open) => !open)}
|
||||
type="button"
|
||||
>
|
||||
{isComparisonOpen ? (
|
||||
<ChevronDown aria-hidden="true" size={16} />
|
||||
) : (
|
||||
<ChevronRight aria-hidden="true" size={16} />
|
||||
)}
|
||||
Vergleich mit detaillierter Bilanz
|
||||
</button>
|
||||
{isComparisonOpen ? (
|
||||
<button
|
||||
aria-label="Vergleich neu laden"
|
||||
className="btn btn-sm btn-outline-secondary structure-icon-button estimate-no-print"
|
||||
onClick={() => void reloadComparison()}
|
||||
title="Vergleich neu laden"
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{isComparisonOpen ? (
|
||||
<div className="card-body" id="estimate-comparison-body">
|
||||
{comparisonError ? (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
{comparisonError}
|
||||
</div>
|
||||
) : null}
|
||||
{comparison ? <EstimateComparisonTable comparison={comparison} /> : null}
|
||||
<p className="text-secondary small mt-2 mb-0">
|
||||
Detailliert = Summe der Gerätezeilen aller Stromkreislisten (Anzahl ×
|
||||
Leistung × Geräte-GZF), zugeordnet über die Kostengruppe; ohne
|
||||
Verteiler-GZF.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{groupDialog ? (
|
||||
<EstimateGroupModal
|
||||
group={groupDialog.group}
|
||||
onClose={() => setGroupDialog(null)}
|
||||
onSave={handleSaveGroup}
|
||||
/>
|
||||
) : null}
|
||||
{gzfTarget ? (
|
||||
<GzfPickerModal onClose={() => setGzfTarget(null)} onSelect={handleSelectGzf} />
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function SaveStatusBadge({
|
||||
error,
|
||||
invalidCount,
|
||||
onRetry,
|
||||
status,
|
||||
}: {
|
||||
error: string | null;
|
||||
invalidCount: number;
|
||||
onRetry: () => void;
|
||||
status: SaveStatus;
|
||||
}) {
|
||||
const label =
|
||||
status === "saving"
|
||||
? "Wird gespeichert …"
|
||||
: status === "pending"
|
||||
? "Ungespeicherte Änderungen"
|
||||
: status === "error"
|
||||
? "Nicht gespeichert – Fehler"
|
||||
: status === "invalid"
|
||||
? "Nicht gespeichert – ungültige Eingaben"
|
||||
: "Gespeichert";
|
||||
const tone =
|
||||
status === "error" || status === "invalid"
|
||||
? "is-error"
|
||||
: status === "saved"
|
||||
? "is-saved"
|
||||
: "is-busy";
|
||||
return (
|
||||
<div className="estimate-save-status" role="status" aria-live="polite">
|
||||
<span className={`estimate-save-badge ${tone}`} title={error ?? undefined}>
|
||||
{label}
|
||||
</span>
|
||||
{invalidCount ? (
|
||||
<span className="estimate-save-hint">
|
||||
{invalidCount === 1
|
||||
? "1 ungültige Eingabe wird nicht gespeichert"
|
||||
: `${invalidCount} ungültige Eingaben werden nicht gespeichert`}
|
||||
</span>
|
||||
) : null}
|
||||
{status === "error" ? (
|
||||
<button className="btn btn-link btn-sm p-0" onClick={onRetry} type="button">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
292
src/app/projects/[projectId]/grobbilanz/page.tsx
Normal file
292
src/app/projects/[projectId]/grobbilanz/page.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowLeft, Copy, FolderOpen, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
createEstimateBalance,
|
||||
deleteEstimateBalance,
|
||||
getProject,
|
||||
listEstimateBalances,
|
||||
} from "../../../../frontend/utils/api";
|
||||
import type {
|
||||
CreateEstimateBalanceInput,
|
||||
EstimateBalanceDto,
|
||||
ProjectCommandResultDto,
|
||||
ProjectDto,
|
||||
} from "../../../../frontend/types";
|
||||
import { calculateEstimateBalance } from "../../../../shared/estimate-balance/estimate-balance";
|
||||
import {
|
||||
EstimateBalanceCreateModal,
|
||||
type EstimateBalanceCreateDefaults,
|
||||
} from "../../../../frontend/estimate-balance/estimate-balance-create-modal";
|
||||
import { createCopyName } from "../../../../frontend/estimate-balance/estimate-balance-document";
|
||||
import {
|
||||
formatIsoDateGerman,
|
||||
formatKw,
|
||||
} from "../../../../frontend/estimate-balance/estimate-balance-format";
|
||||
|
||||
function countRows(balance: EstimateBalanceDto) {
|
||||
return balance.groups.reduce((sum, group) => sum + group.rows.length, 0);
|
||||
}
|
||||
|
||||
export default function EstimateBalanceListPage() {
|
||||
const params = useParams<{ projectId: string }>();
|
||||
const projectId = params.projectId;
|
||||
const router = useRouter();
|
||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||
const [balances, setBalances] = useState<EstimateBalanceDto[]>([]);
|
||||
const [createDefaults, setCreateDefaults] =
|
||||
useState<EstimateBalanceCreateDefaults | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const [currentProject, loadedBalances] = await Promise.all([
|
||||
getProject(projectId),
|
||||
listEstimateBalances(projectId),
|
||||
]);
|
||||
setProject(currentProject);
|
||||
setBalances([...loadedBalances].sort((left, right) => left.sortOrder - right.sortOrder));
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
reload()
|
||||
.then(() => setError(null))
|
||||
.catch((err: unknown) =>
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Grobe Leistungsbilanzen konnten nicht geladen werden."
|
||||
)
|
||||
)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [projectId, reload]);
|
||||
|
||||
async function runCommand<TResult extends ProjectCommandResultDto>(
|
||||
execute: (expectedRevision: number) => Promise<TResult>,
|
||||
fallbackMessage: string
|
||||
): Promise<TResult | null> {
|
||||
if (!project) {
|
||||
return null;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await execute(project.currentRevision);
|
||||
setProject((current) =>
|
||||
current ? { ...current, currentRevision: result.history.currentRevision } : current
|
||||
);
|
||||
await reload();
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : fallbackMessage);
|
||||
// Refresh revision and list so a stale state does not block the next
|
||||
// attempt; the command error stays visible.
|
||||
await reload().catch(() => undefined);
|
||||
return null;
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(input: CreateEstimateBalanceInput) {
|
||||
const result = await runCommand(
|
||||
(expectedRevision) => createEstimateBalance(projectId, input, expectedRevision),
|
||||
"Stand konnte nicht angelegt werden."
|
||||
);
|
||||
if (result) {
|
||||
setCreateDefaults(null);
|
||||
router.push(`/projects/${projectId}/grobbilanz/${result.balance.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(balance: EstimateBalanceDto) {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Stand „${balance.name}“ der groben Leistungsbilanz wirklich löschen?`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await runCommand(
|
||||
(expectedRevision) => deleteEstimateBalance(projectId, balance.id, expectedRevision),
|
||||
"Stand konnte nicht gelöscht werden."
|
||||
);
|
||||
}
|
||||
|
||||
const isBusy = isSaving || !project;
|
||||
const voltage = project?.threePhaseVoltageV ?? 400;
|
||||
|
||||
return (
|
||||
<main className="container py-4">
|
||||
<div className="page-header">
|
||||
<div className="d-flex align-items-start gap-3">
|
||||
<Link
|
||||
aria-label="Zurück zur Projektübersicht"
|
||||
className="btn btn-outline-secondary btn-sm structure-back-link"
|
||||
href={`/projects/${projectId}`}
|
||||
title="Zurück zur Projektübersicht"
|
||||
>
|
||||
<ArrowLeft aria-hidden="true" size={18} />
|
||||
</Link>
|
||||
<div>
|
||||
<div className="kicker">{project?.name ?? "Projekt"}</div>
|
||||
<h1>Grobe Leistungsbilanz</h1>
|
||||
<p className="text-secondary mb-0">
|
||||
Leistungsabschätzung für Vor- und Entwurfsplanung (LPH 2/3), gegliedert nach
|
||||
Kostengruppen der DIN 276 – unabhängig von den Stromkreislisten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
disabled={isBusy}
|
||||
onClick={() => setCreateDefaults({})}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" className="me-1" size={16} />
|
||||
Neuer Stand
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{isLoading ? (
|
||||
<p className="text-secondary" role="status">
|
||||
Grobe Leistungsbilanzen werden geladen …
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section aria-labelledby="estimate-list-heading" className="card shadow-sm">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<h2 className="structure-column-title" id="estimate-list-heading">
|
||||
Stände
|
||||
<span className="badge text-bg-secondary ms-2">{balances.length}</span>
|
||||
</h2>
|
||||
</div>
|
||||
{balances.length ? (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-hover align-middle mb-0 estimate-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Stand</th>
|
||||
<th>LPH</th>
|
||||
<th>Datum</th>
|
||||
<th className="text-end">AV kW</th>
|
||||
<th className="text-end">AV kVA</th>
|
||||
<th className="text-end">SV kW</th>
|
||||
<th className="text-end">SV kVA</th>
|
||||
<th className="text-end">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{balances.map((balance) => {
|
||||
const result = calculateEstimateBalance(balance, voltage);
|
||||
const href = `/projects/${projectId}/grobbilanz/${balance.id}`;
|
||||
const rowCount = countRows(balance);
|
||||
return (
|
||||
<tr key={balance.id}>
|
||||
<td>
|
||||
<Link className="fw-semibold" href={href}>
|
||||
{balance.name}
|
||||
</Link>
|
||||
<div className="text-secondary small">
|
||||
{balance.groups.length} Kostengruppen ·{" "}
|
||||
{rowCount === 1 ? "1 Zeile" : `${rowCount} Zeilen`}
|
||||
</div>
|
||||
</td>
|
||||
<td>{balance.workStage ?? "–"}</td>
|
||||
<td>{formatIsoDateGerman(balance.dateIso)}</td>
|
||||
<td className="text-end estimate-number">
|
||||
{formatKw(result.withReserve.av.kw)}
|
||||
</td>
|
||||
<td className="text-end estimate-number">
|
||||
{formatKw(result.withReserve.av.kva)}
|
||||
</td>
|
||||
<td className="text-end estimate-number">
|
||||
{formatKw(result.withReserve.sv.kw)}
|
||||
</td>
|
||||
<td className="text-end estimate-number">
|
||||
{formatKw(result.withReserve.sv.kva)}
|
||||
</td>
|
||||
<td className="text-end">
|
||||
<div className="d-inline-flex gap-1">
|
||||
<Link
|
||||
aria-label={`Stand „${balance.name}“ öffnen`}
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
href={href}
|
||||
title="Öffnen"
|
||||
>
|
||||
<FolderOpen aria-hidden="true" className="me-1" size={14} />
|
||||
Öffnen
|
||||
</Link>
|
||||
<button
|
||||
aria-label={`Stand „${balance.name}“ als neuen Stand kopieren`}
|
||||
className="btn btn-sm btn-outline-secondary structure-icon-button"
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
setCreateDefaults({
|
||||
name: createCopyName(balance.name),
|
||||
workStage: balance.workStage,
|
||||
template: "copy",
|
||||
copyFromBalanceId: balance.id,
|
||||
})
|
||||
}
|
||||
title="Als neuen Stand kopieren"
|
||||
type="button"
|
||||
>
|
||||
<Copy aria-hidden="true" size={14} />
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Stand „${balance.name}“ löschen`}
|
||||
className="btn btn-sm btn-outline-danger structure-icon-button"
|
||||
disabled={isBusy}
|
||||
onClick={() => void handleDelete(balance)}
|
||||
title="Stand löschen"
|
||||
type="button"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-secondary small px-3 py-2 mb-0">
|
||||
Leistungen jeweils inklusive Reserve.
|
||||
</p>
|
||||
</div>
|
||||
) : !isLoading ? (
|
||||
<div className="card-body">
|
||||
<p className="structure-empty mb-0">
|
||||
Noch kein Stand vorhanden. Mit „Neuer Stand“ eine grobe Leistungsbilanz auf
|
||||
Basis der Vorlage KG 400 anlegen.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{createDefaults ? (
|
||||
<EstimateBalanceCreateModal
|
||||
balances={balances}
|
||||
defaults={createDefaults}
|
||||
isSaving={isSaving}
|
||||
onClose={() => setCreateDefaults(null)}
|
||||
onCreate={(input) => void handleCreate(input)}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
43
src/db/migrations/0010_estimate_balances.sql
Normal file
43
src/db/migrations/0010_estimate_balances.sql
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
CREATE TABLE `estimate_balances` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`work_stage` text,
|
||||
`date_iso` text,
|
||||
`reserve_av_percent` real DEFAULT 0 NOT NULL,
|
||||
`reserve_sv_percent` real DEFAULT 0 NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `estimate_balances_project_id_idx` ON `estimate_balances` (`project_id`);--> statement-breakpoint
|
||||
CREATE TABLE `estimate_groups` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`balance_id` text NOT NULL,
|
||||
`cost_group` text DEFAULT '' NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`editor` text,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (`balance_id`) REFERENCES `estimate_balances`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `estimate_groups_balance_id_idx` ON `estimate_groups` (`balance_id`);--> statement-breakpoint
|
||||
CREATE TABLE `estimate_rows` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`group_id` text NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`supply` text,
|
||||
`circuit_ref` text,
|
||||
`name` text DEFAULT '' NOT NULL,
|
||||
`quantity` real DEFAULT 0 NOT NULL,
|
||||
`unit` text DEFAULT 'piece' NOT NULL,
|
||||
`connection` text,
|
||||
`power_per_unit_w` real DEFAULT 0 NOT NULL,
|
||||
`simultaneity_factor` real DEFAULT 1 NOT NULL,
|
||||
`cos_phi` real DEFAULT 1 NOT NULL,
|
||||
`safety_supply` integer DEFAULT false NOT NULL,
|
||||
`remark` text,
|
||||
FOREIGN KEY (`group_id`) REFERENCES `estimate_groups`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `estimate_rows_group_id_idx` ON `estimate_rows` (`group_id`);
|
||||
2931
src/db/migrations/meta/0010_snapshot.json
Normal file
2931
src/db/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -71,6 +71,13 @@
|
|||
"when": 1790419185882,
|
||||
"tag": "0009_buildings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1790425784626,
|
||||
"tag": "0010_estimate_balances",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
assertEstimateBalanceDeleteProjectCommand,
|
||||
assertEstimateBalanceInsertProjectCommand,
|
||||
assertEstimateBalanceUpdateProjectCommand,
|
||||
createEstimateBalanceDeleteProjectCommand,
|
||||
createEstimateBalanceInsertProjectCommand,
|
||||
createEstimateBalanceUpdateProjectCommand,
|
||||
estimateBalanceDeleteCommandType,
|
||||
estimateBalanceInsertCommandType,
|
||||
estimateBalanceUpdateCommandType,
|
||||
normalizeEstimateBalanceSnapshot,
|
||||
sameEstimateBalance,
|
||||
type EstimateBalanceProjectCommand,
|
||||
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
||||
import type {
|
||||
EstimateBalanceProjectCommandStore,
|
||||
ExecuteEstimateBalanceCommandInput,
|
||||
} from "../../estimate-balance/domain/estimate-balance-project-command.store.js";
|
||||
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { estimateBalances } from "../schema/estimate-balances.js";
|
||||
import { projects } from "../schema/projects.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
import {
|
||||
deleteEstimateBalance,
|
||||
insertEstimateBalance,
|
||||
readEstimateBalances,
|
||||
} from "./estimate-balance.persistence.js";
|
||||
|
||||
export class EstimateBalanceProjectCommandRepository
|
||||
implements EstimateBalanceProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteEstimateBalanceCommandInput) {
|
||||
return executeProjectCommandTransaction(this.database, input, (tx) =>
|
||||
this.applyCommand(tx, input.projectId, input.command)
|
||||
);
|
||||
}
|
||||
|
||||
private applyCommand(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
command: EstimateBalanceProjectCommand
|
||||
): EstimateBalanceProjectCommand {
|
||||
switch (command.type) {
|
||||
case estimateBalanceInsertCommandType:
|
||||
assertEstimateBalanceInsertProjectCommand(command);
|
||||
return this.insert(database, projectId, command.payload.balance);
|
||||
case estimateBalanceDeleteCommandType:
|
||||
assertEstimateBalanceDeleteProjectCommand(command);
|
||||
return this.delete(database, projectId, command.payload.balance);
|
||||
case estimateBalanceUpdateCommandType:
|
||||
assertEstimateBalanceUpdateProjectCommand(command);
|
||||
return this.update(
|
||||
database,
|
||||
projectId,
|
||||
command.payload.expected,
|
||||
command.payload.target
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private insert(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
balance: EstimateBalanceSnapshot
|
||||
) {
|
||||
if (balance.projectId !== projectId) {
|
||||
throw new Error("Estimate-balance snapshot does not belong to project.");
|
||||
}
|
||||
const project = database
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(eq(projects.id, projectId))
|
||||
.get();
|
||||
if (!project) {
|
||||
throw new Error("Project does not exist.");
|
||||
}
|
||||
const existing = database
|
||||
.select({ id: estimateBalances.id })
|
||||
.from(estimateBalances)
|
||||
.where(eq(estimateBalances.id, balance.id))
|
||||
.get();
|
||||
if (existing) {
|
||||
throw new Error("Estimate-balance id already exists.");
|
||||
}
|
||||
const normalized = normalizeEstimateBalanceSnapshot(balance);
|
||||
insertEstimateBalance(database, normalized);
|
||||
return createEstimateBalanceDeleteProjectCommand(normalized);
|
||||
}
|
||||
|
||||
private delete(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: EstimateBalanceSnapshot
|
||||
) {
|
||||
this.assertPersisted(database, projectId, expected, "deletion");
|
||||
if (deleteEstimateBalance(database, projectId, expected.id) !== 1) {
|
||||
throw new Error("Estimate balance could not be deleted.");
|
||||
}
|
||||
return createEstimateBalanceInsertProjectCommand(expected);
|
||||
}
|
||||
|
||||
private update(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: EstimateBalanceSnapshot,
|
||||
target: EstimateBalanceSnapshot
|
||||
) {
|
||||
this.assertPersisted(database, projectId, expected, "update");
|
||||
// Group and row ids stay stable; replacing the whole document keeps the
|
||||
// command exact and its inverse trivially the swapped pair.
|
||||
deleteEstimateBalance(database, projectId, expected.id);
|
||||
const normalized = normalizeEstimateBalanceSnapshot(target);
|
||||
insertEstimateBalance(database, normalized);
|
||||
return createEstimateBalanceUpdateProjectCommand(normalized, expected);
|
||||
}
|
||||
|
||||
private assertPersisted(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
expected: EstimateBalanceSnapshot,
|
||||
action: string
|
||||
) {
|
||||
const [persisted] = readEstimateBalances(database, projectId, expected.id);
|
||||
if (!persisted || !sameEstimateBalance(persisted, expected)) {
|
||||
throw new Error(`Estimate balance changed before ${action}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
121
src/db/repositories/estimate-balance.persistence.ts
Normal file
121
src/db/repositories/estimate-balance.persistence.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import {
|
||||
estimateBalances,
|
||||
estimateGroups,
|
||||
estimateRows,
|
||||
} from "../schema/estimate-balances.js";
|
||||
|
||||
/** Reads complete balance documents of a project, ordered like the UI shows them. */
|
||||
export function readEstimateBalances(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
balanceId?: string
|
||||
): EstimateBalanceSnapshot[] {
|
||||
const balances = database
|
||||
.select()
|
||||
.from(estimateBalances)
|
||||
.where(
|
||||
balanceId
|
||||
? and(
|
||||
eq(estimateBalances.projectId, projectId),
|
||||
eq(estimateBalances.id, balanceId)
|
||||
)
|
||||
: eq(estimateBalances.projectId, projectId)
|
||||
)
|
||||
.orderBy(asc(estimateBalances.sortOrder), asc(estimateBalances.id))
|
||||
.all();
|
||||
if (!balances.length) {
|
||||
return [];
|
||||
}
|
||||
const groups = database
|
||||
.select()
|
||||
.from(estimateGroups)
|
||||
.where(inArray(estimateGroups.balanceId, balances.map((balance) => balance.id)))
|
||||
.orderBy(asc(estimateGroups.sortOrder), asc(estimateGroups.id))
|
||||
.all();
|
||||
const rows = groups.length
|
||||
? database
|
||||
.select()
|
||||
.from(estimateRows)
|
||||
.where(inArray(estimateRows.groupId, groups.map((group) => group.id)))
|
||||
.orderBy(asc(estimateRows.sortOrder), asc(estimateRows.id))
|
||||
.all()
|
||||
: [];
|
||||
return balances.map((balance) => ({
|
||||
id: balance.id,
|
||||
projectId: balance.projectId,
|
||||
name: balance.name,
|
||||
workStage: balance.workStage,
|
||||
dateIso: balance.dateIso,
|
||||
reserveAvPercent: balance.reserveAvPercent,
|
||||
reserveSvPercent: balance.reserveSvPercent,
|
||||
sortOrder: balance.sortOrder,
|
||||
groups: groups
|
||||
.filter((group) => group.balanceId === balance.id)
|
||||
.map((group) => ({
|
||||
id: group.id,
|
||||
costGroup: group.costGroup,
|
||||
title: group.title,
|
||||
editor: group.editor,
|
||||
sortOrder: group.sortOrder,
|
||||
rows: rows
|
||||
.filter((row) => row.groupId === group.id)
|
||||
.map(({ groupId: _groupId, ...row }) => row),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Writes a complete balance document (the caller removed an older version first). */
|
||||
export function insertEstimateBalance(
|
||||
database: AppDatabase,
|
||||
balance: EstimateBalanceSnapshot
|
||||
) {
|
||||
database
|
||||
.insert(estimateBalances)
|
||||
.values({
|
||||
id: balance.id,
|
||||
projectId: balance.projectId,
|
||||
name: balance.name,
|
||||
workStage: balance.workStage,
|
||||
dateIso: balance.dateIso,
|
||||
reserveAvPercent: balance.reserveAvPercent,
|
||||
reserveSvPercent: balance.reserveSvPercent,
|
||||
sortOrder: balance.sortOrder,
|
||||
})
|
||||
.run();
|
||||
for (const group of balance.groups) {
|
||||
database
|
||||
.insert(estimateGroups)
|
||||
.values({
|
||||
id: group.id,
|
||||
balanceId: balance.id,
|
||||
costGroup: group.costGroup,
|
||||
title: group.title,
|
||||
editor: group.editor,
|
||||
sortOrder: group.sortOrder,
|
||||
})
|
||||
.run();
|
||||
if (group.rows.length) {
|
||||
database
|
||||
.insert(estimateRows)
|
||||
.values(group.rows.map((row) => ({ ...row, groupId: group.id })))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a balance with its groups and rows (foreign keys cascade). */
|
||||
export function deleteEstimateBalance(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
balanceId: string
|
||||
) {
|
||||
return database
|
||||
.delete(estimateBalances)
|
||||
.where(
|
||||
and(eq(estimateBalances.projectId, projectId), eq(estimateBalances.id, balanceId))
|
||||
)
|
||||
.run().changes;
|
||||
}
|
||||
60
src/db/repositories/estimate-balance.repository.ts
Normal file
60
src/db/repositories/estimate-balance.repository.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { asc, eq } from "drizzle-orm";
|
||||
import type {
|
||||
DetailedDeviceRowPower,
|
||||
EstimateBalanceSnapshot,
|
||||
} from "../../shared/estimate-balance/estimate-balance.js";
|
||||
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { estimateBalances } from "../schema/estimate-balances.js";
|
||||
import { readEstimateBalances } from "./estimate-balance.persistence.js";
|
||||
|
||||
/** Read side of the estimate-balance module; writes go through commands. */
|
||||
export class EstimateBalanceRepository {
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
listByProject(projectId: string): EstimateBalanceSnapshot[] {
|
||||
return readEstimateBalances(this.database, projectId);
|
||||
}
|
||||
|
||||
findById(projectId: string, balanceId: string): EstimateBalanceSnapshot | null {
|
||||
return readEstimateBalances(this.database, projectId, balanceId)[0] ?? null;
|
||||
}
|
||||
|
||||
nextSortOrder(projectId: string): number {
|
||||
const rows = this.database
|
||||
.select({ sortOrder: estimateBalances.sortOrder })
|
||||
.from(estimateBalances)
|
||||
.where(eq(estimateBalances.projectId, projectId))
|
||||
.orderBy(asc(estimateBalances.sortOrder))
|
||||
.all();
|
||||
return rows.length ? Math.max(...rows.map((row) => row.sortOrder)) + 1 : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Device rows of all circuit lists of the project with their total power
|
||||
* (quantity · power per unit · device GZF, like the circuit tree).
|
||||
*/
|
||||
listDetailedDeviceRowPower(projectId: string): DetailedDeviceRowPower[] {
|
||||
return this.database
|
||||
.select({
|
||||
costGroup: circuitDeviceRows.costGroup,
|
||||
quantity: circuitDeviceRows.quantity,
|
||||
powerPerUnit: circuitDeviceRows.powerPerUnit,
|
||||
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
|
||||
cosPhi: circuitDeviceRows.cosPhi,
|
||||
})
|
||||
.from(circuitDeviceRows)
|
||||
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
|
||||
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||
.where(eq(circuitLists.projectId, projectId))
|
||||
.all()
|
||||
.map((row) => ({
|
||||
costGroup: row.costGroup,
|
||||
kw: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
|
||||
cosPhi: row.cosPhi,
|
||||
}));
|
||||
}
|
||||
}
|
||||
61
src/db/schema/estimate-balances.ts
Normal file
61
src/db/schema/estimate-balances.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
import { projects } from "./projects.js";
|
||||
|
||||
// Grobe Leistungsbilanz (LPH 2/3): a separate module without links into the
|
||||
// circuit structure. See docs/estimate-balance.md.
|
||||
export const estimateBalances = sqliteTable(
|
||||
"estimate_balances",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id")
|
||||
.notNull()
|
||||
.references(() => projects.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
workStage: text("work_stage"),
|
||||
dateIso: text("date_iso"),
|
||||
reserveAvPercent: real("reserve_av_percent").notNull().default(0),
|
||||
reserveSvPercent: real("reserve_sv_percent").notNull().default(0),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
},
|
||||
(table) => [index("estimate_balances_project_id_idx").on(table.projectId)]
|
||||
);
|
||||
|
||||
export const estimateGroups = sqliteTable(
|
||||
"estimate_groups",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
balanceId: text("balance_id")
|
||||
.notNull()
|
||||
.references(() => estimateBalances.id, { onDelete: "cascade" }),
|
||||
costGroup: text("cost_group").notNull().default(""),
|
||||
title: text("title").notNull(),
|
||||
editor: text("editor"),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
},
|
||||
(table) => [index("estimate_groups_balance_id_idx").on(table.balanceId)]
|
||||
);
|
||||
|
||||
export const estimateRows = sqliteTable(
|
||||
"estimate_rows",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
groupId: text("group_id")
|
||||
.notNull()
|
||||
.references(() => estimateGroups.id, { onDelete: "cascade" }),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
supply: text("supply"),
|
||||
circuitRef: text("circuit_ref"),
|
||||
name: text("name").notNull().default(""),
|
||||
quantity: real("quantity").notNull().default(0),
|
||||
unit: text("unit", { enum: ["piece", "area"] }).notNull().default("piece"),
|
||||
connection: text("connection", { enum: ["fixed", "socket"] }),
|
||||
powerPerUnitW: real("power_per_unit_w").notNull().default(0),
|
||||
simultaneityFactor: real("simultaneity_factor").notNull().default(1),
|
||||
cosPhi: real("cos_phi").notNull().default(1),
|
||||
safetySupply: integer("safety_supply", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
remark: text("remark"),
|
||||
},
|
||||
(table) => [index("estimate_rows_group_id_idx").on(table.groupId)]
|
||||
);
|
||||
|
|
@ -194,6 +194,15 @@ import {
|
|||
externalObjectDeleteCreatedCircuitCommandType,
|
||||
} from "../models/external-object-new-circuit-project-command.model.js";
|
||||
import type { ExternalObjectNewCircuitProjectCommandStore } from "../ports/external-object-new-circuit-project-command.store.js";
|
||||
import {
|
||||
assertEstimateBalanceDeleteProjectCommand,
|
||||
assertEstimateBalanceInsertProjectCommand,
|
||||
assertEstimateBalanceUpdateProjectCommand,
|
||||
estimateBalanceDeleteCommandType,
|
||||
estimateBalanceInsertCommandType,
|
||||
estimateBalanceUpdateCommandType,
|
||||
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
||||
import type { EstimateBalanceProjectCommandStore } from "../../estimate-balance/domain/estimate-balance-project-command.store.js";
|
||||
|
||||
interface DispatchProjectCommandInput {
|
||||
projectId: string;
|
||||
|
|
@ -233,7 +242,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
||||
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
|
||||
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
|
||||
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore
|
||||
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
|
||||
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore
|
||||
) {}
|
||||
|
||||
executeUser(
|
||||
|
|
@ -654,6 +664,24 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case estimateBalanceInsertCommandType:
|
||||
case estimateBalanceDeleteCommandType:
|
||||
case estimateBalanceUpdateCommandType: {
|
||||
if (input.command.type === estimateBalanceInsertCommandType) {
|
||||
assertEstimateBalanceInsertProjectCommand(input.command);
|
||||
} else if (input.command.type === estimateBalanceDeleteCommandType) {
|
||||
assertEstimateBalanceDeleteProjectCommand(input.command);
|
||||
} else {
|
||||
assertEstimateBalanceUpdateProjectCommand(input.command);
|
||||
}
|
||||
if (!this.estimateBalanceStore) {
|
||||
throw new Error("Estimate-balance store is not available.");
|
||||
}
|
||||
return this.estimateBalanceStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case projectStateRestoreCommandType: {
|
||||
assertProjectStateRestoreCommand(input.command);
|
||||
return this.projectStateRestoreStore.execute({
|
||||
|
|
|
|||
33
src/estimate-balance/api/estimate-balance-request.schemas.ts
Normal file
33
src/estimate-balance/api/estimate-balance-request.schemas.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { z } from "zod";
|
||||
import { expectedProjectRevisionSchema } from "../../shared/validation/project-command.schemas.js";
|
||||
|
||||
export const createEstimateBalanceRequestSchema = z
|
||||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
name: z.string().trim().min(1).max(200),
|
||||
workStage: z.string().trim().max(50).nullable().optional(),
|
||||
dateIso: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.nullable()
|
||||
.optional(),
|
||||
template: z.enum(["kg400", "empty", "copy"]),
|
||||
copyFromBalanceId: z.string().trim().min(1).optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine((input) => input.template !== "copy" || Boolean(input.copyFromBalanceId), {
|
||||
message: "copyFromBalanceId is required for template copy",
|
||||
path: ["copyFromBalanceId"],
|
||||
});
|
||||
|
||||
export const updateEstimateBalanceRequestSchema = z
|
||||
.object({
|
||||
expectedRevision: expectedProjectRevisionSchema,
|
||||
// Validated in detail by parseEstimateBalanceSnapshot.
|
||||
balance: z.unknown(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const deleteEstimateBalanceRequestSchema = z
|
||||
.object({ expectedRevision: expectedProjectRevisionSchema })
|
||||
.strict();
|
||||
320
src/estimate-balance/domain/estimate-balance-command.model.ts
Normal file
320
src/estimate-balance/domain/estimate-balance-command.model.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import crypto from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
|
||||
import {
|
||||
estimateDefaultGroups,
|
||||
type EstimateBalanceSnapshot,
|
||||
type EstimateGroupSnapshot,
|
||||
} from "../../shared/estimate-balance/estimate-balance.js";
|
||||
|
||||
export const estimateBalanceInsertCommandType = "estimate-balance.insert" as const;
|
||||
export const estimateBalanceDeleteCommandType = "estimate-balance.delete" as const;
|
||||
export const estimateBalanceUpdateCommandType = "estimate-balance.update" as const;
|
||||
export const estimateBalanceCommandSchemaVersion = 1 as const;
|
||||
|
||||
const idSchema = z.string().trim().min(1).max(100);
|
||||
const trimmed = (max: number) =>
|
||||
z.string().max(max).refine((value) => value === value.trim(), {
|
||||
message: "must be trimmed",
|
||||
});
|
||||
const nullableText = (max: number) =>
|
||||
trimmed(max)
|
||||
.refine((value) => value.length > 0, { message: "use null instead of empty" })
|
||||
.nullable();
|
||||
const finite = z.number().finite();
|
||||
|
||||
export const estimateRowSnapshotSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
sortOrder: z.number().int().min(0),
|
||||
supply: nullableText(50),
|
||||
circuitRef: nullableText(50),
|
||||
name: trimmed(200),
|
||||
quantity: finite.min(0),
|
||||
unit: z.enum(["piece", "area"]),
|
||||
connection: z.enum(["fixed", "socket"]).nullable(),
|
||||
powerPerUnitW: finite.min(0),
|
||||
simultaneityFactor: finite.min(0).max(1),
|
||||
cosPhi: finite.gt(0).max(1),
|
||||
safetySupply: z.boolean(),
|
||||
remark: nullableText(500),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const estimateGroupSnapshotSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
costGroup: trimmed(20),
|
||||
title: trimmed(200).refine((value) => value.length > 0, {
|
||||
message: "title must not be empty",
|
||||
}),
|
||||
editor: nullableText(100),
|
||||
sortOrder: z.number().int().min(0),
|
||||
rows: z.array(estimateRowSnapshotSchema).max(1000),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const estimateBalanceSnapshotSchema = z
|
||||
.object({
|
||||
id: idSchema,
|
||||
projectId: idSchema,
|
||||
name: trimmed(200).refine((value) => value.length > 0, {
|
||||
message: "name must not be empty",
|
||||
}),
|
||||
workStage: nullableText(50),
|
||||
dateIso: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.refine((value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), {
|
||||
message: "invalid date",
|
||||
})
|
||||
.nullable(),
|
||||
reserveAvPercent: finite.min(0).max(100),
|
||||
reserveSvPercent: finite.min(0).max(100),
|
||||
sortOrder: z.number().int().min(0),
|
||||
groups: z.array(estimateGroupSnapshotSchema).max(200),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((balance, context) => {
|
||||
const ids = new Set<string>();
|
||||
for (const group of balance.groups) {
|
||||
for (const id of [group.id, ...group.rows.map((row) => row.id)]) {
|
||||
if (ids.has(id)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `duplicate id ${id}`,
|
||||
path: ["groups"],
|
||||
});
|
||||
}
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export interface EstimateBalanceInsertProjectCommand
|
||||
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
||||
type: typeof estimateBalanceInsertCommandType;
|
||||
}
|
||||
|
||||
export interface EstimateBalanceDeleteProjectCommand
|
||||
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
||||
type: typeof estimateBalanceDeleteCommandType;
|
||||
}
|
||||
|
||||
export interface EstimateBalanceUpdateProjectCommand
|
||||
extends SerializedProjectCommand<{
|
||||
expected: EstimateBalanceSnapshot;
|
||||
target: EstimateBalanceSnapshot;
|
||||
}> {
|
||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
||||
type: typeof estimateBalanceUpdateCommandType;
|
||||
}
|
||||
|
||||
export type EstimateBalanceProjectCommand =
|
||||
| EstimateBalanceInsertProjectCommand
|
||||
| EstimateBalanceDeleteProjectCommand
|
||||
| EstimateBalanceUpdateProjectCommand;
|
||||
|
||||
/** Parses and normalizes a snapshot (sort orders of groups and rows follow the array order). */
|
||||
export function parseEstimateBalanceSnapshot(value: unknown): EstimateBalanceSnapshot {
|
||||
const parsed = estimateBalanceSnapshotSchema.parse(value);
|
||||
return normalizeEstimateBalanceSnapshot(parsed);
|
||||
}
|
||||
|
||||
export function normalizeEstimateBalanceSnapshot(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
return {
|
||||
...balance,
|
||||
groups: balance.groups.map((group, groupIndex) => ({
|
||||
...group,
|
||||
sortOrder: groupIndex,
|
||||
rows: group.rows.map((row, rowIndex) => ({ ...row, sortOrder: rowIndex })),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable JSON for exact equality checks of two snapshots. */
|
||||
export function canonicalEstimateBalance(balance: EstimateBalanceSnapshot): string {
|
||||
const normalized = normalizeEstimateBalanceSnapshot(balance);
|
||||
return JSON.stringify({
|
||||
id: normalized.id,
|
||||
projectId: normalized.projectId,
|
||||
name: normalized.name,
|
||||
workStage: normalized.workStage,
|
||||
dateIso: normalized.dateIso,
|
||||
reserveAvPercent: normalized.reserveAvPercent,
|
||||
reserveSvPercent: normalized.reserveSvPercent,
|
||||
sortOrder: normalized.sortOrder,
|
||||
groups: normalized.groups.map((group) => ({
|
||||
id: group.id,
|
||||
costGroup: group.costGroup,
|
||||
title: group.title,
|
||||
editor: group.editor,
|
||||
sortOrder: group.sortOrder,
|
||||
rows: group.rows.map((row) => ({
|
||||
id: row.id,
|
||||
sortOrder: row.sortOrder,
|
||||
supply: row.supply,
|
||||
circuitRef: row.circuitRef,
|
||||
name: row.name,
|
||||
quantity: row.quantity,
|
||||
unit: row.unit,
|
||||
connection: row.connection,
|
||||
powerPerUnitW: row.powerPerUnitW,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi,
|
||||
safetySupply: row.safetySupply,
|
||||
remark: row.remark,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
export function sameEstimateBalance(
|
||||
left: EstimateBalanceSnapshot,
|
||||
right: EstimateBalanceSnapshot
|
||||
): boolean {
|
||||
return canonicalEstimateBalance(left) === canonicalEstimateBalance(right);
|
||||
}
|
||||
|
||||
export function createEstimateBalanceSnapshot(input: {
|
||||
projectId: string;
|
||||
name: string;
|
||||
workStage: string | null;
|
||||
dateIso: string | null;
|
||||
sortOrder: number;
|
||||
template: "kg400" | "empty" | { copyFrom: EstimateBalanceSnapshot };
|
||||
createId?: () => string;
|
||||
}): EstimateBalanceSnapshot {
|
||||
const createId = input.createId ?? (() => crypto.randomUUID());
|
||||
let groups: EstimateGroupSnapshot[] = [];
|
||||
let reserveAvPercent = 10;
|
||||
let reserveSvPercent = 0;
|
||||
if (input.template === "kg400") {
|
||||
groups = estimateDefaultGroups.map((group, index) => ({
|
||||
id: createId(),
|
||||
costGroup: group.costGroup,
|
||||
title: group.title,
|
||||
editor: null,
|
||||
sortOrder: index,
|
||||
rows: [],
|
||||
}));
|
||||
} else if (input.template !== "empty") {
|
||||
const source = input.template.copyFrom;
|
||||
reserveAvPercent = source.reserveAvPercent;
|
||||
reserveSvPercent = source.reserveSvPercent;
|
||||
groups = source.groups.map((group) => ({
|
||||
...group,
|
||||
id: createId(),
|
||||
rows: group.rows.map((row) => ({ ...row, id: createId() })),
|
||||
}));
|
||||
}
|
||||
return parseEstimateBalanceSnapshot({
|
||||
id: createId(),
|
||||
projectId: input.projectId,
|
||||
name: input.name.trim(),
|
||||
workStage: input.workStage?.trim() || null,
|
||||
dateIso: input.dateIso || null,
|
||||
reserveAvPercent,
|
||||
reserveSvPercent,
|
||||
sortOrder: input.sortOrder,
|
||||
groups,
|
||||
});
|
||||
}
|
||||
|
||||
export function createEstimateBalanceInsertProjectCommand(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceInsertProjectCommand {
|
||||
return {
|
||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
||||
type: estimateBalanceInsertCommandType,
|
||||
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
||||
};
|
||||
}
|
||||
|
||||
export function createEstimateBalanceDeleteProjectCommand(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceDeleteProjectCommand {
|
||||
return {
|
||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
||||
type: estimateBalanceDeleteCommandType,
|
||||
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
||||
};
|
||||
}
|
||||
|
||||
export function createEstimateBalanceUpdateProjectCommand(
|
||||
expected: EstimateBalanceSnapshot,
|
||||
target: EstimateBalanceSnapshot
|
||||
): EstimateBalanceUpdateProjectCommand {
|
||||
const payload = {
|
||||
expected: parseEstimateBalanceSnapshot(expected),
|
||||
target: parseEstimateBalanceSnapshot(target),
|
||||
};
|
||||
assertMatchingUpdate(payload.expected, payload.target);
|
||||
return {
|
||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
||||
type: estimateBalanceUpdateCommandType,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function assertMatchingUpdate(
|
||||
expected: EstimateBalanceSnapshot,
|
||||
target: EstimateBalanceSnapshot
|
||||
) {
|
||||
if (expected.id !== target.id || expected.projectId !== target.projectId) {
|
||||
throw new Error("Estimate-balance update must keep id and project.");
|
||||
}
|
||||
if (sameEstimateBalance(expected, target)) {
|
||||
throw new Error("Estimate-balance update must change the balance.");
|
||||
}
|
||||
}
|
||||
|
||||
function assertCommandShape(
|
||||
command: SerializedProjectCommand<unknown>,
|
||||
type: string,
|
||||
keys: string[]
|
||||
): Record<string, unknown> {
|
||||
const payload = command.payload;
|
||||
if (
|
||||
command.schemaVersion !== estimateBalanceCommandSchemaVersion ||
|
||||
command.type !== type ||
|
||||
payload === null ||
|
||||
typeof payload !== "object" ||
|
||||
Array.isArray(payload) ||
|
||||
Object.keys(payload).sort().join(",") !== [...keys].sort().join(",")
|
||||
) {
|
||||
throw new Error("Unsupported estimate-balance command.");
|
||||
}
|
||||
return payload as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function assertEstimateBalanceInsertProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is EstimateBalanceInsertProjectCommand {
|
||||
const payload = assertCommandShape(command, estimateBalanceInsertCommandType, ["balance"]);
|
||||
parseEstimateBalanceSnapshot(payload.balance);
|
||||
}
|
||||
|
||||
export function assertEstimateBalanceDeleteProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is EstimateBalanceDeleteProjectCommand {
|
||||
const payload = assertCommandShape(command, estimateBalanceDeleteCommandType, ["balance"]);
|
||||
parseEstimateBalanceSnapshot(payload.balance);
|
||||
}
|
||||
|
||||
export function assertEstimateBalanceUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is EstimateBalanceUpdateProjectCommand {
|
||||
const payload = assertCommandShape(command, estimateBalanceUpdateCommandType, [
|
||||
"expected",
|
||||
"target",
|
||||
]);
|
||||
assertMatchingUpdate(
|
||||
parseEstimateBalanceSnapshot(payload.expected),
|
||||
parseEstimateBalanceSnapshot(payload.target)
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "../../domain/ports/project-revision.store.js";
|
||||
import type { EstimateBalanceProjectCommand } from "./estimate-balance-command.model.js";
|
||||
|
||||
export interface ExecuteEstimateBalanceCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: EstimateBalanceProjectCommand;
|
||||
}
|
||||
|
||||
export interface ExecutedEstimateBalanceCommand {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: EstimateBalanceProjectCommand;
|
||||
}
|
||||
|
||||
export interface EstimateBalanceProjectCommandStore {
|
||||
execute(input: ExecuteEstimateBalanceCommandInput): ExecutedEstimateBalanceCommand;
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ const PROJECT_SECTIONS = [
|
|||
// Separate project sub-routes (/projects/[id]/<segment>).
|
||||
const PROJECT_ROUTES = [
|
||||
{ segment: "struktur", label: "Gebäudestruktur", icon: "▥" },
|
||||
{ segment: "grobbilanz", label: "Grobe Leistungsbilanz", icon: "Σ" },
|
||||
] as const;
|
||||
|
||||
// Matches the project overview and all of its sub-routes (group 2 = rest).
|
||||
|
|
|
|||
162
src/frontend/estimate-balance/estimate-balance-create-modal.tsx
Normal file
162
src/frontend/estimate-balance/estimate-balance-create-modal.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { FormModal } from "../components/form-modal";
|
||||
import type {
|
||||
CreateEstimateBalanceInput,
|
||||
EstimateBalanceDto,
|
||||
EstimateBalanceTemplate,
|
||||
} from "../types";
|
||||
import { estimateTextLimits } from "./estimate-balance-document";
|
||||
import { getTodayIsoDate } from "./estimate-balance-format";
|
||||
|
||||
export interface EstimateBalanceCreateDefaults {
|
||||
name?: string;
|
||||
workStage?: string | null;
|
||||
template?: EstimateBalanceTemplate;
|
||||
copyFromBalanceId?: string;
|
||||
}
|
||||
|
||||
interface EstimateBalanceCreateModalProps {
|
||||
balances: EstimateBalanceDto[];
|
||||
defaults: EstimateBalanceCreateDefaults;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (input: CreateEstimateBalanceInput) => void;
|
||||
}
|
||||
|
||||
/** "Neuer Stand": name, LPH, date and the starting content. */
|
||||
export function EstimateBalanceCreateModal({
|
||||
balances,
|
||||
defaults,
|
||||
isSaving,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: EstimateBalanceCreateModalProps) {
|
||||
const [name, setName] = useState(defaults.name ?? "");
|
||||
const [workStage, setWorkStage] = useState(defaults.workStage ?? "");
|
||||
const [dateIso, setDateIso] = useState(getTodayIsoDate());
|
||||
const [template, setTemplate] = useState<EstimateBalanceTemplate>(
|
||||
defaults.template ?? "kg400"
|
||||
);
|
||||
const [copyFromBalanceId, setCopyFromBalanceId] = useState(
|
||||
defaults.copyFromBalanceId ?? balances[0]?.id ?? ""
|
||||
);
|
||||
const isCopyInvalid = template === "copy" && !copyFromBalanceId;
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!name.trim() || isCopyInvalid) {
|
||||
return;
|
||||
}
|
||||
onCreate({
|
||||
name: name.trim(),
|
||||
workStage: workStage.trim() || null,
|
||||
dateIso: dateIso || null,
|
||||
template,
|
||||
...(template === "copy" ? { copyFromBalanceId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
isSaving={isSaving}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitDisabled={!name.trim() || isCopyInvalid}
|
||||
submitLabel="Stand anlegen"
|
||||
title="Neuer Stand"
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="estimate-create-name">
|
||||
Bezeichnung
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="estimate-create-name"
|
||||
maxLength={estimateTextLimits.balanceName}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="z. B. Entwurfsplanung"
|
||||
required
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-6 col-md-3">
|
||||
<label className="form-label" htmlFor="estimate-create-work-stage">
|
||||
LPH
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="estimate-create-work-stage"
|
||||
maxLength={estimateTextLimits.workStage}
|
||||
onChange={(event) => setWorkStage(event.target.value)}
|
||||
placeholder="z. B. LPH 3"
|
||||
value={workStage}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-6 col-md-3">
|
||||
<label className="form-label" htmlFor="estimate-create-date">
|
||||
Datum
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="estimate-create-date"
|
||||
onChange={(event) => setDateIso(event.target.value)}
|
||||
type="date"
|
||||
value={dateIso}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="estimate-create-template">
|
||||
Inhalt
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="estimate-create-template"
|
||||
onChange={(event) =>
|
||||
setTemplate(event.target.value as EstimateBalanceTemplate)
|
||||
}
|
||||
value={template}
|
||||
>
|
||||
<option value="kg400">Vorlage KG 400</option>
|
||||
<option value="empty">Leer</option>
|
||||
<option disabled={!balances.length} value="copy">
|
||||
Kopie von …
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{template === "copy" ? (
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="estimate-create-copy-from">
|
||||
Kopie von Stand
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="estimate-create-copy-from"
|
||||
onChange={(event) => setCopyFromBalanceId(event.target.value)}
|
||||
required
|
||||
value={copyFromBalanceId}
|
||||
>
|
||||
{balances.map((balance) => (
|
||||
<option key={balance.id} value={balance.id}>
|
||||
{balance.workStage ? `${balance.name} (${balance.workStage})` : balance.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="col-12">
|
||||
<p className="text-secondary small mb-0">
|
||||
{template === "kg400"
|
||||
? "Legt die Kostengruppen 410 bis 480 und „Allgemein“ ohne Einträge an."
|
||||
: template === "empty"
|
||||
? "Legt einen Stand ohne Kostengruppen an."
|
||||
: "Übernimmt alle Kostengruppen und Zeilen des gewählten Stands."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
301
src/frontend/estimate-balance/estimate-balance-document.ts
Normal file
301
src/frontend/estimate-balance/estimate-balance-document.ts
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
// Pure, immutable editing rules for one "Grobe Leistungsbilanz" document.
|
||||
// The editor keeps the complete document as local draft and saves it as a
|
||||
// whole (PUT), so every edit is a function draft → new draft.
|
||||
import type {
|
||||
EstimateBalanceSnapshot,
|
||||
EstimateGroupSnapshot,
|
||||
EstimateRowSnapshot,
|
||||
} from "../../shared/estimate-balance/estimate-balance";
|
||||
import { createClientId } from "../utils/client-id";
|
||||
|
||||
export type MoveDirection = -1 | 1;
|
||||
|
||||
/** Maximum text lengths accepted by the server. */
|
||||
export const estimateTextLimits = {
|
||||
balanceName: 200,
|
||||
workStage: 50,
|
||||
costGroup: 20,
|
||||
groupTitle: 200,
|
||||
groupEditor: 100,
|
||||
supply: 50,
|
||||
circuitRef: 50,
|
||||
rowName: 200,
|
||||
remark: 500,
|
||||
} as const;
|
||||
|
||||
export type EstimateRowPatch = Partial<Omit<EstimateRowSnapshot, "id" | "sortOrder">>;
|
||||
export type EstimateGroupPatch = Partial<Pick<EstimateGroupSnapshot, "costGroup" | "title" | "editor">>;
|
||||
|
||||
export function createEstimateRow(
|
||||
sortOrder: number,
|
||||
createId: () => string = createClientId
|
||||
): EstimateRowSnapshot {
|
||||
return {
|
||||
id: createId(),
|
||||
sortOrder,
|
||||
supply: null,
|
||||
circuitRef: null,
|
||||
name: "",
|
||||
quantity: 1,
|
||||
unit: "piece",
|
||||
connection: null,
|
||||
powerPerUnitW: 0,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 1,
|
||||
safetySupply: false,
|
||||
remark: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEstimateGroup(
|
||||
input: { costGroup: string; title: string; editor?: string | null },
|
||||
sortOrder: number,
|
||||
createId: () => string = createClientId
|
||||
): EstimateGroupSnapshot {
|
||||
return {
|
||||
id: createId(),
|
||||
costGroup: input.costGroup,
|
||||
title: input.title,
|
||||
editor: input.editor ?? null,
|
||||
sortOrder,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
function bySortOrder<T extends { sortOrder: number }>(left: T, right: T) {
|
||||
return left.sortOrder - right.sortOrder;
|
||||
}
|
||||
|
||||
/** Orders groups and rows by sortOrder and renumbers both from 0. */
|
||||
export function normalizeEstimateSortOrder(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
return {
|
||||
...balance,
|
||||
groups: [...balance.groups].sort(bySortOrder).map((group, groupIndex) => ({
|
||||
...group,
|
||||
sortOrder: groupIndex,
|
||||
rows: [...group.rows].sort(bySortOrder).map((row, rowIndex) => ({
|
||||
...row,
|
||||
sortOrder: rowIndex,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function renumber<T extends { sortOrder: number }>(items: T[]): T[] {
|
||||
return items.map((item, index) =>
|
||||
item.sortOrder === index ? item : { ...item, sortOrder: index }
|
||||
);
|
||||
}
|
||||
|
||||
function mapGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
update: (group: EstimateGroupSnapshot) => EstimateGroupSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
let changed = false;
|
||||
const groups = balance.groups.map((group) => {
|
||||
if (group.id !== groupId) {
|
||||
return group;
|
||||
}
|
||||
const next = update(group);
|
||||
changed = next !== group;
|
||||
return next;
|
||||
});
|
||||
// An unknown group or a no-op keeps the identical document.
|
||||
return changed ? { ...balance, groups } : balance;
|
||||
}
|
||||
|
||||
function moveItem<T>(items: T[], index: number, direction: MoveDirection): T[] | null {
|
||||
const target = index + direction;
|
||||
if (index < 0 || target < 0 || target >= items.length) {
|
||||
return null;
|
||||
}
|
||||
const next = [...items];
|
||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Appends a row (or inserts it after `afterRowId`) and returns the new row id. */
|
||||
export function addEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
options: { afterRowId?: string; createId?: () => string } = {}
|
||||
): { balance: EstimateBalanceSnapshot; rowId: string | null } {
|
||||
let rowId: string | null = null;
|
||||
const next = mapGroup(balance, groupId, (group) => {
|
||||
const row = createEstimateRow(0, options.createId);
|
||||
rowId = row.id;
|
||||
const afterIndex = options.afterRowId
|
||||
? group.rows.findIndex((candidate) => candidate.id === options.afterRowId)
|
||||
: -1;
|
||||
const rows = [...group.rows];
|
||||
rows.splice(afterIndex >= 0 ? afterIndex + 1 : rows.length, 0, row);
|
||||
return { ...group, rows: renumber(rows) };
|
||||
});
|
||||
return { balance: next, rowId };
|
||||
}
|
||||
|
||||
export function updateEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
rowId: string,
|
||||
patch: EstimateRowPatch
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => ({
|
||||
...group,
|
||||
rows: group.rows.map((row) => (row.id === rowId ? { ...row, ...patch } : row)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function removeEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
rowId: string
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => ({
|
||||
...group,
|
||||
rows: renumber(group.rows.filter((row) => row.id !== rowId)),
|
||||
}));
|
||||
}
|
||||
|
||||
export function moveEstimateRow(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
rowId: string,
|
||||
direction: MoveDirection
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => {
|
||||
const rows = moveItem(
|
||||
group.rows,
|
||||
group.rows.findIndex((row) => row.id === rowId),
|
||||
direction
|
||||
);
|
||||
return rows ? { ...group, rows: renumber(rows) } : group;
|
||||
});
|
||||
}
|
||||
|
||||
/** Appends a group and returns its id. */
|
||||
export function addEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
input: { costGroup: string; title: string; editor?: string | null },
|
||||
createId: () => string = createClientId
|
||||
): { balance: EstimateBalanceSnapshot; groupId: string } {
|
||||
const group = createEstimateGroup(input, balance.groups.length, createId);
|
||||
return {
|
||||
balance: { ...balance, groups: renumber([...balance.groups, group]) },
|
||||
groupId: group.id,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
patch: EstimateGroupPatch
|
||||
): EstimateBalanceSnapshot {
|
||||
return mapGroup(balance, groupId, (group) => ({ ...group, ...patch }));
|
||||
}
|
||||
|
||||
export function removeEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string
|
||||
): EstimateBalanceSnapshot {
|
||||
const groups = balance.groups.filter((group) => group.id !== groupId);
|
||||
return groups.length === balance.groups.length
|
||||
? balance
|
||||
: { ...balance, groups: renumber(groups) };
|
||||
}
|
||||
|
||||
export function moveEstimateGroup(
|
||||
balance: EstimateBalanceSnapshot,
|
||||
groupId: string,
|
||||
direction: MoveDirection
|
||||
): EstimateBalanceSnapshot {
|
||||
const groups = moveItem(
|
||||
balance.groups,
|
||||
balance.groups.findIndex((group) => group.id === groupId),
|
||||
direction
|
||||
);
|
||||
return groups ? { ...balance, groups: renumber(groups) } : balance;
|
||||
}
|
||||
|
||||
function requiredText(value: string) {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalText(value: string | null) {
|
||||
const trimmed = value?.trim() ?? "";
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact document the server accepts: strings trimmed, empty optional
|
||||
* texts as null, sort orders continuous. Only fields of the shared snapshot
|
||||
* type are copied, so no UI state can leak into the request.
|
||||
*/
|
||||
export function toSaveableEstimateBalance(
|
||||
balance: EstimateBalanceSnapshot
|
||||
): EstimateBalanceSnapshot {
|
||||
const normalized = normalizeEstimateSortOrder(balance);
|
||||
return {
|
||||
id: normalized.id,
|
||||
projectId: normalized.projectId,
|
||||
name: requiredText(normalized.name),
|
||||
workStage: optionalText(normalized.workStage),
|
||||
dateIso: normalized.dateIso || null,
|
||||
reserveAvPercent: normalized.reserveAvPercent,
|
||||
reserveSvPercent: normalized.reserveSvPercent,
|
||||
sortOrder: normalized.sortOrder,
|
||||
groups: normalized.groups.map((group) => ({
|
||||
id: group.id,
|
||||
costGroup: requiredText(group.costGroup),
|
||||
title: requiredText(group.title),
|
||||
editor: optionalText(group.editor),
|
||||
sortOrder: group.sortOrder,
|
||||
rows: group.rows.map((row) => ({
|
||||
id: row.id,
|
||||
sortOrder: row.sortOrder,
|
||||
supply: optionalText(row.supply),
|
||||
circuitRef: optionalText(row.circuitRef),
|
||||
name: requiredText(row.name),
|
||||
quantity: row.quantity,
|
||||
unit: row.unit,
|
||||
connection: row.connection,
|
||||
powerPerUnitW: row.powerPerUnitW,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi,
|
||||
safetySupply: row.safetySupply,
|
||||
remark: optionalText(row.remark),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Stable comparison key of the saveable document (dirty detection). */
|
||||
export function estimateBalanceFingerprint(balance: EstimateBalanceSnapshot): string {
|
||||
return JSON.stringify(toSaveableEstimateBalance(balance));
|
||||
}
|
||||
|
||||
/**
|
||||
* German problems that would make the server reject the document. The
|
||||
* editor never saves while this list is not empty.
|
||||
*/
|
||||
export function validateEstimateBalance(balance: EstimateBalanceSnapshot): string[] {
|
||||
const problems: string[] = [];
|
||||
const saveable = toSaveableEstimateBalance(balance);
|
||||
if (!saveable.name) {
|
||||
problems.push("Die Bezeichnung des Stands darf nicht leer sein.");
|
||||
}
|
||||
for (const group of saveable.groups) {
|
||||
if (!group.title) {
|
||||
problems.push(`Kostengruppe ${group.costGroup || "ohne Nummer"}: Titel fehlt.`);
|
||||
}
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
export function createCopyName(name: string) {
|
||||
const copyName = `${name.trim()} (Kopie)`;
|
||||
return copyName.slice(0, estimateTextLimits.balanceName);
|
||||
}
|
||||
122
src/frontend/estimate-balance/estimate-balance-format.ts
Normal file
122
src/frontend/estimate-balance/estimate-balance-format.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Pure parsing and formatting rules of the "Grobe Leistungsbilanz" editor.
|
||||
// Numbers are entered and shown in German notation. As in the circuit list
|
||||
// editor, only "," is accepted as decimal separator: a "." is ambiguous
|
||||
// (decimal point or thousands separator) and is rejected instead of guessed.
|
||||
import { roundEstimateValue } from "../../shared/estimate-balance/estimate-balance";
|
||||
|
||||
export type DecimalParseResult =
|
||||
| { ok: true; value: number }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export interface DecimalRange {
|
||||
min?: number;
|
||||
/** Excludes `min` itself (e.g. cos φ > 0). */
|
||||
minExclusive?: boolean;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const DECIMAL_PATTERN = /^[+-]?(\d+(,\d*)?|,\d+)$/;
|
||||
|
||||
function formatBound(value: number) {
|
||||
return value.toLocaleString("de-DE", { maximumFractionDigits: 6, useGrouping: false });
|
||||
}
|
||||
|
||||
export function parseGermanDecimal(text: string, range: DecimalRange = {}): DecimalParseResult {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return { ok: false, error: "Bitte einen Wert eingeben." };
|
||||
}
|
||||
if (trimmed.includes(".")) {
|
||||
return { ok: false, error: "Dezimalstellen mit Komma eingeben (z. B. 0,8)." };
|
||||
}
|
||||
if (!DECIMAL_PATTERN.test(trimmed)) {
|
||||
return { ok: false, error: "Ungültiger Zahlenwert." };
|
||||
}
|
||||
const value = Number(trimmed.replace(",", "."));
|
||||
if (!Number.isFinite(value)) {
|
||||
return { ok: false, error: "Ungültiger Zahlenwert." };
|
||||
}
|
||||
if (range.min !== undefined) {
|
||||
if (range.minExclusive ? value <= range.min : value < range.min) {
|
||||
return {
|
||||
ok: false,
|
||||
error: range.minExclusive
|
||||
? `Der Wert muss größer als ${formatBound(range.min)} sein.`
|
||||
: `Der Wert darf nicht kleiner als ${formatBound(range.min)} sein.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (range.max !== undefined && value > range.max) {
|
||||
return { ok: false, error: `Der Wert darf nicht größer als ${formatBound(range.max)} sein.` };
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
|
||||
/** Value ranges accepted by the server for the numeric document fields. */
|
||||
export const estimateNumberRanges = {
|
||||
quantity: { min: 0 },
|
||||
powerPerUnitW: { min: 0 },
|
||||
simultaneityFactor: { min: 0, max: 1 },
|
||||
cosPhi: { min: 0, minExclusive: true, max: 1 },
|
||||
reservePercent: { min: 0, max: 100 },
|
||||
} satisfies Record<string, DecimalRange>;
|
||||
|
||||
/**
|
||||
* Text of a numeric input field: German comma, no thousands separator, so the
|
||||
* shown text can be parsed again unchanged.
|
||||
*/
|
||||
export function formatDecimalInput(value: number, maximumFractionDigits = 6): string {
|
||||
return value.toLocaleString("de-DE", { maximumFractionDigits, useGrouping: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only display of a calculated value with thousands separator ("1.200",
|
||||
* "30,24"). The value is rounded half away from zero first, like Excel does
|
||||
* (40,175 → 40,18 instead of the binary-rounded 40,17).
|
||||
*/
|
||||
export function formatGermanNumber(
|
||||
value: number,
|
||||
minimumFractionDigits: number,
|
||||
maximumFractionDigits = minimumFractionDigits
|
||||
): string {
|
||||
const rounded = roundEstimateValue(value, maximumFractionDigits);
|
||||
// "+ 0" turns -0 into 0 and avoids "-0,00".
|
||||
return (rounded + 0).toLocaleString("de-DE", {
|
||||
minimumFractionDigits,
|
||||
maximumFractionDigits,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatKw(value: number) {
|
||||
return formatGermanNumber(value, 2);
|
||||
}
|
||||
|
||||
export function formatAmpere(value: number) {
|
||||
return formatGermanNumber(value, 2);
|
||||
}
|
||||
|
||||
export function formatSignedKw(value: number) {
|
||||
const text = formatKw(value);
|
||||
return value > 0 && text !== "0,00" ? `+${text}` : text;
|
||||
}
|
||||
|
||||
export function formatDeltaPercent(value: number | null) {
|
||||
if (value === null) {
|
||||
return "–";
|
||||
}
|
||||
const text = formatGermanNumber(value, 1);
|
||||
return `${value > 0 && text !== "0,0" ? "+" : ""}${text} %`;
|
||||
}
|
||||
|
||||
/** "2026-09-26" → "26.09.2026"; empty/invalid → "–". */
|
||||
export function formatIsoDateGerman(dateIso: string | null): string {
|
||||
const match = dateIso?.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
return match ? `${match[3]}.${match[2]}.${match[1]}` : "–";
|
||||
}
|
||||
|
||||
/** Local calendar date as YYYY-MM-DD (not UTC, so late evenings stay today). */
|
||||
export function getTodayIsoDate(now: Date = new Date()): string {
|
||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(now.getDate()).padStart(2, "0");
|
||||
return `${now.getFullYear()}-${month}-${day}`;
|
||||
}
|
||||
652
src/frontend/estimate-balance/estimate-balance-sheet.tsx
Normal file
652
src/frontend/estimate-balance/estimate-balance-sheet.tsx
Normal file
|
|
@ -0,0 +1,652 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import {
|
||||
calculateEstimateBalance,
|
||||
calculateEstimateRow,
|
||||
estimateRowConnectionLabels,
|
||||
estimateRowUnitLabels,
|
||||
type EstimateBalanceSnapshot,
|
||||
type EstimateGroupSnapshot,
|
||||
type EstimatePower,
|
||||
type EstimateRowConnection,
|
||||
type EstimateRowSnapshot,
|
||||
type EstimateRowUnit,
|
||||
} from "../../shared/estimate-balance/estimate-balance";
|
||||
import {
|
||||
addEstimateRow,
|
||||
estimateTextLimits,
|
||||
moveEstimateGroup,
|
||||
removeEstimateGroup,
|
||||
removeEstimateRow,
|
||||
updateEstimateGroup,
|
||||
updateEstimateRow,
|
||||
type EstimateRowPatch,
|
||||
} from "./estimate-balance-document";
|
||||
import {
|
||||
estimateNumberRanges,
|
||||
formatAmpere,
|
||||
formatDecimalInput,
|
||||
formatKw,
|
||||
parseGermanDecimal,
|
||||
type DecimalRange,
|
||||
} from "./estimate-balance-format";
|
||||
import { DraftInput } from "./estimate-draft-input";
|
||||
|
||||
export type EstimateBalanceUpdate = (
|
||||
update: (balance: EstimateBalanceSnapshot) => EstimateBalanceSnapshot
|
||||
) => void;
|
||||
|
||||
interface EstimateBalanceSheetProps {
|
||||
balance: EstimateBalanceSnapshot;
|
||||
disabled: boolean;
|
||||
onAddGroup: () => void;
|
||||
onChange: EstimateBalanceUpdate;
|
||||
onEditGroup: (group: EstimateGroupSnapshot) => void;
|
||||
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||
threePhaseVoltageV: number;
|
||||
}
|
||||
|
||||
// Leading columns up to cos φ; the power columns follow.
|
||||
const LEADING_COLUMN_COUNT = 10;
|
||||
const COLUMN_COUNT = 17;
|
||||
|
||||
function NumberInput({
|
||||
ariaLabel,
|
||||
disabled,
|
||||
fieldKey,
|
||||
onCommit,
|
||||
onValidityChange,
|
||||
range,
|
||||
value,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
disabled: boolean;
|
||||
fieldKey: string;
|
||||
onCommit: (value: number) => void;
|
||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||
range: DecimalRange;
|
||||
value: number;
|
||||
}) {
|
||||
return (
|
||||
<DraftInput
|
||||
ariaLabel={ariaLabel}
|
||||
className="estimate-cell-input is-numeric"
|
||||
disabled={disabled}
|
||||
format={(current: number) => formatDecimalInput(current)}
|
||||
inputMode="decimal"
|
||||
onCommit={onCommit}
|
||||
onValidityChange={(isValid) => onValidityChange(fieldKey, isValid)}
|
||||
parse={(text) => parseGermanDecimal(text, range)}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInput({
|
||||
ariaLabel,
|
||||
disabled,
|
||||
maxLength,
|
||||
onChange,
|
||||
placeholder,
|
||||
value,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
disabled: boolean;
|
||||
maxLength: number;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
value: string | null;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
aria-label={ariaLabel}
|
||||
className="estimate-cell-input"
|
||||
disabled={disabled}
|
||||
maxLength={maxLength}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
type="text"
|
||||
value={value ?? ""}
|
||||
/>
|
||||
<span className="estimate-print-value">{value ?? ""}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
children,
|
||||
disabled,
|
||||
label,
|
||||
onClick,
|
||||
variant = "secondary",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
disabled: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
variant?: "secondary" | "danger" | "primary";
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
className={`btn btn-sm btn-outline-${variant} estimate-icon-button`}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function PowerCells({ power, className = "" }: { power: EstimatePower; className?: string }) {
|
||||
return (
|
||||
<>
|
||||
<td className={`estimate-number ${className}`}>{formatKw(power.kw)}</td>
|
||||
<td className={`estimate-number ${className}`}>{formatKw(power.kva)}</td>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function groupLabel(group: EstimateGroupSnapshot) {
|
||||
return [group.costGroup, group.title].filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function EstimateRowLine({
|
||||
disabled,
|
||||
group,
|
||||
onChange,
|
||||
onOpenGzfPicker,
|
||||
onValidityChange,
|
||||
row,
|
||||
rowIndex,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
group: EstimateGroupSnapshot;
|
||||
onChange: EstimateBalanceUpdate;
|
||||
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||
row: EstimateRowSnapshot;
|
||||
rowIndex: number;
|
||||
}) {
|
||||
const result = calculateEstimateRow(row);
|
||||
const rowLabel = `${groupLabel(group)}, Zeile ${rowIndex + 1}`;
|
||||
const fieldKey = (field: string) => `${row.id}:${field}`;
|
||||
|
||||
function patch(rowPatch: EstimateRowPatch) {
|
||||
onChange((balance) => updateEstimateRow(balance, group.id, row.id, rowPatch));
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="estimate-row">
|
||||
<td className="estimate-cost-group-cell" />
|
||||
<td>
|
||||
<TextInput
|
||||
ariaLabel={`Versorgung (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.supply}
|
||||
onChange={(supply) => patch({ supply })}
|
||||
placeholder="ELT"
|
||||
value={row.supply}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextInput
|
||||
ariaLabel={`Stromkreis-Nr. (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.circuitRef}
|
||||
onChange={(circuitRef) => patch({ circuitRef })}
|
||||
value={row.circuitRef}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextInput
|
||||
ariaLabel={`Verbraucher / Fläche (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.rowName}
|
||||
onChange={(name) => patch({ name })}
|
||||
placeholder="Verbraucher oder Fläche"
|
||||
value={row.name}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<NumberInput
|
||||
ariaLabel={`Anzahl / Fläche (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("quantity")}
|
||||
onCommit={(quantity) => patch({ quantity })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.quantity}
|
||||
value={row.quantity}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`Einheit (${rowLabel})`}
|
||||
className="estimate-cell-select"
|
||||
disabled={disabled}
|
||||
onChange={(event) => patch({ unit: event.target.value as EstimateRowUnit })}
|
||||
value={row.unit}
|
||||
>
|
||||
{(Object.keys(estimateRowUnitLabels) as EstimateRowUnit[]).map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
{estimateRowUnitLabels[unit]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="estimate-print-value">{estimateRowUnitLabels[row.unit]}</span>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
aria-label={`Anschluss (${rowLabel})`}
|
||||
className="estimate-cell-select"
|
||||
disabled={disabled}
|
||||
onChange={(event) =>
|
||||
patch({
|
||||
connection: (event.target.value || null) as EstimateRowConnection | null,
|
||||
})
|
||||
}
|
||||
value={row.connection ?? ""}
|
||||
>
|
||||
<option value="">–</option>
|
||||
{(Object.keys(estimateRowConnectionLabels) as EstimateRowConnection[]).map(
|
||||
(connection) => (
|
||||
<option key={connection} value={connection}>
|
||||
{estimateRowConnectionLabels[connection]}
|
||||
</option>
|
||||
)
|
||||
)}
|
||||
</select>
|
||||
<span className="estimate-print-value">
|
||||
{row.connection ? estimateRowConnectionLabels[row.connection] : ""}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="estimate-unit-input">
|
||||
<NumberInput
|
||||
ariaLabel={`Einzelleistung in ${row.unit === "area" ? "W/m²" : "W"} (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("powerPerUnitW")}
|
||||
onCommit={(powerPerUnitW) => patch({ powerPerUnitW })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.powerPerUnitW}
|
||||
value={row.powerPerUnitW}
|
||||
/>
|
||||
<span className="estimate-unit-label">{row.unit === "area" ? "W/m²" : "W"}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="estimate-gzf-input">
|
||||
<NumberInput
|
||||
ariaLabel={`Gleichzeitigkeitsfaktor (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("simultaneityFactor")}
|
||||
onCommit={(simultaneityFactor) => patch({ simultaneityFactor })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.simultaneityFactor}
|
||||
value={row.simultaneityFactor}
|
||||
/>
|
||||
<button
|
||||
aria-label={`Gleichzeitigkeitsfaktor nach AMEV wählen (${rowLabel})`}
|
||||
className="estimate-amev-button estimate-no-print"
|
||||
disabled={disabled}
|
||||
onClick={() => onOpenGzfPicker(group.id, row.id)}
|
||||
title="Gleichzeitigkeitsfaktor nach AMEV wählen"
|
||||
type="button"
|
||||
>
|
||||
AMEV
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<NumberInput
|
||||
ariaLabel={`cos φ (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
fieldKey={fieldKey("cosPhi")}
|
||||
onCommit={(cosPhi) => patch({ cosPhi })}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.cosPhi}
|
||||
value={row.cosPhi}
|
||||
/>
|
||||
</td>
|
||||
<PowerCells power={result.av} />
|
||||
<td className="estimate-center">
|
||||
<input
|
||||
aria-label={`Sicherheitsversorgung (${rowLabel})`}
|
||||
checked={row.safetySupply}
|
||||
className="form-check-input estimate-no-print"
|
||||
disabled={disabled}
|
||||
onChange={(event) => patch({ safetySupply: event.target.checked })}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span className="estimate-print-value">{row.safetySupply ? "ja" : "nein"}</span>
|
||||
</td>
|
||||
<PowerCells
|
||||
className={row.safetySupply ? "" : "is-muted"}
|
||||
power={result.sv}
|
||||
/>
|
||||
<td>
|
||||
<TextInput
|
||||
ariaLabel={`Anmerkung (${rowLabel})`}
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.remark}
|
||||
onChange={(remark) => patch({ remark })}
|
||||
value={row.remark}
|
||||
/>
|
||||
</td>
|
||||
<td className="estimate-actions-cell estimate-no-print">
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Zeile löschen (${rowLabel})`}
|
||||
onClick={() => onChange((balance) => removeEstimateRow(balance, group.id, row.id))}
|
||||
variant="danger"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spreadsheet-like editor in the layout of the office template
|
||||
* "Leistungsbilanz – KG400": one section per cost group with title and
|
||||
* subtotal row, totals, reserve and maximum current at the end.
|
||||
*/
|
||||
export function EstimateBalanceSheet({
|
||||
balance,
|
||||
disabled,
|
||||
onAddGroup,
|
||||
onChange,
|
||||
onEditGroup,
|
||||
onOpenGzfPicker,
|
||||
onValidityChange,
|
||||
threePhaseVoltageV,
|
||||
}: EstimateBalanceSheetProps) {
|
||||
const result = calculateEstimateBalance(balance, threePhaseVoltageV);
|
||||
|
||||
function confirmRemoveGroup(group: EstimateGroupSnapshot) {
|
||||
const rowText =
|
||||
group.rows.length === 1 ? "1 Zeile" : `${group.rows.length} Zeilen`;
|
||||
if (
|
||||
window.confirm(
|
||||
`Kostengruppe „${groupLabel(group)}“ mit ${rowText} wirklich löschen?`
|
||||
)
|
||||
) {
|
||||
onChange((current) => removeEstimateGroup(current, group.id));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="estimate-sheet-wrap">
|
||||
<table className="estimate-sheet">
|
||||
<colgroup>
|
||||
<col className="estimate-col-kg" />
|
||||
<col className="estimate-col-supply" />
|
||||
<col className="estimate-col-circuit" />
|
||||
<col className="estimate-col-name" />
|
||||
<col className="estimate-col-quantity" />
|
||||
<col className="estimate-col-unit" />
|
||||
<col className="estimate-col-connection" />
|
||||
<col className="estimate-col-power" />
|
||||
<col className="estimate-col-gzf" />
|
||||
<col className="estimate-col-cosphi" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-sv" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-result" />
|
||||
<col className="estimate-col-remark" />
|
||||
<col className="estimate-col-actions" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowSpan={2}>KG</th>
|
||||
<th rowSpan={2}>
|
||||
Versorgung
|
||||
<span className="estimate-th-hint">ELT/ISP …</span>
|
||||
</th>
|
||||
<th rowSpan={2}>Stromkreis-Nr.</th>
|
||||
<th rowSpan={2}>Verbraucher / Flächen</th>
|
||||
<th colSpan={2}>
|
||||
Anzahl / Fläche
|
||||
<span className="estimate-th-hint">St. / m²</span>
|
||||
</th>
|
||||
<th rowSpan={2}>
|
||||
Anschl.
|
||||
<span className="estimate-th-hint">Fest/Steckd.</span>
|
||||
</th>
|
||||
<th rowSpan={2}>
|
||||
Einzelleist.
|
||||
<span className="estimate-th-hint">W bzw. W/m²</span>
|
||||
</th>
|
||||
<th rowSpan={2}>GL-Faktor</th>
|
||||
<th rowSpan={2}>cos φ</th>
|
||||
<th colSpan={2}>Nötige AV-Leistung</th>
|
||||
<th colSpan={3}>Sicherheitsversorgung (SV)</th>
|
||||
<th rowSpan={2}>Anmerkungen</th>
|
||||
<th aria-label="Aktionen" className="estimate-no-print" rowSpan={2} />
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Wert</th>
|
||||
<th>Einh.</th>
|
||||
<th>kW</th>
|
||||
<th>kVA</th>
|
||||
<th>ja/nein</th>
|
||||
<th>kW</th>
|
||||
<th>kVA</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{balance.groups.map((group, groupIndex) => {
|
||||
const groupResult = result.groups[groupIndex]!;
|
||||
const label = groupLabel(group) || "Kostengruppe";
|
||||
return (
|
||||
<tbody className="estimate-group" key={group.id}>
|
||||
<tr className="estimate-group-title-row">
|
||||
<td className="estimate-cost-group-cell">{group.costGroup}</td>
|
||||
<td colSpan={COLUMN_COUNT - 2}>
|
||||
<div className="estimate-group-title">
|
||||
<span className="estimate-group-name">{group.title}</span>
|
||||
<label className="estimate-group-editor">
|
||||
<span>bearbeitet von</span>
|
||||
<input
|
||||
aria-label={`Bearbeitet von (${label})`}
|
||||
className="estimate-cell-input"
|
||||
disabled={disabled}
|
||||
maxLength={estimateTextLimits.groupEditor}
|
||||
onChange={(event) =>
|
||||
onChange((current) =>
|
||||
updateEstimateGroup(current, group.id, {
|
||||
editor: event.target.value,
|
||||
})
|
||||
)
|
||||
}
|
||||
placeholder="Gewerk / Name"
|
||||
type="text"
|
||||
value={group.editor ?? ""}
|
||||
/>
|
||||
<span className="estimate-print-value">{group.editor ?? ""}</span>
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td className="estimate-actions-cell estimate-no-print">
|
||||
<div className="estimate-group-actions">
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Zeile hinzufügen (${label})`}
|
||||
onClick={() =>
|
||||
onChange((current) => addEstimateRow(current, group.id).balance)
|
||||
}
|
||||
variant="primary"
|
||||
>
|
||||
<Plus aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Gruppe bearbeiten (${label})`}
|
||||
onClick={() => onEditGroup(group)}
|
||||
>
|
||||
<Pencil aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled || groupIndex === 0}
|
||||
label={`Gruppe nach oben (${label})`}
|
||||
onClick={() =>
|
||||
onChange((current) => moveEstimateGroup(current, group.id, -1))
|
||||
}
|
||||
>
|
||||
<ArrowUp aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled || groupIndex === balance.groups.length - 1}
|
||||
label={`Gruppe nach unten (${label})`}
|
||||
onClick={() =>
|
||||
onChange((current) => moveEstimateGroup(current, group.id, 1))
|
||||
}
|
||||
>
|
||||
<ArrowDown aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
label={`Gruppe löschen (${label})`}
|
||||
onClick={() => confirmRemoveGroup(group)}
|
||||
variant="danger"
|
||||
>
|
||||
<Trash2 aria-hidden="true" size={13} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{group.rows.map((row, rowIndex) => (
|
||||
<EstimateRowLine
|
||||
disabled={disabled}
|
||||
group={group}
|
||||
key={row.id}
|
||||
onChange={onChange}
|
||||
onOpenGzfPicker={onOpenGzfPicker}
|
||||
onValidityChange={onValidityChange}
|
||||
row={row}
|
||||
rowIndex={rowIndex}
|
||||
/>
|
||||
))}
|
||||
{!group.rows.length ? (
|
||||
<tr className="estimate-empty-row estimate-no-print">
|
||||
<td />
|
||||
<td colSpan={COLUMN_COUNT - 1}>
|
||||
Noch keine Einträge – mit „+“ eine Zeile hinzufügen.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
<tr className="estimate-subtotal-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Summe {label}
|
||||
</td>
|
||||
<PowerCells power={groupResult.av} />
|
||||
<td />
|
||||
<PowerCells power={groupResult.sv} />
|
||||
<td />
|
||||
<td className="estimate-no-print" />
|
||||
</tr>
|
||||
</tbody>
|
||||
);
|
||||
})}
|
||||
<tfoot>
|
||||
<tr className="estimate-no-print estimate-add-group-row">
|
||||
<td colSpan={COLUMN_COUNT}>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-primary"
|
||||
disabled={disabled}
|
||||
onClick={onAddGroup}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" className="me-1" size={14} />
|
||||
Kostengruppe hinzufügen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="estimate-total-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Gesamtsumme
|
||||
</td>
|
||||
<PowerCells power={result.total.av} />
|
||||
<td />
|
||||
<PowerCells power={result.total.sv} />
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
<tr className="estimate-total-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT - 1} className="estimate-total-label">
|
||||
Reserveleistung
|
||||
</td>
|
||||
<td>
|
||||
<div className="estimate-unit-input">
|
||||
<NumberInput
|
||||
ariaLabel="Reserve AV in Prozent"
|
||||
disabled={disabled}
|
||||
fieldKey="balance:reserveAvPercent"
|
||||
onCommit={(reserveAvPercent) =>
|
||||
onChange((current) => ({ ...current, reserveAvPercent }))
|
||||
}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.reservePercent}
|
||||
value={balance.reserveAvPercent}
|
||||
/>
|
||||
<span className="estimate-unit-label">%</span>
|
||||
</div>
|
||||
</td>
|
||||
<PowerCells power={result.reserve.av} />
|
||||
<td>
|
||||
<div className="estimate-unit-input">
|
||||
<NumberInput
|
||||
ariaLabel="Reserve SV in Prozent"
|
||||
disabled={disabled}
|
||||
fieldKey="balance:reserveSvPercent"
|
||||
onCommit={(reserveSvPercent) =>
|
||||
onChange((current) => ({ ...current, reserveSvPercent }))
|
||||
}
|
||||
onValidityChange={onValidityChange}
|
||||
range={estimateNumberRanges.reservePercent}
|
||||
value={balance.reserveSvPercent}
|
||||
/>
|
||||
<span className="estimate-unit-label">%</span>
|
||||
</div>
|
||||
</td>
|
||||
<PowerCells power={result.reserve.sv} />
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
<tr className="estimate-total-row is-strong">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Leistung inkl. Reserve
|
||||
</td>
|
||||
<PowerCells power={result.withReserve.av} />
|
||||
<td />
|
||||
<PowerCells power={result.withReserve.sv} />
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
<tr className="estimate-total-row">
|
||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||
Maximalstrom
|
||||
<span className="estimate-total-hint">
|
||||
{threePhaseVoltageV > 0
|
||||
? ` (3~, U = ${formatDecimalInput(threePhaseVoltageV)} V)`
|
||||
: " (Drehstromspannung im Projekt fehlt)"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="estimate-number" colSpan={2}>
|
||||
{formatAmpere(result.maxCurrentA.av)} A
|
||||
</td>
|
||||
<td />
|
||||
<td className="estimate-number" colSpan={2}>
|
||||
{formatAmpere(result.maxCurrentA.sv)} A
|
||||
</td>
|
||||
<td colSpan={2} />
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
src/frontend/estimate-balance/estimate-comparison-table.tsx
Normal file
88
src/frontend/estimate-balance/estimate-comparison-table.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
roundEstimateValue,
|
||||
type EstimateComparison,
|
||||
} from "../../shared/estimate-balance/estimate-balance";
|
||||
import { formatDeltaPercent, formatKw, formatSignedKw } from "./estimate-balance-format";
|
||||
|
||||
function deltaClass(deltaKw: number) {
|
||||
if (roundEstimateValue(deltaKw, 2) === 0) {
|
||||
return "estimate-number";
|
||||
}
|
||||
return deltaKw > 0 ? "estimate-number is-over" : "estimate-number is-under";
|
||||
}
|
||||
|
||||
/** Estimated AV power per cost group vs. the detailed circuit-list rows. */
|
||||
export function EstimateComparisonTable({ comparison }: { comparison: EstimateComparison }) {
|
||||
return (
|
||||
<div className="estimate-sheet-wrap">
|
||||
<table className="estimate-sheet estimate-comparison">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>KG</th>
|
||||
<th>Titel</th>
|
||||
<th>geschätzt AV kW</th>
|
||||
<th>detailliert kW</th>
|
||||
<th>Δ kW</th>
|
||||
<th>Δ %</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{comparison.groups.map((group) => (
|
||||
<tr key={group.groupId}>
|
||||
<td className="estimate-cost-group-cell">{group.costGroup}</td>
|
||||
<td>
|
||||
{group.title}
|
||||
{group.detailedRowCount ? (
|
||||
<span className="estimate-th-hint">
|
||||
{group.detailedRowCount === 1
|
||||
? "1 Gerätezeile"
|
||||
: `${group.detailedRowCount} Gerätezeilen`}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="estimate-number">{formatKw(group.estimatedAv.kw)}</td>
|
||||
<td className="estimate-number">{formatKw(group.detailed.kw)}</td>
|
||||
<td className={deltaClass(group.deltaKw)}>{formatSignedKw(group.deltaKw)}</td>
|
||||
<td className={deltaClass(group.deltaKw)}>
|
||||
{formatDeltaPercent(group.deltaPercent)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="estimate-unassigned-row">
|
||||
<td />
|
||||
<td>
|
||||
ohne Kostengruppe / nicht zugeordnet
|
||||
{comparison.unassigned.rowCount ? (
|
||||
<span className="estimate-th-hint">
|
||||
{comparison.unassigned.rowCount === 1
|
||||
? "1 Gerätezeile"
|
||||
: `${comparison.unassigned.rowCount} Gerätezeilen`}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="estimate-number">–</td>
|
||||
<td className="estimate-number">{formatKw(comparison.unassigned.kw)}</td>
|
||||
<td className="estimate-number">–</td>
|
||||
<td className="estimate-number">–</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="estimate-total-row is-strong">
|
||||
<td />
|
||||
<td className="estimate-total-label">Summe</td>
|
||||
<td className="estimate-number">{formatKw(comparison.total.estimatedAv.kw)}</td>
|
||||
<td className="estimate-number">{formatKw(comparison.total.detailed.kw)}</td>
|
||||
<td className={deltaClass(comparison.total.deltaKw)}>
|
||||
{formatSignedKw(comparison.total.deltaKw)}
|
||||
</td>
|
||||
<td className={deltaClass(comparison.total.deltaKw)}>
|
||||
{formatDeltaPercent(comparison.total.deltaPercent)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/frontend/estimate-balance/estimate-draft-input.tsx
Normal file
107
src/frontend/estimate-balance/estimate-draft-input.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export type DraftParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
|
||||
|
||||
interface DraftInputProps<T> {
|
||||
ariaLabel: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
format: (value: T) => string;
|
||||
inputMode?: "decimal" | "text";
|
||||
maxLength?: number;
|
||||
onCommit: (value: T) => void;
|
||||
/** Reports whether the raw text is currently valid (false while invalid). */
|
||||
onValidityChange?: (isValid: boolean) => void;
|
||||
parse: (text: string) => DraftParseResult<T>;
|
||||
placeholder?: string;
|
||||
value: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input that keeps the raw text the user typed. Only a valid text is
|
||||
* committed into the document; an invalid text stays visible with a red
|
||||
* border and is never saved. A value changed from outside (reload, undo)
|
||||
* replaces the text unless the text already represents that value.
|
||||
*/
|
||||
export function DraftInput<T>({
|
||||
ariaLabel,
|
||||
className = "estimate-cell-input",
|
||||
disabled,
|
||||
format,
|
||||
inputMode = "text",
|
||||
maxLength,
|
||||
onCommit,
|
||||
onValidityChange,
|
||||
parse,
|
||||
placeholder,
|
||||
value,
|
||||
}: DraftInputProps<T>) {
|
||||
const [text, setText] = useState(() => format(value));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [syncedValue, setSyncedValue] = useState(value);
|
||||
|
||||
if (!Object.is(syncedValue, value)) {
|
||||
// Adjusting state while rendering (instead of an effect) avoids a frame
|
||||
// with stale text after an external change.
|
||||
setSyncedValue(value);
|
||||
const parsed = parse(text);
|
||||
if (!parsed.ok || !Object.is(parsed.value, value)) {
|
||||
setText(format(value));
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
const validityRef = useRef(onValidityChange);
|
||||
useEffect(() => {
|
||||
validityRef.current = onValidityChange;
|
||||
});
|
||||
const isValid = error === null;
|
||||
useEffect(() => {
|
||||
validityRef.current?.(isValid);
|
||||
}, [isValid]);
|
||||
// A removed input (deleted row) must not leave an invalid marker behind.
|
||||
useEffect(() => () => validityRef.current?.(true), []);
|
||||
|
||||
function handleChange(nextText: string) {
|
||||
setText(nextText);
|
||||
const parsed = parse(nextText);
|
||||
if (!parsed.ok) {
|
||||
setError(parsed.error);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
if (!Object.is(parsed.value, value)) {
|
||||
setSyncedValue(parsed.value);
|
||||
onCommit(parsed.value);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Show the canonical notation again ("0,80" → "0,8") once editing ends.
|
||||
if (error === null) {
|
||||
setText(format(value));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-label={ariaLabel}
|
||||
className={error ? `${className} is-invalid` : className}
|
||||
disabled={disabled}
|
||||
inputMode={inputMode}
|
||||
maxLength={maxLength}
|
||||
onBlur={handleBlur}
|
||||
onChange={(event) => handleChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
title={error ?? undefined}
|
||||
type="text"
|
||||
value={text}
|
||||
/>
|
||||
<span className="estimate-print-value">{error ? format(value) : text}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
128
src/frontend/estimate-balance/estimate-group-modal.tsx
Normal file
128
src/frontend/estimate-balance/estimate-group-modal.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"use client";
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { FormModal } from "../components/form-modal";
|
||||
import {
|
||||
estimateDefaultGroups,
|
||||
type EstimateGroupSnapshot,
|
||||
} from "../../shared/estimate-balance/estimate-balance";
|
||||
import { estimateTextLimits } from "./estimate-balance-document";
|
||||
|
||||
export interface EstimateGroupFormValues {
|
||||
costGroup: string;
|
||||
title: string;
|
||||
editor: string | null;
|
||||
}
|
||||
|
||||
interface EstimateGroupModalProps {
|
||||
group: EstimateGroupSnapshot | null;
|
||||
onClose: () => void;
|
||||
onSave: (values: EstimateGroupFormValues) => void;
|
||||
}
|
||||
|
||||
/** Creates or edits the KG number, title and "bearbeitet von" of one group. */
|
||||
export function EstimateGroupModal({ group, onClose, onSave }: EstimateGroupModalProps) {
|
||||
const [costGroup, setCostGroup] = useState(group?.costGroup ?? "");
|
||||
const [title, setTitle] = useState(group?.title ?? "");
|
||||
const [editor, setEditor] = useState(group?.editor ?? "");
|
||||
|
||||
function applyTemplate(index: string) {
|
||||
const template = estimateDefaultGroups[Number(index)];
|
||||
if (template) {
|
||||
setCostGroup(template.costGroup);
|
||||
setTitle(template.title);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!title.trim()) {
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
costGroup: costGroup.trim(),
|
||||
title: title.trim(),
|
||||
editor: editor.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
isSaving={false}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitDisabled={!title.trim()}
|
||||
submitLabel={group ? "Übernehmen" : "Kostengruppe hinzufügen"}
|
||||
title={group ? "Kostengruppe bearbeiten" : "Kostengruppe hinzufügen"}
|
||||
>
|
||||
<div className="row g-3">
|
||||
{!group ? (
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="estimate-group-template">
|
||||
Aus Vorlage KG 400 übernehmen
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
defaultValue=""
|
||||
id="estimate-group-template"
|
||||
onChange={(event) => applyTemplate(event.target.value)}
|
||||
>
|
||||
<option value="">– Vorlage wählen –</option>
|
||||
{estimateDefaultGroups.map((template, index) => (
|
||||
<option key={`${template.costGroup}-${template.title}`} value={index}>
|
||||
{[template.costGroup, template.title].filter(Boolean).join(" ")}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="col-12 col-md-3">
|
||||
<label className="form-label" htmlFor="estimate-group-cost-group">
|
||||
KG (DIN 276)
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="estimate-group-cost-group"
|
||||
maxLength={estimateTextLimits.costGroup}
|
||||
onChange={(event) => setCostGroup(event.target.value)}
|
||||
placeholder="z. B. 440"
|
||||
value={costGroup}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-9">
|
||||
<label className="form-label" htmlFor="estimate-group-title">
|
||||
Titel
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="estimate-group-title"
|
||||
maxLength={estimateTextLimits.groupTitle}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="z. B. Starkstromanlagen"
|
||||
required
|
||||
value={title}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-label" htmlFor="estimate-group-editor">
|
||||
Bearbeitet von
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="estimate-group-editor"
|
||||
maxLength={estimateTextLimits.groupEditor}
|
||||
onChange={(event) => setEditor(event.target.value)}
|
||||
placeholder="Gewerk oder Name (optional)"
|
||||
value={editor}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<p className="text-secondary small mb-0">
|
||||
Ohne KG-Nummer (z. B. „Allgemein“) wird die Gruppe im Vergleich mit der
|
||||
detaillierten Bilanz keiner Kostengruppe zugeordnet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
|
@ -535,3 +535,34 @@ export interface CreateCircuitDeviceRowInputDto {
|
|||
}
|
||||
|
||||
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
|
||||
|
||||
// ── Grobe Leistungsbilanz (LPH 2/3) ─────────────────────────────────────
|
||||
// The document shapes are shared with the API; see
|
||||
// src/shared/estimate-balance/estimate-balance.ts.
|
||||
import type {
|
||||
EstimateBalanceSnapshot,
|
||||
EstimateComparison,
|
||||
} from "../shared/estimate-balance/estimate-balance";
|
||||
|
||||
export type EstimateBalanceDto = EstimateBalanceSnapshot;
|
||||
export type EstimateComparisonDto = EstimateComparison;
|
||||
|
||||
export type EstimateBalanceTemplate = "kg400" | "empty" | "copy";
|
||||
|
||||
export interface CreateEstimateBalanceInput {
|
||||
name: string;
|
||||
workStage?: string | null;
|
||||
dateIso?: string | null;
|
||||
template: EstimateBalanceTemplate;
|
||||
/** Required for template "copy". */
|
||||
copyFromBalanceId?: string;
|
||||
}
|
||||
|
||||
export interface EstimateBalanceCommandResultDto extends ProjectCommandResultDto {
|
||||
balance: EstimateBalanceDto;
|
||||
}
|
||||
|
||||
/** PUT result: either a new revision or an unchanged document. */
|
||||
export type EstimateBalanceUpdateResultDto =
|
||||
| EstimateBalanceCommandResultDto
|
||||
| { unchanged: true; balance: EstimateBalanceDto };
|
||||
|
|
|
|||
|
|
@ -38,6 +38,11 @@ import type {
|
|||
ExternalInitialImportPlanDto,
|
||||
ApplyExternalInitialImportInput,
|
||||
ExternalInitialImportResultDto,
|
||||
CreateEstimateBalanceInput,
|
||||
EstimateBalanceCommandResultDto,
|
||||
EstimateBalanceDto,
|
||||
EstimateBalanceUpdateResultDto,
|
||||
EstimateComparisonDto,
|
||||
} from "../types";
|
||||
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts";
|
||||
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
|
||||
|
|
@ -87,6 +92,30 @@ import type {
|
|||
CircuitGroupSubtreeSnapshot,
|
||||
} from "../../domain/models/circuit-group-subtree-snapshot.model";
|
||||
|
||||
/**
|
||||
* Error of a failed API request. The message stays the server's German
|
||||
* error text; status and optional error code allow callers to react to
|
||||
* specific failures such as a revision conflict (409).
|
||||
*/
|
||||
export class ApiRequestError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string | null;
|
||||
|
||||
constructor(message: string, status: number, code: string | null = null) {
|
||||
super(message);
|
||||
this.name = "ApiRequestError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export function isRevisionConflictError(error: unknown) {
|
||||
return (
|
||||
error instanceof ApiRequestError &&
|
||||
(error.status === 409 || error.code === "PROJECT_REVISION_CONFLICT")
|
||||
);
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
...init,
|
||||
|
|
@ -99,15 +128,23 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
|||
|
||||
if (!response.ok) {
|
||||
let details = await response.text();
|
||||
let code: string | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(details) as { error?: unknown };
|
||||
const parsed = JSON.parse(details) as { error?: unknown; code?: unknown };
|
||||
if (typeof parsed.error === "string") {
|
||||
details = parsed.error;
|
||||
}
|
||||
if (typeof parsed.code === "string") {
|
||||
code = parsed.code;
|
||||
}
|
||||
} catch {
|
||||
// Keep non-JSON error responses unchanged.
|
||||
}
|
||||
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
|
||||
throw new ApiRequestError(
|
||||
details || `Anfrage fehlgeschlagen (Status ${response.status})`,
|
||||
response.status,
|
||||
code
|
||||
);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
|
|
@ -1135,3 +1172,66 @@ export function disconnectProjectDeviceRows(
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── Grobe Leistungsbilanz (LPH 2/3) ─────────────────────────────────────
|
||||
|
||||
function estimateBalancesUrl(projectId: string, balanceId?: string) {
|
||||
const base = `/api/projects/${projectId}/estimate-balances`;
|
||||
return balanceId ? `${base}/${balanceId}` : base;
|
||||
}
|
||||
|
||||
export function listEstimateBalances(projectId: string) {
|
||||
return request<EstimateBalanceDto[]>(estimateBalancesUrl(projectId));
|
||||
}
|
||||
|
||||
export function getEstimateBalance(projectId: string, balanceId: string) {
|
||||
return request<EstimateBalanceDto>(estimateBalancesUrl(projectId, balanceId));
|
||||
}
|
||||
|
||||
export function createEstimateBalance(
|
||||
projectId: string,
|
||||
input: CreateEstimateBalanceInput,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<EstimateBalanceCommandResultDto>(estimateBalancesUrl(projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...input, expectedRevision }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the complete target document. `keepalive` lets the request outlive
|
||||
* the page, e.g. for the last autosave while the tab is being closed.
|
||||
*/
|
||||
export function updateEstimateBalance(
|
||||
projectId: string,
|
||||
balance: EstimateBalanceDto,
|
||||
expectedRevision: number,
|
||||
options: { keepalive?: boolean } = {}
|
||||
) {
|
||||
return request<EstimateBalanceUpdateResultDto>(
|
||||
estimateBalancesUrl(projectId, balance.id),
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ expectedRevision, balance }),
|
||||
...(options.keepalive ? { keepalive: true } : {}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteEstimateBalance(
|
||||
projectId: string,
|
||||
balanceId: string,
|
||||
expectedRevision: number
|
||||
) {
|
||||
return request<ProjectCommandResultDto>(estimateBalancesUrl(projectId, balanceId), {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ expectedRevision }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getEstimateComparison(projectId: string, balanceId: string) {
|
||||
return request<EstimateComparisonDto>(
|
||||
`${estimateBalancesUrl(projectId, balanceId)}/comparison`
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ const commandTypeLabels: Record<string, string> = {
|
|||
"external-object.unassign-and-delete-created-row":
|
||||
"Externes Objekt aus erzeugter Gerätezeile gelöst",
|
||||
"external-object.update-row-assignment": "Externe Objektzuordnung geändert",
|
||||
"estimate-balance.insert": "Grobe Leistungsbilanz angelegt",
|
||||
"estimate-balance.update": "Grobe Leistungsbilanz bearbeitet",
|
||||
"estimate-balance.delete": "Grobe Leistungsbilanz entfernt",
|
||||
};
|
||||
|
||||
export function getProjectRevisionSourceLabel(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { CircuitProtectionDeviceRepository } from "../../db/repositories/circuit
|
|||
import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js";
|
||||
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
||||
import { BuildingRepository } from "../../db/repositories/building.repository.js";
|
||||
import { EstimateBalanceRepository } from "../../db/repositories/estimate-balance.repository.js";
|
||||
import { FloorRepository } from "../../db/repositories/floor.repository.js";
|
||||
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||
|
|
@ -28,6 +29,7 @@ export const distributionBoardComponentRepository =
|
|||
export const distributionBoardRepository =
|
||||
new DistributionBoardRepository(db);
|
||||
export const buildingRepository = new BuildingRepository(db);
|
||||
export const estimateBalanceRepository = new EstimateBalanceRepository(db);
|
||||
export const floorRepository = new FloorRepository(db);
|
||||
export const globalDeviceRepository = new GlobalDeviceRepository(db);
|
||||
export const projectDeviceRepository = new ProjectDeviceRepository(db);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { ExternalInitialImportProjectCommandRepository } from "../../db/reposito
|
|||
import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js";
|
||||
import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js";
|
||||
import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js";
|
||||
import { EstimateBalanceProjectCommandRepository } from "../../db/repositories/estimate-balance-project-command.repository.js";
|
||||
|
||||
export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
||||
export const circuitDeviceRowProjectCommandStore =
|
||||
|
|
@ -80,6 +81,8 @@ export const externalObjectNewRowProjectCommandStore =
|
|||
new ExternalObjectNewRowProjectCommandRepository(db);
|
||||
export const externalObjectNewCircuitProjectCommandStore =
|
||||
new ExternalObjectNewCircuitProjectCommandRepository(db);
|
||||
export const estimateBalanceProjectCommandStore =
|
||||
new EstimateBalanceProjectCommandRepository(db);
|
||||
export const projectCommandService = new ProjectCommandService(
|
||||
circuitProjectCommandStore,
|
||||
circuitDeviceRowProjectCommandStore,
|
||||
|
|
@ -107,5 +110,6 @@ export const projectCommandService = new ProjectCommandService(
|
|||
externalInitialImportProjectCommandStore,
|
||||
externalObjectRowAssignmentProjectCommandStore,
|
||||
externalObjectNewRowProjectCommandStore,
|
||||
externalObjectNewCircuitProjectCommandStore
|
||||
externalObjectNewCircuitProjectCommandStore,
|
||||
estimateBalanceProjectCommandStore
|
||||
);
|
||||
|
|
|
|||
180
src/server/controllers/estimate-balance.controller.ts
Normal file
180
src/server/controllers/estimate-balance.controller.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import type { Request, Response } from "express";
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
createEstimateBalanceDeleteProjectCommand,
|
||||
createEstimateBalanceInsertProjectCommand,
|
||||
createEstimateBalanceSnapshot,
|
||||
createEstimateBalanceUpdateProjectCommand,
|
||||
parseEstimateBalanceSnapshot,
|
||||
sameEstimateBalance,
|
||||
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
||||
import {
|
||||
createEstimateBalanceRequestSchema,
|
||||
deleteEstimateBalanceRequestSchema,
|
||||
updateEstimateBalanceRequestSchema,
|
||||
} from "../../estimate-balance/api/estimate-balance-request.schemas.js";
|
||||
import { compareEstimateWithDetailed } from "../../shared/estimate-balance/estimate-balance.js";
|
||||
import { estimateBalanceRepository } from "../composition/application-repositories.js";
|
||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||
|
||||
function params(req: Request) {
|
||||
const { projectId, balanceId } = req.params;
|
||||
return {
|
||||
projectId: typeof projectId === "string" ? projectId : null,
|
||||
balanceId: typeof balanceId === "string" ? balanceId : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEstimateBalances(req: Request, res: Response) {
|
||||
const { projectId } = params(req);
|
||||
if (!projectId) {
|
||||
return res.status(400).json({ error: "Ungültige Projekt-ID." });
|
||||
}
|
||||
return res.json(estimateBalanceRepository.listByProject(projectId));
|
||||
}
|
||||
|
||||
export function getEstimateBalance(req: Request, res: Response) {
|
||||
const { projectId, balanceId } = params(req);
|
||||
if (!projectId || !balanceId) {
|
||||
return res.status(400).json({ error: "Ungültige Parameter." });
|
||||
}
|
||||
const balance = estimateBalanceRepository.findById(projectId, balanceId);
|
||||
if (!balance) {
|
||||
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
|
||||
}
|
||||
return res.json(balance);
|
||||
}
|
||||
|
||||
export function createEstimateBalance(req: Request, res: Response) {
|
||||
const { projectId } = params(req);
|
||||
if (!projectId) {
|
||||
return res.status(400).json({ error: "Ungültige Projekt-ID." });
|
||||
}
|
||||
const parsed = createEstimateBalanceRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
let template: Parameters<typeof createEstimateBalanceSnapshot>[0]["template"] =
|
||||
parsed.data.template === "copy" ? "empty" : parsed.data.template;
|
||||
if (parsed.data.template === "copy") {
|
||||
const source = estimateBalanceRepository.findById(
|
||||
projectId,
|
||||
parsed.data.copyFromBalanceId!
|
||||
);
|
||||
if (!source) {
|
||||
return res
|
||||
.status(404)
|
||||
.json({ error: "Der zu kopierende Stand wurde nicht gefunden." });
|
||||
}
|
||||
template = { copyFrom: source };
|
||||
}
|
||||
try {
|
||||
const balance = createEstimateBalanceSnapshot({
|
||||
projectId,
|
||||
name: parsed.data.name,
|
||||
workStage: parsed.data.workStage ?? null,
|
||||
dateIso: parsed.data.dateIso ?? null,
|
||||
sortOrder: estimateBalanceRepository.nextSortOrder(projectId),
|
||||
template,
|
||||
});
|
||||
const result = projectCommandService.executeUser({
|
||||
projectId,
|
||||
expectedRevision: parsed.data.expectedRevision,
|
||||
description: "Grobe Leistungsbilanz anlegen",
|
||||
command: createEstimateBalanceInsertProjectCommand(balance),
|
||||
});
|
||||
return res.status(201).json({ ...result, balance });
|
||||
} catch (error) {
|
||||
return respondWithProjectCommandError(error, res);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateEstimateBalance(req: Request, res: Response) {
|
||||
const { projectId, balanceId } = params(req);
|
||||
if (!projectId || !balanceId) {
|
||||
return res.status(400).json({ error: "Ungültige Parameter." });
|
||||
}
|
||||
const parsed = updateEstimateBalanceRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
let target;
|
||||
try {
|
||||
target = parseEstimateBalanceSnapshot(parsed.data.balance);
|
||||
} catch (error) {
|
||||
return res.status(400).json({
|
||||
error: "Die grobe Leistungsbilanz enthält ungültige Werte.",
|
||||
issues: error instanceof ZodError ? error.issues : undefined,
|
||||
});
|
||||
}
|
||||
if (target.id !== balanceId || target.projectId !== projectId) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Die Bilanz gehört nicht zu diesem Projekt oder dieser Adresse." });
|
||||
}
|
||||
const current = estimateBalanceRepository.findById(projectId, balanceId);
|
||||
if (!current) {
|
||||
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
|
||||
}
|
||||
if (sameEstimateBalance(current, target)) {
|
||||
return res.json({ unchanged: true, balance: current });
|
||||
}
|
||||
try {
|
||||
const result = projectCommandService.executeUser({
|
||||
projectId,
|
||||
expectedRevision: parsed.data.expectedRevision,
|
||||
description: "Grobe Leistungsbilanz bearbeiten",
|
||||
command: createEstimateBalanceUpdateProjectCommand(current, target),
|
||||
});
|
||||
return res.json({
|
||||
...result,
|
||||
balance: estimateBalanceRepository.findById(projectId, balanceId),
|
||||
});
|
||||
} catch (error) {
|
||||
return respondWithProjectCommandError(error, res);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteEstimateBalance(req: Request, res: Response) {
|
||||
const { projectId, balanceId } = params(req);
|
||||
if (!projectId || !balanceId) {
|
||||
return res.status(400).json({ error: "Ungültige Parameter." });
|
||||
}
|
||||
const parsed = deleteEstimateBalanceRequestSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
const current = estimateBalanceRepository.findById(projectId, balanceId);
|
||||
if (!current) {
|
||||
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
|
||||
}
|
||||
try {
|
||||
const result = projectCommandService.executeUser({
|
||||
projectId,
|
||||
expectedRevision: parsed.data.expectedRevision,
|
||||
description: "Grobe Leistungsbilanz löschen",
|
||||
command: createEstimateBalanceDeleteProjectCommand(current),
|
||||
});
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return respondWithProjectCommandError(error, res);
|
||||
}
|
||||
}
|
||||
|
||||
export function getEstimateComparison(req: Request, res: Response) {
|
||||
const { projectId, balanceId } = params(req);
|
||||
if (!projectId || !balanceId) {
|
||||
return res.status(400).json({ error: "Ungültige Parameter." });
|
||||
}
|
||||
const balance = estimateBalanceRepository.findById(projectId, balanceId);
|
||||
if (!balance) {
|
||||
return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." });
|
||||
}
|
||||
return res.json(
|
||||
compareEstimateWithDetailed(
|
||||
balance,
|
||||
estimateBalanceRepository.listDetailedDeviceRowPower(projectId)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
@ -21,6 +21,14 @@ import {
|
|||
} from "../controllers/building.controller.js";
|
||||
import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js";
|
||||
import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js";
|
||||
import {
|
||||
createEstimateBalance,
|
||||
deleteEstimateBalance,
|
||||
getEstimateBalance,
|
||||
getEstimateComparison,
|
||||
listEstimateBalances,
|
||||
updateEstimateBalance,
|
||||
} from "../controllers/estimate-balance.controller.js";
|
||||
import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
|
||||
import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js";
|
||||
import {
|
||||
|
|
@ -118,3 +126,12 @@ projectRouter.get("/:projectId/rooms", listRoomsByProject);
|
|||
projectRouter.post("/:projectId/rooms", createRoom);
|
||||
projectRouter.put("/:projectId/rooms/:roomId", updateRoom);
|
||||
projectRouter.delete("/:projectId/rooms/:roomId", deleteRoom);
|
||||
projectRouter.get("/:projectId/estimate-balances", listEstimateBalances);
|
||||
projectRouter.post("/:projectId/estimate-balances", createEstimateBalance);
|
||||
projectRouter.get("/:projectId/estimate-balances/:balanceId", getEstimateBalance);
|
||||
projectRouter.put("/:projectId/estimate-balances/:balanceId", updateEstimateBalance);
|
||||
projectRouter.delete("/:projectId/estimate-balances/:balanceId", deleteEstimateBalance);
|
||||
projectRouter.get(
|
||||
"/:projectId/estimate-balances/:balanceId/comparison",
|
||||
getEstimateComparison
|
||||
);
|
||||
|
|
|
|||
316
src/shared/estimate-balance/estimate-balance.ts
Normal file
316
src/shared/estimate-balance/estimate-balance.ts
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* Grobe Leistungsbilanz (LPH 2/3): shared data shapes and pure calculation.
|
||||
*
|
||||
* Used by the API (validation, comparison) and by the frontend (live totals),
|
||||
* so this file has no imports: Turbopack resolves frontend imports without
|
||||
* the `.js` suffix the API build needs.
|
||||
*/
|
||||
|
||||
export type EstimateRowUnit = "piece" | "area";
|
||||
export type EstimateRowConnection = "fixed" | "socket";
|
||||
|
||||
export interface EstimateRowSnapshot {
|
||||
id: string;
|
||||
sortOrder: number;
|
||||
/** Versorgung, e.g. "ELT" or "ISP". */
|
||||
supply: string | null;
|
||||
/** Optional circuit reference, free text. */
|
||||
circuitRef: string | null;
|
||||
/** Verbraucher / Fläche. May be empty while a row is being typed. */
|
||||
name: string;
|
||||
/** Anzahl (unit "piece") or area in m² (unit "area"). */
|
||||
quantity: number;
|
||||
unit: EstimateRowUnit;
|
||||
connection: EstimateRowConnection | null;
|
||||
/** W per piece or W/m². */
|
||||
powerPerUnitW: number;
|
||||
simultaneityFactor: number;
|
||||
cosPhi: number;
|
||||
/** Also needed on the safety power supply (SV). */
|
||||
safetySupply: boolean;
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
export interface EstimateGroupSnapshot {
|
||||
id: string;
|
||||
/** DIN 276 cost group, e.g. "410" or "434"; empty for "Allgemein". */
|
||||
costGroup: string;
|
||||
title: string;
|
||||
/** Trade or person responsible for the entries ("bearbeitet von"). */
|
||||
editor: string | null;
|
||||
sortOrder: number;
|
||||
rows: EstimateRowSnapshot[];
|
||||
}
|
||||
|
||||
export interface EstimateBalanceSnapshot {
|
||||
id: string;
|
||||
projectId: string;
|
||||
/** Stand, e.g. "Vorplanung" or "Entwurfsplanung". */
|
||||
name: string;
|
||||
workStage: string | null;
|
||||
/** ISO date YYYY-MM-DD. */
|
||||
dateIso: string | null;
|
||||
reserveAvPercent: number;
|
||||
reserveSvPercent: number;
|
||||
sortOrder: number;
|
||||
groups: EstimateGroupSnapshot[];
|
||||
}
|
||||
|
||||
/** Default structure of the office template "Leistungsbilanz - KG400". */
|
||||
export const estimateDefaultGroups: ReadonlyArray<{ costGroup: string; title: string }> = [
|
||||
{ costGroup: "410", title: "Sanitärtechnik" },
|
||||
{ costGroup: "420", title: "Wärmeversorgung" },
|
||||
{ costGroup: "430", title: "Lufttechnische Anlagen" },
|
||||
{ costGroup: "434", title: "Kälteanlagen" },
|
||||
{ costGroup: "440", title: "Starkstromanlagen" },
|
||||
{ costGroup: "450", title: "Schwachstromanlagen" },
|
||||
{ costGroup: "460", title: "Fördertechnik" },
|
||||
{ costGroup: "470", title: "Nutzungsspezifische Anlagen" },
|
||||
{ costGroup: "480", title: "Gebäudeautomation" },
|
||||
{ costGroup: "", title: "Allgemein" },
|
||||
];
|
||||
|
||||
export const estimateRowUnitLabels: Record<EstimateRowUnit, string> = {
|
||||
piece: "St.",
|
||||
area: "m²",
|
||||
};
|
||||
|
||||
export const estimateRowConnectionLabels: Record<EstimateRowConnection, string> = {
|
||||
fixed: "Fest",
|
||||
socket: "Steckd.",
|
||||
};
|
||||
|
||||
export interface EstimatePower {
|
||||
kw: number;
|
||||
kva: number;
|
||||
}
|
||||
|
||||
export interface EstimateRowResult {
|
||||
av: EstimatePower;
|
||||
sv: EstimatePower;
|
||||
}
|
||||
|
||||
export interface EstimateGroupResult {
|
||||
groupId: string;
|
||||
av: EstimatePower;
|
||||
sv: EstimatePower;
|
||||
}
|
||||
|
||||
export interface EstimateBalanceResult {
|
||||
groups: EstimateGroupResult[];
|
||||
total: { av: EstimatePower; sv: EstimatePower };
|
||||
reserve: { av: EstimatePower; sv: EstimatePower };
|
||||
withReserve: { av: EstimatePower; sv: EstimatePower };
|
||||
/** Maximum current of the three-phase supply in A (S / (√3 · U)). */
|
||||
maxCurrentA: { av: number; sv: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds a calculated value for display like the spreadsheet does: binary
|
||||
* floating point stores 40.175 as 40.17499…, so the scaled value is first
|
||||
* cut to 6 decimals and only then rounded half away from zero
|
||||
* (40.175 -> 40.18, -1.825 -> -1.83).
|
||||
*/
|
||||
export function roundEstimateValue(value: number, digits = 2): number {
|
||||
const factor = 10 ** digits;
|
||||
const scaled = Number((Math.abs(value) * factor).toFixed(6));
|
||||
return (Math.sign(value) * Math.round(scaled)) / factor;
|
||||
}
|
||||
|
||||
const zeroPower = (): EstimatePower => ({ kw: 0, kva: 0 });
|
||||
|
||||
function addPower(left: EstimatePower, right: EstimatePower): EstimatePower {
|
||||
return { kw: left.kw + right.kw, kva: left.kva + right.kva };
|
||||
}
|
||||
|
||||
function scalePower(power: EstimatePower, factor: number): EstimatePower {
|
||||
return { kw: power.kw * factor, kva: power.kva * factor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Required power of one row: P = quantity · power per unit · GLF, S = P / cos φ.
|
||||
* AV covers every consumer (normal supply); SV additionally lists the rows
|
||||
* that must also be supplied by the safety power supply.
|
||||
*/
|
||||
export function calculateEstimateRow(
|
||||
row: Pick<
|
||||
EstimateRowSnapshot,
|
||||
"quantity" | "powerPerUnitW" | "simultaneityFactor" | "cosPhi" | "safetySupply"
|
||||
>
|
||||
): EstimateRowResult {
|
||||
const kw = (row.quantity * row.powerPerUnitW * row.simultaneityFactor) / 1000;
|
||||
const kva = row.cosPhi > 0 ? kw / row.cosPhi : 0;
|
||||
const av = { kw, kva };
|
||||
return { av, sv: row.safetySupply ? { ...av } : zeroPower() };
|
||||
}
|
||||
|
||||
export function calculateEstimateBalance(
|
||||
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
|
||||
threePhaseVoltageV: number
|
||||
): EstimateBalanceResult {
|
||||
const groups = balance.groups.map((group) =>
|
||||
group.rows.reduce<EstimateGroupResult>(
|
||||
(sum, row) => {
|
||||
const result = calculateEstimateRow(row);
|
||||
return {
|
||||
groupId: group.id,
|
||||
av: addPower(sum.av, result.av),
|
||||
sv: addPower(sum.sv, result.sv),
|
||||
};
|
||||
},
|
||||
{ groupId: group.id, av: zeroPower(), sv: zeroPower() }
|
||||
)
|
||||
);
|
||||
const total = groups.reduce(
|
||||
(sum, group) => ({ av: addPower(sum.av, group.av), sv: addPower(sum.sv, group.sv) }),
|
||||
{ av: zeroPower(), sv: zeroPower() }
|
||||
);
|
||||
const reserve = {
|
||||
av: scalePower(total.av, balance.reserveAvPercent / 100),
|
||||
sv: scalePower(total.sv, balance.reserveSvPercent / 100),
|
||||
};
|
||||
const withReserve = {
|
||||
av: addPower(total.av, reserve.av),
|
||||
sv: addPower(total.sv, reserve.sv),
|
||||
};
|
||||
const currentFactor =
|
||||
threePhaseVoltageV > 0 ? 1000 / (Math.sqrt(3) * threePhaseVoltageV) : 0;
|
||||
return {
|
||||
groups,
|
||||
total,
|
||||
reserve,
|
||||
withReserve,
|
||||
maxCurrentA: {
|
||||
av: withReserve.av.kva * currentFactor,
|
||||
sv: withReserve.sv.kva * currentFactor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns a detailed cost group (e.g. "444" or "440.1") to the best matching
|
||||
* estimate group: a group code with trailing zeros removed is a prefix
|
||||
* ("430" -> "43", "434" -> "434"); the longest prefix wins. Returns null when
|
||||
* no group matches.
|
||||
*/
|
||||
export function matchEstimateCostGroup(
|
||||
detailedCostGroup: string | null,
|
||||
groupCostGroups: readonly string[]
|
||||
): string | null {
|
||||
const digits = (detailedCostGroup ?? "").replace(/\D/g, "");
|
||||
if (!digits) {
|
||||
return null;
|
||||
}
|
||||
let best: { code: string; length: number } | null = null;
|
||||
for (const code of groupCostGroups) {
|
||||
const codeDigits = code.replace(/\D/g, "");
|
||||
const prefix = codeDigits.replace(/0+$/, "");
|
||||
if (!prefix || !digits.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
if (!best || prefix.length > best.length) {
|
||||
best = { code, length: prefix.length };
|
||||
}
|
||||
}
|
||||
return best?.code ?? null;
|
||||
}
|
||||
|
||||
export interface EstimateComparisonGroup {
|
||||
groupId: string;
|
||||
costGroup: string;
|
||||
title: string;
|
||||
estimatedAv: EstimatePower;
|
||||
detailed: EstimatePower;
|
||||
detailedRowCount: number;
|
||||
deltaKw: number;
|
||||
/** Relative deviation of the detailed value from the estimate, null without estimate. */
|
||||
deltaPercent: number | null;
|
||||
}
|
||||
|
||||
export interface EstimateComparison {
|
||||
groups: EstimateComparisonGroup[];
|
||||
unassigned: EstimatePower & { rowCount: number };
|
||||
total: {
|
||||
estimatedAv: EstimatePower;
|
||||
detailed: EstimatePower;
|
||||
deltaKw: number;
|
||||
deltaPercent: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface DetailedDeviceRowPower {
|
||||
costGroup: string | null;
|
||||
/** Total power of the device row in kW (quantity · power · device GZF). */
|
||||
kw: number;
|
||||
cosPhi: number | null;
|
||||
}
|
||||
|
||||
function delta(estimated: number, detailed: number) {
|
||||
return {
|
||||
deltaKw: detailed - estimated,
|
||||
deltaPercent: estimated > 0 ? ((detailed - estimated) / estimated) * 100 : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the estimate per cost group with the detailed device rows of all
|
||||
* circuit lists. The distribution-board simultaneity factor is not applied
|
||||
* on either side; a missing cos φ counts as 1.
|
||||
*/
|
||||
export function compareEstimateWithDetailed(
|
||||
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
|
||||
detailedRows: readonly DetailedDeviceRowPower[]
|
||||
): EstimateComparison {
|
||||
const estimate = calculateEstimateBalance(balance, 0);
|
||||
const codes = balance.groups.map((group) => group.costGroup).filter(Boolean);
|
||||
const detailedByCode = new Map<string, EstimatePower & { rowCount: number }>();
|
||||
const unassigned = { kw: 0, kva: 0, rowCount: 0 };
|
||||
for (const row of detailedRows) {
|
||||
const kva = row.cosPhi && row.cosPhi > 0 ? row.kw / row.cosPhi : row.kw;
|
||||
const code = matchEstimateCostGroup(row.costGroup, codes);
|
||||
const target =
|
||||
code === null
|
||||
? unassigned
|
||||
: detailedByCode.get(code) ??
|
||||
detailedByCode.set(code, { kw: 0, kva: 0, rowCount: 0 }).get(code)!;
|
||||
target.kw += row.kw;
|
||||
target.kva += kva;
|
||||
target.rowCount += 1;
|
||||
}
|
||||
// A cost group code can appear in several groups; the detailed value is
|
||||
// shown once, on the first group with that code.
|
||||
const shownCodes = new Set<string>();
|
||||
const groups = balance.groups.map((group, index) => {
|
||||
const estimatedAv = estimate.groups[index]!.av;
|
||||
const detailedEntry =
|
||||
group.costGroup && !shownCodes.has(group.costGroup)
|
||||
? detailedByCode.get(group.costGroup)
|
||||
: undefined;
|
||||
if (group.costGroup) {
|
||||
shownCodes.add(group.costGroup);
|
||||
}
|
||||
const detailed = { kw: detailedEntry?.kw ?? 0, kva: detailedEntry?.kva ?? 0 };
|
||||
return {
|
||||
groupId: group.id,
|
||||
costGroup: group.costGroup,
|
||||
title: group.title,
|
||||
estimatedAv,
|
||||
detailed,
|
||||
detailedRowCount: detailedEntry?.rowCount ?? 0,
|
||||
...delta(estimatedAv.kw, detailed.kw),
|
||||
};
|
||||
});
|
||||
const detailedTotal = groups.reduce(
|
||||
(sum, group) => addPower(sum, group.detailed),
|
||||
{ kw: unassigned.kw, kva: unassigned.kva }
|
||||
);
|
||||
return {
|
||||
groups,
|
||||
unassigned,
|
||||
total: {
|
||||
estimatedAv: estimate.total.av,
|
||||
detailed: detailedTotal,
|
||||
...delta(estimate.total.av.kw, detailedTotal.kw),
|
||||
},
|
||||
};
|
||||
}
|
||||
435
tests/estimate-balance-frontend.test.ts
Normal file
435
tests/estimate-balance-frontend.test.ts
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { EstimateBalanceSnapshot } from "../src/shared/estimate-balance/estimate-balance.js";
|
||||
import {
|
||||
addEstimateGroup,
|
||||
addEstimateRow,
|
||||
createCopyName,
|
||||
createEstimateRow,
|
||||
estimateBalanceFingerprint,
|
||||
moveEstimateGroup,
|
||||
moveEstimateRow,
|
||||
normalizeEstimateSortOrder,
|
||||
removeEstimateGroup,
|
||||
removeEstimateRow,
|
||||
toSaveableEstimateBalance,
|
||||
updateEstimateGroup,
|
||||
updateEstimateRow,
|
||||
validateEstimateBalance,
|
||||
} from "../src/frontend/estimate-balance/estimate-balance-document.js";
|
||||
import {
|
||||
estimateNumberRanges,
|
||||
formatAmpere,
|
||||
formatDecimalInput,
|
||||
formatDeltaPercent,
|
||||
formatIsoDateGerman,
|
||||
formatKw,
|
||||
formatSignedKw,
|
||||
getTodayIsoDate,
|
||||
parseGermanDecimal,
|
||||
} from "../src/frontend/estimate-balance/estimate-balance-format.js";
|
||||
import {
|
||||
ApiRequestError,
|
||||
createEstimateBalance,
|
||||
deleteEstimateBalance,
|
||||
getEstimateBalance,
|
||||
getEstimateComparison,
|
||||
isRevisionConflictError,
|
||||
listEstimateBalances,
|
||||
updateEstimateBalance,
|
||||
} from "../src/frontend/utils/api.js";
|
||||
|
||||
function sequentialIds(prefix = "id") {
|
||||
let next = 0;
|
||||
return () => `${prefix}-${++next}`;
|
||||
}
|
||||
|
||||
function sampleBalance(): EstimateBalanceSnapshot {
|
||||
return {
|
||||
id: "balance-1",
|
||||
projectId: "project-1",
|
||||
name: "Entwurfsplanung",
|
||||
workStage: "LPH 3",
|
||||
dateIso: "2026-09-26",
|
||||
reserveAvPercent: 20,
|
||||
reserveSvPercent: 10,
|
||||
sortOrder: 0,
|
||||
groups: [
|
||||
{
|
||||
id: "group-410",
|
||||
costGroup: "410",
|
||||
title: "Sanitärtechnik",
|
||||
editor: null,
|
||||
sortOrder: 0,
|
||||
rows: [
|
||||
{ ...createEstimateRow(0, () => "row-a"), name: "Hebeanlage", powerPerUnitW: 1200 },
|
||||
{ ...createEstimateRow(1, () => "row-b"), name: "Durchlauferhitzer" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "group-440",
|
||||
costGroup: "440",
|
||||
title: "Starkstromanlagen",
|
||||
editor: "ELT",
|
||||
sortOrder: 1,
|
||||
rows: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("estimate balance number input", () => {
|
||||
it("accepts a comma as decimal separator", () => {
|
||||
assert.deepEqual(parseGermanDecimal("0,8"), { ok: true, value: 0.8 });
|
||||
assert.deepEqual(parseGermanDecimal(" 1200 "), { ok: true, value: 1200 });
|
||||
assert.deepEqual(parseGermanDecimal(",5"), { ok: true, value: 0.5 });
|
||||
assert.deepEqual(parseGermanDecimal("3,"), { ok: true, value: 3 });
|
||||
});
|
||||
|
||||
it("rejects dots, empty input and garbage", () => {
|
||||
for (const text of ["0.8", "1.200", "", " ", "abc", "1,2,3", "1e3", "--1"]) {
|
||||
assert.equal(parseGermanDecimal(text).ok, false, text);
|
||||
}
|
||||
const dot = parseGermanDecimal("0.8");
|
||||
assert.equal(dot.ok === false && dot.error.includes("Komma"), true);
|
||||
});
|
||||
|
||||
it("checks the server ranges of the document fields", () => {
|
||||
assert.equal(parseGermanDecimal("-1", estimateNumberRanges.quantity).ok, false);
|
||||
assert.equal(parseGermanDecimal("0", estimateNumberRanges.quantity).ok, true);
|
||||
assert.equal(parseGermanDecimal("1,1", estimateNumberRanges.simultaneityFactor).ok, false);
|
||||
assert.equal(parseGermanDecimal("1", estimateNumberRanges.simultaneityFactor).ok, true);
|
||||
assert.equal(parseGermanDecimal("0", estimateNumberRanges.cosPhi).ok, false);
|
||||
assert.equal(parseGermanDecimal("0,01", estimateNumberRanges.cosPhi).ok, true);
|
||||
assert.equal(parseGermanDecimal("101", estimateNumberRanges.reservePercent).ok, false);
|
||||
});
|
||||
|
||||
it("formats input text so it parses back unchanged", () => {
|
||||
assert.equal(formatDecimalInput(1200), "1200");
|
||||
assert.equal(formatDecimalInput(0.8), "0,8");
|
||||
const parsed = parseGermanDecimal(formatDecimalInput(12345.678));
|
||||
assert.deepEqual(parsed, { ok: true, value: 12345.678 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimate balance display format", () => {
|
||||
it("rounds half away from zero like Excel", () => {
|
||||
assert.equal(formatKw(40.175), "40,18");
|
||||
assert.equal(formatKw(30.24), "30,24");
|
||||
assert.equal(formatKw(1234.5), "1.234,50");
|
||||
assert.equal(formatKw(-0.001), "0,00");
|
||||
assert.equal(formatAmpere(57.75), "57,75");
|
||||
assert.equal(formatAmpere(235.749), "235,75");
|
||||
});
|
||||
|
||||
it("shows signed deltas and percentages", () => {
|
||||
assert.equal(formatSignedKw(1.5), "+1,50");
|
||||
assert.equal(formatSignedKw(-1.825), "-1,83");
|
||||
assert.equal(formatSignedKw(0.001), "0,00");
|
||||
assert.equal(formatDeltaPercent(12.34), "+12,3 %");
|
||||
assert.equal(formatDeltaPercent(-5), "-5,0 %");
|
||||
assert.equal(formatDeltaPercent(null), "–");
|
||||
});
|
||||
|
||||
it("formats dates", () => {
|
||||
assert.equal(formatIsoDateGerman("2026-09-26"), "26.09.2026");
|
||||
assert.equal(formatIsoDateGerman(null), "–");
|
||||
assert.equal(getTodayIsoDate(new Date(2026, 0, 5, 23, 30)), "2026-01-05");
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimate balance document editing", () => {
|
||||
it("adds rows at the end or after a row with continuous sort orders", () => {
|
||||
const createId = sequentialIds("new");
|
||||
const appended = addEstimateRow(sampleBalance(), "group-410", { createId });
|
||||
assert.equal(appended.rowId, "new-1");
|
||||
assert.deepEqual(
|
||||
appended.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
||||
[["row-a", 0], ["row-b", 1], ["new-1", 2]]
|
||||
);
|
||||
const inserted = addEstimateRow(sampleBalance(), "group-410", {
|
||||
afterRowId: "row-a",
|
||||
createId,
|
||||
});
|
||||
assert.deepEqual(
|
||||
inserted.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
||||
[["row-a", 0], ["new-2", 1], ["row-b", 2]]
|
||||
);
|
||||
const unknownGroup = addEstimateRow(sampleBalance(), "missing", { createId });
|
||||
assert.equal(unknownGroup.rowId, null);
|
||||
});
|
||||
|
||||
it("creates rows with neutral defaults", () => {
|
||||
const row = createEstimateRow(3, () => "row-x");
|
||||
assert.deepEqual(row, {
|
||||
id: "row-x",
|
||||
sortOrder: 3,
|
||||
supply: null,
|
||||
circuitRef: null,
|
||||
name: "",
|
||||
quantity: 1,
|
||||
unit: "piece",
|
||||
connection: null,
|
||||
powerPerUnitW: 0,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 1,
|
||||
safetySupply: false,
|
||||
remark: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates, removes and moves rows without touching the input", () => {
|
||||
const original = sampleBalance();
|
||||
const updated = updateEstimateRow(original, "group-410", "row-b", {
|
||||
quantity: 3,
|
||||
safetySupply: true,
|
||||
});
|
||||
assert.equal(updated.groups[0]!.rows[1]!.quantity, 3);
|
||||
assert.equal(updated.groups[0]!.rows[1]!.safetySupply, true);
|
||||
assert.equal(original.groups[0]!.rows[1]!.quantity, 1);
|
||||
|
||||
const removed = removeEstimateRow(original, "group-410", "row-a");
|
||||
assert.deepEqual(
|
||||
removed.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
||||
[["row-b", 0]]
|
||||
);
|
||||
|
||||
const moved = moveEstimateRow(original, "group-410", "row-b", -1);
|
||||
assert.deepEqual(
|
||||
moved.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
||||
[["row-b", 0], ["row-a", 1]]
|
||||
);
|
||||
assert.equal(moveEstimateRow(original, "group-410", "row-a", -1), original);
|
||||
});
|
||||
|
||||
it("adds, edits, moves and removes groups", () => {
|
||||
const added = addEstimateGroup(
|
||||
sampleBalance(),
|
||||
{ costGroup: "480", title: "Gebäudeautomation" },
|
||||
() => "group-480"
|
||||
);
|
||||
assert.equal(added.groupId, "group-480");
|
||||
assert.deepEqual(
|
||||
added.balance.groups.map((group) => [group.id, group.sortOrder]),
|
||||
[["group-410", 0], ["group-440", 1], ["group-480", 2]]
|
||||
);
|
||||
assert.equal(added.balance.groups[2]!.editor, null);
|
||||
|
||||
const edited = updateEstimateGroup(added.balance, "group-480", {
|
||||
costGroup: "481",
|
||||
editor: "MSR",
|
||||
});
|
||||
assert.equal(edited.groups[2]!.costGroup, "481");
|
||||
assert.equal(edited.groups[2]!.title, "Gebäudeautomation");
|
||||
|
||||
const movedUp = moveEstimateGroup(edited, "group-480", -1);
|
||||
assert.deepEqual(
|
||||
movedUp.groups.map((group) => [group.id, group.sortOrder]),
|
||||
[["group-410", 0], ["group-480", 1], ["group-440", 2]]
|
||||
);
|
||||
assert.equal(moveEstimateGroup(movedUp, "group-440", 1), movedUp);
|
||||
|
||||
const removed = removeEstimateGroup(movedUp, "group-410");
|
||||
assert.deepEqual(
|
||||
removed.groups.map((group) => [group.id, group.sortOrder]),
|
||||
[["group-480", 0], ["group-440", 1]]
|
||||
);
|
||||
assert.equal(removeEstimateGroup(removed, "missing"), removed);
|
||||
});
|
||||
|
||||
it("orders loaded documents by sortOrder", () => {
|
||||
const balance = sampleBalance();
|
||||
const shuffled: EstimateBalanceSnapshot = {
|
||||
...balance,
|
||||
groups: [
|
||||
{ ...balance.groups[1]!, sortOrder: 7 },
|
||||
{
|
||||
...balance.groups[0]!,
|
||||
sortOrder: 2,
|
||||
rows: [
|
||||
{ ...balance.groups[0]!.rows[1]!, sortOrder: 5 },
|
||||
{ ...balance.groups[0]!.rows[0]!, sortOrder: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const normalized = normalizeEstimateSortOrder(shuffled);
|
||||
assert.deepEqual(
|
||||
normalized.groups.map((group) => [group.id, group.sortOrder]),
|
||||
[["group-410", 0], ["group-440", 1]]
|
||||
);
|
||||
assert.deepEqual(
|
||||
normalized.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
||||
[["row-a", 0], ["row-b", 1]]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimate balance saving", () => {
|
||||
it("builds the trimmed server document with null for empty optional texts", () => {
|
||||
let balance = sampleBalance();
|
||||
balance = { ...balance, name: " Entwurf ", workStage: " ", dateIso: "" };
|
||||
balance = updateEstimateGroup(balance, "group-440", { title: " Starkstrom ", editor: " " });
|
||||
balance = updateEstimateRow(balance, "group-410", "row-a", {
|
||||
name: " Hebeanlage ",
|
||||
supply: " ELT ",
|
||||
circuitRef: "",
|
||||
remark: " ",
|
||||
});
|
||||
const saveable = toSaveableEstimateBalance(balance);
|
||||
assert.equal(saveable.name, "Entwurf");
|
||||
assert.equal(saveable.workStage, null);
|
||||
assert.equal(saveable.dateIso, null);
|
||||
assert.equal(saveable.groups[1]!.title, "Starkstrom");
|
||||
assert.equal(saveable.groups[1]!.editor, null);
|
||||
assert.deepEqual(
|
||||
{
|
||||
name: saveable.groups[0]!.rows[0]!.name,
|
||||
supply: saveable.groups[0]!.rows[0]!.supply,
|
||||
circuitRef: saveable.groups[0]!.rows[0]!.circuitRef,
|
||||
remark: saveable.groups[0]!.rows[0]!.remark,
|
||||
},
|
||||
{ name: "Hebeanlage", supply: "ELT", circuitRef: null, remark: null }
|
||||
);
|
||||
});
|
||||
|
||||
it("copies only snapshot fields", () => {
|
||||
const balance = sampleBalance() as EstimateBalanceSnapshot & { uiState?: string };
|
||||
balance.uiState = "leak";
|
||||
assert.equal("uiState" in toSaveableEstimateBalance(balance), false);
|
||||
});
|
||||
|
||||
it("treats whitespace-only changes as unchanged", () => {
|
||||
const balance = sampleBalance();
|
||||
const withSpace = updateEstimateRow(balance, "group-410", "row-a", {
|
||||
name: "Hebeanlage ",
|
||||
});
|
||||
assert.equal(estimateBalanceFingerprint(withSpace), estimateBalanceFingerprint(balance));
|
||||
const changed = updateEstimateRow(balance, "group-410", "row-a", { quantity: 2 });
|
||||
assert.notEqual(estimateBalanceFingerprint(changed), estimateBalanceFingerprint(balance));
|
||||
});
|
||||
|
||||
it("reports documents the server would reject", () => {
|
||||
assert.deepEqual(validateEstimateBalance(sampleBalance()), []);
|
||||
const invalid = updateEstimateGroup(
|
||||
{ ...sampleBalance(), name: " " },
|
||||
"group-440",
|
||||
{ title: " " }
|
||||
);
|
||||
assert.equal(validateEstimateBalance(invalid).length, 2);
|
||||
});
|
||||
|
||||
it("names copies", () => {
|
||||
assert.equal(createCopyName("Entwurfsplanung "), "Entwurfsplanung (Kopie)");
|
||||
assert.equal(createCopyName("x".repeat(200)).length, 200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimate balance frontend API", () => {
|
||||
it("calls the estimate balance endpoints", async () => {
|
||||
const requests: Array<{ url: string; method: string; body: unknown; keepalive?: boolean }> =
|
||||
[];
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (input, init) => {
|
||||
requests.push({
|
||||
url: String(input),
|
||||
method: init?.method ?? "GET",
|
||||
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
|
||||
...(init?.keepalive ? { keepalive: true } : {}),
|
||||
});
|
||||
return new Response(JSON.stringify({}), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
try {
|
||||
const balance = sampleBalance();
|
||||
await listEstimateBalances("project-1");
|
||||
await getEstimateBalance("project-1", "balance-1");
|
||||
await createEstimateBalance(
|
||||
"project-1",
|
||||
{ name: "Vorplanung", workStage: null, dateIso: "2026-09-26", template: "kg400" },
|
||||
4
|
||||
);
|
||||
await createEstimateBalance(
|
||||
"project-1",
|
||||
{ name: "Kopie", template: "copy", copyFromBalanceId: "balance-1" },
|
||||
5
|
||||
);
|
||||
await updateEstimateBalance("project-1", balance, 6);
|
||||
await updateEstimateBalance("project-1", balance, 7, { keepalive: true });
|
||||
await deleteEstimateBalance("project-1", "balance-1", 8);
|
||||
await getEstimateComparison("project-1", "balance-1");
|
||||
const base = "/api/projects/project-1/estimate-balances";
|
||||
assert.deepEqual(requests, [
|
||||
{ url: base, method: "GET", body: undefined },
|
||||
{ url: `${base}/balance-1`, method: "GET", body: undefined },
|
||||
{
|
||||
url: base,
|
||||
method: "POST",
|
||||
body: {
|
||||
name: "Vorplanung",
|
||||
workStage: null,
|
||||
dateIso: "2026-09-26",
|
||||
template: "kg400",
|
||||
expectedRevision: 4,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: base,
|
||||
method: "POST",
|
||||
body: {
|
||||
name: "Kopie",
|
||||
template: "copy",
|
||||
copyFromBalanceId: "balance-1",
|
||||
expectedRevision: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
url: `${base}/balance-1`,
|
||||
method: "PUT",
|
||||
body: { expectedRevision: 6, balance },
|
||||
},
|
||||
{
|
||||
url: `${base}/balance-1`,
|
||||
method: "PUT",
|
||||
body: { expectedRevision: 7, balance },
|
||||
keepalive: true,
|
||||
},
|
||||
{ url: `${base}/balance-1`, method: "DELETE", body: { expectedRevision: 8 } },
|
||||
{ url: `${base}/balance-1/comparison`, method: "GET", body: undefined },
|
||||
]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
it("exposes status and code of failed requests", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: "Projektstand veraltet.",
|
||||
code: "PROJECT_REVISION_CONFLICT",
|
||||
}),
|
||||
{ status: 409, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
try {
|
||||
await assert.rejects(
|
||||
updateEstimateBalance("project-1", sampleBalance(), 1),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiRequestError);
|
||||
assert.equal(error.message, "Projektstand veraltet.");
|
||||
assert.equal(error.status, 409);
|
||||
assert.equal(error.code, "PROJECT_REVISION_CONFLICT");
|
||||
assert.equal(isRevisionConflictError(error), true);
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
assert.equal(isRevisionConflictError(new ApiRequestError("x", 400)), false);
|
||||
assert.equal(isRevisionConflictError(new Error("x")), false);
|
||||
});
|
||||
});
|
||||
459
tests/estimate-balance.test.ts
Normal file
459
tests/estimate-balance.test.ts
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { EstimateBalanceProjectCommandRepository } from "../src/db/repositories/estimate-balance-project-command.repository.js";
|
||||
import { EstimateBalanceRepository } from "../src/db/repositories/estimate-balance.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||
import { estimateRows } from "../src/db/schema/estimate-balances.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import {
|
||||
createEstimateBalanceDeleteProjectCommand,
|
||||
createEstimateBalanceInsertProjectCommand,
|
||||
createEstimateBalanceSnapshot,
|
||||
createEstimateBalanceUpdateProjectCommand,
|
||||
parseEstimateBalanceSnapshot,
|
||||
type EstimateBalanceProjectCommand,
|
||||
} from "../src/estimate-balance/domain/estimate-balance-command.model.js";
|
||||
import { createEstimateBalanceRequestSchema } from "../src/estimate-balance/api/estimate-balance-request.schemas.js";
|
||||
import {
|
||||
calculateEstimateBalance,
|
||||
calculateEstimateRow,
|
||||
compareEstimateWithDetailed,
|
||||
estimateDefaultGroups,
|
||||
matchEstimateCostGroup,
|
||||
roundEstimateValue,
|
||||
type EstimateBalanceSnapshot,
|
||||
type EstimateRowSnapshot,
|
||||
} from "../src/shared/estimate-balance/estimate-balance.js";
|
||||
|
||||
const round2 = (value: number) => roundEstimateValue(value, 2);
|
||||
let nextId = 0;
|
||||
const id = () => `id-${++nextId}`;
|
||||
|
||||
function row(
|
||||
name: string,
|
||||
quantity: number,
|
||||
powerPerUnitW: number,
|
||||
simultaneityFactor: number,
|
||||
cosPhi: number,
|
||||
extra: Partial<EstimateRowSnapshot> = {}
|
||||
): EstimateRowSnapshot {
|
||||
return {
|
||||
id: id(),
|
||||
sortOrder: 0,
|
||||
supply: "ELT",
|
||||
circuitRef: null,
|
||||
name,
|
||||
quantity,
|
||||
unit: "piece",
|
||||
connection: null,
|
||||
powerPerUnitW,
|
||||
simultaneityFactor,
|
||||
cosPhi,
|
||||
safetySupply: false,
|
||||
remark: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** All rows of the office example "Leistungsbilanz - KG400" (Entwurfsplanung). */
|
||||
function templateExample(): EstimateBalanceSnapshot {
|
||||
const rowsByGroup: Record<string, EstimateRowSnapshot[]> = {
|
||||
"410": [
|
||||
row("Durchlauferhitzer 11kW", 9, 11200, 0.3, 1),
|
||||
row("Durchlauferhitzer 4,4kW", 4, 4400, 0.3, 1),
|
||||
],
|
||||
"420": [
|
||||
row("Fernwärmestation", 1, 1000, 0.8, 1),
|
||||
row("Pumpen", 4, 200, 0.8, 0.9),
|
||||
row("Schaltschrank Heizung", 1, 2000, 0.8, 1),
|
||||
],
|
||||
"430": [
|
||||
row("Lüftungsgerät", 1, 6000, 0.8, 0.9),
|
||||
row("Schaltschrank Lüftung", 1, 2000, 0.8, 0.9),
|
||||
],
|
||||
"434": [
|
||||
row("Kälteaggregat Dach", 1, 1000, 0.8, 1),
|
||||
row("Serverraum Kühlung Aggregat Dach", 1, 2000, 0.8, 1),
|
||||
row("Serverraum Innengerät", 1, 1000, 0.8, 1),
|
||||
],
|
||||
"440": [
|
||||
row("Beleuchtungsanlage Klassenzimmer", 17, 500, 0.8, 0.95),
|
||||
row("Beleuchtung Allgemein", 50, 45, 0.7, 0.95),
|
||||
row("Außenbeleuchtung", 10, 100, 0.4, 0.95),
|
||||
row("Fassadenbeleuchtung", 20, 60, 0.5, 0.95),
|
||||
row("Beleuchtung MFR", 15, 50, 0.8, 0.95),
|
||||
row("Steckdosen Klassenzimmer", 170, 300, 0.2, 0.95),
|
||||
row("Steckdosen 230 V allg. Verbr.", 100, 500, 0.2, 0.95),
|
||||
row("Veranstaltungstechnik", 1, 4000, 0.5, 0.95),
|
||||
row("Küchentechnische Anlagen", 1, 10000, 0.4, 0.8),
|
||||
row("Sicherheitsbeleuchtung Zentrale", 1, 2000, 0.8, 0.9),
|
||||
row("Spezielle Experimentieranlagen", 1, 6000, 0.4, 0.9),
|
||||
],
|
||||
"450": [row("EDV Systeme", 5, 3000, 0.8, 0.95)],
|
||||
"460": [row("Aufzug", 1, 16000, 0.7, 0.95)],
|
||||
"470": [row("E-Mobility Vorbereitung", 9, 11000, 0.3, 1)],
|
||||
"480": [],
|
||||
"": [row("Allgemein", 1, 2000, 1, 0.95)],
|
||||
};
|
||||
return {
|
||||
id: "balance-1",
|
||||
projectId: "project-1",
|
||||
name: "Entwurfsplanung",
|
||||
workStage: "LPH 3",
|
||||
dateIso: "2023-06-30",
|
||||
reserveAvPercent: 10,
|
||||
reserveSvPercent: 0,
|
||||
sortOrder: 0,
|
||||
groups: estimateDefaultGroups.map((group, index) => ({
|
||||
id: `group-${group.costGroup || "allg"}`,
|
||||
costGroup: group.costGroup,
|
||||
title: group.title,
|
||||
editor: null,
|
||||
sortOrder: index,
|
||||
rows: rowsByGroup[group.costGroup] ?? [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
||||
context.db
|
||||
.insert(projects)
|
||||
.values([
|
||||
{ id: "project-1", name: "Test project" },
|
||||
{ id: "project-2", name: "Foreign project" },
|
||||
])
|
||||
.run();
|
||||
return context;
|
||||
}
|
||||
|
||||
describe("estimate balance calculation", () => {
|
||||
it("reproduces the office template totals", () => {
|
||||
const result = calculateEstimateBalance(templateExample(), 400);
|
||||
const groupTotals = result.groups.map((group) => [
|
||||
round2(group.av.kw),
|
||||
round2(group.av.kva),
|
||||
]);
|
||||
assert.deepEqual(groupTotals, [
|
||||
[35.52, 35.52],
|
||||
[3.04, 3.11],
|
||||
[6.4, 7.11],
|
||||
[3.2, 3.2],
|
||||
[40.18, 43.31],
|
||||
[12, 12.63],
|
||||
[11.2, 11.79],
|
||||
[29.7, 29.7],
|
||||
[0, 0],
|
||||
[2, 2.11],
|
||||
]);
|
||||
assert.equal(round2(result.total.av.kw), 143.24);
|
||||
assert.equal(round2(result.total.av.kva), 148.48);
|
||||
assert.equal(round2(result.reserve.av.kw), 14.32);
|
||||
assert.equal(round2(result.reserve.av.kva), 14.85);
|
||||
assert.equal(round2(result.withReserve.av.kw), 157.56);
|
||||
assert.equal(round2(result.withReserve.av.kva), 163.33);
|
||||
assert.equal(round2(result.maxCurrentA.av), 235.75);
|
||||
assert.equal(result.total.sv.kw, 0);
|
||||
assert.equal(result.maxCurrentA.sv, 0);
|
||||
});
|
||||
|
||||
it("counts safety-supply rows in AV and additionally in SV, also per m²", () => {
|
||||
const area = calculateEstimateRow({
|
||||
quantity: 250,
|
||||
powerPerUnitW: 12,
|
||||
simultaneityFactor: 0.8,
|
||||
cosPhi: 0.95,
|
||||
safetySupply: true,
|
||||
});
|
||||
assert.equal(round2(area.av.kw), 2.4);
|
||||
assert.equal(round2(area.av.kva), 2.53);
|
||||
assert.deepEqual(area.sv, area.av);
|
||||
const balance = templateExample();
|
||||
balance.reserveSvPercent = 20;
|
||||
balance.groups[4]!.rows[9]!.safetySupply = true; // Sicherheitsbeleuchtung Zentrale
|
||||
const result = calculateEstimateBalance(balance, 400);
|
||||
assert.equal(round2(result.total.sv.kw), 1.6);
|
||||
assert.equal(round2(result.withReserve.sv.kw), 1.92);
|
||||
assert.equal(round2(result.total.av.kw), 143.24);
|
||||
});
|
||||
|
||||
it("rounds displayed values like the spreadsheet", () => {
|
||||
assert.equal(roundEstimateValue(40.175), 40.18);
|
||||
assert.equal(roundEstimateValue(143.235), 143.24);
|
||||
assert.equal(roundEstimateValue(-1.825), -1.83);
|
||||
assert.equal(roundEstimateValue(0.711111), 0.71);
|
||||
assert.equal(roundEstimateValue(235.749, 1), 235.7);
|
||||
});
|
||||
|
||||
it("matches detailed cost groups to the most specific estimate group", () => {
|
||||
const codes = ["410", "430", "434", "440", "480", ""];
|
||||
assert.equal(matchEstimateCostGroup("444", codes), "440");
|
||||
assert.equal(matchEstimateCostGroup("434", codes), "434");
|
||||
assert.equal(matchEstimateCostGroup("431", codes), "430");
|
||||
assert.equal(matchEstimateCostGroup("KG 481.1", codes), "480");
|
||||
assert.equal(matchEstimateCostGroup("4", codes), null);
|
||||
assert.equal(matchEstimateCostGroup("", codes), null);
|
||||
assert.equal(matchEstimateCostGroup(null, codes), null);
|
||||
assert.equal(matchEstimateCostGroup("450", codes), null);
|
||||
});
|
||||
|
||||
it("compares the estimate with detailed device rows per cost group", () => {
|
||||
const comparison = compareEstimateWithDetailed(templateExample(), [
|
||||
{ costGroup: "444", kw: 30, cosPhi: 0.9 },
|
||||
{ costGroup: "445", kw: 12, cosPhi: null },
|
||||
{ costGroup: "412", kw: 40, cosPhi: 1 },
|
||||
{ costGroup: null, kw: 5, cosPhi: 1 },
|
||||
{ costGroup: "399", kw: 1, cosPhi: 1 },
|
||||
]);
|
||||
const kg440 = comparison.groups.find((group) => group.costGroup === "440")!;
|
||||
assert.equal(round2(kg440.detailed.kw), 42);
|
||||
assert.equal(round2(kg440.detailed.kva), 45.33);
|
||||
assert.equal(kg440.detailedRowCount, 2);
|
||||
assert.ok(Math.abs(kg440.deltaKw - 1.825) < 1e-9);
|
||||
assert.equal(round2(kg440.deltaPercent!), 4.54);
|
||||
const kg410 = comparison.groups.find((group) => group.costGroup === "410")!;
|
||||
assert.equal(round2(kg410.deltaKw), 4.48);
|
||||
const kg480 = comparison.groups.find((group) => group.costGroup === "480")!;
|
||||
assert.equal(kg480.deltaPercent, null);
|
||||
assert.deepEqual(comparison.unassigned, { kw: 6, kva: 6, rowCount: 2 });
|
||||
assert.equal(round2(comparison.total.detailed.kw), 88);
|
||||
assert.equal(round2(comparison.total.estimatedAv.kw), 143.24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimate balance snapshots and commands", () => {
|
||||
it("validates snapshots strictly", () => {
|
||||
const valid = templateExample();
|
||||
assert.equal(parseEstimateBalanceSnapshot(valid).groups[4]!.rows[3]!.sortOrder, 3);
|
||||
const invalid: Array<(balance: EstimateBalanceSnapshot) => void> = [
|
||||
(balance) => (balance.groups[0]!.rows[0]!.cosPhi = 0),
|
||||
(balance) => (balance.groups[0]!.rows[0]!.simultaneityFactor = 1.5),
|
||||
(balance) => (balance.groups[0]!.rows[0]!.quantity = -1),
|
||||
(balance) => (balance.groups[0]!.rows[0]!.name = " Leerzeichen "),
|
||||
(balance) => (balance.groups[0]!.rows[0]!.supply = ""),
|
||||
(balance) => (balance.groups[0]!.title = ""),
|
||||
(balance) => (balance.dateIso = "30.06.2023"),
|
||||
(balance) => (balance.reserveAvPercent = 101),
|
||||
(balance) => (balance.groups[1]!.rows[0]!.id = balance.groups[0]!.rows[0]!.id),
|
||||
(balance) => ((balance.groups[0]!.rows[0] as unknown as Record<string, unknown>).extra = 1),
|
||||
];
|
||||
for (const mutate of invalid) {
|
||||
const balance = structuredClone(valid);
|
||||
mutate(balance);
|
||||
assert.throws(() => parseEstimateBalanceSnapshot(balance));
|
||||
}
|
||||
assert.equal(
|
||||
createEstimateBalanceRequestSchema.safeParse({
|
||||
expectedRevision: 0,
|
||||
name: "Entwurf",
|
||||
template: "copy",
|
||||
}).success,
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the KG 400 template and copies a Stand with new ids", () => {
|
||||
const created = createEstimateBalanceSnapshot({
|
||||
projectId: "project-1",
|
||||
name: " Vorplanung ",
|
||||
workStage: "LPH 2",
|
||||
dateIso: "2026-09-26",
|
||||
sortOrder: 0,
|
||||
template: "kg400",
|
||||
});
|
||||
assert.equal(created.name, "Vorplanung");
|
||||
assert.deepEqual(
|
||||
created.groups.map((group) => group.costGroup),
|
||||
estimateDefaultGroups.map((group) => group.costGroup)
|
||||
);
|
||||
assert.equal(created.reserveAvPercent, 10);
|
||||
const source = templateExample();
|
||||
const copy = createEstimateBalanceSnapshot({
|
||||
projectId: "project-1",
|
||||
name: "Entwurfsplanung Kopie",
|
||||
workStage: null,
|
||||
dateIso: null,
|
||||
sortOrder: 1,
|
||||
template: { copyFrom: source },
|
||||
});
|
||||
assert.notEqual(copy.id, source.id);
|
||||
assert.notEqual(copy.groups[0]!.id, source.groups[0]!.id);
|
||||
assert.notEqual(copy.groups[0]!.rows[0]!.id, source.groups[0]!.rows[0]!.id);
|
||||
assert.equal(
|
||||
round2(calculateEstimateBalance(copy, 400).total.av.kw),
|
||||
143.24
|
||||
);
|
||||
});
|
||||
|
||||
it("inserts, updates and deletes balances with persisted undo and redo", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const store = new EstimateBalanceProjectCommandRepository(context.db);
|
||||
const reader = new EstimateBalanceRepository(context.db);
|
||||
const history = new ProjectHistoryRepository(context.db);
|
||||
let revision = 0;
|
||||
const run = (
|
||||
command: EstimateBalanceProjectCommand,
|
||||
source: "user" | "undo" | "redo" = "user"
|
||||
) => {
|
||||
const step = source === "user" ? null : history.getNextCommand("project-1", source);
|
||||
const result = store.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: revision,
|
||||
source,
|
||||
historyTargetChangeSetId: step?.changeSetId,
|
||||
command,
|
||||
});
|
||||
revision += 1;
|
||||
return result;
|
||||
};
|
||||
|
||||
const balance = templateExample();
|
||||
const insert = createEstimateBalanceInsertProjectCommand(balance);
|
||||
const inserted = run(insert);
|
||||
const stored = reader.findById("project-1", balance.id);
|
||||
assert.deepEqual(stored, parseEstimateBalanceSnapshot(balance));
|
||||
assert.equal(reader.listByProject("project-2").length, 0);
|
||||
|
||||
const target = structuredClone(stored!);
|
||||
target.groups[0]!.rows.splice(1, 1);
|
||||
target.groups[0]!.rows[0]!.quantity = 10;
|
||||
target.groups[8]!.rows.push(row("GLT-Schrank", 2, 500, 1, 0.95));
|
||||
target.groups[8]!.editor = "MSR";
|
||||
target.reserveAvPercent = 15;
|
||||
const update = createEstimateBalanceUpdateProjectCommand(stored!, target);
|
||||
const updated = run(update);
|
||||
assert.deepEqual(
|
||||
reader.findById("project-1", balance.id),
|
||||
parseEstimateBalanceSnapshot(target)
|
||||
);
|
||||
|
||||
run(updated.inverse, "undo");
|
||||
assert.deepEqual(reader.findById("project-1", balance.id), stored);
|
||||
run(update, "redo");
|
||||
assert.deepEqual(
|
||||
reader.findById("project-1", balance.id),
|
||||
parseEstimateBalanceSnapshot(target)
|
||||
);
|
||||
|
||||
// A stale expected document is rejected.
|
||||
assert.throws(
|
||||
() => run(createEstimateBalanceUpdateProjectCommand(stored!, { ...stored!, name: "X" })),
|
||||
/changed before update/
|
||||
);
|
||||
assert.throws(
|
||||
() => createEstimateBalanceUpdateProjectCommand(stored!, structuredClone(stored!)),
|
||||
/must change/
|
||||
);
|
||||
|
||||
const current = reader.findById("project-1", balance.id)!;
|
||||
const deleted = run(createEstimateBalanceDeleteProjectCommand(current));
|
||||
assert.equal(reader.findById("project-1", balance.id), null);
|
||||
assert.equal(context.db.select().from(estimateRows).all().length, 0);
|
||||
run(deleted.inverse, "undo");
|
||||
assert.deepEqual(reader.findById("project-1", balance.id), current);
|
||||
// The original insert's inverse no longer matches the edited balance.
|
||||
assert.throws(() => run(inserted.inverse), /changed before deletion/);
|
||||
run(createEstimateBalanceDeleteProjectCommand(current));
|
||||
assert.equal(reader.listByProject("project-1").length, 0);
|
||||
|
||||
assert.throws(
|
||||
() => run(createEstimateBalanceInsertProjectCommand({ ...balance, projectId: "project-2" })),
|
||||
/does not belong to project/
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("sums detailed device rows of all circuit lists of the project", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
context.db
|
||||
.insert(distributionBoards)
|
||||
.values({ id: "board-1", projectId: "project-1", name: "UV 1" })
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitLists)
|
||||
.values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "UV 1" })
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitSections)
|
||||
.values({
|
||||
id: "section-1",
|
||||
circuitListId: "list-1",
|
||||
key: "lighting-1",
|
||||
displayName: "Beleuchtung 1",
|
||||
prefix: "1F1",
|
||||
sortOrder: 0,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values({
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "section-1",
|
||||
equipmentIdentifier: "-1F1",
|
||||
displayName: "Licht",
|
||||
sortOrder: 0,
|
||||
})
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitDeviceRows)
|
||||
.values([
|
||||
{
|
||||
id: "row-1",
|
||||
circuitId: "circuit-1",
|
||||
name: "Leuchte",
|
||||
displayName: "Leuchte",
|
||||
costGroup: "445",
|
||||
quantity: 10,
|
||||
powerPerUnit: 0.05,
|
||||
simultaneityFactor: 0.8,
|
||||
cosPhi: 0.95,
|
||||
},
|
||||
{
|
||||
id: "row-2",
|
||||
circuitId: "circuit-1",
|
||||
name: "Steckdose",
|
||||
displayName: "Steckdose",
|
||||
costGroup: null,
|
||||
quantity: 2,
|
||||
powerPerUnit: 0.2,
|
||||
simultaneityFactor: 0.5,
|
||||
cosPhi: null,
|
||||
},
|
||||
])
|
||||
.run();
|
||||
const detailed = new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower(
|
||||
"project-1"
|
||||
);
|
||||
assert.deepEqual(
|
||||
detailed.map((entry) => ({ ...entry, kw: round2(entry.kw) })),
|
||||
[
|
||||
{ costGroup: "445", kw: 0.4, cosPhi: 0.95 },
|
||||
{ costGroup: null, kw: 0.2, cosPhi: null },
|
||||
]
|
||||
);
|
||||
assert.equal(
|
||||
new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower("project-2").length,
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue