Compare commits
No commits in common. "37a70b6b363959d3018c48d5c40b94e1d25a3163" and "5f4d9bf7898450772c02584756049a7cd624b136" have entirely different histories.
37a70b6b36
...
5f4d9bf789
40 changed files with 8 additions and 8621 deletions
28
AGENTS.md
28
AGENTS.md
|
|
@ -177,13 +177,6 @@ The circuit list editor must remain a custom spreadsheet-like component with con
|
||||||
|
|
||||||
Avoid permanent Bootstrap form controls inside table cells. Table cells should show static text by default and switch to inputs only while editing.
|
Avoid permanent Bootstrap form controls inside table cells. Table cells should show static text by default and switch to inputs only while editing.
|
||||||
|
|
||||||
## Browser Runtime
|
|
||||||
|
|
||||||
The web UI is also opened over plain HTTP from other machines in the LAN, which
|
|
||||||
is not a secure context. Do not call secure-context-only browser APIs such as
|
|
||||||
`crypto.randomUUID()` directly; create client-side ids with `createClientId()`
|
|
||||||
from `src/frontend/utils/client-id.ts`.
|
|
||||||
|
|
||||||
## Drag-and-Drop Rules
|
## Drag-and-Drop Rules
|
||||||
|
|
||||||
Dragging from the circuit identifier / circuit handle moves the whole circuit.
|
Dragging from the circuit identifier / circuit handle moves the whole circuit.
|
||||||
|
|
@ -576,27 +569,6 @@ 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 not part of project snapshots or project transfer yet (open question
|
|
||||||
in issue #7); do not add it there without a snapshot schema version.
|
|
||||||
- UI: `/projects/:id/grobbilanz` (Stände) and `/grobbilanz/:balanceId` (editor,
|
|
||||||
`src/frontend/estimate-balance/`). The editor autosaves the whole document
|
|
||||||
debounced (one Undo step per save), keeps invalid raw input local, and on a
|
|
||||||
409 reloads only if the balance itself changed; otherwise it re-saves with
|
|
||||||
the new revision. `request()` throws `ApiRequestError` (status, code).
|
|
||||||
|
|
||||||
## Persistence and Migration Rules
|
## Persistence and Migration Rules
|
||||||
|
|
||||||
- SQLite is the currently supported database.
|
- SQLite is the currently supported database.
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -214,12 +214,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
# Grobe Leistungsbilanz (LPH 2/3)
|
|
||||||
|
|
||||||
Eigenes Modul für die Leistungsbilanz der Vor- und Entwurfsplanung, gegliedert
|
|
||||||
nach Kostengruppen (DIN 276, KG 400). Es gibt dort noch keine Verteilungen und
|
|
||||||
Stromkreise: Jedes Gewerk trägt unter seiner Kostengruppe Verbraucher als
|
|
||||||
Stückzahl mit Einzelleistung oder als Fläche mit W/m² ein. Aufbau und Rechnung
|
|
||||||
folgen der bisherigen Excel-Vorlage „Leistungsbilanz – KG400“. Abstimmung:
|
|
||||||
Issue #7.
|
|
||||||
|
|
||||||
Das Modul ist **nicht** mit der Stromkreisstruktur verknüpft. Es liest die
|
|
||||||
Gerätezeilen nur für den Vergleich „geschätzt ↔ detailliert“ und schreibt
|
|
||||||
nichts in Stromkreislisten, Verteilungen oder Projektgeräte.
|
|
||||||
|
|
||||||
## Aufbau
|
|
||||||
|
|
||||||
```
|
|
||||||
src/shared/estimate-balance/estimate-balance.ts Datenform, Rechnung, Vergleich (rein, ohne Imports)
|
|
||||||
src/estimate-balance/domain/estimate-balance-command.model.ts Snapshot-Schema (Zod), Befehle
|
|
||||||
src/estimate-balance/domain/estimate-balance-project-command.store.ts Store-Port
|
|
||||||
src/estimate-balance/api/estimate-balance-request.schemas.ts Request-Schemas
|
|
||||||
src/db/schema/estimate-balances.ts Tabellen (Migration 0010)
|
|
||||||
src/db/repositories/estimate-balance*.ts Lesen, Schreiben, Befehls-Store
|
|
||||||
src/server/controllers/estimate-balance.controller.ts API
|
|
||||||
src/app/projects/[projectId]/grobbilanz/ Übersicht der Stände und Editor
|
|
||||||
src/frontend/estimate-balance/ Editor-Komponenten und Helfer
|
|
||||||
```
|
|
||||||
|
|
||||||
Die gemeinsame Datei unter `src/shared/estimate-balance` hat keine Imports, weil
|
|
||||||
das Frontend sie direkt nutzt (Turbopack löst die `.js`-Suffixe der API nicht auf).
|
|
||||||
|
|
||||||
## Datenmodell
|
|
||||||
|
|
||||||
| Tabelle | Inhalt |
|
|
||||||
|---|---|
|
|
||||||
| `estimate_balances` | ein **Stand** je Projekt (z. B. Vorplanung, Entwurfsplanung): Name, LPH, Datum, Reserve AV/SV in %, Sortierung |
|
|
||||||
| `estimate_groups` | Kostengruppe (`"410"` … `"480"`, `""` für „Allgemein“), Titel, „bearbeitet von“, Sortierung |
|
|
||||||
| `estimate_rows` | Versorgung, Stromkreis-Nr. (Freitext), Verbraucher/Fläche, Anzahl bzw. Fläche, Einheit (`piece` = St., `area` = m²), Anschluss (fest/Steckdose), Einzelleistung in W bzw. W/m², GLF, cos φ, SV ja/nein, Anmerkung |
|
|
||||||
|
|
||||||
Neue Stände entstehen aus der Vorlage KG 400 (410 Sanitär, 420 Wärme, 430 Luft,
|
|
||||||
434 Kälte, 440 Starkstrom, 450 Schwachstrom, 460 Förder, 470 Nutzungsspezifisch,
|
|
||||||
480 GA, Allgemein; Reserve AV 10 %), leer oder als Kopie eines bestehenden
|
|
||||||
Stands mit neuen IDs. So lässt sich ein Stand einfrieren und später vergleichen.
|
|
||||||
|
|
||||||
## Rechnung
|
|
||||||
|
|
||||||
- Zeile: P = Anzahl bzw. Fläche × Einzelleistung × GLF, S = P / cos φ.
|
|
||||||
- **AV** enthält alle Verbraucher (Normalversorgung). **SV** listet zusätzlich
|
|
||||||
die Zeilen mit „SV = ja“, die auch aus der Sicherheitsstromversorgung versorgt
|
|
||||||
werden müssen. Das ist die Lesart der Vorlage; die Regel liegt in
|
|
||||||
`calculateEstimateRow` und lässt sich dort zentral ändern.
|
|
||||||
- Summen je Kostengruppe, Gesamtsumme, Reserve in % (AV und SV getrennt),
|
|
||||||
Leistung inklusive Reserve und Maximalstrom I = S / (√3 · U) mit der
|
|
||||||
Drehstromspannung aus den Projekteinstellungen.
|
|
||||||
- Anzeige wie in der Tabellenkalkulation gerundet (`roundEstimateValue`,
|
|
||||||
40,175 → 40,18). Der Test `tests/estimate-balance.test.ts` rechnet das
|
|
||||||
Beispiel der Vorlage vollständig nach (143,24 kW, 148,48 kVA, mit 10 %
|
|
||||||
Reserve 157,56 kW / 163,33 kVA, 235,75 A).
|
|
||||||
|
|
||||||
## Vergleich geschätzt ↔ detailliert
|
|
||||||
|
|
||||||
`GET …/comparison` summiert alle Gerätezeilen aller Stromkreislisten des
|
|
||||||
Projekts (Anzahl × Leistung je Stück × Geräte-GZF, wie der Stromkreisbaum) und
|
|
||||||
ordnet sie über das Feld **Kostengruppe** der Gerätezeile zu. Maßgeblich ist das
|
|
||||||
längste passende Präfix: `434` → Gruppe 434, `431` → Gruppe 430, `444` → 440.
|
|
||||||
Zeilen ohne passende Kostengruppe erscheinen als „nicht zugeordnet“. Der
|
|
||||||
Verteiler-GZF wird auf keiner Seite angewendet; ein fehlendes cos φ zählt als 1.
|
|
||||||
Verglichen wird die AV-Leistung (kW) mit Differenz absolut und in %.
|
|
||||||
|
|
||||||
## Befehle, Undo/Redo
|
|
||||||
|
|
||||||
Alle Änderungen laufen über den bestehenden Projekt-Befehlsmechanismus mit
|
|
||||||
Revision, Inverse und persistentem Undo/Redo:
|
|
||||||
|
|
||||||
- `estimate-balance.insert` / `estimate-balance.delete` mit dem vollständigen
|
|
||||||
Stand (Gruppen und Zeilen)
|
|
||||||
- `estimate-balance.update` mit vollständigem `expected`/`target`-Dokument; der
|
|
||||||
Store prüft, dass der gespeicherte Stand genau `expected` entspricht, und
|
|
||||||
ersetzt ihn atomar. Die Inverse ist das vertauschte Paar.
|
|
||||||
|
|
||||||
Der Editor speichert das ganze Dokument verzögert (Autosave); jeder
|
|
||||||
Speichervorgang ist ein Undo-Schritt. Unveränderte Dokumente erzeugen keine
|
|
||||||
Revision (`{ unchanged: true }`).
|
|
||||||
|
|
||||||
## Bewusst nicht enthalten (Fragen an Julian, Issue #7)
|
|
||||||
|
|
||||||
- Die grobe Bilanz ist **nicht** Teil von Projekt-Snapshot, Restore und
|
|
||||||
Projekttransfer; das Snapshot-Schema bleibt unverändert. Ein Restore lässt
|
|
||||||
die Stände unverändert, eine Projektkopie übernimmt sie nicht.
|
|
||||||
- Keine Rechte je Gewerk; „bearbeitet von“ ist ein Freitext je Kostengruppe.
|
|
||||||
- Kostengruppen der Gerätezeilen sind nicht normiert; der Vergleich arbeitet
|
|
||||||
mit Präfixen.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
- `GET /api/projects/:projectId/estimate-balances`
|
|
||||||
- `POST /api/projects/:projectId/estimate-balances` –
|
|
||||||
`{ "expectedRevision": 7, "name": "Entwurfsplanung", "workStage": "LPH 3", "dateIso": "2026-09-26", "template": "kg400" }`
|
|
||||||
(`template`: `kg400` | `empty` | `copy` mit `copyFromBalanceId`)
|
|
||||||
- `GET /api/projects/:projectId/estimate-balances/:balanceId`
|
|
||||||
- `PUT /api/projects/:projectId/estimate-balances/:balanceId` –
|
|
||||||
`{ "expectedRevision": 8, "balance": { …vollständiges Dokument… } }`
|
|
||||||
- `DELETE /api/projects/:projectId/estimate-balances/:balanceId` – `{ "expectedRevision": 9 }`
|
|
||||||
- `GET /api/projects/:projectId/estimate-balances/:balanceId/comparison`
|
|
||||||
|
|
||||||
Validierung: Name 1–200 Zeichen, getrimmte Texte, Anzahl/Fläche und
|
|
||||||
Einzelleistung ≥ 0, GLF 0–1, cos φ > 0 bis 1, Reserve 0–100 %, Datum
|
|
||||||
`YYYY-MM-DD`, eindeutige Gruppen- und Zeilen-IDs, unbekannte Felder werden
|
|
||||||
abgelehnt.
|
|
||||||
|
|
@ -6,9 +6,6 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -1602,493 +1602,3 @@ 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-circuit { width: 4.6rem; }
|
|
||||||
.estimate-col-name { width: auto; }
|
|
||||||
.estimate-col-quantity { width: 4.8rem; }
|
|
||||||
.estimate-col-unit { width: 3.8rem; }
|
|
||||||
.estimate-col-connection { width: 5rem; }
|
|
||||||
.estimate-col-power { width: 6.4rem; }
|
|
||||||
.estimate-col-gzf { width: 6.8rem; }
|
|
||||||
.estimate-col-cosphi { width: 3.8rem; }
|
|
||||||
.estimate-col-result { width: 4.6rem; }
|
|
||||||
.estimate-col-sv { width: 3.2rem; }
|
|
||||||
.estimate-col-remark { width: 9rem; }
|
|
||||||
.estimate-col-actions { width: 2.4rem; }
|
|
||||||
|
|
||||||
.estimate-cell-input,
|
|
||||||
.estimate-cell-select {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 0;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
border-radius: 3px;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
padding: 1px 4px;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-cell-select {
|
|
||||||
padding-right: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-cell-input:hover:not(:disabled),
|
|
||||||
.estimate-cell-select:hover:not(:disabled) {
|
|
||||||
border-color: var(--panel-border-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-cell-input:focus,
|
|
||||||
.estimate-cell-select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--accent-blue);
|
|
||||||
background: var(--panel-bg);
|
|
||||||
box-shadow: 0 0 0 2px var(--accent-blue-soft-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-cell-input.is-numeric {
|
|
||||||
text-align: right;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-cell-input.is-invalid,
|
|
||||||
.estimate-cell-input.is-invalid:focus,
|
|
||||||
.estimate-cell-input.is-invalid:hover {
|
|
||||||
border-color: var(--color-danger);
|
|
||||||
background: var(--color-danger-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-cell-input::placeholder {
|
|
||||||
color: var(--text-faint);
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-unit-input,
|
|
||||||
.estimate-gzf-input {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-unit-label {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
color: var(--text-faint);
|
|
||||||
min-width: 1.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-amev-button {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border: 1px solid var(--panel-border-strong);
|
|
||||||
border-radius: 3px;
|
|
||||||
background: var(--panel-bg-subtle);
|
|
||||||
color: var(--text-subtle);
|
|
||||||
font-size: 0.6rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.03em;
|
|
||||||
padding: 1px 3px;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-amev-button:hover:not(:disabled) {
|
|
||||||
border-color: var(--accent-blue);
|
|
||||||
color: var(--accent-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-number {
|
|
||||||
text-align: right;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-number.is-muted {
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-number.is-over {
|
|
||||||
color: var(--color-attention-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-number.is-under {
|
|
||||||
color: var(--color-signal-dark);
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-cost-group-cell {
|
|
||||||
font-weight: 700;
|
|
||||||
text-align: center;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-group-title-row td {
|
|
||||||
background: var(--surface-component-header);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-group-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-xs) var(--space-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-group-name {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-group-editor {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-group-editor .estimate-cell-input {
|
|
||||||
width: 12rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-group-actions {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-group-title-row .estimate-actions-cell {
|
|
||||||
width: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-icon-button {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.15rem 0.3rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-actions-cell {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-empty-row td {
|
|
||||||
color: var(--text-faint);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-subtotal-row td {
|
|
||||||
background: var(--panel-bg-subtle);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-total-label {
|
|
||||||
text-align: right;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-total-hint {
|
|
||||||
font-weight: 400;
|
|
||||||
color: var(--text-faint);
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-total-row td {
|
|
||||||
background: var(--surface-header-row);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-total-row.is-strong td {
|
|
||||||
font-weight: 800;
|
|
||||||
border-top: 2px solid var(--panel-border-strong);
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-add-group-row td {
|
|
||||||
background: var(--panel-bg);
|
|
||||||
padding: var(--space-sm) 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-unassigned-row td {
|
|
||||||
font-style: italic;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-collapse-toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-list-table .estimate-number {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Druck-Textwerte: auf dem Bildschirm unsichtbar, im Druck statt Inputs */
|
|
||||||
.estimate-print-value {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@page estimate-balance {
|
|
||||||
size: A4 landscape;
|
|
||||||
margin: 10mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
body:has(.estimate-print-root) .sidebar,
|
|
||||||
.estimate-print-root .estimate-no-print,
|
|
||||||
.estimate-print-root .alert,
|
|
||||||
.estimate-print-root input,
|
|
||||||
.estimate-print-root select,
|
|
||||||
.estimate-print-root button {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
body:has(.estimate-print-root) {
|
|
||||||
background: #fff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
body:has(.estimate-print-root) .app-shell {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-print-root {
|
|
||||||
page: estimate-balance;
|
|
||||||
padding: 0 !important;
|
|
||||||
max-width: none;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-print-root .estimate-print-value {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-print-root .page-header {
|
|
||||||
margin-bottom: 4mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-print-root .page-header h1 {
|
|
||||||
font-size: 14pt;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-print-root .card {
|
|
||||||
border: 0;
|
|
||||||
box-shadow: none;
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-print-root .card-body,
|
|
||||||
.estimate-print-root .card-header {
|
|
||||||
padding: 0;
|
|
||||||
background: transparent;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-print-root .estimate-comparison-card {
|
|
||||||
margin-top: 6mm;
|
|
||||||
break-inside: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-sheet-wrap {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-sheet {
|
|
||||||
min-width: 0;
|
|
||||||
table-layout: auto;
|
|
||||||
font-size: 7.5pt;
|
|
||||||
background: #fff;
|
|
||||||
color: #000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-sheet th,
|
|
||||||
.estimate-sheet td {
|
|
||||||
border-color: #999;
|
|
||||||
padding: 1px 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-sheet thead {
|
|
||||||
display: table-header-group;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-sheet thead th,
|
|
||||||
.estimate-total-row td {
|
|
||||||
background: #e8e8e8 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-group-title-row td {
|
|
||||||
background: #d9d9d9 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-subtotal-row td {
|
|
||||||
background: #f2f2f2 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-group-title-row,
|
|
||||||
.estimate-subtotal-row,
|
|
||||||
.estimate-total-row {
|
|
||||||
break-inside: avoid;
|
|
||||||
-webkit-print-color-adjust: exact;
|
|
||||||
print-color-adjust: exact;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-group-title-row {
|
|
||||||
break-after: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Totals print once at the end, not repeated on every page. */
|
|
||||||
.estimate-sheet tfoot {
|
|
||||||
display: table-row-group;
|
|
||||||
break-inside: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-col-actions {
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-unit-input,
|
|
||||||
.estimate-gzf-input {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.estimate-header-fields {
|
|
||||||
margin-bottom: 3mm;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,743 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams, useRouter } from "next/navigation";
|
|
||||||
import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import {
|
|
||||||
ArrowLeft,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Printer,
|
|
||||||
Redo2,
|
|
||||||
RefreshCw,
|
|
||||||
Undo2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
ApiRequestError,
|
|
||||||
getEstimateBalance,
|
|
||||||
getEstimateComparison,
|
|
||||||
getProject,
|
|
||||||
getProjectHistory,
|
|
||||||
isRevisionConflictError,
|
|
||||||
redoProjectCommand,
|
|
||||||
undoProjectCommand,
|
|
||||||
updateEstimateBalance,
|
|
||||||
} from "../../../../../frontend/utils/api";
|
|
||||||
import type {
|
|
||||||
EstimateBalanceDto,
|
|
||||||
EstimateComparisonDto,
|
|
||||||
ProjectDto,
|
|
||||||
ProjectHistoryStateDto,
|
|
||||||
} from "../../../../../frontend/types";
|
|
||||||
import type { EstimateGroupSnapshot } from "../../../../../shared/estimate-balance/estimate-balance";
|
|
||||||
import { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal";
|
|
||||||
import {
|
|
||||||
addEstimateGroup,
|
|
||||||
estimateBalanceFingerprint,
|
|
||||||
estimateTextLimits,
|
|
||||||
normalizeEstimateSortOrder,
|
|
||||||
toSaveableEstimateBalance,
|
|
||||||
updateEstimateGroup,
|
|
||||||
updateEstimateRow,
|
|
||||||
validateEstimateBalance,
|
|
||||||
} from "../../../../../frontend/estimate-balance/estimate-balance-document";
|
|
||||||
import { formatIsoDateGerman } from "../../../../../frontend/estimate-balance/estimate-balance-format";
|
|
||||||
import {
|
|
||||||
EstimateBalanceSheet,
|
|
||||||
type EstimateBalanceUpdate,
|
|
||||||
} from "../../../../../frontend/estimate-balance/estimate-balance-sheet";
|
|
||||||
import { EstimateComparisonTable } from "../../../../../frontend/estimate-balance/estimate-comparison-table";
|
|
||||||
import { DraftInput } from "../../../../../frontend/estimate-balance/estimate-draft-input";
|
|
||||||
import {
|
|
||||||
EstimateGroupModal,
|
|
||||||
type EstimateGroupFormValues,
|
|
||||||
} from "../../../../../frontend/estimate-balance/estimate-group-modal";
|
|
||||||
|
|
||||||
const AUTOSAVE_DELAY_MS = 800;
|
|
||||||
const CONFLICT_MESSAGE = "Die Bilanz wurde zwischenzeitlich geändert und neu geladen.";
|
|
||||||
|
|
||||||
type SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid";
|
|
||||||
|
|
||||||
type GroupDialog = { group: EstimateGroupSnapshot | null };
|
|
||||||
|
|
||||||
function errorMessage(error: unknown, fallback: string) {
|
|
||||||
return error instanceof Error && error.message ? error.message : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isNotFoundError(error: unknown) {
|
|
||||||
return error instanceof ApiRequestError && error.status === 404;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function EstimateBalanceEditorPage() {
|
|
||||||
const params = useParams<{ projectId: string; balanceId: string }>();
|
|
||||||
const { projectId, balanceId } = params;
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [project, setProject] = useState<ProjectDto | null>(null);
|
|
||||||
const [draft, setDraft] = useState<EstimateBalanceDto | null>(null);
|
|
||||||
const [history, setHistory] = useState<ProjectHistoryStateDto | null>(null);
|
|
||||||
const [comparison, setComparison] = useState<EstimateComparisonDto | null>(null);
|
|
||||||
const [comparisonError, setComparisonError] = useState<string | null>(null);
|
|
||||||
const [isComparisonOpen, setIsComparisonOpen] = useState(false);
|
|
||||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("saved");
|
|
||||||
const [saveError, setSaveError] = useState<string | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [isMissing, setIsMissing] = useState(false);
|
|
||||||
const [isHistoryBusy, setIsHistoryBusy] = useState(false);
|
|
||||||
const [invalidFieldKeys, setInvalidFieldKeys] = useState<ReadonlySet<string>>(
|
|
||||||
() => new Set()
|
|
||||||
);
|
|
||||||
const [groupDialog, setGroupDialog] = useState<GroupDialog | null>(null);
|
|
||||||
const [gzfTarget, setGzfTarget] = useState<{ groupId: string; rowId: string } | null>(
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
// Refs carry the latest values into timers, unload handlers and chained
|
|
||||||
// async saves without re-creating them.
|
|
||||||
const draftRef = useRef<EstimateBalanceDto | null>(null);
|
|
||||||
const revisionRef = useRef<number | null>(null);
|
|
||||||
const savedFingerprintRef = useRef<string | null>(null);
|
|
||||||
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
|
|
||||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
draftRef.current = draft;
|
|
||||||
}, [draft]);
|
|
||||||
|
|
||||||
const applyServerBalance = useCallback((balance: EstimateBalanceDto) => {
|
|
||||||
const normalized = normalizeEstimateSortOrder(balance);
|
|
||||||
savedFingerprintRef.current = estimateBalanceFingerprint(normalized);
|
|
||||||
draftRef.current = normalized;
|
|
||||||
setDraft(normalized);
|
|
||||||
setSaveStatus("saved");
|
|
||||||
setSaveError(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reloadComparison = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setComparison(await getEstimateComparison(projectId, balanceId));
|
|
||||||
setComparisonError(null);
|
|
||||||
} catch (err) {
|
|
||||||
setComparisonError(
|
|
||||||
errorMessage(err, "Der Vergleich konnte nicht geladen werden.")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [balanceId, projectId]);
|
|
||||||
|
|
||||||
/** Reloads project, document and history; replaces the local draft. */
|
|
||||||
const reloadAll = useCallback(async () => {
|
|
||||||
const balanceRequest = getEstimateBalance(projectId, balanceId);
|
|
||||||
// Handled below; avoids an "unhandled rejection" while project/history load.
|
|
||||||
balanceRequest.catch(() => undefined);
|
|
||||||
// Project and history are applied even when the balance is missing, so
|
|
||||||
// Redo stays available after its creation was undone.
|
|
||||||
const [currentProject, currentHistory] = await Promise.all([
|
|
||||||
getProject(projectId),
|
|
||||||
getProjectHistory(projectId).catch(() => null),
|
|
||||||
]);
|
|
||||||
setProject(currentProject);
|
|
||||||
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
|
|
||||||
setHistory(currentHistory);
|
|
||||||
const balance = await balanceRequest;
|
|
||||||
applyServerBalance(balance);
|
|
||||||
setIsMissing(false);
|
|
||||||
void reloadComparison();
|
|
||||||
}, [applyServerBalance, balanceId, projectId, reloadComparison]);
|
|
||||||
|
|
||||||
const handleReloadError = useCallback((err: unknown) => {
|
|
||||||
if (isNotFoundError(err)) {
|
|
||||||
setIsMissing(true);
|
|
||||||
setDraft(null);
|
|
||||||
draftRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(errorMessage(err, "Die grobe Leistungsbilanz konnte nicht geladen werden."));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!projectId || !balanceId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsLoading(true);
|
|
||||||
reloadAll()
|
|
||||||
.then(() => setError(null))
|
|
||||||
.catch(handleReloadError)
|
|
||||||
.finally(() => setIsLoading(false));
|
|
||||||
}, [balanceId, handleReloadError, projectId, reloadAll]);
|
|
||||||
|
|
||||||
const clearSaveTimer = useCallback(() => {
|
|
||||||
if (saveTimerRef.current) {
|
|
||||||
clearTimeout(saveTimerRef.current);
|
|
||||||
saveTimerRef.current = null;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Saves the current draft if it differs from the last saved state. Saves
|
|
||||||
* never overlap: a save requested while another runs waits for it and then
|
|
||||||
* saves whatever changed in the meantime. Resolves to false when the draft
|
|
||||||
* could not be saved.
|
|
||||||
*/
|
|
||||||
const saveNow = useCallback(async (): Promise<boolean> => {
|
|
||||||
clearSaveTimer();
|
|
||||||
while (saveInFlightRef.current) {
|
|
||||||
await saveInFlightRef.current;
|
|
||||||
}
|
|
||||||
const current = draftRef.current;
|
|
||||||
const revision = revisionRef.current;
|
|
||||||
if (!current || revision === null) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const fingerprint = estimateBalanceFingerprint(current);
|
|
||||||
if (fingerprint === savedFingerprintRef.current) {
|
|
||||||
setSaveStatus("saved");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const problems = validateEstimateBalance(current);
|
|
||||||
if (problems.length) {
|
|
||||||
setSaveStatus("invalid");
|
|
||||||
setSaveError(problems.join(" "));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const body = toSaveableEstimateBalance(current);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Another command changed the project revision. When this document is
|
|
||||||
* still unchanged on the server (e.g. an edit in a circuit list), the
|
|
||||||
* draft is kept and saved again; otherwise the server state wins.
|
|
||||||
*/
|
|
||||||
async function resolveConflict(): Promise<"retry" | "reloaded"> {
|
|
||||||
const [currentProject, serverBalance, currentHistory] = await Promise.all([
|
|
||||||
getProject(projectId),
|
|
||||||
getEstimateBalance(projectId, balanceId),
|
|
||||||
getProjectHistory(projectId).catch(() => null),
|
|
||||||
]);
|
|
||||||
setProject(currentProject);
|
|
||||||
setHistory(currentHistory);
|
|
||||||
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
|
|
||||||
if (estimateBalanceFingerprint(serverBalance) === savedFingerprintRef.current) {
|
|
||||||
return "retry";
|
|
||||||
}
|
|
||||||
applyServerBalance(serverBalance);
|
|
||||||
setNotice(CONFLICT_MESSAGE);
|
|
||||||
void reloadComparison();
|
|
||||||
return "reloaded";
|
|
||||||
}
|
|
||||||
|
|
||||||
const run = (async () => {
|
|
||||||
setSaveStatus("saving");
|
|
||||||
for (let attempt = 0; ; attempt += 1) {
|
|
||||||
try {
|
|
||||||
const result = await updateEstimateBalance(
|
|
||||||
projectId,
|
|
||||||
body,
|
|
||||||
attempt === 0 ? revision : revisionRef.current ?? revision
|
|
||||||
);
|
|
||||||
if (!("unchanged" in result)) {
|
|
||||||
revisionRef.current = result.history.currentRevision;
|
|
||||||
setHistory(result.history);
|
|
||||||
setProject((currentProject) =>
|
|
||||||
currentProject
|
|
||||||
? { ...currentProject, currentRevision: result.history.currentRevision }
|
|
||||||
: currentProject
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// The sent document is the saved state; the local draft is kept so
|
|
||||||
// typing is never interrupted by the server's copy.
|
|
||||||
savedFingerprintRef.current = fingerprint;
|
|
||||||
setSaveError(null);
|
|
||||||
const latest = draftRef.current;
|
|
||||||
if (latest && estimateBalanceFingerprint(latest) !== fingerprint) {
|
|
||||||
setSaveStatus("pending");
|
|
||||||
} else {
|
|
||||||
setSaveStatus("saved");
|
|
||||||
}
|
|
||||||
void reloadComparison();
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
if (isRevisionConflictError(err)) {
|
|
||||||
try {
|
|
||||||
if (attempt === 0 && (await resolveConflict()) === "retry") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (attempt > 0) {
|
|
||||||
await reloadAll();
|
|
||||||
setNotice(CONFLICT_MESSAGE);
|
|
||||||
}
|
|
||||||
} catch (reloadError) {
|
|
||||||
handleReloadError(reloadError);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (isNotFoundError(err)) {
|
|
||||||
handleReloadError(err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setSaveStatus("error");
|
|
||||||
setSaveError(errorMessage(err, "Die Änderungen konnten nicht gespeichert werden."));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
saveInFlightRef.current = run;
|
|
||||||
try {
|
|
||||||
return await run;
|
|
||||||
} finally {
|
|
||||||
saveInFlightRef.current = null;
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
applyServerBalance,
|
|
||||||
balanceId,
|
|
||||||
clearSaveTimer,
|
|
||||||
handleReloadError,
|
|
||||||
projectId,
|
|
||||||
reloadAll,
|
|
||||||
reloadComparison,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Debounced autosave after every change of the draft.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!draft) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (estimateBalanceFingerprint(draft) === savedFingerprintRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSaveStatus((status) => (status === "saving" ? status : "pending"));
|
|
||||||
clearSaveTimer();
|
|
||||||
saveTimerRef.current = setTimeout(() => {
|
|
||||||
saveTimerRef.current = null;
|
|
||||||
void saveNow();
|
|
||||||
}, AUTOSAVE_DELAY_MS);
|
|
||||||
return clearSaveTimer;
|
|
||||||
}, [clearSaveTimer, draft, saveNow]);
|
|
||||||
|
|
||||||
// Save immediately when the tab is hidden; on unload/unmount send the last
|
|
||||||
// change with keepalive so it survives the page.
|
|
||||||
useEffect(() => {
|
|
||||||
function hasUnsavedDraft() {
|
|
||||||
const current = draftRef.current;
|
|
||||||
return Boolean(
|
|
||||||
current && estimateBalanceFingerprint(current) !== savedFingerprintRef.current
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function sendKeepalive(): boolean {
|
|
||||||
const current = draftRef.current;
|
|
||||||
const revision = revisionRef.current;
|
|
||||||
if (
|
|
||||||
!current ||
|
|
||||||
revision === null ||
|
|
||||||
saveInFlightRef.current ||
|
|
||||||
validateEstimateBalance(current).length
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
savedFingerprintRef.current = estimateBalanceFingerprint(current);
|
|
||||||
void updateEstimateBalance(projectId, toSaveableEstimateBalance(current), revision, {
|
|
||||||
keepalive: true,
|
|
||||||
}).catch(() => undefined);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
function handleVisibilityChange() {
|
|
||||||
if (document.visibilityState === "hidden" && hasUnsavedDraft()) {
|
|
||||||
void saveNow();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function handleBeforeUnload(event: BeforeUnloadEvent) {
|
|
||||||
if (!hasUnsavedDraft() || sendKeepalive()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// A save is still running or the draft is invalid: let the browser ask.
|
|
||||||
event.preventDefault();
|
|
||||||
event.returnValue = "";
|
|
||||||
}
|
|
||||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
||||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
||||||
// In-app navigation (e.g. sidebar) unmounts the editor.
|
|
||||||
if (hasUnsavedDraft()) {
|
|
||||||
sendKeepalive();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [projectId, saveNow]);
|
|
||||||
|
|
||||||
const handleChange: EstimateBalanceUpdate = useCallback((update) => {
|
|
||||||
setNotice(null);
|
|
||||||
setDraft((current) => (current ? update(current) : current));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleValidityChange = useCallback((fieldKey: string, isValid: boolean) => {
|
|
||||||
setInvalidFieldKeys((current) => {
|
|
||||||
if (isValid ? !current.has(fieldKey) : current.has(fieldKey)) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
const next = new Set(current);
|
|
||||||
if (isValid) {
|
|
||||||
next.delete(fieldKey);
|
|
||||||
} else {
|
|
||||||
next.add(fieldKey);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
async function handleHistory(direction: "undo" | "redo") {
|
|
||||||
setError(null);
|
|
||||||
setNotice(null);
|
|
||||||
setIsHistoryBusy(true);
|
|
||||||
try {
|
|
||||||
// Pending edits are saved first so they become their own undo step.
|
|
||||||
await saveNow();
|
|
||||||
const revision = revisionRef.current;
|
|
||||||
if (revision === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result =
|
|
||||||
direction === "undo"
|
|
||||||
? await undoProjectCommand(projectId, revision)
|
|
||||||
: await redoProjectCommand(projectId, revision);
|
|
||||||
revisionRef.current = result.history.currentRevision;
|
|
||||||
setHistory(result.history);
|
|
||||||
await reloadAll();
|
|
||||||
} catch (err) {
|
|
||||||
if (isRevisionConflictError(err)) {
|
|
||||||
await reloadAll().catch(handleReloadError);
|
|
||||||
setNotice(CONFLICT_MESSAGE);
|
|
||||||
} else if (isNotFoundError(err)) {
|
|
||||||
handleReloadError(err);
|
|
||||||
} else {
|
|
||||||
setError(
|
|
||||||
errorMessage(
|
|
||||||
err,
|
|
||||||
direction === "undo"
|
|
||||||
? "Die letzte Änderung konnte nicht rückgängig gemacht werden."
|
|
||||||
: "Die letzte Änderung konnte nicht wiederholt werden."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsHistoryBusy(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBackClick(event: MouseEvent<HTMLAnchorElement>) {
|
|
||||||
const current = draftRef.current;
|
|
||||||
if (!current || estimateBalanceFingerprint(current) === savedFingerprintRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
await saveNow();
|
|
||||||
router.push(`/projects/${projectId}/grobbilanz`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSaveGroup(values: EstimateGroupFormValues) {
|
|
||||||
const editingGroup = groupDialog?.group ?? null;
|
|
||||||
handleChange((current) =>
|
|
||||||
editingGroup
|
|
||||||
? updateEstimateGroup(current, editingGroup.id, values)
|
|
||||||
: addEstimateGroup(current, values).balance
|
|
||||||
);
|
|
||||||
setGroupDialog(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSelectGzf(value: number) {
|
|
||||||
if (gzfTarget) {
|
|
||||||
handleChange((current) =>
|
|
||||||
updateEstimateRow(current, gzfTarget.groupId, gzfTarget.rowId, {
|
|
||||||
simultaneityFactor: value,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setGzfTarget(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isEditingDisabled = !draft || isHistoryBusy;
|
|
||||||
const invalidCount = invalidFieldKeys.size;
|
|
||||||
const listHref = `/projects/${projectId}/grobbilanz`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="container-fluid py-4 estimate-print-root">
|
|
||||||
<div className="page-header estimate-page-header">
|
|
||||||
<div className="d-flex align-items-start gap-3">
|
|
||||||
<Link
|
|
||||||
aria-label="Zurück zur Übersicht der Stände"
|
|
||||||
className="btn btn-outline-secondary btn-sm structure-back-link estimate-no-print"
|
|
||||||
href={listHref}
|
|
||||||
onClick={(event) => void handleBackClick(event)}
|
|
||||||
title="Zurück zur Übersicht der Stände"
|
|
||||||
>
|
|
||||||
<ArrowLeft aria-hidden="true" size={18} />
|
|
||||||
</Link>
|
|
||||||
<div>
|
|
||||||
<div className="kicker">{project?.name ?? "Projekt"}</div>
|
|
||||||
<h1>Grobe Leistungsbilanz{draft ? ` – ${draft.name}` : ""}</h1>
|
|
||||||
<p className="text-secondary mb-0 estimate-no-print">
|
|
||||||
Leistungsabschätzung nach Kostengruppen (DIN 276) für LPH 2/3.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="estimate-toolbar estimate-no-print">
|
|
||||||
<SaveStatusBadge
|
|
||||||
error={saveError}
|
|
||||||
invalidCount={invalidCount}
|
|
||||||
onRetry={() => void saveNow()}
|
|
||||||
status={saveStatus}
|
|
||||||
/>
|
|
||||||
<div className="btn-group btn-group-sm" role="group" aria-label="Verlauf">
|
|
||||||
<button
|
|
||||||
aria-label="Rückgängig"
|
|
||||||
className="btn btn-outline-secondary"
|
|
||||||
disabled={isHistoryBusy || !history || history.undoDepth === 0}
|
|
||||||
onClick={() => void handleHistory("undo")}
|
|
||||||
title="Rückgängig (letzte Änderung im Projekt)"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Undo2 aria-hidden="true" size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
aria-label="Wiederholen"
|
|
||||||
className="btn btn-outline-secondary"
|
|
||||||
disabled={isHistoryBusy || !history || history.redoDepth === 0}
|
|
||||||
onClick={() => void handleHistory("redo")}
|
|
||||||
title="Wiederholen (im Projekt)"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Redo2 aria-hidden="true" size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
aria-label="Drucken"
|
|
||||||
className="btn btn-sm btn-outline-secondary"
|
|
||||||
disabled={!draft}
|
|
||||||
onClick={() => window.print()}
|
|
||||||
title="Drucken"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Printer aria-hidden="true" className="me-1" size={16} />
|
|
||||||
Drucken
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{notice ? (
|
|
||||||
<div className="alert alert-info estimate-no-print" role="status">
|
|
||||||
{notice}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{error ? (
|
|
||||||
<div className="alert alert-warning estimate-no-print" role="alert">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{isLoading ? (
|
|
||||||
<p className="text-secondary" role="status">
|
|
||||||
Grobe Leistungsbilanz wird geladen …
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{isMissing ? (
|
|
||||||
<div className="alert alert-warning" role="alert">
|
|
||||||
Dieser Stand existiert nicht (mehr), z. B. weil er gelöscht oder sein Anlegen
|
|
||||||
rückgängig gemacht wurde.{" "}
|
|
||||||
<Link href={listHref}>Zur Übersicht der Stände</Link>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{draft && project ? (
|
|
||||||
<>
|
|
||||||
<section className="card shadow-sm mb-3 estimate-sheet-card">
|
|
||||||
<div className="card-body">
|
|
||||||
<div className="estimate-header-fields">
|
|
||||||
<div className="estimate-header-field">
|
|
||||||
<span className="estimate-header-label">Projekt</span>
|
|
||||||
<span className="estimate-header-value">{project.name}</span>
|
|
||||||
</div>
|
|
||||||
<label className="estimate-header-field is-wide">
|
|
||||||
<span className="estimate-header-label">Stand</span>
|
|
||||||
<DraftInput
|
|
||||||
ariaLabel="Bezeichnung des Stands"
|
|
||||||
className="estimate-cell-input estimate-header-input"
|
|
||||||
disabled={isEditingDisabled}
|
|
||||||
format={(value: string) => value}
|
|
||||||
maxLength={estimateTextLimits.balanceName}
|
|
||||||
onCommit={(name) => handleChange((current) => ({ ...current, name }))}
|
|
||||||
onValidityChange={(isValid) =>
|
|
||||||
handleValidityChange("balance:name", isValid)
|
|
||||||
}
|
|
||||||
parse={(text) =>
|
|
||||||
text.trim()
|
|
||||||
? { ok: true, value: text }
|
|
||||||
: { ok: false, error: "Die Bezeichnung darf nicht leer sein." }
|
|
||||||
}
|
|
||||||
value={draft.name}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="estimate-header-field">
|
|
||||||
<span className="estimate-header-label">LPH</span>
|
|
||||||
<input
|
|
||||||
aria-label="Leistungsphase"
|
|
||||||
className="estimate-cell-input estimate-header-input"
|
|
||||||
disabled={isEditingDisabled}
|
|
||||||
maxLength={estimateTextLimits.workStage}
|
|
||||||
onChange={(event) =>
|
|
||||||
handleChange((current) => ({
|
|
||||||
...current,
|
|
||||||
workStage: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="z. B. LPH 3"
|
|
||||||
type="text"
|
|
||||||
value={draft.workStage ?? ""}
|
|
||||||
/>
|
|
||||||
<span className="estimate-print-value">{draft.workStage ?? ""}</span>
|
|
||||||
</label>
|
|
||||||
<label className="estimate-header-field">
|
|
||||||
<span className="estimate-header-label">Datum</span>
|
|
||||||
<input
|
|
||||||
aria-label="Datum"
|
|
||||||
className="estimate-cell-input estimate-header-input"
|
|
||||||
disabled={isEditingDisabled}
|
|
||||||
onChange={(event) =>
|
|
||||||
handleChange((current) => ({
|
|
||||||
...current,
|
|
||||||
dateIso: event.target.value || null,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
type="date"
|
|
||||||
value={draft.dateIso ?? ""}
|
|
||||||
/>
|
|
||||||
<span className="estimate-print-value">
|
|
||||||
{formatIsoDateGerman(draft.dateIso)}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<EstimateBalanceSheet
|
|
||||||
balance={draft}
|
|
||||||
disabled={isEditingDisabled}
|
|
||||||
onAddGroup={() => setGroupDialog({ group: null })}
|
|
||||||
onChange={handleChange}
|
|
||||||
onEditGroup={(group) => setGroupDialog({ group })}
|
|
||||||
onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })}
|
|
||||||
onValidityChange={handleValidityChange}
|
|
||||||
threePhaseVoltageV={project.threePhaseVoltageV}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
className={
|
|
||||||
isComparisonOpen
|
|
||||||
? "card shadow-sm estimate-comparison-card"
|
|
||||||
: "card shadow-sm estimate-comparison-card estimate-no-print"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="card-header d-flex justify-content-between align-items-center gap-2">
|
|
||||||
<button
|
|
||||||
aria-controls="estimate-comparison-body"
|
|
||||||
aria-expanded={isComparisonOpen}
|
|
||||||
className="estimate-collapse-toggle"
|
|
||||||
onClick={() => setIsComparisonOpen((open) => !open)}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{isComparisonOpen ? (
|
|
||||||
<ChevronDown aria-hidden="true" size={16} />
|
|
||||||
) : (
|
|
||||||
<ChevronRight aria-hidden="true" size={16} />
|
|
||||||
)}
|
|
||||||
Vergleich mit detaillierter Bilanz
|
|
||||||
</button>
|
|
||||||
{isComparisonOpen ? (
|
|
||||||
<button
|
|
||||||
aria-label="Vergleich neu laden"
|
|
||||||
className="btn btn-sm btn-outline-secondary structure-icon-button estimate-no-print"
|
|
||||||
onClick={() => void reloadComparison()}
|
|
||||||
title="Vergleich neu laden"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<RefreshCw aria-hidden="true" size={14} />
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{isComparisonOpen ? (
|
|
||||||
<div className="card-body" id="estimate-comparison-body">
|
|
||||||
{comparisonError ? (
|
|
||||||
<div className="alert alert-warning" role="alert">
|
|
||||||
{comparisonError}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{comparison ? <EstimateComparisonTable comparison={comparison} /> : null}
|
|
||||||
<p className="text-secondary small mt-2 mb-0">
|
|
||||||
Detailliert = Summe der Gerätezeilen aller Stromkreislisten (Anzahl ×
|
|
||||||
Leistung × Geräte-GZF), zugeordnet über die Kostengruppe; ohne
|
|
||||||
Verteiler-GZF.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{groupDialog ? (
|
|
||||||
<EstimateGroupModal
|
|
||||||
group={groupDialog.group}
|
|
||||||
onClose={() => setGroupDialog(null)}
|
|
||||||
onSave={handleSaveGroup}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{gzfTarget ? (
|
|
||||||
<GzfPickerModal onClose={() => setGzfTarget(null)} onSelect={handleSelectGzf} />
|
|
||||||
) : null}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SaveStatusBadge({
|
|
||||||
error,
|
|
||||||
invalidCount,
|
|
||||||
onRetry,
|
|
||||||
status,
|
|
||||||
}: {
|
|
||||||
error: string | null;
|
|
||||||
invalidCount: number;
|
|
||||||
onRetry: () => void;
|
|
||||||
status: SaveStatus;
|
|
||||||
}) {
|
|
||||||
const label =
|
|
||||||
status === "saving"
|
|
||||||
? "Wird gespeichert …"
|
|
||||||
: status === "pending"
|
|
||||||
? "Ungespeicherte Änderungen"
|
|
||||||
: status === "error"
|
|
||||||
? "Nicht gespeichert – Fehler"
|
|
||||||
: status === "invalid"
|
|
||||||
? "Nicht gespeichert – ungültige Eingaben"
|
|
||||||
: "Gespeichert";
|
|
||||||
const tone =
|
|
||||||
status === "error" || status === "invalid"
|
|
||||||
? "is-error"
|
|
||||||
: status === "saved"
|
|
||||||
? "is-saved"
|
|
||||||
: "is-busy";
|
|
||||||
return (
|
|
||||||
<div className="estimate-save-status" role="status" aria-live="polite">
|
|
||||||
<span className={`estimate-save-badge ${tone}`} title={error ?? undefined}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
{invalidCount ? (
|
|
||||||
<span className="estimate-save-hint">
|
|
||||||
{invalidCount === 1
|
|
||||||
? "1 ungültige Eingabe wird nicht gespeichert"
|
|
||||||
: `${invalidCount} ungültige Eingaben werden nicht gespeichert`}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{status === "error" ? (
|
|
||||||
<button className="btn btn-link btn-sm p-0" onClick={onRetry} type="button">
|
|
||||||
Erneut versuchen
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,292 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
CREATE TABLE `estimate_balances` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`project_id` text NOT NULL,
|
|
||||||
`name` text NOT NULL,
|
|
||||||
`work_stage` text,
|
|
||||||
`date_iso` text,
|
|
||||||
`reserve_av_percent` real DEFAULT 0 NOT NULL,
|
|
||||||
`reserve_sv_percent` real DEFAULT 0 NOT NULL,
|
|
||||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
|
||||||
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `estimate_balances_project_id_idx` ON `estimate_balances` (`project_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `estimate_groups` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`balance_id` text NOT NULL,
|
|
||||||
`cost_group` text DEFAULT '' NOT NULL,
|
|
||||||
`title` text NOT NULL,
|
|
||||||
`editor` text,
|
|
||||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
|
||||||
FOREIGN KEY (`balance_id`) REFERENCES `estimate_balances`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `estimate_groups_balance_id_idx` ON `estimate_groups` (`balance_id`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `estimate_rows` (
|
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
|
||||||
`group_id` text NOT NULL,
|
|
||||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
|
||||||
`supply` text,
|
|
||||||
`circuit_ref` text,
|
|
||||||
`name` text DEFAULT '' NOT NULL,
|
|
||||||
`quantity` real DEFAULT 0 NOT NULL,
|
|
||||||
`unit` text DEFAULT 'piece' NOT NULL,
|
|
||||||
`connection` text,
|
|
||||||
`power_per_unit_w` real DEFAULT 0 NOT NULL,
|
|
||||||
`simultaneity_factor` real DEFAULT 1 NOT NULL,
|
|
||||||
`cos_phi` real DEFAULT 1 NOT NULL,
|
|
||||||
`safety_supply` integer DEFAULT false NOT NULL,
|
|
||||||
`remark` text,
|
|
||||||
FOREIGN KEY (`group_id`) REFERENCES `estimate_groups`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE INDEX `estimate_rows_group_id_idx` ON `estimate_rows` (`group_id`);
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -71,13 +71,6 @@
|
||||||
"when": 1790419185882,
|
"when": 1790419185882,
|
||||||
"tag": "0009_buildings",
|
"tag": "0009_buildings",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
},
|
|
||||||
{
|
|
||||||
"idx": 10,
|
|
||||||
"version": "6",
|
|
||||||
"when": 1790425784626,
|
|
||||||
"tag": "0010_estimate_balances",
|
|
||||||
"breakpoints": true
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
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}.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,121 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
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,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
||||||
import { projects } from "./projects.js";
|
|
||||||
|
|
||||||
// Grobe Leistungsbilanz (LPH 2/3): a separate module without links into the
|
|
||||||
// circuit structure. See docs/estimate-balance.md.
|
|
||||||
export const estimateBalances = sqliteTable(
|
|
||||||
"estimate_balances",
|
|
||||||
{
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
projectId: text("project_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => projects.id, { onDelete: "cascade" }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
workStage: text("work_stage"),
|
|
||||||
dateIso: text("date_iso"),
|
|
||||||
reserveAvPercent: real("reserve_av_percent").notNull().default(0),
|
|
||||||
reserveSvPercent: real("reserve_sv_percent").notNull().default(0),
|
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
|
||||||
},
|
|
||||||
(table) => [index("estimate_balances_project_id_idx").on(table.projectId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const estimateGroups = sqliteTable(
|
|
||||||
"estimate_groups",
|
|
||||||
{
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
balanceId: text("balance_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => estimateBalances.id, { onDelete: "cascade" }),
|
|
||||||
costGroup: text("cost_group").notNull().default(""),
|
|
||||||
title: text("title").notNull(),
|
|
||||||
editor: text("editor"),
|
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
|
||||||
},
|
|
||||||
(table) => [index("estimate_groups_balance_id_idx").on(table.balanceId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const estimateRows = sqliteTable(
|
|
||||||
"estimate_rows",
|
|
||||||
{
|
|
||||||
id: text("id").primaryKey(),
|
|
||||||
groupId: text("group_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => estimateGroups.id, { onDelete: "cascade" }),
|
|
||||||
sortOrder: integer("sort_order").notNull().default(0),
|
|
||||||
supply: text("supply"),
|
|
||||||
circuitRef: text("circuit_ref"),
|
|
||||||
name: text("name").notNull().default(""),
|
|
||||||
quantity: real("quantity").notNull().default(0),
|
|
||||||
unit: text("unit", { enum: ["piece", "area"] }).notNull().default("piece"),
|
|
||||||
connection: text("connection", { enum: ["fixed", "socket"] }),
|
|
||||||
powerPerUnitW: real("power_per_unit_w").notNull().default(0),
|
|
||||||
simultaneityFactor: real("simultaneity_factor").notNull().default(1),
|
|
||||||
cosPhi: real("cos_phi").notNull().default(1),
|
|
||||||
safetySupply: integer("safety_supply", { mode: "boolean" })
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
remark: text("remark"),
|
|
||||||
},
|
|
||||||
(table) => [index("estimate_rows_group_id_idx").on(table.groupId)]
|
|
||||||
);
|
|
||||||
|
|
@ -194,15 +194,6 @@ 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;
|
||||||
|
|
@ -242,8 +233,7 @@ 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(
|
||||||
|
|
@ -664,24 +654,6 @@ 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({
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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();
|
|
||||||
|
|
@ -1,320 +0,0 @@
|
||||||
import crypto from "node:crypto";
|
|
||||||
import { z } from "zod";
|
|
||||||
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
|
|
||||||
import {
|
|
||||||
estimateDefaultGroups,
|
|
||||||
type EstimateBalanceSnapshot,
|
|
||||||
type EstimateGroupSnapshot,
|
|
||||||
} from "../../shared/estimate-balance/estimate-balance.js";
|
|
||||||
|
|
||||||
export const estimateBalanceInsertCommandType = "estimate-balance.insert" as const;
|
|
||||||
export const estimateBalanceDeleteCommandType = "estimate-balance.delete" as const;
|
|
||||||
export const estimateBalanceUpdateCommandType = "estimate-balance.update" as const;
|
|
||||||
export const estimateBalanceCommandSchemaVersion = 1 as const;
|
|
||||||
|
|
||||||
const idSchema = z.string().trim().min(1).max(100);
|
|
||||||
const trimmed = (max: number) =>
|
|
||||||
z.string().max(max).refine((value) => value === value.trim(), {
|
|
||||||
message: "must be trimmed",
|
|
||||||
});
|
|
||||||
const nullableText = (max: number) =>
|
|
||||||
trimmed(max)
|
|
||||||
.refine((value) => value.length > 0, { message: "use null instead of empty" })
|
|
||||||
.nullable();
|
|
||||||
const finite = z.number().finite();
|
|
||||||
|
|
||||||
export const estimateRowSnapshotSchema = z
|
|
||||||
.object({
|
|
||||||
id: idSchema,
|
|
||||||
sortOrder: z.number().int().min(0),
|
|
||||||
supply: nullableText(50),
|
|
||||||
circuitRef: nullableText(50),
|
|
||||||
name: trimmed(200),
|
|
||||||
quantity: finite.min(0),
|
|
||||||
unit: z.enum(["piece", "area"]),
|
|
||||||
connection: z.enum(["fixed", "socket"]).nullable(),
|
|
||||||
powerPerUnitW: finite.min(0),
|
|
||||||
simultaneityFactor: finite.min(0).max(1),
|
|
||||||
cosPhi: finite.gt(0).max(1),
|
|
||||||
safetySupply: z.boolean(),
|
|
||||||
remark: nullableText(500),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const estimateGroupSnapshotSchema = z
|
|
||||||
.object({
|
|
||||||
id: idSchema,
|
|
||||||
costGroup: trimmed(20),
|
|
||||||
title: trimmed(200).refine((value) => value.length > 0, {
|
|
||||||
message: "title must not be empty",
|
|
||||||
}),
|
|
||||||
editor: nullableText(100),
|
|
||||||
sortOrder: z.number().int().min(0),
|
|
||||||
rows: z.array(estimateRowSnapshotSchema).max(1000),
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
export const estimateBalanceSnapshotSchema = z
|
|
||||||
.object({
|
|
||||||
id: idSchema,
|
|
||||||
projectId: idSchema,
|
|
||||||
name: trimmed(200).refine((value) => value.length > 0, {
|
|
||||||
message: "name must not be empty",
|
|
||||||
}),
|
|
||||||
workStage: nullableText(50),
|
|
||||||
dateIso: z
|
|
||||||
.string()
|
|
||||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
||||||
.refine((value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), {
|
|
||||||
message: "invalid date",
|
|
||||||
})
|
|
||||||
.nullable(),
|
|
||||||
reserveAvPercent: finite.min(0).max(100),
|
|
||||||
reserveSvPercent: finite.min(0).max(100),
|
|
||||||
sortOrder: z.number().int().min(0),
|
|
||||||
groups: z.array(estimateGroupSnapshotSchema).max(200),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.superRefine((balance, context) => {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
for (const group of balance.groups) {
|
|
||||||
for (const id of [group.id, ...group.rows.map((row) => row.id)]) {
|
|
||||||
if (ids.has(id)) {
|
|
||||||
context.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `duplicate id ${id}`,
|
|
||||||
path: ["groups"],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
ids.add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface EstimateBalanceInsertProjectCommand
|
|
||||||
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
|
||||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
|
||||||
type: typeof estimateBalanceInsertCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EstimateBalanceDeleteProjectCommand
|
|
||||||
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
|
|
||||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
|
||||||
type: typeof estimateBalanceDeleteCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EstimateBalanceUpdateProjectCommand
|
|
||||||
extends SerializedProjectCommand<{
|
|
||||||
expected: EstimateBalanceSnapshot;
|
|
||||||
target: EstimateBalanceSnapshot;
|
|
||||||
}> {
|
|
||||||
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
|
|
||||||
type: typeof estimateBalanceUpdateCommandType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EstimateBalanceProjectCommand =
|
|
||||||
| EstimateBalanceInsertProjectCommand
|
|
||||||
| EstimateBalanceDeleteProjectCommand
|
|
||||||
| EstimateBalanceUpdateProjectCommand;
|
|
||||||
|
|
||||||
/** Parses and normalizes a snapshot (sort orders of groups and rows follow the array order). */
|
|
||||||
export function parseEstimateBalanceSnapshot(value: unknown): EstimateBalanceSnapshot {
|
|
||||||
const parsed = estimateBalanceSnapshotSchema.parse(value);
|
|
||||||
return normalizeEstimateBalanceSnapshot(parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeEstimateBalanceSnapshot(
|
|
||||||
balance: EstimateBalanceSnapshot
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
return {
|
|
||||||
...balance,
|
|
||||||
groups: balance.groups.map((group, groupIndex) => ({
|
|
||||||
...group,
|
|
||||||
sortOrder: groupIndex,
|
|
||||||
rows: group.rows.map((row, rowIndex) => ({ ...row, sortOrder: rowIndex })),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stable JSON for exact equality checks of two snapshots. */
|
|
||||||
export function canonicalEstimateBalance(balance: EstimateBalanceSnapshot): string {
|
|
||||||
const normalized = normalizeEstimateBalanceSnapshot(balance);
|
|
||||||
return JSON.stringify({
|
|
||||||
id: normalized.id,
|
|
||||||
projectId: normalized.projectId,
|
|
||||||
name: normalized.name,
|
|
||||||
workStage: normalized.workStage,
|
|
||||||
dateIso: normalized.dateIso,
|
|
||||||
reserveAvPercent: normalized.reserveAvPercent,
|
|
||||||
reserveSvPercent: normalized.reserveSvPercent,
|
|
||||||
sortOrder: normalized.sortOrder,
|
|
||||||
groups: normalized.groups.map((group) => ({
|
|
||||||
id: group.id,
|
|
||||||
costGroup: group.costGroup,
|
|
||||||
title: group.title,
|
|
||||||
editor: group.editor,
|
|
||||||
sortOrder: group.sortOrder,
|
|
||||||
rows: group.rows.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
sortOrder: row.sortOrder,
|
|
||||||
supply: row.supply,
|
|
||||||
circuitRef: row.circuitRef,
|
|
||||||
name: row.name,
|
|
||||||
quantity: row.quantity,
|
|
||||||
unit: row.unit,
|
|
||||||
connection: row.connection,
|
|
||||||
powerPerUnitW: row.powerPerUnitW,
|
|
||||||
simultaneityFactor: row.simultaneityFactor,
|
|
||||||
cosPhi: row.cosPhi,
|
|
||||||
safetySupply: row.safetySupply,
|
|
||||||
remark: row.remark,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sameEstimateBalance(
|
|
||||||
left: EstimateBalanceSnapshot,
|
|
||||||
right: EstimateBalanceSnapshot
|
|
||||||
): boolean {
|
|
||||||
return canonicalEstimateBalance(left) === canonicalEstimateBalance(right);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEstimateBalanceSnapshot(input: {
|
|
||||||
projectId: string;
|
|
||||||
name: string;
|
|
||||||
workStage: string | null;
|
|
||||||
dateIso: string | null;
|
|
||||||
sortOrder: number;
|
|
||||||
template: "kg400" | "empty" | { copyFrom: EstimateBalanceSnapshot };
|
|
||||||
createId?: () => string;
|
|
||||||
}): EstimateBalanceSnapshot {
|
|
||||||
const createId = input.createId ?? (() => crypto.randomUUID());
|
|
||||||
let groups: EstimateGroupSnapshot[] = [];
|
|
||||||
let reserveAvPercent = 10;
|
|
||||||
let reserveSvPercent = 0;
|
|
||||||
if (input.template === "kg400") {
|
|
||||||
groups = estimateDefaultGroups.map((group, index) => ({
|
|
||||||
id: createId(),
|
|
||||||
costGroup: group.costGroup,
|
|
||||||
title: group.title,
|
|
||||||
editor: null,
|
|
||||||
sortOrder: index,
|
|
||||||
rows: [],
|
|
||||||
}));
|
|
||||||
} else if (input.template !== "empty") {
|
|
||||||
const source = input.template.copyFrom;
|
|
||||||
reserveAvPercent = source.reserveAvPercent;
|
|
||||||
reserveSvPercent = source.reserveSvPercent;
|
|
||||||
groups = source.groups.map((group) => ({
|
|
||||||
...group,
|
|
||||||
id: createId(),
|
|
||||||
rows: group.rows.map((row) => ({ ...row, id: createId() })),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return parseEstimateBalanceSnapshot({
|
|
||||||
id: createId(),
|
|
||||||
projectId: input.projectId,
|
|
||||||
name: input.name.trim(),
|
|
||||||
workStage: input.workStage?.trim() || null,
|
|
||||||
dateIso: input.dateIso || null,
|
|
||||||
reserveAvPercent,
|
|
||||||
reserveSvPercent,
|
|
||||||
sortOrder: input.sortOrder,
|
|
||||||
groups,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEstimateBalanceInsertProjectCommand(
|
|
||||||
balance: EstimateBalanceSnapshot
|
|
||||||
): EstimateBalanceInsertProjectCommand {
|
|
||||||
return {
|
|
||||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
|
||||||
type: estimateBalanceInsertCommandType,
|
|
||||||
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEstimateBalanceDeleteProjectCommand(
|
|
||||||
balance: EstimateBalanceSnapshot
|
|
||||||
): EstimateBalanceDeleteProjectCommand {
|
|
||||||
return {
|
|
||||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
|
||||||
type: estimateBalanceDeleteCommandType,
|
|
||||||
payload: { balance: parseEstimateBalanceSnapshot(balance) },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEstimateBalanceUpdateProjectCommand(
|
|
||||||
expected: EstimateBalanceSnapshot,
|
|
||||||
target: EstimateBalanceSnapshot
|
|
||||||
): EstimateBalanceUpdateProjectCommand {
|
|
||||||
const payload = {
|
|
||||||
expected: parseEstimateBalanceSnapshot(expected),
|
|
||||||
target: parseEstimateBalanceSnapshot(target),
|
|
||||||
};
|
|
||||||
assertMatchingUpdate(payload.expected, payload.target);
|
|
||||||
return {
|
|
||||||
schemaVersion: estimateBalanceCommandSchemaVersion,
|
|
||||||
type: estimateBalanceUpdateCommandType,
|
|
||||||
payload,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertMatchingUpdate(
|
|
||||||
expected: EstimateBalanceSnapshot,
|
|
||||||
target: EstimateBalanceSnapshot
|
|
||||||
) {
|
|
||||||
if (expected.id !== target.id || expected.projectId !== target.projectId) {
|
|
||||||
throw new Error("Estimate-balance update must keep id and project.");
|
|
||||||
}
|
|
||||||
if (sameEstimateBalance(expected, target)) {
|
|
||||||
throw new Error("Estimate-balance update must change the balance.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertCommandShape(
|
|
||||||
command: SerializedProjectCommand<unknown>,
|
|
||||||
type: string,
|
|
||||||
keys: string[]
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const payload = command.payload;
|
|
||||||
if (
|
|
||||||
command.schemaVersion !== estimateBalanceCommandSchemaVersion ||
|
|
||||||
command.type !== type ||
|
|
||||||
payload === null ||
|
|
||||||
typeof payload !== "object" ||
|
|
||||||
Array.isArray(payload) ||
|
|
||||||
Object.keys(payload).sort().join(",") !== [...keys].sort().join(",")
|
|
||||||
) {
|
|
||||||
throw new Error("Unsupported estimate-balance command.");
|
|
||||||
}
|
|
||||||
return payload as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertEstimateBalanceInsertProjectCommand(
|
|
||||||
command: SerializedProjectCommand<unknown>
|
|
||||||
): asserts command is EstimateBalanceInsertProjectCommand {
|
|
||||||
const payload = assertCommandShape(command, estimateBalanceInsertCommandType, ["balance"]);
|
|
||||||
parseEstimateBalanceSnapshot(payload.balance);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertEstimateBalanceDeleteProjectCommand(
|
|
||||||
command: SerializedProjectCommand<unknown>
|
|
||||||
): asserts command is EstimateBalanceDeleteProjectCommand {
|
|
||||||
const payload = assertCommandShape(command, estimateBalanceDeleteCommandType, ["balance"]);
|
|
||||||
parseEstimateBalanceSnapshot(payload.balance);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertEstimateBalanceUpdateProjectCommand(
|
|
||||||
command: SerializedProjectCommand<unknown>
|
|
||||||
): asserts command is EstimateBalanceUpdateProjectCommand {
|
|
||||||
const payload = assertCommandShape(command, estimateBalanceUpdateCommandType, [
|
|
||||||
"expected",
|
|
||||||
"target",
|
|
||||||
]);
|
|
||||||
assertMatchingUpdate(
|
|
||||||
parseEstimateBalanceSnapshot(payload.expected),
|
|
||||||
parseEstimateBalanceSnapshot(payload.target)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
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,7 +17,6 @@ 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).
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,6 @@ import {
|
||||||
type MutableDistributionBoardComponentRole,
|
type MutableDistributionBoardComponentRole,
|
||||||
} from "../utils/distribution-board-component-editing";
|
} from "../utils/distribution-board-component-editing";
|
||||||
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
|
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
|
||||||
import { createClientId } from "../utils/client-id";
|
|
||||||
import type {
|
import type {
|
||||||
CellKey,
|
CellKey,
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
|
|
@ -978,7 +977,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
defaultSortOrder: number
|
defaultSortOrder: number
|
||||||
) {
|
) {
|
||||||
return buildCircuitDeviceRowInsertSnapshot({
|
return buildCircuitDeviceRowInsertSnapshot({
|
||||||
id: createClientId(),
|
id: crypto.randomUUID(),
|
||||||
circuitId,
|
circuitId,
|
||||||
values,
|
values,
|
||||||
defaultSortOrder,
|
defaultSortOrder,
|
||||||
|
|
@ -1002,7 +1001,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
),
|
),
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
const circuitId = createClientId();
|
const circuitId = crypto.randomUUID();
|
||||||
const deviceRows = deviceRowValues.map((row, index) =>
|
const deviceRows = deviceRowValues.map((row, index) =>
|
||||||
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
|
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
|
||||||
);
|
);
|
||||||
|
|
@ -1170,7 +1169,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
(section) => section.id === intent.sectionId
|
(section) => section.id === intent.sectionId
|
||||||
)?.components ?? [];
|
)?.components ?? [];
|
||||||
const snapshot = buildDistributionBoardComponentSnapshot({
|
const snapshot = buildDistributionBoardComponentSnapshot({
|
||||||
id: createClientId(),
|
id: crypto.randomUUID(),
|
||||||
circuitListId,
|
circuitListId,
|
||||||
role: intent.role,
|
role: intent.role,
|
||||||
sectionId: intent.sectionId,
|
sectionId: intent.sectionId,
|
||||||
|
|
@ -1235,7 +1234,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
projectId,
|
projectId,
|
||||||
getExpectedProjectRevision(),
|
getExpectedProjectRevision(),
|
||||||
buildNewCircuitGroupSnapshot({
|
buildNewCircuitGroupSnapshot({
|
||||||
id: createClientId(),
|
id: crypto.randomUUID(),
|
||||||
circuitListId,
|
circuitListId,
|
||||||
category: values.category,
|
category: values.category,
|
||||||
displayName: values.displayName,
|
displayName: values.displayName,
|
||||||
|
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,301 +0,0 @@
|
||||||
// Pure, immutable editing rules for one "Grobe Leistungsbilanz" document.
|
|
||||||
// The editor keeps the complete document as local draft and saves it as a
|
|
||||||
// whole (PUT), so every edit is a function draft → new draft.
|
|
||||||
import type {
|
|
||||||
EstimateBalanceSnapshot,
|
|
||||||
EstimateGroupSnapshot,
|
|
||||||
EstimateRowSnapshot,
|
|
||||||
} from "../../shared/estimate-balance/estimate-balance";
|
|
||||||
import { createClientId } from "../utils/client-id";
|
|
||||||
|
|
||||||
export type MoveDirection = -1 | 1;
|
|
||||||
|
|
||||||
/** Maximum text lengths accepted by the server. */
|
|
||||||
export const estimateTextLimits = {
|
|
||||||
balanceName: 200,
|
|
||||||
workStage: 50,
|
|
||||||
costGroup: 20,
|
|
||||||
groupTitle: 200,
|
|
||||||
groupEditor: 100,
|
|
||||||
supply: 50,
|
|
||||||
circuitRef: 50,
|
|
||||||
rowName: 200,
|
|
||||||
remark: 500,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type EstimateRowPatch = Partial<Omit<EstimateRowSnapshot, "id" | "sortOrder">>;
|
|
||||||
export type EstimateGroupPatch = Partial<Pick<EstimateGroupSnapshot, "costGroup" | "title" | "editor">>;
|
|
||||||
|
|
||||||
export function createEstimateRow(
|
|
||||||
sortOrder: number,
|
|
||||||
createId: () => string = createClientId
|
|
||||||
): EstimateRowSnapshot {
|
|
||||||
return {
|
|
||||||
id: createId(),
|
|
||||||
sortOrder,
|
|
||||||
supply: null,
|
|
||||||
circuitRef: null,
|
|
||||||
name: "",
|
|
||||||
quantity: 1,
|
|
||||||
unit: "piece",
|
|
||||||
connection: null,
|
|
||||||
powerPerUnitW: 0,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
cosPhi: 1,
|
|
||||||
safetySupply: false,
|
|
||||||
remark: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEstimateGroup(
|
|
||||||
input: { costGroup: string; title: string; editor?: string | null },
|
|
||||||
sortOrder: number,
|
|
||||||
createId: () => string = createClientId
|
|
||||||
): EstimateGroupSnapshot {
|
|
||||||
return {
|
|
||||||
id: createId(),
|
|
||||||
costGroup: input.costGroup,
|
|
||||||
title: input.title,
|
|
||||||
editor: input.editor ?? null,
|
|
||||||
sortOrder,
|
|
||||||
rows: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function bySortOrder<T extends { sortOrder: number }>(left: T, right: T) {
|
|
||||||
return left.sortOrder - right.sortOrder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Orders groups and rows by sortOrder and renumbers both from 0. */
|
|
||||||
export function normalizeEstimateSortOrder(
|
|
||||||
balance: EstimateBalanceSnapshot
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
return {
|
|
||||||
...balance,
|
|
||||||
groups: [...balance.groups].sort(bySortOrder).map((group, groupIndex) => ({
|
|
||||||
...group,
|
|
||||||
sortOrder: groupIndex,
|
|
||||||
rows: [...group.rows].sort(bySortOrder).map((row, rowIndex) => ({
|
|
||||||
...row,
|
|
||||||
sortOrder: rowIndex,
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function renumber<T extends { sortOrder: number }>(items: T[]): T[] {
|
|
||||||
return items.map((item, index) =>
|
|
||||||
item.sortOrder === index ? item : { ...item, sortOrder: index }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapGroup(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string,
|
|
||||||
update: (group: EstimateGroupSnapshot) => EstimateGroupSnapshot
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
let changed = false;
|
|
||||||
const groups = balance.groups.map((group) => {
|
|
||||||
if (group.id !== groupId) {
|
|
||||||
return group;
|
|
||||||
}
|
|
||||||
const next = update(group);
|
|
||||||
changed = next !== group;
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
// An unknown group or a no-op keeps the identical document.
|
|
||||||
return changed ? { ...balance, groups } : balance;
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveItem<T>(items: T[], index: number, direction: MoveDirection): T[] | null {
|
|
||||||
const target = index + direction;
|
|
||||||
if (index < 0 || target < 0 || target >= items.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const next = [...items];
|
|
||||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Appends a row (or inserts it after `afterRowId`) and returns the new row id. */
|
|
||||||
export function addEstimateRow(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string,
|
|
||||||
options: { afterRowId?: string; createId?: () => string } = {}
|
|
||||||
): { balance: EstimateBalanceSnapshot; rowId: string | null } {
|
|
||||||
let rowId: string | null = null;
|
|
||||||
const next = mapGroup(balance, groupId, (group) => {
|
|
||||||
const row = createEstimateRow(0, options.createId);
|
|
||||||
rowId = row.id;
|
|
||||||
const afterIndex = options.afterRowId
|
|
||||||
? group.rows.findIndex((candidate) => candidate.id === options.afterRowId)
|
|
||||||
: -1;
|
|
||||||
const rows = [...group.rows];
|
|
||||||
rows.splice(afterIndex >= 0 ? afterIndex + 1 : rows.length, 0, row);
|
|
||||||
return { ...group, rows: renumber(rows) };
|
|
||||||
});
|
|
||||||
return { balance: next, rowId };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateEstimateRow(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string,
|
|
||||||
rowId: string,
|
|
||||||
patch: EstimateRowPatch
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
return mapGroup(balance, groupId, (group) => ({
|
|
||||||
...group,
|
|
||||||
rows: group.rows.map((row) => (row.id === rowId ? { ...row, ...patch } : row)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeEstimateRow(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string,
|
|
||||||
rowId: string
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
return mapGroup(balance, groupId, (group) => ({
|
|
||||||
...group,
|
|
||||||
rows: renumber(group.rows.filter((row) => row.id !== rowId)),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moveEstimateRow(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string,
|
|
||||||
rowId: string,
|
|
||||||
direction: MoveDirection
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
return mapGroup(balance, groupId, (group) => {
|
|
||||||
const rows = moveItem(
|
|
||||||
group.rows,
|
|
||||||
group.rows.findIndex((row) => row.id === rowId),
|
|
||||||
direction
|
|
||||||
);
|
|
||||||
return rows ? { ...group, rows: renumber(rows) } : group;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Appends a group and returns its id. */
|
|
||||||
export function addEstimateGroup(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
input: { costGroup: string; title: string; editor?: string | null },
|
|
||||||
createId: () => string = createClientId
|
|
||||||
): { balance: EstimateBalanceSnapshot; groupId: string } {
|
|
||||||
const group = createEstimateGroup(input, balance.groups.length, createId);
|
|
||||||
return {
|
|
||||||
balance: { ...balance, groups: renumber([...balance.groups, group]) },
|
|
||||||
groupId: group.id,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateEstimateGroup(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string,
|
|
||||||
patch: EstimateGroupPatch
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
return mapGroup(balance, groupId, (group) => ({ ...group, ...patch }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeEstimateGroup(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
const groups = balance.groups.filter((group) => group.id !== groupId);
|
|
||||||
return groups.length === balance.groups.length
|
|
||||||
? balance
|
|
||||||
: { ...balance, groups: renumber(groups) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function moveEstimateGroup(
|
|
||||||
balance: EstimateBalanceSnapshot,
|
|
||||||
groupId: string,
|
|
||||||
direction: MoveDirection
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
const groups = moveItem(
|
|
||||||
balance.groups,
|
|
||||||
balance.groups.findIndex((group) => group.id === groupId),
|
|
||||||
direction
|
|
||||||
);
|
|
||||||
return groups ? { ...balance, groups: renumber(groups) } : balance;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requiredText(value: string) {
|
|
||||||
return value.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function optionalText(value: string | null) {
|
|
||||||
const trimmed = value?.trim() ?? "";
|
|
||||||
return trimmed ? trimmed : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The exact document the server accepts: strings trimmed, empty optional
|
|
||||||
* texts as null, sort orders continuous. Only fields of the shared snapshot
|
|
||||||
* type are copied, so no UI state can leak into the request.
|
|
||||||
*/
|
|
||||||
export function toSaveableEstimateBalance(
|
|
||||||
balance: EstimateBalanceSnapshot
|
|
||||||
): EstimateBalanceSnapshot {
|
|
||||||
const normalized = normalizeEstimateSortOrder(balance);
|
|
||||||
return {
|
|
||||||
id: normalized.id,
|
|
||||||
projectId: normalized.projectId,
|
|
||||||
name: requiredText(normalized.name),
|
|
||||||
workStage: optionalText(normalized.workStage),
|
|
||||||
dateIso: normalized.dateIso || null,
|
|
||||||
reserveAvPercent: normalized.reserveAvPercent,
|
|
||||||
reserveSvPercent: normalized.reserveSvPercent,
|
|
||||||
sortOrder: normalized.sortOrder,
|
|
||||||
groups: normalized.groups.map((group) => ({
|
|
||||||
id: group.id,
|
|
||||||
costGroup: requiredText(group.costGroup),
|
|
||||||
title: requiredText(group.title),
|
|
||||||
editor: optionalText(group.editor),
|
|
||||||
sortOrder: group.sortOrder,
|
|
||||||
rows: group.rows.map((row) => ({
|
|
||||||
id: row.id,
|
|
||||||
sortOrder: row.sortOrder,
|
|
||||||
supply: optionalText(row.supply),
|
|
||||||
circuitRef: optionalText(row.circuitRef),
|
|
||||||
name: requiredText(row.name),
|
|
||||||
quantity: row.quantity,
|
|
||||||
unit: row.unit,
|
|
||||||
connection: row.connection,
|
|
||||||
powerPerUnitW: row.powerPerUnitW,
|
|
||||||
simultaneityFactor: row.simultaneityFactor,
|
|
||||||
cosPhi: row.cosPhi,
|
|
||||||
safetySupply: row.safetySupply,
|
|
||||||
remark: optionalText(row.remark),
|
|
||||||
})),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stable comparison key of the saveable document (dirty detection). */
|
|
||||||
export function estimateBalanceFingerprint(balance: EstimateBalanceSnapshot): string {
|
|
||||||
return JSON.stringify(toSaveableEstimateBalance(balance));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* German problems that would make the server reject the document. The
|
|
||||||
* editor never saves while this list is not empty.
|
|
||||||
*/
|
|
||||||
export function validateEstimateBalance(balance: EstimateBalanceSnapshot): string[] {
|
|
||||||
const problems: string[] = [];
|
|
||||||
const saveable = toSaveableEstimateBalance(balance);
|
|
||||||
if (!saveable.name) {
|
|
||||||
problems.push("Die Bezeichnung des Stands darf nicht leer sein.");
|
|
||||||
}
|
|
||||||
for (const group of saveable.groups) {
|
|
||||||
if (!group.title) {
|
|
||||||
problems.push(`Kostengruppe ${group.costGroup || "ohne Nummer"}: Titel fehlt.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return problems;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createCopyName(name: string) {
|
|
||||||
const copyName = `${name.trim()} (Kopie)`;
|
|
||||||
return copyName.slice(0, estimateTextLimits.balanceName);
|
|
||||||
}
|
|
||||||
|
|
@ -1,122 +0,0 @@
|
||||||
// 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}`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,652 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
|
|
||||||
import {
|
|
||||||
calculateEstimateBalance,
|
|
||||||
calculateEstimateRow,
|
|
||||||
estimateRowConnectionLabels,
|
|
||||||
estimateRowUnitLabels,
|
|
||||||
type EstimateBalanceSnapshot,
|
|
||||||
type EstimateGroupSnapshot,
|
|
||||||
type EstimatePower,
|
|
||||||
type EstimateRowConnection,
|
|
||||||
type EstimateRowSnapshot,
|
|
||||||
type EstimateRowUnit,
|
|
||||||
} from "../../shared/estimate-balance/estimate-balance";
|
|
||||||
import {
|
|
||||||
addEstimateRow,
|
|
||||||
estimateTextLimits,
|
|
||||||
moveEstimateGroup,
|
|
||||||
removeEstimateGroup,
|
|
||||||
removeEstimateRow,
|
|
||||||
updateEstimateGroup,
|
|
||||||
updateEstimateRow,
|
|
||||||
type EstimateRowPatch,
|
|
||||||
} from "./estimate-balance-document";
|
|
||||||
import {
|
|
||||||
estimateNumberRanges,
|
|
||||||
formatAmpere,
|
|
||||||
formatDecimalInput,
|
|
||||||
formatKw,
|
|
||||||
parseGermanDecimal,
|
|
||||||
type DecimalRange,
|
|
||||||
} from "./estimate-balance-format";
|
|
||||||
import { DraftInput } from "./estimate-draft-input";
|
|
||||||
|
|
||||||
export type EstimateBalanceUpdate = (
|
|
||||||
update: (balance: EstimateBalanceSnapshot) => EstimateBalanceSnapshot
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
interface EstimateBalanceSheetProps {
|
|
||||||
balance: EstimateBalanceSnapshot;
|
|
||||||
disabled: boolean;
|
|
||||||
onAddGroup: () => void;
|
|
||||||
onChange: EstimateBalanceUpdate;
|
|
||||||
onEditGroup: (group: EstimateGroupSnapshot) => void;
|
|
||||||
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
|
||||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
|
||||||
threePhaseVoltageV: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Leading columns up to cos φ; the power columns follow.
|
|
||||||
const LEADING_COLUMN_COUNT = 10;
|
|
||||||
const COLUMN_COUNT = 17;
|
|
||||||
|
|
||||||
function NumberInput({
|
|
||||||
ariaLabel,
|
|
||||||
disabled,
|
|
||||||
fieldKey,
|
|
||||||
onCommit,
|
|
||||||
onValidityChange,
|
|
||||||
range,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
ariaLabel: string;
|
|
||||||
disabled: boolean;
|
|
||||||
fieldKey: string;
|
|
||||||
onCommit: (value: number) => void;
|
|
||||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
|
||||||
range: DecimalRange;
|
|
||||||
value: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DraftInput
|
|
||||||
ariaLabel={ariaLabel}
|
|
||||||
className="estimate-cell-input is-numeric"
|
|
||||||
disabled={disabled}
|
|
||||||
format={(current: number) => formatDecimalInput(current)}
|
|
||||||
inputMode="decimal"
|
|
||||||
onCommit={onCommit}
|
|
||||||
onValidityChange={(isValid) => onValidityChange(fieldKey, isValid)}
|
|
||||||
parse={(text) => parseGermanDecimal(text, range)}
|
|
||||||
value={value}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TextInput({
|
|
||||||
ariaLabel,
|
|
||||||
disabled,
|
|
||||||
maxLength,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
ariaLabel: string;
|
|
||||||
disabled: boolean;
|
|
||||||
maxLength: number;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
value: string | null;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className="estimate-cell-input"
|
|
||||||
disabled={disabled}
|
|
||||||
maxLength={maxLength}
|
|
||||||
onChange={(event) => onChange(event.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
type="text"
|
|
||||||
value={value ?? ""}
|
|
||||||
/>
|
|
||||||
<span className="estimate-print-value">{value ?? ""}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function IconButton({
|
|
||||||
children,
|
|
||||||
disabled,
|
|
||||||
label,
|
|
||||||
onClick,
|
|
||||||
variant = "secondary",
|
|
||||||
}: {
|
|
||||||
children: ReactNode;
|
|
||||||
disabled: boolean;
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
variant?: "secondary" | "danger" | "primary";
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
aria-label={label}
|
|
||||||
className={`btn btn-sm btn-outline-${variant} estimate-icon-button`}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onClick}
|
|
||||||
title={label}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PowerCells({ power, className = "" }: { power: EstimatePower; className?: string }) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<td className={`estimate-number ${className}`}>{formatKw(power.kw)}</td>
|
|
||||||
<td className={`estimate-number ${className}`}>{formatKw(power.kva)}</td>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function groupLabel(group: EstimateGroupSnapshot) {
|
|
||||||
return [group.costGroup, group.title].filter(Boolean).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function EstimateRowLine({
|
|
||||||
disabled,
|
|
||||||
group,
|
|
||||||
onChange,
|
|
||||||
onOpenGzfPicker,
|
|
||||||
onValidityChange,
|
|
||||||
row,
|
|
||||||
rowIndex,
|
|
||||||
}: {
|
|
||||||
disabled: boolean;
|
|
||||||
group: EstimateGroupSnapshot;
|
|
||||||
onChange: EstimateBalanceUpdate;
|
|
||||||
onOpenGzfPicker: (groupId: string, rowId: string) => void;
|
|
||||||
onValidityChange: (fieldKey: string, isValid: boolean) => void;
|
|
||||||
row: EstimateRowSnapshot;
|
|
||||||
rowIndex: number;
|
|
||||||
}) {
|
|
||||||
const result = calculateEstimateRow(row);
|
|
||||||
const rowLabel = `${groupLabel(group)}, Zeile ${rowIndex + 1}`;
|
|
||||||
const fieldKey = (field: string) => `${row.id}:${field}`;
|
|
||||||
|
|
||||||
function patch(rowPatch: EstimateRowPatch) {
|
|
||||||
onChange((balance) => updateEstimateRow(balance, group.id, row.id, rowPatch));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr className="estimate-row">
|
|
||||||
<td className="estimate-cost-group-cell" />
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
ariaLabel={`Versorgung (${rowLabel})`}
|
|
||||||
disabled={disabled}
|
|
||||||
maxLength={estimateTextLimits.supply}
|
|
||||||
onChange={(supply) => patch({ supply })}
|
|
||||||
placeholder="ELT"
|
|
||||||
value={row.supply}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
ariaLabel={`Stromkreis-Nr. (${rowLabel})`}
|
|
||||||
disabled={disabled}
|
|
||||||
maxLength={estimateTextLimits.circuitRef}
|
|
||||||
onChange={(circuitRef) => patch({ circuitRef })}
|
|
||||||
value={row.circuitRef}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
ariaLabel={`Verbraucher / Fläche (${rowLabel})`}
|
|
||||||
disabled={disabled}
|
|
||||||
maxLength={estimateTextLimits.rowName}
|
|
||||||
onChange={(name) => patch({ name })}
|
|
||||||
placeholder="Verbraucher oder Fläche"
|
|
||||||
value={row.name}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<NumberInput
|
|
||||||
ariaLabel={`Anzahl / Fläche (${rowLabel})`}
|
|
||||||
disabled={disabled}
|
|
||||||
fieldKey={fieldKey("quantity")}
|
|
||||||
onCommit={(quantity) => patch({ quantity })}
|
|
||||||
onValidityChange={onValidityChange}
|
|
||||||
range={estimateNumberRanges.quantity}
|
|
||||||
value={row.quantity}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<select
|
|
||||||
aria-label={`Einheit (${rowLabel})`}
|
|
||||||
className="estimate-cell-select"
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(event) => patch({ unit: event.target.value as EstimateRowUnit })}
|
|
||||||
value={row.unit}
|
|
||||||
>
|
|
||||||
{(Object.keys(estimateRowUnitLabels) as EstimateRowUnit[]).map((unit) => (
|
|
||||||
<option key={unit} value={unit}>
|
|
||||||
{estimateRowUnitLabels[unit]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<span className="estimate-print-value">{estimateRowUnitLabels[row.unit]}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<select
|
|
||||||
aria-label={`Anschluss (${rowLabel})`}
|
|
||||||
className="estimate-cell-select"
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(event) =>
|
|
||||||
patch({
|
|
||||||
connection: (event.target.value || null) as EstimateRowConnection | null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
value={row.connection ?? ""}
|
|
||||||
>
|
|
||||||
<option value="">–</option>
|
|
||||||
{(Object.keys(estimateRowConnectionLabels) as EstimateRowConnection[]).map(
|
|
||||||
(connection) => (
|
|
||||||
<option key={connection} value={connection}>
|
|
||||||
{estimateRowConnectionLabels[connection]}
|
|
||||||
</option>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</select>
|
|
||||||
<span className="estimate-print-value">
|
|
||||||
{row.connection ? estimateRowConnectionLabels[row.connection] : ""}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="estimate-unit-input">
|
|
||||||
<NumberInput
|
|
||||||
ariaLabel={`Einzelleistung in ${row.unit === "area" ? "W/m²" : "W"} (${rowLabel})`}
|
|
||||||
disabled={disabled}
|
|
||||||
fieldKey={fieldKey("powerPerUnitW")}
|
|
||||||
onCommit={(powerPerUnitW) => patch({ powerPerUnitW })}
|
|
||||||
onValidityChange={onValidityChange}
|
|
||||||
range={estimateNumberRanges.powerPerUnitW}
|
|
||||||
value={row.powerPerUnitW}
|
|
||||||
/>
|
|
||||||
<span className="estimate-unit-label">{row.unit === "area" ? "W/m²" : "W"}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="estimate-gzf-input">
|
|
||||||
<NumberInput
|
|
||||||
ariaLabel={`Gleichzeitigkeitsfaktor (${rowLabel})`}
|
|
||||||
disabled={disabled}
|
|
||||||
fieldKey={fieldKey("simultaneityFactor")}
|
|
||||||
onCommit={(simultaneityFactor) => patch({ simultaneityFactor })}
|
|
||||||
onValidityChange={onValidityChange}
|
|
||||||
range={estimateNumberRanges.simultaneityFactor}
|
|
||||||
value={row.simultaneityFactor}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
aria-label={`Gleichzeitigkeitsfaktor nach AMEV wählen (${rowLabel})`}
|
|
||||||
className="estimate-amev-button estimate-no-print"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={() => onOpenGzfPicker(group.id, row.id)}
|
|
||||||
title="Gleichzeitigkeitsfaktor nach AMEV wählen"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
AMEV
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<NumberInput
|
|
||||||
ariaLabel={`cos φ (${rowLabel})`}
|
|
||||||
disabled={disabled}
|
|
||||||
fieldKey={fieldKey("cosPhi")}
|
|
||||||
onCommit={(cosPhi) => patch({ cosPhi })}
|
|
||||||
onValidityChange={onValidityChange}
|
|
||||||
range={estimateNumberRanges.cosPhi}
|
|
||||||
value={row.cosPhi}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<PowerCells power={result.av} />
|
|
||||||
<td className="estimate-center">
|
|
||||||
<input
|
|
||||||
aria-label={`Sicherheitsversorgung (${rowLabel})`}
|
|
||||||
checked={row.safetySupply}
|
|
||||||
className="form-check-input estimate-no-print"
|
|
||||||
disabled={disabled}
|
|
||||||
onChange={(event) => patch({ safetySupply: event.target.checked })}
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
<span className="estimate-print-value">{row.safetySupply ? "ja" : "nein"}</span>
|
|
||||||
</td>
|
|
||||||
<PowerCells
|
|
||||||
className={row.safetySupply ? "" : "is-muted"}
|
|
||||||
power={result.sv}
|
|
||||||
/>
|
|
||||||
<td>
|
|
||||||
<TextInput
|
|
||||||
ariaLabel={`Anmerkung (${rowLabel})`}
|
|
||||||
disabled={disabled}
|
|
||||||
maxLength={estimateTextLimits.remark}
|
|
||||||
onChange={(remark) => patch({ remark })}
|
|
||||||
value={row.remark}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="estimate-actions-cell estimate-no-print">
|
|
||||||
<IconButton
|
|
||||||
disabled={disabled}
|
|
||||||
label={`Zeile löschen (${rowLabel})`}
|
|
||||||
onClick={() => onChange((balance) => removeEstimateRow(balance, group.id, row.id))}
|
|
||||||
variant="danger"
|
|
||||||
>
|
|
||||||
<Trash2 aria-hidden="true" size={13} />
|
|
||||||
</IconButton>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Spreadsheet-like editor in the layout of the office template
|
|
||||||
* "Leistungsbilanz – KG400": one section per cost group with title and
|
|
||||||
* subtotal row, totals, reserve and maximum current at the end.
|
|
||||||
*/
|
|
||||||
export function EstimateBalanceSheet({
|
|
||||||
balance,
|
|
||||||
disabled,
|
|
||||||
onAddGroup,
|
|
||||||
onChange,
|
|
||||||
onEditGroup,
|
|
||||||
onOpenGzfPicker,
|
|
||||||
onValidityChange,
|
|
||||||
threePhaseVoltageV,
|
|
||||||
}: EstimateBalanceSheetProps) {
|
|
||||||
const result = calculateEstimateBalance(balance, threePhaseVoltageV);
|
|
||||||
|
|
||||||
function confirmRemoveGroup(group: EstimateGroupSnapshot) {
|
|
||||||
const rowText =
|
|
||||||
group.rows.length === 1 ? "1 Zeile" : `${group.rows.length} Zeilen`;
|
|
||||||
if (
|
|
||||||
window.confirm(
|
|
||||||
`Kostengruppe „${groupLabel(group)}“ mit ${rowText} wirklich löschen?`
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
onChange((current) => removeEstimateGroup(current, group.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="estimate-sheet-wrap">
|
|
||||||
<table className="estimate-sheet">
|
|
||||||
<colgroup>
|
|
||||||
<col className="estimate-col-kg" />
|
|
||||||
<col className="estimate-col-supply" />
|
|
||||||
<col className="estimate-col-circuit" />
|
|
||||||
<col className="estimate-col-name" />
|
|
||||||
<col className="estimate-col-quantity" />
|
|
||||||
<col className="estimate-col-unit" />
|
|
||||||
<col className="estimate-col-connection" />
|
|
||||||
<col className="estimate-col-power" />
|
|
||||||
<col className="estimate-col-gzf" />
|
|
||||||
<col className="estimate-col-cosphi" />
|
|
||||||
<col className="estimate-col-result" />
|
|
||||||
<col className="estimate-col-result" />
|
|
||||||
<col className="estimate-col-sv" />
|
|
||||||
<col className="estimate-col-result" />
|
|
||||||
<col className="estimate-col-result" />
|
|
||||||
<col className="estimate-col-remark" />
|
|
||||||
<col className="estimate-col-actions" />
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th rowSpan={2}>KG</th>
|
|
||||||
<th rowSpan={2}>
|
|
||||||
Versorgung
|
|
||||||
<span className="estimate-th-hint">ELT/ISP …</span>
|
|
||||||
</th>
|
|
||||||
<th rowSpan={2}>Stromkreis-Nr.</th>
|
|
||||||
<th rowSpan={2}>Verbraucher / Flächen</th>
|
|
||||||
<th colSpan={2}>
|
|
||||||
Anzahl / Fläche
|
|
||||||
<span className="estimate-th-hint">St. / m²</span>
|
|
||||||
</th>
|
|
||||||
<th rowSpan={2}>
|
|
||||||
Anschl.
|
|
||||||
<span className="estimate-th-hint">Fest/Steckd.</span>
|
|
||||||
</th>
|
|
||||||
<th rowSpan={2}>
|
|
||||||
Einzelleist.
|
|
||||||
<span className="estimate-th-hint">W bzw. W/m²</span>
|
|
||||||
</th>
|
|
||||||
<th rowSpan={2}>GL-Faktor</th>
|
|
||||||
<th rowSpan={2}>cos φ</th>
|
|
||||||
<th colSpan={2}>Nötige AV-Leistung</th>
|
|
||||||
<th colSpan={3}>Sicherheitsversorgung (SV)</th>
|
|
||||||
<th rowSpan={2}>Anmerkungen</th>
|
|
||||||
<th aria-label="Aktionen" className="estimate-no-print" rowSpan={2} />
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Wert</th>
|
|
||||||
<th>Einh.</th>
|
|
||||||
<th>kW</th>
|
|
||||||
<th>kVA</th>
|
|
||||||
<th>ja/nein</th>
|
|
||||||
<th>kW</th>
|
|
||||||
<th>kVA</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
{balance.groups.map((group, groupIndex) => {
|
|
||||||
const groupResult = result.groups[groupIndex]!;
|
|
||||||
const label = groupLabel(group) || "Kostengruppe";
|
|
||||||
return (
|
|
||||||
<tbody className="estimate-group" key={group.id}>
|
|
||||||
<tr className="estimate-group-title-row">
|
|
||||||
<td className="estimate-cost-group-cell">{group.costGroup}</td>
|
|
||||||
<td colSpan={COLUMN_COUNT - 2}>
|
|
||||||
<div className="estimate-group-title">
|
|
||||||
<span className="estimate-group-name">{group.title}</span>
|
|
||||||
<label className="estimate-group-editor">
|
|
||||||
<span>bearbeitet von</span>
|
|
||||||
<input
|
|
||||||
aria-label={`Bearbeitet von (${label})`}
|
|
||||||
className="estimate-cell-input"
|
|
||||||
disabled={disabled}
|
|
||||||
maxLength={estimateTextLimits.groupEditor}
|
|
||||||
onChange={(event) =>
|
|
||||||
onChange((current) =>
|
|
||||||
updateEstimateGroup(current, group.id, {
|
|
||||||
editor: event.target.value,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="Gewerk / Name"
|
|
||||||
type="text"
|
|
||||||
value={group.editor ?? ""}
|
|
||||||
/>
|
|
||||||
<span className="estimate-print-value">{group.editor ?? ""}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="estimate-actions-cell estimate-no-print">
|
|
||||||
<div className="estimate-group-actions">
|
|
||||||
<IconButton
|
|
||||||
disabled={disabled}
|
|
||||||
label={`Zeile hinzufügen (${label})`}
|
|
||||||
onClick={() =>
|
|
||||||
onChange((current) => addEstimateRow(current, group.id).balance)
|
|
||||||
}
|
|
||||||
variant="primary"
|
|
||||||
>
|
|
||||||
<Plus aria-hidden="true" size={13} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
disabled={disabled}
|
|
||||||
label={`Gruppe bearbeiten (${label})`}
|
|
||||||
onClick={() => onEditGroup(group)}
|
|
||||||
>
|
|
||||||
<Pencil aria-hidden="true" size={13} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
disabled={disabled || groupIndex === 0}
|
|
||||||
label={`Gruppe nach oben (${label})`}
|
|
||||||
onClick={() =>
|
|
||||||
onChange((current) => moveEstimateGroup(current, group.id, -1))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ArrowUp aria-hidden="true" size={13} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
disabled={disabled || groupIndex === balance.groups.length - 1}
|
|
||||||
label={`Gruppe nach unten (${label})`}
|
|
||||||
onClick={() =>
|
|
||||||
onChange((current) => moveEstimateGroup(current, group.id, 1))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ArrowDown aria-hidden="true" size={13} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
disabled={disabled}
|
|
||||||
label={`Gruppe löschen (${label})`}
|
|
||||||
onClick={() => confirmRemoveGroup(group)}
|
|
||||||
variant="danger"
|
|
||||||
>
|
|
||||||
<Trash2 aria-hidden="true" size={13} />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{group.rows.map((row, rowIndex) => (
|
|
||||||
<EstimateRowLine
|
|
||||||
disabled={disabled}
|
|
||||||
group={group}
|
|
||||||
key={row.id}
|
|
||||||
onChange={onChange}
|
|
||||||
onOpenGzfPicker={onOpenGzfPicker}
|
|
||||||
onValidityChange={onValidityChange}
|
|
||||||
row={row}
|
|
||||||
rowIndex={rowIndex}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{!group.rows.length ? (
|
|
||||||
<tr className="estimate-empty-row estimate-no-print">
|
|
||||||
<td />
|
|
||||||
<td colSpan={COLUMN_COUNT - 1}>
|
|
||||||
Noch keine Einträge – mit „+“ eine Zeile hinzufügen.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : null}
|
|
||||||
<tr className="estimate-subtotal-row">
|
|
||||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
|
||||||
Summe {label}
|
|
||||||
</td>
|
|
||||||
<PowerCells power={groupResult.av} />
|
|
||||||
<td />
|
|
||||||
<PowerCells power={groupResult.sv} />
|
|
||||||
<td />
|
|
||||||
<td className="estimate-no-print" />
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<tfoot>
|
|
||||||
<tr className="estimate-no-print estimate-add-group-row">
|
|
||||||
<td colSpan={COLUMN_COUNT}>
|
|
||||||
<button
|
|
||||||
className="btn btn-sm btn-outline-primary"
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onAddGroup}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<Plus aria-hidden="true" className="me-1" size={14} />
|
|
||||||
Kostengruppe hinzufügen
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr className="estimate-total-row">
|
|
||||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
|
||||||
Gesamtsumme
|
|
||||||
</td>
|
|
||||||
<PowerCells power={result.total.av} />
|
|
||||||
<td />
|
|
||||||
<PowerCells power={result.total.sv} />
|
|
||||||
<td colSpan={2} />
|
|
||||||
</tr>
|
|
||||||
<tr className="estimate-total-row">
|
|
||||||
<td colSpan={LEADING_COLUMN_COUNT - 1} className="estimate-total-label">
|
|
||||||
Reserveleistung
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="estimate-unit-input">
|
|
||||||
<NumberInput
|
|
||||||
ariaLabel="Reserve AV in Prozent"
|
|
||||||
disabled={disabled}
|
|
||||||
fieldKey="balance:reserveAvPercent"
|
|
||||||
onCommit={(reserveAvPercent) =>
|
|
||||||
onChange((current) => ({ ...current, reserveAvPercent }))
|
|
||||||
}
|
|
||||||
onValidityChange={onValidityChange}
|
|
||||||
range={estimateNumberRanges.reservePercent}
|
|
||||||
value={balance.reserveAvPercent}
|
|
||||||
/>
|
|
||||||
<span className="estimate-unit-label">%</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<PowerCells power={result.reserve.av} />
|
|
||||||
<td>
|
|
||||||
<div className="estimate-unit-input">
|
|
||||||
<NumberInput
|
|
||||||
ariaLabel="Reserve SV in Prozent"
|
|
||||||
disabled={disabled}
|
|
||||||
fieldKey="balance:reserveSvPercent"
|
|
||||||
onCommit={(reserveSvPercent) =>
|
|
||||||
onChange((current) => ({ ...current, reserveSvPercent }))
|
|
||||||
}
|
|
||||||
onValidityChange={onValidityChange}
|
|
||||||
range={estimateNumberRanges.reservePercent}
|
|
||||||
value={balance.reserveSvPercent}
|
|
||||||
/>
|
|
||||||
<span className="estimate-unit-label">%</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<PowerCells power={result.reserve.sv} />
|
|
||||||
<td colSpan={2} />
|
|
||||||
</tr>
|
|
||||||
<tr className="estimate-total-row is-strong">
|
|
||||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
|
||||||
Leistung inkl. Reserve
|
|
||||||
</td>
|
|
||||||
<PowerCells power={result.withReserve.av} />
|
|
||||||
<td />
|
|
||||||
<PowerCells power={result.withReserve.sv} />
|
|
||||||
<td colSpan={2} />
|
|
||||||
</tr>
|
|
||||||
<tr className="estimate-total-row">
|
|
||||||
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
|
|
||||||
Maximalstrom
|
|
||||||
<span className="estimate-total-hint">
|
|
||||||
{threePhaseVoltageV > 0
|
|
||||||
? ` (3~, U = ${formatDecimalInput(threePhaseVoltageV)} V)`
|
|
||||||
: " (Drehstromspannung im Projekt fehlt)"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="estimate-number" colSpan={2}>
|
|
||||||
{formatAmpere(result.maxCurrentA.av)} A
|
|
||||||
</td>
|
|
||||||
<td />
|
|
||||||
<td className="estimate-number" colSpan={2}>
|
|
||||||
{formatAmpere(result.maxCurrentA.sv)} A
|
|
||||||
</td>
|
|
||||||
<td colSpan={2} />
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,107 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
export type DraftParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
||||||
|
|
||||||
interface DraftInputProps<T> {
|
|
||||||
ariaLabel: string;
|
|
||||||
className?: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
format: (value: T) => string;
|
|
||||||
inputMode?: "decimal" | "text";
|
|
||||||
maxLength?: number;
|
|
||||||
onCommit: (value: T) => void;
|
|
||||||
/** Reports whether the raw text is currently valid (false while invalid). */
|
|
||||||
onValidityChange?: (isValid: boolean) => void;
|
|
||||||
parse: (text: string) => DraftParseResult<T>;
|
|
||||||
placeholder?: string;
|
|
||||||
value: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Input that keeps the raw text the user typed. Only a valid text is
|
|
||||||
* committed into the document; an invalid text stays visible with a red
|
|
||||||
* border and is never saved. A value changed from outside (reload, undo)
|
|
||||||
* replaces the text unless the text already represents that value.
|
|
||||||
*/
|
|
||||||
export function DraftInput<T>({
|
|
||||||
ariaLabel,
|
|
||||||
className = "estimate-cell-input",
|
|
||||||
disabled,
|
|
||||||
format,
|
|
||||||
inputMode = "text",
|
|
||||||
maxLength,
|
|
||||||
onCommit,
|
|
||||||
onValidityChange,
|
|
||||||
parse,
|
|
||||||
placeholder,
|
|
||||||
value,
|
|
||||||
}: DraftInputProps<T>) {
|
|
||||||
const [text, setText] = useState(() => format(value));
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [syncedValue, setSyncedValue] = useState(value);
|
|
||||||
|
|
||||||
if (!Object.is(syncedValue, value)) {
|
|
||||||
// Adjusting state while rendering (instead of an effect) avoids a frame
|
|
||||||
// with stale text after an external change.
|
|
||||||
setSyncedValue(value);
|
|
||||||
const parsed = parse(text);
|
|
||||||
if (!parsed.ok || !Object.is(parsed.value, value)) {
|
|
||||||
setText(format(value));
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const validityRef = useRef(onValidityChange);
|
|
||||||
useEffect(() => {
|
|
||||||
validityRef.current = onValidityChange;
|
|
||||||
});
|
|
||||||
const isValid = error === null;
|
|
||||||
useEffect(() => {
|
|
||||||
validityRef.current?.(isValid);
|
|
||||||
}, [isValid]);
|
|
||||||
// A removed input (deleted row) must not leave an invalid marker behind.
|
|
||||||
useEffect(() => () => validityRef.current?.(true), []);
|
|
||||||
|
|
||||||
function handleChange(nextText: string) {
|
|
||||||
setText(nextText);
|
|
||||||
const parsed = parse(nextText);
|
|
||||||
if (!parsed.ok) {
|
|
||||||
setError(parsed.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(null);
|
|
||||||
if (!Object.is(parsed.value, value)) {
|
|
||||||
setSyncedValue(parsed.value);
|
|
||||||
onCommit(parsed.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleBlur() {
|
|
||||||
// Show the canonical notation again ("0,80" → "0,8") once editing ends.
|
|
||||||
if (error === null) {
|
|
||||||
setText(format(value));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
aria-invalid={error ? true : undefined}
|
|
||||||
aria-label={ariaLabel}
|
|
||||||
className={error ? `${className} is-invalid` : className}
|
|
||||||
disabled={disabled}
|
|
||||||
inputMode={inputMode}
|
|
||||||
maxLength={maxLength}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onChange={(event) => handleChange(event.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
title={error ?? undefined}
|
|
||||||
type="text"
|
|
||||||
value={text}
|
|
||||||
/>
|
|
||||||
<span className="estimate-print-value">{error ? format(value) : text}</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { type FormEvent, useState } from "react";
|
|
||||||
import { FormModal } from "../components/form-modal";
|
|
||||||
import {
|
|
||||||
estimateDefaultGroups,
|
|
||||||
type EstimateGroupSnapshot,
|
|
||||||
} from "../../shared/estimate-balance/estimate-balance";
|
|
||||||
import { estimateTextLimits } from "./estimate-balance-document";
|
|
||||||
|
|
||||||
export interface EstimateGroupFormValues {
|
|
||||||
costGroup: string;
|
|
||||||
title: string;
|
|
||||||
editor: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EstimateGroupModalProps {
|
|
||||||
group: EstimateGroupSnapshot | null;
|
|
||||||
onClose: () => void;
|
|
||||||
onSave: (values: EstimateGroupFormValues) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Creates or edits the KG number, title and "bearbeitet von" of one group. */
|
|
||||||
export function EstimateGroupModal({ group, onClose, onSave }: EstimateGroupModalProps) {
|
|
||||||
const [costGroup, setCostGroup] = useState(group?.costGroup ?? "");
|
|
||||||
const [title, setTitle] = useState(group?.title ?? "");
|
|
||||||
const [editor, setEditor] = useState(group?.editor ?? "");
|
|
||||||
|
|
||||||
function applyTemplate(index: string) {
|
|
||||||
const template = estimateDefaultGroups[Number(index)];
|
|
||||||
if (template) {
|
|
||||||
setCostGroup(template.costGroup);
|
|
||||||
setTitle(template.title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!title.trim()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSave({
|
|
||||||
costGroup: costGroup.trim(),
|
|
||||||
title: title.trim(),
|
|
||||||
editor: editor.trim() || null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormModal
|
|
||||||
isSaving={false}
|
|
||||||
onClose={onClose}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
submitDisabled={!title.trim()}
|
|
||||||
submitLabel={group ? "Übernehmen" : "Kostengruppe hinzufügen"}
|
|
||||||
title={group ? "Kostengruppe bearbeiten" : "Kostengruppe hinzufügen"}
|
|
||||||
>
|
|
||||||
<div className="row g-3">
|
|
||||||
{!group ? (
|
|
||||||
<div className="col-12">
|
|
||||||
<label className="form-label" htmlFor="estimate-group-template">
|
|
||||||
Aus Vorlage KG 400 übernehmen
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="form-select"
|
|
||||||
defaultValue=""
|
|
||||||
id="estimate-group-template"
|
|
||||||
onChange={(event) => applyTemplate(event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">– Vorlage wählen –</option>
|
|
||||||
{estimateDefaultGroups.map((template, index) => (
|
|
||||||
<option key={`${template.costGroup}-${template.title}`} value={index}>
|
|
||||||
{[template.costGroup, template.title].filter(Boolean).join(" ")}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="col-12 col-md-3">
|
|
||||||
<label className="form-label" htmlFor="estimate-group-cost-group">
|
|
||||||
KG (DIN 276)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
id="estimate-group-cost-group"
|
|
||||||
maxLength={estimateTextLimits.costGroup}
|
|
||||||
onChange={(event) => setCostGroup(event.target.value)}
|
|
||||||
placeholder="z. B. 440"
|
|
||||||
value={costGroup}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12 col-md-9">
|
|
||||||
<label className="form-label" htmlFor="estimate-group-title">
|
|
||||||
Titel
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
id="estimate-group-title"
|
|
||||||
maxLength={estimateTextLimits.groupTitle}
|
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
|
||||||
placeholder="z. B. Starkstromanlagen"
|
|
||||||
required
|
|
||||||
value={title}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12">
|
|
||||||
<label className="form-label" htmlFor="estimate-group-editor">
|
|
||||||
Bearbeitet von
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
id="estimate-group-editor"
|
|
||||||
maxLength={estimateTextLimits.groupEditor}
|
|
||||||
onChange={(event) => setEditor(event.target.value)}
|
|
||||||
placeholder="Gewerk oder Name (optional)"
|
|
||||||
value={editor}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-12">
|
|
||||||
<p className="text-secondary small mb-0">
|
|
||||||
Ohne KG-Nummer (z. B. „Allgemein“) wird die Gruppe im Vergleich mit der
|
|
||||||
detaillierten Bilanz keiner Kostengruppe zugeordnet.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</FormModal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -535,34 +535,3 @@ 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,11 +38,6 @@ 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";
|
||||||
|
|
@ -92,30 +87,6 @@ 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,
|
||||||
|
|
@ -128,23 +99,15 @@ 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; code?: unknown };
|
const parsed = JSON.parse(details) as { error?: 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 ApiRequestError(
|
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
|
||||||
details || `Anfrage fehlgeschlagen (Status ${response.status})`,
|
|
||||||
response.status,
|
|
||||||
code
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
|
|
@ -1172,66 +1135,3 @@ 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`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
/**
|
|
||||||
* Creates a random RFC 4122 version 4 UUID for new entities built in the
|
|
||||||
* browser.
|
|
||||||
*
|
|
||||||
* crypto.randomUUID() exists only in secure contexts (HTTPS or localhost).
|
|
||||||
* The app is also opened over plain HTTP from other machines in the LAN, where
|
|
||||||
* it is undefined, so fall back to crypto.getRandomValues(), which is
|
|
||||||
* available in every context.
|
|
||||||
*/
|
|
||||||
export function createClientId(
|
|
||||||
source: Pick<Crypto, "getRandomValues"> & Partial<Pick<Crypto, "randomUUID">> = crypto
|
|
||||||
): string {
|
|
||||||
if (typeof source.randomUUID === "function") {
|
|
||||||
return source.randomUUID();
|
|
||||||
}
|
|
||||||
const bytes = source.getRandomValues(new Uint8Array(16));
|
|
||||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
|
||||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant
|
|
||||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
||||||
return [
|
|
||||||
hex.slice(0, 8),
|
|
||||||
hex.slice(8, 12),
|
|
||||||
hex.slice(12, 16),
|
|
||||||
hex.slice(16, 20),
|
|
||||||
hex.slice(20),
|
|
||||||
].join("-");
|
|
||||||
}
|
|
||||||
|
|
@ -68,9 +68,6 @@ 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,7 +7,6 @@ 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";
|
||||||
|
|
@ -29,7 +28,6 @@ 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,7 +27,6 @@ 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 =
|
||||||
|
|
@ -81,8 +80,6 @@ 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,
|
||||||
|
|
@ -110,6 +107,5 @@ export const projectCommandService = new ProjectCommandService(
|
||||||
externalInitialImportProjectCommandStore,
|
externalInitialImportProjectCommandStore,
|
||||||
externalObjectRowAssignmentProjectCommandStore,
|
externalObjectRowAssignmentProjectCommandStore,
|
||||||
externalObjectNewRowProjectCommandStore,
|
externalObjectNewRowProjectCommandStore,
|
||||||
externalObjectNewCircuitProjectCommandStore,
|
externalObjectNewCircuitProjectCommandStore
|
||||||
estimateBalanceProjectCommandStore
|
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,180 +0,0 @@
|
||||||
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,14 +21,6 @@ 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 {
|
||||||
|
|
@ -126,12 +118,3 @@ 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
|
|
||||||
);
|
|
||||||
|
|
|
||||||
|
|
@ -1,316 +0,0 @@
|
||||||
/**
|
|
||||||
* Grobe Leistungsbilanz (LPH 2/3): shared data shapes and pure calculation.
|
|
||||||
*
|
|
||||||
* Used by the API (validation, comparison) and by the frontend (live totals),
|
|
||||||
* so this file has no imports: Turbopack resolves frontend imports without
|
|
||||||
* the `.js` suffix the API build needs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type EstimateRowUnit = "piece" | "area";
|
|
||||||
export type EstimateRowConnection = "fixed" | "socket";
|
|
||||||
|
|
||||||
export interface EstimateRowSnapshot {
|
|
||||||
id: string;
|
|
||||||
sortOrder: number;
|
|
||||||
/** Versorgung, e.g. "ELT" or "ISP". */
|
|
||||||
supply: string | null;
|
|
||||||
/** Optional circuit reference, free text. */
|
|
||||||
circuitRef: string | null;
|
|
||||||
/** Verbraucher / Fläche. May be empty while a row is being typed. */
|
|
||||||
name: string;
|
|
||||||
/** Anzahl (unit "piece") or area in m² (unit "area"). */
|
|
||||||
quantity: number;
|
|
||||||
unit: EstimateRowUnit;
|
|
||||||
connection: EstimateRowConnection | null;
|
|
||||||
/** W per piece or W/m². */
|
|
||||||
powerPerUnitW: number;
|
|
||||||
simultaneityFactor: number;
|
|
||||||
cosPhi: number;
|
|
||||||
/** Also needed on the safety power supply (SV). */
|
|
||||||
safetySupply: boolean;
|
|
||||||
remark: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EstimateGroupSnapshot {
|
|
||||||
id: string;
|
|
||||||
/** DIN 276 cost group, e.g. "410" or "434"; empty for "Allgemein". */
|
|
||||||
costGroup: string;
|
|
||||||
title: string;
|
|
||||||
/** Trade or person responsible for the entries ("bearbeitet von"). */
|
|
||||||
editor: string | null;
|
|
||||||
sortOrder: number;
|
|
||||||
rows: EstimateRowSnapshot[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EstimateBalanceSnapshot {
|
|
||||||
id: string;
|
|
||||||
projectId: string;
|
|
||||||
/** Stand, e.g. "Vorplanung" or "Entwurfsplanung". */
|
|
||||||
name: string;
|
|
||||||
workStage: string | null;
|
|
||||||
/** ISO date YYYY-MM-DD. */
|
|
||||||
dateIso: string | null;
|
|
||||||
reserveAvPercent: number;
|
|
||||||
reserveSvPercent: number;
|
|
||||||
sortOrder: number;
|
|
||||||
groups: EstimateGroupSnapshot[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default structure of the office template "Leistungsbilanz - KG400". */
|
|
||||||
export const estimateDefaultGroups: ReadonlyArray<{ costGroup: string; title: string }> = [
|
|
||||||
{ costGroup: "410", title: "Sanitärtechnik" },
|
|
||||||
{ costGroup: "420", title: "Wärmeversorgung" },
|
|
||||||
{ costGroup: "430", title: "Lufttechnische Anlagen" },
|
|
||||||
{ costGroup: "434", title: "Kälteanlagen" },
|
|
||||||
{ costGroup: "440", title: "Starkstromanlagen" },
|
|
||||||
{ costGroup: "450", title: "Schwachstromanlagen" },
|
|
||||||
{ costGroup: "460", title: "Fördertechnik" },
|
|
||||||
{ costGroup: "470", title: "Nutzungsspezifische Anlagen" },
|
|
||||||
{ costGroup: "480", title: "Gebäudeautomation" },
|
|
||||||
{ costGroup: "", title: "Allgemein" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const estimateRowUnitLabels: Record<EstimateRowUnit, string> = {
|
|
||||||
piece: "St.",
|
|
||||||
area: "m²",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const estimateRowConnectionLabels: Record<EstimateRowConnection, string> = {
|
|
||||||
fixed: "Fest",
|
|
||||||
socket: "Steckd.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface EstimatePower {
|
|
||||||
kw: number;
|
|
||||||
kva: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EstimateRowResult {
|
|
||||||
av: EstimatePower;
|
|
||||||
sv: EstimatePower;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EstimateGroupResult {
|
|
||||||
groupId: string;
|
|
||||||
av: EstimatePower;
|
|
||||||
sv: EstimatePower;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EstimateBalanceResult {
|
|
||||||
groups: EstimateGroupResult[];
|
|
||||||
total: { av: EstimatePower; sv: EstimatePower };
|
|
||||||
reserve: { av: EstimatePower; sv: EstimatePower };
|
|
||||||
withReserve: { av: EstimatePower; sv: EstimatePower };
|
|
||||||
/** Maximum current of the three-phase supply in A (S / (√3 · U)). */
|
|
||||||
maxCurrentA: { av: number; sv: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rounds a calculated value for display like the spreadsheet does: binary
|
|
||||||
* floating point stores 40.175 as 40.17499…, so the scaled value is first
|
|
||||||
* cut to 6 decimals and only then rounded half away from zero
|
|
||||||
* (40.175 -> 40.18, -1.825 -> -1.83).
|
|
||||||
*/
|
|
||||||
export function roundEstimateValue(value: number, digits = 2): number {
|
|
||||||
const factor = 10 ** digits;
|
|
||||||
const scaled = Number((Math.abs(value) * factor).toFixed(6));
|
|
||||||
return (Math.sign(value) * Math.round(scaled)) / factor;
|
|
||||||
}
|
|
||||||
|
|
||||||
const zeroPower = (): EstimatePower => ({ kw: 0, kva: 0 });
|
|
||||||
|
|
||||||
function addPower(left: EstimatePower, right: EstimatePower): EstimatePower {
|
|
||||||
return { kw: left.kw + right.kw, kva: left.kva + right.kva };
|
|
||||||
}
|
|
||||||
|
|
||||||
function scalePower(power: EstimatePower, factor: number): EstimatePower {
|
|
||||||
return { kw: power.kw * factor, kva: power.kva * factor };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Required power of one row: P = quantity · power per unit · GLF, S = P / cos φ.
|
|
||||||
* AV covers every consumer (normal supply); SV additionally lists the rows
|
|
||||||
* that must also be supplied by the safety power supply.
|
|
||||||
*/
|
|
||||||
export function calculateEstimateRow(
|
|
||||||
row: Pick<
|
|
||||||
EstimateRowSnapshot,
|
|
||||||
"quantity" | "powerPerUnitW" | "simultaneityFactor" | "cosPhi" | "safetySupply"
|
|
||||||
>
|
|
||||||
): EstimateRowResult {
|
|
||||||
const kw = (row.quantity * row.powerPerUnitW * row.simultaneityFactor) / 1000;
|
|
||||||
const kva = row.cosPhi > 0 ? kw / row.cosPhi : 0;
|
|
||||||
const av = { kw, kva };
|
|
||||||
return { av, sv: row.safetySupply ? { ...av } : zeroPower() };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function calculateEstimateBalance(
|
|
||||||
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
|
|
||||||
threePhaseVoltageV: number
|
|
||||||
): EstimateBalanceResult {
|
|
||||||
const groups = balance.groups.map((group) =>
|
|
||||||
group.rows.reduce<EstimateGroupResult>(
|
|
||||||
(sum, row) => {
|
|
||||||
const result = calculateEstimateRow(row);
|
|
||||||
return {
|
|
||||||
groupId: group.id,
|
|
||||||
av: addPower(sum.av, result.av),
|
|
||||||
sv: addPower(sum.sv, result.sv),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{ groupId: group.id, av: zeroPower(), sv: zeroPower() }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const total = groups.reduce(
|
|
||||||
(sum, group) => ({ av: addPower(sum.av, group.av), sv: addPower(sum.sv, group.sv) }),
|
|
||||||
{ av: zeroPower(), sv: zeroPower() }
|
|
||||||
);
|
|
||||||
const reserve = {
|
|
||||||
av: scalePower(total.av, balance.reserveAvPercent / 100),
|
|
||||||
sv: scalePower(total.sv, balance.reserveSvPercent / 100),
|
|
||||||
};
|
|
||||||
const withReserve = {
|
|
||||||
av: addPower(total.av, reserve.av),
|
|
||||||
sv: addPower(total.sv, reserve.sv),
|
|
||||||
};
|
|
||||||
const currentFactor =
|
|
||||||
threePhaseVoltageV > 0 ? 1000 / (Math.sqrt(3) * threePhaseVoltageV) : 0;
|
|
||||||
return {
|
|
||||||
groups,
|
|
||||||
total,
|
|
||||||
reserve,
|
|
||||||
withReserve,
|
|
||||||
maxCurrentA: {
|
|
||||||
av: withReserve.av.kva * currentFactor,
|
|
||||||
sv: withReserve.sv.kva * currentFactor,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assigns a detailed cost group (e.g. "444" or "440.1") to the best matching
|
|
||||||
* estimate group: a group code with trailing zeros removed is a prefix
|
|
||||||
* ("430" -> "43", "434" -> "434"); the longest prefix wins. Returns null when
|
|
||||||
* no group matches.
|
|
||||||
*/
|
|
||||||
export function matchEstimateCostGroup(
|
|
||||||
detailedCostGroup: string | null,
|
|
||||||
groupCostGroups: readonly string[]
|
|
||||||
): string | null {
|
|
||||||
const digits = (detailedCostGroup ?? "").replace(/\D/g, "");
|
|
||||||
if (!digits) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let best: { code: string; length: number } | null = null;
|
|
||||||
for (const code of groupCostGroups) {
|
|
||||||
const codeDigits = code.replace(/\D/g, "");
|
|
||||||
const prefix = codeDigits.replace(/0+$/, "");
|
|
||||||
if (!prefix || !digits.startsWith(prefix)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!best || prefix.length > best.length) {
|
|
||||||
best = { code, length: prefix.length };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best?.code ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EstimateComparisonGroup {
|
|
||||||
groupId: string;
|
|
||||||
costGroup: string;
|
|
||||||
title: string;
|
|
||||||
estimatedAv: EstimatePower;
|
|
||||||
detailed: EstimatePower;
|
|
||||||
detailedRowCount: number;
|
|
||||||
deltaKw: number;
|
|
||||||
/** Relative deviation of the detailed value from the estimate, null without estimate. */
|
|
||||||
deltaPercent: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EstimateComparison {
|
|
||||||
groups: EstimateComparisonGroup[];
|
|
||||||
unassigned: EstimatePower & { rowCount: number };
|
|
||||||
total: {
|
|
||||||
estimatedAv: EstimatePower;
|
|
||||||
detailed: EstimatePower;
|
|
||||||
deltaKw: number;
|
|
||||||
deltaPercent: number | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DetailedDeviceRowPower {
|
|
||||||
costGroup: string | null;
|
|
||||||
/** Total power of the device row in kW (quantity · power · device GZF). */
|
|
||||||
kw: number;
|
|
||||||
cosPhi: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function delta(estimated: number, detailed: number) {
|
|
||||||
return {
|
|
||||||
deltaKw: detailed - estimated,
|
|
||||||
deltaPercent: estimated > 0 ? ((detailed - estimated) / estimated) * 100 : null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compares the estimate per cost group with the detailed device rows of all
|
|
||||||
* circuit lists. The distribution-board simultaneity factor is not applied
|
|
||||||
* on either side; a missing cos φ counts as 1.
|
|
||||||
*/
|
|
||||||
export function compareEstimateWithDetailed(
|
|
||||||
balance: Pick<EstimateBalanceSnapshot, "groups" | "reserveAvPercent" | "reserveSvPercent">,
|
|
||||||
detailedRows: readonly DetailedDeviceRowPower[]
|
|
||||||
): EstimateComparison {
|
|
||||||
const estimate = calculateEstimateBalance(balance, 0);
|
|
||||||
const codes = balance.groups.map((group) => group.costGroup).filter(Boolean);
|
|
||||||
const detailedByCode = new Map<string, EstimatePower & { rowCount: number }>();
|
|
||||||
const unassigned = { kw: 0, kva: 0, rowCount: 0 };
|
|
||||||
for (const row of detailedRows) {
|
|
||||||
const kva = row.cosPhi && row.cosPhi > 0 ? row.kw / row.cosPhi : row.kw;
|
|
||||||
const code = matchEstimateCostGroup(row.costGroup, codes);
|
|
||||||
const target =
|
|
||||||
code === null
|
|
||||||
? unassigned
|
|
||||||
: detailedByCode.get(code) ??
|
|
||||||
detailedByCode.set(code, { kw: 0, kva: 0, rowCount: 0 }).get(code)!;
|
|
||||||
target.kw += row.kw;
|
|
||||||
target.kva += kva;
|
|
||||||
target.rowCount += 1;
|
|
||||||
}
|
|
||||||
// A cost group code can appear in several groups; the detailed value is
|
|
||||||
// shown once, on the first group with that code.
|
|
||||||
const shownCodes = new Set<string>();
|
|
||||||
const groups = balance.groups.map((group, index) => {
|
|
||||||
const estimatedAv = estimate.groups[index]!.av;
|
|
||||||
const detailedEntry =
|
|
||||||
group.costGroup && !shownCodes.has(group.costGroup)
|
|
||||||
? detailedByCode.get(group.costGroup)
|
|
||||||
: undefined;
|
|
||||||
if (group.costGroup) {
|
|
||||||
shownCodes.add(group.costGroup);
|
|
||||||
}
|
|
||||||
const detailed = { kw: detailedEntry?.kw ?? 0, kva: detailedEntry?.kva ?? 0 };
|
|
||||||
return {
|
|
||||||
groupId: group.id,
|
|
||||||
costGroup: group.costGroup,
|
|
||||||
title: group.title,
|
|
||||||
estimatedAv,
|
|
||||||
detailed,
|
|
||||||
detailedRowCount: detailedEntry?.rowCount ?? 0,
|
|
||||||
...delta(estimatedAv.kw, detailed.kw),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const detailedTotal = groups.reduce(
|
|
||||||
(sum, group) => addPower(sum, group.detailed),
|
|
||||||
{ kw: unassigned.kw, kva: unassigned.kva }
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
groups,
|
|
||||||
unassigned,
|
|
||||||
total: {
|
|
||||||
estimatedAv: estimate.total.av,
|
|
||||||
detailed: detailedTotal,
|
|
||||||
...delta(estimate.total.av.kw, detailedTotal.kw),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { webcrypto } from "node:crypto";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { createClientId } from "../src/frontend/utils/client-id.js";
|
|
||||||
|
|
||||||
const uuidV4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
|
||||||
|
|
||||||
describe("client id", () => {
|
|
||||||
it("uses crypto.randomUUID when the context provides it", () => {
|
|
||||||
const id = createClientId({
|
|
||||||
getRandomValues: () => {
|
|
||||||
throw new Error("fallback must not be used");
|
|
||||||
},
|
|
||||||
randomUUID: () => "11111111-2222-4333-8444-555555555555",
|
|
||||||
});
|
|
||||||
assert.equal(id, "11111111-2222-4333-8444-555555555555");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to getRandomValues outside a secure context", () => {
|
|
||||||
const insecure = {
|
|
||||||
getRandomValues: webcrypto.getRandomValues.bind(webcrypto),
|
|
||||||
} as Pick<Crypto, "getRandomValues">;
|
|
||||||
const ids = new Set<string>();
|
|
||||||
for (let index = 0; index < 200; index += 1) {
|
|
||||||
const id = createClientId(insecure);
|
|
||||||
assert.match(id, uuidV4);
|
|
||||||
assert.equal(z.string().uuid().safeParse(id).success, true);
|
|
||||||
ids.add(id);
|
|
||||||
}
|
|
||||||
assert.equal(ids.size, 200);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sets version and variant bits regardless of the random bytes", () => {
|
|
||||||
const allOnes = {
|
|
||||||
getRandomValues: <T extends ArrayBufferView | null>(array: T) => {
|
|
||||||
if (array instanceof Uint8Array) {
|
|
||||||
array.fill(0xff);
|
|
||||||
}
|
|
||||||
return array;
|
|
||||||
},
|
|
||||||
} as Pick<Crypto, "getRandomValues">;
|
|
||||||
assert.equal(createClientId(allOnes), "ffffffff-ffff-4fff-bfff-ffffffffffff");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,435 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import type { EstimateBalanceSnapshot } from "../src/shared/estimate-balance/estimate-balance.js";
|
|
||||||
import {
|
|
||||||
addEstimateGroup,
|
|
||||||
addEstimateRow,
|
|
||||||
createCopyName,
|
|
||||||
createEstimateRow,
|
|
||||||
estimateBalanceFingerprint,
|
|
||||||
moveEstimateGroup,
|
|
||||||
moveEstimateRow,
|
|
||||||
normalizeEstimateSortOrder,
|
|
||||||
removeEstimateGroup,
|
|
||||||
removeEstimateRow,
|
|
||||||
toSaveableEstimateBalance,
|
|
||||||
updateEstimateGroup,
|
|
||||||
updateEstimateRow,
|
|
||||||
validateEstimateBalance,
|
|
||||||
} from "../src/frontend/estimate-balance/estimate-balance-document.js";
|
|
||||||
import {
|
|
||||||
estimateNumberRanges,
|
|
||||||
formatAmpere,
|
|
||||||
formatDecimalInput,
|
|
||||||
formatDeltaPercent,
|
|
||||||
formatIsoDateGerman,
|
|
||||||
formatKw,
|
|
||||||
formatSignedKw,
|
|
||||||
getTodayIsoDate,
|
|
||||||
parseGermanDecimal,
|
|
||||||
} from "../src/frontend/estimate-balance/estimate-balance-format.js";
|
|
||||||
import {
|
|
||||||
ApiRequestError,
|
|
||||||
createEstimateBalance,
|
|
||||||
deleteEstimateBalance,
|
|
||||||
getEstimateBalance,
|
|
||||||
getEstimateComparison,
|
|
||||||
isRevisionConflictError,
|
|
||||||
listEstimateBalances,
|
|
||||||
updateEstimateBalance,
|
|
||||||
} from "../src/frontend/utils/api.js";
|
|
||||||
|
|
||||||
function sequentialIds(prefix = "id") {
|
|
||||||
let next = 0;
|
|
||||||
return () => `${prefix}-${++next}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sampleBalance(): EstimateBalanceSnapshot {
|
|
||||||
return {
|
|
||||||
id: "balance-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
name: "Entwurfsplanung",
|
|
||||||
workStage: "LPH 3",
|
|
||||||
dateIso: "2026-09-26",
|
|
||||||
reserveAvPercent: 20,
|
|
||||||
reserveSvPercent: 10,
|
|
||||||
sortOrder: 0,
|
|
||||||
groups: [
|
|
||||||
{
|
|
||||||
id: "group-410",
|
|
||||||
costGroup: "410",
|
|
||||||
title: "Sanitärtechnik",
|
|
||||||
editor: null,
|
|
||||||
sortOrder: 0,
|
|
||||||
rows: [
|
|
||||||
{ ...createEstimateRow(0, () => "row-a"), name: "Hebeanlage", powerPerUnitW: 1200 },
|
|
||||||
{ ...createEstimateRow(1, () => "row-b"), name: "Durchlauferhitzer" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "group-440",
|
|
||||||
costGroup: "440",
|
|
||||||
title: "Starkstromanlagen",
|
|
||||||
editor: "ELT",
|
|
||||||
sortOrder: 1,
|
|
||||||
rows: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("estimate balance number input", () => {
|
|
||||||
it("accepts a comma as decimal separator", () => {
|
|
||||||
assert.deepEqual(parseGermanDecimal("0,8"), { ok: true, value: 0.8 });
|
|
||||||
assert.deepEqual(parseGermanDecimal(" 1200 "), { ok: true, value: 1200 });
|
|
||||||
assert.deepEqual(parseGermanDecimal(",5"), { ok: true, value: 0.5 });
|
|
||||||
assert.deepEqual(parseGermanDecimal("3,"), { ok: true, value: 3 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects dots, empty input and garbage", () => {
|
|
||||||
for (const text of ["0.8", "1.200", "", " ", "abc", "1,2,3", "1e3", "--1"]) {
|
|
||||||
assert.equal(parseGermanDecimal(text).ok, false, text);
|
|
||||||
}
|
|
||||||
const dot = parseGermanDecimal("0.8");
|
|
||||||
assert.equal(dot.ok === false && dot.error.includes("Komma"), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("checks the server ranges of the document fields", () => {
|
|
||||||
assert.equal(parseGermanDecimal("-1", estimateNumberRanges.quantity).ok, false);
|
|
||||||
assert.equal(parseGermanDecimal("0", estimateNumberRanges.quantity).ok, true);
|
|
||||||
assert.equal(parseGermanDecimal("1,1", estimateNumberRanges.simultaneityFactor).ok, false);
|
|
||||||
assert.equal(parseGermanDecimal("1", estimateNumberRanges.simultaneityFactor).ok, true);
|
|
||||||
assert.equal(parseGermanDecimal("0", estimateNumberRanges.cosPhi).ok, false);
|
|
||||||
assert.equal(parseGermanDecimal("0,01", estimateNumberRanges.cosPhi).ok, true);
|
|
||||||
assert.equal(parseGermanDecimal("101", estimateNumberRanges.reservePercent).ok, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats input text so it parses back unchanged", () => {
|
|
||||||
assert.equal(formatDecimalInput(1200), "1200");
|
|
||||||
assert.equal(formatDecimalInput(0.8), "0,8");
|
|
||||||
const parsed = parseGermanDecimal(formatDecimalInput(12345.678));
|
|
||||||
assert.deepEqual(parsed, { ok: true, value: 12345.678 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("estimate balance display format", () => {
|
|
||||||
it("rounds half away from zero like Excel", () => {
|
|
||||||
assert.equal(formatKw(40.175), "40,18");
|
|
||||||
assert.equal(formatKw(30.24), "30,24");
|
|
||||||
assert.equal(formatKw(1234.5), "1.234,50");
|
|
||||||
assert.equal(formatKw(-0.001), "0,00");
|
|
||||||
assert.equal(formatAmpere(57.75), "57,75");
|
|
||||||
assert.equal(formatAmpere(235.749), "235,75");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shows signed deltas and percentages", () => {
|
|
||||||
assert.equal(formatSignedKw(1.5), "+1,50");
|
|
||||||
assert.equal(formatSignedKw(-1.825), "-1,83");
|
|
||||||
assert.equal(formatSignedKw(0.001), "0,00");
|
|
||||||
assert.equal(formatDeltaPercent(12.34), "+12,3 %");
|
|
||||||
assert.equal(formatDeltaPercent(-5), "-5,0 %");
|
|
||||||
assert.equal(formatDeltaPercent(null), "–");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("formats dates", () => {
|
|
||||||
assert.equal(formatIsoDateGerman("2026-09-26"), "26.09.2026");
|
|
||||||
assert.equal(formatIsoDateGerman(null), "–");
|
|
||||||
assert.equal(getTodayIsoDate(new Date(2026, 0, 5, 23, 30)), "2026-01-05");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("estimate balance document editing", () => {
|
|
||||||
it("adds rows at the end or after a row with continuous sort orders", () => {
|
|
||||||
const createId = sequentialIds("new");
|
|
||||||
const appended = addEstimateRow(sampleBalance(), "group-410", { createId });
|
|
||||||
assert.equal(appended.rowId, "new-1");
|
|
||||||
assert.deepEqual(
|
|
||||||
appended.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
|
||||||
[["row-a", 0], ["row-b", 1], ["new-1", 2]]
|
|
||||||
);
|
|
||||||
const inserted = addEstimateRow(sampleBalance(), "group-410", {
|
|
||||||
afterRowId: "row-a",
|
|
||||||
createId,
|
|
||||||
});
|
|
||||||
assert.deepEqual(
|
|
||||||
inserted.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
|
||||||
[["row-a", 0], ["new-2", 1], ["row-b", 2]]
|
|
||||||
);
|
|
||||||
const unknownGroup = addEstimateRow(sampleBalance(), "missing", { createId });
|
|
||||||
assert.equal(unknownGroup.rowId, null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates rows with neutral defaults", () => {
|
|
||||||
const row = createEstimateRow(3, () => "row-x");
|
|
||||||
assert.deepEqual(row, {
|
|
||||||
id: "row-x",
|
|
||||||
sortOrder: 3,
|
|
||||||
supply: null,
|
|
||||||
circuitRef: null,
|
|
||||||
name: "",
|
|
||||||
quantity: 1,
|
|
||||||
unit: "piece",
|
|
||||||
connection: null,
|
|
||||||
powerPerUnitW: 0,
|
|
||||||
simultaneityFactor: 1,
|
|
||||||
cosPhi: 1,
|
|
||||||
safetySupply: false,
|
|
||||||
remark: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updates, removes and moves rows without touching the input", () => {
|
|
||||||
const original = sampleBalance();
|
|
||||||
const updated = updateEstimateRow(original, "group-410", "row-b", {
|
|
||||||
quantity: 3,
|
|
||||||
safetySupply: true,
|
|
||||||
});
|
|
||||||
assert.equal(updated.groups[0]!.rows[1]!.quantity, 3);
|
|
||||||
assert.equal(updated.groups[0]!.rows[1]!.safetySupply, true);
|
|
||||||
assert.equal(original.groups[0]!.rows[1]!.quantity, 1);
|
|
||||||
|
|
||||||
const removed = removeEstimateRow(original, "group-410", "row-a");
|
|
||||||
assert.deepEqual(
|
|
||||||
removed.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
|
||||||
[["row-b", 0]]
|
|
||||||
);
|
|
||||||
|
|
||||||
const moved = moveEstimateRow(original, "group-410", "row-b", -1);
|
|
||||||
assert.deepEqual(
|
|
||||||
moved.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
|
||||||
[["row-b", 0], ["row-a", 1]]
|
|
||||||
);
|
|
||||||
assert.equal(moveEstimateRow(original, "group-410", "row-a", -1), original);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds, edits, moves and removes groups", () => {
|
|
||||||
const added = addEstimateGroup(
|
|
||||||
sampleBalance(),
|
|
||||||
{ costGroup: "480", title: "Gebäudeautomation" },
|
|
||||||
() => "group-480"
|
|
||||||
);
|
|
||||||
assert.equal(added.groupId, "group-480");
|
|
||||||
assert.deepEqual(
|
|
||||||
added.balance.groups.map((group) => [group.id, group.sortOrder]),
|
|
||||||
[["group-410", 0], ["group-440", 1], ["group-480", 2]]
|
|
||||||
);
|
|
||||||
assert.equal(added.balance.groups[2]!.editor, null);
|
|
||||||
|
|
||||||
const edited = updateEstimateGroup(added.balance, "group-480", {
|
|
||||||
costGroup: "481",
|
|
||||||
editor: "MSR",
|
|
||||||
});
|
|
||||||
assert.equal(edited.groups[2]!.costGroup, "481");
|
|
||||||
assert.equal(edited.groups[2]!.title, "Gebäudeautomation");
|
|
||||||
|
|
||||||
const movedUp = moveEstimateGroup(edited, "group-480", -1);
|
|
||||||
assert.deepEqual(
|
|
||||||
movedUp.groups.map((group) => [group.id, group.sortOrder]),
|
|
||||||
[["group-410", 0], ["group-480", 1], ["group-440", 2]]
|
|
||||||
);
|
|
||||||
assert.equal(moveEstimateGroup(movedUp, "group-440", 1), movedUp);
|
|
||||||
|
|
||||||
const removed = removeEstimateGroup(movedUp, "group-410");
|
|
||||||
assert.deepEqual(
|
|
||||||
removed.groups.map((group) => [group.id, group.sortOrder]),
|
|
||||||
[["group-480", 0], ["group-440", 1]]
|
|
||||||
);
|
|
||||||
assert.equal(removeEstimateGroup(removed, "missing"), removed);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("orders loaded documents by sortOrder", () => {
|
|
||||||
const balance = sampleBalance();
|
|
||||||
const shuffled: EstimateBalanceSnapshot = {
|
|
||||||
...balance,
|
|
||||||
groups: [
|
|
||||||
{ ...balance.groups[1]!, sortOrder: 7 },
|
|
||||||
{
|
|
||||||
...balance.groups[0]!,
|
|
||||||
sortOrder: 2,
|
|
||||||
rows: [
|
|
||||||
{ ...balance.groups[0]!.rows[1]!, sortOrder: 5 },
|
|
||||||
{ ...balance.groups[0]!.rows[0]!, sortOrder: 1 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const normalized = normalizeEstimateSortOrder(shuffled);
|
|
||||||
assert.deepEqual(
|
|
||||||
normalized.groups.map((group) => [group.id, group.sortOrder]),
|
|
||||||
[["group-410", 0], ["group-440", 1]]
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
normalized.groups[0]!.rows.map((row) => [row.id, row.sortOrder]),
|
|
||||||
[["row-a", 0], ["row-b", 1]]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("estimate balance saving", () => {
|
|
||||||
it("builds the trimmed server document with null for empty optional texts", () => {
|
|
||||||
let balance = sampleBalance();
|
|
||||||
balance = { ...balance, name: " Entwurf ", workStage: " ", dateIso: "" };
|
|
||||||
balance = updateEstimateGroup(balance, "group-440", { title: " Starkstrom ", editor: " " });
|
|
||||||
balance = updateEstimateRow(balance, "group-410", "row-a", {
|
|
||||||
name: " Hebeanlage ",
|
|
||||||
supply: " ELT ",
|
|
||||||
circuitRef: "",
|
|
||||||
remark: " ",
|
|
||||||
});
|
|
||||||
const saveable = toSaveableEstimateBalance(balance);
|
|
||||||
assert.equal(saveable.name, "Entwurf");
|
|
||||||
assert.equal(saveable.workStage, null);
|
|
||||||
assert.equal(saveable.dateIso, null);
|
|
||||||
assert.equal(saveable.groups[1]!.title, "Starkstrom");
|
|
||||||
assert.equal(saveable.groups[1]!.editor, null);
|
|
||||||
assert.deepEqual(
|
|
||||||
{
|
|
||||||
name: saveable.groups[0]!.rows[0]!.name,
|
|
||||||
supply: saveable.groups[0]!.rows[0]!.supply,
|
|
||||||
circuitRef: saveable.groups[0]!.rows[0]!.circuitRef,
|
|
||||||
remark: saveable.groups[0]!.rows[0]!.remark,
|
|
||||||
},
|
|
||||||
{ name: "Hebeanlage", supply: "ELT", circuitRef: null, remark: null }
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("copies only snapshot fields", () => {
|
|
||||||
const balance = sampleBalance() as EstimateBalanceSnapshot & { uiState?: string };
|
|
||||||
balance.uiState = "leak";
|
|
||||||
assert.equal("uiState" in toSaveableEstimateBalance(balance), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats whitespace-only changes as unchanged", () => {
|
|
||||||
const balance = sampleBalance();
|
|
||||||
const withSpace = updateEstimateRow(balance, "group-410", "row-a", {
|
|
||||||
name: "Hebeanlage ",
|
|
||||||
});
|
|
||||||
assert.equal(estimateBalanceFingerprint(withSpace), estimateBalanceFingerprint(balance));
|
|
||||||
const changed = updateEstimateRow(balance, "group-410", "row-a", { quantity: 2 });
|
|
||||||
assert.notEqual(estimateBalanceFingerprint(changed), estimateBalanceFingerprint(balance));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports documents the server would reject", () => {
|
|
||||||
assert.deepEqual(validateEstimateBalance(sampleBalance()), []);
|
|
||||||
const invalid = updateEstimateGroup(
|
|
||||||
{ ...sampleBalance(), name: " " },
|
|
||||||
"group-440",
|
|
||||||
{ title: " " }
|
|
||||||
);
|
|
||||||
assert.equal(validateEstimateBalance(invalid).length, 2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("names copies", () => {
|
|
||||||
assert.equal(createCopyName("Entwurfsplanung "), "Entwurfsplanung (Kopie)");
|
|
||||||
assert.equal(createCopyName("x".repeat(200)).length, 200);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("estimate balance frontend API", () => {
|
|
||||||
it("calls the estimate balance endpoints", async () => {
|
|
||||||
const requests: Array<{ url: string; method: string; body: unknown; keepalive?: boolean }> =
|
|
||||||
[];
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
globalThis.fetch = async (input, init) => {
|
|
||||||
requests.push({
|
|
||||||
url: String(input),
|
|
||||||
method: init?.method ?? "GET",
|
|
||||||
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
|
|
||||||
...(init?.keepalive ? { keepalive: true } : {}),
|
|
||||||
});
|
|
||||||
return new Response(JSON.stringify({}), {
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const balance = sampleBalance();
|
|
||||||
await listEstimateBalances("project-1");
|
|
||||||
await getEstimateBalance("project-1", "balance-1");
|
|
||||||
await createEstimateBalance(
|
|
||||||
"project-1",
|
|
||||||
{ name: "Vorplanung", workStage: null, dateIso: "2026-09-26", template: "kg400" },
|
|
||||||
4
|
|
||||||
);
|
|
||||||
await createEstimateBalance(
|
|
||||||
"project-1",
|
|
||||||
{ name: "Kopie", template: "copy", copyFromBalanceId: "balance-1" },
|
|
||||||
5
|
|
||||||
);
|
|
||||||
await updateEstimateBalance("project-1", balance, 6);
|
|
||||||
await updateEstimateBalance("project-1", balance, 7, { keepalive: true });
|
|
||||||
await deleteEstimateBalance("project-1", "balance-1", 8);
|
|
||||||
await getEstimateComparison("project-1", "balance-1");
|
|
||||||
const base = "/api/projects/project-1/estimate-balances";
|
|
||||||
assert.deepEqual(requests, [
|
|
||||||
{ url: base, method: "GET", body: undefined },
|
|
||||||
{ url: `${base}/balance-1`, method: "GET", body: undefined },
|
|
||||||
{
|
|
||||||
url: base,
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
name: "Vorplanung",
|
|
||||||
workStage: null,
|
|
||||||
dateIso: "2026-09-26",
|
|
||||||
template: "kg400",
|
|
||||||
expectedRevision: 4,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: base,
|
|
||||||
method: "POST",
|
|
||||||
body: {
|
|
||||||
name: "Kopie",
|
|
||||||
template: "copy",
|
|
||||||
copyFromBalanceId: "balance-1",
|
|
||||||
expectedRevision: 5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: `${base}/balance-1`,
|
|
||||||
method: "PUT",
|
|
||||||
body: { expectedRevision: 6, balance },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
url: `${base}/balance-1`,
|
|
||||||
method: "PUT",
|
|
||||||
body: { expectedRevision: 7, balance },
|
|
||||||
keepalive: true,
|
|
||||||
},
|
|
||||||
{ url: `${base}/balance-1`, method: "DELETE", body: { expectedRevision: 8 } },
|
|
||||||
{ url: `${base}/balance-1/comparison`, method: "GET", body: undefined },
|
|
||||||
]);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exposes status and code of failed requests", async () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
globalThis.fetch = async () =>
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
error: "Projektstand veraltet.",
|
|
||||||
code: "PROJECT_REVISION_CONFLICT",
|
|
||||||
}),
|
|
||||||
{ status: 409, headers: { "Content-Type": "application/json" } }
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await assert.rejects(
|
|
||||||
updateEstimateBalance("project-1", sampleBalance(), 1),
|
|
||||||
(error: unknown) => {
|
|
||||||
assert.ok(error instanceof ApiRequestError);
|
|
||||||
assert.equal(error.message, "Projektstand veraltet.");
|
|
||||||
assert.equal(error.status, 409);
|
|
||||||
assert.equal(error.code, "PROJECT_REVISION_CONFLICT");
|
|
||||||
assert.equal(isRevisionConflictError(error), true);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
}
|
|
||||||
assert.equal(isRevisionConflictError(new ApiRequestError("x", 400)), false);
|
|
||||||
assert.equal(isRevisionConflictError(new Error("x")), false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,459 +0,0 @@
|
||||||
import path from "node:path";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
||||||
import {
|
|
||||||
createDatabaseContext,
|
|
||||||
type DatabaseContext,
|
|
||||||
} from "../src/db/database-context.js";
|
|
||||||
import { EstimateBalanceProjectCommandRepository } from "../src/db/repositories/estimate-balance-project-command.repository.js";
|
|
||||||
import { EstimateBalanceRepository } from "../src/db/repositories/estimate-balance.repository.js";
|
|
||||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
|
||||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
|
||||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
|
||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
|
||||||
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
|
||||||
import { estimateRows } from "../src/db/schema/estimate-balances.js";
|
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
|
||||||
import {
|
|
||||||
createEstimateBalanceDeleteProjectCommand,
|
|
||||||
createEstimateBalanceInsertProjectCommand,
|
|
||||||
createEstimateBalanceSnapshot,
|
|
||||||
createEstimateBalanceUpdateProjectCommand,
|
|
||||||
parseEstimateBalanceSnapshot,
|
|
||||||
type EstimateBalanceProjectCommand,
|
|
||||||
} from "../src/estimate-balance/domain/estimate-balance-command.model.js";
|
|
||||||
import { createEstimateBalanceRequestSchema } from "../src/estimate-balance/api/estimate-balance-request.schemas.js";
|
|
||||||
import {
|
|
||||||
calculateEstimateBalance,
|
|
||||||
calculateEstimateRow,
|
|
||||||
compareEstimateWithDetailed,
|
|
||||||
estimateDefaultGroups,
|
|
||||||
matchEstimateCostGroup,
|
|
||||||
roundEstimateValue,
|
|
||||||
type EstimateBalanceSnapshot,
|
|
||||||
type EstimateRowSnapshot,
|
|
||||||
} from "../src/shared/estimate-balance/estimate-balance.js";
|
|
||||||
|
|
||||||
const round2 = (value: number) => roundEstimateValue(value, 2);
|
|
||||||
let nextId = 0;
|
|
||||||
const id = () => `id-${++nextId}`;
|
|
||||||
|
|
||||||
function row(
|
|
||||||
name: string,
|
|
||||||
quantity: number,
|
|
||||||
powerPerUnitW: number,
|
|
||||||
simultaneityFactor: number,
|
|
||||||
cosPhi: number,
|
|
||||||
extra: Partial<EstimateRowSnapshot> = {}
|
|
||||||
): EstimateRowSnapshot {
|
|
||||||
return {
|
|
||||||
id: id(),
|
|
||||||
sortOrder: 0,
|
|
||||||
supply: "ELT",
|
|
||||||
circuitRef: null,
|
|
||||||
name,
|
|
||||||
quantity,
|
|
||||||
unit: "piece",
|
|
||||||
connection: null,
|
|
||||||
powerPerUnitW,
|
|
||||||
simultaneityFactor,
|
|
||||||
cosPhi,
|
|
||||||
safetySupply: false,
|
|
||||||
remark: null,
|
|
||||||
...extra,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** All rows of the office example "Leistungsbilanz - KG400" (Entwurfsplanung). */
|
|
||||||
function templateExample(): EstimateBalanceSnapshot {
|
|
||||||
const rowsByGroup: Record<string, EstimateRowSnapshot[]> = {
|
|
||||||
"410": [
|
|
||||||
row("Durchlauferhitzer 11kW", 9, 11200, 0.3, 1),
|
|
||||||
row("Durchlauferhitzer 4,4kW", 4, 4400, 0.3, 1),
|
|
||||||
],
|
|
||||||
"420": [
|
|
||||||
row("Fernwärmestation", 1, 1000, 0.8, 1),
|
|
||||||
row("Pumpen", 4, 200, 0.8, 0.9),
|
|
||||||
row("Schaltschrank Heizung", 1, 2000, 0.8, 1),
|
|
||||||
],
|
|
||||||
"430": [
|
|
||||||
row("Lüftungsgerät", 1, 6000, 0.8, 0.9),
|
|
||||||
row("Schaltschrank Lüftung", 1, 2000, 0.8, 0.9),
|
|
||||||
],
|
|
||||||
"434": [
|
|
||||||
row("Kälteaggregat Dach", 1, 1000, 0.8, 1),
|
|
||||||
row("Serverraum Kühlung Aggregat Dach", 1, 2000, 0.8, 1),
|
|
||||||
row("Serverraum Innengerät", 1, 1000, 0.8, 1),
|
|
||||||
],
|
|
||||||
"440": [
|
|
||||||
row("Beleuchtungsanlage Klassenzimmer", 17, 500, 0.8, 0.95),
|
|
||||||
row("Beleuchtung Allgemein", 50, 45, 0.7, 0.95),
|
|
||||||
row("Außenbeleuchtung", 10, 100, 0.4, 0.95),
|
|
||||||
row("Fassadenbeleuchtung", 20, 60, 0.5, 0.95),
|
|
||||||
row("Beleuchtung MFR", 15, 50, 0.8, 0.95),
|
|
||||||
row("Steckdosen Klassenzimmer", 170, 300, 0.2, 0.95),
|
|
||||||
row("Steckdosen 230 V allg. Verbr.", 100, 500, 0.2, 0.95),
|
|
||||||
row("Veranstaltungstechnik", 1, 4000, 0.5, 0.95),
|
|
||||||
row("Küchentechnische Anlagen", 1, 10000, 0.4, 0.8),
|
|
||||||
row("Sicherheitsbeleuchtung Zentrale", 1, 2000, 0.8, 0.9),
|
|
||||||
row("Spezielle Experimentieranlagen", 1, 6000, 0.4, 0.9),
|
|
||||||
],
|
|
||||||
"450": [row("EDV Systeme", 5, 3000, 0.8, 0.95)],
|
|
||||||
"460": [row("Aufzug", 1, 16000, 0.7, 0.95)],
|
|
||||||
"470": [row("E-Mobility Vorbereitung", 9, 11000, 0.3, 1)],
|
|
||||||
"480": [],
|
|
||||||
"": [row("Allgemein", 1, 2000, 1, 0.95)],
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
id: "balance-1",
|
|
||||||
projectId: "project-1",
|
|
||||||
name: "Entwurfsplanung",
|
|
||||||
workStage: "LPH 3",
|
|
||||||
dateIso: "2023-06-30",
|
|
||||||
reserveAvPercent: 10,
|
|
||||||
reserveSvPercent: 0,
|
|
||||||
sortOrder: 0,
|
|
||||||
groups: estimateDefaultGroups.map((group, index) => ({
|
|
||||||
id: `group-${group.costGroup || "allg"}`,
|
|
||||||
costGroup: group.costGroup,
|
|
||||||
title: group.title,
|
|
||||||
editor: null,
|
|
||||||
sortOrder: index,
|
|
||||||
rows: rowsByGroup[group.costGroup] ?? [],
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTestDatabase(): DatabaseContext {
|
|
||||||
const context = createDatabaseContext(":memory:");
|
|
||||||
migrate(context.db, { migrationsFolder: path.resolve("src", "db", "migrations") });
|
|
||||||
context.db
|
|
||||||
.insert(projects)
|
|
||||||
.values([
|
|
||||||
{ id: "project-1", name: "Test project" },
|
|
||||||
{ id: "project-2", name: "Foreign project" },
|
|
||||||
])
|
|
||||||
.run();
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("estimate balance calculation", () => {
|
|
||||||
it("reproduces the office template totals", () => {
|
|
||||||
const result = calculateEstimateBalance(templateExample(), 400);
|
|
||||||
const groupTotals = result.groups.map((group) => [
|
|
||||||
round2(group.av.kw),
|
|
||||||
round2(group.av.kva),
|
|
||||||
]);
|
|
||||||
assert.deepEqual(groupTotals, [
|
|
||||||
[35.52, 35.52],
|
|
||||||
[3.04, 3.11],
|
|
||||||
[6.4, 7.11],
|
|
||||||
[3.2, 3.2],
|
|
||||||
[40.18, 43.31],
|
|
||||||
[12, 12.63],
|
|
||||||
[11.2, 11.79],
|
|
||||||
[29.7, 29.7],
|
|
||||||
[0, 0],
|
|
||||||
[2, 2.11],
|
|
||||||
]);
|
|
||||||
assert.equal(round2(result.total.av.kw), 143.24);
|
|
||||||
assert.equal(round2(result.total.av.kva), 148.48);
|
|
||||||
assert.equal(round2(result.reserve.av.kw), 14.32);
|
|
||||||
assert.equal(round2(result.reserve.av.kva), 14.85);
|
|
||||||
assert.equal(round2(result.withReserve.av.kw), 157.56);
|
|
||||||
assert.equal(round2(result.withReserve.av.kva), 163.33);
|
|
||||||
assert.equal(round2(result.maxCurrentA.av), 235.75);
|
|
||||||
assert.equal(result.total.sv.kw, 0);
|
|
||||||
assert.equal(result.maxCurrentA.sv, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("counts safety-supply rows in AV and additionally in SV, also per m²", () => {
|
|
||||||
const area = calculateEstimateRow({
|
|
||||||
quantity: 250,
|
|
||||||
powerPerUnitW: 12,
|
|
||||||
simultaneityFactor: 0.8,
|
|
||||||
cosPhi: 0.95,
|
|
||||||
safetySupply: true,
|
|
||||||
});
|
|
||||||
assert.equal(round2(area.av.kw), 2.4);
|
|
||||||
assert.equal(round2(area.av.kva), 2.53);
|
|
||||||
assert.deepEqual(area.sv, area.av);
|
|
||||||
const balance = templateExample();
|
|
||||||
balance.reserveSvPercent = 20;
|
|
||||||
balance.groups[4]!.rows[9]!.safetySupply = true; // Sicherheitsbeleuchtung Zentrale
|
|
||||||
const result = calculateEstimateBalance(balance, 400);
|
|
||||||
assert.equal(round2(result.total.sv.kw), 1.6);
|
|
||||||
assert.equal(round2(result.withReserve.sv.kw), 1.92);
|
|
||||||
assert.equal(round2(result.total.av.kw), 143.24);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rounds displayed values like the spreadsheet", () => {
|
|
||||||
assert.equal(roundEstimateValue(40.175), 40.18);
|
|
||||||
assert.equal(roundEstimateValue(143.235), 143.24);
|
|
||||||
assert.equal(roundEstimateValue(-1.825), -1.83);
|
|
||||||
assert.equal(roundEstimateValue(0.711111), 0.71);
|
|
||||||
assert.equal(roundEstimateValue(235.749, 1), 235.7);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("matches detailed cost groups to the most specific estimate group", () => {
|
|
||||||
const codes = ["410", "430", "434", "440", "480", ""];
|
|
||||||
assert.equal(matchEstimateCostGroup("444", codes), "440");
|
|
||||||
assert.equal(matchEstimateCostGroup("434", codes), "434");
|
|
||||||
assert.equal(matchEstimateCostGroup("431", codes), "430");
|
|
||||||
assert.equal(matchEstimateCostGroup("KG 481.1", codes), "480");
|
|
||||||
assert.equal(matchEstimateCostGroup("4", codes), null);
|
|
||||||
assert.equal(matchEstimateCostGroup("", codes), null);
|
|
||||||
assert.equal(matchEstimateCostGroup(null, codes), null);
|
|
||||||
assert.equal(matchEstimateCostGroup("450", codes), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("compares the estimate with detailed device rows per cost group", () => {
|
|
||||||
const comparison = compareEstimateWithDetailed(templateExample(), [
|
|
||||||
{ costGroup: "444", kw: 30, cosPhi: 0.9 },
|
|
||||||
{ costGroup: "445", kw: 12, cosPhi: null },
|
|
||||||
{ costGroup: "412", kw: 40, cosPhi: 1 },
|
|
||||||
{ costGroup: null, kw: 5, cosPhi: 1 },
|
|
||||||
{ costGroup: "399", kw: 1, cosPhi: 1 },
|
|
||||||
]);
|
|
||||||
const kg440 = comparison.groups.find((group) => group.costGroup === "440")!;
|
|
||||||
assert.equal(round2(kg440.detailed.kw), 42);
|
|
||||||
assert.equal(round2(kg440.detailed.kva), 45.33);
|
|
||||||
assert.equal(kg440.detailedRowCount, 2);
|
|
||||||
assert.ok(Math.abs(kg440.deltaKw - 1.825) < 1e-9);
|
|
||||||
assert.equal(round2(kg440.deltaPercent!), 4.54);
|
|
||||||
const kg410 = comparison.groups.find((group) => group.costGroup === "410")!;
|
|
||||||
assert.equal(round2(kg410.deltaKw), 4.48);
|
|
||||||
const kg480 = comparison.groups.find((group) => group.costGroup === "480")!;
|
|
||||||
assert.equal(kg480.deltaPercent, null);
|
|
||||||
assert.deepEqual(comparison.unassigned, { kw: 6, kva: 6, rowCount: 2 });
|
|
||||||
assert.equal(round2(comparison.total.detailed.kw), 88);
|
|
||||||
assert.equal(round2(comparison.total.estimatedAv.kw), 143.24);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("estimate balance snapshots and commands", () => {
|
|
||||||
it("validates snapshots strictly", () => {
|
|
||||||
const valid = templateExample();
|
|
||||||
assert.equal(parseEstimateBalanceSnapshot(valid).groups[4]!.rows[3]!.sortOrder, 3);
|
|
||||||
const invalid: Array<(balance: EstimateBalanceSnapshot) => void> = [
|
|
||||||
(balance) => (balance.groups[0]!.rows[0]!.cosPhi = 0),
|
|
||||||
(balance) => (balance.groups[0]!.rows[0]!.simultaneityFactor = 1.5),
|
|
||||||
(balance) => (balance.groups[0]!.rows[0]!.quantity = -1),
|
|
||||||
(balance) => (balance.groups[0]!.rows[0]!.name = " Leerzeichen "),
|
|
||||||
(balance) => (balance.groups[0]!.rows[0]!.supply = ""),
|
|
||||||
(balance) => (balance.groups[0]!.title = ""),
|
|
||||||
(balance) => (balance.dateIso = "30.06.2023"),
|
|
||||||
(balance) => (balance.reserveAvPercent = 101),
|
|
||||||
(balance) => (balance.groups[1]!.rows[0]!.id = balance.groups[0]!.rows[0]!.id),
|
|
||||||
(balance) => ((balance.groups[0]!.rows[0] as unknown as Record<string, unknown>).extra = 1),
|
|
||||||
];
|
|
||||||
for (const mutate of invalid) {
|
|
||||||
const balance = structuredClone(valid);
|
|
||||||
mutate(balance);
|
|
||||||
assert.throws(() => parseEstimateBalanceSnapshot(balance));
|
|
||||||
}
|
|
||||||
assert.equal(
|
|
||||||
createEstimateBalanceRequestSchema.safeParse({
|
|
||||||
expectedRevision: 0,
|
|
||||||
name: "Entwurf",
|
|
||||||
template: "copy",
|
|
||||||
}).success,
|
|
||||||
false
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates the KG 400 template and copies a Stand with new ids", () => {
|
|
||||||
const created = createEstimateBalanceSnapshot({
|
|
||||||
projectId: "project-1",
|
|
||||||
name: " Vorplanung ",
|
|
||||||
workStage: "LPH 2",
|
|
||||||
dateIso: "2026-09-26",
|
|
||||||
sortOrder: 0,
|
|
||||||
template: "kg400",
|
|
||||||
});
|
|
||||||
assert.equal(created.name, "Vorplanung");
|
|
||||||
assert.deepEqual(
|
|
||||||
created.groups.map((group) => group.costGroup),
|
|
||||||
estimateDefaultGroups.map((group) => group.costGroup)
|
|
||||||
);
|
|
||||||
assert.equal(created.reserveAvPercent, 10);
|
|
||||||
const source = templateExample();
|
|
||||||
const copy = createEstimateBalanceSnapshot({
|
|
||||||
projectId: "project-1",
|
|
||||||
name: "Entwurfsplanung Kopie",
|
|
||||||
workStage: null,
|
|
||||||
dateIso: null,
|
|
||||||
sortOrder: 1,
|
|
||||||
template: { copyFrom: source },
|
|
||||||
});
|
|
||||||
assert.notEqual(copy.id, source.id);
|
|
||||||
assert.notEqual(copy.groups[0]!.id, source.groups[0]!.id);
|
|
||||||
assert.notEqual(copy.groups[0]!.rows[0]!.id, source.groups[0]!.rows[0]!.id);
|
|
||||||
assert.equal(
|
|
||||||
round2(calculateEstimateBalance(copy, 400).total.av.kw),
|
|
||||||
143.24
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("inserts, updates and deletes balances with persisted undo and redo", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
const store = new EstimateBalanceProjectCommandRepository(context.db);
|
|
||||||
const reader = new EstimateBalanceRepository(context.db);
|
|
||||||
const history = new ProjectHistoryRepository(context.db);
|
|
||||||
let revision = 0;
|
|
||||||
const run = (
|
|
||||||
command: EstimateBalanceProjectCommand,
|
|
||||||
source: "user" | "undo" | "redo" = "user"
|
|
||||||
) => {
|
|
||||||
const step = source === "user" ? null : history.getNextCommand("project-1", source);
|
|
||||||
const result = store.execute({
|
|
||||||
projectId: "project-1",
|
|
||||||
expectedRevision: revision,
|
|
||||||
source,
|
|
||||||
historyTargetChangeSetId: step?.changeSetId,
|
|
||||||
command,
|
|
||||||
});
|
|
||||||
revision += 1;
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const balance = templateExample();
|
|
||||||
const insert = createEstimateBalanceInsertProjectCommand(balance);
|
|
||||||
const inserted = run(insert);
|
|
||||||
const stored = reader.findById("project-1", balance.id);
|
|
||||||
assert.deepEqual(stored, parseEstimateBalanceSnapshot(balance));
|
|
||||||
assert.equal(reader.listByProject("project-2").length, 0);
|
|
||||||
|
|
||||||
const target = structuredClone(stored!);
|
|
||||||
target.groups[0]!.rows.splice(1, 1);
|
|
||||||
target.groups[0]!.rows[0]!.quantity = 10;
|
|
||||||
target.groups[8]!.rows.push(row("GLT-Schrank", 2, 500, 1, 0.95));
|
|
||||||
target.groups[8]!.editor = "MSR";
|
|
||||||
target.reserveAvPercent = 15;
|
|
||||||
const update = createEstimateBalanceUpdateProjectCommand(stored!, target);
|
|
||||||
const updated = run(update);
|
|
||||||
assert.deepEqual(
|
|
||||||
reader.findById("project-1", balance.id),
|
|
||||||
parseEstimateBalanceSnapshot(target)
|
|
||||||
);
|
|
||||||
|
|
||||||
run(updated.inverse, "undo");
|
|
||||||
assert.deepEqual(reader.findById("project-1", balance.id), stored);
|
|
||||||
run(update, "redo");
|
|
||||||
assert.deepEqual(
|
|
||||||
reader.findById("project-1", balance.id),
|
|
||||||
parseEstimateBalanceSnapshot(target)
|
|
||||||
);
|
|
||||||
|
|
||||||
// A stale expected document is rejected.
|
|
||||||
assert.throws(
|
|
||||||
() => run(createEstimateBalanceUpdateProjectCommand(stored!, { ...stored!, name: "X" })),
|
|
||||||
/changed before update/
|
|
||||||
);
|
|
||||||
assert.throws(
|
|
||||||
() => createEstimateBalanceUpdateProjectCommand(stored!, structuredClone(stored!)),
|
|
||||||
/must change/
|
|
||||||
);
|
|
||||||
|
|
||||||
const current = reader.findById("project-1", balance.id)!;
|
|
||||||
const deleted = run(createEstimateBalanceDeleteProjectCommand(current));
|
|
||||||
assert.equal(reader.findById("project-1", balance.id), null);
|
|
||||||
assert.equal(context.db.select().from(estimateRows).all().length, 0);
|
|
||||||
run(deleted.inverse, "undo");
|
|
||||||
assert.deepEqual(reader.findById("project-1", balance.id), current);
|
|
||||||
// The original insert's inverse no longer matches the edited balance.
|
|
||||||
assert.throws(() => run(inserted.inverse), /changed before deletion/);
|
|
||||||
run(createEstimateBalanceDeleteProjectCommand(current));
|
|
||||||
assert.equal(reader.listByProject("project-1").length, 0);
|
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() => run(createEstimateBalanceInsertProjectCommand({ ...balance, projectId: "project-2" })),
|
|
||||||
/does not belong to project/
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sums detailed device rows of all circuit lists of the project", () => {
|
|
||||||
const context = createTestDatabase();
|
|
||||||
try {
|
|
||||||
context.db
|
|
||||||
.insert(distributionBoards)
|
|
||||||
.values({ id: "board-1", projectId: "project-1", name: "UV 1" })
|
|
||||||
.run();
|
|
||||||
context.db
|
|
||||||
.insert(circuitLists)
|
|
||||||
.values({ id: "list-1", projectId: "project-1", distributionBoardId: "board-1", name: "UV 1" })
|
|
||||||
.run();
|
|
||||||
context.db
|
|
||||||
.insert(circuitSections)
|
|
||||||
.values({
|
|
||||||
id: "section-1",
|
|
||||||
circuitListId: "list-1",
|
|
||||||
key: "lighting-1",
|
|
||||||
displayName: "Beleuchtung 1",
|
|
||||||
prefix: "1F1",
|
|
||||||
sortOrder: 0,
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
context.db
|
|
||||||
.insert(circuits)
|
|
||||||
.values({
|
|
||||||
id: "circuit-1",
|
|
||||||
circuitListId: "list-1",
|
|
||||||
sectionId: "section-1",
|
|
||||||
equipmentIdentifier: "-1F1",
|
|
||||||
displayName: "Licht",
|
|
||||||
sortOrder: 0,
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
context.db
|
|
||||||
.insert(circuitDeviceRows)
|
|
||||||
.values([
|
|
||||||
{
|
|
||||||
id: "row-1",
|
|
||||||
circuitId: "circuit-1",
|
|
||||||
name: "Leuchte",
|
|
||||||
displayName: "Leuchte",
|
|
||||||
costGroup: "445",
|
|
||||||
quantity: 10,
|
|
||||||
powerPerUnit: 0.05,
|
|
||||||
simultaneityFactor: 0.8,
|
|
||||||
cosPhi: 0.95,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "row-2",
|
|
||||||
circuitId: "circuit-1",
|
|
||||||
name: "Steckdose",
|
|
||||||
displayName: "Steckdose",
|
|
||||||
costGroup: null,
|
|
||||||
quantity: 2,
|
|
||||||
powerPerUnit: 0.2,
|
|
||||||
simultaneityFactor: 0.5,
|
|
||||||
cosPhi: null,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
.run();
|
|
||||||
const detailed = new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower(
|
|
||||||
"project-1"
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
detailed.map((entry) => ({ ...entry, kw: round2(entry.kw) })),
|
|
||||||
[
|
|
||||||
{ costGroup: "445", kw: 0.4, cosPhi: 0.95 },
|
|
||||||
{ costGroup: null, kw: 0.2, cosPhi: null },
|
|
||||||
]
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
new EstimateBalanceRepository(context.db).listDetailedDeviceRowPower("project-2").length,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue