Compare commits
10 commits
3ffa50be4c
...
b9462e61c5
| Author | SHA1 | Date | |
|---|---|---|---|
| b9462e61c5 | |||
| 92da72bf14 | |||
| 868f48e7ac | |||
| 4643c8d11a | |||
|
|
b7811085a3 | ||
|
|
acff38afb8 | ||
|
|
5f4d9bf789 | ||
|
|
cd89c4a499 | ||
|
|
7692699ee2 | ||
|
|
4779e4a1a7 |
95 changed files with 15950 additions and 1417 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -6,4 +6,3 @@ dist/
|
||||||
data/*.db
|
data/*.db
|
||||||
data/backups/*.db
|
data/backups/*.db
|
||||||
.codex/*.log
|
.codex/*.log
|
||||||
dynamo/output/
|
|
||||||
|
|
|
||||||
79
AGENTS.md
79
AGENTS.md
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
1152
docs/archive/revit-dynamo-leistungsbilanz-integration-konzept.md
Normal file
1152
docs/archive/revit-dynamo-leistungsbilanz-integration-konzept.md
Normal file
File diff suppressed because it is too large
Load diff
81
docs/building-structure.md
Normal file
81
docs/building-structure.md
Normal 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.
|
||||||
|
|
@ -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" } }`
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
127
docs/print-view.md
Normal 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.
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
|
||||||
}
|
|
||||||
|
|
@ -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(),
|
|
||||||
}
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
360
src/app/projects/[projectId]/print/page.tsx
Normal file
360
src/app/projects/[projectId]/print/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
792
src/app/projects/[projectId]/struktur/page.tsx
Normal file
792
src/app/projects/[projectId]/struktur/page.tsx
Normal 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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/db/migrations/0007_board_info_and_document_logo.sql
Normal file
12
src/db/migrations/0007_board_info_and_document_logo.sql
Normal 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;
|
||||||
10
src/db/migrations/0008_project_cover_and_office_profile.sql
Normal file
10
src/db/migrations/0008_project_cover_and_office_profile.sql
Normal 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;
|
||||||
9
src/db/migrations/0009_buildings.sql
Normal file
9
src/db/migrations/0009_buildings.sql
Normal 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;
|
||||||
2493
src/db/migrations/meta/0007_snapshot.json
Normal file
2493
src/db/migrations/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2553
src/db/migrations/meta/0008_snapshot.json
Normal file
2553
src/db/migrations/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
2626
src/db/migrations/meta/0009_snapshot.json
Normal file
2626
src/db/migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
27
src/db/repositories/building.repository.ts
Normal file
27
src/db/repositories/building.repository.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
) {
|
) {
|
||||||
if (command.type === distributionBoardInsertSubtreeCommandType) {
|
const snapshot = normalizeDistributionBoardSubtreeSnapshot(
|
||||||
this.insert(database, projectId, command.payload.snapshot);
|
|
||||||
return createDistributionBoardDeleteSubtreeProjectCommand(
|
|
||||||
command.payload.snapshot
|
command.payload.snapshot
|
||||||
);
|
);
|
||||||
|
if (command.type === distributionBoardInsertSubtreeCommandType) {
|
||||||
|
this.insert(database, projectId, snapshot);
|
||||||
|
return createDistributionBoardDeleteSubtreeProjectCommand(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.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
86
src/db/repositories/document-branding.repository.ts
Normal file
86
src/db/repositories/document-branding.repository.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,10 +79,14 @@ 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)
|
||||||
|
: command.schemaVersion ===
|
||||||
|
withoutCoverProjectSettingsUpdateCommandSchemaVersion
|
||||||
|
? createWithoutCoverProjectSettingsUpdateCommand(inverseValues)
|
||||||
: createProjectSettingsUpdateProjectCommand(inverseValues);
|
: createProjectSettingsUpdateProjectCommand(inverseValues);
|
||||||
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
|
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
|
||||||
const updated = tx
|
const updated = tx
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
11
src/db/schema/buildings.ts
Normal file
11
src/db/schema/buildings.ts
Normal 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),
|
||||||
|
});
|
||||||
|
|
@ -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"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
18
src/db/schema/document-branding-assets.ts
Normal file
18
src/db/schema/document-branding-assets.ts
Normal 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;
|
||||||
13
src/db/schema/document-branding-settings.ts
Normal file
13
src/db/schema/document-branding-settings.ts
Normal 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())`),
|
||||||
|
});
|
||||||
|
|
@ -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",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
) {
|
|
||||||
throw new Error("floor.sortOrder must be a non-negative integer.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upgradeLegacyFloorSnapshot(floor: unknown): ProjectFloorSnapshot {
|
||||||
|
assertLegacyProjectFloorSnapshot(floor);
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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,7 +135,8 @@ 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 }) =>
|
||||||
|
overviewProjectId ? (
|
||||||
<a
|
<a
|
||||||
key={id}
|
key={id}
|
||||||
href={`#${id}`}
|
href={`#${id}`}
|
||||||
|
|
@ -132,7 +146,35 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
|
||||||
<span className="sidebar-icon">{icon}</span>
|
<span className="sidebar-icon">{icon}</span>
|
||||||
<span className="sidebar-label">{label}</span>
|
<span className="sidebar-label">{label}</span>
|
||||||
</a>
|
</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>
|
||||||
|
|
|
||||||
110
src/frontend/components/distribution-board-info-card.tsx
Normal file
110
src/frontend/components/distribution-board-info-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/frontend/components/floor-select-options.tsx
Normal file
49
src/frontend/components/floor-select-options.tsx
Normal 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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
191
src/frontend/print/print-document-model.ts
Normal file
191
src/frontend/print/print-document-model.ts
Normal 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;
|
||||||
|
}
|
||||||
481
src/frontend/print/print-document.css
Normal file
481
src/frontend/print/print-document.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
625
src/frontend/print/print-document.tsx
Normal file
625
src/frontend/print/print-document.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
284
src/frontend/utils/building-structure.ts
Normal file
284
src/frontend/utils/building-structure.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
111
src/server/controllers/building.controller.ts
Normal file
111
src/server/controllers/building.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
100
src/server/controllers/document-branding.controller.ts
Normal file
100
src/server/controllers/document-branding.controller.ts
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
10
src/server/routes/document-branding.routes.ts
Normal file
10
src/server/routes/document-branding.routes.ts
Normal 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);
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
|
||||||
9
src/shared/constants/document-branding.ts
Normal file
9
src/shared/constants/document-branding.ts
Normal 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;
|
||||||
45
src/shared/constants/project-cover.ts
Normal file
45
src/shared/constants/project-cover.ts
Normal 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,
|
||||||
|
};
|
||||||
42
src/shared/validation/document-branding.schemas.ts
Normal file
42
src/shared/validation/document-branding.schemas.ts
Normal 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();
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
100
tests/building-structure-frontend-api.test.ts
Normal file
100
tests/building-structure-frontend-api.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
284
tests/building-structure-view.test.ts
Normal file
284
tests/building-structure-view.test.ts
Normal 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"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
77
tests/document-branding.repository.test.ts
Normal file
77
tests/document-branding.repository.test.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
207
tests/print-document-model.test.ts
Normal file
207
tests/print-document-model.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
62
tests/print-view-migration.test.ts
Normal file
62
tests/print-view-migration.test.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
305
tests/project-building-structure.test.ts
Normal file
305
tests/project-building-structure.test.ts
Normal 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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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", () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue