Compare commits

..

No commits in common. "5cbbd3a84aaf56e9ef998448b85d2012bac20765" and "49488f4e1b3e1e242a0b8d28bf96f5945ed726b4" have entirely different histories.

140 changed files with 1911 additions and 27442 deletions

1
.gitignore vendored
View file

@ -6,3 +6,4 @@ dist/
data/*.db
data/backups/*.db
.codex/*.log
dynamo/output/

137
AGENTS.md
View file

@ -164,9 +164,6 @@ Keyboard behavior:
- Escape cancels
- Tab / Shift+Tab navigates editable cells
- Arrow keys navigate cells when not editing
- Alt+ArrowDown on a GZF cell opens the AMEV table (`GzfPickerModal`); the
selected GZF cell also shows a small table-icon button. The picked value is
committed like a typed edit.
Support Ctrl+Plus and Ctrl+Shift+Plus for insertion.
@ -180,37 +177,17 @@ Column headers show the label (click to sort) with a funnel icon for the
value filter; an active filter is highlighted and shows its value count.
Header labels break only between words and put a trailing unit such as
`[kW]` on a second line.
The project overview uses the same rule: row actions are icon-only
`IconButton`s, card-header and panel actions show icon plus text, navigation
uses `IconLink` (`src/frontend/components/icon-link.tsx`). Page-header buttons
stay Bootstrap `btn` with a lucide icon (`btn-with-icon`). List cards filter
through a collapsible PM-style filter bar (`CollapsibleFilterPanel`,
`MultiSelectDropdown`; logic in `src/frontend/utils/overview-filters.ts`): only
the bar collapses, its state is kept per card in `localStorage`, a collapsed
active filter shows "Filter aktiv".
## Bootstrap / Styling Rule
Bootstrap may be used for the general application UI, such as navigation, page layout, buttons, forms, cards, alerts and modals.
Cards follow the PM app look: 1px border all around, 16px radius and the
soft shadow defined on `.card` in `globals.css` (own dark-mode value). The
`.card` rule maps Bootstrap's `shadow-sm` to the same shadow; do not add other
`shadow-*` classes or a colored top border to cards.
Do not use Bootstrap as the core table/grid framework for the circuit list editor.
The circuit list editor must remain a custom spreadsheet-like component with controlled cell selection, inline edit mode, row grouping, drag-and-drop indicators and keyboard behavior.
Avoid permanent Bootstrap form controls inside table cells. Table cells should show static text by default and switch to inputs only while editing.
## Browser Runtime
The web UI is also opened over plain HTTP from other machines in the LAN, which
is not a secure context. Do not call secure-context-only browser APIs such as
`crypto.randomUUID()` directly; create client-side ids with `createClientId()`
from `src/frontend/utils/client-id.ts`.
## Drag-and-Drop Rules
Dragging from the circuit identifier / circuit handle moves the whole circuit.
@ -284,16 +261,8 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
snapshot verifies its checksum and the current-state hash, replaces supported
project data atomically and records a new `restore` revision with a complete
inverse command. Restore can therefore be undone and redone after a restart.
Current snapshot schema version 7 additionally stores the project buildings
and `floors[].buildingId` (see `docs/building-structure.md`); versions 1 to 6
are upgraded with no buildings and `buildingId = null`. Version 6 stores the
distribution-board info fields `boardNumber`, `location`, `fedFrom` and
`remark` and the project cover fields `siteAddress`, `workStage`,
`projectLead` and `projectEditor`; all older supported versions are upgraded
with `null` for these fields.
Stored named and automatic snapshots of every supported version (1 to 7)
remain restorable; they are upgraded while parsing. Version 5 stores
the manual quantity share of every CircuitDeviceRow. Version 4 snapshots are upgraded with
Current snapshot schema version 5 additionally stores the manual quantity share
of every CircuitDeviceRow. Version 4 snapshots are upgraded with
`manualQuantity = quantity`; they already contain the complete confirmed
external-model source, import batches, room mappings and objects. Version 3
snapshots are upgraded with an empty external-model state; version 2 snapshots
@ -307,7 +276,7 @@ project graph.
Project-wide Revit CSV configuration uses
`external-csv-configuration.update`. Its complete expected/target snapshot is
persisted through the shared project-command transaction, so create, update,
delete, Undo and Redo are revision-safe. Snapshot schema 5-7, restore and portable
delete, Undo and Redo are revision-safe. Snapshot schema 5, restore and portable
project duplication preserve the configuration; duplication remaps its internal
id and project link.
The project page exposes the Revit CSV modal. It edits the complete
@ -321,7 +290,7 @@ immutable import batches with original bytes plus the classified cell matrix,
normalized source-room mappings and external objects unique by
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
The general external-model repository is read-only; runtime writes belong only
to typed command adapters. Snapshot/restore and portable project transfer schema version 5/6
to typed command adapters. Snapshot/restore and portable project transfer schema version 5
capture the complete external state and remap every internal UUID and link while
preserving IFCGUIDs, source values, original bytes and the classified matrix.
Import batches additionally store the monotonic project CSV configuration
@ -410,10 +379,6 @@ Project settings use the persistent `project.update-settings` command. Project
metadata, the public-building classification, both voltage defaults and the
enabled distribution-board supply types change in one revision and Undo/Redo
restores them together; the project PUT route requires `expectedRevision`.
Schema version 3 additionally carries the print cover fields `siteAddress`,
`workStage`, `projectLead` and `projectEditor` (migration `0008`); version 2
(without them) and baseline version 1 stay executable and keep the current
cover values. The PUT route accepts the cover fields optionally.
The public-building flag is the future sizing input for requiring halogen-free
cables and lines. The system catalog is `AV`, `SV`, `EV`,
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
@ -468,18 +433,6 @@ the only supported circuit-protection source; no flat compatibility fields remai
Distribution-board floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one
persistent undo step.
The same command (still schema version 1, additively extended) carries the
board info-card fields `boardNumber`, `location`, `fedFrom` and `remark`
(migration `0007`). They are nullable, trimmed planner values; the PUT route
only includes submitted info fields in the command and its inverse. An empty
board with info values is no longer an unchanged setup, so history deletion of
it is rejected like a changed simultaneity factor. Populated-subtree commands
stored before these fields existed are normalized to `null` values
(`normalizeDistributionBoardSubtreeSnapshot`). `fedFrom` is deliberately free
text: the logical link to the feeding board/circuit is an open design decision.
These fields are intended to be prefillable from Revit parameters later, but
only through the Phase 14 rules (preview, explicit confirmation, no silent
overwrite).
Floor and room setup uses persistent insert, update and delete commands with
complete stable snapshots. Their inverses remove or restore only unchanged records and
reject floors with assigned rooms/distribution boards or rooms referenced by
@ -501,49 +454,8 @@ Required operations:
- synchronization changes
## Print View (Seitenansicht)
- Route `src/app/projects/[projectId]/print/page.tsx`, opened from the project
page button "Seitenansicht". Read-only: it only uses existing GET endpoints.
- Pure read model `src/frontend/print/print-document-model.ts` (tested in
`tests/print-document-model.test.ts`); React sheets in
`src/frontend/print/print-document.tsx`; styles scoped to `.print-view` in
`src/frontend/print/print-document.css`.
- Pages: cover (A4 portrait, tabular), board overview with structure and
power sums, one plan per board (A4/A3 landscape) whose title block sits in
`<thead>` so it repeats on printed pages. PDF export uses the browser print
dialog; page size, margins and "Seite x von y" come from injected named
`@page` rules (`lb-portrait`, `lb-landscape`).
- The board hierarchy and "Σ inkl. unterlagert" in the overview are
PRELIMINARY (text match of `fedFrom` against board number/name, plain sum,
no upstream simultaneity factor). Replace this once the feed link is modeled.
- Colors/fonts follow the kbp corporate design (`--pd-*` variables in
`print-document.css`). Empty cover values are shown as
"[ergänzen]"; the Planstände editor column shows the project's current
`projectEditor` because snapshots do not record an editor.
- The document logo and the office profile (office name/address on the
cover) are global office branding: tables `document_branding_assets`
(migration `0007`) and `document_branding_settings` (migration `0008`),
endpoints `GET|PUT|DELETE /api/document-branding/logo`,
`PUT /api/document-branding/office` and `GET /api/document-branding`.
It is intentionally outside project revisions, snapshots and transfer.
Uploads are PNG/JPEG/SVG up to 2 MB with a content signature check; the logo
is served with `nosniff` and a restrictive CSP.
## Revit / CSV External Model Work
Status: further Revit work (editor assignment UI, follow-up import, return
export) is deferred and will be tackled later. When it resumes, the exchange
stays CSV-based and uses the CSV export/import of the Linear plugin in Revit.
The internal Revit/CAD steering committee prefers this over manipulating the
model directly through Dynamo. Do not start Dynamo, Revit API or JSON
transport work. The Linear CSV layout is not yet known: check a sample export
against the existing project CSV configuration before changing the parser.
Earlier Dynamo considerations are archived in
`docs/archive/revit-dynamo-leistungsbilanz-integration-konzept.md`; the
removed diagnostic scripts remain reachable through the git tag
`archive/dynamo-vorueberlegungen`.
When explicitly working on Phase 14, use
`docs/spec/revit-csv-integration-requirements.md` as the detailed source of
truth together with the current architecture documents.
@ -607,13 +519,10 @@ circuit list (see `docs/cable-sizing-module.md`). Rules:
by clicking a grid cell; cable cells keep the normal spreadsheet behavior.
- It has no database table and no migration; `/api/cable-sizing/*` is
stateless.
- Current-carrying capacities and correction factors use
DIN VDE 0298-4:2023-06 with Berichtigung 1:2023-10; the exact scope and the
separate basis of voltage-drop and protection checks are documented in
`docs/cable-sizing-module.md`. Inputs outside the supported tables return no
result instead of assuming a factor of 1. Direct burial, grouped method E,
aluminium below 25 mm² and neutral-current sizing above 33 % third harmonic
remain unsupported until their required inputs or normative basis exist.
- Reference data is IEC 60364-5-52:2009 Annex B. Change table values only with
a documented source in `docs/cable-sizing-module.md`; where sources differ,
use the lower value. Inputs outside the tables return no result instead of
assuming a factor of 1.
- Inputs are prefilled from the circuit (power, phase, voltage, weighted cos φ,
length, protective device) and remain editable; manual cable entry without
a calculation stays possible.
@ -640,30 +549,6 @@ 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 deliberately not part of named/automatic project snapshots or snapshot
restore; those operations leave estimate balances unchanged. Portable project
transfer format version 2 includes every estimate balance and remaps balance,
group and row ids when duplicating a project. Version 1 transfers remain
importable and contain no estimate balances.
- UI: `/projects/:id/grobbilanz` (Stände) and `/grobbilanz/:balanceId` (editor,
`src/frontend/estimate-balance/`). The editor autosaves the whole document
debounced (one Undo step per save), keeps invalid raw input local, and on a
409 reloads only if the balance itself changed; otherwise it re-saves with
the new revision. `request()` throws `ApiRequestError` (status, code).
## Persistence and Migration Rules
- SQLite is the currently supported database.
@ -681,15 +566,11 @@ for the early-phase power balance by DIN 276 cost group (see
## Current Deferred Work
- Revit/CSV/IFCGUID round-trip (deferred; later via Linear plugin CSV, never
Dynamo), except when Phase 14 is explicitly requested and
- Revit/CSV/IFCGUID round-trip, except when Phase 14 is explicitly requested and
`docs/spec/revit-csv-integration-requirements.md` is being followed
- full electrical sizing
- multi-user/PostgreSQL operation
- supported production deployment
- logical feed link between distribution boards (`fedFrom` stays free text
until the project owner decides whether it references a board, a feeding
circuit or both) and the resulting hierarchical power summation
Do not implement these while working on an unrelated phase.

View file

@ -4,14 +4,10 @@ Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
Mittelpunkt steht ein tabellenähnlicher Stromkreislisten-Editor, der Stromkreise,
Gerätezeilen und wiederverwendbare Projektgeräte fachlich getrennt behandelt.
Das Projekt befindet sich in aktiver Entwicklung. Für SQLite stehen ein
Docker-Server-Stack mit gebautem Anwendungscode und ein separater
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
und Mehrbenutzerbetrieb fehlen weiterhin. Revit-CSV-Konfiguration, Vorschau
und bestätigter Erstimport sind implementiert; die Editoroberfläche zur
Objektzuordnung, Folgeimporte und Rückexport stehen noch aus. Die weitere
Revit-Arbeit ist zurückgestellt und erfolgt später über den CSV-Export/-Import
des Linear-Plugins in Revit.
Das Projekt befindet sich in aktiver Entwicklung. Der lokale Entwicklungsbetrieb
mit SQLite und Docker Compose ist unterstützt. Ein Produktionsdeployment,
Mehrbenutzerbetrieb und der Revit-/IFCGUID-Datenaustausch sind noch nicht
implementiert.
## Unterstützter Arbeitsablauf
@ -29,11 +25,6 @@ des Linear-Plugins in Revit.
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen
- Seitenansicht mit Deckblatt, Verteilerübersicht und Verteilerplänen öffnen
und über den Browser als PDF drucken; Büroangaben und Logo zentral pflegen
- Revit-CSV-Dateien konfigurieren, prüfen und nach expliziter Zuordnung von
Räumen, Verteilungen und Projektgeräten erstmals importieren; dabei entstehen
noch keine Stromkreise oder Gerätezeilen
## Technik
@ -151,7 +142,6 @@ vor der Baseline werden nicht unterstützt.
- [Aktuelle Architektur](docs/current-architecture.md)
- [Editor-Interaktionen](docs/circuit-list-editor-interactions.md)
- [API des Stromkreislisten-Editors](docs/circuit-list-editor-api.md)
- [Seitenansicht und PDF-Export](docs/print-view.md)
- [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md)
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
- [Roadmap](docs/spec/07-implementation-phases-todo.md)

View file

@ -12,12 +12,6 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
fachliches Modell und Grid-Aufteilung
- [Editor-Interaktionen](circuit-list-editor-interactions.md) – Tastatur,
Drag-and-drop, Sortierung, Filterung und Undo/Redo
- [Seitenansicht und PDF-Export](print-view.md) – Deckblatt,
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
@ -33,8 +27,7 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
- [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md)
- [Revit-CSV-Anforderungen](spec/revit-csv-integration-requirements.md) –
zurückgestellt; später über den CSV-Export/-Import des Linear-Plugins
- [Revit-CSV-Anforderungen](spec/revit-csv-integration-requirements.md)
- [Phase-14-Audit und Implementierungsplan](spec/revit-csv-phase-14-audit-and-plan.md)
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
@ -57,6 +50,3 @@ Limitierungen und Roadmap.
- [Frühe Codex-Promptvorlagen](archive/initial-codex-prompts.md) – historischer
Arbeitsstand, keine aktuelle Implementierungsanweisung
- [Revit-/Dynamo-Anbindung](archive/revit-dynamo-leistungsbilanz-integration-konzept.md) –
nicht weiterverfolgte Vorüberlegung zur direkten Dynamo-/API-Anbindung;
Diagnoseskripte über den Git-Tag `archive/dynamo-vorueberlegungen`

View file

@ -1,83 +0,0 @@
# Gebäudestruktur (Gebäude → Etagen → Räume)
Projekte mit mehreren Gebäuden werden über eine eigene Ebene **Gebäude**
gegliedert. Etagen können einem Gebäude zugeordnet sein, Räume und Verteilungen
hängen wie bisher an der Etage. Das Gebäude eines Raums oder einer Verteilung
ergibt sich also aus der Etage; eine zweite, konkurrierende Zuordnung gibt es
nicht. Abgestimmt in Issue #6: Das Modell ist so bestätigt, und Gebäude mit
zugeordneten Etagen lassen sich nicht löschen.
## Datenmodell
| Tabelle | Feld | Bedeutung |
|---|---|---|
| `buildings` | `id`, `project_id` (FK, cascade), `name`, `sort_order` | Gebäude eines Projekts |
| `floors` | `building_id` (nullable FK → `buildings`, `ON DELETE set null`) | optionales übergeordnetes Gebäude |
Migration `0009_buildings`. Bestehende Etagen behalten `building_id = null` und
erscheinen in der Oberfläche als „Ohne Gebäude“. Es gibt keine Zwangsmigration
und kein automatisch angelegtes Standardgebäude. Vor dem Anwenden der Migration
auf eine bestehende Datenbank ist wie bei jeder Schemamigration eine separate
Datenbanksicherung anzulegen.
## Befehle, Undo/Redo
Alle Änderungen laufen über die bestehende Orts-Struktur
(`ProjectLocationStructureProjectCommandRepository`) mit Revision, Inverse und
persistentem Undo/Redo:
- `project-building.insert` / `project-building.delete` mit vollständigem
Gebäude-Snapshot (Schema-Version 1)
- `project-building.update`: ändert nur den Namen
- Löschen eines Gebäudes wird abgelehnt, solange Etagen zugeordnet sind
(„Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.“)
- Etagenbefehle `project-floor.insert/.update/.delete` haben seit
Schema-Version **2** das Feld `buildingId`. `project-floor.update` darf Name
und/oder Gebäude ändern, nicht aber `sortOrder`. Ein referenziertes Gebäude
muss zum Projekt gehören.
- Version-1-Etagenbefehle aus bestehender Historie bleiben ausführbar. Sie
stammen aus der Zeit vor den Gebäuden und werden vor der Ausführung mit
`buildingId = null` auf Version 2 gehoben; auch die daraus neu gespeicherten
Vorwärts- und Inversbefehle sind Version 2. Das ist bei linearer Historie
exakt: Wird ein Gebäude später zugeordnet, liegt dieser neuere Befehl im
Undo-Stapel vor dem alten Befehl und wird zuerst zurückgenommen.
- Raumbefehle bleiben unverändert (Version 1).
## Snapshots und Transfer
Projekt-Snapshot-Schema **7** enthält `buildings` und `floors[].buildingId`.
Alle älteren unterstützten Versionen (1–6) werden beim Einlesen mit leerer
Gebäudeliste und `buildingId = null` hochgestuft; gespeicherte Sicherungspunkte
bleiben wiederherstellbar. Die Relationen werden geprüft (Gebäude gehört zum
Projekt, `buildingId` verweist auf ein Gebäude des Snapshots). Restore löscht
und schreibt Gebäude zusammen mit den Etagen; der Projekt-Transfer vergibt neue
Gebäude-IDs und stellt die Etagenverweise um.
## API
- `GET /api/projects/:projectId/buildings` – Gebäude sortiert nach
`sortOrder`, Name
- `POST /api/projects/:projectId/buildings` – `{ "name": "Haus A", "expectedRevision": 3 }`
- `PUT /api/projects/:projectId/buildings/:buildingId` – `{ "name": "Haus B", "expectedRevision": 4 }`
- `DELETE /api/projects/:projectId/buildings/:buildingId` – `{ "expectedRevision": 5 }`
- `POST/PUT /api/projects/:projectId/floors[/:floorId]` akzeptieren zusätzlich
optional `buildingId` (`string | null`). Fehlt das Feld, bleibt beim Bearbeiten
das bisherige Gebäude erhalten; ältere Clients funktionieren unverändert.
## Oberfläche
Gebäude, Etagen und Räume werden auf der eigenen Seite
`/projects/:projectId/struktur` („Gebäudestruktur“) gepflegt, erreichbar über
die Seitenleiste und die Kennzahlen der Projektübersicht. Drei Spalten
Gebäude → Etagen → Räume filtern sich gegenseitig: Klick auf ein Gebäude zeigt
nur dessen Etagen, Klick auf eine Etage nur deren Räume, „Alle“ hebt den Filter
auf. Hinzufügen, Bearbeiten und Löschen laufen über Icon-Buttons wie in der
Stromkreisliste.
## Offen
- Revit: Eine Übernahme des Gebäudes aus Revit ist derzeit nicht geplant. Die
Revit-Arbeit ist insgesamt zurückgestellt und läuft später über die
Linear-CSV; das Modell steht einer späteren CSV-Zuordnung nicht im Weg.
- Sortierung von Gebäuden per Drag-and-drop ist noch nicht umgesetzt; neue
Gebäude werden hinten angehängt.

View file

@ -1,109 +1,159 @@
# Leitungsberechnung
# Leitungsberechnung (Cable-Sizing-Modul)
Das optionale Modul gibt für einen einzelnen Stromkreis eine prüfbare
Querschnittsempfehlung aus. Es schreibt selbst keine Projektdaten. Erst die
ausdrückliche Aktion **Übernehmen** aktualisiert `cableCrossSection`,
`cableType` und `cableLength` über den vorhandenen Befehl `circuit.update`.
Revisionen sowie Undo/Redo bleiben dadurch erhalten.
Optionales, abgegrenztes Rechenmodul für die Querschnittsempfehlung eines
Stromkreises. Es wurde als eigenständiges Modul vereinbart, das an die
Stromkreisliste angedockt ist. Es **schreibt keine Projektdaten selbst**: Ein
Ergebnis wird erst durch ausdrückliches „Übernehmen“ über den bestehenden
Befehl `circuit.update` in den Stromkreis geschrieben. Revisionen und
Undo/Redo bleiben damit unverändert gültig.
Eine automatische Bemessung aller Stromkreise ist weiterhin nicht Bestandteil
des Moduls.
Die vollständige, automatische Bemessung aller Stromkreise (siehe
`docs/spec/06-future-sizing-and-calculations.md`) ist weiterhin **nicht**
Teil dieses Moduls.
## Aufbau
## Aufbau und Andockpunkte
```text
src/cable-sizing/domain/cable-sizing-calculation.ts Berechnung und Tabellen
src/cable-sizing/domain/cable-sizing-contracts.ts API-Validierung
```
src/cable-sizing/domain/cable-sizing-calculation.ts reine Berechnung + Tabellen
src/cable-sizing/domain/cable-sizing-contracts.ts Zod-Schema der API
src/server/controllers/cable-sizing.controller.ts zustandslose Endpunkte
src/server/routes/cable-sizing.routes.ts Router
src/server/routes/cable-sizing.routes.ts Router unter /api/cable-sizing
src/frontend/components/cable-sizing-api.ts Fetch-Client
src/frontend/components/cable-sizing-defaults.ts Vorbelegung
src/frontend/components/cable-sizing-defaults.ts Vorbelegung aus dem Stromkreis
src/frontend/components/cable-sizing-modal.tsx Dialog
tests/cable-sizing-calculation.test.ts Tabellen und Grenzfälle
tests/cable-sizing-calculation.test.ts Tabellen, Rechnung, Vorbelegung
```
Das Domänenmodul hängt weder von Datenbank, Server noch React ab. Die API ist
zustandslos. Im Stromkreis-Editor öffnet ein Rechner-Symbol den Dialog; die
Kabelzellen behalten ihr übliches Spreadsheet-Verhalten.
Das Domänenmodul importiert nichts aus `db/`, `server/` oder `frontend/`.
Außerhalb dieser Dateien gibt es genau zwei Andockpunkte:
## Eingaben
1. `src/server/index.ts`: `app.use("/api/cable-sizing", cableSizingRouter)`.
2. `src/frontend/components/circuit-tree-editor.tsx`: Zustand für den
geöffneten Dialog, ein Button **Leitungsberechnung** (Rechner-Icon) in der
Aktionsspalte jeder Stromkreiszeile, der Dialog selbst und
`handleApplyCableSizing`, das über `updateCircuitById` den regulären
`circuit.update`-Befehl ausführt.
Aus dem Stromkreis werden Leistung, Phasen, Spannung, leistungsgewichtetes
cos φ, Leitungslänge und ein vorhandenes Überstromschutzorgan vorbelegt. Der
Planer wählt Verlegeart, Isolierstoff, Leitermaterial, Temperatur, Häufung,
zulässigen Spannungsfall und bei Drehstrom den Anteil der dritten Harmonischen.
Der Aufruf liegt bewusst **nicht** auf einer Tabellenzelle: Klick, Doppelklick,
Enter, F2 und Tippen in den Kabelspalten verhalten sich wie in jeder anderen
Zelle (Spreadsheet-Regel in `AGENTS.md`). Es gibt keine eigene Datenbanktabelle
und keine Migration.
Eine Leitungslänge von `0 m` ist zulässig. Der Dialog weist sichtbar darauf
hin, dass dann kein Spannungsfall angesetzt wird.
## Eingaben und Vorbelegung
## Normative Grundlage und Grenzen
| Eingabe | Vorbelegung |
|---|---|
| Leistung | `circuitTotalPower` des Stromkreises (mit Geräte-GZF), nicht editierbar |
| Phasen | 3~, wenn die Stromkreisgruppe 3-phasig ist oder eine Gerätezeile `three_phase` hat, sonst 1~ |
| Spannung | `circuit.voltage` (aus den Projekteinstellungen abgeleitet); bei manuell abweichender Phasenzahl die jeweilige Projektspannung |
| cos φ | leistungsgewichtet aus den Gerätezeilen: P / Σ(P_Zeile / cos φ_Zeile), fehlendes cos φ = 1; änderbar |
| Länge | `cableLength` des Stromkreises |
| Schutzorgan | Schutzgerät des Stromkreises: LS, FI/LS, AFDD = Leitungsschutzschalter; D0/NH = Sicherung gG; FI ohne Überstromauslöser wird nicht berücksichtigt |
| Verlegeart, Isolierstoff, Leitermaterial, Temperatur, Häufung, max. ΔU, Oberschwingungen | Planereingaben |
Die Strombelastbarkeiten und Korrekturfaktoren wurden gegen
**DIN VDE 0298-4 (VDE 0298-4):2023-06** einschließlich
**Berichtigung 1:2023-10** geprüft:
- Strombelastbarkeit: Tabellen 3 bis 6.
- Temperaturfaktoren: Tabellen 17 und 20.
- Häufung A1 bis C: Tabelle 22 nach der korrigierten Zuordnung.
- Häufung D: Tabelle 26 für sich berührende Rohre oder Kanäle im Erdreich.
- Oberschwingungen: Anhang B, insbesondere Tabelle B.1.
Unterstützt werden A1, A2, B1, B2, C, D und E. D bezeichnet ausschließlich
mehradrige Kabel oder Mantelleitungen im Rohr beziehungsweise Kanal im
Erdreich. Direkte Erdverlegung wird nicht angeboten, weil DIN VDE 0298-4 dafür
auf die Kabel-Produktnorm DIN VDE 0276-603 verweist.
Für Verlegeart E hängen Häufungsfaktoren von Trägerart, Abstand und Anzahl der
Kabelpritschen ab. Diese Angaben gehören noch nicht zum Eingabemodell. Daher
ist E nur für einen einzelnen Stromkreis berechenbar. Für D sind höchstens
sechs gehäufte Stromkreise durch die verwendete Tabellenzeile abgedeckt.
Aluminium wird erst ab 25 mm² angeboten, weil die verwendeten Tabellen keine
kleineren Aluminiumquerschnitte enthalten. Bei mehr als 33 % dritter
Harmonischer ist der Neutralleiterstrom Bemessungsgrundlage. Solange dieser
nicht als Zahlenwert eingegeben und berechnet wird, liefert das Modul keine
Empfehlung.
Die Berechnung des Spannungsfalls ist eine vereinfachte Ergänzung mit
Leitfähigkeit bei Betriebstemperatur; Reaktanz wird vernachlässigt. Ihre
Planungsgrenzen folgen nicht allein aus DIN VDE 0298-4. Auch die Koordination
mit Leitungsschutzschaltern und gG-Sicherungen gehört zur
Überstromschutzbemessung und ist getrennt von der Strombelastbarkeit zu
beurteilen. Der Dialog bezeichnet diese Ergebnisse deshalb nicht als vollständig
normative Leitungsbemessung.
Kabeltyp und Querschnitt lassen sich im Dialog auch ohne Berechnung direkt
eintragen. Weicht der Eintrag von der Empfehlung oder dem
Praxis-Mindestquerschnitt ab, erscheint nur ein Hinweis.
## Rechenweg
1. Betriebsstrom `I_B` aus Leistung, Spannung, cos φ und Phasenzahl.
2. Erforderliche Belastbarkeit aus `I_B` und gegebenenfalls dem vorhandenen
Schutzorgan.
3. Tabellenwert `I_z` multipliziert mit Temperatur-, Häufungs- und gegebenenfalls
Oberschwingungsfaktor.
4. Vereinfachter Spannungsfall für jeden verfügbaren Querschnitt.
5. Empfehlung als größerer Querschnitt aus Belastbarkeit und Spannungsfall.
6. Bei einphasigen Stromkreisen Anhebung auf den vereinbarten praktischen
Mindestquerschnitt von 2,5 mm².
1. Betriebsstrom I_B aus Leistung, Spannung, cos φ und Phasenzahl.
2. Erforderliche Belastbarkeit: ohne Schutzorgan I_B; mit Schutzorgan
max(I_B, I_n, I₂/1,45) nach IEC 60364-4-43 (I_n ≤ I_z und I₂ ≤ 1,45 · I_z).
I₂/I_n: Leitungsschutzschalter 1,45; Sicherung gG 2,1 (I_n ≤ 4 A),
1,9 (4 A < I_n < 16 A), 1,6 (I_n ≥ 16 A).
3. Tabellenwert I_z je Querschnitt × Temperaturfaktor × Häufungsfaktor
(× 0,86 bei 15–33 % 3. Harmonischer im Drehstromkreis).
4. Spannungsfall ΔU = k · L · I_B · cos φ / (κ · A · U) mit k = 2 (1~) bzw.
√3 (3~), κ = 48 (Cu) bzw. 30 (Al) m/(Ω·mm²) bei Betriebstemperatur;
Reaktanz wird vernachlässigt.
5. Empfehlung = größerer Querschnitt aus Belastbarkeit und Spannungsfall;
1-phasige Stromkreise werden auf den Praxis-Mindestquerschnitt 2,5 mm²
angehoben (Planungskonvention aus `docs/spec/06-…`, keine Rechenanforderung).
6. Zusätzlich: maximale Länge für den zulässigen Spannungsfall.
Temperaturen und Häufungen zwischen Tabellenwerten werden zum nächsten
ungünstigeren Tabellenwert gerundet. Außerhalb des belegten Tabellenbereichs
wird keine Empfehlung ausgegeben. Ein abweichender Querschnitt kann weiterhin
manuell eingetragen und bewusst übernommen werden.
Temperaturen und Häufungen **zwischen** Tabellenwerten werden auf den nächsten
ungünstigeren Tabellenwert gerundet (höhere Temperatur, mehr Stromkreise).
Werte **außerhalb** der Tabellen liefern kein Ergebnis und eine Meldung – es
wird nie stillschweigend mit Faktor 1 gerechnet.
## Datenbasis und Prüfnachweis
Grundlage ist **IEC 60364-5-52:2009, Anhang B**. Alle Werte wurden am
26.09.2026 zeilenweise mit öffentlich zugänglichen Wiedergaben abgeglichen:
TiSoft ElectricalDesign-Hilfe zu IEC 60364-5-52
(`ti-soft.com/en/support/help/electricaldesign/standards/iec-60364-5-52/…`)
und ECalPro (`ecalpro.com/standards/iec-60364/…`). Wo die Quellen voneinander
abweichen, ist der kleinere, sichere Wert eingetragen.
| Tabelle | Inhalt | Abgleich | Abweichungen |
|---|---|---|---|
| B.52.2 | PVC, 2 belastete Adern, Cu/Al, A1–D2 | TiSoft; Cu A1–C zusätzlich ECalPro | Cu A1 240 mm²: 321 / 320 → 320 |
| B.52.3 | VPE, 2 belastete Adern, Cu/Al, A1–D2 | TiSoft | – |
| B.52.4 | PVC, 3 belastete Adern, Cu/Al, A1–D2 | TiSoft | – |
| B.52.5 | VPE, 3 belastete Adern, Cu/Al, A1–D2 | TiSoft | – |
| B.52.10 / .11 | PVC, Verlegeart E, mehradrig, Cu / Al | TiSoft; Cu 2 Adern zusätzlich ECalPro | Cu 3 Adern 70 / 120 mm²: 198 / 278 bzw. 196 / 276 → 196 / 276 |
| B.52.12 / .13 | VPE, Verlegeart E, mehradrig, Cu / Al | TiSoft | – |
| B.52.14 | Temperatur Luft, PVC / VPE | TiSoft, ECalPro | – |
| B.52.15 | Temperatur Erde, PVC / VPE | TiSoft | – |
| B.52.17 | Häufung Luft, Zeile 1 (gebündelt) | TiSoft, ECalPro | – |
| B.52.18 | Häufung Erde direkt, Kabel berührend | TiSoft | – |
| B.52.19 A | Häufung mehradrige Kabel in berührenden Rohren im Erdreich | TiSoft | – |
Anwendung der Tabellen:
- Verlegearten A1, A2, B1, B2, C, E in Luft (Bezug 30 °C), D1 und D2 im
Erdreich (Bezug 20 °C, Bodenwärmewiderstand 2,5 K·m/W). Nach Tabelle B.52.1
ist **D1 = Leitung im Rohr/Kabelschutzrohr im Erdreich** und **D2 = Kabel
direkt im Erdreich**.
- Häufung in Luft immer nach B.52.17 Zeile 1, auch für Verlegeart E; das ist
die ungünstigste Anordnung (einlagig auf Pritschen wären höhere Faktoren
zulässig). D1 nach B.52.19 A, D2 nach B.52.18 jeweils „berührend“.
- Aluminium nur mit den echten Aluminiumwerten (ab 2,5 mm², D2 ab 16 mm²);
Querschnitte ohne Tabellenwert werden nie empfohlen.
- Die Verlegearten F und G (Einzeladern mit Abstand) sind nicht enthalten.
**Vor produktivem Einsatz:** Die nationalen Tabellen der DIN VDE 0298-4
können in einzelnen Werten abweichen. Der Planer muss die Tabellen gegen den
Normtext prüfen. Die Prüfung ist in `tests/cable-sizing-calculation.test.ts`
über Stichwerte und eine Monotonieprüfung jeder Tabellenzeile abgesichert.
## Nicht enthalten
- direkte Erdverlegung
- detaillierte Anordnung mehrerer Kabel bei Verlegeart E
- Neutralleiterbemessung bei mehr als 33 % dritter Harmonischer
- Kurzschlussfestigkeit, Schleifenimpedanz, Abschaltbedingungen und Selektivität
- abweichender Bodenwärmewiderstand sowie Verlegearten F und G
- automatische Berechnung oder Änderung mehrerer Stromkreise
- Kurzschlussfestigkeit, Abschaltbedingungen (Schleifenimpedanz) und
Selektivität
- Bodenwärmewiderstand ≠ 2,5 K·m/W, Häufung mit Abstand, Verlegearten F/G
- Bemessung des Neutralleiters bei > 33 % 3. Harmonischer (nur Hinweis)
- automatische Bemessung oder Prüfung aller Stromkreise
## API
`POST /api/cable-sizing/calculate` berechnet zustandslos. Die Verlegeart ist
`A1 | A2 | B1 | B2 | C | D | E`; `lengthM` darf `0` sein. Das Ergebnis enthält
`supported: false` und einen der Gründe `temperature`, `grouping` oder
`harmonics`, wenn keine belastbare Empfehlung möglich ist.
`POST /api/cable-sizing/calculate` – zustandslos, liest und schreibt keine
Projektdaten.
`GET /api/cable-sizing/laying-methods` und
`GET /api/cable-sizing/insulation-materials` liefern die Auswahllisten.
```jsonc
{
"phase": 1, // 1 | 3
"mode": "power", // "power" | "current"
"powerKw": 4.2, // bei mode "power"
"currentA": 18.3, // bei mode "current"
"cosPhi": 0.95, // 0,1 … 1
"voltage": 230,
"lengthM": 23.5,
"layingMethod": "C", // A1 | A2 | B1 | B2 | C | D1 | D2 | E
"conductorMaterial": "copper", // copper | aluminum
"insulation": "pvc", // pvc | xlpe
"ambientTemperatureC": 30, // Luft; bei D1/D2 Bodentemperatur
"groupingCircuits": 1, // 1 … 100, Tabellen bis 20
"maxVoltageDropPercent": 3,
"harmonicNeutralLoad": "none", // none | 15to33Percent | over33Percent
"existingProtection": { "ratedCurrentA": 16, "kind": "breaker" }, // optional; kind: breaker | fuse
"circuitCategory": "single_phase" // optional
}
```
Antwort: `{ "result": CableSizingResult, "alerts": CableSizingAlert[] }`.
`result.supported = false` mit `unsupportedReason` (`temperature` oder
`grouping`), wenn die Eingaben außerhalb der Tabellen liegen.
`GET /api/cable-sizing/laying-methods` und `GET /api/cable-sizing/insulation-materials`
liefern die Auswahllisten.

View file

@ -59,14 +59,6 @@ this value for optimistic concurrency checks.
- clients cannot submit arbitrary `project.restore-state` payloads through
the generic command endpoint
Grobbilanzen sind bewusst kein Teil dieser logischen Snapshots. Der portable
Projektexport unter `GET /projects/:projectId/export` verwendet dagegen
Transferformat 2 und enthält alle Grobbilanzen. Die beiden Importwege
`POST /projects/:projectId/import` und `POST /projects/import` prüfen ihre
Prüfsumme; Ersetzen übernimmt die Grobbilanzen atomar und mit Undo/Redo,
Duplizieren vergibt neue IDs. Transferformat 1 bleibt ohne Grobbilanzen
importierbar.
The public dispatcher supports the Circuit and CircuitDeviceRow commands
`circuit.update`, `circuit.insert`, `circuit.delete`,
`circuit-device-row.update`, `circuit-device-row.insert`,
@ -82,9 +74,8 @@ Protected-board structure uses
persisted inverse of a delete. The dispatcher also supports the documented
project-device, project settings, distribution-board and project-location
commands. The generic editor commands listed here use schema version `1`.
`project.update-settings` uses version `3` (including the print cover fields)
and still accepts version `2` and supported baseline version `1` commands
without changing the newer cover fields or building classification.
`project.update-settings` uses version `2` and still accepts supported baseline
version `1` commands without changing the newer building classification.
Pre-baseline formats remain unsupported. Unsupported types or schema versions
are rejected. Insert commands contain the complete entity or circuit block
with stable ids; delete commands include the expected parent identity. Circuit
@ -182,9 +173,6 @@ synchronizes linked circuit rows implicitly.
- `PUT /projects/:projectId`
- request:
`{ "expectedRevision": 12, "isPublicBuilding": true, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }`
- optional print cover fields `siteAddress` (max. 500), `workStage` (max.
100), `projectLead` and `projectEditor` (max. 200 each); omitted fields keep
their value, empty strings are stored as `null`
- executes `project.update-settings` as one atomic revision
- response:
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
@ -222,12 +210,6 @@ 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
@ -248,13 +230,9 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
- body:
`{ "floorId": null, "supplyType": "AV", "simultaneityFactor": 1, "expectedRevision": 13 }`
- optional info-card fields: `boardNumber` (max. 50), `location`,
`fedFrom` (max. 200 each) and `remark` (max. 2000); strings are trimmed
and an empty string is stored as `null`
- executes `distribution-board.update`; all submitted values, including the
submitted info fields, are restored together by persistent Undo/Redo
- `fedFrom` is free text until the logical feed link is modeled
`{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }`
- executes `distribution-board.update`; floor and supply type are restored
together by persistent Undo/Redo
- `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy`
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
- duplicates the complete distribution-board subtree with new owning UUIDs
@ -271,27 +249,15 @@ commands. Endpoints, payloads and calculation rules: `docs/estimate-balance.md`.
`distribution-board.delete-subtree`. Persistent Undo/Redo restores or
removes the same complete subtree, including protection devices.
### Project Buildings, Floors and Rooms
### Project Floors and Rooms
- `GET /projects/:projectId/buildings`
- `POST /projects/:projectId/buildings`
- body: `{ "name": "Haus A", "expectedRevision": 12 }`
- executes `project-building.insert` with a stable building id
- response: `{ "building": { ... }, "revision": { ... }, "history": { ... } }`
- `PUT /projects/:projectId/buildings/:buildingId`
- body: `{ "name": "Haus B", "expectedRevision": 13 }` (name only)
- `DELETE /projects/:projectId/buildings/:buildingId`
- body: `{ "expectedRevision": 14 }`
- rejects buildings with assigned floors
- `POST /projects/:projectId/floors`
- body: `{ "name": "EG", "buildingId": "building_1", "expectedRevision": 13 }`
(`buildingId` optional, `null` = no building)
- executes `project-floor.insert` (command version 2) with a stable floor id
- body: `{ "name": "EG", "expectedRevision": 13 }`
- executes `project-floor.insert` with a stable floor id
- response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }`
- `PUT /projects/:projectId/floors/:floorId`
- body: `{ "name": "1. OG", "buildingId": null, "expectedRevision": 14 }`
- executes an exact persistent floor update of name and/or building; a
missing `buildingId` keeps the current building
- body: `{ "name": "1. OG", "expectedRevision": 14 }`
- executes an exact persistent floor update
- `DELETE /projects/:projectId/floors/:floorId`
- body: `{ "expectedRevision": 15 }`
- rejects floors with assigned rooms or distribution boards
@ -433,15 +399,14 @@ read nor write project data; applying a result uses `circuit.update` through
- `POST /cable-sizing/calculate`
- request: phase, mode (`power` | `current`), powerKw or currentA, cosPhi,
voltage, lengthM, layingMethod (`A1` | `A2` | `B1` | `B2` | `C` | `D` |
`E`), conductorMaterial, insulation (`pvc` | `xlpe`),
voltage, lengthM, layingMethod (`A1` | `A2` | `B1` | `B2` | `C` | `D1` |
`D2` | `E`), conductorMaterial, insulation (`pvc` | `xlpe`),
ambientTemperatureC, groupingCircuits, maxVoltageDropPercent,
harmonicNeutralLoad, optional `existingProtection`
(`{ ratedCurrentA, kind: "breaker" | "fuse" }`) and circuitCategory
- returns `{ "result": { ... }, "alerts": [ ... ] }`; `result.supported`
is `false` with `unsupportedReason` when temperature or grouping lie
outside the tables or the neutral current needed above 33 % third
harmonic is unavailable
outside the tables
- unknown fields are rejected (`400`)
- `GET /cable-sizing/laying-methods`
- `GET /cable-sizing/insulation-materials`
@ -482,27 +447,3 @@ Full request example and calculation rules: `docs/cable-sizing-module.md`.
Parser errors expose stable codes such as `header-not-found`,
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed
import uploads and parses the file again and verifies its hash.
## Document Branding (print view)
Office-wide branding for the print view. These endpoints are mounted at
`/api/document-branding`, outside `/api/projects`, because the data is global
and intentionally not part of project revisions, snapshots or transfer.
- `GET /document-branding`
- returns `{ "logo": { "mimeType", "fileName", "sizeBytes", "updatedAtIso" } | null, "office": { "officeName", "officeAddress" } }`
- `GET /document-branding/logo`
- returns the stored image bytes with its media type, or `404` without logo
- responses carry `X-Content-Type-Options: nosniff` and a restrictive
Content-Security-Policy so an SVG logo cannot execute scripts
- `PUT /document-branding/logo`
- request: `{ "fileName": "logo.png", "mimeType": "image/png", "contentBase64": "..." }`
- accepted types: `image/png`, `image/jpeg`, `image/svg+xml`; at most 2 MB
after decoding; the content signature must match the declared type
- replaces an existing logo; returns the logo metadata
- `DELETE /document-branding/logo`
- removes the logo; returns `204`
- `PUT /document-branding/office`
- request: `{ "officeName": "…" | null, "officeAddress": "…" | null }`
(at most 200 / 500 characters; empty text is stored as not set)
- returns `{ "office": { "officeName", "officeAddress" } }`

View file

@ -1,8 +1,5 @@
# Circuit List Editor Interactions
The back-arrow link beside the page title returns to the project's distribution
board overview without changing circuit-list data.
## Editing Model
Inline cells are static text by default. A cell enters edit mode by:
@ -47,7 +44,6 @@ Selection, keyboard movement, and editability checks run against this normalized
- arrow keys: move selected cell in grid when not editing
- type-to-edit: printable keys open edit mode and replace current display value with typed character
- `Ctrl+Plus` / `Ctrl+Shift+Plus`: insert below the selected circuit or device context
- `Alt+ArrowDown` on a GZF cell (selected or editing): open the AMEV simultaneity-factor table
- `Delete`: delete the selected circuit or device context after confirmation
For insertion, a circuit-level cell creates a reserve circuit directly after the selected circuit. A device-level cell creates a manual device row directly after the selected device. On the virtual `-frei-` row, or without a valid row selection, the shortcut creates a circuit at the end of the active section. Insertions use command history and never renumber existing equipment identifiers.
@ -56,23 +52,6 @@ For deletion, a circuit-level cell targets the complete circuit and a device-lev
Creating or deleting a device row updates the row and the circuit reserve state in one SQLite transaction.
## AMEV Simultaneity Factor
The selected GZF cell shows a small table-icon button; it and `Alt+ArrowDown` open
the shared `GzfPickerModal` (AMEV Planungshilfe, Tabelle 3), which the project
device dialog uses as well. "Übernehmen" commits the mean value of the chosen
consumer group through the same path as a typed edit (`commitEdit` with an
explicit target cell), so it is one undoable command and works on device rows,
compact circuits, reserve circuits and the `-frei-` row. A running inline edit
of the cell is discarded when the picker opens. The button only appears on the
selected cell; the grid keeps its static-text cells.
The dialog takes keyboard focus, keeps Tab/Shift+Tab within its actions and
closes on Escape without writing a value. Closing returns focus to the grid;
only "Übernehmen" saves the selected mean value. Opening the picker is disabled
while a write or history operation is running. Its cell button stays outside
the Tab order; `Alt+ArrowDown` provides keyboard access from the selected cell.
## Equipment Identifier Validation
Equipment identifiers are compared case-insensitively after trimming whitespace. A conflicting edit is highlighted before submission and cannot be committed. Existing conflicts are highlighted in the grid. Validation errors remain dismissible above the editor instead of replacing the complete workspace.
@ -148,30 +127,11 @@ Cross-section device moves show confirmation-required feedback before drop. The
## Filtering and Sorting
- Clicking a column label changes sorting; the funnel icon next to it opens
the value filter. An active filter is highlighted and shows the number of
selected values. Both controls retain German accessible names and tooltips.
- Header labels wrap between words; trailing units such as `[kW]` appear on
a separate line.
- Per-column filtering works on normalized displayed values.
- Sorting is view-level first and treats circuits as blocks.
- Multi-device circuits are not split during sort.
- Sorting alone does not persist order.
## Action Buttons
The editor toolbar uses icons with visible German labels. Group headers and
circuit/device rows use compact icon buttons with German accessible names and
tooltips. Group protection buttons additionally show `VS` or `FI` to distinguish
their roles. Dividers separate related actions; destructive actions use the
danger style. Circuit deletion uses a trash icon, while device-row deletion
uses a crossed-out list icon.
The shared `src/frontend/components/icon-button.tsx` component preserves
disabled states and expanded states for toggles. Row and group action buttons
retain `tabIndex={-1}` so the spreadsheet's existing cell navigation remains
unchanged. The existing command handlers and deletion confirmations are reused.
## Apply Sorted Order
`Apply sorted order` persists current sorted block order to backend by section.
@ -201,11 +161,6 @@ across multiple sections is atomic and occupies one history step. Explicit
renumbering uses a collision-safe persistent project command and one
project-history step.
The editor creates client-side UUIDs through `createClientId` in
`src/frontend/utils/client-id.ts`. It uses `crypto.randomUUID()` when available
and otherwise generates a UUID v4 with `crypto.getRandomValues()`, so insertion
also works over plain HTTP from another machine in the LAN.
Covered operations include:
- insert/delete circuit

View file

@ -9,8 +9,6 @@
- Named and automatic logical project snapshots can be listed and restored
through the project page with persistent Undo/Redo. Automatic snapshots are
created every 25 revisions and only their newest 12 entries are retained.
- Further Revit work is deferred; it will later use the CSV export/import of
the Linear plugin in Revit, not Dynamo.
- The Revit/CSV foundation and confirmed initial import are implemented.
Existing-row assignment, move and detach plus creation of an external-only
row in an existing or newly created circuit have atomic persistent commands.
@ -20,11 +18,11 @@
- The global device library supports basic CRUD and copy operations, but has no
versioning, permissions or controlled synchronization model.
- There is no automatic sizing of all circuits. The optional cable sizing
module suggests a cross-section for one circuit on demand using
DIN VDE 0298-4:2023-06 with Berichtigung 1:2023-10. It excludes direct
burial, grouped method E and neutral-current sizing above 33 % third
harmonic, and it does not check short-circuit withstand, disconnection time
or selectivity. See `docs/cable-sizing-module.md`.
module suggests a cross-section for one circuit on demand (IEC 60364-5-52
Annex B tables; no short-circuit, disconnection-time or selectivity checks,
no methods F/G, soil resistivity fixed at 2.5 K·m/W) and does not validate
manually entered cable values. National tables (DIN VDE 0298-4) must be
checked by the planner; see `docs/cable-sizing-module.md`.
- Sorting is view-only until users explicitly apply sorted order.
- Complete-circuit drag supports one circuit at a time between groups of the
same category. Cross-category moves and multi-circuit moves across group
@ -33,12 +31,3 @@
- Protection and cable values are planner-owned manual selections. The later
sizing/warning engine does not yet validate them against load, length or
voltage-drop rules.
- Distribution-board `fedFrom` is free text. The print overview derives its
supply structure and "Σ inkl. unterlagert" only from exact text matches with
board numbers/names and adds values without an upstream simultaneity factor
(see `docs/print-view.md`).
- The print view uses a kbp corporate design derived from logo and
presentation material. Snapshots do not record an editor, so the cover's
Planstände list shows the project's current editor for every entry. On screen, long
board plans are not split into pages; pagination happens in the browser print
dialog.

View file

@ -27,18 +27,9 @@ Next.js App Router ── /api/* Rewrite ──▶ Express API :3000
data/leistungsbilanz.db (SQLite)
```
In beiden Docker-Stacks laufen Frontend und API in getrennten Containern.
`compose.yaml` startet den gebauten Stand, `compose.dev.yaml` die Entwicklung
mit Hot Reload. Das Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API
weiter; beim gebauten Stand wird das Ziel zur Build-Zeit festgelegt. Die
Datenbank liegt über einen Host-Mount außerhalb des Containers.
Neue IDs für Stromkreise, Gerätezeilen, Gruppen und Verteilerkomponenten erzeugt
der Editor über `src/frontend/utils/client-id.ts` (`createClientId`). Die
Hilfsfunktion nutzt `crypto.randomUUID()`, sofern verfügbar, andernfalls
`crypto.getRandomValues()` mit UUID-v4-Format. Damit funktioniert das Anlegen
auch beim HTTP-Zugriff im LAN ohne sicheren Browserkontext. Die IDs entstehen
vor dem Command und bleiben über Undo/Redo unverändert.
Im Docker-Entwicklungssetup laufen Frontend und API in getrennten Containern. Das
Frontend leitet `/api/*` über `API_INTERNAL_URL` an die API weiter. Die Datenbank
liegt über einen Host-Mount außerhalb des Containers.
## Wichtige Einstiegspunkte
@ -241,9 +232,8 @@ und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
`project.update-settings` versioniert Projektname, interne und externe
Projektnummer, Bauherr, Beschreibung, die Deckblattangaben (Anschrift,
Leistungsphase, Projektleitung, Bearbeitung; Schema-Version 3, Migration
`0008`), die Kennzeichnung als öffentliches Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten
Projektnummer, Bauherr, Beschreibung, die Kennzeichnung als öffentliches
Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten
Verteiler-Netzarten als eine atomare Änderung. Die Gebäudekennzeichnung ist
der spätere Eingang für die Auswahl halogenfreier Kabel und Leitungen. Der
Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens
@ -255,55 +245,22 @@ Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId`
verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
Kommando- und Snapshot-Payloads müssen dem aktuellen Baseline-Schema entsprechen.
Die Projektseite zeigt Verteilungen als kompakte Bestandsübersicht; ihre
versionierten Erstellwege öffnen beschriftete Modals statt dauerhafter
Eingabezeilen. Gebäude, Etagen und Räume werden auf der eigenen Seite
„Gebäudestruktur“ (`/projects/:projectId/struktur`) gepflegt; Etagen können
einem Gebäude zugeordnet sein (`docs/building-structure.md`). Projektgeräte werden als durchsuchbare,
Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte
Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals
statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare,
fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
damit Änderungen weiterhin niemals still synchronisiert werden.
Die Karten Verteilungen und Projektgeräte besitzen je eine einklappbare
Filterleiste im Stil der PM-App (Suche, Mehrfachauswahl für Etage/Netzart bzw.
Kategorie/Phasenzahl/Kostengruppe, „Zurücksetzen“, Anzeige „x von y“); die
Filterlogik liegt rein in `src/frontend/utils/overview-filters.ts`, der
Einklappzustand je Karte in `localStorage`. Aktionen verwenden wie der Editor
`IconButton`/`IconLink`.
Die allgemeinen Anwendungskarten verwenden in `src/app/globals.css` einen
gleichmäßigen Rahmen, 16-px-Rundungen und einen Schatten mit eigener
Dark-Mode-Abstimmung. Bootstrap-Karten mit `shadow-sm` nutzen denselben
Schatten; der Stromkreis-Grid bleibt eine eigenständige Komponente.
Die Verteilersuche berücksichtigt Name, Verteilernummer, Örtlichkeit und
Etagenname; die Gerätesuche
Name, Anzeigename, Kategorie einschließlich deutscher Bezeichnung, Anschlussart
und Kostengruppe. Der Gerätefilter „Anschluss“ wählt zwischen 1- und 3-phasig.
Mehrere Werte innerhalb eines Filters gelten alternativ, verschiedene Filter
und die Suche gemeinsam. Eine leere Auswahl bedeutet „Alle“; fehlende
Zuordnungen sind über „Ohne Etage“, „Nicht festgelegt“ beziehungsweise
„Ohne Kostengruppe“ auswählbar. Nicht mehr angebotene Auswahlwerte werden bei
der Filterung ignoriert. „Zurücksetzen“ leert die Filter der jeweiligen Karte.
Beim Einklappen bleiben aktive Filter wirksam und durch „Filter aktiv“ sichtbar.
Nur der Einklappzustand wird je Kartentyp im Browser gespeichert; Suchbegriffe
und Filterauswahl werden nicht dauerhaft gespeichert. Die Filter ändern keine
Projektdaten und erzeugen keine Revisionen. Das Löschen eines Projektgeräts
erfordert eine Bestätigung mit dessen Namen und verwendet weiterhin den
bestehenden persistenten Projektbefehl.
`GET /api/projects/:projectId/export` verpackt den vollständigen portablen
Projektzustand in ein format- und schema-versioniertes JSON-Dokument mit
SHA-256-Prüfsumme. Transferformat 2 enthält zusätzlich alle Grobbilanzen,
ohne sie in das logische Snapshot-Schema aufzunehmen; Format 1 bleibt ohne
Grobbilanzen importierbar. `POST /api/projects/:projectId/import` prüft Format,
Snapshot-Relationen, Grobbilanzen und Prüfsumme vor jedem Schreibzugriff. Der
Modus `replace` läuft über `project.restore-state` Version 2 und ersetzt den
Projektzustand und die Grobbilanzen atomar und dauerhaft rückgängig machbar.
Normale Snapshot-Restores verwenden weiterhin Version 1 und lassen
Grobbilanzen unverändert. Der Modus `duplicate` ordnet Projekt-,
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
Struktur-, Gruppen-, Verteilerkomponenten-, Raum-, Stromkreis- und
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen und die IDs aller Grobbilanzen
vollständig neu zu und legt die Kopie mit Revision `0` in einer Transaktion an.
Fachliche Verknüpfungen
Gerätezeilen-UUIDs sowie Schutzgeräte-Referenzen vollständig neu zu und legt
die Kopie mit Revision `0` in einer Transaktion an. Fachliche Verknüpfungen
innerhalb des unterstützten Laufzeitmodells bleiben
erhalten. Die
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
@ -459,16 +416,7 @@ wieder her.
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
Derselbe Befehl versioniert die Felder der Verteiler-Infokarte
(`boardNumber`, `location`, `fedFrom`, `remark`, Migration `0007`). Die
Infokarte liegt im Bearbeiten-Dialog der Verteilung; die Werte sind lokale
Planungswerte und sollen später über den Revit-Abgleich nur nach Vorschau und
ausdrücklicher Bestätigung vorbelegt werden. `fedFrom` ist vorerst Freitext.
Snapshot-Schema 7 enthält zusätzlich die Gebäude und die Gebäudezuordnung
der Etagen; ältere Versionen werden ohne Gebäude hochgestuft.
Snapshot-Schema 6 enthält diese Felder und die Deckblattangaben des
Projekts; ältere Versionen werden mit `null`
hochgestuft. Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
enthält bereits den vollständigen externen Modellzustand
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die
@ -507,10 +455,8 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
## Leitungsberechnung
Das optionale Modul `src/cable-sizing` berechnet auf Anforderung eine
Querschnittsempfehlung für einen Stromkreis. Strombelastbarkeit, Temperatur
und Häufung beruhen auf DIN VDE 0298-4:2023-06 einschließlich
Berichtigung 1:2023-10; Spannungsfall und Schutzorgan werden ergänzend und mit
den in der Moduldokumentation beschriebenen Grenzen geprüft. Die
Querschnittsempfehlung für einen Stromkreis (IEC 60364-5-52 Anhang B:
Belastbarkeit, Temperatur, Häufung, Spannungsfall, Schutzorgan). Die
Domänenlogik ist rein, `POST /api/cable-sizing/calculate` ist zustandslos und
es gibt keine eigene Tabelle. Der Editor öffnet den Dialog über den Button
„Leitungsberechnung“ in der Aktionsspalte; „Übernehmen“ schreibt Kabeltyp,
@ -552,7 +498,7 @@ Revision und Undo/Redo. Details, Rechenweg und Prüfnachweis der Tabellen:
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und
Transfer-Integration verwenden aktuell Snapshot-Schema 6. Persistente
Transfer-Integration verwenden aktuell Snapshot-Schema 5. Persistente
Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten,
Gruppen einschließlich befüllter Unterbäume sowie vollständige
Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur
@ -571,10 +517,6 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
## Revit-CSV-Grundlage
**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann
über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo.
Details: [Revit-CSV-Anforderungen, Abschnitt 1](spec/revit-csv-integration-requirements.md).
Der reine Adapter unter `src/external-model/csv` validiert die projektweite
Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine
klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt-
@ -584,7 +526,7 @@ einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl
`external_csv_configurations` enthält höchstens eine versionierte Konfiguration
je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder
Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 6,
Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 5,
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
werden Konfigurations-UUID und Projektlink remapped.

View file

@ -16,10 +16,9 @@ und macOS keine inotify-Events durchreichen; das kostet dauerhaft CPU, auch
ohne Benutzeraktivität. Dieser Stack gehört deshalb nur auf einen
Entwicklungsrechner.
Beide Stacks enthalten weder TLS, Authentifizierung, einen vorgeschalteten
Reverse Proxy noch ein zentral betriebenes Datenbanksystem. Die oben genannten
Container-Schutzmaßnahmen ersetzen diese Funktionen nicht. Der Stack darf
deshalb nicht öffentlich erreichbar gemacht werden.
Beides enthält weder TLS, Authentifizierung, Reverse Proxy, Prozesshärtung noch
ein zentral betriebenes Datenbanksystem. Der Stack darf deshalb nicht öffentlich
erreichbar gemacht werden.
## Topologie
@ -35,11 +34,6 @@ erreicht, kann jedes Projekt lesen und ändern. Erreichbar ist sie nur aus dem
Compose-Netz, praktisch also über den Next.js-Rewrite. Im Entwicklungs-Stack
hängen beide Ports am Loopback und sind nicht aus dem LAN erreichbar.
Beim HTTP-Zugriff auf den Server-Stack aus dem LAN erzeugt der Editor neue
UUIDs über `createClientId`, mit `crypto.getRandomValues()` als Ersatz für das
dort nicht verfügbare `crypto.randomUUID()`. HTTPS ist für diese
ID-Erzeugung nicht erforderlich.
### Speichergrenzen
Jeder Dienst hat ein `mem_limit`. Im Server-Stack ist zusätzlich
@ -146,17 +140,14 @@ Vor einer produktiven Installation werden mindestens benötigt:
- Entscheidung, ob SQLite für einen einzelnen Prozess genügt oder PostgreSQL für
Mehrbenutzerbetrieb erforderlich ist
Der in der README beschriebene Server-Stack ermöglicht bereits den Betrieb des
gebauten Stands in einer geschützten Umgebung. Ein vollständig unterstütztes
Produktionssetup bleibt bis zur Umsetzung und Prüfung dieser Punkte offen.
Bis diese Punkte umgesetzt und getestet sind, besteht die „Installation“ aus dem
lokalen Entwicklungsstart in der README.
## Backup und Wiederherstellung
`npm run db:backup` erzeugt unter `data/backups/` ein konsistentes SQLite-Backup,
öffnet es unabhängig und prüft Integrität sowie Fremdschlüssel. Diese
Dateisicherungen sind von den bereits vorhandenen benannten und automatischen
logischen Projektsicherungen getrennt. Projektsicherungen werden über die
Projektseite wiederhergestellt und unterstützen persistentes Undo/Redo.
Dateisicherungen sind von zukünftigen logischen Projektversionen getrennt.
Eine produktive Restore-Anweisung wird erst zusammen mit einem unterstützten
Produktionsbetrieb veröffentlicht. Lokale Wiederherstellung darf nur bei

View file

@ -13,11 +13,6 @@ Ein sauberer Clone enthält keine SQLite-Datenbank. Beim ersten Start werden
`data/leistungsbilanz.db`, alle Tabellen und Migrationseinträge automatisch
angelegt. Das Frontend ist unter <http://localhost:3001> erreichbar.
Dieser Standard-Stack verwendet gebauten Anwendungscode. Für die Entwicklung
mit Quellcode-Mounts und Hot Reload stattdessen
`docker compose -f compose.dev.yaml up --build --detach` verwenden; die Dienste
sind dabei nur über die Loopback-Ports des Entwicklungsrechners erreichbar.
Für direkte Node.js-Entwicklung:
```powershell
@ -91,8 +86,7 @@ Bei Docker- oder Laufzeitänderungen zusätzlich:
```powershell
docker compose up --build --detach
docker compose ps
Invoke-WebRequest http://localhost:3001/health
Invoke-WebRequest http://localhost:3001/web-health
Invoke-WebRequest http://localhost:3000/health
```
Bei sichtbaren Editoränderungen ist außerdem ein kurzer GUI-Test erforderlich.

View file

@ -1,119 +0,0 @@
# Grobe Leistungsbilanz (LPH 2/3)
Eigenes Modul für die Leistungsbilanz der Vor- und Entwurfsplanung, gegliedert
nach Kostengruppen (DIN 276, KG 400). Es gibt dort noch keine Verteilungen und
Stromkreise: Jedes Gewerk trägt unter seiner Kostengruppe Verbraucher als
Stückzahl mit Einzelleistung oder als Fläche mit W/m² ein. Aufbau und Rechnung
folgen der bisherigen Excel-Vorlage „Leistungsbilanz – KG400“. Abstimmung:
Issue #7.
Das Modul ist **nicht** mit der Stromkreisstruktur verknüpft. Es liest die
Gerätezeilen nur für den Vergleich „geschätzt ↔ detailliert“ und schreibt
nichts in Stromkreislisten, Verteilungen oder Projektgeräte.
## Aufbau
```
src/shared/estimate-balance/estimate-balance.ts Datenform, Rechnung, Vergleich (rein, ohne Imports)
src/estimate-balance/domain/estimate-balance-command.model.ts Snapshot-Schema (Zod), Befehle
src/estimate-balance/domain/estimate-balance-project-command.store.ts Store-Port
src/estimate-balance/api/estimate-balance-request.schemas.ts Request-Schemas
src/db/schema/estimate-balances.ts Tabellen (Migration 0010)
src/db/repositories/estimate-balance*.ts Lesen, Schreiben, Befehls-Store
src/server/controllers/estimate-balance.controller.ts API
src/app/projects/[projectId]/grobbilanz/ Übersicht der Stände und Editor
src/frontend/estimate-balance/ Editor-Komponenten und Helfer
```
Die gemeinsame Datei unter `src/shared/estimate-balance` hat keine Imports, weil
das Frontend sie direkt nutzt (Turbopack löst die `.js`-Suffixe der API nicht auf).
## Datenmodell
| Tabelle | Inhalt |
|---|---|
| `estimate_balances` | ein **Stand** je Projekt (z. B. Vorplanung, Entwurfsplanung): Name, LPH, Datum, Reserve AV/SV in %, Sortierung |
| `estimate_groups` | Kostengruppe (`"410"` … `"480"`, `""` für „Allgemein“), Titel, „bearbeitet von“, Sortierung |
| `estimate_rows` | Versorgung (Auswahl ELT/MSR oder Freitext), Verbraucher/Fläche, Anzahl bzw. Fläche, Einheit (`piece` = St., `area` = m²), Anschluss (fest/Steckdose), Einzelleistung in W bzw. W/m², GLF, cos φ, SV ja/nein, Anmerkung |
Neue Stände entstehen aus der Vorlage KG 400 (410 Sanitär, 420 Wärme, 430 Luft,
434 Kälte, 440 Starkstrom, 450 Schwachstrom, 460 Förder, 470 Nutzungsspezifisch,
480 GA, Allgemein; Reserve AV 10 %), leer oder als Kopie eines bestehenden
Stands mit neuen IDs. Stände bleiben bearbeitbar; ein zusätzlicher Status oder
eine Bearbeitungssperre ist derzeit nicht vorgesehen.
## Rechnung
- Zeile: P = Anzahl bzw. Fläche × Einzelleistung × GLF, S = P / cos φ.
- **AV** enthält alle Verbraucher (Normalversorgung). **SV** listet zusätzlich
die Zeilen mit „SV = ja“, die auch aus der Sicherheitsstromversorgung versorgt
werden müssen. Das ist die Lesart der Vorlage; die Regel liegt in
`calculateEstimateRow` und lässt sich dort zentral ändern.
- Summen je Kostengruppe, Gesamtsumme, Reserve in % (AV und SV getrennt),
Leistung inklusive Reserve und Maximalstrom I = S / (√3 · U) mit der
Drehstromspannung aus den Projekteinstellungen.
- Anzeige wie in der Tabellenkalkulation gerundet (`roundEstimateValue`,
40,175 → 40,18). Der Test `tests/estimate-balance.test.ts` rechnet das
Beispiel der Vorlage vollständig nach (143,24 kW, 148,48 kVA, mit 10 %
Reserve 157,56 kW / 163,33 kVA, 235,75 A).
## Vergleich geschätzt ↔ detailliert
`GET …/comparison` summiert alle Gerätezeilen aller Stromkreislisten des
Projekts (Anzahl × Leistung je Stück × Geräte-GZF, wie der Stromkreisbaum) und
ordnet sie über das Feld **Kostengruppe** der Gerätezeile zu. Maßgeblich ist das
längste passende Präfix: `434` → Gruppe 434, `431` → Gruppe 430, `444` → 440.
Zeilen ohne passende Kostengruppe erscheinen als „nicht zugeordnet“. Der
Verteiler-GZF wird auf keiner Seite angewendet; ein fehlendes cos φ zählt als 1.
Verglichen wird die AV-Leistung (kW) mit Differenz absolut und in %.
## Befehle, Undo/Redo
Alle Änderungen laufen über den bestehenden Projekt-Befehlsmechanismus mit
Revision, Inverse und persistentem Undo/Redo:
- `estimate-balance.insert` / `estimate-balance.delete` mit dem vollständigen
Stand (Gruppen und Zeilen)
- `estimate-balance.update` mit vollständigem `expected`/`target`-Dokument; der
Store prüft, dass der gespeicherte Stand genau `expected` entspricht, und
ersetzt ihn atomar. Die Inverse ist das vertauschte Paar.
Der Editor speichert das ganze Dokument verzögert (Autosave); jeder
Speichervorgang ist ein Undo-Schritt. Unveränderte Dokumente erzeugen keine
Revision (`{ unchanged: true }`).
## Projekttransfer und bewusste Abgrenzung
- Das reguläre SQLite-Datenbank-Backup sichert die Grobbilanz-Tabellen zusammen
mit allen übrigen Anwendungsdaten.
- Benannte und automatische Projektsnapshots enthalten keine Grobbilanzen; das
Snapshot-Schema bleibt Version 7. Snapshot-Restore und dessen Undo/Redo lassen
vorhandene Grobbilanzen unverändert.
- Der portable Projektexport enthält alle Grobbilanzen ab Transferformat 2.
Ersetzen übernimmt sie atomar und rückgängig machbar; Duplizieren vergibt neue
IDs für Bilanz, Gruppen und Zeilen. Alte Transferdateien in Version 1 bleiben
importierbar und enthalten definitionsgemäß keine Grobbilanzen.
- Keine Rechte je Gewerk; „bearbeitet von“ ist ein Freitext je Kostengruppe.
- Kostengruppen der Gerätezeilen sind nicht normiert; der Vergleich arbeitet
mit Präfixen.
- Der erste Vergleich zeigt die geschätzte und detaillierte AV-Wirkleistung in
kW. SV- und kVA-Vergleiche können später ergänzt werden.
- Eine Stromkreisnummer gehört nicht zur Grobbilanz, weil sie eine frühe
Abschätzung ohne Stromkreisplanung ist.
## API
- `GET /api/projects/:projectId/estimate-balances`
- `POST /api/projects/:projectId/estimate-balances` –
`{ "expectedRevision": 7, "name": "Entwurfsplanung", "workStage": "LPH 3", "dateIso": "2026-09-26", "template": "kg400" }`
(`template`: `kg400` | `empty` | `copy` mit `copyFromBalanceId`)
- `GET /api/projects/:projectId/estimate-balances/:balanceId`
- `PUT /api/projects/:projectId/estimate-balances/:balanceId` –
`{ "expectedRevision": 8, "balance": { …vollständiges Dokument… } }`
- `DELETE /api/projects/:projectId/estimate-balances/:balanceId` – `{ "expectedRevision": 9 }`
- `GET /api/projects/:projectId/estimate-balances/:balanceId/comparison`
Validierung: Name 1–200 Zeichen, getrimmte Texte, Anzahl/Fläche und
Einzelleistung ≥ 0, GLF 0–1, cos φ > 0 bis 1, Reserve 0–100 %, Datum
`YYYY-MM-DD`, eindeutige Gruppen- und Zeilen-IDs, unbekannte Felder werden
abgelehnt.

View file

@ -1,127 +0,0 @@
# Seitenansicht und PDF-Export
Die Seitenansicht ist rein lesend und verändert keine Projektdaten.
Die Aktionen für Büroangaben und Logo speichern dagegen globale Einstellungen.
Projekt- und Verteilerangaben werden in den jeweiligen Bearbeitungsdialogen
über Projektbefehle mit Undo/Redo gepflegt.
Beim Laden werden die Projektrevisionen aller Stromkreislisten und ein
abschließend erneut gelesener Projektstand verglichen. Ändert sich das Projekt
währenddessen, wird statt eines gemischten Datenstands eine Aufforderung zum
Neuladen angezeigt. Eine bereits geladene Seitenansicht aktualisiert sich nicht
automatisch bei späteren Änderungen in einem anderen Tab.
## Aufruf
- Projektseite → Button **Seitenansicht** öffnet
`/projects/:projectId/print` in einem neuen Tab.
- **Als PDF speichern / Drucken** nutzt den Druckdialog des Browsers
(Ziel „Als PDF speichern“). Der Dokumenttitel `Leistungsbilanz – <Projekt>`
wird als Dateiname vorgeschlagen.
- **Planformat** schaltet die Verteilerpläne zwischen A4 quer und A3 quer um.
Deckblatt bleibt A4 hoch.
- **Büroangaben** pflegt Büroname und Büroanschrift (Planverfasser, für alle
Projekte).
- **Logo hinterlegen / ersetzen / entfernen** verwaltet das Büro-Logo.
## Seiten
1. **Deckblatt (A4 hoch)** – tabellarisch: Projekt, Planverfasser, Dokument,
Planstände (aus benannten Sicherungspunkten) und Inhalt. Leere Angaben
erscheinen als orangefarbenes „[ergänzen]“.
2. **Verteilerübersicht (quer)** – Versorgungsstruktur und Tabelle je
Verteiler mit Stromkreisanzahl, P<sub>inst</sub> (Anzahl × Leistung je
Stück), P<sub>Geräte</sub> (mit Geräte-GZF), Verteiler-GZF,
P<sub>gl</sub>, I<sub>B</sub> und „Σ inkl. unterlagert“.
3. **Verteilerpläne (quer, je Verteiler)** – Titelleiste mit
Verteilernummer, Bezeichnung, Örtlichkeit, Netzart, „Gespeist von“ und
P<sub>gl</sub>; darunter Kopfkomponenten, Gruppen, Stromkreise
(mehrzeilige mit Sammelzeile und eingerückten Gerätezeilen),
Gruppensummen und Verteilersumme. Fehlende Kabelangaben sind als „offen“
markiert. Die Titelleiste steht im Tabellenkopf und wiederholt sich beim
Drucken auf Folgeseiten.
Seitenformat, Ränder und „Seite x von y“ kommen beim Drucken aus benannten
`@page`-Regeln (`lb-portrait`, `lb-landscape`), die die Seite zur Laufzeit
einfügt. Die Bildschirmansicht zeigt Blätter in Papiergröße; die exakte
Seitenaufteilung langer Verteilerpläne entsteht erst beim Drucken.
## Vorläufige Punkte
- **Einspeisung / Hierarchie:** `fedFrom` ist Freitext. Die Übersicht ordnet
einen Verteiler nur dann unter, wenn der Text eindeutig der Nummer oder
Bezeichnung eines anderen Verteilers entspricht (ohne Beachtung von
Groß-/Kleinschreibung und äußeren Leerzeichen). Mehrdeutige Treffer bleiben
ohne Zuordnung; „Σ inkl. unterlagert“ ist
eine einfache Summe ohne übergeordneten GZF. Die fachliche Verknüpfung
(Verteiler und/oder speisender Stromkreis) und die Summierungslogik sind
offen.
- **I<sub>B</sub>** ist ein symmetrischer Drehstromwert
S / (√3 · U<sub>3~</sub>) mit S aus P / cos φ je Gerätezeile
(fehlendes cos φ = 1) und Verteiler-GZF.
- **Corporate Design:** Farben (`--pd-*` in
`src/frontend/print/print-document.css`) und Schrift (Arial) sind aus Logo
und kbp-Präsentationsmaterial abgeleitet und werden durch die offizielle
CD-Vorgabe ersetzt, falls diese abweicht.
- **Planstände:** Die Spalte „Bearbeitung“ zeigt für jeden Planstand die
aktuelle Bearbeitung aus den Projekteinstellungen, weil Sicherungspunkte
(noch) keinen Bearbeiter speichern.
## Datenquellen des Deckblatts
| Feld | Quelle |
|---|---|
| Bauvorhaben, Bauherr, Projektnummern, Beschreibung | Projekteinstellungen |
| Anschrift Bauvorhaben, Leistungsphase, Projektleitung, Bearbeitung | Projekteinstellungen → Abschnitt „Deckblatt“ (`siteAddress`, `workStage`, `projectLead`, `projectEditor`) |
| Büro, Anschrift Büro | Seitenansicht → „Büroangaben“ (global) |
| Stand, Datenstand, Anzahl Verteiler, Inhalt | automatisch |
| Planstände | benannte Sicherungspunkte |
Die vier Projektfelder laufen über `project.update-settings` (Schema-Version
3, Migration `0008`) und sind Teil von Snapshot-Schema 6 und Projektexport.
Im `PUT /api/projects/:projectId` sind sie optional; fehlende Felder behalten
ihren Wert.
## Büroangaben
- Tabelle `document_branding_settings` (Schlüssel/Wert, Migration `0008`),
global wie das Logo.
- `GET /api/document-branding` liefert zusätzlich
`office: { officeName, officeAddress }`.
- `PUT /api/document-branding/office` mit `{ "officeName", "officeAddress" }`
(max. 200 / 500 Zeichen, leerer Text = nicht gesetzt).
## Logo
- Globale Büro-Einstellung, nicht Teil von Projekten, Revisionen, Snapshots
oder Projektexport.
- Tabelle `document_branding_assets` (Migration `0007`).
- `GET /api/document-branding` → `{ logo: { mimeType, fileName, sizeBytes, updatedAtIso } | null }`
- `GET /api/document-branding/logo` → Bilddaten (`404` ohne Logo), mit
`X-Content-Type-Options: nosniff` und restriktiver CSP.
- `PUT /api/document-branding/logo` mit
`{ "fileName", "mimeType": "image/png" | "image/jpeg" | "image/svg+xml", "contentBase64" }`,
maximal 2 MB; der Dateiinhalt wird gegen das Format geprüft.
- `DELETE /api/document-branding/logo` → `204`.
## Code
- `src/app/projects/[projectId]/print/page.tsx` – Datenladen, Werkzeugleiste,
`@page`-Regeln
- `src/frontend/print/print-document-model.ts` – reine Berechnungen und
Formatierung (Tests: `tests/print-document-model.test.ts`)
- `src/frontend/print/print-document.tsx` – Blätter und Tabellen
- `src/frontend/print/print-document.css` – Layout, auf `.print-view`
begrenzt
- `src/frontend/components/distribution-board-info-card.tsx` – Infokarte im
Bearbeiten-Dialog der Verteilung
## Bestehende Datenbanken
Vor dem Anwenden der additiven Migrationen `0007` und `0008` eine verifizierte
Datenbanksicherung mit `npm run db:backup` erstellen. Die neuen Projekt- und
Verteilerfelder bleiben bei bestehenden Datensätzen zunächst leer (`null`).
Snapshot-Schema 6 enthält beide Feldgruppen; die unterstützten Versionen 1–5
werden beim Lesen hochgestuft. Restore und Projektkopie erhalten die neuen
Angaben. Globale Büroangaben und Logo gehören zur Datenbanksicherung, nicht
zum logischen Projektsnapshot oder Projekttransfer.

View file

@ -113,10 +113,6 @@ Implemented automatic-snapshot policy:
## Revit / CSV / IFCGUID Round-Trip Direction
**Deferred:** further Revit work will be tackled later, then through the
CSV export/import of the Linear plugin in Revit instead of Dynamo. Details:
[Revit-CSV requirements, section 1](spec/revit-csv-integration-requirements.md).
External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
Conceptual entities:
@ -279,10 +275,6 @@ Completed foundation:
- `project-floor.insert` and `project-room.insert` persist complete location
records with stable ids; their inverses reject changed or referenced records
so Undo never clears room assignments or device-row links implicitly
- `project-building.insert/.update/.delete` persist complete building records;
deleting a building with assigned floors is rejected. Floor commands carry
`buildingId` since command version 2; version 1 floor commands from older
history are upgraded with `buildingId = null` before execution
- the floor and room POST routes require the current expected revision and the
project page tracks the returned history state

View file

@ -1,9 +1,5 @@
# Kontext für die Revit-Anforderungsplanung
**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann
über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo.
Details: [Revit-CSV-Anforderungen, Abschnitt 1](spec/revit-csv-integration-requirements.md).
## Zweck dieser Datei
Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei

View file

@ -532,10 +532,6 @@ Acceptance criteria:
## Phase 14: External Model Round-Trip
**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann
über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo.
Details: [Revit-CSV-Anforderungen, Abschnitt 1](revit-csv-integration-requirements.md).
Status: Phase 14.2 (persistente externe Objekte und bestätigter Erstimport) ist
implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe
[Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md)
@ -544,7 +540,7 @@ und die verbindlichen
Goal:
Exchange model objects and planning parameters with Revit through CSV (Linear plugin export/import).
Exchange model objects and planning parameters with Revit or comparable tools through CSV or another agreed format.
Tasks:

View file

@ -47,9 +47,8 @@ requirements and intended sequencing, not proof of implementation.
- [x] Phase F: documentation and full GUI verification.
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
the planner supplies the calculation requirements. The optional per-circuit
cable sizing module (`docs/cable-sizing-module.md`) exists; its supported
tables were confirmed against DIN VDE 0298-4:2023-06 with
Berichtigung 1:2023-10. Unsupported arrangements stay explicitly blocked.
cable sizing module (`docs/cable-sizing-module.md`) exists; its tables must
be confirmed by the planner against DIN VDE 0298-4.
- Detailed specification:
`docs/spec/09-distribution-board-components-and-protection-groups.md`
@ -144,5 +143,4 @@ The pre-release cleanup established the following compatibility boundary:
The Revit/CSV/IFCGUID round-trip follows the distribution-board component
foundation as a separate jointly planned phase and must not be inferred from
these tasks. It is currently deferred and will later use the CSV
export/import of the Linear plugin in Revit, not Dynamo.
these tasks.

View file

@ -2,19 +2,6 @@
## 1. Status und Verbindlichkeit
> **Zurückgestellt (September 2026).** Die weitere Revit-Arbeit
> (Editor-Oberfläche zur Objektzuordnung, Folgeimport, Rückexport) wird später
> angegangen. Wenn sie wieder aufgenommen wird, bleibt der Austausch
> CSV-basiert: Export und Import laufen über das Linear-Plugin in Revit. Das
> entspricht der Vorgabe des internen Revit/CAD-Lenkungskreises, weil dabei
> nicht direkt per Dynamo im Modell gearbeitet wird. Eine Dynamo- oder
> API-Anbindung ist nicht vorgesehen; die früheren Vorüberlegungen liegen unter
> [docs/archive](../archive/revit-dynamo-leistungsbilanz-integration-konzept.md).
> Der Aufbau der Linear-CSV ist noch offen. Vor der Wiederaufnahme wird ein
> Musterexport gegen die bestehende projektweite CSV-Konfiguration und den
> Parser geprüft; erst danach wird entschieden, was anzupassen ist. Die bereits
> umgesetzten Schritte bleiben unverändert nutzbar.
Dieses Dokument ist die verbindliche fachliche und technische Arbeitsgrundlage für **Phase 14: External Model Round-Trip**.
Es unterscheidet ausdrücklich zwischen:
@ -1351,7 +1338,7 @@ Diese Punkte blockieren die Erstellung dieser Spezifikation nicht.
- automatische raumbezogene Stromkreisvorschläge;
- projektübergreifend wiederverwendbare Mappingprofile;
- Teilmodelle und mehrere externe Quellen;
- Dynamo/API/JSON-Transportadapter (nicht vorgesehen, siehe Abschnitt 1);
- Dynamo/API/JSON-Transportadapter;
- Rollen und Berechtigungen;
- Hintergrundjobs;
- PostgreSQL.

View file

@ -2,10 +2,6 @@
## Status
**Zurückgestellt:** Die weitere Revit-Arbeit wird später angegangen, dann
über den CSV-Export/-Import des Linear-Plugins in Revit statt über Dynamo.
Details: [Revit-CSV-Anforderungen, Abschnitt 1](revit-csv-integration-requirements.md).
Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den
gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 ist abgeschlossen.
Phase 14.2 hat mit den externen Domänenverträgen, Identitätsregeln und dem

View file

@ -0,0 +1,385 @@
"""Read-only Revit 2026 model-identity diagnostics for a Dynamo Python node.
Optional Dynamo input:
IN[0]: output directory or complete .json file path
The script intentionally performs no Revit transaction and changes no model
data. OUT contains a compact summary plus the complete report.
"""
import datetime
import json
import os
import platform
import sys
import tempfile
import traceback
import clr
clr.AddReference("RevitAPI")
clr.AddReference("RevitServices")
from Autodesk.Revit.DB import ModelPathUtils, StorageType # noqa: E402
from RevitServices.Persistence import DocumentManager # noqa: E402
CHECKED_PARAMETER_NAMES = ("LB_ModelId", "LB_ProjectId")
def safe_text(value):
if value is None:
return None
try:
return str(value)
except Exception:
return None
def element_id_text(element_id):
if element_id is None:
return None
try:
return str(element_id.Value)
except Exception:
try:
return str(element_id.IntegerValue)
except Exception:
return safe_text(element_id)
def forge_type_id_text(value):
if value is None:
return None
try:
return value.TypeId
except Exception:
return safe_text(value)
def parameter_value(parameter):
result = {
"hasValue": False,
"raw": None,
"display": None,
}
try:
result["hasValue"] = bool(parameter.HasValue)
except Exception:
pass
try:
storage_type = parameter.StorageType
if storage_type == StorageType.String:
result["raw"] = parameter.AsString()
elif storage_type == StorageType.Integer:
result["raw"] = int(parameter.AsInteger())
elif storage_type == StorageType.Double:
result["raw"] = float(parameter.AsDouble())
elif storage_type == StorageType.ElementId:
result["raw"] = element_id_text(parameter.AsElementId())
except Exception as error:
result["readError"] = safe_text(error)
try:
result["display"] = parameter.AsValueString()
except Exception:
pass
return result
def describe_parameter(parameter):
definition = None
try:
definition = parameter.Definition
except Exception:
pass
name = None
if definition is not None:
try:
name = definition.Name
except Exception:
pass
is_shared = False
try:
is_shared = bool(parameter.IsShared)
except Exception:
pass
shared_guid = None
if is_shared:
try:
shared_guid = str(parameter.GUID)
except Exception:
pass
data_type = None
group_type = None
if definition is not None:
try:
data_type = forge_type_id_text(definition.GetDataType())
except Exception:
pass
try:
group_type = forge_type_id_text(definition.GetGroupTypeId())
except Exception:
pass
unit_type = None
try:
unit_type = forge_type_id_text(parameter.GetUnitTypeId())
except Exception:
pass
try:
storage_type = str(parameter.StorageType)
except Exception:
storage_type = None
try:
is_read_only = bool(parameter.IsReadOnly)
except Exception:
is_read_only = None
try:
user_modifiable = bool(parameter.UserModifiable)
except Exception:
user_modifiable = None
return {
"name": name,
"parameterId": element_id_text(getattr(parameter, "Id", None)),
"isShared": is_shared,
"sharedGuid": shared_guid,
"storageType": storage_type,
"dataTypeId": data_type,
"groupTypeId": group_type,
"unitTypeId": unit_type,
"isReadOnly": is_read_only,
"userModifiable": user_modifiable,
"value": parameter_value(parameter),
}
def sorted_parameters(element):
parameters = []
try:
parameters = [describe_parameter(parameter) for parameter in element.Parameters]
except Exception:
return []
return sorted(
parameters,
key=lambda parameter: (
(parameter.get("name") or "").casefold(),
parameter.get("parameterId") or "",
),
)
def named_parameter_occurrences(element, parameter_name):
result = []
try:
parameters = element.GetParameters(parameter_name)
if parameters is not None:
result = [describe_parameter(parameter) for parameter in parameters]
except Exception:
parameter = None
try:
parameter = element.LookupParameter(parameter_name)
except Exception:
pass
if parameter is not None:
result = [describe_parameter(parameter)]
return result
def loaded_assembly_versions():
result = {}
try:
from System import AppDomain
for assembly in AppDomain.CurrentDomain.GetAssemblies():
try:
name = assembly.GetName()
simple_name = str(name.Name)
if simple_name in (
"DynamoCore",
"DynamoCoreWpf",
"DynamoRevitDS",
"RevitAPI",
"RevitServices",
):
result[simple_name] = str(name.Version)
except Exception:
continue
except Exception:
pass
return dict(sorted(result.items()))
def get_cloud_identity(document):
result = {"isModelInCloud": False}
try:
result["isModelInCloud"] = bool(document.IsModelInCloud)
except Exception:
return result
if not result["isModelInCloud"]:
return result
try:
model_path = document.GetCloudModelPath()
result["userVisiblePath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath(
model_path
)
for property_name, output_name in (
("GetProjectGUID", "projectGuid"),
("GetModelGUID", "modelGuid"),
):
try:
result[output_name] = str(getattr(model_path, property_name)())
except Exception:
pass
except Exception as error:
result["readError"] = safe_text(error)
return result
def get_worksharing_identity(document):
result = {"isWorkshared": False}
try:
result["isWorkshared"] = bool(document.IsWorkshared)
except Exception:
return result
if not result["isWorkshared"]:
return result
try:
model_path = document.GetWorksharingCentralModelPath()
result["centralModelPath"] = ModelPathUtils.ConvertModelPathToUserVisiblePath(
model_path
)
except Exception as error:
result["readError"] = safe_text(error)
return result
def resolve_output_path(configured_path, report_name):
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo")
raw_path = safe_text(configured_path)
if raw_path is None or not raw_path.strip():
directory = default_directory
file_path = os.path.join(directory, report_name + "-" + timestamp + ".json")
else:
expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip())))
if expanded.lower().endswith(".json"):
file_path = expanded
directory = os.path.dirname(file_path)
else:
directory = expanded
file_path = os.path.join(directory, report_name + "-" + timestamp + ".json")
if not directory:
directory = os.getcwd()
if not os.path.isdir(directory):
os.makedirs(directory)
return file_path
def write_json(file_path, payload):
temporary_path = file_path + ".tmp"
with open(temporary_path, "w", encoding="utf-8", newline="\n") as output:
json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True)
output.write("\n")
os.replace(temporary_path, file_path)
def get_input(index, default=None):
values = globals().get("IN", [])
try:
value = values[index]
return default if value is None else value
except Exception:
return default
def build_report():
document = DocumentManager.Instance.CurrentDBDocument
if document is None:
raise RuntimeError("No active Revit document is available.")
project_information = document.ProjectInformation
if project_information is None:
raise RuntimeError("The active document has no Project Information element.")
application = document.Application
checked_parameters = {
name: named_parameter_occurrences(project_information, name)
for name in CHECKED_PARAMETER_NAMES
}
warnings = []
for name in CHECKED_PARAMETER_NAMES:
occurrences = checked_parameters[name]
populated = [
parameter
for parameter in occurrences
if parameter.get("value", {}).get("raw") not in (None, "")
]
if not occurrences:
warnings.append(name + " is not bound to Project Information.")
elif not populated:
warnings.append(name + " exists but has no value on Project Information.")
elif len(occurrences) > 1:
warnings.append(name + " occurs more than once; use a shared-parameter GUID later.")
return {
"reportSchemaVersion": 1,
"generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"readOnly": True,
"environment": {
"revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)),
"revitVersionName": safe_text(getattr(application, "VersionName", None)),
"revitSubVersionNumber": safe_text(
getattr(application, "SubVersionNumber", None)
),
"pythonImplementation": safe_text(getattr(sys.implementation, "name", None)),
"pythonVersion": platform.python_version(),
"assemblies": loaded_assembly_versions(),
},
"document": {
"title": safe_text(document.Title),
"pathName": safe_text(document.PathName),
"isFamilyDocument": bool(document.IsFamilyDocument),
"cloud": get_cloud_identity(document),
"worksharing": get_worksharing_identity(document),
},
"modelIdentityCandidates": {
"projectInformationUniqueId": safe_text(project_information.UniqueId),
"projectInformationElementId": element_id_text(project_information.Id),
"checkedProjectParameters": checked_parameters,
},
"projectInformationParameters": sorted_parameters(project_information),
"warnings": warnings,
}
try:
report = build_report()
output_path = resolve_output_path(get_input(0), "model-identity")
write_json(output_path, report)
OUT = {
"ok": True,
"filePath": output_path,
"projectInformationUniqueId": report["modelIdentityCandidates"][
"projectInformationUniqueId"
],
"warnings": report["warnings"],
"report": report,
}
except Exception as error:
OUT = {
"ok": False,
"error": safe_text(error),
"traceback": traceback.format_exc(),
}

View file

@ -0,0 +1,517 @@
"""Export all Electrical Fixtures instance/type parameters from Revit 2026.
Optional Dynamo inputs:
IN[0]: output directory or complete .json file path
IN[1]: include empty parameters (default True)
IN[2]: maximum aggregated sample values (default 5)
The script is read-only and performs no Revit transaction.
"""
import datetime
import json
import os
import platform
import sys
import tempfile
import traceback
import clr
clr.AddReference("RevitAPI")
clr.AddReference("RevitServices")
from Autodesk.Revit.DB import ( # noqa: E402
BuiltInCategory,
FilteredElementCollector,
ModelPathUtils,
StorageType,
)
from RevitServices.Persistence import DocumentManager # noqa: E402
def safe_text(value):
if value is None:
return None
try:
return str(value)
except Exception:
return None
def element_id_text(element_id):
if element_id is None:
return None
try:
return str(element_id.Value)
except Exception:
try:
return str(element_id.IntegerValue)
except Exception:
return safe_text(element_id)
def forge_type_id_text(value):
if value is None:
return None
try:
return value.TypeId
except Exception:
return safe_text(value)
def parameter_value(parameter):
result = {"hasValue": False, "raw": None, "display": None}
try:
result["hasValue"] = bool(parameter.HasValue)
except Exception:
pass
try:
storage_type = parameter.StorageType
if storage_type == StorageType.String:
result["raw"] = parameter.AsString()
elif storage_type == StorageType.Integer:
result["raw"] = int(parameter.AsInteger())
elif storage_type == StorageType.Double:
result["raw"] = float(parameter.AsDouble())
elif storage_type == StorageType.ElementId:
result["raw"] = element_id_text(parameter.AsElementId())
except Exception as error:
result["readError"] = safe_text(error)
try:
result["display"] = parameter.AsValueString()
except Exception:
pass
return result
def describe_parameter(parameter, scope):
definition = None
try:
definition = parameter.Definition
except Exception:
pass
name = None
data_type = None
group_type = None
if definition is not None:
try:
name = definition.Name
except Exception:
pass
try:
data_type = forge_type_id_text(definition.GetDataType())
except Exception:
pass
try:
group_type = forge_type_id_text(definition.GetGroupTypeId())
except Exception:
pass
is_shared = False
try:
is_shared = bool(parameter.IsShared)
except Exception:
pass
shared_guid = None
if is_shared:
try:
shared_guid = str(parameter.GUID)
except Exception:
pass
unit_type = None
try:
unit_type = forge_type_id_text(parameter.GetUnitTypeId())
except Exception:
pass
try:
storage_type = str(parameter.StorageType)
except Exception:
storage_type = None
try:
is_read_only = bool(parameter.IsReadOnly)
except Exception:
is_read_only = None
try:
user_modifiable = bool(parameter.UserModifiable)
except Exception:
user_modifiable = None
return {
"scope": scope,
"name": name,
"parameterId": element_id_text(getattr(parameter, "Id", None)),
"isShared": is_shared,
"sharedGuid": shared_guid,
"storageType": storage_type,
"dataTypeId": data_type,
"groupTypeId": group_type,
"unitTypeId": unit_type,
"isReadOnly": is_read_only,
"userModifiable": user_modifiable,
"value": parameter_value(parameter),
}
def has_meaningful_value(parameter_description):
value = parameter_description.get("value", {})
return bool(value.get("hasValue")) or value.get("raw") not in (None, "") or value.get(
"display"
) not in (None, "")
def read_parameters(element, scope, include_empty):
result = []
try:
for parameter in element.Parameters:
description = describe_parameter(parameter, scope)
if include_empty or has_meaningful_value(description):
result.append(description)
except Exception as error:
return [], [safe_text(error)]
result.sort(
key=lambda parameter: (
(parameter.get("name") or "").casefold(),
parameter.get("parameterId") or "",
)
)
return result, []
def read_space(element, document):
try:
space = element.Space
except Exception as error:
return None, safe_text(error)
if space is None:
return None, None
level_name = None
try:
level = document.GetElement(space.LevelId)
level_name = None if level is None else safe_text(level.Name)
except Exception:
pass
return {
"uniqueId": safe_text(space.UniqueId),
"elementId": element_id_text(space.Id),
"number": safe_text(getattr(space, "Number", None)),
"name": safe_text(getattr(space, "Name", None)),
"levelName": level_name,
}, None
def read_family_identity(element, document):
symbol = None
try:
symbol = element.Symbol
except Exception:
try:
symbol = document.GetElement(element.GetTypeId())
except Exception:
pass
family_name = None
type_name = None
type_unique_id = None
type_element_id = None
if symbol is not None:
try:
family_name = safe_text(symbol.Family.Name)
except Exception:
family_name = safe_text(getattr(symbol, "FamilyName", None))
type_name = safe_text(getattr(symbol, "Name", None))
type_unique_id = safe_text(getattr(symbol, "UniqueId", None))
type_element_id = element_id_text(getattr(symbol, "Id", None))
return {
"familyName": family_name,
"typeName": type_name,
"typeUniqueId": type_unique_id,
"typeElementId": type_element_id,
}, symbol
def parameter_inventory_key(parameter):
stable_id = parameter.get("sharedGuid") or parameter.get("parameterId") or ""
return "|".join(
(
parameter.get("scope") or "",
stable_id,
parameter.get("name") or "",
parameter.get("dataTypeId") or "",
)
)
def sample_value_key(value):
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def add_to_inventory(inventory, parameter, max_samples):
key = parameter_inventory_key(parameter)
entry = inventory.get(key)
if entry is None:
entry = {
"scope": parameter.get("scope"),
"name": parameter.get("name"),
"parameterId": parameter.get("parameterId"),
"isShared": parameter.get("isShared"),
"sharedGuid": parameter.get("sharedGuid"),
"storageType": parameter.get("storageType"),
"dataTypeId": parameter.get("dataTypeId"),
"groupTypeId": parameter.get("groupTypeId"),
"unitTypeId": parameter.get("unitTypeId"),
"occurrenceCount": 0,
"populatedCount": 0,
"sampleValues": [],
"_sampleKeys": set(),
}
inventory[key] = entry
entry["occurrenceCount"] += 1
if has_meaningful_value(parameter):
entry["populatedCount"] += 1
value = parameter.get("value")
value_key = sample_value_key(value)
if len(entry["sampleValues"]) < max_samples and value_key not in entry["_sampleKeys"]:
entry["_sampleKeys"].add(value_key)
entry["sampleValues"].append(value)
def finalize_inventory(inventory):
result = []
for entry in inventory.values():
clean_entry = dict(entry)
clean_entry.pop("_sampleKeys", None)
result.append(clean_entry)
return sorted(
result,
key=lambda entry: (
entry.get("scope") or "",
(entry.get("name") or "").casefold(),
entry.get("sharedGuid") or entry.get("parameterId") or "",
),
)
def loaded_assembly_versions():
result = {}
try:
from System import AppDomain
for assembly in AppDomain.CurrentDomain.GetAssemblies():
try:
name = assembly.GetName()
simple_name = str(name.Name)
if simple_name in (
"DynamoCore",
"DynamoCoreWpf",
"DynamoRevitDS",
"RevitAPI",
"RevitServices",
):
result[simple_name] = str(name.Version)
except Exception:
continue
except Exception:
pass
return dict(sorted(result.items()))
def resolve_output_path(configured_path):
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
default_directory = os.path.join(tempfile.gettempdir(), "leistungsbilanz-dynamo")
raw_path = safe_text(configured_path)
if raw_path is None or not raw_path.strip():
directory = default_directory
file_path = os.path.join(
directory, "electrical-fixture-parameters-" + timestamp + ".json"
)
else:
expanded = os.path.abspath(os.path.expandvars(os.path.expanduser(raw_path.strip())))
if expanded.lower().endswith(".json"):
file_path = expanded
directory = os.path.dirname(file_path)
else:
directory = expanded
file_path = os.path.join(
directory, "electrical-fixture-parameters-" + timestamp + ".json"
)
if not directory:
directory = os.getcwd()
if not os.path.isdir(directory):
os.makedirs(directory)
return file_path
def write_json(file_path, payload):
temporary_path = file_path + ".tmp"
with open(temporary_path, "w", encoding="utf-8", newline="\n") as output:
json.dump(payload, output, ensure_ascii=False, indent=2, sort_keys=True)
output.write("\n")
os.replace(temporary_path, file_path)
def get_input(index, default=None):
values = globals().get("IN", [])
try:
value = values[index]
return default if value is None else value
except Exception:
return default
def build_report(include_empty, max_samples):
document = DocumentManager.Instance.CurrentDBDocument
if document is None:
raise RuntimeError("No active Revit document is available.")
if document.IsFamilyDocument:
raise RuntimeError("Open a Revit project document, not a family document.")
application = document.Application
collector = (
FilteredElementCollector(document)
.OfCategory(BuiltInCategory.OST_ElectricalFixtures)
.WhereElementIsNotElementType()
)
source_elements = list(collector)
source_elements.sort(key=lambda element: safe_text(element.UniqueId) or "")
elements = []
types_by_unique_id = {}
inventory = {}
errors = []
elements_without_space = 0
for element in source_elements:
element_errors = []
try:
family_identity, symbol = read_family_identity(element, document)
instance_parameters, parameter_errors = read_parameters(
element, "instance", include_empty
)
element_errors.extend(parameter_errors)
for parameter in instance_parameters:
add_to_inventory(inventory, parameter, max_samples)
type_unique_id = family_identity.get("typeUniqueId")
if symbol is not None and type_unique_id and type_unique_id not in types_by_unique_id:
type_parameters, type_errors = read_parameters(symbol, "type", include_empty)
element_errors.extend(type_errors)
for parameter in type_parameters:
add_to_inventory(inventory, parameter, max_samples)
types_by_unique_id[type_unique_id] = {
**family_identity,
"parameters": type_parameters,
}
space, space_error = read_space(element, document)
if space_error:
element_errors.append("MEP Space: " + space_error)
if space is None:
elements_without_space += 1
elements.append(
{
"uniqueId": safe_text(element.UniqueId),
"elementId": element_id_text(element.Id),
"categoryName": safe_text(
None if element.Category is None else element.Category.Name
),
"family": family_identity,
"space": space,
"instanceParameters": instance_parameters,
"warnings": element_errors,
}
)
except Exception as error:
errors.append(
{
"uniqueId": safe_text(getattr(element, "UniqueId", None)),
"elementId": element_id_text(getattr(element, "Id", None)),
"error": safe_text(error),
}
)
return {
"reportSchemaVersion": 1,
"generatedAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"readOnly": True,
"complete": len(errors) == 0,
"scope": {
"builtInCategory": "OST_ElectricalFixtures",
"wholeDocument": True,
"elementTypesExcluded": True,
"includeEmptyParameters": include_empty,
},
"environment": {
"revitVersionNumber": safe_text(getattr(application, "VersionNumber", None)),
"revitVersionName": safe_text(getattr(application, "VersionName", None)),
"revitSubVersionNumber": safe_text(
getattr(application, "SubVersionNumber", None)
),
"pythonImplementation": safe_text(getattr(sys.implementation, "name", None)),
"pythonVersion": platform.python_version(),
"assemblies": loaded_assembly_versions(),
},
"document": {
"title": safe_text(document.Title),
"pathName": safe_text(document.PathName),
"projectInformationUniqueId": safe_text(document.ProjectInformation.UniqueId),
},
"summary": {
"elementCount": len(source_elements),
"exportedElementCount": len(elements),
"typeCount": len(types_by_unique_id),
"parameterDefinitionCount": len(inventory),
"elementsWithoutMepSpace": elements_without_space,
"elementErrorCount": len(errors),
},
"parameterInventory": finalize_inventory(inventory),
"types": sorted(
types_by_unique_id.values(),
key=lambda entry: (
(entry.get("familyName") or "").casefold(),
(entry.get("typeName") or "").casefold(),
entry.get("typeUniqueId") or "",
),
),
"elements": elements,
"errors": errors,
}
try:
include_empty_input = get_input(1, True)
include_empty = bool(include_empty_input)
try:
max_samples = int(get_input(2, 5))
except Exception:
max_samples = 5
max_samples = max(0, min(max_samples, 50))
report = build_report(include_empty, max_samples)
output_path = resolve_output_path(get_input(0))
write_json(output_path, report)
OUT = {
"ok": True,
"filePath": output_path,
"complete": report["complete"],
"summary": report["summary"],
"errors": report["errors"],
}
except Exception as error:
OUT = {
"ok": False,
"error": safe_text(error),
"traceback": traceback.format_exc(),
}

69
dynamo/README.md Normal file
View file

@ -0,0 +1,69 @@
# Revit 2026 / Dynamo diagnostics
This directory contains self-contained Python scripts for a Dynamo **Python
Script** node. They use only Dynamo's built-in Revit integration, the Revit API
and the Python standard library. No Dynamo package is required.
The scripts are read-only. They do not start a Revit transaction and do not
change the open model.
## Python engine
Use the built-in `CPython3` engine in Revit 2026. Autodesk ships Dynamo with
Revit; optional PythonNet3 packages are not required by these diagnostics.
## 01 - Check model identity
File: `01_check_model_identity.py`
The script reports:
- Revit, Dynamo and Python versions;
- `ProjectInformation.UniqueId` as a native model-identity candidate;
- all occurrences and values of `LB_ModelId` and `LB_ProjectId` on Project
Information;
- all Project Information parameters;
- optional cloud/worksharing identity information when the API exposes it.
Input `IN[0]` is optional. It may be either an output directory or a complete
`.json` file path. With no input, the report is written below the current
Windows temporary directory in `leistungsbilanz-dynamo`.
## 02 - Inventory Electrical Fixtures parameters
File: `02_export_electrical_fixture_parameter_inventory.py`
The script reads every instance of
`BuiltInCategory.OST_ElectricalFixtures` in the complete current document. It
exports:
- element, family, type and MEP Space identities;
- every instance parameter and value;
- every unique family-type parameter and value;
- an aggregated parameter inventory with occurrence counts and sample values;
- per-element warnings instead of aborting at the first unreadable element.
Inputs:
- `IN[0]` (optional): output directory or complete `.json` path;
- `IN[1]` (optional): include empty parameters, default `true`;
- `IN[2]` (optional): maximum sample values per aggregated parameter, default
`5`.
The default output location is again the Windows temporary directory. The
generated report can contain model paths and project-specific parameter values;
review it before sharing or committing it.
## Running a script
1. Open the target model in Revit 2026.
2. Open Dynamo from **Manage > Visual Programming > Dynamo**.
3. Create a graph and add a **Python Script** node.
4. Select the `CPython3` engine for the node.
5. Copy the complete content of the desired `.py` file into the node.
6. Optionally connect a String node containing the output path to `IN[0]`.
7. Run the graph and inspect `OUT` for status, counts and the generated path.
For the first test, run `01_check_model_identity.py` in the Revit main model.
Then run the parameter inventory. Keep both generated JSON files so their
structure can be checked before the production snapshot DTO is finalized.

View file

@ -5,10 +5,6 @@ const dbPath = path.resolve("data", "leistungsbilanz.db");
const db = new Database(dbPath, { readonly: true });
const requiredTables = [
"buildings",
"estimate_balances",
"estimate_groups",
"estimate_rows",
"circuit_sections",
"circuits",
"circuit_device_rows",
@ -16,8 +12,6 @@ const requiredTables = [
"circuit_protection_devices",
"distribution_board_components",
"distribution_board_component_protection_devices",
"document_branding_assets",
"document_branding_settings",
];
const tablePlaceholders = requiredTables.map(() => "?").join(", ");
@ -93,10 +87,6 @@ const requiredProjectColumns = [
"description",
"is_public_building",
"enabled_distribution_board_supply_types",
"site_address",
"work_stage",
"project_lead",
"project_editor",
];
const projectColumns = new Set(
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)
@ -104,21 +94,10 @@ const projectColumns = new Set(
const missingProjectColumns = requiredProjectColumns.filter(
(name) => !projectColumns.has(name)
);
const requiredFloorColumns = ["building_id"];
const floorColumns = new Set(
db.prepare("PRAGMA table_info(floors)").all().map((column) => column.name)
);
const missingFloorColumns = requiredFloorColumns.filter(
(name) => !floorColumns.has(name)
);
const requiredDistributionBoardColumns = [
"floor_id",
"supply_type",
"simultaneity_factor",
"board_number",
"location",
"fed_from",
"remark",
];
const distributionBoardColumns = new Set(
db
@ -166,7 +145,6 @@ console.log(
requiredCircuitSectionColumns.join(", ")
);
console.log("Required project columns:", requiredProjectColumns.join(", "));
console.log("Required floor columns:", requiredFloorColumns.join(", "));
console.log(
"Required distribution-board columns:",
requiredDistributionBoardColumns.join(", ")
@ -183,11 +161,6 @@ if (missing.length > 0) {
process.exit(1);
}
if (missingFloorColumns.length > 0) {
console.error("Missing floor columns:", missingFloorColumns.join(", "));
process.exit(1);
}
if (missingProjectDeviceColumns.length > 0) {
console.error("Missing project-device columns:", missingProjectDeviceColumns.join(", "));
process.exit(1);

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,5 @@
"use client";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
@ -11,20 +10,10 @@ export default function CircuitTreeEditPage() {
return (
<main className="container-fluid px-3 px-xxl-4 py-4">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div className="d-flex align-items-center gap-2">
<Link
aria-label="Zurück zur Verteilerübersicht"
className="btn btn-outline-secondary btn-sm"
href={`/projects/${params.projectId}`}
title="Zurück zur Verteilerübersicht"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<h1 className="h4 mb-1">Stromkreisliste</h1>
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
</div>
</div>
<div className="d-flex gap-2">
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
Zum Projekt

View file

@ -1,745 +0,0 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { type MouseEvent, useCallback, useEffect, useRef, useState } from "react";
import {
ArrowLeft,
ChevronDown,
ChevronRight,
Printer,
Redo2,
RefreshCw,
Undo2,
} from "lucide-react";
import {
ApiRequestError,
getEstimateBalance,
getEstimateComparison,
getProject,
getProjectHistory,
isRevisionConflictError,
redoProjectCommand,
undoProjectCommand,
updateEstimateBalance,
} from "../../../../../frontend/utils/api";
import type {
EstimateBalanceDto,
EstimateComparisonDto,
ProjectDto,
ProjectHistoryStateDto,
} from "../../../../../frontend/types";
import type { EstimateGroupSnapshot } from "../../../../../shared/estimate-balance/estimate-balance";
import { GzfPickerModal } from "../../../../../frontend/components/gzf-picker-modal";
import {
addEstimateGroup,
estimateBalanceFingerprint,
estimateTextLimits,
normalizeEstimateSortOrder,
toSaveableEstimateBalance,
updateEstimateGroup,
updateEstimateRow,
validateEstimateBalance,
} from "../../../../../frontend/estimate-balance/estimate-balance-document";
import { formatIsoDateGerman } from "../../../../../frontend/estimate-balance/estimate-balance-format";
import {
EstimateBalanceSheet,
type EstimateBalanceUpdate,
} from "../../../../../frontend/estimate-balance/estimate-balance-sheet";
import { EstimateComparisonTable } from "../../../../../frontend/estimate-balance/estimate-comparison-table";
import { DraftInput } from "../../../../../frontend/estimate-balance/estimate-draft-input";
import { EstimatePrintHeader } from "../../../../../frontend/estimate-balance/estimate-print-header";
import {
EstimateGroupModal,
type EstimateGroupFormValues,
} from "../../../../../frontend/estimate-balance/estimate-group-modal";
const AUTOSAVE_DELAY_MS = 800;
const CONFLICT_MESSAGE = "Die Bilanz wurde zwischenzeitlich geändert und neu geladen.";
type SaveStatus = "saved" | "pending" | "saving" | "error" | "invalid";
type GroupDialog = { group: EstimateGroupSnapshot | null };
function errorMessage(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
function isNotFoundError(error: unknown) {
return error instanceof ApiRequestError && error.status === 404;
}
export default function EstimateBalanceEditorPage() {
const params = useParams<{ projectId: string; balanceId: string }>();
const { projectId, balanceId } = params;
const router = useRouter();
const [project, setProject] = useState<ProjectDto | null>(null);
const [draft, setDraft] = useState<EstimateBalanceDto | null>(null);
const [history, setHistory] = useState<ProjectHistoryStateDto | null>(null);
const [comparison, setComparison] = useState<EstimateComparisonDto | null>(null);
const [comparisonError, setComparisonError] = useState<string | null>(null);
const [isComparisonOpen, setIsComparisonOpen] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("saved");
const [saveError, setSaveError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isMissing, setIsMissing] = useState(false);
const [isHistoryBusy, setIsHistoryBusy] = useState(false);
const [invalidFieldKeys, setInvalidFieldKeys] = useState<ReadonlySet<string>>(
() => new Set()
);
const [groupDialog, setGroupDialog] = useState<GroupDialog | null>(null);
const [gzfTarget, setGzfTarget] = useState<{ groupId: string; rowId: string } | null>(
null
);
// Refs carry the latest values into timers, unload handlers and chained
// async saves without re-creating them.
const draftRef = useRef<EstimateBalanceDto | null>(null);
const revisionRef = useRef<number | null>(null);
const savedFingerprintRef = useRef<string | null>(null);
const saveInFlightRef = useRef<Promise<boolean> | null>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
const applyServerBalance = useCallback((balance: EstimateBalanceDto) => {
const normalized = normalizeEstimateSortOrder(balance);
savedFingerprintRef.current = estimateBalanceFingerprint(normalized);
draftRef.current = normalized;
setDraft(normalized);
setSaveStatus("saved");
setSaveError(null);
}, []);
const reloadComparison = useCallback(async () => {
try {
setComparison(await getEstimateComparison(projectId, balanceId));
setComparisonError(null);
} catch (err) {
setComparisonError(
errorMessage(err, "Der Vergleich konnte nicht geladen werden.")
);
}
}, [balanceId, projectId]);
/** Reloads project, document and history; replaces the local draft. */
const reloadAll = useCallback(async () => {
const balanceRequest = getEstimateBalance(projectId, balanceId);
// Handled below; avoids an "unhandled rejection" while project/history load.
balanceRequest.catch(() => undefined);
// Project and history are applied even when the balance is missing, so
// Redo stays available after its creation was undone.
const [currentProject, currentHistory] = await Promise.all([
getProject(projectId),
getProjectHistory(projectId).catch(() => null),
]);
setProject(currentProject);
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
setHistory(currentHistory);
const balance = await balanceRequest;
applyServerBalance(balance);
setIsMissing(false);
void reloadComparison();
}, [applyServerBalance, balanceId, projectId, reloadComparison]);
const handleReloadError = useCallback((err: unknown) => {
if (isNotFoundError(err)) {
setIsMissing(true);
setDraft(null);
draftRef.current = null;
return;
}
setError(errorMessage(err, "Die grobe Leistungsbilanz konnte nicht geladen werden."));
}, []);
useEffect(() => {
if (!projectId || !balanceId) {
return;
}
setIsLoading(true);
reloadAll()
.then(() => setError(null))
.catch(handleReloadError)
.finally(() => setIsLoading(false));
}, [balanceId, handleReloadError, projectId, reloadAll]);
const clearSaveTimer = useCallback(() => {
if (saveTimerRef.current) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
}, []);
/**
* Saves the current draft if it differs from the last saved state. Saves
* never overlap: a save requested while another runs waits for it and then
* saves whatever changed in the meantime. Resolves to false when the draft
* could not be saved.
*/
const saveNow = useCallback(async (): Promise<boolean> => {
clearSaveTimer();
while (saveInFlightRef.current) {
await saveInFlightRef.current;
}
const current = draftRef.current;
const revision = revisionRef.current;
if (!current || revision === null) {
return true;
}
const fingerprint = estimateBalanceFingerprint(current);
if (fingerprint === savedFingerprintRef.current) {
setSaveStatus("saved");
return true;
}
const problems = validateEstimateBalance(current);
if (problems.length) {
setSaveStatus("invalid");
setSaveError(problems.join(" "));
return false;
}
const body = toSaveableEstimateBalance(current);
/**
* Another command changed the project revision. When this document is
* still unchanged on the server (e.g. an edit in a circuit list), the
* draft is kept and saved again; otherwise the server state wins.
*/
async function resolveConflict(): Promise<"retry" | "reloaded"> {
const [currentProject, serverBalance, currentHistory] = await Promise.all([
getProject(projectId),
getEstimateBalance(projectId, balanceId),
getProjectHistory(projectId).catch(() => null),
]);
setProject(currentProject);
setHistory(currentHistory);
revisionRef.current = currentHistory?.currentRevision ?? currentProject.currentRevision;
if (estimateBalanceFingerprint(serverBalance) === savedFingerprintRef.current) {
return "retry";
}
applyServerBalance(serverBalance);
setNotice(CONFLICT_MESSAGE);
void reloadComparison();
return "reloaded";
}
const run = (async () => {
setSaveStatus("saving");
for (let attempt = 0; ; attempt += 1) {
try {
const result = await updateEstimateBalance(
projectId,
body,
attempt === 0 ? revision : revisionRef.current ?? revision
);
if (!("unchanged" in result)) {
revisionRef.current = result.history.currentRevision;
setHistory(result.history);
setProject((currentProject) =>
currentProject
? { ...currentProject, currentRevision: result.history.currentRevision }
: currentProject
);
}
// The sent document is the saved state; the local draft is kept so
// typing is never interrupted by the server's copy.
savedFingerprintRef.current = fingerprint;
setSaveError(null);
const latest = draftRef.current;
if (latest && estimateBalanceFingerprint(latest) !== fingerprint) {
setSaveStatus("pending");
} else {
setSaveStatus("saved");
}
void reloadComparison();
return true;
} catch (err) {
if (isRevisionConflictError(err)) {
try {
if (attempt === 0 && (await resolveConflict()) === "retry") {
continue;
}
if (attempt > 0) {
await reloadAll();
setNotice(CONFLICT_MESSAGE);
}
} catch (reloadError) {
handleReloadError(reloadError);
}
return false;
}
if (isNotFoundError(err)) {
handleReloadError(err);
return false;
}
setSaveStatus("error");
setSaveError(errorMessage(err, "Die Änderungen konnten nicht gespeichert werden."));
return false;
}
}
})();
saveInFlightRef.current = run;
try {
return await run;
} finally {
saveInFlightRef.current = null;
}
}, [
applyServerBalance,
balanceId,
clearSaveTimer,
handleReloadError,
projectId,
reloadAll,
reloadComparison,
]);
// Debounced autosave after every change of the draft.
useEffect(() => {
if (!draft) {
return;
}
if (estimateBalanceFingerprint(draft) === savedFingerprintRef.current) {
return;
}
setSaveStatus((status) => (status === "saving" ? status : "pending"));
clearSaveTimer();
saveTimerRef.current = setTimeout(() => {
saveTimerRef.current = null;
void saveNow();
}, AUTOSAVE_DELAY_MS);
return clearSaveTimer;
}, [clearSaveTimer, draft, saveNow]);
// Save immediately when the tab is hidden; on unload/unmount send the last
// change with keepalive so it survives the page.
useEffect(() => {
function hasUnsavedDraft() {
const current = draftRef.current;
return Boolean(
current && estimateBalanceFingerprint(current) !== savedFingerprintRef.current
);
}
function sendKeepalive(): boolean {
const current = draftRef.current;
const revision = revisionRef.current;
if (
!current ||
revision === null ||
saveInFlightRef.current ||
validateEstimateBalance(current).length
) {
return false;
}
savedFingerprintRef.current = estimateBalanceFingerprint(current);
void updateEstimateBalance(projectId, toSaveableEstimateBalance(current), revision, {
keepalive: true,
}).catch(() => undefined);
return true;
}
function handleVisibilityChange() {
if (document.visibilityState === "hidden" && hasUnsavedDraft()) {
void saveNow();
}
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (!hasUnsavedDraft() || sendKeepalive()) {
return;
}
// A save is still running or the draft is invalid: let the browser ask.
event.preventDefault();
event.returnValue = "";
}
document.addEventListener("visibilitychange", handleVisibilityChange);
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("beforeunload", handleBeforeUnload);
// In-app navigation (e.g. sidebar) unmounts the editor.
if (hasUnsavedDraft()) {
sendKeepalive();
}
};
}, [projectId, saveNow]);
const handleChange: EstimateBalanceUpdate = useCallback((update) => {
setNotice(null);
setDraft((current) => (current ? update(current) : current));
}, []);
const handleValidityChange = useCallback((fieldKey: string, isValid: boolean) => {
setInvalidFieldKeys((current) => {
if (isValid ? !current.has(fieldKey) : current.has(fieldKey)) {
return current;
}
const next = new Set(current);
if (isValid) {
next.delete(fieldKey);
} else {
next.add(fieldKey);
}
return next;
});
}, []);
async function handleHistory(direction: "undo" | "redo") {
setError(null);
setNotice(null);
setIsHistoryBusy(true);
try {
// Pending edits are saved first so they become their own undo step.
await saveNow();
const revision = revisionRef.current;
if (revision === null) {
return;
}
const result =
direction === "undo"
? await undoProjectCommand(projectId, revision)
: await redoProjectCommand(projectId, revision);
revisionRef.current = result.history.currentRevision;
setHistory(result.history);
await reloadAll();
} catch (err) {
if (isRevisionConflictError(err)) {
await reloadAll().catch(handleReloadError);
setNotice(CONFLICT_MESSAGE);
} else if (isNotFoundError(err)) {
handleReloadError(err);
} else {
setError(
errorMessage(
err,
direction === "undo"
? "Die letzte Änderung konnte nicht rückgängig gemacht werden."
: "Die letzte Änderung konnte nicht wiederholt werden."
)
);
}
} finally {
setIsHistoryBusy(false);
}
}
async function handleBackClick(event: MouseEvent<HTMLAnchorElement>) {
const current = draftRef.current;
if (!current || estimateBalanceFingerprint(current) === savedFingerprintRef.current) {
return;
}
event.preventDefault();
await saveNow();
router.push(`/projects/${projectId}/grobbilanz`);
}
function handleSaveGroup(values: EstimateGroupFormValues) {
const editingGroup = groupDialog?.group ?? null;
handleChange((current) =>
editingGroup
? updateEstimateGroup(current, editingGroup.id, values)
: addEstimateGroup(current, values).balance
);
setGroupDialog(null);
}
function handleSelectGzf(value: number) {
if (gzfTarget) {
handleChange((current) =>
updateEstimateRow(current, gzfTarget.groupId, gzfTarget.rowId, {
simultaneityFactor: value,
})
);
}
setGzfTarget(null);
}
const isEditingDisabled = !draft || isHistoryBusy;
const invalidCount = invalidFieldKeys.size;
const listHref = `/projects/${projectId}/grobbilanz`;
return (
<main className="container-fluid py-4 estimate-print-root">
{project && draft ? <EstimatePrintHeader balance={draft} project={project} /> : null}
<div className="page-header estimate-page-header">
<div className="d-flex align-items-start gap-3">
<Link
aria-label="Zurück zur Übersicht der Stände"
className="btn btn-outline-secondary btn-sm structure-back-link estimate-no-print"
href={listHref}
onClick={(event) => void handleBackClick(event)}
title="Zurück zur Übersicht der Stände"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<div className="kicker">{project?.name ?? "Projekt"}</div>
<h1>Grobe Leistungsbilanz{draft ? ` – ${draft.name}` : ""}</h1>
<p className="text-secondary mb-0 estimate-no-print">
Leistungsabschätzung nach Kostengruppen (DIN 276) für LPH 2/3.
</p>
</div>
</div>
<div className="estimate-toolbar estimate-no-print">
<SaveStatusBadge
error={saveError}
invalidCount={invalidCount}
onRetry={() => void saveNow()}
status={saveStatus}
/>
<div className="btn-group btn-group-sm" role="group" aria-label="Verlauf">
<button
aria-label="Rückgängig"
className="btn btn-outline-secondary"
disabled={isHistoryBusy || !history || history.undoDepth === 0}
onClick={() => void handleHistory("undo")}
title="Rückgängig (letzte Änderung im Projekt)"
type="button"
>
<Undo2 aria-hidden="true" size={16} />
</button>
<button
aria-label="Wiederholen"
className="btn btn-outline-secondary"
disabled={isHistoryBusy || !history || history.redoDepth === 0}
onClick={() => void handleHistory("redo")}
title="Wiederholen (im Projekt)"
type="button"
>
<Redo2 aria-hidden="true" size={16} />
</button>
</div>
<button
aria-label="Drucken"
className="btn btn-sm btn-outline-secondary"
disabled={!draft}
onClick={() => window.print()}
title="Drucken"
type="button"
>
<Printer aria-hidden="true" className="me-1" size={16} />
Drucken
</button>
</div>
</div>
{notice ? (
<div className="alert alert-info estimate-no-print" role="status">
{notice}
</div>
) : null}
{error ? (
<div className="alert alert-warning estimate-no-print" role="alert">
{error}
</div>
) : null}
{isLoading ? (
<p className="text-secondary" role="status">
Grobe Leistungsbilanz wird geladen …
</p>
) : null}
{isMissing ? (
<div className="alert alert-warning" role="alert">
Dieser Stand existiert nicht (mehr), z. B. weil er gelöscht oder sein Anlegen
rückgängig gemacht wurde.{" "}
<Link href={listHref}>Zur Übersicht der Stände</Link>
</div>
) : null}
{draft && project ? (
<>
<section className="card shadow-sm mb-3 estimate-sheet-card">
<div className="card-body">
<div className="estimate-header-fields">
<div className="estimate-header-field">
<span className="estimate-header-label">Projekt</span>
<span className="estimate-header-value">{project.name}</span>
</div>
<label className="estimate-header-field is-wide">
<span className="estimate-header-label">Stand</span>
<DraftInput
ariaLabel="Bezeichnung des Stands"
className="estimate-cell-input estimate-header-input"
disabled={isEditingDisabled}
format={(value: string) => value}
maxLength={estimateTextLimits.balanceName}
onCommit={(name) => handleChange((current) => ({ ...current, name }))}
onValidityChange={(isValid) =>
handleValidityChange("balance:name", isValid)
}
parse={(text) =>
text.trim()
? { ok: true, value: text }
: { ok: false, error: "Die Bezeichnung darf nicht leer sein." }
}
value={draft.name}
/>
</label>
<label className="estimate-header-field">
<span className="estimate-header-label">LPH</span>
<input
aria-label="Leistungsphase"
className="estimate-cell-input estimate-header-input"
disabled={isEditingDisabled}
maxLength={estimateTextLimits.workStage}
onChange={(event) =>
handleChange((current) => ({
...current,
workStage: event.target.value,
}))
}
placeholder="z. B. LPH 3"
type="text"
value={draft.workStage ?? ""}
/>
<span className="estimate-print-value">{draft.workStage ?? ""}</span>
</label>
<label className="estimate-header-field">
<span className="estimate-header-label">Datum</span>
<input
aria-label="Datum"
className="estimate-cell-input estimate-header-input"
disabled={isEditingDisabled}
onChange={(event) =>
handleChange((current) => ({
...current,
dateIso: event.target.value || null,
}))
}
type="date"
value={draft.dateIso ?? ""}
/>
<span className="estimate-print-value">
{formatIsoDateGerman(draft.dateIso)}
</span>
</label>
</div>
<EstimateBalanceSheet
balance={draft}
disabled={isEditingDisabled}
onAddGroup={() => setGroupDialog({ group: null })}
onChange={handleChange}
onEditGroup={(group) => setGroupDialog({ group })}
onOpenGzfPicker={(groupId, rowId) => setGzfTarget({ groupId, rowId })}
onValidityChange={handleValidityChange}
threePhaseVoltageV={project.threePhaseVoltageV}
/>
</div>
</section>
<section
className={
isComparisonOpen
? "card shadow-sm estimate-comparison-card"
: "card shadow-sm estimate-comparison-card estimate-no-print"
}
>
<div className="card-header d-flex justify-content-between align-items-center gap-2">
<button
aria-controls="estimate-comparison-body"
aria-expanded={isComparisonOpen}
className="estimate-collapse-toggle"
onClick={() => setIsComparisonOpen((open) => !open)}
type="button"
>
{isComparisonOpen ? (
<ChevronDown aria-hidden="true" size={16} />
) : (
<ChevronRight aria-hidden="true" size={16} />
)}
Vergleich mit detaillierter Bilanz
</button>
{isComparisonOpen ? (
<button
aria-label="Vergleich neu laden"
className="btn btn-sm btn-outline-secondary structure-icon-button estimate-no-print"
onClick={() => void reloadComparison()}
title="Vergleich neu laden"
type="button"
>
<RefreshCw aria-hidden="true" size={14} />
</button>
) : null}
</div>
{isComparisonOpen ? (
<div className="card-body" id="estimate-comparison-body">
{comparisonError ? (
<div className="alert alert-warning" role="alert">
{comparisonError}
</div>
) : null}
{comparison ? <EstimateComparisonTable comparison={comparison} /> : null}
<p className="text-secondary small mt-2 mb-0">
Detailliert = Summe der Gerätezeilen aller Stromkreislisten (Anzahl ×
Leistung × Geräte-GZF), zugeordnet über die Kostengruppe; ohne
Verteiler-GZF.
</p>
</div>
) : null}
</section>
</>
) : null}
{groupDialog ? (
<EstimateGroupModal
group={groupDialog.group}
onClose={() => setGroupDialog(null)}
onSave={handleSaveGroup}
/>
) : null}
{gzfTarget ? (
<GzfPickerModal onClose={() => setGzfTarget(null)} onSelect={handleSelectGzf} />
) : null}
</main>
);
}
function SaveStatusBadge({
error,
invalidCount,
onRetry,
status,
}: {
error: string | null;
invalidCount: number;
onRetry: () => void;
status: SaveStatus;
}) {
const label =
status === "saving"
? "Wird gespeichert …"
: status === "pending"
? "Ungespeicherte Änderungen"
: status === "error"
? "Nicht gespeichert – Fehler"
: status === "invalid"
? "Nicht gespeichert – ungültige Eingaben"
: "Gespeichert";
const tone =
status === "error" || status === "invalid"
? "is-error"
: status === "saved"
? "is-saved"
: "is-busy";
return (
<div className="estimate-save-status" role="status" aria-live="polite">
<span className={`estimate-save-badge ${tone}`} title={error ?? undefined}>
{label}
</span>
{invalidCount ? (
<span className="estimate-save-hint">
{invalidCount === 1
? "1 ungültige Eingabe wird nicht gespeichert"
: `${invalidCount} ungültige Eingaben werden nicht gespeichert`}
</span>
) : null}
{status === "error" ? (
<button className="btn btn-link btn-sm p-0" onClick={onRetry} type="button">
Erneut versuchen
</button>
) : null}
</div>
);
}

View file

@ -1,292 +0,0 @@
"use client";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { ArrowLeft, Copy, FolderOpen, Plus, Trash2 } from "lucide-react";
import {
createEstimateBalance,
deleteEstimateBalance,
getProject,
listEstimateBalances,
} from "../../../../frontend/utils/api";
import type {
CreateEstimateBalanceInput,
EstimateBalanceDto,
ProjectCommandResultDto,
ProjectDto,
} from "../../../../frontend/types";
import { calculateEstimateBalance } from "../../../../shared/estimate-balance/estimate-balance";
import {
EstimateBalanceCreateModal,
type EstimateBalanceCreateDefaults,
} from "../../../../frontend/estimate-balance/estimate-balance-create-modal";
import { createCopyName } from "../../../../frontend/estimate-balance/estimate-balance-document";
import {
formatIsoDateGerman,
formatKw,
} from "../../../../frontend/estimate-balance/estimate-balance-format";
function countRows(balance: EstimateBalanceDto) {
return balance.groups.reduce((sum, group) => sum + group.rows.length, 0);
}
export default function EstimateBalanceListPage() {
const params = useParams<{ projectId: string }>();
const projectId = params.projectId;
const router = useRouter();
const [project, setProject] = useState<ProjectDto | null>(null);
const [balances, setBalances] = useState<EstimateBalanceDto[]>([]);
const [createDefaults, setCreateDefaults] =
useState<EstimateBalanceCreateDefaults | null>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const reload = useCallback(async () => {
const [currentProject, loadedBalances] = await Promise.all([
getProject(projectId),
listEstimateBalances(projectId),
]);
setProject(currentProject);
setBalances([...loadedBalances].sort((left, right) => left.sortOrder - right.sortOrder));
}, [projectId]);
useEffect(() => {
if (!projectId) {
return;
}
setIsLoading(true);
reload()
.then(() => setError(null))
.catch((err: unknown) =>
setError(
err instanceof Error
? err.message
: "Grobe Leistungsbilanzen konnten nicht geladen werden."
)
)
.finally(() => setIsLoading(false));
}, [projectId, reload]);
async function runCommand<TResult extends ProjectCommandResultDto>(
execute: (expectedRevision: number) => Promise<TResult>,
fallbackMessage: string
): Promise<TResult | null> {
if (!project) {
return null;
}
setIsSaving(true);
setError(null);
try {
const result = await execute(project.currentRevision);
setProject((current) =>
current ? { ...current, currentRevision: result.history.currentRevision } : current
);
await reload();
return result;
} catch (err) {
setError(err instanceof Error ? err.message : fallbackMessage);
// Refresh revision and list so a stale state does not block the next
// attempt; the command error stays visible.
await reload().catch(() => undefined);
return null;
} finally {
setIsSaving(false);
}
}
async function handleCreate(input: CreateEstimateBalanceInput) {
const result = await runCommand(
(expectedRevision) => createEstimateBalance(projectId, input, expectedRevision),
"Stand konnte nicht angelegt werden."
);
if (result) {
setCreateDefaults(null);
router.push(`/projects/${projectId}/grobbilanz/${result.balance.id}`);
}
}
async function handleDelete(balance: EstimateBalanceDto) {
if (
!window.confirm(
`Stand „${balance.name}“ der groben Leistungsbilanz wirklich löschen?`
)
) {
return;
}
await runCommand(
(expectedRevision) => deleteEstimateBalance(projectId, balance.id, expectedRevision),
"Stand konnte nicht gelöscht werden."
);
}
const isBusy = isSaving || !project;
const voltage = project?.threePhaseVoltageV ?? 400;
return (
<main className="container py-4">
<div className="page-header">
<div className="d-flex align-items-start gap-3">
<Link
aria-label="Zurück zur Projektübersicht"
className="btn btn-outline-secondary btn-sm structure-back-link"
href={`/projects/${projectId}`}
title="Zurück zur Projektübersicht"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<div className="kicker">{project?.name ?? "Projekt"}</div>
<h1>Grobe Leistungsbilanz</h1>
<p className="text-secondary mb-0">
Leistungsabschätzung für Vor- und Entwurfsplanung (LPH 2/3), gegliedert nach
Kostengruppen der DIN 276 – unabhängig von den Stromkreislisten.
</p>
</div>
</div>
<button
className="btn btn-primary"
disabled={isBusy}
onClick={() => setCreateDefaults({})}
type="button"
>
<Plus aria-hidden="true" className="me-1" size={16} />
Neuer Stand
</button>
</div>
{error ? (
<div className="alert alert-warning" role="alert">
{error}
</div>
) : null}
{isLoading ? (
<p className="text-secondary" role="status">
Grobe Leistungsbilanzen werden geladen …
</p>
) : null}
<section aria-labelledby="estimate-list-heading" className="card shadow-sm">
<div className="card-header d-flex justify-content-between align-items-center">
<h2 className="structure-column-title" id="estimate-list-heading">
Stände
<span className="badge text-bg-secondary ms-2">{balances.length}</span>
</h2>
</div>
{balances.length ? (
<div className="table-responsive">
<table className="table table-hover align-middle mb-0 estimate-list-table">
<thead>
<tr>
<th>Stand</th>
<th>LPH</th>
<th>Datum</th>
<th className="text-end">AV kW</th>
<th className="text-end">AV kVA</th>
<th className="text-end">SV kW</th>
<th className="text-end">SV kVA</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{balances.map((balance) => {
const result = calculateEstimateBalance(balance, voltage);
const href = `/projects/${projectId}/grobbilanz/${balance.id}`;
const rowCount = countRows(balance);
return (
<tr key={balance.id}>
<td>
<Link className="fw-semibold" href={href}>
{balance.name}
</Link>
<div className="text-secondary small">
{balance.groups.length} Kostengruppen ·{" "}
{rowCount === 1 ? "1 Zeile" : `${rowCount} Zeilen`}
</div>
</td>
<td>{balance.workStage ?? "–"}</td>
<td>{formatIsoDateGerman(balance.dateIso)}</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.av.kw)}
</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.av.kva)}
</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.sv.kw)}
</td>
<td className="text-end estimate-number">
{formatKw(result.withReserve.sv.kva)}
</td>
<td className="text-end">
<div className="d-inline-flex gap-1">
<Link
aria-label={`Stand „${balance.name}“ öffnen`}
className="btn btn-sm btn-outline-primary"
href={href}
title="Öffnen"
>
<FolderOpen aria-hidden="true" className="me-1" size={14} />
Öffnen
</Link>
<button
aria-label={`Stand „${balance.name}“ als neuen Stand kopieren`}
className="btn btn-sm btn-outline-secondary structure-icon-button"
disabled={isBusy}
onClick={() =>
setCreateDefaults({
name: createCopyName(balance.name),
workStage: balance.workStage,
template: "copy",
copyFromBalanceId: balance.id,
})
}
title="Als neuen Stand kopieren"
type="button"
>
<Copy aria-hidden="true" size={14} />
</button>
<button
aria-label={`Stand „${balance.name}“ löschen`}
className="btn btn-sm btn-outline-danger structure-icon-button"
disabled={isBusy}
onClick={() => void handleDelete(balance)}
title="Stand löschen"
type="button"
>
<Trash2 aria-hidden="true" size={14} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
<p className="text-secondary small px-3 py-2 mb-0">
Leistungen jeweils inklusive Reserve.
</p>
</div>
) : !isLoading ? (
<div className="card-body">
<p className="structure-empty mb-0">
Noch kein Stand vorhanden. Mit „Neuer Stand“ eine grobe Leistungsbilanz auf
Basis der Vorlage KG 400 anlegen.
</p>
</div>
) : null}
</section>
{createDefaults ? (
<EstimateBalanceCreateModal
balances={balances}
defaults={createDefaults}
isSaving={isSaving}
onClose={() => setCreateDefaults(null)}
onCreate={(input) => void handleCreate(input)}
/>
) : null}
</main>
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,360 +0,0 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
import "../../../../frontend/print/print-document.css";
import {
deleteDocumentLogo,
documentLogoUrl,
getCircuitTree,
getDocumentBranding,
getProject,
listCircuitLists,
listDistributionBoards,
listFloors,
listProjectSnapshots,
updateOfficeProfile,
uploadDocumentLogo,
} from "../../../../frontend/utils/api";
import type {
DocumentLogoMetadataDto,
OfficeProfileDto,
ProjectDto,
ProjectSnapshotMetadataDto,
} from "../../../../frontend/types";
import { FormModal } from "../../../../frontend/components/form-modal";
import {
officeProfileFieldLabels,
officeProfileFieldMaxLengths,
} from "../../../../shared/constants/project-cover";
import { PrintDocument } from "../../../../frontend/print/print-document";
import {
assertPrintRevisionConsistency,
buildPreliminaryBoardHierarchy,
summarizePrintBoard,
type PrintBoardSummary,
} from "../../../../frontend/print/print-document-model";
import {
documentLogoMimeTypes,
maxDocumentLogoBytes,
} from "../../../../shared/constants/document-branding";
type PlanFormat = "a4" | "a3";
interface PrintData {
project: ProjectDto;
summaries: PrintBoardSummary[];
namedSnapshots: ProjectSnapshotMetadataDto[];
}
const boardSortCollator = new Intl.Collator("de", { numeric: true });
function cssString(value: string) {
return JSON.stringify(value.replace(/[<>\r\n]/g, " "));
}
function pageRules(project: ProjectDto, stand: string, format: PlanFormat) {
const marginBoxes = `
@bottom-left { content: ${cssString(`${project.name} · Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
@bottom-center { content: ${cssString(`Stand ${stand} · Datenstand Rev. ${project.currentRevision}`)}; 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 `
@page lb-portrait { size: A4 portrait; margin: 14mm 16mm 16mm; ${marginBoxes} }
@page lb-landscape { size: ${format === "a3" ? "A3" : "A4"} landscape; margin: 10mm 10mm 13mm; ${marginBoxes} }`;
}
export default function ProjectPrintPage() {
const params = useParams<{ projectId: string }>();
const projectId = params.projectId;
const [data, setData] = useState<PrintData | null>(null);
const [logo, setLogo] = useState<DocumentLogoMetadataDto | null>(null);
const [office, setOffice] = useState<OfficeProfileDto>({
officeName: null,
officeAddress: null,
});
const [officeDraft, setOfficeDraft] = useState<{
officeName: string;
officeAddress: string;
} | null>(null);
const [format, setFormat] = useState<PlanFormat>("a4");
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
const [printedAt] = useState(() => new Date());
useEffect(() => {
if (!projectId) {
return;
}
let cancelled = false;
async function load() {
const [project, boards, circuitLists, floors, snapshots, branding] =
await Promise.all([
getProject(projectId),
listDistributionBoards(projectId),
listCircuitLists(projectId),
listFloors(projectId),
listProjectSnapshots(projectId),
getDocumentBranding(),
]);
const floorNameById = new Map(floors.map((floor) => [floor.id, floor.name]));
const summaries = await Promise.all(
[...boards]
.sort(
(left, right) =>
boardSortCollator.compare(left.boardNumber ?? "", right.boardNumber ?? "") ||
boardSortCollator.compare(left.name, right.name)
)
.flatMap((board) => {
const circuitList = circuitLists.find(
(list) => list.distributionBoardId === board.id
);
return circuitList ? [{ board, circuitList }] : [];
})
.map(async ({ board, circuitList }) =>
summarizePrintBoard({
board,
circuitListId: circuitList.id,
tree: await getCircuitTree(projectId, circuitList.id),
floorName: board.floorId ? floorNameById.get(board.floorId) ?? null : null,
})
)
);
const finalProject = await getProject(projectId);
assertPrintRevisionConsistency(project.currentRevision, summaries, finalProject.currentRevision);
if (cancelled) {
return;
}
setData({
project,
summaries,
namedSnapshots: snapshots
.filter((snapshot) => snapshot.kind === "named")
.sort((left, right) => left.createdAtIso.localeCompare(right.createdAtIso)),
});
setLogo(branding.logo);
setOffice(branding.office);
document.title = `Leistungsbilanz – ${project.name}`;
}
load().catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Die Seitenansicht konnte nicht geladen werden.");
}
});
return () => {
cancelled = true;
};
}, [projectId]);
const entries = useMemo(
() => (data ? buildPreliminaryBoardHierarchy(data.summaries) : []),
[data]
);
const stand = useMemo(
() =>
new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(printedAt),
[printedAt]
);
const logoSrc = logo
? `${documentLogoUrl}?v=${encodeURIComponent(logo.updatedAtIso)}`
: null;
async function handleLogoUpload(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) {
return;
}
if (!(documentLogoMimeTypes as readonly string[]).includes(file.type)) {
setError("Bitte ein PNG-, JPG- oder SVG-Bild wählen.");
return;
}
if (file.size > maxDocumentLogoBytes) {
setError("Das Logo darf höchstens 2 MB groß sein.");
return;
}
setIsBusy(true);
setError(null);
try {
const result = await uploadDocumentLogo(file);
setLogo(result.logo);
setMessage("Logo hinterlegt. Es gilt für alle Projekte.");
} catch (err) {
setError(err instanceof Error ? err.message : "Das Logo konnte nicht gespeichert werden.");
} finally {
setIsBusy(false);
}
}
async function handleOfficeSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!officeDraft) {
return;
}
setIsBusy(true);
setError(null);
try {
const result = await updateOfficeProfile({
officeName: officeDraft.officeName.trim() || null,
officeAddress: officeDraft.officeAddress.trim() || null,
});
setOffice(result.office);
setOfficeDraft(null);
setMessage("Büroangaben gespeichert. Sie gelten für alle Projekte.");
} catch (err) {
setError(err instanceof Error ? err.message : "Die Büroangaben konnten nicht gespeichert werden.");
} finally {
setIsBusy(false);
}
}
async function handleLogoDelete() {
if (!window.confirm("Hinterlegtes Logo für alle Projekte entfernen?")) {
return;
}
setIsBusy(true);
setError(null);
try {
await deleteDocumentLogo();
setLogo(null);
setMessage("Logo entfernt.");
} catch (err) {
setError(err instanceof Error ? err.message : "Das Logo konnte nicht entfernt werden.");
} finally {
setIsBusy(false);
}
}
return (
// Paper output: always light, independent of the app's dark mode.
<div
className={format === "a3" ? "print-view pd-a3" : "print-view"}
data-bs-theme="light"
>
{data ? <style>{pageRules(data.project, stand, format)}</style> : null}
<div className="pd-toolbar">
<span className="pd-toolbar-title">
Seitenansicht{data ? ` · ${data.project.name}` : ""}
</span>
{message ? <span className="pd-toolbar-message">{message}</span> : null}
{error ? <span className="pd-toolbar-error">{error}</span> : null}
<label className="d-flex align-items-center gap-2">
Planformat
<select
className="form-select form-select-sm w-auto"
onChange={(event) => setFormat(event.target.value as PlanFormat)}
value={format}
>
<option value="a4">A4 quer</option>
<option value="a3">A3 quer</option>
</select>
</label>
<button
className="btn btn-sm btn-outline-primary"
disabled={isBusy}
onClick={() =>
setOfficeDraft({
officeName: office.officeName ?? "",
officeAddress: office.officeAddress ?? "",
})
}
type="button"
>
Büroangaben
</button>
<label className={`btn btn-sm btn-outline-primary mb-0${isBusy ? " disabled" : ""}`}>
{logo ? "Logo ersetzen" : "Logo hinterlegen"}
<input
accept={documentLogoMimeTypes.join(",")}
disabled={isBusy}
hidden
onChange={(event) => void handleLogoUpload(event)}
type="file"
/>
</label>
{logo ? (
<button
className="btn btn-sm btn-outline-secondary"
disabled={isBusy}
onClick={() => void handleLogoDelete()}
type="button"
>
Logo entfernen
</button>
) : null}
<button
className="btn btn-sm btn-primary"
disabled={!data}
onClick={() => window.print()}
type="button"
>
Als PDF speichern / Drucken
</button>
<Link className="btn btn-sm btn-outline-secondary" href={`/projects/${projectId}`}>
Zum Projekt
</Link>
</div>
{officeDraft ? (
<FormModal
description="Planverfasser auf dem Deckblatt. Gilt für alle Projekte."
isSaving={isBusy}
onClose={() => setOfficeDraft(null)}
onSubmit={handleOfficeSave}
submitLabel="Büroangaben speichern"
title="Büroangaben"
>
<div className="row g-3">
<div className="col-12">
<label className="form-label" htmlFor="office-name">
{officeProfileFieldLabels.officeName}
</label>
<input
autoFocus
className="form-control"
id="office-name"
maxLength={officeProfileFieldMaxLengths.officeName}
onChange={(event) =>
setOfficeDraft({ ...officeDraft, officeName: event.target.value })
}
value={officeDraft.officeName}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="office-address">
{officeProfileFieldLabels.officeAddress}
</label>
<textarea
className="form-control"
id="office-address"
maxLength={officeProfileFieldMaxLengths.officeAddress}
onChange={(event) =>
setOfficeDraft({ ...officeDraft, officeAddress: event.target.value })
}
placeholder={"Straße Nr.\nPLZ Ort"}
rows={3}
value={officeDraft.officeAddress}
/>
</div>
</div>
</FormModal>
) : null}
{data ? (
<PrintDocument
entries={entries}
logoSrc={logoSrc}
namedSnapshots={data.namedSnapshots}
office={office}
printedAt={printedAt}
project={data.project}
/>
) : error ? null : (
<p className="p-4 text-secondary">Seitenansicht wird geladen …</p>
)}
</div>
);
}

View file

@ -1,792 +0,0 @@
"use client";
import Link from "next/link";
import { useParams } from "next/navigation";
import {
type FormEvent,
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { ArrowLeft, Pencil, Plus, Trash2 } from "lucide-react";
import {
createBuilding,
createFloor,
createRoom,
deleteBuilding,
deleteFloor,
deleteRoom,
getProject,
listBuildings,
listFloors,
listRooms,
updateBuilding,
updateFloor,
updateRoom,
} from "../../../../frontend/utils/api";
import type {
BuildingDto,
CreateRoomInput,
FloorDto,
ProjectCommandResultDto,
ProjectDto,
RoomDto,
} from "../../../../frontend/types";
import {
ALL_SELECTION,
UNASSIGNED_BUILDING_LABEL,
UNASSIGNED_FLOOR_LABEL,
UNASSIGNED_SELECTION,
countFloorsByBuilding,
countRoomsByFloor,
defaultBuildingIdForNewFloor,
defaultFloorIdForNewRoom,
describeRoomLocation,
filterFloorsByBuilding,
filterRooms,
groupFloorsByBuilding,
hasFloorsWithoutBuilding,
normalizeBuildingSelection,
normalizeFloorSelection,
showsRoomsWithoutFloorEntry,
type StructureSelection,
} from "../../../../frontend/utils/building-structure";
import { FormModal } from "../../../../frontend/components/form-modal";
import { RoomModal } from "../../../../frontend/components/room-modal";
const BUILDING_HAS_FLOORS_MESSAGE =
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind.";
type StructureDialog =
| { kind: "building"; building: BuildingDto | null }
| { kind: "floor"; floor: FloorDto | null }
| { kind: "room"; room: RoomDto | null; defaultFloorId: string };
function formatCount(count: number, singular: string, plural: string) {
return `${count} ${count === 1 ? singular : plural}`;
}
function compareRooms(left: RoomDto, right: RoomDto) {
return (
left.roomNumber.localeCompare(right.roomNumber, "de", { numeric: true }) ||
left.roomName.localeCompare(right.roomName, "de")
);
}
interface StructureColumnProps {
addLabel: string;
children: ReactNode;
count: number;
headingId: string;
isAddDisabled: boolean;
onAdd: () => void;
title: string;
}
function StructureColumn({
addLabel,
children,
count,
headingId,
isAddDisabled,
onAdd,
title,
}: StructureColumnProps) {
return (
<section aria-labelledby={headingId} className="card shadow-sm structure-column">
<div className="card-header d-flex justify-content-between align-items-center gap-2">
<h2 className="structure-column-title" id={headingId}>
{title}
<span className="badge text-bg-secondary ms-2">{count}</span>
</h2>
<button
aria-label={addLabel}
className="btn btn-sm btn-outline-primary structure-icon-button"
disabled={isAddDisabled}
onClick={onAdd}
title={addLabel}
type="button"
>
<Plus aria-hidden="true" size={16} />
</button>
</div>
<ul className="list-group list-group-flush structure-list">{children}</ul>
</section>
);
}
interface StructureEntryProps {
actions?: ReactNode;
hint?: string;
isSelected?: boolean;
label: ReactNode;
meta?: string;
onSelect?: () => void;
}
function StructureEntry({
actions,
hint,
isSelected = false,
label,
meta,
onSelect,
}: StructureEntryProps) {
const content = (
<>
<span className="structure-entry-text">
<span className="structure-entry-label">{label}</span>
{hint ? <span className="structure-entry-hint">{hint}</span> : null}
</span>
{meta ? <span className="structure-entry-meta">{meta}</span> : null}
</>
);
return (
<li
className={
isSelected
? "list-group-item structure-entry is-selected"
: "list-group-item structure-entry"
}
>
{onSelect ? (
<button
aria-pressed={isSelected}
className="structure-entry-main"
onClick={onSelect}
type="button"
>
{content}
</button>
) : (
<div className="structure-entry-main">{content}</div>
)}
{actions ? <div className="structure-entry-actions">{actions}</div> : null}
</li>
);
}
interface EntryActionsProps {
deleteLabel: string;
editLabel: string;
isDisabled: boolean;
onDelete: () => void;
onEdit: () => void;
}
function EntryActions({
deleteLabel,
editLabel,
isDisabled,
onDelete,
onEdit,
}: EntryActionsProps) {
return (
<>
<button
aria-label={editLabel}
className="btn btn-sm btn-outline-secondary structure-icon-button"
disabled={isDisabled}
onClick={onEdit}
title={editLabel}
type="button"
>
<Pencil aria-hidden="true" size={14} />
</button>
<button
aria-label={deleteLabel}
className="btn btn-sm btn-outline-danger structure-icon-button"
disabled={isDisabled}
onClick={onDelete}
title={deleteLabel}
type="button"
>
<Trash2 aria-hidden="true" size={14} />
</button>
</>
);
}
export default function BuildingStructurePage() {
const params = useParams<{ projectId: string }>();
const projectId = params.projectId;
const [project, setProject] = useState<ProjectDto | null>(null);
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
const [floors, setFloors] = useState<FloorDto[]>([]);
const [rooms, setRooms] = useState<RoomDto[]>([]);
const [buildingSelection, setBuildingSelection] =
useState<StructureSelection>(ALL_SELECTION);
const [floorSelection, setFloorSelection] =
useState<StructureSelection>(ALL_SELECTION);
const [dialog, setDialog] = useState<StructureDialog | null>(null);
const [dialogName, setDialogName] = useState("");
const [dialogBuildingId, setDialogBuildingId] = useState("");
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const reloadStructure = useCallback(async () => {
const [currentProject, loadedBuildings, loadedFloors, loadedRooms] =
await Promise.all([
getProject(projectId),
listBuildings(projectId),
listFloors(projectId),
listRooms(projectId),
]);
setProject(currentProject);
setBuildings(loadedBuildings);
setFloors(loadedFloors);
setRooms(loadedRooms);
}, [projectId]);
useEffect(() => {
if (!projectId) {
return;
}
setIsLoading(true);
reloadStructure()
.then(() => setError(null))
.catch((err: unknown) =>
setError(
err instanceof Error
? err.message
: "Gebäudestruktur konnte nicht geladen werden."
)
)
.finally(() => setIsLoading(false));
}, [projectId, reloadStructure]);
const effectiveBuildingSelection = normalizeBuildingSelection(
buildingSelection,
floors,
buildings
);
const effectiveFloorSelection = normalizeFloorSelection(
floorSelection,
rooms,
floors,
buildings,
effectiveBuildingSelection
);
const visibleFloors = useMemo(
() => filterFloorsByBuilding(floors, buildings, effectiveBuildingSelection),
[buildings, effectiveBuildingSelection, floors]
);
const visibleFloorGroups = useMemo(
() =>
effectiveBuildingSelection === ALL_SELECTION && buildings.length
? groupFloorsByBuilding(visibleFloors, buildings)
: [{ buildingId: null, label: "", floors: visibleFloors }],
[buildings, effectiveBuildingSelection, visibleFloors]
);
const visibleRooms = useMemo(
() =>
filterRooms(
rooms,
floors,
buildings,
effectiveBuildingSelection,
effectiveFloorSelection
).sort(compareRooms),
[buildings, effectiveBuildingSelection, effectiveFloorSelection, floors, rooms]
);
const roomsInBuildingSelection = useMemo(
() =>
filterRooms(rooms, floors, buildings, effectiveBuildingSelection, ALL_SELECTION)
.length,
[buildings, effectiveBuildingSelection, floors, rooms]
);
const floorCountByBuilding = useMemo(
() => countFloorsByBuilding(floors, buildings),
[buildings, floors]
);
const roomCountByFloor = useMemo(
() => countRoomsByFloor(rooms, floors),
[floors, rooms]
);
const showUnassignedBuilding = hasFloorsWithoutBuilding(floors, buildings);
const showUnassignedFloor = showsRoomsWithoutFloorEntry(
rooms,
floors,
effectiveBuildingSelection
);
const isBusy = isSaving || !project;
function selectBuilding(selection: StructureSelection) {
setBuildingSelection(selection);
setFloorSelection(ALL_SELECTION);
}
async function runCommand<TResult extends ProjectCommandResultDto>(
execute: (expectedRevision: number) => Promise<TResult>,
fallbackMessage: string
): Promise<boolean> {
if (!project) {
return false;
}
setIsSaving(true);
setError(null);
try {
const result = await execute(project.currentRevision);
setProject((current) =>
current
? { ...current, currentRevision: result.history.currentRevision }
: current
);
await reloadStructure();
return true;
} catch (err) {
setError(err instanceof Error ? err.message : fallbackMessage);
// Refresh the revision and lists so a stale state does not block the
// next attempt; the command error stays visible.
await reloadStructure().catch(() => undefined);
return false;
} finally {
setIsSaving(false);
}
}
function openBuildingDialog(building: BuildingDto | null) {
setDialogName(building?.name ?? "");
setDialog({ kind: "building", building });
}
function openFloorDialog(floor: FloorDto | null) {
setDialogName(floor?.name ?? "");
setDialogBuildingId(
floor
? floor.buildingId ?? ""
: defaultBuildingIdForNewFloor(effectiveBuildingSelection)
);
setDialog({ kind: "floor", floor });
}
function openRoomDialog(room: RoomDto | null) {
setDialog({
kind: "room",
room,
defaultFloorId: defaultFloorIdForNewRoom(effectiveFloorSelection),
});
}
async function handleSaveBuilding(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (dialog?.kind !== "building" || !dialogName.trim()) {
return;
}
const editingBuilding = dialog.building;
const name = dialogName.trim();
// An unchanged dialog is not a command; the backend rejects no-op updates.
if (editingBuilding && editingBuilding.name === name) {
setDialog(null);
return;
}
const saved = await runCommand(
(expectedRevision) =>
editingBuilding
? updateBuilding(projectId, editingBuilding.id, { name }, expectedRevision)
: createBuilding(projectId, { name }, expectedRevision),
"Gebäude konnte nicht gespeichert werden."
);
if (saved) {
setDialog(null);
}
}
async function handleSaveFloor(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (dialog?.kind !== "floor" || !dialogName.trim()) {
return;
}
const editingFloor = dialog.floor;
const input = {
name: dialogName.trim(),
buildingId: dialogBuildingId || null,
};
if (
editingFloor &&
editingFloor.name === input.name &&
editingFloor.buildingId === input.buildingId
) {
setDialog(null);
return;
}
const saved = await runCommand(
(expectedRevision) =>
editingFloor
? updateFloor(projectId, editingFloor.id, input, expectedRevision)
: createFloor(projectId, input, expectedRevision),
"Etage konnte nicht gespeichert werden."
);
if (saved) {
setDialog(null);
}
}
async function handleSaveRoom(input: CreateRoomInput) {
if (dialog?.kind !== "room") {
return;
}
const editingRoom = dialog.room;
if (
editingRoom &&
editingRoom.roomNumber === input.roomNumber.trim() &&
editingRoom.roomName === input.roomName.trim() &&
editingRoom.floorId === (input.floorId || null)
) {
setDialog(null);
return;
}
const saved = await runCommand(
(expectedRevision) =>
editingRoom
? updateRoom(projectId, editingRoom.id, input, expectedRevision)
: createRoom(projectId, input, expectedRevision),
"Raum konnte nicht gespeichert werden."
);
if (saved) {
setDialog(null);
}
}
async function handleDeleteBuilding(building: BuildingDto) {
if ((floorCountByBuilding.get(building.id) ?? 0) > 0) {
setError(BUILDING_HAS_FLOORS_MESSAGE);
return;
}
if (!window.confirm(`Gebäude „${building.name}“ wirklich löschen?`)) {
return;
}
await runCommand(
(expectedRevision) =>
deleteBuilding(projectId, building.id, expectedRevision),
"Gebäude konnte nicht gelöscht werden."
);
}
async function handleDeleteFloor(floor: FloorDto) {
if (!window.confirm(`Etage „${floor.name}“ wirklich löschen?`)) {
return;
}
await runCommand(
(expectedRevision) => deleteFloor(projectId, floor.id, expectedRevision),
"Etage konnte nicht gelöscht werden."
);
}
async function handleDeleteRoom(room: RoomDto) {
if (
!window.confirm(
`Raum „${room.roomNumber} ${room.roomName}“ wirklich löschen?`
)
) {
return;
}
await runCommand(
(expectedRevision) => deleteRoom(projectId, room.id, expectedRevision),
"Raum konnte nicht gelöscht werden."
);
}
return (
<main className="container py-4">
<div className="page-header">
<div className="d-flex align-items-start gap-3">
<Link
aria-label="Zurück zur Projektübersicht"
className="btn btn-outline-secondary btn-sm structure-back-link"
href={`/projects/${projectId}`}
title="Zurück zur Projektübersicht"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<div className="kicker">{project?.name ?? "Projekt"}</div>
<h1>Gebäudestruktur</h1>
<p className="text-secondary mb-0">
Gebäude, Etagen und Räume des Projekts gliedern und pflegen.
</p>
</div>
</div>
</div>
{error ? (
<div className="alert alert-warning" role="alert">
{error}
</div>
) : null}
{isLoading ? (
<p className="text-secondary" role="status">
Gebäudestruktur wird geladen …
</p>
) : null}
<div className="structure-columns">
<StructureColumn
addLabel="Gebäude hinzufügen"
count={buildings.length}
headingId="structure-buildings-heading"
isAddDisabled={isBusy}
onAdd={() => openBuildingDialog(null)}
title="Gebäude"
>
<StructureEntry
isSelected={effectiveBuildingSelection === ALL_SELECTION}
label="Alle"
meta={formatCount(floors.length, "Etage", "Etagen")}
onSelect={() => selectBuilding(ALL_SELECTION)}
/>
{buildings.map((building) => (
<StructureEntry
actions={
<EntryActions
deleteLabel={`Gebäude „${building.name}“ löschen`}
editLabel={`Gebäude „${building.name}“ bearbeiten`}
isDisabled={isBusy}
onDelete={() => void handleDeleteBuilding(building)}
onEdit={() => openBuildingDialog(building)}
/>
}
isSelected={effectiveBuildingSelection === building.id}
key={building.id}
label={building.name}
meta={formatCount(
floorCountByBuilding.get(building.id) ?? 0,
"Etage",
"Etagen"
)}
onSelect={() => selectBuilding(building.id)}
/>
))}
{showUnassignedBuilding ? (
<StructureEntry
isSelected={effectiveBuildingSelection === UNASSIGNED_SELECTION}
label={<em>{UNASSIGNED_BUILDING_LABEL}</em>}
meta={formatCount(
floorCountByBuilding.get(null) ?? 0,
"Etage",
"Etagen"
)}
onSelect={() => selectBuilding(UNASSIGNED_SELECTION)}
/>
) : null}
{!buildings.length && !isLoading ? (
<li className="list-group-item structure-empty">
Noch keine Gebäude vorhanden.
</li>
) : null}
</StructureColumn>
<StructureColumn
addLabel="Etage hinzufügen"
count={visibleFloors.length}
headingId="structure-floors-heading"
isAddDisabled={isBusy}
onAdd={() => openFloorDialog(null)}
title="Etagen"
>
<StructureEntry
isSelected={effectiveFloorSelection === ALL_SELECTION}
label="Alle"
meta={formatCount(roomsInBuildingSelection, "Raum", "Räume")}
onSelect={() => setFloorSelection(ALL_SELECTION)}
/>
{visibleFloorGroups.map((group) => (
<FloorGroupEntries
key={group.buildingId ?? "unassigned"}
label={group.label}
>
{group.floors.map((floor) => (
<StructureEntry
actions={
<EntryActions
deleteLabel={`Etage „${floor.name}“ löschen`}
editLabel={`Etage „${floor.name}“ bearbeiten`}
isDisabled={isBusy}
onDelete={() => void handleDeleteFloor(floor)}
onEdit={() => openFloorDialog(floor)}
/>
}
isSelected={effectiveFloorSelection === floor.id}
key={floor.id}
label={floor.name}
meta={formatCount(
roomCountByFloor.get(floor.id) ?? 0,
"Raum",
"Räume"
)}
onSelect={() => setFloorSelection(floor.id)}
/>
))}
</FloorGroupEntries>
))}
{showUnassignedFloor ? (
<StructureEntry
isSelected={effectiveFloorSelection === UNASSIGNED_SELECTION}
label={<em>{UNASSIGNED_FLOOR_LABEL}</em>}
meta={formatCount(roomCountByFloor.get(null) ?? 0, "Raum", "Räume")}
onSelect={() => setFloorSelection(UNASSIGNED_SELECTION)}
/>
) : null}
{!visibleFloors.length && !isLoading ? (
<li className="list-group-item structure-empty">
{floors.length
? "Keine Etagen in dieser Auswahl."
: "Noch keine Etagen vorhanden."}
</li>
) : null}
</StructureColumn>
<StructureColumn
addLabel="Raum hinzufügen"
count={visibleRooms.length}
headingId="structure-rooms-heading"
isAddDisabled={isBusy}
onAdd={() => openRoomDialog(null)}
title="Räume"
>
{visibleRooms.map((room) => (
<StructureEntry
actions={
<EntryActions
deleteLabel={`Raum „${room.roomNumber} ${room.roomName}“ löschen`}
editLabel={`Raum „${room.roomNumber} ${room.roomName}“ bearbeiten`}
isDisabled={isBusy}
onDelete={() => void handleDeleteRoom(room)}
onEdit={() => openRoomDialog(room)}
/>
}
hint={
effectiveFloorSelection === ALL_SELECTION
? describeRoomLocation(room, floors, buildings)
: undefined
}
key={room.id}
label={
<>
<span className="structure-room-number">{room.roomNumber}</span>{" "}
{room.roomName}
</>
}
/>
))}
{!visibleRooms.length && !isLoading ? (
<li className="list-group-item structure-empty">
{rooms.length
? "Keine Räume in dieser Auswahl."
: "Noch keine Räume vorhanden."}
</li>
) : null}
</StructureColumn>
</div>
{dialog?.kind === "building" ? (
<FormModal
isSaving={isSaving}
onClose={() => setDialog(null)}
onSubmit={handleSaveBuilding}
submitDisabled={!dialogName.trim()}
submitLabel={dialog.building ? "Änderungen speichern" : "Gebäude hinzufügen"}
title={dialog.building ? "Gebäude bearbeiten" : "Gebäude hinzufügen"}
>
<label className="form-label" htmlFor="building-name">
Bezeichnung
</label>
<input
autoFocus
className="form-control"
id="building-name"
maxLength={200}
onChange={(event) => setDialogName(event.target.value)}
placeholder="z. B. Haus A"
required
value={dialogName}
/>
</FormModal>
) : null}
{dialog?.kind === "floor" ? (
<FormModal
isSaving={isSaving}
onClose={() => setDialog(null)}
onSubmit={handleSaveFloor}
submitDisabled={!dialogName.trim()}
submitLabel={dialog.floor ? "Änderungen speichern" : "Etage hinzufügen"}
title={dialog.floor ? "Etage bearbeiten" : "Etage hinzufügen"}
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="floor-name">
Bezeichnung
</label>
<input
autoFocus
className="form-control"
id="floor-name"
maxLength={200}
onChange={(event) => setDialogName(event.target.value)}
placeholder="z. B. EG"
required
value={dialogName}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="floor-building">
Gebäude
</label>
<select
className="form-select"
id="floor-building"
onChange={(event) => setDialogBuildingId(event.target.value)}
value={dialogBuildingId}
>
<option value="">{UNASSIGNED_BUILDING_LABEL}</option>
{buildings.map((building) => (
<option key={building.id} value={building.id}>
{building.name}
</option>
))}
</select>
</div>
</div>
</FormModal>
) : null}
{dialog?.kind === "room" ? (
<RoomModal
buildings={buildings}
floors={floors}
initialRoom={dialog.room ?? undefined}
initialValues={
dialog.defaultFloorId ? { floorId: dialog.defaultFloorId } : undefined
}
isSaving={isSaving}
onClose={() => setDialog(null)}
onSave={handleSaveRoom}
/>
) : null}
</main>
);
}
function FloorGroupEntries({
children,
label,
}: {
children: ReactNode;
label: string;
}) {
return (
<>
{label ? (
<li className="list-group-item structure-group-label">
{label}
</li>
) : null}
{children}
</>
);
}

View file

@ -2,14 +2,16 @@
// the module is an optional calculator attached to the circuit list and never
// writes project data itself (see docs/cable-sizing-module.md).
//
// Reference data: DIN VDE 0298-4 (VDE 0298-4):2023-06 together with
// Berichtigung 1:2023-10. The table-by-table scope and the separate sources
// required for voltage drop and protection coordination are documented in
// docs/cable-sizing-module.md.
// Reference data: IEC 60364-5-52:2009, Annex B (tables B.52.2 to B.52.5,
// B.52.10 to B.52.19). Every value was cross-checked against publicly
// available reproductions of these tables; where sources disagreed the lower,
// i.e. safer, value is used. The table-by-table verification record is in
// docs/cable-sizing-module.md. National tables (DIN VDE 0298-4) may differ in
// individual values and must be checked by the planner against the norm text.
// -- Reference installation methods (DIN VDE 0298-4, Table 2) ---------------
// -- Reference installation methods (IEC 60364-5-52, Table B.52.1) ----------
export const LAYING_METHODS = ["A1", "A2", "B1", "B2", "C", "D", "E"] as const;
export const LAYING_METHODS = ["A1", "A2", "B1", "B2", "C", "D1", "D2", "E"] as const;
export type LayingMethod = (typeof LAYING_METHODS)[number];
export const LAYING_METHOD_GROUP: Record<LayingMethod, "air" | "ground"> = {
@ -19,7 +21,8 @@ export const LAYING_METHOD_GROUP: Record<LayingMethod, "air" | "ground"> = {
B2: "air",
C: "air",
E: "air",
D: "ground",
D1: "ground",
D2: "ground",
};
export const LAYING_METHOD_LABELS: Record<LayingMethod, string> = {
@ -28,7 +31,8 @@ export const LAYING_METHOD_LABELS: Record<LayingMethod, string> = {
B1: "B1 – Einzeladern im Rohr auf oder in der Wand",
B2: "B2 – Mehradrige Leitung im Rohr auf oder in der Wand",
C: "C – Mehradrige Leitung direkt auf oder in der Wand",
D: "D – Mehradriges Kabel im Rohr oder Kanal im Erdreich",
D1: "D1 – Leitung im Rohr oder Kabelschutzrohr im Erdreich",
D2: "D2 – Kabel direkt im Erdreich",
E: "E – Mehradrige Leitung frei in Luft",
};
@ -45,7 +49,7 @@ export const INSULATION_MATERIAL_LABELS: Record<InsulationMaterial, string> = {
export const CONDUCTOR_MATERIALS = ["copper", "aluminum"] as const;
export type ConductorMaterial = (typeof CONDUCTOR_MATERIALS)[number];
// -- Harmonic neutral loading (DIN VDE 0298-4, Annex B, three-phase only) ---
// -- Harmonic neutral loading (IEC 60364-5-52, Annex E, three-phase only) ---
export const HARMONIC_NEUTRAL_LOAD_OPTIONS = [
"none",
@ -57,7 +61,7 @@ export type HarmonicNeutralLoad = (typeof HARMONIC_NEUTRAL_LOAD_OPTIONS)[number]
export const HARMONIC_NEUTRAL_LOAD_LABELS: Record<HarmonicNeutralLoad, string> = {
none: "Keine nennenswerte 3. Harmonische (< 15 %) – Standardfall",
"15to33Percent": "3. Harmonische 15–33 % – Reduktionsfaktor 0,86",
over33Percent: "3. Harmonische > 33 % – ohne Neutralleiterstrom keine Empfehlung",
over33Percent: "3. Harmonische > 33 % – Neutralleiter wie Außenleiter dimensionieren",
};
export const HARMONIC_REDUCTION_FACTOR = 0.86;
@ -81,13 +85,14 @@ export const CURRENT_CAPACITY_A: Record<
> = {
pvc: {
copper: {
// DIN VDE 0298-4, tables 3 and 4.
// B.52.2 / B.52.4; E: B.52.10 multi-core. A1 2L 240 mm²: sources 320/321 → 320.
// E 3L 70/120 mm²: sources 196/198 and 276/278 → 196 and 276.
A1: {
2: [15.5, 19.5, 26, 34, 46, 61, 80, 99, 119, 151, 182, 210, 240, 273, 321],
2: [14.5, 19.5, 26, 34, 46, 61, 80, 99, 119, 151, 182, 210, 240, 273, 320],
3: [13.5, 18, 24, 31, 42, 56, 73, 89, 108, 136, 164, 188, 216, 245, 286],
},
A2: {
2: [15.5, 18.5, 25, 32, 43, 57, 75, 92, 110, 139, 167, 192, 219, 248, 291],
2: [14, 18.5, 25, 32, 43, 57, 75, 92, 110, 139, 167, 192, 219, 248, 291],
3: [13, 17.5, 23, 29, 39, 52, 68, 83, 99, 125, 150, 172, 196, 223, 261],
},
B1: {
@ -102,18 +107,22 @@ export const CURRENT_CAPACITY_A: Record<
2: [19.5, 27, 36, 46, 63, 85, 112, 138, 168, 213, 258, 299, 344, 392, 461],
3: [17.5, 24, 32, 41, 57, 76, 96, 119, 144, 184, 223, 259, 299, 341, 403],
},
D: {
D1: {
2: [22, 29, 37, 46, 60, 78, 99, 119, 140, 173, 204, 231, 261, 292, 336],
3: [18, 24, 30, 38, 50, 64, 82, 98, 116, 143, 169, 192, 217, 243, 280],
},
D2: {
2: [22, 28, 38, 48, 64, 83, 110, 132, 156, 192, 230, 261, 293, 331, 382],
3: [19, 24, 33, 41, 54, 70, 92, 110, 130, 162, 193, 220, 246, 278, 320],
},
E: {
2: [22, 30, 40, 51, 70, 94, 119, 148, 180, 232, 282, 328, 379, 434, 514],
3: [18.5, 25, 34, 43, 60, 80, 101, 126, 153, 196, 238, 276, 319, 364, 430],
},
},
aluminum: {
// DIN VDE 0298-4 aluminium values; the normalization below removes all
// entries below the first tabulated aluminium size of 25 mm².
// B.52.2 / B.52.4 aluminium; E: B.52.11. No values below 2.5 mm²
// (D2 below 16 mm²).
A1: {
2: [n, 15, 20, 26, 36, 48, 63, 77, 93, 118, 142, 164, 189, 215, 252],
3: [n, 14, 18.5, 24, 32, 43, 57, 70, 84, 107, 129, 149, 170, 194, 227],
@ -134,10 +143,14 @@ export const CURRENT_CAPACITY_A: Record<
2: [n, 21, 28, 36, 49, 66, 83, 103, 125, 160, 195, 226, 261, 298, 352],
3: [n, 18.5, 25, 32, 44, 59, 73, 90, 110, 140, 170, 197, 227, 259, 305],
},
D: {
D1: {
2: [n, 22, 29, 36, 47, 61, 77, 93, 109, 135, 159, 180, 204, 228, 262],
3: [n, 18.5, 24, 30, 39, 50, 64, 77, 91, 112, 132, 150, 169, 190, 218],
},
D2: {
2: [n, n, n, n, n, 63, 82, 98, 117, 145, 173, 200, 224, 255, 298],
3: [n, n, n, n, n, 53, 69, 83, 99, 122, 148, 169, 189, 214, 250],
},
E: {
2: [n, 23, 31, 39, 54, 73, 89, 111, 135, 173, 210, 244, 282, 322, 380],
3: [n, 19.5, 26, 33, 46, 61, 78, 96, 117, 150, 183, 212, 245, 280, 330],
@ -167,10 +180,14 @@ export const CURRENT_CAPACITY_A: Record<
2: [24, 33, 45, 58, 80, 107, 138, 171, 209, 269, 328, 382, 441, 506, 599],
3: [22, 30, 40, 52, 71, 96, 119, 147, 179, 229, 278, 322, 371, 424, 500],
},
D: {
D1: {
2: [25, 33, 43, 53, 71, 91, 116, 139, 164, 203, 239, 271, 306, 343, 395],
3: [21, 28, 36, 44, 58, 75, 96, 115, 135, 167, 197, 223, 251, 281, 324],
},
D2: {
2: [27, 35, 46, 58, 77, 100, 129, 155, 183, 225, 270, 306, 343, 387, 448],
3: [23, 30, 39, 49, 65, 84, 107, 129, 153, 188, 226, 257, 287, 324, 375],
},
E: {
2: [26, 36, 49, 63, 86, 115, 149, 185, 225, 289, 352, 410, 473, 542, 641],
3: [23, 32, 42, 54, 75, 100, 127, 158, 192, 246, 298, 346, 399, 456, 538],
@ -198,10 +215,14 @@ export const CURRENT_CAPACITY_A: Record<
2: [n, 26, 35, 45, 62, 84, 101, 126, 154, 198, 241, 280, 324, 371, 439],
3: [n, 24, 32, 41, 57, 76, 90, 112, 136, 174, 211, 245, 283, 323, 382],
},
D: {
D1: {
2: [n, 26, 33, 42, 55, 71, 90, 108, 128, 158, 186, 211, 238, 267, 307],
3: [n, 22, 28, 35, 46, 59, 75, 90, 106, 130, 154, 174, 197, 220, 253],
},
D2: {
2: [n, n, n, n, n, 76, 98, 117, 139, 170, 204, 233, 261, 296, 343],
3: [n, n, n, n, n, 64, 82, 98, 117, 144, 172, 197, 220, 250, 290],
},
E: {
2: [n, 28, 38, 49, 67, 91, 108, 135, 164, 211, 257, 300, 346, 397, 470],
3: [n, 24, 32, 42, 58, 77, 97, 120, 146, 187, 227, 263, 304, 347, 409],
@ -210,25 +231,9 @@ export const CURRENT_CAPACITY_A: Record<
},
};
// DIN VDE 0298-4 only supplies aluminium current-carrying capacities from
// 25 mm² onward. Smaller values from the former IEC data set must therefore
// never participate in a recommendation.
for (const insulation of INSULATION_MATERIALS) {
for (const method of LAYING_METHODS) {
for (const coreCount of [2, 3] as const) {
const row = CURRENT_CAPACITY_A[insulation].aluminum[method][coreCount] as (number | null)[];
CROSS_SECTIONS_MM2.forEach((crossSection, index) => {
if (crossSection < 25) {
row[index] = null;
}
});
}
}
}
// -- Correction factors ------------------------------------------------------
/** DIN VDE 0298-4 tables 17 (air) and 20 (ground). */
/** B.52.14 (air, reference 30 °C) and B.52.15 (ground, reference 20 °C). */
export const TEMPERATURE_FACTORS: Record<
"air" | "ground",
Record<InsulationMaterial, Record<number, number>>
@ -255,32 +260,26 @@ export const REFERENCE_TEMPERATURE_C: Record<"air" | "ground", number> = {
};
/**
* Grouping reduction factors according to the corrected references in
* DIN VDE 0298-4 Berichtigung 1:2023-10:
* A1–C use table 22, row 1 (bundled/touching), D uses table 26 for touching
* conduits/ducts in earth. Method E needs details about support, spacing and
* number of trays from table 23, which the current input model does not carry;
* it is therefore supported only for one circuit.
* Grouping reduction factors for touching circuits/cables:
* air (A1–C, E): B.52.17 item 1 "bunched in air, on a surface, embedded or
* enclosed" – the most restrictive arrangement, also used for E (single
* layers on trays would allow higher factors);
* D1: B.52.19 A (multi-core cables in touching ducts);
* D2: B.52.18 (cables direct in ground, touching).
*/
export const GROUPING_FACTORS: Record<"A1_TO_C" | "D" | "E", Record<number, number>> = {
A1_TO_C: {
1: 1.0, 2: 0.8, 3: 0.7, 4: 0.65, 5: 0.6, 6: 0.57, 7: 0.54, 8: 0.52,
9: 0.5, 10: 0.48, 12: 0.45, 14: 0.43, 16: 0.41, 18: 0.39, 20: 0.38,
export const GROUPING_FACTORS: Record<"air" | "D1" | "D2", Record<number, number>> = {
air: { 1: 1.0, 2: 0.8, 3: 0.7, 4: 0.65, 5: 0.6, 6: 0.57, 7: 0.54, 8: 0.52, 9: 0.5, 12: 0.45, 16: 0.41, 20: 0.38 },
D1: {
1: 1.0, 2: 0.85, 3: 0.75, 4: 0.7, 5: 0.65, 6: 0.6, 7: 0.57, 8: 0.54, 9: 0.52, 10: 0.49,
11: 0.47, 12: 0.45, 13: 0.44, 14: 0.42, 15: 0.41, 16: 0.39, 17: 0.38, 18: 0.37, 19: 0.35, 20: 0.34,
},
D: { 1: 1.0, 2: 0.85, 3: 0.75, 4: 0.7, 5: 0.65, 6: 0.6 },
E: { 1: 1.0 },
D2: { 1: 1.0, 2: 0.75, 3: 0.65, 4: 0.6, 5: 0.55, 6: 0.5, 7: 0.45, 8: 0.43, 9: 0.41, 12: 0.36, 16: 0.32, 20: 0.29 },
};
function groupingTableFor(method: LayingMethod) {
return method === "D"
? GROUPING_FACTORS.D
: method === "E"
? GROUPING_FACTORS.E
: GROUPING_FACTORS.A1_TO_C;
}
export const MAX_GROUPING_CIRCUITS = 20;
export function maxGroupingCircuits(method: LayingMethod): number {
return Math.max(...Object.keys(groupingTableFor(method)).map(Number));
function groupingTableFor(method: LayingMethod) {
return method === "D1" ? GROUPING_FACTORS.D1 : method === "D2" ? GROUPING_FACTORS.D2 : GROUPING_FACTORS.air;
}
/**
@ -374,7 +373,7 @@ export interface CableSizingInput {
layingMethod: LayingMethod;
conductorMaterial: ConductorMaterial;
insulation: InsulationMaterial;
/** Ambient air temperature, or ground temperature for D. */
/** Ambient air temperature, or ground temperature for D1/D2. */
ambientTemperatureC: number;
/** Number of grouped circuits or multi-core cables, including this one. */
groupingCircuits: number;
@ -406,7 +405,7 @@ export interface ProtectionCoordinationResult {
coordinated: boolean;
}
export type UnsupportedReason = "temperature" | "grouping" | "harmonics";
export type UnsupportedReason = "temperature" | "grouping";
export interface CableSizingResult {
/** false: the inputs are outside the tabulated range – no numeric result. */
@ -455,23 +454,13 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult
input.ambientTemperatureC
);
const grouping =
input.groupingCircuits <= maxGroupingCircuits(input.layingMethod)
input.groupingCircuits <= MAX_GROUPING_CIRCUITS
? lookupConservative(groupingTableFor(input.layingMethod), Math.max(1, input.groupingCircuits))
: null;
const harmonicsSupported = !(
input.phase === 3 && input.harmonicNeutralLoad === "over33Percent"
);
const unsupportedReason: UnsupportedReason | null = !temperature
? "temperature"
: !grouping
? "grouping"
: !harmonicsSupported
? "harmonics"
: null;
const empty: CableSizingResult = {
supported: false,
unsupportedReason,
unsupportedReason: !temperature ? "temperature" : "grouping",
operatingCurrentA: ib,
designCurrentA,
temperatureUsedC: temperature?.key ?? null,
@ -488,7 +477,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult
rows: [],
protectionCoordination: null,
};
if (unsupportedReason || !temperature || !grouping) {
if (!temperature || !grouping) {
return empty;
}
@ -597,17 +586,12 @@ export function buildCableSizingAlerts(input: CableSizingInput, result: CableSiz
const group = LAYING_METHOD_GROUP[input.layingMethod];
if (!result.supported) {
const groupingMaximum = maxGroupingCircuits(input.layingMethod);
alerts.push({
kind: "critical",
text:
result.unsupportedReason === "temperature"
? `${group === "air" ? "Umgebungstemperatur" : "Bodentemperatur"} ${input.ambientTemperatureC} °C liegt außerhalb der Tabelle für ${input.insulation === "pvc" ? "PVC" : "VPE"} – keine Berechnung möglich.`
: result.unsupportedReason === "harmonics"
? "Bei mehr als 33 % dritter Harmonischer muss nach dem Neutralleiterstrom bemessen werden. Ohne diesen Wert wird keine Querschnittsempfehlung ausgegeben."
: input.layingMethod === "E"
? "Für Verlegeart E benötigt die Norm bei Häufung Angaben zu Trägerart, Abstand und Anzahl der Kabelpritschen. Ohne diese Angaben ist nur ein einzelner Stromkreis berechenbar."
: `Häufung mit ${input.groupingCircuits} Stromkreisen liegt für Verlegeart ${input.layingMethod} außerhalb der verwendeten Tabelle (max. ${groupingMaximum}) – keine Berechnung möglich.`,
: `Häufung mit ${input.groupingCircuits} Stromkreisen liegt außerhalb der Tabelle (max. ${MAX_GROUPING_CIRCUITS}) – keine Berechnung möglich.`,
});
return alerts;
}
@ -653,10 +637,10 @@ export function buildCableSizingAlerts(input: CableSizingInput, result: CableSiz
text: "Reduktionsfaktor 0,86 wegen 15–33 % 3. Harmonischer im Neutralleiter angewendet.",
});
}
if (input.lengthM === 0) {
if (input.phase === 3 && input.harmonicNeutralLoad === "over33Percent") {
alerts.push({
kind: "info",
text: "Leitungslänge 0 m: Für diese Berechnung wird kein Spannungsfall angesetzt.",
kind: "warn",
text: "3. Harmonische > 33 %: Der Neutralleiter ist nach dem Neutralleiterstrom zu bemessen – hier nicht berücksichtigt.",
});
}
if (group === "ground") {

View file

@ -1,12 +0,0 @@
CREATE TABLE `document_branding_assets` (
`key` text PRIMARY KEY NOT NULL,
`mime_type` text NOT NULL,
`file_name` text,
`data` blob NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `board_number` text;--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `location` text;--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `fed_from` text;--> statement-breakpoint
ALTER TABLE `distribution_boards` ADD `remark` text;

View file

@ -1,10 +0,0 @@
CREATE TABLE `document_branding_settings` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL,
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
);
--> statement-breakpoint
ALTER TABLE `projects` ADD `site_address` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `work_stage` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `project_lead` text;--> statement-breakpoint
ALTER TABLE `projects` ADD `project_editor` text;

View file

@ -1,9 +0,0 @@
CREATE TABLE `buildings` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text 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
ALTER TABLE `floors` ADD `building_id` text REFERENCES buildings(id) ON DELETE set null;

View file

@ -1,42 +0,0 @@
CREATE TABLE `estimate_balances` (
`id` text PRIMARY KEY NOT NULL,
`project_id` text NOT NULL,
`name` text NOT NULL,
`work_stage` text,
`date_iso` text,
`reserve_av_percent` real DEFAULT 0 NOT NULL,
`reserve_sv_percent` real DEFAULT 0 NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `estimate_balances_project_id_idx` ON `estimate_balances` (`project_id`);--> statement-breakpoint
CREATE TABLE `estimate_groups` (
`id` text PRIMARY KEY NOT NULL,
`balance_id` text NOT NULL,
`cost_group` text DEFAULT '' NOT NULL,
`title` text NOT NULL,
`editor` text,
`sort_order` integer DEFAULT 0 NOT NULL,
FOREIGN KEY (`balance_id`) REFERENCES `estimate_balances`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `estimate_groups_balance_id_idx` ON `estimate_groups` (`balance_id`);--> statement-breakpoint
CREATE TABLE `estimate_rows` (
`id` text PRIMARY KEY NOT NULL,
`group_id` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`supply` text,
`name` text DEFAULT '' NOT NULL,
`quantity` real DEFAULT 0 NOT NULL,
`unit` text DEFAULT 'piece' NOT NULL,
`connection` text,
`power_per_unit_w` real DEFAULT 0 NOT NULL,
`simultaneity_factor` real DEFAULT 1 NOT NULL,
`cos_phi` real DEFAULT 1 NOT NULL,
`safety_supply` integer DEFAULT false NOT NULL,
`remark` text,
FOREIGN KEY (`group_id`) REFERENCES `estimate_groups`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `estimate_rows_group_id_idx` ON `estimate_rows` (`group_id`);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -50,34 +50,6 @@
"when": 1786043080323,
"tag": "0006_damp_skrulls",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1790411540872,
"tag": "0007_board_info_and_document_logo",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1790411560953,
"tag": "0008_project_cover_and_office_profile",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1790419185882,
"tag": "0009_buildings",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1790438092269,
"tag": "0010_estimate_balances",
"breakpoints": true
}
]
}

View file

@ -1,27 +0,0 @@
import { and, asc, eq } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import { buildings } from "../schema/buildings.js";
export class BuildingRepository {
constructor(private readonly database: AppDatabase) {}
async listByProject(projectId: string) {
return this.database
.select()
.from(buildings)
.where(eq(buildings.projectId, projectId))
.orderBy(asc(buildings.sortOrder), asc(buildings.name));
}
async findById(projectId: string, buildingId: string) {
return (
this.database
.select()
.from(buildings)
.where(
and(eq(buildings.projectId, projectId), eq(buildings.id, buildingId))
)
.get() ?? null
);
}
}

View file

@ -388,15 +388,10 @@ function structureMatches(
) {
const {
simultaneityFactor,
boardNumber,
location,
fedFrom,
remark,
...actualDistributionBoard
} = actual.board;
if (
simultaneityFactor !== 1 ||
[boardNumber, location, fedFrom, remark].some((value) => value !== null) ||
!sameRecord(
expected.distributionBoard,
actualDistributionBoard

View file

@ -10,7 +10,6 @@ import {
} from "../../domain/models/distribution-board-subtree-project-command.model.js";
import {
assertDistributionBoardSubtreeSnapshot,
normalizeDistributionBoardSubtreeSnapshot,
type DistributionBoardSubtreeSnapshot,
} from "../../domain/models/distribution-board-subtree-snapshot.model.js";
import type {
@ -64,20 +63,19 @@ export class DistributionBoardSubtreeProjectCommandRepository
projectId: string,
command: DistributionBoardSubtreeProjectCommand
) {
const snapshot = normalizeDistributionBoardSubtreeSnapshot(
if (command.type === distributionBoardInsertSubtreeCommandType) {
this.insert(database, projectId, command.payload.snapshot);
return createDistributionBoardDeleteSubtreeProjectCommand(
command.payload.snapshot
);
if (command.type === distributionBoardInsertSubtreeCommandType) {
this.insert(database, projectId, snapshot);
return createDistributionBoardDeleteSubtreeProjectCommand(snapshot);
}
if (command.type === distributionBoardDeleteSubtreeCommandType) {
const current = this.captureFrom(
database,
projectId,
snapshot.distributionBoard.id
command.payload.snapshot.distributionBoard.id
);
if (!sameSnapshot(current, snapshot)) {
if (!sameSnapshot(current, command.payload.snapshot)) {
throw new Error(
"Distribution-board subtree changed before deletion."
);
@ -99,7 +97,9 @@ export class DistributionBoardSubtreeProjectCommandRepository
"Distribution-board subtree could not be deleted."
);
}
return createDistributionBoardInsertSubtreeProjectCommand(snapshot);
return createDistributionBoardInsertSubtreeProjectCommand(
command.payload.snapshot
);
}
throw new Error("Unsupported distribution-board subtree command.");
}

View file

@ -1,86 +0,0 @@
import { eq, inArray } from "drizzle-orm";
import type { AppDatabase } from "../database-context.js";
import {
documentBrandingAssets,
type DocumentBrandingAsset,
} from "../schema/document-branding-assets.js";
import { documentBrandingSettings } from "../schema/document-branding-settings.js";
// Plain repository without revision semantics: branding is global office
// configuration, not a project mutation.
export class DocumentBrandingRepository {
constructor(private readonly db: AppDatabase) {}
get(key: string): DocumentBrandingAsset | undefined {
return this.db
.select()
.from(documentBrandingAssets)
.where(eq(documentBrandingAssets.key, key))
.get();
}
upsert(asset: {
key: string;
mimeType: string;
fileName: string | null;
data: Buffer;
}): DocumentBrandingAsset {
const updatedAt = new Date();
return this.db
.insert(documentBrandingAssets)
.values({ ...asset, updatedAt })
.onConflictDoUpdate({
target: documentBrandingAssets.key,
set: {
mimeType: asset.mimeType,
fileName: asset.fileName,
data: asset.data,
updatedAt,
},
})
.returning()
.get();
}
delete(key: string): boolean {
return (
this.db
.delete(documentBrandingAssets)
.where(eq(documentBrandingAssets.key, key))
.run().changes > 0
);
}
getSettings<TKey extends string>(keys: readonly TKey[]): Record<TKey, string | null> {
const rows = this.db
.select()
.from(documentBrandingSettings)
.where(inArray(documentBrandingSettings.key, [...keys]))
.all();
return Object.fromEntries(
keys.map((key) => [key, rows.find((row) => row.key === key)?.value ?? null])
) as Record<TKey, string | null>;
}
/** Stores the given values atomically; `null` removes a setting. */
setSettings(values: Record<string, string | null>) {
const updatedAt = new Date();
this.db.transaction((tx) => {
for (const [key, value] of Object.entries(values)) {
if (value === null) {
tx.delete(documentBrandingSettings)
.where(eq(documentBrandingSettings.key, key))
.run();
} else {
tx.insert(documentBrandingSettings)
.values({ key, value, updatedAt })
.onConflictDoUpdate({
target: documentBrandingSettings.key,
set: { value, updatedAt },
})
.run();
}
}
});
}
}

View file

@ -1,132 +0,0 @@
import { eq } from "drizzle-orm";
import {
assertEstimateBalanceDeleteProjectCommand,
assertEstimateBalanceInsertProjectCommand,
assertEstimateBalanceUpdateProjectCommand,
createEstimateBalanceDeleteProjectCommand,
createEstimateBalanceInsertProjectCommand,
createEstimateBalanceUpdateProjectCommand,
estimateBalanceDeleteCommandType,
estimateBalanceInsertCommandType,
estimateBalanceUpdateCommandType,
normalizeEstimateBalanceSnapshot,
sameEstimateBalance,
type EstimateBalanceProjectCommand,
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type {
EstimateBalanceProjectCommandStore,
ExecuteEstimateBalanceCommandInput,
} from "../../estimate-balance/domain/estimate-balance-project-command.store.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
import type { AppDatabase } from "../database-context.js";
import { estimateBalances } from "../schema/estimate-balances.js";
import { projects } from "../schema/projects.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import {
deleteEstimateBalance,
insertEstimateBalance,
readEstimateBalances,
} from "./estimate-balance.persistence.js";
export class EstimateBalanceProjectCommandRepository
implements EstimateBalanceProjectCommandStore
{
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteEstimateBalanceCommandInput) {
return executeProjectCommandTransaction(this.database, input, (tx) =>
this.applyCommand(tx, input.projectId, input.command)
);
}
private applyCommand(
database: AppDatabase,
projectId: string,
command: EstimateBalanceProjectCommand
): EstimateBalanceProjectCommand {
switch (command.type) {
case estimateBalanceInsertCommandType:
assertEstimateBalanceInsertProjectCommand(command);
return this.insert(database, projectId, command.payload.balance);
case estimateBalanceDeleteCommandType:
assertEstimateBalanceDeleteProjectCommand(command);
return this.delete(database, projectId, command.payload.balance);
case estimateBalanceUpdateCommandType:
assertEstimateBalanceUpdateProjectCommand(command);
return this.update(
database,
projectId,
command.payload.expected,
command.payload.target
);
}
}
private insert(
database: AppDatabase,
projectId: string,
balance: EstimateBalanceSnapshot
) {
if (balance.projectId !== projectId) {
throw new Error("Estimate-balance snapshot does not belong to project.");
}
const project = database
.select({ id: projects.id })
.from(projects)
.where(eq(projects.id, projectId))
.get();
if (!project) {
throw new Error("Project does not exist.");
}
const existing = database
.select({ id: estimateBalances.id })
.from(estimateBalances)
.where(eq(estimateBalances.id, balance.id))
.get();
if (existing) {
throw new Error("Estimate-balance id already exists.");
}
const normalized = normalizeEstimateBalanceSnapshot(balance);
insertEstimateBalance(database, normalized);
return createEstimateBalanceDeleteProjectCommand(normalized);
}
private delete(
database: AppDatabase,
projectId: string,
expected: EstimateBalanceSnapshot
) {
this.assertPersisted(database, projectId, expected, "deletion");
if (deleteEstimateBalance(database, projectId, expected.id) !== 1) {
throw new Error("Estimate balance could not be deleted.");
}
return createEstimateBalanceInsertProjectCommand(expected);
}
private update(
database: AppDatabase,
projectId: string,
expected: EstimateBalanceSnapshot,
target: EstimateBalanceSnapshot
) {
this.assertPersisted(database, projectId, expected, "update");
// Group and row ids stay stable; replacing the whole document keeps the
// command exact and its inverse trivially the swapped pair.
deleteEstimateBalance(database, projectId, expected.id);
const normalized = normalizeEstimateBalanceSnapshot(target);
insertEstimateBalance(database, normalized);
return createEstimateBalanceUpdateProjectCommand(normalized, expected);
}
private assertPersisted(
database: AppDatabase,
projectId: string,
expected: EstimateBalanceSnapshot,
action: string
) {
const [persisted] = readEstimateBalances(database, projectId, expected.id);
if (!persisted || !sameEstimateBalance(persisted, expected)) {
throw new Error(`Estimate balance changed before ${action}.`);
}
}
}

View file

@ -1,121 +0,0 @@
import { and, asc, eq, inArray } from "drizzle-orm";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
import type { AppDatabase } from "../database-context.js";
import {
estimateBalances,
estimateGroups,
estimateRows,
} from "../schema/estimate-balances.js";
/** Reads complete balance documents of a project, ordered like the UI shows them. */
export function readEstimateBalances(
database: AppDatabase,
projectId: string,
balanceId?: string
): EstimateBalanceSnapshot[] {
const balances = database
.select()
.from(estimateBalances)
.where(
balanceId
? and(
eq(estimateBalances.projectId, projectId),
eq(estimateBalances.id, balanceId)
)
: eq(estimateBalances.projectId, projectId)
)
.orderBy(asc(estimateBalances.sortOrder), asc(estimateBalances.id))
.all();
if (!balances.length) {
return [];
}
const groups = database
.select()
.from(estimateGroups)
.where(inArray(estimateGroups.balanceId, balances.map((balance) => balance.id)))
.orderBy(asc(estimateGroups.sortOrder), asc(estimateGroups.id))
.all();
const rows = groups.length
? database
.select()
.from(estimateRows)
.where(inArray(estimateRows.groupId, groups.map((group) => group.id)))
.orderBy(asc(estimateRows.sortOrder), asc(estimateRows.id))
.all()
: [];
return balances.map((balance) => ({
id: balance.id,
projectId: balance.projectId,
name: balance.name,
workStage: balance.workStage,
dateIso: balance.dateIso,
reserveAvPercent: balance.reserveAvPercent,
reserveSvPercent: balance.reserveSvPercent,
sortOrder: balance.sortOrder,
groups: groups
.filter((group) => group.balanceId === balance.id)
.map((group) => ({
id: group.id,
costGroup: group.costGroup,
title: group.title,
editor: group.editor,
sortOrder: group.sortOrder,
rows: rows
.filter((row) => row.groupId === group.id)
.map(({ groupId: _groupId, ...row }) => row),
})),
}));
}
/** Writes a complete balance document (the caller removed an older version first). */
export function insertEstimateBalance(
database: AppDatabase,
balance: EstimateBalanceSnapshot
) {
database
.insert(estimateBalances)
.values({
id: balance.id,
projectId: balance.projectId,
name: balance.name,
workStage: balance.workStage,
dateIso: balance.dateIso,
reserveAvPercent: balance.reserveAvPercent,
reserveSvPercent: balance.reserveSvPercent,
sortOrder: balance.sortOrder,
})
.run();
for (const group of balance.groups) {
database
.insert(estimateGroups)
.values({
id: group.id,
balanceId: balance.id,
costGroup: group.costGroup,
title: group.title,
editor: group.editor,
sortOrder: group.sortOrder,
})
.run();
if (group.rows.length) {
database
.insert(estimateRows)
.values(group.rows.map((row) => ({ ...row, groupId: group.id })))
.run();
}
}
}
/** Removes a balance with its groups and rows (foreign keys cascade). */
export function deleteEstimateBalance(
database: AppDatabase,
projectId: string,
balanceId: string
) {
return database
.delete(estimateBalances)
.where(
and(eq(estimateBalances.projectId, projectId), eq(estimateBalances.id, balanceId))
)
.run().changes;
}

View file

@ -1,60 +0,0 @@
import { asc, eq } from "drizzle-orm";
import type {
DetailedDeviceRowPower,
EstimateBalanceSnapshot,
} from "../../shared/estimate-balance/estimate-balance.js";
import { calculateRowTotalPower } from "../../domain/calculations/circuit-power-calculation.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuits } from "../schema/circuits.js";
import { estimateBalances } from "../schema/estimate-balances.js";
import { readEstimateBalances } from "./estimate-balance.persistence.js";
/** Read side of the estimate-balance module; writes go through commands. */
export class EstimateBalanceRepository {
constructor(private readonly database: AppDatabase) {}
listByProject(projectId: string): EstimateBalanceSnapshot[] {
return readEstimateBalances(this.database, projectId);
}
findById(projectId: string, balanceId: string): EstimateBalanceSnapshot | null {
return readEstimateBalances(this.database, projectId, balanceId)[0] ?? null;
}
nextSortOrder(projectId: string): number {
const rows = this.database
.select({ sortOrder: estimateBalances.sortOrder })
.from(estimateBalances)
.where(eq(estimateBalances.projectId, projectId))
.orderBy(asc(estimateBalances.sortOrder))
.all();
return rows.length ? Math.max(...rows.map((row) => row.sortOrder)) + 1 : 0;
}
/**
* Device rows of all circuit lists of the project with their total power
* (quantity · power per unit · device GZF, like the circuit tree).
*/
listDetailedDeviceRowPower(projectId: string): DetailedDeviceRowPower[] {
return this.database
.select({
costGroup: circuitDeviceRows.costGroup,
quantity: circuitDeviceRows.quantity,
powerPerUnit: circuitDeviceRows.powerPerUnit,
simultaneityFactor: circuitDeviceRows.simultaneityFactor,
cosPhi: circuitDeviceRows.cosPhi,
})
.from(circuitDeviceRows)
.innerJoin(circuits, eq(circuits.id, circuitDeviceRows.circuitId))
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
.where(eq(circuitLists.projectId, projectId))
.all()
.map((row) => ({
costGroup: row.costGroup,
kw: calculateRowTotalPower(row.quantity, row.powerPerUnit, row.simultaneityFactor),
cosPhi: row.cosPhi,
}));
}
}

View file

@ -1,34 +1,23 @@
import { and, eq } from "drizzle-orm";
import {
assertProjectBuildingDeleteProjectCommand,
assertProjectBuildingInsertProjectCommand,
assertProjectBuildingUpdateProjectCommand,
assertProjectFloorDeleteProjectCommand,
assertProjectFloorInsertProjectCommand,
assertProjectFloorUpdateProjectCommand,
assertProjectRoomDeleteProjectCommand,
assertProjectRoomInsertProjectCommand,
assertProjectRoomUpdateProjectCommand,
createProjectBuildingDeleteProjectCommand,
createProjectBuildingInsertProjectCommand,
createProjectBuildingUpdateProjectCommand,
createProjectFloorDeleteProjectCommand,
createProjectFloorInsertProjectCommand,
createProjectFloorUpdateProjectCommand,
createProjectRoomDeleteProjectCommand,
createProjectRoomInsertProjectCommand,
createProjectRoomUpdateProjectCommand,
projectBuildingDeleteCommandType,
projectBuildingInsertCommandType,
projectBuildingUpdateCommandType,
projectFloorDeleteCommandType,
projectFloorInsertCommandType,
projectFloorUpdateCommandType,
projectRoomDeleteCommandType,
projectRoomInsertCommandType,
projectRoomUpdateCommandType,
upgradeProjectFloorCommand,
type ProjectBuildingSnapshot,
type ProjectFloorSnapshot,
type ProjectLocationStructureProjectCommand,
type ProjectRoomSnapshot,
@ -38,7 +27,6 @@ import type {
ProjectLocationStructureProjectCommandStore,
} from "../../domain/ports/project-location-structure-project-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { buildings } from "../schema/buildings.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { floors } from "../schema/floors.js";
import { distributionBoards } from "../schema/distribution-boards.js";
@ -52,14 +40,11 @@ export class ProjectLocationStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectLocationStructureCommandInput) {
// Forward and inverse are persisted with one schema version, so a
// version 1 floor command from older history is upgraded first.
const command = toCurrentCommandVersion(input.command);
return executeProjectCommandTransaction(
this.database,
{ ...input, command },
input,
(tx) =>
this.applyCommand(tx, input.projectId, command)
this.applyCommand(tx, input.projectId, input.command)
);
}
@ -69,43 +54,23 @@ export class ProjectLocationStructureProjectCommandRepository
command: ProjectLocationStructureProjectCommand
): ProjectLocationStructureProjectCommand {
switch (command.type) {
case projectBuildingInsertCommandType:
assertProjectBuildingInsertProjectCommand(command);
return this.insertBuilding(database, projectId, command.payload.building);
case projectBuildingDeleteCommandType:
assertProjectBuildingDeleteProjectCommand(command);
return this.deleteBuilding(database, projectId, command.payload.building);
case projectBuildingUpdateCommandType:
assertProjectBuildingUpdateProjectCommand(command);
return this.updateBuilding(
database,
projectId,
command.payload.expected,
command.payload.target
);
case projectFloorInsertCommandType: {
case projectFloorInsertCommandType:
assertProjectFloorInsertProjectCommand(command);
const current = upgradeProjectFloorCommand(command);
return this.insertFloor(
database,
projectId,
current.payload.floor
command.payload.floor
);
}
case projectFloorDeleteCommandType: {
case projectFloorDeleteCommandType:
assertProjectFloorDeleteProjectCommand(command);
const current = upgradeProjectFloorCommand(command);
return this.deleteFloor(
database,
projectId,
current.payload.floor
command.payload.floor
);
}
case projectFloorUpdateCommandType: {
case projectFloorUpdateCommandType:
assertProjectFloorUpdateProjectCommand(command);
const current = upgradeProjectFloorCommand(command);
return this.updateFloor(database, projectId, current.payload.expected, current.payload.target);
}
return this.updateFloor(database, projectId, command.payload.expected, command.payload.target);
case projectRoomInsertCommandType:
assertProjectRoomInsertProjectCommand(command);
return this.insertRoom(
@ -135,7 +100,6 @@ export class ProjectLocationStructureProjectCommandRepository
if (floor.projectId !== projectId) {
throw new Error("Project-floor snapshot does not belong to project.");
}
this.assertBuildingInProject(database, projectId, floor.buildingId);
const existing = database
.select({ id: floors.id })
.from(floors)
@ -203,104 +167,6 @@ export class ProjectLocationStructureProjectCommandRepository
return createProjectFloorInsertProjectCommand(expected);
}
private insertBuilding(
database: AppDatabase,
projectId: string,
building: ProjectBuildingSnapshot
) {
this.assertProjectExists(database, projectId);
if (building.projectId !== projectId) {
throw new Error("Project-building snapshot does not belong to project.");
}
const existing = database
.select({ id: buildings.id })
.from(buildings)
.where(eq(buildings.id, building.id))
.get();
if (existing) {
throw new Error("Project-building id already exists.");
}
database.insert(buildings).values(building).run();
return createProjectBuildingDeleteProjectCommand(building);
}
private deleteBuilding(
database: AppDatabase,
projectId: string,
expected: ProjectBuildingSnapshot
) {
const persisted = database
.select()
.from(buildings)
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
.get();
if (!persisted || !sameRecord(expected, persisted)) {
throw new Error("Project building changed before deletion.");
}
const referencedFloor = database
.select({ id: floors.id })
.from(floors)
.where(eq(floors.buildingId, expected.id))
.limit(1)
.get();
if (referencedFloor) {
throw new Error(
"Das Gebäude kann nicht gelöscht werden, solange Etagen zugeordnet sind."
);
}
const deleted = database
.delete(buildings)
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
.run();
if (deleted.changes !== 1) {
throw new Error("Project building could not be deleted.");
}
return createProjectBuildingInsertProjectCommand(expected);
}
private updateBuilding(
database: AppDatabase,
projectId: string,
expected: ProjectBuildingSnapshot,
target: ProjectBuildingSnapshot
) {
const persisted = database
.select()
.from(buildings)
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
.get();
if (!persisted || !sameRecord(expected, persisted)) {
throw new Error("Project building changed before update.");
}
const updated = database
.update(buildings)
.set({ name: target.name })
.where(and(eq(buildings.id, expected.id), eq(buildings.projectId, projectId)))
.run();
if (updated.changes !== 1) {
throw new Error("Das Gebäude konnte nicht aktualisiert werden.");
}
return createProjectBuildingUpdateProjectCommand(target, expected);
}
private assertBuildingInProject(
database: AppDatabase,
projectId: string,
buildingId: string | null
) {
if (buildingId === null) {
return;
}
const building = database
.select({ id: buildings.id })
.from(buildings)
.where(and(eq(buildings.id, buildingId), eq(buildings.projectId, projectId)))
.get();
if (!building) {
throw new Error("Project floor references a foreign building.");
}
}
private insertRoom(
database: AppDatabase,
projectId: string,
@ -351,10 +217,9 @@ export class ProjectLocationStructureProjectCommandRepository
if (!persisted || !sameRecord(expected, persisted)) {
throw new Error("Project floor changed before update.");
}
this.assertBuildingInProject(database, projectId, target.buildingId);
const updated = database
.update(floors)
.set({ name: target.name, buildingId: target.buildingId })
.set({ name: target.name })
.where(and(eq(floors.id, expected.id), eq(floors.projectId, projectId)))
.run();
if (updated.changes !== 1) {
@ -461,24 +326,6 @@ export class ProjectLocationStructureProjectCommandRepository
}
}
function toCurrentCommandVersion(
command: ProjectLocationStructureProjectCommand
): ProjectLocationStructureProjectCommand {
switch (command.type) {
case projectFloorInsertCommandType:
assertProjectFloorInsertProjectCommand(command);
return upgradeProjectFloorCommand(command);
case projectFloorDeleteCommandType:
assertProjectFloorDeleteProjectCommand(command);
return upgradeProjectFloorCommand(command);
case projectFloorUpdateCommandType:
assertProjectFloorUpdateProjectCommand(command);
return upgradeProjectFloorCommand(command);
default:
return command;
}
}
function sameRecord(
expected: object,
actual: object

View file

@ -1,12 +1,9 @@
import { and, eq, isNotNull } from "drizzle-orm";
import {
assertProjectSettingsUpdateProjectCommand,
baselineProjectSettingsUpdateCommandSchemaVersion,
createProjectSettingsUpdateProjectCommand,
withoutCoverProjectSettingsUpdateCommandSchemaVersion,
type ProjectSettingsValues,
} from "../../domain/models/project-settings-project-command.model.js";
import { projectCoverFields } from "../../shared/constants/project-cover.js";
import type {
ExecuteProjectSettingsUpdateCommandInput,
ProjectSettingsProjectCommandStore,
@ -43,27 +40,13 @@ export class ProjectSettingsProjectCommandRepository
if (!current) {
throw new Error("Project not found.");
}
const currentCoverValues = {
siteAddress: current.siteAddress,
workStage: current.workStage,
projectLead: current.projectLead,
projectEditor: current.projectEditor,
};
// Older command versions do not carry every value; missing values keep
// the current project state and are therefore also absent from the
// inverse of the same version.
const command = input.command;
const target: ProjectSettingsValues =
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
input.command.schemaVersion === 1
? {
...command.payload,
...input.command.payload,
isPublicBuilding: current.isPublicBuilding,
...currentCoverValues,
}
: command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion
? { ...command.payload, ...currentCoverValues }
: command.payload;
: input.command.payload;
if (projectSettingsEqual(current, target)) {
throw new Error("Project settings did not change.");
}
@ -79,14 +62,10 @@ export class ProjectSettingsProjectCommandRepository
threePhaseVoltageV: current.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
current.enabledDistributionBoardSupplyTypes,
...currentCoverValues,
};
const inverse =
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
input.command.schemaVersion === 1
? createBaselineProjectSettingsUpdateCommand(inverseValues)
: command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion
? createWithoutCoverProjectSettingsUpdateCommand(inverseValues)
: createProjectSettingsUpdateProjectCommand(inverseValues);
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
const updated = tx
@ -103,32 +82,11 @@ export class ProjectSettingsProjectCommandRepository
}
}
function createWithoutCoverProjectSettingsUpdateCommand(
values: ProjectSettingsValues
) {
return {
schemaVersion: withoutCoverProjectSettingsUpdateCommandSchemaVersion,
type: "project.update-settings" as const,
payload: {
name: values.name,
internalProjectNumber: values.internalProjectNumber,
externalProjectNumber: values.externalProjectNumber,
buildingOwner: values.buildingOwner,
description: values.description,
isPublicBuilding: values.isPublicBuilding,
singlePhaseVoltageV: values.singlePhaseVoltageV,
threePhaseVoltageV: values.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
values.enabledDistributionBoardSupplyTypes,
},
};
}
function createBaselineProjectSettingsUpdateCommand(
values: ProjectSettingsValues
) {
return {
schemaVersion: baselineProjectSettingsUpdateCommandSchemaVersion,
schemaVersion: 1 as const,
type: "project.update-settings" as const,
payload: {
name: values.name,
@ -156,9 +114,8 @@ function projectSettingsEqual(
current.description === target.description &&
current.isPublicBuilding === target.isPublicBuilding &&
current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
current.threePhaseVoltageV === target.threePhaseVoltageV &&
projectCoverFields.every((field) => current[field] === target[field]) &&
sameSupplyTypes(
current.threePhaseVoltageV === target.threePhaseVoltageV
&& sameSupplyTypes(
current.enabledDistributionBoardSupplyTypes,
target.enabledDistributionBoardSupplyTypes
)

View file

@ -5,7 +5,6 @@ import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-sn
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import {
deserializeProjectStateSnapshot,
isSupportedProjectStateSnapshotSchemaVersion,
projectStateSnapshotSchemaVersion,
} from "../../domain/models/project-state-snapshot.model.js";
import type {
@ -139,7 +138,7 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
if (actualChecksum !== stored.payloadSha256) {
throw new Error("Project snapshot checksum verification failed.");
}
if (!isSupportedProjectStateSnapshotSchemaVersion(stored.schemaVersion)) {
if (stored.schemaVersion !== projectStateSnapshotSchemaVersion) {
throw new Error("Project snapshot schema version is not supported.");
}
const targetState = deserializeProjectStateSnapshot(stored.payloadJson);

View file

@ -3,8 +3,6 @@ import { ProjectStateConflictError } from "../../domain/errors/project-state-con
import {
assertProjectStateRestoreCommand,
createProjectStateRestoreCommand,
createProjectTransferRestoreCommand,
projectStateRestoreCommandSchemaVersion,
} from "../../domain/models/project-state-restore-command.model.js";
import {
parseProjectStateSnapshot,
@ -15,8 +13,6 @@ import type {
ProjectStateRestoreCommandStore,
} from "../../domain/ports/project-state-restore-command.store.js";
import type { AppDatabase } from "../database-context.js";
import { canonicalEstimateBalance } from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { circuitLists } from "../schema/circuit-lists.js";
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
@ -25,7 +21,6 @@ import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { buildings } from "../schema/buildings.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
import { projectChangeSets } from "../schema/project-change-sets.js";
@ -36,11 +31,6 @@ import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import { estimateBalances } from "../schema/estimate-balances.js";
import {
insertEstimateBalance,
readEstimateBalances,
} from "./estimate-balance.persistence.js";
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
import {
readProjectStateSnapshot,
@ -75,39 +65,10 @@ export class ProjectStateRestoreCommandRepository
if (!current) {
throw new Error("Project not found.");
}
const currentEstimateBalances = readEstimateBalances(tx, input.projectId);
const transferCommand =
input.command.schemaVersion === projectStateRestoreCommandSchemaVersion
? input.command
: null;
if (
current.payloadSha256 !==
input.command.payload.expectedStateSha256 &&
!matchesHistoryRestoreTarget(
tx,
input,
current.state,
currentEstimateBalances
)
) {
throw new ProjectStateConflictError(
input.projectId,
input.command.payload.expectedStateSha256,
current.payloadSha256
);
}
if (
transferCommand &&
canonicalEstimateBalances(currentEstimateBalances) !==
canonicalEstimateBalances(
transferCommand.payload.expectedEstimateBalances
) &&
!matchesHistoryRestoreTarget(
tx,
input,
current.state,
currentEstimateBalances
)
!matchesHistoryRestoreTarget(tx, input, current.state)
) {
throw new ProjectStateConflictError(
input.projectId,
@ -120,25 +81,11 @@ export class ProjectStateRestoreCommandRepository
input.command.payload.targetState
);
replaceProjectState(tx, targetState);
if (transferCommand) {
replaceEstimateBalances(
tx,
input.projectId,
transferCommand.payload.targetEstimateBalances
);
}
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
if (!persistedTarget) {
throw new Error("Restored project could not be loaded.");
}
const inverse = transferCommand
? createProjectTransferRestoreCommand(
persistedTarget.payloadSha256,
current.state,
transferCommand.payload.targetEstimateBalances,
currentEstimateBalances
)
: createProjectStateRestoreCommand(
const inverse = createProjectStateRestoreCommand(
persistedTarget.payloadSha256,
current.state
);
@ -150,8 +97,7 @@ export class ProjectStateRestoreCommandRepository
function matchesHistoryRestoreTarget(
database: AppDatabase,
input: ExecuteProjectStateRestoreCommandInput,
currentState: ProjectStateSnapshot,
currentEstimateBalances: EstimateBalanceSnapshot[]
currentState: ProjectStateSnapshot
) {
if (
(input.source !== "undo" && input.source !== "redo") ||
@ -175,45 +121,25 @@ function matchesHistoryRestoreTarget(
input.source === "undo"
? changeSet.forwardPayloadJson
: changeSet.inversePayloadJson;
const command = JSON.parse(serialized) as unknown;
assertProjectStateRestoreCommand(command);
const expectedState = parseProjectStateSnapshot(command.payload.targetState);
const stateMatches =
canonicalProjectState(expectedState) ===
canonicalProjectState(currentState);
if (!stateMatches) {
const command = JSON.parse(serialized) as {
type?: unknown;
payload?: { targetState?: unknown };
};
if (command.type !== "project.restore-state") {
return false;
}
return command.schemaVersion !== projectStateRestoreCommandSchemaVersion ||
canonicalEstimateBalances(command.payload.targetEstimateBalances) ===
canonicalEstimateBalances(currentEstimateBalances);
const expectedState = parseProjectStateSnapshot(
command.payload?.targetState
);
return (
canonicalProjectState(expectedState) ===
canonicalProjectState(currentState)
);
} catch {
return false;
}
}
function canonicalEstimateBalances(balances: EstimateBalanceSnapshot[]) {
return JSON.stringify(
[...balances]
.sort((left, right) => left.id.localeCompare(right.id))
.map((balance) => canonicalEstimateBalance(balance))
);
}
function replaceEstimateBalances(
database: AppDatabase,
projectId: string,
balances: EstimateBalanceSnapshot[]
) {
database
.delete(estimateBalances)
.where(eq(estimateBalances.projectId, projectId))
.run();
for (const balance of balances) {
insertEstimateBalance(database, balance);
}
}
function canonicalProjectState(state: ProjectStateSnapshot) {
return JSON.stringify({
...state,
@ -238,7 +164,6 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
}))
),
projectDevices: byId(state.projectDevices),
buildings: byId(state.buildings),
floors: byId(state.floors),
rooms: byId(state.rooms),
externalCsvConfiguration: state.externalCsvConfiguration,
@ -294,10 +219,6 @@ function replaceProjectState(
.delete(floors)
.where(eq(floors.projectId, state.project.id))
.run();
database
.delete(buildings)
.where(eq(buildings.projectId, state.project.id))
.run();
database
.update(projects)
@ -312,15 +233,10 @@ function replaceProjectState(
threePhaseVoltageV: state.project.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
state.project.enabledDistributionBoardSupplyTypes,
siteAddress: state.project.siteAddress,
workStage: state.project.workStage,
projectLead: state.project.projectLead,
projectEditor: state.project.projectEditor,
})
.where(eq(projects.id, state.project.id))
.run();
insertMany(database, buildings, state.buildings);
insertMany(database, floors, state.floors);
if (state.externalCsvConfiguration !== null) {
database

View file

@ -15,7 +15,6 @@ import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { buildings } from "../schema/buildings.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
@ -47,10 +46,6 @@ export function readProjectStateSnapshot(
threePhaseVoltageV: projects.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
projects.enabledDistributionBoardSupplyTypes,
siteAddress: projects.siteAddress,
workStage: projects.workStage,
projectLead: projects.projectLead,
projectEditor: projects.projectEditor,
currentRevision: projects.currentRevision,
})
.from(projects)
@ -220,12 +215,6 @@ export function readProjectStateSnapshot(
.where(eq(projectDevices.projectId, projectId))
.orderBy(asc(projectDevices.displayName), asc(projectDevices.id))
.all();
const buildingRows = database
.select()
.from(buildings)
.where(eq(buildings.projectId, projectId))
.orderBy(asc(buildings.sortOrder), asc(buildings.id))
.all();
const floorRows = database
.select()
.from(floors)
@ -260,10 +249,6 @@ export function readProjectStateSnapshot(
threePhaseVoltageV: project.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes:
project.enabledDistributionBoardSupplyTypes,
siteAddress: project.siteAddress,
workStage: project.workStage,
projectLead: project.projectLead,
projectEditor: project.projectEditor,
},
distributionBoards: boardRows,
externalCsvConfiguration,
@ -280,7 +265,6 @@ export function readProjectStateSnapshot(
deviceRows: deviceRowsByCircuit.get(circuit.id) ?? [],
})),
projectDevices: projectDeviceRows,
buildings: buildingRows,
floors: floorRows,
rooms: roomRows,
});

View file

@ -3,11 +3,9 @@ import { eq } from "drizzle-orm";
import {
createProjectTransferEnvelope,
parseProjectTransferEnvelope,
remapEstimateBalances,
remapProjectState,
serializeProjectTransferPayload,
} from "../../domain/models/project-transfer.model.js";
import { createProjectTransferRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js";
import type { AppDatabase } from "../database-context.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
@ -18,7 +16,6 @@ import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js";
import { buildings } from "../schema/buildings.js";
import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js";
@ -28,10 +25,6 @@ import { externalImportBatches } from "../schema/external-import-batches.js";
import { externalModelObjects } from "../schema/external-model-objects.js";
import { externalModelSources } from "../schema/external-model-sources.js";
import { externalRoomMappings } from "../schema/external-room-mappings.js";
import {
insertEstimateBalance,
readEstimateBalances,
} from "./estimate-balance.persistence.js";
import {
hashProjectStatePayload,
readProjectStateSnapshot,
@ -46,15 +39,10 @@ export class ProjectTransferRepository implements ProjectTransferStore {
if (!snapshot) {
return null;
}
const estimateBalances = readEstimateBalances(this.database, projectId);
const payloadSha256 = hashProjectStatePayload(
serializeProjectTransferPayload(snapshot.state, estimateBalances)
);
return createProjectTransferEnvelope(
new Date().toISOString(),
payloadSha256,
snapshot.state,
estimateBalances
snapshot.payloadSha256,
snapshot.state
);
}
@ -72,21 +60,10 @@ export class ProjectTransferRepository implements ProjectTransferStore {
projectId,
() => crypto.randomUUID()
);
const currentEstimateBalances = readEstimateBalances(this.database, projectId);
const targetEstimateBalances =
transfer.projectState.project.id === projectId
? transfer.estimateBalances
: remapEstimateBalances(
transfer.estimateBalances,
projectId,
() => crypto.randomUUID()
);
return {
command: createProjectTransferRestoreCommand(
command: createProjectStateRestoreCommand(
current.payloadSha256,
targetState,
currentEstimateBalances,
targetEstimateBalances
targetState
),
};
}
@ -100,38 +77,19 @@ export class ProjectTransferRepository implements ProjectTransferStore {
() => crypto.randomUUID(),
`${transfer.projectState.project.name} (Kopie)`
);
const estimateBalances = remapEstimateBalances(
transfer.estimateBalances,
projectId,
() => crypto.randomUUID()
);
this.database.transaction((tx) => {
insertProjectState(tx, state);
for (const balance of estimateBalances) {
insertEstimateBalance(tx, balance);
}
});
this.database.transaction((tx) => insertProjectState(tx, state));
return { projectId, name: state.project.name };
}
private parseVerified(value: unknown) {
const isLegacy = isPlainObject(value) && value.formatVersion === 1;
const rawPayloadSha256 = isPlainObject(value) && isPlainObject(value.projectState)
? hashProjectStatePayload(
isLegacy
? JSON.stringify(value.projectState)
: JSON.stringify({
projectState: value.projectState,
estimateBalances: value.estimateBalances,
})
)
const rawPayloadSha256 =
isPlainObject(value) &&
isPlainObject(value.projectState)
? hashProjectStatePayload(JSON.stringify(value.projectState))
: null;
const transfer = parseProjectTransferEnvelope(value);
const payloadJson = isLegacy
? serializeProjectStateSnapshot(transfer.projectState)
: serializeProjectTransferPayload(
transfer.projectState,
transfer.estimateBalances
const payloadJson = serializeProjectStateSnapshot(
transfer.projectState
);
if (
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
@ -169,7 +127,6 @@ function insertProjectState(
.values(state.externalCsvConfiguration)
.run();
}
insertMany(database, buildings, state.buildings);
insertMany(database, floors, state.floors);
insertMany(database, rooms, state.rooms);
insertMany(database, projectDevices, state.projectDevices);

View file

@ -31,10 +31,6 @@ export class ProjectRepository {
enabledDistributionBoardSupplyTypes: [
...defaultDistributionBoardSupplyTypes,
],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
currentRevision: 0,
};
await db.insert(projects).values(project);

View file

@ -1,11 +0,0 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { projects } from "./projects.js";
export const buildings = sqliteTable("buildings", {
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
});

View file

@ -16,9 +16,5 @@ export const distributionBoards = sqliteTable("distribution_boards", {
simultaneityFactor: real("simultaneity_factor")
.notNull()
.default(1),
boardNumber: text("board_number"),
location: text("location"),
fedFrom: text("fed_from"),
remark: text("remark"),
});

View file

@ -1,18 +0,0 @@
import { sql } from "drizzle-orm";
import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// Office-wide branding for printed documents (currently only the logo shown
// on the cover page and plan title blocks). Global application data, not part
// of any project: it is deliberately outside project revisions, logical
// project snapshots and project transfer.
export const documentBrandingAssets = sqliteTable("document_branding_assets", {
key: text("key").primaryKey(),
mimeType: text("mime_type").notNull(),
fileName: text("file_name"),
data: blob("data", { mode: "buffer" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});
export type DocumentBrandingAsset = typeof documentBrandingAssets.$inferSelect;

View file

@ -1,13 +0,0 @@
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// Office-wide text settings for printed documents (e.g. office name and
// address on the cover page). Global application data like
// document_branding_assets: outside project revisions, snapshots and transfer.
export const documentBrandingSettings = sqliteTable("document_branding_settings", {
key: text("key").primaryKey(),
value: text("value").notNull(),
updatedAt: integer("updated_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
});

View file

@ -1,60 +0,0 @@
import { index, integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { projects } from "./projects.js";
// Grobe Leistungsbilanz (LPH 2/3): a separate module without links into the
// circuit structure. See docs/estimate-balance.md.
export const estimateBalances = sqliteTable(
"estimate_balances",
{
id: text("id").primaryKey(),
projectId: text("project_id")
.notNull()
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
workStage: text("work_stage"),
dateIso: text("date_iso"),
reserveAvPercent: real("reserve_av_percent").notNull().default(0),
reserveSvPercent: real("reserve_sv_percent").notNull().default(0),
sortOrder: integer("sort_order").notNull().default(0),
},
(table) => [index("estimate_balances_project_id_idx").on(table.projectId)]
);
export const estimateGroups = sqliteTable(
"estimate_groups",
{
id: text("id").primaryKey(),
balanceId: text("balance_id")
.notNull()
.references(() => estimateBalances.id, { onDelete: "cascade" }),
costGroup: text("cost_group").notNull().default(""),
title: text("title").notNull(),
editor: text("editor"),
sortOrder: integer("sort_order").notNull().default(0),
},
(table) => [index("estimate_groups_balance_id_idx").on(table.balanceId)]
);
export const estimateRows = sqliteTable(
"estimate_rows",
{
id: text("id").primaryKey(),
groupId: text("group_id")
.notNull()
.references(() => estimateGroups.id, { onDelete: "cascade" }),
sortOrder: integer("sort_order").notNull().default(0),
supply: text("supply"),
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)]
);

View file

@ -1,5 +1,4 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { buildings } from "./buildings.js";
import { projects } from "./projects.js";
export const floors = sqliteTable("floors", {
@ -9,9 +8,4 @@ export const floors = sqliteTable("floors", {
.references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
// Optional parent building. Floors created before buildings existed stay
// without one and are shown as "Ohne Gebäude".
buildingId: text("building_id").references(() => buildings.id, {
onDelete: "set null",
}),
});

View file

@ -21,9 +21,5 @@ export const projects = sqliteTable("projects", {
.$type<DistributionBoardSupplyType[]>()
.notNull()
.default(sql`'["AV","SV","EV","USV","MSR","SiBe"]'`),
siteAddress: text("site_address"),
workStage: text("work_stage"),
projectLead: text("project_lead"),
projectEditor: text("project_editor"),
currentRevision: integer("current_revision").notNull().default(0),
});

View file

@ -1,21 +1,14 @@
import {
distributionBoardInfoFieldMaxLengths,
distributionBoardInfoFields,
distributionBoardSupplyTypes,
type DistributionBoardInfoField,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardUpdateCommandType =
"distribution-board.update" as const;
// The info-card fields were added additively to schema version 1: every
// previously stored command remains valid, and the inverse is still derived
// from the persisted row for exactly the changed fields.
export const distributionBoardUpdateCommandSchemaVersion = 1 as const;
export interface DistributionBoardUpdateValues
extends Record<DistributionBoardInfoField, string | null> {
export interface DistributionBoardUpdateValues {
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
@ -89,20 +82,16 @@ export function assertDistributionBoardUpdateProjectCommand(
for (const change of changes) {
if (
!isPlainObject(change) ||
typeof change.field !== "string" ||
(change.field !== "floorId" &&
change.field !== "supplyType" &&
change.field !== "simultaneityFactor" &&
!isInfoField(change.field)) ||
change.field !== "simultaneityFactor") ||
seen.has(change.field)
) {
throw new Error(
"Distribution-board update contains an invalid or duplicate field."
);
}
if (isInfoField(change.field)) {
assertInfoFieldValue(change.field, change.value);
} else if (change.field === "floorId") {
if (change.field === "floorId") {
if (
change.value !== null &&
(typeof change.value !== "string" || !change.value.trim())
@ -132,29 +121,6 @@ export function assertDistributionBoardUpdateProjectCommand(
}
}
function isInfoField(field: string): field is DistributionBoardInfoField {
return (distributionBoardInfoFields as readonly string[]).includes(field);
}
function assertInfoFieldValue(
field: DistributionBoardInfoField,
value: unknown
) {
if (value === null) {
return;
}
if (
typeof value !== "string" ||
!value.trim() ||
value !== value.trim() ||
value.length > distributionBoardInfoFieldMaxLengths[field]
) {
throw new Error(
`${field} must be a trimmed, non-empty string of at most ${distributionBoardInfoFieldMaxLengths[field]} characters or null.`
);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -10,9 +10,7 @@ import {
type DistributionBoardComponentRole,
} from "../../shared/constants/distribution-board-component.js";
import {
distributionBoardInfoFields,
distributionBoardSupplyTypes,
type DistributionBoardInfoField,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import type {
@ -74,7 +72,7 @@ export interface DistributionBoardSubtreeSnapshot {
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
} & Record<DistributionBoardInfoField, string | null>;
};
circuitList: {
id: string;
projectId: string;
@ -171,22 +169,6 @@ export function cloneDistributionBoardSubtree(
return clone;
}
/**
* Subtree commands stored before the board info fields existed carry no
* info values. They are equivalent to a board whose info fields are empty,
* so comparisons against a freshly captured subtree and re-inserts stay
* exact.
*/
export function normalizeDistributionBoardSubtreeSnapshot(
snapshot: DistributionBoardSubtreeSnapshot
): DistributionBoardSubtreeSnapshot {
const board = { ...snapshot.distributionBoard };
for (const field of distributionBoardInfoFields) {
board[field] = board[field] ?? null;
}
return { ...snapshot, distributionBoard: board };
}
export function assertDistributionBoardSubtreeSnapshot(
value: unknown
): asserts value is DistributionBoardSubtreeSnapshot {
@ -222,11 +204,6 @@ export function assertDistributionBoardSubtreeSnapshot(
if (board.simultaneityFactor < 0 || board.simultaneityFactor > 1) {
throw new Error("Distribution-board simultaneity factor is invalid.");
}
for (const field of distributionBoardInfoFields) {
if (board[field] !== undefined) {
assertNullableString(board[field], `distributionBoard.${field}`);
}
}
for (const field of [
"id",
"projectId",

View file

@ -1,42 +1,21 @@
import crypto from "node:crypto";
import type { SerializedProjectCommand } from "./project-command.model.js";
export const projectBuildingInsertCommandType = "project-building.insert" as const;
export const projectBuildingDeleteCommandType = "project-building.delete" as const;
export const projectBuildingUpdateCommandType = "project-building.update" as const;
export const projectFloorInsertCommandType = "project-floor.insert" as const;
export const projectFloorDeleteCommandType = "project-floor.delete" as const;
export const projectFloorUpdateCommandType = "project-floor.update" as const;
export const projectRoomInsertCommandType = "project-room.insert" as const;
export const projectRoomDeleteCommandType = "project-room.delete" as const;
export const projectRoomUpdateCommandType = "project-room.update" as const;
/** Room commands, building commands and legacy floor commands. */
export const projectLocationStructureCommandSchemaVersion = 1 as const;
/**
* Floor commands carry `buildingId` since version 2. Version 1 floor
* commands stay executable from persisted history: they were recorded before
* buildings existed, so they are upgraded with `buildingId = null`.
*/
export const projectFloorCommandSchemaVersion = 2 as const;
const legacyProjectFloorCommandSchemaVersion = 1 as const;
export interface ProjectBuildingSnapshot {
id: string;
projectId: string;
name: string;
sortOrder: number;
}
export interface ProjectFloorSnapshot {
id: string;
projectId: string;
name: string;
sortOrder: number;
buildingId: string | null;
}
export type LegacyProjectFloorSnapshot = Omit<ProjectFloorSnapshot, "buildingId">;
export interface ProjectRoomSnapshot {
id: string;
projectId: string;
@ -45,93 +24,39 @@ export interface ProjectRoomSnapshot {
roomName: string;
}
interface ProjectBuildingStructureCommandPayload {
building: ProjectBuildingSnapshot;
}
interface ProjectFloorStructureCommandPayload {
floor: ProjectFloorSnapshot;
}
interface LegacyProjectFloorStructureCommandPayload {
floor: LegacyProjectFloorSnapshot;
}
interface ProjectRoomStructureCommandPayload {
room: ProjectRoomSnapshot;
}
interface ProjectBuildingUpdateCommandPayload {
expected: ProjectBuildingSnapshot;
target: ProjectBuildingSnapshot;
}
interface ProjectFloorUpdateCommandPayload {
expected: ProjectFloorSnapshot;
target: ProjectFloorSnapshot;
}
interface LegacyProjectFloorUpdateCommandPayload {
expected: LegacyProjectFloorSnapshot;
target: LegacyProjectFloorSnapshot;
}
interface ProjectRoomUpdateCommandPayload {
expected: ProjectRoomSnapshot;
target: ProjectRoomSnapshot;
}
export interface ProjectBuildingInsertProjectCommand
extends SerializedProjectCommand<ProjectBuildingStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectBuildingInsertCommandType;
}
export interface ProjectBuildingDeleteProjectCommand
extends SerializedProjectCommand<ProjectBuildingStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectBuildingDeleteCommandType;
}
export interface ProjectBuildingUpdateProjectCommand
extends SerializedProjectCommand<ProjectBuildingUpdateCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectBuildingUpdateCommandType;
}
export interface ProjectFloorInsertProjectCommand
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
schemaVersion: typeof projectFloorCommandSchemaVersion;
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectFloorInsertCommandType;
}
export interface ProjectFloorDeleteProjectCommand
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
schemaVersion: typeof projectFloorCommandSchemaVersion;
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectFloorDeleteCommandType;
}
export interface ProjectFloorUpdateProjectCommand
extends SerializedProjectCommand<ProjectFloorUpdateCommandPayload> {
schemaVersion: typeof projectFloorCommandSchemaVersion;
type: typeof projectFloorUpdateCommandType;
}
export interface LegacyProjectFloorInsertProjectCommand
extends SerializedProjectCommand<LegacyProjectFloorStructureCommandPayload> {
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
type: typeof projectFloorInsertCommandType;
}
export interface LegacyProjectFloorDeleteProjectCommand
extends SerializedProjectCommand<LegacyProjectFloorStructureCommandPayload> {
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
type: typeof projectFloorDeleteCommandType;
}
export interface LegacyProjectFloorUpdateProjectCommand
extends SerializedProjectCommand<LegacyProjectFloorUpdateCommandPayload> {
schemaVersion: typeof legacyProjectFloorCommandSchemaVersion;
schemaVersion: typeof projectLocationStructureCommandSchemaVersion;
type: typeof projectFloorUpdateCommandType;
}
@ -154,46 +79,23 @@ export interface ProjectRoomUpdateProjectCommand
}
export type ProjectLocationStructureProjectCommand =
| ProjectBuildingInsertProjectCommand
| ProjectBuildingDeleteProjectCommand
| ProjectBuildingUpdateProjectCommand
| ProjectFloorInsertProjectCommand
| ProjectFloorDeleteProjectCommand
| ProjectFloorUpdateProjectCommand
| LegacyProjectFloorInsertProjectCommand
| LegacyProjectFloorDeleteProjectCommand
| LegacyProjectFloorUpdateProjectCommand
| ProjectRoomInsertProjectCommand
| ProjectRoomDeleteProjectCommand
| ProjectRoomUpdateProjectCommand;
export function createProjectBuildingSnapshot(
projectId: string,
name: string,
sortOrder: number
): ProjectBuildingSnapshot {
const building: ProjectBuildingSnapshot = {
id: crypto.randomUUID(),
projectId,
name: name.trim(),
sortOrder,
};
assertProjectBuildingSnapshot(building);
return building;
}
export function createProjectFloorSnapshot(
projectId: string,
name: string,
sortOrder: number,
buildingId: string | null = null
sortOrder: number
): ProjectFloorSnapshot {
const floor: ProjectFloorSnapshot = {
id: crypto.randomUUID(),
projectId,
name: name.trim(),
sortOrder,
buildingId: buildingId?.trim() || null,
};
assertProjectFloorSnapshot(floor);
return floor;
@ -218,46 +120,12 @@ export function createProjectRoomSnapshot(
return room;
}
export function createProjectBuildingInsertProjectCommand(
building: ProjectBuildingSnapshot
): ProjectBuildingInsertProjectCommand {
assertProjectBuildingSnapshot(building);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectBuildingInsertCommandType,
payload: { building },
};
}
export function createProjectBuildingDeleteProjectCommand(
building: ProjectBuildingSnapshot
): ProjectBuildingDeleteProjectCommand {
assertProjectBuildingSnapshot(building);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectBuildingDeleteCommandType,
payload: { building },
};
}
export function createProjectBuildingUpdateProjectCommand(
expected: ProjectBuildingSnapshot,
target: ProjectBuildingSnapshot
): ProjectBuildingUpdateProjectCommand {
assertMatchingBuildingUpdate(expected, target);
return {
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectBuildingUpdateCommandType,
payload: { expected, target },
};
}
export function createProjectFloorInsertProjectCommand(
floor: ProjectFloorSnapshot
): ProjectFloorInsertProjectCommand {
assertProjectFloorSnapshot(floor);
return {
schemaVersion: projectFloorCommandSchemaVersion,
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectFloorInsertCommandType,
payload: { floor },
};
@ -268,7 +136,7 @@ export function createProjectFloorDeleteProjectCommand(
): ProjectFloorDeleteProjectCommand {
assertProjectFloorSnapshot(floor);
return {
schemaVersion: projectFloorCommandSchemaVersion,
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectFloorDeleteCommandType,
payload: { floor },
};
@ -302,7 +170,7 @@ export function createProjectFloorUpdateProjectCommand(
): ProjectFloorUpdateProjectCommand {
assertMatchingFloorUpdate(expected, target);
return {
schemaVersion: projectFloorCommandSchemaVersion,
schemaVersion: projectLocationStructureCommandSchemaVersion,
type: projectFloorUpdateCommandType,
payload: { expected, target },
};
@ -320,43 +188,9 @@ export function createProjectRoomUpdateProjectCommand(
};
}
export function assertProjectBuildingInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectBuildingInsertProjectCommand {
assertProjectBuildingStructureProjectCommand(
command,
projectBuildingInsertCommandType
);
}
export function assertProjectBuildingDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectBuildingDeleteProjectCommand {
assertProjectBuildingStructureProjectCommand(
command,
projectBuildingDeleteCommandType
);
}
export function assertProjectBuildingUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is ProjectBuildingUpdateProjectCommand {
if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
command.type !== projectBuildingUpdateCommandType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 2
) {
throw new Error("Unsupported project-building update command.");
}
assertMatchingBuildingUpdate(command.payload.expected, command.payload.target);
}
export function assertProjectFloorInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is
| ProjectFloorInsertProjectCommand
| LegacyProjectFloorInsertProjectCommand {
): asserts command is ProjectFloorInsertProjectCommand {
assertProjectFloorStructureProjectCommand(
command,
projectFloorInsertCommandType
@ -365,9 +199,7 @@ export function assertProjectFloorInsertProjectCommand(
export function assertProjectFloorDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is
| ProjectFloorDeleteProjectCommand
| LegacyProjectFloorDeleteProjectCommand {
): asserts command is ProjectFloorDeleteProjectCommand {
assertProjectFloorStructureProjectCommand(
command,
projectFloorDeleteCommandType
@ -394,26 +226,15 @@ export function assertProjectRoomDeleteProjectCommand(
export function assertProjectFloorUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is
| ProjectFloorUpdateProjectCommand
| LegacyProjectFloorUpdateProjectCommand {
): asserts command is ProjectFloorUpdateProjectCommand {
if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
command.type !== projectFloorUpdateCommandType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 2
) {
throw new Error("Unsupported project-floor update command.");
}
if (command.schemaVersion === legacyProjectFloorCommandSchemaVersion) {
assertMatchingFloorUpdate(
upgradeLegacyFloorSnapshot(command.payload.expected),
upgradeLegacyFloorSnapshot(command.payload.target)
);
return;
}
if (command.schemaVersion !== projectFloorCommandSchemaVersion) {
throw new Error("Unsupported project-floor update command.");
}
assertMatchingFloorUpdate(command.payload.expected, command.payload.target);
}
@ -431,68 +252,6 @@ export function assertProjectRoomUpdateProjectCommand(
assertMatchingRoomUpdate(command.payload.expected, command.payload.target);
}
/**
* Upgrades a (validated) floor command of any supported version to the
* current version. Version 1 commands predate buildings; their floors had no
* building, so `buildingId` becomes null.
*/
export function upgradeProjectFloorCommand(
command:
| ProjectFloorInsertProjectCommand
| LegacyProjectFloorInsertProjectCommand
): ProjectFloorInsertProjectCommand;
export function upgradeProjectFloorCommand(
command:
| ProjectFloorDeleteProjectCommand
| LegacyProjectFloorDeleteProjectCommand
): ProjectFloorDeleteProjectCommand;
export function upgradeProjectFloorCommand(
command:
| ProjectFloorUpdateProjectCommand
| LegacyProjectFloorUpdateProjectCommand
): ProjectFloorUpdateProjectCommand;
export function upgradeProjectFloorCommand(
command:
| ProjectFloorInsertProjectCommand
| ProjectFloorDeleteProjectCommand
| ProjectFloorUpdateProjectCommand
| LegacyProjectFloorInsertProjectCommand
| LegacyProjectFloorDeleteProjectCommand
| LegacyProjectFloorUpdateProjectCommand
) {
if (command.schemaVersion === projectFloorCommandSchemaVersion) {
return command;
}
switch (command.type) {
case projectFloorInsertCommandType:
return createProjectFloorInsertProjectCommand(
upgradeLegacyFloorSnapshot(command.payload.floor)
);
case projectFloorDeleteCommandType:
return createProjectFloorDeleteProjectCommand(
upgradeLegacyFloorSnapshot(command.payload.floor)
);
case projectFloorUpdateCommandType:
return createProjectFloorUpdateProjectCommand(
upgradeLegacyFloorSnapshot(command.payload.expected),
upgradeLegacyFloorSnapshot(command.payload.target)
);
}
}
function assertMatchingBuildingUpdate(expected: unknown, target: unknown) {
assertProjectBuildingSnapshot(expected);
assertProjectBuildingSnapshot(target);
if (
expected.id !== target.id ||
expected.projectId !== target.projectId ||
expected.sortOrder !== target.sortOrder ||
expected.name === target.name
) {
throw new Error("Project-building update must change only the name.");
}
}
function assertMatchingFloorUpdate(expected: unknown, target: unknown) {
assertProjectFloorSnapshot(expected);
assertProjectFloorSnapshot(target);
@ -500,11 +259,9 @@ function assertMatchingFloorUpdate(expected: unknown, target: unknown) {
expected.id !== target.id ||
expected.projectId !== target.projectId ||
expected.sortOrder !== target.sortOrder ||
(expected.name === target.name && expected.buildingId === target.buildingId)
expected.name === target.name
) {
throw new Error(
"Project-floor update must change only the name or the building."
);
throw new Error("Project-floor update must change only the name.");
}
}
@ -520,49 +277,22 @@ function assertMatchingRoomUpdate(expected: unknown, target: unknown) {
}
}
export function assertProjectBuildingSnapshot(
building: unknown
): asserts building is ProjectBuildingSnapshot {
if (!isPlainObject(building) || Object.keys(building).length !== 4) {
throw new Error("Project-building snapshot is invalid.");
}
assertNormalizedNonEmptyString(building.id, "building.id");
assertNormalizedNonEmptyString(building.projectId, "building.projectId");
assertNormalizedNonEmptyString(building.name, "building.name");
assertSortOrder(building.sortOrder, "building.sortOrder");
}
export function assertProjectFloorSnapshot(
floor: unknown
): asserts floor is ProjectFloorSnapshot {
if (!isPlainObject(floor) || Object.keys(floor).length !== 5) {
throw new Error("Project-floor snapshot is invalid.");
}
assertLegacyFloorFields(floor);
if (floor.buildingId !== null) {
assertNormalizedNonEmptyString(floor.buildingId, "floor.buildingId");
}
}
function assertLegacyProjectFloorSnapshot(
floor: unknown
): asserts floor is LegacyProjectFloorSnapshot {
if (!isPlainObject(floor) || Object.keys(floor).length !== 4) {
throw new Error("Project-floor snapshot is invalid.");
}
assertLegacyFloorFields(floor);
}
function assertLegacyFloorFields(floor: Record<string, unknown>) {
assertNormalizedNonEmptyString(floor.id, "floor.id");
assertNormalizedNonEmptyString(floor.projectId, "floor.projectId");
assertNormalizedNonEmptyString(floor.name, "floor.name");
assertSortOrder(floor.sortOrder, "floor.sortOrder");
}
function upgradeLegacyFloorSnapshot(floor: unknown): ProjectFloorSnapshot {
assertLegacyProjectFloorSnapshot(floor);
return { ...floor, buildingId: null };
if (
typeof floor.sortOrder !== "number" ||
!Number.isSafeInteger(floor.sortOrder) ||
floor.sortOrder < 0
) {
throw new Error("floor.sortOrder must be a non-negative integer.");
}
}
export function assertProjectRoomSnapshot(
@ -580,23 +310,6 @@ export function assertProjectRoomSnapshot(
assertNormalizedNonEmptyString(room.roomName, "room.roomName");
}
function assertProjectBuildingStructureProjectCommand(
command: SerializedProjectCommand<unknown>,
expectedType:
| typeof projectBuildingInsertCommandType
| typeof projectBuildingDeleteCommandType
) {
if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
command.type !== expectedType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported project-building structure command.");
}
assertProjectBuildingSnapshot(command.payload.building);
}
function assertProjectFloorStructureProjectCommand(
command: SerializedProjectCommand<unknown>,
expectedType:
@ -604,19 +317,13 @@ function assertProjectFloorStructureProjectCommand(
| typeof projectFloorDeleteCommandType
) {
if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
command.type !== expectedType ||
!isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1
) {
throw new Error("Unsupported project-floor structure command.");
}
if (command.schemaVersion === legacyProjectFloorCommandSchemaVersion) {
assertLegacyProjectFloorSnapshot(command.payload.floor);
return;
}
if (command.schemaVersion !== projectFloorCommandSchemaVersion) {
throw new Error("Unsupported project-floor structure command.");
}
assertProjectFloorSnapshot(command.payload.floor);
}
@ -637,16 +344,6 @@ function assertProjectRoomStructureProjectCommand(
assertProjectRoomSnapshot(command.payload.room);
}
function assertSortOrder(value: unknown, field: string) {
if (
typeof value !== "number" ||
!Number.isSafeInteger(value) ||
value < 0
) {
throw new Error(`${field} must be a non-negative integer.`);
}
}
function assertNormalizedNonEmptyString(
value: unknown,
field: string

View file

@ -3,23 +3,13 @@ import {
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js";
import {
projectCoverFieldMaxLengths,
projectCoverFields,
type ProjectCoverField,
} from "../../shared/constants/project-cover.js";
export const projectSettingsUpdateCommandType =
"project.update-settings" as const;
// Version 3 adds the print cover fields. Versions 2 (without cover fields)
// and 1 (additionally without the public-building flag) stay executable;
// their missing values keep the current project values.
export const projectSettingsUpdateCommandSchemaVersion = 3 as const;
export const withoutCoverProjectSettingsUpdateCommandSchemaVersion = 2 as const;
export const baselineProjectSettingsUpdateCommandSchemaVersion = 1 as const;
export const projectSettingsUpdateCommandSchemaVersion = 2 as const;
const previousProjectSettingsUpdateCommandSchemaVersion = 1 as const;
export interface ProjectSettingsValues
extends Record<ProjectCoverField, string | null> {
export interface ProjectSettingsValues {
name: string;
internalProjectNumber: string | null;
externalProjectNumber: string | null;
@ -31,13 +21,8 @@ export interface ProjectSettingsValues
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
}
export type WithoutCoverProjectSettingsValues = Omit<
type PreviousProjectSettingsValues = Omit<
ProjectSettingsValues,
ProjectCoverField
>;
export type BaselineProjectSettingsValues = Omit<
WithoutCoverProjectSettingsValues,
"isPublicBuilding"
>;
@ -47,22 +32,15 @@ export interface CurrentProjectSettingsUpdateProjectCommand
type: typeof projectSettingsUpdateCommandType;
}
export interface WithoutCoverProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<WithoutCoverProjectSettingsValues> {
schemaVersion: typeof withoutCoverProjectSettingsUpdateCommandSchemaVersion;
type: typeof projectSettingsUpdateCommandType;
}
export interface BaselineProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<BaselineProjectSettingsValues> {
schemaVersion: typeof baselineProjectSettingsUpdateCommandSchemaVersion;
interface PreviousProjectSettingsUpdateProjectCommand
extends SerializedProjectCommand<PreviousProjectSettingsValues> {
schemaVersion: typeof previousProjectSettingsUpdateCommandSchemaVersion;
type: typeof projectSettingsUpdateCommandType;
}
export type ProjectSettingsUpdateProjectCommand =
| CurrentProjectSettingsUpdateProjectCommand
| WithoutCoverProjectSettingsUpdateProjectCommand
| BaselineProjectSettingsUpdateProjectCommand;
| PreviousProjectSettingsUpdateProjectCommand;
export function createProjectSettingsUpdateProjectCommand(
values: ProjectSettingsValues
@ -82,10 +60,7 @@ export function assertProjectSettingsUpdateProjectCommand(
if (
command.type !== projectSettingsUpdateCommandType ||
(command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion &&
command.schemaVersion !==
withoutCoverProjectSettingsUpdateCommandSchemaVersion &&
command.schemaVersion !==
baselineProjectSettingsUpdateCommandSchemaVersion)
command.schemaVersion !== previousProjectSettingsUpdateCommandSchemaVersion)
) {
throw new Error("Unsupported project settings update command.");
}
@ -116,23 +91,13 @@ export function assertProjectSettingsUpdateProjectCommand(
"Project settings update command contains invalid supply types."
);
}
const payload = command.payload;
const expectedKeyCount =
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
? 8
: command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion
? 9
: 9 + projectCoverFields.length;
const isPreviousSchema =
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion;
if (
Object.keys(payload).length !== expectedKeyCount ||
(command.schemaVersion !==
baselineProjectSettingsUpdateCommandSchemaVersion &&
typeof payload.isPublicBuilding !== "boolean") ||
(command.schemaVersion === projectSettingsUpdateCommandSchemaVersion &&
!projectCoverFields.every((field) =>
isNullableTrimmedString(payload[field], projectCoverFieldMaxLengths[field])
))
(isPreviousSchema && Object.keys(command.payload).length !== 8) ||
(!isPreviousSchema &&
(typeof command.payload.isPublicBuilding !== "boolean" ||
Object.keys(command.payload).length !== 9))
) {
throw new Error(
"Project settings update command contains invalid values."

View file

@ -3,80 +3,32 @@ import {
type ProjectStateSnapshot,
} from "./project-state-snapshot.model.js";
import type { SerializedProjectCommand } from "./project-command.model.js";
import {
parseEstimateBalanceSnapshot,
} from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
export const projectStateRestoreCommandType = "project.restore-state";
export const projectStateRestoreCommandSchemaVersion = 2 as const;
const snapshotRestoreCommandSchemaVersion = 1 as const;
export const projectStateRestoreCommandSchemaVersion = 1 as const;
export interface ProjectStateRestoreCommandPayload {
expectedStateSha256: string;
targetState: ProjectStateSnapshot;
}
export type SnapshotProjectStateRestoreCommand = SerializedProjectCommand<
export type ProjectStateRestoreCommand = SerializedProjectCommand<
ProjectStateRestoreCommandPayload
> & {
schemaVersion: typeof snapshotRestoreCommandSchemaVersion;
type: typeof projectStateRestoreCommandType;
};
export interface ProjectTransferRestoreCommandPayload
extends ProjectStateRestoreCommandPayload {
expectedEstimateBalances: EstimateBalanceSnapshot[];
targetEstimateBalances: EstimateBalanceSnapshot[];
}
export type ProjectTransferRestoreCommand = SerializedProjectCommand<
ProjectTransferRestoreCommandPayload
> & {
schemaVersion: typeof projectStateRestoreCommandSchemaVersion;
type: typeof projectStateRestoreCommandType;
};
export type ProjectStateRestoreCommand =
| SnapshotProjectStateRestoreCommand
| ProjectTransferRestoreCommand;
export function createProjectStateRestoreCommand(
expectedStateSha256: string,
targetState: ProjectStateSnapshot
): ProjectStateRestoreCommand {
const command: SnapshotProjectStateRestoreCommand = {
schemaVersion: snapshotRestoreCommandSchemaVersion,
type: projectStateRestoreCommandType,
payload: {
expectedStateSha256,
targetState: parseProjectStateSnapshot(targetState),
},
};
assertProjectStateRestoreCommand(command);
return command;
}
export function createProjectTransferRestoreCommand(
expectedStateSha256: string,
targetState: ProjectStateSnapshot,
expectedEstimateBalances: EstimateBalanceSnapshot[],
targetEstimateBalances: EstimateBalanceSnapshot[]
): ProjectTransferRestoreCommand {
const command: ProjectTransferRestoreCommand = {
const command: ProjectStateRestoreCommand = {
schemaVersion: projectStateRestoreCommandSchemaVersion,
type: projectStateRestoreCommandType,
payload: {
expectedStateSha256,
targetState: parseProjectStateSnapshot(targetState),
expectedEstimateBalances: parseEstimateBalances(
expectedEstimateBalances,
targetState.project.id
),
targetEstimateBalances: parseEstimateBalances(
targetEstimateBalances,
targetState.project.id
),
},
};
assertProjectStateRestoreCommand(command);
@ -90,7 +42,10 @@ export function assertProjectStateRestoreCommand(
throw new Error("Project state restore command must be an object.");
}
const command = value as Partial<ProjectStateRestoreCommand>;
if (command.type !== projectStateRestoreCommandType) {
if (
command.type !== projectStateRestoreCommandType ||
command.schemaVersion !== projectStateRestoreCommandSchemaVersion
) {
throw new Error("Unsupported project state restore command.");
}
if (!command.payload || typeof command.payload !== "object") {
@ -103,43 +58,5 @@ export function assertProjectStateRestoreCommand(
) {
throw new Error("Expected project state checksum is invalid.");
}
const targetState = parseProjectStateSnapshot(payload.targetState);
if (command.schemaVersion === snapshotRestoreCommandSchemaVersion) {
if (Object.keys(payload).length !== 2) {
throw new Error("Snapshot restore payload contains unknown fields.");
}
return;
}
if (command.schemaVersion !== projectStateRestoreCommandSchemaVersion) {
throw new Error("Unsupported project state restore command.");
}
const transferPayload = payload as Partial<ProjectTransferRestoreCommandPayload>;
parseEstimateBalances(
transferPayload.expectedEstimateBalances,
targetState.project.id
);
parseEstimateBalances(
transferPayload.targetEstimateBalances,
targetState.project.id
);
if (Object.keys(payload).length !== 4) {
throw new Error("Project transfer restore payload contains unknown fields.");
}
}
function parseEstimateBalances(
value: unknown,
projectId: string
): EstimateBalanceSnapshot[] {
if (!Array.isArray(value)) {
throw new Error("Project transfer estimate balances must be an array.");
}
const balances = value.map(parseEstimateBalanceSnapshot);
if (balances.some((balance) => balance.projectId !== projectId)) {
throw new Error("Project transfer estimate balance belongs to another project.");
}
if (new Set(balances.map((balance) => balance.id)).size !== balances.length) {
throw new Error("Project transfer contains duplicate estimate balances.");
}
return balances;
parseProjectStateSnapshot(payload.targetState);
}

View file

@ -1,13 +1,5 @@
import { z } from "zod";
import {
distributionBoardInfoFieldMaxLengths,
distributionBoardSupplyTypes,
type DistributionBoardInfoField,
} from "../../shared/constants/distribution-board.js";
import {
projectCoverFieldMaxLengths,
type ProjectCoverField,
} from "../../shared/constants/project-cover.js";
import { distributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js";
import {
circuitGroupCategories,
} from "../../shared/constants/circuit-group.js";
@ -35,9 +27,7 @@ import {
type ExternalModelStateSnapshot,
} from "../../external-model/domain/external-model-contracts.js";
export const projectStateSnapshotSchemaVersion = 7 as const;
const coverProjectStateSnapshotSchemaVersion = 6 as const;
const boardInfoProjectStateSnapshotSchemaVersion = 5 as const;
export const projectStateSnapshotSchemaVersion = 5 as const;
const previousProjectStateSnapshotSchemaVersion = 4 as const;
const externalModelProjectStateSnapshotSchemaVersion = 3 as const;
const legacyProjectStateSnapshotSchemaVersion = 2 as const;
@ -68,31 +58,11 @@ const previousProjectSchema = z
})
.strict();
const publicBuildingProjectSchema = previousProjectSchema
const projectSchema = previousProjectSchema
.extend({ isPublicBuilding: z.boolean() })
.strict();
const projectCoverValueSchema = (field: ProjectCoverField) =>
z.string().trim().min(1).max(projectCoverFieldMaxLengths[field]).nullable();
// Schema version 6 adds the print cover fields.
const projectSchema = publicBuildingProjectSchema
.extend({
siteAddress: projectCoverValueSchema("siteAddress"),
workStage: projectCoverValueSchema("workStage"),
projectLead: projectCoverValueSchema("projectLead"),
projectEditor: projectCoverValueSchema("projectEditor"),
})
.strict();
const emptyProjectCoverValues = {
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
};
const legacyDistributionBoardSchema = z
const distributionBoardSchema = z
.object({
id: idSchema,
projectId: idSchema,
@ -103,35 +73,6 @@ const legacyDistributionBoardSchema = z
})
.strict();
const distributionBoardInfoValueSchema = (field: DistributionBoardInfoField) =>
z
.string()
.trim()
.min(1)
.max(distributionBoardInfoFieldMaxLengths[field])
.nullable();
const distributionBoardSchema = legacyDistributionBoardSchema
.extend({
boardNumber: distributionBoardInfoValueSchema("boardNumber"),
location: distributionBoardInfoValueSchema("location"),
fedFrom: distributionBoardInfoValueSchema("fedFrom"),
remark: distributionBoardInfoValueSchema("remark"),
})
.strict();
function withEmptyDistributionBoardInfo(
boards: Array<z.infer<typeof legacyDistributionBoardSchema>>
) {
return boards.map((board) => ({
...board,
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
}));
}
const circuitListSchema = z
.object({
id: idSchema,
@ -238,7 +179,7 @@ const projectDeviceSchema = z
})
.strict();
const buildingSchema = z
const floorSchema = z
.object({
id: idSchema,
projectId: idSchema,
@ -247,19 +188,6 @@ const buildingSchema = z
})
.strict();
const legacyFloorSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: z.string().trim().min(1),
sortOrder: finiteNumberSchema,
})
.strict();
const floorSchema = legacyFloorSchema
.extend({ buildingId: idSchema.nullable() })
.strict();
const roomSchema = z
.object({
id: idSchema,
@ -504,24 +432,11 @@ export function parseExternalModelStateSnapshot(
return externalModelStateSchema.parse(value);
}
// Versions 1-6 have floors without a building and no building list.
const previousProjectStateSnapshotContents = {
const projectStateSnapshotContents = {
circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema),
circuits: z.array(circuitSchema),
projectDevices: z.array(projectDeviceSchema),
floors: z.array(legacyFloorSchema),
rooms: z.array(roomSchema),
distributionBoardComponents: z.array(distributionBoardComponentSchema),
circuitProtectionDevices: z.array(circuitProtectionDeviceSchema),
distributionBoardComponentProtectionDevices: z.array(
componentProtectionDeviceSchema
),
};
const projectStateSnapshotContents = {
...previousProjectStateSnapshotContents,
buildings: z.array(buildingSchema),
floors: z.array(floorSchema),
rooms: z.array(roomSchema),
distributionBoardComponents: z.array(distributionBoardComponentSchema),
@ -542,55 +457,33 @@ export const projectStateSnapshotSchema = z
})
.strict();
const coverProjectStateSnapshotSchema = z
const previousProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion),
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...previousProjectStateSnapshotContents,
})
.strict();
const boardInfoProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(boardInfoProjectStateSnapshotSchemaVersion),
project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...previousProjectStateSnapshotContents,
})
.strict();
const previousProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema,
...previousProjectStateSnapshotContents,
...projectStateSnapshotContents,
})
.strict();
const externalModelProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion),
project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
...previousProjectStateSnapshotContents,
...projectStateSnapshotContents,
})
.strict();
const legacyProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
project: publicBuildingProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
...previousProjectStateSnapshotContents,
project: projectSchema,
distributionBoards: z.array(distributionBoardSchema),
...projectStateSnapshotContents,
})
.strict();
@ -598,8 +491,8 @@ const baselineProjectStateSnapshotSchema = z
.object({
schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion),
project: previousProjectSchema,
distributionBoards: z.array(legacyDistributionBoardSchema),
...previousProjectStateSnapshotContents,
distributionBoards: z.array(distributionBoardSchema),
...projectStateSnapshotContents,
})
.strict();
@ -607,20 +500,6 @@ export type ProjectStateSnapshot = z.infer<
typeof projectStateSnapshotSchema
>;
/**
* Stored snapshots of every supported version can be restored: they are
* upgraded to the current version while parsing.
*/
export function isSupportedProjectStateSnapshotSchemaVersion(
schemaVersion: number
): boolean {
return (
Number.isInteger(schemaVersion) &&
schemaVersion >= baselineProjectStateSnapshotSchemaVersion &&
schemaVersion <= projectStateSnapshotSchemaVersion
);
}
export function parseProjectStateSnapshot(
value: unknown
): ProjectStateSnapshot {
@ -633,28 +512,6 @@ export function parseProjectStateSnapshot(
}
function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const cover = upgradeToCoverProjectStateSnapshot(value);
if (
cover === null ||
typeof cover !== "object" ||
Array.isArray(cover) ||
(cover as { schemaVersion?: unknown }).schemaVersion !==
coverProjectStateSnapshotSchemaVersion
) {
return cover;
}
// Every supported version before 7 lacks buildings; its floors had no
// building.
const previous = coverProjectStateSnapshotSchema.parse(cover);
return {
...previous,
schemaVersion: projectStateSnapshotSchemaVersion,
buildings: [],
floors: previous.floors.map((floor) => ({ ...floor, buildingId: null })),
};
}
function upgradeToCoverProjectStateSnapshot(value: unknown): unknown {
if (
value === null ||
typeof value !== "object" ||
@ -664,40 +521,18 @@ function upgradeToCoverProjectStateSnapshot(value: unknown): unknown {
return value;
}
const schemaVersion = (value as { schemaVersion: number }).schemaVersion;
// Every supported version before 6 lacks the distribution-board info
// fields and the project cover fields; they are upgraded as empty (null)
// values.
if (schemaVersion === boardInfoProjectStateSnapshotSchemaVersion) {
const previous = boardInfoProjectStateSnapshotSchema.parse(value);
return {
...previous,
schemaVersion: coverProjectStateSnapshotSchemaVersion,
project: { ...previous.project, ...emptyProjectCoverValues },
distributionBoards: withEmptyDistributionBoardInfo(
previous.distributionBoards
),
};
}
if (schemaVersion === previousProjectStateSnapshotSchemaVersion) {
const previous = previousProjectStateSnapshotSchema.parse(value);
return {
...previous,
schemaVersion: coverProjectStateSnapshotSchemaVersion,
project: { ...previous.project, ...emptyProjectCoverValues },
distributionBoards: withEmptyDistributionBoardInfo(
previous.distributionBoards
),
schemaVersion: projectStateSnapshotSchemaVersion,
};
}
if (schemaVersion === externalModelProjectStateSnapshotSchemaVersion) {
const previous = externalModelProjectStateSnapshotSchema.parse(value);
return {
...previous,
schemaVersion: coverProjectStateSnapshotSchemaVersion,
project: { ...previous.project, ...emptyProjectCoverValues },
distributionBoards: withEmptyDistributionBoardInfo(
previous.distributionBoards
),
schemaVersion: projectStateSnapshotSchemaVersion,
externalModel: emptyExternalModelState(),
};
}
@ -705,11 +540,7 @@ function upgradeToCoverProjectStateSnapshot(value: unknown): unknown {
const legacy = legacyProjectStateSnapshotSchema.parse(value);
return {
...legacy,
schemaVersion: coverProjectStateSnapshotSchemaVersion,
project: { ...legacy.project, ...emptyProjectCoverValues },
distributionBoards: withEmptyDistributionBoardInfo(
legacy.distributionBoards
),
schemaVersion: projectStateSnapshotSchemaVersion,
externalCsvConfiguration: null,
externalModel: emptyExternalModelState(),
};
@ -718,15 +549,8 @@ function upgradeToCoverProjectStateSnapshot(value: unknown): unknown {
const baseline = baselineProjectStateSnapshotSchema.parse(value);
return {
...baseline,
schemaVersion: coverProjectStateSnapshotSchemaVersion,
project: {
...baseline.project,
isPublicBuilding: false,
...emptyProjectCoverValues,
},
distributionBoards: withEmptyDistributionBoardInfo(
baseline.distributionBoards
),
schemaVersion: projectStateSnapshotSchemaVersion,
project: { ...baseline.project, isPublicBuilding: false },
externalCsvConfiguration: null,
externalModel: emptyExternalModelState(),
};
@ -825,7 +649,6 @@ function assertProjectStateSnapshotRelations(
snapshot.projectDevices,
"project device"
);
const buildingIds = uniqueIds(snapshot.buildings, "building");
const floorIds = uniqueIds(snapshot.floors, "floor");
const roomIds = uniqueIds(snapshot.rooms, "room");
const circuitIds = uniqueIds(snapshot.circuits, "circuit");
@ -904,14 +727,8 @@ function assertProjectStateSnapshotRelations(
);
}
}
for (const building of snapshot.buildings) {
assertProjectOwnership(building.projectId, projectId, "building");
}
for (const floor of snapshot.floors) {
assertProjectOwnership(floor.projectId, projectId, "floor");
if (floor.buildingId !== null) {
assertReference(buildingIds, floor.buildingId, "floor building");
}
}
for (const room of snapshot.rooms) {
assertProjectOwnership(room.projectId, projectId, "room");

View file

@ -3,12 +3,9 @@ import {
projectStateSnapshotSchemaVersion,
type ProjectStateSnapshot,
} from "./project-state-snapshot.model.js";
import { parseEstimateBalanceSnapshot } from "../../estimate-balance/domain/estimate-balance-command.model.js";
import type { EstimateBalanceSnapshot } from "../../shared/estimate-balance/estimate-balance.js";
export const projectTransferFormat = "leistungsbilanz.project" as const;
export const projectTransferFormatVersion = 2 as const;
const legacyProjectTransferFormatVersion = 1 as const;
export const projectTransferFormatVersion = 1 as const;
export interface ProjectTransferEnvelope {
format: typeof projectTransferFormat;
@ -16,14 +13,12 @@ export interface ProjectTransferEnvelope {
exportedAtIso: string;
payloadSha256: string;
projectState: ProjectStateSnapshot;
estimateBalances: EstimateBalanceSnapshot[];
}
export function createProjectTransferEnvelope(
exportedAtIso: string,
payloadSha256: string,
projectState: ProjectStateSnapshot,
estimateBalances: EstimateBalanceSnapshot[]
projectState: ProjectStateSnapshot
): ProjectTransferEnvelope {
return parseProjectTransferEnvelope({
format: projectTransferFormat,
@ -31,7 +26,6 @@ export function createProjectTransferEnvelope(
exportedAtIso,
payloadSha256,
projectState,
estimateBalances,
});
}
@ -43,8 +37,7 @@ export function parseProjectTransferEnvelope(
}
if (
value.format !== projectTransferFormat ||
(value.formatVersion !== legacyProjectTransferFormatVersion &&
value.formatVersion !== projectTransferFormatVersion)
value.formatVersion !== projectTransferFormatVersion
) {
throw new Error("Project transfer format is not supported.");
}
@ -64,13 +57,7 @@ export function parseProjectTransferEnvelope(
if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) {
throw new Error("Project transfer snapshot version is not supported.");
}
const estimateBalances =
value.formatVersion === legacyProjectTransferFormatVersion
? []
: parseTransferEstimateBalances(value.estimateBalances, projectState.project.id);
const expectedFieldCount =
value.formatVersion === legacyProjectTransferFormatVersion ? 5 : 6;
if (Object.keys(value).length !== expectedFieldCount) {
if (Object.keys(value).length !== 5) {
throw new Error("Project transfer file contains unknown fields.");
}
return {
@ -79,43 +66,9 @@ export function parseProjectTransferEnvelope(
exportedAtIso: value.exportedAtIso,
payloadSha256: value.payloadSha256,
projectState,
estimateBalances,
};
}
export function serializeProjectTransferPayload(
projectState: ProjectStateSnapshot,
estimateBalances: EstimateBalanceSnapshot[]
) {
const parsedState = parseProjectStateSnapshot(projectState);
return JSON.stringify({
projectState: parsedState,
estimateBalances: parseTransferEstimateBalances(
estimateBalances,
parsedState.project.id
),
});
}
export function remapEstimateBalances(
source: EstimateBalanceSnapshot[],
targetProjectId: string,
createId: () => string
): EstimateBalanceSnapshot[] {
return source.map((balance) =>
parseEstimateBalanceSnapshot({
...balance,
id: createId(),
projectId: targetProjectId,
groups: balance.groups.map((group) => ({
...group,
id: createId(),
rows: group.rows.map((row) => ({ ...row, id: createId() })),
})),
})
);
}
export function remapProjectState(
source: ProjectStateSnapshot,
targetProjectId: string,
@ -131,7 +84,6 @@ export function remapProjectState(
createId
);
const deviceIds = idMap(source.projectDevices, createId);
const buildingIds = idMap(source.buildings, createId);
const floorIds = idMap(source.floors, createId);
const roomIds = idMap(source.rooms, createId);
const rowIds = new Map(
@ -290,19 +242,10 @@ export function remapProjectState(
id: requiredId(deviceIds, device.id),
projectId: targetProjectId,
})),
buildings: source.buildings.map((building) => ({
...building,
id: requiredId(buildingIds, building.id),
projectId: targetProjectId,
})),
floors: source.floors.map((floor) => ({
...floor,
id: requiredId(floorIds, floor.id),
projectId: targetProjectId,
buildingId:
floor.buildingId === null
? null
: requiredId(buildingIds, floor.buildingId),
})),
rooms: source.rooms.map((room) => ({
...room,
@ -329,37 +272,6 @@ function requiredId(ids: ReadonlyMap<string, string>, sourceId: string) {
return targetId;
}
function parseTransferEstimateBalances(
value: unknown,
projectId: string
): EstimateBalanceSnapshot[] {
if (!Array.isArray(value)) {
throw new Error("Project transfer estimate balances must be an array.");
}
const balances = value.map(parseEstimateBalanceSnapshot);
if (balances.some((balance) => balance.projectId !== projectId)) {
throw new Error("Project transfer estimate balance belongs to another project.");
}
assertUniqueIds(balances.map((balance) => balance.id), "estimate balance");
assertUniqueIds(
balances.flatMap((balance) => balance.groups.map((group) => group.id)),
"estimate group"
);
assertUniqueIds(
balances.flatMap((balance) =>
balance.groups.flatMap((group) => group.rows.map((row) => row.id))
),
"estimate row"
);
return balances;
}
function assertUniqueIds(ids: string[], label: string) {
if (new Set(ids).size !== ids.length) {
throw new Error(`Project transfer contains a duplicate ${label} id.`);
}
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

View file

@ -70,18 +70,12 @@ import {
type SerializedProjectCommand,
} from "../models/project-command.model.js";
import {
assertProjectBuildingDeleteProjectCommand,
assertProjectBuildingInsertProjectCommand,
assertProjectBuildingUpdateProjectCommand,
assertProjectFloorDeleteProjectCommand,
assertProjectFloorInsertProjectCommand,
assertProjectFloorUpdateProjectCommand,
assertProjectRoomDeleteProjectCommand,
assertProjectRoomInsertProjectCommand,
assertProjectRoomUpdateProjectCommand,
projectBuildingDeleteCommandType,
projectBuildingInsertCommandType,
projectBuildingUpdateCommandType,
projectFloorDeleteCommandType,
projectFloorInsertCommandType,
projectFloorUpdateCommandType,
@ -194,15 +188,6 @@ 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;
@ -242,8 +227,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
private readonly externalInitialImportStore?: ExternalInitialImportProjectCommandStore,
private readonly externalObjectRowAssignmentStore?: ExternalObjectRowAssignmentProjectCommandStore,
private readonly externalObjectNewRowStore?: ExternalObjectNewRowProjectCommandStore,
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore,
private readonly estimateBalanceStore?: EstimateBalanceProjectCommandStore
private readonly externalObjectNewCircuitStore?: ExternalObjectNewCircuitProjectCommandStore
) {}
executeUser(
@ -508,18 +492,6 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command,
}).revision;
}
case projectBuildingInsertCommandType: {
assertProjectBuildingInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision;
}
case projectBuildingDeleteCommandType: {
assertProjectBuildingDeleteProjectCommand(input.command);
return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision;
}
case projectBuildingUpdateCommandType: {
assertProjectBuildingUpdateProjectCommand(input.command);
return this.projectLocationStructureStore.execute({ ...input, command: input.command }).revision;
}
case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({
@ -664,24 +636,6 @@ 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({

View file

@ -1,33 +0,0 @@
import { z } from "zod";
import { expectedProjectRevisionSchema } from "../../shared/validation/project-command.schemas.js";
export const createEstimateBalanceRequestSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200),
workStage: z.string().trim().max(50).nullable().optional(),
dateIso: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.nullable()
.optional(),
template: z.enum(["kg400", "empty", "copy"]),
copyFromBalanceId: z.string().trim().min(1).optional(),
})
.strict()
.refine((input) => input.template !== "copy" || Boolean(input.copyFromBalanceId), {
message: "copyFromBalanceId is required for template copy",
path: ["copyFromBalanceId"],
});
export const updateEstimateBalanceRequestSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
// Validated in detail by parseEstimateBalanceSnapshot.
balance: z.unknown(),
})
.strict();
export const deleteEstimateBalanceRequestSchema = z
.object({ expectedRevision: expectedProjectRevisionSchema })
.strict();

View file

@ -1,318 +0,0 @@
import crypto from "node:crypto";
import { z } from "zod";
import type { SerializedProjectCommand } from "../../domain/models/project-command.model.js";
import {
estimateDefaultGroups,
type EstimateBalanceSnapshot,
type EstimateGroupSnapshot,
} from "../../shared/estimate-balance/estimate-balance.js";
export const estimateBalanceInsertCommandType = "estimate-balance.insert" as const;
export const estimateBalanceDeleteCommandType = "estimate-balance.delete" as const;
export const estimateBalanceUpdateCommandType = "estimate-balance.update" as const;
export const estimateBalanceCommandSchemaVersion = 1 as const;
const idSchema = z.string().trim().min(1).max(100);
const trimmed = (max: number) =>
z.string().max(max).refine((value) => value === value.trim(), {
message: "must be trimmed",
});
const nullableText = (max: number) =>
trimmed(max)
.refine((value) => value.length > 0, { message: "use null instead of empty" })
.nullable();
const finite = z.number().finite();
export const estimateRowSnapshotSchema = z
.object({
id: idSchema,
sortOrder: z.number().int().min(0),
supply: nullableText(50),
name: trimmed(200),
quantity: finite.min(0),
unit: z.enum(["piece", "area"]),
connection: z.enum(["fixed", "socket"]).nullable(),
powerPerUnitW: finite.min(0),
simultaneityFactor: finite.min(0).max(1),
cosPhi: finite.gt(0).max(1),
safetySupply: z.boolean(),
remark: nullableText(500),
})
.strict();
export const estimateGroupSnapshotSchema = z
.object({
id: idSchema,
costGroup: trimmed(20),
title: trimmed(200).refine((value) => value.length > 0, {
message: "title must not be empty",
}),
editor: nullableText(100),
sortOrder: z.number().int().min(0),
rows: z.array(estimateRowSnapshotSchema).max(1000),
})
.strict();
export const estimateBalanceSnapshotSchema = z
.object({
id: idSchema,
projectId: idSchema,
name: trimmed(200).refine((value) => value.length > 0, {
message: "name must not be empty",
}),
workStage: nullableText(50),
dateIso: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.refine((value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), {
message: "invalid date",
})
.nullable(),
reserveAvPercent: finite.min(0).max(100),
reserveSvPercent: finite.min(0).max(100),
sortOrder: z.number().int().min(0),
groups: z.array(estimateGroupSnapshotSchema).max(200),
})
.strict()
.superRefine((balance, context) => {
const ids = new Set<string>();
for (const group of balance.groups) {
for (const id of [group.id, ...group.rows.map((row) => row.id)]) {
if (ids.has(id)) {
context.addIssue({
code: "custom",
message: `duplicate id ${id}`,
path: ["groups"],
});
}
ids.add(id);
}
}
});
export interface EstimateBalanceInsertProjectCommand
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
type: typeof estimateBalanceInsertCommandType;
}
export interface EstimateBalanceDeleteProjectCommand
extends SerializedProjectCommand<{ balance: EstimateBalanceSnapshot }> {
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
type: typeof estimateBalanceDeleteCommandType;
}
export interface EstimateBalanceUpdateProjectCommand
extends SerializedProjectCommand<{
expected: EstimateBalanceSnapshot;
target: EstimateBalanceSnapshot;
}> {
schemaVersion: typeof estimateBalanceCommandSchemaVersion;
type: typeof estimateBalanceUpdateCommandType;
}
export type EstimateBalanceProjectCommand =
| EstimateBalanceInsertProjectCommand
| EstimateBalanceDeleteProjectCommand
| EstimateBalanceUpdateProjectCommand;
/** Parses and normalizes a snapshot (sort orders of groups and rows follow the array order). */
export function parseEstimateBalanceSnapshot(value: unknown): EstimateBalanceSnapshot {
const parsed = estimateBalanceSnapshotSchema.parse(value);
return normalizeEstimateBalanceSnapshot(parsed);
}
export function normalizeEstimateBalanceSnapshot(
balance: EstimateBalanceSnapshot
): EstimateBalanceSnapshot {
return {
...balance,
groups: balance.groups.map((group, groupIndex) => ({
...group,
sortOrder: groupIndex,
rows: group.rows.map((row, rowIndex) => ({ ...row, sortOrder: rowIndex })),
})),
};
}
/** Stable JSON for exact equality checks of two snapshots. */
export function canonicalEstimateBalance(balance: EstimateBalanceSnapshot): string {
const normalized = normalizeEstimateBalanceSnapshot(balance);
return JSON.stringify({
id: normalized.id,
projectId: normalized.projectId,
name: normalized.name,
workStage: normalized.workStage,
dateIso: normalized.dateIso,
reserveAvPercent: normalized.reserveAvPercent,
reserveSvPercent: normalized.reserveSvPercent,
sortOrder: normalized.sortOrder,
groups: normalized.groups.map((group) => ({
id: group.id,
costGroup: group.costGroup,
title: group.title,
editor: group.editor,
sortOrder: group.sortOrder,
rows: group.rows.map((row) => ({
id: row.id,
sortOrder: row.sortOrder,
supply: row.supply,
name: row.name,
quantity: row.quantity,
unit: row.unit,
connection: row.connection,
powerPerUnitW: row.powerPerUnitW,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
safetySupply: row.safetySupply,
remark: row.remark,
})),
})),
});
}
export function sameEstimateBalance(
left: EstimateBalanceSnapshot,
right: EstimateBalanceSnapshot
): boolean {
return canonicalEstimateBalance(left) === canonicalEstimateBalance(right);
}
export function createEstimateBalanceSnapshot(input: {
projectId: string;
name: string;
workStage: string | null;
dateIso: string | null;
sortOrder: number;
template: "kg400" | "empty" | { copyFrom: EstimateBalanceSnapshot };
createId?: () => string;
}): EstimateBalanceSnapshot {
const createId = input.createId ?? (() => crypto.randomUUID());
let groups: EstimateGroupSnapshot[] = [];
let reserveAvPercent = 10;
let reserveSvPercent = 0;
if (input.template === "kg400") {
groups = estimateDefaultGroups.map((group, index) => ({
id: createId(),
costGroup: group.costGroup,
title: group.title,
editor: null,
sortOrder: index,
rows: [],
}));
} else if (input.template !== "empty") {
const source = input.template.copyFrom;
reserveAvPercent = source.reserveAvPercent;
reserveSvPercent = source.reserveSvPercent;
groups = source.groups.map((group) => ({
...group,
id: createId(),
rows: group.rows.map((row) => ({ ...row, id: createId() })),
}));
}
return parseEstimateBalanceSnapshot({
id: createId(),
projectId: input.projectId,
name: input.name.trim(),
workStage: input.workStage?.trim() || null,
dateIso: input.dateIso || null,
reserveAvPercent,
reserveSvPercent,
sortOrder: input.sortOrder,
groups,
});
}
export function createEstimateBalanceInsertProjectCommand(
balance: EstimateBalanceSnapshot
): EstimateBalanceInsertProjectCommand {
return {
schemaVersion: estimateBalanceCommandSchemaVersion,
type: estimateBalanceInsertCommandType,
payload: { balance: parseEstimateBalanceSnapshot(balance) },
};
}
export function createEstimateBalanceDeleteProjectCommand(
balance: EstimateBalanceSnapshot
): EstimateBalanceDeleteProjectCommand {
return {
schemaVersion: estimateBalanceCommandSchemaVersion,
type: estimateBalanceDeleteCommandType,
payload: { balance: parseEstimateBalanceSnapshot(balance) },
};
}
export function createEstimateBalanceUpdateProjectCommand(
expected: EstimateBalanceSnapshot,
target: EstimateBalanceSnapshot
): EstimateBalanceUpdateProjectCommand {
const payload = {
expected: parseEstimateBalanceSnapshot(expected),
target: parseEstimateBalanceSnapshot(target),
};
assertMatchingUpdate(payload.expected, payload.target);
return {
schemaVersion: estimateBalanceCommandSchemaVersion,
type: estimateBalanceUpdateCommandType,
payload,
};
}
function assertMatchingUpdate(
expected: EstimateBalanceSnapshot,
target: EstimateBalanceSnapshot
) {
if (expected.id !== target.id || expected.projectId !== target.projectId) {
throw new Error("Estimate-balance update must keep id and project.");
}
if (sameEstimateBalance(expected, target)) {
throw new Error("Estimate-balance update must change the balance.");
}
}
function assertCommandShape(
command: SerializedProjectCommand<unknown>,
type: string,
keys: string[]
): Record<string, unknown> {
const payload = command.payload;
if (
command.schemaVersion !== estimateBalanceCommandSchemaVersion ||
command.type !== type ||
payload === null ||
typeof payload !== "object" ||
Array.isArray(payload) ||
Object.keys(payload).sort().join(",") !== [...keys].sort().join(",")
) {
throw new Error("Unsupported estimate-balance command.");
}
return payload as Record<string, unknown>;
}
export function assertEstimateBalanceInsertProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is EstimateBalanceInsertProjectCommand {
const payload = assertCommandShape(command, estimateBalanceInsertCommandType, ["balance"]);
parseEstimateBalanceSnapshot(payload.balance);
}
export function assertEstimateBalanceDeleteProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is EstimateBalanceDeleteProjectCommand {
const payload = assertCommandShape(command, estimateBalanceDeleteCommandType, ["balance"]);
parseEstimateBalanceSnapshot(payload.balance);
}
export function assertEstimateBalanceUpdateProjectCommand(
command: SerializedProjectCommand<unknown>
): asserts command is EstimateBalanceUpdateProjectCommand {
const payload = assertCommandShape(command, estimateBalanceUpdateCommandType, [
"expected",
"target",
]);
assertMatchingUpdate(
parseEstimateBalanceSnapshot(payload.expected),
parseEstimateBalanceSnapshot(payload.target)
);
}

View file

@ -1,24 +0,0 @@
import type {
AppendedProjectRevision,
ProjectRevisionSource,
} from "../../domain/ports/project-revision.store.js";
import type { EstimateBalanceProjectCommand } from "./estimate-balance-command.model.js";
export interface ExecuteEstimateBalanceCommandInput {
projectId: string;
expectedRevision: number;
source: ProjectRevisionSource;
description?: string;
actorId?: string;
historyTargetChangeSetId?: string;
command: EstimateBalanceProjectCommand;
}
export interface ExecutedEstimateBalanceCommand {
revision: AppendedProjectRevision;
inverse: EstimateBalanceProjectCommand;
}
export interface EstimateBalanceProjectCommandStore {
execute(input: ExecuteEstimateBalanceCommandInput): ExecutedEstimateBalanceCommand;
}

View file

@ -4,14 +4,12 @@ import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { Sidebar } from "./Sidebar";
// Every page shows the (collapsible) sidebar except the print view, which is
// a stand-alone document opened in its own tab and printed as PDF.
const PRINT_VIEW_PATTERN = /^\/projects\/[^/]+\/print(\/|$)/;
const PROJECT_AREA_PATTERN = /^\/projects(\/[^/]+)?$/;
const SIDEBAR_COLLAPSED_STORAGE_KEY = "leistungsbilanz:sidebar-collapsed";
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const showSidebar = !PRINT_VIEW_PATTERN.test(pathname ?? "");
const showSidebar = PROJECT_AREA_PATTERN.test(pathname ?? "");
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
useEffect(() => {

View file

@ -7,22 +7,14 @@ import { listProjects } from "../utils/api";
import type { ProjectDto } from "../types";
import { ThemeToggle } from "./ThemeToggle";
// Anchors on the project overview page (/projects/[id]).
const PROJECT_SECTIONS = [
{ id: "verlauf", label: "Verlauf", icon: "↺" },
{ id: "verteilungen", label: "Verteilungen", icon: "⌁" },
{ id: "etagen", label: "Etagen", icon: "▥" },
{ id: "raeume", label: "Räume", icon: "⌂" },
{ id: "projektgeraete", label: "Projektgeräte", icon: "⚙" },
] as const;
// Separate project sub-routes (/projects/[id]/<segment>).
const PROJECT_ROUTES = [
{ segment: "struktur", label: "Gebäudestruktur", icon: "▥" },
{ segment: "grobbilanz", label: "Grobe Leistungsbilanz", icon: "Σ" },
] as const;
// Matches the project overview and all of its sub-routes (group 2 = rest).
const PROJECT_PATH_PATTERN = /^\/projects\/([^/]+)(\/.*)?$/;
interface SidebarProps {
isCollapsed: boolean;
onToggleCollapsed: () => void;
@ -30,14 +22,8 @@ interface SidebarProps {
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
const pathname = usePathname();
const projectMatch = pathname?.match(PROJECT_PATH_PATTERN);
const projectMatch = pathname?.match(/^\/projects\/([^/]+)$/);
const activeProjectId = projectMatch ? projectMatch[1] : null;
const projectSubPath = projectMatch?.[2] ?? "";
// Section anchors only exist (and are observed) on the overview itself.
const overviewProjectId =
activeProjectId && (projectSubPath === "" || projectSubPath === "/")
? activeProjectId
: null;
const [projects, setProjects] = useState<ProjectDto[]>([]);
const [activeSection, setActiveSection] = useState<string | null>(null);
@ -56,7 +42,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
}, []);
useEffect(() => {
if (!overviewProjectId) {
if (!activeProjectId) {
setActiveSection(null);
return;
}
@ -83,7 +69,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
elements.forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, [overviewProjectId]);
}, [activeProjectId]);
return (
<aside className={isCollapsed ? "sidebar sidebar-collapsed" : "sidebar"}>
@ -136,8 +122,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
{activeProjectId && (
<>
<div className="sidebar-section-label">In diesem Projekt</div>
{PROJECT_SECTIONS.map(({ id, label, icon }) =>
overviewProjectId ? (
{PROJECT_SECTIONS.map(({ id, label, icon }) => (
<a
key={id}
href={`#${id}`}
@ -147,35 +132,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
<span className="sidebar-icon">{icon}</span>
<span className="sidebar-label">{label}</span>
</a>
) : (
<Link
key={id}
href={`/projects/${activeProjectId}#${id}`}
className="sidebar-link"
title={label}
>
<span className="sidebar-icon">{icon}</span>
<span className="sidebar-label">{label}</span>
</Link>
)
)}
{PROJECT_ROUTES.map(({ segment, label, icon }) => {
const href = `/projects/${activeProjectId}/${segment}`;
const isActive =
pathname === href || Boolean(pathname?.startsWith(`${href}/`));
return (
<Link
key={segment}
href={href}
aria-current={isActive ? "page" : undefined}
className={isActive ? "sidebar-link active" : "sidebar-link"}
title={label}
>
<span className="sidebar-icon">{icon}</span>
<span className="sidebar-label">{label}</span>
</Link>
);
})}
))}
</>
)}
</nav>

View file

@ -11,9 +11,9 @@ import {
LAYING_METHODS,
LAYING_METHOD_GROUP,
LAYING_METHOD_LABELS,
MAX_GROUPING_CIRCUITS,
PRACTICAL_MINIMUM_CROSS_SECTION_MM2,
REFERENCE_TEMPERATURE_C,
maxGroupingCircuits,
temperatureOptions,
type CableSizingAlert,
type CableSizingInput,
@ -100,7 +100,6 @@ export function CableSizingModal({
? threePhaseVoltageV
: singlePhaseVoltageV;
const group = LAYING_METHOD_GROUP[layingMethod];
const groupingMaximum = maxGroupingCircuits(layingMethod);
const availableTemperatures = temperatureOptions(layingMethod, insulation);
const input: CableSizingInput = useMemo(
@ -209,7 +208,7 @@ export function CableSizingModal({
return (
<FormModal
description="Querschnittsempfehlung mit Strombelastbarkeit, Temperatur- und Häufungsfaktoren nach DIN VDE 0298-4:2023-06 einschließlich Berichtigung 1:2023-10. Spannungsfall und Schutzorgan werden ergänzend geprüft. Die Empfehlung vor der Übernahme fachlich prüfen."
description="Querschnittsempfehlung nach IEC 60364-5-52 Anhang B (Belastbarkeit, Temperatur, Häufung, Spannungsfall, Schutzorgan) oder direkte Eingabe. Die Empfehlung vor der Übernahme fachlich prüfen; nationale Tabellen (DIN VDE 0298-4) können abweichen."
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
@ -235,11 +234,6 @@ export function CableSizingModal({
type="number"
value={lengthM}
/>
{lengthM === 0 ? (
<div className="form-text text-warning">
Bei 0 m wird kein Spannungsfall angesetzt.
</div>
) : null}
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-phase">
@ -285,7 +279,6 @@ export function CableSizingModal({
onChange={(event) => {
const method = event.target.value as LayingMethod;
setLayingMethod(method);
setGroupingCircuits((current) => Math.min(current, maxGroupingCircuits(method)));
applyReferenceTemperature(method, insulation);
resetResult();
}}
@ -369,7 +362,7 @@ export function CableSizingModal({
<input
className="form-control"
id="cable-sizing-grouping"
max={groupingMaximum}
max={MAX_GROUPING_CIRCUITS}
min={1}
onChange={(event) => {
setGroupingCircuits(Math.max(1, Math.round(Number(event.target.value) || 1)));
@ -380,10 +373,7 @@ export function CableSizingModal({
value={groupingCircuits}
/>
<div className="form-text">
Anzahl gemeinsam verlegter Stromkreise, einschließlich dieses
{layingMethod === "E"
? "; ohne Angaben zur Kabelpritsche ist nur 1 zulässig."
: `; für diese Verlegeart sind bis ${groupingMaximum} tabelliert.`}
Anzahl gemeinsam verlegter Stromkreise, einschließlich dieses.
</div>
</div>
{phase === 3 ? (

View file

@ -21,7 +21,6 @@ import {
Plus,
Redo2,
ShieldPlus,
TableProperties,
Trash2,
Undo2,
} from "lucide-react";
@ -101,7 +100,6 @@ import {
type MutableDistributionBoardComponentRole,
} from "../utils/distribution-board-component-editing";
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
import { createClientId } from "../utils/client-id";
import type {
CellKey,
ColumnDef,
@ -161,7 +159,6 @@ import type {
CircuitSnapshot,
} from "../../domain/models/circuit-structure-project-command.model";
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
import { GzfPickerModal } from "./gzf-picker-modal";
import { CircuitGroupModal } from "./circuit-group-modal";
import { CircuitProtectionModal } from "./circuit-protection-modal";
import { CableSizingModal } from "./cable-sizing-modal";
@ -280,7 +277,7 @@ function createValuesFromEditPatch(
* header heights stay uniform.
*/
function renderColumnHeaderLabel(label: string) {
const match = /^(.*?)\s*(\[[^\]]+\])$/.exec(label);
const match = /^(.*?)s*([[^]]+])$/.exec(label);
const name = (match ? match[1] : label).replace(/-/g, "‑");
return match ? (
<>
@ -322,8 +319,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
useState<StructureComponentEditorIntent | null>(null);
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
useState<CircuitGroupEditorIntent | null>(null);
// GZF cell the AMEV picker writes to; null while the picker is closed.
const [gzfPickerCell, setGzfPickerCell] = useState<SelectedCell | null>(null);
const [protectionEditorCircuit, setProtectionEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null);
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
@ -1027,7 +1022,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
defaultSortOrder: number
) {
return buildCircuitDeviceRowInsertSnapshot({
id: createClientId(),
id: crypto.randomUUID(),
circuitId,
values,
defaultSortOrder,
@ -1051,7 +1046,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
),
data
);
const circuitId = createClientId();
const circuitId = crypto.randomUUID();
const deviceRows = deviceRowValues.map((row, index) =>
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
);
@ -1219,7 +1214,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
(section) => section.id === intent.sectionId
)?.components ?? [];
const snapshot = buildDistributionBoardComponentSnapshot({
id: createClientId(),
id: crypto.randomUUID(),
circuitListId,
role: intent.role,
sectionId: intent.sectionId,
@ -1284,7 +1279,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
projectId,
getExpectedProjectRevision(),
buildNewCircuitGroupSnapshot({
id: createClientId(),
id: crypto.randomUUID(),
circuitListId,
category: values.category,
displayName: values.displayName,
@ -1904,26 +1899,22 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
// Commits the active editor input through command history so edits remain undoable.
// `target` commits a value for a cell without an open inline editor
// (AMEV GZF picker); the regular edit path passes no target.
async function commitEdit(
direction: SaveDirection = "stay",
draftOverride?: string,
target?: SelectedCell
draftOverride?: string
) {
const session = target ?? editingCell;
if (!session) {
if (!editingCell) {
return;
}
const row = findRow(session.rowKey);
const cell = row?.cells.find((entry) => entry.cellKey === session.cellKey);
const row = findRow(editingCell.rowKey);
const cell = row?.cells.find((entry) => entry.cellKey === editingCell.cellKey);
if (!row || !cell || !cell.editable) {
setEditingCell(null);
return;
}
const draft = draftOverride ?? editingCell?.draft ?? "";
const draft = draftOverride ?? editingCell.draft;
if (
session.cellKey === "equipmentIdentifier" &&
editingCell.cellKey === "equipmentIdentifier" &&
row.circuit &&
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
) {
@ -1931,7 +1922,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return;
}
let targetSelection: SelectedCell = { rowKey: session.rowKey, cellKey: session.cellKey };
let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey };
const nextSelectionIntent = () => {
let selected = targetSelection;
if (direction !== "stay") {
@ -1954,7 +1945,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
redo: async () => {
const selection = await createFromPlaceholder(
row.sectionId,
session.cellKey,
editingCell.cellKey,
draft
);
targetSelection = selection;
@ -1971,7 +1962,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const command: HistoryCommand = {
label: "Stromkreisfeld bearbeiten",
redo: async () => {
await patchCircuit(row.circuit!.id, session.cellKey, next);
await patchCircuit(row.circuit!.id, editingCell.cellKey, next);
return nextSelectionIntent();
},
};
@ -1985,7 +1976,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const command: HistoryCommand = {
label: "Gerätefeld bearbeiten",
redo: async () => {
await patchDeviceRow(row.device!.id, session.cellKey, next);
await patchDeviceRow(row.device!.id, editingCell.cellKey, next);
return nextSelectionIntent();
},
};
@ -2000,7 +1991,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
label: "Gerätezeile im Reservestromkreis erstellen",
redo: async () => {
const editValues = createValuesFromEditPatch(
buildDeviceRowEditPatch(session.cellKey, next)
buildDeviceRowEditPatch(editingCell.cellKey, next)
);
const snapshot = createDeviceRowSnapshot(
row.circuit!.id,
@ -2020,7 +2011,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
snapshot,
"Gerätezeile im Reservestromkreis erstellen"
);
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: session.cellKey };
targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey };
return nextSelectionIntent();
},
};
@ -2029,45 +2020,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
}
function isGzfPickerCell(target: SelectedCell | null): target is SelectedCell {
return Boolean(
target &&
target.cellKey === "simultaneityFactor" &&
findCell(target.rowKey, target.cellKey)?.editable
);
}
// Opens the AMEV simultaneity-factor table for one GZF cell. A running
// inline edit is discarded because the picked value replaces it.
function openGzfPicker(target: SelectedCell) {
if (isSaving || historyBusy || !isGzfPickerCell(target)) {
return;
}
setEditingCell(null);
setSelectedCell(target);
setGzfPickerCell(target);
}
function closeGzfPicker() {
setGzfPickerCell(null);
focusGridWithoutScroll();
}
// Commits the picked mean value like a typed edit (undoable command).
function handleGzfPicked(value: number) {
const target = gzfPickerCell;
setGzfPickerCell(null);
if (!target) {
return;
}
// parseNumeric accepts only the German decimal comma.
const draft = value.toLocaleString("de-DE", {
maximumFractionDigits: 2,
useGrouping: false,
});
void commitEdit("stay", draft, target);
}
// Cancels current edit session and restores grid keyboard focus.
function cancelEdit() {
if (!editingCell) {
@ -3011,14 +2963,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// - printable key starts overwrite edit (type-to-edit)
// - Arrow/Tab move selectedCell when not editing
function handleContainerKeyDown(event: KeyboardEvent<HTMLDivElement>) {
// The picker modal sits outside the grid; keep grid keys from acting behind it.
if (gzfPickerCell) {
if (event.key === "Escape") {
event.preventDefault();
closeGzfPicker();
}
return;
}
if (editingCell) {
if (event.key === "Tab") {
event.preventDefault();
@ -3086,11 +3030,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
moveHorizontal(-1);
return;
}
if (event.altKey && event.key === "ArrowDown" && isGzfPickerCell(selectedCell)) {
event.preventDefault();
openGzfPicker(selectedCell);
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
moveVertical(1);
@ -3258,9 +3197,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onSave={handleSaveCircuitGroup}
/>
) : null}
{gzfPickerCell ? (
<GzfPickerModal onClose={closeGzfPicker} onSelect={handleGzfPicked} />
) : null}
{protectionEditorCircuit ? (
<CircuitProtectionModal
equipmentIdentifier={
@ -4594,21 +4530,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} else if (event.key === "Tab") {
event.preventDefault();
void commitEdit(event.shiftKey ? "prev" : "next");
} else if (
event.altKey &&
event.key === "ArrowDown" &&
column.key === "simultaneityFactor"
) {
event.preventDefault();
openGzfPicker({ rowKey: row.rowKey, cellKey: column.key });
}
}}
onBlur={() => {
requestAnimationFrame(() => {
const active = document.activeElement as HTMLElement | null;
if (active?.closest('[role="dialog"][aria-modal="true"]')) {
return;
}
if (!active || !containerRef.current?.contains(active)) {
setEditingCell(null);
focusGridWithoutScroll();
@ -4616,27 +4542,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}}
/>
) : column.key === "simultaneityFactor" &&
cell.editable &&
isSelected ? (
<>
<button
aria-label="Gleichzeitigkeitsfaktor nach AMEV wählen"
className="cell-picker-trigger"
disabled={isSaving || historyBusy}
tabIndex={-1}
onClick={(event) => {
event.stopPropagation();
openGzfPicker({ rowKey: row.rowKey, cellKey: column.key });
}}
onDoubleClick={(event) => event.stopPropagation()}
title="Gleichzeitigkeitsfaktor nach AMEV wählen (Alt+↓)"
type="button"
>
<TableProperties aria-hidden="true" size={13} />
</button>
{formatCellValue(cell.value, column.key)}
</>
) : (
formatCellValue(cell.value, column.key)
)}

View file

@ -1,110 +0,0 @@
"use client";
import {
distributionBoardInfoFieldLabels,
distributionBoardInfoFieldMaxLengths,
distributionBoardInfoFields,
type DistributionBoardInfoField,
} from "../../shared/constants/distribution-board";
import type { DistributionBoardInfoDto } from "../types";
export type DistributionBoardInfoDraft = Record<DistributionBoardInfoField, string>;
const placeholders: Record<DistributionBoardInfoField, string> = {
boardNumber: "z. B. UV 1.1",
location: "z. B. Raum 003 Technik",
fedFrom: "z. B. HV 0.1 oder HAK / Zähleranlage",
remark: "",
};
export function toDistributionBoardInfoDraft(
info: DistributionBoardInfoDto
): DistributionBoardInfoDraft {
return {
boardNumber: info.boardNumber ?? "",
location: info.location ?? "",
fedFrom: info.fedFrom ?? "",
remark: info.remark ?? "",
};
}
export function fromDistributionBoardInfoDraft(
draft: DistributionBoardInfoDraft
): DistributionBoardInfoDto {
const value = (field: DistributionBoardInfoField) =>
draft[field].trim() ? draft[field].trim() : null;
return {
boardNumber: value("boardNumber"),
location: value("location"),
fedFrom: value("fedFrom"),
remark: value("remark"),
};
}
/**
* Descriptive board data used by the print view title block. The values are
* local planning values; a later Revit import may prefill them only through
* an explicit, previewed decision.
*/
export function DistributionBoardInfoCard({
draft,
onChange,
}: {
draft: DistributionBoardInfoDraft;
onChange: (draft: DistributionBoardInfoDraft) => void;
}) {
return (
<section
aria-labelledby="distribution-board-info-heading"
className="card"
>
<div className="card-header" id="distribution-board-info-heading">
Infokarte
</div>
<div className="card-body row g-3">
{distributionBoardInfoFields.map((field) => (
<div
className={field === "remark" ? "col-12" : "col-12 col-md-6"}
key={field}
>
<label
className="form-label"
htmlFor={`distribution-board-info-${field}`}
>
{distributionBoardInfoFieldLabels[field]}
</label>
{field === "remark" ? (
<textarea
className="form-control"
id={`distribution-board-info-${field}`}
maxLength={distributionBoardInfoFieldMaxLengths[field]}
onChange={(event) =>
onChange({ ...draft, [field]: event.target.value })
}
rows={2}
value={draft[field]}
/>
) : (
<input
className="form-control"
id={`distribution-board-info-${field}`}
maxLength={distributionBoardInfoFieldMaxLengths[field]}
onChange={(event) =>
onChange({ ...draft, [field]: event.target.value })
}
placeholder={placeholders[field]}
value={draft[field]}
/>
)}
{field === "fedFrom" ? (
<div className="form-text">
Vorerst Freitext. Die logische Verknüpfung mit dem
speisenden Verteiler folgt.
</div>
) : null}
</div>
))}
</div>
</section>
);
}

View file

@ -1,120 +0,0 @@
"use client";
import { ChevronDown, ChevronRight, FilterX } from "lucide-react";
import React, { useEffect, useId, useState, type ReactNode } from "react";
import { IconButton } from "./icon-button";
function readCollapsed(storageKey: string) {
try {
return window.localStorage.getItem(storageKey) === "1";
} catch {
return false;
}
}
function writeCollapsed(storageKey: string, isCollapsed: boolean) {
try {
window.localStorage.setItem(storageKey, isCollapsed ? "1" : "0");
} catch {
// Storage unavailable (private mode, blocked): keep the in-memory state.
}
}
/**
* Collapsible filter area inside a card (PM app style): a small header row
* with toggle, label "Filter", a "Filter aktiv" badge while collapsed and the
* "x von y" count; below it the wrapping filter bar with a "Zurücksetzen"
* button while a filter is active. Only the bar collapses, never the card.
* The collapsed state is remembered per `storageKey` (default: expanded).
*/
export function CollapsibleFilterPanel({
storageKey,
isFilterActive,
visibleCount,
totalCount,
onReset,
children,
}: {
storageKey: string;
isFilterActive: boolean;
visibleCount: number;
totalCount: number;
onReset: () => void;
children: ReactNode;
}) {
const [isCollapsed, setIsCollapsed] = useState(false);
const barId = useId();
useEffect(() => {
setIsCollapsed(readCollapsed(storageKey));
}, [storageKey]);
function toggleCollapsed() {
const next = !isCollapsed;
setIsCollapsed(next);
writeCollapsed(storageKey, next);
}
return (
<div className="filter-panel">
<div className="filter-panel-header">
<IconButton
ariaControls={barId}
ariaExpanded={!isCollapsed}
className="filter-panel-toggle"
icon={isCollapsed ? ChevronRight : ChevronDown}
label={isCollapsed ? "Filter einblenden" : "Filter ausblenden"}
onClick={toggleCollapsed}
/>
<span className="filter-panel-label">Filter</span>
{isCollapsed && isFilterActive ? (
<span className="filter-panel-badge">Filter aktiv</span>
) : null}
<span aria-live="polite" className="filter-panel-count">
{visibleCount} von {totalCount}
</span>
</div>
{isCollapsed ? null : (
<div className="filter-bar" id={barId}>
{children}
{isFilterActive ? (
<IconButton
className="filter-bar-reset"
icon={FilterX}
label="Zurücksetzen"
onClick={onReset}
showLabel
title="Alle Filter zurücksetzen"
/>
) : null}
</div>
)}
</div>
);
}
/** Search field for a filter bar. */
export function FilterSearchField({
label,
placeholder,
value,
onChange,
}: {
label: string;
placeholder: string;
value: string;
onChange: (value: string) => void;
}) {
return (
<label className="filter-bar-field">
<span className="filter-bar-label">{label}</span>
<input
className="filter-bar-search"
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
type="search"
value={value}
/>
</label>
);
}

View file

@ -1,49 +0,0 @@
"use client";
import React from "react";
import type { BuildingDto, FloorDto } from "../types";
import { groupFloorsByBuilding } from "../utils/building-structure";
export type FloorOption = Pick<FloorDto, "id" | "name"> & {
buildingId?: string | null;
};
interface FloorSelectOptionsProps {
buildings?: ReadonlyArray<Pick<BuildingDto, "id" | "name">>;
floors: ReadonlyArray<FloorOption>;
}
/**
* Floor <option>s for a <select>, grouped by building via <optgroup> as soon
* as the project has buildings. The caller renders its own empty option
* ("Ohne Etage") before these.
*/
export function FloorSelectOptions({
buildings = [],
floors,
}: FloorSelectOptionsProps) {
if (!buildings.length) {
return (
<>
{floors.map((floor) => (
<option key={floor.id} value={floor.id}>
{floor.name}
</option>
))}
</>
);
}
return (
<>
{groupFloorsByBuilding(floors, buildings).map((group) => (
<optgroup key={group.buildingId ?? "unassigned"} label={group.label}>
{group.floors.map((floor) => (
<option key={floor.id} value={floor.id}>
{floor.name}
</option>
))}
</optgroup>
))}
</>
);
}

View file

@ -1,6 +1,5 @@
"use client";
import { useEffect, useId, useRef } from "react";
import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf";
interface GzfPickerModalProps {
@ -13,50 +12,14 @@ function formatFactor(value: number) {
}
export function GzfPickerModal({ onClose, onSelect }: GzfPickerModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
useEffect(() => {
// Wait for the inline editor's blur callback before taking keyboard focus.
const frame = requestAnimationFrame(() => {
dialogRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
});
return () => cancelAnimationFrame(frame);
}, []);
return (
<>
<div
aria-labelledby={titleId}
aria-modal="true"
className="modal fade show d-block"
ref={dialogRef}
role="dialog"
tabIndex={-1}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
onClose();
} else if (event.key === "Tab") {
const buttons = dialogRef.current?.querySelectorAll<HTMLButtonElement>("button:not(:disabled)");
const first = buttons?.[0];
const last = buttons?.[buttons.length - 1];
if (event.shiftKey && (document.activeElement === first || document.activeElement === dialogRef.current)) {
event.preventDefault();
last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
}}
>
<div aria-modal="true" className="modal fade show d-block" role="dialog" tabIndex={-1}>
<div className="modal-dialog modal-lg modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<div>
<h2 className="modal-title fs-5" id={titleId}>Gleichzeitigkeitsfaktor nach AMEV</h2>
<h2 className="modal-title fs-5">Gleichzeitigkeitsfaktor nach AMEV</h2>
<p className="text-secondary small mb-0">
Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen
</p>

View file

@ -1,7 +1,7 @@
"use client";
import type { LucideIcon } from "lucide-react";
import React, { type ReactNode } from "react";
import type { ReactNode } from "react";
/**
* Compact editor button with a lucide icon. `label` is always the accessible

View file

@ -1,40 +0,0 @@
"use client";
import type { LucideIcon } from "lucide-react";
import Link from "next/link";
import React from "react";
/**
* Navigation counterpart of `IconButton`: a Next.js link with the same
* look. `label` is always the accessible name and, unless `title` is given,
* the tooltip; with `showLabel` it is also rendered next to the icon.
*/
export function IconLink({
icon: Icon,
label,
title,
href,
variant = "default",
showLabel = false,
className,
}: {
icon: LucideIcon;
label: string;
title?: string;
href: string;
variant?: "default" | "danger" | "primary";
showLabel?: boolean;
className?: string;
}) {
return (
<Link
aria-label={showLabel ? undefined : label}
className={`icon-button icon-button-${variant}${className ? ` ${className}` : ""}`}
href={href}
title={title ?? label}
>
<Icon aria-hidden="true" size={15} strokeWidth={2} />
{showLabel ? <span>{label}</span> : null}
</Link>
);
}

View file

@ -1,127 +0,0 @@
"use client";
import { ChevronDown } from "lucide-react";
import React, { useEffect, useId, useRef, useState } from "react";
import {
toggleFilterValue,
type FilterOption,
} from "../utils/overview-filters";
/**
* Filter dropdown with a checkbox list (PM app style). An empty selection
* means "Alle"; the "Alle" entry clears the selection. Closes on outside
* click and Escape.
*/
export function MultiSelectDropdown({
label,
options,
selected,
onChange,
emptyLabel = "Keine Einträge vorhanden",
}: {
label: string;
options: readonly FilterOption[];
selected: readonly string[];
onChange: (selected: string[]) => void;
emptyLabel?: string;
}) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const labelId = useId();
const valueId = useId();
const panelId = useId();
useEffect(() => {
if (!isOpen) {
return;
}
function handlePointerDown(event: MouseEvent) {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
setIsOpen(false);
toggleRef.current?.focus();
}
}
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
const summary =
selected.length === 0
? "Alle"
: selected.length === 1
? options.find((option) => option.id === selected[0])?.label ??
"1 ausgewählt"
: `${selected.length} ausgewählt`;
return (
<div className="filter-dropdown" ref={containerRef}>
<span className="filter-bar-label" id={labelId}>
{label}
</span>
<button
aria-controls={panelId}
aria-expanded={isOpen}
aria-haspopup="true"
aria-labelledby={`${labelId} ${valueId}`}
className={`filter-dropdown-toggle${selected.length ? " is-active" : ""}`}
onClick={() => setIsOpen((current) => !current)}
ref={toggleRef}
title={summary}
type="button"
>
<span className="filter-dropdown-value" id={valueId}>
{summary}
</span>
<ChevronDown aria-hidden="true" size={14} strokeWidth={2} />
</button>
{isOpen ? (
<div
aria-labelledby={labelId}
className="filter-dropdown-panel"
id={panelId}
role="group"
>
{options.length === 0 ? (
<div className="filter-dropdown-empty">{emptyLabel}</div>
) : (
<>
<label className="filter-dropdown-option filter-dropdown-option-all">
<input
checked={selected.length === 0}
onChange={() => onChange([])}
type="checkbox"
/>
Alle
</label>
{options.map((option) => (
<label className="filter-dropdown-option" key={option.id}>
<input
checked={selected.includes(option.id)}
onChange={() =>
onChange(toggleFilterValue(selected, option.id))
}
type="checkbox"
/>
{option.label}
</label>
))}
</>
)}
</div>
) : null}
</div>
);
}

View file

@ -7,16 +7,9 @@ import {
distributionBoardSupplyTypes,
type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board";
import {
projectCoverFieldLabels,
projectCoverFieldMaxLengths,
projectCoverFields,
type ProjectCoverField,
} from "../../shared/constants/project-cover";
import { FormModal } from "./form-modal";
export interface ProjectSettingsInput
extends Record<ProjectCoverField, string | null> {
export interface ProjectSettingsInput {
name: string;
internalProjectNumber: string | null;
externalProjectNumber: string | null;
@ -28,13 +21,6 @@ export interface ProjectSettingsInput
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
}
const coverFieldPlaceholders: Record<ProjectCoverField, string> = {
siteAddress: "Straße Nr.\nPLZ Ort",
workStage: "z. B. LPH 5 – Ausführungsplanung",
projectLead: "",
projectEditor: "",
};
interface ProjectSettingsModalProps {
isSaving: boolean;
onClose: () => void;
@ -85,14 +71,6 @@ export function ProjectSettingsModal({
] = useState<DistributionBoardSupplyType[]>(
project.enabledDistributionBoardSupplyTypes
);
const [coverValues, setCoverValues] = useState<
Record<ProjectCoverField, string>
>({
siteAddress: project.siteAddress ?? "",
workStage: project.workStage ?? "",
projectLead: project.projectLead ?? "",
projectEditor: project.projectEditor ?? "",
});
const [transfer, setTransfer] = useState<unknown>(null);
const [transferFilename, setTransferFilename] = useState("");
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
@ -113,10 +91,6 @@ export function ProjectSettingsModal({
singlePhaseVoltageV: Number(singlePhaseVoltageV),
threePhaseVoltageV: Number(threePhaseVoltageV),
enabledDistributionBoardSupplyTypes,
siteAddress: toNullableString(coverValues.siteAddress),
workStage: toNullableString(coverValues.workStage),
projectLead: toNullableString(coverValues.projectLead),
projectEditor: toNullableString(coverValues.projectEditor),
});
}
@ -335,57 +309,6 @@ export function ProjectSettingsModal({
value={threePhaseVoltageV}
/>
</div>
<div className="col-12">
<hr className="my-2" />
<h3 className="h6">Deckblatt</h3>
<p className="text-secondary small">
Angaben für das Deckblatt der Seitenansicht. Büroname und
Büroanschrift werden projektübergreifend in der Seitenansicht
gepflegt.
</p>
<div className="row g-3">
{projectCoverFields.map((field) => (
<div
className={field === "siteAddress" ? "col-12" : "col-12 col-md-6"}
key={field}
>
<label className="form-label" htmlFor={`project-cover-${field}`}>
{projectCoverFieldLabels[field]}
</label>
{field === "siteAddress" ? (
<textarea
className="form-control"
id={`project-cover-${field}`}
maxLength={projectCoverFieldMaxLengths[field]}
onChange={(event) =>
setCoverValues((current) => ({
...current,
[field]: event.target.value,
}))
}
placeholder={coverFieldPlaceholders[field]}
rows={2}
value={coverValues[field]}
/>
) : (
<input
className="form-control"
id={`project-cover-${field}`}
maxLength={projectCoverFieldMaxLengths[field]}
onChange={(event) =>
setCoverValues((current) => ({
...current,
[field]: event.target.value,
}))
}
placeholder={coverFieldPlaceholders[field]}
value={coverValues[field]}
/>
)}
</div>
))}
</div>
</div>
<div className="col-12">
<hr className="my-2" />
<h3 className="h6">Projekt importieren oder exportieren</h3>

View file

@ -28,14 +28,6 @@ import {
getProjectSnapshotRevisionDescription,
mergeProjectRevisionPages,
} from "../utils/project-version-history";
import {
ChevronDown,
ChevronUp,
Redo2,
RotateCcw,
Undo2,
} from "lucide-react";
import { IconButton, IconButtonSeparator } from "./icon-button";
interface ProjectVersionHistoryProps {
projectId: string;
@ -242,50 +234,50 @@ export function ProjectVersionHistory({
<span className="badge text-bg-secondary">
Aktuelle Revision {currentRevision}
</span>
<div className="overview-actions">
<IconButton
<button
className="btn btn-sm btn-outline-secondary"
disabled={
isBusy ||
isHistoryLoading ||
!historyIsCurrent ||
!history?.undoDepth
}
icon={Undo2}
label="Rückgängig"
onClick={() => void handleHistoryCommand("undo")}
showLabel
title={
history?.undoDepth
? `${history.undoDepth} Änderung(en) können rückgängig gemacht werden`
: "Keine Änderung zum Rückgängigmachen"
}
/>
<IconButton
type="button"
>
Rückgängig
</button>
<button
className="btn btn-sm btn-outline-secondary"
disabled={
isBusy ||
isHistoryLoading ||
!historyIsCurrent ||
!history?.redoDepth
}
icon={Redo2}
label="Wiederholen"
onClick={() => void handleHistoryCommand("redo")}
showLabel
title={
history?.redoDepth
? `${history.redoDepth} Änderung(en) können wiederholt werden`
: "Keine Änderung zum Wiederholen"
}
/>
<IconButtonSeparator />
<IconButton
ariaExpanded={isOpen}
icon={isOpen ? ChevronUp : ChevronDown}
label={isOpen ? "Ausblenden" : "Anzeigen"}
type="button"
>
Wiederholen
</button>
<button
aria-expanded={isOpen}
className="btn btn-sm btn-outline-secondary"
onClick={() => setIsOpen((current) => !current)}
showLabel
/>
</div>
type="button"
>
{isOpen ? "Ausblenden" : "Anzeigen"}
</button>
</div>
</div>
{error ? (
@ -389,15 +381,14 @@ export function ProjectVersionHistory({
</td>
<td>{formatDateTime(snapshot.createdAtIso)}</td>
<td className="text-end">
<div className="overview-actions justify-content-end">
<IconButton
<button
className="btn btn-sm btn-outline-danger"
disabled={isBusy}
icon={RotateCcw}
label="Wiederherstellen"
onClick={() => setRestoreCandidate(snapshot)}
variant="danger"
/>
</div>
type="button"
>
Wiederherstellen
</button>
</td>
</tr>
))}

View file

@ -1,14 +1,11 @@
"use client";
import React, { type FormEvent, useState } from "react";
import type { BuildingDto, CreateRoomInput, RoomDto } from "../types";
import { FloorSelectOptions, type FloorOption } from "./floor-select-options";
import type { CreateRoomInput, FloorDto, RoomDto } from "../types";
import { FormModal } from "./form-modal";
interface RoomModalProps {
/** When given, the floor select groups floors by building. */
buildings?: ReadonlyArray<Pick<BuildingDto, "id" | "name">>;
floors: ReadonlyArray<FloorOption>;
floors: FloorDto[];
initialRoom?: RoomDto;
initialValues?: Partial<CreateRoomInput>;
isSaving: boolean;
@ -18,7 +15,6 @@ interface RoomModalProps {
}
export function RoomModal({
buildings,
floors,
initialRoom,
initialValues,
@ -87,7 +83,7 @@ export function RoomModal({
value={floorId}
>
<option value="">Ohne Etage</option>
<FloorSelectOptions buildings={buildings} floors={floors} />
{floors.map((floor) => <option key={floor.id} value={floor.id}>{floor.name}</option>)}
</select>
</div>
</div>

View file

@ -1,162 +0,0 @@
"use client";
import { type FormEvent, useState } from "react";
import { FormModal } from "../components/form-modal";
import type {
CreateEstimateBalanceInput,
EstimateBalanceDto,
EstimateBalanceTemplate,
} from "../types";
import { estimateTextLimits } from "./estimate-balance-document";
import { getTodayIsoDate } from "./estimate-balance-format";
export interface EstimateBalanceCreateDefaults {
name?: string;
workStage?: string | null;
template?: EstimateBalanceTemplate;
copyFromBalanceId?: string;
}
interface EstimateBalanceCreateModalProps {
balances: EstimateBalanceDto[];
defaults: EstimateBalanceCreateDefaults;
isSaving: boolean;
onClose: () => void;
onCreate: (input: CreateEstimateBalanceInput) => void;
}
/** "Neuer Stand": name, LPH, date and the starting content. */
export function EstimateBalanceCreateModal({
balances,
defaults,
isSaving,
onClose,
onCreate,
}: EstimateBalanceCreateModalProps) {
const [name, setName] = useState(defaults.name ?? "");
const [workStage, setWorkStage] = useState(defaults.workStage ?? "");
const [dateIso, setDateIso] = useState(getTodayIsoDate());
const [template, setTemplate] = useState<EstimateBalanceTemplate>(
defaults.template ?? "kg400"
);
const [copyFromBalanceId, setCopyFromBalanceId] = useState(
defaults.copyFromBalanceId ?? balances[0]?.id ?? ""
);
const isCopyInvalid = template === "copy" && !copyFromBalanceId;
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!name.trim() || isCopyInvalid) {
return;
}
onCreate({
name: name.trim(),
workStage: workStage.trim() || null,
dateIso: dateIso || null,
template,
...(template === "copy" ? { copyFromBalanceId } : {}),
});
}
return (
<FormModal
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!name.trim() || isCopyInvalid}
submitLabel="Stand anlegen"
title="Neuer Stand"
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="estimate-create-name">
Bezeichnung
</label>
<input
autoFocus
className="form-control"
id="estimate-create-name"
maxLength={estimateTextLimits.balanceName}
onChange={(event) => setName(event.target.value)}
placeholder="z. B. Entwurfsplanung"
required
value={name}
/>
</div>
<div className="col-6 col-md-3">
<label className="form-label" htmlFor="estimate-create-work-stage">
LPH
</label>
<input
className="form-control"
id="estimate-create-work-stage"
maxLength={estimateTextLimits.workStage}
onChange={(event) => setWorkStage(event.target.value)}
placeholder="z. B. LPH 3"
value={workStage}
/>
</div>
<div className="col-6 col-md-3">
<label className="form-label" htmlFor="estimate-create-date">
Datum
</label>
<input
className="form-control"
id="estimate-create-date"
onChange={(event) => setDateIso(event.target.value)}
type="date"
value={dateIso}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="estimate-create-template">
Inhalt
</label>
<select
className="form-select"
id="estimate-create-template"
onChange={(event) =>
setTemplate(event.target.value as EstimateBalanceTemplate)
}
value={template}
>
<option value="kg400">Vorlage KG 400</option>
<option value="empty">Leer</option>
<option disabled={!balances.length} value="copy">
Kopie von …
</option>
</select>
</div>
{template === "copy" ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="estimate-create-copy-from">
Kopie von Stand
</label>
<select
className="form-select"
id="estimate-create-copy-from"
onChange={(event) => setCopyFromBalanceId(event.target.value)}
required
value={copyFromBalanceId}
>
{balances.map((balance) => (
<option key={balance.id} value={balance.id}>
{balance.workStage ? `${balance.name} (${balance.workStage})` : balance.name}
</option>
))}
</select>
</div>
) : null}
<div className="col-12">
<p className="text-secondary small mb-0">
{template === "kg400"
? "Legt die Kostengruppen 410 bis 480 und „Allgemein“ ohne Einträge an."
: template === "empty"
? "Legt einen Stand ohne Kostengruppen an."
: "Übernimmt alle Kostengruppen und Zeilen des gewählten Stands."}
</p>
</div>
</div>
</FormModal>
);
}

View file

@ -1,298 +0,0 @@
// Pure, immutable editing rules for one "Grobe Leistungsbilanz" document.
// The editor keeps the complete document as local draft and saves it as a
// whole (PUT), so every edit is a function draft → new draft.
import type {
EstimateBalanceSnapshot,
EstimateGroupSnapshot,
EstimateRowSnapshot,
} from "../../shared/estimate-balance/estimate-balance";
import { createClientId } from "../utils/client-id";
export type MoveDirection = -1 | 1;
/** Maximum text lengths accepted by the server. */
export const estimateTextLimits = {
balanceName: 200,
workStage: 50,
costGroup: 20,
groupTitle: 200,
groupEditor: 100,
supply: 50,
rowName: 200,
remark: 500,
} as const;
export type EstimateRowPatch = Partial<Omit<EstimateRowSnapshot, "id" | "sortOrder">>;
export type EstimateGroupPatch = Partial<Pick<EstimateGroupSnapshot, "costGroup" | "title" | "editor">>;
export function createEstimateRow(
sortOrder: number,
createId: () => string = createClientId
): EstimateRowSnapshot {
return {
id: createId(),
sortOrder,
supply: null,
name: "",
quantity: 1,
unit: "piece",
connection: null,
powerPerUnitW: 0,
simultaneityFactor: 1,
cosPhi: 1,
safetySupply: false,
remark: null,
};
}
export function createEstimateGroup(
input: { costGroup: string; title: string; editor?: string | null },
sortOrder: number,
createId: () => string = createClientId
): EstimateGroupSnapshot {
return {
id: createId(),
costGroup: input.costGroup,
title: input.title,
editor: input.editor ?? null,
sortOrder,
rows: [],
};
}
function bySortOrder<T extends { sortOrder: number }>(left: T, right: T) {
return left.sortOrder - right.sortOrder;
}
/** Orders groups and rows by sortOrder and renumbers both from 0. */
export function normalizeEstimateSortOrder(
balance: EstimateBalanceSnapshot
): EstimateBalanceSnapshot {
return {
...balance,
groups: [...balance.groups].sort(bySortOrder).map((group, groupIndex) => ({
...group,
sortOrder: groupIndex,
rows: [...group.rows].sort(bySortOrder).map((row, rowIndex) => ({
...row,
sortOrder: rowIndex,
})),
})),
};
}
function renumber<T extends { sortOrder: number }>(items: T[]): T[] {
return items.map((item, index) =>
item.sortOrder === index ? item : { ...item, sortOrder: index }
);
}
function mapGroup(
balance: EstimateBalanceSnapshot,
groupId: string,
update: (group: EstimateGroupSnapshot) => EstimateGroupSnapshot
): EstimateBalanceSnapshot {
let changed = false;
const groups = balance.groups.map((group) => {
if (group.id !== groupId) {
return group;
}
const next = update(group);
changed = next !== group;
return next;
});
// An unknown group or a no-op keeps the identical document.
return changed ? { ...balance, groups } : balance;
}
function moveItem<T>(items: T[], index: number, direction: MoveDirection): T[] | null {
const target = index + direction;
if (index < 0 || target < 0 || target >= items.length) {
return null;
}
const next = [...items];
[next[index], next[target]] = [next[target]!, next[index]!];
return next;
}
/** Appends a row (or inserts it after `afterRowId`) and returns the new row id. */
export function addEstimateRow(
balance: EstimateBalanceSnapshot,
groupId: string,
options: { afterRowId?: string; createId?: () => string } = {}
): { balance: EstimateBalanceSnapshot; rowId: string | null } {
let rowId: string | null = null;
const next = mapGroup(balance, groupId, (group) => {
const row = createEstimateRow(0, options.createId);
rowId = row.id;
const afterIndex = options.afterRowId
? group.rows.findIndex((candidate) => candidate.id === options.afterRowId)
: -1;
const rows = [...group.rows];
rows.splice(afterIndex >= 0 ? afterIndex + 1 : rows.length, 0, row);
return { ...group, rows: renumber(rows) };
});
return { balance: next, rowId };
}
export function updateEstimateRow(
balance: EstimateBalanceSnapshot,
groupId: string,
rowId: string,
patch: EstimateRowPatch
): EstimateBalanceSnapshot {
return mapGroup(balance, groupId, (group) => ({
...group,
rows: group.rows.map((row) => (row.id === rowId ? { ...row, ...patch } : row)),
}));
}
export function removeEstimateRow(
balance: EstimateBalanceSnapshot,
groupId: string,
rowId: string
): EstimateBalanceSnapshot {
return mapGroup(balance, groupId, (group) => ({
...group,
rows: renumber(group.rows.filter((row) => row.id !== rowId)),
}));
}
export function moveEstimateRow(
balance: EstimateBalanceSnapshot,
groupId: string,
rowId: string,
direction: MoveDirection
): EstimateBalanceSnapshot {
return mapGroup(balance, groupId, (group) => {
const rows = moveItem(
group.rows,
group.rows.findIndex((row) => row.id === rowId),
direction
);
return rows ? { ...group, rows: renumber(rows) } : group;
});
}
/** Appends a group and returns its id. */
export function addEstimateGroup(
balance: EstimateBalanceSnapshot,
input: { costGroup: string; title: string; editor?: string | null },
createId: () => string = createClientId
): { balance: EstimateBalanceSnapshot; groupId: string } {
const group = createEstimateGroup(input, balance.groups.length, createId);
return {
balance: { ...balance, groups: renumber([...balance.groups, group]) },
groupId: group.id,
};
}
export function updateEstimateGroup(
balance: EstimateBalanceSnapshot,
groupId: string,
patch: EstimateGroupPatch
): EstimateBalanceSnapshot {
return mapGroup(balance, groupId, (group) => ({ ...group, ...patch }));
}
export function removeEstimateGroup(
balance: EstimateBalanceSnapshot,
groupId: string
): EstimateBalanceSnapshot {
const groups = balance.groups.filter((group) => group.id !== groupId);
return groups.length === balance.groups.length
? balance
: { ...balance, groups: renumber(groups) };
}
export function moveEstimateGroup(
balance: EstimateBalanceSnapshot,
groupId: string,
direction: MoveDirection
): EstimateBalanceSnapshot {
const groups = moveItem(
balance.groups,
balance.groups.findIndex((group) => group.id === groupId),
direction
);
return groups ? { ...balance, groups: renumber(groups) } : balance;
}
function requiredText(value: string) {
return value.trim();
}
function optionalText(value: string | null) {
const trimmed = value?.trim() ?? "";
return trimmed ? trimmed : null;
}
/**
* The exact document the server accepts: strings trimmed, empty optional
* texts as null, sort orders continuous. Only fields of the shared snapshot
* type are copied, so no UI state can leak into the request.
*/
export function toSaveableEstimateBalance(
balance: EstimateBalanceSnapshot
): EstimateBalanceSnapshot {
const normalized = normalizeEstimateSortOrder(balance);
return {
id: normalized.id,
projectId: normalized.projectId,
name: requiredText(normalized.name),
workStage: optionalText(normalized.workStage),
dateIso: normalized.dateIso || null,
reserveAvPercent: normalized.reserveAvPercent,
reserveSvPercent: normalized.reserveSvPercent,
sortOrder: normalized.sortOrder,
groups: normalized.groups.map((group) => ({
id: group.id,
costGroup: requiredText(group.costGroup),
title: requiredText(group.title),
editor: optionalText(group.editor),
sortOrder: group.sortOrder,
rows: group.rows.map((row) => ({
id: row.id,
sortOrder: row.sortOrder,
supply: optionalText(row.supply),
name: requiredText(row.name),
quantity: row.quantity,
unit: row.unit,
connection: row.connection,
powerPerUnitW: row.powerPerUnitW,
simultaneityFactor: row.simultaneityFactor,
cosPhi: row.cosPhi,
safetySupply: row.safetySupply,
remark: optionalText(row.remark),
})),
})),
};
}
/** Stable comparison key of the saveable document (dirty detection). */
export function estimateBalanceFingerprint(balance: EstimateBalanceSnapshot): string {
return JSON.stringify(toSaveableEstimateBalance(balance));
}
/**
* German problems that would make the server reject the document. The
* editor never saves while this list is not empty.
*/
export function validateEstimateBalance(balance: EstimateBalanceSnapshot): string[] {
const problems: string[] = [];
const saveable = toSaveableEstimateBalance(balance);
if (!saveable.name) {
problems.push("Die Bezeichnung des Stands darf nicht leer sein.");
}
for (const group of saveable.groups) {
if (!group.title) {
problems.push(`Kostengruppe ${group.costGroup || "ohne Nummer"}: Titel fehlt.`);
}
}
return problems;
}
export function createCopyName(name: string) {
const copyName = `${name.trim()} (Kopie)`;
return copyName.slice(0, estimateTextLimits.balanceName);
}

View file

@ -1,122 +0,0 @@
// Pure parsing and formatting rules of the "Grobe Leistungsbilanz" editor.
// Numbers are entered and shown in German notation. As in the circuit list
// editor, only "," is accepted as decimal separator: a "." is ambiguous
// (decimal point or thousands separator) and is rejected instead of guessed.
import { roundEstimateValue } from "../../shared/estimate-balance/estimate-balance";
export type DecimalParseResult =
| { ok: true; value: number }
| { ok: false; error: string };
export interface DecimalRange {
min?: number;
/** Excludes `min` itself (e.g. cos φ > 0). */
minExclusive?: boolean;
max?: number;
}
const DECIMAL_PATTERN = /^[+-]?(\d+(,\d*)?|,\d+)$/;
function formatBound(value: number) {
return value.toLocaleString("de-DE", { maximumFractionDigits: 6, useGrouping: false });
}
export function parseGermanDecimal(text: string, range: DecimalRange = {}): DecimalParseResult {
const trimmed = text.trim();
if (!trimmed) {
return { ok: false, error: "Bitte einen Wert eingeben." };
}
if (trimmed.includes(".")) {
return { ok: false, error: "Dezimalstellen mit Komma eingeben (z. B. 0,8)." };
}
if (!DECIMAL_PATTERN.test(trimmed)) {
return { ok: false, error: "Ungültiger Zahlenwert." };
}
const value = Number(trimmed.replace(",", "."));
if (!Number.isFinite(value)) {
return { ok: false, error: "Ungültiger Zahlenwert." };
}
if (range.min !== undefined) {
if (range.minExclusive ? value <= range.min : value < range.min) {
return {
ok: false,
error: range.minExclusive
? `Der Wert muss größer als ${formatBound(range.min)} sein.`
: `Der Wert darf nicht kleiner als ${formatBound(range.min)} sein.`,
};
}
}
if (range.max !== undefined && value > range.max) {
return { ok: false, error: `Der Wert darf nicht größer als ${formatBound(range.max)} sein.` };
}
return { ok: true, value };
}
/** Value ranges accepted by the server for the numeric document fields. */
export const estimateNumberRanges = {
quantity: { min: 0 },
powerPerUnitW: { min: 0 },
simultaneityFactor: { min: 0, max: 1 },
cosPhi: { min: 0, minExclusive: true, max: 1 },
reservePercent: { min: 0, max: 100 },
} satisfies Record<string, DecimalRange>;
/**
* Text of a numeric input field: German comma, no thousands separator, so the
* shown text can be parsed again unchanged.
*/
export function formatDecimalInput(value: number, maximumFractionDigits = 6): string {
return value.toLocaleString("de-DE", { maximumFractionDigits, useGrouping: false });
}
/**
* Read-only display of a calculated value with thousands separator ("1.200",
* "30,24"). The value is rounded half away from zero first, like Excel does
* (40,175 → 40,18 instead of the binary-rounded 40,17).
*/
export function formatGermanNumber(
value: number,
minimumFractionDigits: number,
maximumFractionDigits = minimumFractionDigits
): string {
const rounded = roundEstimateValue(value, maximumFractionDigits);
// "+ 0" turns -0 into 0 and avoids "-0,00".
return (rounded + 0).toLocaleString("de-DE", {
minimumFractionDigits,
maximumFractionDigits,
});
}
export function formatKw(value: number) {
return formatGermanNumber(value, 2);
}
export function formatAmpere(value: number) {
return formatGermanNumber(value, 2);
}
export function formatSignedKw(value: number) {
const text = formatKw(value);
return value > 0 && text !== "0,00" ? `+${text}` : text;
}
export function formatDeltaPercent(value: number | null) {
if (value === null) {
return "–";
}
const text = formatGermanNumber(value, 1);
return `${value > 0 && text !== "0,0" ? "+" : ""}${text} %`;
}
/** "2026-09-26" → "26.09.2026"; empty/invalid → "–". */
export function formatIsoDateGerman(dateIso: string | null): string {
const match = dateIso?.match(/^(\d{4})-(\d{2})-(\d{2})$/);
return match ? `${match[3]}.${match[2]}.${match[1]}` : "–";
}
/** Local calendar date as YYYY-MM-DD (not UTC, so late evenings stay today). */
export function getTodayIsoDate(now: Date = new Date()): string {
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${now.getFullYear()}-${month}-${day}`;
}

View file

@ -1,669 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { ArrowDown, ArrowUp, Pencil, Plus, Trash2 } from "lucide-react";
import {
calculateEstimateBalance,
calculateEstimateRow,
estimateRowConnectionLabels,
estimateRowUnitLabels,
type EstimateBalanceSnapshot,
type EstimateGroupSnapshot,
type EstimatePower,
type EstimateRowConnection,
type EstimateRowSnapshot,
type EstimateRowUnit,
} from "../../shared/estimate-balance/estimate-balance";
import {
addEstimateRow,
estimateTextLimits,
moveEstimateGroup,
removeEstimateGroup,
removeEstimateRow,
updateEstimateGroup,
updateEstimateRow,
type EstimateRowPatch,
} from "./estimate-balance-document";
import {
estimateNumberRanges,
formatAmpere,
formatDecimalInput,
formatKw,
parseGermanDecimal,
type DecimalRange,
formatGermanNumber,
} from "./estimate-balance-format";
import { DraftInput } from "./estimate-draft-input";
export type EstimateBalanceUpdate = (
update: (balance: EstimateBalanceSnapshot) => EstimateBalanceSnapshot
) => void;
interface EstimateBalanceSheetProps {
balance: EstimateBalanceSnapshot;
disabled: boolean;
onAddGroup: () => void;
onChange: EstimateBalanceUpdate;
onEditGroup: (group: EstimateGroupSnapshot) => void;
onOpenGzfPicker: (groupId: string, rowId: string) => void;
onValidityChange: (fieldKey: string, isValid: boolean) => void;
threePhaseVoltageV: number;
}
// Leading columns up to cos φ; the power columns follow.
const LEADING_COLUMN_COUNT = 9;
const COLUMN_COUNT = 16;
/** Suggested supply values; free text stays possible. */
const SUPPLY_SUGGESTIONS = ["ELT", "MSR"];
const SUPPLY_LIST_ID = "estimate-supply-options";
function NumberInput({
ariaLabel,
disabled,
fieldKey,
onCommit,
onValidityChange,
range,
value,
}: {
ariaLabel: string;
disabled: boolean;
fieldKey: string;
onCommit: (value: number) => void;
onValidityChange: (fieldKey: string, isValid: boolean) => void;
range: DecimalRange;
value: number;
}) {
return (
<DraftInput
ariaLabel={ariaLabel}
className="estimate-cell-input is-numeric"
disabled={disabled}
format={(current: number) => formatDecimalInput(current)}
inputMode="decimal"
printFormat={(current: number) => formatGermanNumber(current, 0, 3)}
onCommit={onCommit}
onValidityChange={(isValid) => onValidityChange(fieldKey, isValid)}
parse={(text) => parseGermanDecimal(text, range)}
value={value}
/>
);
}
function supplyOptions(balance: EstimateBalanceSnapshot): string[] {
const used = balance.groups.flatMap((group) =>
group.rows.map((row) => row.supply?.trim() ?? "")
);
return [...new Set([...SUPPLY_SUGGESTIONS, ...used.filter(Boolean)])];
}
function TextInput({
ariaLabel,
disabled,
list,
maxLength,
onChange,
placeholder,
value,
}: {
ariaLabel: string;
disabled: boolean;
list?: string;
maxLength: number;
onChange: (value: string) => void;
placeholder?: string;
value: string | null;
}) {
return (
<>
<input
aria-label={ariaLabel}
className="estimate-cell-input"
disabled={disabled}
list={list}
maxLength={maxLength}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
type="text"
value={value ?? ""}
/>
<span className="estimate-print-value">{value ?? ""}</span>
</>
);
}
function IconButton({
children,
disabled,
label,
onClick,
variant = "secondary",
}: {
children: ReactNode;
disabled: boolean;
label: string;
onClick: () => void;
variant?: "secondary" | "danger" | "primary";
}) {
return (
<button
aria-label={label}
className={`btn btn-sm btn-outline-${variant} estimate-icon-button`}
disabled={disabled}
onClick={onClick}
title={label}
type="button"
>
{children}
</button>
);
}
function PowerCells({ power, className = "" }: { power: EstimatePower; className?: string }) {
return (
<>
<td className={`estimate-number ${className}`}>{formatKw(power.kw)}</td>
<td className={`estimate-number ${className}`}>{formatKw(power.kva)}</td>
</>
);
}
function groupLabel(group: EstimateGroupSnapshot) {
return [group.costGroup, group.title].filter(Boolean).join(" ");
}
function EstimateRowLine({
disabled,
group,
onChange,
onOpenGzfPicker,
onValidityChange,
row,
rowIndex,
}: {
disabled: boolean;
group: EstimateGroupSnapshot;
onChange: EstimateBalanceUpdate;
onOpenGzfPicker: (groupId: string, rowId: string) => void;
onValidityChange: (fieldKey: string, isValid: boolean) => void;
row: EstimateRowSnapshot;
rowIndex: number;
}) {
const result = calculateEstimateRow(row);
const rowLabel = `${groupLabel(group)}, Zeile ${rowIndex + 1}`;
const fieldKey = (field: string) => `${row.id}:${field}`;
function patch(rowPatch: EstimateRowPatch) {
onChange((balance) => updateEstimateRow(balance, group.id, row.id, rowPatch));
}
return (
<tr className="estimate-row">
<td className="estimate-cost-group-cell" />
<td>
<TextInput
ariaLabel={`Versorgung (${rowLabel})`}
disabled={disabled}
list={SUPPLY_LIST_ID}
maxLength={estimateTextLimits.supply}
onChange={(supply) => patch({ supply })}
placeholder="ELT"
value={row.supply}
/>
</td>
<td className="estimate-name-cell">
<TextInput
ariaLabel={`Verbraucher / Fläche (${rowLabel})`}
disabled={disabled}
maxLength={estimateTextLimits.rowName}
onChange={(name) => patch({ name })}
placeholder="Verbraucher oder Fläche"
value={row.name}
/>
</td>
<td>
<NumberInput
ariaLabel={`Anzahl / Fläche (${rowLabel})`}
disabled={disabled}
fieldKey={fieldKey("quantity")}
onCommit={(quantity) => patch({ quantity })}
onValidityChange={onValidityChange}
range={estimateNumberRanges.quantity}
value={row.quantity}
/>
</td>
<td>
<select
aria-label={`Einheit (${rowLabel})`}
className="estimate-cell-select"
disabled={disabled}
onChange={(event) => patch({ unit: event.target.value as EstimateRowUnit })}
value={row.unit}
>
{(Object.keys(estimateRowUnitLabels) as EstimateRowUnit[]).map((unit) => (
<option key={unit} value={unit}>
{estimateRowUnitLabels[unit]}
</option>
))}
</select>
<span className="estimate-print-value">{estimateRowUnitLabels[row.unit]}</span>
</td>
<td>
<select
aria-label={`Anschluss (${rowLabel})`}
className="estimate-cell-select"
disabled={disabled}
onChange={(event) =>
patch({
connection: (event.target.value || null) as EstimateRowConnection | null,
})
}
value={row.connection ?? ""}
>
<option value="">–</option>
{(Object.keys(estimateRowConnectionLabels) as EstimateRowConnection[]).map(
(connection) => (
<option key={connection} value={connection}>
{estimateRowConnectionLabels[connection]}
</option>
)
)}
</select>
<span className="estimate-print-value">
{row.connection ? estimateRowConnectionLabels[row.connection] : ""}
</span>
</td>
<td>
<div className="estimate-unit-input">
<NumberInput
ariaLabel={`Einzelleistung in ${row.unit === "area" ? "W/m²" : "W"} (${rowLabel})`}
disabled={disabled}
fieldKey={fieldKey("powerPerUnitW")}
onCommit={(powerPerUnitW) => patch({ powerPerUnitW })}
onValidityChange={onValidityChange}
range={estimateNumberRanges.powerPerUnitW}
value={row.powerPerUnitW}
/>
<span className="estimate-unit-label">{row.unit === "area" ? "W/m²" : "W"}</span>
</div>
</td>
<td>
<div className="estimate-gzf-input">
<NumberInput
ariaLabel={`Gleichzeitigkeitsfaktor (${rowLabel})`}
disabled={disabled}
fieldKey={fieldKey("simultaneityFactor")}
onCommit={(simultaneityFactor) => patch({ simultaneityFactor })}
onValidityChange={onValidityChange}
range={estimateNumberRanges.simultaneityFactor}
value={row.simultaneityFactor}
/>
<button
aria-label={`Gleichzeitigkeitsfaktor nach AMEV wählen (${rowLabel})`}
className="estimate-amev-button estimate-no-print"
disabled={disabled}
onClick={() => onOpenGzfPicker(group.id, row.id)}
title="Gleichzeitigkeitsfaktor nach AMEV wählen"
type="button"
>
AMEV
</button>
</div>
</td>
<td>
<NumberInput
ariaLabel={`cos φ (${rowLabel})`}
disabled={disabled}
fieldKey={fieldKey("cosPhi")}
onCommit={(cosPhi) => patch({ cosPhi })}
onValidityChange={onValidityChange}
range={estimateNumberRanges.cosPhi}
value={row.cosPhi}
/>
</td>
<PowerCells power={result.av} />
<td className="estimate-center">
<input
aria-label={`Sicherheitsversorgung (${rowLabel})`}
checked={row.safetySupply}
className="form-check-input estimate-no-print"
disabled={disabled}
onChange={(event) => patch({ safetySupply: event.target.checked })}
type="checkbox"
/>
<span className="estimate-print-value">{row.safetySupply ? "ja" : "nein"}</span>
</td>
<PowerCells
className={row.safetySupply ? "" : "is-muted"}
power={result.sv}
/>
<td>
<TextInput
ariaLabel={`Anmerkung (${rowLabel})`}
disabled={disabled}
maxLength={estimateTextLimits.remark}
onChange={(remark) => patch({ remark })}
value={row.remark}
/>
</td>
<td className="estimate-actions-cell estimate-no-print">
<IconButton
disabled={disabled}
label={`Zeile löschen (${rowLabel})`}
onClick={() => onChange((balance) => removeEstimateRow(balance, group.id, row.id))}
variant="danger"
>
<Trash2 aria-hidden="true" size={13} />
</IconButton>
</td>
</tr>
);
}
/**
* Spreadsheet-like editor in the layout of the office template
* "Leistungsbilanz – KG400": one section per cost group with title and
* subtotal row, totals, reserve and maximum current at the end.
*/
export function EstimateBalanceSheet({
balance,
disabled,
onAddGroup,
onChange,
onEditGroup,
onOpenGzfPicker,
onValidityChange,
threePhaseVoltageV,
}: EstimateBalanceSheetProps) {
const result = calculateEstimateBalance(balance, threePhaseVoltageV);
function confirmRemoveGroup(group: EstimateGroupSnapshot) {
const rowText =
group.rows.length === 1 ? "1 Zeile" : `${group.rows.length} Zeilen`;
if (
window.confirm(
`Kostengruppe „${groupLabel(group)}“ mit ${rowText} wirklich löschen?`
)
) {
onChange((current) => removeEstimateGroup(current, group.id));
}
}
return (
<div className="estimate-sheet-wrap">
<datalist id={SUPPLY_LIST_ID}>
{supplyOptions(balance).map((supply) => (
<option key={supply} value={supply} />
))}
</datalist>
<table className="estimate-sheet">
<colgroup>
<col className="estimate-col-kg" />
<col className="estimate-col-supply" />
<col className="estimate-col-name" />
<col className="estimate-col-quantity" />
<col className="estimate-col-unit" />
<col className="estimate-col-connection" />
<col className="estimate-col-power" />
<col className="estimate-col-gzf" />
<col className="estimate-col-cosphi" />
<col className="estimate-col-result" />
<col className="estimate-col-result" />
<col className="estimate-col-sv" />
<col className="estimate-col-result" />
<col className="estimate-col-result" />
<col className="estimate-col-remark" />
<col className="estimate-col-actions" />
</colgroup>
<thead>
<tr>
<th rowSpan={2}>KG</th>
<th rowSpan={2}>
Versorgung
<span className="estimate-th-hint">ELT/MSR …</span>
</th>
<th rowSpan={2}>Verbraucher / Flächen</th>
<th colSpan={2}>
Anzahl / Fläche
<span className="estimate-th-hint">St. / m²</span>
</th>
<th rowSpan={2}>
Anschl.
<span className="estimate-th-hint">Fest/Steckd.</span>
</th>
<th rowSpan={2}>
Einzelleist.
<span className="estimate-th-hint">W bzw. W/m²</span>
</th>
<th rowSpan={2}>GL-Faktor</th>
<th rowSpan={2}>cos φ</th>
<th colSpan={2}>Nötige AV-Leistung</th>
<th colSpan={3}>Sicherheitsversorgung (SV)</th>
<th rowSpan={2}>Anmerkungen</th>
<th aria-label="Aktionen" className="estimate-no-print" rowSpan={2} />
</tr>
<tr>
<th>Wert</th>
<th>Einh.</th>
<th>kW</th>
<th>kVA</th>
<th>ja/nein</th>
<th>kW</th>
<th>kVA</th>
</tr>
</thead>
{balance.groups.map((group, groupIndex) => {
const groupResult = result.groups[groupIndex]!;
const label = groupLabel(group) || "Kostengruppe";
return (
<tbody className="estimate-group" key={group.id}>
<tr className="estimate-group-title-row">
<td className="estimate-cost-group-cell">{group.costGroup}</td>
<td colSpan={COLUMN_COUNT - 2}>
<div className="estimate-group-title">
<span className="estimate-group-name">{group.title}</span>
<label
className={
group.editor?.trim()
? "estimate-group-editor"
: "estimate-group-editor is-empty"
}
>
<span>bearbeitet von</span>
<input
aria-label={`Bearbeitet von (${label})`}
className="estimate-cell-input"
disabled={disabled}
maxLength={estimateTextLimits.groupEditor}
onChange={(event) =>
onChange((current) =>
updateEstimateGroup(current, group.id, {
editor: event.target.value,
})
)
}
placeholder="Gewerk / Name"
type="text"
value={group.editor ?? ""}
/>
<span className="estimate-print-value">{group.editor ?? ""}</span>
</label>
</div>
</td>
<td className="estimate-actions-cell estimate-no-print">
<div className="estimate-group-actions">
<IconButton
disabled={disabled}
label={`Zeile hinzufügen (${label})`}
onClick={() =>
onChange((current) => addEstimateRow(current, group.id).balance)
}
variant="primary"
>
<Plus aria-hidden="true" size={13} />
</IconButton>
<IconButton
disabled={disabled}
label={`Gruppe bearbeiten (${label})`}
onClick={() => onEditGroup(group)}
>
<Pencil aria-hidden="true" size={13} />
</IconButton>
<IconButton
disabled={disabled || groupIndex === 0}
label={`Gruppe nach oben (${label})`}
onClick={() =>
onChange((current) => moveEstimateGroup(current, group.id, -1))
}
>
<ArrowUp aria-hidden="true" size={13} />
</IconButton>
<IconButton
disabled={disabled || groupIndex === balance.groups.length - 1}
label={`Gruppe nach unten (${label})`}
onClick={() =>
onChange((current) => moveEstimateGroup(current, group.id, 1))
}
>
<ArrowDown aria-hidden="true" size={13} />
</IconButton>
<IconButton
disabled={disabled}
label={`Gruppe löschen (${label})`}
onClick={() => confirmRemoveGroup(group)}
variant="danger"
>
<Trash2 aria-hidden="true" size={13} />
</IconButton>
</div>
</td>
</tr>
{group.rows.map((row, rowIndex) => (
<EstimateRowLine
disabled={disabled}
group={group}
key={row.id}
onChange={onChange}
onOpenGzfPicker={onOpenGzfPicker}
onValidityChange={onValidityChange}
row={row}
rowIndex={rowIndex}
/>
))}
{!group.rows.length ? (
<tr className="estimate-empty-row estimate-no-print">
<td />
<td colSpan={COLUMN_COUNT - 1}>
Noch keine Einträge – mit „+“ eine Zeile hinzufügen.
</td>
</tr>
) : null}
<tr className="estimate-subtotal-row">
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
Summe {label}
</td>
<PowerCells power={groupResult.av} />
<td />
<PowerCells power={groupResult.sv} />
<td />
<td className="estimate-no-print" />
</tr>
</tbody>
);
})}
<tfoot>
<tr className="estimate-no-print estimate-add-group-row">
<td colSpan={COLUMN_COUNT}>
<button
className="btn btn-sm btn-outline-primary"
disabled={disabled}
onClick={onAddGroup}
type="button"
>
<Plus aria-hidden="true" className="me-1" size={14} />
Kostengruppe hinzufügen
</button>
</td>
</tr>
<tr className="estimate-total-row">
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
Gesamtsumme
</td>
<PowerCells power={result.total.av} />
<td />
<PowerCells power={result.total.sv} />
<td colSpan={2} />
</tr>
<tr className="estimate-total-row">
<td colSpan={LEADING_COLUMN_COUNT - 1} className="estimate-total-label">
Reserveleistung
</td>
<td>
<div className="estimate-unit-input">
<NumberInput
ariaLabel="Reserve AV in Prozent"
disabled={disabled}
fieldKey="balance:reserveAvPercent"
onCommit={(reserveAvPercent) =>
onChange((current) => ({ ...current, reserveAvPercent }))
}
onValidityChange={onValidityChange}
range={estimateNumberRanges.reservePercent}
value={balance.reserveAvPercent}
/>
<span className="estimate-unit-label">%</span>
</div>
</td>
<PowerCells power={result.reserve.av} />
<td>
<div className="estimate-unit-input">
<NumberInput
ariaLabel="Reserve SV in Prozent"
disabled={disabled}
fieldKey="balance:reserveSvPercent"
onCommit={(reserveSvPercent) =>
onChange((current) => ({ ...current, reserveSvPercent }))
}
onValidityChange={onValidityChange}
range={estimateNumberRanges.reservePercent}
value={balance.reserveSvPercent}
/>
<span className="estimate-unit-label">%</span>
</div>
</td>
<PowerCells power={result.reserve.sv} />
<td colSpan={2} />
</tr>
<tr className="estimate-total-row is-strong">
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
Leistung inkl. Reserve
</td>
<PowerCells power={result.withReserve.av} />
<td />
<PowerCells power={result.withReserve.sv} />
<td colSpan={2} />
</tr>
<tr className="estimate-total-row">
<td colSpan={LEADING_COLUMN_COUNT} className="estimate-total-label">
Maximalstrom
<span className="estimate-total-hint">
{threePhaseVoltageV > 0
? ` (3~, U = ${formatDecimalInput(threePhaseVoltageV)} V)`
: " (Drehstromspannung im Projekt fehlt)"}
</span>
</td>
<td className="estimate-number" colSpan={2}>
{formatAmpere(result.maxCurrentA.av)} A
</td>
<td />
<td className="estimate-number" colSpan={2}>
{formatAmpere(result.maxCurrentA.sv)} A
</td>
<td colSpan={2} />
</tr>
</tfoot>
</table>
</div>
);
}

View file

@ -1,88 +0,0 @@
"use client";
import {
roundEstimateValue,
type EstimateComparison,
} from "../../shared/estimate-balance/estimate-balance";
import { formatDeltaPercent, formatKw, formatSignedKw } from "./estimate-balance-format";
function deltaClass(deltaKw: number) {
if (roundEstimateValue(deltaKw, 2) === 0) {
return "estimate-number";
}
return deltaKw > 0 ? "estimate-number is-over" : "estimate-number is-under";
}
/** Estimated AV power per cost group vs. the detailed circuit-list rows. */
export function EstimateComparisonTable({ comparison }: { comparison: EstimateComparison }) {
return (
<div className="estimate-sheet-wrap">
<table className="estimate-sheet estimate-comparison">
<thead>
<tr>
<th>KG</th>
<th>Titel</th>
<th>geschätzt AV kW</th>
<th>detailliert kW</th>
<th>Δ kW</th>
<th>Δ %</th>
</tr>
</thead>
<tbody>
{comparison.groups.map((group) => (
<tr key={group.groupId}>
<td className="estimate-cost-group-cell">{group.costGroup}</td>
<td>
{group.title}
{group.detailedRowCount ? (
<span className="estimate-th-hint">
{group.detailedRowCount === 1
? "1 Gerätezeile"
: `${group.detailedRowCount} Gerätezeilen`}
</span>
) : null}
</td>
<td className="estimate-number">{formatKw(group.estimatedAv.kw)}</td>
<td className="estimate-number">{formatKw(group.detailed.kw)}</td>
<td className={deltaClass(group.deltaKw)}>{formatSignedKw(group.deltaKw)}</td>
<td className={deltaClass(group.deltaKw)}>
{formatDeltaPercent(group.deltaPercent)}
</td>
</tr>
))}
<tr className="estimate-unassigned-row">
<td />
<td>
ohne Kostengruppe / nicht zugeordnet
{comparison.unassigned.rowCount ? (
<span className="estimate-th-hint">
{comparison.unassigned.rowCount === 1
? "1 Gerätezeile"
: `${comparison.unassigned.rowCount} Gerätezeilen`}
</span>
) : null}
</td>
<td className="estimate-number">–</td>
<td className="estimate-number">{formatKw(comparison.unassigned.kw)}</td>
<td className="estimate-number">–</td>
<td className="estimate-number">–</td>
</tr>
</tbody>
<tfoot>
<tr className="estimate-total-row is-strong">
<td />
<td className="estimate-total-label">Summe</td>
<td className="estimate-number">{formatKw(comparison.total.estimatedAv.kw)}</td>
<td className="estimate-number">{formatKw(comparison.total.detailed.kw)}</td>
<td className={deltaClass(comparison.total.deltaKw)}>
{formatSignedKw(comparison.total.deltaKw)}
</td>
<td className={deltaClass(comparison.total.deltaKw)}>
{formatDeltaPercent(comparison.total.deltaPercent)}
</td>
</tr>
</tfoot>
</table>
</div>
);
}

View file

@ -1,112 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
export type DraftParseResult<T> = { ok: true; value: T } | { ok: false; error: string };
interface DraftInputProps<T> {
ariaLabel: string;
className?: string;
disabled?: boolean;
format: (value: T) => string;
/** Printed text; defaults to the input text. */
printFormat?: (value: T) => string;
inputMode?: "decimal" | "text";
maxLength?: number;
onCommit: (value: T) => void;
/** Reports whether the raw text is currently valid (false while invalid). */
onValidityChange?: (isValid: boolean) => void;
parse: (text: string) => DraftParseResult<T>;
placeholder?: string;
value: T;
}
/**
* Input that keeps the raw text the user typed. Only a valid text is
* committed into the document; an invalid text stays visible with a red
* border and is never saved. A value changed from outside (reload, undo)
* replaces the text unless the text already represents that value.
*/
export function DraftInput<T>({
ariaLabel,
className = "estimate-cell-input",
disabled,
format,
printFormat,
inputMode = "text",
maxLength,
onCommit,
onValidityChange,
parse,
placeholder,
value,
}: DraftInputProps<T>) {
const [text, setText] = useState(() => format(value));
const [error, setError] = useState<string | null>(null);
const [syncedValue, setSyncedValue] = useState(value);
if (!Object.is(syncedValue, value)) {
// Adjusting state while rendering (instead of an effect) avoids a frame
// with stale text after an external change.
setSyncedValue(value);
const parsed = parse(text);
if (!parsed.ok || !Object.is(parsed.value, value)) {
setText(format(value));
setError(null);
}
}
const validityRef = useRef(onValidityChange);
useEffect(() => {
validityRef.current = onValidityChange;
});
const isValid = error === null;
useEffect(() => {
validityRef.current?.(isValid);
}, [isValid]);
// A removed input (deleted row) must not leave an invalid marker behind.
useEffect(() => () => validityRef.current?.(true), []);
function handleChange(nextText: string) {
setText(nextText);
const parsed = parse(nextText);
if (!parsed.ok) {
setError(parsed.error);
return;
}
setError(null);
if (!Object.is(parsed.value, value)) {
setSyncedValue(parsed.value);
onCommit(parsed.value);
}
}
function handleBlur() {
// Show the canonical notation again ("0,80" → "0,8") once editing ends.
if (error === null) {
setText(format(value));
}
}
return (
<>
<input
aria-invalid={error ? true : undefined}
aria-label={ariaLabel}
className={error ? `${className} is-invalid` : className}
disabled={disabled}
inputMode={inputMode}
maxLength={maxLength}
onBlur={handleBlur}
onChange={(event) => handleChange(event.target.value)}
placeholder={placeholder}
title={error ?? undefined}
type="text"
value={text}
/>
<span className="estimate-print-value">
{printFormat ? printFormat(value) : error ? format(value) : text}
</span>
</>
);
}

View file

@ -1,128 +0,0 @@
"use client";
import { type FormEvent, useState } from "react";
import { FormModal } from "../components/form-modal";
import {
estimateDefaultGroups,
type EstimateGroupSnapshot,
} from "../../shared/estimate-balance/estimate-balance";
import { estimateTextLimits } from "./estimate-balance-document";
export interface EstimateGroupFormValues {
costGroup: string;
title: string;
editor: string | null;
}
interface EstimateGroupModalProps {
group: EstimateGroupSnapshot | null;
onClose: () => void;
onSave: (values: EstimateGroupFormValues) => void;
}
/** Creates or edits the KG number, title and "bearbeitet von" of one group. */
export function EstimateGroupModal({ group, onClose, onSave }: EstimateGroupModalProps) {
const [costGroup, setCostGroup] = useState(group?.costGroup ?? "");
const [title, setTitle] = useState(group?.title ?? "");
const [editor, setEditor] = useState(group?.editor ?? "");
function applyTemplate(index: string) {
const template = estimateDefaultGroups[Number(index)];
if (template) {
setCostGroup(template.costGroup);
setTitle(template.title);
}
}
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!title.trim()) {
return;
}
onSave({
costGroup: costGroup.trim(),
title: title.trim(),
editor: editor.trim() || null,
});
}
return (
<FormModal
isSaving={false}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!title.trim()}
submitLabel={group ? "Übernehmen" : "Kostengruppe hinzufügen"}
title={group ? "Kostengruppe bearbeiten" : "Kostengruppe hinzufügen"}
>
<div className="row g-3">
{!group ? (
<div className="col-12">
<label className="form-label" htmlFor="estimate-group-template">
Aus Vorlage KG 400 übernehmen
</label>
<select
className="form-select"
defaultValue=""
id="estimate-group-template"
onChange={(event) => applyTemplate(event.target.value)}
>
<option value="">– Vorlage wählen –</option>
{estimateDefaultGroups.map((template, index) => (
<option key={`${template.costGroup}-${template.title}`} value={index}>
{[template.costGroup, template.title].filter(Boolean).join(" ")}
</option>
))}
</select>
</div>
) : null}
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="estimate-group-cost-group">
KG (DIN 276)
</label>
<input
className="form-control"
id="estimate-group-cost-group"
maxLength={estimateTextLimits.costGroup}
onChange={(event) => setCostGroup(event.target.value)}
placeholder="z. B. 440"
value={costGroup}
/>
</div>
<div className="col-12 col-md-9">
<label className="form-label" htmlFor="estimate-group-title">
Titel
</label>
<input
className="form-control"
id="estimate-group-title"
maxLength={estimateTextLimits.groupTitle}
onChange={(event) => setTitle(event.target.value)}
placeholder="z. B. Starkstromanlagen"
required
value={title}
/>
</div>
<div className="col-12">
<label className="form-label" htmlFor="estimate-group-editor">
Bearbeitet von
</label>
<input
className="form-control"
id="estimate-group-editor"
maxLength={estimateTextLimits.groupEditor}
onChange={(event) => setEditor(event.target.value)}
placeholder="Gewerk oder Name (optional)"
value={editor}
/>
</div>
<div className="col-12">
<p className="text-secondary small mb-0">
Ohne KG-Nummer (z. B. „Allgemein“) wird die Gruppe im Vergleich mit der
detaillierten Bilanz keiner Kostengruppe zugeordnet.
</p>
</div>
</div>
</FormModal>
);
}

View file

@ -1,104 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { documentLogoUrl, getDocumentBranding } from "../utils/api";
import type { EstimateBalanceDto, OfficeProfileDto, ProjectDto } from "../types";
import { formatIsoDateGerman } from "./estimate-balance-format";
interface EstimatePrintHeaderProps {
project: ProjectDto;
balance: EstimateBalanceDto;
}
function cssString(value: string) {
return JSON.stringify(value.replace(/[<>\r\n]/g, " "));
}
/**
* Print-only title block and page footer of the rough power balance in the
* kbp corporate design of the print view (Seitenansicht): logo, blue title,
* meta data, "Seite x von y". Hidden on screen.
*/
export function EstimatePrintHeader({ project, balance }: EstimatePrintHeaderProps) {
const [office, setOffice] = useState<OfficeProfileDto | null>(null);
const [hasLogo, setHasLogo] = useState(false);
const [logoVersion, setLogoVersion] = useState("");
useEffect(() => {
getDocumentBranding()
.then((branding) => {
setOffice(branding.office);
setHasLogo(Boolean(branding.logo));
setLogoVersion(branding.logo ? branding.logo.updatedAtIso : "");
})
.catch(() => {
setOffice(null);
setHasLogo(false);
});
}, []);
const stand = [balance.name, balance.workStage].filter(Boolean).join(" · ");
const date = balance.dateIso ? formatIsoDateGerman(balance.dateIso) : null;
const footer = `
@page estimate-balance {
size: A4 landscape;
margin: 10mm 10mm 13mm;
@bottom-left { content: ${cssString(`${project.name} · Grobe Leistungsbilanz`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
@bottom-center { content: ${cssString(`Stand ${stand}${date ? ` · ${date}` : ""}`)}; font: 7.5pt Arial, sans-serif; color: #6b7280; }
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 7.5pt Arial, sans-serif; color: #6b7280; }
}`;
return (
<>
<style>{footer}</style>
<header className="estimate-print-titleblock" aria-hidden="true">
<div className="estimate-print-logo">
{hasLogo ? (
<img alt="" src={`${documentLogoUrl}?v=${encodeURIComponent(logoVersion)}`} />
) : (
<span>{office?.officeName ?? ""}</span>
)}
</div>
<div className="estimate-print-title">
<div className="estimate-print-kicker">Grobe Leistungsbilanz · KG 400</div>
<div className="estimate-print-heading">
<span className="estimate-print-stand">{balance.name}</span>
<span className="estimate-print-project">{project.name}</span>
</div>
</div>
<dl className="estimate-print-meta">
<div>
<dt>Projekt-Nr.</dt>
<dd>{project.internalProjectNumber ?? "–"}</dd>
</div>
<div>
<dt>Bauherr</dt>
<dd>{project.buildingOwner ?? "–"}</dd>
</div>
<div>
<dt>Anschrift</dt>
<dd>{project.siteAddress?.split("\n").join(", ") ?? "–"}</dd>
</div>
</dl>
<dl className="estimate-print-meta is-tinted">
<div>
<dt>Leistungsphase</dt>
<dd>{balance.workStage ?? "–"}</dd>
</div>
<div>
<dt>Datum</dt>
<dd>{date ?? "–"}</dd>
</div>
<div>
<dt>Bearbeitung</dt>
<dd>{project.projectEditor ?? "–"}</dd>
</div>
<div>
<dt>Büro</dt>
<dd>{office?.officeName ?? "–"}</dd>
</div>
</dl>
</header>
</>
);
}

View file

@ -1,191 +0,0 @@
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device";
import type {
CircuitTreeCircuitDto,
CircuitTreeProtectionDeviceDto,
CircuitTreeResponseDto,
DistributionBoardDto,
} from "../types";
/**
* Pure read model for the print view (cover page, board overview and board
* plans). It only derives display values from the existing circuit-tree
* responses and never changes project data.
*/
export interface PrintBoardSummary {
board: DistributionBoardDto;
circuitListId: string;
tree: CircuitTreeResponseDto;
floorName: string | null;
circuitCount: number;
/** Σ quantity × power per unit, without any simultaneity factor. */
installedPowerKw: number;
/** Σ row totals, i.e. with the device-row simultaneity factors. */
devicePowerKw: number;
simultaneityFactor: number;
/** Device power with the board simultaneity factor applied. */
demandPowerKw: number;
/**
* Symmetrical three-phase design current from the apparent power
* S = Σ(row total / cos φ) × board GZF. A missing cos φ counts as 1.
*/
designCurrentA: number;
}
export function summarizePrintBoard(input: {
board: DistributionBoardDto;
circuitListId: string;
tree: CircuitTreeResponseDto;
floorName: string | null;
}): PrintBoardSummary {
const { tree } = input;
let circuitCount = 0;
let installedPowerKw = 0;
let apparentPowerKva = 0;
for (const section of tree.sections) {
for (const circuit of section.circuits) {
circuitCount += 1;
for (const row of circuit.deviceRows) {
installedPowerKw += row.quantity * row.powerPerUnit;
apparentPowerKva += row.rowTotalPower / (row.cosPhi || 1);
}
}
}
const simultaneityFactor = tree.distributionBoardSimultaneityFactor;
return {
...input,
circuitCount,
installedPowerKw,
devicePowerKw: tree.distributionBoardTotalPower,
simultaneityFactor,
demandPowerKw: tree.distributionBoardTotalPowerWithSimultaneityFactor,
designCurrentA:
(apparentPowerKva * simultaneityFactor * 1000) /
(Math.sqrt(3) * tree.threePhaseVoltageV),
};
}
export interface PrintBoardHierarchyEntry {
summary: PrintBoardSummary;
depth: number;
parentBoardId: string | null;
/** Own demand plus the plain sum of all resolved downstream boards. */
cumulatedDemandPowerKw: number;
}
export function assertPrintRevisionConsistency(
revision: number,
summaries: readonly PrintBoardSummary[],
finalRevision: number
) {
if (finalRevision !== revision || summaries.some(({ tree }) => tree.currentRevision !== revision)) {
throw new Error("Das Projekt wurde während des Ladens geändert. Bitte die Seitenansicht neu laden.");
}
}
/**
* PRELIMINARY: `fedFrom` is free text until the logical feed link is
* decided. A board is shown below another board only when its `fedFrom`
* text equals that board's number or name (case-insensitive). The
* cumulated value is a plain sum without an upstream simultaneity factor.
* Unresolved texts and cycles keep a board at the top level.
*/
export function buildPreliminaryBoardHierarchy(
summaries: PrintBoardSummary[]
): PrintBoardHierarchyEntry[] {
const normalize = (value: string | null) =>
value?.trim().toLocaleLowerCase("de") || null;
const parentById = new Map<string, string | null>();
for (const summary of summaries) {
const fedFrom = normalize(summary.board.fedFrom);
const candidates = fedFrom
? summaries.filter(
(candidate) =>
candidate !== summary &&
(normalize(candidate.board.boardNumber) === fedFrom ||
normalize(candidate.board.name) === fedFrom)
)
: [];
const parent = candidates.length === 1 ? candidates[0] : undefined;
parentById.set(summary.board.id, parent?.board.id ?? null);
}
for (const summary of summaries) {
const visited = new Set<string>([summary.board.id]);
let current = parentById.get(summary.board.id) ?? null;
while (current !== null) {
if (visited.has(current)) {
parentById.set(summary.board.id, null);
break;
}
visited.add(current);
current = parentById.get(current) ?? null;
}
}
const childrenOf = (boardId: string | null) =>
summaries.filter(
(summary) => parentById.get(summary.board.id) === boardId
);
const cumulated = (summary: PrintBoardSummary): number =>
summary.demandPowerKw +
childrenOf(summary.board.id).reduce(
(sum, child) => sum + cumulated(child),
0
);
const entries: PrintBoardHierarchyEntry[] = [];
const walk = (summary: PrintBoardSummary, depth: number) => {
entries.push({
summary,
depth,
parentBoardId: parentById.get(summary.board.id) ?? null,
cumulatedDemandPowerKw: cumulated(summary),
});
childrenOf(summary.board.id).forEach((child) => walk(child, depth + 1));
};
childrenOf(null).forEach((root) => walk(root, 0));
return entries;
}
const kilowattFormat = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const unitPowerFormat = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
});
const currentFormat = new Intl.NumberFormat("de-DE", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
export const formatKilowatt = (value: number) => kilowattFormat.format(value);
export const formatUnitPower = (value: number) => unitPowerFormat.format(value);
export const formatFactor = (value: number) => kilowattFormat.format(value);
export const formatCurrent = (value: number) => currentFormat.format(value);
export function formatProtectionDevice(
protection: CircuitTreeProtectionDeviceDto | undefined
): string | null {
if (!protection) {
return null;
}
const parts = [
protectionDeviceTypeLabels[protection.type],
protection.tripCharacteristic,
`${protection.ratedCurrentA} A`,
protection.fuseUtilizationCategory,
].filter(Boolean);
let text = parts.join(" ");
if (protection.ratedResidualCurrentMa !== undefined) {
text += `, ${protection.ratedResidualCurrentMa} mA`;
if (protection.rcdType) {
text += ` Typ ${protection.rcdType}`;
}
}
return text;
}
export function formatCircuitCable(circuit: CircuitTreeCircuitDto): string | null {
return [circuit.cableType, circuit.cableCrossSection].filter(Boolean).join(" · ") || null;
}

View file

@ -1,481 +0,0 @@
/* Print view (Seitenansicht) in the kbp corporate design. Colors are
derived from the kbp logo and presentation material; change them only
here.
All selectors are scoped to .print-view. The @page rules (format, margins,
page numbers) are injected by the print page because they need project
data. */
.print-view {
--pd-blue: #476088;
--pd-lilac: #9198c4;
--pd-line: #d6dae5;
--pd-tint: #f5f6fa;
--pd-text: #2b2b2b;
--pd-muted: #6b7280;
--pd-accent: #c9782a;
--pd-font: Arial, "Helvetica Neue", Helvetica, sans-serif;
--pd-landscape-width: 297mm;
--pd-landscape-height: 210mm;
min-height: 100vh;
background: #e7e9ef;
color: var(--pd-text);
font-family: var(--pd-font);
color-scheme: light;
}
.print-view.pd-a3 {
--pd-landscape-width: 420mm;
--pd-landscape-height: 297mm;
}
.pd-toolbar {
position: sticky;
top: 0;
z-index: 5;
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid var(--pd-line);
font-size: 14px;
}
.pd-toolbar-title {
margin-right: auto;
color: var(--pd-blue);
font-weight: bold;
}
.pd-toolbar .pd-toolbar-message {
color: var(--pd-muted);
font-size: 13px;
}
.pd-toolbar .pd-toolbar-error {
color: #b04035;
font-size: 13px;
}
.pd-pages {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
padding: 24px 16px 48px;
overflow-x: auto;
}
.pd-sheet {
position: relative;
flex: none;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
font-size: 9pt;
line-height: 1.3;
}
.pd-portrait {
width: 210mm;
min-height: 297mm;
padding: 18mm 18mm 22mm;
page: lb-portrait;
}
.pd-landscape {
width: var(--pd-landscape-width);
min-height: var(--pd-landscape-height);
padding: 12mm 12mm 18mm;
page: lb-landscape;
}
.pd-a3 .pd-landscape {
font-size: 10pt;
}
.print-view table {
border-collapse: collapse;
width: 100%;
}
.print-view caption {
caption-side: top;
padding: 0 0 1.5mm;
color: var(--pd-blue);
font-size: 10pt;
font-weight: bold;
letter-spacing: 0.04em;
text-align: left;
text-transform: uppercase;
}
.print-view sub {
font-size: 0.75em;
}
.pd-r {
text-align: right !important;
}
.pd-placeholder {
color: var(--pd-accent);
font-style: italic;
}
.pd-open {
color: var(--pd-accent);
font-weight: bold;
}
.pd-multiline {
white-space: pre-line;
}
.pd-logo {
display: block;
max-width: 100%;
object-fit: contain;
}
.pd-logo-missing {
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed var(--pd-line);
color: var(--pd-muted);
font-size: 8pt;
}
/* Cover */
.pd-cover-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 2px solid var(--pd-blue);
padding-bottom: 6mm;
}
.pd-cover-kicker {
margin-top: 3mm;
color: var(--pd-muted);
font-size: 10pt;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pd-cover-logo {
height: 15mm;
width: auto;
max-width: 60mm;
}
span.pd-cover-logo {
width: 40mm;
}
.pd-cover-title {
margin: 14mm 0 10mm;
}
.pd-cover-title h1 {
margin: 0;
color: var(--pd-blue);
font-size: 30pt;
font-weight: bold;
}
.pd-cover-title p {
margin: 2mm 0 0;
color: var(--pd-muted);
font-size: 13pt;
}
.pd-kv {
margin-bottom: 7mm;
}
.pd-kv th,
.pd-kv td {
border: 1px solid var(--pd-line);
padding: 2mm 3mm;
font-size: 9.5pt;
text-align: left;
vertical-align: top;
}
.pd-kv th {
width: 42mm;
background: var(--pd-tint);
color: var(--pd-muted);
font-weight: normal;
}
.pd-cover-cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6mm;
}
.pd-cover-cols .pd-kv th {
width: 30mm;
}
.pd-block {
margin-bottom: 7mm;
}
.pd-col-index {
width: 14mm;
}
/* Generic grid tables */
.pd-grid th,
.pd-grid td {
border: 1px solid var(--pd-line);
padding: 1.3mm 2mm;
text-align: left;
vertical-align: top;
}
.pd-grid > thead > tr > th:not(.pd-tb-cell) {
background: var(--pd-blue);
border-color: var(--pd-blue);
color: #fff;
font-weight: bold;
vertical-align: bottom;
}
/* Footer (screen only; printed pages use @page margin boxes) */
.pd-sheet-footer {
position: absolute;
left: 12mm;
right: 12mm;
bottom: 7mm;
display: flex;
justify-content: space-between;
border-top: 1px solid var(--pd-line);
padding-top: 2mm;
color: var(--pd-muted);
font-size: 7.5pt;
}
.pd-portrait .pd-sheet-footer {
left: 18mm;
right: 18mm;
bottom: 10mm;
}
/* Title block */
.pd-grid th.pd-tb-cell {
border: none;
padding: 0 0 4mm;
background: none;
font-weight: normal;
}
.pd-titleblock {
display: grid;
grid-template-columns: 28mm 1fr auto auto;
border: 1.5px solid var(--pd-blue);
margin-bottom: 4mm;
background: #fff;
font-size: 9pt;
}
.pd-grid .pd-titleblock {
margin-bottom: 0;
}
.pd-titleblock > * {
padding: 2.5mm 3.5mm;
border-left: 1px solid var(--pd-line);
}
.pd-titleblock > :first-child {
border-left: none;
}
.pd-tb-logo {
display: flex;
align-items: center;
justify-content: center;
height: 17mm;
}
.pd-tb-logo-image {
max-height: 10mm;
}
span.pd-tb-logo-image {
width: 100%;
height: 10mm;
}
.pd-tb-kicker {
color: var(--pd-muted);
font-size: 7.5pt;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.pd-tb-title {
display: flex;
align-items: baseline;
gap: 4mm;
margin-top: 1mm;
}
.pd-tb-number {
color: var(--pd-blue);
font-size: 18pt;
font-weight: bold;
}
.pd-tb-name {
font-size: 14pt;
font-weight: bold;
}
.pd-tb-grid {
display: grid;
grid-template-columns: auto auto;
gap: 0.8mm 4mm;
align-content: center;
margin: 0;
font-size: 8pt;
}
.pd-tb-grid div {
display: contents;
}
.pd-tb-grid dt {
color: var(--pd-muted);
font-weight: normal;
}
.pd-tb-grid dd {
margin: 0;
font-weight: bold;
}
.pd-tb-meta {
background: var(--pd-tint);
}
/* Board plan table */
.pd-plan {
font-size: 7.6pt;
line-height: 1.2;
}
.pd-a3 .pd-plan {
font-size: 9pt;
}
.pd-plan th,
.pd-plan td {
padding: 0.75mm 1.6mm;
}
.pd-plan td.pd-bmk {
color: var(--pd-blue);
font-weight: bold;
white-space: nowrap;
}
.pd-plan tr.pd-group td {
background: var(--pd-tint);
border-top: 1.5px solid var(--pd-lilac);
font-weight: bold;
}
.pd-plan tr.pd-component td,
.pd-plan tr.pd-component td.pd-bmk,
.pd-plan tr.pd-device td {
color: var(--pd-muted);
}
.pd-plan tr.pd-device td:nth-child(2) {
padding-left: 6mm;
}
.pd-plan tr.pd-reserve td {
color: var(--pd-muted);
font-style: italic;
}
.pd-plan tr.pd-subtotal td {
border-bottom: 1.5px solid var(--pd-line);
font-weight: bold;
}
.pd-plan .pd-sub {
color: var(--pd-muted);
font-size: 0.9em;
}
.pd-plan tfoot td {
background: var(--pd-tint);
font-weight: bold;
}
.pd-plan tfoot tr.pd-grand td {
background: var(--pd-blue);
border-color: var(--pd-blue);
color: #fff;
}
.pd-plan tr {
break-inside: avoid;
}
/* Overview */
.pd-overview {
display: grid;
grid-template-columns: 62mm 1fr;
gap: 6mm;
}
.pd-a3 .pd-overview {
grid-template-columns: 90mm 1fr;
}
.pd-structure h2 {
margin: 0 0 3mm;
color: var(--pd-blue);
font-size: 10pt;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.pd-tree,
.pd-tree ul {
list-style: none;
margin: 0;
padding: 0;
}
.pd-tree ul {
margin-left: 5mm;
padding-left: 4mm;
border-left: 1.5px solid var(--pd-lilac);
}
.pd-tree li {
position: relative;
margin: 2.5mm 0;
}
.pd-tree ul > li::before {
content: "";
position: absolute;
left: -4mm;
top: 5mm;
width: 4mm;
border-top: 1.5px solid var(--pd-lilac);
}
.pd-node {
display: grid;
gap: 0.3mm;
padding: 1.5mm 2.5mm;
border: 1px solid var(--pd-line);
border-left: 3px solid var(--pd-blue);
background: #fff;
}
.pd-node.pd-feed {
border-left-color: var(--pd-accent);
}
.pd-node span {
color: var(--pd-muted);
font-size: 8pt;
}
.pd-node em {
color: var(--pd-blue);
font-size: 8.5pt;
font-style: normal;
font-weight: bold;
}
.pd-note {
margin: 4mm 0 1mm;
padding: 2.5mm 3mm;
border-left: 3px solid var(--pd-accent);
background: #fbf4ec;
font-size: 8pt;
}
.pd-legend {
margin: 1mm 0 0;
color: var(--pd-muted);
font-size: 7.5pt;
}
@media print {
html,
body,
.print-view {
background: #fff !important;
}
/* The dark theme sets color-scheme: dark on the root; Chrome then paints
the page margins dark. Printing always uses the light scheme. */
html:has(.print-view) {
color-scheme: light !important;
}
.pd-toolbar,
.pd-sheet-footer {
display: none !important;
}
.pd-pages {
display: block;
padding: 0;
overflow: visible;
}
.pd-sheet {
width: auto;
min-height: 0;
padding: 0;
box-shadow: none;
break-after: page;
}
.pd-sheet:last-child {
break-after: auto;
}
.print-view * {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

View file

@ -1,625 +0,0 @@
"use client";
import type { ReactNode } from "react";
import type {
CircuitTreeCircuitDto,
CircuitTreeComponentDto,
CircuitTreeDeviceRowDto,
OfficeProfileDto,
ProjectDto,
ProjectSnapshotMetadataDto,
} from "../types";
import {
formatCircuitCable,
formatCurrent,
formatFactor,
formatKilowatt,
formatProtectionDevice,
formatUnitPower,
type PrintBoardHierarchyEntry,
} from "./print-document-model";
export interface PrintDocumentProps {
project: ProjectDto;
office: OfficeProfileDto;
entries: PrintBoardHierarchyEntry[];
namedSnapshots: ProjectSnapshotMetadataDto[];
logoSrc: string | null;
printedAt: Date;
}
/** Shows a value or the orange "[ergänzen]" marker for missing data. */
function Value({ value, multiline = false }: { value: string | null; multiline?: boolean }) {
if (!value) {
return <Placeholder />;
}
return multiline ? <span className="pd-multiline">{value}</span> : <>{value}</>;
}
const dateFormat = new Intl.DateTimeFormat("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
function Placeholder({ label = "ergänzen" }: { label?: string }) {
return <span className="pd-placeholder">[{label}]</span>;
}
function Open() {
return <span className="pd-open">offen</span>;
}
function Logo({ src, className }: { src: string | null; className: string }) {
return src ? (
// eslint-disable-next-line @next/next/no-img-element
<img alt="Logo" className={`pd-logo ${className}`} src={src} />
) : (
<span className={`pd-logo-missing ${className}`}>Logo</span>
);
}
function SheetFooter({ project, stand }: { project: ProjectDto; stand: string }) {
return (
<div className="pd-sheet-footer" aria-hidden="true">
<span>{project.name} · Leistungsbilanz</span>
<span>
Stand {stand} · Datenstand Rev. {project.currentRevision}
</span>
</div>
);
}
function TitleBlock({
kicker,
number,
name,
rows,
project,
stand,
logoSrc,
}: {
kicker: string;
number?: string | null;
name: string;
rows: Array<[ReactNode, ReactNode]>;
project: ProjectDto;
stand: string;
logoSrc: string | null;
}) {
return (
<div className="pd-titleblock">
<div className="pd-tb-logo">
<Logo className="pd-tb-logo-image" src={logoSrc} />
</div>
<div className="pd-tb-main">
<div className="pd-tb-kicker">{kicker}</div>
<div className="pd-tb-title">
{number ? <span className="pd-tb-number">{number}</span> : null}
<span className="pd-tb-name">{name}</span>
</div>
</div>
<dl className="pd-tb-grid">
{rows.map(([label, value], index) => (
<div key={index}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
<dl className="pd-tb-grid pd-tb-meta">
<div>
<dt>Projekt</dt>
<dd>{project.name}</dd>
</div>
<div>
<dt>Projekt-Nr.</dt>
<dd>{project.internalProjectNumber ?? "–"}</dd>
</div>
<div>
<dt>Stand</dt>
<dd>{stand}</dd>
</div>
<div>
<dt>Datenstand</dt>
<dd>Rev. {project.currentRevision}</dd>
</div>
</dl>
</div>
);
}
function boardLabel(entry: PrintBoardHierarchyEntry) {
const { board } = entry.summary;
return board.boardNumber ? `${board.boardNumber} – ${board.name}` : board.name;
}
function CoverSheet({
project,
office,
entries,
namedSnapshots,
logoSrc,
stand,
}: Omit<PrintDocumentProps, "printedAt"> & { stand: string }) {
return (
<section className="pd-sheet pd-portrait pd-cover">
<div className="pd-cover-head">
<div className="pd-cover-kicker">Projektdokumentation Elektrotechnik</div>
<Logo className="pd-cover-logo" src={logoSrc} />
</div>
<div className="pd-cover-title">
<h1>Leistungsbilanz</h1>
<p>Verteilerübersicht und Verteilerpläne</p>
</div>
<table className="pd-kv">
<caption>Projekt</caption>
<tbody>
<tr><th>Bauvorhaben</th><td>{project.name}</td></tr>
<tr><th>Anschrift</th><td><Value multiline value={project.siteAddress} /></td></tr>
<tr><th>Bauherr</th><td><Value value={project.buildingOwner} /></td></tr>
<tr><th>Projektnummer intern</th><td><Value value={project.internalProjectNumber} /></td></tr>
<tr><th>Projektnummer extern</th><td><Value value={project.externalProjectNumber} /></td></tr>
<tr><th>Leistungsphase</th><td><Value value={project.workStage} /></td></tr>
<tr><th>Gewerk</th><td>KG 440 – Starkstromanlagen</td></tr>
<tr><th>Beschreibung</th><td>{project.description ?? "–"}</td></tr>
</tbody>
</table>
<div className="pd-cover-cols">
<table className="pd-kv">
<caption>Planverfasser</caption>
<tbody>
<tr><th>Büro</th><td><Value value={office.officeName} /></td></tr>
<tr><th>Anschrift</th><td><Value multiline value={office.officeAddress} /></td></tr>
<tr><th>Projektleitung</th><td><Value value={project.projectLead} /></td></tr>
<tr><th>Bearbeitung</th><td><Value value={project.projectEditor} /></td></tr>
</tbody>
</table>
<table className="pd-kv">
<caption>Dokument</caption>
<tbody>
<tr><th>Dokumentart</th><td>Leistungsbilanz</td></tr>
<tr><th>Stand</th><td>{stand}</td></tr>
<tr><th>Datenstand</th><td>Revision {project.currentRevision}</td></tr>
<tr><th>Verteiler</th><td>{entries.length}</td></tr>
</tbody>
</table>
</div>
<table className="pd-grid pd-block">
<caption>Planstände</caption>
<thead>
<tr><th className="pd-col-index">Index</th><th>Datum</th><th>Änderung</th><th>Bearbeitung</th></tr>
</thead>
<tbody>
{namedSnapshots.length ? (
namedSnapshots.map((snapshot, index) => (
<tr key={snapshot.id}>
<td>{String.fromCharCode(97 + (index % 26))}</td>
<td>{dateFormat.format(new Date(snapshot.createdAtIso))}</td>
<td>{snapshot.name}</td>
{/* Snapshots do not record an editor yet; the project's
current editor is shown for every entry. */}
<td><Value value={project.projectEditor} /></td>
</tr>
))
) : (
<tr><td colSpan={4}>–</td></tr>
)}
</tbody>
</table>
<table className="pd-grid pd-block">
<caption>Inhalt</caption>
<tbody>
<tr><td className="pd-col-index">1</td><td>Verteilerübersicht und Leistungssummen</td></tr>
{entries.map((entry, index) => (
<tr key={entry.summary.board.id}>
<td className="pd-col-index">2.{index + 1}</td>
<td>Verteilerplan {boardLabel(entry)}</td>
</tr>
))}
</tbody>
</table>
<SheetFooter project={project} stand={stand} />
</section>
);
}
function StructureNode({
entry,
entries,
}: {
entry: PrintBoardHierarchyEntry;
entries: PrintBoardHierarchyEntry[];
}) {
const children = entries.filter(
(candidate) => candidate.parentBoardId === entry.summary.board.id
);
const { board } = entry.summary;
return (
<li>
<div className="pd-node">
<strong>{board.boardNumber ?? board.name}</strong>
{board.boardNumber ? <span>{board.name}</span> : null}
<em>{formatKilowatt(entry.summary.demandPowerKw)} kW</em>
</div>
{children.length ? (
<ul>
{children.map((child) => (
<StructureNode entries={entries} entry={child} key={child.summary.board.id} />
))}
</ul>
) : null}
</li>
);
}
function OverviewSheet({
project,
entries,
logoSrc,
stand,
}: {
project: ProjectDto;
entries: PrintBoardHierarchyEntry[];
logoSrc: string | null;
stand: string;
}) {
const roots = entries.filter((entry) => entry.parentBoardId === null);
// Top-level boards whose "fed from" text names no other board are grouped
// below that text as an external feed (e.g. "HAK / Zähleranlage").
const feedGroups = new Map<string, PrintBoardHierarchyEntry[]>();
for (const root of roots) {
const feed = root.summary.board.fedFrom ?? "";
feedGroups.set(feed, [...(feedGroups.get(feed) ?? []), root]);
}
const sum =(pick: (entry: PrintBoardHierarchyEntry) => number) =>
entries.reduce((total, entry) => total + pick(entry), 0);
return (
<section className="pd-sheet pd-landscape">
<TitleBlock
kicker="Übersicht"
logoSrc={logoSrc}
name="Verteilerübersicht und Leistungssummen"
project={project}
rows={[
["Verteiler", String(entries.length)],
["Netz", `${project.threePhaseVoltageV} / ${project.singlePhaseVoltageV} V`],
]}
stand={stand}
/>
<div className="pd-overview">
<div className="pd-structure">
<h2>Versorgungsstruktur</h2>
<ul className="pd-tree">
{[...feedGroups].map(([feed, feedRoots]) => {
const nodes = feedRoots.map((root) => (
<StructureNode entries={entries} entry={root} key={root.summary.board.id} />
));
return feed ? (
<li key={`feed:${feed}`}>
<div className="pd-node pd-feed">
<strong>Einspeisung</strong>
<span>{feed}</span>
</div>
<ul>{nodes}</ul>
</li>
) : (
nodes
);
})}
</ul>
</div>
<div>
<table className="pd-grid pd-plan">
<thead>
<tr>
<th>Nr.</th><th>Bezeichnung</th><th>Örtlichkeit</th><th>Etage</th><th>Netz</th><th>Gespeist von</th>
<th className="pd-r">SK</th>
<th className="pd-r">P<sub>inst</sub><br />[kW]</th>
<th className="pd-r">P<sub>Geräte</sub><br />[kW]</th>
<th className="pd-r">GZF<br />Vert.</th>
<th className="pd-r">P<sub>gl</sub><br />[kW]</th>
<th className="pd-r">I<sub>B</sub><br />[A]</th>
<th className="pd-r">Σ inkl.<br />unterlagert [kW]</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const { board } = entry.summary;
return (
<tr key={board.id}>
<td style={{ paddingLeft: `${1.6 + entry.depth * 3.5}mm` }}>
{entry.depth ? "└ " : ""}
<strong>{board.boardNumber ?? "–"}</strong>
</td>
<td>{board.name}</td>
<td>{board.location ?? "–"}</td>
<td>{entry.summary.floorName ?? "–"}</td>
<td>{board.supplyType ?? "–"}</td>
<td>{board.fedFrom ?? "–"}</td>
<td className="pd-r">{entry.summary.circuitCount}</td>
<td className="pd-r">{formatKilowatt(entry.summary.installedPowerKw)}</td>
<td className="pd-r">{formatKilowatt(entry.summary.devicePowerKw)}</td>
<td className="pd-r">{formatFactor(entry.summary.simultaneityFactor)}</td>
<td className="pd-r"><strong>{formatKilowatt(entry.summary.demandPowerKw)}</strong></td>
<td className="pd-r">{formatCurrent(entry.summary.designCurrentA)}</td>
<td className="pd-r">{formatKilowatt(entry.cumulatedDemandPowerKw)}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td colSpan={7}>Summe aller Verteiler (ohne übergeordneten GZF)</td>
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.installedPowerKw))}</td>
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.devicePowerKw))}</td>
<td />
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.demandPowerKw))}</td>
<td />
<td />
</tr>
</tfoot>
</table>
<p className="pd-note">
<strong>Vorläufig:</strong> „Gespeist von“ ist noch ein Freitextfeld. Struktur und
„Σ inkl. unterlagert“ werden nur über eindeutig passende Verteilernummern bzw.
-bezeichnungen zugeordnet und einfach addiert (kein übergeordneter GZF).
Mehrdeutige Angaben bleiben ohne Zuordnung. Die fachliche
Verknüpfung und Summierung folgt.
</p>
<p className="pd-legend">
P<sub>inst</sub> = Anzahl × Leistung je Stück · P<sub>Geräte</sub> = mit Geräte-GZF ·
P<sub>gl</sub> = mit Verteiler-GZF · I<sub>B</sub> = S / (√3 · {project.threePhaseVoltageV} V),
S aus cos φ je Gerätezeile
</p>
</div>
</div>
<SheetFooter project={project} stand={stand} />
</section>
);
}
const planColumnCount = 11;
function roomLabel(row: CircuitTreeDeviceRowDto) {
return row.roomNumberSnapshot
? `${row.roomNumberSnapshot} ${row.roomNameSnapshot ?? ""}`.trim()
: "–";
}
function ComponentRow({ component }: { component: CircuitTreeComponentDto }) {
return (
<tr className="pd-component">
<td className="pd-bmk">{component.equipmentIdentifier}</td>
<td colSpan={6}>{component.name}</td>
<td>{formatProtectionDevice(component.protectionDevice) ?? "–"}</td>
<td colSpan={3} />
</tr>
);
}
function CircuitRows({ circuit }: { circuit: CircuitTreeCircuitDto }) {
const rows = circuit.deviceRows;
const circuitCells = (
<>
<td>{formatProtectionDevice(circuit.protectionDevice) ?? <Open />}</td>
<td>{formatCircuitCable(circuit) ?? (circuit.isReserve ? "–" : <Open />)}</td>
<td className="pd-r">
{circuit.cableLength ?? (circuit.isReserve ? "–" : <Open />)}
</td>
<td>{circuit.remark ?? ""}</td>
</>
);
if (rows.length === 0) {
return (
<tr className="pd-circuit pd-reserve">
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
<td>{circuit.displayName ?? "Reserve"}</td>
<td>–</td>
<td /><td /><td />
<td className="pd-r">–</td>
{circuitCells}
</tr>
);
}
if (rows.length === 1) {
const row = rows[0];
const title = circuit.displayName ?? row.displayName;
return (
<tr className="pd-circuit">
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
<td>
{title}
{title !== row.displayName ? (
<span className="pd-sub"> · {row.displayName}</span>
) : null}
</td>
<td>{roomLabel(row)}</td>
<td className="pd-r">{row.quantity}</td>
<td className="pd-r">{formatUnitPower(row.powerPerUnit)}</td>
<td className="pd-r">{formatFactor(row.simultaneityFactor)}</td>
<td className="pd-r"><strong>{formatKilowatt(circuit.circuitTotalPower)}</strong></td>
{circuitCells}
</tr>
);
}
return (
<>
<tr className="pd-circuit">
<td className="pd-bmk">{circuit.equipmentIdentifier}</td>
<td>{circuit.displayName ?? ""}</td>
<td /><td /><td /><td />
<td className="pd-r"><strong>{formatKilowatt(circuit.circuitTotalPower)}</strong></td>
{circuitCells}
</tr>
{rows.map((row) => (
<tr className="pd-device" key={row.id}>
<td />
<td>{row.displayName}</td>
<td>{roomLabel(row)}</td>
<td className="pd-r">{row.quantity}</td>
<td className="pd-r">{formatUnitPower(row.powerPerUnit)}</td>
<td className="pd-r">{formatFactor(row.simultaneityFactor)}</td>
<td className="pd-r">{formatKilowatt(row.rowTotalPower)}</td>
<td colSpan={4} />
</tr>
))}
</>
);
}
function BoardPlanSheet({
entry,
project,
logoSrc,
stand,
}: {
entry: PrintBoardHierarchyEntry;
project: ProjectDto;
logoSrc: string | null;
stand: string;
}) {
const { summary } = entry;
const { board, tree } = summary;
return (
<section className="pd-sheet pd-landscape">
<table className="pd-grid pd-plan">
{/* The title block lives in <thead> so it repeats on every printed page. */}
<thead>
<tr>
<th className="pd-tb-cell" colSpan={planColumnCount}>
<TitleBlock
kicker="Verteilerplan"
logoSrc={logoSrc}
name={board.name}
number={board.boardNumber}
project={project}
rows={[
["Örtlichkeit", [board.location, summary.floorName].filter(Boolean).join(" · ") || "–"],
["Netzart", board.supplyType ?? "–"],
["Gespeist von", board.fedFrom ?? "–"],
[
<>P<sub>gl</sub></>,
<>
{formatKilowatt(summary.demandPowerKw)} kW · I<sub>B</sub>{" "}
{formatCurrent(summary.designCurrentA)} A
</>,
],
]}
stand={stand}
/>
</th>
</tr>
<tr className="pd-head">
<th>SK-Nr.</th><th>Bezeichnung</th><th>Raum</th>
<th className="pd-r">Anz.</th>
<th className="pd-r">P/Stk.<br />[kW]</th>
<th className="pd-r">GZF</th>
<th className="pd-r">P<br />[kW]</th>
<th>Schutz</th><th>Kabel</th>
<th className="pd-r">Länge<br />[m]</th>
<th>Bemerkung</th>
</tr>
</thead>
<tbody>
{tree.headerComponents.map((component) => (
<ComponentRow component={component} key={component.id} />
))}
{tree.sections.map((section) => (
<SectionRows key={section.id} section={section} />
))}
{tree.footerComponents.map((component) => (
<ComponentRow component={component} key={component.id} />
))}
</tbody>
<tfoot>
<tr>
<td />
<td colSpan={5}>
Summe Verteiler (P<sub>inst</sub> {formatKilowatt(summary.installedPowerKw)} kW)
</td>
<td className="pd-r">{formatKilowatt(summary.devicePowerKw)}</td>
<td colSpan={4} />
</tr>
<tr>
<td />
<td colSpan={5}>Gleichzeitigkeitsfaktor Verteiler</td>
<td className="pd-r">{formatFactor(summary.simultaneityFactor)}</td>
<td colSpan={4} />
</tr>
<tr className="pd-grand">
<td />
<td colSpan={5}>Gleichzeitige Leistung P<sub>gl</sub></td>
<td className="pd-r">{formatKilowatt(summary.demandPowerKw)}</td>
<td colSpan={4}>I<sub>B</sub> ≈ {formatCurrent(summary.designCurrentA)} A</td>
</tr>
</tfoot>
</table>
<SheetFooter project={project} stand={stand} />
</section>
);
}
function SectionRows({
section,
}: {
section: PrintBoardHierarchyEntry["summary"]["tree"]["sections"][number];
}) {
return (
<>
<tr className="pd-group">
<td className="pd-bmk">{section.prefix}</td>
<td colSpan={planColumnCount - 1}>{section.displayName}</td>
</tr>
{section.components.map((component) => (
<ComponentRow component={component} key={component.id} />
))}
{section.circuits.map((circuit) => (
<CircuitRows circuit={circuit} key={circuit.id} />
))}
<tr className="pd-subtotal">
<td />
<td colSpan={5}>Summe {section.displayName}</td>
<td className="pd-r">{formatKilowatt(section.sectionTotalPower)}</td>
<td colSpan={4} />
</tr>
</>
);
}
export function PrintDocument({
project,
office,
entries,
namedSnapshots,
logoSrc,
printedAt,
}: PrintDocumentProps) {
const stand = dateFormat.format(printedAt);
return (
<div className="pd-pages">
<CoverSheet
entries={entries}
logoSrc={logoSrc}
namedSnapshots={namedSnapshots}
office={office}
project={project}
stand={stand}
/>
<OverviewSheet entries={entries} logoSrc={logoSrc} project={project} stand={stand} />
{entries.map((entry) => (
<BoardPlanSheet
entry={entry}
key={entry.summary.board.id}
logoSrc={logoSrc}
project={project}
stand={stand}
/>
))}
</div>
);
}

View file

@ -93,18 +93,9 @@ export interface ProjectDto {
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
siteAddress: string | null;
workStage: string | null;
projectLead: string | null;
projectEditor: string | null;
currentRevision: number;
}
export interface OfficeProfileDto {
officeName: string | null;
officeAddress: string | null;
}
export interface ProjectHistoryStateDto {
projectId: string;
currentRevision: number;
@ -181,14 +172,7 @@ export interface ProjectCommandResultDto {
history: ProjectHistoryStateDto;
}
export interface DistributionBoardInfoDto {
boardNumber: string | null;
location: string | null;
fedFrom: string | null;
remark: string | null;
}
export interface DistributionBoardDto extends DistributionBoardInfoDto {
export interface DistributionBoardDto {
id: string;
projectId: string;
name: string;
@ -197,13 +181,6 @@ export interface DistributionBoardDto extends DistributionBoardInfoDto {
simultaneityFactor: number;
}
export interface DocumentLogoMetadataDto {
mimeType: string;
fileName: string | null;
sizeBytes: number;
updatedAtIso: string;
}
export interface DistributionBoardCommandResultDto
extends ProjectCommandResultDto {
distributionBoard: DistributionBoardDto;
@ -226,24 +203,11 @@ export interface CircuitListDto {
name: string;
}
export interface BuildingDto {
id: string;
projectId: string;
name: string;
sortOrder: number;
}
export interface ProjectBuildingCommandResultDto
extends ProjectCommandResultDto {
building: BuildingDto;
}
export interface FloorDto {
id: string;
projectId: string;
name: string;
sortOrder: number;
buildingId: string | null;
}
export interface ProjectFloorCommandResultDto
@ -301,14 +265,8 @@ export interface ProjectDeviceCommandResultDto
projectDevice: ProjectDeviceDto;
}
export interface CreateBuildingInput {
name: string;
}
export interface CreateFloorInput {
name: string;
/** Omitted = keep the current assignment on update; null = no building. */
buildingId?: string | null;
}
export interface CreateRoomInput {
@ -535,34 +493,3 @@ 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 };

Some files were not shown because too many files have changed in this diff Show more