Compare commits
17 Commits
e1fc81a083
...
cfd4778305
| Author | SHA1 | Date | |
|---|---|---|---|
| cfd4778305 | |||
| fac6c9350f | |||
| 9ea081179d | |||
| dcb303284c | |||
| 602ae6da0b | |||
| 1b2ca84258 | |||
| 096ba8aa1c | |||
| 084103bf54 | |||
| b1a11397b3 | |||
| 194bc9c0b1 | |||
| 0180f768ac | |||
| a04c2c04d4 | |||
| edf245a21c | |||
| feec814dc5 | |||
| da8115fe1d | |||
| 5fa2a701dc | |||
| d77cfbeb49 |
@@ -283,17 +283,23 @@ that have remained unchanged. ProjectDevice create, update, delete and
|
|||||||
global-to-project copy API/UI paths use these persistent commands and track the
|
global-to-project copy API/UI paths use these persistent commands and track the
|
||||||
returned project revision. ProjectDevice synchronization/disconnect API and UI
|
returned project revision. ProjectDevice synchronization/disconnect API and UI
|
||||||
paths do the same; their undo action uses the project-wide history endpoint.
|
paths do the same; their undo action uses the project-wide history endpoint.
|
||||||
Project voltage settings use the persistent `project.update-settings` command.
|
Project settings use the persistent `project.update-settings` command. Project
|
||||||
Both voltage defaults change in one revision and Undo/Redo restores them
|
metadata, both voltage defaults and the enabled distribution-board supply types
|
||||||
together; the project PUT route requires `expectedRevision`.
|
change in one revision and Undo/Redo restores them together; the project PUT
|
||||||
|
route requires `expectedRevision`. The system catalog is `AV`, `SV`, `EV`,
|
||||||
|
`USV`, `MSR`, `SiBe`; at least one must be enabled and a type used by a board
|
||||||
|
cannot be disabled.
|
||||||
Distribution-board setup uses `distribution-board.insert` with a complete
|
Distribution-board setup uses `distribution-board.insert` with a complete
|
||||||
stable snapshot of the board, circuit list and four default sections. Its
|
stable snapshot of the board, circuit list and four default sections. Its
|
||||||
inverse removes only the same unchanged and still-empty structure; the POST
|
inverse removes only the same unchanged and still-empty structure; the POST
|
||||||
route requires `expectedRevision` and returns the updated history state.
|
route requires `expectedRevision` and returns the updated history state.
|
||||||
|
Distribution-board floor assignment and a project-enabled supply type use
|
||||||
|
`distribution-board.update`; both values are snapshot/export fields and one
|
||||||
|
persistent undo step.
|
||||||
Floor and room setup use `project-floor.insert` and `project-room.insert` with
|
Floor and room setup use `project-floor.insert` and `project-room.insert` with
|
||||||
complete stable snapshots. Their inverses remove only unchanged records and
|
complete stable snapshots. Their inverses remove only unchanged records and
|
||||||
reject floors with assigned rooms or rooms referenced by device rows or
|
reject floors with assigned rooms/distribution boards or rooms referenced by
|
||||||
retained upgrade data. Both POST routes require `expectedRevision` and return
|
device rows or retained upgrade data. Both POST routes require `expectedRevision` and return
|
||||||
the updated history state.
|
the updated history state.
|
||||||
|
|
||||||
Required operations:
|
Required operations:
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ ausdrücklich getrennt und dürfen nicht als bereits implementiert verstanden we
|
|||||||
## Zukunftsarchitektur
|
## Zukunftsarchitektur
|
||||||
|
|
||||||
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
|
- [Projektversionen und externer Modellaustausch](project-history-and-external-model-architecture.md)
|
||||||
|
- [LLM-Kontext für die Revit-Anforderungsplanung](revit-requirements-llm-context.md)
|
||||||
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
|
- [Zukünftige Dimensionierung](spec/06-future-sizing-and-calculations.md)
|
||||||
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
|
- [Roadmap und Phasen](spec/07-implementation-phases-todo.md)
|
||||||
|
- [Aktueller Produkt-Backlog](spec/08-current-product-backlog.md)
|
||||||
|
|
||||||
## Fachliche Referenzspezifikation
|
## Fachliche Referenzspezifikation
|
||||||
|
|
||||||
|
|||||||
@@ -145,11 +145,18 @@ synchronizes linked circuit rows implicitly.
|
|||||||
|
|
||||||
- `PUT /projects/:projectId`
|
- `PUT /projects/:projectId`
|
||||||
- request:
|
- request:
|
||||||
`{ "expectedRevision": 12, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400 }`
|
`{ "expectedRevision": 12, "singlePhaseVoltageV": 230, "threePhaseVoltageV": 400, "enabledDistributionBoardSupplyTypes": ["AV", "MSR", "SiBe"], ...projectMetadata }`
|
||||||
- executes `project.update-settings` as one atomic revision
|
- executes `project.update-settings` as one atomic revision
|
||||||
- response:
|
- response:
|
||||||
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
|
`{ "project": { ... }, "revision": { ... }, "history": { ... } }`
|
||||||
- persistent Undo/Redo restores both voltage values together
|
- persistent Undo/Redo restores metadata, voltage values and the enabled
|
||||||
|
distribution-board supply types together; project-device and circuit
|
||||||
|
voltages are recalculated from the restored project settings in the same
|
||||||
|
transaction
|
||||||
|
- project-device and circuit voltage are derived values and are not accepted
|
||||||
|
as editable frontend fields
|
||||||
|
- at least one of `AV`, `SV`, `EV`, `USV`, `MSR`, `SiBe` must be enabled;
|
||||||
|
a supply type currently used by a distribution board cannot be disabled
|
||||||
- unchanged values are rejected without creating a revision; a stale
|
- unchanged values are rejected without creating a revision; a stale
|
||||||
revision returns `409 PROJECT_REVISION_CONFLICT`
|
revision returns `409 PROJECT_REVISION_CONFLICT`
|
||||||
|
|
||||||
@@ -181,7 +188,11 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
|||||||
### Distribution Board Setup
|
### Distribution Board Setup
|
||||||
|
|
||||||
- `POST /projects/:projectId/distribution-boards`
|
- `POST /projects/:projectId/distribution-boards`
|
||||||
- body: `{ "name": "UV-02", "expectedRevision": 12 }`
|
- body:
|
||||||
|
`{ "name": "UV-02", "floorId": "floor_1", "supplyType": "SV", "expectedRevision": 12 }`
|
||||||
|
- `floorId` may be `null`; a non-null floor must belong to the project
|
||||||
|
- `supplyType` is one of `AV`, `SV`, `EV`, `USV`, `MSR` or `SiBe` and must
|
||||||
|
be enabled in the project settings
|
||||||
- executes `distribution-board.insert` with stable ids for the distribution
|
- executes `distribution-board.insert` with stable ids for the distribution
|
||||||
board, its circuit list and all four default circuit sections
|
board, its circuit list and all four default circuit sections
|
||||||
- response:
|
- response:
|
||||||
@@ -189,6 +200,11 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
|||||||
- persistent Undo removes only the unchanged and still-empty generated
|
- persistent Undo removes only the unchanged and still-empty generated
|
||||||
structure; Redo restores the same ids
|
structure; Redo restores the same ids
|
||||||
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
- stale revisions return `409 PROJECT_REVISION_CONFLICT`
|
||||||
|
- `PUT /projects/:projectId/distribution-boards/:distributionBoardId`
|
||||||
|
- body:
|
||||||
|
`{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }`
|
||||||
|
- executes `distribution-board.update`; floor and supply type are restored
|
||||||
|
together by persistent Undo/Redo
|
||||||
|
|
||||||
### Project Floors and Rooms
|
### Project Floors and Rooms
|
||||||
|
|
||||||
@@ -203,8 +219,9 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`.
|
|||||||
and must belong to the project when present
|
and must belong to the project when present
|
||||||
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
|
- response: `{ "room": { ... }, "revision": { ... }, "history": { ... } }`
|
||||||
- Persistent Undo removes only unchanged, unreferenced records. A floor with
|
- Persistent Undo removes only unchanged, unreferenced records. A floor with
|
||||||
assigned rooms and a room referenced by device rows or retained upgrade data
|
assigned rooms or distribution boards and a room referenced by device rows
|
||||||
are rejected instead of silently clearing foreign keys.
|
or retained upgrade data are rejected instead of silently clearing foreign
|
||||||
|
keys.
|
||||||
- Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
|
- Stale revisions return `409 PROJECT_REVISION_CONFLICT`.
|
||||||
|
|
||||||
### Tree Endpoint
|
### Tree Endpoint
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ The pure grid modules have no React state and are covered by focused unit tests.
|
|||||||
- `cosPhi`
|
- `cosPhi`
|
||||||
- room snapshots
|
- room snapshots
|
||||||
- category/cost group and related row attributes
|
- category/cost group and related row attributes
|
||||||
|
- canonical `phaseType` values `single_phase` or `three_phase`; the
|
||||||
|
frontend always presents these as `1-phasig` or `3-phasig` and edits
|
||||||
|
them through a selection control
|
||||||
- `ProjectDevice`
|
- `ProjectDevice`
|
||||||
- Reusable device template entity at project level.
|
- Reusable device template entity at project level.
|
||||||
- Can be linked to `CircuitDeviceRow` entries, with copied display values on insert.
|
- Can be linked to `CircuitDeviceRow` entries, with copied display values on insert.
|
||||||
|
|||||||
@@ -149,8 +149,14 @@ Upgrade-only-Verknüpfungen und Migrationsnachweise bleiben erhalten, obwohl sie
|
|||||||
nicht Bestandteil des logischen Snapshots sind.
|
nicht Bestandteil des logischen Snapshots sind.
|
||||||
Die Projektseite bindet diese APIs in einem einklappbaren Bereich
|
Die Projektseite bindet diese APIs in einem einklappbaren Bereich
|
||||||
„Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte
|
„Versionen und Sicherungspunkte“ ein. Dort können Benutzer Sicherungspunkte
|
||||||
benennen, nach expliziter Bestätigung wiederherstellen und die paginierte
|
benennen, jeden aufgeführten benannten oder automatischen Stand nach
|
||||||
Revisions-Timeline mit deutschen Quellen- und Änderungsbezeichnungen lesen.
|
expliziter Bestätigung wiederherstellen und die paginierte Revisions-Timeline
|
||||||
|
mit deutschen Quellen- und Änderungsbezeichnungen lesen. Die Snapshot-Liste
|
||||||
|
ordnet jede positive `sourceRevision` serverseitig ihren unveränderlichen
|
||||||
|
Revisionsmetadaten zu. Dadurch zeigt auch ein älterer automatischer Stand die
|
||||||
|
auslösende Änderung, ohne eine zweite frei formulierte Beschreibung zu
|
||||||
|
speichern oder von den zuletzt paginiert geladenen Timeline-Einträgen
|
||||||
|
abzuhängen. Revision null wird als Projektstart dargestellt.
|
||||||
Projektweites Rückgängig/Wiederholen bleibt im Kopf dieses Bereichs auch im
|
Projektweites Rückgängig/Wiederholen bleibt im Kopf dieses Bereichs auch im
|
||||||
eingeklappten Zustand erreichbar. Die Verfügbarkeit stammt direkt aus den
|
eingeklappten Zustand erreichbar. Die Verfügbarkeit stammt direkt aus den
|
||||||
persistierten Server-Stacks; nach einer Historienaktion oder einem Restore lädt
|
persistierten Server-Stacks; nach einer Historienaktion oder einem Restore lädt
|
||||||
@@ -167,6 +173,26 @@ Felder und vollständiger Zeilenbestand unverändert sind.
|
|||||||
Der Editor erzeugt die vollständigen Move-Zuweisungen aus dem geladenen Tree,
|
Der Editor erzeugt die vollständigen Move-Zuweisungen aus dem geladenen Tree,
|
||||||
vergibt für neue Ziele vor dem Kommando eine stabile UUID und führt das
|
vergibt für neue Ziele vor dem Kommando eine stabile UUID und führt das
|
||||||
Toolbar-Undo/Redo über die projektweite Historie aus.
|
Toolbar-Undo/Redo über die projektweite Historie aus.
|
||||||
|
Die Projektgerätepalette belegt keine permanente Layoutspalte mehr. Sie wird
|
||||||
|
über die Editor-Toolbar als überlagernder Drawer geöffnet, während das
|
||||||
|
Stromkreis-Grid standardmäßig die gesamte verfügbare Breite nutzt. Auswahl,
|
||||||
|
Schnelleinfügen und die vorhandenen Drag-and-drop-Payloads bleiben im Drawer
|
||||||
|
unverändert verfügbar; während eines aktiven Projektgeräte-Drags kann er nicht
|
||||||
|
geschlossen werden. Der Drawer liegt am rechten Fensterrand und verdeckt damit
|
||||||
|
nicht die führenden BMK- und Anzeigenamenspalten. Das Grid zeigt Geräte- und
|
||||||
|
Stromkreisleistungen in einer gemeinsamen Spalte `Gesamtsumme`: Gerätezeilen
|
||||||
|
verwenden `rowTotalPower`, Stromkreis-Sammelzeilen `circuitTotalPower`.
|
||||||
|
Zahlen werden ausschließlich für die Anzeige deutsch und begrenzt formatiert;
|
||||||
|
gespeicherte Werte und Bearbeitungsentwürfe behalten ihre volle Genauigkeit.
|
||||||
|
Jeder Abschnitt liefert und zeigt seine aufsummierte Stromkreisleistung. Die
|
||||||
|
Stromkreisliste zeigt außerdem die ungefilterte Gesamtleistung des Verteilers,
|
||||||
|
den am Verteiler gespeicherten Gleichzeitigkeitsfaktor und die daraus
|
||||||
|
abgeleitete Gesamtleistung unter Berücksichtigung dieses Faktors. Sortierung
|
||||||
|
und Filter verändern diese fachlichen Summen nicht.
|
||||||
|
Reihenfolge und Sichtbarkeit der Grid-Spalten sind reine UI-Präferenzen. Sie
|
||||||
|
werden im Browser unter einem projektspezifischen Schlüssel gespeichert und
|
||||||
|
deshalb beim Wechsel zwischen Verteilern desselben Projekts wiederverwendet,
|
||||||
|
ohne Projektrevisionen oder fachliche Snapshots zu erzeugen.
|
||||||
`circuit.reorder-section` speichert die erwartete und neue Sortierposition
|
`circuit.reorder-section` speichert die erwartete und neue Sortierposition
|
||||||
jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo
|
jedes Stromkreises eines vollständigen Abschnitts. Forward, Undo und Redo
|
||||||
ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs
|
ändern ausschließlich `sortOrder`; Stromkreisblöcke, Gerätezeilen und BMKs
|
||||||
@@ -209,23 +235,66 @@ unverknüpft und vollständig unverändert sind. Gerät, Linkänderungen, Revisi
|
|||||||
und Historienstapel teilen dieselbe Transaktion. Create, Import aus der globalen
|
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 die beiden Projekt-Standardspannungen als
|
`project.update-settings` versioniert Projektname, interne und externe
|
||||||
eine atomare Änderung. Der Store leitet das inverse Kommando aus dem
|
Projektnummer, Bauherr, Beschreibung, beide Standardspannungen sowie die im
|
||||||
gespeicherten Projekt ab und schreibt Werte, Revision und Historienstapel
|
Projekt freigeschalteten Verteiler-Netzarten als eine atomare Änderung. Der
|
||||||
gemeinsam. `PUT /api/projects/:projectId` verlangt deshalb
|
Systemkatalog besteht aus `AV`, `SV`, `EV`, `USV`, `MSR` und `SiBe`; mindestens
|
||||||
`expectedRevision`, liefert Projekt plus aktualisierten Historienstand und
|
eine Netzart muss aktiv bleiben und eine bereits von einer Verteilung
|
||||||
besitzt keinen separaten direkten Settings-Schreibweg mehr.
|
verwendete Netzart kann nicht deaktiviert werden. Die Projektseite bearbeitet diese Angaben in einem
|
||||||
|
beschrifteten Einstellungsmodal statt in einer permanenten Formularkarte. Der
|
||||||
|
Store leitet das inverse Kommando aus dem gespeicherten Projekt ab und schreibt
|
||||||
|
Werte, Revision und Historienstapel gemeinsam. `PUT /api/projects/:projectId`
|
||||||
|
verlangt deshalb `expectedRevision`, liefert Projekt plus aktualisierten
|
||||||
|
Historienstand und besitzt keinen separaten direkten Settings-Schreibweg mehr.
|
||||||
|
Kommando- und Snapshot-Versionen vor dieser Erweiterung bleiben les- und
|
||||||
|
ausführbar; fehlende Metadaten werden dabei als `null` behandelt.
|
||||||
|
Die Projektseite zeigt Verteilungen, Etagen und Räume als kompakte
|
||||||
|
Bestandsübersichten; ihre versionierten Erstellwege öffnen beschriftete Modals
|
||||||
|
statt dauerhafter Eingabezeilen. Projektgeräte werden als durchsuchbare,
|
||||||
|
fünfspaltige Übersicht dargestellt. Manuelle Anlage, Übernahme aus der globalen
|
||||||
|
Bibliothek und vollständige Bearbeitung erfolgen in einem gemeinsamen Modal.
|
||||||
|
Die anschließende Vorschau verknüpfter Stromkreiszeilen bleibt davon getrennt,
|
||||||
|
damit Änderungen weiterhin niemals still synchronisiert werden.
|
||||||
|
`GET /api/projects/:projectId/export` verpackt denselben vollständigen
|
||||||
|
Projektzustand in ein portables, format- und schema-versioniertes JSON-Dokument
|
||||||
|
mit SHA-256-Prüfsumme. `POST /api/projects/:projectId/import` prüft Format,
|
||||||
|
Snapshot-Relationen und Prüfsumme vor jedem Schreibzugriff. Der Modus `replace`
|
||||||
|
läuft über `project.restore-state` und ist dadurch eine atomare, dauerhaft
|
||||||
|
rückgängig machbare Projektrevision. Der Modus `duplicate` ordnet Projekt-,
|
||||||
|
Struktur-, Geräte-, Raum-, Stromkreis- und Gerätezeilen-UUIDs vollständig neu
|
||||||
|
zu und legt die Kopie mit Revision `0` in einer Transaktion an. Upgrade-only-
|
||||||
|
Consumer-Verweise werden nicht in die Kopie übernommen; fachliche Verknüpfungen
|
||||||
|
innerhalb des unterstützten Laufzeitmodells bleiben erhalten. Die
|
||||||
|
Projektübersicht verwendet dafür den separaten Collection-Endpunkt
|
||||||
|
`POST /api/projects/import`, der ausschließlich eine neue Kopie anlegt und
|
||||||
|
deshalb weder eine bestehende Projekt-ID noch `expectedRevision` annimmt. Das
|
||||||
|
Ersetzen eines Projekts bleibt auf dessen Einstellungsmodal und den
|
||||||
|
projektgebundenen Endpunkt beschränkt.
|
||||||
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
`distribution-board.insert` versioniert die Anlage einer Verteilung als einen
|
||||||
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
vollständigen Block aus Verteilung, Stromkreisliste und vier Standardbereichen.
|
||||||
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
Alle UUIDs entstehen vor dem Command und bleiben über Undo/Redo stabil.
|
||||||
`distribution-board.delete` ist die persistierte Inverse und entfernt nur den
|
`distribution-board.delete` ist die persistierte Inverse und entfernt nur den
|
||||||
vollständig unveränderten, weiterhin stromkreislosen Block. Controller und
|
vollständig unveränderten, weiterhin stromkreislosen Block. Controller und
|
||||||
Projektseite übergeben die erwartete Projektrevision; der frühere direkte
|
Projektseite übergeben die erwartete Projektrevision; der frühere direkte
|
||||||
Controller-Schreibweg ist entfernt.
|
Controller-Schreibweg ist entfernt. Verteilungen besitzen eine optionale
|
||||||
|
Etagenreferenz sowie eine Netzart aus dem Projektkatalog. Anlage und
|
||||||
|
nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die
|
||||||
|
Freigabe der Netzart in den Projekteinstellungen.
|
||||||
|
`distribution-board.update` versioniert Etage, Netzart und den
|
||||||
|
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
|
||||||
|
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
|
||||||
|
ist für bestehende sowie neu angelegte Verteilungen standardmäßig `1`.
|
||||||
|
Snapshot-Schema 6 und der portable Projekttransfer enthalten diesen Wert;
|
||||||
|
Schema 5 und älter werden mit dem neutralen Faktor `1` hochgestuft.
|
||||||
|
Snapshot-Schema 4 enthält bereits Etage und Netzart; Schema 1/2 sowie
|
||||||
|
gespeicherte Version-1-Strukturcommands werden ohne erfundene Zuordnung
|
||||||
|
hochgestuft. Snapshot-Schema 3 wird mit allen sechs Netzarten als
|
||||||
|
Projektauswahl hochgestuft.
|
||||||
`project-floor.insert` und `project-room.insert` versionieren die Anlage von
|
`project-floor.insert` und `project-room.insert` versionieren die Anlage von
|
||||||
Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden
|
Geschossen und Räumen mit stabilen UUIDs. Die vollständigen Datensätze bilden
|
||||||
jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur
|
jeweils die persistierte Inverse für Undo/Redo. Ein Geschoss wird durch Undo nur
|
||||||
entfernt, solange ihm kein Raum zugeordnet wurde. Ein Raum wird nur entfernt,
|
entfernt, solange ihm weder ein Raum noch eine Verteilung zugeordnet wurde. Ein
|
||||||
|
Raum wird nur entfernt,
|
||||||
solange weder eine CircuitDeviceRow noch ein aufbewahrter Upgrade-Datensatz auf
|
solange weder eine CircuitDeviceRow noch ein aufbewahrter Upgrade-Datensatz auf
|
||||||
ihn verweist. Beide POST-Endpunkte verlangen `expectedRevision`, liefern den
|
ihn verweist. Beide POST-Endpunkte verlangen `expectedRevision`, liefern den
|
||||||
aktualisierten Historienstand und besitzen keinen direkten Create-Schreibweg
|
aktualisierten Historienstand und besitzen keinen direkten Create-Schreibweg
|
||||||
|
|||||||
@@ -265,7 +265,9 @@ Completed foundation:
|
|||||||
snapshots in the same transaction
|
snapshots in the same transaction
|
||||||
- `project.update-settings` persists both project voltage defaults, its exact
|
- `project.update-settings` persists both project voltage defaults, its exact
|
||||||
inverse, revision and history transition atomically; the project page and
|
inverse, revision and history transition atomically; the project page and
|
||||||
existing PUT route require the current expected revision
|
existing PUT route require the current expected revision; the same
|
||||||
|
transaction derives every project-device and circuit voltage again, so
|
||||||
|
Undo/Redo cannot leave mixed voltage states
|
||||||
- `distribution-board.insert` persists the complete generated board, circuit
|
- `distribution-board.insert` persists the complete generated board, circuit
|
||||||
list and default-section structure with stable ids; its delete inverse
|
list and default-section structure with stable ids; its delete inverse
|
||||||
refuses changed or populated structures and the project-page POST tracks the
|
refuses changed or populated structures and the project-page POST tracks the
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
# Kontext für die Revit-Anforderungsplanung
|
||||||
|
|
||||||
|
## Zweck dieser Datei
|
||||||
|
|
||||||
|
Diese Datei ist eine kompakte Übergabe an ein anderes LLM. Sie soll dabei
|
||||||
|
helfen, gemeinsam mit dem Anwender die fachlichen und technischen Anforderungen
|
||||||
|
für einen zukünftigen Revit-Datenaustausch zu klären.
|
||||||
|
|
||||||
|
Die Revit-Schnittstelle ist **noch nicht implementiert**. Das LLM soll zunächst
|
||||||
|
den tatsächlichen Arbeitsablauf erfragen, Unklarheiten sichtbar machen und
|
||||||
|
daraus eine neue, überprüfbare Arbeitsanweisung erstellen. Es soll weder ein
|
||||||
|
Dateiformat noch ein Datenbankschema oder eine UI ungefragt festlegen.
|
||||||
|
|
||||||
|
## Produkt und fachliches Modell
|
||||||
|
|
||||||
|
Leistungsbilanz ist eine Webanwendung für die elektrische Ausführungsplanung.
|
||||||
|
Ihr Kern ist ein tabellenähnlicher Stromkreislisten-Editor für Verteilungen.
|
||||||
|
|
||||||
|
Das unterstützte Laufzeitmodell ist:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Project
|
||||||
|
├── Floor
|
||||||
|
│ └── Room
|
||||||
|
├── ProjectDevice
|
||||||
|
└── DistributionBoard
|
||||||
|
└── CircuitList
|
||||||
|
└── CircuitSection
|
||||||
|
└── Circuit
|
||||||
|
└── CircuitDeviceRow
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtige fachliche Grenzen:
|
||||||
|
|
||||||
|
- Ein `Circuit` ist nicht dasselbe wie eine Gerätezeile.
|
||||||
|
- Ein Stromkreis kann null, eine oder mehrere `CircuitDeviceRow` enthalten.
|
||||||
|
- BMK (`equipmentIdentifier`), Schutz- und Kabeldaten gehören zum Stromkreis.
|
||||||
|
- Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi, Raum, Kostengruppe
|
||||||
|
und Kategorie gehören zur Gerätezeile.
|
||||||
|
- Ein `ProjectDevice` ist eine wiederverwendbare, projektbezogene Vorlage.
|
||||||
|
- Eine Gerätezeile kann mit einem Projektgerät verknüpft sein, behält aber
|
||||||
|
lokale Werte und ausdrücklich erfasste Abweichungen.
|
||||||
|
- Änderungen eines Projektgeräts überschreiben verknüpfte Zeilen niemals
|
||||||
|
automatisch. Eine Synchronisierung benötigt Vorschau und Benutzerauswahl.
|
||||||
|
- Manuelle, nicht mit einem Projektgerät verknüpfte Gerätezeilen sind regulär
|
||||||
|
erlaubt.
|
||||||
|
- Bestehende BMKs und stabile UUIDs dürfen nicht automatisch verändert werden.
|
||||||
|
- Neunummerierung ist immer eine ausdrückliche Benutzeraktion.
|
||||||
|
- `IFCGUID` darf nicht als BMK oder als Ersatz für eine interne UUID verwendet
|
||||||
|
werden.
|
||||||
|
|
||||||
|
Für einen späteren Revit-Austausch besonders relevante Daten sind:
|
||||||
|
|
||||||
|
- Projekt: Name, interne/externe Projektnummer, Bauherr, Beschreibung,
|
||||||
|
Projektspannungen.
|
||||||
|
- Geschoss und Raum: interne UUID, Name beziehungsweise Raumnummer und
|
||||||
|
Raumname.
|
||||||
|
- Verteilung: interne UUID, Name, Etage, Netzart und Gleichzeitigkeitsfaktor.
|
||||||
|
- Stromkreis: interne UUID, BMK, Anzeigename, Bereich, Schutz-, Kabel-,
|
||||||
|
Spannungs-, Steuerungs-, Status- und Bemerkungsdaten.
|
||||||
|
- Gerätezeile: interne UUID, optionale Projektgeräteverknüpfung, Name,
|
||||||
|
Anzeigename, Phasenart, Anschlussart, Kostengruppe, Kategorie, Geschoss,
|
||||||
|
Raum, Menge, Einzelleistung, Gleichzeitigkeitsfaktor, cosPhi und Bemerkung.
|
||||||
|
- Projektgerät: interne UUID sowie die wiederverwendbaren Gerätewerte.
|
||||||
|
|
||||||
|
Die Phasenart ist fachlich `1-phasig` oder `3-phasig`. Die Spannung wird daraus
|
||||||
|
und aus den Projekteinstellungen abgeleitet; freie Sonderspannungen sind im
|
||||||
|
aktuellen Produkt nicht vorgesehen.
|
||||||
|
|
||||||
|
## Aktuelle technische Architektur
|
||||||
|
|
||||||
|
- Frontend: Next.js App Router, React und TypeScript unter `src/app` und
|
||||||
|
`src/frontend`.
|
||||||
|
- API: Express und Zod unter `src/server`.
|
||||||
|
- Fachregeln und typisierte Commands: `src/domain`.
|
||||||
|
- Persistenz: SQLite, Drizzle und Repository-Adapter unter `src/db`.
|
||||||
|
- Laufzeitkomposition konkreter Repositories:
|
||||||
|
`src/server/composition`.
|
||||||
|
- Lokaler Betrieb: Browser/Next.js auf Port 3001, Express-API auf Port 3000,
|
||||||
|
SQLite-Datei unter `data/leistungsbilanz.db`.
|
||||||
|
- Docker Compose wird für die lokale Entwicklung unterstützt.
|
||||||
|
- PostgreSQL und Mehrbenutzerbetrieb sind noch nicht umgesetzt.
|
||||||
|
|
||||||
|
Der zentrale Editor liegt unter:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx
|
||||||
|
src/frontend/components/circuit-tree-editor.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Frontend-Aktionen werden als validierte API-Commands ausgeführt. Fachregeln
|
||||||
|
liegen in Domain-Services; konkrete SQLite-Zugriffe bleiben in
|
||||||
|
Persistenzadaptern. Neue Revit-Fachlogik darf deshalb nicht direkt von
|
||||||
|
`better-sqlite3`, React-Komponenten oder globalen Datenbankinstanzen abhängen.
|
||||||
|
|
||||||
|
## Revisionen, Undo/Redo und Sicherungspunkte
|
||||||
|
|
||||||
|
Alle normalen projektbezogenen Änderungen laufen über eine gemeinsame
|
||||||
|
Command- und Transaktionsgrenze:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Fachänderung + inverses Kommando + Revision + Undo/Redo-Übergang
|
||||||
|
```
|
||||||
|
|
||||||
|
Diese Bestandteile werden atomar geschrieben oder vollständig zurückgerollt.
|
||||||
|
Jedes Projekt besitzt eine monoton steigende `currentRevision`. Schreibbefehle
|
||||||
|
geben die erwartete Revision mit, damit veraltete Clients keine neueren
|
||||||
|
Änderungen überschreiben.
|
||||||
|
|
||||||
|
Undo/Redo ist projektweit persistent und funktioniert nach Reload oder
|
||||||
|
Anwendungsneustart. Zusätzlich gibt es:
|
||||||
|
|
||||||
|
- eine unveränderliche Revisions-Timeline;
|
||||||
|
- benannte und automatische logische Projektsnapshots;
|
||||||
|
- wiederherstellbare Snapshots mit Prüfsumme;
|
||||||
|
- einen portablen, versionierten JSON-Projekttransfer.
|
||||||
|
|
||||||
|
Ein angewendeter Revit-Import muss später dieselbe Integritätsgrenze verwenden:
|
||||||
|
Die bestätigten Änderungen sollen als ein nachvollziehbarer Projekt-Command
|
||||||
|
beziehungsweise als ein fachlich definierter Änderungssatz atomar,
|
||||||
|
revisioniert und rückgängig machbar sein.
|
||||||
|
|
||||||
|
## Klare Abgrenzung zum vorhandenen JSON-Projekttransfer
|
||||||
|
|
||||||
|
Der vorhandene JSON-Export/-Import überträgt den vollständigen internen
|
||||||
|
Projektzustand. Er dient Sicherung, Wiederherstellung und Duplizierung eines
|
||||||
|
Projekts innerhalb von Leistungsbilanz.
|
||||||
|
|
||||||
|
Dieser Projekttransfer ist **kein** Revit-Austauschformat. Er enthält interne
|
||||||
|
Strukturen und UUIDs und soll nicht ohne bewusste Entscheidung als Grundlage
|
||||||
|
für CSV-Dateien oder Revit-Parameter verwendet werden.
|
||||||
|
|
||||||
|
Der zukünftige Revit-Workflow soll dagegen externe Modellobjekte anhand stabiler
|
||||||
|
externer Identitäten wiedererkennen, ausgewählte Planungswerte zuordnen und
|
||||||
|
Ergebnisse so exportieren, dass Revit sie wieder den richtigen Objekten
|
||||||
|
zuweisen kann.
|
||||||
|
|
||||||
|
## Bereits vereinbarte Leitplanken für den externen Modellaustausch
|
||||||
|
|
||||||
|
Diese Punkte gelten als Architekturvorgaben, noch nicht als fertiges
|
||||||
|
Implementierungsdesign:
|
||||||
|
|
||||||
|
- Ein Import schreibt niemals ungeprüft direkt in Stromkreise oder
|
||||||
|
Gerätezeilen.
|
||||||
|
- Eingelesene Daten landen zuerst in einem Staging-/Vorschaubereich.
|
||||||
|
- Vor der Übernahme werden neue, geänderte, fehlende, doppelte und
|
||||||
|
widersprüchliche Objekte angezeigt.
|
||||||
|
- Wiederholte Importe müssen Objekte deterministisch anhand der externen Quelle
|
||||||
|
und einer stabilen Identität, voraussichtlich IFCGUID, erkennen.
|
||||||
|
- Die Eindeutigkeit einer IFCGUID ist mindestens auf Projekt und externe
|
||||||
|
Modellquelle begrenzt; die genaue Quellendefinition ist noch zu klären.
|
||||||
|
- Importierte Quellwerte und lokal in Leistungsbilanz geplante Werte müssen
|
||||||
|
unterscheidbar bleiben.
|
||||||
|
- Lokale Planungswerte dürfen niemals still überschrieben werden.
|
||||||
|
- Verknüpfungen zwischen externen Objekten und internen Fachobjekten müssen
|
||||||
|
ausdrücklich modelliert werden.
|
||||||
|
- Fehlende oder widersprüchliche Identitäten werden gemeldet und niemals
|
||||||
|
automatisch einem anderen Objekt zugeordnet.
|
||||||
|
- Ein Export bewahrt IFCGUID und erforderliche Quellidentitäten, damit Revit
|
||||||
|
die Werte deterministisch zurückschreiben kann.
|
||||||
|
- Ein Export bezieht sich auf eine bekannte Projektrevision.
|
||||||
|
- Große Datenmengen allein erzwingen noch keinen Wechsel von SQLite zu
|
||||||
|
PostgreSQL. PostgreSQL wird relevant bei Mehrbenutzerbetrieb, zentralem
|
||||||
|
Serverbetrieb oder unabhängigen Hintergrundjobs.
|
||||||
|
|
||||||
|
Als mögliche, aber noch nicht beschlossene Konzepte wurden bisher genannt:
|
||||||
|
|
||||||
|
- `ExternalModelSource`
|
||||||
|
- `ExternalImportBatch`
|
||||||
|
- `ExternalModelObject`
|
||||||
|
- `ExternalObjectLink`
|
||||||
|
- `ExternalParameterMapping`
|
||||||
|
- `ExternalExportBatch`
|
||||||
|
|
||||||
|
Diese Namen beschreiben Verantwortlichkeiten. Das andere LLM darf sie
|
||||||
|
hinterfragen und verbessern, soll sie aber nicht als bereits vorhandene
|
||||||
|
Tabellen behandeln.
|
||||||
|
|
||||||
|
## Auftrag an das andere LLM
|
||||||
|
|
||||||
|
Erarbeite die Anforderungen dialogorientiert mit dem Anwender. Beginne mit dem
|
||||||
|
realen Ablauf in Revit und kläre danach schrittweise mindestens:
|
||||||
|
|
||||||
|
1. Welche Revit-Objekte beziehungsweise Kategorien werden exportiert?
|
||||||
|
2. Wie entsteht die Ausgangsdatei in Revit und wie sieht sie technisch aus
|
||||||
|
(CSV-Dialekt, Trennzeichen, Encoding, Dezimalformat, Kopfzeilen,
|
||||||
|
Revit-Versionen)?
|
||||||
|
3. Welche Identitäten stehen zur Verfügung: IFCGUID, Revit `ElementId`,
|
||||||
|
Typ-ID, Modell-/Dateikennung oder weitere Schlüssel?
|
||||||
|
4. Wie werden Hauptmodell, Teilmodelle, verknüpfte Modelle, Modellkopien und
|
||||||
|
aktualisierte Revit-Dateien unterschieden?
|
||||||
|
5. Welche Parameter kommen aus Revit und welche Werte werden ausschließlich in
|
||||||
|
Leistungsbilanz geplant?
|
||||||
|
6. Welche internen Ziele können externe Objekte erhalten: Projektgerät,
|
||||||
|
Gerätezeile, Stromkreis, Raum, Verteilung oder weitere Entitäten?
|
||||||
|
7. Werden externe Objekte einzeln oder gesammelt einem Stromkreis,
|
||||||
|
Projektgerät, Raum oder einer Verteilung zugeordnet?
|
||||||
|
8. Welche Werte sollen nach Revit zurückgeschrieben werden, in welchen
|
||||||
|
Einheiten und unter welchen Revit-Parameternamen?
|
||||||
|
9. Muss die ursprüngliche Datei einschließlich unbekannter Spalten,
|
||||||
|
Zeilenreihenfolge und Formatierung erhalten werden, oder darf eine neue
|
||||||
|
Rückgabedatei erzeugt werden?
|
||||||
|
10. Wie sollen Spalten-/Parametermappings erstellt, gespeichert,
|
||||||
|
wiederverwendet und zwischen Projekten geteilt werden?
|
||||||
|
11. Was gilt bei erneutem Import für neue, geänderte, gelöschte, verschobene,
|
||||||
|
doppelte oder nicht mehr sichtbare Revit-Objekte?
|
||||||
|
12. Wer gewinnt bei Konflikten zwischen neuem Revit-Wert und lokaler Änderung?
|
||||||
|
Welche Konflikte dürfen automatisch gelöst werden und welche benötigen
|
||||||
|
eine Auswahl?
|
||||||
|
13. Welche Vorschau, Filter, Sammelaktionen und Bestätigungen benötigt der
|
||||||
|
Anwender vor der Übernahme?
|
||||||
|
14. Welche fachlichen Validierungen und Pflichtfelder müssen eine Übernahme
|
||||||
|
verhindern oder nur eine Warnung erzeugen?
|
||||||
|
15. Welche Dateigrößen, Objektzahlen und Laufzeiten sind realistisch?
|
||||||
|
16. Müssen Import und Export synchron im Browser laufen oder werden später
|
||||||
|
Hintergrundjobs benötigt?
|
||||||
|
17. Welche Informationen müssen für Nachvollziehbarkeit, Undo/Redo,
|
||||||
|
Versionshistorie und Support gespeichert werden?
|
||||||
|
18. Welche konkreten End-to-End-Beispiele und Fehlerfälle dienen als
|
||||||
|
Abnahmekriterien?
|
||||||
|
|
||||||
|
Stelle Rückfragen in kleinen, zusammenhängenden Blöcken. Unterscheide in deinen
|
||||||
|
Notizen ausdrücklich:
|
||||||
|
|
||||||
|
- vom Anwender bestätigte Anforderungen;
|
||||||
|
- sinnvolle Vorschläge;
|
||||||
|
- offene Entscheidungen;
|
||||||
|
- technische Folgerungen;
|
||||||
|
- bewusst nicht betrachtete spätere Ausbaustufen.
|
||||||
|
|
||||||
|
## Erwartetes Ergebnis der Anforderungsrunde
|
||||||
|
|
||||||
|
Erstelle am Ende eine neue Arbeitsanweisung für das implementierende LLM. Sie
|
||||||
|
soll mindestens enthalten:
|
||||||
|
|
||||||
|
- Ziel, Umfang und ausdrücklich ausgeschlossene Funktionen;
|
||||||
|
- vollständigen Anwenderworkflow für Erstimport, Zuordnung, Folgeimport und
|
||||||
|
Rückexport;
|
||||||
|
- vereinbartes Dateiformat mit einem anonymisierten Beispiel;
|
||||||
|
- Feld- und Einheitenmapping einschließlich Richtung und Datenhoheit;
|
||||||
|
- Identitäts-, Matching- und Quellmodell;
|
||||||
|
- Regeln für neue, geänderte, fehlende und doppelte Objekte;
|
||||||
|
- Konflikt-, Validierungs- und Freigaberegeln;
|
||||||
|
- UI-Ablauf für Staging, Vorschau, Zuordnung und Bestätigung;
|
||||||
|
- Anforderungen an Revision, Atomarität, Undo/Redo und Audit;
|
||||||
|
- vorgeschlagene Modul- und Persistenzgrenzen, ohne bestehende
|
||||||
|
Architekturregeln zu umgehen;
|
||||||
|
- schrittweise Implementierungsphasen mit überprüfbaren Abnahmekriterien;
|
||||||
|
- offene Punkte, die vor dem jeweiligen Implementierungsschritt geklärt werden
|
||||||
|
müssen.
|
||||||
|
|
||||||
|
Die Arbeitsanweisung soll noch keinen Code enthalten. Sie muss klar markieren,
|
||||||
|
welche Entscheidungen der Anwender tatsächlich getroffen hat und welche Teile
|
||||||
|
nur Architekturvorschläge des LLM sind.
|
||||||
|
|
||||||
|
## Weiterführende Projektdokumente
|
||||||
|
|
||||||
|
- `AGENTS.md` – verbindliche Domänen- und Implementierungsregeln
|
||||||
|
- `docs/current-architecture.md` – vollständiger aktueller Laufzeit- und
|
||||||
|
Command-Pfad
|
||||||
|
- `docs/project-history-and-external-model-architecture.md` – bisherige
|
||||||
|
Zukunftsrichtung für Versionierung, externes Modell und PostgreSQL
|
||||||
|
- `docs/spec/01-domain-context.md` – fachlicher Kontext
|
||||||
|
- `docs/spec/03-data-model-concept.md` – Datenmodellkonzept
|
||||||
|
- `docs/spec/05-linked-devices-and-sync.md` – Regeln für Projektgeräte und
|
||||||
|
kontrollierte Synchronisierung
|
||||||
|
- `docs/spec/07-implementation-phases-todo.md` – Roadmap, insbesondere Phase 13
|
||||||
|
- `docs/circuit-list-editor-known-limitations.md` – noch nicht implementierte
|
||||||
|
Funktionen
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Current Product Backlog
|
||||||
|
|
||||||
|
This document records the next agreed product improvements. It describes
|
||||||
|
requirements and intended sequencing, not proof of implementation.
|
||||||
|
|
||||||
|
## Circuit List Editor
|
||||||
|
|
||||||
|
- Collect the remaining editor interaction issues before changing additional
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
## First Release Baseline
|
||||||
|
|
||||||
|
Complete this immediately before the first supported release, once the schema
|
||||||
|
and initial feature set are frozen:
|
||||||
|
|
||||||
|
- Decide explicitly whether pre-release databases, portable JSON exports,
|
||||||
|
logical snapshots and persisted command histories remain supported.
|
||||||
|
- Create one clean baseline migration that builds the complete release schema
|
||||||
|
on an empty database.
|
||||||
|
- Replace the active development migration chain and Drizzle metadata only if
|
||||||
|
no released installation depends on it.
|
||||||
|
- Remove upgrade-only database, snapshot and command compatibility code only
|
||||||
|
for formats that are explicitly declared unsupported.
|
||||||
|
- Preserve the pre-release migration history in Git history or a release tag.
|
||||||
|
- Verify installation, migrations, application startup and core workflows
|
||||||
|
against a completely empty database.
|
||||||
|
- Mark the resulting compatibility boundary with the first release tag.
|
||||||
|
- Treat every migration published after that release as immutable.
|
||||||
|
|
||||||
|
## Project History
|
||||||
|
|
||||||
|
- [x] Every listed logical snapshot, including automatic snapshots, must be
|
||||||
|
explicitly restorable from the project history UI.
|
||||||
|
- [x] Show which project change produced a snapshot. Prefer immutable revision
|
||||||
|
metadata over storing a second free-form description of the same change.
|
||||||
|
- [x] Keep restoration auditable as a new project revision and preserve persistent
|
||||||
|
undo/redo.
|
||||||
|
|
||||||
|
## Distribution Boards
|
||||||
|
|
||||||
|
- [x] Assign an optional project floor to a distribution board.
|
||||||
|
- [x] Store a supply classification for each distribution board:
|
||||||
|
- `AV` (Allgemeine Stromversorgung)
|
||||||
|
- `SV` (Sicherheitsstromversorgung)
|
||||||
|
- `EV` (Ersatzstromversorgung)
|
||||||
|
- `USV` (Unterbrechungsfreie Stromversorgung)
|
||||||
|
- `MSR` (Mess-, Steuerungs- und Regelungstechnik)
|
||||||
|
- `SiBe` (Sicherheitsbeleuchtung)
|
||||||
|
- [x] Let project settings select which catalog supply types are used in a
|
||||||
|
project. Distribution-board dialogs offer only that selection; a type in
|
||||||
|
use cannot be disabled.
|
||||||
|
- [x] Include both fields in project snapshots and portable project
|
||||||
|
export/import.
|
||||||
|
- [x] Persist changes through project commands so they remain undoable after a
|
||||||
|
restart.
|
||||||
|
|
||||||
|
## Device Voltage Derivation
|
||||||
|
|
||||||
|
- [x] Derive voltage from the selected phase type and the project settings:
|
||||||
|
- single-phase uses the project's single-phase voltage;
|
||||||
|
- three-phase uses the project's three-phase voltage.
|
||||||
|
- [x] Voltage is not editable on global devices, project devices or circuits.
|
||||||
|
Global devices store only their phase; copying one into a project derives
|
||||||
|
the target project's voltage.
|
||||||
|
- [x] Project-device voltage follows its phase. Circuit voltage follows the
|
||||||
|
section phase; an unassigned circuit is three-phase only when all assigned
|
||||||
|
device rows with a valid phase are three-phase.
|
||||||
|
- [x] Changing project voltage settings updates all project devices and circuits
|
||||||
|
atomically in the same persistent Undo/Redo step.
|
||||||
|
- [x] Database migration `0019` and snapshot schema version `5` normalize older
|
||||||
|
stored values. Version-four and older imports remain supported and are
|
||||||
|
normalized while being upgraded.
|
||||||
|
|
||||||
|
## Circuit List Power Summary
|
||||||
|
|
||||||
|
- [x] Show the total power of every circuit section.
|
||||||
|
- [x] Show the unadjusted total power of the complete distribution board.
|
||||||
|
- [x] Store a distribution-board simultaneity factor between zero and one in
|
||||||
|
the distribution-board settings.
|
||||||
|
- [x] Show the distribution-board total after applying that factor.
|
||||||
|
- [x] Preserve the factor in project commands, snapshots and portable project
|
||||||
|
transfers.
|
||||||
|
- [x] Keep column visibility and order as a project-specific UI preference so
|
||||||
|
all distribution boards in the same project open with the same columns.
|
||||||
|
|
||||||
|
## Recommended Sequence
|
||||||
|
|
||||||
|
1. [x] Distribution-board floor and supply fields.
|
||||||
|
2. [x] Device voltage derivation without overrides.
|
||||||
|
3. [x] Snapshot-to-revision descriptions and history presentation.
|
||||||
|
4. [x] Distribution-board power summary and project column layout.
|
||||||
|
|
||||||
|
The Revit/CSV/IFCGUID round-trip remains a separate jointly planned phase and
|
||||||
|
must not be inferred from these tasks.
|
||||||
+2
-2
@@ -15,8 +15,8 @@
|
|||||||
"build:web": "next build",
|
"build:web": "next build",
|
||||||
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
|
"typecheck:scripts": "tsc --noEmit -p tsconfig.scripts.json",
|
||||||
"start": "node dist/server/index.js",
|
"start": "node dist/server/index.js",
|
||||||
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
"test": "tsx --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||||
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
"test:watch": "tsx --watch --test tests/project-device-schema.test.ts tests/project-device-schema-migration.test.ts tests/project-device-placement.service.test.ts tests/project-device-sync.service.test.ts tests/legacy-consumer-migration-planner.test.ts tests/legacy-consumer-migration.service.test.ts tests/legacy-consumer-migration.repository.test.ts tests/circuit-numbering.service.test.ts tests/circuit-power-calculation.test.ts tests/circuit-tree.controller.test.ts tests/circuit-editor-history.test.ts tests/circuit-grid-insertion.test.ts tests/circuit-grid-safety.test.ts tests/circuit-grid-model.test.ts tests/circuit-structure-command.test.ts tests/circuit-device-row-move-command.test.ts tests/circuit-section-reorder-command.test.ts tests/circuit-section-renumber-command.test.ts tests/circuit-grid-projection.test.ts tests/distribution-board-structure-project-command.repository.test.ts tests/project-location-structure-project-command.repository.test.ts tests/project-device-project-command.repository.test.ts tests/project-device-structure-project-command.repository.test.ts tests/project-device-row-sync-project-command.repository.test.ts tests/circuit-section-reorder-project-command.repository.test.ts tests/circuit-section-renumber-project-command.repository.test.ts tests/project-command.model.test.ts tests/project-revision.persistence.test.ts tests/circuit-project-command.repository.test.ts tests/circuit-structure-project-command.repository.test.ts tests/circuit-device-row-project-command.repository.test.ts tests/circuit-device-row-structure-project-command.repository.test.ts tests/circuit-device-row-move-project-command.repository.test.ts tests/project-history.repository.test.ts tests/project-history-timeline.test.ts tests/project-version-history.test.ts tests/project-overview-import.test.ts tests/project-settings-project-command.repository.test.ts tests/project-state-snapshot.test.ts tests/project-snapshot-policy.test.ts tests/project-snapshot.repository.test.ts tests/project-state-restore-command.repository.test.ts tests/project-command.service.test.ts tests/database-backup.test.ts",
|
||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:backup": "tsx scripts/db-backup.ts",
|
"db:backup": "tsx scripts/db-backup.ts",
|
||||||
|
|||||||
@@ -48,12 +48,45 @@ const circuitColumns = new Set(
|
|||||||
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
|
db.prepare("PRAGMA table_info(circuits)").all().map((column) => column.name)
|
||||||
);
|
);
|
||||||
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name));
|
const missingCircuitColumns = requiredCircuitColumns.filter((name) => !circuitColumns.has(name));
|
||||||
|
const requiredProjectColumns = [
|
||||||
|
"internal_project_number",
|
||||||
|
"external_project_number",
|
||||||
|
"building_owner",
|
||||||
|
"description",
|
||||||
|
"enabled_distribution_board_supply_types",
|
||||||
|
];
|
||||||
|
const projectColumns = new Set(
|
||||||
|
db.prepare("PRAGMA table_info(projects)").all().map((column) => column.name)
|
||||||
|
);
|
||||||
|
const missingProjectColumns = requiredProjectColumns.filter(
|
||||||
|
(name) => !projectColumns.has(name)
|
||||||
|
);
|
||||||
|
const requiredDistributionBoardColumns = ["floor_id", "supply_type"];
|
||||||
|
const distributionBoardColumns = new Set(
|
||||||
|
db
|
||||||
|
.prepare("PRAGMA table_info(distribution_boards)")
|
||||||
|
.all()
|
||||||
|
.map((column) => column.name)
|
||||||
|
);
|
||||||
|
const missingDistributionBoardColumns =
|
||||||
|
requiredDistributionBoardColumns.filter(
|
||||||
|
(name) => !distributionBoardColumns.has(name)
|
||||||
|
);
|
||||||
|
const integrityCheck = db.pragma("integrity_check", { simple: true });
|
||||||
|
const foreignKeyViolations = db.pragma("foreign_key_check");
|
||||||
|
|
||||||
console.log("Database:", dbPath);
|
console.log("Database:", dbPath);
|
||||||
console.log("Required tables:", requiredTables.join(", "));
|
console.log("Required tables:", requiredTables.join(", "));
|
||||||
console.log("Existing tables:", [...existing].join(", ") || "(none)");
|
console.log("Existing tables:", [...existing].join(", ") || "(none)");
|
||||||
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
|
console.log("Required project-device columns:", requiredProjectDeviceColumns.join(", "));
|
||||||
console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
|
console.log("Required circuit columns:", requiredCircuitColumns.join(", "));
|
||||||
|
console.log("Required project columns:", requiredProjectColumns.join(", "));
|
||||||
|
console.log(
|
||||||
|
"Required distribution-board columns:",
|
||||||
|
requiredDistributionBoardColumns.join(", ")
|
||||||
|
);
|
||||||
|
console.log("Integrity:", integrityCheck);
|
||||||
|
console.log("Foreign-key violations:", foreignKeyViolations.length);
|
||||||
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
console.error("Missing tables:", missing.join(", "));
|
console.error("Missing tables:", missing.join(", "));
|
||||||
@@ -78,4 +111,22 @@ if (missingCircuitColumns.length > 0) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (missingProjectColumns.length > 0) {
|
||||||
|
console.error("Missing project columns:", missingProjectColumns.join(", "));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingDistributionBoardColumns.length > 0) {
|
||||||
|
console.error(
|
||||||
|
"Missing distribution-board columns:",
|
||||||
|
missingDistributionBoardColumns.join(", ")
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (integrityCheck !== "ok" || foreignKeyViolations.length > 0) {
|
||||||
|
console.error("Database integrity or foreign-key verification failed.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("Circuit-first schema verification passed.");
|
console.log("Circuit-first schema verification passed.");
|
||||||
|
|||||||
+115
-14
@@ -71,6 +71,13 @@ body {
|
|||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-toolbar .project-device-drawer-toggle {
|
||||||
|
border-color: #2563eb;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.active-view-summary {
|
.active-view-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -109,6 +116,39 @@ body {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.distribution-power-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(12rem, 1fr));
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.55rem;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-power-summary > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-power-summary span {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-power-summary strong {
|
||||||
|
color: #172033;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.distribution-power-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.column-settings-menu {
|
.column-settings-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 9;
|
z-index: 9;
|
||||||
@@ -271,12 +311,10 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tree-editor-layout {
|
.tree-editor-layout {
|
||||||
display: grid;
|
display: block;
|
||||||
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
|
|
||||||
gap: 0.75rem;
|
|
||||||
min-height: 560px;
|
min-height: 560px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
align-items: start;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-device-sidebar {
|
.project-device-sidebar {
|
||||||
@@ -354,7 +392,7 @@ body {
|
|||||||
|
|
||||||
.tree-grid {
|
.tree-grid {
|
||||||
width: max-content;
|
width: max-content;
|
||||||
min-width: 100%;
|
min-width: 0;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
@@ -362,37 +400,50 @@ body {
|
|||||||
.tree-grid th,
|
.tree-grid th,
|
||||||
.tree-grid td {
|
.tree-grid td {
|
||||||
border: 1px solid #e4e9f2;
|
border: 1px solid #e4e9f2;
|
||||||
padding: 0.35rem 0.5rem;
|
padding: 0.35rem 0.4rem;
|
||||||
white-space: nowrap;
|
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid th {
|
.tree-grid th {
|
||||||
|
width: 1px;
|
||||||
background: #f4f7fb;
|
background: #f4f7fb;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid td {
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .header-cell {
|
.tree-grid .header-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 0.3rem;
|
align-items: stretch;
|
||||||
|
gap: 0.2rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .header-filter-btn {
|
.tree-grid .header-filter-btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
border: 1px solid #c4cddc;
|
border: 1px solid #c4cddc;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
padding: 0.15rem 0.35rem;
|
padding: 0.15rem 0.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .header-sort-btn {
|
.tree-grid .header-sort-btn {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -400,10 +451,46 @@ body {
|
|||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
line-height: 1.15;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: normal;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.project-device-drawer {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1040;
|
||||||
|
top: 5.5rem;
|
||||||
|
right: 1rem;
|
||||||
|
left: auto;
|
||||||
|
width: min(360px, calc(100vw - 2rem));
|
||||||
|
max-height: calc(100vh - 7rem);
|
||||||
|
overflow: auto;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 0.75rem 2rem rgba(31, 41, 55, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-device-drawer-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-device-drawer-header span {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-device-drawer-header button {
|
||||||
|
border: 1px solid #c4cddc;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-grid .header-sort-btn:hover span:first-child,
|
.tree-grid .header-sort-btn:hover span:first-child,
|
||||||
.tree-grid .header-sort-btn:focus-visible span:first-child {
|
.tree-grid .header-sort-btn:focus-visible span:first-child {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
@@ -641,6 +728,18 @@ body {
|
|||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-grid .section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .section-title span {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-grid .cell-invalid {
|
.tree-grid .cell-invalid {
|
||||||
outline: 2px solid #c2410c;
|
outline: 2px solid #c2410c;
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
@@ -651,7 +750,8 @@ body {
|
|||||||
border-color: #c2410c;
|
border-color: #c2410c;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid input {
|
.tree-grid input,
|
||||||
|
.tree-grid select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 5rem;
|
min-width: 5rem;
|
||||||
border: 1px solid #9fb6e0;
|
border: 1px solid #9fb6e0;
|
||||||
@@ -783,8 +883,9 @@ body {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 720px) {
|
||||||
.tree-editor-layout {
|
.project-device-drawer {
|
||||||
grid-template-columns: 1fr;
|
top: 4.5rem;
|
||||||
|
max-height: calc(100vh - 5.5rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export default function CircuitTreeEditPage() {
|
|||||||
const params = useParams<{ projectId: string; circuitListId: string }>();
|
const params = useParams<{ projectId: string; circuitListId: string }>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container py-4">
|
<main className="container-fluid px-3 px-xxl-4 py-4">
|
||||||
<div className="d-flex justify-content-between align-items-center mb-3">
|
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="h4 mb-1">Stromkreisliste</h1>
|
<h1 className="h4 mb-1">Stromkreisliste</h1>
|
||||||
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
|
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+137
-11
@@ -6,6 +6,7 @@ import {
|
|||||||
createGlobalDevice,
|
createGlobalDevice,
|
||||||
createProject,
|
createProject,
|
||||||
deleteGlobalDevice,
|
deleteGlobalDevice,
|
||||||
|
importProjectTransferAsNew,
|
||||||
listGlobalDevices,
|
listGlobalDevices,
|
||||||
listProjects,
|
listProjects,
|
||||||
updateGlobalDevice,
|
updateGlobalDevice,
|
||||||
@@ -23,7 +24,6 @@ const emptyGlobalDevice: CreateGlobalDeviceInput = {
|
|||||||
quantity: 1,
|
quantity: 1,
|
||||||
installedPowerPerUnitKw: 0.1,
|
installedPowerPerUnitKw: 0.1,
|
||||||
demandFactor: 1,
|
demandFactor: 1,
|
||||||
voltageV: 230,
|
|
||||||
phaseCount: 1,
|
phaseCount: 1,
|
||||||
powerFactor: 1,
|
powerFactor: 1,
|
||||||
note: "",
|
note: "",
|
||||||
@@ -48,13 +48,22 @@ export default function ProjectsPage() {
|
|||||||
quantity: "1",
|
quantity: "1",
|
||||||
installedPowerPerUnitKw: "0.1",
|
installedPowerPerUnitKw: "0.1",
|
||||||
demandFactor: "1",
|
demandFactor: "1",
|
||||||
voltageV: "230",
|
|
||||||
phaseCount: "1",
|
phaseCount: "1",
|
||||||
powerFactor: "1",
|
powerFactor: "1",
|
||||||
note: "",
|
note: "",
|
||||||
});
|
});
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [projectImport, setProjectImport] = useState<unknown>(null);
|
||||||
|
const [projectImportFilename, setProjectImportFilename] = useState("");
|
||||||
|
const [projectImportInputKey, setProjectImportInputKey] = useState(0);
|
||||||
|
const [projectImportFileError, setProjectImportFileError] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
const [importedProject, setImportedProject] = useState<{
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -87,6 +96,49 @@ export default function ProjectsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleProjectImportFile(file: File | undefined) {
|
||||||
|
setProjectImport(null);
|
||||||
|
setProjectImportFilename("");
|
||||||
|
setProjectImportFileError(null);
|
||||||
|
setImportedProject(null);
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setProjectImport(JSON.parse(await file.text()) as unknown);
|
||||||
|
setProjectImportFilename(file.name);
|
||||||
|
} catch {
|
||||||
|
setProjectImportFileError(
|
||||||
|
"Die ausgewählte Datei enthält kein gültiges JSON."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportProject() {
|
||||||
|
if (projectImport === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setImportedProject(null);
|
||||||
|
try {
|
||||||
|
const imported = await importProjectTransferAsNew(projectImport);
|
||||||
|
setProjects(await listProjects());
|
||||||
|
setImportedProject(imported);
|
||||||
|
setProjectImport(null);
|
||||||
|
setProjectImportFilename("");
|
||||||
|
setProjectImportInputKey((current) => current + 1);
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Projektdatei konnte nicht importiert werden."
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreateGlobalDevice(event: FormEvent<HTMLFormElement>) {
|
async function handleCreateGlobalDevice(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!globalDeviceForm.name.trim()) {
|
if (!globalDeviceForm.name.trim()) {
|
||||||
@@ -99,7 +151,6 @@ export default function ProjectsPage() {
|
|||||||
quantity: Number(globalDeviceForm.quantity),
|
quantity: Number(globalDeviceForm.quantity),
|
||||||
installedPowerPerUnitKw: Number(globalDeviceForm.installedPowerPerUnitKw),
|
installedPowerPerUnitKw: Number(globalDeviceForm.installedPowerPerUnitKw),
|
||||||
demandFactor: Number(globalDeviceForm.demandFactor),
|
demandFactor: Number(globalDeviceForm.demandFactor),
|
||||||
voltageV: toOptionalNumber(globalDeviceForm.voltageV),
|
|
||||||
phaseCount: globalDeviceForm.phaseCount === "3" ? 3 : 1,
|
phaseCount: globalDeviceForm.phaseCount === "3" ? 3 : 1,
|
||||||
powerFactor: toOptionalNumber(globalDeviceForm.powerFactor),
|
powerFactor: toOptionalNumber(globalDeviceForm.powerFactor),
|
||||||
note: globalDeviceForm.note.trim() || undefined,
|
note: globalDeviceForm.note.trim() || undefined,
|
||||||
@@ -116,7 +167,6 @@ export default function ProjectsPage() {
|
|||||||
quantity: String(emptyGlobalDevice.quantity),
|
quantity: String(emptyGlobalDevice.quantity),
|
||||||
installedPowerPerUnitKw: String(emptyGlobalDevice.installedPowerPerUnitKw),
|
installedPowerPerUnitKw: String(emptyGlobalDevice.installedPowerPerUnitKw),
|
||||||
demandFactor: String(emptyGlobalDevice.demandFactor),
|
demandFactor: String(emptyGlobalDevice.demandFactor),
|
||||||
voltageV: String(emptyGlobalDevice.voltageV ?? ""),
|
|
||||||
phaseCount: String(emptyGlobalDevice.phaseCount ?? 1),
|
phaseCount: String(emptyGlobalDevice.phaseCount ?? 1),
|
||||||
powerFactor: String(emptyGlobalDevice.powerFactor ?? ""),
|
powerFactor: String(emptyGlobalDevice.powerFactor ?? ""),
|
||||||
note: emptyGlobalDevice.note ?? "",
|
note: emptyGlobalDevice.note ?? "",
|
||||||
@@ -143,7 +193,7 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
async function handleQuickUpdateGlobalDevice(
|
async function handleQuickUpdateGlobalDevice(
|
||||||
device: GlobalDeviceDto,
|
device: GlobalDeviceDto,
|
||||||
key: "name" | "displayName" | "category",
|
key: "name" | "displayName" | "category" | "phaseCount",
|
||||||
value: string
|
value: string
|
||||||
) {
|
) {
|
||||||
const payload: CreateGlobalDeviceInput = {
|
const payload: CreateGlobalDeviceInput = {
|
||||||
@@ -153,8 +203,12 @@ export default function ProjectsPage() {
|
|||||||
quantity: device.quantity,
|
quantity: device.quantity,
|
||||||
installedPowerPerUnitKw: device.installedPowerPerUnitKw,
|
installedPowerPerUnitKw: device.installedPowerPerUnitKw,
|
||||||
demandFactor: device.demandFactor,
|
demandFactor: device.demandFactor,
|
||||||
voltageV: device.voltageV ?? undefined,
|
phaseCount:
|
||||||
phaseCount: device.phaseCount ?? undefined,
|
key === "phaseCount"
|
||||||
|
? value === "3"
|
||||||
|
? 3
|
||||||
|
: 1
|
||||||
|
: device.phaseCount ?? 1,
|
||||||
powerFactor: device.powerFactor ?? undefined,
|
powerFactor: device.powerFactor ?? undefined,
|
||||||
note: device.note ?? undefined,
|
note: device.note ?? undefined,
|
||||||
};
|
};
|
||||||
@@ -179,7 +233,7 @@ export default function ProjectsPage() {
|
|||||||
|
|
||||||
<div className="row g-4">
|
<div className="row g-4">
|
||||||
<section className="col-12 col-lg-4">
|
<section className="col-12 col-lg-4">
|
||||||
<div className="card shadow-sm">
|
<div className="card shadow-sm mb-4">
|
||||||
<div className="card-header">Neues Projekt</div>
|
<div className="card-header">Neues Projekt</div>
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
||||||
@@ -198,6 +252,60 @@ export default function ProjectsPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="card shadow-sm">
|
||||||
|
<div className="card-header">Projekt importieren</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-secondary small">
|
||||||
|
Eine exportierte JSON-Projektdatei wird als eigenständige Kopie
|
||||||
|
mit neuen internen IDs angelegt.
|
||||||
|
</p>
|
||||||
|
<label className="form-label" htmlFor="project-overview-import">
|
||||||
|
Projektdatei auswählen
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
accept=".json,application/json"
|
||||||
|
className="form-control"
|
||||||
|
disabled={isSaving}
|
||||||
|
id="project-overview-import"
|
||||||
|
key={projectImportInputKey}
|
||||||
|
onChange={(event) =>
|
||||||
|
void handleProjectImportFile(event.target.files?.[0])
|
||||||
|
}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
{projectImportFilename ? (
|
||||||
|
<div className="form-text">
|
||||||
|
{projectImportFilename} ist bereit.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{projectImportFileError ? (
|
||||||
|
<div className="text-danger small mt-1">
|
||||||
|
{projectImportFileError}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary mt-3 w-100"
|
||||||
|
disabled={isSaving || projectImport === null}
|
||||||
|
onClick={() => void handleImportProject()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Als neues Projekt importieren
|
||||||
|
</button>
|
||||||
|
{importedProject ? (
|
||||||
|
<div className="alert alert-success mt-3 mb-0">
|
||||||
|
<div className="small mb-2">
|
||||||
|
„{importedProject.name}“ wurde angelegt.
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
className="btn btn-sm btn-success"
|
||||||
|
href={`/projects/${importedProject.projectId}`}
|
||||||
|
>
|
||||||
|
Importiertes Projekt öffnen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="col-12 col-lg-8">
|
<section className="col-12 col-lg-8">
|
||||||
@@ -313,8 +421,8 @@ export default function ProjectsPage() {
|
|||||||
setGlobalDeviceForm((current) => ({ ...current, phaseCount: event.target.value }))
|
setGlobalDeviceForm((current) => ({ ...current, phaseCount: event.target.value }))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="1">1-ph</option>
|
<option value="1">1-phasig</option>
|
||||||
<option value="3">3-ph</option>
|
<option value="3">3-phasig</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-6 col-md-2">
|
<div className="col-6 col-md-2">
|
||||||
@@ -334,6 +442,7 @@ export default function ProjectsPage() {
|
|||||||
<th>Anzahl</th>
|
<th>Anzahl</th>
|
||||||
<th>Leistung je Stück [kW]</th>
|
<th>Leistung je Stück [kW]</th>
|
||||||
<th>GZF</th>
|
<th>GZF</th>
|
||||||
|
<th>Phasenart</th>
|
||||||
<th>Aktion</th>
|
<th>Aktion</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -376,6 +485,23 @@ export default function ProjectsPage() {
|
|||||||
<td>{device.quantity}</td>
|
<td>{device.quantity}</td>
|
||||||
<td>{device.installedPowerPerUnitKw}</td>
|
<td>{device.installedPowerPerUnitKw}</td>
|
||||||
<td>{device.demandFactor}</td>
|
<td>{device.demandFactor}</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
className="form-select form-select-sm"
|
||||||
|
aria-label={`Phasenart für ${device.displayName}`}
|
||||||
|
value={String(device.phaseCount ?? 1)}
|
||||||
|
onChange={(event) =>
|
||||||
|
void handleQuickUpdateGlobalDevice(
|
||||||
|
device,
|
||||||
|
"phaseCount",
|
||||||
|
event.target.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="1">1-phasig</option>
|
||||||
|
<option value="3">3-phasig</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
className="btn btn-sm btn-outline-danger"
|
className="btn btn-sm btn-outline-danger"
|
||||||
@@ -389,7 +515,7 @@ export default function ProjectsPage() {
|
|||||||
))}
|
))}
|
||||||
{!globalDevices.length ? (
|
{!globalDevices.length ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="text-center text-secondary py-4">
|
<td colSpan={8} className="text-center text-secondary py-4">
|
||||||
Noch keine globalen Geräte vorhanden.
|
Noch keine globalen Geräte vorhanden.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE `projects` ADD `internal_project_number` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `projects` ADD `external_project_number` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `projects` ADD `building_owner` text;--> statement-breakpoint
|
||||||
|
ALTER TABLE `projects` ADD `description` text;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `distribution_boards` ADD `floor_id` text REFERENCES floors(id);--> statement-breakpoint
|
||||||
|
ALTER TABLE `distribution_boards` ADD `supply_type` text;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `projects` ADD `enabled_distribution_board_supply_types` text DEFAULT '["AV","SV","EV","USV","MSR","SiBe"]' NOT NULL;
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
UPDATE `global_devices`
|
||||||
|
SET `voltage_v` = NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `project_devices` AS `pd`
|
||||||
|
SET `voltage_v` = CASE
|
||||||
|
WHEN `pd`.`phase_type` = 'three_phase' THEN `p`.`three_phase_voltage_v`
|
||||||
|
ELSE `p`.`single_phase_voltage_v`
|
||||||
|
END
|
||||||
|
FROM `projects` AS `p`
|
||||||
|
WHERE `p`.`id` = `pd`.`project_id`;
|
||||||
|
--> statement-breakpoint
|
||||||
|
WITH `derived_circuit_voltages` AS (
|
||||||
|
SELECT
|
||||||
|
`c`.`id` AS `circuit_id`,
|
||||||
|
CASE
|
||||||
|
WHEN `cs`.`key` = 'three_phase' THEN `p`.`three_phase_voltage_v`
|
||||||
|
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN `p`.`single_phase_voltage_v`
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `circuit_device_rows` AS `three_phase_row`
|
||||||
|
WHERE `three_phase_row`.`circuit_id` = `c`.`id`
|
||||||
|
AND `three_phase_row`.`phase_type` = 'three_phase'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `circuit_device_rows` AS `other_row`
|
||||||
|
WHERE `other_row`.`circuit_id` = `c`.`id`
|
||||||
|
AND `other_row`.`phase_type` = 'single_phase'
|
||||||
|
) THEN `p`.`three_phase_voltage_v`
|
||||||
|
ELSE `p`.`single_phase_voltage_v`
|
||||||
|
END AS `derived_voltage`
|
||||||
|
FROM `circuits` AS `c`
|
||||||
|
INNER JOIN `circuit_lists` AS `cl`
|
||||||
|
ON `cl`.`id` = `c`.`circuit_list_id`
|
||||||
|
INNER JOIN `projects` AS `p`
|
||||||
|
ON `p`.`id` = `cl`.`project_id`
|
||||||
|
INNER JOIN `circuit_sections` AS `cs`
|
||||||
|
ON `cs`.`id` = `c`.`section_id`
|
||||||
|
)
|
||||||
|
UPDATE `circuits`
|
||||||
|
SET `voltage` = (
|
||||||
|
SELECT `derived_voltage`
|
||||||
|
FROM `derived_circuit_voltages`
|
||||||
|
WHERE `circuit_id` = `circuits`.`id`
|
||||||
|
)
|
||||||
|
WHERE `id` IN (
|
||||||
|
SELECT `circuit_id`
|
||||||
|
FROM `derived_circuit_voltages`
|
||||||
|
);
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
UPDATE `project_devices`
|
||||||
|
SET `phase_type` = CASE
|
||||||
|
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||||
|
IN ('1ph', '1phase', '1phasig', 'einphasig', 'singlephase')
|
||||||
|
THEN 'single_phase'
|
||||||
|
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||||
|
IN ('3ph', '3phase', '3phasig', 'dreiphasig', 'threephase')
|
||||||
|
THEN 'three_phase'
|
||||||
|
ELSE `phase_type`
|
||||||
|
END
|
||||||
|
WHERE LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||||
|
IN (
|
||||||
|
'1ph',
|
||||||
|
'1phase',
|
||||||
|
'1phasig',
|
||||||
|
'einphasig',
|
||||||
|
'singlephase',
|
||||||
|
'3ph',
|
||||||
|
'3phase',
|
||||||
|
'3phasig',
|
||||||
|
'dreiphasig',
|
||||||
|
'threephase'
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `circuit_device_rows`
|
||||||
|
SET `phase_type` = CASE
|
||||||
|
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||||
|
IN ('1ph', '1phase', '1phasig', 'einphasig', 'singlephase')
|
||||||
|
THEN 'single_phase'
|
||||||
|
WHEN LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||||
|
IN ('3ph', '3phase', '3phasig', 'dreiphasig', 'threephase')
|
||||||
|
THEN 'three_phase'
|
||||||
|
ELSE `phase_type`
|
||||||
|
END
|
||||||
|
WHERE LOWER(REPLACE(REPLACE(TRIM(`phase_type`), '-', ''), '_', ''))
|
||||||
|
IN (
|
||||||
|
'1ph',
|
||||||
|
'1phase',
|
||||||
|
'1phasig',
|
||||||
|
'einphasig',
|
||||||
|
'singlephase',
|
||||||
|
'3ph',
|
||||||
|
'3phase',
|
||||||
|
'3phasig',
|
||||||
|
'dreiphasig',
|
||||||
|
'threephase'
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
WITH `derived_circuit_voltages` AS (
|
||||||
|
SELECT
|
||||||
|
`c`.`id` AS `circuit_id`,
|
||||||
|
CASE
|
||||||
|
WHEN `cs`.`key` = 'three_phase' THEN `p`.`three_phase_voltage_v`
|
||||||
|
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN `p`.`single_phase_voltage_v`
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `circuit_device_rows` AS `three_phase_row`
|
||||||
|
WHERE `three_phase_row`.`circuit_id` = `c`.`id`
|
||||||
|
AND `three_phase_row`.`phase_type` = 'three_phase'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `circuit_device_rows` AS `single_phase_row`
|
||||||
|
WHERE `single_phase_row`.`circuit_id` = `c`.`id`
|
||||||
|
AND `single_phase_row`.`phase_type` = 'single_phase'
|
||||||
|
) THEN `p`.`three_phase_voltage_v`
|
||||||
|
ELSE `p`.`single_phase_voltage_v`
|
||||||
|
END AS `derived_voltage`
|
||||||
|
FROM `circuits` AS `c`
|
||||||
|
INNER JOIN `circuit_lists` AS `cl`
|
||||||
|
ON `cl`.`id` = `c`.`circuit_list_id`
|
||||||
|
INNER JOIN `projects` AS `p`
|
||||||
|
ON `p`.`id` = `cl`.`project_id`
|
||||||
|
INNER JOIN `circuit_sections` AS `cs`
|
||||||
|
ON `cs`.`id` = `c`.`section_id`
|
||||||
|
)
|
||||||
|
UPDATE `circuits`
|
||||||
|
SET `voltage` = (
|
||||||
|
SELECT `derived_voltage`
|
||||||
|
FROM `derived_circuit_voltages`
|
||||||
|
WHERE `circuit_id` = `circuits`.`id`
|
||||||
|
)
|
||||||
|
WHERE `id` IN (
|
||||||
|
SELECT `circuit_id`
|
||||||
|
FROM `derived_circuit_voltages`
|
||||||
|
);
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
UPDATE `circuit_device_rows`
|
||||||
|
SET `phase_type` = COALESCE(
|
||||||
|
(
|
||||||
|
SELECT `pd`.`phase_type`
|
||||||
|
FROM `project_devices` AS `pd`
|
||||||
|
WHERE `pd`.`id` = `circuit_device_rows`.`linked_project_device_id`
|
||||||
|
AND `pd`.`phase_type` IN ('single_phase', 'three_phase')
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SELECT CASE
|
||||||
|
WHEN `cs`.`key` = 'three_phase' THEN 'three_phase'
|
||||||
|
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN 'single_phase'
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
FROM `circuits` AS `c`
|
||||||
|
INNER JOIN `circuit_sections` AS `cs`
|
||||||
|
ON `cs`.`id` = `c`.`section_id`
|
||||||
|
WHERE `c`.`id` = `circuit_device_rows`.`circuit_id`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE `phase_type` IS NULL
|
||||||
|
AND (
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `project_devices` AS `pd`
|
||||||
|
WHERE `pd`.`id` = `circuit_device_rows`.`linked_project_device_id`
|
||||||
|
AND `pd`.`phase_type` IN ('single_phase', 'three_phase')
|
||||||
|
)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `circuits` AS `c`
|
||||||
|
INNER JOIN `circuit_sections` AS `cs`
|
||||||
|
ON `cs`.`id` = `c`.`section_id`
|
||||||
|
WHERE `c`.`id` = `circuit_device_rows`.`circuit_id`
|
||||||
|
AND `cs`.`key` IN ('lighting', 'single_phase', 'three_phase')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
WITH `derived_circuit_voltages` AS (
|
||||||
|
SELECT
|
||||||
|
`c`.`id` AS `circuit_id`,
|
||||||
|
CASE
|
||||||
|
WHEN `cs`.`key` = 'three_phase' THEN `p`.`three_phase_voltage_v`
|
||||||
|
WHEN `cs`.`key` IN ('lighting', 'single_phase') THEN `p`.`single_phase_voltage_v`
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `circuit_device_rows` AS `three_phase_row`
|
||||||
|
WHERE `three_phase_row`.`circuit_id` = `c`.`id`
|
||||||
|
AND `three_phase_row`.`phase_type` = 'three_phase'
|
||||||
|
) AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM `circuit_device_rows` AS `single_phase_row`
|
||||||
|
WHERE `single_phase_row`.`circuit_id` = `c`.`id`
|
||||||
|
AND `single_phase_row`.`phase_type` = 'single_phase'
|
||||||
|
) THEN `p`.`three_phase_voltage_v`
|
||||||
|
ELSE `p`.`single_phase_voltage_v`
|
||||||
|
END AS `derived_voltage`
|
||||||
|
FROM `circuits` AS `c`
|
||||||
|
INNER JOIN `circuit_lists` AS `cl`
|
||||||
|
ON `cl`.`id` = `c`.`circuit_list_id`
|
||||||
|
INNER JOIN `projects` AS `p`
|
||||||
|
ON `p`.`id` = `cl`.`project_id`
|
||||||
|
INNER JOIN `circuit_sections` AS `cs`
|
||||||
|
ON `cs`.`id` = `c`.`section_id`
|
||||||
|
)
|
||||||
|
UPDATE `circuits`
|
||||||
|
SET `voltage` = (
|
||||||
|
SELECT `derived_voltage`
|
||||||
|
FROM `derived_circuit_voltages`
|
||||||
|
WHERE `circuit_id` = `circuits`.`id`
|
||||||
|
)
|
||||||
|
WHERE `id` IN (
|
||||||
|
SELECT `circuit_id`
|
||||||
|
FROM `derived_circuit_voltages`
|
||||||
|
);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
UPDATE `circuit_device_rows`
|
||||||
|
SET `phase_type` = 'single_phase'
|
||||||
|
WHERE `phase_type` IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
|
UPDATE `global_devices`
|
||||||
|
SET `phase_count` = 1
|
||||||
|
WHERE `phase_count` IS NULL OR `phase_count` NOT IN (1, 3);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `distribution_boards` ADD `simultaneity_factor` real DEFAULT 1 NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,62 @@
|
|||||||
"when": 1785014383032,
|
"when": 1785014383032,
|
||||||
"tag": "0015_modern_madame_masque",
|
"tag": "0015_modern_madame_masque",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785254079435,
|
||||||
|
"tag": "0016_dashing_darkstar",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785307189539,
|
||||||
|
"tag": "0017_vengeful_romulus",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785308458789,
|
||||||
|
"tag": "0018_fancy_argent",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785310907349,
|
||||||
|
"tag": "0019_normalize_project_voltages",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 20,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785314248007,
|
||||||
|
"tag": "0020_normalize_phase_types",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 21,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785314558555,
|
||||||
|
"tag": "0021_fill_missing_phase_types",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 22,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785314626476,
|
||||||
|
"tag": "0022_default_remaining_phase_types",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 23,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1785343645615,
|
||||||
|
"tag": "0023_cheerful_night_thrasher",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -20,11 +20,14 @@ import { circuitLists } from "../schema/circuit-lists.js";
|
|||||||
import { circuitSections } from "../schema/circuit-sections.js";
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
|
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
|
|
||||||
interface PersistedMoveRow {
|
interface PersistedMoveRow {
|
||||||
id: string;
|
id: string;
|
||||||
circuitId: string;
|
circuitId: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
phaseType: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CircuitDeviceRowMoveProjectCommandRepository
|
export class CircuitDeviceRowMoveProjectCommandRepository
|
||||||
@@ -85,6 +88,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
|||||||
);
|
);
|
||||||
this.applyMoves(database, command.payload.moves);
|
this.applyMoves(database, command.payload.moves);
|
||||||
this.updateReserveStates(database, circuitIds);
|
this.updateReserveStates(database, circuitIds);
|
||||||
|
this.updateVoltages(database, projectId, circuitIds);
|
||||||
return inverse;
|
return inverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +98,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
|||||||
const { targetCircuit, targetCircuitAction, moves } =
|
const { targetCircuit, targetCircuitAction, moves } =
|
||||||
command.payload;
|
command.payload;
|
||||||
const rowsById = this.loadExpectedRows(database, moves);
|
const rowsById = this.loadExpectedRows(database, moves);
|
||||||
this.assertCircuitLocation(database, projectId, targetCircuit);
|
const appliedTargetCircuit = {
|
||||||
|
...targetCircuit,
|
||||||
|
voltage: resolveCircuitVoltage(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
targetCircuit.sectionId,
|
||||||
|
moves.map((move) => rowsById.get(move.rowId)?.phaseType)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
this.assertCircuitLocation(database, projectId, appliedTargetCircuit);
|
||||||
|
|
||||||
if (targetCircuitAction === "create") {
|
if (targetCircuitAction === "create") {
|
||||||
const sourceCircuitIds = [
|
const sourceCircuitIds = [
|
||||||
@@ -106,13 +119,13 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
|||||||
sourceCircuitIds,
|
sourceCircuitIds,
|
||||||
targetCircuit.circuitListId
|
targetCircuit.circuitListId
|
||||||
);
|
);
|
||||||
this.assertTargetCircuitAvailable(database, targetCircuit);
|
this.assertTargetCircuitAvailable(database, appliedTargetCircuit);
|
||||||
this.insertTargetCircuit(database, targetCircuit);
|
this.insertTargetCircuit(database, appliedTargetCircuit);
|
||||||
|
|
||||||
const inverse =
|
const inverse =
|
||||||
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
"delete",
|
"delete",
|
||||||
targetCircuit,
|
appliedTargetCircuit,
|
||||||
this.reverseMoves(moves, rowsById)
|
this.reverseMoves(moves, rowsById)
|
||||||
);
|
);
|
||||||
this.applyMoves(database, moves);
|
this.applyMoves(database, moves);
|
||||||
@@ -120,12 +133,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
|||||||
...sourceCircuitIds,
|
...sourceCircuitIds,
|
||||||
targetCircuit.id,
|
targetCircuit.id,
|
||||||
]);
|
]);
|
||||||
|
this.updateVoltages(database, projectId, [
|
||||||
|
...sourceCircuitIds,
|
||||||
|
targetCircuit.id,
|
||||||
|
]);
|
||||||
return inverse;
|
return inverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.assertTargetCircuitUnchanged(
|
this.assertTargetCircuitUnchanged(
|
||||||
database,
|
database,
|
||||||
targetCircuit,
|
appliedTargetCircuit,
|
||||||
moves.map((move) => move.rowId)
|
moves.map((move) => move.rowId)
|
||||||
);
|
);
|
||||||
const destinationCircuitIds = [
|
const destinationCircuitIds = [
|
||||||
@@ -140,7 +157,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
|||||||
const inverse =
|
const inverse =
|
||||||
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
createCircuitDeviceRowMoveWithNewCircuitProjectCommand(
|
||||||
"create",
|
"create",
|
||||||
targetCircuit,
|
appliedTargetCircuit,
|
||||||
this.reverseMoves(moves, rowsById)
|
this.reverseMoves(moves, rowsById)
|
||||||
);
|
);
|
||||||
this.applyMoves(database, moves);
|
this.applyMoves(database, moves);
|
||||||
@@ -148,6 +165,11 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
|||||||
targetCircuit.id,
|
targetCircuit.id,
|
||||||
...destinationCircuitIds,
|
...destinationCircuitIds,
|
||||||
]);
|
]);
|
||||||
|
this.updateVoltages(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
destinationCircuitIds
|
||||||
|
);
|
||||||
const deleted = database
|
const deleted = database
|
||||||
.delete(circuits)
|
.delete(circuits)
|
||||||
.where(
|
.where(
|
||||||
@@ -169,6 +191,16 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
|||||||
return inverse;
|
return inverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateVoltages(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
circuitIds: string[]
|
||||||
|
) {
|
||||||
|
for (const circuitId of new Set(circuitIds)) {
|
||||||
|
updateDerivedCircuitVoltage(database, projectId, circuitId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private loadExpectedRows(
|
private loadExpectedRows(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
moves: CircuitDeviceRowMoveAssignment[]
|
moves: CircuitDeviceRowMoveAssignment[]
|
||||||
@@ -179,6 +211,7 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
|||||||
id: circuitDeviceRows.id,
|
id: circuitDeviceRows.id,
|
||||||
circuitId: circuitDeviceRows.circuitId,
|
circuitId: circuitDeviceRows.circuitId,
|
||||||
sortOrder: circuitDeviceRows.sortOrder,
|
sortOrder: circuitDeviceRows.sortOrder,
|
||||||
|
phaseType: circuitDeviceRows.phaseType,
|
||||||
})
|
})
|
||||||
.from(circuitDeviceRows)
|
.from(circuitDeviceRows)
|
||||||
.where(inArray(circuitDeviceRows.id, rowIds))
|
.where(inArray(circuitDeviceRows.id, rowIds))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
ExecuteCircuitDeviceRowUpdateCommandInput,
|
ExecuteCircuitDeviceRowUpdateCommandInput,
|
||||||
} from "../../domain/ports/circuit-device-row-project-command.store.js";
|
} from "../../domain/ports/circuit-device-row-project-command.store.js";
|
||||||
import { deriveOverriddenFieldsForLocalEdit } from "../../domain/services/project-device-overrides.js";
|
import { deriveOverriddenFieldsForLocalEdit } from "../../domain/services/project-device-overrides.js";
|
||||||
|
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
type CircuitDeviceRowPatchInput,
|
type CircuitDeviceRowPatchInput,
|
||||||
} from "./circuit-device-row.persistence.js";
|
} from "./circuit-device-row.persistence.js";
|
||||||
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
|
||||||
|
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
|
|
||||||
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
||||||
|
|
||||||
@@ -80,6 +82,13 @@ export class CircuitDeviceRowProjectCommandRepository
|
|||||||
change.value,
|
change.value,
|
||||||
])
|
])
|
||||||
) as CircuitDeviceRowPatchInput;
|
) as CircuitDeviceRowPatchInput;
|
||||||
|
if (
|
||||||
|
input.source === "user" &&
|
||||||
|
patch.phaseType !== undefined &&
|
||||||
|
!isElectricalPhaseType(patch.phaseType)
|
||||||
|
) {
|
||||||
|
throw new Error("Device-row phase type is invalid.");
|
||||||
|
}
|
||||||
this.assertLinkedProjectDevice(tx, input.projectId, patch);
|
this.assertLinkedProjectDevice(tx, input.projectId, patch);
|
||||||
this.assertRoom(tx, input.projectId, patch);
|
this.assertRoom(tx, input.projectId, patch);
|
||||||
|
|
||||||
@@ -117,6 +126,13 @@ export class CircuitDeviceRowProjectCommandRepository
|
|||||||
if (update.changes !== 1) {
|
if (update.changes !== 1) {
|
||||||
throw new Error("Circuit device row changed before command execution.");
|
throw new Error("Circuit device row changed before command execution.");
|
||||||
}
|
}
|
||||||
|
if (patch.phaseType !== undefined) {
|
||||||
|
updateDerivedCircuitVoltage(
|
||||||
|
tx,
|
||||||
|
input.projectId,
|
||||||
|
current.circuitId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
forward: appliedForward,
|
forward: appliedForward,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
ExecuteCircuitDeviceRowStructureCommandInput,
|
ExecuteCircuitDeviceRowStructureCommandInput,
|
||||||
} from "../../domain/ports/circuit-device-row-structure-project-command.store.js";
|
} from "../../domain/ports/circuit-device-row-structure-project-command.store.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
toCircuitDeviceRowSnapshot,
|
toCircuitDeviceRowSnapshot,
|
||||||
} from "./circuit-device-row-structure.persistence.js";
|
} from "./circuit-device-row-structure.persistence.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
|
|
||||||
export class CircuitDeviceRowStructureProjectCommandRepository
|
export class CircuitDeviceRowStructureProjectCommandRepository
|
||||||
implements CircuitDeviceRowStructureProjectCommandStore
|
implements CircuitDeviceRowStructureProjectCommandStore
|
||||||
@@ -80,6 +82,9 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
|||||||
"User commands cannot assign legacy consumer ids."
|
"User commands cannot assign legacy consumer ids."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
|
||||||
|
throw new Error("Device-row phase type is invalid.");
|
||||||
|
}
|
||||||
this.assertCircuitInProject(database, projectId, row.circuitId);
|
this.assertCircuitInProject(database, projectId, row.circuitId);
|
||||||
assertCircuitDeviceRowReferencesInProject(database, projectId, row);
|
assertCircuitDeviceRowReferencesInProject(database, projectId, row);
|
||||||
|
|
||||||
@@ -101,6 +106,7 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
|||||||
if (circuitUpdate.changes !== 1) {
|
if (circuitUpdate.changes !== 1) {
|
||||||
throw new Error("Circuit changed before device-row insertion.");
|
throw new Error("Circuit changed before device-row insertion.");
|
||||||
}
|
}
|
||||||
|
updateDerivedCircuitVoltage(database, projectId, row.circuitId);
|
||||||
|
|
||||||
return createCircuitDeviceRowDeleteProjectCommand(
|
return createCircuitDeviceRowDeleteProjectCommand(
|
||||||
row.id,
|
row.id,
|
||||||
@@ -153,6 +159,11 @@ export class CircuitDeviceRowStructureProjectCommandRepository
|
|||||||
if (circuitUpdate.changes !== 1) {
|
if (circuitUpdate.changes !== 1) {
|
||||||
throw new Error("Circuit changed before device-row deletion.");
|
throw new Error("Circuit changed before device-row deletion.");
|
||||||
}
|
}
|
||||||
|
updateDerivedCircuitVoltage(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
expectedCircuitId
|
||||||
|
);
|
||||||
|
|
||||||
return createCircuitDeviceRowInsertProjectCommand(
|
return createCircuitDeviceRowInsertProjectCommand(
|
||||||
toCircuitDeviceRowSnapshot(row)
|
toCircuitDeviceRowSnapshot(row)
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ import {
|
|||||||
toCircuitPatchValues,
|
toCircuitPatchValues,
|
||||||
type CircuitPatchPersistenceInput,
|
type CircuitPatchPersistenceInput,
|
||||||
} from "./circuit.persistence.js";
|
} from "./circuit.persistence.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
|
||||||
|
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
|
||||||
type CircuitRow = typeof circuits.$inferSelect;
|
type CircuitRow = typeof circuits.$inferSelect;
|
||||||
|
|
||||||
@@ -30,7 +32,7 @@ export class CircuitProjectCommandRepository
|
|||||||
executeUpdate(input: ExecuteCircuitUpdateCommandInput) {
|
executeUpdate(input: ExecuteCircuitUpdateCommandInput) {
|
||||||
assertCircuitUpdateProjectCommand(input.command);
|
assertCircuitUpdateProjectCommand(input.command);
|
||||||
|
|
||||||
return executeProjectCommandTransaction(
|
return executeProjectCommandTransactionWithAppliedForward(
|
||||||
this.database,
|
this.database,
|
||||||
input,
|
input,
|
||||||
(tx) => this.applyCommand(tx, input)
|
(tx) => this.applyCommand(tx, input)
|
||||||
@@ -71,14 +73,44 @@ export class CircuitProjectCommandRepository
|
|||||||
])
|
])
|
||||||
) as CircuitPatchPersistenceInput;
|
) as CircuitPatchPersistenceInput;
|
||||||
this.assertSectionInCircuitList(tx, current, patch.sectionId);
|
this.assertSectionInCircuitList(tx, current, patch.sectionId);
|
||||||
|
if (input.source === "user") {
|
||||||
|
if (
|
||||||
|
input.command.payload.changes.every(
|
||||||
|
(change) => change.field === "voltage"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("Circuit voltage is derived and cannot be edited.");
|
||||||
|
}
|
||||||
|
const devicePhaseTypes = tx
|
||||||
|
.select({ phaseType: circuitDeviceRows.phaseType })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.circuitId, current.id))
|
||||||
|
.all()
|
||||||
|
.map((row) => row.phaseType);
|
||||||
|
const derivedVoltage = resolveCircuitVoltage(
|
||||||
|
tx,
|
||||||
|
input.projectId,
|
||||||
|
patch.sectionId ?? current.sectionId,
|
||||||
|
devicePhaseTypes
|
||||||
|
);
|
||||||
|
if (derivedVoltage === current.voltage) {
|
||||||
|
delete patch.voltage;
|
||||||
|
} else {
|
||||||
|
patch.voltage = derivedVoltage;
|
||||||
|
}
|
||||||
|
}
|
||||||
this.assertUniqueEquipmentIdentifier(
|
this.assertUniqueEquipmentIdentifier(
|
||||||
tx,
|
tx,
|
||||||
current,
|
current,
|
||||||
patch.equipmentIdentifier
|
patch.equipmentIdentifier
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const appliedForward = createCircuitUpdateProjectCommand(
|
||||||
|
current.id,
|
||||||
|
patch as CircuitUpdatePatch
|
||||||
|
);
|
||||||
const inversePatch = Object.fromEntries(
|
const inversePatch = Object.fromEntries(
|
||||||
input.command.payload.changes.map((change) => [
|
appliedForward.payload.changes.map((change) => [
|
||||||
change.field,
|
change.field,
|
||||||
getCircuitFieldValue(current, change.field),
|
getCircuitFieldValue(current, change.field),
|
||||||
])
|
])
|
||||||
@@ -97,7 +129,7 @@ export class CircuitProjectCommandRepository
|
|||||||
throw new Error("Circuit changed before command execution.");
|
throw new Error("Circuit changed before command execution.");
|
||||||
}
|
}
|
||||||
|
|
||||||
return inverse;
|
return { forward: appliedForward, inverse };
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertSectionInCircuitList(
|
private assertSectionInCircuitList(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type {
|
|||||||
CircuitStructureProjectCommandStore,
|
CircuitStructureProjectCommandStore,
|
||||||
ExecuteCircuitStructureCommandInput,
|
ExecuteCircuitStructureCommandInput,
|
||||||
} from "../../domain/ports/circuit-structure-project-command.store.js";
|
} from "../../domain/ports/circuit-structure-project-command.store.js";
|
||||||
|
import { isElectricalPhaseType } from "../../domain/services/project-voltage.service.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { circuitLists } from "../schema/circuit-lists.js";
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
toCircuitDeviceRowSnapshot,
|
toCircuitDeviceRowSnapshot,
|
||||||
} from "./circuit-device-row-structure.persistence.js";
|
} from "./circuit-device-row-structure.persistence.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { resolveCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
|
|
||||||
export class CircuitStructureProjectCommandRepository
|
export class CircuitStructureProjectCommandRepository
|
||||||
implements CircuitStructureProjectCommandStore
|
implements CircuitStructureProjectCommandStore
|
||||||
@@ -77,6 +79,19 @@ export class CircuitStructureProjectCommandRepository
|
|||||||
snapshot: CircuitSnapshot
|
snapshot: CircuitSnapshot
|
||||||
) {
|
) {
|
||||||
this.assertCircuitLocation(database, projectId, snapshot);
|
this.assertCircuitLocation(database, projectId, snapshot);
|
||||||
|
if (source === "user") {
|
||||||
|
const derivedVoltage = resolveCircuitVoltage(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
snapshot.sectionId,
|
||||||
|
snapshot.deviceRows.map((row) => row.phaseType)
|
||||||
|
);
|
||||||
|
if (snapshot.voltage !== derivedVoltage) {
|
||||||
|
throw new Error(
|
||||||
|
"Circuit voltage must match the project phase voltage."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const existingCircuit = database
|
const existingCircuit = database
|
||||||
.select({ id: circuits.id })
|
.select({ id: circuits.id })
|
||||||
@@ -122,6 +137,9 @@ export class CircuitStructureProjectCommandRepository
|
|||||||
"User commands cannot assign legacy consumer ids."
|
"User commands cannot assign legacy consumer ids."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (source === "user" && !isElectricalPhaseType(row.phaseType)) {
|
||||||
|
throw new Error("Device-row phase type is invalid.");
|
||||||
|
}
|
||||||
assertCircuitDeviceRowReferencesInProject(
|
assertCircuitDeviceRowReferencesInProject(
|
||||||
database,
|
database,
|
||||||
projectId,
|
projectId,
|
||||||
|
|||||||
@@ -6,9 +6,20 @@ import {
|
|||||||
createDistributionBoardInsertProjectCommand,
|
createDistributionBoardInsertProjectCommand,
|
||||||
distributionBoardDeleteCommandType,
|
distributionBoardDeleteCommandType,
|
||||||
distributionBoardInsertCommandType,
|
distributionBoardInsertCommandType,
|
||||||
|
normalizeDistributionBoardStructureProjectCommand,
|
||||||
type DistributionBoardStructureProjectCommand,
|
type DistributionBoardStructureProjectCommand,
|
||||||
type DistributionBoardStructureSnapshot,
|
type DistributionBoardStructureSnapshot,
|
||||||
|
legacyDistributionBoardStructureCommandSchemaVersion,
|
||||||
} from "../../domain/models/distribution-board-structure-project-command.model.js";
|
} from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertDistributionBoardUpdateProjectCommand,
|
||||||
|
createDistributionBoardUpdateProjectCommand,
|
||||||
|
legacyDistributionBoardUpdateCommandSchemaVersion,
|
||||||
|
type DistributionBoardUpdateField,
|
||||||
|
type DistributionBoardUpdatePatch,
|
||||||
|
type DistributionBoardUpdateProjectCommand,
|
||||||
|
type DistributionBoardUpdateValues,
|
||||||
|
} from "../../domain/models/distribution-board-project-command.model.js";
|
||||||
import type {
|
import type {
|
||||||
DistributionBoardStructureProjectCommandStore,
|
DistributionBoardStructureProjectCommandStore,
|
||||||
ExecuteDistributionBoardStructureCommandInput,
|
ExecuteDistributionBoardStructureCommandInput,
|
||||||
@@ -18,6 +29,7 @@ import { circuitLists } from "../schema/circuit-lists.js";
|
|||||||
import { circuitSections } from "../schema/circuit-sections.js";
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import { floors } from "../schema/floors.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
|
||||||
@@ -35,26 +47,52 @@ export class DistributionBoardStructureProjectCommandRepository
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
executeUpdate(
|
||||||
|
input: Omit<
|
||||||
|
ExecuteDistributionBoardStructureCommandInput,
|
||||||
|
"command"
|
||||||
|
> & {
|
||||||
|
command: DistributionBoardUpdateProjectCommand;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
assertDistributionBoardUpdateProjectCommand(input.command);
|
||||||
|
return executeProjectCommandTransaction(
|
||||||
|
this.database,
|
||||||
|
input,
|
||||||
|
(tx) => this.update(tx, input)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private applyCommand(
|
private applyCommand(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
projectId: string,
|
projectId: string,
|
||||||
command: DistributionBoardStructureProjectCommand
|
command: DistributionBoardStructureProjectCommand
|
||||||
): DistributionBoardStructureProjectCommand {
|
): DistributionBoardStructureProjectCommand {
|
||||||
if (command.type === distributionBoardInsertCommandType) {
|
const normalized =
|
||||||
assertDistributionBoardInsertProjectCommand(command);
|
normalizeDistributionBoardStructureProjectCommand(command);
|
||||||
return this.insert(
|
if (normalized.type === distributionBoardInsertCommandType) {
|
||||||
|
assertDistributionBoardInsertProjectCommand(normalized);
|
||||||
|
const inverse = this.insert(
|
||||||
database,
|
database,
|
||||||
projectId,
|
projectId,
|
||||||
command.payload.structure
|
normalized.payload.structure
|
||||||
);
|
);
|
||||||
|
return command.schemaVersion ===
|
||||||
|
legacyDistributionBoardStructureCommandSchemaVersion
|
||||||
|
? toLegacyStructureCommand(inverse)
|
||||||
|
: inverse;
|
||||||
}
|
}
|
||||||
if (command.type === distributionBoardDeleteCommandType) {
|
if (normalized.type === distributionBoardDeleteCommandType) {
|
||||||
assertDistributionBoardDeleteProjectCommand(command);
|
assertDistributionBoardDeleteProjectCommand(normalized);
|
||||||
return this.delete(
|
const inverse = this.delete(
|
||||||
database,
|
database,
|
||||||
projectId,
|
projectId,
|
||||||
command.payload.structure
|
normalized.payload.structure
|
||||||
);
|
);
|
||||||
|
return command.schemaVersion ===
|
||||||
|
legacyDistributionBoardStructureCommandSchemaVersion
|
||||||
|
? toLegacyStructureCommand(inverse)
|
||||||
|
: inverse;
|
||||||
}
|
}
|
||||||
throw new Error("Unsupported distribution-board structure command.");
|
throw new Error("Unsupported distribution-board structure command.");
|
||||||
}
|
}
|
||||||
@@ -73,13 +111,33 @@ export class DistributionBoardStructureProjectCommandRepository
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const project = database
|
const project = database
|
||||||
.select({ id: projects.id })
|
.select({
|
||||||
|
id: projects.id,
|
||||||
|
enabledSupplyTypes:
|
||||||
|
projects.enabledDistributionBoardSupplyTypes,
|
||||||
|
})
|
||||||
.from(projects)
|
.from(projects)
|
||||||
.where(eq(projects.id, projectId))
|
.where(eq(projects.id, projectId))
|
||||||
.get();
|
.get();
|
||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error("Project not found.");
|
throw new Error("Project not found.");
|
||||||
}
|
}
|
||||||
|
assertSupplyTypeEnabled(
|
||||||
|
project.enabledSupplyTypes,
|
||||||
|
structure.distributionBoard.supplyType
|
||||||
|
);
|
||||||
|
if (structure.distributionBoard.floorId !== null) {
|
||||||
|
const floor = database
|
||||||
|
.select({ projectId: floors.projectId })
|
||||||
|
.from(floors)
|
||||||
|
.where(eq(floors.id, structure.distributionBoard.floorId))
|
||||||
|
.get();
|
||||||
|
if (!floor || floor.projectId !== projectId) {
|
||||||
|
throw new Error(
|
||||||
|
"Distribution-board floor does not belong to project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const existingBoard = database
|
const existingBoard = database
|
||||||
.select({ id: distributionBoards.id })
|
.select({ id: distributionBoards.id })
|
||||||
.from(distributionBoards)
|
.from(distributionBoards)
|
||||||
@@ -179,6 +237,148 @@ export class DistributionBoardStructureProjectCommandRepository
|
|||||||
}
|
}
|
||||||
return createDistributionBoardInsertProjectCommand(structure);
|
return createDistributionBoardInsertProjectCommand(structure);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private update(
|
||||||
|
database: AppDatabase,
|
||||||
|
input: Omit<
|
||||||
|
ExecuteDistributionBoardStructureCommandInput,
|
||||||
|
"command"
|
||||||
|
> & {
|
||||||
|
command: DistributionBoardUpdateProjectCommand;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const current = database
|
||||||
|
.select()
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
distributionBoards.id,
|
||||||
|
input.command.payload.distributionBoardId
|
||||||
|
),
|
||||||
|
eq(distributionBoards.projectId, input.projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!current) {
|
||||||
|
throw new Error("Distribution board does not belong to project.");
|
||||||
|
}
|
||||||
|
const project = database
|
||||||
|
.select({
|
||||||
|
enabledSupplyTypes:
|
||||||
|
projects.enabledDistributionBoardSupplyTypes,
|
||||||
|
})
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, input.projectId))
|
||||||
|
.get();
|
||||||
|
if (!project) {
|
||||||
|
throw new Error("Project not found.");
|
||||||
|
}
|
||||||
|
const patch = Object.fromEntries(
|
||||||
|
input.command.payload.changes.map((change) => [
|
||||||
|
change.field,
|
||||||
|
change.value,
|
||||||
|
])
|
||||||
|
) as DistributionBoardUpdatePatch;
|
||||||
|
if (patch.floorId !== undefined && patch.floorId !== null) {
|
||||||
|
const floor = database
|
||||||
|
.select({ projectId: floors.projectId })
|
||||||
|
.from(floors)
|
||||||
|
.where(eq(floors.id, patch.floorId))
|
||||||
|
.get();
|
||||||
|
if (!floor || floor.projectId !== input.projectId) {
|
||||||
|
throw new Error(
|
||||||
|
"Distribution-board floor does not belong to project."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patch.supplyType !== undefined) {
|
||||||
|
assertSupplyTypeEnabled(
|
||||||
|
project.enabledSupplyTypes,
|
||||||
|
patch.supplyType
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const inversePatch = Object.fromEntries(
|
||||||
|
input.command.payload.changes.map((change) => [
|
||||||
|
change.field,
|
||||||
|
getDistributionBoardFieldValue(current, change.field),
|
||||||
|
])
|
||||||
|
) as DistributionBoardUpdatePatch;
|
||||||
|
const currentInverse = createDistributionBoardUpdateProjectCommand(
|
||||||
|
current.id,
|
||||||
|
inversePatch
|
||||||
|
);
|
||||||
|
const inverse =
|
||||||
|
input.command.schemaVersion ===
|
||||||
|
legacyDistributionBoardUpdateCommandSchemaVersion
|
||||||
|
? ({
|
||||||
|
...currentInverse,
|
||||||
|
schemaVersion:
|
||||||
|
legacyDistributionBoardUpdateCommandSchemaVersion,
|
||||||
|
} as DistributionBoardUpdateProjectCommand)
|
||||||
|
: currentInverse;
|
||||||
|
const updated = database
|
||||||
|
.update(distributionBoards)
|
||||||
|
.set(patch)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(distributionBoards.id, current.id),
|
||||||
|
eq(distributionBoards.projectId, input.projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
if (updated.changes !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
"Distribution board changed before command execution."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return inverse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSupplyTypeEnabled(
|
||||||
|
enabledSupplyTypes: (typeof projects.$inferSelect)["enabledDistributionBoardSupplyTypes"],
|
||||||
|
supplyType: (typeof distributionBoards.$inferSelect)["supplyType"]
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
supplyType !== null &&
|
||||||
|
!enabledSupplyTypes.includes(supplyType)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Die Netzart ${supplyType} ist in den Projekteinstellungen nicht aktiviert.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLegacyStructureCommand(
|
||||||
|
command:
|
||||||
|
| ReturnType<typeof createDistributionBoardInsertProjectCommand>
|
||||||
|
| ReturnType<typeof createDistributionBoardDeleteProjectCommand>
|
||||||
|
): DistributionBoardStructureProjectCommand {
|
||||||
|
const {
|
||||||
|
floorId: _floorId,
|
||||||
|
supplyType: _supplyType,
|
||||||
|
...distributionBoard
|
||||||
|
} = command.payload.structure.distributionBoard;
|
||||||
|
return {
|
||||||
|
schemaVersion: legacyDistributionBoardStructureCommandSchemaVersion,
|
||||||
|
type: command.type,
|
||||||
|
payload: {
|
||||||
|
structure: {
|
||||||
|
...command.payload.structure,
|
||||||
|
distributionBoard,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as DistributionBoardStructureProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDistributionBoardFieldValue<
|
||||||
|
TField extends DistributionBoardUpdateField,
|
||||||
|
>(
|
||||||
|
distributionBoard: typeof distributionBoards.$inferSelect,
|
||||||
|
field: TField
|
||||||
|
): DistributionBoardUpdateValues[TField] {
|
||||||
|
return distributionBoard[field] as DistributionBoardUpdateValues[TField];
|
||||||
}
|
}
|
||||||
|
|
||||||
function structureMatches(
|
function structureMatches(
|
||||||
@@ -189,8 +389,16 @@ function structureMatches(
|
|||||||
sections: Array<typeof circuitSections.$inferSelect>;
|
sections: Array<typeof circuitSections.$inferSelect>;
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
const {
|
||||||
|
simultaneityFactor,
|
||||||
|
...actualDistributionBoard
|
||||||
|
} = actual.board;
|
||||||
if (
|
if (
|
||||||
!sameRecord(expected.distributionBoard, actual.board) ||
|
simultaneityFactor !== 1 ||
|
||||||
|
!sameRecord(
|
||||||
|
expected.distributionBoard,
|
||||||
|
actualDistributionBoard
|
||||||
|
) ||
|
||||||
!sameRecord(expected.circuitList, actual.list) ||
|
!sameRecord(expected.circuitList, actual.list) ||
|
||||||
expected.sections.length !== actual.sections.length
|
expected.sections.length !== actual.sections.length
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { distributionBoards } from "../schema/distribution-boards.js";
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
|
||||||
@@ -12,4 +12,19 @@ export class DistributionBoardRepository {
|
|||||||
.where(eq(distributionBoards.projectId, projectId));
|
.where(eq(distributionBoards.projectId, projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findById(projectId: string, distributionBoardId: string) {
|
||||||
|
return (
|
||||||
|
this.database
|
||||||
|
.select()
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(distributionBoards.projectId, projectId),
|
||||||
|
eq(distributionBoards.id, distributionBoardId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get() ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export class GlobalDeviceRepository {
|
|||||||
quantity: input.quantity,
|
quantity: input.quantity,
|
||||||
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
|
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
|
||||||
demandFactor: input.demandFactor,
|
demandFactor: input.demandFactor,
|
||||||
voltageV: input.voltageV ?? null,
|
voltageV: null,
|
||||||
phaseCount: input.phaseCount ?? null,
|
phaseCount: input.phaseCount,
|
||||||
powerFactor: input.powerFactor ?? null,
|
powerFactor: input.powerFactor ?? null,
|
||||||
note: input.note ?? null,
|
note: input.note ?? null,
|
||||||
});
|
});
|
||||||
@@ -45,8 +45,8 @@ export class GlobalDeviceRepository {
|
|||||||
quantity: input.quantity,
|
quantity: input.quantity,
|
||||||
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
|
installedPowerPerUnitKw: input.installedPowerPerUnitKw,
|
||||||
demandFactor: input.demandFactor,
|
demandFactor: input.demandFactor,
|
||||||
voltageV: input.voltageV ?? null,
|
voltageV: null,
|
||||||
phaseCount: input.phaseCount ?? null,
|
phaseCount: input.phaseCount,
|
||||||
powerFactor: input.powerFactor ?? null,
|
powerFactor: input.powerFactor ?? null,
|
||||||
note: input.note ?? null,
|
note: input.note ?? null,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import type {
|
|||||||
} from "../../domain/ports/project-device-project-command.store.js";
|
} from "../../domain/ports/project-device-project-command.store.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransactionWithAppliedForward } from "./project-command-transaction.persistence.js";
|
||||||
|
import { resolveProjectDeviceVoltage } from "./project-voltage.persistence.js";
|
||||||
|
|
||||||
type ProjectDeviceRow = typeof projectDevices.$inferSelect;
|
type ProjectDeviceRow = typeof projectDevices.$inferSelect;
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ export class ProjectDeviceProjectCommandRepository
|
|||||||
executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) {
|
executeUpdate(input: ExecuteProjectDeviceUpdateCommandInput) {
|
||||||
assertProjectDeviceUpdateProjectCommand(input.command);
|
assertProjectDeviceUpdateProjectCommand(input.command);
|
||||||
|
|
||||||
return executeProjectCommandTransaction(
|
return executeProjectCommandTransactionWithAppliedForward(
|
||||||
this.database,
|
this.database,
|
||||||
input,
|
input,
|
||||||
(tx) => this.applyCommand(tx, input)
|
(tx) => this.applyCommand(tx, input)
|
||||||
@@ -60,8 +61,40 @@ export class ProjectDeviceProjectCommandRepository
|
|||||||
change.value,
|
change.value,
|
||||||
])
|
])
|
||||||
) as ProjectDeviceUpdatePatch;
|
) as ProjectDeviceUpdatePatch;
|
||||||
|
if (input.source === "user") {
|
||||||
|
if (
|
||||||
|
input.command.payload.changes.every(
|
||||||
|
(change) => change.field === "voltageV"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device voltage is derived and cannot be edited."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const targetPhaseType = patch.phaseType ?? current.phaseType;
|
||||||
|
if (
|
||||||
|
targetPhaseType !== "single_phase" &&
|
||||||
|
targetPhaseType !== "three_phase"
|
||||||
|
) {
|
||||||
|
throw new Error("Project-device phase type is invalid.");
|
||||||
|
}
|
||||||
|
const derivedVoltage = resolveProjectDeviceVoltage(
|
||||||
|
tx,
|
||||||
|
input.projectId,
|
||||||
|
targetPhaseType
|
||||||
|
);
|
||||||
|
if (derivedVoltage === current.voltageV) {
|
||||||
|
delete patch.voltageV;
|
||||||
|
} else {
|
||||||
|
patch.voltageV = derivedVoltage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const appliedForward = createProjectDeviceUpdateProjectCommand(
|
||||||
|
current.id,
|
||||||
|
patch
|
||||||
|
);
|
||||||
const inversePatch = Object.fromEntries(
|
const inversePatch = Object.fromEntries(
|
||||||
input.command.payload.changes.map((change) => [
|
appliedForward.payload.changes.map((change) => [
|
||||||
change.field,
|
change.field,
|
||||||
getProjectDeviceFieldValue(current, change.field),
|
getProjectDeviceFieldValue(current, change.field),
|
||||||
])
|
])
|
||||||
@@ -87,7 +120,7 @@ export class ProjectDeviceProjectCommandRepository
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return inverse;
|
return { forward: appliedForward, inverse };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { circuitLists } from "../schema/circuit-lists.js";
|
|||||||
import { circuits } from "../schema/circuits.js";
|
import { circuits } from "../schema/circuits.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { updateDerivedCircuitVoltage } from "./project-voltage.persistence.js";
|
||||||
|
|
||||||
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
type CircuitDeviceRow = typeof circuitDeviceRows.$inferSelect;
|
||||||
|
|
||||||
@@ -129,6 +130,25 @@ export class ProjectDeviceRowSyncProjectCommandRepository
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const affectedCircuitIds = new Set(
|
||||||
|
input.command.payload.rows
|
||||||
|
.filter(
|
||||||
|
(assignment) =>
|
||||||
|
assignment.expected.phaseType !==
|
||||||
|
assignment.target.phaseType
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(assignment) =>
|
||||||
|
persistedById.get(assignment.rowId)!.circuitId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (const circuitId of affectedCircuitIds) {
|
||||||
|
updateDerivedCircuitVoltage(
|
||||||
|
tx,
|
||||||
|
input.projectId,
|
||||||
|
circuitId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return inverse;
|
return inverse;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { projectDevices } from "../schema/project-devices.js";
|
|||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
|
import { toCircuitDeviceRowSnapshot } from "./circuit-device-row-structure.persistence.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { resolveProjectDeviceVoltage } from "./project-voltage.persistence.js";
|
||||||
|
|
||||||
const circuitDeviceRowSnapshotFields = [
|
const circuitDeviceRowSnapshotFields = [
|
||||||
"id",
|
"id",
|
||||||
@@ -125,6 +126,19 @@ export class ProjectDeviceStructureProjectCommandRepository
|
|||||||
if (!project) {
|
if (!project) {
|
||||||
throw new Error("Project does not exist.");
|
throw new Error("Project does not exist.");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
source === "user" &&
|
||||||
|
snapshot.voltageV !==
|
||||||
|
resolveProjectDeviceVoltage(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
snapshot.phaseType
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Project-device voltage must match the project phase voltage."
|
||||||
|
);
|
||||||
|
}
|
||||||
const existing = database
|
const existing = database
|
||||||
.select({ id: projectDevices.id })
|
.select({ id: projectDevices.id })
|
||||||
.from(projectDevices)
|
.from(projectDevices)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { and, asc, desc, eq, lt } from "drizzle-orm";
|
import { and, asc, desc, eq, inArray, lt } from "drizzle-orm";
|
||||||
import { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
|
import { deserializeProjectCommand } from "../../domain/models/project-command.model.js";
|
||||||
import type {
|
import type {
|
||||||
ProjectHistoryCommand,
|
ProjectHistoryCommand,
|
||||||
ProjectHistoryDirection,
|
ProjectHistoryDirection,
|
||||||
ListProjectRevisionsInput,
|
ListProjectRevisionsInput,
|
||||||
ProjectRevisionPage,
|
ProjectRevisionPage,
|
||||||
|
ProjectRevisionSummary,
|
||||||
ProjectHistoryState,
|
ProjectHistoryState,
|
||||||
ProjectHistoryStore,
|
ProjectHistoryStore,
|
||||||
} from "../../domain/ports/project-history.store.js";
|
} from "../../domain/ports/project-history.store.js";
|
||||||
@@ -118,6 +119,64 @@ export class ProjectHistoryRepository implements ProjectHistoryStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listRevisionsByNumbers(
|
||||||
|
projectId: string,
|
||||||
|
revisionNumbers: number[]
|
||||||
|
): ProjectRevisionSummary[] {
|
||||||
|
for (const revisionNumber of revisionNumbers) {
|
||||||
|
if (
|
||||||
|
!Number.isSafeInteger(revisionNumber) ||
|
||||||
|
revisionNumber < 0
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Project revision numbers must be non-negative integers."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const normalizedRevisionNumbers = [
|
||||||
|
...new Set(revisionNumbers),
|
||||||
|
].filter((revisionNumber) => revisionNumber > 0);
|
||||||
|
if (!normalizedRevisionNumbers.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.database
|
||||||
|
.select({
|
||||||
|
revisionId: projectRevisions.id,
|
||||||
|
changeSetId: projectChangeSets.id,
|
||||||
|
revisionNumber: projectRevisions.revisionNumber,
|
||||||
|
createdAtIso: projectRevisions.createdAtIso,
|
||||||
|
actorId: projectRevisions.actorId,
|
||||||
|
source: projectRevisions.source,
|
||||||
|
description: projectRevisions.description,
|
||||||
|
commandType: projectChangeSets.commandType,
|
||||||
|
payloadSchemaVersion: projectChangeSets.payloadSchemaVersion,
|
||||||
|
})
|
||||||
|
.from(projectRevisions)
|
||||||
|
.innerJoin(
|
||||||
|
projectChangeSets,
|
||||||
|
eq(
|
||||||
|
projectChangeSets.projectRevisionId,
|
||||||
|
projectRevisions.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(projectRevisions.projectId, projectId),
|
||||||
|
inArray(
|
||||||
|
projectRevisions.revisionNumber,
|
||||||
|
normalizedRevisionNumbers
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(desc(projectRevisions.revisionNumber))
|
||||||
|
.all()
|
||||||
|
.map((row) => ({
|
||||||
|
...row,
|
||||||
|
source: row.source as ProjectRevisionSource,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
getNextCommand(
|
getNextCommand(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
direction: ProjectHistoryDirection
|
direction: ProjectHistoryDirection
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type { AppDatabase } from "../database-context.js";
|
|||||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
import { consumers } from "../schema/consumers.js";
|
import { consumers } from "../schema/consumers.js";
|
||||||
import { floors } from "../schema/floors.js";
|
import { floors } from "../schema/floors.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
@@ -129,6 +130,17 @@ export class ProjectLocationStructureProjectCommandRepository
|
|||||||
"A project floor with assigned rooms cannot be removed by history."
|
"A project floor with assigned rooms cannot be removed by history."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const referencedDistributionBoard = database
|
||||||
|
.select({ id: distributionBoards.id })
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(eq(distributionBoards.floorId, expected.id))
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
if (referencedDistributionBoard) {
|
||||||
|
throw new Error(
|
||||||
|
"A project floor assigned to a distribution board cannot be removed by history."
|
||||||
|
);
|
||||||
|
}
|
||||||
const deleted = database
|
const deleted = database
|
||||||
.delete(floors)
|
.delete(floors)
|
||||||
.where(
|
.where(
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { and, eq, isNotNull } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
assertProjectSettingsUpdateProjectCommand,
|
assertProjectSettingsUpdateProjectCommand,
|
||||||
createProjectSettingsUpdateProjectCommand,
|
createProjectSettingsUpdateProjectCommand,
|
||||||
|
legacyProjectSettingsUpdateCommandSchemaVersion,
|
||||||
|
normalizeProjectSettingsValues,
|
||||||
|
previousProjectSettingsUpdateCommandSchemaVersion,
|
||||||
|
type ProjectSettingsValues,
|
||||||
} from "../../domain/models/project-settings-project-command.model.js";
|
} from "../../domain/models/project-settings-project-command.model.js";
|
||||||
import type {
|
import type {
|
||||||
ExecuteProjectSettingsUpdateCommandInput,
|
ExecuteProjectSettingsUpdateCommandInput,
|
||||||
@@ -9,7 +13,9 @@ import type {
|
|||||||
} from "../../domain/ports/project-settings-project-command.store.js";
|
} from "../../domain/ports/project-settings-project-command.store.js";
|
||||||
import type { AppDatabase } from "../database-context.js";
|
import type { AppDatabase } from "../database-context.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
|
import { updateAllDerivedProjectVoltages } from "./project-voltage.persistence.js";
|
||||||
|
|
||||||
export class ProjectSettingsProjectCommandRepository
|
export class ProjectSettingsProjectCommandRepository
|
||||||
implements ProjectSettingsProjectCommandStore
|
implements ProjectSettingsProjectCommandStore
|
||||||
@@ -37,28 +43,124 @@ export class ProjectSettingsProjectCommandRepository
|
|||||||
if (!current) {
|
if (!current) {
|
||||||
throw new Error("Project not found.");
|
throw new Error("Project not found.");
|
||||||
}
|
}
|
||||||
if (
|
const target = normalizeProjectSettingsValues(input.command, current);
|
||||||
current.singlePhaseVoltageV ===
|
if (projectSettingsEqual(current, target)) {
|
||||||
input.command.payload.singlePhaseVoltageV &&
|
|
||||||
current.threePhaseVoltageV ===
|
|
||||||
input.command.payload.threePhaseVoltageV
|
|
||||||
) {
|
|
||||||
throw new Error("Project settings did not change.");
|
throw new Error("Project settings did not change.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const inverse = createProjectSettingsUpdateProjectCommand({
|
const inverse =
|
||||||
|
input.command.schemaVersion ===
|
||||||
|
legacyProjectSettingsUpdateCommandSchemaVersion
|
||||||
|
? {
|
||||||
|
schemaVersion: legacyProjectSettingsUpdateCommandSchemaVersion,
|
||||||
|
type: input.command.type,
|
||||||
|
payload: {
|
||||||
singlePhaseVoltageV: current.singlePhaseVoltageV,
|
singlePhaseVoltageV: current.singlePhaseVoltageV,
|
||||||
threePhaseVoltageV: current.threePhaseVoltageV,
|
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: input.command.schemaVersion ===
|
||||||
|
previousProjectSettingsUpdateCommandSchemaVersion
|
||||||
|
? {
|
||||||
|
schemaVersion:
|
||||||
|
previousProjectSettingsUpdateCommandSchemaVersion,
|
||||||
|
type: input.command.type,
|
||||||
|
payload: {
|
||||||
|
name: current.name,
|
||||||
|
internalProjectNumber: current.internalProjectNumber,
|
||||||
|
externalProjectNumber: current.externalProjectNumber,
|
||||||
|
buildingOwner: current.buildingOwner,
|
||||||
|
description: current.description,
|
||||||
|
singlePhaseVoltageV: current.singlePhaseVoltageV,
|
||||||
|
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: createProjectSettingsUpdateProjectCommand({
|
||||||
|
name: current.name,
|
||||||
|
internalProjectNumber: current.internalProjectNumber,
|
||||||
|
externalProjectNumber: current.externalProjectNumber,
|
||||||
|
buildingOwner: current.buildingOwner,
|
||||||
|
description: current.description,
|
||||||
|
singlePhaseVoltageV: current.singlePhaseVoltageV,
|
||||||
|
threePhaseVoltageV: current.threePhaseVoltageV,
|
||||||
|
enabledDistributionBoardSupplyTypes:
|
||||||
|
current.enabledDistributionBoardSupplyTypes,
|
||||||
});
|
});
|
||||||
|
assertDisabledSupplyTypesAreUnused(tx, input.projectId, target);
|
||||||
const updated = tx
|
const updated = tx
|
||||||
.update(projects)
|
.update(projects)
|
||||||
.set(input.command.payload)
|
.set(target)
|
||||||
.where(eq(projects.id, input.projectId))
|
.where(eq(projects.id, input.projectId))
|
||||||
.run();
|
.run();
|
||||||
if (updated.changes !== 1) {
|
if (updated.changes !== 1) {
|
||||||
throw new Error("Project changed before settings update.");
|
throw new Error("Project changed before settings update.");
|
||||||
}
|
}
|
||||||
|
updateAllDerivedProjectVoltages(tx, input.projectId);
|
||||||
|
|
||||||
return inverse;
|
return inverse;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function projectSettingsEqual(
|
||||||
|
current: ProjectSettingsValues,
|
||||||
|
target: ProjectSettingsValues
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
current.name === target.name &&
|
||||||
|
current.internalProjectNumber === target.internalProjectNumber &&
|
||||||
|
current.externalProjectNumber === target.externalProjectNumber &&
|
||||||
|
current.buildingOwner === target.buildingOwner &&
|
||||||
|
current.description === target.description &&
|
||||||
|
current.singlePhaseVoltageV === target.singlePhaseVoltageV &&
|
||||||
|
current.threePhaseVoltageV === target.threePhaseVoltageV
|
||||||
|
&& sameSupplyTypes(
|
||||||
|
current.enabledDistributionBoardSupplyTypes,
|
||||||
|
target.enabledDistributionBoardSupplyTypes
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameSupplyTypes(
|
||||||
|
left: ProjectSettingsValues["enabledDistributionBoardSupplyTypes"],
|
||||||
|
right: ProjectSettingsValues["enabledDistributionBoardSupplyTypes"]
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
left.length === right.length &&
|
||||||
|
left.every((value, index) => value === right[index])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDisabledSupplyTypesAreUnused(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
target: ProjectSettingsValues
|
||||||
|
) {
|
||||||
|
const used = database
|
||||||
|
.select({ supplyType: distributionBoards.supplyType })
|
||||||
|
.from(distributionBoards)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(distributionBoards.projectId, projectId),
|
||||||
|
isNotNull(distributionBoards.supplyType)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
const disabledUsedTypes = [
|
||||||
|
...new Set(
|
||||||
|
used
|
||||||
|
.map((entry) => entry.supplyType)
|
||||||
|
.filter(
|
||||||
|
(supplyType) =>
|
||||||
|
supplyType !== null &&
|
||||||
|
!target.enabledDistributionBoardSupplyTypes.some(
|
||||||
|
(enabled) => enabled === supplyType
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (disabledUsedTypes.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Verwendete Netzarten können nicht deaktiviert werden: ${disabledUsedTypes.join(", ")}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,13 @@ import { ProjectRevisionConflictError } from "../../domain/errors/project-revisi
|
|||||||
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
|
import { ProjectSnapshotNameConflictError } from "../../domain/errors/project-snapshot-name-conflict.error.js";
|
||||||
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
||||||
import {
|
import {
|
||||||
|
distributionBoardProjectStateSnapshotSchemaVersion,
|
||||||
deserializeProjectStateSnapshot,
|
deserializeProjectStateSnapshot,
|
||||||
|
legacyProjectStateSnapshotSchemaVersion,
|
||||||
|
previousProjectStateSnapshotSchemaVersion,
|
||||||
projectStateSnapshotSchemaVersion,
|
projectStateSnapshotSchemaVersion,
|
||||||
|
supplyTypesProjectStateSnapshotSchemaVersion,
|
||||||
|
voltageProjectStateSnapshotSchemaVersion,
|
||||||
} from "../../domain/models/project-state-snapshot.model.js";
|
} from "../../domain/models/project-state-snapshot.model.js";
|
||||||
import type {
|
import type {
|
||||||
CreateNamedProjectSnapshotInput,
|
CreateNamedProjectSnapshotInput,
|
||||||
@@ -138,7 +143,17 @@ export class ProjectSnapshotRepository implements ProjectSnapshotStore {
|
|||||||
if (actualChecksum !== stored.payloadSha256) {
|
if (actualChecksum !== stored.payloadSha256) {
|
||||||
throw new Error("Project snapshot checksum verification failed.");
|
throw new Error("Project snapshot checksum verification failed.");
|
||||||
}
|
}
|
||||||
if (stored.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
if (
|
||||||
|
stored.schemaVersion !== legacyProjectStateSnapshotSchemaVersion &&
|
||||||
|
stored.schemaVersion !== previousProjectStateSnapshotSchemaVersion &&
|
||||||
|
stored.schemaVersion !==
|
||||||
|
distributionBoardProjectStateSnapshotSchemaVersion &&
|
||||||
|
stored.schemaVersion !==
|
||||||
|
supplyTypesProjectStateSnapshotSchemaVersion &&
|
||||||
|
stored.schemaVersion !==
|
||||||
|
voltageProjectStateSnapshotSchemaVersion &&
|
||||||
|
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);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
createProjectStateRestoreCommand,
|
createProjectStateRestoreCommand,
|
||||||
} from "../../domain/models/project-state-restore-command.model.js";
|
} from "../../domain/models/project-state-restore-command.model.js";
|
||||||
import {
|
import {
|
||||||
serializeProjectStateSnapshot,
|
parseProjectStateSnapshot,
|
||||||
type ProjectStateSnapshot,
|
type ProjectStateSnapshot,
|
||||||
} from "../../domain/models/project-state-snapshot.model.js";
|
} from "../../domain/models/project-state-snapshot.model.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -23,11 +23,11 @@ import { floors } from "../schema/floors.js";
|
|||||||
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
import { legacyConsumerCircuitMigrations } from "../schema/legacy-consumer-circuit-migrations.js";
|
||||||
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
|
import { legacyConsumerMigrationReports } from "../schema/legacy-consumer-migration-report.js";
|
||||||
import { projectDevices } from "../schema/project-devices.js";
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { projectChangeSets } from "../schema/project-change-sets.js";
|
||||||
import { projects } from "../schema/projects.js";
|
import { projects } from "../schema/projects.js";
|
||||||
import { rooms } from "../schema/rooms.js";
|
import { rooms } from "../schema/rooms.js";
|
||||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||||
import {
|
import {
|
||||||
hashProjectStatePayload,
|
|
||||||
readProjectStateSnapshot,
|
readProjectStateSnapshot,
|
||||||
} from "./project-state-snapshot.persistence.js";
|
} from "./project-state-snapshot.persistence.js";
|
||||||
|
|
||||||
@@ -38,7 +38,10 @@ export class ProjectStateRestoreCommandRepository
|
|||||||
|
|
||||||
execute(input: ExecuteProjectStateRestoreCommandInput) {
|
execute(input: ExecuteProjectStateRestoreCommandInput) {
|
||||||
assertProjectStateRestoreCommand(input.command);
|
assertProjectStateRestoreCommand(input.command);
|
||||||
if (input.command.payload.targetState.project.id !== input.projectId) {
|
const targetState = parseProjectStateSnapshot(
|
||||||
|
input.command.payload.targetState
|
||||||
|
);
|
||||||
|
if (targetState.project.id !== input.projectId) {
|
||||||
throw new Error("Restore state belongs to a different project.");
|
throw new Error("Restore state belongs to a different project.");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +62,8 @@ export class ProjectStateRestoreCommandRepository
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
current.payloadSha256 !==
|
current.payloadSha256 !==
|
||||||
input.command.payload.expectedStateSha256
|
input.command.payload.expectedStateSha256 &&
|
||||||
|
!matchesHistoryRestoreTarget(tx, input, current.state)
|
||||||
) {
|
) {
|
||||||
throw new ProjectStateConflictError(
|
throw new ProjectStateConflictError(
|
||||||
input.projectId,
|
input.projectId,
|
||||||
@@ -68,20 +72,93 @@ export class ProjectStateRestoreCommandRepository
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetPayloadJson = serializeProjectStateSnapshot(
|
const targetState = parseProjectStateSnapshot(
|
||||||
input.command.payload.targetState
|
input.command.payload.targetState
|
||||||
);
|
);
|
||||||
|
replaceProjectState(tx, targetState);
|
||||||
|
const persistedTarget = readProjectStateSnapshot(tx, input.projectId);
|
||||||
|
if (!persistedTarget) {
|
||||||
|
throw new Error("Restored project could not be loaded.");
|
||||||
|
}
|
||||||
const inverse = createProjectStateRestoreCommand(
|
const inverse = createProjectStateRestoreCommand(
|
||||||
hashProjectStatePayload(targetPayloadJson),
|
persistedTarget.payloadSha256,
|
||||||
current.state
|
current.state
|
||||||
);
|
);
|
||||||
|
|
||||||
replaceProjectState(tx, input.command.payload.targetState);
|
|
||||||
|
|
||||||
return inverse;
|
return inverse;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function matchesHistoryRestoreTarget(
|
||||||
|
database: AppDatabase,
|
||||||
|
input: ExecuteProjectStateRestoreCommandInput,
|
||||||
|
currentState: ProjectStateSnapshot
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
(input.source !== "undo" && input.source !== "redo") ||
|
||||||
|
!input.historyTargetChangeSetId
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const changeSet = database
|
||||||
|
.select({
|
||||||
|
forwardPayloadJson: projectChangeSets.forwardPayloadJson,
|
||||||
|
inversePayloadJson: projectChangeSets.inversePayloadJson,
|
||||||
|
})
|
||||||
|
.from(projectChangeSets)
|
||||||
|
.where(eq(projectChangeSets.id, input.historyTargetChangeSetId))
|
||||||
|
.get();
|
||||||
|
if (!changeSet) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const serialized =
|
||||||
|
input.source === "undo"
|
||||||
|
? changeSet.forwardPayloadJson
|
||||||
|
: changeSet.inversePayloadJson;
|
||||||
|
const command = JSON.parse(serialized) as {
|
||||||
|
type?: unknown;
|
||||||
|
payload?: { targetState?: unknown };
|
||||||
|
};
|
||||||
|
if (command.type !== "project.restore-state") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const expectedState = parseProjectStateSnapshot(
|
||||||
|
command.payload?.targetState
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
canonicalProjectState(expectedState) ===
|
||||||
|
canonicalProjectState(currentState)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalProjectState(state: ProjectStateSnapshot) {
|
||||||
|
return JSON.stringify({
|
||||||
|
...state,
|
||||||
|
distributionBoards: byId(state.distributionBoards),
|
||||||
|
circuitLists: byId(state.circuitLists),
|
||||||
|
circuitSections: byId(state.circuitSections),
|
||||||
|
circuits: byId(
|
||||||
|
state.circuits.map((circuit) => ({
|
||||||
|
...circuit,
|
||||||
|
deviceRows: byId(circuit.deviceRows),
|
||||||
|
}))
|
||||||
|
),
|
||||||
|
projectDevices: byId(state.projectDevices),
|
||||||
|
floors: byId(state.floors),
|
||||||
|
rooms: byId(state.rooms),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function byId<T extends { id: string }>(entries: readonly T[]) {
|
||||||
|
return [...entries].sort((left, right) =>
|
||||||
|
left.id.localeCompare(right.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function replaceProjectState(
|
function replaceProjectState(
|
||||||
database: AppDatabase,
|
database: AppDatabase,
|
||||||
state: ProjectStateSnapshot
|
state: ProjectStateSnapshot
|
||||||
@@ -111,8 +188,14 @@ function replaceProjectState(
|
|||||||
.update(projects)
|
.update(projects)
|
||||||
.set({
|
.set({
|
||||||
name: state.project.name,
|
name: state.project.name,
|
||||||
|
internalProjectNumber: state.project.internalProjectNumber,
|
||||||
|
externalProjectNumber: state.project.externalProjectNumber,
|
||||||
|
buildingOwner: state.project.buildingOwner,
|
||||||
|
description: state.project.description,
|
||||||
singlePhaseVoltageV: state.project.singlePhaseVoltageV,
|
singlePhaseVoltageV: state.project.singlePhaseVoltageV,
|
||||||
threePhaseVoltageV: state.project.threePhaseVoltageV,
|
threePhaseVoltageV: state.project.threePhaseVoltageV,
|
||||||
|
enabledDistributionBoardSupplyTypes:
|
||||||
|
state.project.enabledDistributionBoardSupplyTypes,
|
||||||
})
|
})
|
||||||
.where(eq(projects.id, state.project.id))
|
.where(eq(projects.id, state.project.id))
|
||||||
.run();
|
.run();
|
||||||
|
|||||||
@@ -32,8 +32,14 @@ export function readProjectStateSnapshot(
|
|||||||
.select({
|
.select({
|
||||||
id: projects.id,
|
id: projects.id,
|
||||||
name: projects.name,
|
name: projects.name,
|
||||||
|
internalProjectNumber: projects.internalProjectNumber,
|
||||||
|
externalProjectNumber: projects.externalProjectNumber,
|
||||||
|
buildingOwner: projects.buildingOwner,
|
||||||
|
description: projects.description,
|
||||||
singlePhaseVoltageV: projects.singlePhaseVoltageV,
|
singlePhaseVoltageV: projects.singlePhaseVoltageV,
|
||||||
threePhaseVoltageV: projects.threePhaseVoltageV,
|
threePhaseVoltageV: projects.threePhaseVoltageV,
|
||||||
|
enabledDistributionBoardSupplyTypes:
|
||||||
|
projects.enabledDistributionBoardSupplyTypes,
|
||||||
currentRevision: projects.currentRevision,
|
currentRevision: projects.currentRevision,
|
||||||
})
|
})
|
||||||
.from(projects)
|
.from(projects)
|
||||||
@@ -172,8 +178,14 @@ export function readProjectStateSnapshot(
|
|||||||
project: {
|
project: {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
|
internalProjectNumber: project.internalProjectNumber,
|
||||||
|
externalProjectNumber: project.externalProjectNumber,
|
||||||
|
buildingOwner: project.buildingOwner,
|
||||||
|
description: project.description,
|
||||||
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
||||||
threePhaseVoltageV: project.threePhaseVoltageV,
|
threePhaseVoltageV: project.threePhaseVoltageV,
|
||||||
|
enabledDistributionBoardSupplyTypes:
|
||||||
|
project.enabledDistributionBoardSupplyTypes,
|
||||||
},
|
},
|
||||||
distributionBoards: boardRows,
|
distributionBoards: boardRows,
|
||||||
circuitLists: listRows,
|
circuitLists: listRows,
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
createProjectTransferEnvelope,
|
||||||
|
parseProjectTransferEnvelope,
|
||||||
|
remapProjectState,
|
||||||
|
} from "../../domain/models/project-transfer.model.js";
|
||||||
|
import { createProjectStateRestoreCommand } from "../../domain/models/project-state-restore-command.model.js";
|
||||||
|
import type { ProjectTransferStore } from "../../domain/ports/project-transfer.store.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { distributionBoards } from "../schema/distribution-boards.js";
|
||||||
|
import { floors } from "../schema/floors.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
import { rooms } from "../schema/rooms.js";
|
||||||
|
import {
|
||||||
|
hashProjectStatePayload,
|
||||||
|
readProjectStateSnapshot,
|
||||||
|
} from "./project-state-snapshot.persistence.js";
|
||||||
|
import { serializeProjectStateSnapshot } from "../../domain/models/project-state-snapshot.model.js";
|
||||||
|
|
||||||
|
export class ProjectTransferRepository implements ProjectTransferStore {
|
||||||
|
constructor(private readonly database: AppDatabase) {}
|
||||||
|
|
||||||
|
exportProject(projectId: string) {
|
||||||
|
const snapshot = readProjectStateSnapshot(this.database, projectId);
|
||||||
|
if (!snapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return createProjectTransferEnvelope(
|
||||||
|
new Date().toISOString(),
|
||||||
|
snapshot.payloadSha256,
|
||||||
|
snapshot.state
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
prepareReplace(projectId: string, value: unknown) {
|
||||||
|
const transfer = this.parseVerified(value);
|
||||||
|
const current = readProjectStateSnapshot(this.database, projectId);
|
||||||
|
if (!current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const targetState =
|
||||||
|
transfer.projectState.project.id === projectId
|
||||||
|
? transfer.projectState
|
||||||
|
: remapProjectState(
|
||||||
|
transfer.projectState,
|
||||||
|
projectId,
|
||||||
|
() => crypto.randomUUID()
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
command: createProjectStateRestoreCommand(
|
||||||
|
current.payloadSha256,
|
||||||
|
targetState
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
importDuplicate(value: unknown) {
|
||||||
|
const transfer = this.parseVerified(value);
|
||||||
|
const projectId = crypto.randomUUID();
|
||||||
|
const state = remapProjectState(
|
||||||
|
transfer.projectState,
|
||||||
|
projectId,
|
||||||
|
() => crypto.randomUUID(),
|
||||||
|
`${transfer.projectState.project.name} (Kopie)`
|
||||||
|
);
|
||||||
|
this.database.transaction((tx) => insertProjectState(tx, state));
|
||||||
|
return { projectId, name: state.project.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseVerified(value: unknown) {
|
||||||
|
const rawPayloadSha256 =
|
||||||
|
isPlainObject(value) &&
|
||||||
|
isPlainObject(value.projectState)
|
||||||
|
? hashProjectStatePayload(JSON.stringify(value.projectState))
|
||||||
|
: null;
|
||||||
|
const transfer = parseProjectTransferEnvelope(value);
|
||||||
|
const payloadJson = serializeProjectStateSnapshot(
|
||||||
|
transfer.projectState
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
hashProjectStatePayload(payloadJson) !== transfer.payloadSha256 &&
|
||||||
|
rawPayloadSha256 !== transfer.payloadSha256
|
||||||
|
) {
|
||||||
|
throw new Error("Project transfer checksum verification failed.");
|
||||||
|
}
|
||||||
|
return transfer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertProjectState(
|
||||||
|
database: AppDatabase,
|
||||||
|
state: ReturnType<typeof remapProjectState>
|
||||||
|
) {
|
||||||
|
const existing = database
|
||||||
|
.select({ id: projects.id })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, state.project.id))
|
||||||
|
.get();
|
||||||
|
if (existing) {
|
||||||
|
throw new Error("Project transfer target already exists.");
|
||||||
|
}
|
||||||
|
database
|
||||||
|
.insert(projects)
|
||||||
|
.values({ ...state.project, currentRevision: 0 })
|
||||||
|
.run();
|
||||||
|
insertMany(database, floors, state.floors);
|
||||||
|
insertMany(database, rooms, state.rooms);
|
||||||
|
insertMany(database, projectDevices, state.projectDevices);
|
||||||
|
insertMany(database, distributionBoards, state.distributionBoards);
|
||||||
|
insertMany(database, circuitLists, state.circuitLists);
|
||||||
|
insertMany(database, circuitSections, state.circuitSections);
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
circuits,
|
||||||
|
state.circuits.map(({ deviceRows: _deviceRows, ...circuit }) => ({
|
||||||
|
...circuit,
|
||||||
|
isReserve: circuit.isReserve ? 1 : 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
insertMany(
|
||||||
|
database,
|
||||||
|
circuitDeviceRows,
|
||||||
|
state.circuits.flatMap((circuit) => circuit.deviceRows)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertMany<TTable extends Parameters<AppDatabase["insert"]>[0]>(
|
||||||
|
database: AppDatabase,
|
||||||
|
table: TTable,
|
||||||
|
values: unknown[]
|
||||||
|
) {
|
||||||
|
if (values.length > 0) {
|
||||||
|
database.insert(table).values(values as never).run();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
resolveCircuitPhaseType,
|
||||||
|
resolveProjectVoltage,
|
||||||
|
type ElectricalPhaseType,
|
||||||
|
type ProjectVoltageSettings,
|
||||||
|
} from "../../domain/services/project-voltage.service.js";
|
||||||
|
import type { AppDatabase } from "../database-context.js";
|
||||||
|
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||||
|
import { circuitLists } from "../schema/circuit-lists.js";
|
||||||
|
import { circuitSections } from "../schema/circuit-sections.js";
|
||||||
|
import { circuits } from "../schema/circuits.js";
|
||||||
|
import { projectDevices } from "../schema/project-devices.js";
|
||||||
|
import { projects } from "../schema/projects.js";
|
||||||
|
|
||||||
|
export function readProjectVoltageSettings(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string
|
||||||
|
): ProjectVoltageSettings {
|
||||||
|
const project = database
|
||||||
|
.select({
|
||||||
|
singlePhaseVoltageV: projects.singlePhaseVoltageV,
|
||||||
|
threePhaseVoltageV: projects.threePhaseVoltageV,
|
||||||
|
})
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, projectId))
|
||||||
|
.get();
|
||||||
|
if (!project) {
|
||||||
|
throw new Error("Project not found.");
|
||||||
|
}
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProjectDeviceVoltage(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
phaseType: ElectricalPhaseType
|
||||||
|
) {
|
||||||
|
return resolveProjectVoltage(
|
||||||
|
phaseType,
|
||||||
|
readProjectVoltageSettings(database, projectId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCircuitVoltage(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
sectionId: string,
|
||||||
|
devicePhaseTypes: ReadonlyArray<string | null | undefined> = []
|
||||||
|
) {
|
||||||
|
const section = database
|
||||||
|
.select({ key: circuitSections.key })
|
||||||
|
.from(circuitSections)
|
||||||
|
.innerJoin(
|
||||||
|
circuitLists,
|
||||||
|
eq(circuitLists.id, circuitSections.circuitListId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuitSections.id, sectionId),
|
||||||
|
eq(circuitLists.projectId, projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!section) {
|
||||||
|
throw new Error("Circuit section does not belong to project.");
|
||||||
|
}
|
||||||
|
return resolveProjectVoltage(
|
||||||
|
resolveCircuitPhaseType(section.key, devicePhaseTypes),
|
||||||
|
readProjectVoltageSettings(database, projectId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDerivedCircuitVoltage(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string,
|
||||||
|
circuitId: string
|
||||||
|
) {
|
||||||
|
const circuit = database
|
||||||
|
.select({
|
||||||
|
id: circuits.id,
|
||||||
|
sectionId: circuits.sectionId,
|
||||||
|
})
|
||||||
|
.from(circuits)
|
||||||
|
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(circuits.id, circuitId),
|
||||||
|
eq(circuitLists.projectId, projectId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.get();
|
||||||
|
if (!circuit) {
|
||||||
|
throw new Error("Circuit does not belong to project.");
|
||||||
|
}
|
||||||
|
const devicePhaseTypes = database
|
||||||
|
.select({ phaseType: circuitDeviceRows.phaseType })
|
||||||
|
.from(circuitDeviceRows)
|
||||||
|
.where(eq(circuitDeviceRows.circuitId, circuitId))
|
||||||
|
.all()
|
||||||
|
.map((row) => row.phaseType);
|
||||||
|
const voltage = resolveCircuitVoltage(
|
||||||
|
database,
|
||||||
|
projectId,
|
||||||
|
circuit.sectionId,
|
||||||
|
devicePhaseTypes
|
||||||
|
);
|
||||||
|
database
|
||||||
|
.update(circuits)
|
||||||
|
.set({ voltage })
|
||||||
|
.where(eq(circuits.id, circuitId))
|
||||||
|
.run();
|
||||||
|
return voltage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAllDerivedProjectVoltages(
|
||||||
|
database: AppDatabase,
|
||||||
|
projectId: string
|
||||||
|
) {
|
||||||
|
const settings = readProjectVoltageSettings(database, projectId);
|
||||||
|
const devices = database
|
||||||
|
.select({
|
||||||
|
id: projectDevices.id,
|
||||||
|
phaseType: projectDevices.phaseType,
|
||||||
|
})
|
||||||
|
.from(projectDevices)
|
||||||
|
.where(eq(projectDevices.projectId, projectId))
|
||||||
|
.all();
|
||||||
|
for (const device of devices) {
|
||||||
|
if (
|
||||||
|
device.phaseType !== "single_phase" &&
|
||||||
|
device.phaseType !== "three_phase"
|
||||||
|
) {
|
||||||
|
throw new Error("Persisted project-device phase type is invalid.");
|
||||||
|
}
|
||||||
|
database
|
||||||
|
.update(projectDevices)
|
||||||
|
.set({
|
||||||
|
voltageV: resolveProjectVoltage(device.phaseType, settings),
|
||||||
|
})
|
||||||
|
.where(eq(projectDevices.id, device.id))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectCircuits = database
|
||||||
|
.select({ id: circuits.id })
|
||||||
|
.from(circuits)
|
||||||
|
.innerJoin(circuitLists, eq(circuitLists.id, circuits.circuitListId))
|
||||||
|
.where(eq(circuitLists.projectId, projectId))
|
||||||
|
.all();
|
||||||
|
for (const circuit of projectCircuits) {
|
||||||
|
updateDerivedCircuitVoltage(database, projectId, circuit.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { projects } from "../schema/projects.js";
|
|||||||
import type {
|
import type {
|
||||||
CreateProjectInput,
|
CreateProjectInput,
|
||||||
} from "../../shared/validation/project-structure.schemas.js";
|
} from "../../shared/validation/project-structure.schemas.js";
|
||||||
|
import { defaultDistributionBoardSupplyTypes } from "../../shared/constants/distribution-board.js";
|
||||||
|
|
||||||
export class ProjectRepository {
|
export class ProjectRepository {
|
||||||
constructor(private readonly database: AppDatabase) {}
|
constructor(private readonly database: AppDatabase) {}
|
||||||
@@ -20,8 +21,15 @@ export class ProjectRepository {
|
|||||||
const project = {
|
const project = {
|
||||||
id,
|
id,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
internalProjectNumber: input.internalProjectNumber ?? null,
|
||||||
|
externalProjectNumber: input.externalProjectNumber ?? null,
|
||||||
|
buildingOwner: input.buildingOwner ?? null,
|
||||||
|
description: input.description ?? null,
|
||||||
singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230,
|
singlePhaseVoltageV: input.singlePhaseVoltageV ?? 230,
|
||||||
threePhaseVoltageV: input.threePhaseVoltageV ?? 400,
|
threePhaseVoltageV: input.threePhaseVoltageV ?? 400,
|
||||||
|
enabledDistributionBoardSupplyTypes: [
|
||||||
|
...defaultDistributionBoardSupplyTypes,
|
||||||
|
],
|
||||||
currentRevision: 0,
|
currentRevision: 0,
|
||||||
};
|
};
|
||||||
await db.insert(projects).values(project);
|
await db.insert(projects).values(project);
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { real, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
import { floors } from "./floors.js";
|
||||||
import { projects } from "./projects.js";
|
import { projects } from "./projects.js";
|
||||||
|
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
|
||||||
|
|
||||||
export const distributionBoards = sqliteTable("distribution_boards", {
|
export const distributionBoards = sqliteTable("distribution_boards", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
@@ -7,5 +9,12 @@ export const distributionBoards = sqliteTable("distribution_boards", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => projects.id, { onDelete: "cascade" }),
|
.references(() => projects.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
|
floorId: text("floor_id").references(() => floors.id, {
|
||||||
|
onDelete: "set null",
|
||||||
|
}),
|
||||||
|
supplyType: text("supply_type").$type<DistributionBoardSupplyType>(),
|
||||||
|
simultaneityFactor: real("simultaneity_factor")
|
||||||
|
.notNull()
|
||||||
|
.default(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,22 @@
|
|||||||
|
import { sql } from "drizzle-orm";
|
||||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
import type { DistributionBoardSupplyType } from "../../shared/constants/distribution-board.js";
|
||||||
|
|
||||||
export const projects = sqliteTable("projects", {
|
export const projects = sqliteTable("projects", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
|
internalProjectNumber: text("internal_project_number"),
|
||||||
|
externalProjectNumber: text("external_project_number"),
|
||||||
|
buildingOwner: text("building_owner"),
|
||||||
|
description: text("description"),
|
||||||
singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230),
|
singlePhaseVoltageV: integer("single_phase_voltage_v").notNull().default(230),
|
||||||
threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400),
|
threePhaseVoltageV: integer("three_phase_voltage_v").notNull().default(400),
|
||||||
|
enabledDistributionBoardSupplyTypes: text(
|
||||||
|
"enabled_distribution_board_supply_types",
|
||||||
|
{ mode: "json" }
|
||||||
|
)
|
||||||
|
.$type<DistributionBoardSupplyType[]>()
|
||||||
|
.notNull()
|
||||||
|
.default(sql`'["AV","SV","EV","USV","MSR","SiBe"]'`),
|
||||||
currentRevision: integer("current_revision").notNull().default(0),
|
currentRevision: integer("current_revision").notNull().default(0),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,3 +15,37 @@ export function calculateCircuitTotalPower(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function calculateSectionTotalPower(
|
||||||
|
circuits: Array<{ circuitTotalPower: number }>
|
||||||
|
): number {
|
||||||
|
return circuits.reduce(
|
||||||
|
(sum, circuit) => sum + circuit.circuitTotalPower,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateDistributionBoardTotalPower(
|
||||||
|
sections: Array<{ sectionTotalPower: number }>
|
||||||
|
): number {
|
||||||
|
return sections.reduce(
|
||||||
|
(sum, section) => sum + section.sectionTotalPower,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyDistributionBoardSimultaneityFactor(
|
||||||
|
totalPower: number,
|
||||||
|
simultaneityFactor: number
|
||||||
|
): number {
|
||||||
|
if (
|
||||||
|
!Number.isFinite(simultaneityFactor) ||
|
||||||
|
simultaneityFactor < 0 ||
|
||||||
|
simultaneityFactor > 1
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Distribution-board simultaneity factor must be between zero and one."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return totalPower * simultaneityFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,12 +52,18 @@ export interface CircuitTreeSectionBlock {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
sectionTotalPower: number;
|
||||||
circuits: CircuitTreeCircuit[];
|
circuits: CircuitTreeCircuit[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CircuitTreeResponse {
|
export interface CircuitTreeResponse {
|
||||||
circuitListId: string;
|
circuitListId: string;
|
||||||
currentRevision: number;
|
currentRevision: number;
|
||||||
|
singlePhaseVoltageV: number;
|
||||||
|
threePhaseVoltageV: number;
|
||||||
|
distributionBoardSimultaneityFactor: number;
|
||||||
|
distributionBoardTotalPower: number;
|
||||||
|
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
||||||
sections: CircuitTreeSectionBlock[];
|
sections: CircuitTreeSectionBlock[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import {
|
||||||
|
distributionBoardSupplyTypes,
|
||||||
|
type DistributionBoardSupplyType,
|
||||||
|
} from "../../shared/constants/distribution-board.js";
|
||||||
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
|
export const distributionBoardUpdateCommandType =
|
||||||
|
"distribution-board.update" as const;
|
||||||
|
export const legacyDistributionBoardUpdateCommandSchemaVersion = 1 as const;
|
||||||
|
export const distributionBoardUpdateCommandSchemaVersion = 2 as const;
|
||||||
|
|
||||||
|
export interface DistributionBoardUpdateValues {
|
||||||
|
floorId: string | null;
|
||||||
|
supplyType: DistributionBoardSupplyType | null;
|
||||||
|
simultaneityFactor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DistributionBoardUpdateField =
|
||||||
|
keyof DistributionBoardUpdateValues;
|
||||||
|
export type DistributionBoardUpdatePatch =
|
||||||
|
Partial<DistributionBoardUpdateValues>;
|
||||||
|
|
||||||
|
export interface DistributionBoardUpdateFieldChange<
|
||||||
|
TField extends DistributionBoardUpdateField =
|
||||||
|
DistributionBoardUpdateField,
|
||||||
|
> {
|
||||||
|
field: TField;
|
||||||
|
value: DistributionBoardUpdateValues[TField];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DistributionBoardUpdateCommandPayload {
|
||||||
|
distributionBoardId: string;
|
||||||
|
changes: DistributionBoardUpdateFieldChange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CurrentDistributionBoardUpdateProjectCommand
|
||||||
|
extends SerializedProjectCommand<DistributionBoardUpdateCommandPayload> {
|
||||||
|
schemaVersion: typeof distributionBoardUpdateCommandSchemaVersion;
|
||||||
|
type: typeof distributionBoardUpdateCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LegacyDistributionBoardUpdateProjectCommand
|
||||||
|
extends SerializedProjectCommand<DistributionBoardUpdateCommandPayload> {
|
||||||
|
schemaVersion: typeof legacyDistributionBoardUpdateCommandSchemaVersion;
|
||||||
|
type: typeof distributionBoardUpdateCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DistributionBoardUpdateProjectCommand =
|
||||||
|
| CurrentDistributionBoardUpdateProjectCommand
|
||||||
|
| LegacyDistributionBoardUpdateProjectCommand;
|
||||||
|
|
||||||
|
export function createDistributionBoardUpdateProjectCommand(
|
||||||
|
distributionBoardId: string,
|
||||||
|
patch: DistributionBoardUpdatePatch
|
||||||
|
): CurrentDistributionBoardUpdateProjectCommand {
|
||||||
|
const command: CurrentDistributionBoardUpdateProjectCommand = {
|
||||||
|
schemaVersion: distributionBoardUpdateCommandSchemaVersion,
|
||||||
|
type: distributionBoardUpdateCommandType,
|
||||||
|
payload: {
|
||||||
|
distributionBoardId,
|
||||||
|
changes: Object.entries(patch).map(([field, value]) => ({
|
||||||
|
field: field as DistributionBoardUpdateField,
|
||||||
|
value,
|
||||||
|
})) as DistributionBoardUpdateFieldChange[],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
assertDistributionBoardUpdateProjectCommand(command);
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertDistributionBoardUpdateProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>
|
||||||
|
): asserts command is DistributionBoardUpdateProjectCommand {
|
||||||
|
if (
|
||||||
|
(command.schemaVersion !==
|
||||||
|
legacyDistributionBoardUpdateCommandSchemaVersion &&
|
||||||
|
command.schemaVersion !== distributionBoardUpdateCommandSchemaVersion) ||
|
||||||
|
command.type !== distributionBoardUpdateCommandType ||
|
||||||
|
!isPlainObject(command.payload)
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported distribution-board update command.");
|
||||||
|
}
|
||||||
|
const { distributionBoardId, changes } = command.payload;
|
||||||
|
if (
|
||||||
|
typeof distributionBoardId !== "string" ||
|
||||||
|
!distributionBoardId.trim() ||
|
||||||
|
!Array.isArray(changes) ||
|
||||||
|
changes.length === 0
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Distribution-board update command requires an id and changes."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const change of changes) {
|
||||||
|
if (
|
||||||
|
!isPlainObject(change) ||
|
||||||
|
(change.field !== "floorId" &&
|
||||||
|
change.field !== "supplyType" &&
|
||||||
|
(command.schemaVersion ===
|
||||||
|
legacyDistributionBoardUpdateCommandSchemaVersion ||
|
||||||
|
change.field !== "simultaneityFactor")) ||
|
||||||
|
seen.has(change.field)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Distribution-board update contains an invalid or duplicate field."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (change.field === "floorId") {
|
||||||
|
if (
|
||||||
|
change.value !== null &&
|
||||||
|
(typeof change.value !== "string" || !change.value.trim())
|
||||||
|
) {
|
||||||
|
throw new Error("floorId must be a non-empty string or null.");
|
||||||
|
}
|
||||||
|
} else if (change.field === "supplyType") {
|
||||||
|
if (
|
||||||
|
change.value !== null &&
|
||||||
|
!distributionBoardSupplyTypes.includes(
|
||||||
|
change.value as DistributionBoardSupplyType
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("supplyType is invalid.");
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
typeof change.value !== "number" ||
|
||||||
|
!Number.isFinite(change.value) ||
|
||||||
|
change.value < 0 ||
|
||||||
|
change.value > 1
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"simultaneityFactor must be between zero and one."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
seen.add(change.field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import {
|
||||||
|
distributionBoardSupplyTypes,
|
||||||
|
type DistributionBoardSupplyType,
|
||||||
|
} from "../../shared/constants/distribution-board.js";
|
||||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
|
||||||
export const distributionBoardInsertCommandType =
|
export const distributionBoardInsertCommandType =
|
||||||
"distribution-board.insert" as const;
|
"distribution-board.insert" as const;
|
||||||
export const distributionBoardDeleteCommandType =
|
export const distributionBoardDeleteCommandType =
|
||||||
"distribution-board.delete" as const;
|
"distribution-board.delete" as const;
|
||||||
export const distributionBoardStructureCommandSchemaVersion = 1 as const;
|
export const legacyDistributionBoardStructureCommandSchemaVersion = 1 as const;
|
||||||
|
export const distributionBoardStructureCommandSchemaVersion = 2 as const;
|
||||||
|
|
||||||
export const defaultCircuitSectionDefinitions = [
|
export const defaultCircuitSectionDefinitions = [
|
||||||
{
|
{
|
||||||
@@ -34,11 +39,23 @@ export const defaultCircuitSectionDefinitions = [
|
|||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
interface LegacyDistributionBoardStructureSnapshot {
|
||||||
|
distributionBoard: {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
circuitList: DistributionBoardStructureSnapshot["circuitList"];
|
||||||
|
sections: DistributionBoardStructureSnapshot["sections"];
|
||||||
|
}
|
||||||
|
|
||||||
export interface DistributionBoardStructureSnapshot {
|
export interface DistributionBoardStructureSnapshot {
|
||||||
distributionBoard: {
|
distributionBoard: {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
floorId: string | null;
|
||||||
|
supplyType: DistributionBoardSupplyType | null;
|
||||||
};
|
};
|
||||||
circuitList: {
|
circuitList: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -56,29 +73,52 @@ export interface DistributionBoardStructureSnapshot {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DistributionBoardStructureCommandPayload {
|
interface DistributionBoardStructureCommandPayload<
|
||||||
structure: DistributionBoardStructureSnapshot;
|
TStructure = DistributionBoardStructureSnapshot,
|
||||||
|
> {
|
||||||
|
structure: TStructure;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CurrentDistributionBoardStructureProjectCommand
|
||||||
|
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
|
||||||
|
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
|
||||||
|
type:
|
||||||
|
| typeof distributionBoardInsertCommandType
|
||||||
|
| typeof distributionBoardDeleteCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LegacyDistributionBoardStructureProjectCommand
|
||||||
|
extends SerializedProjectCommand<
|
||||||
|
DistributionBoardStructureCommandPayload<LegacyDistributionBoardStructureSnapshot>
|
||||||
|
> {
|
||||||
|
schemaVersion: typeof legacyDistributionBoardStructureCommandSchemaVersion;
|
||||||
|
type:
|
||||||
|
| typeof distributionBoardInsertCommandType
|
||||||
|
| typeof distributionBoardDeleteCommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DistributionBoardInsertProjectCommand
|
export interface DistributionBoardInsertProjectCommand
|
||||||
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
|
extends CurrentDistributionBoardStructureProjectCommand {
|
||||||
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
|
|
||||||
type: typeof distributionBoardInsertCommandType;
|
type: typeof distributionBoardInsertCommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DistributionBoardDeleteProjectCommand
|
export interface DistributionBoardDeleteProjectCommand
|
||||||
extends SerializedProjectCommand<DistributionBoardStructureCommandPayload> {
|
extends CurrentDistributionBoardStructureProjectCommand {
|
||||||
schemaVersion: typeof distributionBoardStructureCommandSchemaVersion;
|
|
||||||
type: typeof distributionBoardDeleteCommandType;
|
type: typeof distributionBoardDeleteCommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DistributionBoardStructureProjectCommand =
|
export type DistributionBoardStructureProjectCommand =
|
||||||
| DistributionBoardInsertProjectCommand
|
| DistributionBoardInsertProjectCommand
|
||||||
| DistributionBoardDeleteProjectCommand;
|
| DistributionBoardDeleteProjectCommand
|
||||||
|
| LegacyDistributionBoardStructureProjectCommand;
|
||||||
|
|
||||||
export function createDistributionBoardStructureSnapshot(
|
export function createDistributionBoardStructureSnapshot(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
name: string
|
name: string,
|
||||||
|
input: {
|
||||||
|
floorId?: string | null;
|
||||||
|
supplyType?: DistributionBoardSupplyType | null;
|
||||||
|
} = {}
|
||||||
): DistributionBoardStructureSnapshot {
|
): DistributionBoardStructureSnapshot {
|
||||||
const normalizedName = name.trim();
|
const normalizedName = name.trim();
|
||||||
assertNonEmptyString(projectId, "projectId");
|
assertNonEmptyString(projectId, "projectId");
|
||||||
@@ -89,6 +129,8 @@ export function createDistributionBoardStructureSnapshot(
|
|||||||
id: structureId,
|
id: structureId,
|
||||||
projectId,
|
projectId,
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
|
floorId: input.floorId ?? null,
|
||||||
|
supplyType: input.supplyType ?? null,
|
||||||
},
|
},
|
||||||
circuitList: {
|
circuitList: {
|
||||||
id: structureId,
|
id: structureId,
|
||||||
@@ -132,7 +174,7 @@ export function createDistributionBoardDeleteProjectCommand(
|
|||||||
|
|
||||||
export function assertDistributionBoardInsertProjectCommand(
|
export function assertDistributionBoardInsertProjectCommand(
|
||||||
command: SerializedProjectCommand<unknown>
|
command: SerializedProjectCommand<unknown>
|
||||||
): asserts command is DistributionBoardInsertProjectCommand {
|
): asserts command is DistributionBoardStructureProjectCommand {
|
||||||
assertDistributionBoardStructureProjectCommand(
|
assertDistributionBoardStructureProjectCommand(
|
||||||
command,
|
command,
|
||||||
distributionBoardInsertCommandType
|
distributionBoardInsertCommandType
|
||||||
@@ -141,23 +183,88 @@ export function assertDistributionBoardInsertProjectCommand(
|
|||||||
|
|
||||||
export function assertDistributionBoardDeleteProjectCommand(
|
export function assertDistributionBoardDeleteProjectCommand(
|
||||||
command: SerializedProjectCommand<unknown>
|
command: SerializedProjectCommand<unknown>
|
||||||
): asserts command is DistributionBoardDeleteProjectCommand {
|
): asserts command is DistributionBoardStructureProjectCommand {
|
||||||
assertDistributionBoardStructureProjectCommand(
|
assertDistributionBoardStructureProjectCommand(
|
||||||
command,
|
command,
|
||||||
distributionBoardDeleteCommandType
|
distributionBoardDeleteCommandType
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeDistributionBoardStructureProjectCommand(
|
||||||
|
command: DistributionBoardStructureProjectCommand
|
||||||
|
): DistributionBoardInsertProjectCommand | DistributionBoardDeleteProjectCommand {
|
||||||
|
if (
|
||||||
|
command.schemaVersion ===
|
||||||
|
legacyDistributionBoardStructureCommandSchemaVersion
|
||||||
|
) {
|
||||||
|
const structure: DistributionBoardStructureSnapshot = {
|
||||||
|
...command.payload.structure,
|
||||||
|
distributionBoard: {
|
||||||
|
...command.payload.structure.distributionBoard,
|
||||||
|
floorId: null,
|
||||||
|
supplyType: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return command.type === distributionBoardInsertCommandType
|
||||||
|
? createDistributionBoardInsertProjectCommand(structure)
|
||||||
|
: createDistributionBoardDeleteProjectCommand(structure);
|
||||||
|
}
|
||||||
|
return command as
|
||||||
|
| DistributionBoardInsertProjectCommand
|
||||||
|
| DistributionBoardDeleteProjectCommand;
|
||||||
|
}
|
||||||
|
|
||||||
export function assertDistributionBoardStructureSnapshot(
|
export function assertDistributionBoardStructureSnapshot(
|
||||||
structure: unknown
|
structure: unknown
|
||||||
): asserts structure is DistributionBoardStructureSnapshot {
|
): asserts structure is DistributionBoardStructureSnapshot {
|
||||||
|
assertDistributionBoardStructureSnapshotVersion(structure, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDistributionBoardStructureProjectCommand(
|
||||||
|
command: SerializedProjectCommand<unknown>,
|
||||||
|
expectedType:
|
||||||
|
| typeof distributionBoardInsertCommandType
|
||||||
|
| typeof distributionBoardDeleteCommandType
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
command.type !== expectedType ||
|
||||||
|
!isPlainObject(command.payload) ||
|
||||||
|
Object.keys(command.payload).length !== 1
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported distribution-board structure command.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
command.schemaVersion ===
|
||||||
|
legacyDistributionBoardStructureCommandSchemaVersion
|
||||||
|
) {
|
||||||
|
assertDistributionBoardStructureSnapshotVersion(
|
||||||
|
command.payload.structure,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
command.schemaVersion !== distributionBoardStructureCommandSchemaVersion
|
||||||
|
) {
|
||||||
|
throw new Error("Unsupported distribution-board structure command.");
|
||||||
|
}
|
||||||
|
assertDistributionBoardStructureSnapshotVersion(
|
||||||
|
command.payload.structure,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDistributionBoardStructureSnapshotVersion(
|
||||||
|
structure: unknown,
|
||||||
|
legacy: boolean
|
||||||
|
) {
|
||||||
if (!isPlainObject(structure) || Object.keys(structure).length !== 3) {
|
if (!isPlainObject(structure) || Object.keys(structure).length !== 3) {
|
||||||
throw new Error("Distribution-board structure is invalid.");
|
throw new Error("Distribution-board structure is invalid.");
|
||||||
}
|
}
|
||||||
const { distributionBoard, circuitList, sections } = structure;
|
const { distributionBoard, circuitList, sections } = structure;
|
||||||
if (
|
if (
|
||||||
!isPlainObject(distributionBoard) ||
|
!isPlainObject(distributionBoard) ||
|
||||||
Object.keys(distributionBoard).length !== 3 ||
|
Object.keys(distributionBoard).length !== (legacy ? 3 : 5) ||
|
||||||
!isPlainObject(circuitList) ||
|
!isPlainObject(circuitList) ||
|
||||||
Object.keys(circuitList).length !== 4 ||
|
Object.keys(circuitList).length !== 4 ||
|
||||||
!Array.isArray(sections) ||
|
!Array.isArray(sections) ||
|
||||||
@@ -165,8 +272,15 @@ export function assertDistributionBoardStructureSnapshot(
|
|||||||
) {
|
) {
|
||||||
throw new Error("Distribution-board structure is incomplete.");
|
throw new Error("Distribution-board structure is incomplete.");
|
||||||
}
|
}
|
||||||
for (const [field, value] of Object.entries(distributionBoard)) {
|
for (const field of ["id", "projectId", "name"] as const) {
|
||||||
assertNonEmptyString(value, `distributionBoard.${field}`);
|
assertNonEmptyString(
|
||||||
|
distributionBoard[field],
|
||||||
|
`distributionBoard.${field}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!legacy) {
|
||||||
|
assertNullableId(distributionBoard.floorId, "distributionBoard.floorId");
|
||||||
|
assertNullableSupplyType(distributionBoard.supplyType);
|
||||||
}
|
}
|
||||||
for (const [field, value] of Object.entries(circuitList)) {
|
for (const [field, value] of Object.entries(circuitList)) {
|
||||||
assertNonEmptyString(value, `circuitList.${field}`);
|
assertNonEmptyString(value, `circuitList.${field}`);
|
||||||
@@ -174,22 +288,16 @@ export function assertDistributionBoardStructureSnapshot(
|
|||||||
if (
|
if (
|
||||||
circuitList.projectId !== distributionBoard.projectId ||
|
circuitList.projectId !== distributionBoard.projectId ||
|
||||||
circuitList.distributionBoardId !== distributionBoard.id ||
|
circuitList.distributionBoardId !== distributionBoard.id ||
|
||||||
circuitList.name !==
|
circuitList.name !== `${distributionBoard.name} Stromkreisliste`
|
||||||
`${distributionBoard.name} Stromkreisliste`
|
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error("Distribution board and circuit list are inconsistent.");
|
||||||
"Distribution board and circuit list are inconsistent."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sectionIds = new Set<string>();
|
const sectionIds = new Set<string>();
|
||||||
for (let index = 0; index < sections.length; index += 1) {
|
for (let index = 0; index < sections.length; index += 1) {
|
||||||
const section = sections[index];
|
const section = sections[index];
|
||||||
const expected = defaultCircuitSectionDefinitions[index];
|
const expected = defaultCircuitSectionDefinitions[index];
|
||||||
if (
|
if (!isPlainObject(section) || Object.keys(section).length !== 6) {
|
||||||
!isPlainObject(section) ||
|
|
||||||
Object.keys(section).length !== 6
|
|
||||||
) {
|
|
||||||
throw new Error("Default circuit section is invalid.");
|
throw new Error("Default circuit section is invalid.");
|
||||||
}
|
}
|
||||||
assertNonEmptyString(section.id, "section.id");
|
assertNonEmptyString(section.id, "section.id");
|
||||||
@@ -211,22 +319,23 @@ export function assertDistributionBoardStructureSnapshot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertDistributionBoardStructureProjectCommand(
|
function assertNullableId(value: unknown, field: string) {
|
||||||
command: SerializedProjectCommand<unknown>,
|
if (value !== null) {
|
||||||
expectedType:
|
assertNonEmptyString(value, field);
|
||||||
| typeof distributionBoardInsertCommandType
|
}
|
||||||
| typeof distributionBoardDeleteCommandType
|
}
|
||||||
) {
|
|
||||||
if (
|
function assertNullableSupplyType(
|
||||||
command.schemaVersion !==
|
value: unknown
|
||||||
distributionBoardStructureCommandSchemaVersion ||
|
): asserts value is DistributionBoardSupplyType | null {
|
||||||
command.type !== expectedType ||
|
if (
|
||||||
!isPlainObject(command.payload) ||
|
value !== null &&
|
||||||
Object.keys(command.payload).length !== 1
|
!distributionBoardSupplyTypes.includes(
|
||||||
) {
|
value as DistributionBoardSupplyType
|
||||||
throw new Error("Unsupported distribution-board structure command.");
|
)
|
||||||
|
) {
|
||||||
|
throw new Error("distributionBoard.supplyType is invalid.");
|
||||||
}
|
}
|
||||||
assertDistributionBoardStructureSnapshot(command.payload.structure);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertNonEmptyString(
|
function assertNonEmptyString(
|
||||||
|
|||||||
@@ -1,24 +1,59 @@
|
|||||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||||
|
import {
|
||||||
|
distributionBoardSupplyTypes,
|
||||||
|
type DistributionBoardSupplyType,
|
||||||
|
} from "../../shared/constants/distribution-board.js";
|
||||||
|
|
||||||
export const projectSettingsUpdateCommandType =
|
export const projectSettingsUpdateCommandType =
|
||||||
"project.update-settings" as const;
|
"project.update-settings" as const;
|
||||||
export const projectSettingsUpdateCommandSchemaVersion = 1 as const;
|
export const legacyProjectSettingsUpdateCommandSchemaVersion = 1 as const;
|
||||||
|
export const previousProjectSettingsUpdateCommandSchemaVersion = 2 as const;
|
||||||
|
export const projectSettingsUpdateCommandSchemaVersion = 3 as const;
|
||||||
|
|
||||||
export interface ProjectSettingsValues {
|
export interface LegacyProjectSettingsValues {
|
||||||
singlePhaseVoltageV: number;
|
singlePhaseVoltageV: number;
|
||||||
threePhaseVoltageV: number;
|
threePhaseVoltageV: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectSettingsUpdateProjectCommand
|
export interface PreviousProjectSettingsValues extends LegacyProjectSettingsValues {
|
||||||
|
name: string;
|
||||||
|
internalProjectNumber: string | null;
|
||||||
|
externalProjectNumber: string | null;
|
||||||
|
buildingOwner: string | null;
|
||||||
|
description: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectSettingsValues extends PreviousProjectSettingsValues {
|
||||||
|
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegacyProjectSettingsUpdateProjectCommand
|
||||||
|
extends SerializedProjectCommand<LegacyProjectSettingsValues> {
|
||||||
|
schemaVersion: typeof legacyProjectSettingsUpdateCommandSchemaVersion;
|
||||||
|
type: typeof projectSettingsUpdateCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentProjectSettingsUpdateProjectCommand
|
||||||
extends SerializedProjectCommand<ProjectSettingsValues> {
|
extends SerializedProjectCommand<ProjectSettingsValues> {
|
||||||
schemaVersion: typeof projectSettingsUpdateCommandSchemaVersion;
|
schemaVersion: typeof projectSettingsUpdateCommandSchemaVersion;
|
||||||
type: typeof projectSettingsUpdateCommandType;
|
type: typeof projectSettingsUpdateCommandType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PreviousProjectSettingsUpdateProjectCommand
|
||||||
|
extends SerializedProjectCommand<PreviousProjectSettingsValues> {
|
||||||
|
schemaVersion: typeof previousProjectSettingsUpdateCommandSchemaVersion;
|
||||||
|
type: typeof projectSettingsUpdateCommandType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectSettingsUpdateProjectCommand =
|
||||||
|
| LegacyProjectSettingsUpdateProjectCommand
|
||||||
|
| PreviousProjectSettingsUpdateProjectCommand
|
||||||
|
| CurrentProjectSettingsUpdateProjectCommand;
|
||||||
|
|
||||||
export function createProjectSettingsUpdateProjectCommand(
|
export function createProjectSettingsUpdateProjectCommand(
|
||||||
values: ProjectSettingsValues
|
values: ProjectSettingsValues
|
||||||
): ProjectSettingsUpdateProjectCommand {
|
): CurrentProjectSettingsUpdateProjectCommand {
|
||||||
const command: ProjectSettingsUpdateProjectCommand = {
|
const command: CurrentProjectSettingsUpdateProjectCommand = {
|
||||||
schemaVersion: projectSettingsUpdateCommandSchemaVersion,
|
schemaVersion: projectSettingsUpdateCommandSchemaVersion,
|
||||||
type: projectSettingsUpdateCommandType,
|
type: projectSettingsUpdateCommandType,
|
||||||
payload: { ...values },
|
payload: { ...values },
|
||||||
@@ -31,16 +66,100 @@ export function assertProjectSettingsUpdateProjectCommand(
|
|||||||
command: SerializedProjectCommand<unknown>
|
command: SerializedProjectCommand<unknown>
|
||||||
): asserts command is ProjectSettingsUpdateProjectCommand {
|
): asserts command is ProjectSettingsUpdateProjectCommand {
|
||||||
if (
|
if (
|
||||||
command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion ||
|
command.type !== projectSettingsUpdateCommandType ||
|
||||||
command.type !== projectSettingsUpdateCommandType
|
(command.schemaVersion !==
|
||||||
|
legacyProjectSettingsUpdateCommandSchemaVersion &&
|
||||||
|
command.schemaVersion !==
|
||||||
|
previousProjectSettingsUpdateCommandSchemaVersion &&
|
||||||
|
command.schemaVersion !== projectSettingsUpdateCommandSchemaVersion)
|
||||||
) {
|
) {
|
||||||
throw new Error("Unsupported project settings update command.");
|
throw new Error("Unsupported project settings update command.");
|
||||||
}
|
}
|
||||||
|
if (!isPlainObject(command.payload)) {
|
||||||
|
throw new Error(
|
||||||
|
"Project settings update command contains invalid values."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (command.schemaVersion === legacyProjectSettingsUpdateCommandSchemaVersion) {
|
||||||
|
assertLegacyValues(command.payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const expectedKeyCount =
|
||||||
|
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion
|
||||||
|
? 7
|
||||||
|
: 8;
|
||||||
if (
|
if (
|
||||||
!isPlainObject(command.payload) ||
|
!isTrimmedString(command.payload.name, 1, 200) ||
|
||||||
|
!isNullableTrimmedString(command.payload.internalProjectNumber, 100) ||
|
||||||
|
!isNullableTrimmedString(command.payload.externalProjectNumber, 100) ||
|
||||||
|
!isNullableTrimmedString(command.payload.buildingOwner, 200) ||
|
||||||
|
!isNullableTrimmedString(command.payload.description, 2000) ||
|
||||||
!isPositiveFiniteNumber(command.payload.singlePhaseVoltageV) ||
|
!isPositiveFiniteNumber(command.payload.singlePhaseVoltageV) ||
|
||||||
!isPositiveFiniteNumber(command.payload.threePhaseVoltageV) ||
|
!isPositiveFiniteNumber(command.payload.threePhaseVoltageV) ||
|
||||||
Object.keys(command.payload).length !== 2
|
Object.keys(command.payload).length !== expectedKeyCount
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Project settings update command contains invalid values."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
command.schemaVersion === projectSettingsUpdateCommandSchemaVersion &&
|
||||||
|
!isValidSupplyTypes(
|
||||||
|
command.payload.enabledDistributionBoardSupplyTypes
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Project settings update command contains invalid supply types."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProjectSettingsValues(
|
||||||
|
command: ProjectSettingsUpdateProjectCommand,
|
||||||
|
current: ProjectSettingsValues
|
||||||
|
): ProjectSettingsValues {
|
||||||
|
if (command.schemaVersion === legacyProjectSettingsUpdateCommandSchemaVersion) {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
...command.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
command.schemaVersion === previousProjectSettingsUpdateCommandSchemaVersion
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...command.payload,
|
||||||
|
enabledDistributionBoardSupplyTypes:
|
||||||
|
current.enabledDistributionBoardSupplyTypes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return command.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidSupplyTypes(
|
||||||
|
value: unknown
|
||||||
|
): value is DistributionBoardSupplyType[] {
|
||||||
|
return (
|
||||||
|
Array.isArray(value) &&
|
||||||
|
value.length > 0 &&
|
||||||
|
new Set(value).size === value.length &&
|
||||||
|
value.every(
|
||||||
|
(entry) =>
|
||||||
|
typeof entry === "string" &&
|
||||||
|
distributionBoardSupplyTypes.includes(
|
||||||
|
entry as DistributionBoardSupplyType
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLegacyValues(
|
||||||
|
payload: Record<string, unknown>
|
||||||
|
): asserts payload is Record<string, unknown> & LegacyProjectSettingsValues {
|
||||||
|
if (
|
||||||
|
!isPositiveFiniteNumber(payload.singlePhaseVoltageV) ||
|
||||||
|
!isPositiveFiniteNumber(payload.threePhaseVoltageV) ||
|
||||||
|
Object.keys(payload).length !== 2
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Project settings update command contains invalid voltages."
|
"Project settings update command contains invalid voltages."
|
||||||
@@ -59,3 +178,26 @@ function isPositiveFiniteNumber(value: unknown): value is number {
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTrimmedString(
|
||||||
|
value: unknown,
|
||||||
|
minimumLength: number,
|
||||||
|
maximumLength: number
|
||||||
|
): value is string {
|
||||||
|
return (
|
||||||
|
typeof value === "string" &&
|
||||||
|
value === value.trim() &&
|
||||||
|
value.length >= minimumLength &&
|
||||||
|
value.length <= maximumLength
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNullableTrimmedString(
|
||||||
|
value: unknown,
|
||||||
|
maximumLength: number
|
||||||
|
): value is string | null {
|
||||||
|
return (
|
||||||
|
value === null ||
|
||||||
|
(isTrimmedString(value, 1, maximumLength))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
defaultDistributionBoardSupplyTypes,
|
||||||
|
distributionBoardSupplyTypes,
|
||||||
|
} from "../../shared/constants/distribution-board.js";
|
||||||
|
import {
|
||||||
|
resolveCircuitPhaseType,
|
||||||
|
resolveProjectVoltage,
|
||||||
|
normalizeKnownElectricalPhaseType,
|
||||||
|
} from "../services/project-voltage.service.js";
|
||||||
|
|
||||||
export const projectStateSnapshotSchemaVersion = 1 as const;
|
export const legacyProjectStateSnapshotSchemaVersion = 1 as const;
|
||||||
|
export const previousProjectStateSnapshotSchemaVersion = 2 as const;
|
||||||
|
export const distributionBoardProjectStateSnapshotSchemaVersion = 3 as const;
|
||||||
|
export const supplyTypesProjectStateSnapshotSchemaVersion = 4 as const;
|
||||||
|
export const voltageProjectStateSnapshotSchemaVersion = 5 as const;
|
||||||
|
export const projectStateSnapshotSchemaVersion = 6 as const;
|
||||||
|
|
||||||
const idSchema = z.string().trim().min(1);
|
const idSchema = z.string().trim().min(1);
|
||||||
const nullableStringSchema = z.string().nullable();
|
const nullableStringSchema = z.string().nullable();
|
||||||
const finiteNumberSchema = z.number().finite();
|
const finiteNumberSchema = z.number().finite();
|
||||||
|
|
||||||
const projectSchema = z
|
const legacyProjectSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
name: z.string().trim().min(1),
|
name: z.string().trim().min(1),
|
||||||
@@ -15,7 +29,21 @@ const projectSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
const distributionBoardSchema = z
|
const projectSchema = legacyProjectSchema.extend({
|
||||||
|
internalProjectNumber: z.string().trim().min(1).max(100).nullable(),
|
||||||
|
externalProjectNumber: z.string().trim().min(1).max(100).nullable(),
|
||||||
|
buildingOwner: z.string().trim().min(1).max(200).nullable(),
|
||||||
|
description: z.string().trim().min(1).max(2000).nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentProjectSchema = projectSchema.extend({
|
||||||
|
enabledDistributionBoardSupplyTypes: z
|
||||||
|
.array(z.enum(distributionBoardSupplyTypes))
|
||||||
|
.min(1)
|
||||||
|
.refine((values) => new Set(values).size === values.length),
|
||||||
|
});
|
||||||
|
|
||||||
|
const legacyDistributionBoardSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
projectId: idSchema,
|
projectId: idSchema,
|
||||||
@@ -23,6 +51,19 @@ const distributionBoardSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
const distributionBoardSchema = legacyDistributionBoardSchema
|
||||||
|
.extend({
|
||||||
|
floorId: idSchema.nullable(),
|
||||||
|
supplyType: z.enum(distributionBoardSupplyTypes).nullable(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const currentDistributionBoardSchema = distributionBoardSchema
|
||||||
|
.extend({
|
||||||
|
simultaneityFactor: finiteNumberSchema.min(0).max(1),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
const circuitListSchema = z
|
const circuitListSchema = z
|
||||||
.object({
|
.object({
|
||||||
id: idSchema,
|
id: idSchema,
|
||||||
@@ -132,20 +173,66 @@ const roomSchema = z
|
|||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
export const projectStateSnapshotSchema = z
|
const commonProjectStateSnapshotContents = {
|
||||||
.object({
|
|
||||||
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
|
||||||
project: projectSchema,
|
|
||||||
distributionBoards: z.array(distributionBoardSchema),
|
|
||||||
circuitLists: z.array(circuitListSchema),
|
circuitLists: z.array(circuitListSchema),
|
||||||
circuitSections: z.array(circuitSectionSchema),
|
circuitSections: z.array(circuitSectionSchema),
|
||||||
circuits: z.array(circuitSchema),
|
circuits: z.array(circuitSchema),
|
||||||
projectDevices: z.array(projectDeviceSchema),
|
projectDevices: z.array(projectDeviceSchema),
|
||||||
floors: z.array(floorSchema),
|
floors: z.array(floorSchema),
|
||||||
rooms: z.array(roomSchema),
|
rooms: z.array(roomSchema),
|
||||||
|
};
|
||||||
|
|
||||||
|
const legacyProjectStateSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(legacyProjectStateSnapshotSchemaVersion),
|
||||||
|
project: legacyProjectSchema,
|
||||||
|
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||||
|
...commonProjectStateSnapshotContents,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
const previousProjectStateSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(previousProjectStateSnapshotSchemaVersion),
|
||||||
|
project: projectSchema,
|
||||||
|
distributionBoards: z.array(legacyDistributionBoardSchema),
|
||||||
|
...commonProjectStateSnapshotContents,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const distributionBoardProjectStateSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(
|
||||||
|
distributionBoardProjectStateSnapshotSchemaVersion
|
||||||
|
),
|
||||||
|
project: projectSchema,
|
||||||
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
|
...commonProjectStateSnapshotContents,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const supplyTypesProjectStateSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(supplyTypesProjectStateSnapshotSchemaVersion),
|
||||||
|
project: currentProjectSchema,
|
||||||
|
distributionBoards: z.array(distributionBoardSchema),
|
||||||
|
...commonProjectStateSnapshotContents,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
const voltageProjectStateSnapshotSchema =
|
||||||
|
supplyTypesProjectStateSnapshotSchema.extend({
|
||||||
|
schemaVersion: z.literal(
|
||||||
|
voltageProjectStateSnapshotSchemaVersion
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const projectStateSnapshotSchema =
|
||||||
|
voltageProjectStateSnapshotSchema.extend({
|
||||||
|
schemaVersion: z.literal(projectStateSnapshotSchemaVersion),
|
||||||
|
distributionBoards: z.array(currentDistributionBoardSchema),
|
||||||
|
});
|
||||||
|
|
||||||
export type ProjectStateSnapshot = z.infer<
|
export type ProjectStateSnapshot = z.infer<
|
||||||
typeof projectStateSnapshotSchema
|
typeof projectStateSnapshotSchema
|
||||||
>;
|
>;
|
||||||
@@ -153,11 +240,193 @@ export type ProjectStateSnapshot = z.infer<
|
|||||||
export function parseProjectStateSnapshot(
|
export function parseProjectStateSnapshot(
|
||||||
value: unknown
|
value: unknown
|
||||||
): ProjectStateSnapshot {
|
): ProjectStateSnapshot {
|
||||||
const snapshot = projectStateSnapshotSchema.parse(value);
|
const version = isPlainObject(value) ? value.schemaVersion : undefined;
|
||||||
|
const parsedSnapshot =
|
||||||
|
version === legacyProjectStateSnapshotSchemaVersion
|
||||||
|
? upgradeVoltageProjectStateSnapshot(
|
||||||
|
upgradeSupplyTypesProjectStateSnapshot(
|
||||||
|
upgradeDistributionBoardProjectStateSnapshot(
|
||||||
|
upgradePreviousProjectStateSnapshot(
|
||||||
|
upgradeLegacyProjectStateSnapshot(
|
||||||
|
legacyProjectStateSnapshotSchema.parse(value)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: version === previousProjectStateSnapshotSchemaVersion
|
||||||
|
? upgradeVoltageProjectStateSnapshot(
|
||||||
|
upgradeSupplyTypesProjectStateSnapshot(
|
||||||
|
upgradeDistributionBoardProjectStateSnapshot(
|
||||||
|
upgradePreviousProjectStateSnapshot(
|
||||||
|
previousProjectStateSnapshotSchema.parse(value)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: version === distributionBoardProjectStateSnapshotSchemaVersion
|
||||||
|
? upgradeVoltageProjectStateSnapshot(
|
||||||
|
upgradeSupplyTypesProjectStateSnapshot(
|
||||||
|
upgradeDistributionBoardProjectStateSnapshot(
|
||||||
|
distributionBoardProjectStateSnapshotSchema.parse(value)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: version === supplyTypesProjectStateSnapshotSchemaVersion
|
||||||
|
? upgradeVoltageProjectStateSnapshot(
|
||||||
|
upgradeSupplyTypesProjectStateSnapshot(
|
||||||
|
supplyTypesProjectStateSnapshotSchema.parse(value)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: version === voltageProjectStateSnapshotSchemaVersion
|
||||||
|
? upgradeVoltageProjectStateSnapshot(
|
||||||
|
voltageProjectStateSnapshotSchema.parse(value)
|
||||||
|
)
|
||||||
|
: projectStateSnapshotSchema.parse(value);
|
||||||
|
const snapshot = normalizeSnapshotPhaseTypes(parsedSnapshot);
|
||||||
assertProjectStateSnapshotRelations(snapshot);
|
assertProjectStateSnapshotRelations(snapshot);
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSnapshotPhaseTypes(
|
||||||
|
snapshot: ProjectStateSnapshot
|
||||||
|
): ProjectStateSnapshot {
|
||||||
|
const sectionById = new Map(
|
||||||
|
snapshot.circuitSections.map((section) => [section.id, section])
|
||||||
|
);
|
||||||
|
const projectDeviceById = new Map(
|
||||||
|
snapshot.projectDevices.map((device) => [device.id, device])
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
circuits: snapshot.circuits.map((circuit) => {
|
||||||
|
const section = sectionById.get(circuit.sectionId);
|
||||||
|
const deviceRows = circuit.deviceRows.map((row) => {
|
||||||
|
const normalizedPhaseType =
|
||||||
|
normalizeKnownElectricalPhaseType(row.phaseType);
|
||||||
|
const linkedPhaseType = row.linkedProjectDeviceId
|
||||||
|
? projectDeviceById.get(row.linkedProjectDeviceId)?.phaseType
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
phaseType:
|
||||||
|
normalizedPhaseType ??
|
||||||
|
linkedPhaseType ??
|
||||||
|
(section?.key === "three_phase"
|
||||||
|
? "three_phase"
|
||||||
|
: "single_phase"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...circuit,
|
||||||
|
voltage: section
|
||||||
|
? resolveProjectVoltage(
|
||||||
|
resolveCircuitPhaseType(
|
||||||
|
section.key,
|
||||||
|
deviceRows.map((row) => row.phaseType)
|
||||||
|
),
|
||||||
|
snapshot.project
|
||||||
|
)
|
||||||
|
: circuit.voltage,
|
||||||
|
deviceRows,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeLegacyProjectStateSnapshot(
|
||||||
|
snapshot: z.infer<typeof legacyProjectStateSnapshotSchema>
|
||||||
|
): z.infer<typeof previousProjectStateSnapshotSchema> {
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
schemaVersion: previousProjectStateSnapshotSchemaVersion,
|
||||||
|
project: {
|
||||||
|
...snapshot.project,
|
||||||
|
internalProjectNumber: null,
|
||||||
|
externalProjectNumber: null,
|
||||||
|
buildingOwner: null,
|
||||||
|
description: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradePreviousProjectStateSnapshot(
|
||||||
|
snapshot: z.infer<typeof previousProjectStateSnapshotSchema>
|
||||||
|
): z.infer<typeof distributionBoardProjectStateSnapshotSchema> {
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
schemaVersion: distributionBoardProjectStateSnapshotSchemaVersion,
|
||||||
|
distributionBoards: snapshot.distributionBoards.map((board) => ({
|
||||||
|
...board,
|
||||||
|
floorId: null,
|
||||||
|
supplyType: null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeDistributionBoardProjectStateSnapshot(
|
||||||
|
snapshot: z.infer<typeof distributionBoardProjectStateSnapshotSchema>
|
||||||
|
): z.infer<typeof supplyTypesProjectStateSnapshotSchema> {
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
schemaVersion: supplyTypesProjectStateSnapshotSchemaVersion,
|
||||||
|
project: {
|
||||||
|
...snapshot.project,
|
||||||
|
enabledDistributionBoardSupplyTypes: [
|
||||||
|
...defaultDistributionBoardSupplyTypes,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeSupplyTypesProjectStateSnapshot(
|
||||||
|
snapshot: z.infer<typeof supplyTypesProjectStateSnapshotSchema>
|
||||||
|
): z.infer<typeof voltageProjectStateSnapshotSchema> {
|
||||||
|
const settings = snapshot.project;
|
||||||
|
const sectionById = new Map(
|
||||||
|
snapshot.circuitSections.map((section) => [section.id, section])
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
schemaVersion: voltageProjectStateSnapshotSchemaVersion,
|
||||||
|
projectDevices: snapshot.projectDevices.map((device) => ({
|
||||||
|
...device,
|
||||||
|
voltageV: resolveProjectVoltage(device.phaseType, settings),
|
||||||
|
})),
|
||||||
|
circuits: snapshot.circuits.map((circuit) => {
|
||||||
|
const section = sectionById.get(circuit.sectionId);
|
||||||
|
if (!section) {
|
||||||
|
return circuit;
|
||||||
|
}
|
||||||
|
const phaseType = resolveCircuitPhaseType(
|
||||||
|
section.key,
|
||||||
|
circuit.deviceRows.map((row) => row.phaseType)
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...circuit,
|
||||||
|
voltage: resolveProjectVoltage(phaseType, settings),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function upgradeVoltageProjectStateSnapshot(
|
||||||
|
snapshot: z.infer<typeof voltageProjectStateSnapshotSchema>
|
||||||
|
): ProjectStateSnapshot {
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
schemaVersion: projectStateSnapshotSchemaVersion,
|
||||||
|
distributionBoards: snapshot.distributionBoards.map((board) => ({
|
||||||
|
...board,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function serializeProjectStateSnapshot(
|
export function serializeProjectStateSnapshot(
|
||||||
snapshot: ProjectStateSnapshot
|
snapshot: ProjectStateSnapshot
|
||||||
): string {
|
): string {
|
||||||
@@ -199,6 +468,23 @@ function assertProjectStateSnapshotRelations(
|
|||||||
|
|
||||||
for (const board of snapshot.distributionBoards) {
|
for (const board of snapshot.distributionBoards) {
|
||||||
assertProjectOwnership(board.projectId, projectId, "distribution board");
|
assertProjectOwnership(board.projectId, projectId, "distribution board");
|
||||||
|
if (board.floorId !== null) {
|
||||||
|
assertReference(
|
||||||
|
floorIds,
|
||||||
|
board.floorId,
|
||||||
|
"distribution board floor"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
board.supplyType !== null &&
|
||||||
|
!snapshot.project.enabledDistributionBoardSupplyTypes.includes(
|
||||||
|
board.supplyType
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Snapshot distribution board uses a disabled supply type."
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const list of snapshot.circuitLists) {
|
for (const list of snapshot.circuitLists) {
|
||||||
assertProjectOwnership(list.projectId, projectId, "circuit list");
|
assertProjectOwnership(list.projectId, projectId, "circuit list");
|
||||||
@@ -217,6 +503,14 @@ function assertProjectStateSnapshotRelations(
|
|||||||
}
|
}
|
||||||
for (const device of snapshot.projectDevices) {
|
for (const device of snapshot.projectDevices) {
|
||||||
assertProjectOwnership(device.projectId, projectId, "project device");
|
assertProjectOwnership(device.projectId, projectId, "project device");
|
||||||
|
if (
|
||||||
|
device.voltageV !==
|
||||||
|
resolveProjectVoltage(device.phaseType, snapshot.project)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"Snapshot project device voltage must match project settings."
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const floor of snapshot.floors) {
|
for (const floor of snapshot.floors) {
|
||||||
assertProjectOwnership(floor.projectId, projectId, "floor");
|
assertProjectOwnership(floor.projectId, projectId, "floor");
|
||||||
@@ -247,6 +541,18 @@ function assertProjectStateSnapshotRelations(
|
|||||||
"Snapshot circuit reserve state must match its device rows."
|
"Snapshot circuit reserve state must match its device rows."
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const expectedVoltage = resolveProjectVoltage(
|
||||||
|
resolveCircuitPhaseType(
|
||||||
|
section.key,
|
||||||
|
circuit.deviceRows.map((row) => row.phaseType)
|
||||||
|
),
|
||||||
|
snapshot.project
|
||||||
|
);
|
||||||
|
if (circuit.voltage !== expectedVoltage) {
|
||||||
|
throw new Error(
|
||||||
|
"Snapshot circuit voltage must match project settings."
|
||||||
|
);
|
||||||
|
}
|
||||||
for (const row of circuit.deviceRows) {
|
for (const row of circuit.deviceRows) {
|
||||||
if (row.circuitId !== circuit.id) {
|
if (row.circuitId !== circuit.id) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import {
|
||||||
|
parseProjectStateSnapshot,
|
||||||
|
projectStateSnapshotSchemaVersion,
|
||||||
|
type ProjectStateSnapshot,
|
||||||
|
} from "./project-state-snapshot.model.js";
|
||||||
|
|
||||||
|
export const projectTransferFormat = "leistungsbilanz.project" as const;
|
||||||
|
export const projectTransferFormatVersion = 1 as const;
|
||||||
|
|
||||||
|
export interface ProjectTransferEnvelope {
|
||||||
|
format: typeof projectTransferFormat;
|
||||||
|
formatVersion: typeof projectTransferFormatVersion;
|
||||||
|
exportedAtIso: string;
|
||||||
|
payloadSha256: string;
|
||||||
|
projectState: ProjectStateSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProjectTransferEnvelope(
|
||||||
|
exportedAtIso: string,
|
||||||
|
payloadSha256: string,
|
||||||
|
projectState: ProjectStateSnapshot
|
||||||
|
): ProjectTransferEnvelope {
|
||||||
|
return parseProjectTransferEnvelope({
|
||||||
|
format: projectTransferFormat,
|
||||||
|
formatVersion: projectTransferFormatVersion,
|
||||||
|
exportedAtIso,
|
||||||
|
payloadSha256,
|
||||||
|
projectState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseProjectTransferEnvelope(
|
||||||
|
value: unknown
|
||||||
|
): ProjectTransferEnvelope {
|
||||||
|
if (!isPlainObject(value)) {
|
||||||
|
throw new Error("Project transfer file must be an object.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
value.format !== projectTransferFormat ||
|
||||||
|
value.formatVersion !== projectTransferFormatVersion
|
||||||
|
) {
|
||||||
|
throw new Error("Project transfer format is not supported.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof value.exportedAtIso !== "string" ||
|
||||||
|
!Number.isFinite(Date.parse(value.exportedAtIso))
|
||||||
|
) {
|
||||||
|
throw new Error("Project transfer timestamp is invalid.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof value.payloadSha256 !== "string" ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(value.payloadSha256)
|
||||||
|
) {
|
||||||
|
throw new Error("Project transfer checksum is invalid.");
|
||||||
|
}
|
||||||
|
const projectState = parseProjectStateSnapshot(value.projectState);
|
||||||
|
if (projectState.schemaVersion !== projectStateSnapshotSchemaVersion) {
|
||||||
|
throw new Error("Project transfer snapshot version is not supported.");
|
||||||
|
}
|
||||||
|
if (Object.keys(value).length !== 5) {
|
||||||
|
throw new Error("Project transfer file contains unknown fields.");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
format: projectTransferFormat,
|
||||||
|
formatVersion: projectTransferFormatVersion,
|
||||||
|
exportedAtIso: value.exportedAtIso,
|
||||||
|
payloadSha256: value.payloadSha256,
|
||||||
|
projectState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remapProjectState(
|
||||||
|
source: ProjectStateSnapshot,
|
||||||
|
targetProjectId: string,
|
||||||
|
createId: () => string,
|
||||||
|
name: string = source.project.name
|
||||||
|
): ProjectStateSnapshot {
|
||||||
|
const boardIds = idMap(source.distributionBoards, createId);
|
||||||
|
const listIds = idMap(source.circuitLists, createId);
|
||||||
|
const sectionIds = idMap(source.circuitSections, createId);
|
||||||
|
const circuitIds = idMap(source.circuits, createId);
|
||||||
|
const deviceIds = idMap(source.projectDevices, createId);
|
||||||
|
const floorIds = idMap(source.floors, createId);
|
||||||
|
const roomIds = idMap(source.rooms, createId);
|
||||||
|
const rowIds = new Map(
|
||||||
|
source.circuits.flatMap((circuit) =>
|
||||||
|
circuit.deviceRows.map((row) => [row.id, createId()] as const)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return parseProjectStateSnapshot({
|
||||||
|
...source,
|
||||||
|
project: { ...source.project, id: targetProjectId, name },
|
||||||
|
distributionBoards: source.distributionBoards.map((board) => ({
|
||||||
|
...board,
|
||||||
|
id: requiredId(boardIds, board.id),
|
||||||
|
projectId: targetProjectId,
|
||||||
|
floorId:
|
||||||
|
board.floorId === null
|
||||||
|
? null
|
||||||
|
: requiredId(floorIds, board.floorId),
|
||||||
|
})),
|
||||||
|
circuitLists: source.circuitLists.map((list) => ({
|
||||||
|
...list,
|
||||||
|
id: requiredId(listIds, list.id),
|
||||||
|
projectId: targetProjectId,
|
||||||
|
distributionBoardId: requiredId(boardIds, list.distributionBoardId),
|
||||||
|
})),
|
||||||
|
circuitSections: source.circuitSections.map((section) => ({
|
||||||
|
...section,
|
||||||
|
id: requiredId(sectionIds, section.id),
|
||||||
|
circuitListId: requiredId(listIds, section.circuitListId),
|
||||||
|
})),
|
||||||
|
circuits: source.circuits.map((circuit) => ({
|
||||||
|
...circuit,
|
||||||
|
id: requiredId(circuitIds, circuit.id),
|
||||||
|
circuitListId: requiredId(listIds, circuit.circuitListId),
|
||||||
|
sectionId: requiredId(sectionIds, circuit.sectionId),
|
||||||
|
deviceRows: circuit.deviceRows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
id: requiredId(rowIds, row.id),
|
||||||
|
circuitId: requiredId(circuitIds, circuit.id),
|
||||||
|
linkedProjectDeviceId:
|
||||||
|
row.linkedProjectDeviceId === null
|
||||||
|
? null
|
||||||
|
: requiredId(deviceIds, row.linkedProjectDeviceId),
|
||||||
|
legacyConsumerId: null,
|
||||||
|
roomId:
|
||||||
|
row.roomId === null ? null : requiredId(roomIds, row.roomId),
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
projectDevices: source.projectDevices.map((device) => ({
|
||||||
|
...device,
|
||||||
|
id: requiredId(deviceIds, device.id),
|
||||||
|
projectId: targetProjectId,
|
||||||
|
})),
|
||||||
|
floors: source.floors.map((floor) => ({
|
||||||
|
...floor,
|
||||||
|
id: requiredId(floorIds, floor.id),
|
||||||
|
projectId: targetProjectId,
|
||||||
|
})),
|
||||||
|
rooms: source.rooms.map((room) => ({
|
||||||
|
...room,
|
||||||
|
id: requiredId(roomIds, room.id),
|
||||||
|
projectId: targetProjectId,
|
||||||
|
floorId:
|
||||||
|
room.floorId === null ? null : requiredId(floorIds, room.floorId),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function idMap(
|
||||||
|
entries: ReadonlyArray<{ id: string }>,
|
||||||
|
createId: () => string
|
||||||
|
) {
|
||||||
|
return new Map(entries.map((entry) => [entry.id, createId()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredId(ids: ReadonlyMap<string, string>, sourceId: string) {
|
||||||
|
const targetId = ids.get(sourceId);
|
||||||
|
if (!targetId) {
|
||||||
|
throw new Error(`Project transfer reference "${sourceId}" is invalid.`);
|
||||||
|
}
|
||||||
|
return targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DistributionBoardStructureProjectCommand } from "../models/distribution-board-structure-project-command.model.js";
|
import type { DistributionBoardStructureProjectCommand } from "../models/distribution-board-structure-project-command.model.js";
|
||||||
|
import type { DistributionBoardUpdateProjectCommand } from "../models/distribution-board-project-command.model.js";
|
||||||
import type {
|
import type {
|
||||||
AppendedProjectRevision,
|
AppendedProjectRevision,
|
||||||
ProjectRevisionSource,
|
ProjectRevisionSource,
|
||||||
@@ -23,4 +24,15 @@ export interface DistributionBoardStructureProjectCommandStore {
|
|||||||
execute(
|
execute(
|
||||||
input: ExecuteDistributionBoardStructureCommandInput
|
input: ExecuteDistributionBoardStructureCommandInput
|
||||||
): ExecutedDistributionBoardStructureCommand;
|
): ExecutedDistributionBoardStructureCommand;
|
||||||
|
executeUpdate(
|
||||||
|
input: Omit<
|
||||||
|
ExecuteDistributionBoardStructureCommandInput,
|
||||||
|
"command"
|
||||||
|
> & {
|
||||||
|
command: DistributionBoardUpdateProjectCommand;
|
||||||
|
}
|
||||||
|
): {
|
||||||
|
revision: AppendedProjectRevision;
|
||||||
|
inverse: DistributionBoardUpdateProjectCommand;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ export interface ProjectHistoryStore {
|
|||||||
listRevisions(
|
listRevisions(
|
||||||
input: ListProjectRevisionsInput
|
input: ListProjectRevisionsInput
|
||||||
): ProjectRevisionPage | null;
|
): ProjectRevisionPage | null;
|
||||||
|
listRevisionsByNumbers(
|
||||||
|
projectId: string,
|
||||||
|
revisionNumbers: number[]
|
||||||
|
): ProjectRevisionSummary[];
|
||||||
getNextCommand(
|
getNextCommand(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
direction: ProjectHistoryDirection
|
direction: ProjectHistoryDirection
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { ProjectStateRestoreCommand } from "../models/project-state-restore-command.model.js";
|
||||||
|
import type { ProjectTransferEnvelope } from "../models/project-transfer.model.js";
|
||||||
|
|
||||||
|
export interface PreparedProjectTransferReplace {
|
||||||
|
command: ProjectStateRestoreCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectTransferStore {
|
||||||
|
exportProject(projectId: string): ProjectTransferEnvelope | null;
|
||||||
|
prepareReplace(
|
||||||
|
projectId: string,
|
||||||
|
transfer: unknown
|
||||||
|
): PreparedProjectTransferReplace | null;
|
||||||
|
importDuplicate(transfer: unknown): { projectId: string; name: string };
|
||||||
|
}
|
||||||
@@ -37,6 +37,10 @@ import {
|
|||||||
circuitDeleteCommandType,
|
circuitDeleteCommandType,
|
||||||
circuitInsertCommandType,
|
circuitInsertCommandType,
|
||||||
} from "../models/circuit-structure-project-command.model.js";
|
} from "../models/circuit-structure-project-command.model.js";
|
||||||
|
import {
|
||||||
|
assertDistributionBoardUpdateProjectCommand,
|
||||||
|
distributionBoardUpdateCommandType,
|
||||||
|
} from "../models/distribution-board-project-command.model.js";
|
||||||
import {
|
import {
|
||||||
assertDistributionBoardDeleteProjectCommand,
|
assertDistributionBoardDeleteProjectCommand,
|
||||||
assertDistributionBoardInsertProjectCommand,
|
assertDistributionBoardInsertProjectCommand,
|
||||||
@@ -285,6 +289,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
|||||||
command: input.command,
|
command: input.command,
|
||||||
}).revision;
|
}).revision;
|
||||||
}
|
}
|
||||||
|
case distributionBoardUpdateCommandType: {
|
||||||
|
assertDistributionBoardUpdateProjectCommand(input.command);
|
||||||
|
return this.distributionBoardStructureStore.executeUpdate({
|
||||||
|
...input,
|
||||||
|
command: input.command,
|
||||||
|
}).revision;
|
||||||
|
}
|
||||||
case projectFloorInsertCommandType: {
|
case projectFloorInsertCommandType: {
|
||||||
assertProjectFloorInsertProjectCommand(input.command);
|
assertProjectFloorInsertProjectCommand(input.command);
|
||||||
return this.projectLocationStructureStore.execute({
|
return this.projectLocationStructureStore.execute({
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
export type ElectricalPhaseType = "single_phase" | "three_phase";
|
||||||
|
|
||||||
|
export function isElectricalPhaseType(
|
||||||
|
value: unknown
|
||||||
|
): value is ElectricalPhaseType {
|
||||||
|
return value === "single_phase" || value === "three_phase";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeKnownElectricalPhaseType(
|
||||||
|
value: string | null
|
||||||
|
): ElectricalPhaseType | null {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const normalized = value
|
||||||
|
.trim()
|
||||||
|
.toLocaleLowerCase("de-DE")
|
||||||
|
.replaceAll("-", "")
|
||||||
|
.replaceAll("_", "");
|
||||||
|
if (
|
||||||
|
["1ph", "1phase", "1phasig", "einphasig", "singlephase"].includes(
|
||||||
|
normalized
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "single_phase";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
["3ph", "3phase", "3phasig", "dreiphasig", "threephase"].includes(
|
||||||
|
normalized
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "three_phase";
|
||||||
|
}
|
||||||
|
throw new Error(`Unknown electrical phase type: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectVoltageSettings {
|
||||||
|
singlePhaseVoltageV: number;
|
||||||
|
threePhaseVoltageV: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProjectVoltage(
|
||||||
|
phaseType: ElectricalPhaseType,
|
||||||
|
settings: ProjectVoltageSettings
|
||||||
|
) {
|
||||||
|
return phaseType === "three_phase"
|
||||||
|
? settings.threePhaseVoltageV
|
||||||
|
: settings.singlePhaseVoltageV;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCircuitPhaseType(
|
||||||
|
sectionKey: string,
|
||||||
|
devicePhaseTypes: ReadonlyArray<string | null | undefined> = []
|
||||||
|
): ElectricalPhaseType {
|
||||||
|
if (sectionKey === "three_phase") {
|
||||||
|
return "three_phase";
|
||||||
|
}
|
||||||
|
if (sectionKey === "lighting" || sectionKey === "single_phase") {
|
||||||
|
return "single_phase";
|
||||||
|
}
|
||||||
|
const assignedPhaseTypes = devicePhaseTypes.filter(
|
||||||
|
isElectricalPhaseType
|
||||||
|
);
|
||||||
|
return assignedPhaseTypes.length > 0 &&
|
||||||
|
assignedPhaseTypes.every((phaseType) => phaseType === "three_phase")
|
||||||
|
? "three_phase"
|
||||||
|
: "single_phase";
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
inferProjectDeviceSectionKey,
|
inferProjectDeviceSectionKey,
|
||||||
isProjectDevicePlacementValid,
|
isProjectDevicePlacementValid,
|
||||||
} from "../../domain/services/project-device-placement.service";
|
} from "../../domain/services/project-device-placement.service";
|
||||||
|
import {
|
||||||
|
resolveCircuitPhaseType,
|
||||||
|
resolveProjectVoltage,
|
||||||
|
} from "../../domain/services/project-voltage.service";
|
||||||
import {
|
import {
|
||||||
getInsertionSortOrder,
|
getInsertionSortOrder,
|
||||||
resolveGridInsertionIntent,
|
resolveGridInsertionIntent,
|
||||||
@@ -21,7 +25,13 @@ import {
|
|||||||
buildDeviceRowEditPatch,
|
buildDeviceRowEditPatch,
|
||||||
defaultVisibleColumnKeys,
|
defaultVisibleColumnKeys,
|
||||||
deviceFieldKeys,
|
deviceFieldKeys,
|
||||||
|
formatPhaseTypeLabel,
|
||||||
formatValue,
|
formatValue,
|
||||||
|
getCircuitSectionLabel,
|
||||||
|
getProjectColumnLayoutStorageKey,
|
||||||
|
isGridEditorControlTarget,
|
||||||
|
normalizeColumnOrder,
|
||||||
|
parseStoredColumnLayout,
|
||||||
} from "../utils/circuit-grid-model";
|
} from "../utils/circuit-grid-model";
|
||||||
import {
|
import {
|
||||||
buildCircuitDeviceRowInsertSnapshot,
|
buildCircuitDeviceRowInsertSnapshot,
|
||||||
@@ -125,7 +135,8 @@ type CircuitReorderDropIntent =
|
|||||||
| { kind: "after-circuit"; sectionId: string; targetCircuitId: string; valid: boolean }
|
| { kind: "after-circuit"; sectionId: string; targetCircuitId: string; valid: boolean }
|
||||||
| { kind: "section-end"; sectionId: string; valid: boolean };
|
| { kind: "section-end"; sectionId: string; valid: boolean };
|
||||||
|
|
||||||
const COLUMN_LAYOUT_STORAGE_KEY = "circuitTreeEditor.columnLayout.v1";
|
const LEGACY_COLUMN_LAYOUT_STORAGE_KEY =
|
||||||
|
"circuitTreeEditor.columnLayout.v1";
|
||||||
|
|
||||||
function normalizeUiError(err: unknown): string {
|
function normalizeUiError(err: unknown): string {
|
||||||
const message = err instanceof Error ? err.message : "Der Vorgang ist fehlgeschlagen.";
|
const message = err instanceof Error ? err.message : "Der Vorgang ist fehlgeschlagen.";
|
||||||
@@ -149,14 +160,8 @@ function normalizeUiError(err: unknown): string {
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPhaseTypeLabel(value: string): string {
|
function getFullColumnLabel(column: ColumnDef): string {
|
||||||
if (value === "three_phase") {
|
return column.fullLabel ?? column.label;
|
||||||
return "Dreiphasig";
|
|
||||||
}
|
|
||||||
if (value === "single_phase") {
|
|
||||||
return "Einphasig";
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPrintableKey(event: KeyboardEvent<HTMLElement>) {
|
function isPrintableKey(event: KeyboardEvent<HTMLElement>) {
|
||||||
@@ -196,6 +201,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
|
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
|
||||||
|
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
|
||||||
|
useState(false);
|
||||||
const [projectDeviceSearch, setProjectDeviceSearch] = useState("");
|
const [projectDeviceSearch, setProjectDeviceSearch] = useState("");
|
||||||
const [selectedProjectDeviceId, setSelectedProjectDeviceId] = useState<string | null>(null);
|
const [selectedProjectDeviceId, setSelectedProjectDeviceId] = useState<string | null>(null);
|
||||||
const [targetSectionId, setTargetSectionId] = useState<string | null>(null);
|
const [targetSectionId, setTargetSectionId] = useState<string | null>(null);
|
||||||
@@ -220,6 +227,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const [filterValueSearch, setFilterValueSearch] = useState("");
|
const [filterValueSearch, setFilterValueSearch] = useState("");
|
||||||
const [visibleColumnKeys, setVisibleColumnKeys] = useState<CellKey[]>(defaultVisibleColumnKeys);
|
const [visibleColumnKeys, setVisibleColumnKeys] = useState<CellKey[]>(defaultVisibleColumnKeys);
|
||||||
const [columnOrder, setColumnOrder] = useState<CellKey[]>(allColumns.map((column) => column.key));
|
const [columnOrder, setColumnOrder] = useState<CellKey[]>(allColumns.map((column) => column.key));
|
||||||
|
const [
|
||||||
|
loadedColumnLayoutProjectId,
|
||||||
|
setLoadedColumnLayoutProjectId,
|
||||||
|
] = useState<string | null>(null);
|
||||||
const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false);
|
const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false);
|
||||||
const [columnSearch, setColumnSearch] = useState("");
|
const [columnSearch, setColumnSearch] = useState("");
|
||||||
const [draggingColumnKey, setDraggingColumnKey] = useState<CellKey | null>(null);
|
const [draggingColumnKey, setDraggingColumnKey] = useState<CellKey | null>(null);
|
||||||
@@ -229,7 +240,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const pendingSelectedDeviceRowIdsAfterReload = useRef<string[] | null>(null);
|
const pendingSelectedDeviceRowIdsAfterReload = useRef<string[] | null>(null);
|
||||||
const pendingSelectedCircuitIdsAfterReload = useRef<string[] | null>(null);
|
const pendingSelectedCircuitIdsAfterReload = useRef<string[] | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | HTMLSelectElement | null>(null);
|
||||||
const focusTokenRef = useRef(1);
|
const focusTokenRef = useRef(1);
|
||||||
const projectRevisionRef = useRef<number | null>(null);
|
const projectRevisionRef = useRef<number | null>(null);
|
||||||
|
|
||||||
@@ -265,13 +276,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeColumnOrder(keys: CellKey[]) {
|
|
||||||
const unique = [...new Set(keys)];
|
|
||||||
const allKeys = allColumns.map((column) => column.key);
|
|
||||||
const merged = [...unique, ...allKeys.filter((key) => !unique.includes(key))];
|
|
||||||
return ["equipmentIdentifier" as CellKey, ...merged.filter((key) => key !== "equipmentIdentifier")];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial/identity-change tree load for current route context.
|
// Initial/identity-change tree load for current route context.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadTree({ showLoading: true });
|
void loadTree({ showLoading: true });
|
||||||
@@ -290,32 +294,48 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setLoadedColumnLayoutProjectId(null);
|
||||||
|
setColumnOrder(allColumns.map((column) => column.key));
|
||||||
|
setVisibleColumnKeys(defaultVisibleColumnKeys);
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(COLUMN_LAYOUT_STORAGE_KEY);
|
const parsed = parseStoredColumnLayout(
|
||||||
if (!raw) {
|
localStorage.getItem(
|
||||||
|
getProjectColumnLayoutStorageKey(projectId)
|
||||||
|
) ??
|
||||||
|
localStorage.getItem(
|
||||||
|
LEGACY_COLUMN_LAYOUT_STORAGE_KEY
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (!parsed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parsed = JSON.parse(raw) as { order?: string[]; visible?: string[] };
|
setColumnOrder(parsed.order);
|
||||||
const validKeys = new Set(allColumns.map((column) => column.key));
|
setVisibleColumnKeys(parsed.visible);
|
||||||
const parsedOrder = (parsed.order ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey));
|
|
||||||
const parsedVisible = (parsed.visible ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey));
|
|
||||||
if (!parsedOrder.length || !parsedVisible.length || !parsedVisible.includes("equipmentIdentifier")) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setColumnOrder(normalizeColumnOrder(parsedOrder));
|
|
||||||
setVisibleColumnKeys(parsedVisible);
|
|
||||||
} catch {
|
} catch {
|
||||||
// ignore invalid local storage and use defaults
|
// ignore invalid local storage and use defaults
|
||||||
|
} finally {
|
||||||
|
setLoadedColumnLayoutProjectId(projectId);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (loadedColumnLayoutProjectId !== projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
order: columnOrder,
|
order: columnOrder,
|
||||||
visible: visibleColumnKeys,
|
visible: visibleColumnKeys,
|
||||||
};
|
};
|
||||||
localStorage.setItem(COLUMN_LAYOUT_STORAGE_KEY, JSON.stringify(payload));
|
localStorage.setItem(
|
||||||
}, [columnOrder, visibleColumnKeys]);
|
getProjectColumnLayoutStorageKey(projectId),
|
||||||
|
JSON.stringify(payload)
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
columnOrder,
|
||||||
|
loadedColumnLayoutProjectId,
|
||||||
|
projectId,
|
||||||
|
visibleColumnKeys,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const visible = new Set(visibleColumnKeys);
|
const visible = new Set(visibleColumnKeys);
|
||||||
@@ -443,7 +463,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
{activeColumnFilters.map(({ column, values }) => {
|
{activeColumnFilters.map(({ column, values }) => {
|
||||||
const shownValues = values.slice(0, 2).join(", ");
|
const shownValues = values
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((value) =>
|
||||||
|
column.key === "phaseType"
|
||||||
|
? formatPhaseTypeLabel(value)
|
||||||
|
: value
|
||||||
|
)
|
||||||
|
.join(", ");
|
||||||
const valueSummary = values.length > 2 ? `${shownValues} +${values.length - 2}` : shownValues;
|
const valueSummary = values.length > 2 ? `${shownValues} +${values.length - 2}` : shownValues;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -465,6 +492,51 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderDistributionPowerSummary() {
|
||||||
|
if (!data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="distribution-power-summary"
|
||||||
|
aria-label="Leistungszusammenfassung des Verteilers"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span>Gesamtleistung Verteiler</span>
|
||||||
|
<strong>
|
||||||
|
{formatValue(
|
||||||
|
data.distributionBoardTotalPower,
|
||||||
|
"rowTotalPower"
|
||||||
|
)}{" "}
|
||||||
|
kW
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Verteilerweiter Gleichzeitigkeitsfaktor</span>
|
||||||
|
<strong>
|
||||||
|
{formatValue(
|
||||||
|
data.distributionBoardSimultaneityFactor,
|
||||||
|
"simultaneityFactor"
|
||||||
|
)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
Gesamtleistung Verteiler unter Berücksichtigung des
|
||||||
|
Gleichzeitigkeitsfaktors
|
||||||
|
</span>
|
||||||
|
<strong>
|
||||||
|
{formatValue(
|
||||||
|
data.distributionBoardTotalPowerWithSimultaneityFactor,
|
||||||
|
"rowTotalPower"
|
||||||
|
)}{" "}
|
||||||
|
kW
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function closeColumnSettingsMenu() {
|
function closeColumnSettingsMenu() {
|
||||||
setIsColumnMenuOpen(false);
|
setIsColumnMenuOpen(false);
|
||||||
setColumnSearch("");
|
setColumnSearch("");
|
||||||
@@ -486,7 +558,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
|
|
||||||
const search = columnSearch.trim().toLocaleLowerCase("de-DE");
|
const search = columnSearch.trim().toLocaleLowerCase("de-DE");
|
||||||
const matchingColumns = orderedColumns.filter((column) =>
|
const matchingColumns = orderedColumns.filter((column) =>
|
||||||
column.label.toLocaleLowerCase("de-DE").includes(search)
|
`${column.label} ${getFullColumnLabel(column)}`
|
||||||
|
.toLocaleLowerCase("de-DE")
|
||||||
|
.includes(search)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -530,7 +604,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
onClick={() => toggleColumnVisibility(column.key)}
|
onClick={() => toggleColumnVisibility(column.key)}
|
||||||
>
|
>
|
||||||
<span className="selection-marker" aria-hidden="true">{visible ? "✓" : ""}</span>
|
<span className="selection-marker" aria-hidden="true">{visible ? "✓" : ""}</span>
|
||||||
<span>{column.label}</span>
|
<span>{getFullColumnLabel(column)}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="column-settings-order">
|
<div className="column-settings-order">
|
||||||
<button type="button" onClick={() => moveColumn(column.key, -1)} disabled={column.locked} title="Spalte nach links verschieben">
|
<button type="button" onClick={() => moveColumn(column.key, -1)} disabled={column.locked} title="Spalte nach links verschieben">
|
||||||
@@ -817,6 +891,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
values: CreateCircuitInputDto,
|
values: CreateCircuitInputDto,
|
||||||
deviceRowValues: CreateCircuitDeviceRowInputDto[] = []
|
deviceRowValues: CreateCircuitDeviceRowInputDto[] = []
|
||||||
) {
|
) {
|
||||||
|
const section = data?.sections.find(
|
||||||
|
(entry) => entry.id === values.sectionId
|
||||||
|
);
|
||||||
|
if (!section || !data) {
|
||||||
|
throw new Error("Bereich wurde nicht gefunden.");
|
||||||
|
}
|
||||||
|
const voltage = resolveProjectVoltage(
|
||||||
|
resolveCircuitPhaseType(
|
||||||
|
section.key,
|
||||||
|
deviceRowValues.map((row) => row.phaseType)
|
||||||
|
),
|
||||||
|
data
|
||||||
|
);
|
||||||
const circuitId = crypto.randomUUID();
|
const circuitId = crypto.randomUUID();
|
||||||
const deviceRows = deviceRowValues.map((row, index) =>
|
const deviceRows = deviceRowValues.map((row, index) =>
|
||||||
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
|
createDeviceRowSnapshot(circuitId, row, (index + 1) * 10)
|
||||||
@@ -824,7 +911,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return buildCircuitInsertSnapshot({
|
return buildCircuitInsertSnapshot({
|
||||||
id: circuitId,
|
id: circuitId,
|
||||||
circuitListId,
|
circuitListId,
|
||||||
values,
|
values: { ...values, voltage },
|
||||||
deviceRows,
|
deviceRows,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1194,9 +1281,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
inputRef.current.focus();
|
inputRef.current.focus();
|
||||||
if (editingCell.mode === "selectExisting") {
|
if (
|
||||||
|
inputRef.current instanceof HTMLInputElement &&
|
||||||
|
editingCell.mode === "selectExisting"
|
||||||
|
) {
|
||||||
inputRef.current.select();
|
inputRef.current.select();
|
||||||
} else {
|
} else if (inputRef.current instanceof HTMLInputElement) {
|
||||||
const len = inputRef.current.value.length;
|
const len = inputRef.current.value.length;
|
||||||
inputRef.current.setSelectionRange(len, len);
|
inputRef.current.setSelectionRange(len, len);
|
||||||
}
|
}
|
||||||
@@ -1226,7 +1316,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Spreadsheet behavior: typing on a selected cell starts overwrite mode.
|
// Spreadsheet behavior: typing on a selected cell starts overwrite mode.
|
||||||
const currentDisplay = mode === "replaceWithTypedChar" ? typedChar ?? "" : String(visibleCell.value ?? "");
|
const currentDisplay =
|
||||||
|
mode === "replaceWithTypedChar" && cell.cellKey !== "phaseType"
|
||||||
|
? typedChar ?? ""
|
||||||
|
: String(visibleCell.value ?? "");
|
||||||
focusTokenRef.current += 1;
|
focusTokenRef.current += 1;
|
||||||
setEditingCell({
|
setEditingCell({
|
||||||
...cell,
|
...cell,
|
||||||
@@ -1365,7 +1458,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Commits the active editor input through command history so edits remain undoable.
|
// Commits the active editor input through command history so edits remain undoable.
|
||||||
async function commitEdit(direction: SaveDirection = "stay") {
|
async function commitEdit(
|
||||||
|
direction: SaveDirection = "stay",
|
||||||
|
draftOverride?: string
|
||||||
|
) {
|
||||||
if (!editingCell) {
|
if (!editingCell) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1375,10 +1471,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
setEditingCell(null);
|
setEditingCell(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const draft = draftOverride ?? editingCell.draft;
|
||||||
if (
|
if (
|
||||||
editingCell.cellKey === "equipmentIdentifier" &&
|
editingCell.cellKey === "equipmentIdentifier" &&
|
||||||
row.circuit &&
|
row.circuit &&
|
||||||
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, editingCell.draft)
|
hasEquipmentIdentifierConflict(allCircuits, row.circuit.id, draft)
|
||||||
) {
|
) {
|
||||||
setError("Dieses Betriebsmittelkennzeichen ist bereits vorhanden. Die Änderung wurde nicht gespeichert.");
|
setError("Dieses Betriebsmittelkennzeichen ist bereits vorhanden. Die Änderung wurde nicht gespeichert.");
|
||||||
return;
|
return;
|
||||||
@@ -1405,7 +1502,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const command: HistoryCommand = {
|
const command: HistoryCommand = {
|
||||||
label: "Aus freier Zeile erstellen",
|
label: "Aus freier Zeile erstellen",
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
const selection = await createFromPlaceholder(row.sectionId, editingCell.cellKey, editingCell.draft);
|
const selection = await createFromPlaceholder(
|
||||||
|
row.sectionId,
|
||||||
|
editingCell.cellKey,
|
||||||
|
draft
|
||||||
|
);
|
||||||
targetSelection = selection;
|
targetSelection = selection;
|
||||||
return nextSelectionIntent();
|
return nextSelectionIntent();
|
||||||
},
|
},
|
||||||
@@ -1416,7 +1517,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cell.kind === "circuitField" && row.circuit) {
|
if (cell.kind === "circuitField" && row.circuit) {
|
||||||
const next = editingCell.draft;
|
const next = draft;
|
||||||
const command: HistoryCommand = {
|
const command: HistoryCommand = {
|
||||||
label: "Stromkreisfeld bearbeiten",
|
label: "Stromkreisfeld bearbeiten",
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
@@ -1430,7 +1531,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cell.kind === "deviceField" && row.device) {
|
if (cell.kind === "deviceField" && row.device) {
|
||||||
const next = editingCell.draft;
|
const next = draft;
|
||||||
const command: HistoryCommand = {
|
const command: HistoryCommand = {
|
||||||
label: "Gerätefeld bearbeiten",
|
label: "Gerätefeld bearbeiten",
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
@@ -1444,7 +1545,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cell.kind === "deviceField" && row.rowType === "reserveCircuit" && row.circuit) {
|
if (cell.kind === "deviceField" && row.rowType === "reserveCircuit" && row.circuit) {
|
||||||
const next = editingCell.draft;
|
const next = draft;
|
||||||
const command: HistoryCommand = {
|
const command: HistoryCommand = {
|
||||||
label: "Gerätezeile im Reservestromkreis erstellen",
|
label: "Gerätezeile im Reservestromkreis erstellen",
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
@@ -2452,6 +2553,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
{renderColumnSettingsMenu("col-empty")}
|
{renderColumnSettingsMenu("col-empty")}
|
||||||
</div>
|
</div>
|
||||||
{renderActiveViewSummary()}
|
{renderActiveViewSummary()}
|
||||||
|
{renderDistributionPowerSummary()}
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="notice error editor-error-notice" role="alert">
|
<div className="notice error editor-error-notice" role="alert">
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
@@ -2537,6 +2639,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
|
<button type="button" onClick={toggleColumnSettingsMenu} aria-expanded={isColumnMenuOpen}>
|
||||||
Spalten
|
Spalten
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
aria-controls="project-device-drawer"
|
||||||
|
aria-expanded={isProjectDeviceDrawerOpen}
|
||||||
|
className="project-device-drawer-toggle"
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setIsProjectDeviceDrawerOpen((current) => !current)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isProjectDeviceDrawerOpen
|
||||||
|
? "Projektgeräte schließen"
|
||||||
|
: `Projektgeräte öffnen (${projectDevices.length})`}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clearSortAndFilters}
|
onClick={clearSortAndFilters}
|
||||||
@@ -2547,6 +2662,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
{renderColumnSettingsMenu("col")}
|
{renderColumnSettingsMenu("col")}
|
||||||
</div>
|
</div>
|
||||||
{renderActiveViewSummary()}
|
{renderActiveViewSummary()}
|
||||||
|
{renderDistributionPowerSummary()}
|
||||||
{hasActiveSortOrFilter ? (
|
{hasActiveSortOrFilter ? (
|
||||||
<div className="notice muted">Sortierung und Filter verändern nur die Ansicht. Die Neunummerierung ist währenddessen deaktiviert.</div>
|
<div className="notice muted">Sortierung und Filter verändern nur die Ansicht. Die Neunummerierung ist währenddessen deaktiviert.</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -2566,8 +2682,25 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
) : null}
|
) : null}
|
||||||
{isSaving ? <div className="notice info">Wird gespeichert …</div> : null}
|
{isSaving ? <div className="notice info">Wird gespeichert …</div> : null}
|
||||||
<div className="tree-editor-layout">
|
<div className="tree-editor-layout">
|
||||||
<aside className="project-device-sidebar">
|
{isProjectDeviceDrawerOpen ? (
|
||||||
|
<aside
|
||||||
|
className="project-device-sidebar project-device-drawer"
|
||||||
|
id="project-device-drawer"
|
||||||
|
>
|
||||||
|
<div className="project-device-drawer-header">
|
||||||
|
<div>
|
||||||
<h3>Projektgeräte</h3>
|
<h3>Projektgeräte</h3>
|
||||||
|
<span>{projectDevices.length} verfügbar</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="Projektgeräte schließen"
|
||||||
|
disabled={draggingProjectDeviceId !== null}
|
||||||
|
onClick={() => setIsProjectDeviceDrawerOpen(false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Name oder Anzeigename suchen …"
|
placeholder="Name oder Anzeigename suchen …"
|
||||||
@@ -2604,10 +2737,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
<strong>{device.displayName || device.name}</strong>
|
<strong>{device.displayName || device.name}</strong>
|
||||||
<span>Name: {device.name}</span>
|
<span>Name: {device.name}</span>
|
||||||
<span>Phasenart: {formatPhaseTypeLabel(device.phaseType)}</span>
|
<span>Phasenart: {formatPhaseTypeLabel(device.phaseType)}</span>
|
||||||
<span>Anzahl: {device.quantity}</span>
|
<span>Anzahl: {formatValue(device.quantity, "quantity")}</span>
|
||||||
<span>Leistung/Gerät: {device.powerPerUnit}</span>
|
<span>
|
||||||
<span>Gleichzeitigkeit: {device.simultaneityFactor}</span>
|
Leistung/Gerät: {formatValue(device.powerPerUnit, "powerPerUnit")} kW
|
||||||
<span>Gesamtleistung: {device.totalPower}</span>
|
</span>
|
||||||
|
<span>
|
||||||
|
Gleichzeitigkeit:{" "}
|
||||||
|
{formatValue(device.simultaneityFactor, "simultaneityFactor")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Gesamtleistung: {formatValue(device.totalPower, "rowTotalPower")} kW
|
||||||
|
</span>
|
||||||
<span>Kostengruppe: {device.costGroup || "-"}</span>
|
<span>Kostengruppe: {device.costGroup || "-"}</span>
|
||||||
<span>Kategorie: {device.category || "-"}</span>
|
<span>Kategorie: {device.category || "-"}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -2626,14 +2766,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const valid = !selectedProjectDevice || isProjectDevicePlacementValid(selectedProjectDevice, section);
|
const valid = !selectedProjectDevice || isProjectDevicePlacementValid(selectedProjectDevice, section);
|
||||||
return (
|
return (
|
||||||
<option key={section.id} value={section.id} disabled={!valid}>
|
<option key={section.id} value={section.id} disabled={!valid}>
|
||||||
{section.displayName}{valid ? "" : " (nicht zulässig)"}
|
{getCircuitSectionLabel(section)}
|
||||||
|
{valid ? "" : " (nicht zulässig)"}
|
||||||
</option>
|
</option>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{selectedProjectDevice && suggestedSection ? (
|
{selectedProjectDevice && suggestedSection ? (
|
||||||
<p className="notice muted">Vorgeschlagener Bereich: {suggestedSection.displayName}</p>
|
<p className="notice muted">
|
||||||
|
Vorgeschlagener Bereich:{" "}
|
||||||
|
{getCircuitSectionLabel(suggestedSection)}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2668,6 +2812,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
) : null}
|
||||||
<div
|
<div
|
||||||
className="tree-grid-wrap"
|
className="tree-grid-wrap"
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
@@ -2690,8 +2835,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="header-sort-btn"
|
className="header-sort-btn"
|
||||||
aria-label={`${column.label} sortieren`}
|
aria-label={`${getFullColumnLabel(column)} sortieren`}
|
||||||
title="Zum Sortieren klicken"
|
title={`${getFullColumnLabel(column)} – zum Sortieren klicken`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSortState((current) => {
|
setSortState((current) => {
|
||||||
if (!current || current.key !== column.key) {
|
if (!current || current.key !== column.key) {
|
||||||
@@ -2719,7 +2864,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
title={
|
title={
|
||||||
(columnFilters[column.key]?.length ?? 0) > 0
|
(columnFilters[column.key]?.length ?? 0) > 0
|
||||||
? `${columnFilters[column.key]?.length} Filterwert(e) ausgewählt`
|
? `${columnFilters[column.key]?.length} Filterwert(e) ausgewählt`
|
||||||
: `${column.label} filtern`
|
: `${getFullColumnLabel(column)} filtern`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Filtern{(columnFilters[column.key]?.length ?? 0) > 0 ? ` (${columnFilters[column.key]?.length})` : ""}
|
Filtern{(columnFilters[column.key]?.length ?? 0) > 0 ? ` (${columnFilters[column.key]?.length})` : ""}
|
||||||
@@ -2728,7 +2873,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
{openFilterColumn === column.key ? (
|
{openFilterColumn === column.key ? (
|
||||||
<div className="header-filter-menu">
|
<div className="header-filter-menu">
|
||||||
<div className="header-filter-title-row">
|
<div className="header-filter-title-row">
|
||||||
<strong>{column.label} filtern</strong>
|
<strong>{getFullColumnLabel(column)} filtern</strong>
|
||||||
<button type="button" className="header-filter-close" onClick={closeColumnFilterMenu} aria-label="Filter schließen">
|
<button type="button" className="header-filter-close" onClick={closeColumnFilterMenu} aria-label="Filter schließen">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -2742,7 +2887,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
value={filterValueSearch}
|
value={filterValueSearch}
|
||||||
onChange={(event) => setFilterValueSearch(event.target.value)}
|
onChange={(event) => setFilterValueSearch(event.target.value)}
|
||||||
placeholder="Werte suchen …"
|
placeholder="Werte suchen …"
|
||||||
aria-label={`Werte für ${column.label} suchen`}
|
aria-label={`Werte für ${getFullColumnLabel(column)} suchen`}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<div className="header-filter-selection-actions">
|
<div className="header-filter-selection-actions">
|
||||||
@@ -2759,7 +2904,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
</div>
|
</div>
|
||||||
<div className="header-filter-values">
|
<div className="header-filter-values">
|
||||||
{(distinctValuesByColumn[column.key] ?? [])
|
{(distinctValuesByColumn[column.key] ?? [])
|
||||||
.filter((value) => value.toLocaleLowerCase("de-DE").includes(filterValueSearch.trim().toLocaleLowerCase("de-DE")))
|
.filter((value) =>
|
||||||
|
(column.key === "phaseType"
|
||||||
|
? formatPhaseTypeLabel(value)
|
||||||
|
: value
|
||||||
|
)
|
||||||
|
.toLocaleLowerCase("de-DE")
|
||||||
|
.includes(
|
||||||
|
filterValueSearch
|
||||||
|
.trim()
|
||||||
|
.toLocaleLowerCase("de-DE")
|
||||||
|
)
|
||||||
|
)
|
||||||
.map((value) => {
|
.map((value) => {
|
||||||
const selected = filterDraftValues.includes(value);
|
const selected = filterDraftValues.includes(value);
|
||||||
return (
|
return (
|
||||||
@@ -2777,12 +2933,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="selection-marker" aria-hidden="true">{selected ? "✓" : ""}</span>
|
<span className="selection-marker" aria-hidden="true">{selected ? "✓" : ""}</span>
|
||||||
<span>{value}</span>
|
<span>
|
||||||
|
{column.key === "phaseType"
|
||||||
|
? formatPhaseTypeLabel(value)
|
||||||
|
: value}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{(distinctValuesByColumn[column.key] ?? []).filter((value) =>
|
{(distinctValuesByColumn[column.key] ?? []).filter(
|
||||||
value.toLocaleLowerCase("de-DE").includes(filterValueSearch.trim().toLocaleLowerCase("de-DE"))
|
(value) =>
|
||||||
|
(column.key === "phaseType"
|
||||||
|
? formatPhaseTypeLabel(value)
|
||||||
|
: value
|
||||||
|
)
|
||||||
|
.toLocaleLowerCase("de-DE")
|
||||||
|
.includes(
|
||||||
|
filterValueSearch
|
||||||
|
.trim()
|
||||||
|
.toLocaleLowerCase("de-DE")
|
||||||
|
)
|
||||||
).length === 0 ? (
|
).length === 0 ? (
|
||||||
<div className="header-filter-empty">Keine passenden Werte gefunden.</div>
|
<div className="header-filter-empty">Keine passenden Werte gefunden.</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -2879,7 +3049,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
>
|
>
|
||||||
<td colSpan={visibleColumns.length + 1} className="section-drop-cell">
|
<td colSpan={visibleColumns.length + 1} className="section-drop-cell">
|
||||||
<div className="section-content">
|
<div className="section-content">
|
||||||
<strong>{section.displayName}</strong>
|
<div className="section-title">
|
||||||
|
<strong>
|
||||||
|
{getCircuitSectionLabel(section)}
|
||||||
|
</strong>
|
||||||
|
<span>
|
||||||
|
Gesamtleistung Abschnitt:{" "}
|
||||||
|
{formatValue(
|
||||||
|
section.sectionTotalPower,
|
||||||
|
"rowTotalPower"
|
||||||
|
)}{" "}
|
||||||
|
kW
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div className="section-actions">
|
<div className="section-actions">
|
||||||
<button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
|
<button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
|
||||||
Stromkreis hinzufügen
|
Stromkreis hinzufügen
|
||||||
@@ -3304,6 +3486,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
setCircuitReorderIntent(null);
|
setCircuitReorderIntent(null);
|
||||||
}}
|
}}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
|
if (isGridEditorControlTarget(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (cell.editable) {
|
if (cell.editable) {
|
||||||
handleRowSelectionClick(row, column.key, {
|
handleRowSelectionClick(row, column.key, {
|
||||||
ctrlKey: event.ctrlKey,
|
ctrlKey: event.ctrlKey,
|
||||||
@@ -3312,15 +3497,61 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={(event) => {
|
||||||
|
if (isGridEditorControlTarget(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (cell.editable) {
|
if (cell.editable) {
|
||||||
startEdit({ rowKey: row.rowKey, cellKey: column.key }, "selectExisting");
|
startEdit({ rowKey: row.rowKey, cellKey: column.key }, "selectExisting");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isEditing ? (
|
{isEditing && column.key === "phaseType" ? (
|
||||||
|
<select
|
||||||
|
ref={(element) => {
|
||||||
|
inputRef.current = element;
|
||||||
|
}}
|
||||||
|
value={editingCell.draft}
|
||||||
|
aria-label="Phasenart auswählen"
|
||||||
|
onChange={(event) =>
|
||||||
|
void commitEdit("stay", event.target.value)
|
||||||
|
}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
event.preventDefault();
|
||||||
|
void commitEdit("stay");
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
cancelEdit();
|
||||||
|
} else if (event.key === "Tab") {
|
||||||
|
event.preventDefault();
|
||||||
|
void commitEdit(
|
||||||
|
event.shiftKey ? "prev" : "next"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const active =
|
||||||
|
document.activeElement as HTMLElement | null;
|
||||||
|
if (
|
||||||
|
!active ||
|
||||||
|
!containerRef.current?.contains(active)
|
||||||
|
) {
|
||||||
|
setEditingCell(null);
|
||||||
|
focusGridWithoutScroll();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="single_phase">1-phasig</option>
|
||||||
|
<option value="three_phase">3-phasig</option>
|
||||||
|
</select>
|
||||||
|
) : isEditing ? (
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={(element) => {
|
||||||
|
inputRef.current = element;
|
||||||
|
}}
|
||||||
value={editingCell.draft}
|
value={editingCell.draft}
|
||||||
aria-invalid={hasDraftIdentifierConflict}
|
aria-invalid={hasDraftIdentifierConflict}
|
||||||
title={hasDraftIdentifierConflict ? "Dieses Betriebsmittelkennzeichen ist bereits vorhanden." : undefined}
|
title={hasDraftIdentifierConflict ? "Dieses Betriebsmittelkennzeichen ist bereits vorhanden." : undefined}
|
||||||
@@ -3352,7 +3583,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
formatValue(cell.value)
|
formatValue(cell.value, column.key)
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,26 +4,10 @@ import { useEffect, useState } from "react";
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import { getCircuitTree } from "../utils/api";
|
import { getCircuitTree } from "../utils/api";
|
||||||
import type { CircuitTreeCircuitDto, CircuitTreeResponseDto } from "../types";
|
import type { CircuitTreeCircuitDto, CircuitTreeResponseDto } from "../types";
|
||||||
|
import {
|
||||||
function formatNumber(value: number | undefined, digits = 2) {
|
formatValue,
|
||||||
if (value === undefined || Number.isNaN(value)) {
|
getCircuitSectionLabel,
|
||||||
return "-";
|
} from "../utils/circuit-grid-model";
|
||||||
}
|
|
||||||
return new Intl.NumberFormat("de-DE", {
|
|
||||||
minimumFractionDigits: digits,
|
|
||||||
maximumFractionDigits: digits,
|
|
||||||
}).format(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPhaseType(value: string | undefined) {
|
|
||||||
if (value === "three_phase") {
|
|
||||||
return "Dreiphasig";
|
|
||||||
}
|
|
||||||
if (value === "single_phase") {
|
|
||||||
return "Einphasig";
|
|
||||||
}
|
|
||||||
return value ?? "-";
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCircuitSummaryLabel(circuit: CircuitTreeCircuitDto) {
|
function renderCircuitSummaryLabel(circuit: CircuitTreeCircuitDto) {
|
||||||
if (circuit.displayName?.trim()) {
|
if (circuit.displayName?.trim()) {
|
||||||
@@ -86,11 +70,10 @@ export function CircuitTreePreview(props: { projectId: string; circuitListId: st
|
|||||||
<th>Raumnummer</th>
|
<th>Raumnummer</th>
|
||||||
<th>Raumname</th>
|
<th>Raumname</th>
|
||||||
<th className="text-end">Anzahl</th>
|
<th className="text-end">Anzahl</th>
|
||||||
<th className="text-end">Leistung / Gerät</th>
|
<th className="text-end">Leistung / Gerät [kW]</th>
|
||||||
<th className="text-end">Gleichzeitigkeit</th>
|
<th className="text-end">Gleichzeitigkeit</th>
|
||||||
<th className="text-end">cos φ</th>
|
<th className="text-end">cos φ</th>
|
||||||
<th className="text-end">Zeilensumme</th>
|
<th className="text-end">Gesamtsumme [kW]</th>
|
||||||
<th className="text-end">Stromkreissumme</th>
|
|
||||||
<th>Schutzart</th>
|
<th>Schutzart</th>
|
||||||
<th className="text-end">Bemessungsstrom</th>
|
<th className="text-end">Bemessungsstrom</th>
|
||||||
<th>Charakteristik</th>
|
<th>Charakteristik</th>
|
||||||
@@ -117,8 +100,8 @@ function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr className="section-row">
|
<tr className="section-row">
|
||||||
<td colSpan={22}>
|
<td colSpan={21}>
|
||||||
<strong>{section.displayName}</strong>
|
<strong>{getCircuitSectionLabel(section)}</strong>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{section.circuits.map((circuit) => {
|
{section.circuits.map((circuit) => {
|
||||||
@@ -127,14 +110,18 @@ function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number
|
|||||||
<tr key={circuit.id} className="reserve-row">
|
<tr key={circuit.id} className="reserve-row">
|
||||||
<td>{circuit.equipmentIdentifier}</td>
|
<td>{circuit.equipmentIdentifier}</td>
|
||||||
<td>{circuit.displayName?.trim() || "Reserve"}</td>
|
<td>{circuit.displayName?.trim() || "Reserve"}</td>
|
||||||
<td colSpan={12}>-</td>
|
<td colSpan={11}>-</td>
|
||||||
<td className="text-end">{formatNumber(circuit.circuitTotalPower)}</td>
|
<td className="text-end">
|
||||||
|
{formatValue(circuit.circuitTotalPower, "circuitTotalPower")}
|
||||||
|
</td>
|
||||||
<td>{circuit.protectionType ?? "-"}</td>
|
<td>{circuit.protectionType ?? "-"}</td>
|
||||||
<td className="text-end">{formatNumber(circuit.protectionRatedCurrent)}</td>
|
<td className="text-end">
|
||||||
|
{formatValue(circuit.protectionRatedCurrent, "protectionRatedCurrent")}
|
||||||
|
</td>
|
||||||
<td>{circuit.protectionCharacteristic ?? "-"}</td>
|
<td>{circuit.protectionCharacteristic ?? "-"}</td>
|
||||||
<td>{circuit.cableType ?? "-"}</td>
|
<td>{circuit.cableType ?? "-"}</td>
|
||||||
<td>{circuit.cableCrossSection ?? "-"}</td>
|
<td>{circuit.cableCrossSection ?? "-"}</td>
|
||||||
<td className="text-end">{formatNumber(circuit.cableLength)}</td>
|
<td className="text-end">{formatValue(circuit.cableLength, "cableLength")}</td>
|
||||||
<td>{circuit.remark ?? "-"}</td>
|
<td>{circuit.remark ?? "-"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -146,25 +133,28 @@ function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number
|
|||||||
<tr key={circuit.id} className={circuit.isReserve ? "reserve-row" : ""}>
|
<tr key={circuit.id} className={circuit.isReserve ? "reserve-row" : ""}>
|
||||||
<td>{circuit.equipmentIdentifier}</td>
|
<td>{circuit.equipmentIdentifier}</td>
|
||||||
<td>{row.displayName || row.name}</td>
|
<td>{row.displayName || row.name}</td>
|
||||||
<td>{formatPhaseType(row.phaseType)}</td>
|
<td>{formatValue(row.phaseType ?? undefined, "phaseType")}</td>
|
||||||
<td>{row.connectionKind ?? "-"}</td>
|
<td>{row.connectionKind ?? "-"}</td>
|
||||||
<td>{row.costGroup ?? "-"}</td>
|
<td>{row.costGroup ?? "-"}</td>
|
||||||
<td>{row.category ?? "-"}</td>
|
<td>{row.category ?? "-"}</td>
|
||||||
<td>{row.level ?? "-"}</td>
|
<td>{row.level ?? "-"}</td>
|
||||||
<td>{row.roomNumberSnapshot ?? "-"}</td>
|
<td>{row.roomNumberSnapshot ?? "-"}</td>
|
||||||
<td>{row.roomNameSnapshot ?? "-"}</td>
|
<td>{row.roomNameSnapshot ?? "-"}</td>
|
||||||
<td className="text-end">{formatNumber(row.quantity, 0)}</td>
|
<td className="text-end">{formatValue(row.quantity, "quantity")}</td>
|
||||||
<td className="text-end">{formatNumber(row.powerPerUnit)}</td>
|
<td className="text-end">{formatValue(row.powerPerUnit, "powerPerUnit")}</td>
|
||||||
<td className="text-end">{formatNumber(row.simultaneityFactor)}</td>
|
<td className="text-end">
|
||||||
<td className="text-end">{formatNumber(row.cosPhi)}</td>
|
{formatValue(row.simultaneityFactor, "simultaneityFactor")}
|
||||||
<td className="text-end">{formatNumber(row.rowTotalPower)}</td>
|
</td>
|
||||||
<td className="text-end">{formatNumber(circuit.circuitTotalPower)}</td>
|
<td className="text-end">{formatValue(row.cosPhi, "cosPhi")}</td>
|
||||||
|
<td className="text-end">{formatValue(row.rowTotalPower, "rowTotalPower")}</td>
|
||||||
<td>{circuit.protectionType ?? "-"}</td>
|
<td>{circuit.protectionType ?? "-"}</td>
|
||||||
<td className="text-end">{formatNumber(circuit.protectionRatedCurrent)}</td>
|
<td className="text-end">
|
||||||
|
{formatValue(circuit.protectionRatedCurrent, "protectionRatedCurrent")}
|
||||||
|
</td>
|
||||||
<td>{circuit.protectionCharacteristic ?? "-"}</td>
|
<td>{circuit.protectionCharacteristic ?? "-"}</td>
|
||||||
<td>{circuit.cableType ?? "-"}</td>
|
<td>{circuit.cableType ?? "-"}</td>
|
||||||
<td>{circuit.cableCrossSection ?? "-"}</td>
|
<td>{circuit.cableCrossSection ?? "-"}</td>
|
||||||
<td className="text-end">{formatNumber(circuit.cableLength)}</td>
|
<td className="text-end">{formatValue(circuit.cableLength, "cableLength")}</td>
|
||||||
<td>{row.remark ?? circuit.remark ?? "-"}</td>
|
<td>{row.remark ?? circuit.remark ?? "-"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -175,33 +165,38 @@ function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number
|
|||||||
<tr key={`${circuit.id}-summary`} className="summary-row">
|
<tr key={`${circuit.id}-summary`} className="summary-row">
|
||||||
<td>{circuit.equipmentIdentifier}</td>
|
<td>{circuit.equipmentIdentifier}</td>
|
||||||
<td>{renderCircuitSummaryLabel(circuit)}</td>
|
<td>{renderCircuitSummaryLabel(circuit)}</td>
|
||||||
<td colSpan={12}>-</td>
|
<td colSpan={11}>-</td>
|
||||||
<td className="text-end">{formatNumber(circuit.circuitTotalPower)}</td>
|
<td className="text-end">
|
||||||
|
{formatValue(circuit.circuitTotalPower, "circuitTotalPower")}
|
||||||
|
</td>
|
||||||
<td>{circuit.protectionType ?? "-"}</td>
|
<td>{circuit.protectionType ?? "-"}</td>
|
||||||
<td className="text-end">{formatNumber(circuit.protectionRatedCurrent)}</td>
|
<td className="text-end">
|
||||||
|
{formatValue(circuit.protectionRatedCurrent, "protectionRatedCurrent")}
|
||||||
|
</td>
|
||||||
<td>{circuit.protectionCharacteristic ?? "-"}</td>
|
<td>{circuit.protectionCharacteristic ?? "-"}</td>
|
||||||
<td>{circuit.cableType ?? "-"}</td>
|
<td>{circuit.cableType ?? "-"}</td>
|
||||||
<td>{circuit.cableCrossSection ?? "-"}</td>
|
<td>{circuit.cableCrossSection ?? "-"}</td>
|
||||||
<td className="text-end">{formatNumber(circuit.cableLength)}</td>
|
<td className="text-end">{formatValue(circuit.cableLength, "cableLength")}</td>
|
||||||
<td>{circuit.remark ?? "-"}</td>
|
<td>{circuit.remark ?? "-"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{circuit.deviceRows.map((row) => (
|
{circuit.deviceRows.map((row) => (
|
||||||
<tr key={row.id} className="device-row">
|
<tr key={row.id} className="device-row">
|
||||||
<td className="text-muted"> </td>
|
<td className="text-muted"> </td>
|
||||||
<td className="indented-cell">{row.displayName || row.name}</td>
|
<td className="indented-cell">{row.displayName || row.name}</td>
|
||||||
<td>{formatPhaseType(row.phaseType)}</td>
|
<td>{formatValue(row.phaseType ?? undefined, "phaseType")}</td>
|
||||||
<td>{row.connectionKind ?? "-"}</td>
|
<td>{row.connectionKind ?? "-"}</td>
|
||||||
<td>{row.costGroup ?? "-"}</td>
|
<td>{row.costGroup ?? "-"}</td>
|
||||||
<td>{row.category ?? "-"}</td>
|
<td>{row.category ?? "-"}</td>
|
||||||
<td>{row.level ?? "-"}</td>
|
<td>{row.level ?? "-"}</td>
|
||||||
<td>{row.roomNumberSnapshot ?? "-"}</td>
|
<td>{row.roomNumberSnapshot ?? "-"}</td>
|
||||||
<td>{row.roomNameSnapshot ?? "-"}</td>
|
<td>{row.roomNameSnapshot ?? "-"}</td>
|
||||||
<td className="text-end">{formatNumber(row.quantity, 0)}</td>
|
<td className="text-end">{formatValue(row.quantity, "quantity")}</td>
|
||||||
<td className="text-end">{formatNumber(row.powerPerUnit)}</td>
|
<td className="text-end">{formatValue(row.powerPerUnit, "powerPerUnit")}</td>
|
||||||
<td className="text-end">{formatNumber(row.simultaneityFactor)}</td>
|
<td className="text-end">
|
||||||
<td className="text-end">{formatNumber(row.cosPhi)}</td>
|
{formatValue(row.simultaneityFactor, "simultaneityFactor")}
|
||||||
<td className="text-end">{formatNumber(row.rowTotalPower)}</td>
|
</td>
|
||||||
<td className="text-end">-</td>
|
<td className="text-end">{formatValue(row.cosPhi, "cosPhi")}</td>
|
||||||
|
<td className="text-end">{formatValue(row.rowTotalPower, "rowTotalPower")}</td>
|
||||||
<td>-</td>
|
<td>-</td>
|
||||||
<td className="text-end">-</td>
|
<td className="text-end">-</td>
|
||||||
<td>-</td>
|
<td>-</td>
|
||||||
@@ -216,7 +211,7 @@ function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number
|
|||||||
})}
|
})}
|
||||||
<tr className="placeholder-row">
|
<tr className="placeholder-row">
|
||||||
<td>-frei-</td>
|
<td>-frei-</td>
|
||||||
<td colSpan={21}>Freie Zeile</td>
|
<td colSpan={20}>Freie Zeile</td>
|
||||||
</tr>
|
</tr>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { type FormEvent, type ReactNode } from "react";
|
||||||
|
|
||||||
|
interface FormModalProps {
|
||||||
|
children: ReactNode;
|
||||||
|
description?: string;
|
||||||
|
isSaving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
|
submitDisabled?: boolean;
|
||||||
|
submitLabel: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormModal({
|
||||||
|
children,
|
||||||
|
description,
|
||||||
|
isSaving,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
submitDisabled,
|
||||||
|
submitLabel,
|
||||||
|
title,
|
||||||
|
}: FormModalProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
aria-modal="true"
|
||||||
|
className="modal fade show d-block"
|
||||||
|
role="dialog"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||||
|
<form className="modal-content" onSubmit={onSubmit}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<h2 className="modal-title fs-5">{title}</h2>
|
||||||
|
{description ? (
|
||||||
|
<p className="text-secondary small mb-0">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="btn-close"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">{children}</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={isSaving || submitDisabled}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{isSaving ? "Wird gespeichert …" : submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-backdrop fade show" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { FormEvent, useState } from "react";
|
||||||
|
import type {
|
||||||
|
CreateProjectDeviceInput,
|
||||||
|
GlobalDeviceDto,
|
||||||
|
ProjectDeviceDto,
|
||||||
|
} from "../types";
|
||||||
|
import { FormModal } from "./form-modal";
|
||||||
|
|
||||||
|
interface ProjectDeviceModalProps {
|
||||||
|
globalDevices: GlobalDeviceDto[];
|
||||||
|
initialDevice?: ProjectDeviceDto;
|
||||||
|
isSaving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onImportGlobal: (globalDeviceId: string) => Promise<void>;
|
||||||
|
onSave: (input: CreateProjectDeviceInput) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectDeviceModal({
|
||||||
|
globalDevices,
|
||||||
|
initialDevice,
|
||||||
|
isSaving,
|
||||||
|
onClose,
|
||||||
|
onImportGlobal,
|
||||||
|
onSave,
|
||||||
|
}: ProjectDeviceModalProps) {
|
||||||
|
const [values, setValues] = useState(() => toFormValues(initialDevice));
|
||||||
|
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
|
||||||
|
|
||||||
|
function update(key: keyof typeof values, value: string) {
|
||||||
|
setValues((current) => ({ ...current, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
await onSave({
|
||||||
|
name: values.name.trim(),
|
||||||
|
displayName: values.displayName.trim() || values.name.trim(),
|
||||||
|
phaseType:
|
||||||
|
values.phaseType === "three_phase" ? "three_phase" : "single_phase",
|
||||||
|
connectionKind: optionalString(values.connectionKind),
|
||||||
|
costGroup: optionalString(values.costGroup),
|
||||||
|
category: optionalString(values.category),
|
||||||
|
quantity: Number(values.quantity),
|
||||||
|
powerPerUnit: Number(values.powerPerUnit),
|
||||||
|
simultaneityFactor: Number(values.simultaneityFactor),
|
||||||
|
cosPhi: optionalNumber(values.cosPhi),
|
||||||
|
remark: optionalString(values.remark),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid =
|
||||||
|
values.name.trim().length > 0 &&
|
||||||
|
Number(values.quantity) >= 0 &&
|
||||||
|
Number(values.powerPerUnit) >= 0 &&
|
||||||
|
Number(values.simultaneityFactor) >= 0 &&
|
||||||
|
Number(values.simultaneityFactor) <= 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormModal
|
||||||
|
description={
|
||||||
|
initialDevice
|
||||||
|
? "Kanonische Gerätedaten bearbeiten; verknüpfte Stromkreiszeilen werden nicht automatisch überschrieben."
|
||||||
|
: "Ein Gerät manuell anlegen oder aus der globalen Bibliothek übernehmen."
|
||||||
|
}
|
||||||
|
isSaving={isSaving}
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
submitDisabled={!isValid}
|
||||||
|
submitLabel={initialDevice ? "Änderungen speichern" : "Gerät anlegen"}
|
||||||
|
title={initialDevice ? "Projektgerät bearbeiten" : "Projektgerät hinzufügen"}
|
||||||
|
>
|
||||||
|
{!initialDevice && globalDevices.length > 0 ? (
|
||||||
|
<div className="border rounded p-3 mb-4">
|
||||||
|
<label className="form-label" htmlFor="global-project-device">
|
||||||
|
Aus globaler Gerätebibliothek
|
||||||
|
</label>
|
||||||
|
<div className="input-group">
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="global-project-device"
|
||||||
|
onChange={(event) => setSelectedGlobalDeviceId(event.target.value)}
|
||||||
|
value={selectedGlobalDeviceId}
|
||||||
|
>
|
||||||
|
<option value="">Gerät auswählen</option>
|
||||||
|
{globalDevices.map((device) => (
|
||||||
|
<option key={device.id} value={device.id}>
|
||||||
|
{device.displayName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary"
|
||||||
|
disabled={isSaving || !selectedGlobalDeviceId}
|
||||||
|
onClick={() => void onImportGlobal(selectedGlobalDeviceId)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Übernehmen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="row g-3">
|
||||||
|
<TextField
|
||||||
|
id="device-name"
|
||||||
|
label="Interner Name"
|
||||||
|
onChange={(value) => update("name", value)}
|
||||||
|
required
|
||||||
|
value={values.name}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="device-display-name"
|
||||||
|
label="Anzeigename"
|
||||||
|
onChange={(value) => update("displayName", value)}
|
||||||
|
value={values.displayName}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="device-category"
|
||||||
|
label="Kategorie"
|
||||||
|
onChange={(value) => update("category", value)}
|
||||||
|
value={values.category}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="device-connection"
|
||||||
|
label="Anschlussart"
|
||||||
|
onChange={(value) => update("connectionKind", value)}
|
||||||
|
value={values.connectionKind}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="device-cost-group"
|
||||||
|
label="Kostengruppe"
|
||||||
|
onChange={(value) => update("costGroup", value)}
|
||||||
|
value={values.costGroup}
|
||||||
|
/>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="device-phase-type">
|
||||||
|
Phasenart
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="device-phase-type"
|
||||||
|
onChange={(event) => update("phaseType", event.target.value)}
|
||||||
|
value={values.phaseType}
|
||||||
|
>
|
||||||
|
<option value="single_phase">1-phasig</option>
|
||||||
|
<option value="three_phase">3-phasig</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<NumberField
|
||||||
|
id="device-quantity"
|
||||||
|
label="Anzahl"
|
||||||
|
min="0"
|
||||||
|
onChange={(value) => update("quantity", value)}
|
||||||
|
value={values.quantity}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
id="device-power"
|
||||||
|
label="Leistung je Stück [kW]"
|
||||||
|
min="0"
|
||||||
|
onChange={(value) => update("powerPerUnit", value)}
|
||||||
|
step="0.01"
|
||||||
|
value={values.powerPerUnit}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
id="device-simultaneity"
|
||||||
|
label="Gleichzeitigkeitsfaktor"
|
||||||
|
max="1"
|
||||||
|
min="0"
|
||||||
|
onChange={(value) => update("simultaneityFactor", value)}
|
||||||
|
step="0.01"
|
||||||
|
value={values.simultaneityFactor}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
id="device-cos-phi"
|
||||||
|
label="cos Phi"
|
||||||
|
max="1"
|
||||||
|
min="0"
|
||||||
|
onChange={(value) => update("cosPhi", value)}
|
||||||
|
step="0.01"
|
||||||
|
value={values.cosPhi}
|
||||||
|
/>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label" htmlFor="device-remark">
|
||||||
|
Bemerkung
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
id="device-remark"
|
||||||
|
onChange={(event) => update("remark", event.target.value)}
|
||||||
|
rows={3}
|
||||||
|
value={values.remark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldProps {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
required?: boolean;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextField({ id, label, onChange, required, value }: FieldProps) {
|
||||||
|
return (
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor={id}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id={id}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
required={required}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NumberField({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
max,
|
||||||
|
min,
|
||||||
|
onChange,
|
||||||
|
step,
|
||||||
|
value,
|
||||||
|
}: FieldProps & { max?: string; min?: string; step?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor={id}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id={id}
|
||||||
|
max={max}
|
||||||
|
min={min}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
step={step}
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFormValues(device?: ProjectDeviceDto) {
|
||||||
|
return {
|
||||||
|
name: device?.name ?? "",
|
||||||
|
displayName: device?.displayName ?? "",
|
||||||
|
phaseType: device?.phaseType ?? "single_phase",
|
||||||
|
connectionKind: device?.connectionKind ?? "",
|
||||||
|
costGroup: device?.costGroup ?? "",
|
||||||
|
category: device?.category ?? "",
|
||||||
|
quantity: String(device?.quantity ?? 1),
|
||||||
|
powerPerUnit: String(device?.powerPerUnit ?? 0.1),
|
||||||
|
simultaneityFactor: String(device?.simultaneityFactor ?? 1),
|
||||||
|
cosPhi: String(device?.cosPhi ?? 1),
|
||||||
|
remark: device?.remark ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalString(value: string) {
|
||||||
|
return value.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalNumber(value: string) {
|
||||||
|
return value.trim() ? Number(value) : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { FormEvent, useState } from "react";
|
||||||
|
import type { ProjectDto } from "../types";
|
||||||
|
import {
|
||||||
|
distributionBoardSupplyTypeLabels,
|
||||||
|
distributionBoardSupplyTypes,
|
||||||
|
type DistributionBoardSupplyType,
|
||||||
|
} from "../../shared/constants/distribution-board";
|
||||||
|
|
||||||
|
export interface ProjectSettingsInput {
|
||||||
|
name: string;
|
||||||
|
internalProjectNumber: string | null;
|
||||||
|
externalProjectNumber: string | null;
|
||||||
|
buildingOwner: string | null;
|
||||||
|
description: string | null;
|
||||||
|
singlePhaseVoltageV: number;
|
||||||
|
threePhaseVoltageV: number;
|
||||||
|
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectSettingsModalProps {
|
||||||
|
isSaving: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onExport: () => Promise<void>;
|
||||||
|
onImport: (
|
||||||
|
transfer: unknown,
|
||||||
|
mode: "replace" | "duplicate"
|
||||||
|
) => Promise<void>;
|
||||||
|
onSave: (input: ProjectSettingsInput) => Promise<void>;
|
||||||
|
project: ProjectDto;
|
||||||
|
usedDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectSettingsModal({
|
||||||
|
isSaving,
|
||||||
|
onClose,
|
||||||
|
onExport,
|
||||||
|
onImport,
|
||||||
|
onSave,
|
||||||
|
project,
|
||||||
|
usedDistributionBoardSupplyTypes,
|
||||||
|
}: ProjectSettingsModalProps) {
|
||||||
|
const [name, setName] = useState(project.name);
|
||||||
|
const [internalProjectNumber, setInternalProjectNumber] = useState(
|
||||||
|
project.internalProjectNumber ?? ""
|
||||||
|
);
|
||||||
|
const [externalProjectNumber, setExternalProjectNumber] = useState(
|
||||||
|
project.externalProjectNumber ?? ""
|
||||||
|
);
|
||||||
|
const [buildingOwner, setBuildingOwner] = useState(
|
||||||
|
project.buildingOwner ?? ""
|
||||||
|
);
|
||||||
|
const [description, setDescription] = useState(
|
||||||
|
project.description ?? ""
|
||||||
|
);
|
||||||
|
const [singlePhaseVoltageV, setSinglePhaseVoltageV] = useState(
|
||||||
|
String(project.singlePhaseVoltageV)
|
||||||
|
);
|
||||||
|
const [threePhaseVoltageV, setThreePhaseVoltageV] = useState(
|
||||||
|
String(project.threePhaseVoltageV)
|
||||||
|
);
|
||||||
|
const [
|
||||||
|
enabledDistributionBoardSupplyTypes,
|
||||||
|
setEnabledDistributionBoardSupplyTypes,
|
||||||
|
] = useState<DistributionBoardSupplyType[]>(
|
||||||
|
project.enabledDistributionBoardSupplyTypes
|
||||||
|
);
|
||||||
|
const [transfer, setTransfer] = useState<unknown>(null);
|
||||||
|
const [transferFilename, setTransferFilename] = useState("");
|
||||||
|
const [importMode, setImportMode] = useState<"replace" | "duplicate">(
|
||||||
|
"duplicate"
|
||||||
|
);
|
||||||
|
const [replaceConfirmed, setReplaceConfirmed] = useState(false);
|
||||||
|
const [fileError, setFileError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
await onSave({
|
||||||
|
name: name.trim(),
|
||||||
|
internalProjectNumber: toNullableString(internalProjectNumber),
|
||||||
|
externalProjectNumber: toNullableString(externalProjectNumber),
|
||||||
|
buildingOwner: toNullableString(buildingOwner),
|
||||||
|
description: toNullableString(description),
|
||||||
|
singlePhaseVoltageV: Number(singlePhaseVoltageV),
|
||||||
|
threePhaseVoltageV: Number(threePhaseVoltageV),
|
||||||
|
enabledDistributionBoardSupplyTypes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid =
|
||||||
|
name.trim().length > 0 &&
|
||||||
|
Number(singlePhaseVoltageV) > 0 &&
|
||||||
|
Number(threePhaseVoltageV) > 0 &&
|
||||||
|
enabledDistributionBoardSupplyTypes.length > 0;
|
||||||
|
|
||||||
|
function toggleSupplyType(supplyType: DistributionBoardSupplyType) {
|
||||||
|
if (
|
||||||
|
enabledDistributionBoardSupplyTypes.includes(supplyType) &&
|
||||||
|
usedDistributionBoardSupplyTypes.includes(supplyType)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEnabledDistributionBoardSupplyTypes((current) =>
|
||||||
|
current.includes(supplyType)
|
||||||
|
? current.filter((entry) => entry !== supplyType)
|
||||||
|
: distributionBoardSupplyTypes.filter(
|
||||||
|
(entry) => entry === supplyType || current.includes(entry)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTransferFile(file: File | undefined) {
|
||||||
|
setTransfer(null);
|
||||||
|
setTransferFilename("");
|
||||||
|
setFileError(null);
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setTransfer(JSON.parse(await file.text()) as unknown);
|
||||||
|
setTransferFilename(file.name);
|
||||||
|
} catch {
|
||||||
|
setFileError("Die ausgewählte Datei enthält kein gültiges JSON.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
aria-labelledby="project-settings-title"
|
||||||
|
aria-modal="true"
|
||||||
|
className="modal fade show d-block"
|
||||||
|
role="dialog"
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
<div className="modal-dialog modal-lg modal-dialog-centered">
|
||||||
|
<form className="modal-content" onSubmit={handleSubmit}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<div>
|
||||||
|
<h2 className="modal-title fs-5" id="project-settings-title">
|
||||||
|
Projekteinstellungen
|
||||||
|
</h2>
|
||||||
|
<p className="text-secondary small mb-0">
|
||||||
|
Stammdaten und elektrische Standardwerte des Projekts
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-label="Schließen"
|
||||||
|
className="btn-close"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<div className="row g-3">
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label" htmlFor="project-name">
|
||||||
|
Projektname
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className="form-control"
|
||||||
|
id="project-name"
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
required
|
||||||
|
value={name}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="internal-project-number">
|
||||||
|
Projektnummer intern
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="internal-project-number"
|
||||||
|
maxLength={100}
|
||||||
|
onChange={(event) =>
|
||||||
|
setInternalProjectNumber(event.target.value)
|
||||||
|
}
|
||||||
|
value={internalProjectNumber}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="external-project-number">
|
||||||
|
Projektnummer extern
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="external-project-number"
|
||||||
|
maxLength={100}
|
||||||
|
onChange={(event) =>
|
||||||
|
setExternalProjectNumber(event.target.value)
|
||||||
|
}
|
||||||
|
value={externalProjectNumber}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<fieldset>
|
||||||
|
<legend className="form-label mb-1">
|
||||||
|
Verwendete Netzarten
|
||||||
|
</legend>
|
||||||
|
<p className="form-text mt-0">
|
||||||
|
Nur ausgewählte Netzarten stehen bei Verteilungen zur
|
||||||
|
Auswahl. Bereits verwendete Netzarten können nicht
|
||||||
|
deaktiviert werden.
|
||||||
|
</p>
|
||||||
|
<div className="row g-2">
|
||||||
|
{distributionBoardSupplyTypes.map((supplyType) => (
|
||||||
|
<div
|
||||||
|
className="col-12 col-md-6"
|
||||||
|
key={supplyType}
|
||||||
|
>
|
||||||
|
<label className="form-check">
|
||||||
|
<input
|
||||||
|
checked={enabledDistributionBoardSupplyTypes.includes(
|
||||||
|
supplyType
|
||||||
|
)}
|
||||||
|
className="form-check-input"
|
||||||
|
disabled={usedDistributionBoardSupplyTypes.includes(
|
||||||
|
supplyType
|
||||||
|
)}
|
||||||
|
onChange={() => toggleSupplyType(supplyType)}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">
|
||||||
|
{distributionBoardSupplyTypeLabels[supplyType]}
|
||||||
|
{usedDistributionBoardSupplyTypes.includes(
|
||||||
|
supplyType
|
||||||
|
)
|
||||||
|
? " (in Verwendung)"
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{enabledDistributionBoardSupplyTypes.length === 0 ? (
|
||||||
|
<div className="text-danger small mt-2">
|
||||||
|
Mindestens eine Netzart muss aktiviert sein.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label" htmlFor="building-owner">
|
||||||
|
Bauherr
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="building-owner"
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(event) => setBuildingOwner(event.target.value)}
|
||||||
|
value={buildingOwner}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<label className="form-label" htmlFor="project-description">
|
||||||
|
Beschreibung
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
id="project-description"
|
||||||
|
maxLength={2000}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
rows={4}
|
||||||
|
value={description}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="single-phase-voltage">
|
||||||
|
Standardspannung 1-phasig [V]
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="single-phase-voltage"
|
||||||
|
min="1"
|
||||||
|
onChange={(event) =>
|
||||||
|
setSinglePhaseVoltageV(event.target.value)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={singlePhaseVoltageV}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12 col-md-6">
|
||||||
|
<label className="form-label" htmlFor="three-phase-voltage">
|
||||||
|
Standardspannung 3-phasig [V]
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
id="three-phase-voltage"
|
||||||
|
min="1"
|
||||||
|
onChange={(event) =>
|
||||||
|
setThreePhaseVoltageV(event.target.value)
|
||||||
|
}
|
||||||
|
required
|
||||||
|
type="number"
|
||||||
|
value={threePhaseVoltageV}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-12">
|
||||||
|
<hr className="my-2" />
|
||||||
|
<h3 className="h6">Projekt importieren oder exportieren</h3>
|
||||||
|
<p className="text-secondary small">
|
||||||
|
Der Export enthält den vollständigen unterstützten
|
||||||
|
Projektzustand. Beim Duplizieren bleibt dieses Projekt
|
||||||
|
unverändert.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-primary mb-3"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={() => void onExport()}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Projektdatei herunterladen
|
||||||
|
</button>
|
||||||
|
<label className="form-label d-block" htmlFor="project-import">
|
||||||
|
Projektdatei auswählen
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
accept=".json,application/json"
|
||||||
|
className="form-control"
|
||||||
|
id="project-import"
|
||||||
|
onChange={(event) =>
|
||||||
|
void handleTransferFile(event.target.files?.[0])
|
||||||
|
}
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
{transferFilename ? (
|
||||||
|
<div className="form-text">{transferFilename} ist bereit.</div>
|
||||||
|
) : null}
|
||||||
|
{fileError ? (
|
||||||
|
<div className="text-danger small mt-1">{fileError}</div>
|
||||||
|
) : null}
|
||||||
|
<div className="d-flex flex-wrap gap-3 mt-3">
|
||||||
|
<label className="form-check">
|
||||||
|
<input
|
||||||
|
checked={importMode === "duplicate"}
|
||||||
|
className="form-check-input"
|
||||||
|
name="import-mode"
|
||||||
|
onChange={() => setImportMode("duplicate")}
|
||||||
|
type="radio"
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">
|
||||||
|
Als neues Projekt duplizieren
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="form-check">
|
||||||
|
<input
|
||||||
|
checked={importMode === "replace"}
|
||||||
|
className="form-check-input"
|
||||||
|
name="import-mode"
|
||||||
|
onChange={() => setImportMode("replace")}
|
||||||
|
type="radio"
|
||||||
|
/>
|
||||||
|
<span className="form-check-label">
|
||||||
|
Dieses Projekt ersetzen
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{importMode === "replace" ? (
|
||||||
|
<label className="form-check mt-2">
|
||||||
|
<input
|
||||||
|
checked={replaceConfirmed}
|
||||||
|
className="form-check-input"
|
||||||
|
onChange={(event) =>
|
||||||
|
setReplaceConfirmed(event.target.checked)
|
||||||
|
}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span className="form-check-label text-danger">
|
||||||
|
Ich bestätige, dass der aktuelle Projektstand ersetzt
|
||||||
|
wird.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-danger mt-3"
|
||||||
|
disabled={
|
||||||
|
isSaving ||
|
||||||
|
transfer === null ||
|
||||||
|
(importMode === "replace" && !replaceConfirmed)
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
transfer === null
|
||||||
|
? undefined
|
||||||
|
: void onImport(transfer, importMode)
|
||||||
|
}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Projektdatei importieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline-secondary"
|
||||||
|
disabled={isSaving}
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={isSaving || !isValid}
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
{isSaving ? "Wird gespeichert …" : "Einstellungen speichern"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-backdrop fade show" />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNullableString(value: string) {
|
||||||
|
return value.trim() || null;
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
getProjectRevisionDescription,
|
getProjectRevisionDescription,
|
||||||
getProjectRevisionSourceLabel,
|
getProjectRevisionSourceLabel,
|
||||||
getProjectSnapshotKindLabel,
|
getProjectSnapshotKindLabel,
|
||||||
|
getProjectSnapshotRevisionDescription,
|
||||||
mergeProjectRevisionPages,
|
mergeProjectRevisionPages,
|
||||||
} from "../utils/project-version-history";
|
} from "../utils/project-version-history";
|
||||||
|
|
||||||
@@ -343,7 +344,7 @@ export function ProjectVersionHistory({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Projektstand</th>
|
<th>Gesicherter Projektstand</th>
|
||||||
<th>Erstellt</th>
|
<th>Erstellt</th>
|
||||||
<th className="text-end">Aktion</th>
|
<th className="text-end">Aktion</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -368,7 +369,16 @@ export function ProjectVersionHistory({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td>Revision {snapshot.sourceRevision}</td>
|
<td>
|
||||||
|
<strong>
|
||||||
|
{snapshot.sourceRevision === 0
|
||||||
|
? "Ausgangsstand"
|
||||||
|
: `Revision ${snapshot.sourceRevision}`}
|
||||||
|
</strong>
|
||||||
|
<div className="small text-secondary">
|
||||||
|
{getProjectSnapshotRevisionDescription(snapshot)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
<td>{formatDateTime(snapshot.createdAtIso)}</td>
|
||||||
<td className="text-end">
|
<td className="text-end">
|
||||||
<button
|
<button
|
||||||
|
|||||||
+18
-3
@@ -1,8 +1,15 @@
|
|||||||
|
import type { DistributionBoardSupplyType } from "../shared/constants/distribution-board";
|
||||||
|
|
||||||
export interface ProjectDto {
|
export interface ProjectDto {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
internalProjectNumber: string | null;
|
||||||
|
externalProjectNumber: string | null;
|
||||||
|
buildingOwner: string | null;
|
||||||
|
description: string | null;
|
||||||
singlePhaseVoltageV: number;
|
singlePhaseVoltageV: number;
|
||||||
threePhaseVoltageV: number;
|
threePhaseVoltageV: number;
|
||||||
|
enabledDistributionBoardSupplyTypes: DistributionBoardSupplyType[];
|
||||||
currentRevision: number;
|
currentRevision: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +52,7 @@ export interface ProjectSnapshotMetadataDto {
|
|||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
sourceRevision: number;
|
sourceRevision: number;
|
||||||
|
sourceRevisionMetadata: ProjectRevisionSummaryDto | null;
|
||||||
schemaVersion: number;
|
schemaVersion: number;
|
||||||
kind: "named" | "automatic";
|
kind: "named" | "automatic";
|
||||||
name: string;
|
name: string;
|
||||||
@@ -85,6 +93,9 @@ export interface DistributionBoardDto {
|
|||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
floorId: string | null;
|
||||||
|
supplyType: DistributionBoardSupplyType | null;
|
||||||
|
simultaneityFactor: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DistributionBoardCommandResultDto
|
export interface DistributionBoardCommandResultDto
|
||||||
@@ -178,8 +189,7 @@ export interface CreateGlobalDeviceInput {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
installedPowerPerUnitKw: number;
|
installedPowerPerUnitKw: number;
|
||||||
demandFactor: number;
|
demandFactor: number;
|
||||||
voltageV?: number;
|
phaseCount: 1 | 3;
|
||||||
phaseCount?: 1 | 3;
|
|
||||||
powerFactor?: number;
|
powerFactor?: number;
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
@@ -196,7 +206,6 @@ export interface CreateProjectDeviceInput {
|
|||||||
simultaneityFactor: number;
|
simultaneityFactor: number;
|
||||||
cosPhi?: number;
|
cosPhi?: number;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
voltageV?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectDeviceSyncDifferenceDto {
|
export interface ProjectDeviceSyncDifferenceDto {
|
||||||
@@ -288,6 +297,7 @@ export interface CircuitTreeSectionDto {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
prefix: string;
|
prefix: string;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
|
sectionTotalPower: number;
|
||||||
circuits: CircuitTreeCircuitDto[];
|
circuits: CircuitTreeCircuitDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +315,11 @@ export interface CircuitTreeMigrationReportDto {
|
|||||||
export interface CircuitTreeResponseDto {
|
export interface CircuitTreeResponseDto {
|
||||||
circuitListId: string;
|
circuitListId: string;
|
||||||
currentRevision: number;
|
currentRevision: number;
|
||||||
|
singlePhaseVoltageV: number;
|
||||||
|
threePhaseVoltageV: number;
|
||||||
|
distributionBoardSimultaneityFactor: number;
|
||||||
|
distributionBoardTotalPower: number;
|
||||||
|
distributionBoardTotalPowerWithSimultaneityFactor: number;
|
||||||
sections: CircuitTreeSectionDto[];
|
sections: CircuitTreeSectionDto[];
|
||||||
migrationReport?: CircuitTreeMigrationReportDto;
|
migrationReport?: CircuitTreeMigrationReportDto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,15 @@ async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const details = await response.text();
|
let details = await response.text();
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(details) as { error?: unknown };
|
||||||
|
if (typeof parsed.error === "string") {
|
||||||
|
details = parsed.error;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep non-JSON error responses unchanged.
|
||||||
|
}
|
||||||
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
|
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +218,16 @@ export function createProject(name: string) {
|
|||||||
export function updateProjectSettings(
|
export function updateProjectSettings(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
expectedRevision: number,
|
expectedRevision: number,
|
||||||
input: { singlePhaseVoltageV: number; threePhaseVoltageV: number }
|
input: {
|
||||||
|
name: string;
|
||||||
|
internalProjectNumber: string | null;
|
||||||
|
externalProjectNumber: string | null;
|
||||||
|
buildingOwner: string | null;
|
||||||
|
description: string | null;
|
||||||
|
singlePhaseVoltageV: number;
|
||||||
|
threePhaseVoltageV: number;
|
||||||
|
enabledDistributionBoardSupplyTypes: ProjectDto["enabledDistributionBoardSupplyTypes"];
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
return request<ProjectSettingsCommandResultDto>(
|
return request<ProjectSettingsCommandResultDto>(
|
||||||
`/api/projects/${projectId}`,
|
`/api/projects/${projectId}`,
|
||||||
@@ -221,20 +238,76 @@ export function updateProjectSettings(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function exportProjectTransfer(projectId: string) {
|
||||||
|
return request<unknown>(`/api/projects/${projectId}/export`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importProjectTransferAsNew(transfer: unknown) {
|
||||||
|
return request<{ projectId: string; name: string }>(
|
||||||
|
"/api/projects/import",
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ transfer }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importProjectTransfer(
|
||||||
|
projectId: string,
|
||||||
|
mode: "replace" | "duplicate",
|
||||||
|
expectedRevision: number,
|
||||||
|
transfer: unknown
|
||||||
|
) {
|
||||||
|
return request<
|
||||||
|
| { projectId: string; name: string }
|
||||||
|
| ProjectSettingsCommandResultDto
|
||||||
|
>(`/api/projects/${projectId}/import`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(
|
||||||
|
mode === "replace"
|
||||||
|
? { mode, expectedRevision, transfer }
|
||||||
|
: { mode, transfer }
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function listDistributionBoards(projectId: string) {
|
export function listDistributionBoards(projectId: string) {
|
||||||
return request<DistributionBoardDto[]>(`/api/projects/${projectId}/distribution-boards`);
|
return request<DistributionBoardDto[]>(`/api/projects/${projectId}/distribution-boards`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createDistributionBoard(
|
export function createDistributionBoard(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
name: string,
|
input: {
|
||||||
|
name: string;
|
||||||
|
floorId: string | null;
|
||||||
|
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
|
||||||
|
},
|
||||||
expectedRevision: number
|
expectedRevision: number
|
||||||
) {
|
) {
|
||||||
return request<DistributionBoardCommandResultDto>(
|
return request<DistributionBoardCommandResultDto>(
|
||||||
`/api/projects/${projectId}/distribution-boards`,
|
`/api/projects/${projectId}/distribution-boards`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ name, expectedRevision }),
|
body: JSON.stringify({ ...input, expectedRevision }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDistributionBoard(
|
||||||
|
projectId: string,
|
||||||
|
distributionBoardId: string,
|
||||||
|
input: {
|
||||||
|
floorId: string | null;
|
||||||
|
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
|
||||||
|
simultaneityFactor: number;
|
||||||
|
},
|
||||||
|
expectedRevision: number
|
||||||
|
) {
|
||||||
|
return request<DistributionBoardCommandResultDto>(
|
||||||
|
`/api/projects/${projectId}/distribution-boards/${distributionBoardId}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ ...input, expectedRevision }),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export type GridValue = string | number | boolean | undefined;
|
|||||||
export interface ColumnDef {
|
export interface ColumnDef {
|
||||||
key: CellKey;
|
key: CellKey;
|
||||||
label: string;
|
label: string;
|
||||||
|
fullLabel?: string;
|
||||||
numeric?: boolean;
|
numeric?: boolean;
|
||||||
defaultVisible?: boolean;
|
defaultVisible?: boolean;
|
||||||
locked?: boolean;
|
locked?: boolean;
|
||||||
@@ -57,36 +58,64 @@ export interface ColumnDef {
|
|||||||
// BMK stays locked as the first column because circuit identity and circuit drag
|
// BMK stays locked as the first column because circuit identity and circuit drag
|
||||||
// handles depend on an always-visible equipment identifier.
|
// handles depend on an always-visible equipment identifier.
|
||||||
export const allColumns: ColumnDef[] = [
|
export const allColumns: ColumnDef[] = [
|
||||||
{ key: "equipmentIdentifier", label: "Betriebsmittelkennzeichen", defaultVisible: true, locked: true },
|
{
|
||||||
|
key: "equipmentIdentifier",
|
||||||
|
label: "SK-Nr.",
|
||||||
|
fullLabel: "Stromkreisnummer / Betriebsmittelkennzeichen",
|
||||||
|
defaultVisible: true,
|
||||||
|
locked: true,
|
||||||
|
},
|
||||||
{ key: "displayName", label: "Anzeigename", defaultVisible: true },
|
{ key: "displayName", label: "Anzeigename", defaultVisible: true },
|
||||||
{ key: "quantity", label: "Anzahl", numeric: true, defaultVisible: true },
|
{ key: "quantity", label: "Anzahl", numeric: true, defaultVisible: true },
|
||||||
{ key: "powerPerUnit", label: "Leistung / Gerät", numeric: true, defaultVisible: true },
|
{
|
||||||
{ key: "simultaneityFactor", label: "Gleichzeitigkeit", numeric: true, defaultVisible: true },
|
key: "powerPerUnit",
|
||||||
{ key: "rowTotalPower", label: "Zeilensumme", numeric: true, defaultVisible: true },
|
label: "P/Stk. [kW]",
|
||||||
{ key: "circuitTotalPower", label: "Stromkreissumme", numeric: true, defaultVisible: true },
|
fullLabel: "Leistung je Gerät [kW]",
|
||||||
|
numeric: true,
|
||||||
|
defaultVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "simultaneityFactor",
|
||||||
|
label: "GZF",
|
||||||
|
fullLabel: "Gleichzeitigkeitsfaktor",
|
||||||
|
numeric: true,
|
||||||
|
defaultVisible: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "rowTotalPower",
|
||||||
|
label: "Ges.-Leistung [kW]",
|
||||||
|
fullLabel: "Gesamtleistung [kW]",
|
||||||
|
numeric: true,
|
||||||
|
defaultVisible: true,
|
||||||
|
},
|
||||||
{ key: "protectionSummary", label: "Schutz", defaultVisible: true },
|
{ key: "protectionSummary", label: "Schutz", defaultVisible: true },
|
||||||
{ key: "cableSummary", label: "Kabel", defaultVisible: true },
|
{ key: "cableSummary", label: "Kabel", defaultVisible: true },
|
||||||
{ key: "roomSummary", label: "Raum", defaultVisible: true },
|
{ key: "roomSummary", label: "Raum", defaultVisible: true },
|
||||||
{ key: "remark", label: "Bemerkung", defaultVisible: true },
|
{ key: "remark", label: "Bemerkung", defaultVisible: true },
|
||||||
{ key: "technicalName", label: "Technischer Name" },
|
{ key: "technicalName", label: "Techn. Name", fullLabel: "Technischer Name" },
|
||||||
{ key: "connectionKind", label: "Anschlussart" },
|
{ key: "connectionKind", label: "Anschluss", fullLabel: "Anschlussart" },
|
||||||
{ key: "phaseType", label: "Phasenart" },
|
{ key: "phaseType", label: "Phasenart" },
|
||||||
{ key: "costGroup", label: "Kostengruppe" },
|
{ key: "costGroup", label: "KG", fullLabel: "Kostengruppe" },
|
||||||
{ key: "category", label: "Kategorie" },
|
{ key: "category", label: "Kategorie" },
|
||||||
{ key: "level", label: "Ebene" },
|
{ key: "level", label: "Ebene" },
|
||||||
{ key: "roomNumberSnapshot", label: "Raumnummer" },
|
{ key: "roomNumberSnapshot", label: "Raum-Nr.", fullLabel: "Raumnummer" },
|
||||||
{ key: "roomNameSnapshot", label: "Raumname" },
|
{ key: "roomNameSnapshot", label: "Raumname" },
|
||||||
{ key: "cosPhi", label: "cos φ", numeric: true },
|
{ key: "cosPhi", label: "cos φ", numeric: true },
|
||||||
{ key: "protectionType", label: "Schutzart" },
|
{ key: "protectionType", label: "Schutzart" },
|
||||||
{ key: "protectionRatedCurrent", label: "Bemessungsstrom", numeric: true },
|
{
|
||||||
|
key: "protectionRatedCurrent",
|
||||||
|
label: "Bem.-Strom",
|
||||||
|
fullLabel: "Bemessungsstrom",
|
||||||
|
numeric: true,
|
||||||
|
},
|
||||||
{ key: "protectionCharacteristic", label: "Charakteristik" },
|
{ key: "protectionCharacteristic", label: "Charakteristik" },
|
||||||
{ key: "cableType", label: "Kabeltyp" },
|
{ key: "cableType", label: "Kabeltyp" },
|
||||||
{ key: "cableCrossSection", label: "Kabelquerschnitt" },
|
{ key: "cableCrossSection", label: "Querschnitt", fullLabel: "Kabelquerschnitt" },
|
||||||
{ key: "cableLength", label: "Kabellänge", numeric: true },
|
{ key: "cableLength", label: "Kabellänge", numeric: true },
|
||||||
{ key: "rcdAssignment", label: "RCD-Zuordnung" },
|
{ key: "rcdAssignment", label: "RCD-Zuordnung" },
|
||||||
{ key: "terminalDesignation", label: "Klemmenbezeichnung" },
|
{ key: "terminalDesignation", label: "Klemme", fullLabel: "Klemmenbezeichnung" },
|
||||||
{ key: "voltage", label: "Spannung", numeric: true },
|
{ key: "voltage", label: "Spannung", numeric: true },
|
||||||
{ key: "controlRequirement", label: "Steuerungsanforderung" },
|
{ key: "controlRequirement", label: "Steuerung", fullLabel: "Steuerungsanforderung" },
|
||||||
{ key: "status", label: "Status" },
|
{ key: "status", label: "Status" },
|
||||||
{ key: "isReserve", label: "Reserve" },
|
{ key: "isReserve", label: "Reserve" },
|
||||||
];
|
];
|
||||||
@@ -95,6 +124,96 @@ export const defaultVisibleColumnKeys = allColumns
|
|||||||
.filter((column) => column.defaultVisible)
|
.filter((column) => column.defaultVisible)
|
||||||
.map((column) => column.key);
|
.map((column) => column.key);
|
||||||
|
|
||||||
|
const projectColumnLayoutStoragePrefix =
|
||||||
|
"circuitTreeEditor.columnLayout.v2";
|
||||||
|
|
||||||
|
export interface CircuitColumnLayout {
|
||||||
|
order: CellKey[];
|
||||||
|
visible: CellKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const circuitSectionLabels: Record<string, string> = {
|
||||||
|
lighting: "Licht",
|
||||||
|
single_phase: "Einphasig",
|
||||||
|
three_phase: "Dreiphasig",
|
||||||
|
unassigned: "Nicht zugeordnet",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getCircuitSectionLabel(
|
||||||
|
section: { key: string; displayName: string }
|
||||||
|
): string {
|
||||||
|
return circuitSectionLabels[section.key] ?? section.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProjectColumnLayoutStorageKey(
|
||||||
|
projectId: string
|
||||||
|
): string {
|
||||||
|
return `${projectColumnLayoutStoragePrefix}.${projectId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeColumnOrder(keys: CellKey[]): CellKey[] {
|
||||||
|
const unique = [...new Set(keys)];
|
||||||
|
const allKeys = allColumns.map((column) => column.key);
|
||||||
|
const merged = [
|
||||||
|
...unique,
|
||||||
|
...allKeys.filter((key) => !unique.includes(key)),
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
"equipmentIdentifier",
|
||||||
|
...merged.filter((key) => key !== "equipmentIdentifier"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseStoredColumnLayout(
|
||||||
|
serialized: string | null
|
||||||
|
): CircuitColumnLayout | null {
|
||||||
|
if (!serialized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(serialized) as {
|
||||||
|
order?: unknown;
|
||||||
|
visible?: unknown;
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
!Array.isArray(parsed.order) ||
|
||||||
|
!Array.isArray(parsed.visible)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const validKeys = new Set(
|
||||||
|
allColumns.map((column) => column.key)
|
||||||
|
);
|
||||||
|
const order = parsed.order.filter(
|
||||||
|
(key): key is CellKey =>
|
||||||
|
typeof key === "string" &&
|
||||||
|
validKeys.has(key as CellKey)
|
||||||
|
);
|
||||||
|
const visible = [
|
||||||
|
...new Set(
|
||||||
|
parsed.visible.filter(
|
||||||
|
(key): key is CellKey =>
|
||||||
|
typeof key === "string" &&
|
||||||
|
validKeys.has(key as CellKey)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (
|
||||||
|
!order.length ||
|
||||||
|
!visible.length ||
|
||||||
|
!visible.includes("equipmentIdentifier")
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
order: normalizeColumnOrder(order),
|
||||||
|
visible,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const deviceOnlyColumns = new Set<CellKey>([
|
const deviceOnlyColumns = new Set<CellKey>([
|
||||||
"quantity",
|
"quantity",
|
||||||
"powerPerUnit",
|
"powerPerUnit",
|
||||||
@@ -162,23 +281,87 @@ const circuitFieldKeys = new Set<CellKey>([
|
|||||||
"cableSummary",
|
"cableSummary",
|
||||||
"rcdAssignment",
|
"rcdAssignment",
|
||||||
"terminalDesignation",
|
"terminalDesignation",
|
||||||
"voltage",
|
|
||||||
"controlRequirement",
|
"controlRequirement",
|
||||||
"status",
|
"status",
|
||||||
"isReserve",
|
"isReserve",
|
||||||
"remark",
|
"remark",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function formatValue(value: GridValue): string {
|
const numberFormatters = new Map<number, Intl.NumberFormat>();
|
||||||
|
|
||||||
|
function formatNumber(value: number, maximumFractionDigits: number): string {
|
||||||
|
let formatter = numberFormatters.get(maximumFractionDigits);
|
||||||
|
if (!formatter) {
|
||||||
|
formatter = new Intl.NumberFormat("de-DE", {
|
||||||
|
maximumFractionDigits,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
useGrouping: false,
|
||||||
|
});
|
||||||
|
numberFormatters.set(maximumFractionDigits, formatter);
|
||||||
|
}
|
||||||
|
return formatter.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMaximumFractionDigits(key: CellKey | undefined): number {
|
||||||
|
if (
|
||||||
|
key === "powerPerUnit" ||
|
||||||
|
key === "rowTotalPower" ||
|
||||||
|
key === "circuitTotalPower"
|
||||||
|
) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
key === "simultaneityFactor" ||
|
||||||
|
key === "cosPhi" ||
|
||||||
|
key === "protectionRatedCurrent" ||
|
||||||
|
key === "cableLength"
|
||||||
|
) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
if (key === "voltage") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatValue(value: GridValue, key?: CellKey): string {
|
||||||
if (value === undefined || value === null || value === "") {
|
if (value === undefined || value === null || value === "") {
|
||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
if (typeof value === "boolean") {
|
if (typeof value === "boolean") {
|
||||||
return value ? "Ja" : "Nein";
|
return value ? "Ja" : "Nein";
|
||||||
}
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return formatNumber(value, getMaximumFractionDigits(key));
|
||||||
|
}
|
||||||
|
if (key === "phaseType") {
|
||||||
|
return formatPhaseTypeLabel(String(value));
|
||||||
|
}
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatPhaseTypeLabel(value: string): string {
|
||||||
|
if (value === "single_phase") {
|
||||||
|
return "1-phasig";
|
||||||
|
}
|
||||||
|
if (value === "three_phase") {
|
||||||
|
return "3-phasig";
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGridEditorControlTarget(target: unknown): boolean {
|
||||||
|
if (
|
||||||
|
target === null ||
|
||||||
|
typeof target !== "object" ||
|
||||||
|
!("closest" in target) ||
|
||||||
|
typeof target.closest !== "function"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(target.closest("input, select, textarea"));
|
||||||
|
}
|
||||||
|
|
||||||
export function parseNumeric(cellKey: CellKey, draft: string): number | undefined {
|
export function parseNumeric(cellKey: CellKey, draft: string): number | undefined {
|
||||||
const trimmed = draft.trim();
|
const trimmed = draft.trim();
|
||||||
if (trimmed === "") {
|
if (trimmed === "") {
|
||||||
@@ -317,12 +500,15 @@ export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): G
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeFilterValue(value: GridValue): string {
|
export function normalizeFilterValue(key: CellKey, value: GridValue): string {
|
||||||
return formatValue(value);
|
return formatValue(value, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBlockSortValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue {
|
export function getBlockSortValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue {
|
||||||
const firstRow = circuit.deviceRows[0];
|
const firstRow = circuit.deviceRows[0];
|
||||||
|
if (key === "rowTotalPower") {
|
||||||
|
return circuit.circuitTotalPower;
|
||||||
|
}
|
||||||
if (circuitOnlyColumns.has(key)) {
|
if (circuitOnlyColumns.has(key)) {
|
||||||
return getCircuitValue(circuit, key);
|
return getCircuitValue(circuit, key);
|
||||||
}
|
}
|
||||||
@@ -351,6 +537,7 @@ export function compareSortValues(left: GridValue, right: GridValue): number {
|
|||||||
|
|
||||||
export function getCellKind(rowType: RowType, key: CellKey): CellKind {
|
export function getCellKind(rowType: RowType, key: CellKey): CellKind {
|
||||||
if (key === "rowTotalPower" || key === "circuitTotalPower") return "computed";
|
if (key === "rowTotalPower" || key === "circuitTotalPower") return "computed";
|
||||||
|
if (key === "voltage") return "readonly";
|
||||||
if (rowType === "section") return "readonly";
|
if (rowType === "section") return "readonly";
|
||||||
if (rowType === "placeholder") {
|
if (rowType === "placeholder") {
|
||||||
if (deviceFieldKeys.has(key)) return "deviceField";
|
if (deviceFieldKeys.has(key)) return "deviceField";
|
||||||
|
|||||||
@@ -47,16 +47,19 @@ export interface VisibleGridRow {
|
|||||||
|
|
||||||
function getCircuitBlockFilterValues(circuit: CircuitTreeCircuitDto, key: CellKey): Set<string> {
|
function getCircuitBlockFilterValues(circuit: CircuitTreeCircuitDto, key: CellKey): Set<string> {
|
||||||
const values = new Set<string>();
|
const values = new Set<string>();
|
||||||
|
if (key === "rowTotalPower") {
|
||||||
|
values.add(normalizeFilterValue(key, circuit.circuitTotalPower));
|
||||||
|
}
|
||||||
if (circuitOnlyColumns.has(key) || key === "displayName" || key === "remark") {
|
if (circuitOnlyColumns.has(key) || key === "displayName" || key === "remark") {
|
||||||
values.add(normalizeFilterValue(getCircuitValue(circuit, key)));
|
values.add(normalizeFilterValue(key, getCircuitValue(circuit, key)));
|
||||||
}
|
}
|
||||||
if (!circuitOnlyColumns.has(key)) {
|
if (!circuitOnlyColumns.has(key)) {
|
||||||
for (const device of circuit.deviceRows) {
|
for (const device of circuit.deviceRows) {
|
||||||
values.add(normalizeFilterValue(getDeviceValue(device, key)));
|
values.add(normalizeFilterValue(key, getDeviceValue(device, key)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (values.size === 0 && deviceFieldKeys.has(key)) {
|
if (values.size === 0 && deviceFieldKeys.has(key)) {
|
||||||
values.add(normalizeFilterValue(undefined));
|
values.add(normalizeFilterValue(key, undefined));
|
||||||
}
|
}
|
||||||
return values;
|
return values;
|
||||||
}
|
}
|
||||||
@@ -140,10 +143,14 @@ function makeVisibleGridRow(
|
|||||||
value = getDeviceValue(device, column.key);
|
value = getDeviceValue(device, column.key);
|
||||||
} else if (kind === "circuitField" && circuit) {
|
} else if (kind === "circuitField" && circuit) {
|
||||||
value = getCircuitValue(circuit, column.key);
|
value = getCircuitValue(circuit, column.key);
|
||||||
} else if (column.key === "circuitTotalPower" && circuit) {
|
|
||||||
value = circuit.circuitTotalPower;
|
|
||||||
} else if (column.key === "rowTotalPower" && device) {
|
} else if (column.key === "rowTotalPower" && device) {
|
||||||
value = device.rowTotalPower;
|
value = device.rowTotalPower;
|
||||||
|
} else if (column.key === "rowTotalPower" && circuit) {
|
||||||
|
value = circuit.circuitTotalPower;
|
||||||
|
} else if (column.key === "circuitTotalPower" && circuit) {
|
||||||
|
value = circuit.circuitTotalPower;
|
||||||
|
} else if (column.key === "voltage" && circuit) {
|
||||||
|
value = circuit.voltage;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { cellKey: column.key, editable, kind, value };
|
return { cellKey: column.key, editable, kind, value };
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const commandTypeLabels: Record<string, string> = {
|
|||||||
"project-device.sync-rows": "Projektgerät-Verknüpfungen geändert",
|
"project-device.sync-rows": "Projektgerät-Verknüpfungen geändert",
|
||||||
"project.update-settings": "Projekteinstellungen bearbeitet",
|
"project.update-settings": "Projekteinstellungen bearbeitet",
|
||||||
"distribution-board.insert": "Verteilung angelegt",
|
"distribution-board.insert": "Verteilung angelegt",
|
||||||
|
"distribution-board.update": "Verteilung bearbeitet",
|
||||||
"distribution-board.delete": "Verteilung entfernt",
|
"distribution-board.delete": "Verteilung entfernt",
|
||||||
"project-floor.insert": "Geschoss angelegt",
|
"project-floor.insert": "Geschoss angelegt",
|
||||||
"project-floor.delete": "Geschoss entfernt",
|
"project-floor.delete": "Geschoss entfernt",
|
||||||
@@ -48,8 +49,15 @@ export function getProjectRevisionSourceLabel(
|
|||||||
export function getProjectRevisionDescription(
|
export function getProjectRevisionDescription(
|
||||||
revision: ProjectRevisionSummaryDto
|
revision: ProjectRevisionSummaryDto
|
||||||
) {
|
) {
|
||||||
|
const description = revision.description?.trim();
|
||||||
|
if (
|
||||||
|
description === `Undo ${revision.commandType}` ||
|
||||||
|
description === `Redo ${revision.commandType}`
|
||||||
|
) {
|
||||||
|
return commandTypeLabels[revision.commandType] || revision.commandType;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
revision.description?.trim() ||
|
description ||
|
||||||
commandTypeLabels[revision.commandType] ||
|
commandTypeLabels[revision.commandType] ||
|
||||||
revision.commandType
|
revision.commandType
|
||||||
);
|
);
|
||||||
@@ -61,6 +69,26 @@ export function getProjectSnapshotKindLabel(
|
|||||||
return kind === "automatic" ? "Automatisch" : "Benannt";
|
return kind === "automatic" ? "Automatisch" : "Benannt";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getProjectSnapshotRevisionDescription(
|
||||||
|
snapshot: ProjectSnapshotMetadataDto
|
||||||
|
) {
|
||||||
|
if (snapshot.sourceRevision === 0) {
|
||||||
|
return "Projektstart";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!snapshot.sourceRevisionMetadata ||
|
||||||
|
snapshot.sourceRevisionMetadata.revisionNumber !==
|
||||||
|
snapshot.sourceRevision
|
||||||
|
) {
|
||||||
|
return "Änderungsdetails nicht verfügbar";
|
||||||
|
}
|
||||||
|
return `${getProjectRevisionSourceLabel(
|
||||||
|
snapshot.sourceRevisionMetadata.source
|
||||||
|
)}: ${getProjectRevisionDescription(
|
||||||
|
snapshot.sourceRevisionMetadata
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeProjectRevisionPages(
|
export function mergeProjectRevisionPages(
|
||||||
current: ProjectRevisionSummaryDto[],
|
current: ProjectRevisionSummaryDto[],
|
||||||
next: ProjectRevisionSummaryDto[]
|
next: ProjectRevisionSummaryDto[]
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { db } from "../../db/client.js";
|
||||||
|
import { ProjectTransferRepository } from "../../db/repositories/project-transfer.repository.js";
|
||||||
|
|
||||||
|
export const projectTransferStore = new ProjectTransferRepository(db);
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import {
|
import {
|
||||||
|
applyDistributionBoardSimultaneityFactor,
|
||||||
calculateCircuitTotalPower,
|
calculateCircuitTotalPower,
|
||||||
|
calculateDistributionBoardTotalPower,
|
||||||
calculateRowTotalPower,
|
calculateRowTotalPower,
|
||||||
|
calculateSectionTotalPower,
|
||||||
} from "../../domain/calculations/circuit-power-calculation.js";
|
} from "../../domain/calculations/circuit-power-calculation.js";
|
||||||
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
|
import type { CircuitTreeResponse } from "../../domain/models/circuit-tree.model.js";
|
||||||
import {
|
import {
|
||||||
@@ -9,6 +12,7 @@ import {
|
|||||||
circuitListRepository,
|
circuitListRepository,
|
||||||
circuitRepository,
|
circuitRepository,
|
||||||
circuitSectionRepository,
|
circuitSectionRepository,
|
||||||
|
distributionBoardRepository,
|
||||||
projectRepository,
|
projectRepository,
|
||||||
} from "../composition/application-repositories.js";
|
} from "../composition/application-repositories.js";
|
||||||
|
|
||||||
@@ -37,6 +41,16 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
if (!project) {
|
if (!project) {
|
||||||
return res.status(404).json({ error: "Project not found" });
|
return res.status(404).json({ error: "Project not found" });
|
||||||
}
|
}
|
||||||
|
const distributionBoard =
|
||||||
|
await distributionBoardRepository.findById(
|
||||||
|
projectId,
|
||||||
|
list.distributionBoardId
|
||||||
|
);
|
||||||
|
if (!distributionBoard) {
|
||||||
|
return res.status(404).json({
|
||||||
|
error: "Distribution board not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
|
const sections = await circuitSectionRepository.listByCircuitList(circuitListId);
|
||||||
@@ -55,12 +69,19 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
const tree: CircuitTreeResponse = {
|
const tree: CircuitTreeResponse = {
|
||||||
circuitListId,
|
circuitListId,
|
||||||
currentRevision: project.currentRevision,
|
currentRevision: project.currentRevision,
|
||||||
|
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
||||||
|
threePhaseVoltageV: project.threePhaseVoltageV,
|
||||||
|
distributionBoardSimultaneityFactor:
|
||||||
|
distributionBoard.simultaneityFactor,
|
||||||
|
distributionBoardTotalPower: 0,
|
||||||
|
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
||||||
sections: sections.map((section) => ({
|
sections: sections.map((section) => ({
|
||||||
id: section.id,
|
id: section.id,
|
||||||
key: section.key,
|
key: section.key,
|
||||||
displayName: section.displayName,
|
displayName: section.displayName,
|
||||||
prefix: section.prefix,
|
prefix: section.prefix,
|
||||||
sortOrder: section.sortOrder,
|
sortOrder: section.sortOrder,
|
||||||
|
sectionTotalPower: 0,
|
||||||
circuits: [],
|
circuits: [],
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@@ -121,12 +142,31 @@ export async function getCircuitTree(req: Request, res: Response) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const section of tree.sections) {
|
||||||
|
section.sectionTotalPower = calculateSectionTotalPower(
|
||||||
|
section.circuits
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tree.distributionBoardTotalPower =
|
||||||
|
calculateDistributionBoardTotalPower(tree.sections);
|
||||||
|
tree.distributionBoardTotalPowerWithSimultaneityFactor =
|
||||||
|
applyDistributionBoardSimultaneityFactor(
|
||||||
|
tree.distributionBoardTotalPower,
|
||||||
|
tree.distributionBoardSimultaneityFactor
|
||||||
|
);
|
||||||
|
|
||||||
return res.json(tree);
|
return res.json(tree);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isMissingCircuitTreeSchemaError(error)) {
|
if (isMissingCircuitTreeSchemaError(error)) {
|
||||||
return res.json({
|
return res.json({
|
||||||
circuitListId,
|
circuitListId,
|
||||||
currentRevision: project.currentRevision,
|
currentRevision: project.currentRevision,
|
||||||
|
singlePhaseVoltageV: project.singlePhaseVoltageV,
|
||||||
|
threePhaseVoltageV: project.threePhaseVoltageV,
|
||||||
|
distributionBoardSimultaneityFactor:
|
||||||
|
distributionBoard.simultaneityFactor,
|
||||||
|
distributionBoardTotalPower: 0,
|
||||||
|
distributionBoardTotalPowerWithSimultaneityFactor: 0,
|
||||||
sections: [],
|
sections: [],
|
||||||
warning:
|
warning:
|
||||||
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
|
"Circuit-first tables are not available yet. Run database migrations (including 0008_circuit_first_model).",
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
|
import { createDistributionBoardUpdateProjectCommand } from "../../domain/models/distribution-board-project-command.model.js";
|
||||||
import {
|
import {
|
||||||
createDistributionBoardInsertProjectCommand,
|
createDistributionBoardInsertProjectCommand,
|
||||||
createDistributionBoardStructureSnapshot,
|
createDistributionBoardStructureSnapshot,
|
||||||
} from "../../domain/models/distribution-board-structure-project-command.model.js";
|
} from "../../domain/models/distribution-board-structure-project-command.model.js";
|
||||||
import { createDistributionBoardSchema } from "../../shared/validation/project-structure.schemas.js";
|
import {
|
||||||
|
createDistributionBoardSchema,
|
||||||
|
updateDistributionBoardSchema,
|
||||||
|
} from "../../shared/validation/project-structure.schemas.js";
|
||||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||||
import { distributionBoardRepository } from "../composition/application-repositories.js";
|
import { distributionBoardRepository } from "../composition/application-repositories.js";
|
||||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||||
@@ -31,7 +35,11 @@ export async function createDistributionBoard(req: Request, res: Response) {
|
|||||||
|
|
||||||
const structure = createDistributionBoardStructureSnapshot(
|
const structure = createDistributionBoardStructureSnapshot(
|
||||||
projectId,
|
projectId,
|
||||||
parsed.data.name
|
parsed.data.name,
|
||||||
|
{
|
||||||
|
floorId: parsed.data.floorId,
|
||||||
|
supplyType: parsed.data.supplyType,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
const result = projectCommandService.executeUser({
|
const result = projectCommandService.executeUser({
|
||||||
@@ -43,9 +51,53 @@ export async function createDistributionBoard(req: Request, res: Response) {
|
|||||||
});
|
});
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
...result,
|
...result,
|
||||||
distributionBoard: structure.distributionBoard,
|
distributionBoard: {
|
||||||
|
...structure.distributionBoard,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return respondWithProjectCommandError(error, res);
|
return respondWithProjectCommandError(error, res);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateDistributionBoard(
|
||||||
|
req: Request,
|
||||||
|
res: Response
|
||||||
|
) {
|
||||||
|
const { projectId, distributionBoardId } = req.params;
|
||||||
|
if (
|
||||||
|
typeof projectId !== "string" ||
|
||||||
|
typeof distributionBoardId !== "string"
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: "Invalid distribution board id" });
|
||||||
|
}
|
||||||
|
const parsed = updateDistributionBoardSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = projectCommandService.executeUser({
|
||||||
|
projectId,
|
||||||
|
expectedRevision: parsed.data.expectedRevision,
|
||||||
|
description: "Verteilung bearbeiten",
|
||||||
|
command: createDistributionBoardUpdateProjectCommand(
|
||||||
|
distributionBoardId,
|
||||||
|
{
|
||||||
|
floorId: parsed.data.floorId,
|
||||||
|
supplyType: parsed.data.supplyType,
|
||||||
|
simultaneityFactor: parsed.data.simultaneityFactor,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const distributionBoard = (
|
||||||
|
await distributionBoardRepository.listByProject(projectId)
|
||||||
|
).find((board) => board.id === distributionBoardId);
|
||||||
|
if (!distributionBoard) {
|
||||||
|
return res.status(404).json({ error: "Distribution board not found" });
|
||||||
|
}
|
||||||
|
return res.json({ ...result, distributionBoard });
|
||||||
|
} catch (error) {
|
||||||
|
return respondWithProjectCommandError(error, res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ export async function copyProjectDeviceToGlobal(req: Request, res: Response) {
|
|||||||
quantity: source.quantity,
|
quantity: source.quantity,
|
||||||
installedPowerPerUnitKw: source.powerPerUnit,
|
installedPowerPerUnitKw: source.powerPerUnit,
|
||||||
demandFactor: source.simultaneityFactor,
|
demandFactor: source.simultaneityFactor,
|
||||||
voltageV: source.voltageV ?? undefined,
|
|
||||||
phaseCount: source.phaseType === "three_phase" ? 3 : 1,
|
phaseCount: source.phaseType === "three_phase" ? 3 : 1,
|
||||||
powerFactor: source.cosPhi ?? undefined,
|
powerFactor: source.cosPhi ?? undefined,
|
||||||
note: source.remark ?? undefined,
|
note: source.remark ?? undefined,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { projectCommandService } from "../composition/project-command-stores.js"
|
|||||||
import {
|
import {
|
||||||
globalDeviceRepository,
|
globalDeviceRepository,
|
||||||
projectDeviceRepository,
|
projectDeviceRepository,
|
||||||
|
projectRepository,
|
||||||
} from "../composition/application-repositories.js";
|
} from "../composition/application-repositories.js";
|
||||||
import {
|
import {
|
||||||
createProjectDeviceCommandSchema,
|
createProjectDeviceCommandSchema,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
} from "../../shared/validation/project-device.schemas.js";
|
} from "../../shared/validation/project-device.schemas.js";
|
||||||
import type { CreateProjectDeviceInput } from "../../shared/validation/project-device.schemas.js";
|
import type { CreateProjectDeviceInput } from "../../shared/validation/project-device.schemas.js";
|
||||||
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
import { respondWithProjectCommandError } from "./project-command.controller.js";
|
||||||
|
import { resolveProjectVoltage } from "../../domain/services/project-voltage.service.js";
|
||||||
|
|
||||||
export async function listProjectDevicesByProject(req: Request, res: Response) {
|
export async function listProjectDevicesByProject(req: Request, res: Response) {
|
||||||
const { projectId } = req.params;
|
const { projectId } = req.params;
|
||||||
@@ -41,9 +43,18 @@ export async function createProjectDevice(req: Request, res: Response) {
|
|||||||
return res.status(400).json({ error: parsed.error.flatten() });
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
}
|
}
|
||||||
const { expectedRevision, ...input } = parsed.data;
|
const { expectedRevision, ...input } = parsed.data;
|
||||||
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), input);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const project = await projectRepository.findById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
return res.status(404).json({ error: "Project not found" });
|
||||||
|
}
|
||||||
|
const projectDevice = toProjectDeviceSnapshot(
|
||||||
|
projectId,
|
||||||
|
randomUUID(),
|
||||||
|
input,
|
||||||
|
project
|
||||||
|
);
|
||||||
const result = projectCommandService.executeUser({
|
const result = projectCommandService.executeUser({
|
||||||
projectId,
|
projectId,
|
||||||
expectedRevision,
|
expectedRevision,
|
||||||
@@ -73,13 +84,17 @@ export async function updateProjectDevice(req: Request, res: Response) {
|
|||||||
const { expectedRevision, ...input } = parsed.data;
|
const { expectedRevision, ...input } = parsed.data;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const project = await projectRepository.findById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
return res.status(404).json({ error: "Project not found" });
|
||||||
|
}
|
||||||
const result = projectCommandService.executeUser({
|
const result = projectCommandService.executeUser({
|
||||||
projectId,
|
projectId,
|
||||||
expectedRevision,
|
expectedRevision,
|
||||||
description: "Projektgerät bearbeiten",
|
description: "Projektgerät bearbeiten",
|
||||||
command: createProjectDeviceUpdateProjectCommand(
|
command: createProjectDeviceUpdateProjectCommand(
|
||||||
projectDeviceId,
|
projectDeviceId,
|
||||||
toProjectDeviceValues(input)
|
toProjectDeviceValues(input, project)
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
// Linked rows are synchronized only through the explicit review flow.
|
// Linked rows are synchronized only through the explicit review flow.
|
||||||
@@ -133,6 +148,10 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
|||||||
if (!source) {
|
if (!source) {
|
||||||
return res.status(404).json({ error: "Global device not found" });
|
return res.status(404).json({ error: "Global device not found" });
|
||||||
}
|
}
|
||||||
|
const project = await projectRepository.findById(projectId);
|
||||||
|
if (!project) {
|
||||||
|
return res.status(404).json({ error: "Project not found" });
|
||||||
|
}
|
||||||
|
|
||||||
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), {
|
const projectDevice = toProjectDeviceSnapshot(projectId, randomUUID(), {
|
||||||
name: source.name,
|
name: source.name,
|
||||||
@@ -144,8 +163,7 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
|||||||
simultaneityFactor: source.demandFactor,
|
simultaneityFactor: source.demandFactor,
|
||||||
cosPhi: source.powerFactor ?? undefined,
|
cosPhi: source.powerFactor ?? undefined,
|
||||||
remark: source.note ?? undefined,
|
remark: source.note ?? undefined,
|
||||||
voltageV: source.voltageV ?? undefined,
|
}, project);
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = projectCommandService.executeUser({
|
const result = projectCommandService.executeUser({
|
||||||
@@ -167,16 +185,26 @@ export async function copyGlobalDeviceToProject(req: Request, res: Response) {
|
|||||||
function toProjectDeviceSnapshot(
|
function toProjectDeviceSnapshot(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
id: string,
|
id: string,
|
||||||
input: CreateProjectDeviceInput
|
input: CreateProjectDeviceInput,
|
||||||
|
project: {
|
||||||
|
singlePhaseVoltageV: number;
|
||||||
|
threePhaseVoltageV: number;
|
||||||
|
}
|
||||||
): ProjectDeviceSnapshot {
|
): ProjectDeviceSnapshot {
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
projectId,
|
projectId,
|
||||||
...toProjectDeviceValues(input),
|
...toProjectDeviceValues(input, project),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toProjectDeviceValues(input: CreateProjectDeviceInput) {
|
function toProjectDeviceValues(
|
||||||
|
input: CreateProjectDeviceInput,
|
||||||
|
project: {
|
||||||
|
singlePhaseVoltageV: number;
|
||||||
|
threePhaseVoltageV: number;
|
||||||
|
}
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
name: input.name,
|
name: input.name,
|
||||||
displayName: input.displayName,
|
displayName: input.displayName,
|
||||||
@@ -189,7 +217,7 @@ function toProjectDeviceValues(input: CreateProjectDeviceInput) {
|
|||||||
simultaneityFactor: input.simultaneityFactor,
|
simultaneityFactor: input.simultaneityFactor,
|
||||||
cosPhi: input.cosPhi ?? null,
|
cosPhi: input.cosPhi ?? null,
|
||||||
remark: input.remark ?? null,
|
remark: input.remark ?? null,
|
||||||
voltageV: input.voltageV ?? null,
|
voltageV: resolveProjectVoltage(input.phaseType, project),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import {
|
|||||||
createNamedProjectSnapshotSchema,
|
createNamedProjectSnapshotSchema,
|
||||||
restoreProjectSnapshotSchema,
|
restoreProjectSnapshotSchema,
|
||||||
} from "../../shared/validation/project-snapshot.schemas.js";
|
} from "../../shared/validation/project-snapshot.schemas.js";
|
||||||
import { projectCommandService } from "../composition/project-command-stores.js";
|
import {
|
||||||
|
projectCommandService,
|
||||||
|
projectHistoryStore,
|
||||||
|
} from "../composition/project-command-stores.js";
|
||||||
import { projectSnapshotStore } from "../composition/project-snapshot-store.js";
|
import { projectSnapshotStore } from "../composition/project-snapshot-store.js";
|
||||||
|
|
||||||
export function listProjectSnapshots(req: Request, res: Response) {
|
export function listProjectSnapshots(req: Request, res: Response) {
|
||||||
@@ -18,7 +21,7 @@ export function listProjectSnapshots(req: Request, res: Response) {
|
|||||||
if (!snapshots) {
|
if (!snapshots) {
|
||||||
return res.status(404).json({ error: "Project not found" });
|
return res.status(404).json({ error: "Project not found" });
|
||||||
}
|
}
|
||||||
return res.json(snapshots);
|
return res.json(withSourceRevisionMetadata(projectId, snapshots));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createNamedProjectSnapshot(
|
export function createNamedProjectSnapshot(
|
||||||
@@ -43,7 +46,9 @@ export function createNamedProjectSnapshot(
|
|||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return res.status(404).json({ error: "Project not found" });
|
return res.status(404).json({ error: "Project not found" });
|
||||||
}
|
}
|
||||||
return res.status(201).json(snapshot);
|
return res
|
||||||
|
.status(201)
|
||||||
|
.json(withSourceRevisionMetadata(projectId, [snapshot])[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ProjectRevisionConflictError) {
|
if (error instanceof ProjectRevisionConflictError) {
|
||||||
return res.status(409).json({
|
return res.status(409).json({
|
||||||
@@ -97,7 +102,9 @@ export function restoreProjectSnapshot(
|
|||||||
command: prepared.command,
|
command: prepared.command,
|
||||||
});
|
});
|
||||||
return res.json({
|
return res.json({
|
||||||
snapshot: prepared.snapshot,
|
snapshot: withSourceRevisionMetadata(projectId, [
|
||||||
|
prepared.snapshot,
|
||||||
|
])[0],
|
||||||
revision: result.revision,
|
revision: result.revision,
|
||||||
history: result.history,
|
history: result.history,
|
||||||
});
|
});
|
||||||
@@ -120,6 +127,29 @@ export function restoreProjectSnapshot(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withSourceRevisionMetadata<
|
||||||
|
TSnapshot extends { sourceRevision: number },
|
||||||
|
>(
|
||||||
|
projectId: string,
|
||||||
|
snapshots: TSnapshot[]
|
||||||
|
) {
|
||||||
|
const revisions = projectHistoryStore.listRevisionsByNumbers(
|
||||||
|
projectId,
|
||||||
|
snapshots.map((snapshot) => snapshot.sourceRevision)
|
||||||
|
);
|
||||||
|
const revisionByNumber = new Map(
|
||||||
|
revisions.map((revision) => [
|
||||||
|
revision.revisionNumber,
|
||||||
|
revision,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
return snapshots.map((snapshot) => ({
|
||||||
|
...snapshot,
|
||||||
|
sourceRevisionMetadata:
|
||||||
|
revisionByNumber.get(snapshot.sourceRevision) ?? null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function getProjectId(req: Request, res: Response) {
|
function getProjectId(req: Request, res: Response) {
|
||||||
const { projectId } = req.params;
|
const { projectId } = req.params;
|
||||||
if (typeof projectId !== "string" || !projectId.trim()) {
|
if (typeof projectId !== "string" || !projectId.trim()) {
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { ProjectRevisionConflictError } from "../../domain/errors/project-revision-conflict.error.js";
|
||||||
|
import { ProjectStateConflictError } from "../../domain/errors/project-state-conflict.error.js";
|
||||||
|
import {
|
||||||
|
importProjectTransferAsNewSchema,
|
||||||
|
importProjectTransferSchema,
|
||||||
|
} from "../../shared/validation/project-transfer.schemas.js";
|
||||||
|
import { projectCommandService } from "../composition/project-command-stores.js";
|
||||||
|
import { projectTransferStore } from "../composition/project-transfer-store.js";
|
||||||
|
|
||||||
|
export function exportProjectTransfer(req: Request, res: Response) {
|
||||||
|
const projectId = getProjectId(req, res);
|
||||||
|
if (!projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const transfer = projectTransferStore.exportProject(projectId);
|
||||||
|
if (!transfer) {
|
||||||
|
return res.status(404).json({ error: "Project not found" });
|
||||||
|
}
|
||||||
|
const filename = safeFilename(transfer.projectState.project.name);
|
||||||
|
res.setHeader(
|
||||||
|
"Content-Disposition",
|
||||||
|
`attachment; filename="${filename}.leistungsbilanz.json"`
|
||||||
|
);
|
||||||
|
return res.json(transfer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importProjectTransferAsNewProject(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const parsed = importProjectTransferAsNewSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return res
|
||||||
|
.status(201)
|
||||||
|
.json(projectTransferStore.importDuplicate(parsed.data.transfer));
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.message.startsWith("Project transfer") ||
|
||||||
|
error.message.startsWith("Snapshot"))
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function importProjectTransfer(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
) {
|
||||||
|
const projectId = getProjectId(req, res);
|
||||||
|
if (!projectId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = importProjectTransferSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (parsed.data.mode === "duplicate") {
|
||||||
|
return res
|
||||||
|
.status(201)
|
||||||
|
.json(projectTransferStore.importDuplicate(parsed.data.transfer));
|
||||||
|
}
|
||||||
|
const prepared = projectTransferStore.prepareReplace(
|
||||||
|
projectId,
|
||||||
|
parsed.data.transfer
|
||||||
|
);
|
||||||
|
if (!prepared) {
|
||||||
|
return res.status(404).json({ error: "Project not found" });
|
||||||
|
}
|
||||||
|
const result = projectCommandService.executeRestore({
|
||||||
|
projectId,
|
||||||
|
expectedRevision: parsed.data.expectedRevision,
|
||||||
|
description: "Projektimport wiederherstellen",
|
||||||
|
command: prepared.command,
|
||||||
|
});
|
||||||
|
return res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ProjectRevisionConflictError) {
|
||||||
|
return res.status(409).json({
|
||||||
|
error: error.message,
|
||||||
|
code: "PROJECT_REVISION_CONFLICT",
|
||||||
|
expectedRevision: error.expectedRevision,
|
||||||
|
currentRevision: error.actualRevision,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (error instanceof ProjectStateConflictError) {
|
||||||
|
return res
|
||||||
|
.status(409)
|
||||||
|
.json({ error: error.message, code: "PROJECT_STATE_CONFLICT" });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
(error.message.startsWith("Project transfer") ||
|
||||||
|
error.message.startsWith("Snapshot"))
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: error.message });
|
||||||
|
}
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProjectId(req: Request, res: Response) {
|
||||||
|
const { projectId } = req.params;
|
||||||
|
if (typeof projectId !== "string" || !projectId.trim()) {
|
||||||
|
res.status(400).json({ error: "Invalid projectId" });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return projectId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeFilename(name: string) {
|
||||||
|
return (
|
||||||
|
name
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[^a-zA-Z0-9_-]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 80) || "projekt"
|
||||||
|
);
|
||||||
|
}
|
||||||
+1
-1
@@ -8,7 +8,7 @@ import { errorMiddleware } from "./middleware/error.middleware.js";
|
|||||||
const app = express();
|
const app = express();
|
||||||
const port = Number(process.env.PORT || 3000);
|
const port = Number(process.env.PORT || 3000);
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: "25mb" }));
|
||||||
|
|
||||||
app.get("/health", (_req, res) => {
|
app.get("/health", (_req, res) => {
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
createDistributionBoard,
|
createDistributionBoard,
|
||||||
listDistributionBoardsByProject,
|
listDistributionBoardsByProject,
|
||||||
|
updateDistributionBoard,
|
||||||
} from "../controllers/distribution-board.controller.js";
|
} from "../controllers/distribution-board.controller.js";
|
||||||
import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js";
|
import { listCircuitListsByProject } from "../controllers/circuit-list.controller.js";
|
||||||
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
|
import { createFloor, listFloorsByProject } from "../controllers/floor.controller.js";
|
||||||
@@ -27,12 +28,20 @@ import {
|
|||||||
listProjectSnapshots,
|
listProjectSnapshots,
|
||||||
restoreProjectSnapshot,
|
restoreProjectSnapshot,
|
||||||
} from "../controllers/project-snapshot.controller.js";
|
} from "../controllers/project-snapshot.controller.js";
|
||||||
|
import {
|
||||||
|
exportProjectTransfer,
|
||||||
|
importProjectTransfer,
|
||||||
|
importProjectTransferAsNewProject,
|
||||||
|
} from "../controllers/project-transfer.controller.js";
|
||||||
|
|
||||||
export const projectRouter = Router();
|
export const projectRouter = Router();
|
||||||
|
|
||||||
projectRouter.get("/", listProjects);
|
projectRouter.get("/", listProjects);
|
||||||
projectRouter.post("/", createProject);
|
projectRouter.post("/", createProject);
|
||||||
|
projectRouter.post("/import", importProjectTransferAsNewProject);
|
||||||
projectRouter.get("/:projectId", getProject);
|
projectRouter.get("/:projectId", getProject);
|
||||||
|
projectRouter.get("/:projectId/export", exportProjectTransfer);
|
||||||
|
projectRouter.post("/:projectId/import", importProjectTransfer);
|
||||||
projectRouter.get("/:projectId/history", getProjectHistory);
|
projectRouter.get("/:projectId/history", getProjectHistory);
|
||||||
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
|
projectRouter.get("/:projectId/history/revisions", listProjectRevisions);
|
||||||
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
projectRouter.post("/:projectId/commands", executeProjectCommand);
|
||||||
@@ -47,6 +56,10 @@ projectRouter.post(
|
|||||||
projectRouter.put("/:projectId", updateProjectSettings);
|
projectRouter.put("/:projectId", updateProjectSettings);
|
||||||
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
projectRouter.get("/:projectId/distribution-boards", listDistributionBoardsByProject);
|
||||||
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
projectRouter.post("/:projectId/distribution-boards", createDistributionBoard);
|
||||||
|
projectRouter.put(
|
||||||
|
"/:projectId/distribution-boards/:distributionBoardId",
|
||||||
|
updateDistributionBoard
|
||||||
|
);
|
||||||
projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
|
projectRouter.get("/:projectId/circuit-lists", listCircuitListsByProject);
|
||||||
projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree);
|
projectRouter.get("/:projectId/circuit-lists/:circuitListId/tree", getCircuitTree);
|
||||||
projectRouter.get("/:projectId/floors", listFloorsByProject);
|
projectRouter.get("/:projectId/floors", listFloorsByProject);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export const distributionBoardSupplyTypes = [
|
||||||
|
"AV",
|
||||||
|
"SV",
|
||||||
|
"EV",
|
||||||
|
"USV",
|
||||||
|
"MSR",
|
||||||
|
"SiBe",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type DistributionBoardSupplyType =
|
||||||
|
(typeof distributionBoardSupplyTypes)[number];
|
||||||
|
|
||||||
|
export const defaultDistributionBoardSupplyTypes: DistributionBoardSupplyType[] =
|
||||||
|
[...distributionBoardSupplyTypes];
|
||||||
|
|
||||||
|
export const distributionBoardSupplyTypeLabels: Record<
|
||||||
|
DistributionBoardSupplyType,
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
AV: "AV – Allgemeine Stromversorgung",
|
||||||
|
SV: "SV – Sicherheitsstromversorgung",
|
||||||
|
EV: "EV – Ersatzstromversorgung",
|
||||||
|
USV: "USV – Unterbrechungsfreie Stromversorgung",
|
||||||
|
MSR: "MSR – Mess-, Steuerungs- und Regelungstechnik",
|
||||||
|
SiBe: "SiBe – Sicherheitsbeleuchtung",
|
||||||
|
};
|
||||||
@@ -7,11 +7,10 @@ export const createGlobalDeviceSchema = z.object({
|
|||||||
quantity: z.number().min(0),
|
quantity: z.number().min(0),
|
||||||
installedPowerPerUnitKw: z.number().min(0),
|
installedPowerPerUnitKw: z.number().min(0),
|
||||||
demandFactor: z.number().min(0).max(1),
|
demandFactor: z.number().min(0).max(1),
|
||||||
voltageV: z.number().positive().optional(),
|
phaseCount: z.union([z.literal(1), z.literal(3)]),
|
||||||
phaseCount: z.union([z.literal(1), z.literal(3)]).optional(),
|
|
||||||
powerFactor: z.number().min(0).max(1).optional(),
|
powerFactor: z.number().min(0).max(1).optional(),
|
||||||
note: z.string().optional(),
|
note: z.string().optional(),
|
||||||
});
|
}).strict();
|
||||||
|
|
||||||
export const updateGlobalDeviceSchema = createGlobalDeviceSchema;
|
export const updateGlobalDeviceSchema = createGlobalDeviceSchema;
|
||||||
|
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ export const createProjectDeviceSchema = z.object({
|
|||||||
simultaneityFactor: z.number().min(0).max(1),
|
simultaneityFactor: z.number().min(0).max(1),
|
||||||
cosPhi: z.number().min(0).max(1).optional(),
|
cosPhi: z.number().min(0).max(1).optional(),
|
||||||
remark: z.string().optional(),
|
remark: z.string().optional(),
|
||||||
// Transitional metadata used when importing from the legacy global-device library.
|
}).strict();
|
||||||
voltageV: z.number().positive().optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const updateProjectDeviceSchema = createProjectDeviceSchema;
|
export const updateProjectDeviceSchema = createProjectDeviceSchema;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { distributionBoardSupplyTypes } from "../constants/distribution-board.js";
|
||||||
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
||||||
|
|
||||||
export const createProjectSchema = z.object({
|
export const createProjectSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().trim().min(1).max(200),
|
||||||
|
internalProjectNumber: z.string().trim().max(100).optional(),
|
||||||
|
externalProjectNumber: z.string().trim().max(100).optional(),
|
||||||
|
buildingOwner: z.string().trim().max(200).optional(),
|
||||||
|
description: z.string().trim().max(2000).optional(),
|
||||||
singlePhaseVoltageV: z.number().positive().optional(),
|
singlePhaseVoltageV: z.number().positive().optional(),
|
||||||
threePhaseVoltageV: z.number().positive().optional(),
|
threePhaseVoltageV: z.number().positive().optional(),
|
||||||
});
|
});
|
||||||
@@ -10,8 +15,19 @@ export const createProjectSchema = z.object({
|
|||||||
export const updateProjectSettingsSchema = z
|
export const updateProjectSettingsSchema = z
|
||||||
.object({
|
.object({
|
||||||
expectedRevision: expectedProjectRevisionSchema,
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
internalProjectNumber: z.string().trim().max(100).nullable(),
|
||||||
|
externalProjectNumber: z.string().trim().max(100).nullable(),
|
||||||
|
buildingOwner: z.string().trim().max(200).nullable(),
|
||||||
|
description: z.string().trim().max(2000).nullable(),
|
||||||
singlePhaseVoltageV: z.number().positive(),
|
singlePhaseVoltageV: z.number().positive(),
|
||||||
threePhaseVoltageV: z.number().positive(),
|
threePhaseVoltageV: z.number().positive(),
|
||||||
|
enabledDistributionBoardSupplyTypes: z
|
||||||
|
.array(z.enum(distributionBoardSupplyTypes))
|
||||||
|
.min(1)
|
||||||
|
.refine((values) => new Set(values).size === values.length, {
|
||||||
|
message: "Netzarten dürfen nicht mehrfach ausgewählt werden.",
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -19,6 +35,17 @@ export const createDistributionBoardSchema = z
|
|||||||
.object({
|
.object({
|
||||||
expectedRevision: expectedProjectRevisionSchema,
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
name: z.string().trim().min(1),
|
name: z.string().trim().min(1),
|
||||||
|
floorId: z.string().trim().min(1).nullable(),
|
||||||
|
supplyType: z.enum(distributionBoardSupplyTypes),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const updateDistributionBoardSchema = z
|
||||||
|
.object({
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
floorId: z.string().trim().min(1).nullable(),
|
||||||
|
supplyType: z.enum(distributionBoardSupplyTypes),
|
||||||
|
simultaneityFactor: z.number().finite().min(0).max(1),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { expectedProjectRevisionSchema } from "./project-command.schemas.js";
|
||||||
|
|
||||||
|
const requiredProjectTransferSchema = z
|
||||||
|
.unknown()
|
||||||
|
.refine((value) => value !== undefined, {
|
||||||
|
message: "Projektdatei fehlt.",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const importProjectTransferAsNewSchema = z
|
||||||
|
.object({
|
||||||
|
transfer: requiredProjectTransferSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const importProjectTransferSchema = z.discriminatedUnion("mode", [
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
mode: z.literal("replace"),
|
||||||
|
expectedRevision: expectedProjectRevisionSchema,
|
||||||
|
transfer: requiredProjectTransferSchema,
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
mode: z.literal("duplicate"),
|
||||||
|
transfer: requiredProjectTransferSchema,
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
]);
|
||||||
@@ -4,10 +4,15 @@ import {
|
|||||||
allColumns,
|
allColumns,
|
||||||
buildCircuitEditPatch,
|
buildCircuitEditPatch,
|
||||||
buildDeviceRowEditPatch,
|
buildDeviceRowEditPatch,
|
||||||
|
formatValue,
|
||||||
|
getCircuitSectionLabel,
|
||||||
|
getProjectColumnLayoutStorageKey,
|
||||||
|
isGridEditorControlTarget,
|
||||||
getBlockSortValue,
|
getBlockSortValue,
|
||||||
getCellKind,
|
getCellKind,
|
||||||
getCircuitValue,
|
getCircuitValue,
|
||||||
getDeviceValue,
|
getDeviceValue,
|
||||||
|
parseStoredColumnLayout,
|
||||||
parseNumeric,
|
parseNumeric,
|
||||||
} from "../src/frontend/utils/circuit-grid-model.js";
|
} from "../src/frontend/utils/circuit-grid-model.js";
|
||||||
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../src/frontend/types.js";
|
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../src/frontend/types.js";
|
||||||
@@ -49,11 +54,69 @@ const circuit: CircuitTreeCircuitDto = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("circuit grid model", () => {
|
describe("circuit grid model", () => {
|
||||||
|
it("does not route clicks from active editor controls back to the grid cell", () => {
|
||||||
|
const inputTarget = {
|
||||||
|
closest: (selector: string) =>
|
||||||
|
selector === "input, select, textarea" ? {} : null,
|
||||||
|
};
|
||||||
|
const cellTarget = { closest: () => null };
|
||||||
|
|
||||||
|
assert.equal(isGridEditorControlTarget(inputTarget), true);
|
||||||
|
assert.equal(isGridEditorControlTarget(cellTarget), false);
|
||||||
|
assert.equal(isGridEditorControlTarget(null), false);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps the equipment identifier locked as the first column", () => {
|
it("keeps the equipment identifier locked as the first column", () => {
|
||||||
assert.equal(allColumns[0].key, "equipmentIdentifier");
|
assert.equal(allColumns[0].key, "equipmentIdentifier");
|
||||||
|
assert.equal(allColumns[0].label, "SK-Nr.");
|
||||||
|
assert.equal(
|
||||||
|
allColumns[0].fullLabel,
|
||||||
|
"Stromkreisnummer / Betriebsmittelkennzeichen"
|
||||||
|
);
|
||||||
assert.equal(allColumns[0].locked, true);
|
assert.equal(allColumns[0].locked, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("stores valid column layouts under a project-specific key", () => {
|
||||||
|
assert.equal(
|
||||||
|
getProjectColumnLayoutStorageKey("project-1"),
|
||||||
|
"circuitTreeEditor.columnLayout.v2.project-1"
|
||||||
|
);
|
||||||
|
const layout = parseStoredColumnLayout(
|
||||||
|
JSON.stringify({
|
||||||
|
order: ["displayName", "equipmentIdentifier"],
|
||||||
|
visible: [
|
||||||
|
"displayName",
|
||||||
|
"equipmentIdentifier",
|
||||||
|
"unknown",
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert.ok(layout);
|
||||||
|
assert.equal(layout.order[0], "equipmentIdentifier");
|
||||||
|
assert.deepEqual(layout.visible, [
|
||||||
|
"displayName",
|
||||||
|
"equipmentIdentifier",
|
||||||
|
]);
|
||||||
|
assert.equal(parseStoredColumnLayout("{invalid"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows stable circuit sections with German labels", () => {
|
||||||
|
assert.equal(
|
||||||
|
getCircuitSectionLabel({
|
||||||
|
key: "lighting",
|
||||||
|
displayName: "Lighting",
|
||||||
|
}),
|
||||||
|
"Licht"
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
getCircuitSectionLabel({
|
||||||
|
key: "custom",
|
||||||
|
displayName: "Sonderbereich",
|
||||||
|
}),
|
||||||
|
"Sonderbereich"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("maps shared fields to the correct level for every row shape", () => {
|
it("maps shared fields to the correct level for every row shape", () => {
|
||||||
assert.equal(getCellKind("circuitCompact", "displayName"), "deviceField");
|
assert.equal(getCellKind("circuitCompact", "displayName"), "deviceField");
|
||||||
assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField");
|
assert.equal(getCellKind("circuitCompact", "equipmentIdentifier"), "circuitField");
|
||||||
@@ -76,6 +139,15 @@ describe("circuit grid model", () => {
|
|||||||
assert.equal(getCircuitValue(circuit, "cableSummary"), "NYM-J, 1.5 mm², 20 m");
|
assert.equal(getCircuitValue(circuit, "cableSummary"), "NYM-J, 1.5 mm², 20 m");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("formats displayed numeric values without changing their stored precision", () => {
|
||||||
|
assert.equal(formatValue(0.123456, "powerPerUnit"), "0,123");
|
||||||
|
assert.equal(formatValue(0.87654, "simultaneityFactor"), "0,88");
|
||||||
|
assert.equal(formatValue(230.4, "voltage"), "230");
|
||||||
|
assert.equal(formatValue(16, "protectionRatedCurrent"), "16");
|
||||||
|
assert.equal(formatValue("single_phase", "phaseType"), "1-phasig");
|
||||||
|
assert.equal(formatValue("three_phase", "phaseType"), "3-phasig");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses circuit display values for block sorting and falls back to the first device", () => {
|
it("uses circuit display values for block sorting and falls back to the first device", () => {
|
||||||
assert.equal(getBlockSortValue(circuit, "displayName"), "Lighting circuit");
|
assert.equal(getBlockSortValue(circuit, "displayName"), "Lighting circuit");
|
||||||
assert.equal(getBlockSortValue({ ...circuit, displayName: undefined }, "displayName"), "Office light");
|
assert.equal(getBlockSortValue({ ...circuit, displayName: undefined }, "displayName"), "Office light");
|
||||||
|
|||||||
@@ -164,6 +164,15 @@ describe("circuit grid projection", () => {
|
|||||||
rows.at(-1)?.cells.find((cell) => cell.cellKey === "equipmentIdentifier")?.value,
|
rows.at(-1)?.cells.find((cell) => cell.cellKey === "equipmentIdentifier")?.value,
|
||||||
"-frei-"
|
"-frei-"
|
||||||
);
|
);
|
||||||
|
const circuitSummary = rows.find((row) => row.rowType === "circuitSummary");
|
||||||
|
assert.equal(
|
||||||
|
circuitSummary?.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value,
|
||||||
|
multiDeviceCircuit.circuitTotalPower
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
circuitSummary?.cells.some((cell) => cell.cellKey === "circuitTotalPower"),
|
||||||
|
false
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("collects filter options from both circuit and device values", () => {
|
it("collects filter options from both circuit and device values", () => {
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import {
|
import {
|
||||||
|
applyDistributionBoardSimultaneityFactor,
|
||||||
calculateCircuitTotalPower,
|
calculateCircuitTotalPower,
|
||||||
|
calculateDistributionBoardTotalPower,
|
||||||
calculateRowTotalPower,
|
calculateRowTotalPower,
|
||||||
|
calculateSectionTotalPower,
|
||||||
} from "../src/domain/calculations/circuit-power-calculation.js";
|
} from "../src/domain/calculations/circuit-power-calculation.js";
|
||||||
|
import {
|
||||||
|
resolveCircuitPhaseType,
|
||||||
|
resolveProjectVoltage,
|
||||||
|
normalizeKnownElectricalPhaseType,
|
||||||
|
} from "../src/domain/services/project-voltage.service.js";
|
||||||
|
|
||||||
describe("circuit power calculation", () => {
|
describe("circuit power calculation", () => {
|
||||||
it("calculates row and circuit totals from device rows", () => {
|
it("calculates row and circuit totals from device rows", () => {
|
||||||
@@ -14,5 +22,68 @@ describe("circuit power calculation", () => {
|
|||||||
]);
|
]);
|
||||||
assert.equal(total, 4.5);
|
assert.equal(total, 4.5);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("calculates section and distribution-board totals", () => {
|
||||||
|
const lighting = calculateSectionTotalPower([
|
||||||
|
{ circuitTotalPower: 1.5 },
|
||||||
|
{ circuitTotalPower: 2.25 },
|
||||||
|
]);
|
||||||
|
const singlePhase = calculateSectionTotalPower([
|
||||||
|
{ circuitTotalPower: 3 },
|
||||||
|
]);
|
||||||
|
const total = calculateDistributionBoardTotalPower([
|
||||||
|
{ sectionTotalPower: lighting },
|
||||||
|
{ sectionTotalPower: singlePhase },
|
||||||
|
{ sectionTotalPower: 0 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.equal(lighting, 3.75);
|
||||||
|
assert.equal(total, 6.75);
|
||||||
|
assert.equal(
|
||||||
|
applyDistributionBoardSimultaneityFactor(total, 0.8),
|
||||||
|
5.4
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => applyDistributionBoardSimultaneityFactor(total, 1.1),
|
||||||
|
/between zero and one/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("project voltage derivation", () => {
|
||||||
|
const settings = {
|
||||||
|
singlePhaseVoltageV: 240,
|
||||||
|
threePhaseVoltageV: 415,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("derives device voltage exclusively from phase and project settings", () => {
|
||||||
|
assert.equal(resolveProjectVoltage("single_phase", settings), 240);
|
||||||
|
assert.equal(resolveProjectVoltage("three_phase", settings), 415);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses section phase and handles unassigned circuits conservatively", () => {
|
||||||
|
assert.equal(resolveCircuitPhaseType("lighting", ["three_phase"]), "single_phase");
|
||||||
|
assert.equal(resolveCircuitPhaseType("single_phase", ["three_phase"]), "single_phase");
|
||||||
|
assert.equal(resolveCircuitPhaseType("three_phase", ["single_phase"]), "three_phase");
|
||||||
|
assert.equal(resolveCircuitPhaseType("unassigned", ["three_phase"]), "three_phase");
|
||||||
|
assert.equal(
|
||||||
|
resolveCircuitPhaseType("unassigned", ["three_phase", "single_phase"]),
|
||||||
|
"single_phase"
|
||||||
|
);
|
||||||
|
assert.equal(resolveCircuitPhaseType("unassigned", []), "single_phase");
|
||||||
|
assert.equal(resolveCircuitPhaseType("unassigned", [null]), "single_phase");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes known legacy phase labels without guessing unknown values", () => {
|
||||||
|
assert.equal(normalizeKnownElectricalPhaseType("1phasig"), "single_phase");
|
||||||
|
assert.equal(normalizeKnownElectricalPhaseType("1-phasig"), "single_phase");
|
||||||
|
assert.equal(normalizeKnownElectricalPhaseType("3phase"), "three_phase");
|
||||||
|
assert.equal(normalizeKnownElectricalPhaseType("3-phasig"), "three_phase");
|
||||||
|
assert.equal(normalizeKnownElectricalPhaseType(null), null);
|
||||||
|
assert.throws(
|
||||||
|
() => normalizeKnownElectricalPhaseType("zweiphasig"),
|
||||||
|
/Unknown electrical phase type/
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ describe("circuit project-command repository", () => {
|
|||||||
const circuit = getCircuit(fixture.context);
|
const circuit = getCircuit(fixture.context);
|
||||||
assert.equal(circuit.displayName, null);
|
assert.equal(circuit.displayName, null);
|
||||||
assert.equal(circuit.protectionRatedCurrent, 16);
|
assert.equal(circuit.protectionRatedCurrent, 16);
|
||||||
assert.equal(circuit.voltage, 400);
|
assert.equal(circuit.voltage, 230);
|
||||||
assert.equal(circuit.controlRequirement, "DALI");
|
assert.equal(circuit.controlRequirement, "DALI");
|
||||||
assert.equal(circuit.isReserve, 1);
|
assert.equal(circuit.isReserve, 1);
|
||||||
assert.equal(executed.revision.revisionNumber, 1);
|
assert.equal(executed.revision.revisionNumber, 1);
|
||||||
@@ -138,7 +138,6 @@ describe("circuit project-command repository", () => {
|
|||||||
changes: [
|
changes: [
|
||||||
{ field: "displayName", value: "Licht Bestand" },
|
{ field: "displayName", value: "Licht Bestand" },
|
||||||
{ field: "protectionRatedCurrent", value: 10 },
|
{ field: "protectionRatedCurrent", value: 10 },
|
||||||
{ field: "voltage", value: 230 },
|
|
||||||
{ field: "controlRequirement", value: "none" },
|
{ field: "controlRequirement", value: "none" },
|
||||||
{ field: "isReserve", value: false },
|
{ field: "isReserve", value: false },
|
||||||
],
|
],
|
||||||
@@ -149,7 +148,15 @@ describe("circuit project-command repository", () => {
|
|||||||
.from(projectChangeSets)
|
.from(projectChangeSets)
|
||||||
.get();
|
.get();
|
||||||
assert.ok(changeSet);
|
assert.ok(changeSet);
|
||||||
assert.deepEqual(deserializeProjectCommand(changeSet.forwardPayloadJson), command);
|
assert.deepEqual(
|
||||||
|
deserializeProjectCommand(changeSet.forwardPayloadJson),
|
||||||
|
createCircuitUpdateProjectCommand("circuit-1", {
|
||||||
|
displayName: null,
|
||||||
|
protectionRatedCurrent: 16,
|
||||||
|
controlRequirement: "DALI",
|
||||||
|
isReserve: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
deserializeProjectCommand(changeSet.inversePayloadJson),
|
deserializeProjectCommand(changeSet.inversePayloadJson),
|
||||||
executed.inverse
|
executed.inverse
|
||||||
|
|||||||
@@ -13,14 +13,23 @@ import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
|||||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||||
import { circuits } from "../src/db/schema/circuits.js";
|
import { circuits } from "../src/db/schema/circuits.js";
|
||||||
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||||
|
import { floors } from "../src/db/schema/floors.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||||
import {
|
import {
|
||||||
createDistributionBoardInsertProjectCommand,
|
createDistributionBoardInsertProjectCommand,
|
||||||
createDistributionBoardStructureSnapshot,
|
createDistributionBoardStructureSnapshot,
|
||||||
|
type DistributionBoardStructureProjectCommand,
|
||||||
} from "../src/domain/models/distribution-board-structure-project-command.model.js";
|
} from "../src/domain/models/distribution-board-structure-project-command.model.js";
|
||||||
import { createDistributionBoard } from "../src/frontend/utils/api.js";
|
import {
|
||||||
|
createDistributionBoardUpdateProjectCommand,
|
||||||
|
type DistributionBoardUpdateProjectCommand,
|
||||||
|
} from "../src/domain/models/distribution-board-project-command.model.js";
|
||||||
|
import {
|
||||||
|
createDistributionBoard,
|
||||||
|
updateDistributionBoard,
|
||||||
|
} from "../src/frontend/utils/api.js";
|
||||||
|
|
||||||
function createTestDatabase(): DatabaseContext {
|
function createTestDatabase(): DatabaseContext {
|
||||||
const context = createDatabaseContext(":memory:");
|
const context = createDatabaseContext(":memory:");
|
||||||
@@ -50,9 +59,11 @@ describe("distribution-board structure project command", () => {
|
|||||||
it("builds the fixed setup and sends the expected frontend revision", async () => {
|
it("builds the fixed setup and sends the expected frontend revision", async () => {
|
||||||
const structure = createDistributionBoardStructureSnapshot(
|
const structure = createDistributionBoardStructureSnapshot(
|
||||||
"project-1",
|
"project-1",
|
||||||
" UV-02 "
|
" UV-02 ",
|
||||||
|
{ floorId: null, supplyType: "SV" }
|
||||||
);
|
);
|
||||||
assert.equal(structure.distributionBoard.name, "UV-02");
|
assert.equal(structure.distributionBoard.name, "UV-02");
|
||||||
|
assert.equal(structure.distributionBoard.supplyType, "SV");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
structure.circuitList.name,
|
structure.circuitList.name,
|
||||||
"UV-02 Stromkreisliste"
|
"UV-02 Stromkreisliste"
|
||||||
@@ -91,14 +102,27 @@ describe("distribution-board structure project command", () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await createDistributionBoard("project-1", "UV-02", 7);
|
await createDistributionBoard(
|
||||||
|
"project-1",
|
||||||
|
{
|
||||||
|
name: "UV-02",
|
||||||
|
floorId: null,
|
||||||
|
supplyType: "SV",
|
||||||
|
},
|
||||||
|
7
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
}
|
}
|
||||||
assert.deepEqual(requests, [
|
assert.deepEqual(requests, [
|
||||||
{
|
{
|
||||||
url: "/api/projects/project-1/distribution-boards",
|
url: "/api/projects/project-1/distribution-boards",
|
||||||
body: { name: "UV-02", expectedRevision: 7 },
|
body: {
|
||||||
|
name: "UV-02",
|
||||||
|
floorId: null,
|
||||||
|
supplyType: "SV",
|
||||||
|
expectedRevision: 7,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -178,6 +202,306 @@ describe("distribution-board structure project command", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows only supply types enabled in the project", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.db
|
||||||
|
.update(projects)
|
||||||
|
.set({
|
||||||
|
enabledDistributionBoardSupplyTypes: ["MSR", "SiBe"],
|
||||||
|
})
|
||||||
|
.where(eq(projects.id, "project-1"))
|
||||||
|
.run();
|
||||||
|
const repository =
|
||||||
|
new DistributionBoardStructureProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createDistributionBoardInsertProjectCommand(
|
||||||
|
createDistributionBoardStructureSnapshot(
|
||||||
|
"project-1",
|
||||||
|
"UV AV",
|
||||||
|
{ floorId: null, supplyType: "AV" }
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/nicht aktiviert/
|
||||||
|
);
|
||||||
|
const structure = createDistributionBoardStructureSnapshot(
|
||||||
|
"project-1",
|
||||||
|
"UV SiBe",
|
||||||
|
{ floorId: null, supplyType: "SiBe" }
|
||||||
|
);
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createDistributionBoardInsertProjectCommand(structure),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(distributionBoards).get()?.supplyType,
|
||||||
|
"SiBe"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies stored version-one setup commands with unassigned new fields", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository =
|
||||||
|
new DistributionBoardStructureProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
const current = createDistributionBoardStructureSnapshot(
|
||||||
|
"project-1",
|
||||||
|
"UV Alt",
|
||||||
|
{ floorId: null, supplyType: null }
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
floorId: _floorId,
|
||||||
|
supplyType: _supplyType,
|
||||||
|
...legacyBoard
|
||||||
|
} = current.distributionBoard;
|
||||||
|
const legacyCommand = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "distribution-board.insert",
|
||||||
|
payload: {
|
||||||
|
structure: {
|
||||||
|
...current,
|
||||||
|
distributionBoard: legacyBoard,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as DistributionBoardStructureProjectCommand;
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: legacyCommand,
|
||||||
|
});
|
||||||
|
const inserted = context.db
|
||||||
|
.select()
|
||||||
|
.from(distributionBoards)
|
||||||
|
.get();
|
||||||
|
assert.equal(inserted?.floorId, null);
|
||||||
|
assert.equal(inserted?.supplyType, null);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates floor, supply type and simultaneity factor with persisted undo", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
context.db
|
||||||
|
.insert(floors)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: "floor-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "EG",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "floor-foreign",
|
||||||
|
projectId: "project-2",
|
||||||
|
name: "Fremd",
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
const repository =
|
||||||
|
new DistributionBoardStructureProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
const structure = createDistributionBoardStructureSnapshot(
|
||||||
|
"project-1",
|
||||||
|
"UV-02",
|
||||||
|
{ floorId: null, supplyType: "AV" }
|
||||||
|
);
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command:
|
||||||
|
createDistributionBoardInsertProjectCommand(structure),
|
||||||
|
});
|
||||||
|
const forward = repository.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: createDistributionBoardUpdateProjectCommand(
|
||||||
|
structure.distributionBoard.id,
|
||||||
|
{
|
||||||
|
floorId: "floor-1",
|
||||||
|
supplyType: "SV",
|
||||||
|
simultaneityFactor: 0.75,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
context.db
|
||||||
|
.select({
|
||||||
|
floorId: distributionBoards.floorId,
|
||||||
|
supplyType: distributionBoards.supplyType,
|
||||||
|
simultaneityFactor:
|
||||||
|
distributionBoards.simultaneityFactor,
|
||||||
|
})
|
||||||
|
.from(distributionBoards)
|
||||||
|
.get(),
|
||||||
|
{
|
||||||
|
floorId: "floor-1",
|
||||||
|
supplyType: "SV",
|
||||||
|
simultaneityFactor: 0.75,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
context.db
|
||||||
|
.select({
|
||||||
|
floorId: distributionBoards.floorId,
|
||||||
|
supplyType: distributionBoards.supplyType,
|
||||||
|
simultaneityFactor:
|
||||||
|
distributionBoards.simultaneityFactor,
|
||||||
|
})
|
||||||
|
.from(distributionBoards)
|
||||||
|
.get(),
|
||||||
|
{
|
||||||
|
floorId: null,
|
||||||
|
supplyType: "AV",
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 3,
|
||||||
|
source: "user",
|
||||||
|
command: createDistributionBoardUpdateProjectCommand(
|
||||||
|
structure.distributionBoard.id,
|
||||||
|
{ floorId: "floor-foreign" }
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
/does not belong/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db
|
||||||
|
.select()
|
||||||
|
.from(projectRevisions)
|
||||||
|
.all().length,
|
||||||
|
3
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stored version-one board updates executable", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository =
|
||||||
|
new DistributionBoardStructureProjectCommandRepository(
|
||||||
|
context.db
|
||||||
|
);
|
||||||
|
const structure = createDistributionBoardStructureSnapshot(
|
||||||
|
"project-1",
|
||||||
|
"UV Alt",
|
||||||
|
{ supplyType: "AV" }
|
||||||
|
);
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command:
|
||||||
|
createDistributionBoardInsertProjectCommand(structure),
|
||||||
|
});
|
||||||
|
const legacyUpdate = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
type: "distribution-board.update",
|
||||||
|
payload: {
|
||||||
|
distributionBoardId: structure.distributionBoard.id,
|
||||||
|
changes: [{ field: "supplyType", value: "SV" }],
|
||||||
|
},
|
||||||
|
} as DistributionBoardUpdateProjectCommand;
|
||||||
|
|
||||||
|
repository.executeUpdate({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "user",
|
||||||
|
command: legacyUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const board = context.db
|
||||||
|
.select()
|
||||||
|
.from(distributionBoards)
|
||||||
|
.get();
|
||||||
|
assert.equal(board?.supplyType, "SV");
|
||||||
|
assert.equal(board?.simultaneityFactor, 1);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends distribution-board updates to the revision-safe API", async () => {
|
||||||
|
const requests: Array<{ url: string; method: string; body: unknown }> = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
requests.push({
|
||||||
|
url: String(input),
|
||||||
|
method: init?.method ?? "GET",
|
||||||
|
body: JSON.parse(String(init?.body)),
|
||||||
|
});
|
||||||
|
return new Response(JSON.stringify({}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await updateDistributionBoard(
|
||||||
|
"project-1",
|
||||||
|
"board-1",
|
||||||
|
{
|
||||||
|
floorId: "floor-1",
|
||||||
|
supplyType: "USV",
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
},
|
||||||
|
9
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
assert.deepEqual(requests, [
|
||||||
|
{
|
||||||
|
url: "/api/projects/project-1/distribution-boards/board-1",
|
||||||
|
method: "PUT",
|
||||||
|
body: {
|
||||||
|
floorId: "floor-1",
|
||||||
|
supplyType: "USV",
|
||||||
|
simultaneityFactor: 0.8,
|
||||||
|
expectedRevision: 9,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects foreign, stale and changed structures without partial writes", () => {
|
it("rejects foreign, stale and changed structures without partial writes", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -951,8 +951,21 @@ describe("project command service", () => {
|
|||||||
projectId: "project-1",
|
projectId: "project-1",
|
||||||
expectedRevision: 0,
|
expectedRevision: 0,
|
||||||
command: createProjectSettingsUpdateProjectCommand({
|
command: createProjectSettingsUpdateProjectCommand({
|
||||||
|
name: "Test project",
|
||||||
|
internalProjectNumber: null,
|
||||||
|
externalProjectNumber: null,
|
||||||
|
buildingOwner: null,
|
||||||
|
description: null,
|
||||||
singlePhaseVoltageV: 240,
|
singlePhaseVoltageV: 240,
|
||||||
threePhaseVoltageV: 415,
|
threePhaseVoltageV: 415,
|
||||||
|
enabledDistributionBoardSupplyTypes: [
|
||||||
|
"AV",
|
||||||
|
"SV",
|
||||||
|
"EV",
|
||||||
|
"USV",
|
||||||
|
"MSR",
|
||||||
|
"SiBe",
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
assert.equal(updated.history.currentRevision, 1);
|
assert.equal(updated.history.currentRevision, 1);
|
||||||
|
|||||||
@@ -41,6 +41,20 @@ describe("project device circuit-first schema", () => {
|
|||||||
assert.equal(result.success, false);
|
assert.equal(result.success, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects manually supplied device voltages", () => {
|
||||||
|
const result = createProjectDeviceSchema.safeParse({
|
||||||
|
name: "Pumpe",
|
||||||
|
displayName: "Pumpe",
|
||||||
|
phaseType: "three_phase",
|
||||||
|
quantity: 1,
|
||||||
|
powerPerUnit: 2,
|
||||||
|
simultaneityFactor: 1,
|
||||||
|
voltageV: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.success, false);
|
||||||
|
});
|
||||||
|
|
||||||
it("requires a project revision for create and update commands", () => {
|
it("requires a project revision for create and update commands", () => {
|
||||||
const device = {
|
const device = {
|
||||||
name: "E-Line Pro",
|
name: "E-Line Pro",
|
||||||
|
|||||||
@@ -177,6 +177,33 @@ describe("project history repository", () => {
|
|||||||
nextBeforeRevision: null,
|
nextBeforeRevision: null,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
history
|
||||||
|
.listRevisionsByNumbers("project-1", [3, 1, 3, 99])
|
||||||
|
.map((revision) => ({
|
||||||
|
revisionNumber: revision.revisionNumber,
|
||||||
|
description: revision.description,
|
||||||
|
})),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
revisionNumber: 3,
|
||||||
|
description: "Anzahl zurücknehmen",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
revisionNumber: 1,
|
||||||
|
description: "Stromkreis umbenennen",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
history.listRevisionsByNumbers("project-1", [0]),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
history.listRevisionsByNumbers("project-1", [-1]),
|
||||||
|
/non-negative integers/
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
context.close();
|
context.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||||
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
|
import { ProjectLocationStructureProjectCommandRepository } from "../src/db/repositories/project-location-structure-project-command.repository.js";
|
||||||
import { consumers } from "../src/db/schema/consumers.js";
|
import { consumers } from "../src/db/schema/consumers.js";
|
||||||
|
import { distributionBoards } from "../src/db/schema/distribution-boards.js";
|
||||||
import { floors } from "../src/db/schema/floors.js";
|
import { floors } from "../src/db/schema/floors.js";
|
||||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||||
import { projects } from "../src/db/schema/projects.js";
|
import { projects } from "../src/db/schema/projects.js";
|
||||||
@@ -183,6 +184,52 @@ describe("project-location structure project command", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not undo a floor assigned to a distribution board", () => {
|
||||||
|
const context = createTestDatabase();
|
||||||
|
try {
|
||||||
|
const repository =
|
||||||
|
new ProjectLocationStructureProjectCommandRepository(context.db);
|
||||||
|
const floor = createProjectFloorSnapshot("project-1", "EG", 0);
|
||||||
|
const inserted = repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 0,
|
||||||
|
source: "user",
|
||||||
|
command: createProjectFloorInsertProjectCommand(floor),
|
||||||
|
});
|
||||||
|
context.db
|
||||||
|
.insert(distributionBoards)
|
||||||
|
.values({
|
||||||
|
id: "board-1",
|
||||||
|
projectId: "project-1",
|
||||||
|
name: "UV-01",
|
||||||
|
floorId: floor.id,
|
||||||
|
supplyType: "AV",
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
const undoStep = new ProjectHistoryRepository(
|
||||||
|
context.db
|
||||||
|
).getNextCommand("project-1", "undo");
|
||||||
|
assert.ok(undoStep);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
repository.execute({
|
||||||
|
projectId: "project-1",
|
||||||
|
expectedRevision: 1,
|
||||||
|
source: "undo",
|
||||||
|
historyTargetChangeSetId: undoStep.changeSetId,
|
||||||
|
command: inserted.inverse,
|
||||||
|
}),
|
||||||
|
/assigned to a distribution board/
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
context.db.select().from(projectRevisions).all().length,
|
||||||
|
1
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
context.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("creates a stable room and supports persisted undo and redo", () => {
|
it("creates a stable room and supports persisted undo and redo", () => {
|
||||||
const context = createTestDatabase();
|
const context = createTestDatabase();
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import * as React from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import ProjectsPage from "../src/app/projects/page.js";
|
||||||
|
import { importProjectTransferAsNew } from "../src/frontend/utils/api.js";
|
||||||
|
import { importProjectTransferAsNewSchema } from "../src/shared/validation/project-transfer.schemas.js";
|
||||||
|
|
||||||
|
describe("project overview import", () => {
|
||||||
|
it("renders a dedicated import-as-new-project entry point", () => {
|
||||||
|
const globalWithReact = globalThis as typeof globalThis & {
|
||||||
|
React?: typeof React;
|
||||||
|
};
|
||||||
|
const originalReact = globalWithReact.React;
|
||||||
|
globalWithReact.React = React;
|
||||||
|
let markup: string;
|
||||||
|
try {
|
||||||
|
markup = renderToStaticMarkup(React.createElement(ProjectsPage));
|
||||||
|
} finally {
|
||||||
|
globalWithReact.React = originalReact;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.match(markup, /Projekt importieren/);
|
||||||
|
assert.match(markup, /Projektdatei auswählen/);
|
||||||
|
assert.match(markup, /Als neues Projekt importieren/);
|
||||||
|
assert.doesNotMatch(markup, /Dieses Projekt ersetzen/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the collection import endpoint without a target project id", async () => {
|
||||||
|
const requests: Array<{
|
||||||
|
url: string;
|
||||||
|
method: string;
|
||||||
|
body: unknown;
|
||||||
|
}> = [];
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = async (input, init) => {
|
||||||
|
requests.push({
|
||||||
|
url: String(input),
|
||||||
|
method: init?.method ?? "GET",
|
||||||
|
body: init?.body ? JSON.parse(String(init.body)) : null,
|
||||||
|
});
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ projectId: "project-copy", name: "Projekt (Kopie)" }),
|
||||||
|
{
|
||||||
|
status: 201,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transfer = { format: "test" };
|
||||||
|
const result = await importProjectTransferAsNew(transfer);
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
projectId: "project-copy",
|
||||||
|
name: "Projekt (Kopie)",
|
||||||
|
});
|
||||||
|
assert.deepEqual(requests, [
|
||||||
|
{
|
||||||
|
url: "/api/projects/import",
|
||||||
|
method: "POST",
|
||||||
|
body: { transfer },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts only the strict transfer wrapper", () => {
|
||||||
|
assert.equal(
|
||||||
|
importProjectTransferAsNewSchema.safeParse({ transfer: {} }).success,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert.equal(importProjectTransferAsNewSchema.safeParse({}).success, false);
|
||||||
|
assert.equal(
|
||||||
|
importProjectTransferAsNewSchema.safeParse({
|
||||||
|
transfer: {},
|
||||||
|
mode: "replace",
|
||||||
|
}).success,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user