From 37a70b6b363959d3018c48d5c40b94e1d25a3163 Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Sat, 26 Sep 2026 14:50:48 +0200 Subject: [PATCH 1/4] Add the rough power balance module for early design phases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In LPH 2/3 there are no circuits yet, but the office needs a power balance by DIN 276 cost group, as in the Excel template "Leistungsbilanz - KG400": each trade enters consumers by piece or by area (W/m²) with GLF and cos phi; the balance sums AV and SV per cost group, adds a reserve and derives the maximum current. The module is separate from the circuit structure: own tables (migration 0010), own folder src/estimate-balance and an import-free shared calculation used by API and UI. Writes go through project commands (estimate-balance.insert/.update/.delete with complete documents), so revision checks and Undo/Redo apply; it is not part of project snapshots or transfer yet. A comparison endpoint sums the device rows of all circuit lists by cost group (longest prefix) against the estimate. The UI adds a sidebar entry, a list of Stände (template KG 400, empty or copy) and a spreadsheet-like editor with autosave, AMEV picker for the GLF, totals, reserve, maximum current, comparison table and print layout. The tests reproduce the template example exactly (143.24 kW, 148.48 kVA, 157.56 kW / 163.33 kVA with 10 % reserve, 235.75 A). See docs/estimate-balance.md and issue #7. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 21 + docs/README.md | 2 + docs/circuit-list-editor-api.md | 6 + docs/estimate-balance.md | 108 + scripts/db-verify-circuit-schema.js | 3 + src/app/globals.css | 490 +++ .../grobbilanz/[balanceId]/page.tsx | 743 +++++ .../projects/[projectId]/grobbilanz/page.tsx | 292 ++ src/db/migrations/0010_estimate_balances.sql | 43 + src/db/migrations/meta/0010_snapshot.json | 2931 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + ...mate-balance-project-command.repository.ts | 132 + .../estimate-balance.persistence.ts | 121 + .../estimate-balance.repository.ts | 60 + src/db/schema/estimate-balances.ts | 61 + .../services/project-command.service.ts | 30 +- .../api/estimate-balance-request.schemas.ts | 33 + .../domain/estimate-balance-command.model.ts | 320 ++ .../estimate-balance-project-command.store.ts | 24 + src/frontend/components/Sidebar.tsx | 1 + .../estimate-balance-create-modal.tsx | 162 + .../estimate-balance-document.ts | 301 ++ .../estimate-balance-format.ts | 122 + .../estimate-balance-sheet.tsx | 652 ++++ .../estimate-comparison-table.tsx | 88 + .../estimate-balance/estimate-draft-input.tsx | 107 + .../estimate-balance/estimate-group-modal.tsx | 128 + src/frontend/types.ts | 31 + src/frontend/utils/api.ts | 104 +- src/frontend/utils/project-version-history.ts | 3 + .../composition/application-repositories.ts | 2 + .../composition/project-command-stores.ts | 6 +- .../estimate-balance.controller.ts | 180 + src/server/routes/project.routes.ts | 17 + .../estimate-balance/estimate-balance.ts | 316 ++ tests/estimate-balance-frontend.test.ts | 435 +++ tests/estimate-balance.test.ts | 459 +++ 37 files changed, 8537 insertions(+), 4 deletions(-) create mode 100644 docs/estimate-balance.md create mode 100644 src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx create mode 100644 src/app/projects/[projectId]/grobbilanz/page.tsx create mode 100644 src/db/migrations/0010_estimate_balances.sql create mode 100644 src/db/migrations/meta/0010_snapshot.json create mode 100644 src/db/repositories/estimate-balance-project-command.repository.ts create mode 100644 src/db/repositories/estimate-balance.persistence.ts create mode 100644 src/db/repositories/estimate-balance.repository.ts create mode 100644 src/db/schema/estimate-balances.ts create mode 100644 src/estimate-balance/api/estimate-balance-request.schemas.ts create mode 100644 src/estimate-balance/domain/estimate-balance-command.model.ts create mode 100644 src/estimate-balance/domain/estimate-balance-project-command.store.ts create mode 100644 src/frontend/estimate-balance/estimate-balance-create-modal.tsx create mode 100644 src/frontend/estimate-balance/estimate-balance-document.ts create mode 100644 src/frontend/estimate-balance/estimate-balance-format.ts create mode 100644 src/frontend/estimate-balance/estimate-balance-sheet.tsx create mode 100644 src/frontend/estimate-balance/estimate-comparison-table.tsx create mode 100644 src/frontend/estimate-balance/estimate-draft-input.tsx create mode 100644 src/frontend/estimate-balance/estimate-group-modal.tsx create mode 100644 src/server/controllers/estimate-balance.controller.ts create mode 100644 src/shared/estimate-balance/estimate-balance.ts create mode 100644 tests/estimate-balance-frontend.test.ts create mode 100644 tests/estimate-balance.test.ts diff --git a/AGENTS.md b/AGENTS.md index 77b0170..d435cf3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -576,6 +576,27 @@ Future sizing will need: Users must be able to override sizing suggestions. +## Estimate Balance Module (Grobe Leistungsbilanz) + +`src/estimate-balance` plus `src/shared/estimate-balance` is a separate module +for the early-phase power balance by DIN 276 cost group (see +`docs/estimate-balance.md`). Rules: + +- It has its own tables (`estimate_*`) and never writes circuit, board or + project-device data; it only reads device rows for the comparison. +- Writes use the project command mechanism (`estimate-balance.insert/.update/ + .delete` with complete documents), so revision checks and Undo/Redo apply. +- The calculation and comparison live in the import-free + `src/shared/estimate-balance/estimate-balance.ts` and are shared by API and UI; + displayed values use `roundEstimateValue`. +- It is not part of project snapshots or project transfer yet (open question + in issue #7); do not add it there without a snapshot schema version. +- UI: `/projects/:id/grobbilanz` (Stände) and `/grobbilanz/:balanceId` (editor, + `src/frontend/estimate-balance/`). The editor autosaves the whole document + debounced (one Undo step per save), keeps invalid raw input local, and on a + 409 reloads only if the balance itself changed; otherwise it re-saves with + the new revision. `request()` throws `ApiRequestError` (status, code). + ## Persistence and Migration Rules - SQLite is the currently supported database. diff --git a/docs/README.md b/docs/README.md index cdddb67..09a3668 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we Verteilerübersicht, Verteilerpläne, Büro-Logo - [Gebäudestruktur](building-structure.md) – Gebäude → Etagen → Räume, Befehle, Snapshot-Schema 7, eigene Seite +- [Grobe Leistungsbilanz](estimate-balance.md) – LPH 2/3 nach Kostengruppen, + Rechnung, Vergleich mit der detaillierten Bilanz - [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte - [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) – bewusst noch nicht implementierte oder begrenzte Funktionen diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index 156be62..8e6e720 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -214,6 +214,12 @@ A stale `expectedRevision` returns HTTP `409` with `PROJECT_REVISION_CONFLICT`. Undo or redo without an eligible stack entry returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`. +## Estimate Balance (Grobe Leistungsbilanz) + +Separate module under `/projects/:projectId/estimate-balances`; complete +documents are written through `estimate-balance.insert/.update/.delete` project +commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`. + ## Circuit-First Endpoints ### Distribution Board Setup diff --git a/docs/estimate-balance.md b/docs/estimate-balance.md new file mode 100644 index 0000000..4e43cec --- /dev/null +++ b/docs/estimate-balance.md @@ -0,0 +1,108 @@ +# Grobe Leistungsbilanz (LPH 2/3) + +Eigenes Modul für die Leistungsbilanz der Vor- und Entwurfsplanung, gegliedert +nach Kostengruppen (DIN 276, KG 400). Es gibt dort noch keine Verteilungen und +Stromkreise: Jedes Gewerk trägt unter seiner Kostengruppe Verbraucher als +Stückzahl mit Einzelleistung oder als Fläche mit W/m² ein. Aufbau und Rechnung +folgen der bisherigen Excel-Vorlage „Leistungsbilanz – KG400“. Abstimmung: +Issue #7. + +Das Modul ist **nicht** mit der Stromkreisstruktur verknüpft. Es liest die +Gerätezeilen nur für den Vergleich „geschätzt ↔ detailliert“ und schreibt +nichts in Stromkreislisten, Verteilungen oder Projektgeräte. + +## Aufbau + +``` +src/shared/estimate-balance/estimate-balance.ts Datenform, Rechnung, Vergleich (rein, ohne Imports) +src/estimate-balance/domain/estimate-balance-command.model.ts Snapshot-Schema (Zod), Befehle +src/estimate-balance/domain/estimate-balance-project-command.store.ts Store-Port +src/estimate-balance/api/estimate-balance-request.schemas.ts Request-Schemas +src/db/schema/estimate-balances.ts Tabellen (Migration 0010) +src/db/repositories/estimate-balance*.ts Lesen, Schreiben, Befehls-Store +src/server/controllers/estimate-balance.controller.ts API +src/app/projects/[projectId]/grobbilanz/ Übersicht der Stände und Editor +src/frontend/estimate-balance/ Editor-Komponenten und Helfer +``` + +Die gemeinsame Datei unter `src/shared/estimate-balance` hat keine Imports, weil +das Frontend sie direkt nutzt (Turbopack löst die `.js`-Suffixe der API nicht auf). + +## Datenmodell + +| Tabelle | Inhalt | +|---|---| +| `estimate_balances` | ein **Stand** je Projekt (z. B. Vorplanung, Entwurfsplanung): Name, LPH, Datum, Reserve AV/SV in %, Sortierung | +| `estimate_groups` | Kostengruppe (`"410"` … `"480"`, `""` für „Allgemein“), Titel, „bearbeitet von“, Sortierung | +| `estimate_rows` | Versorgung, Stromkreis-Nr. (Freitext), Verbraucher/Fläche, Anzahl bzw. Fläche, Einheit (`piece` = St., `area` = m²), Anschluss (fest/Steckdose), Einzelleistung in W bzw. W/m², GLF, cos φ, SV ja/nein, Anmerkung | + +Neue Stände entstehen aus der Vorlage KG 400 (410 Sanitär, 420 Wärme, 430 Luft, +434 Kälte, 440 Starkstrom, 450 Schwachstrom, 460 Förder, 470 Nutzungsspezifisch, +480 GA, Allgemein; Reserve AV 10 %), leer oder als Kopie eines bestehenden +Stands mit neuen IDs. So lässt sich ein Stand einfrieren und später vergleichen. + +## Rechnung + +- Zeile: P = Anzahl bzw. Fläche × Einzelleistung × GLF, S = P / cos φ. +- **AV** enthält alle Verbraucher (Normalversorgung). **SV** listet zusätzlich + die Zeilen mit „SV = ja“, die auch aus der Sicherheitsstromversorgung versorgt + werden müssen. Das ist die Lesart der Vorlage; die Regel liegt in + `calculateEstimateRow` und lässt sich dort zentral ändern. +- Summen je Kostengruppe, Gesamtsumme, Reserve in % (AV und SV getrennt), + Leistung inklusive Reserve und Maximalstrom I = S / (√3 · U) mit der + Drehstromspannung aus den Projekteinstellungen. +- Anzeige wie in der Tabellenkalkulation gerundet (`roundEstimateValue`, + 40,175 → 40,18). Der Test `tests/estimate-balance.test.ts` rechnet das + Beispiel der Vorlage vollständig nach (143,24 kW, 148,48 kVA, mit 10 % + Reserve 157,56 kW / 163,33 kVA, 235,75 A). + +## Vergleich geschätzt ↔ detailliert + +`GET …/comparison` summiert alle Gerätezeilen aller Stromkreislisten des +Projekts (Anzahl × Leistung je Stück × Geräte-GZF, wie der Stromkreisbaum) und +ordnet sie über das Feld **Kostengruppe** der Gerätezeile zu. Maßgeblich ist das +längste passende Präfix: `434` → Gruppe 434, `431` → Gruppe 430, `444` → 440. +Zeilen ohne passende Kostengruppe erscheinen als „nicht zugeordnet“. Der +Verteiler-GZF wird auf keiner Seite angewendet; ein fehlendes cos φ zählt als 1. +Verglichen wird die AV-Leistung (kW) mit Differenz absolut und in %. + +## Befehle, Undo/Redo + +Alle Änderungen laufen über den bestehenden Projekt-Befehlsmechanismus mit +Revision, Inverse und persistentem Undo/Redo: + +- `estimate-balance.insert` / `estimate-balance.delete` mit dem vollständigen + Stand (Gruppen und Zeilen) +- `estimate-balance.update` mit vollständigem `expected`/`target`-Dokument; der + Store prüft, dass der gespeicherte Stand genau `expected` entspricht, und + ersetzt ihn atomar. Die Inverse ist das vertauschte Paar. + +Der Editor speichert das ganze Dokument verzögert (Autosave); jeder +Speichervorgang ist ein Undo-Schritt. Unveränderte Dokumente erzeugen keine +Revision (`{ unchanged: true }`). + +## Bewusst nicht enthalten (Fragen an Julian, Issue #7) + +- Die grobe Bilanz ist **nicht** Teil von Projekt-Snapshot, Restore und + Projekttransfer; das Snapshot-Schema bleibt unverändert. Ein Restore lässt + die Stände unverändert, eine Projektkopie übernimmt sie nicht. +- Keine Rechte je Gewerk; „bearbeitet von“ ist ein Freitext je Kostengruppe. +- Kostengruppen der Gerätezeilen sind nicht normiert; der Vergleich arbeitet + mit Präfixen. + +## API + +- `GET /api/projects/:projectId/estimate-balances` +- `POST /api/projects/:projectId/estimate-balances` – + `{ "expectedRevision": 7, "name": "Entwurfsplanung", "workStage": "LPH 3", "dateIso": "2026-09-26", "template": "kg400" }` + (`template`: `kg400` | `empty` | `copy` mit `copyFromBalanceId`) +- `GET /api/projects/:projectId/estimate-balances/:balanceId` +- `PUT /api/projects/:projectId/estimate-balances/:balanceId` – + `{ "expectedRevision": 8, "balance": { …vollständiges Dokument… } }` +- `DELETE /api/projects/:projectId/estimate-balances/:balanceId` – `{ "expectedRevision": 9 }` +- `GET /api/projects/:projectId/estimate-balances/:balanceId/comparison` + +Validierung: Name 1–200 Zeichen, getrimmte Texte, Anzahl/Fläche und +Einzelleistung ≥ 0, GLF 0–1, cos φ > 0 bis 1, Reserve 0–100 %, Datum +`YYYY-MM-DD`, eindeutige Gruppen- und Zeilen-IDs, unbekannte Felder werden +abgelehnt. diff --git a/scripts/db-verify-circuit-schema.js b/scripts/db-verify-circuit-schema.js index 947767a..fa91cc4 100644 --- a/scripts/db-verify-circuit-schema.js +++ b/scripts/db-verify-circuit-schema.js @@ -6,6 +6,9 @@ const db = new Database(dbPath, { readonly: true }); const requiredTables = [ "buildings", + "estimate_balances", + "estimate_groups", + "estimate_rows", "circuit_sections", "circuits", "circuit_device_rows", diff --git a/src/app/globals.css b/src/app/globals.css index 0c40c5c..b3f526a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1602,3 +1602,493 @@ button.structure-entry-main:focus-visible { color: var(--color-ink-soft); font-size: 0.9rem; } + +/* ── Grobe Leistungsbilanz ──────────────────────────────────────────── */ +/* Tabelle im Stil der Büro-Vorlage „Leistungsbilanz – KG400“: kompakte + Zellen-Inputs statt Bootstrap-Formularfelder, Druck als A4 quer. */ + +.estimate-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-sm); +} + +.estimate-save-status { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-sm); + font-size: 0.8rem; +} + +.estimate-save-badge { + display: inline-block; + padding: 0.2rem 0.55rem; + border-radius: 999px; + font-weight: 600; + border: 1px solid var(--color-border); + color: var(--color-ink-soft); + background: var(--color-bg); +} + +.estimate-save-badge.is-saved { + color: var(--color-signal-dark); + border-color: var(--color-signal); +} + +.estimate-save-badge.is-busy { + color: var(--color-warn); + border-color: var(--color-warn); +} + +.estimate-save-badge.is-error { + color: var(--color-danger); + border-color: var(--color-danger); + background: var(--color-danger-bg); +} + +.estimate-save-hint { + color: var(--color-danger); +} + +.estimate-header-fields { + display: flex; + flex-wrap: wrap; + gap: var(--space-sm) var(--space-lg); + margin-bottom: var(--space-md); +} + +.estimate-header-field { + display: flex; + align-items: center; + gap: var(--space-sm); + min-width: 10rem; +} + +.estimate-header-field.is-wide { + flex: 1 1 18rem; +} + +.estimate-header-label { + font-size: 0.7rem; + letter-spacing: 0.06em; + text-transform: uppercase; + font-weight: 600; + color: var(--color-ink-soft); +} + +.estimate-header-value { + font-weight: 600; +} + +.estimate-header-input { + font-size: 0.95rem; + font-weight: 600; + border-color: var(--panel-border); +} + +.estimate-sheet-wrap { + overflow-x: auto; +} + +.estimate-sheet { + width: 100%; + min-width: 1400px; + table-layout: fixed; + border-collapse: collapse; + font-size: 0.82rem; + background: var(--panel-bg); + color: var(--text-strong); +} + +.estimate-comparison { + min-width: 640px; + table-layout: auto; +} + +.estimate-sheet th, +.estimate-sheet td { + border: 1px solid var(--panel-border); + padding: 2px 4px; + vertical-align: middle; +} + +.estimate-sheet thead th { + background: var(--surface-header-row); + font-size: 0.72rem; + font-weight: 700; + text-align: center; + line-height: 1.2; +} + +.estimate-th-hint { + display: block; + font-size: 0.68rem; + font-weight: 400; + color: var(--text-faint); +} + +.estimate-col-kg { width: 3rem; } +.estimate-col-supply { width: 4rem; } +.estimate-col-circuit { width: 4.6rem; } +.estimate-col-name { width: auto; } +.estimate-col-quantity { width: 4.8rem; } +.estimate-col-unit { width: 3.8rem; } +.estimate-col-connection { width: 5rem; } +.estimate-col-power { width: 6.4rem; } +.estimate-col-gzf { width: 6.8rem; } +.estimate-col-cosphi { width: 3.8rem; } +.estimate-col-result { width: 4.6rem; } +.estimate-col-sv { width: 3.2rem; } +.estimate-col-remark { width: 9rem; } +.estimate-col-actions { width: 2.4rem; } + +.estimate-cell-input, +.estimate-cell-select { + width: 100%; + min-width: 0; + border: 1px solid transparent; + border-radius: 3px; + background: transparent; + color: inherit; + font: inherit; + padding: 1px 4px; + line-height: 1.4; +} + +.estimate-cell-select { + padding-right: 0; + cursor: pointer; +} + +.estimate-cell-input:hover:not(:disabled), +.estimate-cell-select:hover:not(:disabled) { + border-color: var(--panel-border-strong); +} + +.estimate-cell-input:focus, +.estimate-cell-select:focus { + outline: none; + border-color: var(--accent-blue); + background: var(--panel-bg); + box-shadow: 0 0 0 2px var(--accent-blue-soft-bg); +} + +.estimate-cell-input.is-numeric { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.estimate-cell-input.is-invalid, +.estimate-cell-input.is-invalid:focus, +.estimate-cell-input.is-invalid:hover { + border-color: var(--color-danger); + background: var(--color-danger-bg); +} + +.estimate-cell-input::placeholder { + color: var(--text-faint); + opacity: 0.7; +} + +.estimate-unit-input, +.estimate-gzf-input { + display: flex; + align-items: center; + gap: 2px; +} + +.estimate-unit-label { + flex: 0 0 auto; + font-size: 0.7rem; + color: var(--text-faint); + min-width: 1.9rem; +} + +.estimate-amev-button { + flex: 0 0 auto; + border: 1px solid var(--panel-border-strong); + border-radius: 3px; + background: var(--panel-bg-subtle); + color: var(--text-subtle); + font-size: 0.6rem; + font-weight: 700; + letter-spacing: 0.03em; + padding: 1px 3px; + line-height: 1.3; +} + +.estimate-amev-button:hover:not(:disabled) { + border-color: var(--accent-blue); + color: var(--accent-blue); +} + +.estimate-number { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.estimate-number.is-muted { + color: var(--text-faint); +} + +.estimate-number.is-over { + color: var(--color-attention-dark); +} + +.estimate-number.is-under { + color: var(--color-signal-dark); +} + +.estimate-center { + text-align: center; +} + +.estimate-cost-group-cell { + font-weight: 700; + text-align: center; + font-variant-numeric: tabular-nums; +} + +.estimate-group-title-row td { + background: var(--surface-component-header); + font-weight: 700; +} + +.estimate-group-title { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs) var(--space-lg); +} + +.estimate-group-name { + font-size: 0.9rem; +} + +.estimate-group-editor { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + font-weight: 400; + font-size: 0.75rem; + color: var(--text-muted); +} + +.estimate-group-editor .estimate-cell-input { + width: 12rem; +} + +.estimate-group-actions { + display: flex; + justify-content: flex-end; + gap: 2px; +} + +.estimate-group-title-row .estimate-actions-cell { + width: auto; + white-space: nowrap; +} + +.estimate-icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.15rem 0.3rem; + line-height: 1; +} + +.estimate-actions-cell { + text-align: center; +} + +.estimate-empty-row td { + color: var(--text-faint); + font-style: italic; +} + +.estimate-subtotal-row td { + background: var(--panel-bg-subtle); + font-weight: 700; +} + +.estimate-total-label { + text-align: right; + font-weight: 700; +} + +.estimate-total-hint { + font-weight: 400; + color: var(--text-faint); +} + +.estimate-total-row td { + background: var(--surface-header-row); + font-weight: 600; +} + +.estimate-total-row.is-strong td { + font-weight: 800; + border-top: 2px solid var(--panel-border-strong); +} + +.estimate-add-group-row td { + background: var(--panel-bg); + padding: var(--space-sm) 4px; +} + +.estimate-unassigned-row td { + font-style: italic; + color: var(--text-muted); +} + +.estimate-collapse-toggle { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + border: 0; + background: transparent; + color: inherit; + font: inherit; + font-weight: 700; + padding: 0; +} + +.estimate-list-table .estimate-number { + font-variant-numeric: tabular-nums; +} + +/* Druck-Textwerte: auf dem Bildschirm unsichtbar, im Druck statt Inputs */ +.estimate-print-value { + display: none; +} + +@page estimate-balance { + size: A4 landscape; + margin: 10mm; +} + +@media print { + body:has(.estimate-print-root) .sidebar, + .estimate-print-root .estimate-no-print, + .estimate-print-root .alert, + .estimate-print-root input, + .estimate-print-root select, + .estimate-print-root button { + display: none !important; + } + + body:has(.estimate-print-root) { + background: #fff !important; + } + + body:has(.estimate-print-root) .app-shell { + display: block; + } + + .estimate-print-root { + page: estimate-balance; + padding: 0 !important; + max-width: none; + color: #000; + } + + .estimate-print-root .estimate-print-value { + display: inline; + } + + .estimate-print-root .page-header { + margin-bottom: 4mm; + } + + .estimate-print-root .page-header h1 { + font-size: 14pt; + color: #000; + } + + .estimate-print-root .card { + border: 0; + box-shadow: none; + background: transparent; + } + + .estimate-print-root .card-body, + .estimate-print-root .card-header { + padding: 0; + background: transparent; + border: 0; + } + + .estimate-print-root .estimate-comparison-card { + margin-top: 6mm; + break-inside: avoid; + } + + .estimate-sheet-wrap { + overflow: visible; + } + + .estimate-sheet { + min-width: 0; + table-layout: auto; + font-size: 7.5pt; + background: #fff; + color: #000; + } + + .estimate-sheet th, + .estimate-sheet td { + border-color: #999; + padding: 1px 3px; + } + + .estimate-sheet thead { + display: table-header-group; + } + + .estimate-sheet thead th, + .estimate-total-row td { + background: #e8e8e8 !important; + } + + .estimate-group-title-row td { + background: #d9d9d9 !important; + } + + .estimate-subtotal-row td { + background: #f2f2f2 !important; + } + + .estimate-group-title-row, + .estimate-subtotal-row, + .estimate-total-row { + break-inside: avoid; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + .estimate-group-title-row { + break-after: avoid; + } + + /* Totals print once at the end, not repeated on every page. */ + .estimate-sheet tfoot { + display: table-row-group; + break-inside: avoid; + } + + .estimate-col-actions { + width: 0; + } + + .estimate-unit-input, + .estimate-gzf-input { + justify-content: flex-end; + } + + .estimate-header-fields { + margin-bottom: 3mm; + } +} diff --git a/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx b/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx new file mode 100644 index 0000000..2aa7f5e --- /dev/null +++ b/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx @@ -0,0 +1,743 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react"; +import { + ArrowLeft, + ChevronDown, + ChevronRight, + Printer, + Redo2, + RefreshCw, + Undo2, +} from "lucide-react"; +import { + ApiRequestError, + getEstimateBalance, + getEstimateComparison, + getProject, + getProjectHistory, + isRevisionConflictError, + redoProjectCommand, + undoProjectCommand, + updateEstimateBalance, +} from "../../../../../frontend/utils/api"; +import type { + EstimateBalanceDto, + EstimateComparisonDto, + ProjectDto, + ProjectHistoryStateDto, +} from "../../../../../frontend/types"; +import type { EstimateGroupSnapshot } from "../../../../../shared/estimate-balance/estimate-balance"; +import { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal"; +import { + addEstimateGroup, + estimateBalanceFingerprint, + estimateTextLimits, + normalizeEstimateSortOrder, + toSaveableEstimateBalance, + updateEstimateGroup, + updateEstimateRow, + validateEstimateBalance, +} from "../../../../../frontend/estimate-balance/estimate-balance-document"; +import { formatIsoDateGerman } from "../../../../../frontend/estimate-balance/estimate-balance-format"; +import { + EstimateBalanceSheet, + type EstimateBalanceUpdate, +} from "../../../../../frontend/estimate-balance/estimate-balance-sheet"; +import { EstimateComparisonTable } from "../../../../../frontend/estimate-balance/estimate-comparison-table"; +import { DraftInput } from "../../../../../frontend/estimate-balance/estimate-draft-input"; +import { + EstimateGroupModal, + type EstimateGroupFormValues, +} from "../../../../../frontend/estimate-balance/estimate-group-modal"; + +const AUTOSAVE_DELAY_MS = 800; +const CONFLICT_MESSAGE = "Die Bilanz wurde zwischenzeitlich geändert und neu geladen."; + +type SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid"; + +type GroupDialog = { group: EstimateGroupSnapshot | null }; + +function errorMessage(error: unknown, fallback: string) { + return error instanceof Error && error.message ? error.message : fallback; +} + +function isNotFoundError(error: unknown) { + return error instanceof ApiRequestError && error.status === 404; +} + +export default function EstimateBalanceEditorPage() { + const params = useParams<{ projectId: string; balanceId: string }>(); + const { projectId, balanceId } = params; + const router = useRouter(); + + const [project, setProject] = useState(null); + const [draft, setDraft] = useState(null); + const [history, setHistory] = useState(null); + const [comparison, setComparison] = useState(null); + const [comparisonError, setComparisonError] = useState(null); + const [isComparisonOpen, setIsComparisonOpen] = useState(false); + const [saveStatus, setSaveStatus] = useState("saved"); + const [saveError, setSaveError] = useState(null); + const [error, setError] = useState(null); + const [notice, setNotice] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [isMissing, setIsMissing] = useState(false); + const [isHistoryBusy, setIsHistoryBusy] = useState(false); + const [invalidFieldKeys, setInvalidFieldKeys] = useState>( + () => new Set() + ); + const [groupDialog, setGroupDialog] = useState(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(null); + const revisionRef = useRef(null); + const savedFingerprintRef = useRef(null); + const saveInFlightRef = useRef | null>(null); + const saveTimerRef = useRef | 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 => { + 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) { + 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 ( +
+
+
+ void handleBackClick(event)} + title="Zurück zur Übersicht der Stände" + > +
+
+ void saveNow()} + status={saveStatus} + /> +
+ + +
+ +
+
+ + {notice ? ( +
+ {notice} +
+ ) : null} + {error ? ( +
+ {error} +
+ ) : null} + {isLoading ? ( +

+ Grobe Leistungsbilanz wird geladen … +

+ ) : null} + {isMissing ? ( +
+ Dieser Stand existiert nicht (mehr), z. B. weil er gelöscht oder sein Anlegen + rückgängig gemacht wurde.{" "} + Zur Übersicht der Stände +
+ ) : null} + + {draft && project ? ( + <> +
+
+
+
+ Projekt + {project.name} +
+ + + +
+ setGroupDialog({ group: null })} + onChange={handleChange} + onEditGroup={(group) => setGroupDialog({ group })} + onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })} + onValidityChange={handleValidityChange} + threePhaseVoltageV={project.threePhaseVoltageV} + /> +
+
+ +
+
+ + {isComparisonOpen ? ( + + ) : null} +
+ {isComparisonOpen ? ( +
+ {comparisonError ? ( +
+ {comparisonError} +
+ ) : null} + {comparison ? : null} +

+ Detailliert = Summe der Gerätezeilen aller Stromkreislisten (Anzahl × + Leistung × Geräte-GZF), zugeordnet über die Kostengruppe; ohne + Verteiler-GZF. +

+
+ ) : null} +
+ + ) : null} + + {groupDialog ? ( + setGroupDialog(null)} + onSave={handleSaveGroup} + /> + ) : null} + {gzfTarget ? ( + setGzfTarget(null)} onSelect={handleSelectGzf} /> + ) : null} +
+ ); +} + +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 ( +
+ + {label} + + {invalidCount ? ( + + {invalidCount === 1 + ? "1 ungültige Eingabe wird nicht gespeichert" + : `${invalidCount} ungültige Eingaben werden nicht gespeichert`} + + ) : null} + {status === "error" ? ( + + ) : null} +
+ ); +} diff --git a/src/app/projects/[projectId]/grobbilanz/page.tsx b/src/app/projects/[projectId]/grobbilanz/page.tsx new file mode 100644 index 0000000..4b2cf81 --- /dev/null +++ b/src/app/projects/[projectId]/grobbilanz/page.tsx @@ -0,0 +1,292 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; +import { ArrowLeft, Copy, FolderOpen, Plus, Trash2 } from "lucide-react"; +import { + createEstimateBalance, + deleteEstimateBalance, + getProject, + listEstimateBalances, +} from "../../../../frontend/utils/api"; +import type { + CreateEstimateBalanceInput, + EstimateBalanceDto, + ProjectCommandResultDto, + ProjectDto, +} from "../../../../frontend/types"; +import { calculateEstimateBalance } from "../../../../shared/estimate-balance/estimate-balance"; +import { + EstimateBalanceCreateModal, + type EstimateBalanceCreateDefaults, +} from "../../../../frontend/estimate-balance/estimate-balance-create-modal"; +import { createCopyName } from "../../../../frontend/estimate-balance/estimate-balance-document"; +import { + formatIsoDateGerman, + formatKw, +} from "../../../../frontend/estimate-balance/estimate-balance-format"; + +function countRows(balance: EstimateBalanceDto) { + return balance.groups.reduce((sum, group) => sum + group.rows.length, 0); +} + +export default function EstimateBalanceListPage() { + const params = useParams<{ projectId: string }>(); + const projectId = params.projectId; + const router = useRouter(); + const [project, setProject] = useState(null); + const [balances, setBalances] = useState([]); + const [createDefaults, setCreateDefaults] = + useState(null); + const [error, setError] = useState(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( + execute: (expectedRevision: number) => Promise, + fallbackMessage: string + ): Promise { + 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 ( +
+
+
+ +
+ +
+ + {error ? ( +
+ {error} +
+ ) : null} + {isLoading ? ( +

+ Grobe Leistungsbilanzen werden geladen … +

+ ) : null} + +
+
+

+ Stände + {balances.length} +

+
+ {balances.length ? ( +
+ + + + + + + + + + + + + + + {balances.map((balance) => { + const result = calculateEstimateBalance(balance, voltage); + const href = `/projects/${projectId}/grobbilanz/${balance.id}`; + const rowCount = countRows(balance); + return ( + + + + + + + + + + + ); + })} + +
StandLPHDatumAV kWAV kVASV kWSV kVAAktionen
+ + {balance.name} + +
+ {balance.groups.length} Kostengruppen ·{" "} + {rowCount === 1 ? "1 Zeile" : `${rowCount} Zeilen`} +
+
{balance.workStage ?? "–"}{formatIsoDateGerman(balance.dateIso)} + {formatKw(result.withReserve.av.kw)} + + {formatKw(result.withReserve.av.kva)} + + {formatKw(result.withReserve.sv.kw)} + + {formatKw(result.withReserve.sv.kva)} + +
+ +
+
+

+ Leistungen jeweils inklusive Reserve. +

+
+ ) : !isLoading ? ( +
+

+ Noch kein Stand vorhanden. Mit „Neuer Stand“ eine grobe Leistungsbilanz auf + Basis der Vorlage KG 400 anlegen. +

+
+ ) : null} +
+ + {createDefaults ? ( + setCreateDefaults(null)} + onCreate={(input) => void handleCreate(input)} + /> + ) : null} +
+ ); +} diff --git a/src/db/migrations/0010_estimate_balances.sql b/src/db/migrations/0010_estimate_balances.sql new file mode 100644 index 0000000..645ccbe --- /dev/null +++ b/src/db/migrations/0010_estimate_balances.sql @@ -0,0 +1,43 @@ +CREATE TABLE `estimate_balances` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `name` text NOT NULL, + `work_stage` text, + `date_iso` text, + `reserve_av_percent` real DEFAULT 0 NOT NULL, + `reserve_sv_percent` real DEFAULT 0 NOT NULL, + `sort_order` integer DEFAULT 0 NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `estimate_balances_project_id_idx` ON `estimate_balances` (`project_id`);--> statement-breakpoint +CREATE TABLE `estimate_groups` ( + `id` text PRIMARY KEY NOT NULL, + `balance_id` text NOT NULL, + `cost_group` text DEFAULT '' NOT NULL, + `title` text NOT NULL, + `editor` text, + `sort_order` integer DEFAULT 0 NOT NULL, + FOREIGN KEY (`balance_id`) REFERENCES `estimate_balances`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `estimate_groups_balance_id_idx` ON `estimate_groups` (`balance_id`);--> statement-breakpoint +CREATE TABLE `estimate_rows` ( + `id` text PRIMARY KEY NOT NULL, + `group_id` text NOT NULL, + `sort_order` integer DEFAULT 0 NOT NULL, + `supply` text, + `circuit_ref` text, + `name` text DEFAULT '' NOT NULL, + `quantity` real DEFAULT 0 NOT NULL, + `unit` text DEFAULT 'piece' NOT NULL, + `connection` text, + `power_per_unit_w` real DEFAULT 0 NOT NULL, + `simultaneity_factor` real DEFAULT 1 NOT NULL, + `cos_phi` real DEFAULT 1 NOT NULL, + `safety_supply` integer DEFAULT false NOT NULL, + `remark` text, + FOREIGN KEY (`group_id`) REFERENCES `estimate_groups`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `estimate_rows_group_id_idx` ON `estimate_rows` (`group_id`); \ No newline at end of file diff --git a/src/db/migrations/meta/0010_snapshot.json b/src/db/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..869d504 --- /dev/null +++ b/src/db/migrations/meta/0010_snapshot.json @@ -0,0 +1,2931 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6df688bd-a78a-46a1-ba7a-4068119bf8bd", + "prevId": "2cd9adef-2f08-4635-b3d9-ecf71f624c7f", + "tables": { + "buildings": { + "name": "buildings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "buildings_project_id_projects_id_fk": { + "name": "buildings_project_id_projects_id_fk", + "tableFrom": "buildings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_device_rows": { + "name": "circuit_device_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number_snapshot": { + "name": "room_number_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_name_snapshot": { + "name": "room_name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "manual_quantity": { + "name": "manual_quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_device_rows_circuit_id_idx": { + "name": "circuit_device_rows_circuit_id_idx", + "columns": [ + "circuit_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "circuit_device_rows_circuit_id_circuits_id_fk": { + "name": "circuit_device_rows_circuit_id_circuits_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_device_rows_linked_project_device_id_project_devices_id_fk": { + "name": "circuit_device_rows_linked_project_device_id_project_devices_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "circuit_device_rows_room_id_rooms_id_fk": { + "name": "circuit_device_rows_room_id_rooms_id_fk", + "tableFrom": "circuit_device_rows", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_list_equipment_identifiers": { + "name": "circuit_list_equipment_identifiers", + "columns": { + "owner_type": { + "name": "owner_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_list_equipment_identifiers_owner_unique": { + "name": "circuit_list_equipment_identifiers_owner_unique", + "columns": [ + "owner_type", + "owner_id" + ], + "isUnique": true + }, + "circuit_list_equipment_identifiers_list_identifier_unique": { + "name": "circuit_list_equipment_identifiers_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_list_equipment_identifiers_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_list_equipment_identifiers", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_lists": { + "name": "circuit_lists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "circuit_lists_distribution_board_id_unique": { + "name": "circuit_lists_distribution_board_id_unique", + "columns": [ + "distribution_board_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_lists_project_id_projects_id_fk": { + "name": "circuit_lists_project_id_projects_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuit_lists_distribution_board_id_distribution_boards_id_fk": { + "name": "circuit_lists_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "circuit_lists", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_protection_devices": { + "name": "circuit_protection_devices", + "columns": { + "circuit_id": { + "name": "circuit_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "circuit_protection_devices_circuit_id_circuits_id_fk": { + "name": "circuit_protection_devices_circuit_id_circuits_id_fk", + "tableFrom": "circuit_protection_devices", + "tableTo": "circuits", + "columnsFrom": [ + "circuit_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuit_sections": { + "name": "circuit_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_number": { + "name": "group_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuit_sections_list_key_unique": { + "name": "circuit_sections_list_key_unique", + "columns": [ + "circuit_list_id", + "key" + ], + "isUnique": true + }, + "circuit_sections_list_prefix_unique": { + "name": "circuit_sections_list_prefix_unique", + "columns": [ + "circuit_list_id", + "prefix" + ], + "isUnique": true + }, + "circuit_sections_list_category_group_unique": { + "name": "circuit_sections_list_category_group_unique", + "columns": [ + "circuit_list_id", + "category", + "group_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuit_sections_circuit_list_id_circuit_lists_id_fk": { + "name": "circuit_sections_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuit_sections", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "circuits": { + "name": "circuits", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cable_type": { + "name": "cable_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_cross_section": { + "name": "cable_cross_section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cable_length": { + "name": "cable_length", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_assignment": { + "name": "rcd_assignment", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_designation": { + "name": "terminal_designation", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage": { + "name": "voltage", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "control_requirement": { + "name": "control_requirement", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_reserve": { + "name": "is_reserve", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "circuits_section_id_idx": { + "name": "circuits_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + }, + "circuits_list_equipment_identifier_unique": { + "name": "circuits_list_equipment_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + } + }, + "foreignKeys": { + "circuits_circuit_list_id_circuit_lists_id_fk": { + "name": "circuits_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "circuits_section_id_circuit_sections_id_fk": { + "name": "circuits_section_id_circuit_sections_id_fk", + "tableFrom": "circuits", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_component_protection_devices": { + "name": "distribution_board_component_protection_devices", + "columns": { + "component_id": { + "name": "component_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rated_current_a": { + "name": "rated_current_a", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuse_utilization_category": { + "name": "fuse_utilization_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trip_characteristic": { + "name": "trip_characteristic", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rcd_type": { + "name": "rcd_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rated_residual_current_ma": { + "name": "rated_residual_current_ma", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk": { + "name": "distribution_board_component_protection_devices_component_id_distribution_board_components_id_fk", + "tableFrom": "distribution_board_component_protection_devices", + "tableTo": "distribution_board_components", + "columnsFrom": [ + "component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_board_components": { + "name": "distribution_board_components", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "circuit_list_id": { + "name": "circuit_list_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "equipment_identifier": { + "name": "equipment_identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "placement": { + "name": "placement", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "distribution_board_components_list_identifier_unique": { + "name": "distribution_board_components_list_identifier_unique", + "columns": [ + "circuit_list_id", + "equipment_identifier" + ], + "isUnique": true + }, + "distribution_board_components_section_role_unique": { + "name": "distribution_board_components_section_role_unique", + "columns": [ + "section_id", + "role" + ], + "isUnique": true + } + }, + "foreignKeys": { + "distribution_board_components_circuit_list_id_circuit_lists_id_fk": { + "name": "distribution_board_components_circuit_list_id_circuit_lists_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_lists", + "columnsFrom": [ + "circuit_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_board_components_section_id_circuit_sections_id_fk": { + "name": "distribution_board_components_section_id_circuit_sections_id_fk", + "tableFrom": "distribution_board_components", + "tableTo": "circuit_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "distribution_boards": { + "name": "distribution_boards", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "supply_type": { + "name": "supply_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "board_number": { + "name": "board_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fed_from": { + "name": "fed_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "distribution_boards_project_id_projects_id_fk": { + "name": "distribution_boards_project_id_projects_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "distribution_boards_floor_id_floors_id_fk": { + "name": "distribution_boards_floor_id_floors_id_fk", + "tableFrom": "distribution_boards", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_branding_assets": { + "name": "document_branding_assets", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_branding_settings": { + "name": "document_branding_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_balances": { + "name": "estimate_balances", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "work_stage": { + "name": "work_stage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date_iso": { + "name": "date_iso", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reserve_av_percent": { + "name": "reserve_av_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reserve_sv_percent": { + "name": "reserve_sv_percent", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "estimate_balances_project_id_idx": { + "name": "estimate_balances_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_balances_project_id_projects_id_fk": { + "name": "estimate_balances_project_id_projects_id_fk", + "tableFrom": "estimate_balances", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_groups": { + "name": "estimate_groups", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "balance_id": { + "name": "balance_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "editor": { + "name": "editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "estimate_groups_balance_id_idx": { + "name": "estimate_groups_balance_id_idx", + "columns": [ + "balance_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_groups_balance_id_estimate_balances_id_fk": { + "name": "estimate_groups_balance_id_estimate_balances_id_fk", + "tableFrom": "estimate_groups", + "tableTo": "estimate_balances", + "columnsFrom": [ + "balance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "estimate_rows": { + "name": "estimate_rows", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "supply": { + "name": "supply", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_ref": { + "name": "circuit_ref", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "quantity": { + "name": "quantity", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'piece'" + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_per_unit_w": { + "name": "power_per_unit_w", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "safety_supply": { + "name": "safety_supply", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "estimate_rows_group_id_idx": { + "name": "estimate_rows_group_id_idx", + "columns": [ + "group_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "estimate_rows_group_id_estimate_groups_id_fk": { + "name": "estimate_rows_group_id_estimate_groups_id_fk", + "tableFrom": "estimate_rows", + "tableTo": "estimate_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_csv_configurations": { + "name": "external_csv_configurations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_csv_configurations_project_id_unique": { + "name": "external_csv_configurations_project_id_unique", + "columns": [ + "project_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_csv_configurations_project_id_projects_id_fk": { + "name": "external_csv_configurations_project_id_projects_id_fk", + "tableFrom": "external_csv_configurations", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_import_batches": { + "name": "external_import_batches", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "import_kind": { + "name": "import_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at_iso": { + "name": "imported_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "applied_project_revision": { + "name": "applied_project_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_version": { + "name": "configuration_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "configuration_snapshot": { + "name": "configuration_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_bytes": { + "name": "original_bytes", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document": { + "name": "document", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "external_import_batches_project_revision_idx": { + "name": "external_import_batches_project_revision_idx", + "columns": [ + "project_id", + "applied_project_revision" + ], + "isUnique": false + }, + "external_import_batches_source_imported_idx": { + "name": "external_import_batches_source_imported_idx", + "columns": [ + "source_id", + "imported_at_iso" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_import_batches_project_id_projects_id_fk": { + "name": "external_import_batches_project_id_projects_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_import_batches_source_id_external_model_sources_id_fk": { + "name": "external_import_batches_source_id_external_model_sources_id_fk", + "tableFrom": "external_import_batches", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_objects": { + "name": "external_model_objects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ifc_guid": { + "name": "ifc_guid", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_seen_import_batch_id": { + "name": "last_seen_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_accepted_import_batch_id": { + "name": "last_accepted_import_batch_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_source_values": { + "name": "accepted_source_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "planning_values": { + "name": "planning_values", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overridden_fields": { + "name": "overridden_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_room_mapping_id": { + "name": "external_room_mapping_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distribution_board_id": { + "name": "distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "linked_project_device_id": { + "name": "linked_project_device_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "circuit_device_row_id": { + "name": "circuit_device_row_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "presence_status": { + "name": "presence_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'present'" + } + }, + "indexes": { + "external_model_objects_source_ifc_guid_unique": { + "name": "external_model_objects_source_ifc_guid_unique", + "columns": [ + "source_id", + "ifc_guid" + ], + "isUnique": true + }, + "external_model_objects_project_presence_idx": { + "name": "external_model_objects_project_presence_idx", + "columns": [ + "project_id", + "presence_status" + ], + "isUnique": false + }, + "external_model_objects_distribution_board_idx": { + "name": "external_model_objects_distribution_board_idx", + "columns": [ + "distribution_board_id" + ], + "isUnique": false + }, + "external_model_objects_circuit_device_row_idx": { + "name": "external_model_objects_circuit_device_row_idx", + "columns": [ + "circuit_device_row_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_model_objects_project_id_projects_id_fk": { + "name": "external_model_objects_project_id_projects_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_source_id_external_model_sources_id_fk": { + "name": "external_model_objects_source_id_external_model_sources_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_seen_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_seen_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk": { + "name": "external_model_objects_last_accepted_import_batch_id_external_import_batches_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_import_batches", + "columnsFrom": [ + "last_accepted_import_batch_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk": { + "name": "external_model_objects_external_room_mapping_id_external_room_mappings_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "external_room_mappings", + "columnsFrom": [ + "external_room_mapping_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_distribution_board_id_distribution_boards_id_fk": { + "name": "external_model_objects_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "distribution_boards", + "columnsFrom": [ + "distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_linked_project_device_id_project_devices_id_fk": { + "name": "external_model_objects_linked_project_device_id_project_devices_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "project_devices", + "columnsFrom": [ + "linked_project_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk": { + "name": "external_model_objects_circuit_device_row_id_circuit_device_rows_id_fk", + "tableFrom": "external_model_objects", + "tableTo": "circuit_device_rows", + "columnsFrom": [ + "circuit_device_row_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_model_sources": { + "name": "external_model_sources", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'revit_csv'" + } + }, + "indexes": { + "external_model_sources_project_type_unique": { + "name": "external_model_sources_project_type_unique", + "columns": [ + "project_id", + "source_type" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_model_sources_project_id_projects_id_fk": { + "name": "external_model_sources_project_id_projects_id_fk", + "tableFrom": "external_model_sources", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_room_mappings": { + "name": "external_room_mappings", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "normalized_source_room_key": { + "name": "normalized_source_room_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_floor_name": { + "name": "source_floor_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_room_number": { + "name": "source_room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_room_name": { + "name": "source_room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_id": { + "name": "room_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_distribution_board_id": { + "name": "default_distribution_board_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "external_room_mappings_source_key_unique": { + "name": "external_room_mappings_source_key_unique", + "columns": [ + "source_id", + "normalized_source_room_key" + ], + "isUnique": true + }, + "external_room_mappings_project_room_idx": { + "name": "external_room_mappings_project_room_idx", + "columns": [ + "project_id", + "room_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_room_mappings_project_id_projects_id_fk": { + "name": "external_room_mappings_project_id_projects_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_source_id_external_model_sources_id_fk": { + "name": "external_room_mappings_source_id_external_model_sources_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "external_model_sources", + "columnsFrom": [ + "source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "external_room_mappings_room_id_rooms_id_fk": { + "name": "external_room_mappings_room_id_rooms_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "rooms", + "columnsFrom": [ + "room_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk": { + "name": "external_room_mappings_default_distribution_board_id_distribution_boards_id_fk", + "tableFrom": "external_room_mappings", + "tableTo": "distribution_boards", + "columnsFrom": [ + "default_distribution_board_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "floors": { + "name": "floors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "building_id": { + "name": "building_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "floors_project_id_projects_id_fk": { + "name": "floors_project_id_projects_id_fk", + "tableFrom": "floors", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "floors_building_id_buildings_id_fk": { + "name": "floors_building_id_buildings_id_fk", + "tableFrom": "floors", + "tableTo": "buildings", + "columnsFrom": [ + "building_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_devices": { + "name": "global_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_power_per_unit_kw": { + "name": "installed_power_per_unit_kw", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "demand_factor": { + "name": "demand_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phase_count": { + "name": "phase_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "power_factor": { + "name": "power_factor", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_change_sets": { + "name": "project_change_sets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_revision_id": { + "name": "project_revision_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "command_type": { + "name": "command_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_schema_version": { + "name": "payload_schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "forward_payload_json": { + "name": "forward_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inverse_payload_json": { + "name": "inverse_payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_change_sets_revision_unique": { + "name": "project_change_sets_revision_unique", + "columns": [ + "project_revision_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_change_sets_project_revision_id_project_revisions_id_fk": { + "name": "project_change_sets_project_revision_id_project_revisions_id_fk", + "tableFrom": "project_change_sets", + "tableTo": "project_revisions", + "columnsFrom": [ + "project_revision_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_devices": { + "name": "project_devices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase_type": { + "name": "phase_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'single_phase'" + }, + "connection_kind": { + "name": "connection_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cost_group": { + "name": "cost_group", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "power_per_unit": { + "name": "power_per_unit", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "simultaneity_factor": { + "name": "simultaneity_factor", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "cos_phi": { + "name": "cos_phi", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remark": { + "name": "remark", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "voltage_v": { + "name": "voltage_v", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "project_devices_project_id_projects_id_fk": { + "name": "project_devices_project_id_projects_id_fk", + "tableFrom": "project_devices", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_history_stack_entries": { + "name": "project_history_stack_entries", + "columns": { + "change_set_id": { + "name": "change_set_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "stack": { + "name": "stack", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "project_history_stack_entries_position_unique": { + "name": "project_history_stack_entries_position_unique", + "columns": [ + "project_id", + "stack", + "position" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_history_stack_entries_change_set_id_project_change_sets_id_fk": { + "name": "project_history_stack_entries_change_set_id_project_change_sets_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "project_change_sets", + "columnsFrom": [ + "change_set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_history_stack_entries_project_id_projects_id_fk": { + "name": "project_history_stack_entries_project_id_projects_id_fk", + "tableFrom": "project_history_stack_entries", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "project_history_stack_entries_stack_check": { + "name": "project_history_stack_entries_stack_check", + "value": "\"project_history_stack_entries\".\"stack\" in ('undo', 'redo')" + } + } + }, + "project_revisions": { + "name": "project_revisions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revision_number": { + "name": "revision_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_revisions_project_number_unique": { + "name": "project_revisions_project_number_unique", + "columns": [ + "project_id", + "revision_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_revisions_project_id_projects_id_fk": { + "name": "project_revisions_project_id_projects_id_fk", + "tableFrom": "project_revisions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "project_snapshots": { + "name": "project_snapshots", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_revision": { + "name": "source_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'named'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_sha256": { + "name": "payload_sha256", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at_iso": { + "name": "created_at_iso", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_actor_id": { + "name": "created_by_actor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "project_snapshots_project_created_idx": { + "name": "project_snapshots_project_created_idx", + "columns": [ + "project_id", + "created_at_iso" + ], + "isUnique": false + }, + "project_snapshots_project_kind_revision_idx": { + "name": "project_snapshots_project_kind_revision_idx", + "columns": [ + "project_id", + "kind", + "source_revision" + ], + "isUnique": false + }, + "project_snapshots_project_name_unique": { + "name": "project_snapshots_project_name_unique", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "project_snapshots_project_id_projects_id_fk": { + "name": "project_snapshots_project_id_projects_id_fk", + "tableFrom": "project_snapshots", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "internal_project_number": { + "name": "internal_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_project_number": { + "name": "external_project_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "building_owner": { + "name": "building_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_public_building": { + "name": "is_public_building", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "single_phase_voltage_v": { + "name": "single_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 230 + }, + "three_phase_voltage_v": { + "name": "three_phase_voltage_v", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 400 + }, + "enabled_distribution_board_supply_types": { + "name": "enabled_distribution_board_supply_types", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"AV\",\"SV\",\"EV\",\"USV\",\"MSR\",\"SiBe\"]'" + }, + "site_address": { + "name": "site_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "work_stage": { + "name": "work_stage", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_lead": { + "name": "project_lead", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "project_editor": { + "name": "project_editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "current_revision": { + "name": "current_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rooms": { + "name": "rooms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "floor_id": { + "name": "floor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "room_number": { + "name": "room_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "room_name": { + "name": "room_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "rooms_project_id_projects_id_fk": { + "name": "rooms_project_id_projects_id_fk", + "tableFrom": "rooms", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "rooms_floor_id_floors_id_fk": { + "name": "rooms_floor_id_floors_id_fk", + "tableFrom": "rooms", + "tableTo": "floors", + "columnsFrom": [ + "floor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 485b8f4..209a0a2 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1790419185882, "tag": "0009_buildings", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1790425784626, + "tag": "0010_estimate_balances", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/repositories/estimate-balance-project-command.repository.ts b/src/db/repositories/estimate-balance-project-command.repository.ts new file mode 100644 index 0000000..b31ca78 --- /dev/null +++ b/src/db/repositories/estimate-balance-project-command.repository.ts @@ -0,0 +1,132 @@ +import { eq } from "drizzle-orm"; +import { + assertEstimateBalanceDeleteProjectCommand, + assertEstimateBalanceInsertProjectCommand, + assertEstimateBalanceUpdateProjectCommand, + createEstimateBalanceDeleteProjectCommand, + createEstimateBalanceInsertProjectCommand, + createEstimateBalanceUpdateProjectCommand, + estimateBalanceDeleteCommandType, + estimateBalanceInsertCommandType, + estimateBalanceUpdateCommandType, + normalizeEstimateBalanceSnapshot, + sameEstimateBalance, + type EstimateBalanceProjectCommand, +} from "../../estimate-balance/domain/estimate-balance-command.model.js"; +import type { + EstimateBalanceProjectCommandStore, + ExecuteEstimateBalanceCommandInput, +} from "../../estimate-balance/domain/estimate-balance-project-command.store.js"; +import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js"; +import type { AppDatabase } from "../database-context.js"; +import { estimateBalances } from "../schema/estimate-balances.js"; +import { projects } from "../schema/projects.js"; +import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js"; +import { + deleteEstimateBalance, + insertEstimateBalance, + readEstimateBalances, +} from "./estimate-balance.persistence.js"; + +export class EstimateBalanceProjectCommandRepository + implements EstimateBalanceProjectCommandStore +{ + constructor(private readonly database: AppDatabase) {} + + execute(input: ExecuteEstimateBalanceCommandInput) { + return executeProjectCommandTransaction(this.database, input, (tx) => + this.applyCommand(tx, input.projectId, input.command) + ); + } + + private applyCommand( + database: AppDatabase, + projectId: string, + command: EstimateBalanceProjectCommand + ): EstimateBalanceProjectCommand { + switch (command.type) { + case estimateBalanceInsertCommandType: + assertEstimateBalanceInsertProjectCommand(command); + return this.insert(database, projectId, command.payload.balance); + case estimateBalanceDeleteCommandType: + assertEstimateBalanceDeleteProjectCommand(command); + return this.delete(database, projectId, command.payload.balance); + case estimateBalanceUpdateCommandType: + assertEstimateBalanceUpdateProjectCommand(command); + return this.update( + database, + projectId, + command.payload.expected, + command.payload.target + ); + } + } + + private insert( + database: AppDatabase, + projectId: string, + balance: EstimateBalanceSnapshot + ) { + if (balance.projectId !== projectId) { + throw new Error("Estimate-balance snapshot does not belong to project."); + } + const project = database + .select({ id: projects.id }) + .from(projects) + .where(eq(projects.id, projectId)) + .get(); + if (!project) { + throw new Error("Project does not exist."); + } + const existing = database + .select({ id: estimateBalances.id }) + .from(estimateBalances) + .where(eq(estimateBalances.id, balance.id)) + .get(); + if (existing) { + throw new Error("Estimate-balance id already exists."); + } + const normalized = normalizeEstimateBalanceSnapshot(balance); + insertEstimateBalance(database, normalized); + return createEstimateBalanceDeleteProjectCommand(normalized); + } + + private delete( + database: AppDatabase, + projectId: string, + expected: EstimateBalanceSnapshot + ) { + this.assertPersisted(database, projectId, expected, "deletion"); + if (deleteEstimateBalance(database, projectId, expected.id) !== 1) { + throw new Error("Estimate balance could not be deleted."); + } + return createEstimateBalanceInsertProjectCommand(expected); + } + + private update( + database: AppDatabase, + projectId: string, + expected: EstimateBalanceSnapshot, + target: EstimateBalanceSnapshot + ) { + this.assertPersisted(database, projectId, expected, "update"); + // Group and row ids stay stable; replacing the whole document keeps the + // command exact and its inverse trivially the swapped pair. + deleteEstimateBalance(database, projectId, expected.id); + const normalized = normalizeEstimateBalanceSnapshot(target); + insertEstimateBalance(database, normalized); + return createEstimateBalanceUpdateProjectCommand(normalized, expected); + } + + private assertPersisted( + database: AppDatabase, + projectId: string, + expected: EstimateBalanceSnapshot, + action: string + ) { + const [persisted] = readEstimateBalances(database, projectId, expected.id); + if (!persisted || !sameEstimateBalance(persisted, expected)) { + throw new Error(`Estimate balance changed before ${action}.`); + } + } +} diff --git a/src/db/repositories/estimate-balance.persistence.ts b/src/db/repositories/estimate-balance.persistence.ts new file mode 100644 index 0000000..4d59aa6 --- /dev/null +++ b/src/db/repositories/estimate-balance.persistence.ts @@ -0,0 +1,121 @@ +import { and, asc, eq, inArray } from "drizzle-orm"; +import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js"; +import type { AppDatabase } from "../database-context.js"; +import { + estimateBalances, + estimateGroups, + estimateRows, +} from "../schema/estimate-balances.js"; + +/** Reads complete balance documents of a project, ordered like the UI shows them. */ +export function readEstimateBalances( + database: AppDatabase, + projectId: string, + balanceId?: string +): EstimateBalanceSnapshot[] { + const balances = database + .select() + .from(estimateBalances) + .where( + balanceId + ? and( + eq(estimateBalances.projectId, projectId), + eq(estimateBalances.id, balanceId) + ) + : eq(estimateBalances.projectId, projectId) + ) + .orderBy(asc(estimateBalances.sortOrder), asc(estimateBalances.id)) + .all(); + if (!balances.length) { + return []; + } + const groups = database + .select() + .from(estimateGroups) + .where(inArray(estimateGroups.balanceId, balances.map((balance) => balance.id))) + .orderBy(asc(estimateGroups.sortOrder), asc(estimateGroups.id)) + .all(); + const rows = groups.length + ? database + .select() + .from(estimateRows) + .where(inArray(estimateRows.groupId, groups.map((group) => group.id))) + .orderBy(asc(estimateRows.sortOrder), asc(estimateRows.id)) + .all() + : []; + return balances.map((balance) => ({ + id: balance.id, + projectId: balance.projectId, + name: balance.name, + workStage: balance.workStage, + dateIso: balance.dateIso, + reserveAvPercent: balance.reserveAvPercent, + reserveSvPercent: balance.reserveSvPercent, + sortOrder: balance.sortOrder, + groups: groups + .filter((group) => group.balanceId === balance.id) + .map((group) => ({ + id: group.id, + costGroup: group.costGroup, + title: group.title, + editor: group.editor, + sortOrder: group.sortOrder, + rows: rows + .filter((row) => row.groupId === group.id) + .map(({ groupId: _groupId, ...row }) => row), + })), + })); +} + +/** Writes a complete balance document (the caller removed an older version first). */ +export function insertEstimateBalance( + database: AppDatabase, + balance: EstimateBalanceSnapshot +) { + database + .insert(estimateBalances) + .values({ + id: balance.id, + projectId: balance.projectId, + name: balance.name, + workStage: balance.workStage, + dateIso: balance.dateIso, + reserveAvPercent: balance.reserveAvPercent, + reserveSvPercent: balance.reserveSvPercent, + sortOrder: balance.sortOrder, + }) + .run(); + for (const group of balance.groups) { + database + .insert(estimateGroups) + .values({ + id: group.id, + balanceId: balance.id, + costGroup: group.costGroup, + title: group.title, + editor: group.editor, + sortOrder: group.sortOrder, + }) + .run(); + if (group.rows.length) { + database + .insert(estimateRows) + .values(group.rows.map((row) => ({ ...row, groupId: group.id }))) + .run(); + } + } +} + +/** Removes a balance with its groups and rows (foreign keys cascade). */ +export function deleteEstimateBalance( + database: AppDatabase, + projectId: string, + balanceId: string +) { + return database + .delete(estimateBalances) + .where( + and(eq(estimateBalances.projectId, projectId), eq(estimateBalances.id, balanceId)) + ) + .run().changes; +} diff --git a/src/db/repositories/estimate-balance.repository.ts b/src/db/repositories/estimate-balance.repository.ts new file mode 100644 index 0000000..4c70d27 --- /dev/null +++ b/src/db/repositories/estimate-balance.repository.ts @@ -0,0 +1,60 @@ +import { asc, eq } from "drizzle-orm"; +import type { + DetailedDeviceRowPower, + EstimateBalanceSnapshot, +} from "../../shared/estimate-balance/estimate-balance.js"; +import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js"; +import type { AppDatabase } from "../database-context.js"; +import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; +import { circuitLists } from "../schema/circuit-lists.js"; +import { circuits } from "../schema/circuits.js"; +import { estimateBalances } from "../schema/estimate-balances.js"; +import { readEstimateBalances } from "./estimate-balance.persistence.js"; + +/** Read side of the estimate-balance module; writes go through commands. */ +export class EstimateBalanceRepository { + constructor(private readonly database: AppDatabase) {} + + listByProject(projectId: string): EstimateBalanceSnapshot[] { + return readEstimateBalances(this.database, projectId); + } + + findById(projectId: string, balanceId: string): EstimateBalanceSnapshot | null { + return readEstimateBalances(this.database, projectId, balanceId)[0] ?? null; + } + + nextSortOrder(projectId: string): number { + const rows = this.database + .select({ sortOrder: estimateBalances.sortOrder }) + .from(estimateBalances) + .where(eq(estimateBalances.projectId, projectId)) + .orderBy(asc(estimateBalances.sortOrder)) + .all(); + return rows.length ? Math.max(...rows.map((row) => row.sortOrder)) + 1 : 0; + } + + /** + * Device rows of all circuit lists of the project with their total power + * (quantity · power per unit · device GZF, like the circuit tree). + */ + listDetailedDeviceRowPower(projectId: string): DetailedDeviceRowPower[] { + return this.database + .select({ + costGroup: circuitDeviceRows.costGroup, + quantity: circuitDeviceRows.quantity, + powerPerUnit: circuitDeviceRows.powerPerUnit, + simultaneityFactor: circuitDeviceRows.simultaneityFactor, + cosPhi: circuitDeviceRows.cosPhi, + }) + .from(circuitDeviceRows) + .innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId)) + .innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId)) + .where(eq(circuitLists.projectId, projectId)) + .all() + .map((row) => ({ + costGroup: row.costGroup, + kw: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor), + cosPhi: row.cosPhi, + })); + } +} diff --git a/src/db/schema/estimate-balances.ts b/src/db/schema/estimate-balances.ts new file mode 100644 index 0000000..81420f1 --- /dev/null +++ b/src/db/schema/estimate-balances.ts @@ -0,0 +1,61 @@ +import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { projects } from "./projects.js"; + +// Grobe Leistungsbilanz (LPH 2/3): a separate module without links into the +// circuit structure. See docs/estimate-balance.md. +export const estimateBalances = sqliteTable( + "estimate_balances", + { + id: text("id").primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + name: text("name").notNull(), + workStage: text("work_stage"), + dateIso: text("date_iso"), + reserveAvPercent: real("reserve_av_percent").notNull().default(0), + reserveSvPercent: real("reserve_sv_percent").notNull().default(0), + sortOrder: integer("sort_order").notNull().default(0), + }, + (table) => [index("estimate_balances_project_id_idx").on(table.projectId)] +); + +export const estimateGroups = sqliteTable( + "estimate_groups", + { + id: text("id").primaryKey(), + balanceId: text("balance_id") + .notNull() + .references(() => estimateBalances.id, { onDelete: "cascade" }), + costGroup: text("cost_group").notNull().default(""), + title: text("title").notNull(), + editor: text("editor"), + sortOrder: integer("sort_order").notNull().default(0), + }, + (table) => [index("estimate_groups_balance_id_idx").on(table.balanceId)] +); + +export const estimateRows = sqliteTable( + "estimate_rows", + { + id: text("id").primaryKey(), + groupId: text("group_id") + .notNull() + .references(() => estimateGroups.id, { onDelete: "cascade" }), + sortOrder: integer("sort_order").notNull().default(0), + supply: text("supply"), + circuitRef: text("circuit_ref"), + name: text("name").notNull().default(""), + quantity: real("quantity").notNull().default(0), + unit: text("unit", { enum: ["piece", "area"] }).notNull().default("piece"), + connection: text("connection", { enum: ["fixed", "socket"] }), + powerPerUnitW: real("power_per_unit_w").notNull().default(0), + simultaneityFactor: real("simultaneity_factor").notNull().default(1), + cosPhi: real("cos_phi").notNull().default(1), + safetySupply: integer("safety_supply", { mode: "boolean" }) + .notNull() + .default(false), + remark: text("remark"), + }, + (table) => [index("estimate_rows_group_id_idx").on(table.groupId)] +); diff --git a/src/domain/services/project-command.service.ts b/src/domain/services/project-command.service.ts index 91ee754..cf1f740 100644 --- a/src/domain/services/project-command.service.ts +++ b/src/domain/services/project-command.service.ts @@ -194,6 +194,15 @@ import { externalObjectDeleteCreatedCircuitCommandType, } from "../models/external-object-new-circuit-project-command.model.js"; import type { ExternalObjectNewCircuitProjectCommandStore } from "../ports/external-object-new-circuit-project-command.store.js"; +import { + assertEstimateBalanceDeleteProjectCommand, + assertEstimateBalanceInsertProjectCommand, + assertEstimateBalanceUpdateProjectCommand, + estimateBalanceDeleteCommandType, + estimateBalanceInsertCommandType, + estimateBalanceUpdateCommandType, +} from "../../estimate-balance/domain/estimate-balance-command.model.js"; +import type { EstimateBalanceProjectCommandStore } from "../../estimate-balance/domain/estimate-balance-project-command.store.js"; interface DispatchProjectCommandInput { projectId: string; @@ -233,7 +242,8 @@ export class ProjectCommandService implements ProjectCommandExecutor { private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore, private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore, private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore, - private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore + private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore, + private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore ) {} executeUser( @@ -654,6 +664,24 @@ export class ProjectCommandService implements ProjectCommandExecutor { command: input.command, }).revision; } + case estimateBalanceInsertCommandType: + case estimateBalanceDeleteCommandType: + case estimateBalanceUpdateCommandType: { + if (input.command.type === estimateBalanceInsertCommandType) { + assertEstimateBalanceInsertProjectCommand(input.command); + } else if (input.command.type === estimateBalanceDeleteCommandType) { + assertEstimateBalanceDeleteProjectCommand(input.command); + } else { + assertEstimateBalanceUpdateProjectCommand(input.command); + } + if (!this.estimateBalanceStore) { + throw new Error("Estimate-balance store is not available."); + } + return this.estimateBalanceStore.execute({ + ...input, + command: input.command, + }).revision; + } case projectStateRestoreCommandType: { assertProjectStateRestoreCommand(input.command); return this.projectStateRestoreStore.execute({ diff --git a/src/estimate-balance/api/estimate-balance-request.schemas.ts b/src/estimate-balance/api/estimate-balance-request.schemas.ts new file mode 100644 index 0000000..d1f1a5f --- /dev/null +++ b/src/estimate-balance/api/estimate-balance-request.schemas.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; +import { expectedProjectRevisionSchema } from "../../shared/validation/project-command.schemas.js"; + +export const createEstimateBalanceRequestSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + name: z.string().trim().min(1).max(200), + workStage: z.string().trim().max(50).nullable().optional(), + dateIso: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .nullable() + .optional(), + template: z.enum(["kg400", "empty", "copy"]), + copyFromBalanceId: z.string().trim().min(1).optional(), + }) + .strict() + .refine((input) => input.template !== "copy" || Boolean(input.copyFromBalanceId), { + message: "copyFromBalanceId is required for template copy", + path: ["copyFromBalanceId"], + }); + +export const updateEstimateBalanceRequestSchema = z + .object({ + expectedRevision: expectedProjectRevisionSchema, + // Validated in detail by parseEstimateBalanceSnapshot. + balance: z.unknown(), + }) + .strict(); + +export const deleteEstimateBalanceRequestSchema = z + .object({ expectedRevision: expectedProjectRevisionSchema }) + .strict(); diff --git a/src/estimate-balance/domain/estimate-balance-command.model.ts b/src/estimate-balance/domain/estimate-balance-command.model.ts new file mode 100644 index 0000000..44263e4 --- /dev/null +++ b/src/estimate-balance/domain/estimate-balance-command.model.ts @@ -0,0 +1,320 @@ +import crypto from "node:crypto"; +import { z } from "zod"; +import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js"; +import { + estimateDefaultGroups, + type EstimateBalanceSnapshot, + type EstimateGroupSnapshot, +} from "../../shared/estimate-balance/estimate-balance.js"; + +export const estimateBalanceInsertCommandType = "estimate-balance.insert" as const; +export const estimateBalanceDeleteCommandType = "estimate-balance.delete" as const; +export const estimateBalanceUpdateCommandType = "estimate-balance.update" as const; +export const estimateBalanceCommandSchemaVersion = 1 as const; + +const idSchema = z.string().trim().min(1).max(100); +const trimmed = (max: number) => + z.string().max(max).refine((value) => value === value.trim(), { + message: "must be trimmed", + }); +const nullableText = (max: number) => + trimmed(max) + .refine((value) => value.length > 0, { message: "use null instead of empty" }) + .nullable(); +const finite = z.number().finite(); + +export const estimateRowSnapshotSchema = z + .object({ + id: idSchema, + sortOrder: z.number().int().min(0), + supply: nullableText(50), + circuitRef: nullableText(50), + name: trimmed(200), + quantity: finite.min(0), + unit: z.enum(["piece", "area"]), + connection: z.enum(["fixed", "socket"]).nullable(), + powerPerUnitW: finite.min(0), + simultaneityFactor: finite.min(0).max(1), + cosPhi: finite.gt(0).max(1), + safetySupply: z.boolean(), + remark: nullableText(500), + }) + .strict(); + +export const estimateGroupSnapshotSchema = z + .object({ + id: idSchema, + costGroup: trimmed(20), + title: trimmed(200).refine((value) => value.length > 0, { + message: "title must not be empty", + }), + editor: nullableText(100), + sortOrder: z.number().int().min(0), + rows: z.array(estimateRowSnapshotSchema).max(1000), + }) + .strict(); + +export const estimateBalanceSnapshotSchema = z + .object({ + id: idSchema, + projectId: idSchema, + name: trimmed(200).refine((value) => value.length > 0, { + message: "name must not be empty", + }), + workStage: nullableText(50), + dateIso: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine((value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), { + message: "invalid date", + }) + .nullable(), + reserveAvPercent: finite.min(0).max(100), + reserveSvPercent: finite.min(0).max(100), + sortOrder: z.number().int().min(0), + groups: z.array(estimateGroupSnapshotSchema).max(200), + }) + .strict() + .superRefine((balance, context) => { + const ids = new Set(); + 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, + type: string, + keys: string[] +): Record { + 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; +} + +export function assertEstimateBalanceInsertProjectCommand( + command: SerializedProjectCommand +): asserts command is EstimateBalanceInsertProjectCommand { + const payload = assertCommandShape(command, estimateBalanceInsertCommandType, ["balance"]); + parseEstimateBalanceSnapshot(payload.balance); +} + +export function assertEstimateBalanceDeleteProjectCommand( + command: SerializedProjectCommand +): asserts command is EstimateBalanceDeleteProjectCommand { + const payload = assertCommandShape(command, estimateBalanceDeleteCommandType, ["balance"]); + parseEstimateBalanceSnapshot(payload.balance); +} + +export function assertEstimateBalanceUpdateProjectCommand( + command: SerializedProjectCommand +): asserts command is EstimateBalanceUpdateProjectCommand { + const payload = assertCommandShape(command, estimateBalanceUpdateCommandType, [ + "expected", + "target", + ]); + assertMatchingUpdate( + parseEstimateBalanceSnapshot(payload.expected), + parseEstimateBalanceSnapshot(payload.target) + ); +} diff --git a/src/estimate-balance/domain/estimate-balance-project-command.store.ts b/src/estimate-balance/domain/estimate-balance-project-command.store.ts new file mode 100644 index 0000000..e5f7e04 --- /dev/null +++ b/src/estimate-balance/domain/estimate-balance-project-command.store.ts @@ -0,0 +1,24 @@ +import type { + AppendedProjectRevision, + ProjectRevisionSource, +} from "../../domain/ports/project-revision.store.js"; +import type { EstimateBalanceProjectCommand } from "./estimate-balance-command.model.js"; + +export interface ExecuteEstimateBalanceCommandInput { + projectId: string; + expectedRevision: number; + source: ProjectRevisionSource; + description?: string; + actorId?: string; + historyTargetChangeSetId?: string; + command: EstimateBalanceProjectCommand; +} + +export interface ExecutedEstimateBalanceCommand { + revision: AppendedProjectRevision; + inverse: EstimateBalanceProjectCommand; +} + +export interface EstimateBalanceProjectCommandStore { + execute(input: ExecuteEstimateBalanceCommandInput): ExecutedEstimateBalanceCommand; +} diff --git a/src/frontend/components/Sidebar.tsx b/src/frontend/components/Sidebar.tsx index a94378e..395cd7c 100644 --- a/src/frontend/components/Sidebar.tsx +++ b/src/frontend/components/Sidebar.tsx @@ -17,6 +17,7 @@ const PROJECT_SECTIONS = [ // Separate project sub-routes (/projects/[id]/). const PROJECT_ROUTES = [ { segment: "struktur", label: "Gebäudestruktur", icon: "▥" }, + { segment: "grobbilanz", label: "Grobe Leistungsbilanz", icon: "Σ" }, ] as const; // Matches the project overview and all of its sub-routes (group 2 = rest). diff --git a/src/frontend/estimate-balance/estimate-balance-create-modal.tsx b/src/frontend/estimate-balance/estimate-balance-create-modal.tsx new file mode 100644 index 0000000..6d8f123 --- /dev/null +++ b/src/frontend/estimate-balance/estimate-balance-create-modal.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { type FormEvent, useState } from "react"; +import { FormModal } from "../components/form-modal"; +import type { + CreateEstimateBalanceInput, + EstimateBalanceDto, + EstimateBalanceTemplate, +} from "../types"; +import { estimateTextLimits } from "./estimate-balance-document"; +import { getTodayIsoDate } from "./estimate-balance-format"; + +export interface EstimateBalanceCreateDefaults { + name?: string; + workStage?: string | null; + template?: EstimateBalanceTemplate; + copyFromBalanceId?: string; +} + +interface EstimateBalanceCreateModalProps { + balances: EstimateBalanceDto[]; + defaults: EstimateBalanceCreateDefaults; + isSaving: boolean; + onClose: () => void; + onCreate: (input: CreateEstimateBalanceInput) => void; +} + +/** "Neuer Stand": name, LPH, date and the starting content. */ +export function EstimateBalanceCreateModal({ + balances, + defaults, + isSaving, + onClose, + onCreate, +}: EstimateBalanceCreateModalProps) { + const [name, setName] = useState(defaults.name ?? ""); + const [workStage, setWorkStage] = useState(defaults.workStage ?? ""); + const [dateIso, setDateIso] = useState(getTodayIsoDate()); + const [template, setTemplate] = useState( + defaults.template ?? "kg400" + ); + const [copyFromBalanceId, setCopyFromBalanceId] = useState( + defaults.copyFromBalanceId ?? balances[0]?.id ?? "" + ); + const isCopyInvalid = template === "copy" && !copyFromBalanceId; + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!name.trim() || isCopyInvalid) { + return; + } + onCreate({ + name: name.trim(), + workStage: workStage.trim() || null, + dateIso: dateIso || null, + template, + ...(template === "copy" ? { copyFromBalanceId } : {}), + }); + } + + return ( + +
+
+ + setName(event.target.value)} + placeholder="z. B. Entwurfsplanung" + required + value={name} + /> +
+
+ + setWorkStage(event.target.value)} + placeholder="z. B. LPH 3" + value={workStage} + /> +
+
+ + setDateIso(event.target.value)} + type="date" + value={dateIso} + /> +
+
+ + +
+ {template === "copy" ? ( +
+ + +
+ ) : null} +
+

+ {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."} +

+
+
+
+ ); +} diff --git a/src/frontend/estimate-balance/estimate-balance-document.ts b/src/frontend/estimate-balance/estimate-balance-document.ts new file mode 100644 index 0000000..0049dc2 --- /dev/null +++ b/src/frontend/estimate-balance/estimate-balance-document.ts @@ -0,0 +1,301 @@ +// Pure, immutable editing rules for one "Grobe Leistungsbilanz" document. +// The editor keeps the complete document as local draft and saves it as a +// whole (PUT), so every edit is a function draft → new draft. +import type { + EstimateBalanceSnapshot, + EstimateGroupSnapshot, + EstimateRowSnapshot, +} from "../../shared/estimate-balance/estimate-balance"; +import { createClientId } from "../utils/client-id"; + +export type MoveDirection = -1 | 1; + +/** Maximum text lengths accepted by the server. */ +export const estimateTextLimits = { + balanceName: 200, + workStage: 50, + costGroup: 20, + groupTitle: 200, + groupEditor: 100, + supply: 50, + circuitRef: 50, + rowName: 200, + remark: 500, +} as const; + +export type EstimateRowPatch = Partial>; +export type EstimateGroupPatch = Partial>; + +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(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(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(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); +} diff --git a/src/frontend/estimate-balance/estimate-balance-format.ts b/src/frontend/estimate-balance/estimate-balance-format.ts new file mode 100644 index 0000000..85cddf3 --- /dev/null +++ b/src/frontend/estimate-balance/estimate-balance-format.ts @@ -0,0 +1,122 @@ +// Pure parsing and formatting rules of the "Grobe Leistungsbilanz" editor. +// Numbers are entered and shown in German notation. As in the circuit list +// editor, only "," is accepted as decimal separator: a "." is ambiguous +// (decimal point or thousands separator) and is rejected instead of guessed. +import { roundEstimateValue } from "../../shared/estimate-balance/estimate-balance"; + +export type DecimalParseResult = + | { ok: true; value: number } + | { ok: false; error: string }; + +export interface DecimalRange { + min?: number; + /** Excludes `min` itself (e.g. cos φ > 0). */ + minExclusive?: boolean; + max?: number; +} + +const DECIMAL_PATTERN = /^[+-]?(\d+(,\d*)?|,\d+)$/; + +function formatBound(value: number) { + return value.toLocaleString("de-DE", { maximumFractionDigits: 6, useGrouping: false }); +} + +export function parseGermanDecimal(text: string, range: DecimalRange = {}): DecimalParseResult { + const trimmed = text.trim(); + if (!trimmed) { + return { ok: false, error: "Bitte einen Wert eingeben." }; + } + if (trimmed.includes(".")) { + return { ok: false, error: "Dezimalstellen mit Komma eingeben (z. B. 0,8)." }; + } + if (!DECIMAL_PATTERN.test(trimmed)) { + return { ok: false, error: "Ungültiger Zahlenwert." }; + } + const value = Number(trimmed.replace(",", ".")); + if (!Number.isFinite(value)) { + return { ok: false, error: "Ungültiger Zahlenwert." }; + } + if (range.min !== undefined) { + if (range.minExclusive ? value <= range.min : value < range.min) { + return { + ok: false, + error: range.minExclusive + ? `Der Wert muss größer als ${formatBound(range.min)} sein.` + : `Der Wert darf nicht kleiner als ${formatBound(range.min)} sein.`, + }; + } + } + if (range.max !== undefined && value > range.max) { + return { ok: false, error: `Der Wert darf nicht größer als ${formatBound(range.max)} sein.` }; + } + return { ok: true, value }; +} + +/** Value ranges accepted by the server for the numeric document fields. */ +export const estimateNumberRanges = { + quantity: { min: 0 }, + powerPerUnitW: { min: 0 }, + simultaneityFactor: { min: 0, max: 1 }, + cosPhi: { min: 0, minExclusive: true, max: 1 }, + reservePercent: { min: 0, max: 100 }, +} satisfies Record; + +/** + * 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}`; +} diff --git a/src/frontend/estimate-balance/estimate-balance-sheet.tsx b/src/frontend/estimate-balance/estimate-balance-sheet.tsx new file mode 100644 index 0000000..debba7f --- /dev/null +++ b/src/frontend/estimate-balance/estimate-balance-sheet.tsx @@ -0,0 +1,652 @@ +"use client"; + +import type { ReactNode } from "react"; +import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react"; +import { + calculateEstimateBalance, + calculateEstimateRow, + estimateRowConnectionLabels, + estimateRowUnitLabels, + type EstimateBalanceSnapshot, + type EstimateGroupSnapshot, + type EstimatePower, + type EstimateRowConnection, + type EstimateRowSnapshot, + type EstimateRowUnit, +} from "../../shared/estimate-balance/estimate-balance"; +import { + addEstimateRow, + estimateTextLimits, + moveEstimateGroup, + removeEstimateGroup, + removeEstimateRow, + updateEstimateGroup, + updateEstimateRow, + type EstimateRowPatch, +} from "./estimate-balance-document"; +import { + estimateNumberRanges, + formatAmpere, + formatDecimalInput, + formatKw, + parseGermanDecimal, + type DecimalRange, +} from "./estimate-balance-format"; +import { DraftInput } from "./estimate-draft-input"; + +export type EstimateBalanceUpdate = ( + update: (balance: EstimateBalanceSnapshot) => EstimateBalanceSnapshot +) => void; + +interface EstimateBalanceSheetProps { + balance: EstimateBalanceSnapshot; + disabled: boolean; + onAddGroup: () => void; + onChange: EstimateBalanceUpdate; + onEditGroup: (group: EstimateGroupSnapshot) => void; + onOpenGzfPicker: (groupId: string, rowId: string) => void; + onValidityChange: (fieldKey: string, isValid: boolean) => void; + threePhaseVoltageV: number; +} + +// Leading columns up to cos φ; the power columns follow. +const LEADING_COLUMN_COUNT = 10; +const COLUMN_COUNT = 17; + +function NumberInput({ + ariaLabel, + disabled, + fieldKey, + onCommit, + onValidityChange, + range, + value, +}: { + ariaLabel: string; + disabled: boolean; + fieldKey: string; + onCommit: (value: number) => void; + onValidityChange: (fieldKey: string, isValid: boolean) => void; + range: DecimalRange; + value: number; +}) { + return ( + 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 ( + <> + onChange(event.target.value)} + placeholder={placeholder} + type="text" + value={value ?? ""} + /> + {value ?? ""} + + ); +} + +function IconButton({ + children, + disabled, + label, + onClick, + variant = "secondary", +}: { + children: ReactNode; + disabled: boolean; + label: string; + onClick: () => void; + variant?: "secondary" | "danger" | "primary"; +}) { + return ( + + ); +} + +function PowerCells({ power, className = "" }: { power: EstimatePower; className?: string }) { + return ( + <> + {formatKw(power.kw)} + {formatKw(power.kva)} + + ); +} + +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 ( + + + + patch({ supply })} + placeholder="ELT" + value={row.supply} + /> + + + patch({ circuitRef })} + value={row.circuitRef} + /> + + + patch({ name })} + placeholder="Verbraucher oder Fläche" + value={row.name} + /> + + + patch({ quantity })} + onValidityChange={onValidityChange} + range={estimateNumberRanges.quantity} + value={row.quantity} + /> + + + + {estimateRowUnitLabels[row.unit]} + + + + + {row.connection ? estimateRowConnectionLabels[row.connection] : ""} + + + +
+ patch({ powerPerUnitW })} + onValidityChange={onValidityChange} + range={estimateNumberRanges.powerPerUnitW} + value={row.powerPerUnitW} + /> + {row.unit === "area" ? "W/m²" : "W"} +
+ + +
+ patch({ simultaneityFactor })} + onValidityChange={onValidityChange} + range={estimateNumberRanges.simultaneityFactor} + value={row.simultaneityFactor} + /> + +
+ + + patch({ cosPhi })} + onValidityChange={onValidityChange} + range={estimateNumberRanges.cosPhi} + value={row.cosPhi} + /> + + + + patch({ safetySupply: event.target.checked })} + type="checkbox" + /> + {row.safetySupply ? "ja" : "nein"} + + + + patch({ remark })} + value={row.remark} + /> + + + onChange((balance) => removeEstimateRow(balance, group.id, row.id))} + variant="danger" + > + + + + ); +} + +/** + * 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 ( +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {balance.groups.map((group, groupIndex) => { + const groupResult = result.groups[groupIndex]!; + const label = groupLabel(group) || "Kostengruppe"; + return ( + + + + + + + {group.rows.map((row, rowIndex) => ( + + ))} + {!group.rows.length ? ( + + + + ) : null} + + + + + + ); + })} + + + + + + + + + + + + + + + + + + + + + + + + + +
KG + Versorgung + ELT/ISP … + Stromkreis-Nr.Verbraucher / Flächen + Anzahl / Fläche + St. / m² + + Anschl. + Fest/Steckd. + + Einzelleist. + W bzw. W/m² + GL-Faktorcos φNötige AV-LeistungSicherheitsversorgung (SV)Anmerkungen +
WertEinh.kWkVAja/neinkWkVA
{group.costGroup} +
+ {group.title} + +
+
+
+ + onChange((current) => addEstimateRow(current, group.id).balance) + } + variant="primary" + > + + onEditGroup(group)} + > + + + onChange((current) => moveEstimateGroup(current, group.id, -1)) + } + > + + + onChange((current) => moveEstimateGroup(current, group.id, 1)) + } + > + + confirmRemoveGroup(group)} + variant="danger" + > + +
+
+ + Noch keine Einträge – mit „+“ eine Zeile hinzufügen. +
+ Summe {label} + + + + +
+ +
+ Gesamtsumme + + + +
+ Reserveleistung + +
+ + onChange((current) => ({ ...current, reserveAvPercent })) + } + onValidityChange={onValidityChange} + range={estimateNumberRanges.reservePercent} + value={balance.reserveAvPercent} + /> + % +
+
+
+ + onChange((current) => ({ ...current, reserveSvPercent })) + } + onValidityChange={onValidityChange} + range={estimateNumberRanges.reservePercent} + value={balance.reserveSvPercent} + /> + % +
+
+
+ Leistung inkl. Reserve + + + +
+ Maximalstrom + + {threePhaseVoltageV > 0 + ? ` (3~, U = ${formatDecimalInput(threePhaseVoltageV)} V)` + : " (Drehstromspannung im Projekt fehlt)"} + + + {formatAmpere(result.maxCurrentA.av)} A + + + {formatAmpere(result.maxCurrentA.sv)} A + +
+
+ ); +} diff --git a/src/frontend/estimate-balance/estimate-comparison-table.tsx b/src/frontend/estimate-balance/estimate-comparison-table.tsx new file mode 100644 index 0000000..f7098b0 --- /dev/null +++ b/src/frontend/estimate-balance/estimate-comparison-table.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { + roundEstimateValue, + type EstimateComparison, +} from "../../shared/estimate-balance/estimate-balance"; +import { formatDeltaPercent, formatKw, formatSignedKw } from "./estimate-balance-format"; + +function deltaClass(deltaKw: number) { + if (roundEstimateValue(deltaKw, 2) === 0) { + return "estimate-number"; + } + return deltaKw > 0 ? "estimate-number is-over" : "estimate-number is-under"; +} + +/** Estimated AV power per cost group vs. the detailed circuit-list rows. */ +export function EstimateComparisonTable({ comparison }: { comparison: EstimateComparison }) { + return ( +
+ + + + + + + + + + + + + {comparison.groups.map((group) => ( + + + + + + + + + ))} + + + + + + + + + + + + + + + + + +
KGTitelgeschätzt AV kWdetailliert kWΔ kWΔ %
{group.costGroup} + {group.title} + {group.detailedRowCount ? ( + + {group.detailedRowCount === 1 + ? "1 Gerätezeile" + : `${group.detailedRowCount} Gerätezeilen`} + + ) : null} + {formatKw(group.estimatedAv.kw)}{formatKw(group.detailed.kw)}{formatSignedKw(group.deltaKw)} + {formatDeltaPercent(group.deltaPercent)} +
+ + ohne Kostengruppe / nicht zugeordnet + {comparison.unassigned.rowCount ? ( + + {comparison.unassigned.rowCount === 1 + ? "1 Gerätezeile" + : `${comparison.unassigned.rowCount} Gerätezeilen`} + + ) : null} + –{formatKw(comparison.unassigned.kw)}––
+ Summe{formatKw(comparison.total.estimatedAv.kw)}{formatKw(comparison.total.detailed.kw)} + {formatSignedKw(comparison.total.deltaKw)} + + {formatDeltaPercent(comparison.total.deltaPercent)} +
+
+ ); +} diff --git a/src/frontend/estimate-balance/estimate-draft-input.tsx b/src/frontend/estimate-balance/estimate-draft-input.tsx new file mode 100644 index 0000000..805b264 --- /dev/null +++ b/src/frontend/estimate-balance/estimate-draft-input.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +export type DraftParseResult = { ok: true; value: T } | { ok: false; error: string }; + +interface DraftInputProps { + 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; + 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({ + ariaLabel, + className = "estimate-cell-input", + disabled, + format, + inputMode = "text", + maxLength, + onCommit, + onValidityChange, + parse, + placeholder, + value, +}: DraftInputProps) { + const [text, setText] = useState(() => format(value)); + const [error, setError] = useState(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 ( + <> + handleChange(event.target.value)} + placeholder={placeholder} + title={error ?? undefined} + type="text" + value={text} + /> + {error ? format(value) : text} + + ); +} diff --git a/src/frontend/estimate-balance/estimate-group-modal.tsx b/src/frontend/estimate-balance/estimate-group-modal.tsx new file mode 100644 index 0000000..8d56308 --- /dev/null +++ b/src/frontend/estimate-balance/estimate-group-modal.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { type FormEvent, useState } from "react"; +import { FormModal } from "../components/form-modal"; +import { + estimateDefaultGroups, + type EstimateGroupSnapshot, +} from "../../shared/estimate-balance/estimate-balance"; +import { estimateTextLimits } from "./estimate-balance-document"; + +export interface EstimateGroupFormValues { + costGroup: string; + title: string; + editor: string | null; +} + +interface EstimateGroupModalProps { + group: EstimateGroupSnapshot | null; + onClose: () => void; + onSave: (values: EstimateGroupFormValues) => void; +} + +/** Creates or edits the KG number, title and "bearbeitet von" of one group. */ +export function EstimateGroupModal({ group, onClose, onSave }: EstimateGroupModalProps) { + const [costGroup, setCostGroup] = useState(group?.costGroup ?? ""); + const [title, setTitle] = useState(group?.title ?? ""); + const [editor, setEditor] = useState(group?.editor ?? ""); + + function applyTemplate(index: string) { + const template = estimateDefaultGroups[Number(index)]; + if (template) { + setCostGroup(template.costGroup); + setTitle(template.title); + } + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!title.trim()) { + return; + } + onSave({ + costGroup: costGroup.trim(), + title: title.trim(), + editor: editor.trim() || null, + }); + } + + return ( + +
+ {!group ? ( +
+ + +
+ ) : null} +
+ + setCostGroup(event.target.value)} + placeholder="z. B. 440" + value={costGroup} + /> +
+
+ + setTitle(event.target.value)} + placeholder="z. B. Starkstromanlagen" + required + value={title} + /> +
+
+ + setEditor(event.target.value)} + placeholder="Gewerk oder Name (optional)" + value={editor} + /> +
+
+

+ Ohne KG-Nummer (z. B. „Allgemein“) wird die Gruppe im Vergleich mit der + detaillierten Bilanz keiner Kostengruppe zugeordnet. +

+
+
+
+ ); +} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 5c14f5e..11bde4a 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -535,3 +535,34 @@ export interface CreateCircuitDeviceRowInputDto { } import type { ProjectDeviceSyncField } from "../shared/constants/project-device-sync-fields"; + +// ── Grobe Leistungsbilanz (LPH 2/3) ───────────────────────────────────── +// The document shapes are shared with the API; see +// src/shared/estimate-balance/estimate-balance.ts. +import type { + EstimateBalanceSnapshot, + EstimateComparison, +} from "../shared/estimate-balance/estimate-balance"; + +export type EstimateBalanceDto = EstimateBalanceSnapshot; +export type EstimateComparisonDto = EstimateComparison; + +export type EstimateBalanceTemplate = "kg400" | "empty" | "copy"; + +export interface CreateEstimateBalanceInput { + name: string; + workStage?: string | null; + dateIso?: string | null; + template: EstimateBalanceTemplate; + /** Required for template "copy". */ + copyFromBalanceId?: string; +} + +export interface EstimateBalanceCommandResultDto extends ProjectCommandResultDto { + balance: EstimateBalanceDto; +} + +/** PUT result: either a new revision or an unchanged document. */ +export type EstimateBalanceUpdateResultDto = + | EstimateBalanceCommandResultDto + | { unchanged: true; balance: EstimateBalanceDto }; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index e1f4608..501ed01 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -38,6 +38,11 @@ import type { ExternalInitialImportPlanDto, ApplyExternalInitialImportInput, ExternalInitialImportResultDto, + CreateEstimateBalanceInput, + EstimateBalanceCommandResultDto, + EstimateBalanceDto, + EstimateBalanceUpdateResultDto, + EstimateComparisonDto, } from "../types"; import type { ExternalCsvConfiguration } from "../../external-model/csv/external-csv-contracts"; import type { ProjectDeviceSyncField } from "../../shared/constants/project-device-sync-fields"; @@ -87,6 +92,30 @@ import type { CircuitGroupSubtreeSnapshot, } from "../../domain/models/circuit-group-subtree-snapshot.model"; +/** + * Error of a failed API request. The message stays the server's German + * error text; status and optional error code allow callers to react to + * specific failures such as a revision conflict (409). + */ +export class ApiRequestError extends Error { + readonly status: number; + readonly code: string | null; + + constructor(message: string, status: number, code: string | null = null) { + super(message); + this.name = "ApiRequestError"; + this.status = status; + this.code = code; + } +} + +export function isRevisionConflictError(error: unknown) { + return ( + error instanceof ApiRequestError && + (error.status === 409 || error.code === "PROJECT_REVISION_CONFLICT") + ); +} + async function request(url: string, init?: RequestInit): Promise { const response = await fetch(url, { ...init, @@ -99,15 +128,23 @@ async function request(url: string, init?: RequestInit): Promise { if (!response.ok) { let details = await response.text(); + let code: string | null = null; try { - const parsed = JSON.parse(details) as { error?: unknown }; + const parsed = JSON.parse(details) as { error?: unknown; code?: unknown }; if (typeof parsed.error === "string") { details = parsed.error; } + if (typeof parsed.code === "string") { + code = parsed.code; + } } catch { // Keep non-JSON error responses unchanged. } - throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`); + throw new ApiRequestError( + details || `Anfrage fehlgeschlagen (Status ${response.status})`, + response.status, + code + ); } if (response.status === 204) { @@ -1135,3 +1172,66 @@ export function disconnectProjectDeviceRows( } ); } + +// ── Grobe Leistungsbilanz (LPH 2/3) ───────────────────────────────────── + +function estimateBalancesUrl(projectId: string, balanceId?: string) { + const base = `/api/projects/${projectId}/estimate-balances`; + return balanceId ? `${base}/${balanceId}` : base; +} + +export function listEstimateBalances(projectId: string) { + return request(estimateBalancesUrl(projectId)); +} + +export function getEstimateBalance(projectId: string, balanceId: string) { + return request(estimateBalancesUrl(projectId, balanceId)); +} + +export function createEstimateBalance( + projectId: string, + input: CreateEstimateBalanceInput, + expectedRevision: number +) { + return request(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( + 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(estimateBalancesUrl(projectId, balanceId), { + method: "DELETE", + body: JSON.stringify({ expectedRevision }), + }); +} + +export function getEstimateComparison(projectId: string, balanceId: string) { + return request( + `${estimateBalancesUrl(projectId, balanceId)}/comparison` + ); +} diff --git a/src/frontend/utils/project-version-history.ts b/src/frontend/utils/project-version-history.ts index a2880ec..146cda8 100644 --- a/src/frontend/utils/project-version-history.ts +++ b/src/frontend/utils/project-version-history.ts @@ -68,6 +68,9 @@ const commandTypeLabels: Record = { "external-object.unassign-and-delete-created-row": "Externes Objekt aus erzeugter Gerätezeile gelöst", "external-object.update-row-assignment": "Externe Objektzuordnung geändert", + "estimate-balance.insert": "Grobe Leistungsbilanz angelegt", + "estimate-balance.update": "Grobe Leistungsbilanz bearbeitet", + "estimate-balance.delete": "Grobe Leistungsbilanz entfernt", }; export function getProjectRevisionSourceLabel( diff --git a/src/server/composition/application-repositories.ts b/src/server/composition/application-repositories.ts index 4f9b5ee..66fdd92 100644 --- a/src/server/composition/application-repositories.ts +++ b/src/server/composition/application-repositories.ts @@ -7,6 +7,7 @@ import { CircuitProtectionDeviceRepository } from "../../db/repositories/circuit import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js"; import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js"; import { BuildingRepository } from "../../db/repositories/building.repository.js"; +import { EstimateBalanceRepository } from "../../db/repositories/estimate-balance.repository.js"; import { FloorRepository } from "../../db/repositories/floor.repository.js"; import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js"; import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; @@ -28,6 +29,7 @@ export const distributionBoardComponentRepository = export const distributionBoardRepository = new DistributionBoardRepository(db); export const buildingRepository = new BuildingRepository(db); +export const estimateBalanceRepository = new EstimateBalanceRepository(db); export const floorRepository = new FloorRepository(db); export const globalDeviceRepository = new GlobalDeviceRepository(db); export const projectDeviceRepository = new ProjectDeviceRepository(db); diff --git a/src/server/composition/project-command-stores.ts b/src/server/composition/project-command-stores.ts index 79a4a9f..8768173 100644 --- a/src/server/composition/project-command-stores.ts +++ b/src/server/composition/project-command-stores.ts @@ -27,6 +27,7 @@ import { ExternalInitialImportProjectCommandRepository } from "../../db/reposito import { ExternalObjectRowAssignmentProjectCommandRepository } from "../../db/repositories/external-object-row-assignment-project-command.repository.js"; import { ExternalObjectNewRowProjectCommandRepository } from "../../db/repositories/external-object-new-row-project-command.repository.js"; import { ExternalObjectNewCircuitProjectCommandRepository } from "../../db/repositories/external-object-new-circuit-project-command.repository.js"; +import { EstimateBalanceProjectCommandRepository } from "../../db/repositories/estimate-balance-project-command.repository.js"; export const circuitProjectCommandStore = new CircuitProjectCommandRepository(db); export const circuitDeviceRowProjectCommandStore = @@ -80,6 +81,8 @@ export const externalObjectNewRowProjectCommandStore = new ExternalObjectNewRowProjectCommandRepository(db); export const externalObjectNewCircuitProjectCommandStore = new ExternalObjectNewCircuitProjectCommandRepository(db); +export const estimateBalanceProjectCommandStore = + new EstimateBalanceProjectCommandRepository(db); export const projectCommandService = new ProjectCommandService( circuitProjectCommandStore, circuitDeviceRowProjectCommandStore, @@ -107,5 +110,6 @@ export const projectCommandService = new ProjectCommandService( externalInitialImportProjectCommandStore, externalObjectRowAssignmentProjectCommandStore, externalObjectNewRowProjectCommandStore, - externalObjectNewCircuitProjectCommandStore + externalObjectNewCircuitProjectCommandStore, + estimateBalanceProjectCommandStore ); diff --git a/src/server/controllers/estimate-balance.controller.ts b/src/server/controllers/estimate-balance.controller.ts new file mode 100644 index 0000000..a22bcb1 --- /dev/null +++ b/src/server/controllers/estimate-balance.controller.ts @@ -0,0 +1,180 @@ +import type { Request, Response } from "express"; +import { ZodError } from "zod"; +import { + createEstimateBalanceDeleteProjectCommand, + createEstimateBalanceInsertProjectCommand, + createEstimateBalanceSnapshot, + createEstimateBalanceUpdateProjectCommand, + parseEstimateBalanceSnapshot, + sameEstimateBalance, +} from "../../estimate-balance/domain/estimate-balance-command.model.js"; +import { + createEstimateBalanceRequestSchema, + deleteEstimateBalanceRequestSchema, + updateEstimateBalanceRequestSchema, +} from "../../estimate-balance/api/estimate-balance-request.schemas.js"; +import { compareEstimateWithDetailed } from "../../shared/estimate-balance/estimate-balance.js"; +import { estimateBalanceRepository } from "../composition/application-repositories.js"; +import { projectCommandService } from "../composition/project-command-stores.js"; +import { respondWithProjectCommandError } from "./project-command.controller.js"; + +function params(req: Request) { + const { projectId, balanceId } = req.params; + return { + projectId: typeof projectId === "string" ? projectId : null, + balanceId: typeof balanceId === "string" ? balanceId : null, + }; +} + +export function listEstimateBalances(req: Request, res: Response) { + const { projectId } = params(req); + if (!projectId) { + return res.status(400).json({ error: "Ungültige Projekt-ID." }); + } + return res.json(estimateBalanceRepository.listByProject(projectId)); +} + +export function getEstimateBalance(req: Request, res: Response) { + const { projectId, balanceId } = params(req); + if (!projectId || !balanceId) { + return res.status(400).json({ error: "Ungültige Parameter." }); + } + const balance = estimateBalanceRepository.findById(projectId, balanceId); + if (!balance) { + return res.status(404).json({ error: "Grobe Leistungsbilanz nicht gefunden." }); + } + return res.json(balance); +} + +export function createEstimateBalance(req: Request, res: Response) { + const { projectId } = params(req); + if (!projectId) { + return res.status(400).json({ error: "Ungültige Projekt-ID." }); + } + const parsed = createEstimateBalanceRequestSchema.safeParse(req.body); + if (!parsed.success) { + return res.status(400).json({ error: parsed.error.flatten() }); + } + let template: Parameters[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) + ) + ); +} diff --git a/src/server/routes/project.routes.ts b/src/server/routes/project.routes.ts index 0b61255..529def2 100644 --- a/src/server/routes/project.routes.ts +++ b/src/server/routes/project.routes.ts @@ -21,6 +21,14 @@ import { } from "../controllers/building.controller.js"; import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js"; import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js"; +import { + createEstimateBalance, + deleteEstimateBalance, + getEstimateBalance, + getEstimateComparison, + listEstimateBalances, + updateEstimateBalance, +} from "../controllers/estimate-balance.controller.js"; import { getCircuitTree } from "../controllers/circuit-tree.controller.js"; import { getNextCircuitIdentifier } from "../controllers/circuit.controller.js"; import { @@ -118,3 +126,12 @@ projectRouter.get("/:projectId/rooms", listRoomsByProject); projectRouter.post("/:projectId/rooms", createRoom); projectRouter.put("/:projectId/rooms/:roomId", updateRoom); projectRouter.delete("/:projectId/rooms/:roomId", deleteRoom); +projectRouter.get("/:projectId/estimate-balances", listEstimateBalances); +projectRouter.post("/:projectId/estimate-balances", createEstimateBalance); +projectRouter.get("/:projectId/estimate-balances/:balanceId", getEstimateBalance); +projectRouter.put("/:projectId/estimate-balances/:balanceId", updateEstimateBalance); +projectRouter.delete("/:projectId/estimate-balances/:balanceId", deleteEstimateBalance); +projectRouter.get( + "/:projectId/estimate-balances/:balanceId/comparison", + getEstimateComparison +); diff --git a/src/shared/estimate-balance/estimate-balance.ts b/src/shared/estimate-balance/estimate-balance.ts new file mode 100644 index 0000000..1a7b5a5 --- /dev/null +++ b/src/shared/estimate-balance/estimate-balance.ts @@ -0,0 +1,316 @@ +/** + * Grobe Leistungsbilanz (LPH 2/3): shared data shapes and pure calculation. + * + * Used by the API (validation, comparison) and by the frontend (live totals), + * so this file has no imports: Turbopack resolves frontend imports without + * the `.js` suffix the API build needs. + */ + +export type EstimateRowUnit = "piece" | "area"; +export type EstimateRowConnection = "fixed" | "socket"; + +export interface EstimateRowSnapshot { + id: string; + sortOrder: number; + /** Versorgung, e.g. "ELT" or "ISP". */ + supply: string | null; + /** Optional circuit reference, free text. */ + circuitRef: string | null; + /** Verbraucher / Fläche. May be empty while a row is being typed. */ + name: string; + /** Anzahl (unit "piece") or area in m² (unit "area"). */ + quantity: number; + unit: EstimateRowUnit; + connection: EstimateRowConnection | null; + /** W per piece or W/m². */ + powerPerUnitW: number; + simultaneityFactor: number; + cosPhi: number; + /** Also needed on the safety power supply (SV). */ + safetySupply: boolean; + remark: string | null; +} + +export interface EstimateGroupSnapshot { + id: string; + /** DIN 276 cost group, e.g. "410" or "434"; empty for "Allgemein". */ + costGroup: string; + title: string; + /** Trade or person responsible for the entries ("bearbeitet von"). */ + editor: string | null; + sortOrder: number; + rows: EstimateRowSnapshot[]; +} + +export interface EstimateBalanceSnapshot { + id: string; + projectId: string; + /** Stand, e.g. "Vorplanung" or "Entwurfsplanung". */ + name: string; + workStage: string | null; + /** ISO date YYYY-MM-DD. */ + dateIso: string | null; + reserveAvPercent: number; + reserveSvPercent: number; + sortOrder: number; + groups: EstimateGroupSnapshot[]; +} + +/** Default structure of the office template "Leistungsbilanz - KG400". */ +export const estimateDefaultGroups: ReadonlyArray<{ costGroup: string; title: string }> = [ + { costGroup: "410", title: "Sanitärtechnik" }, + { costGroup: "420", title: "Wärmeversorgung" }, + { costGroup: "430", title: "Lufttechnische Anlagen" }, + { costGroup: "434", title: "Kälteanlagen" }, + { costGroup: "440", title: "Starkstromanlagen" }, + { costGroup: "450", title: "Schwachstromanlagen" }, + { costGroup: "460", title: "Fördertechnik" }, + { costGroup: "470", title: "Nutzungsspezifische Anlagen" }, + { costGroup: "480", title: "Gebäudeautomation" }, + { costGroup: "", title: "Allgemein" }, +]; + +export const estimateRowUnitLabels: Record = { + piece: "St.", + area: "m²", +}; + +export const estimateRowConnectionLabels: Record = { + 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, + threePhaseVoltageV: number +): EstimateBalanceResult { + const groups = balance.groups.map((group) => + group.rows.reduce( + (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, + detailedRows: readonly DetailedDeviceRowPower[] +): EstimateComparison { + const estimate = calculateEstimateBalance(balance, 0); + const codes = balance.groups.map((group) => group.costGroup).filter(Boolean); + const detailedByCode = new Map(); + 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(); + 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), + }, + }; +} diff --git a/tests/estimate-balance-frontend.test.ts b/tests/estimate-balance-frontend.test.ts new file mode 100644 index 0000000..fa9368d --- /dev/null +++ b/tests/estimate-balance-frontend.test.ts @@ -0,0 +1,435 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import type { EstimateBalanceSnapshot } from "../src/shared/estimate-balance/estimate-balance.js"; +import { + addEstimateGroup, + addEstimateRow, + createCopyName, + createEstimateRow, + estimateBalanceFingerprint, + moveEstimateGroup, + moveEstimateRow, + normalizeEstimateSortOrder, + removeEstimateGroup, + removeEstimateRow, + toSaveableEstimateBalance, + updateEstimateGroup, + updateEstimateRow, + validateEstimateBalance, +} from "../src/frontend/estimate-balance/estimate-balance-document.js"; +import { + estimateNumberRanges, + formatAmpere, + formatDecimalInput, + formatDeltaPercent, + formatIsoDateGerman, + formatKw, + formatSignedKw, + getTodayIsoDate, + parseGermanDecimal, +} from "../src/frontend/estimate-balance/estimate-balance-format.js"; +import { + ApiRequestError, + createEstimateBalance, + deleteEstimateBalance, + getEstimateBalance, + getEstimateComparison, + isRevisionConflictError, + listEstimateBalances, + updateEstimateBalance, +} from "../src/frontend/utils/api.js"; + +function sequentialIds(prefix = "id") { + let next = 0; + return () => `${prefix}-${++next}`; +} + +function sampleBalance(): EstimateBalanceSnapshot { + return { + id: "balance-1", + projectId: "project-1", + name: "Entwurfsplanung", + workStage: "LPH 3", + dateIso: "2026-09-26", + reserveAvPercent: 20, + reserveSvPercent: 10, + sortOrder: 0, + groups: [ + { + id: "group-410", + costGroup: "410", + title: "Sanitärtechnik", + editor: null, + sortOrder: 0, + rows: [ + { ...createEstimateRow(0, () => "row-a"), name: "Hebeanlage", powerPerUnitW: 1200 }, + { ...createEstimateRow(1, () => "row-b"), name: "Durchlauferhitzer" }, + ], + }, + { + id: "group-440", + costGroup: "440", + title: "Starkstromanlagen", + editor: "ELT", + sortOrder: 1, + rows: [], + }, + ], + }; +} + +describe("estimate balance number input", () => { + it("accepts a comma as decimal separator", () => { + assert.deepEqual(parseGermanDecimal("0,8"), { ok: true, value: 0.8 }); + assert.deepEqual(parseGermanDecimal(" 1200 "), { ok: true, value: 1200 }); + assert.deepEqual(parseGermanDecimal(",5"), { ok: true, value: 0.5 }); + assert.deepEqual(parseGermanDecimal("3,"), { ok: true, value: 3 }); + }); + + it("rejects dots, empty input and garbage", () => { + for (const text of ["0.8", "1.200", "", " ", "abc", "1,2,3", "1e3", "--1"]) { + assert.equal(parseGermanDecimal(text).ok, false, text); + } + const dot = parseGermanDecimal("0.8"); + assert.equal(dot.ok === false && dot.error.includes("Komma"), true); + }); + + it("checks the server ranges of the document fields", () => { + assert.equal(parseGermanDecimal("-1", estimateNumberRanges.quantity).ok, false); + assert.equal(parseGermanDecimal("0", estimateNumberRanges.quantity).ok, true); + assert.equal(parseGermanDecimal("1,1", estimateNumberRanges.simultaneityFactor).ok, false); + assert.equal(parseGermanDecimal("1", estimateNumberRanges.simultaneityFactor).ok, true); + assert.equal(parseGermanDecimal("0", estimateNumberRanges.cosPhi).ok, false); + assert.equal(parseGermanDecimal("0,01", estimateNumberRanges.cosPhi).ok, true); + assert.equal(parseGermanDecimal("101", estimateNumberRanges.reservePercent).ok, false); + }); + + it("formats input text so it parses back unchanged", () => { + assert.equal(formatDecimalInput(1200), "1200"); + assert.equal(formatDecimalInput(0.8), "0,8"); + const parsed = parseGermanDecimal(formatDecimalInput(12345.678)); + assert.deepEqual(parsed, { ok: true, value: 12345.678 }); + }); +}); + +describe("estimate balance display format", () => { + it("rounds half away from zero like Excel", () => { + assert.equal(formatKw(40.175), "40,18"); + assert.equal(formatKw(30.24), "30,24"); + assert.equal(formatKw(1234.5), "1.234,50"); + assert.equal(formatKw(-0.001), "0,00"); + assert.equal(formatAmpere(57.75), "57,75"); + assert.equal(formatAmpere(235.749), "235,75"); + }); + + it("shows signed deltas and percentages", () => { + assert.equal(formatSignedKw(1.5), "+1,50"); + assert.equal(formatSignedKw(-1.825), "-1,83"); + assert.equal(formatSignedKw(0.001), "0,00"); + assert.equal(formatDeltaPercent(12.34), "+12,3 %"); + assert.equal(formatDeltaPercent(-5), "-5,0 %"); + assert.equal(formatDeltaPercent(null), "–"); + }); + + it("formats dates", () => { + assert.equal(formatIsoDateGerman("2026-09-26"), "26.09.2026"); + assert.equal(formatIsoDateGerman(null), "–"); + assert.equal(getTodayIsoDate(new Date(2026, 0, 5, 23, 30)), "2026-01-05"); + }); +}); + +describe("estimate balance document editing", () => { + it("adds rows at the end or after a row with continuous sort orders", () => { + const createId = sequentialIds("new"); + const appended = addEstimateRow(sampleBalance(), "group-410", { createId }); + assert.equal(appended.rowId, "new-1"); + assert.deepEqual( + appended.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]), + [["row-a", 0], ["row-b", 1], ["new-1", 2]] + ); + const inserted = addEstimateRow(sampleBalance(), "group-410", { + afterRowId: "row-a", + createId, + }); + assert.deepEqual( + inserted.balance.groups[0]!.rows.map((row) => [row.id, row.sortOrder]), + [["row-a", 0], ["new-2", 1], ["row-b", 2]] + ); + const unknownGroup = addEstimateRow(sampleBalance(), "missing", { createId }); + assert.equal(unknownGroup.rowId, null); + }); + + it("creates rows with neutral defaults", () => { + const row = createEstimateRow(3, () => "row-x"); + assert.deepEqual(row, { + id: "row-x", + sortOrder: 3, + supply: null, + circuitRef: null, + name: "", + quantity: 1, + unit: "piece", + connection: null, + powerPerUnitW: 0, + simultaneityFactor: 1, + cosPhi: 1, + safetySupply: false, + remark: null, + }); + }); + + it("updates, removes and moves rows without touching the input", () => { + const original = sampleBalance(); + const updated = updateEstimateRow(original, "group-410", "row-b", { + quantity: 3, + safetySupply: true, + }); + assert.equal(updated.groups[0]!.rows[1]!.quantity, 3); + assert.equal(updated.groups[0]!.rows[1]!.safetySupply, true); + assert.equal(original.groups[0]!.rows[1]!.quantity, 1); + + const removed = removeEstimateRow(original, "group-410", "row-a"); + assert.deepEqual( + removed.groups[0]!.rows.map((row) => [row.id, row.sortOrder]), + [["row-b", 0]] + ); + + const moved = moveEstimateRow(original, "group-410", "row-b", -1); + assert.deepEqual( + moved.groups[0]!.rows.map((row) => [row.id, row.sortOrder]), + [["row-b", 0], ["row-a", 1]] + ); + assert.equal(moveEstimateRow(original, "group-410", "row-a", -1), original); + }); + + it("adds, edits, moves and removes groups", () => { + const added = addEstimateGroup( + sampleBalance(), + { costGroup: "480", title: "Gebäudeautomation" }, + () => "group-480" + ); + assert.equal(added.groupId, "group-480"); + assert.deepEqual( + added.balance.groups.map((group) => [group.id, group.sortOrder]), + [["group-410", 0], ["group-440", 1], ["group-480", 2]] + ); + assert.equal(added.balance.groups[2]!.editor, null); + + const edited = updateEstimateGroup(added.balance, "group-480", { + costGroup: "481", + editor: "MSR", + }); + assert.equal(edited.groups[2]!.costGroup, "481"); + assert.equal(edited.groups[2]!.title, "Gebäudeautomation"); + + const movedUp = moveEstimateGroup(edited, "group-480", -1); + assert.deepEqual( + movedUp.groups.map((group) => [group.id, group.sortOrder]), + [["group-410", 0], ["group-480", 1], ["group-440", 2]] + ); + assert.equal(moveEstimateGroup(movedUp, "group-440", 1), movedUp); + + const removed = removeEstimateGroup(movedUp, "group-410"); + assert.deepEqual( + removed.groups.map((group) => [group.id, group.sortOrder]), + [["group-480", 0], ["group-440", 1]] + ); + assert.equal(removeEstimateGroup(removed, "missing"), removed); + }); + + it("orders loaded documents by sortOrder", () => { + const balance = sampleBalance(); + const shuffled: EstimateBalanceSnapshot = { + ...balance, + groups: [ + { ...balance.groups[1]!, sortOrder: 7 }, + { + ...balance.groups[0]!, + sortOrder: 2, + rows: [ + { ...balance.groups[0]!.rows[1]!, sortOrder: 5 }, + { ...balance.groups[0]!.rows[0]!, sortOrder: 1 }, + ], + }, + ], + }; + const normalized = normalizeEstimateSortOrder(shuffled); + assert.deepEqual( + normalized.groups.map((group) => [group.id, group.sortOrder]), + [["group-410", 0], ["group-440", 1]] + ); + assert.deepEqual( + normalized.groups[0]!.rows.map((row) => [row.id, row.sortOrder]), + [["row-a", 0], ["row-b", 1]] + ); + }); +}); + +describe("estimate balance saving", () => { + it("builds the trimmed server document with null for empty optional texts", () => { + let balance = sampleBalance(); + balance = { ...balance, name: " Entwurf ", workStage: " ", dateIso: "" }; + balance = updateEstimateGroup(balance, "group-440", { title: " Starkstrom ", editor: " " }); + balance = updateEstimateRow(balance, "group-410", "row-a", { + name: " Hebeanlage ", + supply: " ELT ", + circuitRef: "", + remark: " ", + }); + const saveable = toSaveableEstimateBalance(balance); + assert.equal(saveable.name, "Entwurf"); + assert.equal(saveable.workStage, null); + assert.equal(saveable.dateIso, null); + assert.equal(saveable.groups[1]!.title, "Starkstrom"); + assert.equal(saveable.groups[1]!.editor, null); + assert.deepEqual( + { + name: saveable.groups[0]!.rows[0]!.name, + supply: saveable.groups[0]!.rows[0]!.supply, + circuitRef: saveable.groups[0]!.rows[0]!.circuitRef, + remark: saveable.groups[0]!.rows[0]!.remark, + }, + { name: "Hebeanlage", supply: "ELT", circuitRef: null, remark: null } + ); + }); + + it("copies only snapshot fields", () => { + const balance = sampleBalance() as EstimateBalanceSnapshot & { uiState?: string }; + balance.uiState = "leak"; + assert.equal("uiState" in toSaveableEstimateBalance(balance), false); + }); + + it("treats whitespace-only changes as unchanged", () => { + const balance = sampleBalance(); + const withSpace = updateEstimateRow(balance, "group-410", "row-a", { + name: "Hebeanlage ", + }); + assert.equal(estimateBalanceFingerprint(withSpace), estimateBalanceFingerprint(balance)); + const changed = updateEstimateRow(balance, "group-410", "row-a", { quantity: 2 }); + assert.notEqual(estimateBalanceFingerprint(changed), estimateBalanceFingerprint(balance)); + }); + + it("reports documents the server would reject", () => { + assert.deepEqual(validateEstimateBalance(sampleBalance()), []); + const invalid = updateEstimateGroup( + { ...sampleBalance(), name: " " }, + "group-440", + { title: " " } + ); + assert.equal(validateEstimateBalance(invalid).length, 2); + }); + + it("names copies", () => { + assert.equal(createCopyName("Entwurfsplanung "), "Entwurfsplanung (Kopie)"); + assert.equal(createCopyName("x".repeat(200)).length, 200); + }); +}); + +describe("estimate balance frontend API", () => { + it("calls the estimate balance endpoints", async () => { + const requests: Array<{ url: string; method: string; body: unknown; keepalive?: boolean }> = + []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input, init) => { + requests.push({ + url: String(input), + method: init?.method ?? "GET", + body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), + ...(init?.keepalive ? { keepalive: true } : {}), + }); + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }; + try { + const balance = sampleBalance(); + await listEstimateBalances("project-1"); + await getEstimateBalance("project-1", "balance-1"); + await createEstimateBalance( + "project-1", + { name: "Vorplanung", workStage: null, dateIso: "2026-09-26", template: "kg400" }, + 4 + ); + await createEstimateBalance( + "project-1", + { name: "Kopie", template: "copy", copyFromBalanceId: "balance-1" }, + 5 + ); + await updateEstimateBalance("project-1", balance, 6); + await updateEstimateBalance("project-1", balance, 7, { keepalive: true }); + await deleteEstimateBalance("project-1", "balance-1", 8); + await getEstimateComparison("project-1", "balance-1"); + const base = "/api/projects/project-1/estimate-balances"; + assert.deepEqual(requests, [ + { url: base, method: "GET", body: undefined }, + { url: `${base}/balance-1`, method: "GET", body: undefined }, + { + url: base, + method: "POST", + body: { + name: "Vorplanung", + workStage: null, + dateIso: "2026-09-26", + template: "kg400", + expectedRevision: 4, + }, + }, + { + url: base, + method: "POST", + body: { + name: "Kopie", + template: "copy", + copyFromBalanceId: "balance-1", + expectedRevision: 5, + }, + }, + { + url: `${base}/balance-1`, + method: "PUT", + body: { expectedRevision: 6, balance }, + }, + { + url: `${base}/balance-1`, + method: "PUT", + body: { expectedRevision: 7, balance }, + keepalive: true, + }, + { url: `${base}/balance-1`, method: "DELETE", body: { expectedRevision: 8 } }, + { url: `${base}/balance-1/comparison`, method: "GET", body: undefined }, + ]); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("exposes status and code of failed requests", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response( + JSON.stringify({ + error: "Projektstand veraltet.", + code: "PROJECT_REVISION_CONFLICT", + }), + { status: 409, headers: { "Content-Type": "application/json" } } + ); + try { + await assert.rejects( + updateEstimateBalance("project-1", sampleBalance(), 1), + (error: unknown) => { + assert.ok(error instanceof ApiRequestError); + assert.equal(error.message, "Projektstand veraltet."); + assert.equal(error.status, 409); + assert.equal(error.code, "PROJECT_REVISION_CONFLICT"); + assert.equal(isRevisionConflictError(error), true); + return true; + } + ); + } finally { + globalThis.fetch = originalFetch; + } + assert.equal(isRevisionConflictError(new ApiRequestError("x", 400)), false); + assert.equal(isRevisionConflictError(new Error("x")), false); + }); +}); diff --git a/tests/estimate-balance.test.ts b/tests/estimate-balance.test.ts new file mode 100644 index 0000000..7d294da --- /dev/null +++ b/tests/estimate-balance.test.ts @@ -0,0 +1,459 @@ +import path from "node:path"; +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { + createDatabaseContext, + type DatabaseContext, +} from "../src/db/database-context.js"; +import { EstimateBalanceProjectCommandRepository } from "../src/db/repositories/estimate-balance-project-command.repository.js"; +import { EstimateBalanceRepository } from "../src/db/repositories/estimate-balance.repository.js"; +import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js"; +import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js"; +import { circuitLists } from "../src/db/schema/circuit-lists.js"; +import { circuitSections } from "../src/db/schema/circuit-sections.js"; +import { circuits } from "../src/db/schema/circuits.js"; +import { distributionBoards } from "../src/db/schema/distribution-boards.js"; +import { estimateRows } from "../src/db/schema/estimate-balances.js"; +import { projects } from "../src/db/schema/projects.js"; +import { + createEstimateBalanceDeleteProjectCommand, + createEstimateBalanceInsertProjectCommand, + createEstimateBalanceSnapshot, + createEstimateBalanceUpdateProjectCommand, + parseEstimateBalanceSnapshot, + type EstimateBalanceProjectCommand, +} from "../src/estimate-balance/domain/estimate-balance-command.model.js"; +import { createEstimateBalanceRequestSchema } from "../src/estimate-balance/api/estimate-balance-request.schemas.js"; +import { + calculateEstimateBalance, + calculateEstimateRow, + compareEstimateWithDetailed, + estimateDefaultGroups, + matchEstimateCostGroup, + roundEstimateValue, + type EstimateBalanceSnapshot, + type EstimateRowSnapshot, +} from "../src/shared/estimate-balance/estimate-balance.js"; + +const round2 = (value: number) => roundEstimateValue(value, 2); +let nextId = 0; +const id = () => `id-${++nextId}`; + +function row( + name: string, + quantity: number, + powerPerUnitW: number, + simultaneityFactor: number, + cosPhi: number, + extra: Partial = {} +): EstimateRowSnapshot { + 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 = { + "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).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(); + } + }); +}); From e06c7d91e2fff2c764cf19b03a7cb88e73ce90ed Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Sat, 26 Sep 2026 17:48:37 +0200 Subject: [PATCH 2/4] Print the rough power balance in the kbp design The print of the rough power balance was plain grey. It now follows the kbp design of the print view: a title block with office logo, stand, project, number, owner, address, work stage, date, editor and office; header row in kbp blue, cost-group rows tinted with a lilac rule, bold subtotals, the total including reserve as a blue band, colored deviations in the comparison, Arial, exact print colors, and a footer with project, stand and "Seite x von y" on every page. Co-Authored-By: Claude Opus 5.5 --- src/app/globals.css | 210 ++++++++++++++++-- .../grobbilanz/[balanceId]/page.tsx | 2 + .../estimate-print-header.tsx | 104 +++++++++ 3 files changed, 292 insertions(+), 24 deletions(-) create mode 100644 src/frontend/estimate-balance/estimate-print-header.tsx diff --git a/src/app/globals.css b/src/app/globals.css index b3f526a..4a0adc8 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1965,14 +1965,23 @@ button.structure-entry-main:focus-visible { display: none; } +/* Druck im kbp-CD der Seitenansicht (src/frontend/print/print-document.css): + gleiche Farben, Schrift, Titelblock und Fußzeile. Die Fußzeile mit + Seitenzahlen setzt EstimatePrintHeader als @page-Regel mit Projektdaten. */ +.estimate-print-titleblock { + display: none; +} + @page estimate-balance { size: A4 landscape; - margin: 10mm; + margin: 10mm 10mm 13mm; } @media print { body:has(.estimate-print-root) .sidebar, .estimate-print-root .estimate-no-print, + .estimate-print-root .estimate-page-header, + .estimate-print-root .estimate-header-fields, .estimate-print-root .alert, .estimate-print-root input, .estimate-print-root select, @@ -1989,28 +1998,122 @@ button.structure-entry-main:focus-visible { } .estimate-print-root { + --ep-blue: #476088; + --ep-lilac: #9198c4; + --ep-line: #d6dae5; + --ep-tint: #f5f6fa; + --ep-text: #2b2b2b; + --ep-muted: #6b7280; + --ep-accent: #c9782a; page: estimate-balance; padding: 0 !important; max-width: none; - color: #000; + color: var(--ep-text); + font-family: Arial, "Helvetica Neue", Helvetica, sans-serif; + color-scheme: light; + } + + .estimate-print-root * { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; } .estimate-print-root .estimate-print-value { display: inline; } - .estimate-print-root .page-header { + /* Titelblock wie der Plankopf der Seitenansicht */ + .estimate-print-titleblock { + display: grid; + grid-template-columns: 30mm 1fr auto auto; margin-bottom: 4mm; + border: 1.5px solid var(--ep-blue); + background: #fff; + font-size: 8.5pt; + break-inside: avoid; } - .estimate-print-root .page-header h1 { - font-size: 14pt; - color: #000; + .estimate-print-titleblock > * { + padding: 2.5mm 3.5mm; + border-left: 1px solid var(--ep-line); } + .estimate-print-titleblock > :first-child { + border-left: none; + } + + .estimate-print-logo { + display: flex; + align-items: center; + justify-content: center; + min-height: 16mm; + color: var(--ep-blue); + font-weight: bold; + text-align: center; + } + + .estimate-print-logo img { + max-width: 100%; + max-height: 10mm; + } + + .estimate-print-kicker { + color: var(--ep-muted); + font-size: 7pt; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + .estimate-print-heading { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 1mm 4mm; + margin-top: 1mm; + } + + .estimate-print-stand { + color: var(--ep-blue); + font-size: 16pt; + font-weight: bold; + } + + .estimate-print-project { + font-size: 12pt; + font-weight: bold; + } + + .estimate-print-meta { + display: grid; + grid-template-columns: auto auto; + gap: 0.8mm 4mm; + align-content: center; + margin: 0; + font-size: 7.5pt; + } + + .estimate-print-meta div { + display: contents; + } + + .estimate-print-meta dt { + color: var(--ep-muted); + font-weight: normal; + } + + .estimate-print-meta dd { + margin: 0; + font-weight: bold; + } + + .estimate-print-meta.is-tinted { + background: var(--ep-tint); + } + + /* Karten und Ränder der Bildschirmansicht entfallen */ .estimate-print-root .card { border: 0; - box-shadow: none; + box-shadow: none !important; background: transparent; } @@ -2026,54 +2129,117 @@ button.structure-entry-main:focus-visible { break-inside: avoid; } + .estimate-print-root .estimate-comparison-card .card-header { + margin-bottom: 2mm; + color: var(--ep-blue); + font-size: 10pt; + font-weight: bold; + } + .estimate-sheet-wrap { overflow: visible; } + /* Tabelle */ .estimate-sheet { min-width: 0; table-layout: auto; - font-size: 7.5pt; + font-size: 7.4pt; + line-height: 1.2; background: #fff; - color: #000; + color: var(--ep-text); } .estimate-sheet th, .estimate-sheet td { - border-color: #999; - padding: 1px 3px; + border: 1px solid var(--ep-line) !important; + padding: 0.7mm 1.5mm; + background: #fff; + color: var(--ep-text); } .estimate-sheet thead { display: table-header-group; } - .estimate-sheet thead th, - .estimate-total-row td { - background: #e8e8e8 !important; + .estimate-sheet thead th { + background: var(--ep-blue) !important; + border-color: var(--ep-blue) !important; + color: #fff !important; + font-weight: bold; + vertical-align: bottom; + } + + .estimate-sheet thead .estimate-th-hint { + color: #dfe3f0 !important; } .estimate-group-title-row td { - background: #d9d9d9 !important; + background: var(--ep-tint) !important; + border-top: 1.5px solid var(--ep-lilac) !important; + font-weight: bold; + } + + .estimate-group-title-row .estimate-cost-group-cell { + color: var(--ep-blue) !important; + } + + .estimate-group-title-row .estimate-group-editor { + color: var(--ep-muted); + font-weight: normal; } .estimate-subtotal-row td { - background: #f2f2f2 !important; + border-bottom: 1.5px solid var(--ep-line) !important; + font-weight: bold; + } + + .estimate-subtotal-row .estimate-total-label { + color: var(--ep-muted); + } + + .estimate-total-row td { + background: var(--ep-tint) !important; + font-weight: bold; + } + + .estimate-total-row.is-strong td { + background: var(--ep-blue) !important; + border-color: var(--ep-blue) !important; + color: #fff !important; + } + + .estimate-total-row .estimate-total-hint { + color: var(--ep-muted); + font-weight: normal; + } + + .estimate-total-row.is-strong .estimate-total-hint { + color: #dfe3f0; + } + + .estimate-comparison .is-over { + color: var(--ep-accent) !important; + font-weight: bold; + } + + .estimate-comparison .is-under { + color: var(--ep-blue) !important; + font-weight: bold; } .estimate-group-title-row, .estimate-subtotal-row, - .estimate-total-row { + .estimate-total-row, + .estimate-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. */ + /* Summen einmal am Ende, nicht auf jeder Seite */ .estimate-sheet tfoot { display: table-row-group; break-inside: avoid; @@ -2087,8 +2253,4 @@ button.structure-entry-main:focus-visible { .estimate-gzf-input { justify-content: flex-end; } - - .estimate-header-fields { - margin-bottom: 3mm; - } } diff --git a/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx b/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx index 2aa7f5e..584f5cf 100644 --- a/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx +++ b/src/app/projects/[projectId]/grobbilanz/[balanceId]/page.tsx @@ -48,6 +48,7 @@ import { } from "../../../../../frontend/estimate-balance/estimate-balance-sheet"; import { EstimateComparisonTable } from "../../../../../frontend/estimate-balance/estimate-comparison-table"; import { DraftInput } from "../../../../../frontend/estimate-balance/estimate-draft-input"; +import { EstimatePrintHeader } from "../../../../../frontend/estimate-balance/estimate-print-header"; import { EstimateGroupModal, type EstimateGroupFormValues, @@ -461,6 +462,7 @@ export default function EstimateBalanceEditorPage() { return (
+ {project && draft ? : null}
\r\n]/g, " ")); +} + +/** + * Print-only title block and page footer of the rough power balance in the + * kbp corporate design of the print view (Seitenansicht): logo, blue title, + * meta data, "Seite x von y". Hidden on screen. + */ +export function EstimatePrintHeader({ project, balance }: EstimatePrintHeaderProps) { + const [office, setOffice] = useState(null); + const [hasLogo, setHasLogo] = useState(false); + const [logoVersion, setLogoVersion] = useState(""); + + useEffect(() => { + getDocumentBranding() + .then((branding) => { + setOffice(branding.office); + setHasLogo(Boolean(branding.logo)); + setLogoVersion(branding.logo ? branding.logo.updatedAtIso : ""); + }) + .catch(() => { + setOffice(null); + setHasLogo(false); + }); + }, []); + + const stand = [balance.name, balance.workStage].filter(Boolean).join(" · "); + const date = balance.dateIso ? formatIsoDateGerman(balance.dateIso) : null; + const footer = ` + @page estimate-balance { + size: A4 landscape; + margin: 10mm 10mm 13mm; + @bottom-left { content: ${cssString(`${project.name} · Grobe Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; } + @bottom-center { content: ${cssString(`Stand ${stand}${date ? ` · ${date}` : ""}`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; } + @bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 7.5pt Arial, sans-serif; color: #6b7280; } + }`; + + return ( + <> + + + + ); +} From 72189d23a50b12ade4672bd6901d2c78626710f2 Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Sat, 26 Sep 2026 17:56:53 +0200 Subject: [PATCH 3/4] Offer supply suggestions, drop the circuit column, center cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Versorgung field suggests ELT and MSR (plus values already used in the balance) through a datalist and still accepts free text. The circuit number column is not needed in early design phases; it is removed from the data model, the regenerated migration 0010, the editor and the print. All cells are centered except "Verbraucher / Flächen", group titles and total labels. Co-Authored-By: Claude Opus 5.5 --- docs/estimate-balance.md | 2 +- src/app/globals.css | 33 +++++++++++++++- src/db/migrations/0010_estimate_balances.sql | 1 - src/db/migrations/meta/0010_snapshot.json | 9 +---- src/db/migrations/meta/_journal.json | 2 +- src/db/schema/estimate-balances.ts | 1 - .../domain/estimate-balance-command.model.ts | 2 - .../estimate-balance-document.ts | 3 -- .../estimate-balance-sheet.tsx | 39 ++++++++++++------- .../estimate-balance/estimate-balance.ts | 4 +- tests/estimate-balance-frontend.test.ts | 5 +-- tests/estimate-balance.test.ts | 1 - 12 files changed, 60 insertions(+), 42 deletions(-) diff --git a/docs/estimate-balance.md b/docs/estimate-balance.md index 4e43cec..afe4780 100644 --- a/docs/estimate-balance.md +++ b/docs/estimate-balance.md @@ -34,7 +34,7 @@ das Frontend sie direkt nutzt (Turbopack löst die `.js`-Suffixe der API nicht a |---|---| | `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 | +| `estimate_rows` | Versorgung (Auswahl ELT/MSR oder Freitext), Verbraucher/Fläche, Anzahl bzw. Fläche, Einheit (`piece` = St., `area` = m²), Anschluss (fest/Steckdose), Einzelleistung in W bzw. W/m², GLF, cos φ, SV ja/nein, Anmerkung | Neue Stände entstehen aus der Vorlage KG 400 (410 Sanitär, 420 Wärme, 430 Luft, 434 Kälte, 440 Starkstrom, 450 Schwachstrom, 460 Förder, 470 Nutzungsspezifisch, diff --git a/src/app/globals.css b/src/app/globals.css index 4a0adc8..4f0bcb7 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1731,7 +1731,6 @@ button.structure-entry-main:focus-visible { .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; } @@ -1965,6 +1964,36 @@ button.structure-entry-main:focus-visible { display: none; } +/* Ausrichtung: alle Zellen zentriert, nur „Verbraucher / Flächen“, die + Gruppentitel und die Summenbeschriftungen stehen links bzw. rechts. */ +.estimate-sheet td, +.estimate-sheet td.estimate-number, +.estimate-sheet .estimate-cell-input, +.estimate-sheet .estimate-cell-input.is-numeric { + text-align: center; +} + +.estimate-sheet .estimate-cell-select { + text-align: center; + text-align-last: center; +} + +.estimate-sheet .estimate-unit-input, +.estimate-sheet .estimate-gzf-input { + justify-content: center; +} + +.estimate-sheet td.estimate-name-cell, +.estimate-sheet td.estimate-name-cell .estimate-cell-input, +.estimate-sheet .estimate-group-title-row td:not(.estimate-cost-group-cell), +.estimate-comparison td:nth-child(2) { + text-align: left; +} + +.estimate-sheet td.estimate-total-label { + text-align: right; +} + /* Druck im kbp-CD der Seitenansicht (src/frontend/print/print-document.css): gleiche Farben, Schrift, Titelblock und Fußzeile. Die Fußzeile mit Seitenzahlen setzt EstimatePrintHeader als @page-Regel mit Projektdaten. */ @@ -2251,6 +2280,6 @@ button.structure-entry-main:focus-visible { .estimate-unit-input, .estimate-gzf-input { - justify-content: flex-end; + justify-content: center; } } diff --git a/src/db/migrations/0010_estimate_balances.sql b/src/db/migrations/0010_estimate_balances.sql index 645ccbe..bfa9dd3 100644 --- a/src/db/migrations/0010_estimate_balances.sql +++ b/src/db/migrations/0010_estimate_balances.sql @@ -27,7 +27,6 @@ CREATE TABLE `estimate_rows` ( `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, diff --git a/src/db/migrations/meta/0010_snapshot.json b/src/db/migrations/meta/0010_snapshot.json index 869d504..d58518a 100644 --- a/src/db/migrations/meta/0010_snapshot.json +++ b/src/db/migrations/meta/0010_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "6df688bd-a78a-46a1-ba7a-4068119bf8bd", + "id": "5376ae9f-67c7-4603-b3d2-8527136436f9", "prevId": "2cd9adef-2f08-4635-b3d9-ecf71f624c7f", "tables": { "buildings": { @@ -1322,13 +1322,6 @@ "notNull": false, "autoincrement": false }, - "circuit_ref": { - "name": "circuit_ref", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, "name": { "name": "name", "type": "text", diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 209a0a2..f2c5dba 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -75,7 +75,7 @@ { "idx": 10, "version": "6", - "when": 1790425784626, + "when": 1790438092269, "tag": "0010_estimate_balances", "breakpoints": true } diff --git a/src/db/schema/estimate-balances.ts b/src/db/schema/estimate-balances.ts index 81420f1..4425a03 100644 --- a/src/db/schema/estimate-balances.ts +++ b/src/db/schema/estimate-balances.ts @@ -44,7 +44,6 @@ export const estimateRows = sqliteTable( .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"), diff --git a/src/estimate-balance/domain/estimate-balance-command.model.ts b/src/estimate-balance/domain/estimate-balance-command.model.ts index 44263e4..f1b0da8 100644 --- a/src/estimate-balance/domain/estimate-balance-command.model.ts +++ b/src/estimate-balance/domain/estimate-balance-command.model.ts @@ -28,7 +28,6 @@ export const estimateRowSnapshotSchema = z 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"]), @@ -158,7 +157,6 @@ export function canonicalEstimateBalance(balance: EstimateBalanceSnapshot): stri id: row.id, sortOrder: row.sortOrder, supply: row.supply, - circuitRef: row.circuitRef, name: row.name, quantity: row.quantity, unit: row.unit, diff --git a/src/frontend/estimate-balance/estimate-balance-document.ts b/src/frontend/estimate-balance/estimate-balance-document.ts index 0049dc2..e2726d1 100644 --- a/src/frontend/estimate-balance/estimate-balance-document.ts +++ b/src/frontend/estimate-balance/estimate-balance-document.ts @@ -18,7 +18,6 @@ export const estimateTextLimits = { groupTitle: 200, groupEditor: 100, supply: 50, - circuitRef: 50, rowName: 200, remark: 500, } as const; @@ -34,7 +33,6 @@ export function createEstimateRow( id: createId(), sortOrder, supply: null, - circuitRef: null, name: "", quantity: 1, unit: "piece", @@ -257,7 +255,6 @@ export function toSaveableEstimateBalance( id: row.id, sortOrder: row.sortOrder, supply: optionalText(row.supply), - circuitRef: optionalText(row.circuitRef), name: requiredText(row.name), quantity: row.quantity, unit: row.unit, diff --git a/src/frontend/estimate-balance/estimate-balance-sheet.tsx b/src/frontend/estimate-balance/estimate-balance-sheet.tsx index debba7f..4703397 100644 --- a/src/frontend/estimate-balance/estimate-balance-sheet.tsx +++ b/src/frontend/estimate-balance/estimate-balance-sheet.tsx @@ -50,8 +50,12 @@ interface EstimateBalanceSheetProps { } // Leading columns up to cos φ; the power columns follow. -const LEADING_COLUMN_COUNT = 10; -const COLUMN_COUNT = 17; +const LEADING_COLUMN_COUNT = 9; +const COLUMN_COUNT = 16; + +/** Suggested supply values; free text stays possible. */ +const SUPPLY_SUGGESTIONS = ["ELT", "MSR"]; +const SUPPLY_LIST_ID = "estimate-supply-options"; function NumberInput({ ariaLabel, @@ -85,9 +89,17 @@ function NumberInput({ ); } +function supplyOptions(balance: EstimateBalanceSnapshot): string[] { + const used = balance.groups.flatMap((group) => + group.rows.map((row) => row.supply?.trim() ?? "") + ); + return [...new Set([...SUPPLY_SUGGESTIONS, ...used.filter(Boolean)])]; +} + function TextInput({ ariaLabel, disabled, + list, maxLength, onChange, placeholder, @@ -95,6 +107,7 @@ function TextInput({ }: { ariaLabel: string; disabled: boolean; + list?: string; maxLength: number; onChange: (value: string) => void; placeholder?: string; @@ -106,6 +119,7 @@ function TextInput({ aria-label={ariaLabel} className="estimate-cell-input" disabled={disabled} + list={list} maxLength={maxLength} onChange={(event) => onChange(event.target.value)} placeholder={placeholder} @@ -189,22 +203,14 @@ function EstimateRowLine({ patch({ supply })} placeholder="ELT" value={row.supply} /> - - patch({ circuitRef })} - value={row.circuitRef} - /> - - + + + {supplyOptions(balance).map((supply) => ( + - @@ -409,9 +419,8 @@ export function EstimateBalanceSheet({ -
KG Versorgung - ELT/ISP … + ELT/MSR … Stromkreis-Nr. Verbraucher / Flächen Anzahl / Fläche diff --git a/src/shared/estimate-balance/estimate-balance.ts b/src/shared/estimate-balance/estimate-balance.ts index 1a7b5a5..63d3753 100644 --- a/src/shared/estimate-balance/estimate-balance.ts +++ b/src/shared/estimate-balance/estimate-balance.ts @@ -12,10 +12,8 @@ export type EstimateRowConnection = "fixed" | "socket"; export interface EstimateRowSnapshot { id: string; sortOrder: number; - /** Versorgung, e.g. "ELT" or "ISP". */ + /** Versorgung, e.g. "ELT" or "MSR" (free text). */ 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"). */ diff --git a/tests/estimate-balance-frontend.test.ts b/tests/estimate-balance-frontend.test.ts index fa9368d..ed5f4c7 100644 --- a/tests/estimate-balance-frontend.test.ts +++ b/tests/estimate-balance-frontend.test.ts @@ -165,7 +165,6 @@ describe("estimate balance document editing", () => { id: "row-x", sortOrder: 3, supply: null, - circuitRef: null, name: "", quantity: 1, unit: "piece", @@ -273,7 +272,6 @@ describe("estimate balance saving", () => { balance = updateEstimateRow(balance, "group-410", "row-a", { name: " Hebeanlage ", supply: " ELT ", - circuitRef: "", remark: " ", }); const saveable = toSaveableEstimateBalance(balance); @@ -286,10 +284,9 @@ describe("estimate balance saving", () => { { 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 } + { name: "Hebeanlage", supply: "ELT", remark: null } ); }); diff --git a/tests/estimate-balance.test.ts b/tests/estimate-balance.test.ts index 7d294da..1463fe4 100644 --- a/tests/estimate-balance.test.ts +++ b/tests/estimate-balance.test.ts @@ -52,7 +52,6 @@ function row( id: id(), sortOrder: 0, supply: "ELT", - circuitRef: null, name, quantity, unit: "piece", From 7b2419b347470807a6a5a9a6bc395fe9ba51009b Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Sat, 26 Sep 2026 18:00:57 +0200 Subject: [PATCH 4/4] Polish the printed rough power balance The printed page kept the dark app background in the page margins, the consumer column wrapped while the empty remark column took the space, an empty "bearbeitet von" label was printed, and numbers were printed as typed (11200). Print margins are white now, the consumer column is wider, empty editors are omitted and numbers print with thousands separators (11.200) like the template. Co-Authored-By: Claude Opus 5.5 --- src/app/globals.css | 28 ++++++++++++++++++- .../estimate-balance-sheet.tsx | 10 ++++++- .../estimate-balance/estimate-draft-input.tsx | 7 ++++- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 4f0bcb7..dbf49fb 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2018,10 +2018,36 @@ button.structure-entry-main:focus-visible { display: none !important; } - body:has(.estimate-print-root) { + html:has(.estimate-print-root), + body:has(.estimate-print-root), + body:has(.estimate-print-root) .app-shell, + body:has(.estimate-print-root) .app-shell-content { background: #fff !important; } + /* Der Dunkelmodus setzt color-scheme: dark; Chrome färbt damit auch den + Seitenrand dunkel. Im Druck gilt immer das helle Schema. */ + html:has(.estimate-print-root) { + color-scheme: light !important; + } + + .estimate-print-root .estimate-group-editor.is-empty { + display: none; + } + + /* Verbraucher bekommt den Platz; Anmerkungen bleiben schmal. */ + .estimate-sheet .estimate-col-name { + width: 52mm; + } + + .estimate-sheet .estimate-col-remark { + width: 24mm; + } + + .estimate-sheet .estimate-col-supply { + width: 13mm; + } + body:has(.estimate-print-root) .app-shell { display: block; } diff --git a/src/frontend/estimate-balance/estimate-balance-sheet.tsx b/src/frontend/estimate-balance/estimate-balance-sheet.tsx index 4703397..07dc19c 100644 --- a/src/frontend/estimate-balance/estimate-balance-sheet.tsx +++ b/src/frontend/estimate-balance/estimate-balance-sheet.tsx @@ -31,6 +31,7 @@ import { formatKw, parseGermanDecimal, type DecimalRange, + formatGermanNumber, } from "./estimate-balance-format"; import { DraftInput } from "./estimate-draft-input"; @@ -81,6 +82,7 @@ function NumberInput({ disabled={disabled} format={(current: number) => formatDecimalInput(current)} inputMode="decimal" + printFormat={(current: number) => formatGermanNumber(current, 0, 3)} onCommit={onCommit} onValidityChange={(isValid) => onValidityChange(fieldKey, isValid)} parse={(text) => parseGermanDecimal(text, range)} @@ -461,7 +463,13 @@ export function EstimateBalanceSheet({
{group.title} -