Compare commits
No commits in common. "7692699ee284e27e699bc0dc5c8bbe85155c4bcf" and "2e3e85a8e52cfd1bd3434b9cc03af22bf164119d" have entirely different histories.
7692699ee2
...
2e3e85a8e5
56 changed files with 72 additions and 8702 deletions
61
AGENTS.md
61
AGENTS.md
|
|
@ -250,13 +250,8 @@ not change the project revision or undo/redo stacks. Restoring a server-stored
|
||||||
snapshot verifies its checksum and the current-state hash, replaces supported
|
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 6 additionally stores the distribution-board
|
Current snapshot schema version 5 additionally stores the manual quantity share
|
||||||
info fields `boardNumber`, `location`, `fedFrom` and `remark` and the project
|
of every CircuitDeviceRow. Version 4 snapshots are upgraded with
|
||||||
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 6)
|
|
||||||
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
|
||||||
|
|
@ -270,7 +265,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/6, restore and portable
|
delete, Undo and Redo are revision-safe. Snapshot schema 5, restore and portable
|
||||||
project duplication preserve the configuration; duplication remaps its internal
|
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
|
||||||
|
|
@ -284,7 +279,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/6
|
to typed command adapters. Snapshot/restore and portable project transfer schema version 5
|
||||||
capture the complete external state and remap every internal UUID and link while
|
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
|
||||||
|
|
@ -373,10 +368,6 @@ 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
|
||||||
|
|
@ -431,18 +422,6 @@ the only supported circuit-protection source; no flat compatibility fields remai
|
||||||
Distribution-board floor assignment and a project-enabled supply type use
|
Distribution-board 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
|
||||||
|
|
@ -464,35 +443,6 @@ 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
|
||||||
|
|
||||||
When explicitly working on Phase 14, use
|
When explicitly working on Phase 14, use
|
||||||
|
|
@ -588,9 +538,6 @@ Users must be able to override sizing suggestions.
|
||||||
- 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,6 @@ 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
|
|
||||||
- [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
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,8 @@ 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 `3` (including the print cover fields)
|
`project.update-settings` uses version `2` and still accepts supported baseline
|
||||||
and still accepts version `2` and supported baseline version `1` commands
|
version `1` commands without changing the newer building classification.
|
||||||
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
|
||||||
|
|
@ -174,9 +173,6 @@ 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": { ... } }`
|
||||||
|
|
@ -234,13 +230,9 @@ 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", "simultaneityFactor": 1, "expectedRevision": 13 }`
|
`{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }`
|
||||||
- optional info-card fields: `boardNumber` (max. 50), `location`,
|
- executes `distribution-board.update`; floor and supply type are restored
|
||||||
`fedFrom` (max. 200 each) and `remark` (max. 2000); strings are trimmed
|
together by persistent Undo/Redo
|
||||||
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
|
||||||
|
|
@ -433,27 +425,3 @@ 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" } }`
|
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,3 @@
|
||||||
- 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.
|
|
||||||
|
|
|
||||||
|
|
@ -232,9 +232,8 @@ 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 Deckblattangaben (Anschrift,
|
Projektnummer, Bauherr, Beschreibung, die Kennzeichnung als öffentliches
|
||||||
Leistungsphase, Projektleitung, Bearbeitung; Schema-Version 3, Migration
|
Gebäude, beide Standardspannungen sowie die im Projekt freigeschalteten
|
||||||
`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
|
||||||
|
|
@ -417,14 +416,7 @@ 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`.
|
||||||
Derselbe Befehl versioniert die Felder der Verteiler-Infokarte
|
Snapshot-Schema 5 enthält zusätzlich den manuellen Mengenanteil jeder
|
||||||
(`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 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
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
# Seitenansicht und PDF-Export
|
|
||||||
|
|
||||||
Die Seitenansicht ist rein lesend und verändert keine Projektdaten.
|
|
||||||
|
|
||||||
## 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 genau der Nummer oder
|
|
||||||
Bezeichnung eines anderen Verteilers entspricht; „Σ 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
|
|
||||||
|
|
@ -12,8 +12,6 @@ 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(", ");
|
||||||
|
|
@ -89,10 +87,6 @@ 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)
|
||||||
|
|
@ -104,10 +98,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,6 @@ 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 {
|
import {
|
||||||
ProjectSettingsModal,
|
ProjectSettingsModal,
|
||||||
type ProjectSettingsInput,
|
type ProjectSettingsInput,
|
||||||
|
|
@ -107,15 +101,6 @@ export default function ProjectDetailPage() {
|
||||||
setEditingBoardSimultaneityFactor,
|
setEditingBoardSimultaneityFactor,
|
||||||
] = useState("1");
|
] = useState("1");
|
||||||
const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
|
const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
|
||||||
const [editingBoardInfo, setEditingBoardInfo] =
|
|
||||||
useState<DistributionBoardInfoDraft>(
|
|
||||||
toDistributionBoardInfoDraft({
|
|
||||||
boardNumber: null,
|
|
||||||
location: null,
|
|
||||||
fedFrom: null,
|
|
||||||
remark: null,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const [floorName, setFloorName] = useState("");
|
const [floorName, setFloorName] = useState("");
|
||||||
const [editingFloor, setEditingFloor] = useState<FloorDto | null>(null);
|
const [editingFloor, setEditingFloor] = useState<FloorDto | null>(null);
|
||||||
const [editingRoom, setEditingRoom] = useState<RoomDto | null>(null);
|
const [editingRoom, setEditingRoom] = useState<RoomDto | null>(null);
|
||||||
|
|
@ -439,7 +424,6 @@ 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() {
|
||||||
|
|
@ -475,7 +459,6 @@ export default function ProjectDetailPage() {
|
||||||
floorId: editingBoardFloorId || null,
|
floorId: editingBoardFloorId || null,
|
||||||
supplyType: editingBoardSupplyType,
|
supplyType: editingBoardSupplyType,
|
||||||
simultaneityFactor,
|
simultaneityFactor,
|
||||||
...fromDistributionBoardInfoDraft(editingBoardInfo),
|
|
||||||
},
|
},
|
||||||
project.currentRevision
|
project.currentRevision
|
||||||
);
|
);
|
||||||
|
|
@ -866,13 +849,6 @@ 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"
|
|
||||||
href={`/projects/${projectId}/print`}
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Seitenansicht
|
|
||||||
</Link>
|
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-primary"
|
className="btn btn-outline-primary"
|
||||||
disabled={!project}
|
disabled={!project}
|
||||||
|
|
@ -946,9 +922,7 @@ 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>
|
||||||
|
|
@ -960,11 +934,7 @@ 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"
|
||||||
|
|
@ -1015,7 +985,7 @@ export default function ProjectDetailPage() {
|
||||||
})}
|
})}
|
||||||
{!boards.length ? (
|
{!boards.length ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="text-center text-secondary py-4">
|
<td colSpan={5} className="text-center text-secondary py-4">
|
||||||
Noch keine Verteilungen vorhanden.
|
Noch keine Verteilungen vorhanden.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1494,12 +1464,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,357 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { ChangeEvent, FormEvent, useEffect, useMemo, useState } from "react";
|
|
||||||
import "../../../../frontend/print/print-document.css";
|
|
||||||
import {
|
|
||||||
deleteDocumentLogo,
|
|
||||||
documentLogoUrl,
|
|
||||||
getCircuitTree,
|
|
||||||
getDocumentBranding,
|
|
||||||
getProject,
|
|
||||||
listCircuitLists,
|
|
||||||
listDistributionBoards,
|
|
||||||
listFloors,
|
|
||||||
listProjectSnapshots,
|
|
||||||
updateOfficeProfile,
|
|
||||||
uploadDocumentLogo,
|
|
||||||
} from "../../../../frontend/utils/api";
|
|
||||||
import type {
|
|
||||||
DocumentLogoMetadataDto,
|
|
||||||
OfficeProfileDto,
|
|
||||||
ProjectDto,
|
|
||||||
ProjectSnapshotMetadataDto,
|
|
||||||
} from "../../../../frontend/types";
|
|
||||||
import { FormModal } from "../../../../frontend/components/form-modal";
|
|
||||||
import {
|
|
||||||
officeProfileFieldLabels,
|
|
||||||
officeProfileFieldMaxLengths,
|
|
||||||
} from "../../../../shared/constants/project-cover";
|
|
||||||
import { PrintDocument } from "../../../../frontend/print/print-document";
|
|
||||||
import {
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
);
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
CREATE TABLE `document_branding_assets` (
|
|
||||||
`key` text PRIMARY KEY NOT NULL,
|
|
||||||
`mime_type` text NOT NULL,
|
|
||||||
`file_name` text,
|
|
||||||
`data` blob NOT NULL,
|
|
||||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `distribution_boards` ADD `board_number` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `distribution_boards` ADD `location` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `distribution_boards` ADD `fed_from` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `distribution_boards` ADD `remark` text;
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
CREATE TABLE `document_branding_settings` (
|
|
||||||
`key` text PRIMARY KEY NOT NULL,
|
|
||||||
`value` text NOT NULL,
|
|
||||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
ALTER TABLE `projects` ADD `site_address` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `projects` ADD `work_stage` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `projects` ADD `project_lead` text;--> statement-breakpoint
|
|
||||||
ALTER TABLE `projects` ADD `project_editor` text;
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -50,20 +50,6 @@
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -388,15 +388,10 @@ 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,7 +10,6 @@ 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 {
|
||||||
|
|
@ -64,20 +63,19 @@ export class DistributionBoardSubtreeProjectCommandRepository
|
||||||
projectId: string,
|
projectId: string,
|
||||||
command: DistributionBoardSubtreeProjectCommand
|
command: DistributionBoardSubtreeProjectCommand
|
||||||
) {
|
) {
|
||||||
const snapshot = normalizeDistributionBoardSubtreeSnapshot(
|
|
||||||
command.payload.snapshot
|
|
||||||
);
|
|
||||||
if (command.type === distributionBoardInsertSubtreeCommandType) {
|
if (command.type === distributionBoardInsertSubtreeCommandType) {
|
||||||
this.insert(database, projectId, snapshot);
|
this.insert(database, projectId, command.payload.snapshot);
|
||||||
return createDistributionBoardDeleteSubtreeProjectCommand(snapshot);
|
return createDistributionBoardDeleteSubtreeProjectCommand(
|
||||||
|
command.payload.snapshot
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (command.type === distributionBoardDeleteSubtreeCommandType) {
|
if (command.type === distributionBoardDeleteSubtreeCommandType) {
|
||||||
const current = this.captureFrom(
|
const current = this.captureFrom(
|
||||||
database,
|
database,
|
||||||
projectId,
|
projectId,
|
||||||
snapshot.distributionBoard.id
|
command.payload.snapshot.distributionBoard.id
|
||||||
);
|
);
|
||||||
if (!sameSnapshot(current, snapshot)) {
|
if (!sameSnapshot(current, command.payload.snapshot)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Distribution-board subtree changed before deletion."
|
"Distribution-board subtree changed before deletion."
|
||||||
);
|
);
|
||||||
|
|
@ -99,7 +97,9 @@ export class DistributionBoardSubtreeProjectCommandRepository
|
||||||
"Distribution-board subtree could not be deleted."
|
"Distribution-board subtree could not be deleted."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return createDistributionBoardInsertSubtreeProjectCommand(snapshot);
|
return createDistributionBoardInsertSubtreeProjectCommand(
|
||||||
|
command.payload.snapshot
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw new Error("Unsupported distribution-board subtree command.");
|
throw new Error("Unsupported distribution-board subtree command.");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,86 +0,0 @@
|
||||||
import { eq, inArray } from "drizzle-orm";
|
|
||||||
import type { AppDatabase } from "../database-context.js";
|
|
||||||
import {
|
|
||||||
documentBrandingAssets,
|
|
||||||
type DocumentBrandingAsset,
|
|
||||||
} from "../schema/document-branding-assets.js";
|
|
||||||
import { documentBrandingSettings } from "../schema/document-branding-settings.js";
|
|
||||||
|
|
||||||
// Plain repository without revision semantics: branding is global office
|
|
||||||
// configuration, not a project mutation.
|
|
||||||
export class DocumentBrandingRepository {
|
|
||||||
constructor(private readonly db: AppDatabase) {}
|
|
||||||
|
|
||||||
get(key: string): DocumentBrandingAsset | undefined {
|
|
||||||
return this.db
|
|
||||||
.select()
|
|
||||||
.from(documentBrandingAssets)
|
|
||||||
.where(eq(documentBrandingAssets.key, key))
|
|
||||||
.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
upsert(asset: {
|
|
||||||
key: string;
|
|
||||||
mimeType: string;
|
|
||||||
fileName: string | null;
|
|
||||||
data: Buffer;
|
|
||||||
}): DocumentBrandingAsset {
|
|
||||||
const updatedAt = new Date();
|
|
||||||
return this.db
|
|
||||||
.insert(documentBrandingAssets)
|
|
||||||
.values({ ...asset, updatedAt })
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: documentBrandingAssets.key,
|
|
||||||
set: {
|
|
||||||
mimeType: asset.mimeType,
|
|
||||||
fileName: asset.fileName,
|
|
||||||
data: asset.data,
|
|
||||||
updatedAt,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.returning()
|
|
||||||
.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(key: string): boolean {
|
|
||||||
return (
|
|
||||||
this.db
|
|
||||||
.delete(documentBrandingAssets)
|
|
||||||
.where(eq(documentBrandingAssets.key, key))
|
|
||||||
.run().changes > 0
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getSettings<TKey extends string>(keys: readonly TKey[]): Record<TKey, string | null> {
|
|
||||||
const rows = this.db
|
|
||||||
.select()
|
|
||||||
.from(documentBrandingSettings)
|
|
||||||
.where(inArray(documentBrandingSettings.key, [...keys]))
|
|
||||||
.all();
|
|
||||||
return Object.fromEntries(
|
|
||||||
keys.map((key) => [key, rows.find((row) => row.key === key)?.value ?? null])
|
|
||||||
) as Record<TKey, string | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Stores the given values atomically; `null` removes a setting. */
|
|
||||||
setSettings(values: Record<string, string | null>) {
|
|
||||||
const updatedAt = new Date();
|
|
||||||
this.db.transaction((tx) => {
|
|
||||||
for (const [key, value] of Object.entries(values)) {
|
|
||||||
if (value === null) {
|
|
||||||
tx.delete(documentBrandingSettings)
|
|
||||||
.where(eq(documentBrandingSettings.key, key))
|
|
||||||
.run();
|
|
||||||
} else {
|
|
||||||
tx.insert(documentBrandingSettings)
|
|
||||||
.values({ key, value, updatedAt })
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: documentBrandingSettings.key,
|
|
||||||
set: { value, updatedAt },
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
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,
|
||||||
|
|
@ -43,27 +40,13 @@ 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 =
|
||||||
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
|
input.command.schemaVersion === 1
|
||||||
? {
|
? {
|
||||||
...command.payload,
|
...input.command.payload,
|
||||||
isPublicBuilding: current.isPublicBuilding,
|
isPublicBuilding: current.isPublicBuilding,
|
||||||
...currentCoverValues,
|
|
||||||
}
|
}
|
||||||
: command.schemaVersion ===
|
: input.command.payload;
|
||||||
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.");
|
||||||
}
|
}
|
||||||
|
|
@ -79,15 +62,11 @@ export class ProjectSettingsProjectCommandRepository
|
||||||
threePhaseVoltageV: current.threePhaseVoltageV,
|
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||||
enabledDistributionBoardSupplyTypes:
|
enabledDistributionBoardSupplyTypes:
|
||||||
current.enabledDistributionBoardSupplyTypes,
|
current.enabledDistributionBoardSupplyTypes,
|
||||||
...currentCoverValues,
|
|
||||||
};
|
};
|
||||||
const inverse =
|
const inverse =
|
||||||
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
|
input.command.schemaVersion === 1
|
||||||
? createBaselineProjectSettingsUpdateCommand(inverseValues)
|
? createBaselineProjectSettingsUpdateCommand(inverseValues)
|
||||||
: command.schemaVersion ===
|
: createProjectSettingsUpdateProjectCommand(inverseValues);
|
||||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion
|
|
||||||
? createWithoutCoverProjectSettingsUpdateCommand(inverseValues)
|
|
||||||
: createProjectSettingsUpdateProjectCommand(inverseValues);
|
|
||||||
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
|
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
|
||||||
const updated = tx
|
const updated = tx
|
||||||
.update(projects)
|
.update(projects)
|
||||||
|
|
@ -103,32 +82,11 @@ export class ProjectSettingsProjectCommandRepository
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWithoutCoverProjectSettingsUpdateCommand(
|
|
||||||
values: ProjectSettingsValues
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
schemaVersion: withoutCoverProjectSettingsUpdateCommandSchemaVersion,
|
|
||||||
type: "project.update-settings" as const,
|
|
||||||
payload: {
|
|
||||||
name: values.name,
|
|
||||||
internalProjectNumber: values.internalProjectNumber,
|
|
||||||
externalProjectNumber: values.externalProjectNumber,
|
|
||||||
buildingOwner: values.buildingOwner,
|
|
||||||
description: values.description,
|
|
||||||
isPublicBuilding: values.isPublicBuilding,
|
|
||||||
singlePhaseVoltageV: values.singlePhaseVoltageV,
|
|
||||||
threePhaseVoltageV: values.threePhaseVoltageV,
|
|
||||||
enabledDistributionBoardSupplyTypes:
|
|
||||||
values.enabledDistributionBoardSupplyTypes,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createBaselineProjectSettingsUpdateCommand(
|
function createBaselineProjectSettingsUpdateCommand(
|
||||||
values: ProjectSettingsValues
|
values: ProjectSettingsValues
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
schemaVersion: baselineProjectSettingsUpdateCommandSchemaVersion,
|
schemaVersion: 1 as const,
|
||||||
type: "project.update-settings" as const,
|
type: "project.update-settings" as const,
|
||||||
payload: {
|
payload: {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
|
|
@ -156,9 +114,8 @@ 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
|
||||||
projectCoverFields.every((field) => current[field] === target[field]) &&
|
&& sameSupplyTypes(
|
||||||
sameSupplyTypes(
|
|
||||||
current.enabledDistributionBoardSupplyTypes,
|
current.enabledDistributionBoardSupplyTypes,
|
||||||
target.enabledDistributionBoardSupplyTypes
|
target.enabledDistributionBoardSupplyTypes
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ 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 {
|
||||||
|
|
@ -139,7 +138,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 (!isSupportedProjectStateSnapshotSchemaVersion(stored.schemaVersion)) {
|
if (stored.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
||||||
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);
|
||||||
|
|
|
||||||
|
|
@ -233,10 +233,6 @@ 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();
|
||||||
|
|
|
||||||
|
|
@ -46,10 +46,6 @@ 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)
|
||||||
|
|
@ -253,10 +249,6 @@ 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,
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,6 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,5 @@ 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"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
||||||
|
|
||||||
// Office-wide branding for printed documents (currently only the logo shown
|
|
||||||
// on the cover page and plan title blocks). Global application data, not part
|
|
||||||
// of any project: it is deliberately outside project revisions, logical
|
|
||||||
// project snapshots and project transfer.
|
|
||||||
export const documentBrandingAssets = sqliteTable("document_branding_assets", {
|
|
||||||
key: text("key").primaryKey(),
|
|
||||||
mimeType: text("mime_type").notNull(),
|
|
||||||
fileName: text("file_name"),
|
|
||||||
data: blob("data", { mode: "buffer" }).notNull(),
|
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(unixepoch())`),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type DocumentBrandingAsset = typeof documentBrandingAssets.$inferSelect;
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
|
||||||
|
|
||||||
// Office-wide text settings for printed documents (e.g. office name and
|
|
||||||
// address on the cover page). Global application data like
|
|
||||||
// document_branding_assets: outside project revisions, snapshots and transfer.
|
|
||||||
export const documentBrandingSettings = sqliteTable("document_branding_settings", {
|
|
||||||
key: text("key").primaryKey(),
|
|
||||||
value: text("value").notNull(),
|
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
|
||||||
.notNull()
|
|
||||||
.default(sql`(unixepoch())`),
|
|
||||||
});
|
|
||||||
|
|
@ -21,9 +21,5 @@ 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,21 +1,14 @@
|
||||||
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;
|
||||||
|
|
@ -89,20 +82,16 @@ 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 (isInfoField(change.field)) {
|
if (change.field === "floorId") {
|
||||||
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())
|
||||||
|
|
@ -132,29 +121,6 @@ export function assertDistributionBoardUpdateProjectCommand(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInfoField(field: string): field is DistributionBoardInfoField {
|
|
||||||
return (distributionBoardInfoFields as readonly string[]).includes(field);
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertInfoFieldValue(
|
|
||||||
field: DistributionBoardInfoField,
|
|
||||||
value: unknown
|
|
||||||
) {
|
|
||||||
if (value === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof value !== "string" ||
|
|
||||||
!value.trim() ||
|
|
||||||
value !== value.trim() ||
|
|
||||||
value.length > distributionBoardInfoFieldMaxLengths[field]
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`${field} must be a trimmed, non-empty string of at most ${distributionBoardInfoFieldMaxLengths[field]} characters or null.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
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,9 +10,7 @@ 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 {
|
||||||
|
|
@ -74,7 +72,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;
|
||||||
|
|
@ -171,22 +169,6 @@ 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 {
|
||||||
|
|
@ -222,11 +204,6 @@ 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",
|
||||||
|
|
|
||||||
|
|
@ -3,23 +3,13 @@ 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;
|
||||||
// Version 3 adds the print cover fields. Versions 2 (without cover fields)
|
export const projectSettingsUpdateCommandSchemaVersion = 2 as const;
|
||||||
// and 1 (additionally without the public-building flag) stay executable;
|
const previousProjectSettingsUpdateCommandSchemaVersion = 1 as const;
|
||||||
// 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;
|
||||||
|
|
@ -31,13 +21,8 @@ export interface ProjectSettingsValues
|
||||||
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WithoutCoverProjectSettingsValues = Omit<
|
type PreviousProjectSettingsValues = Omit<
|
||||||
ProjectSettingsValues,
|
ProjectSettingsValues,
|
||||||
ProjectCoverField
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type BaselineProjectSettingsValues = Omit<
|
|
||||||
WithoutCoverProjectSettingsValues,
|
|
||||||
"isPublicBuilding"
|
"isPublicBuilding"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|
@ -47,22 +32,15 @@ export interface CurrentProjectSettingsUpdateProjectCommand
|
||||||
type: typeof projectSettingsUpdateCommandType;
|
type: typeof projectSettingsUpdateCommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WithoutCoverProjectSettingsUpdateProjectCommand
|
interface PreviousProjectSettingsUpdateProjectCommand
|
||||||
extends SerializedProjectCommand<WithoutCoverProjectSettingsValues> {
|
extends SerializedProjectCommand<PreviousProjectSettingsValues> {
|
||||||
schemaVersion: typeof withoutCoverProjectSettingsUpdateCommandSchemaVersion;
|
schemaVersion: typeof previousProjectSettingsUpdateCommandSchemaVersion;
|
||||||
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
|
||||||
| WithoutCoverProjectSettingsUpdateProjectCommand
|
| PreviousProjectSettingsUpdateProjectCommand;
|
||||||
| BaselineProjectSettingsUpdateProjectCommand;
|
|
||||||
|
|
||||||
export function createProjectSettingsUpdateProjectCommand(
|
export function createProjectSettingsUpdateProjectCommand(
|
||||||
values: ProjectSettingsValues
|
values: ProjectSettingsValues
|
||||||
|
|
@ -82,10 +60,7 @@ export function assertProjectSettingsUpdateProjectCommand(
|
||||||
if (
|
if (
|
||||||
command.type !== projectSettingsUpdateCommandType ||
|
command.type !== projectSettingsUpdateCommandType ||
|
||||||
(command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion &&
|
(command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion &&
|
||||||
command.schemaVersion !==
|
command.schemaVersion !== previousProjectSettingsUpdateCommandSchemaVersion)
|
||||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion &&
|
|
||||||
command.schemaVersion !==
|
|
||||||
baselineProjectSettingsUpdateCommandSchemaVersion)
|
|
||||||
) {
|
) {
|
||||||
throw new Error("Unsupported project settings update command.");
|
throw new Error("Unsupported project settings update command.");
|
||||||
}
|
}
|
||||||
|
|
@ -116,23 +91,13 @@ export function assertProjectSettingsUpdateProjectCommand(
|
||||||
"Project settings update command contains invalid supply types."
|
"Project settings update command contains invalid supply types."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const payload = command.payload;
|
const isPreviousSchema =
|
||||||
const expectedKeyCount =
|
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion;
|
||||||
command.schemaVersion === baselineProjectSettingsUpdateCommandSchemaVersion
|
|
||||||
? 8
|
|
||||||
: command.schemaVersion ===
|
|
||||||
withoutCoverProjectSettingsUpdateCommandSchemaVersion
|
|
||||||
? 9
|
|
||||||
: 9 + projectCoverFields.length;
|
|
||||||
if (
|
if (
|
||||||
Object.keys(payload).length !== expectedKeyCount ||
|
(isPreviousSchema && Object.keys(command.payload).length !== 8) ||
|
||||||
(command.schemaVersion !==
|
(!isPreviousSchema &&
|
||||||
baselineProjectSettingsUpdateCommandSchemaVersion &&
|
(typeof command.payload.isPublicBuilding !== "boolean" ||
|
||||||
typeof payload.isPublicBuilding !== "boolean") ||
|
Object.keys(command.payload).length !== 9))
|
||||||
(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,13 +1,5 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { distributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js";
|
||||||
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";
|
||||||
|
|
@ -35,8 +27,7 @@ import {
|
||||||
type ExternalModelStateSnapshot,
|
type ExternalModelStateSnapshot,
|
||||||
} from "../../external-model/domain/external-model-contracts.js";
|
} from "../../external-model/domain/external-model-contracts.js";
|
||||||
|
|
||||||
export const projectStateSnapshotSchemaVersion = 6 as const;
|
export const projectStateSnapshotSchemaVersion = 5 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;
|
||||||
|
|
@ -67,31 +58,11 @@ const previousProjectSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const publicBuildingProjectSchema = previousProjectSchema
|
const projectSchema = previousProjectSchema
|
||||||
.extend({ isPublicBuilding: z.boolean() })
|
.extend({ isPublicBuilding: z.boolean() })
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const projectCoverValueSchema = (field: ProjectCoverField) =>
|
const distributionBoardSchema = z
|
||||||
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,
|
||||||
|
|
@ -102,35 +73,6 @@ const legacyDistributionBoardSchema = 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,
|
||||||
|
|
@ -515,22 +457,11 @@ export const projectStateSnapshotSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const boardInfoProjectStateSnapshotSchema = z
|
|
||||||
.object({
|
|
||||||
schemaVersion: z.literal(boardInfoProjectStateSnapshotSchemaVersion),
|
|
||||||
project: publicBuildingProjectSchema,
|
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
|
||||||
externalModel: externalModelStateSchema,
|
|
||||||
...projectStateSnapshotContents,
|
|
||||||
})
|
|
||||||
.strict();
|
|
||||||
|
|
||||||
const previousProjectStateSnapshotSchema = z
|
const previousProjectStateSnapshotSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
||||||
project: publicBuildingProjectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
externalModel: externalModelStateSchema,
|
externalModel: externalModelStateSchema,
|
||||||
...projectStateSnapshotContents,
|
...projectStateSnapshotContents,
|
||||||
|
|
@ -540,8 +471,8 @@ const previousProjectStateSnapshotSchema = z
|
||||||
const externalModelProjectStateSnapshotSchema = z
|
const externalModelProjectStateSnapshotSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(externalModelProjectStateSnapshotSchemaVersion),
|
||||||
project: publicBuildingProjectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
externalCsvConfiguration: externalCsvConfigurationSnapshotSchema.nullable(),
|
||||||
...projectStateSnapshotContents,
|
...projectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
|
|
@ -550,8 +481,8 @@ const externalModelProjectStateSnapshotSchema = z
|
||||||
const legacyProjectStateSnapshotSchema = z
|
const legacyProjectStateSnapshotSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
||||||
project: publicBuildingProjectSchema,
|
project: projectSchema,
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
...projectStateSnapshotContents,
|
...projectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
@ -560,7 +491,7 @@ const baselineProjectStateSnapshotSchema = z
|
||||||
.object({
|
.object({
|
||||||
schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion),
|
schemaVersion: z.literal(baselineProjectStateSnapshotSchemaVersion),
|
||||||
project: previousProjectSchema,
|
project: previousProjectSchema,
|
||||||
distributionBoards: z.array(legacyDistributionBoardSchema),
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
...projectStateSnapshotContents,
|
...projectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
@ -569,20 +500,6 @@ 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 {
|
||||||
|
|
@ -604,29 +521,11 @@ 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: projectStateSnapshotSchemaVersion,
|
|
||||||
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: projectStateSnapshotSchemaVersion,
|
||||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
|
||||||
distributionBoards: withEmptyDistributionBoardInfo(
|
|
||||||
previous.distributionBoards
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (schemaVersion === externalModelProjectStateSnapshotSchemaVersion) {
|
if (schemaVersion === externalModelProjectStateSnapshotSchemaVersion) {
|
||||||
|
|
@ -634,10 +533,6 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||||
return {
|
return {
|
||||||
...previous,
|
...previous,
|
||||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
project: { ...previous.project, ...emptyProjectCoverValues },
|
|
||||||
distributionBoards: withEmptyDistributionBoardInfo(
|
|
||||||
previous.distributionBoards
|
|
||||||
),
|
|
||||||
externalModel: emptyExternalModelState(),
|
externalModel: emptyExternalModelState(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -646,10 +541,6 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||||
return {
|
return {
|
||||||
...legacy,
|
...legacy,
|
||||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
project: { ...legacy.project, ...emptyProjectCoverValues },
|
|
||||||
distributionBoards: withEmptyDistributionBoardInfo(
|
|
||||||
legacy.distributionBoards
|
|
||||||
),
|
|
||||||
externalCsvConfiguration: null,
|
externalCsvConfiguration: null,
|
||||||
externalModel: emptyExternalModelState(),
|
externalModel: emptyExternalModelState(),
|
||||||
};
|
};
|
||||||
|
|
@ -659,14 +550,7 @@ function upgradePreviousProjectStateSnapshot(value: unknown): unknown {
|
||||||
return {
|
return {
|
||||||
...baseline,
|
...baseline,
|
||||||
schemaVersion: projectStateSnapshotSchemaVersion,
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
project: {
|
project: { ...baseline.project, isPublicBuilding: false },
|
||||||
...baseline.project,
|
|
||||||
isPublicBuilding: false,
|
|
||||||
...emptyProjectCoverValues,
|
|
||||||
},
|
|
||||||
distributionBoards: withEmptyDistributionBoardInfo(
|
|
||||||
baseline.distributionBoards
|
|
||||||
),
|
|
||||||
externalCsvConfiguration: null,
|
externalCsvConfiguration: null,
|
||||||
externalModel: emptyExternalModelState(),
|
externalModel: emptyExternalModelState(),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
distributionBoardInfoFieldLabels,
|
|
||||||
distributionBoardInfoFieldMaxLengths,
|
|
||||||
distributionBoardInfoFields,
|
|
||||||
type DistributionBoardInfoField,
|
|
||||||
} from "../../shared/constants/distribution-board";
|
|
||||||
import type { DistributionBoardInfoDto } from "../types";
|
|
||||||
|
|
||||||
export type DistributionBoardInfoDraft = Record<DistributionBoardInfoField, string>;
|
|
||||||
|
|
||||||
const placeholders: Record<DistributionBoardInfoField, string> = {
|
|
||||||
boardNumber: "z. B. UV 1.1",
|
|
||||||
location: "z. B. Raum 003 Technik",
|
|
||||||
fedFrom: "z. B. HV 0.1 oder HAK / Zähleranlage",
|
|
||||||
remark: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function toDistributionBoardInfoDraft(
|
|
||||||
info: DistributionBoardInfoDto
|
|
||||||
): DistributionBoardInfoDraft {
|
|
||||||
return {
|
|
||||||
boardNumber: info.boardNumber ?? "",
|
|
||||||
location: info.location ?? "",
|
|
||||||
fedFrom: info.fedFrom ?? "",
|
|
||||||
remark: info.remark ?? "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromDistributionBoardInfoDraft(
|
|
||||||
draft: DistributionBoardInfoDraft
|
|
||||||
): DistributionBoardInfoDto {
|
|
||||||
const value = (field: DistributionBoardInfoField) =>
|
|
||||||
draft[field].trim() ? draft[field].trim() : null;
|
|
||||||
return {
|
|
||||||
boardNumber: value("boardNumber"),
|
|
||||||
location: value("location"),
|
|
||||||
fedFrom: value("fedFrom"),
|
|
||||||
remark: value("remark"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Descriptive board data used by the print view title block. The values are
|
|
||||||
* local planning values; a later Revit import may prefill them only through
|
|
||||||
* an explicit, previewed decision.
|
|
||||||
*/
|
|
||||||
export function DistributionBoardInfoCard({
|
|
||||||
draft,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
draft: DistributionBoardInfoDraft;
|
|
||||||
onChange: (draft: DistributionBoardInfoDraft) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
aria-labelledby="distribution-board-info-heading"
|
|
||||||
className="card"
|
|
||||||
>
|
|
||||||
<div className="card-header" id="distribution-board-info-heading">
|
|
||||||
Infokarte
|
|
||||||
</div>
|
|
||||||
<div className="card-body row g-3">
|
|
||||||
{distributionBoardInfoFields.map((field) => (
|
|
||||||
<div
|
|
||||||
className={field === "remark" ? "col-12" : "col-12 col-md-6"}
|
|
||||||
key={field}
|
|
||||||
>
|
|
||||||
<label
|
|
||||||
className="form-label"
|
|
||||||
htmlFor={`distribution-board-info-${field}`}
|
|
||||||
>
|
|
||||||
{distributionBoardInfoFieldLabels[field]}
|
|
||||||
</label>
|
|
||||||
{field === "remark" ? (
|
|
||||||
<textarea
|
|
||||||
className="form-control"
|
|
||||||
id={`distribution-board-info-${field}`}
|
|
||||||
maxLength={distributionBoardInfoFieldMaxLengths[field]}
|
|
||||||
onChange={(event) =>
|
|
||||||
onChange({ ...draft, [field]: event.target.value })
|
|
||||||
}
|
|
||||||
rows={2}
|
|
||||||
value={draft[field]}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<input
|
|
||||||
className="form-control"
|
|
||||||
id={`distribution-board-info-${field}`}
|
|
||||||
maxLength={distributionBoardInfoFieldMaxLengths[field]}
|
|
||||||
onChange={(event) =>
|
|
||||||
onChange({ ...draft, [field]: event.target.value })
|
|
||||||
}
|
|
||||||
placeholder={placeholders[field]}
|
|
||||||
value={draft[field]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{field === "fedFrom" ? (
|
|
||||||
<div className="form-text">
|
|
||||||
Vorerst Freitext. Die logische Verknüpfung mit dem
|
|
||||||
speisenden Verteiler folgt.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -7,16 +7,9 @@ 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;
|
||||||
|
|
@ -28,13 +21,6 @@ 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;
|
||||||
|
|
@ -85,14 +71,6 @@ 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">(
|
||||||
|
|
@ -113,10 +91,6 @@ 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),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -335,57 +309,6 @@ 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,180 +0,0 @@
|
||||||
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device";
|
|
||||||
import type {
|
|
||||||
CircuitTreeCircuitDto,
|
|
||||||
CircuitTreeProtectionDeviceDto,
|
|
||||||
CircuitTreeResponseDto,
|
|
||||||
DistributionBoardDto,
|
|
||||||
} from "../types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pure read model for the print view (cover page, board overview and board
|
|
||||||
* plans). It only derives display values from the existing circuit-tree
|
|
||||||
* responses and never changes project data.
|
|
||||||
*/
|
|
||||||
export interface PrintBoardSummary {
|
|
||||||
board: DistributionBoardDto;
|
|
||||||
circuitListId: string;
|
|
||||||
tree: CircuitTreeResponseDto;
|
|
||||||
floorName: string | null;
|
|
||||||
circuitCount: number;
|
|
||||||
/** Σ quantity × power per unit, without any simultaneity factor. */
|
|
||||||
installedPowerKw: number;
|
|
||||||
/** Σ row totals, i.e. with the device-row simultaneity factors. */
|
|
||||||
devicePowerKw: number;
|
|
||||||
simultaneityFactor: number;
|
|
||||||
/** Device power with the board simultaneity factor applied. */
|
|
||||||
demandPowerKw: number;
|
|
||||||
/**
|
|
||||||
* Symmetrical three-phase design current from the apparent power
|
|
||||||
* S = Σ(row total / cos φ) × board GZF. A missing cos φ counts as 1.
|
|
||||||
*/
|
|
||||||
designCurrentA: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizePrintBoard(input: {
|
|
||||||
board: DistributionBoardDto;
|
|
||||||
circuitListId: string;
|
|
||||||
tree: CircuitTreeResponseDto;
|
|
||||||
floorName: string | null;
|
|
||||||
}): PrintBoardSummary {
|
|
||||||
const { tree } = input;
|
|
||||||
let circuitCount = 0;
|
|
||||||
let installedPowerKw = 0;
|
|
||||||
let apparentPowerKva = 0;
|
|
||||||
for (const section of tree.sections) {
|
|
||||||
for (const circuit of section.circuits) {
|
|
||||||
circuitCount += 1;
|
|
||||||
for (const row of circuit.deviceRows) {
|
|
||||||
installedPowerKw += row.quantity * row.powerPerUnit;
|
|
||||||
apparentPowerKva += row.rowTotalPower / (row.cosPhi || 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const simultaneityFactor = tree.distributionBoardSimultaneityFactor;
|
|
||||||
return {
|
|
||||||
...input,
|
|
||||||
circuitCount,
|
|
||||||
installedPowerKw,
|
|
||||||
devicePowerKw: tree.distributionBoardTotalPower,
|
|
||||||
simultaneityFactor,
|
|
||||||
demandPowerKw: tree.distributionBoardTotalPowerWithSimultaneityFactor,
|
|
||||||
designCurrentA:
|
|
||||||
(apparentPowerKva * simultaneityFactor * 1000) /
|
|
||||||
(Math.sqrt(3) * tree.threePhaseVoltageV),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PrintBoardHierarchyEntry {
|
|
||||||
summary: PrintBoardSummary;
|
|
||||||
depth: number;
|
|
||||||
parentBoardId: string | null;
|
|
||||||
/** Own demand plus the plain sum of all resolved downstream boards. */
|
|
||||||
cumulatedDemandPowerKw: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 parent = fedFrom
|
|
||||||
? summaries.find(
|
|
||||||
(candidate) =>
|
|
||||||
candidate !== summary &&
|
|
||||||
(normalize(candidate.board.boardNumber) === fedFrom ||
|
|
||||||
normalize(candidate.board.name) === fedFrom)
|
|
||||||
)
|
|
||||||
: 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 || null;
|
|
||||||
}
|
|
||||||
|
|
@ -1,476 +0,0 @@
|
||||||
/* Print view (Seitenansicht) in the kbp corporate design. Colors are
|
|
||||||
derived from the kbp logo and presentation material; change them only
|
|
||||||
here.
|
|
||||||
All selectors are scoped to .print-view. The @page rules (format, margins,
|
|
||||||
page numbers) are injected by the print page because they need project
|
|
||||||
data. */
|
|
||||||
.print-view {
|
|
||||||
--pd-blue: #476088;
|
|
||||||
--pd-lilac: #9198c4;
|
|
||||||
--pd-line: #d6dae5;
|
|
||||||
--pd-tint: #f5f6fa;
|
|
||||||
--pd-text: #2b2b2b;
|
|
||||||
--pd-muted: #6b7280;
|
|
||||||
--pd-accent: #c9782a;
|
|
||||||
--pd-font: Arial, "Helvetica Neue", Helvetica, sans-serif;
|
|
||||||
--pd-landscape-width: 297mm;
|
|
||||||
--pd-landscape-height: 210mm;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: #e7e9ef;
|
|
||||||
color: var(--pd-text);
|
|
||||||
font-family: var(--pd-font);
|
|
||||||
color-scheme: light;
|
|
||||||
}
|
|
||||||
.print-view.pd-a3 {
|
|
||||||
--pd-landscape-width: 420mm;
|
|
||||||
--pd-landscape-height: 297mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pd-toolbar {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 5;
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 10px 16px;
|
|
||||||
background: #fff;
|
|
||||||
border-bottom: 1px solid var(--pd-line);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
.pd-toolbar-title {
|
|
||||||
margin-right: auto;
|
|
||||||
color: var(--pd-blue);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-toolbar .pd-toolbar-message {
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
.pd-toolbar .pd-toolbar-error {
|
|
||||||
color: #b04035;
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pd-pages {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 24px;
|
|
||||||
padding: 24px 16px 48px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.pd-sheet {
|
|
||||||
position: relative;
|
|
||||||
flex: none;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
|
||||||
font-size: 9pt;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
.pd-portrait {
|
|
||||||
width: 210mm;
|
|
||||||
min-height: 297mm;
|
|
||||||
padding: 18mm 18mm 22mm;
|
|
||||||
page: lb-portrait;
|
|
||||||
}
|
|
||||||
.pd-landscape {
|
|
||||||
width: var(--pd-landscape-width);
|
|
||||||
min-height: var(--pd-landscape-height);
|
|
||||||
padding: 12mm 12mm 18mm;
|
|
||||||
page: lb-landscape;
|
|
||||||
}
|
|
||||||
.pd-a3 .pd-landscape {
|
|
||||||
font-size: 10pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
.print-view table {
|
|
||||||
border-collapse: collapse;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
.print-view caption {
|
|
||||||
caption-side: top;
|
|
||||||
padding: 0 0 1.5mm;
|
|
||||||
color: var(--pd-blue);
|
|
||||||
font-size: 10pt;
|
|
||||||
font-weight: bold;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-align: left;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.print-view sub {
|
|
||||||
font-size: 0.75em;
|
|
||||||
}
|
|
||||||
.pd-r {
|
|
||||||
text-align: right !important;
|
|
||||||
}
|
|
||||||
.pd-placeholder {
|
|
||||||
color: var(--pd-accent);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.pd-open {
|
|
||||||
color: var(--pd-accent);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-multiline {
|
|
||||||
white-space: pre-line;
|
|
||||||
}
|
|
||||||
.pd-logo {
|
|
||||||
display: block;
|
|
||||||
max-width: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
.pd-logo-missing {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border: 1px dashed var(--pd-line);
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 8pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cover */
|
|
||||||
.pd-cover-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
border-bottom: 2px solid var(--pd-blue);
|
|
||||||
padding-bottom: 6mm;
|
|
||||||
}
|
|
||||||
.pd-cover-kicker {
|
|
||||||
margin-top: 3mm;
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 10pt;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.pd-cover-logo {
|
|
||||||
height: 15mm;
|
|
||||||
width: auto;
|
|
||||||
max-width: 60mm;
|
|
||||||
}
|
|
||||||
span.pd-cover-logo {
|
|
||||||
width: 40mm;
|
|
||||||
}
|
|
||||||
.pd-cover-title {
|
|
||||||
margin: 14mm 0 10mm;
|
|
||||||
}
|
|
||||||
.pd-cover-title h1 {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--pd-blue);
|
|
||||||
font-size: 30pt;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-cover-title p {
|
|
||||||
margin: 2mm 0 0;
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 13pt;
|
|
||||||
}
|
|
||||||
.pd-kv {
|
|
||||||
margin-bottom: 7mm;
|
|
||||||
}
|
|
||||||
.pd-kv th,
|
|
||||||
.pd-kv td {
|
|
||||||
border: 1px solid var(--pd-line);
|
|
||||||
padding: 2mm 3mm;
|
|
||||||
font-size: 9.5pt;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
.pd-kv th {
|
|
||||||
width: 42mm;
|
|
||||||
background: var(--pd-tint);
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
.pd-cover-cols {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 6mm;
|
|
||||||
}
|
|
||||||
.pd-cover-cols .pd-kv th {
|
|
||||||
width: 30mm;
|
|
||||||
}
|
|
||||||
.pd-block {
|
|
||||||
margin-bottom: 7mm;
|
|
||||||
}
|
|
||||||
.pd-col-index {
|
|
||||||
width: 14mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Generic grid tables */
|
|
||||||
.pd-grid th,
|
|
||||||
.pd-grid td {
|
|
||||||
border: 1px solid var(--pd-line);
|
|
||||||
padding: 1.3mm 2mm;
|
|
||||||
text-align: left;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
.pd-grid > thead > tr > th:not(.pd-tb-cell) {
|
|
||||||
background: var(--pd-blue);
|
|
||||||
border-color: var(--pd-blue);
|
|
||||||
color: #fff;
|
|
||||||
font-weight: bold;
|
|
||||||
vertical-align: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footer (screen only; printed pages use @page margin boxes) */
|
|
||||||
.pd-sheet-footer {
|
|
||||||
position: absolute;
|
|
||||||
left: 12mm;
|
|
||||||
right: 12mm;
|
|
||||||
bottom: 7mm;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
border-top: 1px solid var(--pd-line);
|
|
||||||
padding-top: 2mm;
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 7.5pt;
|
|
||||||
}
|
|
||||||
.pd-portrait .pd-sheet-footer {
|
|
||||||
left: 18mm;
|
|
||||||
right: 18mm;
|
|
||||||
bottom: 10mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Title block */
|
|
||||||
.pd-grid th.pd-tb-cell {
|
|
||||||
border: none;
|
|
||||||
padding: 0 0 4mm;
|
|
||||||
background: none;
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
.pd-titleblock {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 28mm 1fr auto auto;
|
|
||||||
border: 1.5px solid var(--pd-blue);
|
|
||||||
margin-bottom: 4mm;
|
|
||||||
background: #fff;
|
|
||||||
font-size: 9pt;
|
|
||||||
}
|
|
||||||
.pd-grid .pd-titleblock {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
.pd-titleblock > * {
|
|
||||||
padding: 2.5mm 3.5mm;
|
|
||||||
border-left: 1px solid var(--pd-line);
|
|
||||||
}
|
|
||||||
.pd-titleblock > :first-child {
|
|
||||||
border-left: none;
|
|
||||||
}
|
|
||||||
.pd-tb-logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 17mm;
|
|
||||||
}
|
|
||||||
.pd-tb-logo-image {
|
|
||||||
max-height: 10mm;
|
|
||||||
}
|
|
||||||
span.pd-tb-logo-image {
|
|
||||||
width: 100%;
|
|
||||||
height: 10mm;
|
|
||||||
}
|
|
||||||
.pd-tb-kicker {
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 7.5pt;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.pd-tb-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 4mm;
|
|
||||||
margin-top: 1mm;
|
|
||||||
}
|
|
||||||
.pd-tb-number {
|
|
||||||
color: var(--pd-blue);
|
|
||||||
font-size: 18pt;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-tb-name {
|
|
||||||
font-size: 14pt;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-tb-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto auto;
|
|
||||||
gap: 0.8mm 4mm;
|
|
||||||
align-content: center;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 8pt;
|
|
||||||
}
|
|
||||||
.pd-tb-grid div {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
.pd-tb-grid dt {
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-weight: normal;
|
|
||||||
}
|
|
||||||
.pd-tb-grid dd {
|
|
||||||
margin: 0;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-tb-meta {
|
|
||||||
background: var(--pd-tint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Board plan table */
|
|
||||||
.pd-plan {
|
|
||||||
font-size: 7.6pt;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
.pd-a3 .pd-plan {
|
|
||||||
font-size: 9pt;
|
|
||||||
}
|
|
||||||
.pd-plan th,
|
|
||||||
.pd-plan td {
|
|
||||||
padding: 0.75mm 1.6mm;
|
|
||||||
}
|
|
||||||
.pd-plan td.pd-bmk {
|
|
||||||
color: var(--pd-blue);
|
|
||||||
font-weight: bold;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.pd-plan tr.pd-group td {
|
|
||||||
background: var(--pd-tint);
|
|
||||||
border-top: 1.5px solid var(--pd-lilac);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-plan tr.pd-component td,
|
|
||||||
.pd-plan tr.pd-component td.pd-bmk,
|
|
||||||
.pd-plan tr.pd-device td {
|
|
||||||
color: var(--pd-muted);
|
|
||||||
}
|
|
||||||
.pd-plan tr.pd-device td:nth-child(2) {
|
|
||||||
padding-left: 6mm;
|
|
||||||
}
|
|
||||||
.pd-plan tr.pd-reserve td {
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.pd-plan tr.pd-subtotal td {
|
|
||||||
border-bottom: 1.5px solid var(--pd-line);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-plan .pd-sub {
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
.pd-plan tfoot td {
|
|
||||||
background: var(--pd-tint);
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-plan tfoot tr.pd-grand td {
|
|
||||||
background: var(--pd-blue);
|
|
||||||
border-color: var(--pd-blue);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.pd-plan tr {
|
|
||||||
break-inside: avoid;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Overview */
|
|
||||||
.pd-overview {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 62mm 1fr;
|
|
||||||
gap: 6mm;
|
|
||||||
}
|
|
||||||
.pd-a3 .pd-overview {
|
|
||||||
grid-template-columns: 90mm 1fr;
|
|
||||||
}
|
|
||||||
.pd-structure h2 {
|
|
||||||
margin: 0 0 3mm;
|
|
||||||
color: var(--pd-blue);
|
|
||||||
font-size: 10pt;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.pd-tree,
|
|
||||||
.pd-tree ul {
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.pd-tree ul {
|
|
||||||
margin-left: 5mm;
|
|
||||||
padding-left: 4mm;
|
|
||||||
border-left: 1.5px solid var(--pd-lilac);
|
|
||||||
}
|
|
||||||
.pd-tree li {
|
|
||||||
position: relative;
|
|
||||||
margin: 2.5mm 0;
|
|
||||||
}
|
|
||||||
.pd-tree ul > li::before {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
left: -4mm;
|
|
||||||
top: 5mm;
|
|
||||||
width: 4mm;
|
|
||||||
border-top: 1.5px solid var(--pd-lilac);
|
|
||||||
}
|
|
||||||
.pd-node {
|
|
||||||
display: grid;
|
|
||||||
gap: 0.3mm;
|
|
||||||
padding: 1.5mm 2.5mm;
|
|
||||||
border: 1px solid var(--pd-line);
|
|
||||||
border-left: 3px solid var(--pd-blue);
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
.pd-node.pd-feed {
|
|
||||||
border-left-color: var(--pd-accent);
|
|
||||||
}
|
|
||||||
.pd-node span {
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 8pt;
|
|
||||||
}
|
|
||||||
.pd-node em {
|
|
||||||
color: var(--pd-blue);
|
|
||||||
font-size: 8.5pt;
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.pd-note {
|
|
||||||
margin: 4mm 0 1mm;
|
|
||||||
padding: 2.5mm 3mm;
|
|
||||||
border-left: 3px solid var(--pd-accent);
|
|
||||||
background: #fbf4ec;
|
|
||||||
font-size: 8pt;
|
|
||||||
}
|
|
||||||
.pd-legend {
|
|
||||||
margin: 1mm 0 0;
|
|
||||||
color: var(--pd-muted);
|
|
||||||
font-size: 7.5pt;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
html,
|
|
||||||
body,
|
|
||||||
.print-view {
|
|
||||||
background: #fff !important;
|
|
||||||
}
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,624 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
import type {
|
|
||||||
CircuitTreeCircuitDto,
|
|
||||||
CircuitTreeComponentDto,
|
|
||||||
CircuitTreeDeviceRowDto,
|
|
||||||
OfficeProfileDto,
|
|
||||||
ProjectDto,
|
|
||||||
ProjectSnapshotMetadataDto,
|
|
||||||
} from "../types";
|
|
||||||
import {
|
|
||||||
formatCircuitCable,
|
|
||||||
formatCurrent,
|
|
||||||
formatFactor,
|
|
||||||
formatKilowatt,
|
|
||||||
formatProtectionDevice,
|
|
||||||
formatUnitPower,
|
|
||||||
type PrintBoardHierarchyEntry,
|
|
||||||
} from "./print-document-model";
|
|
||||||
|
|
||||||
export interface PrintDocumentProps {
|
|
||||||
project: ProjectDto;
|
|
||||||
office: OfficeProfileDto;
|
|
||||||
entries: PrintBoardHierarchyEntry[];
|
|
||||||
namedSnapshots: ProjectSnapshotMetadataDto[];
|
|
||||||
logoSrc: string | null;
|
|
||||||
printedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shows a value or the orange "[ergänzen]" marker for missing data. */
|
|
||||||
function Value({ value, multiline = false }: { value: string | null; multiline?: boolean }) {
|
|
||||||
if (!value) {
|
|
||||||
return <Placeholder />;
|
|
||||||
}
|
|
||||||
return multiline ? <span className="pd-multiline">{value}</span> : <>{value}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dateFormat = new Intl.DateTimeFormat("de-DE", {
|
|
||||||
day: "2-digit",
|
|
||||||
month: "2-digit",
|
|
||||||
year: "numeric",
|
|
||||||
});
|
|
||||||
|
|
||||||
function Placeholder({ label = "ergänzen" }: { label?: string }) {
|
|
||||||
return <span className="pd-placeholder">[{label}]</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Open() {
|
|
||||||
return <span className="pd-open">offen</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Logo({ src, className }: { src: string | null; className: string }) {
|
|
||||||
return src ? (
|
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
|
||||||
<img alt="Logo" className={`pd-logo ${className}`} src={src} />
|
|
||||||
) : (
|
|
||||||
<span className={`pd-logo-missing ${className}`}>Logo</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetFooter({ project, stand }: { project: ProjectDto; stand: string }) {
|
|
||||||
return (
|
|
||||||
<div className="pd-sheet-footer" aria-hidden="true">
|
|
||||||
<span>{project.name} · Leistungsbilanz</span>
|
|
||||||
<span>
|
|
||||||
Stand {stand} · Datenstand Rev. {project.currentRevision}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TitleBlock({
|
|
||||||
kicker,
|
|
||||||
number,
|
|
||||||
name,
|
|
||||||
rows,
|
|
||||||
project,
|
|
||||||
stand,
|
|
||||||
logoSrc,
|
|
||||||
}: {
|
|
||||||
kicker: string;
|
|
||||||
number?: string | null;
|
|
||||||
name: string;
|
|
||||||
rows: Array<[ReactNode, ReactNode]>;
|
|
||||||
project: ProjectDto;
|
|
||||||
stand: string;
|
|
||||||
logoSrc: string | null;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="pd-titleblock">
|
|
||||||
<div className="pd-tb-logo">
|
|
||||||
<Logo className="pd-tb-logo-image" src={logoSrc} />
|
|
||||||
</div>
|
|
||||||
<div className="pd-tb-main">
|
|
||||||
<div className="pd-tb-kicker">{kicker}</div>
|
|
||||||
<div className="pd-tb-title">
|
|
||||||
{number ? <span className="pd-tb-number">{number}</span> : null}
|
|
||||||
<span className="pd-tb-name">{name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<dl className="pd-tb-grid">
|
|
||||||
{rows.map(([label, value], index) => (
|
|
||||||
<div key={index}>
|
|
||||||
<dt>{label}</dt>
|
|
||||||
<dd>{value}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
<dl className="pd-tb-grid pd-tb-meta">
|
|
||||||
<div>
|
|
||||||
<dt>Projekt</dt>
|
|
||||||
<dd>{project.name}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Projekt-Nr.</dt>
|
|
||||||
<dd>{project.internalProjectNumber ?? "–"}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Stand</dt>
|
|
||||||
<dd>{stand}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Datenstand</dt>
|
|
||||||
<dd>Rev. {project.currentRevision}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function boardLabel(entry: PrintBoardHierarchyEntry) {
|
|
||||||
const { board } = entry.summary;
|
|
||||||
return board.boardNumber ? `${board.boardNumber} – ${board.name}` : board.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function CoverSheet({
|
|
||||||
project,
|
|
||||||
office,
|
|
||||||
entries,
|
|
||||||
namedSnapshots,
|
|
||||||
logoSrc,
|
|
||||||
stand,
|
|
||||||
}: Omit<PrintDocumentProps, "printedAt"> & { stand: string }) {
|
|
||||||
return (
|
|
||||||
<section className="pd-sheet pd-portrait pd-cover">
|
|
||||||
<div className="pd-cover-head">
|
|
||||||
<div className="pd-cover-kicker">Projektdokumentation Elektrotechnik</div>
|
|
||||||
<Logo className="pd-cover-logo" src={logoSrc} />
|
|
||||||
</div>
|
|
||||||
<div className="pd-cover-title">
|
|
||||||
<h1>Leistungsbilanz</h1>
|
|
||||||
<p>Verteilerübersicht und Verteilerpläne</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table className="pd-kv">
|
|
||||||
<caption>Projekt</caption>
|
|
||||||
<tbody>
|
|
||||||
<tr><th>Bauvorhaben</th><td>{project.name}</td></tr>
|
|
||||||
<tr><th>Anschrift</th><td><Value multiline value={project.siteAddress} /></td></tr>
|
|
||||||
<tr><th>Bauherr</th><td><Value value={project.buildingOwner} /></td></tr>
|
|
||||||
<tr><th>Projektnummer intern</th><td><Value value={project.internalProjectNumber} /></td></tr>
|
|
||||||
<tr><th>Projektnummer extern</th><td><Value value={project.externalProjectNumber} /></td></tr>
|
|
||||||
<tr><th>Leistungsphase</th><td><Value value={project.workStage} /></td></tr>
|
|
||||||
<tr><th>Gewerk</th><td>KG 440 – Starkstromanlagen</td></tr>
|
|
||||||
<tr><th>Beschreibung</th><td>{project.description ?? "–"}</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<div className="pd-cover-cols">
|
|
||||||
<table className="pd-kv">
|
|
||||||
<caption>Planverfasser</caption>
|
|
||||||
<tbody>
|
|
||||||
<tr><th>Büro</th><td><Value value={office.officeName} /></td></tr>
|
|
||||||
<tr><th>Anschrift</th><td><Value multiline value={office.officeAddress} /></td></tr>
|
|
||||||
<tr><th>Projektleitung</th><td><Value value={project.projectLead} /></td></tr>
|
|
||||||
<tr><th>Bearbeitung</th><td><Value value={project.projectEditor} /></td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<table className="pd-kv">
|
|
||||||
<caption>Dokument</caption>
|
|
||||||
<tbody>
|
|
||||||
<tr><th>Dokumentart</th><td>Leistungsbilanz</td></tr>
|
|
||||||
<tr><th>Stand</th><td>{stand}</td></tr>
|
|
||||||
<tr><th>Datenstand</th><td>Revision {project.currentRevision}</td></tr>
|
|
||||||
<tr><th>Verteiler</th><td>{entries.length}</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<table className="pd-grid pd-block">
|
|
||||||
<caption>Planstände</caption>
|
|
||||||
<thead>
|
|
||||||
<tr><th className="pd-col-index">Index</th><th>Datum</th><th>Änderung</th><th>Bearbeitung</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{namedSnapshots.length ? (
|
|
||||||
namedSnapshots.map((snapshot, index) => (
|
|
||||||
<tr key={snapshot.id}>
|
|
||||||
<td>{String.fromCharCode(97 + (index % 26))}</td>
|
|
||||||
<td>{dateFormat.format(new Date(snapshot.createdAtIso))}</td>
|
|
||||||
<td>{snapshot.name}</td>
|
|
||||||
{/* Snapshots do not record an editor yet; the project's
|
|
||||||
current editor is shown for every entry. */}
|
|
||||||
<td><Value value={project.projectEditor} /></td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<tr><td colSpan={4}>–</td></tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table className="pd-grid pd-block">
|
|
||||||
<caption>Inhalt</caption>
|
|
||||||
<tbody>
|
|
||||||
<tr><td className="pd-col-index">1</td><td>Verteilerübersicht und Leistungssummen</td></tr>
|
|
||||||
{entries.map((entry, index) => (
|
|
||||||
<tr key={entry.summary.board.id}>
|
|
||||||
<td className="pd-col-index">2.{index + 1}</td>
|
|
||||||
<td>Verteilerplan {boardLabel(entry)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<SheetFooter project={project} stand={stand} />
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StructureNode({
|
|
||||||
entry,
|
|
||||||
entries,
|
|
||||||
}: {
|
|
||||||
entry: PrintBoardHierarchyEntry;
|
|
||||||
entries: PrintBoardHierarchyEntry[];
|
|
||||||
}) {
|
|
||||||
const children = entries.filter(
|
|
||||||
(candidate) => candidate.parentBoardId === entry.summary.board.id
|
|
||||||
);
|
|
||||||
const { board } = entry.summary;
|
|
||||||
return (
|
|
||||||
<li>
|
|
||||||
<div className="pd-node">
|
|
||||||
<strong>{board.boardNumber ?? board.name}</strong>
|
|
||||||
{board.boardNumber ? <span>{board.name}</span> : null}
|
|
||||||
<em>{formatKilowatt(entry.summary.demandPowerKw)} kW</em>
|
|
||||||
</div>
|
|
||||||
{children.length ? (
|
|
||||||
<ul>
|
|
||||||
{children.map((child) => (
|
|
||||||
<StructureNode entries={entries} entry={child} key={child.summary.board.id} />
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function OverviewSheet({
|
|
||||||
project,
|
|
||||||
entries,
|
|
||||||
logoSrc,
|
|
||||||
stand,
|
|
||||||
}: {
|
|
||||||
project: ProjectDto;
|
|
||||||
entries: PrintBoardHierarchyEntry[];
|
|
||||||
logoSrc: string | null;
|
|
||||||
stand: string;
|
|
||||||
}) {
|
|
||||||
const roots = entries.filter((entry) => entry.parentBoardId === null);
|
|
||||||
// Top-level boards whose "fed from" text names no other board are grouped
|
|
||||||
// below that text as an external feed (e.g. "HAK / Zähleranlage").
|
|
||||||
const feedGroups = new Map<string, PrintBoardHierarchyEntry[]>();
|
|
||||||
for (const root of roots) {
|
|
||||||
const feed = root.summary.board.fedFrom ?? "";
|
|
||||||
feedGroups.set(feed, [...(feedGroups.get(feed) ?? []), root]);
|
|
||||||
}
|
|
||||||
const sum =(pick: (entry: PrintBoardHierarchyEntry) => number) =>
|
|
||||||
entries.reduce((total, entry) => total + pick(entry), 0);
|
|
||||||
return (
|
|
||||||
<section className="pd-sheet pd-landscape">
|
|
||||||
<TitleBlock
|
|
||||||
kicker="Übersicht"
|
|
||||||
logoSrc={logoSrc}
|
|
||||||
name="Verteilerübersicht und Leistungssummen"
|
|
||||||
project={project}
|
|
||||||
rows={[
|
|
||||||
["Verteiler", String(entries.length)],
|
|
||||||
["Netz", `${project.threePhaseVoltageV} / ${project.singlePhaseVoltageV} V`],
|
|
||||||
]}
|
|
||||||
stand={stand}
|
|
||||||
/>
|
|
||||||
<div className="pd-overview">
|
|
||||||
<div className="pd-structure">
|
|
||||||
<h2>Versorgungsstruktur</h2>
|
|
||||||
<ul className="pd-tree">
|
|
||||||
{[...feedGroups].map(([feed, feedRoots]) => {
|
|
||||||
const nodes = feedRoots.map((root) => (
|
|
||||||
<StructureNode entries={entries} entry={root} key={root.summary.board.id} />
|
|
||||||
));
|
|
||||||
return feed ? (
|
|
||||||
<li key={`feed:${feed}`}>
|
|
||||||
<div className="pd-node pd-feed">
|
|
||||||
<strong>Einspeisung</strong>
|
|
||||||
<span>{feed}</span>
|
|
||||||
</div>
|
|
||||||
<ul>{nodes}</ul>
|
|
||||||
</li>
|
|
||||||
) : (
|
|
||||||
nodes
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<table className="pd-grid pd-plan">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Nr.</th><th>Bezeichnung</th><th>Örtlichkeit</th><th>Etage</th><th>Netz</th><th>Gespeist von</th>
|
|
||||||
<th className="pd-r">SK</th>
|
|
||||||
<th className="pd-r">P<sub>inst</sub><br />[kW]</th>
|
|
||||||
<th className="pd-r">P<sub>Geräte</sub><br />[kW]</th>
|
|
||||||
<th className="pd-r">GZF<br />Vert.</th>
|
|
||||||
<th className="pd-r">P<sub>gl</sub><br />[kW]</th>
|
|
||||||
<th className="pd-r">I<sub>B</sub><br />[A]</th>
|
|
||||||
<th className="pd-r">Σ inkl.<br />unterlagert [kW]</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{entries.map((entry) => {
|
|
||||||
const { board } = entry.summary;
|
|
||||||
return (
|
|
||||||
<tr key={board.id}>
|
|
||||||
<td style={{ paddingLeft: `${1.6 + entry.depth * 3.5}mm` }}>
|
|
||||||
{entry.depth ? "└ " : ""}
|
|
||||||
<strong>{board.boardNumber ?? "–"}</strong>
|
|
||||||
</td>
|
|
||||||
<td>{board.name}</td>
|
|
||||||
<td>{board.location ?? "–"}</td>
|
|
||||||
<td>{entry.summary.floorName ?? "–"}</td>
|
|
||||||
<td>{board.supplyType ?? "–"}</td>
|
|
||||||
<td>{board.fedFrom ?? "–"}</td>
|
|
||||||
<td className="pd-r">{entry.summary.circuitCount}</td>
|
|
||||||
<td className="pd-r">{formatKilowatt(entry.summary.installedPowerKw)}</td>
|
|
||||||
<td className="pd-r">{formatKilowatt(entry.summary.devicePowerKw)}</td>
|
|
||||||
<td className="pd-r">{formatFactor(entry.summary.simultaneityFactor)}</td>
|
|
||||||
<td className="pd-r"><strong>{formatKilowatt(entry.summary.demandPowerKw)}</strong></td>
|
|
||||||
<td className="pd-r">{formatCurrent(entry.summary.designCurrentA)}</td>
|
|
||||||
<td className="pd-r">{formatKilowatt(entry.cumulatedDemandPowerKw)}</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7}>Summe aller Verteiler (ohne übergeordneten GZF)</td>
|
|
||||||
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.installedPowerKw))}</td>
|
|
||||||
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.devicePowerKw))}</td>
|
|
||||||
<td />
|
|
||||||
<td className="pd-r">{formatKilowatt(sum((entry) => entry.summary.demandPowerKw))}</td>
|
|
||||||
<td />
|
|
||||||
<td />
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
<p className="pd-note">
|
|
||||||
<strong>Vorläufig:</strong> „Gespeist von“ ist noch ein Freitextfeld. Struktur und
|
|
||||||
„Σ inkl. unterlagert“ werden nur über gleichlautende Verteilernummern bzw.
|
|
||||||
-bezeichnungen zugeordnet und einfach addiert (kein übergeordneter GZF). 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,18 +93,9 @@ 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;
|
||||||
|
|
@ -181,14 +172,7 @@ export interface ProjectCommandResultDto {
|
||||||
history: ProjectHistoryStateDto;
|
history: ProjectHistoryStateDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DistributionBoardInfoDto {
|
export interface DistributionBoardDto {
|
||||||
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;
|
||||||
|
|
@ -197,13 +181,6 @@ export interface DistributionBoardDto extends DistributionBoardInfoDto {
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,7 @@ import type {
|
||||||
DistributionBoardCommandResultDto,
|
DistributionBoardCommandResultDto,
|
||||||
DistributionBoardCopyCommandResultDto,
|
DistributionBoardCopyCommandResultDto,
|
||||||
DistributionBoardDeleteCommandResultDto,
|
DistributionBoardDeleteCommandResultDto,
|
||||||
DistributionBoardInfoDto,
|
|
||||||
DocumentLogoMetadataDto,
|
|
||||||
FloorDto,
|
FloorDto,
|
||||||
OfficeProfileDto,
|
|
||||||
GlobalDeviceDto,
|
GlobalDeviceDto,
|
||||||
ProjectCommandDto,
|
ProjectCommandDto,
|
||||||
ProjectCommandResultDto,
|
ProjectCommandResultDto,
|
||||||
|
|
@ -259,7 +256,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}`,
|
||||||
|
|
@ -332,7 +329,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>(
|
||||||
|
|
@ -344,42 +341,6 @@ 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`
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ 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);
|
||||||
|
|
@ -34,4 +33,3 @@ 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);
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ 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,
|
||||||
|
|
@ -65,10 +64,6 @@ 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) {
|
||||||
|
|
@ -91,13 +86,6 @@ 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,
|
||||||
|
|
@ -109,7 +97,6 @@ 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,
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
@ -4,10 +4,6 @@ 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";
|
||||||
|
|
@ -50,27 +46,12 @@ 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({
|
command: createProjectSettingsUpdateProjectCommand(settings),
|
||||||
...settings,
|
|
||||||
...coverValues,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const project = await projectRepository.findById(projectId);
|
const project = await projectRepository.findById(projectId);
|
||||||
if (!project) {
|
if (!project) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
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";
|
||||||
|
|
@ -52,7 +51,6 @@ 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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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);
|
|
||||||
|
|
@ -24,44 +24,3 @@ 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,
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export const documentLogoMimeTypes = [
|
|
||||||
"image/png",
|
|
||||||
"image/jpeg",
|
|
||||||
"image/svg+xml",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type DocumentLogoMimeType = (typeof documentLogoMimeTypes)[number];
|
|
||||||
|
|
||||||
export const maxDocumentLogoBytes = 2 * 1024 * 1024;
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
};
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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,25 +1,7 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import {
|
import { distributionBoardSupplyTypes } from "../constants/distribution-board.js";
|
||||||
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(),
|
||||||
|
|
@ -48,11 +30,6 @@ 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();
|
||||||
|
|
||||||
|
|
@ -65,27 +42,12 @@ 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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -387,110 +387,6 @@ 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,67 +192,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
import path from "node:path";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
|
||||||
import { createDatabaseContext } 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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { describe, it } from "node:test";
|
|
||||||
import {
|
|
||||||
buildPreliminaryBoardHierarchy,
|
|
||||||
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("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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1074,10 +1074,6 @@ 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);
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,6 @@ 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)
|
||||||
|
|
@ -85,10 +81,6 @@ function settings(
|
||||||
"MSR",
|
"MSR",
|
||||||
"SiBe",
|
"SiBe",
|
||||||
],
|
],
|
||||||
siteAddress: null,
|
|
||||||
workStage: null,
|
|
||||||
projectLead: null,
|
|
||||||
projectEditor: null,
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -225,10 +217,6 @@ describe("project settings project-command repository", () => {
|
||||||
"MSR",
|
"MSR",
|
||||||
"SiBe",
|
"SiBe",
|
||||||
],
|
],
|
||||||
siteAddress: null,
|
|
||||||
workStage: null,
|
|
||||||
projectLead: null,
|
|
||||||
projectEditor: null,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -301,10 +289,6 @@ 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");
|
||||||
|
|
@ -333,10 +317,6 @@ 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), {
|
||||||
|
|
@ -376,10 +356,6 @@ 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), {
|
||||||
|
|
@ -457,117 +433,6 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -377,67 +377,6 @@ 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>>;
|
|
||||||
};
|
|
||||||
payload.schemaVersion = 5;
|
|
||||||
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
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("portable project transfer", () => {
|
describe("portable project transfer", () => {
|
||||||
|
|
|
||||||
|
|
@ -8,23 +8,6 @@ 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 6. */
|
|
||||||
function withoutVersionSixFields<
|
|
||||||
T extends { project: object; distributionBoards: object[] },
|
|
||||||
>(snapshot: T): T {
|
|
||||||
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,
|
||||||
|
|
@ -46,10 +29,6 @@ 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,
|
||||||
|
|
@ -82,10 +61,6 @@ 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: [
|
||||||
|
|
@ -176,7 +151,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 = withoutVersionSixFields(structuredClone(minimalSnapshot()));
|
const baseline = 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;
|
||||||
|
|
@ -190,7 +165,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 = withoutVersionSixFields(structuredClone(minimalSnapshot()));
|
const previous = 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;
|
||||||
|
|
@ -208,7 +183,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 = withoutVersionSixFields(structuredClone(minimalSnapshot()));
|
const previous = 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;
|
||||||
|
|
||||||
|
|
@ -224,52 +199,8 @@ 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 = withoutVersionSixFields(protectedSnapshot());
|
const previous = 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 = [{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue