forked from jappel/leistungsbilanz-ts
Merge rough estimate balance
This commit is contained in:
commit
060a203425
45 changed files with 9378 additions and 58 deletions
24
AGENTS.md
24
AGENTS.md
|
|
@ -615,6 +615,30 @@ Future sizing will need:
|
||||||
|
|
||||||
Users must be able to override sizing suggestions.
|
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 deliberately not part of named/automatic project snapshots or snapshot
|
||||||
|
restore; those operations leave estimate balances unchanged. Portable project
|
||||||
|
transfer format version 2 includes every estimate balance and remaps balance,
|
||||||
|
group and row ids when duplicating a project. Version 1 transfers remain
|
||||||
|
importable and contain no estimate balances.
|
||||||
|
- 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
|
## Persistence and Migration Rules
|
||||||
|
|
||||||
- SQLite is the currently supported database.
|
- 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
|
Verteilerübersicht, Verteilerpläne, Büro-Logo
|
||||||
- [Gebäudestruktur](building-structure.md) – Gebäude → Etagen → Räume,
|
- [Gebäudestruktur](building-structure.md) – Gebäude → Etagen → Räume,
|
||||||
Befehle, Snapshot-Schema 7, eigene Seite
|
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
|
- [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte
|
||||||
- [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) –
|
- [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) –
|
||||||
bewusst noch nicht implementierte oder begrenzte Funktionen
|
bewusst noch nicht implementierte oder begrenzte Funktionen
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,14 @@ this value for optimistic concurrency checks.
|
||||||
- clients cannot submit arbitrary `project.restore-state` payloads through
|
- clients cannot submit arbitrary `project.restore-state` payloads through
|
||||||
the generic command endpoint
|
the generic command endpoint
|
||||||
|
|
||||||
|
Grobbilanzen sind bewusst kein Teil dieser logischen Snapshots. Der portable
|
||||||
|
Projektexport unter `GET /projects/:projectId/export` verwendet dagegen
|
||||||
|
Transferformat 2 und enthält alle Grobbilanzen. Die beiden Importwege
|
||||||
|
`POST /projects/:projectId/import` und `POST /projects/import` prüfen ihre
|
||||||
|
Prüfsumme; Ersetzen übernimmt die Grobbilanzen atomar und mit Undo/Redo,
|
||||||
|
Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen
|
||||||
|
importierbar.
|
||||||
|
|
||||||
The public dispatcher supports the Circuit and CircuitDeviceRow commands
|
The public dispatcher supports the Circuit and CircuitDeviceRow commands
|
||||||
`circuit.update`, `circuit.insert`, `circuit.delete`,
|
`circuit.update`, `circuit.insert`, `circuit.delete`,
|
||||||
`circuit-device-row.update`, `circuit-device-row.insert`,
|
`circuit-device-row.update`, `circuit-device-row.insert`,
|
||||||
|
|
@ -214,6 +222,12 @@ A stale `expectedRevision` returns HTTP `409` with
|
||||||
`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry
|
`PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry
|
||||||
returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
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
|
## Circuit-First Endpoints
|
||||||
|
|
||||||
### Distribution Board Setup
|
### Distribution Board Setup
|
||||||
|
|
|
||||||
|
|
@ -290,15 +290,20 @@ Projektdaten und erzeugen keine Revisionen. Das Löschen eines Projektgeräts
|
||||||
erfordert eine Bestätigung mit dessen Namen und verwendet weiterhin den
|
erfordert eine Bestätigung mit dessen Namen und verwendet weiterhin den
|
||||||
bestehenden persistenten Projektbefehl.
|
bestehenden persistenten Projektbefehl.
|
||||||
|
|
||||||
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
|
`GET /api/projects/:projectId/export` verpackt den vollständigen portablen
|
||||||
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
|
Projektzustand in ein format- und schema-versioniertes JSON-Dokument mit
|
||||||
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
SHA-256-Prüfsumme. Transferformat 2 enthält zusätzlich alle Grobbilanzen,
|
||||||
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
|
ohne sie in das logische Snapshot-Schema aufzunehmen; Format 1 bleibt ohne
|
||||||
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
|
Grobbilanzen importierbar. `POST /api/projects/:projectId/import` prüft Format,
|
||||||
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
|
Snapshot-Relationen, Grobbilanzen und Prüfsumme vor jedem Schreibzugriff. Der
|
||||||
|
Modus `replace` läuft über `project.restore-state` Version 2 und ersetzt den
|
||||||
|
Projektzustand und die Grobbilanzen atomar und dauerhaft rückgängig machbar.
|
||||||
|
Normale Snapshot-Restores verwenden weiterhin Version 1 und lassen
|
||||||
|
Grobbilanzen unverändert. Der Modus `duplicate` ordnet Projekt-,
|
||||||
Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und
|
Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und
|
||||||
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt
|
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen und die IDs aller Grobbilanzen
|
||||||
die Kopie mit Revision `0` in einer Transaktion an. Fachliche Verknüpfungen
|
vollständig neu zu und legt die Kopie mit Revision `0` in einer Transaktion an.
|
||||||
|
Fachliche Verknüpfungen
|
||||||
innerhalb des unterstützten Laufzeitmodells bleiben
|
innerhalb des unterstützten Laufzeitmodells bleiben
|
||||||
erhalten. Die
|
erhalten. Die
|
||||||
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
||||||
|
|
|
||||||
119
docs/estimate-balance.md
Normal file
119
docs/estimate-balance.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
# 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 (Auswahl ELT/MSR oder 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. Stände bleiben bearbeitbar; ein zusätzlicher Status oder
|
||||||
|
eine Bearbeitungssperre ist derzeit nicht vorgesehen.
|
||||||
|
|
||||||
|
## 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 }`).
|
||||||
|
|
||||||
|
## Projekttransfer und bewusste Abgrenzung
|
||||||
|
|
||||||
|
- Das reguläre SQLite-Datenbank-Backup sichert die Grobbilanz-Tabellen zusammen
|
||||||
|
mit allen übrigen Anwendungsdaten.
|
||||||
|
- Benannte und automatische Projektsnapshots enthalten keine Grobbilanzen; das
|
||||||
|
Snapshot-Schema bleibt Version 7. Snapshot-Restore und dessen Undo/Redo lassen
|
||||||
|
vorhandene Grobbilanzen unverändert.
|
||||||
|
- Der portable Projektexport enthält alle Grobbilanzen ab Transferformat 2.
|
||||||
|
Ersetzen übernimmt sie atomar und rückgängig machbar; Duplizieren vergibt neue
|
||||||
|
IDs für Bilanz, Gruppen und Zeilen. Alte Transferdateien in Version 1 bleiben
|
||||||
|
importierbar und enthalten definitionsgemäß keine Grobbilanzen.
|
||||||
|
- Keine Rechte je Gewerk; „bearbeitet von“ ist ein Freitext je Kostengruppe.
|
||||||
|
- Kostengruppen der Gerätezeilen sind nicht normiert; der Vergleich arbeitet
|
||||||
|
mit Präfixen.
|
||||||
|
- Der erste Vergleich zeigt die geschätzte und detaillierte AV-Wirkleistung in
|
||||||
|
kW. SV- und kVA-Vergleiche können später ergänzt werden.
|
||||||
|
- Eine Stromkreisnummer gehört nicht zur Grobbilanz, weil sie eine frühe
|
||||||
|
Abschätzung ohne Stromkreisplanung ist.
|
||||||
|
|
||||||
|
## 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 = [
|
const requiredTables = [
|
||||||
"buildings",
|
"buildings",
|
||||||
|
"estimate_balances",
|
||||||
|
"estimate_groups",
|
||||||
|
"estimate_rows",
|
||||||
"circuit_sections",
|
"circuit_sections",
|
||||||
"circuits",
|
"circuits",
|
||||||
"circuit_device_rows",
|
"circuit_device_rows",
|
||||||
|
|
|
||||||
|
|
@ -2002,3 +2002,710 @@ button.structure-entry-main:focus-visible {
|
||||||
color: var(--color-ink-soft);
|
color: var(--color-ink-soft);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Grobe Leistungsbilanz ──────────────────────────────────────────── */
|
||||||
|
/* Tabelle im Stil der Büro-Vorlage „Leistungsbilanz – KG400“: kompakte
|
||||||
|
Zellen-Inputs statt Bootstrap-Formularfelder, Druck als A4 quer. */
|
||||||
|
|
||||||
|
.estimate-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-save-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-save-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-ink-soft);
|
||||||
|
background: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-save-badge.is-saved {
|
||||||
|
color: var(--color-signal-dark);
|
||||||
|
border-color: var(--color-signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-save-badge.is-busy {
|
||||||
|
color: var(--color-warn);
|
||||||
|
border-color: var(--color-warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-save-badge.is-error {
|
||||||
|
color: var(--color-danger);
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-save-hint {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-header-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-sm) var(--space-lg);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-header-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
min-width: 10rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-header-field.is-wide {
|
||||||
|
flex: 1 1 18rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-header-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-header-value {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-header-input {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-color: var(--panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 1400px;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
background: var(--panel-bg);
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-comparison {
|
||||||
|
min-width: 640px;
|
||||||
|
table-layout: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet th,
|
||||||
|
.estimate-sheet td {
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
padding: 2px 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet thead th {
|
||||||
|
background: var(--surface-header-row);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-th-hint {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-col-kg { width: 3rem; }
|
||||||
|
.estimate-col-supply { width: 4rem; }
|
||||||
|
.estimate-col-name { width: auto; }
|
||||||
|
.estimate-col-quantity { width: 4.8rem; }
|
||||||
|
.estimate-col-unit { width: 3.8rem; }
|
||||||
|
.estimate-col-connection { width: 5rem; }
|
||||||
|
.estimate-col-power { width: 6.4rem; }
|
||||||
|
.estimate-col-gzf { width: 6.8rem; }
|
||||||
|
.estimate-col-cosphi { width: 3.8rem; }
|
||||||
|
.estimate-col-result { width: 4.6rem; }
|
||||||
|
.estimate-col-sv { width: 3.2rem; }
|
||||||
|
.estimate-col-remark { width: 9rem; }
|
||||||
|
.estimate-col-actions { width: 2.4rem; }
|
||||||
|
|
||||||
|
.estimate-cell-input,
|
||||||
|
.estimate-cell-select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
padding: 1px 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-cell-select {
|
||||||
|
padding-right: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-cell-input:hover:not(:disabled),
|
||||||
|
.estimate-cell-select:hover:not(:disabled) {
|
||||||
|
border-color: var(--panel-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-cell-input:focus,
|
||||||
|
.estimate-cell-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
background: var(--panel-bg);
|
||||||
|
box-shadow: 0 0 0 2px var(--accent-blue-soft-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-cell-input.is-numeric {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-cell-input.is-invalid,
|
||||||
|
.estimate-cell-input.is-invalid:focus,
|
||||||
|
.estimate-cell-input.is-invalid:hover {
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
background: var(--color-danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-cell-input::placeholder {
|
||||||
|
color: var(--text-faint);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-unit-input,
|
||||||
|
.estimate-gzf-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-unit-label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-faint);
|
||||||
|
min-width: 1.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-amev-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border: 1px solid var(--panel-border-strong);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--panel-bg-subtle);
|
||||||
|
color: var(--text-subtle);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
padding: 1px 3px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-amev-button:hover:not(:disabled) {
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
color: var(--accent-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-number {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-number.is-muted {
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-number.is-over {
|
||||||
|
color: var(--color-attention-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-number.is-under {
|
||||||
|
color: var(--color-signal-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-cost-group-cell {
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title-row td {
|
||||||
|
background: var(--surface-component-header);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs) var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-name {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-editor {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-editor .estimate-cell-input {
|
||||||
|
width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title-row .estimate-actions-cell {
|
||||||
|
width: auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-icon-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-actions-cell {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-empty-row td {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-subtotal-row td {
|
||||||
|
background: var(--panel-bg-subtle);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-label {
|
||||||
|
text-align: right;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-hint {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-row td {
|
||||||
|
background: var(--surface-header-row);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-row.is-strong td {
|
||||||
|
font-weight: 800;
|
||||||
|
border-top: 2px solid var(--panel-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-add-group-row td {
|
||||||
|
background: var(--panel-bg);
|
||||||
|
padding: var(--space-sm) 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-unassigned-row td {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-collapse-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-list-table .estimate-number {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Druck-Textwerte: auf dem Bildschirm unsichtbar, im Druck statt Inputs */
|
||||||
|
.estimate-print-value {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ausrichtung: alle Zellen zentriert, nur „Verbraucher / Flächen“, die
|
||||||
|
Gruppentitel und die Summenbeschriftungen stehen links bzw. rechts. */
|
||||||
|
.estimate-sheet td,
|
||||||
|
.estimate-sheet td.estimate-number,
|
||||||
|
.estimate-sheet .estimate-cell-input,
|
||||||
|
.estimate-sheet .estimate-cell-input.is-numeric {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet .estimate-cell-select {
|
||||||
|
text-align: center;
|
||||||
|
text-align-last: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet .estimate-unit-input,
|
||||||
|
.estimate-sheet .estimate-gzf-input {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet td.estimate-name-cell,
|
||||||
|
.estimate-sheet td.estimate-name-cell .estimate-cell-input,
|
||||||
|
.estimate-sheet .estimate-group-title-row td:not(.estimate-cost-group-cell),
|
||||||
|
.estimate-comparison td:nth-child(2) {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet td.estimate-total-label {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Druck im kbp-CD der Seitenansicht (src/frontend/print/print-document.css):
|
||||||
|
gleiche Farben, Schrift, Titelblock und Fußzeile. Die Fußzeile mit
|
||||||
|
Seitenzahlen setzt EstimatePrintHeader als @page-Regel mit Projektdaten. */
|
||||||
|
.estimate-print-titleblock {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@page estimate-balance {
|
||||||
|
size: A4 landscape;
|
||||||
|
margin: 10mm 10mm 13mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
body:has(.estimate-print-root) .sidebar,
|
||||||
|
.estimate-print-root .estimate-no-print,
|
||||||
|
.estimate-print-root .estimate-page-header,
|
||||||
|
.estimate-print-root .estimate-header-fields,
|
||||||
|
.estimate-print-root .alert,
|
||||||
|
.estimate-print-root input,
|
||||||
|
.estimate-print-root select,
|
||||||
|
.estimate-print-root button {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:has(.estimate-print-root),
|
||||||
|
body:has(.estimate-print-root),
|
||||||
|
body:has(.estimate-print-root) .app-shell,
|
||||||
|
body:has(.estimate-print-root) .app-shell-content {
|
||||||
|
background: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Der Dunkelmodus setzt color-scheme: dark; Chrome färbt damit auch den
|
||||||
|
Seitenrand dunkel. Im Druck gilt immer das helle Schema. */
|
||||||
|
html:has(.estimate-print-root) {
|
||||||
|
color-scheme: light !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-root .estimate-group-editor.is-empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Verbraucher bekommt den Platz; Anmerkungen bleiben schmal. */
|
||||||
|
.estimate-sheet .estimate-col-name {
|
||||||
|
width: 52mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet .estimate-col-remark {
|
||||||
|
width: 24mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet .estimate-col-supply {
|
||||||
|
width: 13mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
body:has(.estimate-print-root) .app-shell {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-root {
|
||||||
|
--ep-blue: #476088;
|
||||||
|
--ep-lilac: #9198c4;
|
||||||
|
--ep-line: #d6dae5;
|
||||||
|
--ep-tint: #f5f6fa;
|
||||||
|
--ep-text: #2b2b2b;
|
||||||
|
--ep-muted: #6b7280;
|
||||||
|
--ep-accent: #c9782a;
|
||||||
|
page: estimate-balance;
|
||||||
|
padding: 0 !important;
|
||||||
|
max-width: none;
|
||||||
|
color: var(--ep-text);
|
||||||
|
font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-root * {
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-root .estimate-print-value {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Titelblock wie der Plankopf der Seitenansicht */
|
||||||
|
.estimate-print-titleblock {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 30mm 1fr auto auto;
|
||||||
|
margin-bottom: 4mm;
|
||||||
|
border: 1.5px solid var(--ep-blue);
|
||||||
|
background: #fff;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-titleblock > * {
|
||||||
|
padding: 2.5mm 3.5mm;
|
||||||
|
border-left: 1px solid var(--ep-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-titleblock > :first-child {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 16mm;
|
||||||
|
color: var(--ep-blue);
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-logo img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 10mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-kicker {
|
||||||
|
color: var(--ep-muted);
|
||||||
|
font-size: 7pt;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1mm 4mm;
|
||||||
|
margin-top: 1mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-stand {
|
||||||
|
color: var(--ep-blue);
|
||||||
|
font-size: 16pt;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-project {
|
||||||
|
font-size: 12pt;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
gap: 0.8mm 4mm;
|
||||||
|
align-content: center;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-meta div {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-meta dt {
|
||||||
|
color: var(--ep-muted);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-meta.is-tinted {
|
||||||
|
background: var(--ep-tint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Karten und Ränder der Bildschirmansicht entfallen */
|
||||||
|
.estimate-print-root .card {
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none !important;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-root .card-body,
|
||||||
|
.estimate-print-root .card-header {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-root .estimate-comparison-card {
|
||||||
|
margin-top: 6mm;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-print-root .estimate-comparison-card .card-header {
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
color: var(--ep-blue);
|
||||||
|
font-size: 10pt;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet-wrap {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabelle */
|
||||||
|
.estimate-sheet {
|
||||||
|
min-width: 0;
|
||||||
|
table-layout: auto;
|
||||||
|
font-size: 7.4pt;
|
||||||
|
line-height: 1.2;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ep-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet th,
|
||||||
|
.estimate-sheet td {
|
||||||
|
border: 1px solid var(--ep-line) !important;
|
||||||
|
padding: 0.7mm 1.5mm;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ep-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet thead {
|
||||||
|
display: table-header-group;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet thead th {
|
||||||
|
background: var(--ep-blue) !important;
|
||||||
|
border-color: var(--ep-blue) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
font-weight: bold;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-sheet thead .estimate-th-hint {
|
||||||
|
color: #dfe3f0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title-row td {
|
||||||
|
background: var(--ep-tint) !important;
|
||||||
|
border-top: 1.5px solid var(--ep-lilac) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title-row .estimate-cost-group-cell {
|
||||||
|
color: var(--ep-blue) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title-row .estimate-group-editor {
|
||||||
|
color: var(--ep-muted);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-subtotal-row td {
|
||||||
|
border-bottom: 1.5px solid var(--ep-line) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-subtotal-row .estimate-total-label {
|
||||||
|
color: var(--ep-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-row td {
|
||||||
|
background: var(--ep-tint) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-row.is-strong td {
|
||||||
|
background: var(--ep-blue) !important;
|
||||||
|
border-color: var(--ep-blue) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-row .estimate-total-hint {
|
||||||
|
color: var(--ep-muted);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-total-row.is-strong .estimate-total-hint {
|
||||||
|
color: #dfe3f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-comparison .is-over {
|
||||||
|
color: var(--ep-accent) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-comparison .is-under {
|
||||||
|
color: var(--ep-blue) !important;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title-row,
|
||||||
|
.estimate-subtotal-row,
|
||||||
|
.estimate-total-row,
|
||||||
|
.estimate-row {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-group-title-row {
|
||||||
|
break-after: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Summen einmal am Ende, nicht auf jeder Seite */
|
||||||
|
.estimate-sheet tfoot {
|
||||||
|
display: table-row-group;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-col-actions {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.estimate-unit-input,
|
||||||
|
.estimate-gzf-input {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
745
src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx
Normal file
745
src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx
Normal file
|
|
@ -0,0 +1,745 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
Printer,
|
||||||
|
Redo2,
|
||||||
|
RefreshCw,
|
||||||
|
Undo2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
ApiRequestError,
|
||||||
|
getEstimateBalance,
|
||||||
|
getEstimateComparison,
|
||||||
|
getProject,
|
||||||
|
getProjectHistory,
|
||||||
|
isRevisionConflictError,
|
||||||
|
redoProjectCommand,
|
||||||
|
undoProjectCommand,
|
||||||
|
updateEstimateBalance,
|
||||||
|
} from "../../../../../frontend/utils/api";
|
||||||
|
import type {
|
||||||
|
EstimateBalanceDto,
|
||||||
|
EstimateComparisonDto,
|
||||||
|
ProjectDto,
|
||||||
|
ProjectHistoryStateDto,
|
||||||
|
} from "../../../../../frontend/types";
|
||||||
|
import type { EstimateGroupSnapshot } from "../../../../../shared/estimate-balance/estimate-balance";
|
||||||
|
import { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal";
|
||||||
|
import {
|
||||||
|
addEstimateGroup,
|
||||||
|
estimateBalanceFingerprint,
|
||||||
|
estimateTextLimits,
|
||||||
|
normalizeEstimateSortOrder,
|
||||||
|
toSaveableEstimateBalance,
|
||||||
|
updateEstimateGroup,
|
||||||
|
updateEstimateRow,
|
||||||
|
validateEstimateBalance,
|
||||||
|
} from "../../../../../frontend/estimate-balance/estimate-balance-document";
|
||||||
|
import { formatIsoDateGerman } from "../../../../../frontend/estimate-balance/estimate-balance-format";
|
||||||
|
import {
|
||||||
|
EstimateBalanceSheet,
|
||||||
|
type EstimateBalanceUpdate,
|
||||||
|
} from "../../../../../frontend/estimate-balance/estimate-balance-sheet";
|
||||||
|
import { EstimateComparisonTable } from "../../../../../frontend/estimate-balance/estimate-comparison-table";
|
||||||
|
import { DraftInput } from "../../../../../frontend/estimate-balance/estimate-draft-input";
|
||||||
|
import { EstimatePrintHeader } from "../../../../../frontend/estimate-balance/estimate-print-header";
|
||||||
|
import {
|
||||||
|
EstimateGroupModal,
|
||||||
|
type EstimateGroupFormValues,
|
||||||
|
} from "../../../../../frontend/estimate-balance/estimate-group-modal";
|
||||||
|
|
||||||
|
const AUTOSAVE_DELAY_MS = 800;
|
||||||
|
const CONFLICT_MESSAGE = "Die Bilanz wurde zwischenzeitlich geändert und neu geladen.";
|
||||||
|
|
||||||
|
type SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid";
|
||||||
|
|
||||||
|
type GroupDialog = { group: EstimateGroupSnapshot | null };
|
||||||
|
|
||||||
|
function errorMessage(error: unknown, fallback: string) {
|
||||||
|
return error instanceof Error && error.message ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNotFoundError(error: unknown) {
|
||||||
|
return error instanceof ApiRequestError && error.status === 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EstimateBalanceEditorPage() {
|
||||||
|
const params = useParams<{ projectId: string; balanceId: string }>();
|
||||||
|
const { projectId, balanceId } = params;
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||||
|
const [draft, setDraft] = useState<EstimateBalanceDto | null>(null);
|
||||||
|
const [history, setHistory] = useState<ProjectHistoryStateDto | null>(null);
|
||||||
|
const [comparison, setComparison] = useState<EstimateComparisonDto | null>(null);
|
||||||
|
const [comparisonError, setComparisonError] = useState<string | null>(null);
|
||||||
|
const [isComparisonOpen, setIsComparisonOpen] = useState(false);
|
||||||
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>("saved");
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [notice, setNotice] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isMissing, setIsMissing] = useState(false);
|
||||||
|
const [isHistoryBusy, setIsHistoryBusy] = useState(false);
|
||||||
|
const [invalidFieldKeys, setInvalidFieldKeys] = useState<ReadonlySet<string>>(
|
||||||
|
() => new Set()
|
||||||
|
);
|
||||||
|
const [groupDialog, setGroupDialog] = useState<GroupDialog | null>(null);
|
||||||
|
const [gzfTarget, setGzfTarget] = useState<{ groupId: string; rowId: string } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refs carry the latest values into timers, unload handlers and chained
|
||||||
|
// async saves without re-creating them.
|
||||||
|
const draftRef = useRef<EstimateBalanceDto | null>(null);
|
||||||
|
const revisionRef = useRef<number | null>(null);
|
||||||
|
const savedFingerprintRef = useRef<string | null>(null);
|
||||||
|
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
draftRef.current = draft;
|
||||||
|
}, [draft]);
|
||||||
|
|
||||||
|
const applyServerBalance = useCallback((balance: EstimateBalanceDto) => {
|
||||||
|
const normalized = normalizeEstimateSortOrder(balance);
|
||||||
|
savedFingerprintRef.current = estimateBalanceFingerprint(normalized);
|
||||||
|
draftRef.current = normalized;
|
||||||
|
setDraft(normalized);
|
||||||
|
setSaveStatus("saved");
|
||||||
|
setSaveError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reloadComparison = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setComparison(await getEstimateComparison(projectId, balanceId));
|
||||||
|
setComparisonError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setComparisonError(
|
||||||
|
errorMessage(err, "Der Vergleich konnte nicht geladen werden.")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [balanceId, projectId]);
|
||||||
|
|
||||||
|
/** Reloads project, document and history; replaces the local draft. */
|
||||||
|
const reloadAll = useCallback(async () => {
|
||||||
|
const balanceRequest = getEstimateBalance(projectId, balanceId);
|
||||||
|
// Handled below; avoids an "unhandled rejection" while project/history load.
|
||||||
|
balanceRequest.catch(() => undefined);
|
||||||
|
// Project and history are applied even when the balance is missing, so
|
||||||
|
// Redo stays available after its creation was undone.
|
||||||
|
const [currentProject, currentHistory] = await Promise.all([
|
||||||
|
getProject(projectId),
|
||||||
|
getProjectHistory(projectId).catch(() => null),
|
||||||
|
]);
|
||||||
|
setProject(currentProject);
|
||||||
|
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
|
||||||
|
setHistory(currentHistory);
|
||||||
|
const balance = await balanceRequest;
|
||||||
|
applyServerBalance(balance);
|
||||||
|
setIsMissing(false);
|
||||||
|
void reloadComparison();
|
||||||
|
}, [applyServerBalance, balanceId, projectId, reloadComparison]);
|
||||||
|
|
||||||
|
const handleReloadError = useCallback((err: unknown) => {
|
||||||
|
if (isNotFoundError(err)) {
|
||||||
|
setIsMissing(true);
|
||||||
|
setDraft(null);
|
||||||
|
draftRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(errorMessage(err, "Die grobe Leistungsbilanz konnte nicht geladen werden."));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId || !balanceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
reloadAll()
|
||||||
|
.then(() => setError(null))
|
||||||
|
.catch(handleReloadError)
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [balanceId, handleReloadError, projectId, reloadAll]);
|
||||||
|
|
||||||
|
const clearSaveTimer = useCallback(() => {
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the current draft if it differs from the last saved state. Saves
|
||||||
|
* never overlap: a save requested while another runs waits for it and then
|
||||||
|
* saves whatever changed in the meantime. Resolves to false when the draft
|
||||||
|
* could not be saved.
|
||||||
|
*/
|
||||||
|
const saveNow = useCallback(async (): Promise<boolean> => {
|
||||||
|
clearSaveTimer();
|
||||||
|
while (saveInFlightRef.current) {
|
||||||
|
await saveInFlightRef.current;
|
||||||
|
}
|
||||||
|
const current = draftRef.current;
|
||||||
|
const revision = revisionRef.current;
|
||||||
|
if (!current || revision === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const fingerprint = estimateBalanceFingerprint(current);
|
||||||
|
if (fingerprint === savedFingerprintRef.current) {
|
||||||
|
setSaveStatus("saved");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const problems = validateEstimateBalance(current);
|
||||||
|
if (problems.length) {
|
||||||
|
setSaveStatus("invalid");
|
||||||
|
setSaveError(problems.join(" "));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const body = toSaveableEstimateBalance(current);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Another command changed the project revision. When this document is
|
||||||
|
* still unchanged on the server (e.g. an edit in a circuit list), the
|
||||||
|
* draft is kept and saved again; otherwise the server state wins.
|
||||||
|
*/
|
||||||
|
async function resolveConflict(): Promise<"retry" | "reloaded"> {
|
||||||
|
const [currentProject, serverBalance, currentHistory] = await Promise.all([
|
||||||
|
getProject(projectId),
|
||||||
|
getEstimateBalance(projectId, balanceId),
|
||||||
|
getProjectHistory(projectId).catch(() => null),
|
||||||
|
]);
|
||||||
|
setProject(currentProject);
|
||||||
|
setHistory(currentHistory);
|
||||||
|
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
|
||||||
|
if (estimateBalanceFingerprint(serverBalance) === savedFingerprintRef.current) {
|
||||||
|
return "retry";
|
||||||
|
}
|
||||||
|
applyServerBalance(serverBalance);
|
||||||
|
setNotice(CONFLICT_MESSAGE);
|
||||||
|
void reloadComparison();
|
||||||
|
return "reloaded";
|
||||||
|
}
|
||||||
|
|
||||||
|
const run = (async () => {
|
||||||
|
setSaveStatus("saving");
|
||||||
|
for (let attempt = 0; ; attempt += 1) {
|
||||||
|
try {
|
||||||
|
const result = await updateEstimateBalance(
|
||||||
|
projectId,
|
||||||
|
body,
|
||||||
|
attempt === 0 ? revision : revisionRef.current ?? revision
|
||||||
|
);
|
||||||
|
if (!("unchanged" in result)) {
|
||||||
|
revisionRef.current = result.history.currentRevision;
|
||||||
|
setHistory(result.history);
|
||||||
|
setProject((currentProject) =>
|
||||||
|
currentProject
|
||||||
|
? { ...currentProject, currentRevision: result.history.currentRevision }
|
||||||
|
: currentProject
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The sent document is the saved state; the local draft is kept so
|
||||||
|
// typing is never interrupted by the server's copy.
|
||||||
|
savedFingerprintRef.current = fingerprint;
|
||||||
|
setSaveError(null);
|
||||||
|
const latest = draftRef.current;
|
||||||
|
if (latest && estimateBalanceFingerprint(latest) !== fingerprint) {
|
||||||
|
setSaveStatus("pending");
|
||||||
|
} else {
|
||||||
|
setSaveStatus("saved");
|
||||||
|
}
|
||||||
|
void reloadComparison();
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (isRevisionConflictError(err)) {
|
||||||
|
try {
|
||||||
|
if (attempt === 0 && (await resolveConflict()) === "retry") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (attempt > 0) {
|
||||||
|
await reloadAll();
|
||||||
|
setNotice(CONFLICT_MESSAGE);
|
||||||
|
}
|
||||||
|
} catch (reloadError) {
|
||||||
|
handleReloadError(reloadError);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isNotFoundError(err)) {
|
||||||
|
handleReloadError(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setSaveStatus("error");
|
||||||
|
setSaveError(errorMessage(err, "Die Änderungen konnten nicht gespeichert werden."));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
saveInFlightRef.current = run;
|
||||||
|
try {
|
||||||
|
return await run;
|
||||||
|
} finally {
|
||||||
|
saveInFlightRef.current = null;
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
applyServerBalance,
|
||||||
|
balanceId,
|
||||||
|
clearSaveTimer,
|
||||||
|
handleReloadError,
|
||||||
|
projectId,
|
||||||
|
reloadAll,
|
||||||
|
reloadComparison,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Debounced autosave after every change of the draft.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!draft) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (estimateBalanceFingerprint(draft) === savedFingerprintRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaveStatus((status) => (status === "saving" ? status : "pending"));
|
||||||
|
clearSaveTimer();
|
||||||
|
saveTimerRef.current = setTimeout(() => {
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
void saveNow();
|
||||||
|
}, AUTOSAVE_DELAY_MS);
|
||||||
|
return clearSaveTimer;
|
||||||
|
}, [clearSaveTimer, draft, saveNow]);
|
||||||
|
|
||||||
|
// Save immediately when the tab is hidden; on unload/unmount send the last
|
||||||
|
// change with keepalive so it survives the page.
|
||||||
|
useEffect(() => {
|
||||||
|
function hasUnsavedDraft() {
|
||||||
|
const current = draftRef.current;
|
||||||
|
return Boolean(
|
||||||
|
current && estimateBalanceFingerprint(current) !== savedFingerprintRef.current
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function sendKeepalive(): boolean {
|
||||||
|
const current = draftRef.current;
|
||||||
|
const revision = revisionRef.current;
|
||||||
|
if (
|
||||||
|
!current ||
|
||||||
|
revision === null ||
|
||||||
|
saveInFlightRef.current ||
|
||||||
|
validateEstimateBalance(current).length
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
savedFingerprintRef.current = estimateBalanceFingerprint(current);
|
||||||
|
void updateEstimateBalance(projectId, toSaveableEstimateBalance(current), revision, {
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(() => undefined);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.visibilityState === "hidden" && hasUnsavedDraft()) {
|
||||||
|
void saveNow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
||||||
|
if (!hasUnsavedDraft() || sendKeepalive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A save is still running or the draft is invalid: let the browser ask.
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
|
}
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||||
|
// In-app navigation (e.g. sidebar) unmounts the editor.
|
||||||
|
if (hasUnsavedDraft()) {
|
||||||
|
sendKeepalive();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [projectId, saveNow]);
|
||||||
|
|
||||||
|
const handleChange: EstimateBalanceUpdate = useCallback((update) => {
|
||||||
|
setNotice(null);
|
||||||
|
setDraft((current) => (current ? update(current) : current));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleValidityChange = useCallback((fieldKey: string, isValid: boolean) => {
|
||||||
|
setInvalidFieldKeys((current) => {
|
||||||
|
if (isValid ? !current.has(fieldKey) : current.has(fieldKey)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
const next = new Set(current);
|
||||||
|
if (isValid) {
|
||||||
|
next.delete(fieldKey);
|
||||||
|
} else {
|
||||||
|
next.add(fieldKey);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleHistory(direction: "undo" | "redo") {
|
||||||
|
setError(null);
|
||||||
|
setNotice(null);
|
||||||
|
setIsHistoryBusy(true);
|
||||||
|
try {
|
||||||
|
// Pending edits are saved first so they become their own undo step.
|
||||||
|
await saveNow();
|
||||||
|
const revision = revisionRef.current;
|
||||||
|
if (revision === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result =
|
||||||
|
direction === "undo"
|
||||||
|
? await undoProjectCommand(projectId, revision)
|
||||||
|
: await redoProjectCommand(projectId, revision);
|
||||||
|
revisionRef.current = result.history.currentRevision;
|
||||||
|
setHistory(result.history);
|
||||||
|
await reloadAll();
|
||||||
|
} catch (err) {
|
||||||
|
if (isRevisionConflictError(err)) {
|
||||||
|
await reloadAll().catch(handleReloadError);
|
||||||
|
setNotice(CONFLICT_MESSAGE);
|
||||||
|
} else if (isNotFoundError(err)) {
|
||||||
|
handleReloadError(err);
|
||||||
|
} else {
|
||||||
|
setError(
|
||||||
|
errorMessage(
|
||||||
|
err,
|
||||||
|
direction === "undo"
|
||||||
|
? "Die letzte Änderung konnte nicht rückgängig gemacht werden."
|
||||||
|
: "Die letzte Änderung konnte nicht wiederholt werden."
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsHistoryBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleBackClick(event: MouseEvent<HTMLAnchorElement>) {
|
||||||
|
const current = draftRef.current;
|
||||||
|
if (!current || estimateBalanceFingerprint(current) === savedFingerprintRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
await saveNow();
|
||||||
|
router.push(`/projects/${projectId}/grobbilanz`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveGroup(values: EstimateGroupFormValues) {
|
||||||
|
const editingGroup = groupDialog?.group ?? null;
|
||||||
|
handleChange((current) =>
|
||||||
|
editingGroup
|
||||||
|
? updateEstimateGroup(current, editingGroup.id, values)
|
||||||
|
: addEstimateGroup(current, values).balance
|
||||||
|
);
|
||||||
|
setGroupDialog(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectGzf(value: number) {
|
||||||
|
if (gzfTarget) {
|
||||||
|
handleChange((current) =>
|
||||||
|
updateEstimateRow(current, gzfTarget.groupId, gzfTarget.rowId, {
|
||||||
|
simultaneityFactor: value,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setGzfTarget(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isEditingDisabled = !draft || isHistoryBusy;
|
||||||
|
const invalidCount = invalidFieldKeys.size;
|
||||||
|
const listHref = `/projects/${projectId}/grobbilanz`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container-fluid py-4 estimate-print-root">
|
||||||
|
{project && draft ? <EstimatePrintHeader balance={draft} project={project} /> : null}
|
||||||
|
<div className="page-header estimate-page-header">
|
||||||
|
<div className="d-flex align-items-start gap-3">
|
||||||
|
<Link
|
||||||
|
aria-label="Zurück zur Übersicht der Stände"
|
||||||
|
className="btn btn-outline-secondary btn-sm structure-back-link estimate-no-print"
|
||||||
|
href={listHref}
|
||||||
|
onClick={(event) => void handleBackClick(event)}
|
||||||
|
title="Zurück zur Übersicht der Stände"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" size={18} />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="kicker">{project?.name ?? "Projekt"}</div>
|
||||||
|
<h1>Grobe Leistungsbilanz{draft ? ` – ${draft.name}` : ""}</h1>
|
||||||
|
<p className="text-secondary mb-0 estimate-no-print">
|
||||||
|
Leistungsabschätzung nach Kostengruppen (DIN 276) für LPH 2/3.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="estimate-toolbar estimate-no-print">
|
||||||
|
<SaveStatusBadge
|
||||||
|
error={saveError}
|
||||||
|
invalidCount={invalidCount}
|
||||||
|
onRetry={() => void saveNow()}
|
||||||
|
status={saveStatus}
|
||||||
|
/>
|
||||||
|
<div className="btn-group btn-group-sm" role="group" aria-label="Verlauf">
|
||||||
|
<button
|
||||||
|
aria-label="Rückgängig"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
disabled={isHistoryBusy || !history || history.undoDepth === 0}
|
||||||
|
onClick={() => void handleHistory("undo")}
|
||||||
|
title="Rückgängig (letzte Änderung im Projekt)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Undo2 aria-hidden="true" size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Wiederholen"
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
disabled={isHistoryBusy || !history || history.redoDepth === 0}
|
||||||
|
onClick={() => void handleHistory("redo")}
|
||||||
|
title="Wiederholen (im Projekt)"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Redo2 aria-hidden="true" size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="Drucken"
|
||||||
|
className="btn btn-sm btn-outline-secondary"
|
||||||
|
disabled={!draft}
|
||||||
|
onClick={() => window.print()}
|
||||||
|
title="Drucken"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Printer aria-hidden="true" className="me-1" size={16} />
|
||||||
|
Drucken
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{notice ? (
|
||||||
|
<div className="alert alert-info estimate-no-print" role="status">
|
||||||
|
{notice}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<div className="alert alert-warning estimate-no-print" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-secondary" role="status">
|
||||||
|
Grobe Leistungsbilanz wird geladen …
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{isMissing ? (
|
||||||
|
<div className="alert alert-warning" role="alert">
|
||||||
|
Dieser Stand existiert nicht (mehr), z. B. weil er gelöscht oder sein Anlegen
|
||||||
|
rückgängig gemacht wurde.{" "}
|
||||||
|
<Link href={listHref}>Zur Übersicht der Stände</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{draft && project ? (
|
||||||
|
<>
|
||||||
|
<section className="card shadow-sm mb-3 estimate-sheet-card">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="estimate-header-fields">
|
||||||
|
<div className="estimate-header-field">
|
||||||
|
<span className="estimate-header-label">Projekt</span>
|
||||||
|
<span className="estimate-header-value">{project.name}</span>
|
||||||
|
</div>
|
||||||
|
<label className="estimate-header-field is-wide">
|
||||||
|
<span className="estimate-header-label">Stand</span>
|
||||||
|
<DraftInput
|
||||||
|
ariaLabel="Bezeichnung des Stands"
|
||||||
|
className="estimate-cell-input estimate-header-input"
|
||||||
|
disabled={isEditingDisabled}
|
||||||
|
format={(value: string) => value}
|
||||||
|
maxLength={estimateTextLimits.balanceName}
|
||||||
|
onCommit={(name) => handleChange((current) => ({ ...current, name }))}
|
||||||
|
onValidityChange={(isValid) =>
|
||||||
|
handleValidityChange("balance:name", isValid)
|
||||||
|
}
|
||||||
|
parse={(text) =>
|
||||||
|
text.trim()
|
||||||
|
? { ok: true, value: text }
|
||||||
|
: { ok: false, error: "Die Bezeichnung darf nicht leer sein." }
|
||||||
|
}
|
||||||
|
value={draft.name}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="estimate-header-field">
|
||||||
|
<span className="estimate-header-label">LPH</span>
|
||||||
|
<input
|
||||||
|
aria-label="Leistungsphase"
|
||||||
|
className="estimate-cell-input estimate-header-input"
|
||||||
|
disabled={isEditingDisabled}
|
||||||
|
maxLength={estimateTextLimits.workStage}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleChange((current) => ({
|
||||||
|
...current,
|
||||||
|
workStage: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="z. B. LPH 3"
|
||||||
|
type="text"
|
||||||
|
value={draft.workStage ?? ""}
|
||||||
|
/>
|
||||||
|
<span className="estimate-print-value">{draft.workStage ?? ""}</span>
|
||||||
|
</label>
|
||||||
|
<label className="estimate-header-field">
|
||||||
|
<span className="estimate-header-label">Datum</span>
|
||||||
|
<input
|
||||||
|
aria-label="Datum"
|
||||||
|
className="estimate-cell-input estimate-header-input"
|
||||||
|
disabled={isEditingDisabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleChange((current) => ({
|
||||||
|
...current,
|
||||||
|
dateIso: event.target.value || null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
type="date"
|
||||||
|
value={draft.dateIso ?? ""}
|
||||||
|
/>
|
||||||
|
<span className="estimate-print-value">
|
||||||
|
{formatIsoDateGerman(draft.dateIso)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<EstimateBalanceSheet
|
||||||
|
balance={draft}
|
||||||
|
disabled={isEditingDisabled}
|
||||||
|
onAddGroup={() => setGroupDialog({ group: null })}
|
||||||
|
onChange={handleChange}
|
||||||
|
onEditGroup={(group) => setGroupDialog({ group })}
|
||||||
|
onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })}
|
||||||
|
onValidityChange={handleValidityChange}
|
||||||
|
threePhaseVoltageV={project.threePhaseVoltageV}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
className={
|
||||||
|
isComparisonOpen
|
||||||
|
? "card shadow-sm estimate-comparison-card"
|
||||||
|
: "card shadow-sm estimate-comparison-card estimate-no-print"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center gap-2">
|
||||||
|
<button
|
||||||
|
aria-controls="estimate-comparison-body"
|
||||||
|
aria-expanded={isComparisonOpen}
|
||||||
|
className="estimate-collapse-toggle"
|
||||||
|
onClick={() => setIsComparisonOpen((open) => !open)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{isComparisonOpen ? (
|
||||||
|
<ChevronDown aria-hidden="true" size={16} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight aria-hidden="true" size={16} />
|
||||||
|
)}
|
||||||
|
Vergleich mit detaillierter Bilanz
|
||||||
|
</button>
|
||||||
|
{isComparisonOpen ? (
|
||||||
|
<button
|
||||||
|
aria-label="Vergleich neu laden"
|
||||||
|
className="btn btn-sm btn-outline-secondary structure-icon-button estimate-no-print"
|
||||||
|
onClick={() => void reloadComparison()}
|
||||||
|
title="Vergleich neu laden"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<RefreshCw aria-hidden="true" size={14} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{isComparisonOpen ? (
|
||||||
|
<div className="card-body" id="estimate-comparison-body">
|
||||||
|
{comparisonError ? (
|
||||||
|
<div className="alert alert-warning" role="alert">
|
||||||
|
{comparisonError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{comparison ? <EstimateComparisonTable comparison={comparison} /> : null}
|
||||||
|
<p className="text-secondary small mt-2 mb-0">
|
||||||
|
Detailliert = Summe der Gerätezeilen aller Stromkreislisten (Anzahl ×
|
||||||
|
Leistung × Geräte-GZF), zugeordnet über die Kostengruppe; ohne
|
||||||
|
Verteiler-GZF.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{groupDialog ? (
|
||||||
|
<EstimateGroupModal
|
||||||
|
group={groupDialog.group}
|
||||||
|
onClose={() => setGroupDialog(null)}
|
||||||
|
onSave={handleSaveGroup}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{gzfTarget ? (
|
||||||
|
<GzfPickerModal onClose={() => setGzfTarget(null)} onSelect={handleSelectGzf} />
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveStatusBadge({
|
||||||
|
error,
|
||||||
|
invalidCount,
|
||||||
|
onRetry,
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
error: string | null;
|
||||||
|
invalidCount: number;
|
||||||
|
onRetry: () => void;
|
||||||
|
status: SaveStatus;
|
||||||
|
}) {
|
||||||
|
const label =
|
||||||
|
status === "saving"
|
||||||
|
? "Wird gespeichert …"
|
||||||
|
: status === "pending"
|
||||||
|
? "Ungespeicherte Änderungen"
|
||||||
|
: status === "error"
|
||||||
|
? "Nicht gespeichert – Fehler"
|
||||||
|
: status === "invalid"
|
||||||
|
? "Nicht gespeichert – ungültige Eingaben"
|
||||||
|
: "Gespeichert";
|
||||||
|
const tone =
|
||||||
|
status === "error" || status === "invalid"
|
||||||
|
? "is-error"
|
||||||
|
: status === "saved"
|
||||||
|
? "is-saved"
|
||||||
|
: "is-busy";
|
||||||
|
return (
|
||||||
|
<div className="estimate-save-status" role="status" aria-live="polite">
|
||||||
|
<span className={`estimate-save-badge ${tone}`} title={error ?? undefined}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{invalidCount ? (
|
||||||
|
<span className="estimate-save-hint">
|
||||||
|
{invalidCount === 1
|
||||||
|
? "1 ungültige Eingabe wird nicht gespeichert"
|
||||||
|
: `${invalidCount} ungültige Eingaben werden nicht gespeichert`}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{status === "error" ? (
|
||||||
|
<button className="btn btn-link btn-sm p-0" onClick={onRetry} type="button">
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
292
src/app/projects/[projectId]/grobbilanz/page.tsx
Normal file
292
src/app/projects/[projectId]/grobbilanz/page.tsx
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { ArrowLeft, Copy, FolderOpen, Plus, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
createEstimateBalance,
|
||||||
|
deleteEstimateBalance,
|
||||||
|
getProject,
|
||||||
|
listEstimateBalances,
|
||||||
|
} from "../../../../frontend/utils/api";
|
||||||
|
import type {
|
||||||
|
CreateEstimateBalanceInput,
|
||||||
|
EstimateBalanceDto,
|
||||||
|
ProjectCommandResultDto,
|
||||||
|
ProjectDto,
|
||||||
|
} from "../../../../frontend/types";
|
||||||
|
import { calculateEstimateBalance } from "../../../../shared/estimate-balance/estimate-balance";
|
||||||
|
import {
|
||||||
|
EstimateBalanceCreateModal,
|
||||||
|
type EstimateBalanceCreateDefaults,
|
||||||
|
} from "../../../../frontend/estimate-balance/estimate-balance-create-modal";
|
||||||
|
import { createCopyName } from "../../../../frontend/estimate-balance/estimate-balance-document";
|
||||||
|
import {
|
||||||
|
formatIsoDateGerman,
|
||||||
|
formatKw,
|
||||||
|
} from "../../../../frontend/estimate-balance/estimate-balance-format";
|
||||||
|
|
||||||
|
function countRows(balance: EstimateBalanceDto) {
|
||||||
|
return balance.groups.reduce((sum, group) => sum + group.rows.length, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EstimateBalanceListPage() {
|
||||||
|
const params = useParams<{ projectId: string }>();
|
||||||
|
const projectId = params.projectId;
|
||||||
|
const router = useRouter();
|
||||||
|
const [project, setProject] = useState<ProjectDto | null>(null);
|
||||||
|
const [balances, setBalances] = useState<EstimateBalanceDto[]>([]);
|
||||||
|
const [createDefaults, setCreateDefaults] =
|
||||||
|
useState<EstimateBalanceCreateDefaults | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
const [currentProject, loadedBalances] = await Promise.all([
|
||||||
|
getProject(projectId),
|
||||||
|
listEstimateBalances(projectId),
|
||||||
|
]);
|
||||||
|
setProject(currentProject);
|
||||||
|
setBalances([...loadedBalances].sort((left, right) => left.sortOrder - right.sortOrder));
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
reload()
|
||||||
|
.then(() => setError(null))
|
||||||
|
.catch((err: unknown) =>
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Grobe Leistungsbilanzen konnten nicht geladen werden."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, [projectId, reload]);
|
||||||
|
|
||||||
|
async function runCommand<TResult extends ProjectCommandResultDto>(
|
||||||
|
execute: (expectedRevision: number) => Promise<TResult>,
|
||||||
|
fallbackMessage: string
|
||||||
|
): Promise<TResult | null> {
|
||||||
|
if (!project) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await execute(project.currentRevision);
|
||||||
|
setProject((current) =>
|
||||||
|
current ? { ...current, currentRevision: result.history.currentRevision } : current
|
||||||
|
);
|
||||||
|
await reload();
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : fallbackMessage);
|
||||||
|
// Refresh revision and list so a stale state does not block the next
|
||||||
|
// attempt; the command error stays visible.
|
||||||
|
await reload().catch(() => undefined);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate(input: CreateEstimateBalanceInput) {
|
||||||
|
const result = await runCommand(
|
||||||
|
(expectedRevision) => createEstimateBalance(projectId, input, expectedRevision),
|
||||||
|
"Stand konnte nicht angelegt werden."
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
setCreateDefaults(null);
|
||||||
|
router.push(`/projects/${projectId}/grobbilanz/${result.balance.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(balance: EstimateBalanceDto) {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Stand „${balance.name}“ der groben Leistungsbilanz wirklich löschen?`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runCommand(
|
||||||
|
(expectedRevision) => deleteEstimateBalance(projectId, balance.id, expectedRevision),
|
||||||
|
"Stand konnte nicht gelöscht werden."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBusy = isSaving || !project;
|
||||||
|
const voltage = project?.threePhaseVoltageV ?? 400;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="container py-4">
|
||||||
|
<div className="page-header">
|
||||||
|
<div className="d-flex align-items-start gap-3">
|
||||||
|
<Link
|
||||||
|
aria-label="Zurück zur Projektübersicht"
|
||||||
|
className="btn btn-outline-secondary btn-sm structure-back-link"
|
||||||
|
href={`/projects/${projectId}`}
|
||||||
|
title="Zurück zur Projektübersicht"
|
||||||
|
>
|
||||||
|
<ArrowLeft aria-hidden="true" size={18} />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="kicker">{project?.name ?? "Projekt"}</div>
|
||||||
|
<h1>Grobe Leistungsbilanz</h1>
|
||||||
|
<p className="text-secondary mb-0">
|
||||||
|
Leistungsabschätzung für Vor- und Entwurfsplanung (LPH 2/3), gegliedert nach
|
||||||
|
Kostengruppen der DIN 276 – unabhängig von den Stromkreislisten.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => setCreateDefaults({})}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="me-1" size={16} />
|
||||||
|
Neuer Stand
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="alert alert-warning" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-secondary" role="status">
|
||||||
|
Grobe Leistungsbilanzen werden geladen …
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section aria-labelledby="estimate-list-heading" className="card shadow-sm">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h2 className="structure-column-title" id="estimate-list-heading">
|
||||||
|
Stände
|
||||||
|
<span className="badge text-bg-secondary ms-2">{balances.length}</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{balances.length ? (
|
||||||
|
<div className="table-responsive">
|
||||||
|
<table className="table table-hover align-middle mb-0 estimate-list-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Stand</th>
|
||||||
|
<th>LPH</th>
|
||||||
|
<th>Datum</th>
|
||||||
|
<th className="text-end">AV kW</th>
|
||||||
|
<th className="text-end">AV kVA</th>
|
||||||
|
<th className="text-end">SV kW</th>
|
||||||
|
<th className="text-end">SV kVA</th>
|
||||||
|
<th className="text-end">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{balances.map((balance) => {
|
||||||
|
const result = calculateEstimateBalance(balance, voltage);
|
||||||
|
const href = `/projects/${projectId}/grobbilanz/${balance.id}`;
|
||||||
|
const rowCount = countRows(balance);
|
||||||
|
return (
|
||||||
|
<tr key={balance.id}>
|
||||||
|
<td>
|
||||||
|
<Link className="fw-semibold" href={href}>
|
||||||
|
{balance.name}
|
||||||
|
</Link>
|
||||||
|
<div className="text-secondary small">
|
||||||
|
{balance.groups.length} Kostengruppen ·{" "}
|
||||||
|
{rowCount === 1 ? "1 Zeile" : `${rowCount} Zeilen`}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{balance.workStage ?? "–"}</td>
|
||||||
|
<td>{formatIsoDateGerman(balance.dateIso)}</td>
|
||||||
|
<td className="text-end estimate-number">
|
||||||
|
{formatKw(result.withReserve.av.kw)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end estimate-number">
|
||||||
|
{formatKw(result.withReserve.av.kva)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end estimate-number">
|
||||||
|
{formatKw(result.withReserve.sv.kw)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end estimate-number">
|
||||||
|
{formatKw(result.withReserve.sv.kva)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">
|
||||||
|
<div className="d-inline-flex gap-1">
|
||||||
|
<Link
|
||||||
|
aria-label={`Stand „${balance.name}“ öffnen`}
|
||||||
|
className="btn btn-sm btn-outline-primary"
|
||||||
|
href={href}
|
||||||
|
title="Öffnen"
|
||||||
|
>
|
||||||
|
<FolderOpen aria-hidden="true" className="me-1" size={14} />
|
||||||
|
Öffnen
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
aria-label={`Stand „${balance.name}“ als neuen Stand kopieren`}
|
||||||
|
className="btn btn-sm btn-outline-secondary structure-icon-button"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() =>
|
||||||
|
setCreateDefaults({
|
||||||
|
name: createCopyName(balance.name),
|
||||||
|
workStage: balance.workStage,
|
||||||
|
template: "copy",
|
||||||
|
copyFromBalanceId: balance.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
title="Als neuen Stand kopieren"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Copy aria-hidden="true" size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={`Stand „${balance.name}“ löschen`}
|
||||||
|
className="btn btn-sm btn-outline-danger structure-icon-button"
|
||||||
|
disabled={isBusy}
|
||||||
|
onClick={() => void handleDelete(balance)}
|
||||||
|
title="Stand löschen"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<p className="text-secondary small px-3 py-2 mb-0">
|
||||||
|
Leistungen jeweils inklusive Reserve.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : !isLoading ? (
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="structure-empty mb-0">
|
||||||
|
Noch kein Stand vorhanden. Mit „Neuer Stand“ eine grobe Leistungsbilanz auf
|
||||||
|
Basis der Vorlage KG 400 anlegen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{createDefaults ? (
|
||||||
|
<EstimateBalanceCreateModal
|
||||||
|
balances={balances}
|
||||||
|
defaults={createDefaults}
|
||||||
|
isSaving={isSaving}
|
||||||
|
onClose={() => setCreateDefaults(null)}
|
||||||
|
onCreate={(input) => void handleCreate(input)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/db/migrations/0010_estimate_balances.sql
Normal file
42
src/db/migrations/0010_estimate_balances.sql
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
CREATE TABLE `estimate_balances` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`project_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`work_stage` text,
|
||||||
|
`date_iso` text,
|
||||||
|
`reserve_av_percent` real DEFAULT 0 NOT NULL,
|
||||||
|
`reserve_sv_percent` real DEFAULT 0 NOT NULL,
|
||||||
|
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||||
|
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `estimate_balances_project_id_idx` ON `estimate_balances` (`project_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `estimate_groups` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`balance_id` text NOT NULL,
|
||||||
|
`cost_group` text DEFAULT '' NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`editor` text,
|
||||||
|
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||||
|
FOREIGN KEY (`balance_id`) REFERENCES `estimate_balances`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `estimate_groups_balance_id_idx` ON `estimate_groups` (`balance_id`);--> statement-breakpoint
|
||||||
|
CREATE TABLE `estimate_rows` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`group_id` text NOT NULL,
|
||||||
|
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||||
|
`supply` text,
|
||||||
|
`name` text DEFAULT '' NOT NULL,
|
||||||
|
`quantity` real DEFAULT 0 NOT NULL,
|
||||||
|
`unit` text DEFAULT 'piece' NOT NULL,
|
||||||
|
`connection` text,
|
||||||
|
`power_per_unit_w` real DEFAULT 0 NOT NULL,
|
||||||
|
`simultaneity_factor` real DEFAULT 1 NOT NULL,
|
||||||
|
`cos_phi` real DEFAULT 1 NOT NULL,
|
||||||
|
`safety_supply` integer DEFAULT false NOT NULL,
|
||||||
|
`remark` text,
|
||||||
|
FOREIGN KEY (`group_id`) REFERENCES `estimate_groups`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `estimate_rows_group_id_idx` ON `estimate_rows` (`group_id`);
|
||||||
2924
src/db/migrations/meta/0010_snapshot.json
Normal file
2924
src/db/migrations/meta/0010_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -71,6 +71,13 @@
|
||||||
"when": 1790419185882,
|
"when": 1790419185882,
|
||||||
"tag": "0009_buildings",
|
"tag": "0009_buildings",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1790438092269,
|
||||||
|
"tag": "0010_estimate_balances",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
assertEstimateBalanceDeleteProjectCommand,
|
||||||
|
assertEstimateBalanceInsertProjectCommand,
|
||||||
|
assertEstimateBalanceUpdateProjectCommand,
|
||||||
|
createEstimateBalanceDeleteProjectCommand,
|
||||||
|
createEstimateBalanceInsertProjectCommand,
|
||||||
|
createEstimateBalanceUpdateProjectCommand,
|
||||||
|
estimateBalanceDeleteCommandType,
|
||||||
|
estimateBalanceInsertCommandType,
|
||||||
|
estimateBalanceUpdateCommandType,
|
||||||
|
normalizeEstimateBalanceSnapshot,
|
||||||
|
sameEstimateBalance,
|
||||||
|
type EstimateBalanceProjectCommand,
|
||||||
|
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
||||||
|
import type {
|
||||||
|
EstimateBalanceProjectCommandStore,
|
||||||
|
ExecuteEstimateBalanceCommandInput,
|
||||||
|
} from "../../estimate-balance/domain/estimate-balance-project-command.store.js";
|
||||||
|
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { estimateBalances } from "../schema/estimate-balances.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import {
|
||||||
|
deleteEstimateBalance,
|
||||||
|
insertEstimateBalance,
|
||||||
|
readEstimateBalances,
|
||||||
|
} from "./estimate-balance.persistence.js";
|
||||||
|
|
||||||
|
export class EstimateBalanceProjectCommandRepository
|
||||||
|
implements EstimateBalanceProjectCommandStore
|
||||||
|
{
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
execute(input: ExecuteEstimateBalanceCommandInput) {
|
||||||
|
return executeProjectCommandTransaction(this.database, input, (tx) =>
|
||||||
|
this.applyCommand(tx, input.projectId, input.command)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyCommand(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
command: EstimateBalanceProjectCommand
|
||||||
|
): EstimateBalanceProjectCommand {
|
||||||
|
switch (command.type) {
|
||||||
|
case estimateBalanceInsertCommandType:
|
||||||
|
assertEstimateBalanceInsertProjectCommand(command);
|
||||||
|
return this.insert(database, projectId, command.payload.balance);
|
||||||
|
case estimateBalanceDeleteCommandType:
|
||||||
|
assertEstimateBalanceDeleteProjectCommand(command);
|
||||||
|
return this.delete(database, projectId, command.payload.balance);
|
||||||
|
case estimateBalanceUpdateCommandType:
|
||||||
|
assertEstimateBalanceUpdateProjectCommand(command);
|
||||||
|
return this.update(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
command.payload.expected,
|
||||||
|
command.payload.target
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private insert(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
balance: EstimateBalanceSnapshot
|
||||||
|
) {
|
||||||
|
if (balance.projectId !== projectId) {
|
||||||
|
throw new Error("Estimate-balance snapshot does not belong to project.");
|
||||||
|
}
|
||||||
|
const project = database
|
||||||
|
.select({ id: projects.id })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.get();
|
||||||
|
if (!project) {
|
||||||
|
throw new Error("Project does not exist.");
|
||||||
|
}
|
||||||
|
const existing = database
|
||||||
|
.select({ id: estimateBalances.id })
|
||||||
|
.from(estimateBalances)
|
||||||
|
.where(eq(estimateBalances.id, balance.id))
|
||||||
|
.get();
|
||||||
|
if (existing) {
|
||||||
|
throw new Error("Estimate-balance id already exists.");
|
||||||
|
}
|
||||||
|
const normalized = normalizeEstimateBalanceSnapshot(balance);
|
||||||
|
insertEstimateBalance(database, normalized);
|
||||||
|
return createEstimateBalanceDeleteProjectCommand(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private delete(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
expected: EstimateBalanceSnapshot
|
||||||
|
) {
|
||||||
|
this.assertPersisted(database, projectId, expected, "deletion");
|
||||||
|
if (deleteEstimateBalance(database, projectId, expected.id) !== 1) {
|
||||||
|
throw new Error("Estimate balance could not be deleted.");
|
||||||
|
}
|
||||||
|
return createEstimateBalanceInsertProjectCommand(expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private update(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
expected: EstimateBalanceSnapshot,
|
||||||
|
target: EstimateBalanceSnapshot
|
||||||
|
) {
|
||||||
|
this.assertPersisted(database, projectId, expected, "update");
|
||||||
|
// Group and row ids stay stable; replacing the whole document keeps the
|
||||||
|
// command exact and its inverse trivially the swapped pair.
|
||||||
|
deleteEstimateBalance(database, projectId, expected.id);
|
||||||
|
const normalized = normalizeEstimateBalanceSnapshot(target);
|
||||||
|
insertEstimateBalance(database, normalized);
|
||||||
|
return createEstimateBalanceUpdateProjectCommand(normalized, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertPersisted(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
expected: EstimateBalanceSnapshot,
|
||||||
|
action: string
|
||||||
|
) {
|
||||||
|
const [persisted] = readEstimateBalances(database, projectId, expected.id);
|
||||||
|
if (!persisted || !sameEstimateBalance(persisted, expected)) {
|
||||||
|
throw new Error(`Estimate balance changed before ${action}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/db/repositories/estimate-balance.persistence.ts
Normal file
121
src/db/repositories/estimate-balance.persistence.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||||
|
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import {
|
||||||
|
estimateBalances,
|
||||||
|
estimateGroups,
|
||||||
|
estimateRows,
|
||||||
|
} from "../schema/estimate-balances.js";
|
||||||
|
|
||||||
|
/** Reads complete balance documents of a project, ordered like the UI shows them. */
|
||||||
|
export function readEstimateBalances(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
balanceId?: string
|
||||||
|
): EstimateBalanceSnapshot[] {
|
||||||
|
const balances = database
|
||||||
|
.select()
|
||||||
|
.from(estimateBalances)
|
||||||
|
.where(
|
||||||
|
balanceId
|
||||||
|
? and(
|
||||||
|
eq(estimateBalances.projectId, projectId),
|
||||||
|
eq(estimateBalances.id, balanceId)
|
||||||
|
)
|
||||||
|
: eq(estimateBalances.projectId, projectId)
|
||||||
|
)
|
||||||
|
.orderBy(asc(estimateBalances.sortOrder), asc(estimateBalances.id))
|
||||||
|
.all();
|
||||||
|
if (!balances.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const groups = database
|
||||||
|
.select()
|
||||||
|
.from(estimateGroups)
|
||||||
|
.where(inArray(estimateGroups.balanceId, balances.map((balance) => balance.id)))
|
||||||
|
.orderBy(asc(estimateGroups.sortOrder), asc(estimateGroups.id))
|
||||||
|
.all();
|
||||||
|
const rows = groups.length
|
||||||
|
? database
|
||||||
|
.select()
|
||||||
|
.from(estimateRows)
|
||||||
|
.where(inArray(estimateRows.groupId, groups.map((group) => group.id)))
|
||||||
|
.orderBy(asc(estimateRows.sortOrder), asc(estimateRows.id))
|
||||||
|
.all()
|
||||||
|
: [];
|
||||||
|
return balances.map((balance) => ({
|
||||||
|
id: balance.id,
|
||||||
|
projectId: balance.projectId,
|
||||||
|
name: balance.name,
|
||||||
|
workStage: balance.workStage,
|
||||||
|
dateIso: balance.dateIso,
|
||||||
|
reserveAvPercent: balance.reserveAvPercent,
|
||||||
|
reserveSvPercent: balance.reserveSvPercent,
|
||||||
|
sortOrder: balance.sortOrder,
|
||||||
|
groups: groups
|
||||||
|
.filter((group) => group.balanceId === balance.id)
|
||||||
|
.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
costGroup: group.costGroup,
|
||||||
|
title: group.title,
|
||||||
|
editor: group.editor,
|
||||||
|
sortOrder: group.sortOrder,
|
||||||
|
rows: rows
|
||||||
|
.filter((row) => row.groupId === group.id)
|
||||||
|
.map(({ groupId: _groupId, ...row }) => row),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes a complete balance document (the caller removed an older version first). */
|
||||||
|
export function insertEstimateBalance(
|
||||||
|
database: AppDatabase,
|
||||||
|
balance: EstimateBalanceSnapshot
|
||||||
|
) {
|
||||||
|
database
|
||||||
|
.insert(estimateBalances)
|
||||||
|
.values({
|
||||||
|
id: balance.id,
|
||||||
|
projectId: balance.projectId,
|
||||||
|
name: balance.name,
|
||||||
|
workStage: balance.workStage,
|
||||||
|
dateIso: balance.dateIso,
|
||||||
|
reserveAvPercent: balance.reserveAvPercent,
|
||||||
|
reserveSvPercent: balance.reserveSvPercent,
|
||||||
|
sortOrder: balance.sortOrder,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
for (const group of balance.groups) {
|
||||||
|
database
|
||||||
|
.insert(estimateGroups)
|
||||||
|
.values({
|
||||||
|
id: group.id,
|
||||||
|
balanceId: balance.id,
|
||||||
|
costGroup: group.costGroup,
|
||||||
|
title: group.title,
|
||||||
|
editor: group.editor,
|
||||||
|
sortOrder: group.sortOrder,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
if (group.rows.length) {
|
||||||
|
database
|
||||||
|
.insert(estimateRows)
|
||||||
|
.values(group.rows.map((row) => ({ ...row, groupId: group.id })))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a balance with its groups and rows (foreign keys cascade). */
|
||||||
|
export function deleteEstimateBalance(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
balanceId: string
|
||||||
|
) {
|
||||||
|
return database
|
||||||
|
.delete(estimateBalances)
|
||||||
|
.where(
|
||||||
|
and(eq(estimateBalances.projectId, projectId), eq(estimateBalances.id, balanceId))
|
||||||
|
)
|
||||||
|
.run().changes;
|
||||||
|
}
|
||||||
60
src/db/repositories/estimate-balance.repository.ts
Normal file
60
src/db/repositories/estimate-balance.repository.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import type {
|
||||||
|
DetailedDeviceRowPower,
|
||||||
|
EstimateBalanceSnapshot,
|
||||||
|
} from "../../shared/estimate-balance/estimate-balance.js";
|
||||||
|
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { estimateBalances } from "../schema/estimate-balances.js";
|
||||||
|
import { readEstimateBalances } from "./estimate-balance.persistence.js";
|
||||||
|
|
||||||
|
/** Read side of the estimate-balance module; writes go through commands. */
|
||||||
|
export class EstimateBalanceRepository {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
listByProject(projectId: string): EstimateBalanceSnapshot[] {
|
||||||
|
return readEstimateBalances(this.database, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
findById(projectId: string, balanceId: string): EstimateBalanceSnapshot | null {
|
||||||
|
return readEstimateBalances(this.database, projectId, balanceId)[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSortOrder(projectId: string): number {
|
||||||
|
const rows = this.database
|
||||||
|
.select({ sortOrder: estimateBalances.sortOrder })
|
||||||
|
.from(estimateBalances)
|
||||||
|
.where(eq(estimateBalances.projectId, projectId))
|
||||||
|
.orderBy(asc(estimateBalances.sortOrder))
|
||||||
|
.all();
|
||||||
|
return rows.length ? Math.max(...rows.map((row) => row.sortOrder)) + 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device rows of all circuit lists of the project with their total power
|
||||||
|
* (quantity · power per unit · device GZF, like the circuit tree).
|
||||||
|
*/
|
||||||
|
listDetailedDeviceRowPower(projectId: string): DetailedDeviceRowPower[] {
|
||||||
|
return this.database
|
||||||
|
.select({
|
||||||
|
costGroup: circuitDeviceRows.costGroup,
|
||||||
|
quantity: circuitDeviceRows.quantity,
|
||||||
|
powerPerUnit: circuitDeviceRows.powerPerUnit,
|
||||||
|
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
|
||||||
|
cosPhi: circuitDeviceRows.cosPhi,
|
||||||
|
})
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
|
||||||
|
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.all()
|
||||||
|
.map((row) => ({
|
||||||
|
costGroup: row.costGroup,
|
||||||
|
kw: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
|
||||||
|
cosPhi: row.cosPhi,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,8 @@ import { ProjectStateConflictError } from "../../domain/errors/project-state-con
|
||||||
import {
|
import {
|
||||||
assertProjectStateRestoreCommand,
|
assertProjectStateRestoreCommand,
|
||||||
createProjectStateRestoreCommand,
|
createProjectStateRestoreCommand,
|
||||||
|
createProjectTransferRestoreCommand,
|
||||||
|
projectStateRestoreCommandSchemaVersion,
|
||||||
} from "../../domain/models/project-state-restore-command.model.js";
|
} from "../../domain/models/project-state-restore-command.model.js";
|
||||||
import {
|
import {
|
||||||
parseProjectStateSnapshot,
|
parseProjectStateSnapshot,
|
||||||
|
|
@ -13,6 +15,8 @@ import type {
|
||||||
ProjectStateRestoreCommandStore,
|
ProjectStateRestoreCommandStore,
|
||||||
} from "../../domain/ports/project-state-restore-command.store.js";
|
} from "../../domain/ports/project-state-restore-command.store.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { canonicalEstimateBalance } from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
||||||
|
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||||
|
|
@ -32,6 +36,11 @@ import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
import { externalModelSources } from "../schema/external-model-sources.js";
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||||
|
import { estimateBalances } from "../schema/estimate-balances.js";
|
||||||
|
import {
|
||||||
|
insertEstimateBalance,
|
||||||
|
readEstimateBalances,
|
||||||
|
} from "./estimate-balance.persistence.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
import {
|
import {
|
||||||
readProjectStateSnapshot,
|
readProjectStateSnapshot,
|
||||||
|
|
@ -66,10 +75,39 @@ export class ProjectStateRestoreCommandRepository
|
||||||
if (!current) {
|
if (!current) {
|
||||||
throw new Error("Project not found.");
|
throw new Error("Project not found.");
|
||||||
}
|
}
|
||||||
|
const currentEstimateBalances = readEstimateBalances(tx, input.projectId);
|
||||||
|
const transferCommand =
|
||||||
|
input.command.schemaVersion === projectStateRestoreCommandSchemaVersion
|
||||||
|
? input.command
|
||||||
|
: null;
|
||||||
if (
|
if (
|
||||||
current.payloadSha256 !==
|
current.payloadSha256 !==
|
||||||
input.command.payload.expectedStateSha256 &&
|
input.command.payload.expectedStateSha256 &&
|
||||||
!matchesHistoryRestoreTarget(tx, input, current.state)
|
!matchesHistoryRestoreTarget(
|
||||||
|
tx,
|
||||||
|
input,
|
||||||
|
current.state,
|
||||||
|
currentEstimateBalances
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ProjectStateConflictError(
|
||||||
|
input.projectId,
|
||||||
|
input.command.payload.expectedStateSha256,
|
||||||
|
current.payloadSha256
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
transferCommand &&
|
||||||
|
canonicalEstimateBalances(currentEstimateBalances) !==
|
||||||
|
canonicalEstimateBalances(
|
||||||
|
transferCommand.payload.expectedEstimateBalances
|
||||||
|
) &&
|
||||||
|
!matchesHistoryRestoreTarget(
|
||||||
|
tx,
|
||||||
|
input,
|
||||||
|
current.state,
|
||||||
|
currentEstimateBalances
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
throw new ProjectStateConflictError(
|
throw new ProjectStateConflictError(
|
||||||
input.projectId,
|
input.projectId,
|
||||||
|
|
@ -82,14 +120,28 @@ export class ProjectStateRestoreCommandRepository
|
||||||
input.command.payload.targetState
|
input.command.payload.targetState
|
||||||
);
|
);
|
||||||
replaceProjectState(tx, targetState);
|
replaceProjectState(tx, targetState);
|
||||||
|
if (transferCommand) {
|
||||||
|
replaceEstimateBalances(
|
||||||
|
tx,
|
||||||
|
input.projectId,
|
||||||
|
transferCommand.payload.targetEstimateBalances
|
||||||
|
);
|
||||||
|
}
|
||||||
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
|
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
|
||||||
if (!persistedTarget) {
|
if (!persistedTarget) {
|
||||||
throw new Error("Restored project could not be loaded.");
|
throw new Error("Restored project could not be loaded.");
|
||||||
}
|
}
|
||||||
const inverse = createProjectStateRestoreCommand(
|
const inverse = transferCommand
|
||||||
persistedTarget.payloadSha256,
|
? createProjectTransferRestoreCommand(
|
||||||
current.state
|
persistedTarget.payloadSha256,
|
||||||
);
|
current.state,
|
||||||
|
transferCommand.payload.targetEstimateBalances,
|
||||||
|
currentEstimateBalances
|
||||||
|
)
|
||||||
|
: createProjectStateRestoreCommand(
|
||||||
|
persistedTarget.payloadSha256,
|
||||||
|
current.state
|
||||||
|
);
|
||||||
|
|
||||||
return inverse;
|
return inverse;
|
||||||
}
|
}
|
||||||
|
|
@ -98,7 +150,8 @@ export class ProjectStateRestoreCommandRepository
|
||||||
function matchesHistoryRestoreTarget(
|
function matchesHistoryRestoreTarget(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
input: ExecuteProjectStateRestoreCommandInput,
|
input: ExecuteProjectStateRestoreCommandInput,
|
||||||
currentState: ProjectStateSnapshot
|
currentState: ProjectStateSnapshot,
|
||||||
|
currentEstimateBalances: EstimateBalanceSnapshot[]
|
||||||
) {
|
) {
|
||||||
if (
|
if (
|
||||||
(input.source !== "undo" && input.source !== "redo") ||
|
(input.source !== "undo" && input.source !== "redo") ||
|
||||||
|
|
@ -122,25 +175,45 @@ function matchesHistoryRestoreTarget(
|
||||||
input.source === "undo"
|
input.source === "undo"
|
||||||
? changeSet.forwardPayloadJson
|
? changeSet.forwardPayloadJson
|
||||||
: changeSet.inversePayloadJson;
|
: changeSet.inversePayloadJson;
|
||||||
const command = JSON.parse(serialized) as {
|
const command = JSON.parse(serialized) as unknown;
|
||||||
type?: unknown;
|
assertProjectStateRestoreCommand(command);
|
||||||
payload?: { targetState?: unknown };
|
const expectedState = parseProjectStateSnapshot(command.payload.targetState);
|
||||||
};
|
const stateMatches =
|
||||||
if (command.type !== "project.restore-state") {
|
canonicalProjectState(expectedState) ===
|
||||||
|
canonicalProjectState(currentState);
|
||||||
|
if (!stateMatches) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const expectedState = parseProjectStateSnapshot(
|
return command.schemaVersion !== projectStateRestoreCommandSchemaVersion ||
|
||||||
command.payload?.targetState
|
canonicalEstimateBalances(command.payload.targetEstimateBalances) ===
|
||||||
);
|
canonicalEstimateBalances(currentEstimateBalances);
|
||||||
return (
|
|
||||||
canonicalProjectState(expectedState) ===
|
|
||||||
canonicalProjectState(currentState)
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canonicalEstimateBalances(balances: EstimateBalanceSnapshot[]) {
|
||||||
|
return JSON.stringify(
|
||||||
|
[...balances]
|
||||||
|
.sort((left, right) => left.id.localeCompare(right.id))
|
||||||
|
.map((balance) => canonicalEstimateBalance(balance))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceEstimateBalances(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
balances: EstimateBalanceSnapshot[]
|
||||||
|
) {
|
||||||
|
database
|
||||||
|
.delete(estimateBalances)
|
||||||
|
.where(eq(estimateBalances.projectId, projectId))
|
||||||
|
.run();
|
||||||
|
for (const balance of balances) {
|
||||||
|
insertEstimateBalance(database, balance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function canonicalProjectState(state: ProjectStateSnapshot) {
|
function canonicalProjectState(state: ProjectStateSnapshot) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
...state,
|
...state,
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ import { eq } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
createProjectTransferEnvelope,
|
createProjectTransferEnvelope,
|
||||||
parseProjectTransferEnvelope,
|
parseProjectTransferEnvelope,
|
||||||
|
remapEstimateBalances,
|
||||||
remapProjectState,
|
remapProjectState,
|
||||||
|
serializeProjectTransferPayload,
|
||||||
} from "../../domain/models/project-transfer.model.js";
|
} from "../../domain/models/project-transfer.model.js";
|
||||||
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
import { createProjectTransferRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
||||||
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js";
|
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
|
@ -26,6 +28,10 @@ import { externalImportBatches } from "../schema/external-import-batches.js";
|
||||||
import { externalModelObjects } from "../schema/external-model-objects.js";
|
import { externalModelObjects } from "../schema/external-model-objects.js";
|
||||||
import { externalModelSources } from "../schema/external-model-sources.js";
|
import { externalModelSources } from "../schema/external-model-sources.js";
|
||||||
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
import { externalRoomMappings } from "../schema/external-room-mappings.js";
|
||||||
|
import {
|
||||||
|
insertEstimateBalance,
|
||||||
|
readEstimateBalances,
|
||||||
|
} from "./estimate-balance.persistence.js";
|
||||||
import {
|
import {
|
||||||
hashProjectStatePayload,
|
hashProjectStatePayload,
|
||||||
readProjectStateSnapshot,
|
readProjectStateSnapshot,
|
||||||
|
|
@ -40,10 +46,15 @@ export class ProjectTransferRepository implements ProjectTransferStore {
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const estimateBalances = readEstimateBalances(this.database, projectId);
|
||||||
|
const payloadSha256 = hashProjectStatePayload(
|
||||||
|
serializeProjectTransferPayload(snapshot.state, estimateBalances)
|
||||||
|
);
|
||||||
return createProjectTransferEnvelope(
|
return createProjectTransferEnvelope(
|
||||||
new Date().toISOString(),
|
new Date().toISOString(),
|
||||||
snapshot.payloadSha256,
|
payloadSha256,
|
||||||
snapshot.state
|
snapshot.state,
|
||||||
|
estimateBalances
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,10 +72,21 @@ export class ProjectTransferRepository implements ProjectTransferStore {
|
||||||
projectId,
|
projectId,
|
||||||
() => crypto.randomUUID()
|
() => crypto.randomUUID()
|
||||||
);
|
);
|
||||||
|
const currentEstimateBalances = readEstimateBalances(this.database, projectId);
|
||||||
|
const targetEstimateBalances =
|
||||||
|
transfer.projectState.project.id === projectId
|
||||||
|
? transfer.estimateBalances
|
||||||
|
: remapEstimateBalances(
|
||||||
|
transfer.estimateBalances,
|
||||||
|
projectId,
|
||||||
|
() => crypto.randomUUID()
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
command: createProjectStateRestoreCommand(
|
command: createProjectTransferRestoreCommand(
|
||||||
current.payloadSha256,
|
current.payloadSha256,
|
||||||
targetState
|
targetState,
|
||||||
|
currentEstimateBalances,
|
||||||
|
targetEstimateBalances
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -78,20 +100,39 @@ export class ProjectTransferRepository implements ProjectTransferStore {
|
||||||
() => crypto.randomUUID(),
|
() => crypto.randomUUID(),
|
||||||
`${transfer.projectState.project.name} (Kopie)`
|
`${transfer.projectState.project.name} (Kopie)`
|
||||||
);
|
);
|
||||||
this.database.transaction((tx) => insertProjectState(tx, state));
|
const estimateBalances = remapEstimateBalances(
|
||||||
|
transfer.estimateBalances,
|
||||||
|
projectId,
|
||||||
|
() => crypto.randomUUID()
|
||||||
|
);
|
||||||
|
this.database.transaction((tx) => {
|
||||||
|
insertProjectState(tx, state);
|
||||||
|
for (const balance of estimateBalances) {
|
||||||
|
insertEstimateBalance(tx, balance);
|
||||||
|
}
|
||||||
|
});
|
||||||
return { projectId, name: state.project.name };
|
return { projectId, name: state.project.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseVerified(value: unknown) {
|
private parseVerified(value: unknown) {
|
||||||
const rawPayloadSha256 =
|
const isLegacy = isPlainObject(value) && value.formatVersion === 1;
|
||||||
isPlainObject(value) &&
|
const rawPayloadSha256 = isPlainObject(value) && isPlainObject(value.projectState)
|
||||||
isPlainObject(value.projectState)
|
? hashProjectStatePayload(
|
||||||
? hashProjectStatePayload(JSON.stringify(value.projectState))
|
isLegacy
|
||||||
: null;
|
? JSON.stringify(value.projectState)
|
||||||
|
: JSON.stringify({
|
||||||
|
projectState: value.projectState,
|
||||||
|
estimateBalances: value.estimateBalances,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
: null;
|
||||||
const transfer = parseProjectTransferEnvelope(value);
|
const transfer = parseProjectTransferEnvelope(value);
|
||||||
const payloadJson = serializeProjectStateSnapshot(
|
const payloadJson = isLegacy
|
||||||
transfer.projectState
|
? serializeProjectStateSnapshot(transfer.projectState)
|
||||||
);
|
: serializeProjectTransferPayload(
|
||||||
|
transfer.projectState,
|
||||||
|
transfer.estimateBalances
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
|
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
|
||||||
rawPayloadSha256 !== transfer.payloadSha256
|
rawPayloadSha256 !== transfer.payloadSha256
|
||||||
|
|
|
||||||
60
src/db/schema/estimate-balances.ts
Normal file
60
src/db/schema/estimate-balances.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
import { projects } from "./projects.js";
|
||||||
|
|
||||||
|
// Grobe Leistungsbilanz (LPH 2/3): a separate module without links into the
|
||||||
|
// circuit structure. See docs/estimate-balance.md.
|
||||||
|
export const estimateBalances = sqliteTable(
|
||||||
|
"estimate_balances",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
projectId: text("project_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
workStage: text("work_stage"),
|
||||||
|
dateIso: text("date_iso"),
|
||||||
|
reserveAvPercent: real("reserve_av_percent").notNull().default(0),
|
||||||
|
reserveSvPercent: real("reserve_sv_percent").notNull().default(0),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
},
|
||||||
|
(table) => [index("estimate_balances_project_id_idx").on(table.projectId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const estimateGroups = sqliteTable(
|
||||||
|
"estimate_groups",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
balanceId: text("balance_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => estimateBalances.id, { onDelete: "cascade" }),
|
||||||
|
costGroup: text("cost_group").notNull().default(""),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
editor: text("editor"),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
},
|
||||||
|
(table) => [index("estimate_groups_balance_id_idx").on(table.balanceId)]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const estimateRows = sqliteTable(
|
||||||
|
"estimate_rows",
|
||||||
|
{
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
groupId: text("group_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => estimateGroups.id, { onDelete: "cascade" }),
|
||||||
|
sortOrder: integer("sort_order").notNull().default(0),
|
||||||
|
supply: text("supply"),
|
||||||
|
name: text("name").notNull().default(""),
|
||||||
|
quantity: real("quantity").notNull().default(0),
|
||||||
|
unit: text("unit", { enum: ["piece", "area"] }).notNull().default("piece"),
|
||||||
|
connection: text("connection", { enum: ["fixed", "socket"] }),
|
||||||
|
powerPerUnitW: real("power_per_unit_w").notNull().default(0),
|
||||||
|
simultaneityFactor: real("simultaneity_factor").notNull().default(1),
|
||||||
|
cosPhi: real("cos_phi").notNull().default(1),
|
||||||
|
safetySupply: integer("safety_supply", { mode: "boolean" })
|
||||||
|
.notNull()
|
||||||
|
.default(false),
|
||||||
|
remark: text("remark"),
|
||||||
|
},
|
||||||
|
(table) => [index("estimate_rows_group_id_idx").on(table.groupId)]
|
||||||
|
);
|
||||||
|
|
@ -3,32 +3,80 @@ import {
|
||||||
type ProjectStateSnapshot,
|
type ProjectStateSnapshot,
|
||||||
} from "./project-state-snapshot.model.js";
|
} from "./project-state-snapshot.model.js";
|
||||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
import {
|
||||||
|
parseEstimateBalanceSnapshot,
|
||||||
|
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
||||||
|
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
||||||
|
|
||||||
export const projectStateRestoreCommandType = "project.restore-state";
|
export const projectStateRestoreCommandType = "project.restore-state";
|
||||||
export const projectStateRestoreCommandSchemaVersion = 1 as const;
|
export const projectStateRestoreCommandSchemaVersion = 2 as const;
|
||||||
|
const snapshotRestoreCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
export interface ProjectStateRestoreCommandPayload {
|
export interface ProjectStateRestoreCommandPayload {
|
||||||
expectedStateSha256: string;
|
expectedStateSha256: string;
|
||||||
targetState: ProjectStateSnapshot;
|
targetState: ProjectStateSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProjectStateRestoreCommand = SerializedProjectCommand<
|
export type SnapshotProjectStateRestoreCommand = SerializedProjectCommand<
|
||||||
ProjectStateRestoreCommandPayload
|
ProjectStateRestoreCommandPayload
|
||||||
|
> & {
|
||||||
|
schemaVersion: typeof snapshotRestoreCommandSchemaVersion;
|
||||||
|
type: typeof projectStateRestoreCommandType;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ProjectTransferRestoreCommandPayload
|
||||||
|
extends ProjectStateRestoreCommandPayload {
|
||||||
|
expectedEstimateBalances: EstimateBalanceSnapshot[];
|
||||||
|
targetEstimateBalances: EstimateBalanceSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectTransferRestoreCommand = SerializedProjectCommand<
|
||||||
|
ProjectTransferRestoreCommandPayload
|
||||||
> & {
|
> & {
|
||||||
schemaVersion: typeof projectStateRestoreCommandSchemaVersion;
|
schemaVersion: typeof projectStateRestoreCommandSchemaVersion;
|
||||||
type: typeof projectStateRestoreCommandType;
|
type: typeof projectStateRestoreCommandType;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ProjectStateRestoreCommand =
|
||||||
|
| SnapshotProjectStateRestoreCommand
|
||||||
|
| ProjectTransferRestoreCommand;
|
||||||
|
|
||||||
export function createProjectStateRestoreCommand(
|
export function createProjectStateRestoreCommand(
|
||||||
expectedStateSha256: string,
|
expectedStateSha256: string,
|
||||||
targetState: ProjectStateSnapshot
|
targetState: ProjectStateSnapshot
|
||||||
): ProjectStateRestoreCommand {
|
): ProjectStateRestoreCommand {
|
||||||
const command: ProjectStateRestoreCommand = {
|
const command: SnapshotProjectStateRestoreCommand = {
|
||||||
|
schemaVersion: snapshotRestoreCommandSchemaVersion,
|
||||||
|
type: projectStateRestoreCommandType,
|
||||||
|
payload: {
|
||||||
|
expectedStateSha256,
|
||||||
|
targetState: parseProjectStateSnapshot(targetState),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assertProjectStateRestoreCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProjectTransferRestoreCommand(
|
||||||
|
expectedStateSha256: string,
|
||||||
|
targetState: ProjectStateSnapshot,
|
||||||
|
expectedEstimateBalances: EstimateBalanceSnapshot[],
|
||||||
|
targetEstimateBalances: EstimateBalanceSnapshot[]
|
||||||
|
): ProjectTransferRestoreCommand {
|
||||||
|
const command: ProjectTransferRestoreCommand = {
|
||||||
schemaVersion: projectStateRestoreCommandSchemaVersion,
|
schemaVersion: projectStateRestoreCommandSchemaVersion,
|
||||||
type: projectStateRestoreCommandType,
|
type: projectStateRestoreCommandType,
|
||||||
payload: {
|
payload: {
|
||||||
expectedStateSha256,
|
expectedStateSha256,
|
||||||
targetState: parseProjectStateSnapshot(targetState),
|
targetState: parseProjectStateSnapshot(targetState),
|
||||||
|
expectedEstimateBalances: parseEstimateBalances(
|
||||||
|
expectedEstimateBalances,
|
||||||
|
targetState.project.id
|
||||||
|
),
|
||||||
|
targetEstimateBalances: parseEstimateBalances(
|
||||||
|
targetEstimateBalances,
|
||||||
|
targetState.project.id
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
assertProjectStateRestoreCommand(command);
|
assertProjectStateRestoreCommand(command);
|
||||||
|
|
@ -42,10 +90,7 @@ export function assertProjectStateRestoreCommand(
|
||||||
throw new Error("Project state restore command must be an object.");
|
throw new Error("Project state restore command must be an object.");
|
||||||
}
|
}
|
||||||
const command = value as Partial<ProjectStateRestoreCommand>;
|
const command = value as Partial<ProjectStateRestoreCommand>;
|
||||||
if (
|
if (command.type !== projectStateRestoreCommandType) {
|
||||||
command.type !== projectStateRestoreCommandType ||
|
|
||||||
command.schemaVersion !== projectStateRestoreCommandSchemaVersion
|
|
||||||
) {
|
|
||||||
throw new Error("Unsupported project state restore command.");
|
throw new Error("Unsupported project state restore command.");
|
||||||
}
|
}
|
||||||
if (!command.payload || typeof command.payload !== "object") {
|
if (!command.payload || typeof command.payload !== "object") {
|
||||||
|
|
@ -58,5 +103,43 @@ export function assertProjectStateRestoreCommand(
|
||||||
) {
|
) {
|
||||||
throw new Error("Expected project state checksum is invalid.");
|
throw new Error("Expected project state checksum is invalid.");
|
||||||
}
|
}
|
||||||
parseProjectStateSnapshot(payload.targetState);
|
const targetState = parseProjectStateSnapshot(payload.targetState);
|
||||||
|
if (command.schemaVersion === snapshotRestoreCommandSchemaVersion) {
|
||||||
|
if (Object.keys(payload).length !== 2) {
|
||||||
|
throw new Error("Snapshot restore payload contains unknown fields.");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (command.schemaVersion !== projectStateRestoreCommandSchemaVersion) {
|
||||||
|
throw new Error("Unsupported project state restore command.");
|
||||||
|
}
|
||||||
|
const transferPayload = payload as Partial<ProjectTransferRestoreCommandPayload>;
|
||||||
|
parseEstimateBalances(
|
||||||
|
transferPayload.expectedEstimateBalances,
|
||||||
|
targetState.project.id
|
||||||
|
);
|
||||||
|
parseEstimateBalances(
|
||||||
|
transferPayload.targetEstimateBalances,
|
||||||
|
targetState.project.id
|
||||||
|
);
|
||||||
|
if (Object.keys(payload).length !== 4) {
|
||||||
|
throw new Error("Project transfer restore payload contains unknown fields.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEstimateBalances(
|
||||||
|
value: unknown,
|
||||||
|
projectId: string
|
||||||
|
): EstimateBalanceSnapshot[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new Error("Project transfer estimate balances must be an array.");
|
||||||
|
}
|
||||||
|
const balances = value.map(parseEstimateBalanceSnapshot);
|
||||||
|
if (balances.some((balance) => balance.projectId !== projectId)) {
|
||||||
|
throw new Error("Project transfer estimate balance belongs to another project.");
|
||||||
|
}
|
||||||
|
if (new Set(balances.map((balance) => balance.id)).size !== balances.length) {
|
||||||
|
throw new Error("Project transfer contains duplicate estimate balances.");
|
||||||
|
}
|
||||||
|
return balances;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@ import {
|
||||||
projectStateSnapshotSchemaVersion,
|
projectStateSnapshotSchemaVersion,
|
||||||
type ProjectStateSnapshot,
|
type ProjectStateSnapshot,
|
||||||
} from "./project-state-snapshot.model.js";
|
} from "./project-state-snapshot.model.js";
|
||||||
|
import { parseEstimateBalanceSnapshot } from "../../estimate-balance/domain/estimate-balance-command.model.js";
|
||||||
|
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
|
||||||
|
|
||||||
export const projectTransferFormat = "leistungsbilanz.project" as const;
|
export const projectTransferFormat = "leistungsbilanz.project" as const;
|
||||||
export const projectTransferFormatVersion = 1 as const;
|
export const projectTransferFormatVersion = 2 as const;
|
||||||
|
const legacyProjectTransferFormatVersion = 1 as const;
|
||||||
|
|
||||||
export interface ProjectTransferEnvelope {
|
export interface ProjectTransferEnvelope {
|
||||||
format: typeof projectTransferFormat;
|
format: typeof projectTransferFormat;
|
||||||
|
|
@ -13,12 +16,14 @@ export interface ProjectTransferEnvelope {
|
||||||
exportedAtIso: string;
|
exportedAtIso: string;
|
||||||
payloadSha256: string;
|
payloadSha256: string;
|
||||||
projectState: ProjectStateSnapshot;
|
projectState: ProjectStateSnapshot;
|
||||||
|
estimateBalances: EstimateBalanceSnapshot[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createProjectTransferEnvelope(
|
export function createProjectTransferEnvelope(
|
||||||
exportedAtIso: string,
|
exportedAtIso: string,
|
||||||
payloadSha256: string,
|
payloadSha256: string,
|
||||||
projectState: ProjectStateSnapshot
|
projectState: ProjectStateSnapshot,
|
||||||
|
estimateBalances: EstimateBalanceSnapshot[]
|
||||||
): ProjectTransferEnvelope {
|
): ProjectTransferEnvelope {
|
||||||
return parseProjectTransferEnvelope({
|
return parseProjectTransferEnvelope({
|
||||||
format: projectTransferFormat,
|
format: projectTransferFormat,
|
||||||
|
|
@ -26,6 +31,7 @@ export function createProjectTransferEnvelope(
|
||||||
exportedAtIso,
|
exportedAtIso,
|
||||||
payloadSha256,
|
payloadSha256,
|
||||||
projectState,
|
projectState,
|
||||||
|
estimateBalances,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,7 +43,8 @@ export function parseProjectTransferEnvelope(
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
value.format !== projectTransferFormat ||
|
value.format !== projectTransferFormat ||
|
||||||
value.formatVersion !== projectTransferFormatVersion
|
(value.formatVersion !== legacyProjectTransferFormatVersion &&
|
||||||
|
value.formatVersion !== projectTransferFormatVersion)
|
||||||
) {
|
) {
|
||||||
throw new Error("Project transfer format is not supported.");
|
throw new Error("Project transfer format is not supported.");
|
||||||
}
|
}
|
||||||
|
|
@ -57,7 +64,13 @@ export function parseProjectTransferEnvelope(
|
||||||
if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
||||||
throw new Error("Project transfer snapshot version is not supported.");
|
throw new Error("Project transfer snapshot version is not supported.");
|
||||||
}
|
}
|
||||||
if (Object.keys(value).length !== 5) {
|
const estimateBalances =
|
||||||
|
value.formatVersion === legacyProjectTransferFormatVersion
|
||||||
|
? []
|
||||||
|
: parseTransferEstimateBalances(value.estimateBalances, projectState.project.id);
|
||||||
|
const expectedFieldCount =
|
||||||
|
value.formatVersion === legacyProjectTransferFormatVersion ? 5 : 6;
|
||||||
|
if (Object.keys(value).length !== expectedFieldCount) {
|
||||||
throw new Error("Project transfer file contains unknown fields.");
|
throw new Error("Project transfer file contains unknown fields.");
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|
@ -66,9 +79,43 @@ export function parseProjectTransferEnvelope(
|
||||||
exportedAtIso: value.exportedAtIso,
|
exportedAtIso: value.exportedAtIso,
|
||||||
payloadSha256: value.payloadSha256,
|
payloadSha256: value.payloadSha256,
|
||||||
projectState,
|
projectState,
|
||||||
|
estimateBalances,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function serializeProjectTransferPayload(
|
||||||
|
projectState: ProjectStateSnapshot,
|
||||||
|
estimateBalances: EstimateBalanceSnapshot[]
|
||||||
|
) {
|
||||||
|
const parsedState = parseProjectStateSnapshot(projectState);
|
||||||
|
return JSON.stringify({
|
||||||
|
projectState: parsedState,
|
||||||
|
estimateBalances: parseTransferEstimateBalances(
|
||||||
|
estimateBalances,
|
||||||
|
parsedState.project.id
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remapEstimateBalances(
|
||||||
|
source: EstimateBalanceSnapshot[],
|
||||||
|
targetProjectId: string,
|
||||||
|
createId: () => string
|
||||||
|
): EstimateBalanceSnapshot[] {
|
||||||
|
return source.map((balance) =>
|
||||||
|
parseEstimateBalanceSnapshot({
|
||||||
|
...balance,
|
||||||
|
id: createId(),
|
||||||
|
projectId: targetProjectId,
|
||||||
|
groups: balance.groups.map((group) => ({
|
||||||
|
...group,
|
||||||
|
id: createId(),
|
||||||
|
rows: group.rows.map((row) => ({ ...row, id: createId() })),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function remapProjectState(
|
export function remapProjectState(
|
||||||
source: ProjectStateSnapshot,
|
source: ProjectStateSnapshot,
|
||||||
targetProjectId: string,
|
targetProjectId: string,
|
||||||
|
|
@ -282,6 +329,37 @@ function requiredId(ids: ReadonlyMap<string, string>, sourceId: string) {
|
||||||
return targetId;
|
return targetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseTransferEstimateBalances(
|
||||||
|
value: unknown,
|
||||||
|
projectId: string
|
||||||
|
): EstimateBalanceSnapshot[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new Error("Project transfer estimate balances must be an array.");
|
||||||
|
}
|
||||||
|
const balances = value.map(parseEstimateBalanceSnapshot);
|
||||||
|
if (balances.some((balance) => balance.projectId !== projectId)) {
|
||||||
|
throw new Error("Project transfer estimate balance belongs to another project.");
|
||||||
|
}
|
||||||
|
assertUniqueIds(balances.map((balance) => balance.id), "estimate balance");
|
||||||
|
assertUniqueIds(
|
||||||
|
balances.flatMap((balance) => balance.groups.map((group) => group.id)),
|
||||||
|
"estimate group"
|
||||||
|
);
|
||||||
|
assertUniqueIds(
|
||||||
|
balances.flatMap((balance) =>
|
||||||
|
balance.groups.flatMap((group) => group.rows.map((row) => row.id))
|
||||||
|
),
|
||||||
|
"estimate row"
|
||||||
|
);
|
||||||
|
return balances;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertUniqueIds(ids: string[], label: string) {
|
||||||
|
if (new Set(ids).size !== ids.length) {
|
||||||
|
throw new Error(`Project transfer contains a duplicate ${label} id.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,15 @@ import {
|
||||||
externalObjectDeleteCreatedCircuitCommandType,
|
externalObjectDeleteCreatedCircuitCommandType,
|
||||||
} from "../models/external-object-new-circuit-project-command.model.js";
|
} from "../models/external-object-new-circuit-project-command.model.js";
|
||||||
import type { ExternalObjectNewCircuitProjectCommandStore } from "../ports/external-object-new-circuit-project-command.store.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 {
|
interface DispatchProjectCommandInput {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|
@ -233,7 +242,8 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
|
||||||
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
|
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
|
||||||
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
|
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
|
||||||
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore
|
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
|
||||||
|
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
executeUser(
|
executeUser(
|
||||||
|
|
@ -654,6 +664,24 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).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: {
|
case projectStateRestoreCommandType: {
|
||||||
assertProjectStateRestoreCommand(input.command);
|
assertProjectStateRestoreCommand(input.command);
|
||||||
return this.projectStateRestoreStore.execute({
|
return this.projectStateRestoreStore.execute({
|
||||||
|
|
|
||||||
33
src/estimate-balance/api/estimate-balance-request.schemas.ts
Normal file
33
src/estimate-balance/api/estimate-balance-request.schemas.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { expectedProjectRevisionSchema } from "../../shared/validation/project-command.schemas.js";
|
||||||
|
|
||||||
|
export const createEstimateBalanceRequestSchema = z
|
||||||
|
.object({
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
workStage: z.string().trim().max(50).nullable().optional(),
|
||||||
|
dateIso: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.nullable()
|
||||||
|
.optional(),
|
||||||
|
template: z.enum(["kg400", "empty", "copy"]),
|
||||||
|
copyFromBalanceId: z.string().trim().min(1).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.refine((input) => input.template !== "copy" || Boolean(input.copyFromBalanceId), {
|
||||||
|
message: "copyFromBalanceId is required for template copy",
|
||||||
|
path: ["copyFromBalanceId"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateEstimateBalanceRequestSchema = z
|
||||||
|
.object({
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
// Validated in detail by parseEstimateBalanceSnapshot.
|
||||||
|
balance: z.unknown(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const deleteEstimateBalanceRequestSchema = z
|
||||||
|
.object({ expectedRevision: expectedProjectRevisionSchema })
|
||||||
|
.strict();
|
||||||
318
src/estimate-balance/domain/estimate-balance-command.model.ts
Normal file
318
src/estimate-balance/domain/estimate-balance-command.model.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
|
||||||
|
import {
|
||||||
|
estimateDefaultGroups,
|
||||||
|
type EstimateBalanceSnapshot,
|
||||||
|
type EstimateGroupSnapshot,
|
||||||
|
} from "../../shared/estimate-balance/estimate-balance.js";
|
||||||
|
|
||||||
|
export const estimateBalanceInsertCommandType = "estimate-balance.insert" as const;
|
||||||
|
export const estimateBalanceDeleteCommandType = "estimate-balance.delete" as const;
|
||||||
|
export const estimateBalanceUpdateCommandType = "estimate-balance.update" as const;
|
||||||
|
export const estimateBalanceCommandSchemaVersion = 1 as const;
|
||||||
|
|
||||||
|
const idSchema = z.string().trim().min(1).max(100);
|
||||||
|
const trimmed = (max: number) =>
|
||||||
|
z.string().max(max).refine((value) => value === value.trim(), {
|
||||||
|
message: "must be trimmed",
|
||||||
|
});
|
||||||
|
const nullableText = (max: number) =>
|
||||||
|
trimmed(max)
|
||||||
|
.refine((value) => value.length > 0, { message: "use null instead of empty" })
|
||||||
|
.nullable();
|
||||||
|
const finite = z.number().finite();
|
||||||
|
|
||||||
|
export const estimateRowSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
id: idSchema,
|
||||||
|
sortOrder: z.number().int().min(0),
|
||||||
|
supply: nullableText(50),
|
||||||
|
name: trimmed(200),
|
||||||
|
quantity: finite.min(0),
|
||||||
|
unit: z.enum(["piece", "area"]),
|
||||||
|
connection: z.enum(["fixed", "socket"]).nullable(),
|
||||||
|
powerPerUnitW: finite.min(0),
|
||||||
|
simultaneityFactor: finite.min(0).max(1),
|
||||||
|
cosPhi: finite.gt(0).max(1),
|
||||||
|
safetySupply: z.boolean(),
|
||||||
|
remark: nullableText(500),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const estimateGroupSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
id: idSchema,
|
||||||
|
costGroup: trimmed(20),
|
||||||
|
title: trimmed(200).refine((value) => value.length > 0, {
|
||||||
|
message: "title must not be empty",
|
||||||
|
}),
|
||||||
|
editor: nullableText(100),
|
||||||
|
sortOrder: z.number().int().min(0),
|
||||||
|
rows: z.array(estimateRowSnapshotSchema).max(1000),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const estimateBalanceSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
id: idSchema,
|
||||||
|
projectId: idSchema,
|
||||||
|
name: trimmed(200).refine((value) => value.length > 0, {
|
||||||
|
message: "name must not be empty",
|
||||||
|
}),
|
||||||
|
workStage: nullableText(50),
|
||||||
|
dateIso: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.refine((value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), {
|
||||||
|
message: "invalid date",
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
reserveAvPercent: finite.min(0).max(100),
|
||||||
|
reserveSvPercent: finite.min(0).max(100),
|
||||||
|
sortOrder: z.number().int().min(0),
|
||||||
|
groups: z.array(estimateGroupSnapshotSchema).max(200),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.superRefine((balance, context) => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const group of balance.groups) {
|
||||||
|
for (const id of [group.id, ...group.rows.map((row) => row.id)]) {
|
||||||
|
if (ids.has(id)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `duplicate id ${id}`,
|
||||||
|
path: ["groups"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ids.add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface EstimateBalanceInsertProjectCommand
|
||||||
|
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
||||||
|
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
||||||
|
type: typeof estimateBalanceInsertCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstimateBalanceDeleteProjectCommand
|
||||||
|
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
||||||
|
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
||||||
|
type: typeof estimateBalanceDeleteCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstimateBalanceUpdateProjectCommand
|
||||||
|
extends SerializedProjectCommand<{
|
||||||
|
expected: EstimateBalanceSnapshot;
|
||||||
|
target: EstimateBalanceSnapshot;
|
||||||
|
}> {
|
||||||
|
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
||||||
|
type: typeof estimateBalanceUpdateCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EstimateBalanceProjectCommand =
|
||||||
|
| EstimateBalanceInsertProjectCommand
|
||||||
|
| EstimateBalanceDeleteProjectCommand
|
||||||
|
| EstimateBalanceUpdateProjectCommand;
|
||||||
|
|
||||||
|
/** Parses and normalizes a snapshot (sort orders of groups and rows follow the array order). */
|
||||||
|
export function parseEstimateBalanceSnapshot(value: unknown): EstimateBalanceSnapshot {
|
||||||
|
const parsed = estimateBalanceSnapshotSchema.parse(value);
|
||||||
|
return normalizeEstimateBalanceSnapshot(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEstimateBalanceSnapshot(
|
||||||
|
balance: EstimateBalanceSnapshot
|
||||||
|
): EstimateBalanceSnapshot {
|
||||||
|
return {
|
||||||
|
...balance,
|
||||||
|
groups: balance.groups.map((group, groupIndex) => ({
|
||||||
|
...group,
|
||||||
|
sortOrder: groupIndex,
|
||||||
|
rows: group.rows.map((row, rowIndex) => ({ ...row, sortOrder: rowIndex })),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable JSON for exact equality checks of two snapshots. */
|
||||||
|
export function canonicalEstimateBalance(balance: EstimateBalanceSnapshot): string {
|
||||||
|
const normalized = normalizeEstimateBalanceSnapshot(balance);
|
||||||
|
return JSON.stringify({
|
||||||
|
id: normalized.id,
|
||||||
|
projectId: normalized.projectId,
|
||||||
|
name: normalized.name,
|
||||||
|
workStage: normalized.workStage,
|
||||||
|
dateIso: normalized.dateIso,
|
||||||
|
reserveAvPercent: normalized.reserveAvPercent,
|
||||||
|
reserveSvPercent: normalized.reserveSvPercent,
|
||||||
|
sortOrder: normalized.sortOrder,
|
||||||
|
groups: normalized.groups.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
costGroup: group.costGroup,
|
||||||
|
title: group.title,
|
||||||
|
editor: group.editor,
|
||||||
|
sortOrder: group.sortOrder,
|
||||||
|
rows: group.rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
supply: row.supply,
|
||||||
|
name: row.name,
|
||||||
|
quantity: row.quantity,
|
||||||
|
unit: row.unit,
|
||||||
|
connection: row.connection,
|
||||||
|
powerPerUnitW: row.powerPerUnitW,
|
||||||
|
simultaneityFactor: row.simultaneityFactor,
|
||||||
|
cosPhi: row.cosPhi,
|
||||||
|
safetySupply: row.safetySupply,
|
||||||
|
remark: row.remark,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sameEstimateBalance(
|
||||||
|
left: EstimateBalanceSnapshot,
|
||||||
|
right: EstimateBalanceSnapshot
|
||||||
|
): boolean {
|
||||||
|
return canonicalEstimateBalance(left) === canonicalEstimateBalance(right);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEstimateBalanceSnapshot(input: {
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
workStage: string | null;
|
||||||
|
dateIso: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
template: "kg400" | "empty" | { copyFrom: EstimateBalanceSnapshot };
|
||||||
|
createId?: () => string;
|
||||||
|
}): EstimateBalanceSnapshot {
|
||||||
|
const createId = input.createId ?? (() => crypto.randomUUID());
|
||||||
|
let groups: EstimateGroupSnapshot[] = [];
|
||||||
|
let reserveAvPercent = 10;
|
||||||
|
let reserveSvPercent = 0;
|
||||||
|
if (input.template === "kg400") {
|
||||||
|
groups = estimateDefaultGroups.map((group, index) => ({
|
||||||
|
id: createId(),
|
||||||
|
costGroup: group.costGroup,
|
||||||
|
title: group.title,
|
||||||
|
editor: null,
|
||||||
|
sortOrder: index,
|
||||||
|
rows: [],
|
||||||
|
}));
|
||||||
|
} else if (input.template !== "empty") {
|
||||||
|
const source = input.template.copyFrom;
|
||||||
|
reserveAvPercent = source.reserveAvPercent;
|
||||||
|
reserveSvPercent = source.reserveSvPercent;
|
||||||
|
groups = source.groups.map((group) => ({
|
||||||
|
...group,
|
||||||
|
id: createId(),
|
||||||
|
rows: group.rows.map((row) => ({ ...row, id: createId() })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return parseEstimateBalanceSnapshot({
|
||||||
|
id: createId(),
|
||||||
|
projectId: input.projectId,
|
||||||
|
name: input.name.trim(),
|
||||||
|
workStage: input.workStage?.trim() || null,
|
||||||
|
dateIso: input.dateIso || null,
|
||||||
|
reserveAvPercent,
|
||||||
|
reserveSvPercent,
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
groups,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEstimateBalanceInsertProjectCommand(
|
||||||
|
balance: EstimateBalanceSnapshot
|
||||||
|
): EstimateBalanceInsertProjectCommand {
|
||||||
|
return {
|
||||||
|
schemaVersion: estimateBalanceCommandSchemaVersion,
|
||||||
|
type: estimateBalanceInsertCommandType,
|
||||||
|
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEstimateBalanceDeleteProjectCommand(
|
||||||
|
balance: EstimateBalanceSnapshot
|
||||||
|
): EstimateBalanceDeleteProjectCommand {
|
||||||
|
return {
|
||||||
|
schemaVersion: estimateBalanceCommandSchemaVersion,
|
||||||
|
type: estimateBalanceDeleteCommandType,
|
||||||
|
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEstimateBalanceUpdateProjectCommand(
|
||||||
|
expected: EstimateBalanceSnapshot,
|
||||||
|
target: EstimateBalanceSnapshot
|
||||||
|
): EstimateBalanceUpdateProjectCommand {
|
||||||
|
const payload = {
|
||||||
|
expected: parseEstimateBalanceSnapshot(expected),
|
||||||
|
target: parseEstimateBalanceSnapshot(target),
|
||||||
|
};
|
||||||
|
assertMatchingUpdate(payload.expected, payload.target);
|
||||||
|
return {
|
||||||
|
schemaVersion: estimateBalanceCommandSchemaVersion,
|
||||||
|
type: estimateBalanceUpdateCommandType,
|
||||||
|
payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMatchingUpdate(
|
||||||
|
expected: EstimateBalanceSnapshot,
|
||||||
|
target: EstimateBalanceSnapshot
|
||||||
|
) {
|
||||||
|
if (expected.id !== target.id || expected.projectId !== target.projectId) {
|
||||||
|
throw new Error("Estimate-balance update must keep id and project.");
|
||||||
|
}
|
||||||
|
if (sameEstimateBalance(expected, target)) {
|
||||||
|
throw new Error("Estimate-balance update must change the balance.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCommandShape(
|
||||||
|
command: SerializedProjectCommand<unknown>,
|
||||||
|
type: string,
|
||||||
|
keys: string[]
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const payload = command.payload;
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== estimateBalanceCommandSchemaVersion ||
|
||||||
|
command.type !== type ||
|
||||||
|
payload === null ||
|
||||||
|
typeof payload !== "object" ||
|
||||||
|
Array.isArray(payload) ||
|
||||||
|
Object.keys(payload).sort().join(",") !== [...keys].sort().join(",")
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported estimate-balance command.");
|
||||||
|
}
|
||||||
|
return payload as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertEstimateBalanceInsertProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is EstimateBalanceInsertProjectCommand {
|
||||||
|
const payload = assertCommandShape(command, estimateBalanceInsertCommandType, ["balance"]);
|
||||||
|
parseEstimateBalanceSnapshot(payload.balance);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertEstimateBalanceDeleteProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is EstimateBalanceDeleteProjectCommand {
|
||||||
|
const payload = assertCommandShape(command, estimateBalanceDeleteCommandType, ["balance"]);
|
||||||
|
parseEstimateBalanceSnapshot(payload.balance);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertEstimateBalanceUpdateProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is EstimateBalanceUpdateProjectCommand {
|
||||||
|
const payload = assertCommandShape(command, estimateBalanceUpdateCommandType, [
|
||||||
|
"expected",
|
||||||
|
"target",
|
||||||
|
]);
|
||||||
|
assertMatchingUpdate(
|
||||||
|
parseEstimateBalanceSnapshot(payload.expected),
|
||||||
|
parseEstimateBalanceSnapshot(payload.target)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type {
|
||||||
|
AppendedProjectRevision,
|
||||||
|
ProjectRevisionSource,
|
||||||
|
} from "../../domain/ports/project-revision.store.js";
|
||||||
|
import type { EstimateBalanceProjectCommand } from "./estimate-balance-command.model.js";
|
||||||
|
|
||||||
|
export interface ExecuteEstimateBalanceCommandInput {
|
||||||
|
projectId: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
source: ProjectRevisionSource;
|
||||||
|
description?: string;
|
||||||
|
actorId?: string;
|
||||||
|
historyTargetChangeSetId?: string;
|
||||||
|
command: EstimateBalanceProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecutedEstimateBalanceCommand {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: EstimateBalanceProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstimateBalanceProjectCommandStore {
|
||||||
|
execute(input: ExecuteEstimateBalanceCommandInput): ExecutedEstimateBalanceCommand;
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ const PROJECT_SECTIONS = [
|
||||||
// Separate project sub-routes (/projects/[id]/<segment>).
|
// Separate project sub-routes (/projects/[id]/<segment>).
|
||||||
const PROJECT_ROUTES = [
|
const PROJECT_ROUTES = [
|
||||||
{ segment: "struktur", label: "Gebäudestruktur", icon: "▥" },
|
{ segment: "struktur", label: "Gebäudestruktur", icon: "▥" },
|
||||||
|
{ segment: "grobbilanz", label: "Grobe Leistungsbilanz", icon: "Σ" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Matches the project overview and all of its sub-routes (group 2 = rest).
|
// Matches the project overview and all of its sub-routes (group 2 = rest).
|
||||||
|
|
|
||||||
162
src/frontend/estimate-balance/estimate-balance-create-modal.tsx
Normal file
162
src/frontend/estimate-balance/estimate-balance-create-modal.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type FormEvent, useState } from "react";
|
||||||
|
import { FormModal } from "../components/form-modal";
|
||||||
|
import type {
|
||||||
|
CreateEstimateBalanceInput,
|
||||||
|
EstimateBalanceDto,
|
||||||
|
EstimateBalanceTemplate,
|
||||||
|
} from "../types";
|
||||||
|
import { estimateTextLimits } from "./estimate-balance-document";
|
||||||
|
import { getTodayIsoDate } from "./estimate-balance-format";
|
||||||
|
|
||||||
|
export interface EstimateBalanceCreateDefaults {
|
||||||
|
name?: string;
|
||||||
|
workStage?: string | null;
|
||||||
|
template?: EstimateBalanceTemplate;
|
||||||
|
copyFromBalanceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EstimateBalanceCreateModalProps {
|
||||||
|
balances: EstimateBalanceDto[];
|
||||||
|
defaults: EstimateBalanceCreateDefaults;
|
||||||
|
isSaving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (input: CreateEstimateBalanceInput) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Neuer Stand": name, LPH, date and the starting content. */
|
||||||
|
export function EstimateBalanceCreateModal({
|
||||||
|
balances,
|
||||||
|
defaults,
|
||||||
|
isSaving,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
}: EstimateBalanceCreateModalProps) {
|
||||||
|
const [name, setName] = useState(defaults.name ?? "");
|
||||||
|
const [workStage, setWorkStage] = useState(defaults.workStage ?? "");
|
||||||
|
const [dateIso, setDateIso] = useState(getTodayIsoDate());
|
||||||
|
const [template, setTemplate] = useState<EstimateBalanceTemplate>(
|
||||||
|
defaults.template ?? "kg400"
|
||||||
|
);
|
||||||
|
const [copyFromBalanceId, setCopyFromBalanceId] = useState(
|
||||||
|
defaults.copyFromBalanceId ?? balances[0]?.id ?? ""
|
||||||
|
);
|
||||||
|
const isCopyInvalid = template === "copy" && !copyFromBalanceId;
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!name.trim() || isCopyInvalid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onCreate({
|
||||||
|
name: name.trim(),
|
||||||
|
workStage: workStage.trim() || null,
|
||||||
|
dateIso: dateIso || null,
|
||||||
|
template,
|
||||||
|
...(template === "copy" ? { copyFromBalanceId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isSaving={isSaving}
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitDisabled={!name.trim() || isCopyInvalid}
|
||||||
|
submitLabel="Stand anlegen"
|
||||||
|
title="Neuer Stand"
|
||||||
|
>
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="estimate-create-name">
|
||||||
|
Bezeichnung
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="form-control"
|
||||||
|
id="estimate-create-name"
|
||||||
|
maxLength={estimateTextLimits.balanceName}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="z. B. Entwurfsplanung"
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-6 col-md-3">
|
||||||
|
<label className="form-label" htmlFor="estimate-create-work-stage">
|
||||||
|
LPH
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="estimate-create-work-stage"
|
||||||
|
maxLength={estimateTextLimits.workStage}
|
||||||
|
onChange={(event) => setWorkStage(event.target.value)}
|
||||||
|
placeholder="z. B. LPH 3"
|
||||||
|
value={workStage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-6 col-md-3">
|
||||||
|
<label className="form-label" htmlFor="estimate-create-date">
|
||||||
|
Datum
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="estimate-create-date"
|
||||||
|
onChange={(event) => setDateIso(event.target.value)}
|
||||||
|
type="date"
|
||||||
|
value={dateIso}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="estimate-create-template">
|
||||||
|
Inhalt
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="estimate-create-template"
|
||||||
|
onChange={(event) =>
|
||||||
|
setTemplate(event.target.value as EstimateBalanceTemplate)
|
||||||
|
}
|
||||||
|
value={template}
|
||||||
|
>
|
||||||
|
<option value="kg400">Vorlage KG 400</option>
|
||||||
|
<option value="empty">Leer</option>
|
||||||
|
<option disabled={!balances.length} value="copy">
|
||||||
|
Kopie von …
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{template === "copy" ? (
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="estimate-create-copy-from">
|
||||||
|
Kopie von Stand
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="estimate-create-copy-from"
|
||||||
|
onChange={(event) => setCopyFromBalanceId(event.target.value)}
|
||||||
|
required
|
||||||
|
value={copyFromBalanceId}
|
||||||
|
>
|
||||||
|
{balances.map((balance) => (
|
||||||
|
<option key={balance.id} value={balance.id}>
|
||||||
|
{balance.workStage ? `${balance.name} (${balance.workStage})` : balance.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="col-12">
|
||||||
|
<p className="text-secondary small mb-0">
|
||||||
|
{template === "kg400"
|
||||||
|
? "Legt die Kostengruppen 410 bis 480 und „Allgemein“ ohne Einträge an."
|
||||||
|
: template === "empty"
|
||||||
|
? "Legt einen Stand ohne Kostengruppen an."
|
||||||
|
: "Übernimmt alle Kostengruppen und Zeilen des gewählten Stands."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
298
src/frontend/estimate-balance/estimate-balance-document.ts
Normal file
298
src/frontend/estimate-balance/estimate-balance-document.ts
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
// Pure, immutable editing rules for one "Grobe Leistungsbilanz" document.
|
||||||
|
// The editor keeps the complete document as local draft and saves it as a
|
||||||
|
// whole (PUT), so every edit is a function draft → new draft.
|
||||||
|
import type {
|
||||||
|
EstimateBalanceSnapshot,
|
||||||
|
EstimateGroupSnapshot,
|
||||||
|
EstimateRowSnapshot,
|
||||||
|
} from "../../shared/estimate-balance/estimate-balance";
|
||||||
|
import { createClientId } from "../utils/client-id";
|
||||||
|
|
||||||
|
export type MoveDirection = -1 | 1;
|
||||||
|
|
||||||
|
/** Maximum text lengths accepted by the server. */
|
||||||
|
export const estimateTextLimits = {
|
||||||
|
balanceName: 200,
|
||||||
|
workStage: 50,
|
||||||
|
costGroup: 20,
|
||||||
|
groupTitle: 200,
|
||||||
|
groupEditor: 100,
|
||||||
|
supply: 50,
|
||||||
|
rowName: 200,
|
||||||
|
remark: 500,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type EstimateRowPatch = Partial<Omit<EstimateRowSnapshot, "id" | "sortOrder">>;
|
||||||
|
export type EstimateGroupPatch = Partial<Pick<EstimateGroupSnapshot, "costGroup" | "title" | "editor">>;
|
||||||
|
|
||||||
|
export function createEstimateRow(
|
||||||
|
sortOrder: number,
|
||||||
|
createId: () => string = createClientId
|
||||||
|
): EstimateRowSnapshot {
|
||||||
|
return {
|
||||||
|
id: createId(),
|
||||||
|
sortOrder,
|
||||||
|
supply: null,
|
||||||
|
name: "",
|
||||||
|
quantity: 1,
|
||||||
|
unit: "piece",
|
||||||
|
connection: null,
|
||||||
|
powerPerUnitW: 0,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
cosPhi: 1,
|
||||||
|
safetySupply: false,
|
||||||
|
remark: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createEstimateGroup(
|
||||||
|
input: { costGroup: string; title: string; editor?: string | null },
|
||||||
|
sortOrder: number,
|
||||||
|
createId: () => string = createClientId
|
||||||
|
): EstimateGroupSnapshot {
|
||||||
|
return {
|
||||||
|
id: createId(),
|
||||||
|
costGroup: input.costGroup,
|
||||||
|
title: input.title,
|
||||||
|
editor: input.editor ?? null,
|
||||||
|
sortOrder,
|
||||||
|
rows: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function bySortOrder<T extends { sortOrder: number }>(left: T, right: T) {
|
||||||
|
return left.sortOrder - right.sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Orders groups and rows by sortOrder and renumbers both from 0. */
|
||||||
|
export function normalizeEstimateSortOrder(
|
||||||
|
balance: EstimateBalanceSnapshot
|
||||||
|
): EstimateBalanceSnapshot {
|
||||||
|
return {
|
||||||
|
...balance,
|
||||||
|
groups: [...balance.groups].sort(bySortOrder).map((group, groupIndex) => ({
|
||||||
|
...group,
|
||||||
|
sortOrder: groupIndex,
|
||||||
|
rows: [...group.rows].sort(bySortOrder).map((row, rowIndex) => ({
|
||||||
|
...row,
|
||||||
|
sortOrder: rowIndex,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renumber<T extends { sortOrder: number }>(items: T[]): T[] {
|
||||||
|
return items.map((item, index) =>
|
||||||
|
item.sortOrder === index ? item : { ...item, sortOrder: index }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapGroup(
|
||||||
|
balance: EstimateBalanceSnapshot,
|
||||||
|
groupId: string,
|
||||||
|
update: (group: EstimateGroupSnapshot) => EstimateGroupSnapshot
|
||||||
|
): EstimateBalanceSnapshot {
|
||||||
|
let changed = false;
|
||||||
|
const groups = balance.groups.map((group) => {
|
||||||
|
if (group.id !== groupId) {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
const next = update(group);
|
||||||
|
changed = next !== group;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
// An unknown group or a no-op keeps the identical document.
|
||||||
|
return changed ? { ...balance, groups } : balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveItem<T>(items: T[], index: number, direction: MoveDirection): T[] | null {
|
||||||
|
const target = index + direction;
|
||||||
|
if (index < 0 || target < 0 || target >= items.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const next = [...items];
|
||||||
|
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends a row (or inserts it after `afterRowId`) and returns the new row id. */
|
||||||
|
export function addEstimateRow(
|
||||||
|
balance: EstimateBalanceSnapshot,
|
||||||
|
groupId: string,
|
||||||
|
options: { afterRowId?: string; createId?: () => string } = {}
|
||||||
|
): { balance: EstimateBalanceSnapshot; rowId: string | null } {
|
||||||
|
let rowId: string | null = null;
|
||||||
|
const next = mapGroup(balance, groupId, (group) => {
|
||||||
|
const row = createEstimateRow(0, options.createId);
|
||||||
|
rowId = row.id;
|
||||||
|
const afterIndex = options.afterRowId
|
||||||
|
? group.rows.findIndex((candidate) => candidate.id === options.afterRowId)
|
||||||
|
: -1;
|
||||||
|
const rows = [...group.rows];
|
||||||
|
rows.splice(afterIndex >= 0 ? afterIndex + 1 : rows.length, 0, row);
|
||||||
|
return { ...group, rows: renumber(rows) };
|
||||||
|
});
|
||||||
|
return { balance: next, rowId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateEstimateRow(
|
||||||
|
balance: EstimateBalanceSnapshot,
|
||||||
|
groupId: string,
|
||||||
|
rowId: string,
|
||||||
|
patch: EstimateRowPatch
|
||||||
|
): EstimateBalanceSnapshot {
|
||||||
|
return mapGroup(balance, groupId, (group) => ({
|
||||||
|
...group,
|
||||||
|
rows: group.rows.map((row) => (row.id === rowId ? { ...row, ...patch } : row)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeEstimateRow(
|
||||||
|
balance: EstimateBalanceSnapshot,
|
||||||
|
groupId: string,
|
||||||
|
rowId: string
|
||||||
|
): EstimateBalanceSnapshot {
|
||||||
|
return mapGroup(balance, groupId, (group) => ({
|
||||||
|
...group,
|
||||||
|
rows: renumber(group.rows.filter((row) => row.id !== rowId)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveEstimateRow(
|
||||||
|
balance: EstimateBalanceSnapshot,
|
||||||
|
groupId: string,
|
||||||
|
rowId: string,
|
||||||
|
direction: MoveDirection
|
||||||
|
): EstimateBalanceSnapshot {
|
||||||
|
return mapGroup(balance, groupId, (group) => {
|
||||||
|
const rows = moveItem(
|
||||||
|
group.rows,
|
||||||
|
group.rows.findIndex((row) => row.id === rowId),
|
||||||
|
direction
|
||||||
|
);
|
||||||
|
return rows ? { ...group, rows: renumber(rows) } : group;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Appends a group and returns its id. */
|
||||||
|
export function addEstimateGroup(
|
||||||
|
balance: EstimateBalanceSnapshot,
|
||||||
|
input: { costGroup: string; title: string; editor?: string | null },
|
||||||
|
createId: () => string = createClientId
|
||||||
|
): { balance: EstimateBalanceSnapshot; groupId: string } {
|
||||||
|
const group = createEstimateGroup(input, balance.groups.length, createId);
|
||||||
|
return {
|
||||||
|
balance: { ...balance, groups: renumber([...balance.groups, group]) },
|
||||||
|
groupId: group.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateEstimateGroup(
|
||||||
|
balance: EstimateBalanceSnapshot,
|
||||||
|
groupId: string,
|
||||||
|
patch: EstimateGroupPatch
|
||||||
|
): EstimateBalanceSnapshot {
|
||||||
|
return mapGroup(balance, groupId, (group) => ({ ...group, ...patch }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeEstimateGroup(
|
||||||
|
balance: EstimateBalanceSnapshot,
|
||||||
|
groupId: string
|
||||||
|
): EstimateBalanceSnapshot {
|
||||||
|
const groups = balance.groups.filter((group) => group.id !== groupId);
|
||||||
|
return groups.length === balance.groups.length
|
||||||
|
? balance
|
||||||
|
: { ...balance, groups: renumber(groups) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveEstimateGroup(
|
||||||
|
balance: EstimateBalanceSnapshot,
|
||||||
|
groupId: string,
|
||||||
|
direction: MoveDirection
|
||||||
|
): EstimateBalanceSnapshot {
|
||||||
|
const groups = moveItem(
|
||||||
|
balance.groups,
|
||||||
|
balance.groups.findIndex((group) => group.id === groupId),
|
||||||
|
direction
|
||||||
|
);
|
||||||
|
return groups ? { ...balance, groups: renumber(groups) } : balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredText(value: string) {
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalText(value: string | null) {
|
||||||
|
const trimmed = value?.trim() ?? "";
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exact document the server accepts: strings trimmed, empty optional
|
||||||
|
* texts as null, sort orders continuous. Only fields of the shared snapshot
|
||||||
|
* type are copied, so no UI state can leak into the request.
|
||||||
|
*/
|
||||||
|
export function toSaveableEstimateBalance(
|
||||||
|
balance: EstimateBalanceSnapshot
|
||||||
|
): EstimateBalanceSnapshot {
|
||||||
|
const normalized = normalizeEstimateSortOrder(balance);
|
||||||
|
return {
|
||||||
|
id: normalized.id,
|
||||||
|
projectId: normalized.projectId,
|
||||||
|
name: requiredText(normalized.name),
|
||||||
|
workStage: optionalText(normalized.workStage),
|
||||||
|
dateIso: normalized.dateIso || null,
|
||||||
|
reserveAvPercent: normalized.reserveAvPercent,
|
||||||
|
reserveSvPercent: normalized.reserveSvPercent,
|
||||||
|
sortOrder: normalized.sortOrder,
|
||||||
|
groups: normalized.groups.map((group) => ({
|
||||||
|
id: group.id,
|
||||||
|
costGroup: requiredText(group.costGroup),
|
||||||
|
title: requiredText(group.title),
|
||||||
|
editor: optionalText(group.editor),
|
||||||
|
sortOrder: group.sortOrder,
|
||||||
|
rows: group.rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
supply: optionalText(row.supply),
|
||||||
|
name: requiredText(row.name),
|
||||||
|
quantity: row.quantity,
|
||||||
|
unit: row.unit,
|
||||||
|
connection: row.connection,
|
||||||
|
powerPerUnitW: row.powerPerUnitW,
|
||||||
|
simultaneityFactor: row.simultaneityFactor,
|
||||||
|
cosPhi: row.cosPhi,
|
||||||
|
safetySupply: row.safetySupply,
|
||||||
|
remark: optionalText(row.remark),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable comparison key of the saveable document (dirty detection). */
|
||||||
|
export function estimateBalanceFingerprint(balance: EstimateBalanceSnapshot): string {
|
||||||
|
return JSON.stringify(toSaveableEstimateBalance(balance));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* German problems that would make the server reject the document. The
|
||||||
|
* editor never saves while this list is not empty.
|
||||||
|
*/
|
||||||
|
export function validateEstimateBalance(balance: EstimateBalanceSnapshot): string[] {
|
||||||
|
const problems: string[] = [];
|
||||||
|
const saveable = toSaveableEstimateBalance(balance);
|
||||||
|
if (!saveable.name) {
|
||||||
|
problems.push("Die Bezeichnung des Stands darf nicht leer sein.");
|
||||||
|
}
|
||||||
|
for (const group of saveable.groups) {
|
||||||
|
if (!group.title) {
|
||||||
|
problems.push(`Kostengruppe ${group.costGroup || "ohne Nummer"}: Titel fehlt.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCopyName(name: string) {
|
||||||
|
const copyName = `${name.trim()} (Kopie)`;
|
||||||
|
return copyName.slice(0, estimateTextLimits.balanceName);
|
||||||
|
}
|
||||||
122
src/frontend/estimate-balance/estimate-balance-format.ts
Normal file
122
src/frontend/estimate-balance/estimate-balance-format.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
// Pure parsing and formatting rules of the "Grobe Leistungsbilanz" editor.
|
||||||
|
// Numbers are entered and shown in German notation. As in the circuit list
|
||||||
|
// editor, only "," is accepted as decimal separator: a "." is ambiguous
|
||||||
|
// (decimal point or thousands separator) and is rejected instead of guessed.
|
||||||
|
import { roundEstimateValue } from "../../shared/estimate-balance/estimate-balance";
|
||||||
|
|
||||||
|
export type DecimalParseResult =
|
||||||
|
| { ok: true; value: number }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
export interface DecimalRange {
|
||||||
|
min?: number;
|
||||||
|
/** Excludes `min` itself (e.g. cos φ > 0). */
|
||||||
|
minExclusive?: boolean;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DECIMAL_PATTERN = /^[+-]?(\d+(,\d*)?|,\d+)$/;
|
||||||
|
|
||||||
|
function formatBound(value: number) {
|
||||||
|
return value.toLocaleString("de-DE", { maximumFractionDigits: 6, useGrouping: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseGermanDecimal(text: string, range: DecimalRange = {}): DecimalParseResult {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return { ok: false, error: "Bitte einen Wert eingeben." };
|
||||||
|
}
|
||||||
|
if (trimmed.includes(".")) {
|
||||||
|
return { ok: false, error: "Dezimalstellen mit Komma eingeben (z. B. 0,8)." };
|
||||||
|
}
|
||||||
|
if (!DECIMAL_PATTERN.test(trimmed)) {
|
||||||
|
return { ok: false, error: "Ungültiger Zahlenwert." };
|
||||||
|
}
|
||||||
|
const value = Number(trimmed.replace(",", "."));
|
||||||
|
if (!Number.isFinite(value)) {
|
||||||
|
return { ok: false, error: "Ungültiger Zahlenwert." };
|
||||||
|
}
|
||||||
|
if (range.min !== undefined) {
|
||||||
|
if (range.minExclusive ? value <= range.min : value < range.min) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: range.minExclusive
|
||||||
|
? `Der Wert muss größer als ${formatBound(range.min)} sein.`
|
||||||
|
: `Der Wert darf nicht kleiner als ${formatBound(range.min)} sein.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (range.max !== undefined && value > range.max) {
|
||||||
|
return { ok: false, error: `Der Wert darf nicht größer als ${formatBound(range.max)} sein.` };
|
||||||
|
}
|
||||||
|
return { ok: true, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Value ranges accepted by the server for the numeric document fields. */
|
||||||
|
export const estimateNumberRanges = {
|
||||||
|
quantity: { min: 0 },
|
||||||
|
powerPerUnitW: { min: 0 },
|
||||||
|
simultaneityFactor: { min: 0, max: 1 },
|
||||||
|
cosPhi: { min: 0, minExclusive: true, max: 1 },
|
||||||
|
reservePercent: { min: 0, max: 100 },
|
||||||
|
} satisfies Record<string, DecimalRange>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text of a numeric input field: German comma, no thousands separator, so the
|
||||||
|
* shown text can be parsed again unchanged.
|
||||||
|
*/
|
||||||
|
export function formatDecimalInput(value: number, maximumFractionDigits = 6): string {
|
||||||
|
return value.toLocaleString("de-DE", { maximumFractionDigits, useGrouping: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only display of a calculated value with thousands separator ("1.200",
|
||||||
|
* "30,24"). The value is rounded half away from zero first, like Excel does
|
||||||
|
* (40,175 → 40,18 instead of the binary-rounded 40,17).
|
||||||
|
*/
|
||||||
|
export function formatGermanNumber(
|
||||||
|
value: number,
|
||||||
|
minimumFractionDigits: number,
|
||||||
|
maximumFractionDigits = minimumFractionDigits
|
||||||
|
): string {
|
||||||
|
const rounded = roundEstimateValue(value, maximumFractionDigits);
|
||||||
|
// "+ 0" turns -0 into 0 and avoids "-0,00".
|
||||||
|
return (rounded + 0).toLocaleString("de-DE", {
|
||||||
|
minimumFractionDigits,
|
||||||
|
maximumFractionDigits,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatKw(value: number) {
|
||||||
|
return formatGermanNumber(value, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAmpere(value: number) {
|
||||||
|
return formatGermanNumber(value, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatSignedKw(value: number) {
|
||||||
|
const text = formatKw(value);
|
||||||
|
return value > 0 && text !== "0,00" ? `+${text}` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDeltaPercent(value: number | null) {
|
||||||
|
if (value === null) {
|
||||||
|
return "–";
|
||||||
|
}
|
||||||
|
const text = formatGermanNumber(value, 1);
|
||||||
|
return `${value > 0 && text !== "0,0" ? "+" : ""}${text} %`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2026-09-26" → "26.09.2026"; empty/invalid → "–". */
|
||||||
|
export function formatIsoDateGerman(dateIso: string | null): string {
|
||||||
|
const match = dateIso?.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
return match ? `${match[3]}.${match[2]}.${match[1]}` : "–";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Local calendar date as YYYY-MM-DD (not UTC, so late evenings stay today). */
|
||||||
|
export function getTodayIsoDate(now: Date = new Date()): string {
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(now.getDate()).padStart(2, "0");
|
||||||
|
return `${now.getFullYear()}-${month}-${day}`;
|
||||||
|
}
|
||||||
669
src/frontend/estimate-balance/estimate-balance-sheet.tsx
Normal file
669
src/frontend/estimate-balance/estimate-balance-sheet.tsx
Normal file
|
|
@ -0,0 +1,669 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
calculateEstimateBalance,
|
||||||
|
calculateEstimateRow,
|
||||||
|
estimateRowConnectionLabels,
|
||||||
|
estimateRowUnitLabels,
|
||||||
|
type EstimateBalanceSnapshot,
|
||||||
|
type EstimateGroupSnapshot,
|
||||||
|
type EstimatePower,
|
||||||
|
type EstimateRowConnection,
|
||||||
|
type EstimateRowSnapshot,
|
||||||
|
type EstimateRowUnit,
|
||||||
|
} from "../../shared/estimate-balance/estimate-balance";
|
||||||
|
import {
|
||||||
|
addEstimateRow,
|
||||||
|
estimateTextLimits,
|
||||||
|
moveEstimateGroup,
|
||||||
|
removeEstimateGroup,
|
||||||
|
removeEstimateRow,
|
||||||
|
updateEstimateGroup,
|
||||||
|
updateEstimateRow,
|
||||||
|
type EstimateRowPatch,
|
||||||
|
} from "./estimate-balance-document";
|
||||||
|
import {
|
||||||
|
estimateNumberRanges,
|
||||||
|
formatAmpere,
|
||||||
|
formatDecimalInput,
|
||||||
|
formatKw,
|
||||||
|
parseGermanDecimal,
|
||||||
|
type DecimalRange,
|
||||||
|
formatGermanNumber,
|
||||||
|
} from "./estimate-balance-format";
|
||||||
|
import { DraftInput } from "./estimate-draft-input";
|
||||||
|
|
||||||
|
export type EstimateBalanceUpdate = (
|
||||||
|
update: (balance: EstimateBalanceSnapshot) => EstimateBalanceSnapshot
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
interface EstimateBalanceSheetProps {
|
||||||
|
balance: EstimateBalanceSnapshot;
|
||||||
|
disabled: boolean;
|
||||||
|
onAddGroup: () => void;
|
||||||
|
onChange: EstimateBalanceUpdate;
|
||||||
|
onEditGroup: (group: EstimateGroupSnapshot) => void;
|
||||||
|
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
||||||
|
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||||
|
threePhaseVoltageV: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leading columns up to cos φ; the power columns follow.
|
||||||
|
const LEADING_COLUMN_COUNT = 9;
|
||||||
|
const COLUMN_COUNT = 16;
|
||||||
|
|
||||||
|
/** Suggested supply values; free text stays possible. */
|
||||||
|
const SUPPLY_SUGGESTIONS = ["ELT", "MSR"];
|
||||||
|
const SUPPLY_LIST_ID = "estimate-supply-options";
|
||||||
|
|
||||||
|
function NumberInput({
|
||||||
|
ariaLabel,
|
||||||
|
disabled,
|
||||||
|
fieldKey,
|
||||||
|
onCommit,
|
||||||
|
onValidityChange,
|
||||||
|
range,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
ariaLabel: string;
|
||||||
|
disabled: boolean;
|
||||||
|
fieldKey: string;
|
||||||
|
onCommit: (value: number) => void;
|
||||||
|
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||||
|
range: DecimalRange;
|
||||||
|
value: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DraftInput
|
||||||
|
ariaLabel={ariaLabel}
|
||||||
|
className="estimate-cell-input is-numeric"
|
||||||
|
disabled={disabled}
|
||||||
|
format={(current: number) => formatDecimalInput(current)}
|
||||||
|
inputMode="decimal"
|
||||||
|
printFormat={(current: number) => formatGermanNumber(current, 0, 3)}
|
||||||
|
onCommit={onCommit}
|
||||||
|
onValidityChange={(isValid) => onValidityChange(fieldKey, isValid)}
|
||||||
|
parse={(text) => parseGermanDecimal(text, range)}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function supplyOptions(balance: EstimateBalanceSnapshot): string[] {
|
||||||
|
const used = balance.groups.flatMap((group) =>
|
||||||
|
group.rows.map((row) => row.supply?.trim() ?? "")
|
||||||
|
);
|
||||||
|
return [...new Set([...SUPPLY_SUGGESTIONS, ...used.filter(Boolean)])];
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextInput({
|
||||||
|
ariaLabel,
|
||||||
|
disabled,
|
||||||
|
list,
|
||||||
|
maxLength,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
ariaLabel: string;
|
||||||
|
disabled: boolean;
|
||||||
|
list?: string;
|
||||||
|
maxLength: number;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
value: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="estimate-cell-input"
|
||||||
|
disabled={disabled}
|
||||||
|
list={list}
|
||||||
|
maxLength={maxLength}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
type="text"
|
||||||
|
value={value ?? ""}
|
||||||
|
/>
|
||||||
|
<span className="estimate-print-value">{value ?? ""}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconButton({
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
variant = "secondary",
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
disabled: boolean;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
variant?: "secondary" | "danger" | "primary";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
aria-label={label}
|
||||||
|
className={`btn btn-sm btn-outline-${variant} estimate-icon-button`}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
title={label}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PowerCells({ power, className = "" }: { power: EstimatePower; className?: string }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td className={`estimate-number ${className}`}>{formatKw(power.kw)}</td>
|
||||||
|
<td className={`estimate-number ${className}`}>{formatKw(power.kva)}</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupLabel(group: EstimateGroupSnapshot) {
|
||||||
|
return [group.costGroup, group.title].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function EstimateRowLine({
|
||||||
|
disabled,
|
||||||
|
group,
|
||||||
|
onChange,
|
||||||
|
onOpenGzfPicker,
|
||||||
|
onValidityChange,
|
||||||
|
row,
|
||||||
|
rowIndex,
|
||||||
|
}: {
|
||||||
|
disabled: boolean;
|
||||||
|
group: EstimateGroupSnapshot;
|
||||||
|
onChange: EstimateBalanceUpdate;
|
||||||
|
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
||||||
|
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
||||||
|
row: EstimateRowSnapshot;
|
||||||
|
rowIndex: number;
|
||||||
|
}) {
|
||||||
|
const result = calculateEstimateRow(row);
|
||||||
|
const rowLabel = `${groupLabel(group)}, Zeile ${rowIndex + 1}`;
|
||||||
|
const fieldKey = (field: string) => `${row.id}:${field}`;
|
||||||
|
|
||||||
|
function patch(rowPatch: EstimateRowPatch) {
|
||||||
|
onChange((balance) => updateEstimateRow(balance, group.id, row.id, rowPatch));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className="estimate-row">
|
||||||
|
<td className="estimate-cost-group-cell" />
|
||||||
|
<td>
|
||||||
|
<TextInput
|
||||||
|
ariaLabel={`Versorgung (${rowLabel})`}
|
||||||
|
disabled={disabled}
|
||||||
|
list={SUPPLY_LIST_ID}
|
||||||
|
maxLength={estimateTextLimits.supply}
|
||||||
|
onChange={(supply) => patch({ supply })}
|
||||||
|
placeholder="ELT"
|
||||||
|
value={row.supply}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="estimate-name-cell">
|
||||||
|
<TextInput
|
||||||
|
ariaLabel={`Verbraucher / Fläche (${rowLabel})`}
|
||||||
|
disabled={disabled}
|
||||||
|
maxLength={estimateTextLimits.rowName}
|
||||||
|
onChange={(name) => patch({ name })}
|
||||||
|
placeholder="Verbraucher oder Fläche"
|
||||||
|
value={row.name}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<NumberInput
|
||||||
|
ariaLabel={`Anzahl / Fläche (${rowLabel})`}
|
||||||
|
disabled={disabled}
|
||||||
|
fieldKey={fieldKey("quantity")}
|
||||||
|
onCommit={(quantity) => patch({ quantity })}
|
||||||
|
onValidityChange={onValidityChange}
|
||||||
|
range={estimateNumberRanges.quantity}
|
||||||
|
value={row.quantity}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
aria-label={`Einheit (${rowLabel})`}
|
||||||
|
className="estimate-cell-select"
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => patch({ unit: event.target.value as EstimateRowUnit })}
|
||||||
|
value={row.unit}
|
||||||
|
>
|
||||||
|
{(Object.keys(estimateRowUnitLabels) as EstimateRowUnit[]).map((unit) => (
|
||||||
|
<option key={unit} value={unit}>
|
||||||
|
{estimateRowUnitLabels[unit]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="estimate-print-value">{estimateRowUnitLabels[row.unit]}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
aria-label={`Anschluss (${rowLabel})`}
|
||||||
|
className="estimate-cell-select"
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) =>
|
||||||
|
patch({
|
||||||
|
connection: (event.target.value || null) as EstimateRowConnection | null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
value={row.connection ?? ""}
|
||||||
|
>
|
||||||
|
<option value="">–</option>
|
||||||
|
{(Object.keys(estimateRowConnectionLabels) as EstimateRowConnection[]).map(
|
||||||
|
(connection) => (
|
||||||
|
<option key={connection} value={connection}>
|
||||||
|
{estimateRowConnectionLabels[connection]}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
<span className="estimate-print-value">
|
||||||
|
{row.connection ? estimateRowConnectionLabels[row.connection] : ""}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="estimate-unit-input">
|
||||||
|
<NumberInput
|
||||||
|
ariaLabel={`Einzelleistung in ${row.unit === "area" ? "W/m²" : "W"} (${rowLabel})`}
|
||||||
|
disabled={disabled}
|
||||||
|
fieldKey={fieldKey("powerPerUnitW")}
|
||||||
|
onCommit={(powerPerUnitW) => patch({ powerPerUnitW })}
|
||||||
|
onValidityChange={onValidityChange}
|
||||||
|
range={estimateNumberRanges.powerPerUnitW}
|
||||||
|
value={row.powerPerUnitW}
|
||||||
|
/>
|
||||||
|
<span className="estimate-unit-label">{row.unit === "area" ? "W/m²" : "W"}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="estimate-gzf-input">
|
||||||
|
<NumberInput
|
||||||
|
ariaLabel={`Gleichzeitigkeitsfaktor (${rowLabel})`}
|
||||||
|
disabled={disabled}
|
||||||
|
fieldKey={fieldKey("simultaneityFactor")}
|
||||||
|
onCommit={(simultaneityFactor) => patch({ simultaneityFactor })}
|
||||||
|
onValidityChange={onValidityChange}
|
||||||
|
range={estimateNumberRanges.simultaneityFactor}
|
||||||
|
value={row.simultaneityFactor}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
aria-label={`Gleichzeitigkeitsfaktor nach AMEV wählen (${rowLabel})`}
|
||||||
|
className="estimate-amev-button estimate-no-print"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => onOpenGzfPicker(group.id, row.id)}
|
||||||
|
title="Gleichzeitigkeitsfaktor nach AMEV wählen"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
AMEV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<NumberInput
|
||||||
|
ariaLabel={`cos φ (${rowLabel})`}
|
||||||
|
disabled={disabled}
|
||||||
|
fieldKey={fieldKey("cosPhi")}
|
||||||
|
onCommit={(cosPhi) => patch({ cosPhi })}
|
||||||
|
onValidityChange={onValidityChange}
|
||||||
|
range={estimateNumberRanges.cosPhi}
|
||||||
|
value={row.cosPhi}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<PowerCells power={result.av} />
|
||||||
|
<td className="estimate-center">
|
||||||
|
<input
|
||||||
|
aria-label={`Sicherheitsversorgung (${rowLabel})`}
|
||||||
|
checked={row.safetySupply}
|
||||||
|
className="form-check-input estimate-no-print"
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => patch({ safetySupply: event.target.checked })}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span className="estimate-print-value">{row.safetySupply ? "ja" : "nein"}</span>
|
||||||
|
</td>
|
||||||
|
<PowerCells
|
||||||
|
className={row.safetySupply ? "" : "is-muted"}
|
||||||
|
power={result.sv}
|
||||||
|
/>
|
||||||
|
<td>
|
||||||
|
<TextInput
|
||||||
|
ariaLabel={`Anmerkung (${rowLabel})`}
|
||||||
|
disabled={disabled}
|
||||||
|
maxLength={estimateTextLimits.remark}
|
||||||
|
onChange={(remark) => patch({ remark })}
|
||||||
|
value={row.remark}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="estimate-actions-cell estimate-no-print">
|
||||||
|
<IconButton
|
||||||
|
disabled={disabled}
|
||||||
|
label={`Zeile löschen (${rowLabel})`}
|
||||||
|
onClick={() => onChange((balance) => removeEstimateRow(balance, group.id, row.id))}
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" size={13} />
|
||||||
|
</IconButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spreadsheet-like editor in the layout of the office template
|
||||||
|
* "Leistungsbilanz – KG400": one section per cost group with title and
|
||||||
|
* subtotal row, totals, reserve and maximum current at the end.
|
||||||
|
*/
|
||||||
|
export function EstimateBalanceSheet({
|
||||||
|
balance,
|
||||||
|
disabled,
|
||||||
|
onAddGroup,
|
||||||
|
onChange,
|
||||||
|
onEditGroup,
|
||||||
|
onOpenGzfPicker,
|
||||||
|
onValidityChange,
|
||||||
|
threePhaseVoltageV,
|
||||||
|
}: EstimateBalanceSheetProps) {
|
||||||
|
const result = calculateEstimateBalance(balance, threePhaseVoltageV);
|
||||||
|
|
||||||
|
function confirmRemoveGroup(group: EstimateGroupSnapshot) {
|
||||||
|
const rowText =
|
||||||
|
group.rows.length === 1 ? "1 Zeile" : `${group.rows.length} Zeilen`;
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
`Kostengruppe „${groupLabel(group)}“ mit ${rowText} wirklich löschen?`
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
onChange((current) => removeEstimateGroup(current, group.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="estimate-sheet-wrap">
|
||||||
|
<datalist id={SUPPLY_LIST_ID}>
|
||||||
|
{supplyOptions(balance).map((supply) => (
|
||||||
|
<option key={supply} value={supply} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<table className="estimate-sheet">
|
||||||
|
<colgroup>
|
||||||
|
<col className="estimate-col-kg" />
|
||||||
|
<col className="estimate-col-supply" />
|
||||||
|
<col className="estimate-col-name" />
|
||||||
|
<col className="estimate-col-quantity" />
|
||||||
|
<col className="estimate-col-unit" />
|
||||||
|
<col className="estimate-col-connection" />
|
||||||
|
<col className="estimate-col-power" />
|
||||||
|
<col className="estimate-col-gzf" />
|
||||||
|
<col className="estimate-col-cosphi" />
|
||||||
|
<col className="estimate-col-result" />
|
||||||
|
<col className="estimate-col-result" />
|
||||||
|
<col className="estimate-col-sv" />
|
||||||
|
<col className="estimate-col-result" />
|
||||||
|
<col className="estimate-col-result" />
|
||||||
|
<col className="estimate-col-remark" />
|
||||||
|
<col className="estimate-col-actions" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th rowSpan={2}>KG</th>
|
||||||
|
<th rowSpan={2}>
|
||||||
|
Versorgung
|
||||||
|
<span className="estimate-th-hint">ELT/MSR …</span>
|
||||||
|
</th>
|
||||||
|
<th rowSpan={2}>Verbraucher / Flächen</th>
|
||||||
|
<th colSpan={2}>
|
||||||
|
Anzahl / Fläche
|
||||||
|
<span className="estimate-th-hint">St. / m²</span>
|
||||||
|
</th>
|
||||||
|
<th rowSpan={2}>
|
||||||
|
Anschl.
|
||||||
|
<span className="estimate-th-hint">Fest/Steckd.</span>
|
||||||
|
</th>
|
||||||
|
<th rowSpan={2}>
|
||||||
|
Einzelleist.
|
||||||
|
<span className="estimate-th-hint">W bzw. W/m²</span>
|
||||||
|
</th>
|
||||||
|
<th rowSpan={2}>GL-Faktor</th>
|
||||||
|
<th rowSpan={2}>cos φ</th>
|
||||||
|
<th colSpan={2}>Nötige AV-Leistung</th>
|
||||||
|
<th colSpan={3}>Sicherheitsversorgung (SV)</th>
|
||||||
|
<th rowSpan={2}>Anmerkungen</th>
|
||||||
|
<th aria-label="Aktionen" className="estimate-no-print" rowSpan={2} />
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Wert</th>
|
||||||
|
<th>Einh.</th>
|
||||||
|
<th>kW</th>
|
||||||
|
<th>kVA</th>
|
||||||
|
<th>ja/nein</th>
|
||||||
|
<th>kW</th>
|
||||||
|
<th>kVA</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
{balance.groups.map((group, groupIndex) => {
|
||||||
|
const groupResult = result.groups[groupIndex]!;
|
||||||
|
const label = groupLabel(group) || "Kostengruppe";
|
||||||
|
return (
|
||||||
|
<tbody className="estimate-group" key={group.id}>
|
||||||
|
<tr className="estimate-group-title-row">
|
||||||
|
<td className="estimate-cost-group-cell">{group.costGroup}</td>
|
||||||
|
<td colSpan={COLUMN_COUNT - 2}>
|
||||||
|
<div className="estimate-group-title">
|
||||||
|
<span className="estimate-group-name">{group.title}</span>
|
||||||
|
<label
|
||||||
|
className={
|
||||||
|
group.editor?.trim()
|
||||||
|
? "estimate-group-editor"
|
||||||
|
: "estimate-group-editor is-empty"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>bearbeitet von</span>
|
||||||
|
<input
|
||||||
|
aria-label={`Bearbeitet von (${label})`}
|
||||||
|
className="estimate-cell-input"
|
||||||
|
disabled={disabled}
|
||||||
|
maxLength={estimateTextLimits.groupEditor}
|
||||||
|
onChange={(event) =>
|
||||||
|
onChange((current) =>
|
||||||
|
updateEstimateGroup(current, group.id, {
|
||||||
|
editor: event.target.value,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Gewerk / Name"
|
||||||
|
type="text"
|
||||||
|
value={group.editor ?? ""}
|
||||||
|
/>
|
||||||
|
<span className="estimate-print-value">{group.editor ?? ""}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="estimate-actions-cell estimate-no-print">
|
||||||
|
<div className="estimate-group-actions">
|
||||||
|
<IconButton
|
||||||
|
disabled={disabled}
|
||||||
|
label={`Zeile hinzufügen (${label})`}
|
||||||
|
onClick={() =>
|
||||||
|
onChange((current) => addEstimateRow(current, group.id).balance)
|
||||||
|
}
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" size={13} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
disabled={disabled}
|
||||||
|
label={`Gruppe bearbeiten (${label})`}
|
||||||
|
onClick={() => onEditGroup(group)}
|
||||||
|
>
|
||||||
|
<Pencil aria-hidden="true" size={13} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
disabled={disabled || groupIndex === 0}
|
||||||
|
label={`Gruppe nach oben (${label})`}
|
||||||
|
onClick={() =>
|
||||||
|
onChange((current) => moveEstimateGroup(current, group.id, -1))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowUp aria-hidden="true" size={13} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
disabled={disabled || groupIndex === balance.groups.length - 1}
|
||||||
|
label={`Gruppe nach unten (${label})`}
|
||||||
|
onClick={() =>
|
||||||
|
onChange((current) => moveEstimateGroup(current, group.id, 1))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ArrowDown aria-hidden="true" size={13} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
disabled={disabled}
|
||||||
|
label={`Gruppe löschen (${label})`}
|
||||||
|
onClick={() => confirmRemoveGroup(group)}
|
||||||
|
variant="danger"
|
||||||
|
>
|
||||||
|
<Trash2 aria-hidden="true" size={13} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{group.rows.map((row, rowIndex) => (
|
||||||
|
<EstimateRowLine
|
||||||
|
disabled={disabled}
|
||||||
|
group={group}
|
||||||
|
key={row.id}
|
||||||
|
onChange={onChange}
|
||||||
|
onOpenGzfPicker={onOpenGzfPicker}
|
||||||
|
onValidityChange={onValidityChange}
|
||||||
|
row={row}
|
||||||
|
rowIndex={rowIndex}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{!group.rows.length ? (
|
||||||
|
<tr className="estimate-empty-row estimate-no-print">
|
||||||
|
<td />
|
||||||
|
<td colSpan={COLUMN_COUNT - 1}>
|
||||||
|
Noch keine Einträge – mit „+“ eine Zeile hinzufügen.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
<tr className="estimate-subtotal-row">
|
||||||
|
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||||
|
Summe {label}
|
||||||
|
</td>
|
||||||
|
<PowerCells power={groupResult.av} />
|
||||||
|
<td />
|
||||||
|
<PowerCells power={groupResult.sv} />
|
||||||
|
<td />
|
||||||
|
<td className="estimate-no-print" />
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<tfoot>
|
||||||
|
<tr className="estimate-no-print estimate-add-group-row">
|
||||||
|
<td colSpan={COLUMN_COUNT}>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline-primary"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onAddGroup}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus aria-hidden="true" className="me-1" size={14} />
|
||||||
|
Kostengruppe hinzufügen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr className="estimate-total-row">
|
||||||
|
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||||
|
Gesamtsumme
|
||||||
|
</td>
|
||||||
|
<PowerCells power={result.total.av} />
|
||||||
|
<td />
|
||||||
|
<PowerCells power={result.total.sv} />
|
||||||
|
<td colSpan={2} />
|
||||||
|
</tr>
|
||||||
|
<tr className="estimate-total-row">
|
||||||
|
<td colSpan={LEADING_COLUMN_COUNT - 1} className="estimate-total-label">
|
||||||
|
Reserveleistung
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="estimate-unit-input">
|
||||||
|
<NumberInput
|
||||||
|
ariaLabel="Reserve AV in Prozent"
|
||||||
|
disabled={disabled}
|
||||||
|
fieldKey="balance:reserveAvPercent"
|
||||||
|
onCommit={(reserveAvPercent) =>
|
||||||
|
onChange((current) => ({ ...current, reserveAvPercent }))
|
||||||
|
}
|
||||||
|
onValidityChange={onValidityChange}
|
||||||
|
range={estimateNumberRanges.reservePercent}
|
||||||
|
value={balance.reserveAvPercent}
|
||||||
|
/>
|
||||||
|
<span className="estimate-unit-label">%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<PowerCells power={result.reserve.av} />
|
||||||
|
<td>
|
||||||
|
<div className="estimate-unit-input">
|
||||||
|
<NumberInput
|
||||||
|
ariaLabel="Reserve SV in Prozent"
|
||||||
|
disabled={disabled}
|
||||||
|
fieldKey="balance:reserveSvPercent"
|
||||||
|
onCommit={(reserveSvPercent) =>
|
||||||
|
onChange((current) => ({ ...current, reserveSvPercent }))
|
||||||
|
}
|
||||||
|
onValidityChange={onValidityChange}
|
||||||
|
range={estimateNumberRanges.reservePercent}
|
||||||
|
value={balance.reserveSvPercent}
|
||||||
|
/>
|
||||||
|
<span className="estimate-unit-label">%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<PowerCells power={result.reserve.sv} />
|
||||||
|
<td colSpan={2} />
|
||||||
|
</tr>
|
||||||
|
<tr className="estimate-total-row is-strong">
|
||||||
|
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||||
|
Leistung inkl. Reserve
|
||||||
|
</td>
|
||||||
|
<PowerCells power={result.withReserve.av} />
|
||||||
|
<td />
|
||||||
|
<PowerCells power={result.withReserve.sv} />
|
||||||
|
<td colSpan={2} />
|
||||||
|
</tr>
|
||||||
|
<tr className="estimate-total-row">
|
||||||
|
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
||||||
|
Maximalstrom
|
||||||
|
<span className="estimate-total-hint">
|
||||||
|
{threePhaseVoltageV > 0
|
||||||
|
? ` (3~, U = ${formatDecimalInput(threePhaseVoltageV)} V)`
|
||||||
|
: " (Drehstromspannung im Projekt fehlt)"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="estimate-number" colSpan={2}>
|
||||||
|
{formatAmpere(result.maxCurrentA.av)} A
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
<td className="estimate-number" colSpan={2}>
|
||||||
|
{formatAmpere(result.maxCurrentA.sv)} A
|
||||||
|
</td>
|
||||||
|
<td colSpan={2} />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/frontend/estimate-balance/estimate-comparison-table.tsx
Normal file
88
src/frontend/estimate-balance/estimate-comparison-table.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
roundEstimateValue,
|
||||||
|
type EstimateComparison,
|
||||||
|
} from "../../shared/estimate-balance/estimate-balance";
|
||||||
|
import { formatDeltaPercent, formatKw, formatSignedKw } from "./estimate-balance-format";
|
||||||
|
|
||||||
|
function deltaClass(deltaKw: number) {
|
||||||
|
if (roundEstimateValue(deltaKw, 2) === 0) {
|
||||||
|
return "estimate-number";
|
||||||
|
}
|
||||||
|
return deltaKw > 0 ? "estimate-number is-over" : "estimate-number is-under";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estimated AV power per cost group vs. the detailed circuit-list rows. */
|
||||||
|
export function EstimateComparisonTable({ comparison }: { comparison: EstimateComparison }) {
|
||||||
|
return (
|
||||||
|
<div className="estimate-sheet-wrap">
|
||||||
|
<table className="estimate-sheet estimate-comparison">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>KG</th>
|
||||||
|
<th>Titel</th>
|
||||||
|
<th>geschätzt AV kW</th>
|
||||||
|
<th>detailliert kW</th>
|
||||||
|
<th>Δ kW</th>
|
||||||
|
<th>Δ %</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{comparison.groups.map((group) => (
|
||||||
|
<tr key={group.groupId}>
|
||||||
|
<td className="estimate-cost-group-cell">{group.costGroup}</td>
|
||||||
|
<td>
|
||||||
|
{group.title}
|
||||||
|
{group.detailedRowCount ? (
|
||||||
|
<span className="estimate-th-hint">
|
||||||
|
{group.detailedRowCount === 1
|
||||||
|
? "1 Gerätezeile"
|
||||||
|
: `${group.detailedRowCount} Gerätezeilen`}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="estimate-number">{formatKw(group.estimatedAv.kw)}</td>
|
||||||
|
<td className="estimate-number">{formatKw(group.detailed.kw)}</td>
|
||||||
|
<td className={deltaClass(group.deltaKw)}>{formatSignedKw(group.deltaKw)}</td>
|
||||||
|
<td className={deltaClass(group.deltaKw)}>
|
||||||
|
{formatDeltaPercent(group.deltaPercent)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="estimate-unassigned-row">
|
||||||
|
<td />
|
||||||
|
<td>
|
||||||
|
ohne Kostengruppe / nicht zugeordnet
|
||||||
|
{comparison.unassigned.rowCount ? (
|
||||||
|
<span className="estimate-th-hint">
|
||||||
|
{comparison.unassigned.rowCount === 1
|
||||||
|
? "1 Gerätezeile"
|
||||||
|
: `${comparison.unassigned.rowCount} Gerätezeilen`}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="estimate-number">–</td>
|
||||||
|
<td className="estimate-number">{formatKw(comparison.unassigned.kw)}</td>
|
||||||
|
<td className="estimate-number">–</td>
|
||||||
|
<td className="estimate-number">–</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="estimate-total-row is-strong">
|
||||||
|
<td />
|
||||||
|
<td className="estimate-total-label">Summe</td>
|
||||||
|
<td className="estimate-number">{formatKw(comparison.total.estimatedAv.kw)}</td>
|
||||||
|
<td className="estimate-number">{formatKw(comparison.total.detailed.kw)}</td>
|
||||||
|
<td className={deltaClass(comparison.total.deltaKw)}>
|
||||||
|
{formatSignedKw(comparison.total.deltaKw)}
|
||||||
|
</td>
|
||||||
|
<td className={deltaClass(comparison.total.deltaKw)}>
|
||||||
|
{formatDeltaPercent(comparison.total.deltaPercent)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/frontend/estimate-balance/estimate-draft-input.tsx
Normal file
112
src/frontend/estimate-balance/estimate-draft-input.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export type DraftParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
|
||||||
|
|
||||||
|
interface DraftInputProps<T> {
|
||||||
|
ariaLabel: string;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
format: (value: T) => string;
|
||||||
|
/** Printed text; defaults to the input text. */
|
||||||
|
printFormat?: (value: T) => string;
|
||||||
|
inputMode?: "decimal" | "text";
|
||||||
|
maxLength?: number;
|
||||||
|
onCommit: (value: T) => void;
|
||||||
|
/** Reports whether the raw text is currently valid (false while invalid). */
|
||||||
|
onValidityChange?: (isValid: boolean) => void;
|
||||||
|
parse: (text: string) => DraftParseResult<T>;
|
||||||
|
placeholder?: string;
|
||||||
|
value: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Input that keeps the raw text the user typed. Only a valid text is
|
||||||
|
* committed into the document; an invalid text stays visible with a red
|
||||||
|
* border and is never saved. A value changed from outside (reload, undo)
|
||||||
|
* replaces the text unless the text already represents that value.
|
||||||
|
*/
|
||||||
|
export function DraftInput<T>({
|
||||||
|
ariaLabel,
|
||||||
|
className = "estimate-cell-input",
|
||||||
|
disabled,
|
||||||
|
format,
|
||||||
|
printFormat,
|
||||||
|
inputMode = "text",
|
||||||
|
maxLength,
|
||||||
|
onCommit,
|
||||||
|
onValidityChange,
|
||||||
|
parse,
|
||||||
|
placeholder,
|
||||||
|
value,
|
||||||
|
}: DraftInputProps<T>) {
|
||||||
|
const [text, setText] = useState(() => format(value));
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [syncedValue, setSyncedValue] = useState(value);
|
||||||
|
|
||||||
|
if (!Object.is(syncedValue, value)) {
|
||||||
|
// Adjusting state while rendering (instead of an effect) avoids a frame
|
||||||
|
// with stale text after an external change.
|
||||||
|
setSyncedValue(value);
|
||||||
|
const parsed = parse(text);
|
||||||
|
if (!parsed.ok || !Object.is(parsed.value, value)) {
|
||||||
|
setText(format(value));
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validityRef = useRef(onValidityChange);
|
||||||
|
useEffect(() => {
|
||||||
|
validityRef.current = onValidityChange;
|
||||||
|
});
|
||||||
|
const isValid = error === null;
|
||||||
|
useEffect(() => {
|
||||||
|
validityRef.current?.(isValid);
|
||||||
|
}, [isValid]);
|
||||||
|
// A removed input (deleted row) must not leave an invalid marker behind.
|
||||||
|
useEffect(() => () => validityRef.current?.(true), []);
|
||||||
|
|
||||||
|
function handleChange(nextText: string) {
|
||||||
|
setText(nextText);
|
||||||
|
const parsed = parse(nextText);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
setError(parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
if (!Object.is(parsed.value, value)) {
|
||||||
|
setSyncedValue(parsed.value);
|
||||||
|
onCommit(parsed.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
// Show the canonical notation again ("0,80" → "0,8") once editing ends.
|
||||||
|
if (error === null) {
|
||||||
|
setText(format(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
aria-invalid={error ? true : undefined}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={error ? `${className} is-invalid` : className}
|
||||||
|
disabled={disabled}
|
||||||
|
inputMode={inputMode}
|
||||||
|
maxLength={maxLength}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onChange={(event) => handleChange(event.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
title={error ?? undefined}
|
||||||
|
type="text"
|
||||||
|
value={text}
|
||||||
|
/>
|
||||||
|
<span className="estimate-print-value">
|
||||||
|
{printFormat ? printFormat(value) : error ? format(value) : text}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/frontend/estimate-balance/estimate-group-modal.tsx
Normal file
128
src/frontend/estimate-balance/estimate-group-modal.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type FormEvent, useState } from "react";
|
||||||
|
import { FormModal } from "../components/form-modal";
|
||||||
|
import {
|
||||||
|
estimateDefaultGroups,
|
||||||
|
type EstimateGroupSnapshot,
|
||||||
|
} from "../../shared/estimate-balance/estimate-balance";
|
||||||
|
import { estimateTextLimits } from "./estimate-balance-document";
|
||||||
|
|
||||||
|
export interface EstimateGroupFormValues {
|
||||||
|
costGroup: string;
|
||||||
|
title: string;
|
||||||
|
editor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EstimateGroupModalProps {
|
||||||
|
group: EstimateGroupSnapshot | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (values: EstimateGroupFormValues) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates or edits the KG number, title and "bearbeitet von" of one group. */
|
||||||
|
export function EstimateGroupModal({ group, onClose, onSave }: EstimateGroupModalProps) {
|
||||||
|
const [costGroup, setCostGroup] = useState(group?.costGroup ?? "");
|
||||||
|
const [title, setTitle] = useState(group?.title ?? "");
|
||||||
|
const [editor, setEditor] = useState(group?.editor ?? "");
|
||||||
|
|
||||||
|
function applyTemplate(index: string) {
|
||||||
|
const template = estimateDefaultGroups[Number(index)];
|
||||||
|
if (template) {
|
||||||
|
setCostGroup(template.costGroup);
|
||||||
|
setTitle(template.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!title.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave({
|
||||||
|
costGroup: costGroup.trim(),
|
||||||
|
title: title.trim(),
|
||||||
|
editor: editor.trim() || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
isSaving={false}
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitDisabled={!title.trim()}
|
||||||
|
submitLabel={group ? "Übernehmen" : "Kostengruppe hinzufügen"}
|
||||||
|
title={group ? "Kostengruppe bearbeiten" : "Kostengruppe hinzufügen"}
|
||||||
|
>
|
||||||
|
<div className="row g-3">
|
||||||
|
{!group ? (
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label" htmlFor="estimate-group-template">
|
||||||
|
Aus Vorlage KG 400 übernehmen
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
defaultValue=""
|
||||||
|
id="estimate-group-template"
|
||||||
|
onChange={(event) => applyTemplate(event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">– Vorlage wählen –</option>
|
||||||
|
{estimateDefaultGroups.map((template, index) => (
|
||||||
|
<option key={`${template.costGroup}-${template.title}`} value={index}>
|
||||||
|
{[template.costGroup, template.title].filter(Boolean).join(" ")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div className="col-12 col-md-3">
|
||||||
|
<label className="form-label" htmlFor="estimate-group-cost-group">
|
||||||
|
KG (DIN 276)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="estimate-group-cost-group"
|
||||||
|
maxLength={estimateTextLimits.costGroup}
|
||||||
|
onChange={(event) => setCostGroup(event.target.value)}
|
||||||
|
placeholder="z. B. 440"
|
||||||
|
value={costGroup}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-9">
|
||||||
|
<label className="form-label" htmlFor="estimate-group-title">
|
||||||
|
Titel
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="estimate-group-title"
|
||||||
|
maxLength={estimateTextLimits.groupTitle}
|
||||||
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
|
placeholder="z. B. Starkstromanlagen"
|
||||||
|
required
|
||||||
|
value={title}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label" htmlFor="estimate-group-editor">
|
||||||
|
Bearbeitet von
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="estimate-group-editor"
|
||||||
|
maxLength={estimateTextLimits.groupEditor}
|
||||||
|
onChange={(event) => setEditor(event.target.value)}
|
||||||
|
placeholder="Gewerk oder Name (optional)"
|
||||||
|
value={editor}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<p className="text-secondary small mb-0">
|
||||||
|
Ohne KG-Nummer (z. B. „Allgemein“) wird die Gruppe im Vergleich mit der
|
||||||
|
detaillierten Bilanz keiner Kostengruppe zugeordnet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
src/frontend/estimate-balance/estimate-print-header.tsx
Normal file
104
src/frontend/estimate-balance/estimate-print-header.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { documentLogoUrl, getDocumentBranding } from "../utils/api";
|
||||||
|
import type { EstimateBalanceDto, OfficeProfileDto, ProjectDto } from "../types";
|
||||||
|
import { formatIsoDateGerman } from "./estimate-balance-format";
|
||||||
|
|
||||||
|
interface EstimatePrintHeaderProps {
|
||||||
|
project: ProjectDto;
|
||||||
|
balance: EstimateBalanceDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssString(value: string) {
|
||||||
|
return JSON.stringify(value.replace(/[<>\r\n]/g, " "));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print-only title block and page footer of the rough power balance in the
|
||||||
|
* kbp corporate design of the print view (Seitenansicht): logo, blue title,
|
||||||
|
* meta data, "Seite x von y". Hidden on screen.
|
||||||
|
*/
|
||||||
|
export function EstimatePrintHeader({ project, balance }: EstimatePrintHeaderProps) {
|
||||||
|
const [office, setOffice] = useState<OfficeProfileDto | null>(null);
|
||||||
|
const [hasLogo, setHasLogo] = useState(false);
|
||||||
|
const [logoVersion, setLogoVersion] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getDocumentBranding()
|
||||||
|
.then((branding) => {
|
||||||
|
setOffice(branding.office);
|
||||||
|
setHasLogo(Boolean(branding.logo));
|
||||||
|
setLogoVersion(branding.logo ? branding.logo.updatedAtIso : "");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setOffice(null);
|
||||||
|
setHasLogo(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stand = [balance.name, balance.workStage].filter(Boolean).join(" · ");
|
||||||
|
const date = balance.dateIso ? formatIsoDateGerman(balance.dateIso) : null;
|
||||||
|
const footer = `
|
||||||
|
@page estimate-balance {
|
||||||
|
size: A4 landscape;
|
||||||
|
margin: 10mm 10mm 13mm;
|
||||||
|
@bottom-left { content: ${cssString(`${project.name} · Grobe Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
|
||||||
|
@bottom-center { content: ${cssString(`Stand ${stand}${date ? ` · ${date}` : ""}`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
|
||||||
|
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 7.5pt Arial, sans-serif; color: #6b7280; }
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{footer}</style>
|
||||||
|
<header className="estimate-print-titleblock" aria-hidden="true">
|
||||||
|
<div className="estimate-print-logo">
|
||||||
|
{hasLogo ? (
|
||||||
|
<img alt="" src={`${documentLogoUrl}?v=${encodeURIComponent(logoVersion)}`} />
|
||||||
|
) : (
|
||||||
|
<span>{office?.officeName ?? ""}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="estimate-print-title">
|
||||||
|
<div className="estimate-print-kicker">Grobe Leistungsbilanz · KG 400</div>
|
||||||
|
<div className="estimate-print-heading">
|
||||||
|
<span className="estimate-print-stand">{balance.name}</span>
|
||||||
|
<span className="estimate-print-project">{project.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<dl className="estimate-print-meta">
|
||||||
|
<div>
|
||||||
|
<dt>Projekt-Nr.</dt>
|
||||||
|
<dd>{project.internalProjectNumber ?? "–"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Bauherr</dt>
|
||||||
|
<dd>{project.buildingOwner ?? "–"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Anschrift</dt>
|
||||||
|
<dd>{project.siteAddress?.split("\n").join(", ") ?? "–"}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<dl className="estimate-print-meta is-tinted">
|
||||||
|
<div>
|
||||||
|
<dt>Leistungsphase</dt>
|
||||||
|
<dd>{balance.workStage ?? "–"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Datum</dt>
|
||||||
|
<dd>{date ?? "–"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Bearbeitung</dt>
|
||||||
|
<dd>{project.projectEditor ?? "–"}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Büro</dt>
|
||||||
|
<dd>{office?.officeName ?? "–"}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</header>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -535,3 +535,34 @@ export interface CreateCircuitDeviceRowInputDto {
|
||||||
}
|
}
|
||||||
|
|
||||||
import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields";
|
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,
|
ExternalInitialImportPlanDto,
|
||||||
ApplyExternalInitialImportInput,
|
ApplyExternalInitialImportInput,
|
||||||
ExternalInitialImportResultDto,
|
ExternalInitialImportResultDto,
|
||||||
|
CreateEstimateBalanceInput,
|
||||||
|
EstimateBalanceCommandResultDto,
|
||||||
|
EstimateBalanceDto,
|
||||||
|
EstimateBalanceUpdateResultDto,
|
||||||
|
EstimateComparisonDto,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts";
|
import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts";
|
||||||
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
|
import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields";
|
||||||
|
|
@ -87,6 +92,30 @@ import type {
|
||||||
CircuitGroupSubtreeSnapshot,
|
CircuitGroupSubtreeSnapshot,
|
||||||
} from "../../domain/models/circuit-group-subtree-snapshot.model";
|
} 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> {
|
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...init,
|
...init,
|
||||||
|
|
@ -99,15 +128,23 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let details = await response.text();
|
let details = await response.text();
|
||||||
|
let code: string | null = null;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(details) as { error?: unknown };
|
const parsed = JSON.parse(details) as { error?: unknown; code?: unknown };
|
||||||
if (typeof parsed.error === "string") {
|
if (typeof parsed.error === "string") {
|
||||||
details = parsed.error;
|
details = parsed.error;
|
||||||
}
|
}
|
||||||
|
if (typeof parsed.code === "string") {
|
||||||
|
code = parsed.code;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Keep non-JSON error responses unchanged.
|
// 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) {
|
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":
|
"external-object.unassign-and-delete-created-row":
|
||||||
"Externes Objekt aus erzeugter Gerätezeile gelöst",
|
"Externes Objekt aus erzeugter Gerätezeile gelöst",
|
||||||
"external-object.update-row-assignment": "Externe Objektzuordnung geändert",
|
"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(
|
export function getProjectRevisionSourceLabel(
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { CircuitProtectionDeviceRepository } from "../../db/repositories/circuit
|
||||||
import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js";
|
import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js";
|
||||||
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
|
||||||
import { BuildingRepository } from "../../db/repositories/building.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 { FloorRepository } from "../../db/repositories/floor.repository.js";
|
||||||
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
|
||||||
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
|
||||||
|
|
@ -28,6 +29,7 @@ export const distributionBoardComponentRepository =
|
||||||
export const distributionBoardRepository =
|
export const distributionBoardRepository =
|
||||||
new DistributionBoardRepository(db);
|
new DistributionBoardRepository(db);
|
||||||
export const buildingRepository = new BuildingRepository(db);
|
export const buildingRepository = new BuildingRepository(db);
|
||||||
|
export const estimateBalanceRepository = new EstimateBalanceRepository(db);
|
||||||
export const floorRepository = new FloorRepository(db);
|
export const floorRepository = new FloorRepository(db);
|
||||||
export const globalDeviceRepository = new GlobalDeviceRepository(db);
|
export const globalDeviceRepository = new GlobalDeviceRepository(db);
|
||||||
export const projectDeviceRepository = new ProjectDeviceRepository(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 { 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 { 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 { 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 circuitProjectCommandStore = new CircuitProjectCommandRepository(db);
|
||||||
export const circuitDeviceRowProjectCommandStore =
|
export const circuitDeviceRowProjectCommandStore =
|
||||||
|
|
@ -80,6 +81,8 @@ export const externalObjectNewRowProjectCommandStore =
|
||||||
new ExternalObjectNewRowProjectCommandRepository(db);
|
new ExternalObjectNewRowProjectCommandRepository(db);
|
||||||
export const externalObjectNewCircuitProjectCommandStore =
|
export const externalObjectNewCircuitProjectCommandStore =
|
||||||
new ExternalObjectNewCircuitProjectCommandRepository(db);
|
new ExternalObjectNewCircuitProjectCommandRepository(db);
|
||||||
|
export const estimateBalanceProjectCommandStore =
|
||||||
|
new EstimateBalanceProjectCommandRepository(db);
|
||||||
export const projectCommandService = new ProjectCommandService(
|
export const projectCommandService = new ProjectCommandService(
|
||||||
circuitProjectCommandStore,
|
circuitProjectCommandStore,
|
||||||
circuitDeviceRowProjectCommandStore,
|
circuitDeviceRowProjectCommandStore,
|
||||||
|
|
@ -107,5 +110,6 @@ export const projectCommandService = new ProjectCommandService(
|
||||||
externalInitialImportProjectCommandStore,
|
externalInitialImportProjectCommandStore,
|
||||||
externalObjectRowAssignmentProjectCommandStore,
|
externalObjectRowAssignmentProjectCommandStore,
|
||||||
externalObjectNewRowProjectCommandStore,
|
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";
|
} from "../controllers/building.controller.js";
|
||||||
import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js";
|
import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js";
|
||||||
import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.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 { getCircuitTree } from "../controllers/circuit-tree.controller.js";
|
||||||
import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js";
|
import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -118,3 +126,12 @@ projectRouter.get("/:projectId/rooms", listRoomsByProject);
|
||||||
projectRouter.post("/:projectId/rooms", createRoom);
|
projectRouter.post("/:projectId/rooms", createRoom);
|
||||||
projectRouter.put("/:projectId/rooms/:roomId", updateRoom);
|
projectRouter.put("/:projectId/rooms/:roomId", updateRoom);
|
||||||
projectRouter.delete("/:projectId/rooms/:roomId", deleteRoom);
|
projectRouter.delete("/:projectId/rooms/:roomId", deleteRoom);
|
||||||
|
projectRouter.get("/:projectId/estimate-balances", listEstimateBalances);
|
||||||
|
projectRouter.post("/:projectId/estimate-balances", createEstimateBalance);
|
||||||
|
projectRouter.get("/:projectId/estimate-balances/:balanceId", getEstimateBalance);
|
||||||
|
projectRouter.put("/:projectId/estimate-balances/:balanceId", updateEstimateBalance);
|
||||||
|
projectRouter.delete("/:projectId/estimate-balances/:balanceId", deleteEstimateBalance);
|
||||||
|
projectRouter.get(
|
||||||
|
"/:projectId/estimate-balances/:balanceId/comparison",
|
||||||
|
getEstimateComparison
|
||||||
|
);
|
||||||
|
|
|
||||||
314
src/shared/estimate-balance/estimate-balance.ts
Normal file
314
src/shared/estimate-balance/estimate-balance.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
/**
|
||||||
|
* Grobe Leistungsbilanz (LPH 2/3): shared data shapes and pure calculation.
|
||||||
|
*
|
||||||
|
* Used by the API (validation, comparison) and by the frontend (live totals),
|
||||||
|
* so this file has no imports: Turbopack resolves frontend imports without
|
||||||
|
* the `.js` suffix the API build needs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type EstimateRowUnit = "piece" | "area";
|
||||||
|
export type EstimateRowConnection = "fixed" | "socket";
|
||||||
|
|
||||||
|
export interface EstimateRowSnapshot {
|
||||||
|
id: string;
|
||||||
|
sortOrder: number;
|
||||||
|
/** Versorgung, e.g. "ELT" or "MSR" (free text). */
|
||||||
|
supply: string | null;
|
||||||
|
/** Verbraucher / Fläche. May be empty while a row is being typed. */
|
||||||
|
name: string;
|
||||||
|
/** Anzahl (unit "piece") or area in m² (unit "area"). */
|
||||||
|
quantity: number;
|
||||||
|
unit: EstimateRowUnit;
|
||||||
|
connection: EstimateRowConnection | null;
|
||||||
|
/** W per piece or W/m². */
|
||||||
|
powerPerUnitW: number;
|
||||||
|
simultaneityFactor: number;
|
||||||
|
cosPhi: number;
|
||||||
|
/** Also needed on the safety power supply (SV). */
|
||||||
|
safetySupply: boolean;
|
||||||
|
remark: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstimateGroupSnapshot {
|
||||||
|
id: string;
|
||||||
|
/** DIN 276 cost group, e.g. "410" or "434"; empty for "Allgemein". */
|
||||||
|
costGroup: string;
|
||||||
|
title: string;
|
||||||
|
/** Trade or person responsible for the entries ("bearbeitet von"). */
|
||||||
|
editor: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
rows: EstimateRowSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstimateBalanceSnapshot {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
/** Stand, e.g. "Vorplanung" or "Entwurfsplanung". */
|
||||||
|
name: string;
|
||||||
|
workStage: string | null;
|
||||||
|
/** ISO date YYYY-MM-DD. */
|
||||||
|
dateIso: string | null;
|
||||||
|
reserveAvPercent: number;
|
||||||
|
reserveSvPercent: number;
|
||||||
|
sortOrder: number;
|
||||||
|
groups: EstimateGroupSnapshot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default structure of the office template "Leistungsbilanz - KG400". */
|
||||||
|
export const estimateDefaultGroups: ReadonlyArray<{ costGroup: string; title: string }> = [
|
||||||
|
{ costGroup: "410", title: "Sanitärtechnik" },
|
||||||
|
{ costGroup: "420", title: "Wärmeversorgung" },
|
||||||
|
{ costGroup: "430", title: "Lufttechnische Anlagen" },
|
||||||
|
{ costGroup: "434", title: "Kälteanlagen" },
|
||||||
|
{ costGroup: "440", title: "Starkstromanlagen" },
|
||||||
|
{ costGroup: "450", title: "Schwachstromanlagen" },
|
||||||
|
{ costGroup: "460", title: "Fördertechnik" },
|
||||||
|
{ costGroup: "470", title: "Nutzungsspezifische Anlagen" },
|
||||||
|
{ costGroup: "480", title: "Gebäudeautomation" },
|
||||||
|
{ costGroup: "", title: "Allgemein" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const estimateRowUnitLabels: Record<EstimateRowUnit, string> = {
|
||||||
|
piece: "St.",
|
||||||
|
area: "m²",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const estimateRowConnectionLabels: Record<EstimateRowConnection, string> = {
|
||||||
|
fixed: "Fest",
|
||||||
|
socket: "Steckd.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface EstimatePower {
|
||||||
|
kw: number;
|
||||||
|
kva: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstimateRowResult {
|
||||||
|
av: EstimatePower;
|
||||||
|
sv: EstimatePower;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstimateGroupResult {
|
||||||
|
groupId: string;
|
||||||
|
av: EstimatePower;
|
||||||
|
sv: EstimatePower;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstimateBalanceResult {
|
||||||
|
groups: EstimateGroupResult[];
|
||||||
|
total: { av: EstimatePower; sv: EstimatePower };
|
||||||
|
reserve: { av: EstimatePower; sv: EstimatePower };
|
||||||
|
withReserve: { av: EstimatePower; sv: EstimatePower };
|
||||||
|
/** Maximum current of the three-phase supply in A (S / (√3 · U)). */
|
||||||
|
maxCurrentA: { av: number; sv: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rounds a calculated value for display like the spreadsheet does: binary
|
||||||
|
* floating point stores 40.175 as 40.17499…, so the scaled value is first
|
||||||
|
* cut to 6 decimals and only then rounded half away from zero
|
||||||
|
* (40.175 -> 40.18, -1.825 -> -1.83).
|
||||||
|
*/
|
||||||
|
export function roundEstimateValue(value: number, digits = 2): number {
|
||||||
|
const factor = 10 ** digits;
|
||||||
|
const scaled = Number((Math.abs(value) * factor).toFixed(6));
|
||||||
|
return (Math.sign(value) * Math.round(scaled)) / factor;
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeroPower = (): EstimatePower => ({ kw: 0, kva: 0 });
|
||||||
|
|
||||||
|
function addPower(left: EstimatePower, right: EstimatePower): EstimatePower {
|
||||||
|
return { kw: left.kw + right.kw, kva: left.kva + right.kva };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scalePower(power: EstimatePower, factor: number): EstimatePower {
|
||||||
|
return { kw: power.kw * factor, kva: power.kva * factor };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required power of one row: P = quantity · power per unit · GLF, S = P / cos φ.
|
||||||
|
* AV covers every consumer (normal supply); SV additionally lists the rows
|
||||||
|
* that must also be supplied by the safety power supply.
|
||||||
|
*/
|
||||||
|
export function calculateEstimateRow(
|
||||||
|
row: Pick<
|
||||||
|
EstimateRowSnapshot,
|
||||||
|
"quantity" | "powerPerUnitW" | "simultaneityFactor" | "cosPhi" | "safetySupply"
|
||||||
|
>
|
||||||
|
): EstimateRowResult {
|
||||||
|
const kw = (row.quantity * row.powerPerUnitW * row.simultaneityFactor) / 1000;
|
||||||
|
const kva = row.cosPhi > 0 ? kw / row.cosPhi : 0;
|
||||||
|
const av = { kw, kva };
|
||||||
|
return { av, sv: row.safetySupply ? { ...av } : zeroPower() };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateEstimateBalance(
|
||||||
|
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
|
||||||
|
threePhaseVoltageV: number
|
||||||
|
): EstimateBalanceResult {
|
||||||
|
const groups = balance.groups.map((group) =>
|
||||||
|
group.rows.reduce<EstimateGroupResult>(
|
||||||
|
(sum, row) => {
|
||||||
|
const result = calculateEstimateRow(row);
|
||||||
|
return {
|
||||||
|
groupId: group.id,
|
||||||
|
av: addPower(sum.av, result.av),
|
||||||
|
sv: addPower(sum.sv, result.sv),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{ groupId: group.id, av: zeroPower(), sv: zeroPower() }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const total = groups.reduce(
|
||||||
|
(sum, group) => ({ av: addPower(sum.av, group.av), sv: addPower(sum.sv, group.sv) }),
|
||||||
|
{ av: zeroPower(), sv: zeroPower() }
|
||||||
|
);
|
||||||
|
const reserve = {
|
||||||
|
av: scalePower(total.av, balance.reserveAvPercent / 100),
|
||||||
|
sv: scalePower(total.sv, balance.reserveSvPercent / 100),
|
||||||
|
};
|
||||||
|
const withReserve = {
|
||||||
|
av: addPower(total.av, reserve.av),
|
||||||
|
sv: addPower(total.sv, reserve.sv),
|
||||||
|
};
|
||||||
|
const currentFactor =
|
||||||
|
threePhaseVoltageV > 0 ? 1000 / (Math.sqrt(3) * threePhaseVoltageV) : 0;
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
total,
|
||||||
|
reserve,
|
||||||
|
withReserve,
|
||||||
|
maxCurrentA: {
|
||||||
|
av: withReserve.av.kva * currentFactor,
|
||||||
|
sv: withReserve.sv.kva * currentFactor,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigns a detailed cost group (e.g. "444" or "440.1") to the best matching
|
||||||
|
* estimate group: a group code with trailing zeros removed is a prefix
|
||||||
|
* ("430" -> "43", "434" -> "434"); the longest prefix wins. Returns null when
|
||||||
|
* no group matches.
|
||||||
|
*/
|
||||||
|
export function matchEstimateCostGroup(
|
||||||
|
detailedCostGroup: string | null,
|
||||||
|
groupCostGroups: readonly string[]
|
||||||
|
): string | null {
|
||||||
|
const digits = (detailedCostGroup ?? "").replace(/\D/g, "");
|
||||||
|
if (!digits) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let best: { code: string; length: number } | null = null;
|
||||||
|
for (const code of groupCostGroups) {
|
||||||
|
const codeDigits = code.replace(/\D/g, "");
|
||||||
|
const prefix = codeDigits.replace(/0+$/, "");
|
||||||
|
if (!prefix || !digits.startsWith(prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!best || prefix.length > best.length) {
|
||||||
|
best = { code, length: prefix.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best?.code ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstimateComparisonGroup {
|
||||||
|
groupId: string;
|
||||||
|
costGroup: string;
|
||||||
|
title: string;
|
||||||
|
estimatedAv: EstimatePower;
|
||||||
|
detailed: EstimatePower;
|
||||||
|
detailedRowCount: number;
|
||||||
|
deltaKw: number;
|
||||||
|
/** Relative deviation of the detailed value from the estimate, null without estimate. */
|
||||||
|
deltaPercent: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EstimateComparison {
|
||||||
|
groups: EstimateComparisonGroup[];
|
||||||
|
unassigned: EstimatePower & { rowCount: number };
|
||||||
|
total: {
|
||||||
|
estimatedAv: EstimatePower;
|
||||||
|
detailed: EstimatePower;
|
||||||
|
deltaKw: number;
|
||||||
|
deltaPercent: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailedDeviceRowPower {
|
||||||
|
costGroup: string | null;
|
||||||
|
/** Total power of the device row in kW (quantity · power · device GZF). */
|
||||||
|
kw: number;
|
||||||
|
cosPhi: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function delta(estimated: number, detailed: number) {
|
||||||
|
return {
|
||||||
|
deltaKw: detailed - estimated,
|
||||||
|
deltaPercent: estimated > 0 ? ((detailed - estimated) / estimated) * 100 : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compares the estimate per cost group with the detailed device rows of all
|
||||||
|
* circuit lists. The distribution-board simultaneity factor is not applied
|
||||||
|
* on either side; a missing cos φ counts as 1.
|
||||||
|
*/
|
||||||
|
export function compareEstimateWithDetailed(
|
||||||
|
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
|
||||||
|
detailedRows: readonly DetailedDeviceRowPower[]
|
||||||
|
): EstimateComparison {
|
||||||
|
const estimate = calculateEstimateBalance(balance, 0);
|
||||||
|
const codes = balance.groups.map((group) => group.costGroup).filter(Boolean);
|
||||||
|
const detailedByCode = new Map<string, EstimatePower & { rowCount: number }>();
|
||||||
|
const unassigned = { kw: 0, kva: 0, rowCount: 0 };
|
||||||
|
for (const row of detailedRows) {
|
||||||
|
const kva = row.cosPhi && row.cosPhi > 0 ? row.kw / row.cosPhi : row.kw;
|
||||||
|
const code = matchEstimateCostGroup(row.costGroup, codes);
|
||||||
|
const target =
|
||||||
|
code === null
|
||||||
|
? unassigned
|
||||||
|
: detailedByCode.get(code) ??
|
||||||
|
detailedByCode.set(code, { kw: 0, kva: 0, rowCount: 0 }).get(code)!;
|
||||||
|
target.kw += row.kw;
|
||||||
|
target.kva += kva;
|
||||||
|
target.rowCount += 1;
|
||||||
|
}
|
||||||
|
// A cost group code can appear in several groups; the detailed value is
|
||||||
|
// shown once, on the first group with that code.
|
||||||
|
const shownCodes = new Set<string>();
|
||||||
|
const groups = balance.groups.map((group, index) => {
|
||||||
|
const estimatedAv = estimate.groups[index]!.av;
|
||||||
|
const detailedEntry =
|
||||||
|
group.costGroup && !shownCodes.has(group.costGroup)
|
||||||
|
? detailedByCode.get(group.costGroup)
|
||||||
|
: undefined;
|
||||||
|
if (group.costGroup) {
|
||||||
|
shownCodes.add(group.costGroup);
|
||||||
|
}
|
||||||
|
const detailed = { kw: detailedEntry?.kw ?? 0, kva: detailedEntry?.kva ?? 0 };
|
||||||
|
return {
|
||||||
|
groupId: group.id,
|
||||||
|
costGroup: group.costGroup,
|
||||||
|
title: group.title,
|
||||||
|
estimatedAv,
|
||||||
|
detailed,
|
||||||
|
detailedRowCount: detailedEntry?.rowCount ?? 0,
|
||||||
|
...delta(estimatedAv.kw, detailed.kw),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const detailedTotal = groups.reduce(
|
||||||
|
(sum, group) => addPower(sum, group.detailed),
|
||||||
|
{ kw: unassigned.kw, kva: unassigned.kva }
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
unassigned,
|
||||||
|
total: {
|
||||||
|
estimatedAv: estimate.total.av,
|
||||||
|
detailed: detailedTotal,
|
||||||
|
...delta(estimate.total.av.kw, detailedTotal.kw),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
432
tests/estimate-balance-frontend.test.ts
Normal file
432
tests/estimate-balance-frontend.test.ts
Normal file
|
|
@ -0,0 +1,432 @@
|
||||||
|
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,
|
||||||
|
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 ",
|
||||||
|
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,
|
||||||
|
remark: saveable.groups[0]!.rows[0]!.remark,
|
||||||
|
},
|
||||||
|
{ name: "Hebeanlage", supply: "ELT", 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
458
tests/estimate-balance.test.ts
Normal file
458
tests/estimate-balance.test.ts
Normal file
|
|
@ -0,0 +1,458 @@
|
||||||
|
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",
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -49,6 +49,9 @@ it("upgrades a populated pre-print database through the current schema without c
|
||||||
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM buildings").get()!.count, 0);
|
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM buildings").get()!.count, 0);
|
||||||
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_assets").get()!.count, 0);
|
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_assets").get()!.count, 0);
|
||||||
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_settings").get()!.count, 0);
|
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_settings").get()!.count, 0);
|
||||||
|
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_balances").get()!.count, 0);
|
||||||
|
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_groups").get()!.count, 0);
|
||||||
|
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM estimate_rows").get()!.count, 0);
|
||||||
assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []);
|
assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []);
|
||||||
assert.equal(context.sqlite.pragma("integrity_check", { simple: true }), "ok");
|
assert.equal(context.sqlite.pragma("integrity_check", { simple: true }), "ok");
|
||||||
migrate(context.db, { migrationsFolder: source });
|
migrate(context.db, { migrationsFolder: source });
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,13 @@ import {
|
||||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||||
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
|
import { ProjectSnapshotRepository } from "../src/db/repositories/project-snapshot.repository.js";
|
||||||
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
|
import { ProjectTransferRepository } from "../src/db/repositories/project-transfer.repository.js";
|
||||||
|
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
|
||||||
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
|
import { readProjectStateSnapshot } from "../src/db/repositories/project-state-snapshot.persistence.js";
|
||||||
|
import {
|
||||||
|
deleteEstimateBalance,
|
||||||
|
insertEstimateBalance,
|
||||||
|
readEstimateBalances,
|
||||||
|
} from "../src/db/repositories/estimate-balance.persistence.js";
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||||
|
|
@ -28,8 +34,48 @@ import { projects } from "../src/db/schema/projects.js";
|
||||||
import { rooms } from "../src/db/schema/rooms.js";
|
import { rooms } from "../src/db/schema/rooms.js";
|
||||||
import { deserializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
|
import { deserializeProjectStateSnapshot } from "../src/domain/models/project-state-snapshot.model.js";
|
||||||
import { projectStateSnapshotSchemaVersion } from "../src/domain/models/project-state-snapshot.model.js";
|
import { projectStateSnapshotSchemaVersion } from "../src/domain/models/project-state-snapshot.model.js";
|
||||||
|
import { createProjectStateRestoreCommand } from "../src/domain/models/project-state-restore-command.model.js";
|
||||||
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||||
import { ProjectSnapshotNameConflictError } from "../src/domain/errors/project-snapshot-name-conflict.error.js";
|
import { ProjectSnapshotNameConflictError } from "../src/domain/errors/project-snapshot-name-conflict.error.js";
|
||||||
|
import { parseEstimateBalanceSnapshot } from "../src/estimate-balance/domain/estimate-balance-command.model.js";
|
||||||
|
|
||||||
|
function estimateBalance(projectId: string, suffix = "") {
|
||||||
|
return parseEstimateBalanceSnapshot({
|
||||||
|
id: `estimate${suffix}`,
|
||||||
|
projectId,
|
||||||
|
name: `Vorplanung${suffix}`,
|
||||||
|
workStage: "LPH 2",
|
||||||
|
dateIso: "2026-09-27",
|
||||||
|
reserveAvPercent: 10,
|
||||||
|
reserveSvPercent: 5,
|
||||||
|
sortOrder: 0,
|
||||||
|
groups: [
|
||||||
|
{
|
||||||
|
id: `estimate-group${suffix}`,
|
||||||
|
costGroup: "440",
|
||||||
|
title: "Starkstromanlagen",
|
||||||
|
editor: "Planung",
|
||||||
|
sortOrder: 0,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: `estimate-row${suffix}`,
|
||||||
|
sortOrder: 0,
|
||||||
|
supply: "ELT",
|
||||||
|
name: "Beleuchtung",
|
||||||
|
quantity: 100,
|
||||||
|
unit: "area",
|
||||||
|
connection: "fixed",
|
||||||
|
powerPerUnitW: 12,
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
cosPhi: 0.95,
|
||||||
|
safetySupply: false,
|
||||||
|
remark: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function createTestDatabase(): DatabaseContext {
|
function createTestDatabase(): DatabaseContext {
|
||||||
const context = createDatabaseContext(":memory:");
|
const context = createDatabaseContext(":memory:");
|
||||||
|
|
@ -463,14 +509,41 @@ describe("project snapshot repository", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("portable project transfer", () => {
|
describe("portable project transfer", () => {
|
||||||
|
it("keeps estimate balances outside normal snapshot restore", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
insertEstimateBalance(context.db, estimateBalance("project-1"));
|
||||||
|
const current = readProjectStateSnapshot(context.db, "project-1");
|
||||||
|
assert.ok(current);
|
||||||
|
new ProjectStateRestoreCommandRepository(context.db).execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "restore",
|
||||||
|
command: createProjectStateRestoreCommand(current.payloadSha256, {
|
||||||
|
...current.state,
|
||||||
|
project: { ...current.state.project, name: "Restored project" },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
readEstimateBalances(context.db, "project-1").map((entry) => entry.id),
|
||||||
|
["estimate"]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("exports and atomically duplicates a complete project with new ids", () => {
|
it("exports and atomically duplicates a complete project with new ids", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
context.db.update(projects).set({ siteAddress: "Musterstraße 1", workStage: "LPH 5", projectLead: "Planung", projectEditor: "Test" }).where(eq(projects.id, "project-1")).run();
|
context.db.update(projects).set({ siteAddress: "Musterstraße 1", workStage: "LPH 5", projectLead: "Planung", projectEditor: "Test" }).where(eq(projects.id, "project-1")).run();
|
||||||
context.db.update(distributionBoards).set({ boardNumber: "UV-01", location: "Technik", fedFrom: "HAK", remark: "Prüfstand" }).where(eq(distributionBoards.projectId, "project-1")).run();
|
context.db.update(distributionBoards).set({ boardNumber: "UV-01", location: "Technik", fedFrom: "HAK", remark: "Prüfstand" }).where(eq(distributionBoards.projectId, "project-1")).run();
|
||||||
|
insertEstimateBalance(context.db, estimateBalance("project-1"));
|
||||||
const repository = new ProjectTransferRepository(context.db);
|
const repository = new ProjectTransferRepository(context.db);
|
||||||
const transfer = repository.exportProject("project-1");
|
const transfer = repository.exportProject("project-1");
|
||||||
assert.ok(transfer);
|
assert.ok(transfer);
|
||||||
|
assert.equal(transfer.formatVersion, 2);
|
||||||
|
assert.equal(transfer.estimateBalances.length, 1);
|
||||||
const duplicate = repository.importDuplicate(transfer);
|
const duplicate = repository.importDuplicate(transfer);
|
||||||
assert.notEqual(duplicate.projectId, "project-1");
|
assert.notEqual(duplicate.projectId, "project-1");
|
||||||
assert.equal(duplicate.name, "Snapshot-Projekt (Kopie)");
|
assert.equal(duplicate.name, "Snapshot-Projekt (Kopie)");
|
||||||
|
|
@ -541,6 +614,18 @@ describe("portable project transfer", () => {
|
||||||
copied.state.distributionBoards[0].simultaneityFactor,
|
copied.state.distributionBoards[0].simultaneityFactor,
|
||||||
0.65
|
0.65
|
||||||
);
|
);
|
||||||
|
const copiedBalances = readEstimateBalances(context.db, duplicate.projectId);
|
||||||
|
assert.equal(copiedBalances.length, 1);
|
||||||
|
assert.notEqual(copiedBalances[0].id, transfer.estimateBalances[0].id);
|
||||||
|
assert.notEqual(
|
||||||
|
copiedBalances[0].groups[0].id,
|
||||||
|
transfer.estimateBalances[0].groups[0].id
|
||||||
|
);
|
||||||
|
assert.notEqual(
|
||||||
|
copiedBalances[0].groups[0].rows[0].id,
|
||||||
|
transfer.estimateBalances[0].groups[0].rows[0].id
|
||||||
|
);
|
||||||
|
assert.equal(copiedBalances[0].groups[0].rows[0].name, "Beleuchtung");
|
||||||
} finally {
|
} finally {
|
||||||
context.close();
|
context.close();
|
||||||
}
|
}
|
||||||
|
|
@ -566,6 +651,14 @@ describe("portable project transfer", () => {
|
||||||
}),
|
}),
|
||||||
/checksum verification failed/
|
/checksum verification failed/
|
||||||
);
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.importDuplicate({
|
||||||
|
...transfer,
|
||||||
|
estimateBalances: [estimateBalance("project-1", "-tampered")],
|
||||||
|
}),
|
||||||
|
/checksum verification failed/
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
context.db.select().from(projects).all().length,
|
context.db.select().from(projects).all().length,
|
||||||
1
|
1
|
||||||
|
|
@ -575,15 +668,79 @@ describe("portable project transfer", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("imports legacy transfer version 1 with no estimate balances", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const snapshot = readProjectStateSnapshot(context.db, "project-1");
|
||||||
|
assert.ok(snapshot);
|
||||||
|
const legacyTransfer = {
|
||||||
|
format: "leistungsbilanz.project",
|
||||||
|
formatVersion: 1,
|
||||||
|
exportedAtIso: "2026-09-27T12:00:00.000Z",
|
||||||
|
payloadSha256: snapshot.payloadSha256,
|
||||||
|
projectState: snapshot.state,
|
||||||
|
};
|
||||||
|
const duplicate = new ProjectTransferRepository(context.db).importDuplicate(
|
||||||
|
legacyTransfer
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
readEstimateBalances(context.db, duplicate.projectId),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces exported estimate balances atomically and restores them on undo", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
insertEstimateBalance(context.db, estimateBalance("project-1"));
|
||||||
|
const transferRepository = new ProjectTransferRepository(context.db);
|
||||||
|
const transfer = transferRepository.exportProject("project-1");
|
||||||
|
assert.ok(transfer);
|
||||||
|
|
||||||
|
deleteEstimateBalance(context.db, "project-1", "estimate");
|
||||||
|
insertEstimateBalance(context.db, estimateBalance("project-1", "-changed"));
|
||||||
|
const prepared = transferRepository.prepareReplace("project-1", transfer);
|
||||||
|
assert.ok(prepared);
|
||||||
|
const restored = new ProjectStateRestoreCommandRepository(context.db).execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "restore",
|
||||||
|
command: prepared.command,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
readEstimateBalances(context.db, "project-1").map((entry) => entry.id),
|
||||||
|
["estimate"]
|
||||||
|
);
|
||||||
|
|
||||||
|
new ProjectStateRestoreCommandRepository(context.db).execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: restored.revision.changeSetId,
|
||||||
|
command: restored.inverse,
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
readEstimateBalances(context.db, "project-1").map((entry) => entry.id),
|
||||||
|
["estimate-changed"]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("rolls back a duplicate when a late project-state insert fails", () => {
|
it("rolls back a duplicate when a late project-state insert fails", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
insertEstimateBalance(context.db, estimateBalance("project-1"));
|
||||||
const repository = new ProjectTransferRepository(context.db);
|
const repository = new ProjectTransferRepository(context.db);
|
||||||
const transfer = repository.exportProject("project-1");
|
const transfer = repository.exportProject("project-1");
|
||||||
assert.ok(transfer);
|
assert.ok(transfer);
|
||||||
context.sqlite.exec(`
|
context.sqlite.exec(`
|
||||||
CREATE TRIGGER fail_imported_device_row
|
CREATE TRIGGER fail_imported_estimate_row
|
||||||
BEFORE INSERT ON circuit_device_rows
|
BEFORE INSERT ON estimate_rows
|
||||||
BEGIN
|
BEGIN
|
||||||
SELECT RAISE(ABORT, 'forced project transfer failure');
|
SELECT RAISE(ABORT, 'forced project transfer failure');
|
||||||
END;
|
END;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue