Compare commits

...

10 commits

Author SHA1 Message Date
b9462e61c5 Defer Revit work and remove Dynamo scripts
Further Revit exchange will use the CSV export/import of the Linear
plugin instead of Dynamo, as preferred by the internal Revit/CAD
steering committee. Mark the Revit documentation as deferred and remove
the Dynamo diagnostic scripts, which stay reachable through the tag
archive/dynamo-vorueberlegungen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:51:13 +02:00
92da72bf14 Archive the Revit Dynamo integration concept
Keep the earlier Dynamo/API considerations as historical reference only.
Further Revit exchange will use CSV via the Linear plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 10:50:23 +02:00
868f48e7ac Integrate project building structure 2026-09-27 10:45:10 +02:00
4643c8d11a Integrate print view and document branding 2026-09-27 10:32:07 +02:00
Grovy311
b7811085a3 Keep print margins white in dark mode
The dark theme sets color-scheme: dark on the root element; Chrome then
paints the page margins of printed pages dark, even with a white
background. The print view forces the light scheme and a white root
background while printing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 18:04:54 +02:00
Grovy311
acff38afb8 Show a printer icon on the print view button
The "Seitenansicht" link in the project header had text only, while the
other header buttons show an icon plus text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 13:15:54 +02:00
Grovy311
5f4d9bf789 Show the sidebar on every page except the print view
AppShell showed the sidebar only on the project list and overview, so it
disappeared on the building structure page and in the circuit list
editor. It is collapsible, so it now stays on every page. Only the print
view, a stand-alone document opened in its own tab and printed as PDF,
keeps the full width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 13:00:20 +02:00
Grovy311
cd89c4a499 Add buildings above floors and a building structure page
Projects with several buildings could not be structured: floors and rooms
hung directly off the project. Floors can now belong to a building
(migration 0009: table buildings, nullable floors.building_id). Rooms and
distribution boards keep their floor; their building follows from it.
Existing floors stay without a building.

Buildings use the location-structure command store with
project-building.insert/.update/.delete, inverses and persistent Undo/Redo;
deleting a building with assigned floors is rejected. Floor commands carry
buildingId since command version 2. Version 1 floor commands from existing
history predate buildings and are upgraded with buildingId = null before
they are executed and persisted, so forward and inverse keep one version.

Project snapshot schema 7 stores buildings and floor.buildingId; versions
1-6 are upgraded without buildings. Restore and portable transfer include
buildings, and the transfer remaps building ids.

Buildings, floors and rooms move from the project overview to their own
page /projects/:id/struktur with three columns (Gebäude -> Etagen -> Räume)
that filter each other, and a sidebar entry. Floor selects in the board and
room dialogs are grouped by building. Unchanged dialogs close without a
command. Details in docs/building-structure.md; see issue #6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 12:52:34 +02:00
Grovy311
7692699ee2 Add print cover fields and office profile
- Project settings gain siteAddress, workStage, projectLead and
  projectEditor (project.update-settings schema version 3, migration 0009);
  versions 2 and 1 stay executable and keep the current cover values.
  Snapshot schema 6 carries the fields; older versions upgrade with null.
- Global office profile (office name/address) next to the document logo:
  table document_branding_settings, PUT /api/document-branding/office,
  editable from the print view toolbar.
- Cover page reads all fields; empty values show the "[ergänzen]" marker.
  The Planstände editor column shows the project editor.
- Shared logo constants moved to a dependency-free module so the Next.js
  bundler can import them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:39:18 +02:00
Grovy311
4779e4a1a7 Add print view, board info card and document logo upload
- Seitenansicht (/projects/:projectId/print): tabular cover page, board
  overview with supply structure and power sums, one plan per board with a
  repeating title block; PDF via the browser print dialog with named @page
  rules (A4 portrait cover, A4/A3 landscape plans, page x of y).
- Distribution-board info card (board number, location, fed-from, remark)
  in the board edit dialog, persisted through the additively extended
  distribution-board.update command (migration 0008, snapshot schema 6,
  legacy subtree commands normalized).
- Global document logo upload (PNG/JPEG/SVG, max 2 MB) outside project
  revisions and snapshots.
- fed-from stays free text; the overview hierarchy and cumulated sums are
  preliminary until the feed link is modeled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 10:38:59 +02:00
95 changed files with 15950 additions and 1417 deletions

1
.gitignore vendored
View file

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

View file

@ -284,8 +284,16 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
snapshot verifies its checksum and the current-state hash, replaces supported snapshot verifies its checksum and the current-state hash, replaces supported
project data atomically and records a new `restore` revision with a complete project data atomically and records a new `restore` revision with a complete
inverse command. Restore can therefore be undone and redone after a restart. inverse command. Restore can therefore be undone and redone after a restart.
Current snapshot schema version 5 additionally stores the manual quantity share Current snapshot schema version 7 additionally stores the project buildings
of every CircuitDeviceRow. Version 4 snapshots are upgraded with 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
`manualQuantity = quantity`; they already contain the complete confirmed `manualQuantity = quantity`; they already contain the complete confirmed
external-model source, import batches, room mappings and objects. Version 3 external-model source, import batches, room mappings and objects. Version 3
snapshots are upgraded with an empty external-model state; version 2 snapshots snapshots are upgraded with an empty external-model state; version 2 snapshots
@ -299,7 +307,7 @@ project graph.
Project-wide Revit CSV configuration uses Project-wide Revit CSV configuration uses
`external-csv-configuration.update`. Its complete expected/target snapshot is `external-csv-configuration.update`. Its complete expected/target snapshot is
persisted through the shared project-command transaction, so create, update, persisted through the shared project-command transaction, so create, update,
delete, Undo and Redo are revision-safe. Snapshot schema 5, restore and portable delete, Undo and Redo are revision-safe. Snapshot schema 5-7, restore and portable
project duplication preserve the configuration; duplication remaps its internal project duplication preserve the configuration; duplication remaps its internal
id and project link. id and project link.
The project page exposes the Revit CSV modal. It edits the complete The project page exposes the Revit CSV modal. It edits the complete
@ -313,7 +321,7 @@ immutable import batches with original bytes plus the classified cell matrix,
normalized source-room mappings and external objects unique by normalized source-room mappings and external objects unique by
`(sourceId, ifcGuid)`. Source values, planning values and overrides are separate. `(sourceId, ifcGuid)`. Source values, planning values and overrides are separate.
The general external-model repository is read-only; runtime writes belong only 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 to typed command adapters. Snapshot/restore and portable project transfer schema version 5/6
capture the complete external state and remap every internal UUID and link while capture the complete external state and remap every internal UUID and link while
preserving IFCGUIDs, source values, original bytes and the classified matrix. preserving IFCGUIDs, source values, original bytes and the classified matrix.
Import batches additionally store the monotonic project CSV configuration Import batches additionally store the monotonic project CSV configuration
@ -402,6 +410,10 @@ Project settings use the persistent `project.update-settings` command. Project
metadata, the public-building classification, both voltage defaults and the metadata, the public-building classification, both voltage defaults and the
enabled distribution-board supply types change in one revision and Undo/Redo enabled distribution-board supply types change in one revision and Undo/Redo
restores them together; the project PUT route requires `expectedRevision`. 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 The public-building flag is the future sizing input for requiring halogen-free
cables and lines. The system catalog is `AV`, `SV`, `EV`, 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 `USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
@ -456,6 +468,18 @@ the only supported circuit-protection source; no flat compatibility fields remai
Distribution-board floor assignment and a project-enabled supply type use Distribution-board floor assignment and a project-enabled supply type use
`distribution-board.update`; both values are snapshot/export fields and one `distribution-board.update`; both values are snapshot/export fields and one
persistent undo step. 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 Floor and room setup uses persistent insert, update and delete commands with
complete stable snapshots. Their inverses remove or restore only unchanged records and complete stable snapshots. Their inverses remove or restore only unchanged records and
reject floors with assigned rooms/distribution boards or rooms referenced by reject floors with assigned rooms/distribution boards or rooms referenced by
@ -477,8 +501,49 @@ Required operations:
- synchronization changes - 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 ## 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 When explicitly working on Phase 14, use
`docs/spec/revit-csv-integration-requirements.md` as the detailed source of `docs/spec/revit-csv-integration-requirements.md` as the detailed source of
truth together with the current architecture documents. truth together with the current architecture documents.
@ -567,11 +632,15 @@ Users must be able to override sizing suggestions.
## Current Deferred Work ## Current Deferred Work
- Revit/CSV/IFCGUID round-trip, except when Phase 14 is explicitly requested and - Revit/CSV/IFCGUID round-trip (deferred; later via Linear plugin CSV, never
Dynamo), except when Phase 14 is explicitly requested and
`docs/spec/revit-csv-integration-requirements.md` is being followed `docs/spec/revit-csv-integration-requirements.md` is being followed
- full electrical sizing - full electrical sizing
- multi-user/PostgreSQL operation - multi-user/PostgreSQL operation
- supported production deployment - 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. Do not implement these while working on an unrelated phase.

View file

@ -9,7 +9,9 @@ Docker-Server-Stack mit gebautem Anwendungscode und ein separater
Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment Entwicklungsstack bereit. Ein vollständig unterstütztes Produktionsdeployment
und Mehrbenutzerbetrieb fehlen weiterhin. Revit-CSV-Konfiguration, Vorschau und Mehrbenutzerbetrieb fehlen weiterhin. Revit-CSV-Konfiguration, Vorschau
und bestätigter Erstimport sind implementiert; die Editoroberfläche zur und bestätigter Erstimport sind implementiert; die Editoroberfläche zur
Objektzuordnung, Folgeimporte und Rückexport stehen noch aus. 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.
## Unterstützter Arbeitsablauf ## Unterstützter Arbeitsablauf
@ -27,6 +29,8 @@ Objektzuordnung, Folgeimporte und Rückexport stehen noch aus.
- Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen - Änderungen projektweit und auch nach einem Reload rückgängig machen und wiederholen
- benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen - benannte Sicherungspunkte anlegen, wiederherstellen und Revisionen einsehen
- automatische Sicherungspunkte mit begrenzter Aufbewahrung nutzen - 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 - Revit-CSV-Dateien konfigurieren, prüfen und nach expliziter Zuordnung von
Räumen, Verteilungen und Projektgeräten erstmals importieren; dabei entstehen Räumen, Verteilungen und Projektgeräten erstmals importieren; dabei entstehen
noch keine Stromkreise oder Gerätezeilen noch keine Stromkreise oder Gerätezeilen
@ -147,6 +151,7 @@ vor der Baseline werden nicht unterstützt.
- [Aktuelle Architektur](docs/current-architecture.md) - [Aktuelle Architektur](docs/current-architecture.md)
- [Editor-Interaktionen](docs/circuit-list-editor-interactions.md) - [Editor-Interaktionen](docs/circuit-list-editor-interactions.md)
- [API des Stromkreislisten-Editors](docs/circuit-list-editor-api.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) - [Entwicklungs- und Contributor-Workflow](docs/development-workflow.md)
- [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md) - [Bekannte Einschränkungen](docs/circuit-list-editor-known-limitations.md)
- [Roadmap](docs/spec/07-implementation-phases-todo.md) - [Roadmap](docs/spec/07-implementation-phases-todo.md)

View file

@ -12,6 +12,10 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
fachliches Modell und Grid-Aufteilung fachliches Modell und Grid-Aufteilung
- [Editor-Interaktionen](circuit-list-editor-interactions.md) – Tastatur, - [Editor-Interaktionen](circuit-list-editor-interactions.md) – Tastatur,
Drag-and-drop, Sortierung, Filterung und Undo/Redo 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
- [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte - [Editor-API](circuit-list-editor-api.md) – unterstützte Circuit-First-Endpunkte
- [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) – - [Bekannte Einschränkungen](circuit-list-editor-known-limitations.md) –
bewusst noch nicht implementierte oder begrenzte Funktionen bewusst noch nicht implementierte oder begrenzte Funktionen
@ -25,7 +29,8 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md) - [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
- [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md) - [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md)
- [Revit-CSV-Anforderungen](spec/revit-csv-integration-requirements.md) - [Revit-CSV-Anforderungen](spec/revit-csv-integration-requirements.md) –
zurückgestellt; später über den CSV-Export/-Import des Linear-Plugins
- [Phase-14-Audit und Implementierungsplan](spec/revit-csv-phase-14-audit-and-plan.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) - [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md) - [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
@ -48,3 +53,6 @@ Limitierungen und Roadmap.
- [Frühe Codex-Promptvorlagen](archive/initial-codex-prompts.md) – historischer - [Frühe Codex-Promptvorlagen](archive/initial-codex-prompts.md) – historischer
Arbeitsstand, keine aktuelle Implementierungsanweisung 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`

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,81 @@
# 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. Abstimmung mit Julian: Issue #6.
## 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: Das Gebäude kann später aus einem Revit-Parameter kommen und über die
bestehende CSV-Zuordnung gemappt werden (Frage an Julian in Issue #6).
- Sortierung von Gebäuden per Drag-and-drop ist noch nicht umgesetzt; neue
Gebäude werden hinten angehängt.

View file

@ -74,8 +74,9 @@ Protected-board structure uses
persisted inverse of a delete. The dispatcher also supports the documented persisted inverse of a delete. The dispatcher also supports the documented
project-device, project settings, distribution-board and project-location project-device, project settings, distribution-board and project-location
commands. The generic editor commands listed here use schema version `1`. commands. The generic editor commands listed here use schema version `1`.
`project.update-settings` uses version `2` and still accepts supported baseline `project.update-settings` uses version `3` (including the print cover fields)
version `1` commands without changing the newer building classification. and still accepts version `2` and supported baseline version `1` commands
without changing the newer cover fields or building classification.
Pre-baseline formats remain unsupported. Unsupported types or schema versions Pre-baseline formats remain unsupported. Unsupported types or schema versions
are rejected. Insert commands contain the complete entity or circuit block are rejected. Insert commands contain the complete entity or circuit block
with stable ids; delete commands include the expected parent identity. Circuit with stable ids; delete commands include the expected parent identity. Circuit
@ -173,6 +174,9 @@ synchronizes linked circuit rows implicitly.
- `PUT /projects/:projectId` - `PUT /projects/:projectId`
- request: - request:
`{ "expectedRevision": 12, "isPublicBuilding": true, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }` `{ "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 - executes `project.update-settings` as one atomic revision
- response: - response:
`{ "project": { ... }, "revision": { ... }, "history": { ... } }` `{ "project": { ... }, "revision": { ... }, "history": { ... } }`
@ -230,9 +234,13 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
- stale revisions return `409 PROJECT_REVISION_CONFLICT` - stale revisions return `409 PROJECT_REVISION_CONFLICT`
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId` - `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
- body: - body:
`{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }` `{ "floorId": null, "supplyType": "AV", "simultaneityFactor": 1, "expectedRevision": 13 }`
- executes `distribution-board.update`; floor and supply type are restored - optional info-card fields: `boardNumber` (max. 50), `location`,
together by persistent Undo/Redo `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
- `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy` - `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy`
- body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }` - body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }`
- duplicates the complete distribution-board subtree with new owning UUIDs - duplicates the complete distribution-board subtree with new owning UUIDs
@ -249,15 +257,27 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
`distribution-board.delete-subtree`. Persistent Undo/Redo restores or `distribution-board.delete-subtree`. Persistent Undo/Redo restores or
removes the same complete subtree, including protection devices. removes the same complete subtree, including protection devices.
### Project Floors and Rooms ### Project Buildings, 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` - `POST /projects/:projectId/floors`
- body: `{ "name": "EG", "expectedRevision": 13 }` - body: `{ "name": "EG", "buildingId": "building_1", "expectedRevision": 13 }`
- executes `project-floor.insert` with a stable floor id (`buildingId` optional, `null` = no building)
- executes `project-floor.insert` (command version 2) with a stable floor id
- response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }` - response: `{ "floor": { ... }, "revision": { ... }, "history": { ... } }`
- `PUT /projects/:projectId/floors/:floorId` - `PUT /projects/:projectId/floors/:floorId`
- body: `{ "name": "1. OG", "expectedRevision": 14 }` - body: `{ "name": "1. OG", "buildingId": null, "expectedRevision": 14 }`
- executes an exact persistent floor update - executes an exact persistent floor update of name and/or building; a
missing `buildingId` keeps the current building
- `DELETE /projects/:projectId/floors/:floorId` - `DELETE /projects/:projectId/floors/:floorId`
- body: `{ "expectedRevision": 15 }` - body: `{ "expectedRevision": 15 }`
- rejects floors with assigned rooms or distribution boards - rejects floors with assigned rooms or distribution boards
@ -425,3 +445,27 @@ project device never triggers synchronization implicitly.
Parser errors expose stable codes such as `header-not-found`, Parser errors expose stable codes such as `header-not-found`,
`ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed `ambiguous-header`, `invalid-ifc-guid` and `duplicate-ifc-guid`. The confirmed
import uploads and parses the file again and verifies its hash. 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

@ -9,6 +9,8 @@
- Named and automatic logical project snapshots can be listed and restored - Named and automatic logical project snapshots can be listed and restored
through the project page with persistent Undo/Redo. Automatic snapshots are through the project page with persistent Undo/Redo. Automatic snapshots are
created every 25 revisions and only their newest 12 entries are retained. 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. - The Revit/CSV foundation and confirmed initial import are implemented.
Existing-row assignment, move and detach plus creation of an external-only Existing-row assignment, move and detach plus creation of an external-only
row in an existing or newly created circuit have atomic persistent commands. row in an existing or newly created circuit have atomic persistent commands.
@ -27,3 +29,12 @@
- Protection and cable values are planner-owned manual selections. The later - Protection and cable values are planner-owned manual selections. The later
sizing/warning engine does not yet validate them against load, length or sizing/warning engine does not yet validate them against load, length or
voltage-drop rules. 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

@ -241,8 +241,9 @@ und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten Gerätebibliothek und Delete laufen über dieselbe Command-Grenze; ihre Antworten
liefern Gerät und aktualisierten Historienstand an die Projektseite zurück. liefern Gerät und aktualisierten Historienstand an die Projektseite zurück.
`project.update-settings` versioniert Projektname, interne und externe `project.update-settings` versioniert Projektname, interne und externe
Projektnummer, Bauherr, Beschreibung, die Kennzeichnung als öffentliches Projektnummer, Bauherr, Beschreibung, die Deckblattangaben (Anschrift,
Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten Leistungsphase, Projektleitung, Bearbeitung; Schema-Version 3, Migration
`0008`), die Kennzeichnung als öffentliches Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten
Verteiler-Netzarten als eine atomare Änderung. Die Gebäudekennzeichnung ist Verteiler-Netzarten als eine atomare Änderung. Die Gebäudekennzeichnung ist
der spätere Eingang für die Auswahl halogenfreier Kabel und Leitungen. Der der spätere Eingang für die Auswahl halogenfreier Kabel und Leitungen. Der
Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens
@ -254,9 +255,11 @@ Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId`
verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr. Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
Kommando- und Snapshot-Payloads müssen dem aktuellen Baseline-Schema entsprechen. Kommando- und Snapshot-Payloads müssen dem aktuellen Baseline-Schema entsprechen.
Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte Die Projektseite zeigt Verteilungen als kompakte Bestandsübersicht; ihre
Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals versionierten Erstellwege öffnen beschriftete Modals statt dauerhafter
statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare, 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,
fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal. Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt, Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
@ -271,7 +274,8 @@ Die allgemeinen Anwendungskarten verwenden in `src/app/globals.css` einen
gleichmäßigen Rahmen, 16-px-Rundungen und einen Schatten mit eigener gleichmäßigen Rahmen, 16-px-Rundungen und einen Schatten mit eigener
Dark-Mode-Abstimmung. Bootstrap-Karten mit `shadow-sm` nutzen denselben Dark-Mode-Abstimmung. Bootstrap-Karten mit `shadow-sm` nutzen denselben
Schatten; der Stromkreis-Grid bleibt eine eigenständige Komponente. Schatten; der Stromkreis-Grid bleibt eine eigenständige Komponente.
Die Verteilersuche berücksichtigt aktuell Name und Etagenname; die Gerätesuche Die Verteilersuche berücksichtigt Name, Verteilernummer, Örtlichkeit und
Etagenname; die Gerätesuche
Name, Anzeigename, Kategorie einschließlich deutscher Bezeichnung, Anschlussart Name, Anzeigename, Kategorie einschließlich deutscher Bezeichnung, Anschlussart
und Kostengruppe. Der Gerätefilter „Anschluss“ wählt zwischen 1- und 3-phasig. und Kostengruppe. Der Gerätefilter „Anschluss“ wählt zwischen 1- und 3-phasig.
Mehrere Werte innerhalb eines Filters gelten alternativ, verschiedene Filter Mehrere Werte innerhalb eines Filters gelten alternativ, verschiedene Filter
@ -450,7 +454,16 @@ wieder her.
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`. ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder 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
Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und Gerätezeile. Version 4 wird mit `manualQuantity = quantity` hochgestuft und
enthält bereits den vollständigen externen Modellzustand enthält bereits den vollständigen externen Modellzustand
aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die aus Quelle, Batches, Raum-Mappings und Objekten. Es enthält außerdem die
@ -520,7 +533,7 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
`toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird `toLowerCase()` gegen das vollständige Register der Stromkreisliste und wird
von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten von jedem Anlage-/Umbenennungspfad für Stromkreise und Verteilerkomponenten
aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und aufgerufen, auch dort, wo zuvor kein Vorab-Check existierte. Snapshot- und
Transfer-Integration verwenden aktuell Snapshot-Schema 5. Persistente Transfer-Integration verwenden aktuell Snapshot-Schema 6. Persistente
Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten, Insert/Delete/Update-Commands für veränderliche Verteilerkomponenten,
Gruppen einschließlich befüllter Unterbäume sowie vollständige Gruppen einschließlich befüllter Unterbäume sowie vollständige
Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur Gruppensortierung sind integriert. Der Editor zeigt die geschützte Struktur
@ -539,6 +552,10 @@ bei einem späteren Wechsel trotzdem einen eigenen PostgreSQL-Adapter.
## Revit-CSV-Grundlage ## 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 Der reine Adapter unter `src/external-model/csv` validiert die projektweite
Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine Transport- und Mappingkonfiguration und parst UTF-8-CSV verlustarm in eine
klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt- klassifizierte Zellmatrix. Er erkennt Metadaten, Header, Passthrough-, Objekt-
@ -548,7 +565,7 @@ einschließlich BOM, CRLF und Quotierung bytegleich zurück. Der lokale Befehl
`external_csv_configurations` enthält höchstens eine versionierte Konfiguration `external_csv_configurations` enthält höchstens eine versionierte Konfiguration
je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder je Projekt. `external-csv-configuration.update` schreibt Anlage, Änderung oder
Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 5, Entfernung zusammen mit Revision und persistentem Undo/Redo. Snapshot-Schema 6,
Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren Restore und portabler Projekttransfer enthalten diesen Zustand; beim Duplizieren
werden Konfigurations-UUID und Projektlink remapped. werden Konfigurations-UUID und Projektlink remapped.

127
docs/print-view.md Normal file
View file

@ -0,0 +1,127 @@
# 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,6 +113,10 @@ Implemented automatic-snapshot policy:
## Revit / CSV / IFCGUID Round-Trip Direction ## 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. External model exchange should use explicit staging and link entities instead of writing imported rows directly into circuits.
Conceptual entities: Conceptual entities:
@ -275,6 +279,10 @@ Completed foundation:
- `project-floor.insert` and `project-room.insert` persist complete location - `project-floor.insert` and `project-room.insert` persist complete location
records with stable ids; their inverses reject changed or referenced records records with stable ids; their inverses reject changed or referenced records
so Undo never clears room assignments or device-row links implicitly 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 - the floor and room POST routes require the current expected revision and the
project page tracks the returned history state project page tracks the returned history state

View file

@ -1,5 +1,9 @@
# Kontext für die Revit-Anforderungsplanung # 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 ## Zweck dieser Datei
Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei

View file

@ -532,6 +532,10 @@ Acceptance criteria:
## Phase 14: External Model Round-Trip ## 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 Status: Phase 14.2 (persistente externe Objekte und bestätigter Erstimport) ist
implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe implementiert. Editor-Zuordnung, Rückexport und Folgeimport folgen. Siehe
[Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md) [Phase-14-Audit und Implementierungsplan](revit-csv-phase-14-audit-and-plan.md)
@ -540,7 +544,7 @@ und die verbindlichen
Goal: Goal:
Exchange model objects and planning parameters with Revit or comparable tools through CSV or another agreed format. Exchange model objects and planning parameters with Revit through CSV (Linear plugin export/import).
Tasks: Tasks:

View file

@ -141,4 +141,5 @@ The pre-release cleanup established the following compatibility boundary:
The Revit/CSV/IFCGUID round-trip follows the distribution-board component The Revit/CSV/IFCGUID round-trip follows the distribution-board component
foundation as a separate jointly planned phase and must not be inferred from foundation as a separate jointly planned phase and must not be inferred from
these tasks. these tasks. It is currently deferred and will later use the CSV
export/import of the Linear plugin in Revit, not Dynamo.

View file

@ -2,6 +2,19 @@
## 1. Status und Verbindlichkeit ## 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**. Dieses Dokument ist die verbindliche fachliche und technische Arbeitsgrundlage für **Phase 14: External Model Round-Trip**.
Es unterscheidet ausdrücklich zwischen: Es unterscheidet ausdrücklich zwischen:
@ -1338,7 +1351,7 @@ Diese Punkte blockieren die Erstellung dieser Spezifikation nicht.
- automatische raumbezogene Stromkreisvorschläge; - automatische raumbezogene Stromkreisvorschläge;
- projektübergreifend wiederverwendbare Mappingprofile; - projektübergreifend wiederverwendbare Mappingprofile;
- Teilmodelle und mehrere externe Quellen; - Teilmodelle und mehrere externe Quellen;
- Dynamo/API/JSON-Transportadapter; - Dynamo/API/JSON-Transportadapter (nicht vorgesehen, siehe Abschnitt 1);
- Rollen und Berechtigungen; - Rollen und Berechtigungen;
- Hintergrundjobs; - Hintergrundjobs;
- PostgreSQL. - PostgreSQL.

View file

@ -2,6 +2,10 @@
## Status ## 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 Phase 14.0 ist abgeschlossen und freigegeben. Dieses Dokument beschreibt den
gegen den aktuellen Code geprüften Zielzuschnitt. Phase 14.1 ist abgeschlossen. 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 Phase 14.2 hat mit den externen Domänenverträgen, Identitätsregeln und dem

View file

@ -1,385 +0,0 @@
"""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

@ -1,517 +0,0 @@
"""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(),
}

View file

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

View file

@ -220,7 +220,7 @@ a {
--bs-alert-border-color: #205a38; --bs-alert-border-color: #205a38;
} }
/* ── App-Shell / Seitenleiste (nur auf der Projektseite) ────────────────── */ /* ── App-Shell / Seitenleiste (alle Seiten außer der Seitenansicht) ──── */
.app-shell { .app-shell {
display: grid; display: grid;
@ -1861,3 +1861,144 @@ a.kpi:hover {
.sidebar-collapsed .sidebar-label { .sidebar-collapsed .sidebar-label {
display: none; display: none;
} }
/* ── Gebäudestruktur (Gebäude → Etagen → Räume) ─────────────────────── */
.structure-columns {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-md);
align-items: start;
}
@media (min-width: 992px) {
.structure-columns {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.structure-column-title {
font-size: 1rem;
font-weight: 700;
margin: 0;
display: flex;
align-items: center;
}
.structure-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.25rem 0.4rem;
line-height: 1;
}
.structure-back-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.35rem 0.45rem;
margin-top: 0.1rem;
}
.structure-list {
max-height: 70vh;
overflow-y: auto;
}
.structure-entry {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: 0;
}
.structure-entry-main {
flex: 1 1 auto;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: 0.55rem 0.75rem;
border: 0;
background: transparent;
color: inherit;
text-align: left;
font: inherit;
}
button.structure-entry-main {
cursor: pointer;
}
button.structure-entry-main:hover {
background: var(--surface-hover);
}
button.structure-entry-main:focus-visible {
outline: 2px solid var(--accent-blue);
outline-offset: -2px;
}
.structure-entry.is-selected {
background: var(--surface-selected);
box-shadow: inset 3px 0 0 var(--color-signal);
}
.structure-entry.is-selected .structure-entry-label {
font-weight: 600;
}
.structure-entry.is-selected button.structure-entry-main:hover {
background: transparent;
}
.structure-entry-text {
min-width: 0;
display: flex;
flex-direction: column;
}
.structure-entry-label {
overflow-wrap: anywhere;
}
.structure-entry-hint {
font-size: 0.78rem;
color: var(--color-ink-soft);
}
.structure-entry-meta {
flex: 0 0 auto;
font-size: 0.78rem;
color: var(--color-ink-soft);
white-space: nowrap;
}
.structure-entry-actions {
flex: 0 0 auto;
display: inline-flex;
gap: 4px;
padding-right: 0.75rem;
}
.structure-room-number {
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.structure-group-label {
font-size: 0.66rem;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
color: var(--color-accent);
background: var(--panel-bg-subtle);
padding: 0.35rem 0.75rem;
}
.structure-empty {
color: var(--color-ink-soft);
font-size: 0.9rem;
}

View file

@ -49,17 +49,15 @@ import {
copyDistributionBoard, copyDistributionBoard,
copyProjectDeviceToGlobal, copyProjectDeviceToGlobal,
createDistributionBoard, createDistributionBoard,
createFloor,
createProjectDevice, createProjectDevice,
createRoom, createRoom,
deleteFloor,
deleteProjectDevice, deleteProjectDevice,
deleteRoom,
deleteDistributionBoard, deleteDistributionBoard,
disconnectProjectDeviceRows, disconnectProjectDeviceRows,
exportProjectTransfer, exportProjectTransfer,
getProject, getProject,
getProjectDeviceSyncPreview, getProjectDeviceSyncPreview,
listBuildings,
listCircuitLists, listCircuitLists,
listDistributionBoards, listDistributionBoards,
listFloors, listFloors,
@ -69,12 +67,11 @@ import {
importProjectTransfer, importProjectTransfer,
synchronizeProjectDeviceRows, synchronizeProjectDeviceRows,
updateDistributionBoard, updateDistributionBoard,
updateFloor,
updateProjectDevice, updateProjectDevice,
updateProjectSettings, updateProjectSettings,
updateRoom,
} from "../../../frontend/utils/api"; } from "../../../frontend/utils/api";
import type { import type {
BuildingDto,
CircuitListDto, CircuitListDto,
CreateRoomInput, CreateRoomInput,
CreateProjectDeviceInput, CreateProjectDeviceInput,
@ -95,6 +92,13 @@ import {
type DistributionBoardSupplyType, type DistributionBoardSupplyType,
} from "../../../shared/constants/distribution-board"; } from "../../../shared/constants/distribution-board";
import { ProjectVersionHistory } from "../../../frontend/components/project-version-history"; import { ProjectVersionHistory } from "../../../frontend/components/project-version-history";
import {
DistributionBoardInfoCard,
fromDistributionBoardInfoDraft,
toDistributionBoardInfoDraft,
type DistributionBoardInfoDraft,
} from "../../../frontend/components/distribution-board-info-card";
import { Printer } from "lucide-react";
import { import {
ProjectSettingsModal, ProjectSettingsModal,
type ProjectSettingsInput, type ProjectSettingsInput,
@ -102,7 +106,7 @@ import {
import { FormModal } from "../../../frontend/components/form-modal"; import { FormModal } from "../../../frontend/components/form-modal";
import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal"; import { ProjectDeviceModal } from "../../../frontend/components/project-device-modal";
import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal"; import { RevitCsvModal } from "../../../frontend/components/revit-csv-modal";
import { RoomModal } from "../../../frontend/components/room-modal"; import { FloorSelectOptions } from "../../../frontend/components/floor-select-options";
const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = { const projectDeviceSyncFieldLabels: Record<ProjectDeviceSyncField, string> = {
name: "Technischer Name", name: "Technischer Name",
@ -147,6 +151,7 @@ export default function ProjectDetailPage() {
const [project, setProject] = useState<ProjectDto | null>(null); const [project, setProject] = useState<ProjectDto | null>(null);
const [boards, setBoards] = useState<DistributionBoardDto[]>([]); const [boards, setBoards] = useState<DistributionBoardDto[]>([]);
const [circuitLists, setCircuitLists] = useState<CircuitListDto[]>([]); const [circuitLists, setCircuitLists] = useState<CircuitListDto[]>([]);
const [buildings, setBuildings] = useState<BuildingDto[]>([]);
const [floors, setFloors] = useState<FloorDto[]>([]); const [floors, setFloors] = useState<FloorDto[]>([]);
const [rooms, setRooms] = useState<RoomDto[]>([]); const [rooms, setRooms] = useState<RoomDto[]>([]);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]); const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
@ -165,14 +170,18 @@ export default function ProjectDetailPage() {
setEditingBoardSimultaneityFactor, setEditingBoardSimultaneityFactor,
] = useState("1"); ] = useState("1");
const [editingBoardCopyName, setEditingBoardCopyName] = useState(""); const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
const [floorName, setFloorName] = useState(""); const [editingBoardInfo, setEditingBoardInfo] =
const [editingFloor, setEditingFloor] = useState<FloorDto | null>(null); useState<DistributionBoardInfoDraft>(
const [editingRoom, setEditingRoom] = useState<RoomDto | null>(null); toDistributionBoardInfoDraft({
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
})
);
const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false); const [isProjectSettingsOpen, setIsProjectSettingsOpen] = useState(false);
const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false); const [isRevitCsvOpen, setIsRevitCsvOpen] = useState(false);
const [structureModal, setStructureModal] = useState< const [structureModal, setStructureModal] = useState<"board" | null>(null);
"board" | "floor" | "room" | null
>(null);
const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] = const [isProjectDeviceModalOpen, setIsProjectDeviceModalOpen] =
useState(false); useState(false);
const [editingProjectDevice, setEditingProjectDevice] = const [editingProjectDevice, setEditingProjectDevice] =
@ -200,6 +209,7 @@ export default function ProjectDetailPage() {
getProject(projectId), getProject(projectId),
listDistributionBoards(projectId), listDistributionBoards(projectId),
listCircuitLists(projectId), listCircuitLists(projectId),
listBuildings(projectId),
listFloors(projectId), listFloors(projectId),
listRooms(projectId), listRooms(projectId),
listProjectDevices(projectId), listProjectDevices(projectId),
@ -209,6 +219,7 @@ export default function ProjectDetailPage() {
currentProject, currentProject,
distributionBoards, distributionBoards,
loadedCircuitLists, loadedCircuitLists,
loadedBuildings,
loadedFloors, loadedFloors,
loadedRooms, loadedRooms,
loadedProjectDevices, loadedProjectDevices,
@ -217,6 +228,7 @@ export default function ProjectDetailPage() {
setProject(currentProject); setProject(currentProject);
setBoards(distributionBoards); setBoards(distributionBoards);
setCircuitLists(loadedCircuitLists); setCircuitLists(loadedCircuitLists);
setBuildings(loadedBuildings);
setFloors(loadedFloors); setFloors(loadedFloors);
setRooms(loadedRooms); setRooms(loadedRooms);
setProjectDevices(loadedProjectDevices); setProjectDevices(loadedProjectDevices);
@ -229,6 +241,7 @@ export default function ProjectDetailPage() {
}, [projectId]); }, [projectId]);
const boardCount = useMemo(() => boards.length, [boards.length]); const boardCount = useMemo(() => boards.length, [boards.length]);
const buildingCount = useMemo(() => buildings.length, [buildings.length]);
const floorCount = useMemo(() => floors.length, [floors.length]); const floorCount = useMemo(() => floors.length, [floors.length]);
const roomCount = useMemo(() => rooms.length, [rooms.length]); const roomCount = useMemo(() => rooms.length, [rooms.length]);
const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]); const projectDeviceCount = useMemo(() => projectDevices.length, [projectDevices.length]);
@ -358,74 +371,6 @@ export default function ProjectDetailPage() {
} }
} }
async function handleSaveFloor(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!projectId || !project || !floorName.trim()) {
return;
}
setIsSaving(true);
setError(null);
try {
const result = editingFloor
? await updateFloor(
projectId,
editingFloor.id,
{ name: floorName.trim() },
project.currentRevision
)
: await createFloor(
projectId,
{ name: floorName.trim() },
project.currentRevision
);
setFloors((current) =>
editingFloor
? current.map((floor) =>
floor.id === result.floor.id ? result.floor : floor
)
: [...current, result.floor]
);
applyProjectRevision(result.history.currentRevision);
setFloorName("");
setEditingFloor(null);
setStructureModal(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Etage konnte nicht gespeichert werden.");
} finally {
setIsSaving(false);
}
}
async function handleSaveRoom(input: CreateRoomInput) {
if (!projectId || !project) return;
setIsSaving(true);
setError(null);
try {
const result = editingRoom
? await updateRoom(
projectId,
editingRoom.id,
input,
project.currentRevision
)
: await createRoom(projectId, input, project.currentRevision);
setRooms((current) =>
editingRoom
? current.map((room) =>
room.id === result.room.id ? result.room : room
)
: [...current, result.room]
);
applyProjectRevision(result.history.currentRevision);
setEditingRoom(null);
setStructureModal(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Raum konnte nicht gespeichert werden.");
} finally {
setIsSaving(false);
}
}
async function handleSaveProjectSettings(input: ProjectSettingsInput) { async function handleSaveProjectSettings(input: ProjectSettingsInput) {
if (!projectId || !project) { if (!projectId || !project) {
return; return;
@ -542,6 +487,7 @@ export default function ProjectDetailPage() {
String(board.simultaneityFactor) String(board.simultaneityFactor)
); );
setEditingBoardCopyName(`${board.name} Kopie`); setEditingBoardCopyName(`${board.name} Kopie`);
setEditingBoardInfo(toDistributionBoardInfoDraft(board));
} }
function openBoardCreator() { function openBoardCreator() {
@ -577,6 +523,7 @@ export default function ProjectDetailPage() {
floorId: editingBoardFloorId || null, floorId: editingBoardFloorId || null,
supplyType: editingBoardSupplyType, supplyType: editingBoardSupplyType,
simultaneityFactor, simultaneityFactor,
...fromDistributionBoardInfoDraft(editingBoardInfo),
}, },
project.currentRevision project.currentRevision
); );
@ -616,65 +563,6 @@ export default function ProjectDetailPage() {
return result; return result;
} }
function openCreateFloor() {
setEditingFloor(null);
setFloorName("");
setStructureModal("floor");
}
function openEditFloor(floor: FloorDto) {
setEditingFloor(floor);
setFloorName(floor.name);
setStructureModal("floor");
}
function openCreateRoom() {
setEditingRoom(null);
setStructureModal("room");
}
function openEditRoom(room: RoomDto) {
setEditingRoom(room);
setStructureModal("room");
}
async function handleDeleteFloor() {
if (!projectId || !project || !editingFloor) return;
if (!window.confirm(`Etage „${editingFloor.name}“ wirklich löschen?`)) return;
setIsSaving(true);
setError(null);
try {
const result = await deleteFloor(projectId, editingFloor.id, project.currentRevision);
setFloors((current) => current.filter((floor) => floor.id !== editingFloor.id));
applyProjectRevision(result.history.currentRevision);
setEditingFloor(null);
setFloorName("");
setStructureModal(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Etage konnte nicht gelöscht werden.");
} finally {
setIsSaving(false);
}
}
async function handleDeleteRoom() {
if (!projectId || !project || !editingRoom) return;
if (!window.confirm(`Raum „${editingRoom.roomNumber} ${editingRoom.roomName}“ wirklich löschen?`)) return;
setIsSaving(true);
setError(null);
try {
const result = await deleteRoom(projectId, editingRoom.id, project.currentRevision);
setRooms((current) => current.filter((room) => room.id !== editingRoom.id));
applyProjectRevision(result.history.currentRevision);
setEditingRoom(null);
setStructureModal(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Raum konnte nicht gelöscht werden.");
} finally {
setIsSaving(false);
}
}
async function handleCopyBoard() { async function handleCopyBoard() {
if ( if (
!projectId || !projectId ||
@ -967,6 +855,14 @@ export default function ProjectDetailPage() {
</p> </p>
</div> </div>
<div className="d-flex gap-2"> <div className="d-flex gap-2">
<Link
className="btn btn-primary d-inline-flex align-items-center gap-2"
href={`/projects/${projectId}/print`}
target="_blank"
>
<Printer aria-hidden="true" size={16} />
Seitenansicht
</Link>
<button <button
className="btn btn-outline-primary btn-with-icon" className="btn btn-outline-primary btn-with-icon"
disabled={!project} disabled={!project}
@ -1002,14 +898,18 @@ export default function ProjectDetailPage() {
<div className="kpi-label">Verteilungen</div> <div className="kpi-label">Verteilungen</div>
<div className="kpi-value">{boardCount}</div> <div className="kpi-value">{boardCount}</div>
</a> </a>
<a className="kpi" href="#etagen"> <Link className="kpi" href={`/projects/${projectId}/struktur`}>
<div className="kpi-label">Gebäude</div>
<div className="kpi-value">{buildingCount}</div>
</Link>
<Link className="kpi" href={`/projects/${projectId}/struktur`}>
<div className="kpi-label">Etagen</div> <div className="kpi-label">Etagen</div>
<div className="kpi-value">{floorCount}</div> <div className="kpi-value">{floorCount}</div>
</a> </Link>
<a className="kpi" href="#raeume"> <Link className="kpi" href={`/projects/${projectId}/struktur`}>
<div className="kpi-label">Räume</div> <div className="kpi-label">Räume</div>
<div className="kpi-value">{roomCount}</div> <div className="kpi-value">{roomCount}</div>
</a> </Link>
<a className="kpi" href="#projektgeraete"> <a className="kpi" href="#projektgeraete">
<div className="kpi-label">Projektgeräte</div> <div className="kpi-label">Projektgeräte</div>
<div className="kpi-value">{projectDeviceCount}</div> <div className="kpi-value">{projectDeviceCount}</div>
@ -1084,7 +984,9 @@ export default function ProjectDetailPage() {
<table className="table table-hover align-middle mb-0"> <table className="table table-hover align-middle mb-0">
<thead> <thead>
<tr> <tr>
<th>Nr.</th>
<th>Verteilung</th> <th>Verteilung</th>
<th>Örtlichkeit</th>
<th>Etage</th> <th>Etage</th>
<th>Netzart</th> <th>Netzart</th>
<th>GZF</th> <th>GZF</th>
@ -1096,7 +998,11 @@ export default function ProjectDetailPage() {
const circuitList = circuitListByBoardId.get(board.id); const circuitList = circuitListByBoardId.get(board.id);
return ( return (
<tr key={board.id}> <tr key={board.id}>
<td className="fw-semibold">
{board.boardNumber ?? "–"}
</td>
<td>{board.name}</td> <td>{board.name}</td>
<td>{board.location ?? "–"}</td>
<td> <td>
{board.floorId {board.floorId
? floorById.get(board.floorId)?.name ?? "Unbekannt" ? floorById.get(board.floorId)?.name ?? "Unbekannt"
@ -1146,7 +1052,7 @@ export default function ProjectDetailPage() {
})} })}
{!boards.length ? ( {!boards.length ? (
<tr> <tr>
<td colSpan={5} className="text-center text-secondary py-4"> <td colSpan={7} className="text-center text-secondary py-4">
Noch keine Verteilungen vorhanden. Noch keine Verteilungen vorhanden.
</td> </td>
</tr> </tr>
@ -1161,88 +1067,6 @@ export default function ProjectDetailPage() {
</div> </div>
</div> </div>
</section> </section>
<section className="col-12 col-lg-5" id="etagen">
<div className="card shadow-sm h-100">
<div className="card-header d-flex justify-content-between align-items-center">
<div>
<span>Etagen</span>
<span className="badge text-bg-secondary ms-2">{floorCount}</span>
</div>
<button
className="btn btn-sm btn-outline-primary"
onClick={openCreateFloor}
type="button"
>
Hinzufügen
</button>
</div>
<ul className="list-group list-group-flush">
{floors.map((floor) => (
<li className="list-group-item d-flex justify-content-between align-items-center gap-2" key={floor.id}>
<span>{floor.name}</span>
<button className="btn btn-sm btn-outline-secondary" onClick={() => openEditFloor(floor)} type="button">
Bearbeiten
</button>
</li>
))}
{!floors.length ? (
<li className="list-group-item text-secondary">Noch keine Etagen vorhanden.</li>
) : null}
</ul>
</div>
</section>
<section className="col-12 col-lg-7" id="raeume">
<div className="card shadow-sm">
<div className="card-header d-flex justify-content-between align-items-center">
<div>
<span>Räume</span>
<span className="badge text-bg-secondary ms-2">{roomCount}</span>
</div>
<button
className="btn btn-sm btn-outline-primary"
onClick={openCreateRoom}
type="button"
>
Hinzufügen
</button>
</div>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Raumnummer</th>
<th>Raumname</th>
<th>Etage</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{rooms.map((room) => (
<tr key={room.id}>
<td>{room.roomNumber}</td>
<td>{room.roomName}</td>
<td>{room.floorId ? floorById.get(room.floorId)?.name ?? "-" : "-"}</td>
<td className="text-end">
<button className="btn btn-sm btn-outline-secondary" onClick={() => openEditRoom(room)} type="button">
Bearbeiten
</button>
</td>
</tr>
))}
{!rooms.length ? (
<tr>
<td colSpan={4} className="text-center text-secondary py-4">
Noch keine Räume vorhanden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
</section>
</div> </div>
<section className="card shadow-sm mt-4" id="projektgeraete"> <section className="card shadow-sm mt-4" id="projektgeraete">
@ -1535,11 +1359,7 @@ export default function ProjectDetailPage() {
value={boardFloorId} value={boardFloorId}
> >
<option value="">Ohne Etage</option> <option value="">Ohne Etage</option>
{floors.map((floor) => ( <FloorSelectOptions buildings={buildings} floors={floors} />
<option key={floor.id} value={floor.id}>
{floor.name}
</option>
))}
</select> </select>
</div> </div>
<div className="col-12 col-md-6"> <div className="col-12 col-md-6">
@ -1605,11 +1425,7 @@ export default function ProjectDetailPage() {
value={editingBoardFloorId} value={editingBoardFloorId}
> >
<option value="">Ohne Etage</option> <option value="">Ohne Etage</option>
{floors.map((floor) => ( <FloorSelectOptions buildings={buildings} floors={floors} />
<option key={floor.id} value={floor.id}>
{floor.name}
</option>
))}
</select> </select>
</div> </div>
<div className="col-12 col-md-6"> <div className="col-12 col-md-6">
@ -1663,6 +1479,12 @@ export default function ProjectDetailPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="mt-4">
<DistributionBoardInfoCard
draft={editingBoardInfo}
onChange={setEditingBoardInfo}
/>
</div>
<hr className="my-4" /> <hr className="my-4" />
<section aria-labelledby="copy-distribution-board-heading"> <section aria-labelledby="copy-distribution-board-heading">
<h3 <h3
@ -1735,57 +1557,6 @@ export default function ProjectDetailPage() {
</FormModal> </FormModal>
) : null} ) : null}
{structureModal === "floor" ? (
<FormModal
isSaving={isSaving}
onClose={() => {
setStructureModal(null);
setEditingFloor(null);
}}
onSubmit={handleSaveFloor}
submitDisabled={!floorName.trim()}
submitLabel={editingFloor ? "Änderungen speichern" : "Etage hinzufügen"}
title={editingFloor ? "Etage bearbeiten" : "Etage hinzufügen"}
>
<label className="form-label" htmlFor="floor-name">
Bezeichnung
</label>
<input
autoFocus
className="form-control"
id="floor-name"
onChange={(event) => setFloorName(event.target.value)}
placeholder="z. B. EG"
required
value={floorName}
/>
{editingFloor ? (
<div className="border-top mt-4 pt-3">
<p className="text-secondary small mb-2">
Etagen mit zugeordneten Räumen oder Verteilungen können nicht gelöscht werden.
</p>
<button className="btn btn-outline-danger" disabled={isSaving} onClick={() => void handleDeleteFloor()} type="button">
Etage löschen
</button>
</div>
) : null}
</FormModal>
) : null}
{structureModal === "room" ? (
<RoomModal
floors={floors}
initialRoom={editingRoom ?? undefined}
isSaving={isSaving}
onClose={() => {
setStructureModal(null);
setEditingRoom(null);
}}
onDelete={editingRoom ? () => void handleDeleteRoom() : undefined}
onSave={handleSaveRoom}
/>
) : null}
{isProjectDeviceModalOpen ? ( {isProjectDeviceModalOpen ? (
<ProjectDeviceModal <ProjectDeviceModal
globalDevices={globalDevices} globalDevices={globalDevices}

View file

@ -0,0 +1,360 @@
"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

@ -0,0 +1,792 @@
"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

@ -0,0 +1,12 @@
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

@ -0,0 +1,10 @@
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

@ -0,0 +1,9 @@
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;

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,6 +50,27 @@
"when": 1786043080323, "when": 1786043080323,
"tag": "0006_damp_skrulls", "tag": "0006_damp_skrulls",
"breakpoints": true "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
} }
] ]
} }

View file

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

View file

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

View file

@ -0,0 +1,86 @@
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,23 +1,34 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { import {
assertProjectBuildingDeleteProjectCommand,
assertProjectBuildingInsertProjectCommand,
assertProjectBuildingUpdateProjectCommand,
assertProjectFloorDeleteProjectCommand, assertProjectFloorDeleteProjectCommand,
assertProjectFloorInsertProjectCommand, assertProjectFloorInsertProjectCommand,
assertProjectFloorUpdateProjectCommand, assertProjectFloorUpdateProjectCommand,
assertProjectRoomDeleteProjectCommand, assertProjectRoomDeleteProjectCommand,
assertProjectRoomInsertProjectCommand, assertProjectRoomInsertProjectCommand,
assertProjectRoomUpdateProjectCommand, assertProjectRoomUpdateProjectCommand,
createProjectBuildingDeleteProjectCommand,
createProjectBuildingInsertProjectCommand,
createProjectBuildingUpdateProjectCommand,
createProjectFloorDeleteProjectCommand, createProjectFloorDeleteProjectCommand,
createProjectFloorInsertProjectCommand, createProjectFloorInsertProjectCommand,
createProjectFloorUpdateProjectCommand, createProjectFloorUpdateProjectCommand,
createProjectRoomDeleteProjectCommand, createProjectRoomDeleteProjectCommand,
createProjectRoomInsertProjectCommand, createProjectRoomInsertProjectCommand,
createProjectRoomUpdateProjectCommand, createProjectRoomUpdateProjectCommand,
projectBuildingDeleteCommandType,
projectBuildingInsertCommandType,
projectBuildingUpdateCommandType,
projectFloorDeleteCommandType, projectFloorDeleteCommandType,
projectFloorInsertCommandType, projectFloorInsertCommandType,
projectFloorUpdateCommandType, projectFloorUpdateCommandType,
projectRoomDeleteCommandType, projectRoomDeleteCommandType,
projectRoomInsertCommandType, projectRoomInsertCommandType,
projectRoomUpdateCommandType, projectRoomUpdateCommandType,
upgradeProjectFloorCommand,
type ProjectBuildingSnapshot,
type ProjectFloorSnapshot, type ProjectFloorSnapshot,
type ProjectLocationStructureProjectCommand, type ProjectLocationStructureProjectCommand,
type ProjectRoomSnapshot, type ProjectRoomSnapshot,
@ -27,6 +38,7 @@ import type {
ProjectLocationStructureProjectCommandStore, ProjectLocationStructureProjectCommandStore,
} from "../../domain/ports/project-location-structure-project-command.store.js"; } from "../../domain/ports/project-location-structure-project-command.store.js";
import type { AppDatabase } from "../database-context.js"; import type { AppDatabase } from "../database-context.js";
import { buildings } from "../schema/buildings.js";
import { circuitDeviceRows } from "../schema/circuit-device-rows.js"; import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
import { floors } from "../schema/floors.js"; import { floors } from "../schema/floors.js";
import { distributionBoards } from "../schema/distribution-boards.js"; import { distributionBoards } from "../schema/distribution-boards.js";
@ -40,11 +52,14 @@ export class ProjectLocationStructureProjectCommandRepository
constructor(private readonly database: AppDatabase) {} constructor(private readonly database: AppDatabase) {}
execute(input: ExecuteProjectLocationStructureCommandInput) { 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( return executeProjectCommandTransaction(
this.database, this.database,
input, { ...input, command },
(tx) => (tx) =>
this.applyCommand(tx, input.projectId, input.command) this.applyCommand(tx, input.projectId, command)
); );
} }
@ -54,23 +69,43 @@ export class ProjectLocationStructureProjectCommandRepository
command: ProjectLocationStructureProjectCommand command: ProjectLocationStructureProjectCommand
): ProjectLocationStructureProjectCommand { ): ProjectLocationStructureProjectCommand {
switch (command.type) { switch (command.type) {
case projectFloorInsertCommandType: 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: {
assertProjectFloorInsertProjectCommand(command); assertProjectFloorInsertProjectCommand(command);
const current = upgradeProjectFloorCommand(command);
return this.insertFloor( return this.insertFloor(
database, database,
projectId, projectId,
command.payload.floor current.payload.floor
); );
case projectFloorDeleteCommandType: }
case projectFloorDeleteCommandType: {
assertProjectFloorDeleteProjectCommand(command); assertProjectFloorDeleteProjectCommand(command);
const current = upgradeProjectFloorCommand(command);
return this.deleteFloor( return this.deleteFloor(
database, database,
projectId, projectId,
command.payload.floor current.payload.floor
); );
case projectFloorUpdateCommandType: }
case projectFloorUpdateCommandType: {
assertProjectFloorUpdateProjectCommand(command); assertProjectFloorUpdateProjectCommand(command);
return this.updateFloor(database, projectId, command.payload.expected, command.payload.target); const current = upgradeProjectFloorCommand(command);
return this.updateFloor(database, projectId, current.payload.expected, current.payload.target);
}
case projectRoomInsertCommandType: case projectRoomInsertCommandType:
assertProjectRoomInsertProjectCommand(command); assertProjectRoomInsertProjectCommand(command);
return this.insertRoom( return this.insertRoom(
@ -100,6 +135,7 @@ export class ProjectLocationStructureProjectCommandRepository
if (floor.projectId !== projectId) { if (floor.projectId !== projectId) {
throw new Error("Project-floor snapshot does not belong to project."); throw new Error("Project-floor snapshot does not belong to project.");
} }
this.assertBuildingInProject(database, projectId, floor.buildingId);
const existing = database const existing = database
.select({ id: floors.id }) .select({ id: floors.id })
.from(floors) .from(floors)
@ -167,6 +203,104 @@ export class ProjectLocationStructureProjectCommandRepository
return createProjectFloorInsertProjectCommand(expected); 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( private insertRoom(
database: AppDatabase, database: AppDatabase,
projectId: string, projectId: string,
@ -217,9 +351,10 @@ export class ProjectLocationStructureProjectCommandRepository
if (!persisted || !sameRecord(expected, persisted)) { if (!persisted || !sameRecord(expected, persisted)) {
throw new Error("Project floor changed before update."); throw new Error("Project floor changed before update.");
} }
this.assertBuildingInProject(database, projectId, target.buildingId);
const updated = database const updated = database
.update(floors) .update(floors)
.set({ name: target.name }) .set({ name: target.name, buildingId: target.buildingId })
.where(and(eq(floors.id, expected.id), eq(floors.projectId, projectId))) .where(and(eq(floors.id, expected.id), eq(floors.projectId, projectId)))
.run(); .run();
if (updated.changes !== 1) { if (updated.changes !== 1) {
@ -326,6 +461,24 @@ 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( function sameRecord(
expected: object, expected: object,
actual: object actual: object

View file

@ -1,9 +1,12 @@
import { and, eq, isNotNull } from "drizzle-orm"; import { and, eq, isNotNull } from "drizzle-orm";
import { import {
assertProjectSettingsUpdateProjectCommand, assertProjectSettingsUpdateProjectCommand,
baselineProjectSettingsUpdateCommandSchemaVersion,
createProjectSettingsUpdateProjectCommand, createProjectSettingsUpdateProjectCommand,
withoutCoverProjectSettingsUpdateCommandSchemaVersion,
type ProjectSettingsValues, type ProjectSettingsValues,
} from "../../domain/models/project-settings-project-command.model.js"; } from "../../domain/models/project-settings-project-command.model.js";
import { projectCoverFields } from "../../shared/constants/project-cover.js";
import type { import type {
ExecuteProjectSettingsUpdateCommandInput, ExecuteProjectSettingsUpdateCommandInput,
ProjectSettingsProjectCommandStore, ProjectSettingsProjectCommandStore,
@ -40,13 +43,27 @@ export class ProjectSettingsProjectCommandRepository
if (!current) { if (!current) {
throw new Error("Project not found."); 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 = const target: ProjectSettingsValues =
input.command.schemaVersion === 1 command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
? { ? {
...input.command.payload, ...command.payload,
isPublicBuilding: current.isPublicBuilding, isPublicBuilding: current.isPublicBuilding,
...currentCoverValues,
} }
: input.command.payload; : command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion
? { ...command.payload, ...currentCoverValues }
: command.payload;
if (projectSettingsEqual(current, target)) { if (projectSettingsEqual(current, target)) {
throw new Error("Project settings did not change."); throw new Error("Project settings did not change.");
} }
@ -62,11 +79,15 @@ export class ProjectSettingsProjectCommandRepository
threePhaseVoltageV: current.threePhaseVoltageV, threePhaseVoltageV: current.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes: enabledDistributionBoardSupplyTypes:
current.enabledDistributionBoardSupplyTypes, current.enabledDistributionBoardSupplyTypes,
...currentCoverValues,
}; };
const inverse = const inverse =
input.command.schemaVersion === 1 command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
? createBaselineProjectSettingsUpdateCommand(inverseValues) ? createBaselineProjectSettingsUpdateCommand(inverseValues)
: createProjectSettingsUpdateProjectCommand(inverseValues); : command.schemaVersion ===
withoutCoverProjectSettingsUpdateCommandSchemaVersion
? createWithoutCoverProjectSettingsUpdateCommand(inverseValues)
: createProjectSettingsUpdateProjectCommand(inverseValues);
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target); assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
const updated = tx const updated = tx
.update(projects) .update(projects)
@ -82,11 +103,32 @@ 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( function createBaselineProjectSettingsUpdateCommand(
values: ProjectSettingsValues values: ProjectSettingsValues
) { ) {
return { return {
schemaVersion: 1 as const, schemaVersion: baselineProjectSettingsUpdateCommandSchemaVersion,
type: "project.update-settings" as const, type: "project.update-settings" as const,
payload: { payload: {
name: values.name, name: values.name,
@ -114,8 +156,9 @@ function projectSettingsEqual(
current.description === target.description && current.description === target.description &&
current.isPublicBuilding === target.isPublicBuilding && current.isPublicBuilding === target.isPublicBuilding &&
current.singlePhaseVoltageV === target.singlePhaseVoltageV && current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
current.threePhaseVoltageV === target.threePhaseVoltageV current.threePhaseVoltageV === target.threePhaseVoltageV &&
&& sameSupplyTypes( projectCoverFields.every((field) => current[field] === target[field]) &&
sameSupplyTypes(
current.enabledDistributionBoardSupplyTypes, current.enabledDistributionBoardSupplyTypes,
target.enabledDistributionBoardSupplyTypes target.enabledDistributionBoardSupplyTypes
) )

View file

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

View file

@ -21,6 +21,7 @@ import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js"; import { distributionBoards } from "../schema/distribution-boards.js";
import { buildings } from "../schema/buildings.js";
import { floors } from "../schema/floors.js"; import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js"; import { projectDevices } from "../schema/project-devices.js";
import { projectChangeSets } from "../schema/project-change-sets.js"; import { projectChangeSets } from "../schema/project-change-sets.js";
@ -164,6 +165,7 @@ function canonicalProjectState(state: ProjectStateSnapshot) {
})) }))
), ),
projectDevices: byId(state.projectDevices), projectDevices: byId(state.projectDevices),
buildings: byId(state.buildings),
floors: byId(state.floors), floors: byId(state.floors),
rooms: byId(state.rooms), rooms: byId(state.rooms),
externalCsvConfiguration: state.externalCsvConfiguration, externalCsvConfiguration: state.externalCsvConfiguration,
@ -219,6 +221,10 @@ function replaceProjectState(
.delete(floors) .delete(floors)
.where(eq(floors.projectId, state.project.id)) .where(eq(floors.projectId, state.project.id))
.run(); .run();
database
.delete(buildings)
.where(eq(buildings.projectId, state.project.id))
.run();
database database
.update(projects) .update(projects)
@ -233,10 +239,15 @@ function replaceProjectState(
threePhaseVoltageV: state.project.threePhaseVoltageV, threePhaseVoltageV: state.project.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes: enabledDistributionBoardSupplyTypes:
state.project.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)) .where(eq(projects.id, state.project.id))
.run(); .run();
insertMany(database, buildings, state.buildings);
insertMany(database, floors, state.floors); insertMany(database, floors, state.floors);
if (state.externalCsvConfiguration !== null) { if (state.externalCsvConfiguration !== null) {
database database

View file

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

View file

@ -16,6 +16,7 @@ import { circuits } from "../schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponentProtectionDevices } from "../schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../schema/distribution-board-components.js"; import { distributionBoardComponents } from "../schema/distribution-board-components.js";
import { distributionBoards } from "../schema/distribution-boards.js"; import { distributionBoards } from "../schema/distribution-boards.js";
import { buildings } from "../schema/buildings.js";
import { floors } from "../schema/floors.js"; import { floors } from "../schema/floors.js";
import { projectDevices } from "../schema/project-devices.js"; import { projectDevices } from "../schema/project-devices.js";
import { projects } from "../schema/projects.js"; import { projects } from "../schema/projects.js";
@ -127,6 +128,7 @@ function insertProjectState(
.values(state.externalCsvConfiguration) .values(state.externalCsvConfiguration)
.run(); .run();
} }
insertMany(database, buildings, state.buildings);
insertMany(database, floors, state.floors); insertMany(database, floors, state.floors);
insertMany(database, rooms, state.rooms); insertMany(database, rooms, state.rooms);
insertMany(database, projectDevices, state.projectDevices); insertMany(database, projectDevices, state.projectDevices);

View file

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

View file

@ -0,0 +1,11 @@
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,5 +16,9 @@ export const distributionBoards = sqliteTable("distribution_boards", {
simultaneityFactor: real("simultaneity_factor") simultaneityFactor: real("simultaneity_factor")
.notNull() .notNull()
.default(1), .default(1),
boardNumber: text("board_number"),
location: text("location"),
fedFrom: text("fed_from"),
remark: text("remark"),
}); });

View file

@ -0,0 +1,18 @@
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

@ -0,0 +1,13 @@
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,4 +1,5 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { buildings } from "./buildings.js";
import { projects } from "./projects.js"; import { projects } from "./projects.js";
export const floors = sqliteTable("floors", { export const floors = sqliteTable("floors", {
@ -8,4 +9,9 @@ export const floors = sqliteTable("floors", {
.references(() => projects.id, { onDelete: "cascade" }), .references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(), name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0), 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,5 +21,9 @@ export const projects = sqliteTable("projects", {
.$type<DistributionBoardSupplyType[]>() .$type<DistributionBoardSupplyType[]>()
.notNull() .notNull()
.default(sql`'["AV","SV","EV","USV","MSR","SiBe"]'`), .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), currentRevision: integer("current_revision").notNull().default(0),
}); });

View file

@ -1,14 +1,21 @@
import { import {
distributionBoardInfoFieldMaxLengths,
distributionBoardInfoFields,
distributionBoardSupplyTypes, distributionBoardSupplyTypes,
type DistributionBoardInfoField,
type DistributionBoardSupplyType, type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js"; } from "../../shared/constants/distribution-board.js";
import type { SerializedProjectCommand } from "./project-command.model.js"; import type { SerializedProjectCommand } from "./project-command.model.js";
export const distributionBoardUpdateCommandType = export const distributionBoardUpdateCommandType =
"distribution-board.update" as const; "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 const distributionBoardUpdateCommandSchemaVersion = 1 as const;
export interface DistributionBoardUpdateValues { export interface DistributionBoardUpdateValues
extends Record<DistributionBoardInfoField, string | null> {
floorId: string | null; floorId: string | null;
supplyType: DistributionBoardSupplyType | null; supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number; simultaneityFactor: number;
@ -82,16 +89,20 @@ export function assertDistributionBoardUpdateProjectCommand(
for (const change of changes) { for (const change of changes) {
if ( if (
!isPlainObject(change) || !isPlainObject(change) ||
typeof change.field !== "string" ||
(change.field !== "floorId" && (change.field !== "floorId" &&
change.field !== "supplyType" && change.field !== "supplyType" &&
change.field !== "simultaneityFactor") || change.field !== "simultaneityFactor" &&
!isInfoField(change.field)) ||
seen.has(change.field) seen.has(change.field)
) { ) {
throw new Error( throw new Error(
"Distribution-board update contains an invalid or duplicate field." "Distribution-board update contains an invalid or duplicate field."
); );
} }
if (change.field === "floorId") { if (isInfoField(change.field)) {
assertInfoFieldValue(change.field, change.value);
} else if (change.field === "floorId") {
if ( if (
change.value !== null && change.value !== null &&
(typeof change.value !== "string" || !change.value.trim()) (typeof change.value !== "string" || !change.value.trim())
@ -121,6 +132,29 @@ 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> { function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value); return value !== null && typeof value === "object" && !Array.isArray(value);
} }

View file

@ -10,7 +10,9 @@ import {
type DistributionBoardComponentRole, type DistributionBoardComponentRole,
} from "../../shared/constants/distribution-board-component.js"; } from "../../shared/constants/distribution-board-component.js";
import { import {
distributionBoardInfoFields,
distributionBoardSupplyTypes, distributionBoardSupplyTypes,
type DistributionBoardInfoField,
type DistributionBoardSupplyType, type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board.js"; } from "../../shared/constants/distribution-board.js";
import type { import type {
@ -72,7 +74,7 @@ export interface DistributionBoardSubtreeSnapshot {
floorId: string | null; floorId: string | null;
supplyType: DistributionBoardSupplyType | null; supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number; simultaneityFactor: number;
}; } & Record<DistributionBoardInfoField, string | null>;
circuitList: { circuitList: {
id: string; id: string;
projectId: string; projectId: string;
@ -169,6 +171,22 @@ export function cloneDistributionBoardSubtree(
return clone; 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( export function assertDistributionBoardSubtreeSnapshot(
value: unknown value: unknown
): asserts value is DistributionBoardSubtreeSnapshot { ): asserts value is DistributionBoardSubtreeSnapshot {
@ -204,6 +222,11 @@ export function assertDistributionBoardSubtreeSnapshot(
if (board.simultaneityFactor < 0 || board.simultaneityFactor > 1) { if (board.simultaneityFactor < 0 || board.simultaneityFactor > 1) {
throw new Error("Distribution-board simultaneity factor is invalid."); 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 [ for (const field of [
"id", "id",
"projectId", "projectId",

View file

@ -1,21 +1,42 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import type { SerializedProjectCommand } from "./project-command.model.js"; 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 projectFloorInsertCommandType = "project-floor.insert" as const;
export const projectFloorDeleteCommandType = "project-floor.delete" as const; export const projectFloorDeleteCommandType = "project-floor.delete" as const;
export const projectFloorUpdateCommandType = "project-floor.update" as const; export const projectFloorUpdateCommandType = "project-floor.update" as const;
export const projectRoomInsertCommandType = "project-room.insert" as const; export const projectRoomInsertCommandType = "project-room.insert" as const;
export const projectRoomDeleteCommandType = "project-room.delete" as const; export const projectRoomDeleteCommandType = "project-room.delete" as const;
export const projectRoomUpdateCommandType = "project-room.update" as const; export const projectRoomUpdateCommandType = "project-room.update" as const;
/** Room commands, building commands and legacy floor commands. */
export const projectLocationStructureCommandSchemaVersion = 1 as const; 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 { export interface ProjectFloorSnapshot {
id: string; id: string;
projectId: string; projectId: string;
name: string; name: string;
sortOrder: number; sortOrder: number;
buildingId: string | null;
} }
export type LegacyProjectFloorSnapshot = Omit<ProjectFloorSnapshot, "buildingId">;
export interface ProjectRoomSnapshot { export interface ProjectRoomSnapshot {
id: string; id: string;
projectId: string; projectId: string;
@ -24,39 +45,93 @@ export interface ProjectRoomSnapshot {
roomName: string; roomName: string;
} }
interface ProjectBuildingStructureCommandPayload {
building: ProjectBuildingSnapshot;
}
interface ProjectFloorStructureCommandPayload { interface ProjectFloorStructureCommandPayload {
floor: ProjectFloorSnapshot; floor: ProjectFloorSnapshot;
} }
interface LegacyProjectFloorStructureCommandPayload {
floor: LegacyProjectFloorSnapshot;
}
interface ProjectRoomStructureCommandPayload { interface ProjectRoomStructureCommandPayload {
room: ProjectRoomSnapshot; room: ProjectRoomSnapshot;
} }
interface ProjectBuildingUpdateCommandPayload {
expected: ProjectBuildingSnapshot;
target: ProjectBuildingSnapshot;
}
interface ProjectFloorUpdateCommandPayload { interface ProjectFloorUpdateCommandPayload {
expected: ProjectFloorSnapshot; expected: ProjectFloorSnapshot;
target: ProjectFloorSnapshot; target: ProjectFloorSnapshot;
} }
interface LegacyProjectFloorUpdateCommandPayload {
expected: LegacyProjectFloorSnapshot;
target: LegacyProjectFloorSnapshot;
}
interface ProjectRoomUpdateCommandPayload { interface ProjectRoomUpdateCommandPayload {
expected: ProjectRoomSnapshot; expected: ProjectRoomSnapshot;
target: 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 export interface ProjectFloorInsertProjectCommand
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> { extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion; schemaVersion: typeof projectFloorCommandSchemaVersion;
type: typeof projectFloorInsertCommandType; type: typeof projectFloorInsertCommandType;
} }
export interface ProjectFloorDeleteProjectCommand export interface ProjectFloorDeleteProjectCommand
extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> { extends SerializedProjectCommand<ProjectFloorStructureCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion; schemaVersion: typeof projectFloorCommandSchemaVersion;
type: typeof projectFloorDeleteCommandType; type: typeof projectFloorDeleteCommandType;
} }
export interface ProjectFloorUpdateProjectCommand export interface ProjectFloorUpdateProjectCommand
extends SerializedProjectCommand<ProjectFloorUpdateCommandPayload> { extends SerializedProjectCommand<ProjectFloorUpdateCommandPayload> {
schemaVersion: typeof projectLocationStructureCommandSchemaVersion; 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;
type: typeof projectFloorUpdateCommandType; type: typeof projectFloorUpdateCommandType;
} }
@ -79,23 +154,46 @@ export interface ProjectRoomUpdateProjectCommand
} }
export type ProjectLocationStructureProjectCommand = export type ProjectLocationStructureProjectCommand =
| ProjectBuildingInsertProjectCommand
| ProjectBuildingDeleteProjectCommand
| ProjectBuildingUpdateProjectCommand
| ProjectFloorInsertProjectCommand | ProjectFloorInsertProjectCommand
| ProjectFloorDeleteProjectCommand | ProjectFloorDeleteProjectCommand
| ProjectFloorUpdateProjectCommand | ProjectFloorUpdateProjectCommand
| LegacyProjectFloorInsertProjectCommand
| LegacyProjectFloorDeleteProjectCommand
| LegacyProjectFloorUpdateProjectCommand
| ProjectRoomInsertProjectCommand | ProjectRoomInsertProjectCommand
| ProjectRoomDeleteProjectCommand | ProjectRoomDeleteProjectCommand
| ProjectRoomUpdateProjectCommand; | ProjectRoomUpdateProjectCommand;
export function createProjectFloorSnapshot( export function createProjectBuildingSnapshot(
projectId: string, projectId: string,
name: string, name: string,
sortOrder: number 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
): ProjectFloorSnapshot { ): ProjectFloorSnapshot {
const floor: ProjectFloorSnapshot = { const floor: ProjectFloorSnapshot = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
projectId, projectId,
name: name.trim(), name: name.trim(),
sortOrder, sortOrder,
buildingId: buildingId?.trim() || null,
}; };
assertProjectFloorSnapshot(floor); assertProjectFloorSnapshot(floor);
return floor; return floor;
@ -120,12 +218,46 @@ export function createProjectRoomSnapshot(
return room; 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( export function createProjectFloorInsertProjectCommand(
floor: ProjectFloorSnapshot floor: ProjectFloorSnapshot
): ProjectFloorInsertProjectCommand { ): ProjectFloorInsertProjectCommand {
assertProjectFloorSnapshot(floor); assertProjectFloorSnapshot(floor);
return { return {
schemaVersion: projectLocationStructureCommandSchemaVersion, schemaVersion: projectFloorCommandSchemaVersion,
type: projectFloorInsertCommandType, type: projectFloorInsertCommandType,
payload: { floor }, payload: { floor },
}; };
@ -136,7 +268,7 @@ export function createProjectFloorDeleteProjectCommand(
): ProjectFloorDeleteProjectCommand { ): ProjectFloorDeleteProjectCommand {
assertProjectFloorSnapshot(floor); assertProjectFloorSnapshot(floor);
return { return {
schemaVersion: projectLocationStructureCommandSchemaVersion, schemaVersion: projectFloorCommandSchemaVersion,
type: projectFloorDeleteCommandType, type: projectFloorDeleteCommandType,
payload: { floor }, payload: { floor },
}; };
@ -170,7 +302,7 @@ export function createProjectFloorUpdateProjectCommand(
): ProjectFloorUpdateProjectCommand { ): ProjectFloorUpdateProjectCommand {
assertMatchingFloorUpdate(expected, target); assertMatchingFloorUpdate(expected, target);
return { return {
schemaVersion: projectLocationStructureCommandSchemaVersion, schemaVersion: projectFloorCommandSchemaVersion,
type: projectFloorUpdateCommandType, type: projectFloorUpdateCommandType,
payload: { expected, target }, payload: { expected, target },
}; };
@ -188,9 +320,43 @@ 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( export function assertProjectFloorInsertProjectCommand(
command: SerializedProjectCommand<unknown> command: SerializedProjectCommand<unknown>
): asserts command is ProjectFloorInsertProjectCommand { ): asserts command is
| ProjectFloorInsertProjectCommand
| LegacyProjectFloorInsertProjectCommand {
assertProjectFloorStructureProjectCommand( assertProjectFloorStructureProjectCommand(
command, command,
projectFloorInsertCommandType projectFloorInsertCommandType
@ -199,7 +365,9 @@ export function assertProjectFloorInsertProjectCommand(
export function assertProjectFloorDeleteProjectCommand( export function assertProjectFloorDeleteProjectCommand(
command: SerializedProjectCommand<unknown> command: SerializedProjectCommand<unknown>
): asserts command is ProjectFloorDeleteProjectCommand { ): asserts command is
| ProjectFloorDeleteProjectCommand
| LegacyProjectFloorDeleteProjectCommand {
assertProjectFloorStructureProjectCommand( assertProjectFloorStructureProjectCommand(
command, command,
projectFloorDeleteCommandType projectFloorDeleteCommandType
@ -226,15 +394,26 @@ export function assertProjectRoomDeleteProjectCommand(
export function assertProjectFloorUpdateProjectCommand( export function assertProjectFloorUpdateProjectCommand(
command: SerializedProjectCommand<unknown> command: SerializedProjectCommand<unknown>
): asserts command is ProjectFloorUpdateProjectCommand { ): asserts command is
| ProjectFloorUpdateProjectCommand
| LegacyProjectFloorUpdateProjectCommand {
if ( if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
command.type !== projectFloorUpdateCommandType || command.type !== projectFloorUpdateCommandType ||
!isPlainObject(command.payload) || !isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 2 Object.keys(command.payload).length !== 2
) { ) {
throw new Error("Unsupported project-floor update command."); 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); assertMatchingFloorUpdate(command.payload.expected, command.payload.target);
} }
@ -252,6 +431,68 @@ export function assertProjectRoomUpdateProjectCommand(
assertMatchingRoomUpdate(command.payload.expected, command.payload.target); 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) { function assertMatchingFloorUpdate(expected: unknown, target: unknown) {
assertProjectFloorSnapshot(expected); assertProjectFloorSnapshot(expected);
assertProjectFloorSnapshot(target); assertProjectFloorSnapshot(target);
@ -259,9 +500,11 @@ function assertMatchingFloorUpdate(expected: unknown, target: unknown) {
expected.id !== target.id || expected.id !== target.id ||
expected.projectId !== target.projectId || expected.projectId !== target.projectId ||
expected.sortOrder !== target.sortOrder || expected.sortOrder !== target.sortOrder ||
expected.name === target.name (expected.name === target.name && expected.buildingId === target.buildingId)
) { ) {
throw new Error("Project-floor update must change only the name."); throw new Error(
"Project-floor update must change only the name or the building."
);
} }
} }
@ -277,22 +520,49 @@ 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( export function assertProjectFloorSnapshot(
floor: unknown floor: unknown
): asserts floor is ProjectFloorSnapshot { ): 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) { if (!isPlainObject(floor) || Object.keys(floor).length !== 4) {
throw new Error("Project-floor snapshot is invalid."); throw new Error("Project-floor snapshot is invalid.");
} }
assertLegacyFloorFields(floor);
}
function assertLegacyFloorFields(floor: Record<string, unknown>) {
assertNormalizedNonEmptyString(floor.id, "floor.id"); assertNormalizedNonEmptyString(floor.id, "floor.id");
assertNormalizedNonEmptyString(floor.projectId, "floor.projectId"); assertNormalizedNonEmptyString(floor.projectId, "floor.projectId");
assertNormalizedNonEmptyString(floor.name, "floor.name"); assertNormalizedNonEmptyString(floor.name, "floor.name");
if ( assertSortOrder(floor.sortOrder, "floor.sortOrder");
typeof floor.sortOrder !== "number" || }
!Number.isSafeInteger(floor.sortOrder) ||
floor.sortOrder < 0 function upgradeLegacyFloorSnapshot(floor: unknown): ProjectFloorSnapshot {
) { assertLegacyProjectFloorSnapshot(floor);
throw new Error("floor.sortOrder must be a non-negative integer."); return { ...floor, buildingId: null };
}
} }
export function assertProjectRoomSnapshot( export function assertProjectRoomSnapshot(
@ -310,11 +580,11 @@ export function assertProjectRoomSnapshot(
assertNormalizedNonEmptyString(room.roomName, "room.roomName"); assertNormalizedNonEmptyString(room.roomName, "room.roomName");
} }
function assertProjectFloorStructureProjectCommand( function assertProjectBuildingStructureProjectCommand(
command: SerializedProjectCommand<unknown>, command: SerializedProjectCommand<unknown>,
expectedType: expectedType:
| typeof projectFloorInsertCommandType | typeof projectBuildingInsertCommandType
| typeof projectFloorDeleteCommandType | typeof projectBuildingDeleteCommandType
) { ) {
if ( if (
command.schemaVersion !== projectLocationStructureCommandSchemaVersion || command.schemaVersion !== projectLocationStructureCommandSchemaVersion ||
@ -322,6 +592,29 @@ function assertProjectFloorStructureProjectCommand(
!isPlainObject(command.payload) || !isPlainObject(command.payload) ||
Object.keys(command.payload).length !== 1 Object.keys(command.payload).length !== 1
) { ) {
throw new Error("Unsupported project-building structure command.");
}
assertProjectBuildingSnapshot(command.payload.building);
}
function assertProjectFloorStructureProjectCommand(
command: SerializedProjectCommand<unknown>,
expectedType:
| typeof projectFloorInsertCommandType
| typeof projectFloorDeleteCommandType
) {
if (
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."); throw new Error("Unsupported project-floor structure command.");
} }
assertProjectFloorSnapshot(command.payload.floor); assertProjectFloorSnapshot(command.payload.floor);
@ -344,6 +637,16 @@ function assertProjectRoomStructureProjectCommand(
assertProjectRoomSnapshot(command.payload.room); 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( function assertNormalizedNonEmptyString(
value: unknown, value: unknown,
field: string field: string

View file

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

View file

@ -1,5 +1,13 @@
import { z } from "zod"; import { z } from "zod";
import { distributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js"; import {
distributionBoardInfoFieldMaxLengths,
distributionBoardSupplyTypes,
type DistributionBoardInfoField,
} from "../../shared/constants/distribution-board.js";
import {
projectCoverFieldMaxLengths,
type ProjectCoverField,
} from "../../shared/constants/project-cover.js";
import { import {
circuitGroupCategories, circuitGroupCategories,
} from "../../shared/constants/circuit-group.js"; } from "../../shared/constants/circuit-group.js";
@ -27,7 +35,9 @@ import {
type ExternalModelStateSnapshot, type ExternalModelStateSnapshot,
} from "../../external-model/domain/external-model-contracts.js"; } from "../../external-model/domain/external-model-contracts.js";
export const projectStateSnapshotSchemaVersion = 5 as const; export const projectStateSnapshotSchemaVersion = 7 as const;
const coverProjectStateSnapshotSchemaVersion = 6 as const;
const boardInfoProjectStateSnapshotSchemaVersion = 5 as const;
const previousProjectStateSnapshotSchemaVersion = 4 as const; const previousProjectStateSnapshotSchemaVersion = 4 as const;
const externalModelProjectStateSnapshotSchemaVersion = 3 as const; const externalModelProjectStateSnapshotSchemaVersion = 3 as const;
const legacyProjectStateSnapshotSchemaVersion = 2 as const; const legacyProjectStateSnapshotSchemaVersion = 2 as const;
@ -58,11 +68,31 @@ const previousProjectSchema = z
}) })
.strict(); .strict();
const projectSchema = previousProjectSchema const publicBuildingProjectSchema = previousProjectSchema
.extend({ isPublicBuilding: z.boolean() }) .extend({ isPublicBuilding: z.boolean() })
.strict(); .strict();
const distributionBoardSchema = z 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
.object({ .object({
id: idSchema, id: idSchema,
projectId: idSchema, projectId: idSchema,
@ -73,6 +103,35 @@ const distributionBoardSchema = z
}) })
.strict(); .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 const circuitListSchema = z
.object({ .object({
id: idSchema, id: idSchema,
@ -179,7 +238,7 @@ const projectDeviceSchema = z
}) })
.strict(); .strict();
const floorSchema = z const buildingSchema = z
.object({ .object({
id: idSchema, id: idSchema,
projectId: idSchema, projectId: idSchema,
@ -188,6 +247,19 @@ const floorSchema = z
}) })
.strict(); .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 const roomSchema = z
.object({ .object({
id: idSchema, id: idSchema,
@ -432,11 +504,24 @@ export function parseExternalModelStateSnapshot(
return externalModelStateSchema.parse(value); return externalModelStateSchema.parse(value);
} }
const projectStateSnapshotContents = { // Versions 1-6 have floors without a building and no building list.
const previousProjectStateSnapshotContents = {
circuitLists: z.array(circuitListSchema), circuitLists: z.array(circuitListSchema),
circuitSections: z.array(circuitSectionSchema), circuitSections: z.array(circuitSectionSchema),
circuits: z.array(circuitSchema), circuits: z.array(circuitSchema),
projectDevices: z.array(projectDeviceSchema), 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), floors: z.array(floorSchema),
rooms: z.array(roomSchema), rooms: z.array(roomSchema),
distributionBoardComponents: z.array(distributionBoardComponentSchema), distributionBoardComponents: z.array(distributionBoardComponentSchema),
@ -457,33 +542,55 @@ export const projectStateSnapshotSchema = z
}) })
.strict(); .strict();
const previousProjectStateSnapshotSchema = z const coverProjectStateSnapshotSchema = z
.object({ .object({
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion), schemaVersion: z.literal(coverProjectStateSnapshotSchemaVersion),
project: projectSchema, project: projectSchema,
distributionBoards: z.array(distributionBoardSchema), distributionBoards: z.array(distributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
externalModel: externalModelStateSchema, externalModel: externalModelStateSchema,
...projectStateSnapshotContents, ...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,
}) })
.strict(); .strict();
const externalModelProjectStateSnapshotSchema = z const externalModelProjectStateSnapshotSchema = z
.object({ .object({
schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion), schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion),
project: projectSchema, project: publicBuildingProjectSchema,
distributionBoards: z.array(distributionBoardSchema), distributionBoards: z.array(legacyDistributionBoardSchema),
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(), externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
...projectStateSnapshotContents, ...previousProjectStateSnapshotContents,
}) })
.strict(); .strict();
const legacyProjectStateSnapshotSchema = z const legacyProjectStateSnapshotSchema = z
.object({ .object({
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion), schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
project: projectSchema, project: publicBuildingProjectSchema,
distributionBoards: z.array(distributionBoardSchema), distributionBoards: z.array(legacyDistributionBoardSchema),
...projectStateSnapshotContents, ...previousProjectStateSnapshotContents,
}) })
.strict(); .strict();
@ -491,8 +598,8 @@ const baselineProjectStateSnapshotSchema = z
.object({ .object({
schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion), schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion),
project: previousProjectSchema, project: previousProjectSchema,
distributionBoards: z.array(distributionBoardSchema), distributionBoards: z.array(legacyDistributionBoardSchema),
...projectStateSnapshotContents, ...previousProjectStateSnapshotContents,
}) })
.strict(); .strict();
@ -500,6 +607,20 @@ export type ProjectStateSnapshot = z.infer<
typeof projectStateSnapshotSchema 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( export function parseProjectStateSnapshot(
value: unknown value: unknown
): ProjectStateSnapshot { ): ProjectStateSnapshot {
@ -512,6 +633,28 @@ export function parseProjectStateSnapshot(
} }
function upgradePreviousProjectStateSnapshot(value: unknown): unknown { 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 ( if (
value === null || value === null ||
typeof value !== "object" || typeof value !== "object" ||
@ -521,18 +664,40 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
return value; return value;
} }
const schemaVersion = (value as { schemaVersion: number }).schemaVersion; 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) { if (schemaVersion === previousProjectStateSnapshotSchemaVersion) {
const previous = previousProjectStateSnapshotSchema.parse(value); const previous = previousProjectStateSnapshotSchema.parse(value);
return { return {
...previous, ...previous,
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: coverProjectStateSnapshotSchemaVersion,
project: { ...previous.project, ...emptyProjectCoverValues },
distributionBoards: withEmptyDistributionBoardInfo(
previous.distributionBoards
),
}; };
} }
if (schemaVersion === externalModelProjectStateSnapshotSchemaVersion) { if (schemaVersion === externalModelProjectStateSnapshotSchemaVersion) {
const previous = externalModelProjectStateSnapshotSchema.parse(value); const previous = externalModelProjectStateSnapshotSchema.parse(value);
return { return {
...previous, ...previous,
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: coverProjectStateSnapshotSchemaVersion,
project: { ...previous.project, ...emptyProjectCoverValues },
distributionBoards: withEmptyDistributionBoardInfo(
previous.distributionBoards
),
externalModel: emptyExternalModelState(), externalModel: emptyExternalModelState(),
}; };
} }
@ -540,7 +705,11 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const legacy = legacyProjectStateSnapshotSchema.parse(value); const legacy = legacyProjectStateSnapshotSchema.parse(value);
return { return {
...legacy, ...legacy,
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: coverProjectStateSnapshotSchemaVersion,
project: { ...legacy.project, ...emptyProjectCoverValues },
distributionBoards: withEmptyDistributionBoardInfo(
legacy.distributionBoards
),
externalCsvConfiguration: null, externalCsvConfiguration: null,
externalModel: emptyExternalModelState(), externalModel: emptyExternalModelState(),
}; };
@ -549,8 +718,15 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
const baseline = baselineProjectStateSnapshotSchema.parse(value); const baseline = baselineProjectStateSnapshotSchema.parse(value);
return { return {
...baseline, ...baseline,
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: coverProjectStateSnapshotSchemaVersion,
project: { ...baseline.project, isPublicBuilding: false }, project: {
...baseline.project,
isPublicBuilding: false,
...emptyProjectCoverValues,
},
distributionBoards: withEmptyDistributionBoardInfo(
baseline.distributionBoards
),
externalCsvConfiguration: null, externalCsvConfiguration: null,
externalModel: emptyExternalModelState(), externalModel: emptyExternalModelState(),
}; };
@ -649,6 +825,7 @@ function assertProjectStateSnapshotRelations(
snapshot.projectDevices, snapshot.projectDevices,
"project device" "project device"
); );
const buildingIds = uniqueIds(snapshot.buildings, "building");
const floorIds = uniqueIds(snapshot.floors, "floor"); const floorIds = uniqueIds(snapshot.floors, "floor");
const roomIds = uniqueIds(snapshot.rooms, "room"); const roomIds = uniqueIds(snapshot.rooms, "room");
const circuitIds = uniqueIds(snapshot.circuits, "circuit"); const circuitIds = uniqueIds(snapshot.circuits, "circuit");
@ -727,8 +904,14 @@ function assertProjectStateSnapshotRelations(
); );
} }
} }
for (const building of snapshot.buildings) {
assertProjectOwnership(building.projectId, projectId, "building");
}
for (const floor of snapshot.floors) { for (const floor of snapshot.floors) {
assertProjectOwnership(floor.projectId, projectId, "floor"); assertProjectOwnership(floor.projectId, projectId, "floor");
if (floor.buildingId !== null) {
assertReference(buildingIds, floor.buildingId, "floor building");
}
} }
for (const room of snapshot.rooms) { for (const room of snapshot.rooms) {
assertProjectOwnership(room.projectId, projectId, "room"); assertProjectOwnership(room.projectId, projectId, "room");

View file

@ -84,6 +84,7 @@ export function remapProjectState(
createId createId
); );
const deviceIds = idMap(source.projectDevices, createId); const deviceIds = idMap(source.projectDevices, createId);
const buildingIds = idMap(source.buildings, createId);
const floorIds = idMap(source.floors, createId); const floorIds = idMap(source.floors, createId);
const roomIds = idMap(source.rooms, createId); const roomIds = idMap(source.rooms, createId);
const rowIds = new Map( const rowIds = new Map(
@ -242,10 +243,19 @@ export function remapProjectState(
id: requiredId(deviceIds, device.id), id: requiredId(deviceIds, device.id),
projectId: targetProjectId, projectId: targetProjectId,
})), })),
buildings: source.buildings.map((building) => ({
...building,
id: requiredId(buildingIds, building.id),
projectId: targetProjectId,
})),
floors: source.floors.map((floor) => ({ floors: source.floors.map((floor) => ({
...floor, ...floor,
id: requiredId(floorIds, floor.id), id: requiredId(floorIds, floor.id),
projectId: targetProjectId, projectId: targetProjectId,
buildingId:
floor.buildingId === null
? null
: requiredId(buildingIds, floor.buildingId),
})), })),
rooms: source.rooms.map((room) => ({ rooms: source.rooms.map((room) => ({
...room, ...room,

View file

@ -70,12 +70,18 @@ import {
type SerializedProjectCommand, type SerializedProjectCommand,
} from "../models/project-command.model.js"; } from "../models/project-command.model.js";
import { import {
assertProjectBuildingDeleteProjectCommand,
assertProjectBuildingInsertProjectCommand,
assertProjectBuildingUpdateProjectCommand,
assertProjectFloorDeleteProjectCommand, assertProjectFloorDeleteProjectCommand,
assertProjectFloorInsertProjectCommand, assertProjectFloorInsertProjectCommand,
assertProjectFloorUpdateProjectCommand, assertProjectFloorUpdateProjectCommand,
assertProjectRoomDeleteProjectCommand, assertProjectRoomDeleteProjectCommand,
assertProjectRoomInsertProjectCommand, assertProjectRoomInsertProjectCommand,
assertProjectRoomUpdateProjectCommand, assertProjectRoomUpdateProjectCommand,
projectBuildingDeleteCommandType,
projectBuildingInsertCommandType,
projectBuildingUpdateCommandType,
projectFloorDeleteCommandType, projectFloorDeleteCommandType,
projectFloorInsertCommandType, projectFloorInsertCommandType,
projectFloorUpdateCommandType, projectFloorUpdateCommandType,
@ -492,6 +498,18 @@ export class ProjectCommandService implements ProjectCommandExecutor {
command: input.command, command: input.command,
}).revision; }).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: { case projectFloorInsertCommandType: {
assertProjectFloorInsertProjectCommand(input.command); assertProjectFloorInsertProjectCommand(input.command);
return this.projectLocationStructureStore.execute({ return this.projectLocationStructureStore.execute({

View file

@ -4,12 +4,14 @@ import { useEffect, useState } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
const PROJECT_AREA_PATTERN = /^\/projects(\/[^/]+)?$/; // 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 SIDEBAR_COLLAPSED_STORAGE_KEY = "leistungsbilanz:sidebar-collapsed"; const SIDEBAR_COLLAPSED_STORAGE_KEY = "leistungsbilanz:sidebar-collapsed";
export function AppShell({ children }: { children: React.ReactNode }) { export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const showSidebar = PROJECT_AREA_PATTERN.test(pathname ?? ""); const showSidebar = !PRINT_VIEW_PATTERN.test(pathname ?? "");
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
useEffect(() => { useEffect(() => {

View file

@ -7,14 +7,21 @@ import { listProjects } from "../utils/api";
import type { ProjectDto } from "../types"; import type { ProjectDto } from "../types";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
// Anchors on the project overview page (/projects/[id]).
const PROJECT_SECTIONS = [ const PROJECT_SECTIONS = [
{ id: "verlauf", label: "Verlauf", icon: "↺" }, { id: "verlauf", label: "Verlauf", icon: "↺" },
{ id: "verteilungen", label: "Verteilungen", icon: "⌁" }, { id: "verteilungen", label: "Verteilungen", icon: "⌁" },
{ id: "etagen", label: "Etagen", icon: "▥" },
{ id: "raeume", label: "Räume", icon: "⌂" },
{ id: "projektgeraete", label: "Projektgeräte", icon: "⚙" }, { id: "projektgeraete", label: "Projektgeräte", icon: "⚙" },
] as const; ] as const;
// Separate project sub-routes (/projects/[id]/<segment>).
const PROJECT_ROUTES = [
{ segment: "struktur", label: "Gebäudestruktur", icon: "▥" },
] as const;
// Matches the project overview and all of its sub-routes (group 2 = rest).
const PROJECT_PATH_PATTERN = /^\/projects\/([^/]+)(\/.*)?$/;
interface SidebarProps { interface SidebarProps {
isCollapsed: boolean; isCollapsed: boolean;
onToggleCollapsed: () => void; onToggleCollapsed: () => void;
@ -22,8 +29,14 @@ interface SidebarProps {
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const projectMatch = pathname?.match(/^\/projects\/([^/]+)$/); const projectMatch = pathname?.match(PROJECT_PATH_PATTERN);
const activeProjectId = projectMatch ? projectMatch[1] : null; 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 [projects, setProjects] = useState<ProjectDto[]>([]);
const [activeSection, setActiveSection] = useState<string | null>(null); const [activeSection, setActiveSection] = useState<string | null>(null);
@ -42,7 +55,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!activeProjectId) { if (!overviewProjectId) {
setActiveSection(null); setActiveSection(null);
return; return;
} }
@ -69,7 +82,7 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
elements.forEach((element) => observer.observe(element)); elements.forEach((element) => observer.observe(element));
return () => observer.disconnect(); return () => observer.disconnect();
}, [activeProjectId]); }, [overviewProjectId]);
return ( return (
<aside className={isCollapsed ? "sidebar sidebar-collapsed" : "sidebar"}> <aside className={isCollapsed ? "sidebar sidebar-collapsed" : "sidebar"}>
@ -122,17 +135,46 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
{activeProjectId && ( {activeProjectId && (
<> <>
<div className="sidebar-section-label">In diesem Projekt</div> <div className="sidebar-section-label">In diesem Projekt</div>
{PROJECT_SECTIONS.map(({ id, label, icon }) => ( {PROJECT_SECTIONS.map(({ id, label, icon }) =>
<a overviewProjectId ? (
key={id} <a
href={`#${id}`} key={id}
className={activeSection === id ? "sidebar-link active" : "sidebar-link"} href={`#${id}`}
title={label} className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
> title={label}
<span className="sidebar-icon">{icon}</span> >
<span className="sidebar-label">{label}</span> <span className="sidebar-icon">{icon}</span>
</a> <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> </nav>

View file

@ -0,0 +1,110 @@
"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

@ -0,0 +1,49 @@
"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

@ -7,9 +7,16 @@ import {
distributionBoardSupplyTypes, distributionBoardSupplyTypes,
type DistributionBoardSupplyType, type DistributionBoardSupplyType,
} from "../../shared/constants/distribution-board"; } from "../../shared/constants/distribution-board";
import {
projectCoverFieldLabels,
projectCoverFieldMaxLengths,
projectCoverFields,
type ProjectCoverField,
} from "../../shared/constants/project-cover";
import { FormModal } from "./form-modal"; import { FormModal } from "./form-modal";
export interface ProjectSettingsInput { export interface ProjectSettingsInput
extends Record<ProjectCoverField, string | null> {
name: string; name: string;
internalProjectNumber: string | null; internalProjectNumber: string | null;
externalProjectNumber: string | null; externalProjectNumber: string | null;
@ -21,6 +28,13 @@ export interface ProjectSettingsInput {
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[]; enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
} }
const coverFieldPlaceholders: Record<ProjectCoverField, string> = {
siteAddress: "Straße Nr.\nPLZ Ort",
workStage: "z. B. LPH 5 – Ausführungsplanung",
projectLead: "",
projectEditor: "",
};
interface ProjectSettingsModalProps { interface ProjectSettingsModalProps {
isSaving: boolean; isSaving: boolean;
onClose: () => void; onClose: () => void;
@ -71,6 +85,14 @@ export function ProjectSettingsModal({
] = useState<DistributionBoardSupplyType[]>( ] = useState<DistributionBoardSupplyType[]>(
project.enabledDistributionBoardSupplyTypes 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 [transfer, setTransfer] = useState<unknown>(null);
const [transferFilename, setTransferFilename] = useState(""); const [transferFilename, setTransferFilename] = useState("");
const [importMode, setImportMode] = useState<"replace" | "duplicate">( const [importMode, setImportMode] = useState<"replace" | "duplicate">(
@ -91,6 +113,10 @@ export function ProjectSettingsModal({
singlePhaseVoltageV: Number(singlePhaseVoltageV), singlePhaseVoltageV: Number(singlePhaseVoltageV),
threePhaseVoltageV: Number(threePhaseVoltageV), threePhaseVoltageV: Number(threePhaseVoltageV),
enabledDistributionBoardSupplyTypes, enabledDistributionBoardSupplyTypes,
siteAddress: toNullableString(coverValues.siteAddress),
workStage: toNullableString(coverValues.workStage),
projectLead: toNullableString(coverValues.projectLead),
projectEditor: toNullableString(coverValues.projectEditor),
}); });
} }
@ -309,6 +335,57 @@ export function ProjectSettingsModal({
value={threePhaseVoltageV} value={threePhaseVoltageV}
/> />
</div> </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"> <div className="col-12">
<hr className="my-2" /> <hr className="my-2" />
<h3 className="h6">Projekt importieren oder exportieren</h3> <h3 className="h6">Projekt importieren oder exportieren</h3>

View file

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

View file

@ -0,0 +1,191 @@
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

@ -0,0 +1,481 @@
/* 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

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

View file

@ -1,5 +1,7 @@
import type { import type {
BuildingDto,
CircuitListDto, CircuitListDto,
CreateBuildingInput,
CreateFloorInput, CreateFloorInput,
CreateProjectDeviceInput, CreateProjectDeviceInput,
CreateRoomInput, CreateRoomInput,
@ -8,12 +10,16 @@ import type {
DistributionBoardCommandResultDto, DistributionBoardCommandResultDto,
DistributionBoardCopyCommandResultDto, DistributionBoardCopyCommandResultDto,
DistributionBoardDeleteCommandResultDto, DistributionBoardDeleteCommandResultDto,
DistributionBoardInfoDto,
DocumentLogoMetadataDto,
FloorDto, FloorDto,
OfficeProfileDto,
GlobalDeviceDto, GlobalDeviceDto,
ProjectCommandDto, ProjectCommandDto,
ProjectCommandResultDto, ProjectCommandResultDto,
ProjectDeviceCommandResultDto, ProjectDeviceCommandResultDto,
ProjectDeviceDto, ProjectDeviceDto,
ProjectBuildingCommandResultDto,
ProjectFloorCommandResultDto, ProjectFloorCommandResultDto,
ProjectHistoryStateDto, ProjectHistoryStateDto,
ProjectRevisionPageDto, ProjectRevisionPageDto,
@ -256,7 +262,7 @@ export function updateProjectSettings(
singlePhaseVoltageV: number; singlePhaseVoltageV: number;
threePhaseVoltageV: number; threePhaseVoltageV: number;
enabledDistributionBoardSupplyTypes: ProjectDto["enabledDistributionBoardSupplyTypes"]; enabledDistributionBoardSupplyTypes: ProjectDto["enabledDistributionBoardSupplyTypes"];
} } & Partial<Pick<ProjectDto, "siteAddress" | "workStage" | "projectLead" | "projectEditor">>
) { ) {
return request<ProjectSettingsCommandResultDto>( return request<ProjectSettingsCommandResultDto>(
`/api/projects/${projectId}`, `/api/projects/${projectId}`,
@ -329,7 +335,7 @@ export function updateDistributionBoard(
floorId: string | null; floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>; supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
simultaneityFactor: number; simultaneityFactor: number;
}, } & Partial<DistributionBoardInfoDto>,
expectedRevision: number expectedRevision: number
) { ) {
return request<DistributionBoardCommandResultDto>( return request<DistributionBoardCommandResultDto>(
@ -341,6 +347,42 @@ export function updateDistributionBoard(
); );
} }
export const documentLogoUrl = "/api/document-branding/logo";
export function getDocumentBranding() {
return request<{
logo: DocumentLogoMetadataDto | null;
office: OfficeProfileDto;
}>("/api/document-branding");
}
export function updateOfficeProfile(office: OfficeProfileDto) {
return request<{ office: OfficeProfileDto }>("/api/document-branding/office", {
method: "PUT",
body: JSON.stringify(office),
});
}
export async function uploadDocumentLogo(file: File) {
const bytes = new Uint8Array(await file.arrayBuffer());
let binary = "";
for (let index = 0; index < bytes.length; index += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
}
return request<{ logo: DocumentLogoMetadataDto }>(documentLogoUrl, {
method: "PUT",
body: JSON.stringify({
fileName: file.name,
mimeType: file.type,
contentBase64: btoa(binary),
}),
});
}
export function deleteDocumentLogo() {
return request<void>(documentLogoUrl, { method: "DELETE" });
}
export function getExternalCsvConfiguration(projectId: string) { export function getExternalCsvConfiguration(projectId: string) {
return request<{ configuration: ExternalCsvConfigurationSnapshotDto | null }>( return request<{ configuration: ExternalCsvConfigurationSnapshotDto | null }>(
`/api/projects/${projectId}/external-csv/configuration` `/api/projects/${projectId}/external-csv/configuration`
@ -842,6 +884,61 @@ export function renumberCircuitSectionCommand(
); );
} }
export function listBuildings(projectId: string) {
return request<BuildingDto[]>(`/api/projects/${projectId}/buildings`);
}
export function createBuilding(
projectId: string,
input: CreateBuildingInput,
expectedRevision: number
) {
return request<ProjectBuildingCommandResultDto>(
`/api/projects/${projectId}/buildings`,
{
method: "POST",
body: JSON.stringify({ name: input.name, expectedRevision }),
}
);
}
export function updateBuilding(
projectId: string,
buildingId: string,
input: CreateBuildingInput,
expectedRevision: number
) {
return request<ProjectBuildingCommandResultDto>(
`/api/projects/${projectId}/buildings/${buildingId}`,
{
method: "PUT",
body: JSON.stringify({ name: input.name, expectedRevision }),
}
);
}
export function deleteBuilding(
projectId: string,
buildingId: string,
expectedRevision: number
) {
return request<ProjectCommandResultDto>(
`/api/projects/${projectId}/buildings/${buildingId}`,
{
method: "DELETE",
body: JSON.stringify({ expectedRevision }),
}
);
}
// buildingId is only sent when the caller passes it (string or null);
// an omitted buildingId keeps the current assignment on update.
function toFloorRequestBody(input: CreateFloorInput, expectedRevision: number) {
return input.buildingId === undefined
? { name: input.name, expectedRevision }
: { name: input.name, buildingId: input.buildingId, expectedRevision };
}
export function listFloors(projectId: string) { export function listFloors(projectId: string) {
return request<FloorDto[]>(`/api/projects/${projectId}/floors`); return request<FloorDto[]>(`/api/projects/${projectId}/floors`);
} }
@ -855,7 +952,7 @@ export function createFloor(
`/api/projects/${projectId}/floors`, `/api/projects/${projectId}/floors`,
{ {
method: "POST", method: "POST",
body: JSON.stringify({ ...input, expectedRevision }), body: JSON.stringify(toFloorRequestBody(input, expectedRevision)),
} }
); );
} }
@ -868,7 +965,7 @@ export function updateFloor(
) { ) {
return request<ProjectFloorCommandResultDto>(`/api/projects/${projectId}/floors/${floorId}`, { return request<ProjectFloorCommandResultDto>(`/api/projects/${projectId}/floors/${floorId}`, {
method: "PUT", method: "PUT",
body: JSON.stringify({ ...input, expectedRevision }), body: JSON.stringify(toFloorRequestBody(input, expectedRevision)),
}); });
} }

View file

@ -0,0 +1,284 @@
// Pure selection, filtering and grouping rules for the building structure
// view (Gebäude → Etagen → Räume) and grouped floor selects.
import type { BuildingDto, FloorDto, RoomDto } from "../types";
/** Selection key for "Alle" in the building and floor columns. */
export const ALL_SELECTION = "__all__";
/** Selection key for "Ohne Gebäude" / "Ohne Etage". */
export const UNASSIGNED_SELECTION = "__unassigned__";
/** ALL_SELECTION, UNASSIGNED_SELECTION or a building/floor id. */
export type StructureSelection = string;
export const UNASSIGNED_BUILDING_LABEL = "Ohne Gebäude";
export const UNASSIGNED_FLOOR_LABEL = "Ohne Etage";
type BuildingLike = Pick<BuildingDto, "id" | "name">;
type FloorLike = Pick<FloorDto, "id" | "name"> & {
buildingId?: string | null;
};
type RoomLike = Pick<RoomDto, "floorId">;
export interface FloorGroup<TFloor extends FloorLike = FloorLike> {
/** null for floors without (known) building. */
buildingId: string | null;
label: string;
floors: TFloor[];
}
/**
* The building a floor belongs to, or null when it has none or references a
* building that is not (or no longer) loaded.
*/
export function resolveFloorBuildingId(
floor: FloorLike,
buildingIds: ReadonlySet<string>
): string | null {
const buildingId = floor.buildingId ?? null;
return buildingId !== null && buildingIds.has(buildingId)
? buildingId
: null;
}
function toIdSet(entries: ReadonlyArray<{ id: string }>) {
return new Set(entries.map((entry) => entry.id));
}
export function hasFloorsWithoutBuilding(
floors: ReadonlyArray<FloorLike>,
buildings: ReadonlyArray<BuildingLike>
): boolean {
const buildingIds = toIdSet(buildings);
return floors.some(
(floor) => resolveFloorBuildingId(floor, buildingIds) === null
);
}
export function hasRoomsWithoutFloor(
rooms: ReadonlyArray<RoomLike>,
floors: ReadonlyArray<FloorLike>
): boolean {
const floorIds = toIdSet(floors);
return rooms.some(
(room) => room.floorId === null || !floorIds.has(room.floorId)
);
}
/**
* Groups floors by building in building order; floors without a known
* building come last under "Ohne Gebäude". Empty groups are omitted and the
* floor order inside a group is preserved.
*/
export function groupFloorsByBuilding<TFloor extends FloorLike>(
floors: ReadonlyArray<TFloor>,
buildings: ReadonlyArray<BuildingLike>
): FloorGroup<TFloor>[] {
const buildingIds = toIdSet(buildings);
const floorsByBuilding = new Map<string | null, TFloor[]>();
for (const floor of floors) {
const buildingId = resolveFloorBuildingId(floor, buildingIds);
const group = floorsByBuilding.get(buildingId) ?? [];
group.push(floor);
floorsByBuilding.set(buildingId, group);
}
const groups: FloorGroup<TFloor>[] = [];
for (const building of buildings) {
const groupFloors = floorsByBuilding.get(building.id);
if (groupFloors?.length) {
groups.push({
buildingId: building.id,
label: building.name,
floors: groupFloors,
});
}
}
const unassigned = floorsByBuilding.get(null);
if (unassigned?.length) {
groups.push({
buildingId: null,
label: UNASSIGNED_BUILDING_LABEL,
floors: unassigned,
});
}
return groups;
}
/** Floors shown in the floor column for a building selection. */
export function filterFloorsByBuilding<TFloor extends FloorLike>(
floors: ReadonlyArray<TFloor>,
buildings: ReadonlyArray<BuildingLike>,
buildingSelection: StructureSelection
): TFloor[] {
if (buildingSelection === ALL_SELECTION) {
return [...floors];
}
const buildingIds = toIdSet(buildings);
const targetBuildingId =
buildingSelection === UNASSIGNED_SELECTION ? null : buildingSelection;
return floors.filter(
(floor) => resolveFloorBuildingId(floor, buildingIds) === targetBuildingId
);
}
/**
* "Ohne Etage" is offered only when rooms without floor exist and the
* building selection can contain them ("Alle" or "Ohne Gebäude"), because a
* room without floor has no building either.
*/
export function showsRoomsWithoutFloorEntry(
rooms: ReadonlyArray<RoomLike>,
floors: ReadonlyArray<FloorLike>,
buildingSelection: StructureSelection
): boolean {
return (
(buildingSelection === ALL_SELECTION ||
buildingSelection === UNASSIGNED_SELECTION) &&
hasRoomsWithoutFloor(rooms, floors)
);
}
/** Rooms shown in the room column for the current selections. */
export function filterRooms<TRoom extends RoomLike>(
rooms: ReadonlyArray<TRoom>,
floors: ReadonlyArray<FloorLike>,
buildings: ReadonlyArray<BuildingLike>,
buildingSelection: StructureSelection,
floorSelection: StructureSelection
): TRoom[] {
const floorIds = toIdSet(floors);
const isWithoutFloor = (room: RoomLike) =>
room.floorId === null || !floorIds.has(room.floorId);
if (floorSelection === UNASSIGNED_SELECTION) {
return showsRoomsWithoutFloorEntry(rooms, floors, buildingSelection)
? rooms.filter(isWithoutFloor)
: [];
}
if (floorSelection !== ALL_SELECTION) {
return rooms.filter((room) => room.floorId === floorSelection);
}
if (buildingSelection === ALL_SELECTION) {
return [...rooms];
}
const visibleFloorIds = toIdSet(
filterFloorsByBuilding(floors, buildings, buildingSelection)
);
return rooms.filter(
(room) =>
(room.floorId !== null && visibleFloorIds.has(room.floorId)) ||
(buildingSelection === UNASSIGNED_SELECTION && isWithoutFloor(room))
);
}
/** Falls back to "Alle" when a selected building vanished. */
export function normalizeBuildingSelection(
selection: StructureSelection,
floors: ReadonlyArray<FloorLike>,
buildings: ReadonlyArray<BuildingLike>
): StructureSelection {
if (selection === ALL_SELECTION) {
return selection;
}
if (selection === UNASSIGNED_SELECTION) {
return hasFloorsWithoutBuilding(floors, buildings)
? selection
: ALL_SELECTION;
}
return buildings.some((building) => building.id === selection)
? selection
: ALL_SELECTION;
}
/** Falls back to "Alle" when a selected floor is no longer visible. */
export function normalizeFloorSelection(
selection: StructureSelection,
rooms: ReadonlyArray<RoomLike>,
floors: ReadonlyArray<FloorLike>,
buildings: ReadonlyArray<BuildingLike>,
buildingSelection: StructureSelection
): StructureSelection {
if (selection === ALL_SELECTION) {
return selection;
}
if (selection === UNASSIGNED_SELECTION) {
return showsRoomsWithoutFloorEntry(rooms, floors, buildingSelection)
? selection
: ALL_SELECTION;
}
return filterFloorsByBuilding(floors, buildings, buildingSelection).some(
(floor) => floor.id === selection
)
? selection
: ALL_SELECTION;
}
/** Building preselected in the floor dialog ("" = Ohne Gebäude). */
export function defaultBuildingIdForNewFloor(
buildingSelection: StructureSelection
): string {
return buildingSelection === ALL_SELECTION ||
buildingSelection === UNASSIGNED_SELECTION
? ""
: buildingSelection;
}
/** Floor preselected in the room dialog ("" = Ohne Etage). */
export function defaultFloorIdForNewRoom(
floorSelection: StructureSelection
): string {
return floorSelection === ALL_SELECTION ||
floorSelection === UNASSIGNED_SELECTION
? ""
: floorSelection;
}
export function countFloorsByBuilding(
floors: ReadonlyArray<FloorLike>,
buildings: ReadonlyArray<BuildingLike>
): Map<string | null, number> {
const buildingIds = toIdSet(buildings);
const counts = new Map<string | null, number>();
for (const floor of floors) {
const buildingId = resolveFloorBuildingId(floor, buildingIds);
counts.set(buildingId, (counts.get(buildingId) ?? 0) + 1);
}
return counts;
}
/** Room counts per floor id; rooms without known floor count under null. */
export function countRoomsByFloor(
rooms: ReadonlyArray<RoomLike>,
floors: ReadonlyArray<FloorLike>
): Map<string | null, number> {
const floorIds = toIdSet(floors);
const counts = new Map<string | null, number>();
for (const room of rooms) {
const floorId =
room.floorId !== null && floorIds.has(room.floorId)
? room.floorId
: null;
counts.set(floorId, (counts.get(floorId) ?? 0) + 1);
}
return counts;
}
/** "EG · Haus A", "EG" (floor without building) or "Ohne Etage". */
export function describeRoomLocation(
room: RoomLike,
floors: ReadonlyArray<FloorLike>,
buildings: ReadonlyArray<BuildingLike>
): string {
const floor =
room.floorId === null
? undefined
: floors.find((entry) => entry.id === room.floorId);
if (!floor) {
return UNASSIGNED_FLOOR_LABEL;
}
const buildingId = resolveFloorBuildingId(floor, toIdSet(buildings));
const building =
buildingId === null
? undefined
: buildings.find((entry) => entry.id === buildingId);
return building ? `${floor.name} · ${building.name}` : floor.name;
}

View file

@ -46,6 +46,9 @@ const commandTypeLabels: Record<string, string> = {
"circuit.move-group": "Stromkreis in andere Gruppe verschoben", "circuit.move-group": "Stromkreis in andere Gruppe verschoben",
"circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt", "circuit-group.delete-subtree": "Stromkreisgruppe vollständig entfernt",
"circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt", "circuit-group.restore-subtree": "Stromkreisgruppe vollständig wiederhergestellt",
"project-building.insert": "Gebäude angelegt",
"project-building.update": "Gebäude bearbeitet",
"project-building.delete": "Gebäude entfernt",
"project-floor.insert": "Geschoss angelegt", "project-floor.insert": "Geschoss angelegt",
"project-floor.update": "Geschoss bearbeitet", "project-floor.update": "Geschoss bearbeitet",
"project-floor.delete": "Geschoss entfernt", "project-floor.delete": "Geschoss entfernt",

View file

@ -6,6 +6,7 @@ import { CircuitRepository } from "../../db/repositories/circuit.repository.js";
import { CircuitProtectionDeviceRepository } from "../../db/repositories/circuit-protection-device.repository.js"; import { CircuitProtectionDeviceRepository } from "../../db/repositories/circuit-protection-device.repository.js";
import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js"; import { DistributionBoardComponentRepository } from "../../db/repositories/distribution-board-component.repository.js";
import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js"; import { DistributionBoardRepository } from "../../db/repositories/distribution-board.repository.js";
import { BuildingRepository } from "../../db/repositories/building.repository.js";
import { FloorRepository } from "../../db/repositories/floor.repository.js"; import { FloorRepository } from "../../db/repositories/floor.repository.js";
import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js"; import { GlobalDeviceRepository } from "../../db/repositories/global-device.repository.js";
import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js"; import { ProjectDeviceRepository } from "../../db/repositories/project-device.repository.js";
@ -13,6 +14,7 @@ import { ProjectRepository } from "../../db/repositories/project.repository.js";
import { RoomRepository } from "../../db/repositories/room.repository.js"; import { RoomRepository } from "../../db/repositories/room.repository.js";
import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js"; import { ExternalCsvConfigurationRepository } from "../../db/repositories/external-csv-configuration.repository.js";
import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js"; import { ExternalModelStateRepository } from "../../db/repositories/external-model-state.repository.js";
import { DocumentBrandingRepository } from "../../db/repositories/document-branding.repository.js";
export const circuitDeviceRowRepository = export const circuitDeviceRowRepository =
new CircuitDeviceRowRepository(db); new CircuitDeviceRowRepository(db);
@ -25,6 +27,7 @@ export const distributionBoardComponentRepository =
new DistributionBoardComponentRepository(db); new DistributionBoardComponentRepository(db);
export const distributionBoardRepository = export const distributionBoardRepository =
new DistributionBoardRepository(db); new DistributionBoardRepository(db);
export const buildingRepository = new BuildingRepository(db);
export const floorRepository = new FloorRepository(db); export const floorRepository = new FloorRepository(db);
export const globalDeviceRepository = new GlobalDeviceRepository(db); export const globalDeviceRepository = new GlobalDeviceRepository(db);
export const projectDeviceRepository = new ProjectDeviceRepository(db); export const projectDeviceRepository = new ProjectDeviceRepository(db);
@ -33,3 +36,4 @@ export const roomRepository = new RoomRepository(db);
export const externalCsvConfigurationRepository = export const externalCsvConfigurationRepository =
new ExternalCsvConfigurationRepository(db); new ExternalCsvConfigurationRepository(db);
export const externalModelStateRepository = new ExternalModelStateRepository(db); export const externalModelStateRepository = new ExternalModelStateRepository(db);
export const documentBrandingRepository = new DocumentBrandingRepository(db);

View file

@ -0,0 +1,111 @@
import type { Request, Response } from "express";
import {
createProjectBuildingDeleteProjectCommand,
createProjectBuildingInsertProjectCommand,
createProjectBuildingSnapshot,
createProjectBuildingUpdateProjectCommand,
} from "../../domain/models/project-location-structure-project-command.model.js";
import {
createBuildingSchema,
deleteProjectLocationSchema,
updateBuildingSchema,
} from "../../shared/validation/project-structure.schemas.js";
import { projectCommandService } from "../composition/project-command-stores.js";
import { buildingRepository } from "../composition/application-repositories.js";
import { respondWithProjectCommandError } from "./project-command.controller.js";
export async function listBuildingsByProject(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" });
}
const result = await buildingRepository.listByProject(projectId);
return res.json(result);
}
export async function createBuilding(req: Request, res: Response) {
const { projectId } = req.params;
if (typeof projectId !== "string") {
return res.status(400).json({ error: "Invalid projectId" });
}
const parsed = createBuildingSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const existingBuildings = await buildingRepository.listByProject(projectId);
const nextSortOrder = existingBuildings.length
? Math.max(...existingBuildings.map((building) => building.sortOrder)) + 1
: 0;
const building = createProjectBuildingSnapshot(
projectId,
parsed.data.name,
nextSortOrder
);
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Gebäude anlegen",
command: createProjectBuildingInsertProjectCommand(building),
});
return res.status(201).json({ ...result, building });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export async function updateBuilding(req: Request, res: Response) {
const { projectId, buildingId } = req.params;
if (typeof projectId !== "string" || typeof buildingId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = updateBuildingSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const current = await buildingRepository.findById(projectId, buildingId);
if (!current) {
return res.status(404).json({ error: "Building not found" });
}
const target = { ...current, name: parsed.data.name };
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Gebäude bearbeiten",
command: createProjectBuildingUpdateProjectCommand(current, target),
});
return res.json({ ...result, building: target });
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}
export async function deleteBuilding(req: Request, res: Response) {
const { projectId, buildingId } = req.params;
if (typeof projectId !== "string" || typeof buildingId !== "string") {
return res.status(400).json({ error: "Invalid parameters" });
}
const parsed = deleteProjectLocationSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const current = await buildingRepository.findById(projectId, buildingId);
if (!current) {
return res.status(404).json({ error: "Building not found" });
}
try {
const result = projectCommandService.executeUser({
projectId,
expectedRevision: parsed.data.expectedRevision,
description: "Gebäude löschen",
command: createProjectBuildingDeleteProjectCommand(current),
});
return res.json(result);
} catch (error) {
return respondWithProjectCommandError(error, res);
}
}

View file

@ -9,6 +9,7 @@ import {
createDistributionBoardInsertSubtreeProjectCommand, createDistributionBoardInsertSubtreeProjectCommand,
} from "../../domain/models/distribution-board-subtree-project-command.model.js"; } from "../../domain/models/distribution-board-subtree-project-command.model.js";
import { cloneDistributionBoardSubtree } from "../../domain/models/distribution-board-subtree-snapshot.model.js"; import { cloneDistributionBoardSubtree } from "../../domain/models/distribution-board-subtree-snapshot.model.js";
import { distributionBoardInfoFields } from "../../shared/constants/distribution-board.js";
import { import {
copyDistributionBoardSchema, copyDistributionBoardSchema,
createDistributionBoardSchema, createDistributionBoardSchema,
@ -64,6 +65,10 @@ export async function createDistributionBoard(req: Request, res: Response) {
distributionBoard: { distributionBoard: {
...structure.distributionBoard, ...structure.distributionBoard,
simultaneityFactor: 1, simultaneityFactor: 1,
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
}, },
}); });
} catch (error) { } catch (error) {
@ -86,6 +91,13 @@ export async function updateDistributionBoard(
if (!parsed.success) { if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() }); return res.status(400).json({ error: parsed.error.flatten() });
} }
// Info fields are optional in the request; only submitted fields become
// part of the command and therefore of its inverse.
const infoPatch = Object.fromEntries(
distributionBoardInfoFields
.filter((field) => parsed.data[field] !== undefined)
.map((field) => [field, parsed.data[field]])
);
try { try {
const result = projectCommandService.executeUser({ const result = projectCommandService.executeUser({
projectId, projectId,
@ -97,6 +109,7 @@ export async function updateDistributionBoard(
floorId: parsed.data.floorId, floorId: parsed.data.floorId,
supplyType: parsed.data.supplyType, supplyType: parsed.data.supplyType,
simultaneityFactor: parsed.data.simultaneityFactor, simultaneityFactor: parsed.data.simultaneityFactor,
...infoPatch,
} }
), ),
}); });

View file

@ -0,0 +1,100 @@
import type { Request, Response } from "express";
import type { DocumentBrandingAsset } from "../../db/schema/document-branding-assets.js";
import { officeProfileFields } from "../../shared/constants/project-cover.js";
import {
maxDocumentLogoBytes,
updateOfficeProfileSchema,
uploadDocumentLogoSchema,
type DocumentLogoMimeType,
} from "../../shared/validation/document-branding.schemas.js";
import { documentBrandingRepository } from "../composition/application-repositories.js";
const logoKey = "logo";
export function getDocumentBrandingHandler(_req: Request, res: Response) {
const logo = documentBrandingRepository.get(logoKey);
return res.json({
logo: logo ? toLogoMetadata(logo) : null,
office: documentBrandingRepository.getSettings(officeProfileFields),
});
}
export function updateOfficeProfileHandler(req: Request, res: Response) {
const parsed = updateOfficeProfileSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
documentBrandingRepository.setSettings(parsed.data);
return res.json({
office: documentBrandingRepository.getSettings(officeProfileFields),
});
}
export function getDocumentLogoHandler(_req: Request, res: Response) {
const logo = documentBrandingRepository.get(logoKey);
if (!logo) {
return res.status(404).json({ error: "No logo uploaded" });
}
res.setHeader("Content-Type", logo.mimeType);
res.setHeader("Cache-Control", "no-cache");
res.setHeader("X-Content-Type-Options", "nosniff");
// SVG uploads are rendered through <img> only; forbid script execution if
// the file is ever opened directly.
res.setHeader(
"Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; img-src data:"
);
return res.send(logo.data);
}
export function uploadDocumentLogoHandler(req: Request, res: Response) {
const parsed = uploadDocumentLogoSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
const data = Buffer.from(parsed.data.contentBase64, "base64");
if (data.length === 0 || data.length > maxDocumentLogoBytes) {
return res
.status(400)
.json({ error: "Das Logo darf höchstens 2 MB groß sein." });
}
if (!matchesMimeType(data, parsed.data.mimeType)) {
return res.status(400).json({
error: "Der Dateiinhalt passt nicht zum angegebenen Bildformat.",
});
}
const logo = documentBrandingRepository.upsert({
key: logoKey,
mimeType: parsed.data.mimeType,
fileName: parsed.data.fileName,
data,
});
return res.json({ logo: toLogoMetadata(logo) });
}
export function deleteDocumentLogoHandler(_req: Request, res: Response) {
documentBrandingRepository.delete(logoKey);
return res.status(204).end();
}
function toLogoMetadata(logo: DocumentBrandingAsset) {
return {
mimeType: logo.mimeType,
fileName: logo.fileName,
sizeBytes: logo.data.length,
updatedAtIso: logo.updatedAt.toISOString(),
};
}
function matchesMimeType(data: Buffer, mimeType: DocumentLogoMimeType) {
if (mimeType === "image/png") {
return data
.subarray(0, 8)
.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]));
}
if (mimeType === "image/jpeg") {
return data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff;
}
const head = data.subarray(0, 2048).toString("utf8").toLowerCase();
return head.includes("<svg");
}

View file

@ -42,7 +42,8 @@ export async function createFloor(req: Request, res: Response) {
const floor = createProjectFloorSnapshot( const floor = createProjectFloorSnapshot(
projectId, projectId,
parsed.data.name, parsed.data.name,
nextSortOrder nextSortOrder,
parsed.data.buildingId ?? null
); );
try { try {
const result = projectCommandService.executeUser({ const result = projectCommandService.executeUser({
@ -70,7 +71,14 @@ export async function updateFloor(req: Request, res: Response) {
if (!current) { if (!current) {
return res.status(404).json({ error: "Floor not found" }); return res.status(404).json({ error: "Floor not found" });
} }
const target = { ...current, name: parsed.data.name }; const target = {
...current,
name: parsed.data.name,
buildingId:
parsed.data.buildingId === undefined
? current.buildingId
: parsed.data.buildingId,
};
try { try {
const result = projectCommandService.executeUser({ const result = projectCommandService.executeUser({
projectId, projectId,

View file

@ -4,6 +4,10 @@ import {
updateProjectSettingsSchema, updateProjectSettingsSchema,
} from "../../shared/validation/project-structure.schemas.js"; } from "../../shared/validation/project-structure.schemas.js";
import { createProjectSettingsUpdateProjectCommand } from "../../domain/models/project-settings-project-command.model.js"; import { createProjectSettingsUpdateProjectCommand } from "../../domain/models/project-settings-project-command.model.js";
import {
projectCoverFields,
type ProjectCoverField,
} from "../../shared/constants/project-cover.js";
import { projectCommandService } from "../composition/project-command-stores.js"; import { projectCommandService } from "../composition/project-command-stores.js";
import { projectRepository } from "../composition/application-repositories.js"; import { projectRepository } from "../composition/application-repositories.js";
import { respondWithProjectCommandError } from "./project-command.controller.js"; import { respondWithProjectCommandError } from "./project-command.controller.js";
@ -46,12 +50,27 @@ export async function updateProjectSettings(req: Request, res: Response) {
} }
const { expectedRevision, ...settings } = parsed.data; const { expectedRevision, ...settings } = parsed.data;
const current = await projectRepository.findById(projectId);
if (!current) {
return res.status(404).json({ error: "Project not found" });
}
// Cover fields are optional in the request and keep their current value
// when omitted. `expectedRevision` rejects a project changed meanwhile.
const coverValues = Object.fromEntries(
projectCoverFields.map((field) => [
field,
settings[field] === undefined ? current[field] : settings[field],
])
) as Record<ProjectCoverField, string | null>;
try { try {
const result = projectCommandService.executeUser({ const result = projectCommandService.executeUser({
projectId, projectId,
expectedRevision, expectedRevision,
description: "Projekteinstellungen bearbeiten", description: "Projekteinstellungen bearbeiten",
command: createProjectSettingsUpdateProjectCommand(settings), command: createProjectSettingsUpdateProjectCommand({
...settings,
...coverValues,
}),
}); });
const project = await projectRepository.findById(projectId); const project = await projectRepository.findById(projectId);
if (!project) { if (!project) {

View file

@ -1,6 +1,7 @@
import express from "express"; import express from "express";
import { globalDeviceRouter } from "./routes/global-device.routes.js"; import { globalDeviceRouter } from "./routes/global-device.routes.js";
import { projectDeviceRouter } from "./routes/project-device.routes.js"; import { projectDeviceRouter } from "./routes/project-device.routes.js";
import { documentBrandingRouter } from "./routes/document-branding.routes.js";
import { projectRouter } from "./routes/project.routes.js"; import { projectRouter } from "./routes/project.routes.js";
import { errorMiddleware } from "./middleware/error.middleware.js"; import { errorMiddleware } from "./middleware/error.middleware.js";
import { createLogger, toErrorMeta } from "../shared/logging/logger.js"; import { createLogger, toErrorMeta } from "../shared/logging/logger.js";
@ -51,6 +52,7 @@ app.get("/health", (_req, res) => {
app.use("/api/projects", projectRouter); app.use("/api/projects", projectRouter);
app.use("/api/global-devices", globalDeviceRouter); app.use("/api/global-devices", globalDeviceRouter);
app.use("/api/project-devices", projectDeviceRouter); app.use("/api/project-devices", projectDeviceRouter);
app.use("/api/document-branding", documentBrandingRouter);
app.use(errorMiddleware); app.use(errorMiddleware);

View file

@ -0,0 +1,10 @@
import express from "express";
import * as documentBrandingController from "../controllers/document-branding.controller.js";
export const documentBrandingRouter = express.Router();
documentBrandingRouter.get("/", documentBrandingController.getDocumentBrandingHandler);
documentBrandingRouter.put("/office", documentBrandingController.updateOfficeProfileHandler);
documentBrandingRouter.get("/logo", documentBrandingController.getDocumentLogoHandler);
documentBrandingRouter.put("/logo", documentBrandingController.uploadDocumentLogoHandler);
documentBrandingRouter.delete("/logo", documentBrandingController.deleteDocumentLogoHandler);

View file

@ -13,6 +13,12 @@ import {
updateDistributionBoard, updateDistributionBoard,
} from "../controllers/distribution-board.controller.js"; } from "../controllers/distribution-board.controller.js";
import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js"; import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js";
import {
createBuilding,
deleteBuilding,
listBuildingsByProject,
updateBuilding,
} from "../controllers/building.controller.js";
import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js"; import { createFloor, deleteFloor, listFloorsByProject, updateFloor } from "../controllers/floor.controller.js";
import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js"; import { createRoom, deleteRoom, listRoomsByProject, updateRoom } from "../controllers/room.controller.js";
import { getCircuitTree } from "../controllers/circuit-tree.controller.js"; import { getCircuitTree } from "../controllers/circuit-tree.controller.js";
@ -100,6 +106,10 @@ projectRouter.get(
"/:projectId/circuit-sections/:sectionId/next-identifier", "/:projectId/circuit-sections/:sectionId/next-identifier",
getNextCircuitIdentifier getNextCircuitIdentifier
); );
projectRouter.get("/:projectId/buildings", listBuildingsByProject);
projectRouter.post("/:projectId/buildings", createBuilding);
projectRouter.put("/:projectId/buildings/:buildingId", updateBuilding);
projectRouter.delete("/:projectId/buildings/:buildingId", deleteBuilding);
projectRouter.get("/:projectId/floors", listFloorsByProject); projectRouter.get("/:projectId/floors", listFloorsByProject);
projectRouter.post("/:projectId/floors", createFloor); projectRouter.post("/:projectId/floors", createFloor);
projectRouter.put("/:projectId/floors/:floorId", updateFloor); projectRouter.put("/:projectId/floors/:floorId", updateFloor);

View file

@ -24,3 +24,44 @@ export const distributionBoardSupplyTypeLabels: Record<
MSR: "MSR – Mess-, Steuerungs- und Regelungstechnik", MSR: "MSR – Mess-, Steuerungs- und Regelungstechnik",
SiBe: "SiBe – Sicherheitsbeleuchtung", SiBe: "SiBe – Sicherheitsbeleuchtung",
}; };
/**
* Planner-owned descriptive distribution-board data shown on the board info
* card and in the print view. The values are local planning values. A later
* Revit/CSV round-trip may prefill them from mapped model parameters, but only
* through a previewed, explicitly confirmed project command (see
* docs/spec/revit-csv-integration-requirements.md); imports never overwrite
* them silently.
*
* `fedFrom` is intentionally free text for now. How a board is logically
* linked to its upstream board or feeding circuit is not decided yet.
*/
export const distributionBoardInfoFields = [
"boardNumber",
"location",
"fedFrom",
"remark",
] as const;
export type DistributionBoardInfoField =
(typeof distributionBoardInfoFields)[number];
export const distributionBoardInfoFieldLabels: Record<
DistributionBoardInfoField,
string
> = {
boardNumber: "Verteilernummer",
location: "Örtlichkeit",
fedFrom: "Gespeist von",
remark: "Bemerkung",
};
export const distributionBoardInfoFieldMaxLengths: Record<
DistributionBoardInfoField,
number
> = {
boardNumber: 50,
location: 200,
fedFrom: 200,
remark: 2000,
};

View file

@ -0,0 +1,9 @@
export const documentLogoMimeTypes = [
"image/png",
"image/jpeg",
"image/svg+xml",
] as const;
export type DocumentLogoMimeType = (typeof documentLogoMimeTypes)[number];
export const maxDocumentLogoBytes = 2 * 1024 * 1024;

View file

@ -0,0 +1,45 @@
/**
* Project data shown on the print view cover page. They are ordinary project
* settings (persisted through `project.update-settings`, part of logical
* snapshots and project transfer).
*/
export const projectCoverFields = [
"siteAddress",
"workStage",
"projectLead",
"projectEditor",
] as const;
export type ProjectCoverField = (typeof projectCoverFields)[number];
export const projectCoverFieldLabels: Record<ProjectCoverField, string> = {
siteAddress: "Anschrift Bauvorhaben",
workStage: "Leistungsphase",
projectLead: "Projektleitung",
projectEditor: "Bearbeitung",
};
export const projectCoverFieldMaxLengths: Record<ProjectCoverField, number> = {
siteAddress: 500,
workStage: 100,
projectLead: 200,
projectEditor: 200,
};
/**
* Office-wide data for the cover page ("Planverfasser"). Global branding like
* the document logo, outside project revisions and snapshots.
*/
export const officeProfileFields = ["officeName", "officeAddress"] as const;
export type OfficeProfileField = (typeof officeProfileFields)[number];
export const officeProfileFieldLabels: Record<OfficeProfileField, string> = {
officeName: "Büro",
officeAddress: "Anschrift Büro",
};
export const officeProfileFieldMaxLengths: Record<OfficeProfileField, number> = {
officeName: 200,
officeAddress: 500,
};

View file

@ -0,0 +1,42 @@
import { z } from "zod";
import {
documentLogoMimeTypes,
maxDocumentLogoBytes,
} from "../constants/document-branding.js";
import { officeProfileFieldMaxLengths } from "../constants/project-cover.js";
export {
documentLogoMimeTypes,
maxDocumentLogoBytes,
type DocumentLogoMimeType,
} from "../constants/document-branding.js";
export const uploadDocumentLogoSchema = z
.object({
fileName: z.string().trim().min(1).max(200),
mimeType: z.enum(documentLogoMimeTypes),
contentBase64: z
.string()
.min(1)
// Base64 expands by 4/3; the decoded size is checked again server-side.
.max(Math.ceil((maxDocumentLogoBytes * 4) / 3) + 4),
})
.strict();
export type UploadDocumentLogoInput = z.infer<typeof uploadDocumentLogoSchema>;
// Empty text is stored as "not set" (null).
const officeValueSchema = (maximumLength: number) =>
z
.string()
.trim()
.max(maximumLength)
.nullable()
.transform((value) => (value ? value : null));
export const updateOfficeProfileSchema = z
.object({
officeName: officeValueSchema(officeProfileFieldMaxLengths.officeName),
officeAddress: officeValueSchema(officeProfileFieldMaxLengths.officeAddress),
})
.strict();

View file

@ -1,7 +1,25 @@
import { z } from "zod"; import { z } from "zod";
import { distributionBoardSupplyTypes } from "../constants/distribution-board.js"; import {
distributionBoardInfoFieldMaxLengths,
distributionBoardSupplyTypes,
type DistributionBoardInfoField,
} from "../constants/distribution-board.js";
import {
projectCoverFieldMaxLengths,
type ProjectCoverField,
} from "../constants/project-cover.js";
import { expectedProjectRevisionSchema } from "./project-command.schemas.js"; import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
// Empty text is stored as null so "not entered" has one representation.
const optionalCoverValueSchema = (field: ProjectCoverField) =>
z
.string()
.trim()
.max(projectCoverFieldMaxLengths[field])
.nullable()
.transform((value) => (value ? value : null))
.optional();
export const createProjectSchema = z.object({ export const createProjectSchema = z.object({
name: z.string().trim().min(1).max(200), name: z.string().trim().min(1).max(200),
internalProjectNumber: z.string().trim().max(100).optional(), internalProjectNumber: z.string().trim().max(100).optional(),
@ -30,6 +48,11 @@ export const updateProjectSettingsSchema = z
.refine((values) => new Set(values).size === values.length, { .refine((values) => new Set(values).size === values.length, {
message: "Netzarten dürfen nicht mehrfach ausgewählt werden.", message: "Netzarten dürfen nicht mehrfach ausgewählt werden.",
}), }),
// Print cover fields; omitted fields keep their current value.
siteAddress: optionalCoverValueSchema("siteAddress"),
workStage: optionalCoverValueSchema("workStage"),
projectLead: optionalCoverValueSchema("projectLead"),
projectEditor: optionalCoverValueSchema("projectEditor"),
}) })
.strict(); .strict();
@ -42,12 +65,27 @@ export const createDistributionBoardSchema = z
}) })
.strict(); .strict();
// Empty info text is stored as null so that "not entered" has one
// representation in the database, snapshots and print view.
const distributionBoardInfoValueSchema = (field: DistributionBoardInfoField) =>
z
.string()
.trim()
.max(distributionBoardInfoFieldMaxLengths[field])
.nullable()
.transform((value) => (value ? value : null))
.optional();
export const updateDistributionBoardSchema = z export const updateDistributionBoardSchema = z
.object({ .object({
expectedRevision: expectedProjectRevisionSchema, expectedRevision: expectedProjectRevisionSchema,
floorId: z.string().trim().min(1).nullable(), floorId: z.string().trim().min(1).nullable(),
supplyType: z.enum(distributionBoardSupplyTypes), supplyType: z.enum(distributionBoardSupplyTypes),
simultaneityFactor: z.number().finite().min(0).max(1), simultaneityFactor: z.number().finite().min(0).max(1),
boardNumber: distributionBoardInfoValueSchema("boardNumber"),
location: distributionBoardInfoValueSchema("location"),
fedFrom: distributionBoardInfoValueSchema("fedFrom"),
remark: distributionBoardInfoValueSchema("remark"),
}) })
.strict(); .strict();
@ -64,10 +102,22 @@ export const deleteDistributionBoardSchema = z
}) })
.strict(); .strict();
export const createBuildingSchema = z
.object({
expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200),
})
.strict();
export const updateBuildingSchema = createBuildingSchema;
// A missing buildingId keeps the current value (older clients send only the
// name); null removes the floor from its building.
export const createFloorSchema = z export const createFloorSchema = z
.object({ .object({
expectedRevision: expectedProjectRevisionSchema, expectedRevision: expectedProjectRevisionSchema,
name: z.string().trim().min(1).max(200), name: z.string().trim().min(1).max(200),
buildingId: z.string().trim().min(1).nullable().optional(),
}) })
.strict(); .strict();

View file

@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
createBuilding,
createFloor,
deleteBuilding,
listBuildings,
updateBuilding,
updateFloor,
} from "../src/frontend/utils/api.js";
describe("building structure frontend API", () => {
it("sends building commands and optional floor building assignments", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
requests.push({
url: String(input),
method: init?.method ?? "GET",
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
});
return new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
try {
await listBuildings("project-1");
await createBuilding("project-1", { name: "Haus A" }, 3);
await updateBuilding("project-1", "building-1", { name: "Haus B" }, 4);
await deleteBuilding("project-1", "building-1", 5);
await createFloor(
"project-1",
{ name: "EG", buildingId: "building-1" },
6
);
await createFloor("project-1", { name: "Dach", buildingId: null }, 7);
await createFloor("project-1", { name: "UG", buildingId: undefined }, 8);
await updateFloor(
"project-1",
"floor-1",
{ name: "1. OG", buildingId: null },
9
);
await updateFloor("project-1", "floor-1", { name: "2. OG" }, 10);
} finally {
globalThis.fetch = originalFetch;
}
assert.deepEqual(requests, [
{
url: "/api/projects/project-1/buildings",
method: "GET",
body: undefined,
},
{
url: "/api/projects/project-1/buildings",
method: "POST",
body: { name: "Haus A", expectedRevision: 3 },
},
{
url: "/api/projects/project-1/buildings/building-1",
method: "PUT",
body: { name: "Haus B", expectedRevision: 4 },
},
{
url: "/api/projects/project-1/buildings/building-1",
method: "DELETE",
body: { expectedRevision: 5 },
},
{
url: "/api/projects/project-1/floors",
method: "POST",
body: { name: "EG", buildingId: "building-1", expectedRevision: 6 },
},
{
url: "/api/projects/project-1/floors",
method: "POST",
body: { name: "Dach", buildingId: null, expectedRevision: 7 },
},
{
url: "/api/projects/project-1/floors",
method: "POST",
body: { name: "UG", expectedRevision: 8 },
},
{
url: "/api/projects/project-1/floors/floor-1",
method: "PUT",
body: { name: "1. OG", buildingId: null, expectedRevision: 9 },
},
{
url: "/api/projects/project-1/floors/floor-1",
method: "PUT",
body: { name: "2. OG", expectedRevision: 10 },
},
]);
const updateWithoutBuilding = requests[8].body as Record<string, unknown>;
assert.equal("buildingId" in updateWithoutBuilding, false);
});
});

View file

@ -0,0 +1,284 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type {
BuildingDto,
FloorDto,
RoomDto,
} from "../src/frontend/types.js";
import {
ALL_SELECTION,
UNASSIGNED_SELECTION,
countFloorsByBuilding,
countRoomsByFloor,
defaultBuildingIdForNewFloor,
defaultFloorIdForNewRoom,
describeRoomLocation,
filterFloorsByBuilding,
filterRooms,
groupFloorsByBuilding,
hasFloorsWithoutBuilding,
hasRoomsWithoutFloor,
normalizeBuildingSelection,
normalizeFloorSelection,
showsRoomsWithoutFloorEntry,
} from "../src/frontend/utils/building-structure.js";
function building(id: string, name: string, sortOrder: number): BuildingDto {
return { id, projectId: "project-1", name, sortOrder };
}
function floor(
id: string,
name: string,
buildingId: string | null,
sortOrder = 0
): FloorDto {
return { id, projectId: "project-1", name, sortOrder, buildingId };
}
function room(id: string, floorId: string | null, roomNumber = id): RoomDto {
return {
id,
projectId: "project-1",
floorId,
roomNumber,
roomName: `Raum ${roomNumber}`,
};
}
const buildings = [building("b-a", "Haus A", 0), building("b-b", "Haus B", 1)];
const floors = [
floor("f-free", "Außenanlage", null, 0),
floor("f-a-eg", "EG", "b-a", 1),
floor("f-b-eg", "EG", "b-b", 2),
floor("f-a-og", "1. OG", "b-a", 3),
floor("f-orphan", "Keller", "b-deleted", 4),
];
const rooms = [
room("r-a-eg", "f-a-eg"),
room("r-a-og", "f-a-og"),
room("r-b-eg", "f-b-eg"),
room("r-free", "f-free"),
room("r-orphan-floor", "f-orphan"),
room("r-none", null),
room("r-missing-floor", "f-deleted"),
];
const ids = (entries: ReadonlyArray<{ id: string }>) =>
entries.map((entry) => entry.id);
describe("building structure view", () => {
it("groups floors in building order and puts unassigned floors last", () => {
const groups = groupFloorsByBuilding(floors, buildings);
assert.deepEqual(
groups.map((group) => ({
buildingId: group.buildingId,
label: group.label,
floors: ids(group.floors),
})),
[
{ buildingId: "b-a", label: "Haus A", floors: ["f-a-eg", "f-a-og"] },
{ buildingId: "b-b", label: "Haus B", floors: ["f-b-eg"] },
{
buildingId: null,
label: "Ohne Gebäude",
floors: ["f-free", "f-orphan"],
},
]
);
});
it("omits empty groups", () => {
const groups = groupFloorsByBuilding(
[floor("f-1", "EG", "b-b")],
buildings
);
assert.deepEqual(
groups.map((group) => group.buildingId),
["b-b"]
);
assert.deepEqual(groupFloorsByBuilding([], buildings), []);
});
it("filters floors by building selection", () => {
assert.deepEqual(
ids(filterFloorsByBuilding(floors, buildings, ALL_SELECTION)),
ids(floors)
);
assert.deepEqual(
ids(filterFloorsByBuilding(floors, buildings, "b-a")),
["f-a-eg", "f-a-og"]
);
assert.deepEqual(
ids(filterFloorsByBuilding(floors, buildings, UNASSIGNED_SELECTION)),
["f-free", "f-orphan"]
);
});
it("detects floors without building and rooms without floor", () => {
assert.equal(hasFloorsWithoutBuilding(floors, buildings), true);
assert.equal(
hasFloorsWithoutBuilding([floor("f-1", "EG", "b-a")], buildings),
false
);
assert.equal(hasRoomsWithoutFloor(rooms, floors), true);
assert.equal(hasRoomsWithoutFloor([room("r-1", "f-a-eg")], floors), false);
});
it("filters rooms for building and floor selections", () => {
assert.deepEqual(
ids(filterRooms(rooms, floors, buildings, ALL_SELECTION, ALL_SELECTION)),
ids(rooms)
);
assert.deepEqual(
ids(filterRooms(rooms, floors, buildings, "b-a", ALL_SELECTION)),
["r-a-eg", "r-a-og"]
);
assert.deepEqual(
ids(filterRooms(rooms, floors, buildings, "b-a", "f-a-og")),
["r-a-og"]
);
assert.deepEqual(
ids(filterRooms(rooms, floors, buildings, ALL_SELECTION, "f-b-eg")),
["r-b-eg"]
);
assert.deepEqual(
ids(
filterRooms(
rooms,
floors,
buildings,
UNASSIGNED_SELECTION,
ALL_SELECTION
)
),
["r-free", "r-orphan-floor", "r-none", "r-missing-floor"]
);
assert.deepEqual(
ids(
filterRooms(
rooms,
floors,
buildings,
ALL_SELECTION,
UNASSIGNED_SELECTION
)
),
["r-none", "r-missing-floor"]
);
assert.deepEqual(
ids(filterRooms(rooms, floors, buildings, "b-a", UNASSIGNED_SELECTION)),
[]
);
});
it("offers 'Ohne Etage' only where rooms without floor can belong", () => {
assert.equal(showsRoomsWithoutFloorEntry(rooms, floors, ALL_SELECTION), true);
assert.equal(
showsRoomsWithoutFloorEntry(rooms, floors, UNASSIGNED_SELECTION),
true
);
assert.equal(showsRoomsWithoutFloorEntry(rooms, floors, "b-a"), false);
assert.equal(
showsRoomsWithoutFloorEntry([room("r-1", "f-a-eg")], floors, ALL_SELECTION),
false
);
});
it("falls back to 'Alle' for vanished or hidden selections", () => {
assert.equal(normalizeBuildingSelection("b-a", floors, buildings), "b-a");
assert.equal(
normalizeBuildingSelection("b-deleted", floors, buildings),
ALL_SELECTION
);
assert.equal(
normalizeBuildingSelection(UNASSIGNED_SELECTION, floors, buildings),
UNASSIGNED_SELECTION
);
assert.equal(
normalizeBuildingSelection(
UNASSIGNED_SELECTION,
[floor("f-1", "EG", "b-a")],
buildings
),
ALL_SELECTION
);
assert.equal(
normalizeFloorSelection("f-a-og", rooms, floors, buildings, "b-a"),
"f-a-og"
);
assert.equal(
normalizeFloorSelection("f-b-eg", rooms, floors, buildings, "b-a"),
ALL_SELECTION
);
assert.equal(
normalizeFloorSelection("f-deleted", rooms, floors, buildings, ALL_SELECTION),
ALL_SELECTION
);
assert.equal(
normalizeFloorSelection(
UNASSIGNED_SELECTION,
rooms,
floors,
buildings,
ALL_SELECTION
),
UNASSIGNED_SELECTION
);
assert.equal(
normalizeFloorSelection(UNASSIGNED_SELECTION, rooms, floors, buildings, "b-a"),
ALL_SELECTION
);
});
it("preselects the selected building and floor for new entries", () => {
assert.equal(defaultBuildingIdForNewFloor("b-a"), "b-a");
assert.equal(defaultBuildingIdForNewFloor(ALL_SELECTION), "");
assert.equal(defaultBuildingIdForNewFloor(UNASSIGNED_SELECTION), "");
assert.equal(defaultFloorIdForNewRoom("f-a-eg"), "f-a-eg");
assert.equal(defaultFloorIdForNewRoom(ALL_SELECTION), "");
assert.equal(defaultFloorIdForNewRoom(UNASSIGNED_SELECTION), "");
});
it("counts floors per building and rooms per floor", () => {
assert.deepEqual(
[...countFloorsByBuilding(floors, buildings)],
[
[null, 2],
["b-a", 2],
["b-b", 1],
]
);
assert.deepEqual(
[...countRoomsByFloor(rooms, floors)],
[
["f-a-eg", 1],
["f-a-og", 1],
["f-b-eg", 1],
["f-free", 1],
["f-orphan", 1],
[null, 2],
]
);
});
it("describes a room location with floor and building", () => {
assert.equal(
describeRoomLocation(room("r", "f-a-og"), floors, buildings),
"1. OG · Haus A"
);
assert.equal(
describeRoomLocation(room("r", "f-free"), floors, buildings),
"Außenanlage"
);
assert.equal(
describeRoomLocation(room("r", "f-orphan"), floors, buildings),
"Keller"
);
assert.equal(
describeRoomLocation(room("r", null), floors, buildings),
"Ohne Etage"
);
});
});

View file

@ -387,6 +387,110 @@ describe("distribution-board structure project command", () => {
} }
}); });
it("updates the info card fields with persisted undo", () => {
const context = createTestDatabase();
try {
const repository =
new DistributionBoardStructureProjectCommandRepository(
context.db
);
const structure = createDistributionBoardStructureSnapshot(
"project-1",
"UV-EG",
{ floorId: null, supplyType: "AV" }
);
const insert = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createDistributionBoardInsertProjectCommand(structure),
});
const readInfo = () =>
context.db
.select({
boardNumber: distributionBoards.boardNumber,
location: distributionBoards.location,
fedFrom: distributionBoards.fedFrom,
remark: distributionBoards.remark,
})
.from(distributionBoards)
.get();
assert.deepEqual(readInfo(), {
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
});
const forward = repository.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: createDistributionBoardUpdateProjectCommand(
structure.distributionBoard.id,
{
boardNumber: "UV 0.1",
location: "Raum 003 Technik",
fedFrom: "HAK",
}
),
});
assert.deepEqual(readInfo(), {
boardNumber: "UV 0.1",
location: "Raum 003 Technik",
fedFrom: "HAK",
remark: null,
});
assert.deepEqual(
forward.inverse.payload.changes.map((change) => change.field),
["boardNumber", "location", "fedFrom"]
);
// A board with info values is no longer the unchanged empty setup.
assert.throws(
() =>
repository.execute({
projectId: "project-1",
expectedRevision: 2,
source: "user",
command: insert.inverse,
}),
/changed before deletion/
);
const undoStep = new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo");
assert.ok(undoStep);
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 2,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: forward.inverse,
});
assert.deepEqual(readInfo(), {
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
});
for (const value of ["", " UV 1", "x".repeat(51)]) {
assert.throws(
() =>
createDistributionBoardUpdateProjectCommand(
structure.distributionBoard.id,
{ boardNumber: value }
),
/boardNumber/
);
}
} finally {
context.close();
}
});
it("sends distribution-board writes to the revision-safe API", async () => { it("sends distribution-board writes to the revision-safe API", async () => {
const requests: Array<{ url: string; method: string; body: unknown }> = []; const requests: Array<{ url: string; method: string; body: unknown }> = [];
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;

View file

@ -192,6 +192,67 @@ describe("distribution-board subtree project command", () => {
} }
}); });
it("copies board info values and accepts snapshots stored before they existed", () => {
const context = createTestDatabase();
try {
const board = createPopulatedBoard(context);
context.db
.update(distributionBoards)
.set({ boardNumber: "UV 1.1", location: "Technik" })
.where(eq(distributionBoards.id, board.id))
.run();
const repository =
new DistributionBoardSubtreeProjectCommandRepository(context.db);
const withInfo = repository.capture("project-1", board.id);
const clone = cloneDistributionBoardSubtree(withInfo, "UV 1 Kopie");
assert.equal(clone.distributionBoard.boardNumber, "UV 1.1");
assert.equal(clone.distributionBoard.location, "Technik");
context.db
.update(distributionBoards)
.set({ boardNumber: null, location: null })
.where(eq(distributionBoards.id, board.id))
.run();
const legacySnapshot = structuredClone(
repository.capture("project-1", board.id)
);
const legacyBoard = legacySnapshot.distributionBoard as Record<
string,
unknown
>;
delete legacyBoard.boardNumber;
delete legacyBoard.location;
delete legacyBoard.fedFrom;
delete legacyBoard.remark;
const deleted = repository.execute({
projectId: "project-1",
expectedRevision: 0,
source: "user",
description: "Verteilung löschen",
command: createDistributionBoardDeleteSubtreeProjectCommand(
legacySnapshot
),
});
assert.throws(
() => repository.capture("project-1", board.id),
/not found/
);
repository.execute({
projectId: "project-1",
expectedRevision: 1,
source: "user",
command: deleted.inverse,
});
assert.equal(
repository.capture("project-1", board.id).distributionBoard.fedFrom,
null
);
} finally {
context.close();
}
});
it("rejects changed deletion snapshots without partial writes", () => { it("rejects changed deletion snapshots without partial writes", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {

View file

@ -0,0 +1,77 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
import { DocumentBrandingRepository } from "../src/db/repositories/document-branding.repository.js";
import { officeProfileFields } from "../src/shared/constants/project-cover.js";
import { updateOfficeProfileSchema } from "../src/shared/validation/document-branding.schemas.js";
function createRepository() {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
return { context, repository: new DocumentBrandingRepository(context.db) };
}
describe("document branding repository", () => {
it("stores, replaces and removes the office profile", () => {
const { context, repository } = createRepository();
try {
assert.deepEqual(repository.getSettings(officeProfileFields), {
officeName: null,
officeAddress: null,
});
repository.setSettings(
updateOfficeProfileSchema.parse({
officeName: " kbp köhler beraten + planen GmbH ",
officeAddress: "Straße 1\n12345 Ort",
})
);
assert.deepEqual(repository.getSettings(officeProfileFields), {
officeName: "kbp köhler beraten + planen GmbH",
officeAddress: "Straße 1\n12345 Ort",
});
repository.setSettings(
updateOfficeProfileSchema.parse({ officeName: "Büro", officeAddress: "" })
);
assert.deepEqual(repository.getSettings(officeProfileFields), {
officeName: "Büro",
officeAddress: null,
});
assert.equal(
updateOfficeProfileSchema.safeParse({ officeName: "x".repeat(201), officeAddress: null })
.success,
false
);
} finally {
context.close();
}
});
it("replaces and deletes the logo", () => {
const { context, repository } = createRepository();
try {
repository.upsert({
key: "logo",
mimeType: "image/png",
fileName: "a.png",
data: Buffer.from([1, 2, 3]),
});
repository.upsert({
key: "logo",
mimeType: "image/svg+xml",
fileName: "b.svg",
data: Buffer.from("<svg/>"),
});
const logo = repository.get("logo");
assert.equal(logo?.mimeType, "image/svg+xml");
assert.equal(logo?.data.toString(), "<svg/>");
assert.equal(repository.delete("logo"), true);
assert.equal(repository.get("logo"), undefined);
} finally {
context.close();
}
});
});

View file

@ -0,0 +1,207 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
assertPrintRevisionConsistency,
buildPreliminaryBoardHierarchy,
formatCircuitCable,
formatProtectionDevice,
summarizePrintBoard,
} from "../src/frontend/print/print-document-model.js";
import type {
CircuitTreeResponseDto,
DistributionBoardDto,
} from "../src/frontend/types.js";
function board(
id: string,
info: Partial<DistributionBoardDto> = {}
): DistributionBoardDto {
return {
id,
projectId: "project-1",
name: id,
floorId: null,
supplyType: "AV",
simultaneityFactor: 1,
boardNumber: null,
location: null,
fedFrom: null,
remark: null,
...info,
};
}
function tree(
rows: Array<{
quantity: number;
powerPerUnit: number;
simultaneityFactor: number;
cosPhi?: number;
}>,
boardSimultaneityFactor = 1
): CircuitTreeResponseDto {
const deviceRows = rows.map((row, index) => ({
id: `row-${index}`,
sortOrder: index,
name: "Gerät",
displayName: "Gerät",
manualQuantity: row.quantity,
rowTotalPower: row.quantity * row.powerPerUnit * row.simultaneityFactor,
...row,
}));
const total = deviceRows.reduce((sum, row) => sum + row.rowTotalPower, 0);
return {
circuitListId: "list",
currentRevision: 1,
singlePhaseVoltageV: 230,
threePhaseVoltageV: 400,
distributionBoardSimultaneityFactor: boardSimultaneityFactor,
distributionBoardTotalPower: total,
distributionBoardTotalPowerWithSimultaneityFactor:
total * boardSimultaneityFactor,
headerComponents: [],
footerComponents: [],
sections: [
{
id: "section",
key: "single_phase",
displayName: "1-phasig 1",
prefix: "-2F1.",
sortOrder: 1,
sectionTotalPower: total,
components: [],
circuits: [
{
id: "circuit",
circuitListId: "list",
sectionId: "section",
equipmentIdentifier: "-2F1.1",
sortOrder: 1,
isReserve: false,
circuitTotalPower: total,
deviceRows,
},
],
},
],
};
}
function summary(
id: string,
info: Partial<DistributionBoardDto>,
demandKw: number
) {
return summarizePrintBoard({
board: board(id, info),
circuitListId: id,
floorName: null,
tree: tree([{ quantity: 1, powerPerUnit: demandKw, simultaneityFactor: 1 }]),
});
}
describe("print document model", () => {
it("rejects mixed project revisions instead of printing mislabeled data", () => {
const summaries = [summary("uv", {}, 1)];
assert.doesNotThrow(() => assertPrintRevisionConsistency(1, summaries, 1));
assert.throws(() => assertPrintRevisionConsistency(0, summaries, 0), /neu laden/);
assert.throws(() => assertPrintRevisionConsistency(1, summaries, 2), /neu laden/);
assert.throws(() => assertPrintRevisionConsistency(1, [], 2), /neu laden/);
});
it("prints both cable type and cross-section without hiding either value", () => {
const circuit = tree([]).sections[0].circuits[0];
assert.equal(formatCircuitCable({ ...circuit, cableType: "NYM-J", cableCrossSection: "3x2,5" }), "NYM-J · 3x2,5");
assert.equal(formatCircuitCable({ ...circuit, cableType: "NYM-J" }), "NYM-J");
assert.equal(formatCircuitCable({ ...circuit, cableCrossSection: "3x2,5" }), "3x2,5");
assert.equal(formatCircuitCable(circuit), null);
});
it("does not guess a parent when feed text matches more than one board", () => {
const summaries = [
summary("a", { boardNumber: "HV" }, 2),
summary("b", { name: "HV" }, 3),
summary("uv", { fedFrom: " hv " }, 1),
];
for (const ordered of [summaries, [...summaries].reverse()]) {
const entries = buildPreliminaryBoardHierarchy(ordered);
assert.ok(entries.every((entry) => entry.parentBoardId === null));
assert.equal(entries.reduce((sum, entry) => sum + entry.cumulatedDemandPowerKw, 0), 6);
}
});
it("derives installed, device and demand power and the design current", () => {
const result = summarizePrintBoard({
board: board("uv"),
circuitListId: "uv",
floorName: "EG",
tree: tree(
[
{ quantity: 4, powerPerUnit: 2, simultaneityFactor: 0.5, cosPhi: 0.8 },
{ quantity: 1, powerPerUnit: 6, simultaneityFactor: 1 },
],
0.5
),
});
assert.equal(result.circuitCount, 1);
assert.equal(result.installedPowerKw, 14);
assert.equal(result.devicePowerKw, 10);
assert.equal(result.demandPowerKw, 5);
// S = (4 / 0.8 + 6 / 1) × 0.5 = 5.5 kVA → I = 5500 / (√3 · 400)
assert.ok(Math.abs(result.designCurrentA - 5500 / (Math.sqrt(3) * 400)) < 1e-9);
});
it("nests boards by matching free-text feed and sums downstream demand", () => {
const entries = buildPreliminaryBoardHierarchy([
summary("hv", { boardNumber: "HV 0.1", fedFrom: "HAK" }, 10),
summary("uv-a", { boardNumber: "UV 1.1", fedFrom: " hv 0.1 " }, 4),
summary("uv-b", { boardNumber: "UV 1.2", fedFrom: "UV 1.1" }, 2),
summary("uv-c", { name: "UV-C", fedFrom: "unbekannt" }, 1),
]);
assert.deepEqual(
entries.map((entry) => [
entry.summary.board.id,
entry.depth,
entry.parentBoardId,
entry.cumulatedDemandPowerKw,
]),
[
["hv", 0, null, 16],
["uv-a", 1, "hv", 6],
["uv-b", 2, "uv-a", 2],
["uv-c", 0, null, 1],
]
);
});
it("keeps boards with cyclic feed texts at the top level", () => {
const entries = buildPreliminaryBoardHierarchy([
summary("a", { boardNumber: "A", fedFrom: "B" }, 1),
summary("b", { boardNumber: "B", fedFrom: "A" }, 1),
]);
assert.equal(entries.length, 2);
assert.ok(entries.some((entry) => entry.parentBoardId === null));
assert.equal(
entries.reduce((sum, entry) => sum + (entry.depth === 0 ? entry.cumulatedDemandPowerKw : 0), 0),
2
);
});
it("formats protection devices for the plan table", () => {
assert.equal(
formatProtectionDevice({ type: "LS", ratedCurrentA: 16, tripCharacteristic: "B" }),
"LS B 16 A"
);
assert.equal(
formatProtectionDevice({
type: "FI_LS",
ratedCurrentA: 16,
tripCharacteristic: "B",
rcdType: "A",
ratedResidualCurrentMa: 30,
}),
"FI/LS B 16 A, 30 mA Typ A"
);
assert.equal(formatProtectionDevice(undefined), null);
});
});

View file

@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { it } from "node:test";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import { createDatabaseContext } from "../src/db/database-context.js";
it("upgrades a populated pre-print database through the current schema without changing existing records", async () => {
const source = path.resolve("src/db/migrations");
const staging = mkdtempSync(path.join(tmpdir(), "leistungsbilanz-print-migration-"));
const journal = JSON.parse(readFileSync(path.join(source, "meta/_journal.json"), "utf8"));
journal.entries = journal.entries.filter((entry: { idx: number }) => entry.idx <= 6);
mkdirSync(path.join(staging, "meta"));
const files = [path.join(staging, "meta/_journal.json")];
writeFileSync(files[0], JSON.stringify(journal));
for (const entry of journal.entries) {
const file = path.join(staging, `${entry.tag}.sql`);
copyFileSync(path.join(source, `${entry.tag}.sql`), file);
files.push(file);
}
const context = createDatabaseContext(":memory:");
try {
migrate(context.db, { migrationsFolder: staging });
context.sqlite.exec(`
INSERT INTO projects (id, name, current_revision) VALUES ('p', 'Existing project', 17);
INSERT INTO distribution_boards (id, project_id, name, simultaneity_factor)
VALUES ('b', 'p', 'Existing board', 0.7);
INSERT INTO floors (id, project_id, name, sort_order)
VALUES ('f', 'p', 'Existing floor', 3);
`);
const oldProject = context.sqlite.prepare("SELECT * FROM projects").get() as Record<string, unknown>;
const oldBoard = context.sqlite.prepare("SELECT * FROM distribution_boards").get() as Record<string, unknown>;
const oldFloor = context.sqlite.prepare("SELECT * FROM floors").get() as Record<string, unknown>;
const backup = path.join(staging, "before-print.db");
files.push(backup);
await context.sqlite.backup(backup);
migrate(context.db, { migrationsFolder: source });
const project = context.sqlite.prepare("SELECT * FROM projects").get() as Record<string, unknown>;
const board = context.sqlite.prepare("SELECT * FROM distribution_boards").get() as Record<string, unknown>;
const floor = context.sqlite.prepare("SELECT * FROM floors").get() as Record<string, unknown>;
for (const [key, value] of Object.entries(oldProject)) assert.deepEqual(project[key], value);
for (const [key, value] of Object.entries(oldBoard)) assert.deepEqual(board[key], value);
for (const [key, value] of Object.entries(oldFloor)) assert.deepEqual(floor[key], value);
for (const field of ["site_address", "work_stage", "project_lead", "project_editor"]) assert.equal(project[field], null);
for (const field of ["board_number", "location", "fed_from", "remark"]) assert.equal(board[field], null);
assert.equal(floor.building_id, null);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM buildings").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_assets").get()!.count, 0);
assert.equal(context.sqlite.prepare("SELECT count(*) AS count FROM document_branding_settings").get()!.count, 0);
assert.deepEqual(context.sqlite.pragma("foreign_key_check"), []);
assert.equal(context.sqlite.pragma("integrity_check", { simple: true }), "ok");
migrate(context.db, { migrationsFolder: source });
assert.deepEqual(context.sqlite.prepare("SELECT * FROM projects").get(), project);
} finally {
context.close();
for (const file of files) unlinkSync(file);
rmdirSync(path.join(staging, "meta"));
rmdirSync(staging);
}
});

View file

@ -0,0 +1,305 @@
import path from "node:path";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { eq } from "drizzle-orm";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
import {
createDatabaseContext,
type DatabaseContext,
} from "../src/db/database-context.js";
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
import { buildings } from "../src/db/schema/buildings.js";
import { floors } from "../src/db/schema/floors.js";
import { projects } from "../src/db/schema/projects.js";
import {
assertProjectFloorInsertProjectCommand,
createProjectBuildingDeleteProjectCommand,
createProjectBuildingInsertProjectCommand,
createProjectBuildingSnapshot,
createProjectBuildingUpdateProjectCommand,
createProjectFloorInsertProjectCommand,
createProjectFloorSnapshot,
createProjectFloorUpdateProjectCommand,
projectFloorCommandSchemaVersion,
type ProjectLocationStructureProjectCommand,
} from "../src/domain/models/project-location-structure-project-command.model.js";
import {
createBuildingSchema,
createFloorSchema,
updateFloorSchema,
} from "../src/shared/validation/project-structure.schemas.js";
function createTestDatabase(): DatabaseContext {
const context = createDatabaseContext(":memory:");
migrate(context.db, {
migrationsFolder: path.resolve("src", "db", "migrations"),
});
context.db
.insert(projects)
.values([
{ id: "project-1", name: "Test project" },
{ id: "project-2", name: "Foreign project" },
])
.run();
return context;
}
/** Executes a user command and returns a runner for its undo and redo. */
function createRunner(context: DatabaseContext) {
const repository = new ProjectLocationStructureProjectCommandRepository(
context.db
);
const history = new ProjectHistoryRepository(context.db);
let revision = 0;
const run = (
command: ProjectLocationStructureProjectCommand,
source: "user" | "undo" | "redo" = "user"
) => {
const step =
source === "user" ? null : history.getNextCommand("project-1", source);
if (source !== "user") {
assert.ok(step);
}
const result = repository.execute({
projectId: "project-1",
expectedRevision: revision,
source,
historyTargetChangeSetId: step?.changeSetId,
command,
});
revision += 1;
return result;
};
return { repository, run };
}
function getBuilding(context: DatabaseContext, id: string) {
return context.db.select().from(buildings).where(eq(buildings.id, id)).get();
}
function getFloor(context: DatabaseContext, id: string) {
return context.db.select().from(floors).where(eq(floors.id, id)).get();
}
describe("project building structure", () => {
it("creates, renames and deletes buildings with persisted undo and redo", () => {
const context = createTestDatabase();
try {
const { run } = createRunner(context);
const building = createProjectBuildingSnapshot("project-1", " Haus A ", 0);
assert.equal(building.name, "Haus A");
const insert = createProjectBuildingInsertProjectCommand(building);
const inserted = run(insert);
assert.deepEqual(getBuilding(context, building.id), building);
const renamed = { ...building, name: "Haus B" };
const update = createProjectBuildingUpdateProjectCommand(building, renamed);
const updated = run(update);
assert.equal(getBuilding(context, building.id)?.name, "Haus B");
run(updated.inverse, "undo");
assert.deepEqual(getBuilding(context, building.id), building);
run(inserted.inverse, "undo");
assert.equal(getBuilding(context, building.id), undefined);
run(insert, "redo");
run(update, "redo");
assert.deepEqual(getBuilding(context, building.id), renamed);
const deleted = run(createProjectBuildingDeleteProjectCommand(renamed));
assert.equal(getBuilding(context, building.id), undefined);
run(deleted.inverse, "undo");
assert.deepEqual(getBuilding(context, building.id), renamed);
} finally {
context.close();
}
});
it("assigns floors to buildings and blocks deleting a building in use", () => {
const context = createTestDatabase();
try {
const { run } = createRunner(context);
const houseA = createProjectBuildingSnapshot("project-1", "Haus A", 0);
const houseB = createProjectBuildingSnapshot("project-1", "Haus B", 1);
run(createProjectBuildingInsertProjectCommand(houseA));
run(createProjectBuildingInsertProjectCommand(houseB));
const floor = createProjectFloorSnapshot("project-1", "EG", 0, houseA.id);
const floorInsert = createProjectFloorInsertProjectCommand(floor);
assert.equal(floorInsert.schemaVersion, projectFloorCommandSchemaVersion);
run(floorInsert);
assert.deepEqual(getFloor(context, floor.id), floor);
const moved = { ...floor, buildingId: houseB.id };
const moveCommand = createProjectFloorUpdateProjectCommand(floor, moved);
const movedResult = run(moveCommand);
assert.equal(getFloor(context, floor.id)?.buildingId, houseB.id);
assert.throws(
() => run(createProjectBuildingDeleteProjectCommand(houseB)),
/solange Etagen zugeordnet/
);
assert.ok(getBuilding(context, houseB.id));
run(movedResult.inverse, "undo");
assert.equal(getFloor(context, floor.id)?.buildingId, houseA.id);
run(moveCommand, "redo");
assert.equal(getFloor(context, floor.id)?.buildingId, houseB.id);
const unassigned = { ...moved, buildingId: null };
run(createProjectFloorUpdateProjectCommand(moved, unassigned));
assert.equal(getFloor(context, floor.id)?.buildingId, null);
run(createProjectBuildingDeleteProjectCommand(houseB));
assert.equal(getBuilding(context, houseB.id), undefined);
} finally {
context.close();
}
});
it("rejects floors that reference a missing or foreign building", () => {
const context = createTestDatabase();
try {
const { run } = createRunner(context);
context.db
.insert(buildings)
.values({ id: "foreign", projectId: "project-2", name: "Fremd", sortOrder: 0 })
.run();
assert.throws(
() =>
run(
createProjectFloorInsertProjectCommand(
createProjectFloorSnapshot("project-1", "EG", 0, "foreign")
)
),
/foreign building/
);
assert.throws(
() =>
run(
createProjectFloorInsertProjectCommand(
createProjectFloorSnapshot("project-1", "EG", 0, "missing")
)
),
/foreign building/
);
assert.equal(context.db.select().from(floors).all().length, 0);
const floor = createProjectFloorSnapshot("project-1", "EG", 0);
run(createProjectFloorInsertProjectCommand(floor));
assert.throws(
() =>
run(
createProjectFloorUpdateProjectCommand(floor, {
...floor,
buildingId: "foreign",
})
),
/foreign building/
);
assert.throws(
() => createProjectFloorUpdateProjectCommand(floor, { ...floor }),
/only the name or the building/
);
assert.throws(
() =>
createProjectFloorUpdateProjectCommand(floor, {
...floor,
sortOrder: 5,
name: "Erdgeschoss",
}),
/only the name or the building/
);
} finally {
context.close();
}
});
it("executes version 1 floor commands recorded before buildings existed", () => {
const context = createTestDatabase();
try {
const { run } = createRunner(context);
const legacyFloor = {
id: "floor-legacy",
projectId: "project-1",
name: "EG",
sortOrder: 0,
};
const legacyInsert = {
schemaVersion: 1 as const,
type: "project-floor.insert" as const,
payload: { floor: legacyFloor },
};
assertProjectFloorInsertProjectCommand(legacyInsert);
const inserted = run(legacyInsert);
assert.deepEqual(getFloor(context, legacyFloor.id), {
...legacyFloor,
buildingId: null,
});
// The inverse is always written in the current version.
assert.equal(inserted.inverse.schemaVersion, projectFloorCommandSchemaVersion);
const legacyUpdate = {
schemaVersion: 1 as const,
type: "project-floor.update" as const,
payload: {
expected: legacyFloor,
target: { ...legacyFloor, name: "Erdgeschoss" },
},
};
const updated = run(legacyUpdate);
assert.equal(getFloor(context, legacyFloor.id)?.name, "Erdgeschoss");
run(updated.inverse, "undo");
assert.equal(getFloor(context, legacyFloor.id)?.name, "EG");
// A version 1 snapshot must not carry buildingId, a version 2 one must.
assert.throws(() =>
assertProjectFloorInsertProjectCommand({
schemaVersion: 1,
type: "project-floor.insert",
payload: { floor: { ...legacyFloor, buildingId: null } },
})
);
assert.throws(() =>
assertProjectFloorInsertProjectCommand({
schemaVersion: 2,
type: "project-floor.insert",
payload: { floor: legacyFloor },
})
);
} finally {
context.close();
}
});
it("validates building and floor requests", () => {
assert.equal(
createBuildingSchema.safeParse({ expectedRevision: 0, name: "Haus A" }).success,
true
);
assert.equal(
createBuildingSchema.safeParse({ expectedRevision: 0, name: " " }).success,
false
);
assert.equal(
createBuildingSchema.safeParse({
expectedRevision: 0,
name: "Haus A",
sortOrder: 1,
}).success,
false
);
for (const buildingId of [undefined, null, "building-1"]) {
assert.equal(
createFloorSchema.safeParse({ expectedRevision: 0, name: "EG", buildingId })
.success,
true
);
}
assert.equal(
updateFloorSchema.safeParse({ expectedRevision: 0, name: "EG", buildingId: "" })
.success,
false
);
});
});

View file

@ -41,6 +41,7 @@ import { projectChangeSets } from "../src/db/schema/project-change-sets.js";
import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js";
import { projects } from "../src/db/schema/projects.js"; import { projects } from "../src/db/schema/projects.js";
import { buildings } from "../src/db/schema/buildings.js";
import { floors } from "../src/db/schema/floors.js"; import { floors } from "../src/db/schema/floors.js";
import { rooms } from "../src/db/schema/rooms.js"; import { rooms } from "../src/db/schema/rooms.js";
import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js"; import { externalCsvConfigurations } from "../src/db/schema/external-csv-configurations.js";
@ -78,6 +79,8 @@ import {
createDistributionBoardComponentUpdateProjectCommand, createDistributionBoardComponentUpdateProjectCommand,
} from "../src/domain/models/distribution-board-component-structure-project-command.model.js"; } from "../src/domain/models/distribution-board-component-structure-project-command.model.js";
import { import {
createProjectBuildingInsertProjectCommand,
createProjectBuildingSnapshot,
createProjectFloorInsertProjectCommand, createProjectFloorInsertProjectCommand,
createProjectFloorSnapshot, createProjectFloorSnapshot,
createProjectRoomInsertProjectCommand, createProjectRoomInsertProjectCommand,
@ -1074,6 +1077,10 @@ describe("project command service", () => {
"MSR", "MSR",
"SiBe", "SiBe",
], ],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
}), }),
}); });
assert.equal(updated.history.currentRevision, 1); assert.equal(updated.history.currentRevision, 1);
@ -1606,6 +1613,43 @@ describe("project command service", () => {
} }
}); });
it("dispatches building commands and floor assignments with persisted inverses", () => {
const context = createTestDatabase();
try {
const service = createService(context);
const building = createProjectBuildingSnapshot("project-1", "Haus A", 0);
service.executeUser({
projectId: "project-1",
expectedRevision: 0,
command: createProjectBuildingInsertProjectCommand(building),
});
const floor = createProjectFloorSnapshot("project-1", "EG", 0, building.id);
const created = service.executeUser({
projectId: "project-1",
expectedRevision: 1,
command: createProjectFloorInsertProjectCommand(floor),
});
assert.equal(created.history.currentRevision, 2);
assert.equal(
context.db.select().from(floors).where(eq(floors.id, floor.id)).get()
?.buildingId,
building.id
);
createService(context).undo({ projectId: "project-1", expectedRevision: 2 });
createService(context).undo({ projectId: "project-1", expectedRevision: 3 });
assert.equal(context.db.select().from(buildings).all().length, 0);
createService(context).redo({ projectId: "project-1", expectedRevision: 4 });
createService(context).redo({ projectId: "project-1", expectedRevision: 5 });
assert.deepEqual(
context.db.select().from(floors).where(eq(floors.id, floor.id)).get(),
floor
);
} finally {
context.close();
}
});
it("dispatches floor and room setup with their persisted inverses", () => { it("dispatches floor and room setup with their persisted inverses", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {

View file

@ -52,6 +52,10 @@ function getSettings(context: DatabaseContext) {
threePhaseVoltageV: projects.threePhaseVoltageV, threePhaseVoltageV: projects.threePhaseVoltageV,
enabledDistributionBoardSupplyTypes: enabledDistributionBoardSupplyTypes:
projects.enabledDistributionBoardSupplyTypes, projects.enabledDistributionBoardSupplyTypes,
siteAddress: projects.siteAddress,
workStage: projects.workStage,
projectLead: projects.projectLead,
projectEditor: projects.projectEditor,
currentRevision: projects.currentRevision, currentRevision: projects.currentRevision,
}) })
.from(projects) .from(projects)
@ -81,6 +85,10 @@ function settings(
"MSR", "MSR",
"SiBe", "SiBe",
], ],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
...overrides, ...overrides,
}; };
} }
@ -217,6 +225,10 @@ describe("project settings project-command repository", () => {
"MSR", "MSR",
"SiBe", "SiBe",
], ],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
}, },
}, },
]); ]);
@ -289,6 +301,10 @@ describe("project settings project-command repository", () => {
"MSR", "MSR",
"SiBe", "SiBe",
], ],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
}); });
const undoStep = history.getNextCommand("project-1", "undo"); const undoStep = history.getNextCommand("project-1", "undo");
@ -317,6 +333,10 @@ describe("project settings project-command repository", () => {
"MSR", "MSR",
"SiBe", "SiBe",
], ],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
currentRevision: 2, currentRevision: 2,
}); });
assert.deepEqual(getDerivedVoltages(context), { assert.deepEqual(getDerivedVoltages(context), {
@ -356,6 +376,10 @@ describe("project settings project-command repository", () => {
"MSR", "MSR",
"SiBe", "SiBe",
], ],
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
currentRevision: 3, currentRevision: 3,
}); });
assert.deepEqual(getDerivedVoltages(context), { assert.deepEqual(getDerivedVoltages(context), {
@ -433,6 +457,117 @@ describe("project settings project-command repository", () => {
} }
}); });
it("persists the print cover fields with persisted undo", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSettingsProjectCommandRepository(
context.db
);
const readCover = () =>
context.db
.select({
siteAddress: projects.siteAddress,
workStage: projects.workStage,
projectLead: projects.projectLead,
projectEditor: projects.projectEditor,
})
.from(projects)
.where(eq(projects.id, "project-1"))
.get();
const forward = repository.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: createProjectSettingsUpdateProjectCommand(
settings({
siteAddress: "Musterstraße 1\n12345 Musterstadt",
workStage: "LPH 5",
projectLead: "P. Leitung",
projectEditor: "B. Arbeiter",
})
),
});
assert.deepEqual(readCover(), {
siteAddress: "Musterstraße 1\n12345 Musterstadt",
workStage: "LPH 5",
projectLead: "P. Leitung",
projectEditor: "B. Arbeiter",
});
assert.equal(forward.inverse.schemaVersion, 3);
const undoStep = new ProjectHistoryRepository(
context.db
).getNextCommand("project-1", "undo");
assert.ok(undoStep);
repository.executeUpdate({
projectId: "project-1",
expectedRevision: 1,
source: "undo",
historyTargetChangeSetId: undoStep.changeSetId,
command: forward.inverse,
});
assert.deepEqual(readCover(), {
siteAddress: null,
workStage: null,
projectLead: null,
projectEditor: null,
});
assert.throws(
() =>
createProjectSettingsUpdateProjectCommand(
settings({ workStage: " LPH 5" })
),
/invalid values/
);
} finally {
context.close();
}
});
it("keeps the cover fields when applying a version 2 command", () => {
const context = createTestDatabase();
try {
context.db
.update(projects)
.set({ workStage: "LPH 3", projectLead: "P. Leitung" })
.where(eq(projects.id, "project-1"))
.run();
const repository = new ProjectSettingsProjectCommandRepository(
context.db
);
const {
siteAddress: _siteAddress,
workStage: _workStage,
projectLead: _projectLead,
projectEditor: _projectEditor,
...versionTwoPayload
} = settings({ name: "Stand aus Version 2" });
const forward = repository.executeUpdate({
projectId: "project-1",
expectedRevision: 0,
source: "user",
command: {
schemaVersion: 2,
type: "project.update-settings",
payload: versionTwoPayload,
},
});
const project = context.db
.select()
.from(projects)
.where(eq(projects.id, "project-1"))
.get();
assert.equal(project?.name, "Stand aus Version 2");
assert.equal(project?.workStage, "LPH 3");
assert.equal(project?.projectLead, "P. Leitung");
assert.equal(forward.inverse.schemaVersion, 2);
assert.equal(Object.keys(forward.inverse.payload).length, 9);
} finally {
context.close();
}
});
it("persists the project supply-type selection and rejects disabling an in-use type", () => { it("persists the project supply-type selection and rejects disabling an in-use type", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {

View file

@ -20,6 +20,7 @@ import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js"; import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js"; import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { buildings } from "../src/db/schema/buildings.js";
import { floors } from "../src/db/schema/floors.js"; import { floors } from "../src/db/schema/floors.js";
import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectSnapshots } from "../src/db/schema/project-snapshots.js"; import { projectSnapshots } from "../src/db/schema/project-snapshots.js";
@ -59,6 +60,15 @@ function createTestDatabase(): DatabaseContext {
.where(eq(circuitSections.circuitListId, list.id)) .where(eq(circuitSections.circuitListId, list.id))
.get(); .get();
assert.ok(section); assert.ok(section);
context.db
.insert(buildings)
.values({
id: "building-1",
projectId: "project-1",
name: "Haus A",
sortOrder: 0,
})
.run();
context.db context.db
.insert(floors) .insert(floors)
.values({ .values({
@ -66,6 +76,7 @@ function createTestDatabase(): DatabaseContext {
projectId: "project-1", projectId: "project-1",
name: "EG", name: "EG",
sortOrder: 10, sortOrder: 10,
buildingId: "building-1",
}) })
.run(); .run();
context.db context.db
@ -248,7 +259,9 @@ describe("project snapshot repository", () => {
"device-1" "device-1"
); );
assert.equal(state.projectDevices.length, 1); assert.equal(state.projectDevices.length, 1);
assert.equal(state.buildings.length, 1);
assert.equal(state.floors.length, 1); assert.equal(state.floors.length, 1);
assert.equal(state.floors[0].buildingId, "building-1");
assert.equal(state.rooms.length, 1); assert.equal(state.rooms.length, 1);
assert.deepEqual(repository.listByProject("project-1"), [ assert.deepEqual(repository.listByProject("project-1"), [
created, created,
@ -377,12 +390,84 @@ describe("project snapshot repository", () => {
context.close(); context.close();
} }
}); });
it("restores snapshots stored with an older supported schema version", () => {
const context = createTestDatabase();
try {
const repository = new ProjectSnapshotRepository(context.db);
const snapshot = repository.createNamed({
projectId: "project-1",
expectedRevision: 0,
name: "Vor dem Update",
});
assert.ok(snapshot);
// Rewrite the stored payload into the previous (version 5) shape, as it
// exists for snapshots created before the current version.
const payload = JSON.parse(
context.db
.select({ payloadJson: projectSnapshots.payloadJson })
.from(projectSnapshots)
.where(eq(projectSnapshots.id, snapshot.id))
.get()!.payloadJson
) as {
schemaVersion: number;
project: Record<string, unknown>;
distributionBoards: Array<Record<string, unknown>>;
buildings?: unknown;
floors: Array<Record<string, unknown>>;
};
payload.schemaVersion = 5;
delete payload.buildings;
for (const floor of payload.floors) {
delete floor.buildingId;
}
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) {
delete payload.project[field];
}
for (const board of payload.distributionBoards) {
for (const field of ["boardNumber", "location", "fedFrom", "remark"]) {
delete board[field];
}
}
const payloadJson = JSON.stringify(payload);
context.db
.update(projectSnapshots)
.set({
schemaVersion: 5,
payloadJson,
payloadSha256: crypto
.createHash("sha256")
.update(payloadJson)
.digest("hex"),
})
.where(eq(projectSnapshots.id, snapshot.id))
.run();
const prepared = repository.prepareRestore("project-1", snapshot.id);
assert.ok(prepared);
const targetState = prepared.command.payload.targetState;
assert.equal(targetState.schemaVersion, projectStateSnapshotSchemaVersion);
assert.ok(targetState.distributionBoards.length > 0);
assert.ok(
targetState.distributionBoards.every(
(board) => board.boardNumber === null && board.fedFrom === null
)
);
// Version 5 predates buildings: the upgraded state has none.
assert.deepEqual(targetState.buildings, []);
assert.ok(targetState.floors.every((floor) => floor.buildingId === null));
} finally {
context.close();
}
});
}); });
describe("portable project transfer", () => { describe("portable project transfer", () => {
it("exports and atomically duplicates a complete project with new ids", () => { it("exports and atomically duplicates a complete project with new ids", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
context.db.update(projects).set({ siteAddress: "Musterstraße 1", workStage: "LPH 5", projectLead: "Planung", projectEditor: "Test" }).where(eq(projects.id, "project-1")).run();
context.db.update(distributionBoards).set({ boardNumber: "UV-01", location: "Technik", fedFrom: "HAK", remark: "Prüfstand" }).where(eq(distributionBoards.projectId, "project-1")).run();
const repository = new ProjectTransferRepository(context.db); const repository = new ProjectTransferRepository(context.db);
const transfer = repository.exportProject("project-1"); const transfer = repository.exportProject("project-1");
assert.ok(transfer); assert.ok(transfer);
@ -398,6 +483,13 @@ describe("portable project transfer", () => {
assert.ok(source); assert.ok(source);
assert.ok(copied); assert.ok(copied);
assert.equal(copied.currentRevision, 0); assert.equal(copied.currentRevision, 0);
assert.equal(copied.state.project.siteAddress, source.state.project.siteAddress);
assert.equal(copied.state.project.workStage, "LPH 5");
assert.equal(copied.state.project.projectLead, "Planung");
assert.equal(copied.state.project.projectEditor, "Test");
for (const field of ["boardNumber", "location", "fedFrom", "remark"] as const) {
assert.equal(copied.state.distributionBoards[0][field], source.state.distributionBoards[0][field]);
}
assert.equal(copied.state.circuits.length, source.state.circuits.length); assert.equal(copied.state.circuits.length, source.state.circuits.length);
assert.notEqual( assert.notEqual(
copied.state.circuits[0].id, copied.state.circuits[0].id,
@ -436,6 +528,11 @@ describe("portable project transfer", () => {
copied.state.distributionBoards[0].floorId, copied.state.distributionBoards[0].floorId,
copied.state.floors[0].id copied.state.floors[0].id
); );
assert.notEqual(copied.state.buildings[0].id, "building-1");
assert.equal(
copied.state.floors[0].buildingId,
copied.state.buildings[0].id
);
assert.equal( assert.equal(
copied.state.distributionBoards[0].supplyType, copied.state.distributionBoards[0].supplyType,
"SV" "SV"

View file

@ -39,6 +39,7 @@ import { circuitSections } from "../src/db/schema/circuit-sections.js";
import { circuits } from "../src/db/schema/circuits.js"; import { circuits } from "../src/db/schema/circuits.js";
import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js"; import { distributionBoardComponentProtectionDevices } from "../src/db/schema/distribution-board-component-protection-devices.js";
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js"; import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
import { floors } from "../src/db/schema/floors.js"; import { floors } from "../src/db/schema/floors.js";
import { projectDevices } from "../src/db/schema/project-devices.js"; import { projectDevices } from "../src/db/schema/project-devices.js";
import { projectRevisions } from "../src/db/schema/project-revisions.js"; import { projectRevisions } from "../src/db/schema/project-revisions.js";
@ -340,6 +341,8 @@ describe("project state restore command", () => {
it("restores a complete snapshot as a revision and supports undo and redo", () => { it("restores a complete snapshot as a revision and supports undo and redo", () => {
const context = createTestDatabase(); const context = createTestDatabase();
try { try {
context.db.update(projects).set({ siteAddress: "Originaladresse", workStage: "LPH 3", projectLead: "Leitung", projectEditor: "Bearbeitung" }).where(eq(projects.id, "project-1")).run();
context.db.update(distributionBoards).set({ boardNumber: "UV-01", location: "EG", fedFrom: "HAK", remark: "Original" }).where(eq(distributionBoards.projectId, "project-1")).run();
const snapshots = new ProjectSnapshotRepository(context.db); const snapshots = new ProjectSnapshotRepository(context.db);
const snapshot = snapshots.createNamed({ const snapshot = snapshots.createNamed({
projectId: "project-1", projectId: "project-1",
@ -351,6 +354,8 @@ describe("project state restore command", () => {
assert.ok(original); assert.ok(original);
changeCompleteProjectState(context); changeCompleteProjectState(context);
context.db.update(projects).set({ siteAddress: "Neue Adresse", workStage: "LPH 5", projectLead: null, projectEditor: null }).where(eq(projects.id, "project-1")).run();
context.db.update(distributionBoards).set({ boardNumber: "UV-02", location: "OG", fedFrom: "HV", remark: null }).where(eq(distributionBoards.projectId, "project-1")).run();
const changed = readProjectStateSnapshot(context.db, "project-1"); const changed = readProjectStateSnapshot(context.db, "project-1");
assert.ok(changed); assert.ok(changed);
assert.notEqual(changed.payloadSha256, original.payloadSha256); assert.notEqual(changed.payloadSha256, original.payloadSha256);

View file

@ -8,6 +8,35 @@ import {
} from "../src/domain/models/project-state-snapshot.model.js"; } from "../src/domain/models/project-state-snapshot.model.js";
import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js"; import { createNamedProjectSnapshotSchema } from "../src/shared/validation/project-snapshot.schemas.js";
/** Removes the fields introduced by snapshot schema version 7. */
function withoutVersionSevenFields<
T extends { floors: object[] },
>(snapshot: T): T {
delete (snapshot as Record<string, unknown>).buildings;
for (const floor of snapshot.floors) {
delete (floor as Record<string, unknown>).buildingId;
}
return snapshot;
}
/** Removes the fields introduced by snapshot schema versions 6 and 7. */
function withoutVersionSixFields<
T extends { project: object; distributionBoards: object[]; floors: object[] },
>(snapshot: T): T {
withoutVersionSevenFields(snapshot);
const project = snapshot.project as Record<string, unknown>;
for (const field of ["siteAddress", "workStage", "projectLead", "projectEditor"]) {
delete project[field];
}
for (const board of snapshot.distributionBoards) {
const record = board as Record<string, unknown>;
for (const field of ["boardNumber", "location", "fedFrom", "remark"]) {
delete record[field];
}
}
return snapshot;
}
function minimalSnapshot() { function minimalSnapshot() {
return { return {
schemaVersion: projectStateSnapshotSchemaVersion, schemaVersion: projectStateSnapshotSchemaVersion,
@ -29,6 +58,10 @@ function minimalSnapshot() {
"MSR", "MSR",
"SiBe", "SiBe",
], ],
siteAddress: "Musterstraße 1\n12345 Musterstadt" as string | null,
workStage: "LPH 5" as string | null,
projectLead: "P. Leitung" as string | null,
projectEditor: null as string | null,
}, },
distributionBoards: [], distributionBoards: [],
externalCsvConfiguration: null, externalCsvConfiguration: null,
@ -45,7 +78,19 @@ function minimalSnapshot() {
distributionBoardComponentProtectionDevices: [], distributionBoardComponentProtectionDevices: [],
circuits: [], circuits: [],
projectDevices: [], projectDevices: [],
floors: [], buildings: [] as Array<{
id: string;
projectId: string;
name: string;
sortOrder: number;
}>,
floors: [] as Array<{
id: string;
projectId: string;
name: string;
sortOrder: number;
buildingId: string | null;
}>,
rooms: [], rooms: [],
}; };
} }
@ -61,6 +106,10 @@ function protectedSnapshot() {
floorId: null, floorId: null,
supplyType: null, supplyType: null,
simultaneityFactor: 1, simultaneityFactor: 1,
boardNumber: "UV 1.1" as string | null,
location: "Raum 003 Technik" as string | null,
fedFrom: "HV 0.1" as string | null,
remark: null as string | null,
}, },
], ],
circuitLists: [ circuitLists: [
@ -151,7 +200,7 @@ describe("project state snapshot model", () => {
}); });
it("upgrades the supported baseline snapshot with a safe building default", () => { it("upgrades the supported baseline snapshot with a safe building default", () => {
const baseline = structuredClone(minimalSnapshot()); const baseline = withoutVersionSixFields(structuredClone(minimalSnapshot()));
baseline.schemaVersion = 1 as typeof baseline.schemaVersion; baseline.schemaVersion = 1 as typeof baseline.schemaVersion;
delete (baseline.project as Record<string, unknown>).isPublicBuilding; delete (baseline.project as Record<string, unknown>).isPublicBuilding;
delete (baseline as Record<string, unknown>).externalCsvConfiguration; delete (baseline as Record<string, unknown>).externalCsvConfiguration;
@ -165,7 +214,7 @@ describe("project state snapshot model", () => {
}); });
it("upgrades snapshot version 2 with an empty external CSV configuration", () => { it("upgrades snapshot version 2 with an empty external CSV configuration", () => {
const previous = structuredClone(minimalSnapshot()); const previous = withoutVersionSixFields(structuredClone(minimalSnapshot()));
previous.schemaVersion = 2 as typeof previous.schemaVersion; previous.schemaVersion = 2 as typeof previous.schemaVersion;
delete (previous as Record<string, unknown>).externalCsvConfiguration; delete (previous as Record<string, unknown>).externalCsvConfiguration;
delete (previous as Record<string, unknown>).externalModel; delete (previous as Record<string, unknown>).externalModel;
@ -183,7 +232,7 @@ describe("project state snapshot model", () => {
}); });
it("upgrades snapshot version 3 with an empty external model", () => { it("upgrades snapshot version 3 with an empty external model", () => {
const previous = structuredClone(minimalSnapshot()); const previous = withoutVersionSixFields(structuredClone(minimalSnapshot()));
previous.schemaVersion = 3 as typeof previous.schemaVersion; previous.schemaVersion = 3 as typeof previous.schemaVersion;
delete (previous as Record<string, unknown>).externalModel; delete (previous as Record<string, unknown>).externalModel;
@ -199,8 +248,52 @@ describe("project state snapshot model", () => {
}); });
}); });
it("upgrades snapshot version 5 with empty board info and cover fields", () => {
const previous = withoutVersionSixFields(protectedSnapshot());
previous.schemaVersion = 5 as typeof previous.schemaVersion;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.deepEqual(
{
siteAddress: upgraded.project.siteAddress,
workStage: upgraded.project.workStage,
projectLead: upgraded.project.projectLead,
projectEditor: upgraded.project.projectEditor,
},
{ siteAddress: null, workStage: null, projectLead: null, projectEditor: null }
);
assert.deepEqual(
{
boardNumber: upgraded.distributionBoards[0]?.boardNumber,
location: upgraded.distributionBoards[0]?.location,
fedFrom: upgraded.distributionBoards[0]?.fedFrom,
remark: upgraded.distributionBoards[0]?.remark,
},
{ boardNumber: null, location: null, fedFrom: null, remark: null }
);
});
it("keeps board info and cover values and rejects them in older versions", () => {
const parsed = parseProjectStateSnapshot(protectedSnapshot());
assert.equal(parsed.distributionBoards[0]?.boardNumber, "UV 1.1");
assert.equal(parsed.distributionBoards[0]?.location, "Raum 003 Technik");
assert.equal(parsed.distributionBoards[0]?.fedFrom, "HV 0.1");
assert.equal(parsed.project.siteAddress, "Musterstraße 1\n12345 Musterstadt");
assert.equal(parsed.project.workStage, "LPH 5");
const mislabeled = protectedSnapshot();
mislabeled.schemaVersion = 5 as typeof mislabeled.schemaVersion;
assert.throws(() => parseProjectStateSnapshot(mislabeled));
const missingCover = structuredClone(protectedSnapshot());
delete (missingCover.project as Record<string, unknown>).workStage;
assert.throws(() => parseProjectStateSnapshot(missingCover));
});
it("upgrades snapshot version 4 with the existing quantity as manual share", () => { it("upgrades snapshot version 4 with the existing quantity as manual share", () => {
const previous = protectedSnapshot(); const previous = withoutVersionSixFields(protectedSnapshot());
previous.schemaVersion = 4 as typeof previous.schemaVersion; previous.schemaVersion = 4 as typeof previous.schemaVersion;
previous.circuits[0]!.isReserve = false; previous.circuits[0]!.isReserve = false;
previous.circuits[0]!.deviceRows = [{ previous.circuits[0]!.deviceRows = [{
@ -237,7 +330,10 @@ describe("project state snapshot model", () => {
delete (outdated.project as Record<string, unknown>).description; delete (outdated.project as Record<string, unknown>).description;
assert.throws(() => parseProjectStateSnapshot(outdated)); assert.throws(() => parseProjectStateSnapshot(outdated));
assert.throws(() => assert.throws(() =>
parseProjectStateSnapshot({ ...minimalSnapshot(), schemaVersion: 7 }) parseProjectStateSnapshot({
...minimalSnapshot(),
schemaVersion: projectStateSnapshotSchemaVersion + 1,
})
); );
}); });
@ -286,12 +382,99 @@ describe("project state snapshot model", () => {
projectId: "project-2", projectId: "project-2",
name: "EG", name: "EG",
sortOrder: 10, sortOrder: 10,
buildingId: null,
}); });
assert.throws( assert.throws(
() => parseProjectStateSnapshot(wrongOwner), () => parseProjectStateSnapshot(wrongOwner),
/floor belongs to a different project/ /floor belongs to a different project/
); );
}); });
it("keeps buildings and validates floor building references", () => {
const snapshot = minimalSnapshot();
snapshot.buildings.push({
id: "building-1",
projectId: "project-1",
name: "Haus A",
sortOrder: 0,
});
snapshot.floors.push(
{
id: "floor-1",
projectId: "project-1",
name: "EG",
sortOrder: 0,
buildingId: "building-1",
},
{
id: "floor-2",
projectId: "project-1",
name: "Keller",
sortOrder: 1,
buildingId: null,
}
);
const parsed = deserializeProjectStateSnapshot(
serializeProjectStateSnapshot(parseProjectStateSnapshot(snapshot))
);
assert.deepEqual(parsed.buildings, snapshot.buildings);
assert.deepEqual(
parsed.floors.map((floor) => floor.buildingId),
["building-1", null]
);
const unknownBuilding = structuredClone(snapshot);
unknownBuilding.floors[0]!.buildingId = "building-x";
assert.throws(
() => parseProjectStateSnapshot(unknownBuilding),
/floor building/
);
const foreignBuilding = structuredClone(snapshot);
foreignBuilding.buildings[0]!.projectId = "project-2";
assert.throws(
() => parseProjectStateSnapshot(foreignBuilding),
/building belongs to a different project/
);
const floorWithoutBuildingField = structuredClone(snapshot);
delete (floorWithoutBuildingField.floors[1] as Record<string, unknown>)
.buildingId;
assert.throws(() => parseProjectStateSnapshot(floorWithoutBuildingField));
});
it("upgrades snapshot version 6 without buildings", () => {
const snapshot = minimalSnapshot();
snapshot.floors.push({
id: "floor-1",
projectId: "project-1",
name: "EG",
sortOrder: 0,
buildingId: null,
});
const previous = withoutVersionSevenFields(structuredClone(snapshot));
previous.schemaVersion = 6 as typeof previous.schemaVersion;
const upgraded = parseProjectStateSnapshot(previous);
assert.equal(upgraded.schemaVersion, projectStateSnapshotSchemaVersion);
assert.deepEqual(upgraded.buildings, []);
assert.deepEqual(upgraded.floors, [
{
id: "floor-1",
projectId: "project-1",
name: "EG",
sortOrder: 0,
buildingId: null,
},
]);
// Version 6 cover values survive the upgrade.
assert.equal(upgraded.project.workStage, "LPH 5");
const mislabeled = structuredClone(snapshot);
mislabeled.schemaVersion = 6 as typeof mislabeled.schemaVersion;
assert.throws(() => parseProjectStateSnapshot(mislabeled));
});
}); });
describe("named project snapshot request", () => { describe("named project snapshot request", () => {