Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4bfef9071 | |||
| 4a859f31b8 | |||
| 2d97fafce9 | |||
| 7c8d4a7ddf | |||
| ab989cc637 | |||
| 93ec294759 | |||
| be9c6f0d52 | |||
| 0cb280ddb2 | |||
| 8898117ed4 | |||
| 18ca5eb3d4 | |||
| 0c92fbd09e | |||
| 756e307bd8 | |||
| 31589875b5 | |||
| 704f4df2a8 |
@@ -12,7 +12,11 @@ implementiert.
|
||||
## Unterstützter Arbeitsablauf
|
||||
|
||||
- Projekte, Verteilungen, Etagen und Räume verwalten
|
||||
- pro Verteilung eine Stromkreisliste mit festen Bereichen bearbeiten
|
||||
- pro Verteilung geschützte Stromkreisgruppen für Beleuchtung, 1-phasige und
|
||||
3-phasige Stromkreise verwalten
|
||||
- Hauptschalter, Überspannungsableiter, Gruppenschutz und zusätzliche
|
||||
Verteilergeräte in der Stromkreisliste abbilden
|
||||
- Schutzgeräte je Stromkreis und optional je Gruppe konfigurieren
|
||||
- leere, einzeilige und mehrzeilige Stromkreise abbilden
|
||||
- Stromkreise und Gerätezeilen per Drag-and-drop umstrukturieren
|
||||
- Projektgeräte einfügen, verknüpfen und kontrolliert synchronisieren
|
||||
@@ -90,6 +94,7 @@ Frontend und API laufen anschließend auf denselben Ports wie im Docker-Setup.
|
||||
npm test
|
||||
npm run build:api
|
||||
npm run build:web
|
||||
npm run typecheck:scripts
|
||||
npx tsc --noEmit -p tsconfig.next.json
|
||||
```
|
||||
|
||||
|
||||
@@ -59,16 +59,23 @@ this value for optimistic concurrency checks.
|
||||
- clients cannot submit arbitrary `project.restore-state` payloads through
|
||||
the generic command endpoint
|
||||
|
||||
The public dispatcher currently supports `circuit.update`, `circuit.insert`,
|
||||
`circuit.delete`, `circuit-device-row.update`,
|
||||
`circuit-device-row.insert`, `circuit-device-row.delete`,
|
||||
`circuit-device-row.move`, `circuit-device-row.move-with-new-circuit`,
|
||||
`circuit.reorder-section`, `circuit.reorder-sections`,
|
||||
`circuit.renumber-section` and
|
||||
`project-device.update`, `project-device.insert`, `project-device.delete` and
|
||||
`project-device.sync-rows` as well as `project.update-settings`, all with
|
||||
schema version `1`. Other command types
|
||||
are rejected. Insert commands contain the complete entity or circuit block
|
||||
The public dispatcher supports the Circuit and CircuitDeviceRow commands
|
||||
`circuit.update`, `circuit.insert`, `circuit.delete`,
|
||||
`circuit-device-row.update`, `circuit-device-row.insert`,
|
||||
`circuit-device-row.delete`, `circuit-device-row.move`,
|
||||
`circuit-device-row.move-with-new-circuit`, `circuit.reorder-section`,
|
||||
`circuit.reorder-sections` and `circuit.renumber-section`.
|
||||
|
||||
Protected-board structure uses
|
||||
`distribution-board-component.insert|update|delete`,
|
||||
`circuit-group.insert|update|delete|reorder|renumber`,
|
||||
`circuit.move-group`, `circuit-group.delete-subtree` and
|
||||
`circuit-protection.update`. A subtree restore is normally emitted only as the
|
||||
persisted inverse of a delete. The dispatcher also supports the documented
|
||||
project-device, project settings, distribution-board and project-location
|
||||
commands. The generic editor commands listed here use schema version `1`;
|
||||
older stored formats can remain supported by their owning command boundary.
|
||||
Unsupported types are rejected. Insert commands contain the complete entity or circuit block
|
||||
with stable ids; delete commands include the expected parent identity. Circuit
|
||||
snapshots contain zero, one or multiple complete device rows. Move commands
|
||||
contain each row's expected and target circuit plus its exact expected and
|
||||
@@ -101,6 +108,24 @@ targets occupied by other sections, then applies swaps through collision-safe
|
||||
temporary identifiers. Undo restores the exact prior identifiers; sort
|
||||
positions and device rows remain unchanged.
|
||||
|
||||
`circuit-group.reorder` changes only complete group sort assignments.
|
||||
`circuit-group.renumber` carries every affected group, child circuit and group
|
||||
component with expected and target identifiers so swaps can be applied
|
||||
collision-safely. `circuit.move-group` changes one complete circuit's group,
|
||||
BMK and position and stores the deterministic target BMK. The target group must
|
||||
have the same category.
|
||||
|
||||
`circuit-group.delete-subtree` carries the complete warned group state,
|
||||
including both protection layers and every device-row link/override value. The
|
||||
server captures the current subtree again inside the transaction and rejects a
|
||||
stale or incomplete snapshot. Undo restores the same UUIDs through the stored
|
||||
inverse command.
|
||||
|
||||
`circuit-protection.update` inserts or replaces the complete validated
|
||||
one-to-one circuit protection state. Users cannot remove the required
|
||||
protection row; removal is available only to a stored inverse when undoing its
|
||||
initial creation.
|
||||
|
||||
`project-device.sync-rows` represents `synchronize`, `disconnect` and
|
||||
`reconnect` operations. Every selected row carries complete expected and target
|
||||
snapshots of all ProjectDevice-sync fields, the link and `overriddenFields`.
|
||||
|
||||
@@ -17,13 +17,15 @@ The pure grid modules have no React state and are covered by focused unit tests.
|
||||
## Domain Model Overview
|
||||
|
||||
- `CircuitSection`
|
||||
- Groups circuits by planning section (for example lighting, single-phase, three-phase).
|
||||
- Owns section metadata (`key`, `displayName`, `prefix`, ordering).
|
||||
- Represents a protected circuit group in one of the categories lighting,
|
||||
single-phase or three-phase.
|
||||
- Owns category, positive group number, generated prefix, display name and
|
||||
ordering.
|
||||
- `Circuit`
|
||||
- Core electrical unit in the list.
|
||||
- Owns circuit-level identifiers and technical data:
|
||||
- `equipmentIdentifier` (BMK)
|
||||
- protection data
|
||||
- one explicit one-to-one protection device
|
||||
- cable data
|
||||
- reserve state
|
||||
- voltage and optional control requirement for future sizing
|
||||
@@ -43,6 +45,10 @@ The pure grid modules have no React state and are covered by focused unit tests.
|
||||
- `ProjectDevice`
|
||||
- Reusable device template entity at project level.
|
||||
- Can be linked to `CircuitDeviceRow` entries, with copied display values on insert.
|
||||
- `DistributionBoardComponent`
|
||||
- Represents fixed header components, optional group protection and manually
|
||||
named auxiliary footer devices with their own unique BMK.
|
||||
- Group protection may own a separate one-to-one protection configuration.
|
||||
- Retained legacy migration source
|
||||
- Old `consumers` rows, mappings and reports are available only to explicit
|
||||
database upgrade tooling.
|
||||
@@ -77,7 +83,9 @@ Section-level `-frei-` placeholder rows represent insertion targets for creating
|
||||
`equipmentIdentifier` / BMK is circuit-owned (`Circuit.equipmentIdentifier`).
|
||||
|
||||
- Device rows do not have their own BMK.
|
||||
- Existing identifiers must stay stable unless explicitly changed by user action.
|
||||
- Existing identifiers stay stable on sort, insert and delete. Explicit
|
||||
renumbering may change them, and a user-initiated complete-circuit move to
|
||||
another same-category group assigns the next target-group identifier.
|
||||
- Renumbering is explicit, not implicit on move/sort/delete.
|
||||
|
||||
## Data Ownership Split: Circuit vs Device Row
|
||||
@@ -113,9 +121,9 @@ The sibling `/tree` route is a read-only structure preview. Old
|
||||
|
||||
## Future Persistence Direction
|
||||
|
||||
Persistent undo/redo, project revisions, logical snapshots, external-model exchange and PostgreSQL readiness are specified in [Project History and External Model Architecture](./project-history-and-external-model-architecture.md).
|
||||
|
||||
Critical multi-write commands already use explicit persistence transaction
|
||||
adapters. The next step is to compose these command boundaries into server-side,
|
||||
project-scoped revisions and change sets. Database backups remain separate from
|
||||
user-visible project snapshots.
|
||||
Persistent Undo/Redo, project revisions and logical snapshots are implemented
|
||||
through project-scoped commands and the shared transaction boundary. Database
|
||||
backups remain separate from user-visible project snapshots. External-model
|
||||
exchange and PostgreSQL readiness are specified in
|
||||
[Project History and External Model Architecture](./project-history-and-external-model-architecture.md)
|
||||
and remain future work.
|
||||
|
||||
@@ -28,6 +28,7 @@ The editor can have a selected cell without an active editor input. `editingCell
|
||||
The UI works from a normalized `visibleRows` model built from filtered/sorted sections:
|
||||
|
||||
- section header rows
|
||||
- fixed header, group-protection and auxiliary footer component rows
|
||||
- circuit rows (`circuitCompact`, `circuitSummary`, `reserveCircuit`)
|
||||
- device rows (`deviceRow`)
|
||||
- section placeholder rows (`placeholder`)
|
||||
@@ -82,13 +83,21 @@ Intent is separated by drag source type:
|
||||
- device row drag:
|
||||
- drop to existing circuit -> move row(s) into that circuit
|
||||
- drop to placeholder -> create new target circuit and move row(s)
|
||||
- drop on the upper or lower edge of a circuit row -> create a new target
|
||||
circuit directly before or after that circuit and move the row(s)
|
||||
- the center of a circuit row remains the explicit target for adding the
|
||||
row(s) to that existing circuit
|
||||
- crossing a section boundary requires explicit confirmation
|
||||
- phase type, category, linked project device and local row values remain unchanged
|
||||
- target creation, row assignments and reserve-state updates use one SQLite transaction
|
||||
- circuit drag (BMK handle):
|
||||
- reorder circuits inside same section only
|
||||
- cross-section reorder is rejected
|
||||
- the complete section order is validated and stored in one SQLite transaction
|
||||
- reorder one or multiple circuits inside the same group without changing BMKs
|
||||
- move exactly one circuit into another group of the same category
|
||||
- a cross-group move assigns the highest existing target suffix plus one;
|
||||
gaps are not filled
|
||||
- cross-category and cross-group multi-circuit moves are rejected
|
||||
- same-group reorder validates the complete group order; cross-group move
|
||||
stores the one exact source/target transition
|
||||
- bulk device row move:
|
||||
- supported via multi-selection + drag
|
||||
- multi-circuit move:
|
||||
@@ -96,7 +105,25 @@ Intent is separated by drag source type:
|
||||
|
||||
The sidebar insertion controls use the same project-device placement rules as drag-and-drop. Invalid section and circuit options are disabled after selecting a project device.
|
||||
|
||||
Cross-section device moves show confirmation-required feedback before drop. The confirmation names source and target sections and warns that the unchanged device classification may need manual review. Cancelling leaves every row and circuit unchanged. Moving never renumbers existing circuits.
|
||||
Cross-section device moves show confirmation-required feedback before drop. The confirmation names source and target sections and warns that the unchanged device classification may need manual review. Cancelling leaves every row and circuit unchanged. Device-row moves never renumber circuits; the explicitly initiated complete-circuit group move is the sole automatic BMK-changing move.
|
||||
|
||||
## Protected Group and Component Controls
|
||||
|
||||
- New groups are created manually in one of the three supported categories and
|
||||
receive the highest existing category group number plus one.
|
||||
- Group headings always show the stored editable display name; category labels
|
||||
do not overwrite names of the default first groups.
|
||||
- Group reorder buttons move only relative to category peers and never change a
|
||||
group number or BMK.
|
||||
- `Gruppen-BMK neu nummerieren` is an explicit confirmed category-wide action.
|
||||
It follows the persisted group order and updates group prefixes, optional
|
||||
`.0` protection BMKs and circuit BMKs together.
|
||||
- Optional group upstream protection and group FI as well as auxiliary footer
|
||||
components use labeled, type-dependent modals.
|
||||
- Each circuit protection device uses its own modal; only fields valid for the
|
||||
selected type are shown.
|
||||
- Removing a populated group warns with circuit, device-row and group
|
||||
protection counts. The complete deletion remains persistently undoable.
|
||||
|
||||
## Filtering and Sorting
|
||||
|
||||
@@ -118,7 +145,8 @@ Cross-section device moves show confirmation-required feedback before drop. The
|
||||
|
||||
- Column visibility and order are configurable.
|
||||
- BMK column (`equipmentIdentifier`) is locked as first column.
|
||||
- Layout is saved in local storage (`circuitTreeEditor.columnLayout.v1`).
|
||||
- Layout is saved in local storage under a project-specific key so switching
|
||||
distribution boards preserves the same project layout.
|
||||
|
||||
## Undo/Redo
|
||||
|
||||
@@ -138,8 +166,10 @@ Covered operations include:
|
||||
- insert/delete circuit
|
||||
- insert/delete row
|
||||
- edit cell values
|
||||
- moves (single/bulk rows, circuit reorder)
|
||||
- renumber and identifier update flows
|
||||
- moves (single/bulk rows, circuit reorder and same-category group move)
|
||||
- circuit and group renumber/identifier flows
|
||||
- component, group and protection editing
|
||||
- warned populated-group deletion and exact restoration
|
||||
- apply sorted order
|
||||
|
||||
The editor toolbar exposes availability through its Undo/Redo buttons. The
|
||||
|
||||
@@ -16,5 +16,11 @@
|
||||
- Final electrical sizing logic is not implemented yet.
|
||||
- No full norm-compliant voltage-drop and protection-dimensioning calculation flow yet.
|
||||
- Sorting is view-only until users explicitly apply sorted order.
|
||||
- Cross-section circuit drag-reorder is intentionally blocked.
|
||||
- Complete-circuit drag supports one circuit at a time between groups of the
|
||||
same category. Cross-category moves and multi-circuit moves across group
|
||||
boundaries are intentionally rejected; multi-circuit reorder inside one
|
||||
group remains supported.
|
||||
- Protection and cable values are planner-owned manual selections. The later
|
||||
sizing/warning engine does not yet validate them against load, length or
|
||||
voltage-drop rules.
|
||||
- Legacy consumer UI and server endpoints are removed; retained source rows and migration mappings remain available only for upgrade traceability.
|
||||
|
||||
@@ -296,15 +296,15 @@ einen vollständigen Zielzustand aus; ID, Eigentum, Rolle und Platzierungszone
|
||||
bleiben dabei unverändert. Projekt-, Listen- und Gruppenzugehörigkeit sowie
|
||||
veraltete Zustände werden innerhalb derselben Transaktion geprüft. Die festen
|
||||
Kopfkomponenten Hauptschalter und Überspannungsableiter sind von diesen
|
||||
allgemeinen Commands ausgeschlossen. API- und Editorintegration folgen in den
|
||||
nächsten Arbeitspaketen.
|
||||
allgemeinen Commands ausgeschlossen. Veränderliche Gruppen- und Fußkomponenten
|
||||
werden im Editor über eigene Modale und diese Commands verwaltet.
|
||||
Leere Stromkreisgruppen werden über `circuit-group.insert`,
|
||||
`circuit-group.update` und `circuit-group.delete` verwaltet. Ihr vollständiger
|
||||
Snapshot koppelt Kategorie, positive Gruppennummer und daraus abgeleiteten
|
||||
BMK-Präfix; allgemeine Updates ändern ausschließlich den Anzeigenamen.
|
||||
Löschen verlangt einen exakt unveränderten Zustand ohne Stromkreise und ohne
|
||||
Gruppenkomponenten. Das bestätigte Löschen befüllter Unterbäume bleibt ein
|
||||
eigener späterer Command.
|
||||
Gruppenkomponenten. Befüllte Gruppen verwenden getrennt davon den vollständigen
|
||||
`circuit-group.delete-subtree`-Command mit ausdrücklicher Warnung.
|
||||
`circuit-group.reorder` sortiert Gruppen als vollständige Stromkreislisten-
|
||||
Zuordnung. Jede vorhandene Gruppe muss mit erwarteter und neuer Position
|
||||
enthalten sein. Der Command verändert ausschließlich `sortOrder`; Nummern,
|
||||
@@ -355,6 +355,58 @@ rein und deterministisch in die drei Zonen Kopf, vollständige Gruppen und Fuß.
|
||||
Innerhalb einer Gruppe folgen auf die Überschrift optionale Schutzkomponenten,
|
||||
vollständige Stromkreisblöcke und der freie Einfügeplatz. Leere Gruppen bleiben
|
||||
sichtbar; Komponenten werden stabil nach `sortOrder` und ID sortiert.
|
||||
`circuit-grid-projection.ts` normalisiert diese Struktur zusammen mit den
|
||||
bisherigen kompakten und mehrzeiligen Stromkreisblöcken für den Editor. Kopf-,
|
||||
Gruppen- und Fußkomponenten erscheinen als Zeilen über die volle Tabellenbreite
|
||||
und nehmen nicht an Zellauswahl oder Tastatur-Einfügen teil. Hauptschalter und
|
||||
Überspannungsableiter bleiben feste, schreibgeschützte Kopfkomponenten.
|
||||
Optionale Gruppenvorsicherungen, Gruppen-FIs und zusätzliche Verteilergeräte
|
||||
werden über beschriftete Modale angelegt, bearbeitet und nach Bestätigung
|
||||
entfernt. Der Frontend-API-Adapter serialisiert dafür die vorhandenen
|
||||
`distribution-board-component.*`-Commands; die verbindliche
|
||||
geräteabhängige Validierung und atomare Revision bleiben serverseitig.
|
||||
Neue Stromkreisgruppen erhalten im Editor die höchste vorhandene
|
||||
Gruppennummer ihrer Kategorie plus eins und ein daraus abgeleitetes Präfix.
|
||||
Das Bearbeiten ändert ausschließlich den Anzeigenamen. Nur vollständig leere
|
||||
Gruppen können über `circuit-group.delete` entfernt werden; für befüllte
|
||||
Gruppen bleibt die Aktion bis zum gesonderten Warn- und Unterbaumdialog
|
||||
gesperrt.
|
||||
`circuit-protection.update` bildet die eigene persistente Schreibgrenze für
|
||||
die neue 1:1-Stromkreisschutztabelle. Der Command vergleicht den vollständigen
|
||||
erwarteten Datensatz, validiert Typ und abhängige Felder, prüft die
|
||||
Projektzugehörigkeit und schreibt Schutzgerät, Revision und Historienübergang
|
||||
atomar. Benutzer können Schutzdaten anlegen oder ändern, aber nicht entfernen;
|
||||
Undo einer erstmaligen Anlage darf den zuvor fehlenden Datensatz exakt
|
||||
wiederherstellen.
|
||||
Der vollständige `CircuitSnapshot` kann rückwärtskompatibel einen
|
||||
`protectionDevice`-Datensatz enthalten. Neue gruppierte Stromkreise und durch
|
||||
Geräteverschiebung erzeugte Zielstromkreise verwenden die vereinbarten
|
||||
Kategorie-Standardwerte und schreiben Schutzgerät, Stromkreis sowie
|
||||
Gerätezeilen atomar. Delete/Undo erfasst denselben Datensatz vollständig.
|
||||
Im Editor sind die bisherigen Schutzspalten deshalb nur noch eine
|
||||
schreibgeschützte Projektion der 1:1-Daten; Änderungen erfolgen über ein
|
||||
geräteabhängiges Schutzgeräte-Modal und `circuit-protection.update`.
|
||||
Gruppen lassen sich im Editor schrittweise nur gegenüber einer benachbarten
|
||||
Gruppe derselben Kategorie verschieben. Der Client sendet dabei immer die
|
||||
vollständige erwartete und gewünschte Sortierreihenfolge über
|
||||
`circuit-group.reorder`; Gruppennummer, Präfix und Kind-BMK bleiben unverändert.
|
||||
Eine separate, ausdrücklich bestätigte Editoraktion erzeugt dagegen einen
|
||||
vollständigen `circuit-group.renumber`-Plan für genau eine Kategorie. Sie
|
||||
nummeriert deren Gruppen gemäß aktueller Reihenfolge ab eins und ändert
|
||||
Gruppenpräfixe, optionale Gruppen-Schutz-BMK und Stromkreis-BMK gemeinsam;
|
||||
die Stromkreisnummer hinter dem letzten Punkt bleibt erhalten.
|
||||
Das vorhandene Stromkreis-Drag-Handle kann außerdem genau einen vollständigen
|
||||
Stromkreis in eine andere Gruppe derselben Kategorie verschieben. Der atomare
|
||||
`circuit.move-group`-Befehl erhält Gerätezeilen und Schutzdaten, setzt die
|
||||
Zielposition und vergibt dort die höchste vorhandene Stromkreisnummer plus eins;
|
||||
Lücken werden nicht automatisch gefüllt.
|
||||
Befüllte Gruppen werden im Editor erst nach einer Warnung mit Anzahl der
|
||||
enthaltenen Stromkreise, Gerätezeilen und Gruppenschutzgeräte gelöscht. Der
|
||||
Client sendet dafür den vollständigen aktuellen Unterbaum an
|
||||
`circuit-group.delete-subtree`; der Server vergleicht ihn innerhalb derselben
|
||||
Transaktion mit dem Datenbankstand. Undo stellt Gruppe, Schutzgeräte,
|
||||
Stromkreise, Gerätereihen und Verknüpfungs-/Override-Metadaten vollständig
|
||||
wieder her.
|
||||
`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
|
||||
@@ -426,9 +478,12 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
|
||||
stromkreislistenweite BMK-Eindeutigkeit über Stromkreise und
|
||||
Verteilerkomponenten. Snapshot- und Transfer-Integration verwenden
|
||||
Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für
|
||||
veränderliche Verteilerkomponenten sowie CRUD-Commands für leere Gruppen
|
||||
sowie vollständige Gruppensortierung sind integriert; befüllte Unterbäume
|
||||
und UI folgen in abgegrenzten Arbeitspaketen.
|
||||
veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter
|
||||
Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
|
||||
zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und
|
||||
Fußkomponenten über dedizierte Command-Modale. Gruppenanlage, -umbenennung,
|
||||
-sortierung, explizite Neunummerierung, Same-Category-Stromkreiswechsel,
|
||||
geschütztes Unterbaumlöschen und Stromkreisschutz sind integriert.
|
||||
|
||||
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
|
||||
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
|
||||
|
||||
@@ -45,10 +45,16 @@ Projekt- oder Kundendaten versehentlich ins Repository. Für einen lokalen
|
||||
Testbestand:
|
||||
|
||||
1. In der UI ein Projekt `Demo` anlegen.
|
||||
2. Im Projekt eine Verteilung `UV-01` erstellen.
|
||||
2. Im Projekt eine Verteilung `UV-01` erstellen. Die Stromkreisliste enthält
|
||||
danach Hauptschalter, Überspannungsableiter und je eine Gruppe für
|
||||
Beleuchtung, 1-phasige und 3-phasige Stromkreise.
|
||||
3. Ein Projektgerät mit unkritischen Fantasiewerten anlegen.
|
||||
4. Die erzeugte Stromkreisliste öffnen und das Gerät in einen passenden Bereich
|
||||
4. Die erzeugte Stromkreisliste öffnen und das Gerät in eine passende Gruppe
|
||||
ziehen.
|
||||
5. Optional eine zweite Gruppe derselben Kategorie, einen Gruppen-FI und ein
|
||||
zusätzliches Verteilergerät anlegen. Damit lassen sich Gruppensortierung,
|
||||
explizite Neunummerierung, Stromkreiswechsel und persistentes Undo/Redo ohne
|
||||
reale Projekt- oder Kundendaten prüfen.
|
||||
|
||||
Dateien unter `data/` und `data/backups/` dürfen nicht committed werden.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ requirements and intended sequencing, not proof of implementation.
|
||||
|
||||
## Distribution Board Components and Protection Groups
|
||||
|
||||
- [ ] Implement the agreed protected circuit-group and distribution-board
|
||||
- [x] Implement the agreed protected circuit-group and distribution-board
|
||||
component model in phased work packages.
|
||||
- [x] Phase A: central protection catalog, validation, defaults and pure grouped
|
||||
BMK rules.
|
||||
@@ -33,9 +33,18 @@ requirements and intended sequencing, not proof of implementation.
|
||||
- [x] Phase D3b: persistent populated-group delete/restore command.
|
||||
- [x] Phase E1: tree read model for components, groups and protection devices.
|
||||
- [x] Phase E2a: pure header/group/footer structure projection.
|
||||
- [ ] Phase E2b: render the structure projection in the editor grid.
|
||||
- [ ] Phase E: editor projection and editing.
|
||||
- [ ] Phase F: documentation and full GUI verification.
|
||||
- [x] Phase E2b: render the structure projection in the editor grid.
|
||||
- [x] Phase E3a: create, edit and delete mutable group and footer components.
|
||||
- [x] Phase E3b1: create, rename and safely delete empty circuit groups.
|
||||
- [x] Phase E3b2a: persistent circuit-protection update command.
|
||||
- [x] Phase E3b2b: circuit-protection defaults on insert and editor modal.
|
||||
- [x] Phase E4a: persistent same-category group reorder controls.
|
||||
- [x] Phase E4b: explicit group renumber, circuit moves and populated-delete warning.
|
||||
- [x] Explicit same-category group renumbering updates prefixes and all child BMK atomically.
|
||||
- [x] Single-circuit moves between same-category groups with automatic next-free BMK assignment.
|
||||
- [x] Populated-group delete warning and explicit subtree deletion.
|
||||
- [x] Phase E: editor projection and editing.
|
||||
- [x] Phase F: documentation and full GUI verification.
|
||||
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
|
||||
the planner supplies the calculation requirements.
|
||||
- Detailed specification:
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
## Status
|
||||
|
||||
This document defines the agreed target behavior for distribution-board
|
||||
components, protected circuit groups and circuit protection devices. Phase A
|
||||
and the relational Phase-B1 foundation are implemented; later sections remain
|
||||
requirements and are not proof of runtime or editor support.
|
||||
This document records the agreed and implemented behavior for
|
||||
distribution-board components, protected circuit groups and circuit
|
||||
protection devices. Delivery phases A through E are complete. Phase F retains
|
||||
the final GUI checklist; the current runtime paths are documented in
|
||||
`docs/current-architecture.md`.
|
||||
|
||||
Full rule-based protection and cable sizing is deliberately deferred. This
|
||||
phase establishes the structure and manually selected technical values that a
|
||||
@@ -739,8 +740,7 @@ Acceptance:
|
||||
|
||||
### E. Editor Projection and Editing
|
||||
|
||||
Status: In progress. The complete tree read model E1 and pure structural
|
||||
projection E2a are complete. Grid rendering and editing remain pending.
|
||||
Status: Complete.
|
||||
|
||||
- render fixed header components
|
||||
- render group components and circuit blocks
|
||||
@@ -749,7 +749,7 @@ projection E2a are complete. Grid rendering and editing remain pending.
|
||||
- add type-dependent protection editing
|
||||
- keep spreadsheet selection, keyboard and drag behavior coherent
|
||||
|
||||
Implemented in E1/E2a:
|
||||
Implemented in E1/E2a/E2b/E3a:
|
||||
|
||||
- the tree response exposes fixed header, group and auxiliary footer
|
||||
components plus both one-to-one protection layers
|
||||
@@ -759,6 +759,43 @@ Implemented in E1/E2a:
|
||||
circuit blocks and free placeholder together
|
||||
- empty groups remain visible and circuit device rows remain owned by their
|
||||
circuit block
|
||||
- the editor renders fixed header, group protection and auxiliary footer
|
||||
components as read-only full-width rows
|
||||
- structural component rows are excluded from spreadsheet cell navigation,
|
||||
insertion and deletion commands until their dedicated editing controls exist
|
||||
- group headings offer at most one optional upstream protection device and one
|
||||
optional group RCD; the agreed group-RCD default is applied only when it is
|
||||
created
|
||||
- mutable group protection and auxiliary footer components use labeled modals,
|
||||
type-dependent protection fields and persistent project commands
|
||||
- fixed main-switch and surge-protection header components remain read-only
|
||||
- users can create a group in any supported category; its number is the
|
||||
category's highest existing number plus one and its prefix is generated
|
||||
- group editing changes only the display name; category, number, prefix and
|
||||
child BMKs remain stable
|
||||
- an exactly empty group can be removed through the compact persistent group
|
||||
command
|
||||
- `circuit-protection.update` inserts or updates one validated 1:1 protection
|
||||
snapshot, rejects stale state and preserves exact persistent Undo/Redo
|
||||
- user commands cannot remove circuit protection; a nullable target exists
|
||||
only as the inverse of adding protection to a retained circuit without it
|
||||
- newly inserted circuits and placeholder targets carry the category-specific
|
||||
default protection in their complete atomic snapshot
|
||||
- circuit deletion and restoration preserve the exact 1:1 protection row
|
||||
together with the circuit and all device rows
|
||||
- the editor modal exposes only fields valid for the selected protection type;
|
||||
the old flat protection columns now project the new 1:1 data read-only
|
||||
- group controls move a group only to the adjacent peer of the same category;
|
||||
the complete list order is one persistent command and no number or BMK changes
|
||||
- explicit category-wide group renumbering follows the visible persisted group
|
||||
order, updates every affected prefix and child BMK atomically and preserves
|
||||
outgoing-circuit suffixes
|
||||
- dragging one circuit into another group of the same category moves the
|
||||
complete circuit and assigns the highest target suffix plus one; same-group
|
||||
multi-circuit drag remains a pure reorder
|
||||
- populated-group deletion shows the affected circuit, device-row and group
|
||||
protection counts before sending the complete subtree to the guarded
|
||||
delete/restore command
|
||||
|
||||
Acceptance:
|
||||
|
||||
@@ -769,8 +806,12 @@ Acceptance:
|
||||
|
||||
### F. Documentation and Full Verification
|
||||
|
||||
Status: Complete. Documentation, automated verification and the agreed GUI
|
||||
checklist are complete.
|
||||
|
||||
- update current architecture, API and interaction documentation
|
||||
- update demo/sample data
|
||||
- update the safe local demo-data instructions; no versioned customer-like
|
||||
seed data is introduced
|
||||
- run focused domain and transaction tests
|
||||
- run the complete required test, build and type-check suite
|
||||
- perform the agreed GUI test checklist
|
||||
@@ -798,14 +839,15 @@ Acceptance:
|
||||
- Snapshot incompatibility:
|
||||
version payloads and test every supported upgrade path.
|
||||
|
||||
## Open Implementation Decisions
|
||||
## Resolved Implementation Decisions
|
||||
|
||||
These decisions may be resolved during the relevant delivery phase without
|
||||
changing the agreed domain behavior:
|
||||
|
||||
- exact relational implementation of the shared BMK ownership boundary
|
||||
- presentation of group ownership through indentation, headings or a column
|
||||
- filter behavior for structural component rows
|
||||
- a trigger-maintained circuit-list registry enforces shared BMK uniqueness
|
||||
across circuits and distribution-board components
|
||||
- group headings and full-width structural rows show ownership without adding
|
||||
a permanent group column
|
||||
- structural component rows remain outside editable-cell filtering and
|
||||
spreadsheet navigation; circuit filtering continues to preserve complete
|
||||
circuit blocks
|
||||
- explicit UI for converting retained legacy identifiers to grouped numbering
|
||||
- whether fixed header components become editable beyond name and BMK in this
|
||||
phase
|
||||
|
||||
+347
-2
@@ -1,9 +1,303 @@
|
||||
/* Design-System "Petrol · Volt · Kupfer" v2.1 (H:\design-system-econsult.md),
|
||||
projektübergreifendes Update für die Econsult-Familie (siehe elt-planung-suite).
|
||||
Bootstrap bleibt Basis für Formulare/Tabellen/Modals; wir tönen die
|
||||
vorhandenen Bootstrap-Komponenten über deren eigene --bs-btn-*-Variablen
|
||||
um (globals.css lädt nach bootstrap.min.css, gleiche Spezifität gewinnt
|
||||
per Ladereihenfolge) statt Bootstrap zu ersetzen. */
|
||||
:root {
|
||||
--color-primary: #1c3f52; /* Petrol */
|
||||
--color-primary-dark: #0f2733; /* Petrol Dark */
|
||||
--color-primary-deep: #0a1920; /* Petrol Deep */
|
||||
--color-accent: #4e7e93; /* Petrol Light */
|
||||
--color-accent-pale: #9dbac9; /* Petrol Pale */
|
||||
--color-signal: #3fa66b; /* Volt - generelle aktiv/positiv-Farbe */
|
||||
--color-signal-dark: #237a47;
|
||||
--color-attention: #c97a3b; /* Kupfer - bewusst selten, "braucht Aufmerksamkeit" */
|
||||
--color-attention-dark: #9c5a26;
|
||||
--color-ink: #182229; /* Graphite */
|
||||
--color-ink-soft: #59666d; /* Slate */
|
||||
--color-bg: #ffffff;
|
||||
--color-surface: #f1f2ee; /* Fog */
|
||||
--color-border: #dee2dc;
|
||||
--color-danger: #b04035;
|
||||
--color-danger-bg: #f7e6e3;
|
||||
--color-warn: #a9791e;
|
||||
|
||||
--font-head: system-ui, "Segoe UI", "Helvetica Neue", sans-serif;
|
||||
--font-body: system-ui, "Segoe UI", "Helvetica Neue", sans-serif;
|
||||
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 40px;
|
||||
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f5f7fb;
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* ── Bootstrap-Einfärbung auf Petrol/Volt/Kupfer ────────────────────────── */
|
||||
|
||||
.btn-primary {
|
||||
--bs-btn-bg: var(--color-primary);
|
||||
--bs-btn-border-color: var(--color-primary);
|
||||
--bs-btn-hover-bg: var(--color-primary-dark);
|
||||
--bs-btn-hover-border-color: var(--color-primary-dark);
|
||||
--bs-btn-active-bg: var(--color-primary-deep);
|
||||
--bs-btn-active-border-color: var(--color-primary-deep);
|
||||
--bs-btn-disabled-bg: var(--color-primary);
|
||||
--bs-btn-disabled-border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
--bs-btn-color: var(--color-primary);
|
||||
--bs-btn-border-color: var(--color-primary);
|
||||
--bs-btn-hover-bg: var(--color-primary);
|
||||
--bs-btn-hover-border-color: var(--color-primary);
|
||||
--bs-btn-active-bg: var(--color-primary-dark);
|
||||
--bs-btn-active-border-color: var(--color-primary-dark);
|
||||
}
|
||||
|
||||
.btn-success,
|
||||
.btn-outline-success {
|
||||
--bs-btn-bg: var(--color-signal);
|
||||
--bs-btn-border-color: var(--color-signal);
|
||||
--bs-btn-color: #fff;
|
||||
--bs-btn-hover-bg: var(--color-signal-dark);
|
||||
--bs-btn-hover-border-color: var(--color-signal-dark);
|
||||
--bs-btn-active-bg: var(--color-signal-dark);
|
||||
--bs-btn-active-border-color: var(--color-signal-dark);
|
||||
}
|
||||
|
||||
.btn-outline-success {
|
||||
--bs-btn-bg: transparent;
|
||||
--bs-btn-color: var(--color-signal-dark);
|
||||
--bs-btn-hover-color: #fff;
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 0.25rem rgba(28, 63, 82, 0.15);
|
||||
}
|
||||
|
||||
.form-check-input:checked {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.text-bg-secondary {
|
||||
background-color: var(--color-surface) !important;
|
||||
color: var(--color-ink-soft) !important;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
--bs-alert-color: var(--color-signal-dark);
|
||||
--bs-alert-bg: #eaf6f0;
|
||||
--bs-alert-border-color: #b6e2ce;
|
||||
}
|
||||
|
||||
/* ── App-Shell / Seitenleiste (nur auf der Projektseite) ────────────────── */
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
var(--color-primary-deep) 0%,
|
||||
var(--color-primary-dark) 45%,
|
||||
var(--color-primary) 100%
|
||||
);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding: 22px 22px 18px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.sidebar-wordmark {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-bar {
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, var(--color-signal), var(--color-attention));
|
||||
margin-top: 9px;
|
||||
width: 42px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.sidebar-tag {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.13em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-accent-pale);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.sidebar-section-label {
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(199, 214, 224, 0.7);
|
||||
padding: 16px 14px 4px;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
color: #c7d6e0;
|
||||
text-decoration: none;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 500;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #fff;
|
||||
box-shadow: inset 3px 0 0 var(--color-signal);
|
||||
}
|
||||
|
||||
.sidebar-icon {
|
||||
width: 1.1em;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-shell-content {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-accent);
|
||||
margin-bottom: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── KPI-Kacheln ─────────────────────────────────────────────────────── */
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.kpi {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 9px;
|
||||
padding: 13px 15px;
|
||||
display: block;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
a.kpi:hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 3px 8px rgba(15, 39, 51, 0.07), 0 16px 32px -14px rgba(15, 39, 51, 0.28);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
font-size: 0.66rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-ink-soft);
|
||||
margin-bottom: var(--space-xs);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.card {
|
||||
border-radius: 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
border-top: 3px solid var(--color-primary);
|
||||
box-shadow:
|
||||
0 1px 2px rgba(15, 39, 51, 0.05),
|
||||
0 10px 24px -14px rgba(15, 39, 51, 0.22);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
background: var(--color-bg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.table td input.form-control-sm,
|
||||
@@ -657,6 +951,57 @@ body {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-row td {
|
||||
padding: 0.38rem 0.6rem;
|
||||
border-bottom-color: #d8dee9;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-row.headerComponent td {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-row.groupComponent td {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-row.footerComponent td {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-content {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-content strong {
|
||||
flex: 0 0 auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-protection {
|
||||
color: #475569;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-actions button {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.18rem 0.45rem;
|
||||
}
|
||||
|
||||
.tree-grid .structure-component-fixed {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.tree-grid .section-drop-cell {
|
||||
padding: 0.45rem 0.6rem;
|
||||
}
|
||||
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import "bootstrap/dist/css/bootstrap.min.css";
|
||||
import "./globals.css";
|
||||
import { AppShell } from "../frontend/components/AppShell";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Leistungsbilanz",
|
||||
@@ -10,7 +11,9 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
<AppShell>{children}</AppShell>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -661,9 +661,10 @@ export default function ProjectDetailPage() {
|
||||
|
||||
return (
|
||||
<main className="container py-4">
|
||||
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="h3 mb-1">{project?.name ?? "Projekt"}</h1>
|
||||
<div className="kicker">Projekt</div>
|
||||
<h1>{project?.name ?? "Projekt"}</h1>
|
||||
<p className="text-secondary mb-0">
|
||||
{project?.internalProjectNumber
|
||||
? `Projektnummer ${project.internalProjectNumber} · `
|
||||
@@ -688,9 +689,28 @@ export default function ProjectDetailPage() {
|
||||
|
||||
{error ? <div className="alert alert-warning">{error}</div> : null}
|
||||
|
||||
<div className="kpi-grid">
|
||||
<a className="kpi" href="#verteilungen">
|
||||
<div className="kpi-label">Verteilungen</div>
|
||||
<div className="kpi-value">{boardCount}</div>
|
||||
</a>
|
||||
<a className="kpi" href="#etagen">
|
||||
<div className="kpi-label">Etagen</div>
|
||||
<div className="kpi-value">{floorCount}</div>
|
||||
</a>
|
||||
<a className="kpi" href="#raeume">
|
||||
<div className="kpi-label">Räume</div>
|
||||
<div className="kpi-value">{roomCount}</div>
|
||||
</a>
|
||||
<a className="kpi" href="#projektgeraete">
|
||||
<div className="kpi-label">Projektgeräte</div>
|
||||
<div className="kpi-value">{projectDeviceCount}</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="row g-4">
|
||||
{project ? (
|
||||
<section className="col-12">
|
||||
<section className="col-12" id="verlauf">
|
||||
<ProjectVersionHistory
|
||||
currentRevision={project.currentRevision}
|
||||
onProjectStateChange={() => window.location.reload()}
|
||||
@@ -699,7 +719,7 @@ export default function ProjectDetailPage() {
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="col-12">
|
||||
<section className="col-12" id="verteilungen">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
@@ -792,7 +812,7 @@ export default function ProjectDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="col-12 col-lg-5">
|
||||
<section className="col-12 col-lg-5" id="etagen">
|
||||
<div className="card shadow-sm h-100">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
@@ -820,7 +840,7 @@ export default function ProjectDetailPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="col-12 col-lg-7">
|
||||
<section className="col-12 col-lg-7" id="raeume">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
@@ -866,7 +886,7 @@ export default function ProjectDetailPage() {
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="card shadow-sm mt-4">
|
||||
<section className="card shadow-sm mt-4" id="projektgeraete">
|
||||
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div>
|
||||
<span>Projektgeräte</span>
|
||||
|
||||
+98
-117
@@ -8,15 +8,15 @@ import {
|
||||
deleteGlobalDevice,
|
||||
importProjectTransferAsNew,
|
||||
listGlobalDevices,
|
||||
listProjects,
|
||||
updateGlobalDevice,
|
||||
} from "../../frontend/utils/api";
|
||||
import type {
|
||||
CreateGlobalDeviceInput,
|
||||
GlobalDeviceDto,
|
||||
ProjectDto,
|
||||
} from "../../frontend/types";
|
||||
|
||||
const PROJECTS_CHANGED_EVENT = "leistungsbilanz:projects-changed";
|
||||
|
||||
const emptyGlobalDevice: CreateGlobalDeviceInput = {
|
||||
name: "",
|
||||
displayName: "",
|
||||
@@ -38,7 +38,6 @@ function toOptionalNumber(value: string) {
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const [projects, setProjects] = useState<ProjectDto[]>([]);
|
||||
const [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [globalDeviceForm, setGlobalDeviceForm] = useState<Record<string, string>>({
|
||||
@@ -64,12 +63,14 @@ export default function ProjectsPage() {
|
||||
projectId: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
const [createdProject, setCreatedProject] = useState<{
|
||||
id: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
async function loadData() {
|
||||
setError(null);
|
||||
const [loadedProjects, loadedGlobalDevices] = await Promise.all([listProjects(), listGlobalDevices()]);
|
||||
setProjects(loadedProjects);
|
||||
setGlobalDevices(loadedGlobalDevices);
|
||||
setGlobalDevices(await listGlobalDevices());
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -85,10 +86,12 @@ export default function ProjectsPage() {
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
setCreatedProject(null);
|
||||
try {
|
||||
const created = await createProject(projectName.trim());
|
||||
setProjects((current) => [...current, created]);
|
||||
setCreatedProject({ id: created.id, name: created.name });
|
||||
setProjectName("");
|
||||
window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Projekt konnte nicht erstellt werden.");
|
||||
} finally {
|
||||
@@ -123,11 +126,11 @@ export default function ProjectsPage() {
|
||||
setImportedProject(null);
|
||||
try {
|
||||
const imported = await importProjectTransferAsNew(projectImport);
|
||||
setProjects(await listProjects());
|
||||
setImportedProject(imported);
|
||||
setProjectImport(null);
|
||||
setProjectImportFilename("");
|
||||
setProjectImportInputKey((current) => current + 1);
|
||||
window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT));
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
@@ -222,130 +225,107 @@ export default function ProjectsPage() {
|
||||
|
||||
return (
|
||||
<main className="container py-4">
|
||||
<div className="d-flex justify-content-between align-items-center mb-4">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="h3 mb-1">Projekte</h1>
|
||||
<div className="kicker">Leistungsbilanz</div>
|
||||
<h1>Projekte</h1>
|
||||
<p className="text-secondary mb-0">Projektübersicht und globale Geräteverwaltung</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <div className="alert alert-warning">{error}</div> : null}
|
||||
|
||||
<div className="row g-4">
|
||||
<section className="col-12 col-lg-4">
|
||||
<div className="card shadow-sm mb-4">
|
||||
<div className="card-header">Neues Projekt</div>
|
||||
<div className="card-body">
|
||||
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
||||
<div>
|
||||
<label className="form-label">Projektname</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={projectName}
|
||||
onChange={(event) => setProjectName(event.target.value)}
|
||||
placeholder="z. B. Neubau Schule"
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" type="submit" disabled={isSaving}>
|
||||
Projekt erstellen
|
||||
</button>
|
||||
</form>
|
||||
</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
|
||||
<div className="d-flex flex-column gap-4">
|
||||
<section className="card shadow-sm">
|
||||
<div className="card-header">Neues Projekt</div>
|
||||
<div className="card-body" style={{ maxWidth: 480 }}>
|
||||
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
||||
<div>
|
||||
<label className="form-label">Projektname</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={projectName}
|
||||
onChange={(event) => setProjectName(event.target.value)}
|
||||
placeholder="z. B. Neubau Schule"
|
||||
/>
|
||||
</div>
|
||||
<button className="btn btn-primary" type="submit" disabled={isSaving}>
|
||||
Projekt erstellen
|
||||
</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>
|
||||
</form>
|
||||
{createdProject ? (
|
||||
<div className="alert alert-success mt-3 mb-0">
|
||||
<div className="small mb-2">
|
||||
„{createdProject.name}“ wurde angelegt.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Link
|
||||
className="btn btn-sm btn-success"
|
||||
href={`/projects/${createdProject.id}`}
|
||||
>
|
||||
Projekt öffnen
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="col-12 col-lg-8">
|
||||
<div className="card shadow-sm">
|
||||
<div className="card-header">Alle Projekte</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Projekt</th>
|
||||
<th className="text-end">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{projects.map((project) => (
|
||||
<tr key={project.id}>
|
||||
<td>{project.name}</td>
|
||||
<td className="text-end">
|
||||
<Link className="btn btn-sm btn-outline-primary" href={`/projects/${project.id}`}>
|
||||
Projekt öffnen
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!projects.length ? (
|
||||
<tr>
|
||||
<td colSpan={2} className="text-center text-secondary py-4">
|
||||
Noch keine Projekte vorhanden.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<section className="card shadow-sm">
|
||||
<div className="card-header">Projekt importieren</div>
|
||||
<div className="card-body" style={{ maxWidth: 480 }}>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="card shadow-sm mt-4">
|
||||
<div className="card-header">Globale Geräte / Verbraucher</div>
|
||||
<section className="card shadow-sm">
|
||||
<div className="card-header">Globale Geräte / Verbraucher</div>
|
||||
<div className="card-body border-bottom">
|
||||
<form className="row g-2" onSubmit={handleCreateGlobalDevice}>
|
||||
<div className="col-12 col-md-3">
|
||||
@@ -524,6 +504,7 @@ export default function ProjectsPage() {
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
@@ -396,6 +397,12 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
remark: snapshot.remark,
|
||||
})
|
||||
.run();
|
||||
if (snapshot.protectionDevice) {
|
||||
database
|
||||
.insert(circuitProtectionDevices)
|
||||
.values(snapshot.protectionDevice)
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
private assertTargetCircuitUnchanged(
|
||||
@@ -438,6 +445,27 @@ export class CircuitDeviceRowMoveProjectCommandRepository
|
||||
"Created target circuit changed before command execution."
|
||||
);
|
||||
}
|
||||
const protectionDevice = database
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, snapshot.id))
|
||||
.get();
|
||||
if (
|
||||
snapshot.protectionDevice === undefined
|
||||
? protectionDevice !== undefined
|
||||
: snapshot.protectionDevice === null
|
||||
? protectionDevice !== undefined
|
||||
: !protectionDevice ||
|
||||
Object.entries(snapshot.protectionDevice).some(
|
||||
([key, value]) =>
|
||||
(protectionDevice as Record<string, unknown>)[key] !==
|
||||
value
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
"Created target circuit protection changed before command execution."
|
||||
);
|
||||
}
|
||||
|
||||
const currentRowIds = database
|
||||
.select({ id: circuitDeviceRows.id })
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
assertCircuitProtectionUpdateProjectCommand,
|
||||
createCircuitProtectionUpdateProjectCommand,
|
||||
type CircuitProtectionSnapshot,
|
||||
} from "../../domain/models/circuit-protection-project-command.model.js";
|
||||
import type {
|
||||
CircuitProtectionProjectCommandStore,
|
||||
ExecuteCircuitProtectionCommandInput,
|
||||
} from "../../domain/ports/circuit-protection-project-command.store.js";
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import { executeProjectCommandTransaction } from "./project-command-transaction.persistence.js";
|
||||
|
||||
export class CircuitProtectionProjectCommandRepository
|
||||
implements CircuitProtectionProjectCommandStore
|
||||
{
|
||||
constructor(private readonly database: AppDatabase) {}
|
||||
|
||||
execute(input: ExecuteCircuitProtectionCommandInput) {
|
||||
return executeProjectCommandTransaction(this.database, input, (tx) => {
|
||||
assertCircuitProtectionUpdateProjectCommand(input.command);
|
||||
const { circuitId, expected, target } = input.command.payload;
|
||||
if (input.source === "user" && target === null) {
|
||||
throw new Error("User commands cannot remove circuit protection.");
|
||||
}
|
||||
this.assertOwnership(tx, input.projectId, circuitId);
|
||||
const actual = tx
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, circuitId))
|
||||
.get();
|
||||
if (!sameNullableSnapshot(expected, actual)) {
|
||||
throw new Error("Circuit protection changed before update.");
|
||||
}
|
||||
if (target === null) {
|
||||
tx.delete(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, circuitId))
|
||||
.run();
|
||||
} else if (actual) {
|
||||
tx.update(circuitProtectionDevices)
|
||||
.set(target)
|
||||
.where(eq(circuitProtectionDevices.circuitId, circuitId))
|
||||
.run();
|
||||
} else {
|
||||
tx.insert(circuitProtectionDevices).values(target).run();
|
||||
}
|
||||
return createCircuitProtectionUpdateProjectCommand(
|
||||
circuitId,
|
||||
target,
|
||||
expected
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private assertOwnership(
|
||||
database: AppDatabase,
|
||||
projectId: string,
|
||||
circuitId: string
|
||||
) {
|
||||
const circuit = database
|
||||
.select({ projectId: circuitLists.projectId })
|
||||
.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 protection does not belong to project.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sameNullableSnapshot(
|
||||
expected: CircuitProtectionSnapshot | null,
|
||||
actual: typeof circuitProtectionDevices.$inferSelect | undefined
|
||||
) {
|
||||
if (expected === null) {
|
||||
return actual === undefined;
|
||||
}
|
||||
if (!actual) {
|
||||
return false;
|
||||
}
|
||||
return Object.entries(expected).every(
|
||||
([key, value]) =>
|
||||
(actual as Record<string, unknown>)[key] === value
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { isElectricalPhaseType } from "../../domain/services/project-voltage.ser
|
||||
import type { AppDatabase } from "../database-context.js";
|
||||
import { circuitDeviceRows } from "../schema/circuit-device-rows.js";
|
||||
import { circuitLists } from "../schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../schema/circuit-sections.js";
|
||||
import { circuits } from "../schema/circuits.js";
|
||||
import {
|
||||
@@ -175,6 +176,12 @@ export class CircuitStructureProjectCommandRepository
|
||||
if (snapshot.deviceRows.length > 0) {
|
||||
database.insert(circuitDeviceRows).values(snapshot.deviceRows).run();
|
||||
}
|
||||
if (snapshot.protectionDevice) {
|
||||
database
|
||||
.insert(circuitProtectionDevices)
|
||||
.values(snapshot.protectionDevice)
|
||||
.run();
|
||||
}
|
||||
|
||||
return createCircuitDeleteProjectCommand(
|
||||
snapshot.id,
|
||||
@@ -219,6 +226,11 @@ export class CircuitStructureProjectCommandRepository
|
||||
asc(circuitDeviceRows.id)
|
||||
)
|
||||
.all();
|
||||
const protectionDevice = database
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, circuit.id))
|
||||
.get();
|
||||
const inverse = createCircuitInsertProjectCommand({
|
||||
id: circuit.id,
|
||||
circuitListId: circuit.circuitListId,
|
||||
@@ -240,6 +252,7 @@ export class CircuitStructureProjectCommandRepository
|
||||
isReserve: Boolean(circuit.isReserve),
|
||||
remark: circuit.remark,
|
||||
deviceRows: rows.map(toCircuitDeviceRowSnapshot),
|
||||
protectionDevice: protectionDevice ?? null,
|
||||
});
|
||||
|
||||
const result = database
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { protectionDeviceConfigurationSchema } from "../../shared/validation/protection-device.schemas.js";
|
||||
import type {
|
||||
BreakerTripCharacteristic,
|
||||
FuseUtilizationCategory,
|
||||
ProtectionDeviceType,
|
||||
RcdType,
|
||||
} from "../../shared/constants/protection-device.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
|
||||
export const circuitProtectionUpdateCommandType =
|
||||
"circuit-protection.update" as const;
|
||||
export const circuitProtectionUpdateCommandSchemaVersion = 1 as const;
|
||||
|
||||
export interface CircuitProtectionSnapshot {
|
||||
circuitId: string;
|
||||
type: ProtectionDeviceType;
|
||||
ratedCurrentA: number;
|
||||
fuseUtilizationCategory: FuseUtilizationCategory | null;
|
||||
tripCharacteristic: BreakerTripCharacteristic | null;
|
||||
rcdType: RcdType | null;
|
||||
ratedResidualCurrentMa: number | null;
|
||||
}
|
||||
|
||||
interface CircuitProtectionUpdatePayload {
|
||||
circuitId: string;
|
||||
expected: CircuitProtectionSnapshot | null;
|
||||
target: CircuitProtectionSnapshot | null;
|
||||
}
|
||||
|
||||
export interface CircuitProtectionUpdateProjectCommand
|
||||
extends SerializedProjectCommand<CircuitProtectionUpdatePayload> {
|
||||
schemaVersion: typeof circuitProtectionUpdateCommandSchemaVersion;
|
||||
type: typeof circuitProtectionUpdateCommandType;
|
||||
}
|
||||
|
||||
export function createCircuitProtectionUpdateProjectCommand(
|
||||
circuitId: string,
|
||||
expected: CircuitProtectionSnapshot | null,
|
||||
target: CircuitProtectionSnapshot | null
|
||||
): CircuitProtectionUpdateProjectCommand {
|
||||
const command: CircuitProtectionUpdateProjectCommand = {
|
||||
schemaVersion: circuitProtectionUpdateCommandSchemaVersion,
|
||||
type: circuitProtectionUpdateCommandType,
|
||||
payload: { circuitId, expected, target },
|
||||
};
|
||||
assertCircuitProtectionUpdateProjectCommand(command);
|
||||
return command;
|
||||
}
|
||||
|
||||
export function assertCircuitProtectionUpdateProjectCommand(
|
||||
command: SerializedProjectCommand<unknown>
|
||||
): asserts command is CircuitProtectionUpdateProjectCommand {
|
||||
if (
|
||||
command.schemaVersion !== circuitProtectionUpdateCommandSchemaVersion ||
|
||||
command.type !== circuitProtectionUpdateCommandType ||
|
||||
!isPlainObject(command.payload) ||
|
||||
Object.keys(command.payload).length !== 3
|
||||
) {
|
||||
throw new Error("Unsupported circuit-protection update command.");
|
||||
}
|
||||
const { circuitId, expected, target } = command.payload;
|
||||
if (typeof circuitId !== "string" || !circuitId.trim()) {
|
||||
throw new Error("Circuit protection requires a circuit id.");
|
||||
}
|
||||
if (expected === null && target === null) {
|
||||
throw new Error("Circuit protection update must change state.");
|
||||
}
|
||||
if (expected !== null) {
|
||||
assertCircuitProtectionSnapshot(expected, circuitId);
|
||||
}
|
||||
if (target !== null) {
|
||||
assertCircuitProtectionSnapshot(target, circuitId);
|
||||
}
|
||||
if (JSON.stringify(expected) === JSON.stringify(target)) {
|
||||
throw new Error("Circuit protection update must change state.");
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCircuitProtectionSnapshot(
|
||||
value: unknown,
|
||||
circuitId: string
|
||||
) {
|
||||
if (
|
||||
!isPlainObject(value) ||
|
||||
Object.keys(value).length !== 7 ||
|
||||
value.circuitId !== circuitId
|
||||
) {
|
||||
throw new Error("Circuit-protection snapshot is invalid.");
|
||||
}
|
||||
const result = protectionDeviceConfigurationSchema.safeParse({
|
||||
type: value.type,
|
||||
ratedCurrentA: value.ratedCurrentA,
|
||||
...(value.fuseUtilizationCategory === null
|
||||
? {}
|
||||
: { fuseUtilizationCategory: value.fuseUtilizationCategory }),
|
||||
...(value.tripCharacteristic === null
|
||||
? {}
|
||||
: { tripCharacteristic: value.tripCharacteristic }),
|
||||
...(value.rcdType === null ? {} : { rcdType: value.rcdType }),
|
||||
...(value.ratedResidualCurrentMa === null
|
||||
? {}
|
||||
: { ratedResidualCurrentMa: value.ratedResidualCurrentMa }),
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error("Circuit-protection configuration is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import {
|
||||
type CircuitDeviceRowSnapshot,
|
||||
} from "./circuit-device-row-structure-project-command.model.js";
|
||||
import type { SerializedProjectCommand } from "./project-command.model.js";
|
||||
import {
|
||||
assertCircuitProtectionSnapshot,
|
||||
type CircuitProtectionSnapshot,
|
||||
} from "./circuit-protection-project-command.model.js";
|
||||
|
||||
export const circuitInsertCommandType = "circuit.insert" as const;
|
||||
export const circuitDeleteCommandType = "circuit.delete" as const;
|
||||
@@ -31,6 +35,7 @@ export interface CircuitSnapshot {
|
||||
isReserve: boolean;
|
||||
remark: string | null;
|
||||
deviceRows: CircuitDeviceRowSnapshot[];
|
||||
protectionDevice?: CircuitProtectionSnapshot | null;
|
||||
}
|
||||
|
||||
export interface CircuitInsertCommandPayload {
|
||||
@@ -142,6 +147,15 @@ export function assertCircuitInsertProjectCommand(
|
||||
if (!Array.isArray(circuit.deviceRows)) {
|
||||
throw new Error("circuit.deviceRows must be an array.");
|
||||
}
|
||||
if (
|
||||
circuit.protectionDevice !== undefined &&
|
||||
circuit.protectionDevice !== null
|
||||
) {
|
||||
assertCircuitProtectionSnapshot(
|
||||
circuit.protectionDevice,
|
||||
circuit.id as string
|
||||
);
|
||||
}
|
||||
if (circuit.isReserve !== (circuit.deviceRows.length === 0)) {
|
||||
throw new Error(
|
||||
"Circuit reserve state must match whether device rows exist."
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type {
|
||||
CircuitProtectionUpdateProjectCommand,
|
||||
} from "../models/circuit-protection-project-command.model.js";
|
||||
import type {
|
||||
AppendedProjectRevision,
|
||||
ProjectRevisionSource,
|
||||
} from "./project-revision.store.js";
|
||||
|
||||
export interface ExecuteCircuitProtectionCommandInput {
|
||||
projectId: string;
|
||||
expectedRevision: number;
|
||||
source: ProjectRevisionSource;
|
||||
description?: string;
|
||||
actorId?: string;
|
||||
historyTargetChangeSetId?: string;
|
||||
command: CircuitProtectionUpdateProjectCommand;
|
||||
}
|
||||
|
||||
export interface CircuitProtectionProjectCommandStore {
|
||||
execute(input: ExecuteCircuitProtectionCommandInput): {
|
||||
revision: AppendedProjectRevision;
|
||||
inverse: CircuitProtectionUpdateProjectCommand;
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
assertCircuitUpdateProjectCommand,
|
||||
circuitUpdateCommandType,
|
||||
} from "../models/circuit-project-command.model.js";
|
||||
import {
|
||||
assertCircuitProtectionUpdateProjectCommand,
|
||||
circuitProtectionUpdateCommandType,
|
||||
} from "../models/circuit-protection-project-command.model.js";
|
||||
import {
|
||||
assertCircuitSectionReorderProjectCommand,
|
||||
circuitSectionReorderCommandType,
|
||||
@@ -95,6 +99,7 @@ import type { CircuitDeviceRowProjectCommandStore } from "../ports/circuit-devic
|
||||
import type { CircuitDeviceRowMoveProjectCommandStore } from "../ports/circuit-device-row-move-project-command.store.js";
|
||||
import type { CircuitDeviceRowStructureProjectCommandStore } from "../ports/circuit-device-row-structure-project-command.store.js";
|
||||
import type { CircuitProjectCommandStore } from "../ports/circuit-project-command.store.js";
|
||||
import type { CircuitProtectionProjectCommandStore } from "../ports/circuit-protection-project-command.store.js";
|
||||
import type { CircuitSectionReorderProjectCommandStore } from "../ports/circuit-section-reorder-project-command.store.js";
|
||||
import type { CircuitSectionRenumberProjectCommandStore } from "../ports/circuit-section-renumber-project-command.store.js";
|
||||
import type { CircuitStructureProjectCommandStore } from "../ports/circuit-structure-project-command.store.js";
|
||||
@@ -177,6 +182,7 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
private readonly circuitGroupRenumberStore: CircuitGroupRenumberProjectCommandStore,
|
||||
private readonly circuitGroupMoveStore: CircuitGroupMoveProjectCommandStore,
|
||||
private readonly circuitGroupSubtreeStore: CircuitGroupSubtreeProjectCommandStore,
|
||||
private readonly circuitProtectionStore: CircuitProtectionProjectCommandStore,
|
||||
private readonly historyStore: ProjectHistoryStore
|
||||
) {}
|
||||
|
||||
@@ -421,6 +427,13 @@ export class ProjectCommandService implements ProjectCommandExecutor {
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case circuitProtectionUpdateCommandType: {
|
||||
assertCircuitProtectionUpdateProjectCommand(input.command);
|
||||
return this.circuitProtectionStore.execute({
|
||||
...input,
|
||||
command: input.command,
|
||||
}).revision;
|
||||
}
|
||||
case projectFloorInsertCommandType: {
|
||||
assertProjectFloorInsertProjectCommand(input.command);
|
||||
return this.projectLocationStructureStore.execute({
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
const PROJECT_AREA_PATTERN = /^\/projects(\/[^/]+)?$/;
|
||||
|
||||
export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const showSidebar = PROJECT_AREA_PATTERN.test(pathname ?? "");
|
||||
|
||||
if (!showSidebar) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<Sidebar />
|
||||
<div className="app-shell-content">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { listProjects } from "../utils/api";
|
||||
import type { ProjectDto } from "../types";
|
||||
|
||||
const PROJECT_SECTIONS = [
|
||||
{ id: "verlauf", label: "Verlauf", icon: "↺" },
|
||||
{ id: "verteilungen", label: "Verteilungen", icon: "⌁" },
|
||||
{ id: "etagen", label: "Etagen", icon: "▥" },
|
||||
{ id: "raeume", label: "Räume", icon: "⌂" },
|
||||
{ id: "projektgeraete", label: "Projektgeräte", icon: "⚙" },
|
||||
] as const;
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const projectMatch = pathname?.match(/^\/projects\/([^/]+)$/);
|
||||
const activeProjectId = projectMatch ? projectMatch[1] : null;
|
||||
|
||||
const [projects, setProjects] = useState<ProjectDto[]>([]);
|
||||
const [activeSection, setActiveSection] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function refreshProjects() {
|
||||
listProjects()
|
||||
.then(setProjects)
|
||||
.catch(() => setProjects([]));
|
||||
}
|
||||
|
||||
refreshProjects();
|
||||
window.addEventListener("leistungsbilanz:projects-changed", refreshProjects);
|
||||
return () =>
|
||||
window.removeEventListener("leistungsbilanz:projects-changed", refreshProjects);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeProjectId) {
|
||||
setActiveSection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = PROJECT_SECTIONS.map((section) =>
|
||||
document.getElementById(section.id)
|
||||
).filter((element): element is HTMLElement => element !== null);
|
||||
|
||||
if (!elements.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||||
if (visible[0]) {
|
||||
setActiveSection(visible[0].target.id);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 }
|
||||
);
|
||||
|
||||
elements.forEach((element) => observer.observe(element));
|
||||
return () => observer.disconnect();
|
||||
}, [activeProjectId]);
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-brand">
|
||||
<div className="sidebar-wordmark">Leistungsbilanz</div>
|
||||
<div className="sidebar-bar" />
|
||||
<div className="sidebar-tag">Stromkreis- & Lastberechnung</div>
|
||||
</div>
|
||||
<nav className="sidebar-nav">
|
||||
<Link
|
||||
href="/projects"
|
||||
className={pathname === "/projects" ? "sidebar-link active" : "sidebar-link"}
|
||||
>
|
||||
<span className="sidebar-icon">▦</span>
|
||||
Alle Projekte
|
||||
</Link>
|
||||
|
||||
<div className="sidebar-section-label">Projekte</div>
|
||||
{projects.map((project) => (
|
||||
<Link
|
||||
key={project.id}
|
||||
href={`/projects/${project.id}`}
|
||||
className={
|
||||
project.id === activeProjectId ? "sidebar-link active" : "sidebar-link"
|
||||
}
|
||||
>
|
||||
<span className="sidebar-icon">▣</span>
|
||||
{project.name}
|
||||
</Link>
|
||||
))}
|
||||
{!projects.length ? (
|
||||
<div className="sidebar-section-label" style={{ opacity: 0.6 }}>
|
||||
Noch keine Projekte
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeProjectId && (
|
||||
<>
|
||||
<div className="sidebar-section-label">In diesem Projekt</div>
|
||||
{PROJECT_SECTIONS.map(({ id, label, icon }) => (
|
||||
<a
|
||||
key={id}
|
||||
href={`#${id}`}
|
||||
className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
|
||||
>
|
||||
<span className="sidebar-icon">{icon}</span>
|
||||
{label}
|
||||
</a>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
import {
|
||||
circuitGroupCategories,
|
||||
circuitGroupCategoryLabels,
|
||||
type CircuitGroupCategory,
|
||||
} from "../../shared/constants/circuit-group";
|
||||
import type { CircuitTreeSectionDto } from "../types";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
interface CircuitGroupModalProps {
|
||||
initialSection?: CircuitTreeSectionDto;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (values: {
|
||||
category: CircuitGroupCategory;
|
||||
displayName: string;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
export function CircuitGroupModal({
|
||||
initialSection,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: CircuitGroupModalProps) {
|
||||
const [category, setCategory] = useState<CircuitGroupCategory>(
|
||||
initialSection?.category ?? "lighting"
|
||||
);
|
||||
const [displayName, setDisplayName] = useState(
|
||||
initialSection?.displayName ?? ""
|
||||
);
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
await onSave({ category, displayName });
|
||||
}
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
description={
|
||||
initialSection
|
||||
? "Nur der Anzeigename wird geändert. Kategorie, Nummer, Präfix und vorhandene BMK bleiben stabil."
|
||||
: "Die nächste freie Gruppennummer der gewählten Kategorie wird automatisch vergeben."
|
||||
}
|
||||
isSaving={isSaving}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitDisabled={Boolean(initialSection && !displayName.trim())}
|
||||
submitLabel={initialSection ? "Änderungen speichern" : "Gruppe anlegen"}
|
||||
title={initialSection ? "Stromkreisgruppe bearbeiten" : "Stromkreisgruppe hinzufügen"}
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-group-category">
|
||||
Kategorie
|
||||
</label>
|
||||
<select
|
||||
autoFocus={!initialSection}
|
||||
className="form-select"
|
||||
disabled={Boolean(initialSection)}
|
||||
id="circuit-group-category"
|
||||
onChange={(event) =>
|
||||
setCategory(event.target.value as CircuitGroupCategory)
|
||||
}
|
||||
value={category}
|
||||
>
|
||||
{circuitGroupCategories.map((entry) => (
|
||||
<option key={entry} value={entry}>
|
||||
{circuitGroupCategoryLabels[entry]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-group-name">
|
||||
Anzeigename
|
||||
</label>
|
||||
<input
|
||||
autoFocus={Boolean(initialSection)}
|
||||
className="form-control"
|
||||
id="circuit-group-name"
|
||||
onChange={(event) => setDisplayName(event.target.value)}
|
||||
placeholder={
|
||||
initialSection
|
||||
? undefined
|
||||
: "Leer lassen für den automatisch erzeugten Namen"
|
||||
}
|
||||
required={Boolean(initialSection)}
|
||||
value={displayName}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
import {
|
||||
allowedRatedCurrentsAByProtectionDeviceType,
|
||||
breakerTripCharacteristics,
|
||||
fuseProtectionDeviceTypes,
|
||||
fuseUtilizationCategories,
|
||||
protectionDeviceTypeLabels,
|
||||
protectionDeviceTypes,
|
||||
ratedResidualCurrentsMa,
|
||||
rcdTypes,
|
||||
type ProtectionDeviceType,
|
||||
} from "../../shared/constants/protection-device";
|
||||
import type { CircuitTreeProtectionDeviceDto } from "../types";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
interface CircuitProtectionModalProps {
|
||||
equipmentIdentifier: string;
|
||||
initialProtection: CircuitTreeProtectionDeviceDto;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (protection: CircuitTreeProtectionDeviceDto) => Promise<void>;
|
||||
}
|
||||
|
||||
export function CircuitProtectionModal({
|
||||
equipmentIdentifier,
|
||||
initialProtection,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: CircuitProtectionModalProps) {
|
||||
const [type, setType] = useState(initialProtection.type);
|
||||
const [ratedCurrentA, setRatedCurrentA] = useState(
|
||||
initialProtection.ratedCurrentA
|
||||
);
|
||||
const [fuseUtilizationCategory, setFuseUtilizationCategory] = useState(
|
||||
initialProtection.fuseUtilizationCategory ?? "gG"
|
||||
);
|
||||
const [tripCharacteristic, setTripCharacteristic] = useState(
|
||||
initialProtection.tripCharacteristic ?? "B"
|
||||
);
|
||||
const [rcdType, setRcdType] = useState(
|
||||
initialProtection.rcdType ?? "A"
|
||||
);
|
||||
const [ratedResidualCurrentMa, setRatedResidualCurrentMa] = useState(
|
||||
initialProtection.ratedResidualCurrentMa ?? 30
|
||||
);
|
||||
const usesFuseCategory = (
|
||||
fuseProtectionDeviceTypes as readonly string[]
|
||||
).includes(type);
|
||||
const usesTripCharacteristic =
|
||||
type === "LS" || type === "FI_LS" || type === "AFDD";
|
||||
const usesResidualCurrent = type === "FI" || type === "FI_LS";
|
||||
const allowedRatedCurrents =
|
||||
allowedRatedCurrentsAByProtectionDeviceType[type];
|
||||
|
||||
function handleTypeChange(nextType: ProtectionDeviceType) {
|
||||
setType(nextType);
|
||||
setRatedCurrentA(
|
||||
allowedRatedCurrentsAByProtectionDeviceType[nextType][0]
|
||||
);
|
||||
setFuseUtilizationCategory("gG");
|
||||
setTripCharacteristic("B");
|
||||
setRcdType("A");
|
||||
setRatedResidualCurrentMa(30);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
await onSave({
|
||||
type,
|
||||
ratedCurrentA,
|
||||
...(usesFuseCategory ? { fuseUtilizationCategory } : {}),
|
||||
...(usesTripCharacteristic ? { tripCharacteristic } : {}),
|
||||
...(usesResidualCurrent
|
||||
? { rcdType, ratedResidualCurrentMa }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
description="Die Auswahl ist planerisch gesetzt. Eine spätere Dimensionierung wird nur Empfehlungen und Warnungen ergänzen."
|
||||
isSaving={isSaving}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitDisabled={
|
||||
!(allowedRatedCurrents as readonly number[]).includes(
|
||||
ratedCurrentA
|
||||
)
|
||||
}
|
||||
submitLabel="Schutzgerät speichern"
|
||||
title={`Schutzgerät bearbeiten – ${equipmentIdentifier}`}
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-protection-type">
|
||||
Schutzgerät
|
||||
</label>
|
||||
<select
|
||||
autoFocus
|
||||
className="form-select"
|
||||
id="circuit-protection-type"
|
||||
onChange={(event) =>
|
||||
handleTypeChange(
|
||||
event.target.value as ProtectionDeviceType
|
||||
)
|
||||
}
|
||||
value={type}
|
||||
>
|
||||
{protectionDeviceTypes.map((entry) => (
|
||||
<option key={entry} value={entry}>
|
||||
{protectionDeviceTypeLabels[entry]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-protection-current">
|
||||
Bemessungsstrom
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="circuit-protection-current"
|
||||
onChange={(event) =>
|
||||
setRatedCurrentA(Number(event.target.value))
|
||||
}
|
||||
value={ratedCurrentA}
|
||||
>
|
||||
{allowedRatedCurrents.map((current) => (
|
||||
<option key={current} value={current}>
|
||||
{current} A
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{usesFuseCategory ? (
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-fuse-category">
|
||||
Sicherungscharakteristik
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="circuit-fuse-category"
|
||||
onChange={(event) =>
|
||||
setFuseUtilizationCategory(
|
||||
event.target
|
||||
.value as (typeof fuseUtilizationCategories)[number]
|
||||
)
|
||||
}
|
||||
value={fuseUtilizationCategory}
|
||||
>
|
||||
{fuseUtilizationCategories.map((entry) => (
|
||||
<option key={entry} value={entry}>
|
||||
{entry}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{usesTripCharacteristic ? (
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-trip-characteristic">
|
||||
Auslösecharakteristik
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="circuit-trip-characteristic"
|
||||
onChange={(event) =>
|
||||
setTripCharacteristic(
|
||||
event.target
|
||||
.value as (typeof breakerTripCharacteristics)[number]
|
||||
)
|
||||
}
|
||||
value={tripCharacteristic}
|
||||
>
|
||||
{breakerTripCharacteristics.map((entry) => (
|
||||
<option key={entry} value={entry}>
|
||||
{entry}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{usesResidualCurrent ? (
|
||||
<>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-rcd-type">
|
||||
FI-Typ
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="circuit-rcd-type"
|
||||
onChange={(event) =>
|
||||
setRcdType(
|
||||
event.target.value as (typeof rcdTypes)[number]
|
||||
)
|
||||
}
|
||||
value={rcdType}
|
||||
>
|
||||
{rcdTypes.map((entry) => (
|
||||
<option key={entry} value={entry}>
|
||||
Typ {entry}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="circuit-residual-current">
|
||||
Bemessungsdifferenzstrom
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="circuit-residual-current"
|
||||
onChange={(event) =>
|
||||
setRatedResidualCurrentMa(Number(event.target.value))
|
||||
}
|
||||
value={ratedResidualCurrentMa}
|
||||
>
|
||||
{ratedResidualCurrentsMa.map((entry) => (
|
||||
<option key={entry} value={entry}>
|
||||
{entry} mA
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,316 @@
|
||||
"use client";
|
||||
|
||||
import { type FormEvent, useState } from "react";
|
||||
import {
|
||||
allowedRatedCurrentsAByProtectionDeviceType,
|
||||
breakerTripCharacteristics,
|
||||
fuseProtectionDeviceTypes,
|
||||
fuseUtilizationCategories,
|
||||
protectionDeviceTypeLabels,
|
||||
ratedResidualCurrentsMa,
|
||||
rcdTypes,
|
||||
type ProtectionDeviceType,
|
||||
} from "../../shared/constants/protection-device";
|
||||
import type { CircuitTreeComponentDto } from "../types";
|
||||
import type {
|
||||
DistributionBoardComponentEditorValues,
|
||||
MutableDistributionBoardComponentRole,
|
||||
} from "../utils/distribution-board-component-editing";
|
||||
import { FormModal } from "./form-modal";
|
||||
|
||||
interface DistributionBoardComponentModalProps {
|
||||
initialComponent?: CircuitTreeComponentDto;
|
||||
initialEquipmentIdentifier?: string;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (values: DistributionBoardComponentEditorValues) => Promise<void>;
|
||||
role: MutableDistributionBoardComponentRole;
|
||||
}
|
||||
|
||||
const upstreamProtectionTypes: readonly ProtectionDeviceType[] = [
|
||||
...fuseProtectionDeviceTypes,
|
||||
"LS",
|
||||
];
|
||||
|
||||
export function DistributionBoardComponentModal({
|
||||
initialComponent,
|
||||
initialEquipmentIdentifier = "",
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
role,
|
||||
}: DistributionBoardComponentModalProps) {
|
||||
const [equipmentIdentifier, setEquipmentIdentifier] = useState(
|
||||
initialComponent?.equipmentIdentifier ?? initialEquipmentIdentifier
|
||||
);
|
||||
const [name, setName] = useState(
|
||||
initialComponent?.name ?? defaultName(role)
|
||||
);
|
||||
const initialProtection = initialComponent?.protectionDevice;
|
||||
const [protectionType, setProtectionType] = useState<
|
||||
ProtectionDeviceType | ""
|
||||
>(
|
||||
initialProtection?.type ??
|
||||
(role === "group_residual_current_protection" ? "FI" : "")
|
||||
);
|
||||
const [ratedCurrentA, setRatedCurrentA] = useState(
|
||||
initialProtection?.ratedCurrentA ??
|
||||
(role === "group_residual_current_protection" ? 40 : 0)
|
||||
);
|
||||
const [fuseUtilizationCategory, setFuseUtilizationCategory] = useState(
|
||||
initialProtection?.fuseUtilizationCategory ?? "gG"
|
||||
);
|
||||
const [tripCharacteristic, setTripCharacteristic] = useState(
|
||||
initialProtection?.tripCharacteristic ?? "B"
|
||||
);
|
||||
const [rcdType, setRcdType] = useState(
|
||||
initialProtection?.rcdType ?? "A"
|
||||
);
|
||||
const [ratedResidualCurrentMa, setRatedResidualCurrentMa] = useState(
|
||||
initialProtection?.ratedResidualCurrentMa ?? 30
|
||||
);
|
||||
|
||||
const isAuxiliary = role === "auxiliary";
|
||||
const availableProtectionTypes =
|
||||
role === "group_residual_current_protection"
|
||||
? (["FI"] as const)
|
||||
: upstreamProtectionTypes;
|
||||
const usesFuseCategory =
|
||||
protectionType !== "" &&
|
||||
(fuseProtectionDeviceTypes as readonly string[]).includes(protectionType);
|
||||
const usesTripCharacteristic = protectionType === "LS";
|
||||
const usesResidualCurrent = protectionType === "FI";
|
||||
const allowedRatedCurrents =
|
||||
protectionType === ""
|
||||
? []
|
||||
: allowedRatedCurrentsAByProtectionDeviceType[protectionType];
|
||||
|
||||
function handleProtectionTypeChange(type: ProtectionDeviceType) {
|
||||
setProtectionType(type);
|
||||
setRatedCurrentA(allowedRatedCurrentsAByProtectionDeviceType[type][0]);
|
||||
setFuseUtilizationCategory("gG");
|
||||
setTripCharacteristic("B");
|
||||
setRcdType("A");
|
||||
setRatedResidualCurrentMa(30);
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
await onSave({
|
||||
equipmentIdentifier,
|
||||
name,
|
||||
...(!isAuxiliary && protectionType
|
||||
? {
|
||||
protectionDevice: {
|
||||
type: protectionType,
|
||||
ratedCurrentA,
|
||||
...(usesFuseCategory
|
||||
? { fuseUtilizationCategory }
|
||||
: {}),
|
||||
...(usesTripCharacteristic
|
||||
? { tripCharacteristic }
|
||||
: {}),
|
||||
...(usesResidualCurrent
|
||||
? { rcdType, ratedResidualCurrentMa }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
const isValid =
|
||||
equipmentIdentifier.trim().length > 0 &&
|
||||
name.trim().length > 0 &&
|
||||
(isAuxiliary ||
|
||||
(protectionType !== "" &&
|
||||
(allowedRatedCurrents as readonly number[]).includes(ratedCurrentA)));
|
||||
|
||||
return (
|
||||
<FormModal
|
||||
description={
|
||||
isAuxiliary
|
||||
? "Zusätzliches Gerät im Verteiler. BMK und Name werden manuell vergeben."
|
||||
: "Schutzgerät dieser Stromkreisgruppe konfigurieren."
|
||||
}
|
||||
isSaving={isSaving}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitDisabled={!isValid}
|
||||
submitLabel={initialComponent ? "Änderungen speichern" : "Komponente anlegen"}
|
||||
title={`${initialComponent ? "Komponente bearbeiten" : "Komponente hinzufügen"} – ${defaultName(role)}`}
|
||||
>
|
||||
<div className="row g-3">
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-equipment-identifier">
|
||||
Betriebsmittelkennzeichen
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
className="form-control"
|
||||
id="component-equipment-identifier"
|
||||
onChange={(event) => setEquipmentIdentifier(event.target.value)}
|
||||
required
|
||||
value={equipmentIdentifier}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-name">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
className="form-control"
|
||||
id="component-name"
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
required
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
{!isAuxiliary ? (
|
||||
<>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-protection-type">
|
||||
Schutzgerät
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
disabled={role === "group_residual_current_protection"}
|
||||
id="component-protection-type"
|
||||
onChange={(event) =>
|
||||
handleProtectionTypeChange(
|
||||
event.target.value as ProtectionDeviceType
|
||||
)
|
||||
}
|
||||
required
|
||||
value={protectionType}
|
||||
>
|
||||
<option value="">Schutzgerät auswählen</option>
|
||||
{availableProtectionTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{protectionDeviceTypeLabels[type]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-rated-current">
|
||||
Bemessungsstrom
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
disabled={!protectionType}
|
||||
id="component-rated-current"
|
||||
onChange={(event) => setRatedCurrentA(Number(event.target.value))}
|
||||
value={ratedCurrentA}
|
||||
>
|
||||
{allowedRatedCurrents.map((ratedCurrent) => (
|
||||
<option key={ratedCurrent} value={ratedCurrent}>
|
||||
{ratedCurrent} A
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{usesFuseCategory ? (
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-fuse-category">
|
||||
Sicherungscharakteristik
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="component-fuse-category"
|
||||
onChange={(event) =>
|
||||
setFuseUtilizationCategory(
|
||||
event.target.value as (typeof fuseUtilizationCategories)[number]
|
||||
)
|
||||
}
|
||||
value={fuseUtilizationCategory}
|
||||
>
|
||||
{fuseUtilizationCategories.map((category) => (
|
||||
<option key={category} value={category}>
|
||||
{category}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{usesTripCharacteristic ? (
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-trip-characteristic">
|
||||
Auslösecharakteristik
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="component-trip-characteristic"
|
||||
onChange={(event) =>
|
||||
setTripCharacteristic(
|
||||
event.target.value as (typeof breakerTripCharacteristics)[number]
|
||||
)
|
||||
}
|
||||
value={tripCharacteristic}
|
||||
>
|
||||
{breakerTripCharacteristics.map((characteristic) => (
|
||||
<option key={characteristic} value={characteristic}>
|
||||
{characteristic}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{usesResidualCurrent ? (
|
||||
<>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-rcd-type">
|
||||
FI-Typ
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="component-rcd-type"
|
||||
onChange={(event) =>
|
||||
setRcdType(event.target.value as (typeof rcdTypes)[number])
|
||||
}
|
||||
value={rcdType}
|
||||
>
|
||||
{rcdTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
Typ {type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-12 col-md-6">
|
||||
<label className="form-label" htmlFor="component-residual-current">
|
||||
Bemessungsdifferenzstrom
|
||||
</label>
|
||||
<select
|
||||
className="form-select"
|
||||
id="component-residual-current"
|
||||
onChange={(event) =>
|
||||
setRatedResidualCurrentMa(Number(event.target.value))
|
||||
}
|
||||
value={ratedResidualCurrentMa}
|
||||
>
|
||||
{ratedResidualCurrentsMa.map((ratedResidualCurrent) => (
|
||||
<option key={ratedResidualCurrent} value={ratedResidualCurrent}>
|
||||
{ratedResidualCurrent} mA
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</FormModal>
|
||||
);
|
||||
}
|
||||
|
||||
function defaultName(role: MutableDistributionBoardComponentRole): string {
|
||||
if (role === "group_upstream_protection") {
|
||||
return "Gruppenvorsicherung";
|
||||
}
|
||||
if (role === "group_residual_current_protection") {
|
||||
return "Gruppen-FI";
|
||||
}
|
||||
return "Verteilergerät";
|
||||
}
|
||||
@@ -52,6 +52,25 @@ import type {
|
||||
import type {
|
||||
CircuitSectionRenumberAssignment,
|
||||
} from "../../domain/models/circuit-section-renumber-project-command.model";
|
||||
import type {
|
||||
DistributionBoardComponentSnapshot,
|
||||
} from "../../domain/models/distribution-board-component-structure-project-command.model";
|
||||
import type {
|
||||
CircuitGroupSnapshot,
|
||||
CircuitGroupReorderAssignment,
|
||||
} from "../../domain/models/circuit-group-structure-project-command.model";
|
||||
import type {
|
||||
CircuitProtectionSnapshot,
|
||||
} from "../../domain/models/circuit-protection-project-command.model";
|
||||
import type {
|
||||
CircuitGroupRenumberPlan,
|
||||
} from "../../domain/services/circuit-group-renumbering";
|
||||
import type {
|
||||
CircuitGroupMovePlan,
|
||||
} from "../../domain/services/circuit-group-move-planning";
|
||||
import type {
|
||||
CircuitGroupSubtreeSnapshot,
|
||||
} from "../../domain/models/circuit-group-subtree-snapshot.model";
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(url, {
|
||||
@@ -320,6 +339,199 @@ export function getCircuitTree(projectId: string, circuitListId: string) {
|
||||
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`);
|
||||
}
|
||||
|
||||
export function insertDistributionBoardComponentCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
snapshot: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "distribution-board-component.insert",
|
||||
payload: { snapshot },
|
||||
},
|
||||
"Verteilerkomponente hinzufügen"
|
||||
);
|
||||
}
|
||||
|
||||
export function updateDistributionBoardComponentCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
expected: DistributionBoardComponentSnapshot,
|
||||
target: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "distribution-board-component.update",
|
||||
payload: { expected, target },
|
||||
},
|
||||
"Verteilerkomponente bearbeiten"
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteDistributionBoardComponentCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
snapshot: DistributionBoardComponentSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "distribution-board-component.delete",
|
||||
payload: { snapshot },
|
||||
},
|
||||
"Verteilerkomponente entfernen"
|
||||
);
|
||||
}
|
||||
|
||||
export function insertCircuitGroupCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
snapshot: CircuitGroupSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-group.insert",
|
||||
payload: { snapshot },
|
||||
},
|
||||
"Stromkreisgruppe hinzufügen"
|
||||
);
|
||||
}
|
||||
|
||||
export function updateCircuitGroupCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
expected: CircuitGroupSnapshot,
|
||||
target: CircuitGroupSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-group.update",
|
||||
payload: { expected, target },
|
||||
},
|
||||
"Stromkreisgruppe bearbeiten"
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCircuitGroupCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
snapshot: CircuitGroupSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-group.delete",
|
||||
payload: { snapshot },
|
||||
},
|
||||
"Leere Stromkreisgruppe entfernen"
|
||||
);
|
||||
}
|
||||
|
||||
export function deleteCircuitGroupSubtreeCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
snapshot: CircuitGroupSubtreeSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-group.delete-subtree",
|
||||
payload: { snapshot },
|
||||
},
|
||||
"Befüllte Stromkreisgruppe entfernen"
|
||||
);
|
||||
}
|
||||
|
||||
export function reorderCircuitGroupsCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
circuitListId: string,
|
||||
assignments: CircuitGroupReorderAssignment[]
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-group.reorder",
|
||||
payload: { circuitListId, assignments },
|
||||
},
|
||||
"Stromkreisgruppen sortieren"
|
||||
);
|
||||
}
|
||||
|
||||
export function renumberCircuitGroupsCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
circuitListId: string,
|
||||
plan: CircuitGroupRenumberPlan
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-group.renumber",
|
||||
payload: { circuitListId, groups: plan.groups },
|
||||
},
|
||||
"Stromkreisgruppen neu nummerieren"
|
||||
);
|
||||
}
|
||||
|
||||
export function moveCircuitToGroupCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
plan: CircuitGroupMovePlan
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit.move-group",
|
||||
payload: plan,
|
||||
},
|
||||
"Stromkreis in andere Gruppe verschieben"
|
||||
);
|
||||
}
|
||||
|
||||
export function updateCircuitProtectionCommand(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
circuitId: string,
|
||||
expected: CircuitProtectionSnapshot | null,
|
||||
target: CircuitProtectionSnapshot
|
||||
) {
|
||||
return executeProjectCommand(
|
||||
projectId,
|
||||
expectedRevision,
|
||||
{
|
||||
schemaVersion: 1,
|
||||
type: "circuit-protection.update",
|
||||
payload: { circuitId, expected, target },
|
||||
},
|
||||
"Stromkreisschutz bearbeiten"
|
||||
);
|
||||
}
|
||||
|
||||
export function updateCircuitById(
|
||||
projectId: string,
|
||||
expectedRevision: number,
|
||||
|
||||
@@ -7,6 +7,16 @@ export type GridInsertionRowType =
|
||||
|
||||
export type GridInsertionCellKind = "circuitField" | "deviceField" | "readonly" | "computed";
|
||||
|
||||
export function isGridInsertionRowType(rowType: string): rowType is GridInsertionRowType {
|
||||
return (
|
||||
rowType === "circuitCompact" ||
|
||||
rowType === "circuitSummary" ||
|
||||
rowType === "deviceRow" ||
|
||||
rowType === "reserveCircuit" ||
|
||||
rowType === "placeholder"
|
||||
);
|
||||
}
|
||||
|
||||
export interface GridInsertionSelection {
|
||||
rowType: GridInsertionRowType;
|
||||
cellKind: GridInsertionCellKind;
|
||||
@@ -77,3 +87,26 @@ export function getInsertionSortOrder(
|
||||
const next = entries[index + 1]?.sortOrder;
|
||||
return next === undefined ? current + 10 : current + (next - current) / 2;
|
||||
}
|
||||
|
||||
export function getAdjacentInsertionSortOrder(
|
||||
orderedEntries: Array<{ id: string; sortOrder: number }>,
|
||||
targetId: string,
|
||||
placement: "before" | "after"
|
||||
): number {
|
||||
const entries = [...orderedEntries].sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
const index = entries.findIndex((entry) => entry.id === targetId);
|
||||
if (index < 0) {
|
||||
throw new Error("Der Zielstromkreis wurde nicht gefunden.");
|
||||
}
|
||||
if (placement === "after") {
|
||||
return getInsertionSortOrder(entries, targetId);
|
||||
}
|
||||
const previous = entries[index - 1];
|
||||
return previous
|
||||
? getInsertionSortOrder(entries, previous.id)
|
||||
: entries[index].sortOrder - 10;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js";
|
||||
import type { CircuitUpdatePatch } from "../../domain/models/circuit-project-command.model.js";
|
||||
import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model.js";
|
||||
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device";
|
||||
|
||||
export type CellKey =
|
||||
| "equipmentIdentifier"
|
||||
@@ -37,12 +38,15 @@ export type CellKey =
|
||||
| "isReserve";
|
||||
|
||||
export type RowType =
|
||||
| "headerComponent"
|
||||
| "section"
|
||||
| "groupComponent"
|
||||
| "circuitCompact"
|
||||
| "circuitSummary"
|
||||
| "deviceRow"
|
||||
| "reserveCircuit"
|
||||
| "placeholder";
|
||||
| "placeholder"
|
||||
| "footerComponent";
|
||||
export type CellKind = "circuitField" | "deviceField" | "computed" | "readonly";
|
||||
export type GridValue = string | number | boolean | undefined;
|
||||
|
||||
@@ -140,8 +144,15 @@ const circuitSectionLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function getCircuitSectionLabel(
|
||||
section: { key: string; displayName: string }
|
||||
section: {
|
||||
key: string;
|
||||
displayName: string;
|
||||
category?: string;
|
||||
}
|
||||
): string {
|
||||
if (section.category) {
|
||||
return section.displayName;
|
||||
}
|
||||
return circuitSectionLabels[section.key] ?? section.displayName;
|
||||
}
|
||||
|
||||
@@ -469,18 +480,54 @@ export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): G
|
||||
}
|
||||
|
||||
export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue {
|
||||
const protection = circuit.protectionDevice;
|
||||
const protectionCharacteristic = protection
|
||||
? [
|
||||
protection.fuseUtilizationCategory,
|
||||
protection.tripCharacteristic,
|
||||
protection.rcdType ? `Typ ${protection.rcdType}` : undefined,
|
||||
protection.ratedResidualCurrentMa !== undefined
|
||||
? `${protection.ratedResidualCurrentMa} mA`
|
||||
: undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")
|
||||
: undefined;
|
||||
switch (key) {
|
||||
case "equipmentIdentifier": return circuit.equipmentIdentifier;
|
||||
case "displayName": return circuit.displayName;
|
||||
case "circuitTotalPower": return circuit.circuitTotalPower;
|
||||
case "protectionType": return circuit.protectionType;
|
||||
case "protectionRatedCurrent": return circuit.protectionRatedCurrent;
|
||||
case "protectionCharacteristic": return circuit.protectionCharacteristic;
|
||||
case "protectionType":
|
||||
return protection
|
||||
? protectionDeviceTypeLabels[protection.type]
|
||||
: circuit.protectionType;
|
||||
case "protectionRatedCurrent":
|
||||
return protection?.ratedCurrentA ?? circuit.protectionRatedCurrent;
|
||||
case "protectionCharacteristic":
|
||||
return protectionCharacteristic || circuit.protectionCharacteristic;
|
||||
case "protectionSummary": {
|
||||
const current = circuit.protectionRatedCurrent !== undefined && circuit.protectionRatedCurrent !== null
|
||||
? `${circuit.protectionRatedCurrent}A`
|
||||
if (!protection) {
|
||||
const legacyCurrent =
|
||||
circuit.protectionRatedCurrent !== undefined &&
|
||||
circuit.protectionRatedCurrent !== null
|
||||
? `${circuit.protectionRatedCurrent}A`
|
||||
: "";
|
||||
return [
|
||||
circuit.protectionType,
|
||||
legacyCurrent,
|
||||
circuit.protectionCharacteristic,
|
||||
].filter(Boolean).join(" ").trim() || undefined;
|
||||
}
|
||||
const currentValue =
|
||||
protection.ratedCurrentA;
|
||||
const current = currentValue !== undefined && currentValue !== null
|
||||
? `${currentValue} A`
|
||||
: "";
|
||||
return [circuit.protectionType, current, circuit.protectionCharacteristic].filter(Boolean).join(" ").trim() || undefined;
|
||||
return [
|
||||
protectionDeviceTypeLabels[protection.type],
|
||||
current,
|
||||
protectionCharacteristic,
|
||||
].filter(Boolean).join(" · ").trim() || undefined;
|
||||
}
|
||||
case "cableSummary": {
|
||||
const length = circuit.cableLength !== undefined && circuit.cableLength !== null ? `${circuit.cableLength} m` : "";
|
||||
@@ -536,7 +583,14 @@ export function compareSortValues(left: GridValue, right: GridValue): number {
|
||||
}
|
||||
|
||||
export function getCellKind(rowType: RowType, key: CellKey): CellKind {
|
||||
if (key === "rowTotalPower" || key === "circuitTotalPower") return "computed";
|
||||
if (
|
||||
key === "rowTotalPower" ||
|
||||
key === "circuitTotalPower" ||
|
||||
key === "protectionType" ||
|
||||
key === "protectionRatedCurrent" ||
|
||||
key === "protectionCharacteristic" ||
|
||||
key === "protectionSummary"
|
||||
) return "computed";
|
||||
if (key === "voltage") return "readonly";
|
||||
if (rowType === "section") return "readonly";
|
||||
if (rowType === "placeholder") {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {
|
||||
CircuitTreeCircuitDto,
|
||||
CircuitTreeComponentDto,
|
||||
CircuitTreeDeviceRowDto,
|
||||
CircuitTreeSectionDto,
|
||||
} from "../types.js";
|
||||
import { buildCircuitStructureProjection } from "./circuit-structure-projection";
|
||||
import {
|
||||
allColumns,
|
||||
circuitOnlyColumns,
|
||||
@@ -42,6 +44,7 @@ export interface VisibleGridRow {
|
||||
sectionId: string;
|
||||
circuit?: CircuitTreeCircuitDto;
|
||||
device?: CircuitTreeDeviceRowDto;
|
||||
component?: CircuitTreeComponentDto;
|
||||
cells: VisibleGridCell[];
|
||||
}
|
||||
|
||||
@@ -160,21 +163,64 @@ function makeVisibleGridRow(
|
||||
}
|
||||
|
||||
export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[]): VisibleGridRow[] {
|
||||
const rows: VisibleGridRow[] = [];
|
||||
for (const section of sections) {
|
||||
rows.push({
|
||||
rowKey: `section:${section.id}`,
|
||||
rowType: "section",
|
||||
sectionId: section.id,
|
||||
cells: allColumns.map((column) => ({
|
||||
cellKey: column.key,
|
||||
editable: false,
|
||||
kind: "readonly",
|
||||
value: undefined,
|
||||
})),
|
||||
});
|
||||
return buildVisibleGridRowsWithStructure(sections, {
|
||||
headerComponents: [],
|
||||
footerComponents: [],
|
||||
});
|
||||
}
|
||||
|
||||
for (const circuit of section.circuits) {
|
||||
export function buildVisibleGridRowsWithStructure(
|
||||
sections: readonly CircuitTreeSectionDto[],
|
||||
structure: {
|
||||
headerComponents: readonly CircuitTreeComponentDto[];
|
||||
footerComponents: readonly CircuitTreeComponentDto[];
|
||||
}
|
||||
): VisibleGridRow[] {
|
||||
const rows: VisibleGridRow[] = [];
|
||||
const projected = buildCircuitStructureProjection({
|
||||
headerComponents: [...structure.headerComponents],
|
||||
sections: [...sections],
|
||||
footerComponents: [...structure.footerComponents],
|
||||
});
|
||||
for (const row of projected) {
|
||||
if ("component" in row) {
|
||||
const component = row.component;
|
||||
rows.push({
|
||||
rowKey: row.rowKey,
|
||||
rowType: row.rowType,
|
||||
sectionId: component.sectionId ?? `__${row.zone}__`,
|
||||
component,
|
||||
cells: allColumns.map((column) => ({
|
||||
cellKey: column.key,
|
||||
editable: false,
|
||||
kind: "readonly",
|
||||
value:
|
||||
column.key === "equipmentIdentifier"
|
||||
? component.equipmentIdentifier
|
||||
: column.key === "displayName"
|
||||
? component.name
|
||||
: undefined,
|
||||
})),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const section = row.section;
|
||||
if (row.rowType === "groupHeader") {
|
||||
rows.push({
|
||||
rowKey: `section:${section.id}`,
|
||||
rowType: "section",
|
||||
sectionId: section.id,
|
||||
cells: allColumns.map((column) => ({
|
||||
cellKey: column.key,
|
||||
editable: false,
|
||||
kind: "readonly",
|
||||
value: undefined,
|
||||
})),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (row.rowType === "circuitBlock") {
|
||||
const circuit = row.circuit;
|
||||
if (circuit.deviceRows.length === 0) {
|
||||
rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit));
|
||||
} else if (circuit.deviceRows.length === 1) {
|
||||
@@ -185,8 +231,8 @@ export function buildVisibleGridRows(sections: readonly CircuitTreeSectionDto[])
|
||||
rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push(makeVisibleGridRow("placeholder", section.id));
|
||||
}
|
||||
return rows;
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
import type {
|
||||
CircuitGroupSnapshot,
|
||||
} from "../../domain/models/circuit-group-structure-project-command.model";
|
||||
import {
|
||||
circuitGroupCategoryNumbers,
|
||||
circuitGroupCategoryLabels,
|
||||
type CircuitGroupCategory,
|
||||
} from "../../shared/constants/circuit-group";
|
||||
import type { CircuitTreeSectionDto } from "../types";
|
||||
import type {
|
||||
CircuitGroupReorderAssignment,
|
||||
} from "../../domain/models/circuit-group-structure-project-command.model";
|
||||
import type {
|
||||
CircuitGroupRenumberPlan,
|
||||
} from "../../domain/services/circuit-group-renumbering";
|
||||
import type {
|
||||
CircuitGroupMovePlan,
|
||||
} from "../../domain/services/circuit-group-move-planning";
|
||||
import type {
|
||||
CircuitGroupSubtreeSnapshot,
|
||||
} from "../../domain/models/circuit-group-subtree-snapshot.model";
|
||||
import {
|
||||
toDistributionBoardComponentSnapshot,
|
||||
} from "./distribution-board-component-editing";
|
||||
|
||||
export function toCircuitGroupSnapshot(
|
||||
section: CircuitTreeSectionDto,
|
||||
circuitListId: string
|
||||
): CircuitGroupSnapshot {
|
||||
if (!section.category || !section.groupNumber) {
|
||||
throw new Error("Der Bereich ist keine verwaltbare Stromkreisgruppe.");
|
||||
}
|
||||
return {
|
||||
id: section.id,
|
||||
circuitListId,
|
||||
key: section.key,
|
||||
displayName: section.displayName,
|
||||
prefix: section.prefix,
|
||||
sortOrder: section.sortOrder,
|
||||
category: section.category,
|
||||
groupNumber: section.groupNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNewCircuitGroupSnapshot(input: {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
category: CircuitGroupCategory;
|
||||
displayName?: string;
|
||||
sections: readonly CircuitTreeSectionDto[];
|
||||
}): CircuitGroupSnapshot {
|
||||
const groupedSections = input.sections.filter(
|
||||
(section): section is CircuitTreeSectionDto & {
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
} => Boolean(section.category && section.groupNumber)
|
||||
);
|
||||
const groupNumber =
|
||||
Math.max(
|
||||
0,
|
||||
...groupedSections
|
||||
.filter((section) => section.category === input.category)
|
||||
.map((section) => section.groupNumber)
|
||||
) + 1;
|
||||
return {
|
||||
id: input.id,
|
||||
circuitListId: input.circuitListId,
|
||||
key: `${input.category}_${groupNumber}`,
|
||||
displayName:
|
||||
input.displayName?.trim() ||
|
||||
`${circuitGroupCategoryLabels[input.category]} ${groupNumber}`,
|
||||
prefix: `-${circuitGroupCategoryNumbers[input.category]}F${groupNumber}.`,
|
||||
sortOrder: getNewGroupSortOrder(
|
||||
input.category,
|
||||
groupedSections
|
||||
),
|
||||
category: input.category,
|
||||
groupNumber,
|
||||
};
|
||||
}
|
||||
|
||||
export function renameCircuitGroupSnapshot(
|
||||
expected: CircuitGroupSnapshot,
|
||||
displayName: string
|
||||
): CircuitGroupSnapshot {
|
||||
return { ...expected, displayName: displayName.trim() };
|
||||
}
|
||||
|
||||
export function canDeleteCircuitGroup(
|
||||
section: CircuitTreeSectionDto
|
||||
): boolean {
|
||||
return section.circuits.length === 0 && section.components.length === 0;
|
||||
}
|
||||
|
||||
export function buildCircuitGroupSubtreeSnapshot(
|
||||
section: CircuitTreeSectionDto,
|
||||
circuitListId: string
|
||||
): CircuitGroupSubtreeSnapshot {
|
||||
const components = [...section.components]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
)
|
||||
.map(toDistributionBoardComponentSnapshot);
|
||||
const circuits = [...section.circuits]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
)
|
||||
.map((circuit) => ({
|
||||
circuit: {
|
||||
id: circuit.id,
|
||||
circuitListId: circuit.circuitListId,
|
||||
sectionId: circuit.sectionId,
|
||||
equipmentIdentifier: circuit.equipmentIdentifier,
|
||||
displayName: circuit.displayName ?? null,
|
||||
sortOrder: circuit.sortOrder,
|
||||
protectionType: circuit.protectionType ?? null,
|
||||
protectionRatedCurrent:
|
||||
circuit.protectionRatedCurrent ?? null,
|
||||
protectionCharacteristic:
|
||||
circuit.protectionCharacteristic ?? null,
|
||||
cableType: circuit.cableType ?? null,
|
||||
cableCrossSection: circuit.cableCrossSection ?? null,
|
||||
cableLength: circuit.cableLength ?? null,
|
||||
rcdAssignment: circuit.rcdAssignment ?? null,
|
||||
terminalDesignation: circuit.terminalDesignation ?? null,
|
||||
voltage: circuit.voltage ?? null,
|
||||
controlRequirement: circuit.controlRequirement ?? null,
|
||||
status: circuit.status ?? null,
|
||||
isReserve: circuit.isReserve,
|
||||
remark: circuit.remark ?? null,
|
||||
deviceRows: [...circuit.deviceRows]
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
circuitId: circuit.id,
|
||||
linkedProjectDeviceId:
|
||||
row.linkedProjectDeviceId ?? null,
|
||||
legacyConsumerId: row.legacyConsumerId ?? null,
|
||||
sortOrder: row.sortOrder,
|
||||
name: row.name,
|
||||
displayName: row.displayName,
|
||||
phaseType: row.phaseType ?? null,
|
||||
connectionKind: row.connectionKind ?? null,
|
||||
costGroup: row.costGroup ?? null,
|
||||
category: row.category ?? null,
|
||||
level: row.level ?? null,
|
||||
roomId: row.roomId ?? null,
|
||||
roomNumberSnapshot: row.roomNumberSnapshot ?? null,
|
||||
roomNameSnapshot: row.roomNameSnapshot ?? null,
|
||||
quantity: row.quantity,
|
||||
powerPerUnit: row.powerPerUnit,
|
||||
simultaneityFactor: row.simultaneityFactor,
|
||||
cosPhi: row.cosPhi ?? null,
|
||||
remark: row.remark ?? null,
|
||||
overriddenFields: row.overriddenFields ?? null,
|
||||
})),
|
||||
},
|
||||
protectionDevice: circuit.protectionDevice
|
||||
? {
|
||||
circuitId: circuit.id,
|
||||
type: circuit.protectionDevice.type,
|
||||
ratedCurrentA:
|
||||
circuit.protectionDevice.ratedCurrentA,
|
||||
fuseUtilizationCategory:
|
||||
circuit.protectionDevice.fuseUtilizationCategory ??
|
||||
null,
|
||||
tripCharacteristic:
|
||||
circuit.protectionDevice.tripCharacteristic ?? null,
|
||||
rcdType: circuit.protectionDevice.rcdType ?? null,
|
||||
ratedResidualCurrentMa:
|
||||
circuit.protectionDevice.ratedResidualCurrentMa ??
|
||||
null,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
return {
|
||||
group: toCircuitGroupSnapshot(section, circuitListId),
|
||||
components,
|
||||
circuits,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeCircuitGroupSubtree(
|
||||
snapshot: CircuitGroupSubtreeSnapshot
|
||||
) {
|
||||
return {
|
||||
circuitCount: snapshot.circuits.length,
|
||||
deviceRowCount: snapshot.circuits.reduce(
|
||||
(count, entry) =>
|
||||
count + entry.circuit.deviceRows.length,
|
||||
0
|
||||
),
|
||||
protectionComponentCount: snapshot.components.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCircuitGroupReorderAssignments(
|
||||
sections: readonly CircuitTreeSectionDto[],
|
||||
groupId: string,
|
||||
direction: -1 | 1
|
||||
): CircuitGroupReorderAssignment[] | null {
|
||||
const current = sections.find((section) => section.id === groupId);
|
||||
if (!current?.category) {
|
||||
return null;
|
||||
}
|
||||
const categoryGroups = sections
|
||||
.filter((section) => section.category === current.category)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
const currentIndex = categoryGroups.findIndex(
|
||||
(section) => section.id === groupId
|
||||
);
|
||||
const target = categoryGroups[currentIndex + direction];
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
return sections.map((section) => ({
|
||||
groupId: section.id,
|
||||
expectedSortOrder: section.sortOrder,
|
||||
targetSortOrder:
|
||||
section.id === current.id
|
||||
? target.sortOrder
|
||||
: section.id === target.id
|
||||
? current.sortOrder
|
||||
: section.sortOrder,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildCircuitGroupRenumberPlan(
|
||||
sections: readonly CircuitTreeSectionDto[],
|
||||
category: CircuitGroupCategory
|
||||
): CircuitGroupRenumberPlan | null {
|
||||
const groups = getOrderedCategoryGroups(sections, category);
|
||||
if (!canRenumberCircuitGroups(sections, category)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
groups: groups.map((group, index) => {
|
||||
const targetGroupNumber = index + 1;
|
||||
const expectedPrefix = formatGroupPrefix(
|
||||
category,
|
||||
group.groupNumber
|
||||
);
|
||||
if (group.prefix !== expectedPrefix) {
|
||||
throw new Error(
|
||||
`Das Präfix der Gruppe „${group.displayName}“ passt nicht zu ihrer Gruppennummer.`
|
||||
);
|
||||
}
|
||||
return {
|
||||
groupId: group.id,
|
||||
category,
|
||||
expectedGroupNumber: group.groupNumber,
|
||||
targetGroupNumber,
|
||||
expectedPrefix,
|
||||
targetPrefix: formatGroupPrefix(category, targetGroupNumber),
|
||||
circuits: group.circuits.map((circuit) => {
|
||||
const circuitNumber = parseCircuitNumber(
|
||||
circuit.equipmentIdentifier,
|
||||
category,
|
||||
group.groupNumber
|
||||
);
|
||||
return {
|
||||
circuitId: circuit.id,
|
||||
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
|
||||
targetEquipmentIdentifier: formatCircuitIdentifier(
|
||||
category,
|
||||
targetGroupNumber,
|
||||
circuitNumber
|
||||
),
|
||||
};
|
||||
}),
|
||||
components: group.components.map((component) => ({
|
||||
componentId: component.id,
|
||||
expectedEquipmentIdentifier: component.equipmentIdentifier,
|
||||
targetEquipmentIdentifier: formatGroupComponentIdentifier(
|
||||
category,
|
||||
targetGroupNumber,
|
||||
component.role
|
||||
),
|
||||
})),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function canRenumberCircuitGroups(
|
||||
sections: readonly CircuitTreeSectionDto[],
|
||||
category: CircuitGroupCategory
|
||||
) {
|
||||
const groups = getOrderedCategoryGroups(sections, category);
|
||||
return (
|
||||
groups.length > 0 &&
|
||||
groups.some((group, index) => group.groupNumber !== index + 1)
|
||||
);
|
||||
}
|
||||
|
||||
export function canMoveCircuitToGroup(
|
||||
sections: readonly CircuitTreeSectionDto[],
|
||||
circuitId: string,
|
||||
targetSectionId: string
|
||||
) {
|
||||
const source = sections.find((section) =>
|
||||
section.circuits.some((circuit) => circuit.id === circuitId)
|
||||
);
|
||||
const target = sections.find(
|
||||
(section) => section.id === targetSectionId
|
||||
);
|
||||
return Boolean(
|
||||
source?.category &&
|
||||
target?.category &&
|
||||
source.id !== target.id &&
|
||||
source.category === target.category
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCircuitGroupMovePlan(input: {
|
||||
sections: readonly CircuitTreeSectionDto[];
|
||||
circuitId: string;
|
||||
targetSectionId: string;
|
||||
placement:
|
||||
| { kind: "end" }
|
||||
| {
|
||||
kind: "before" | "after";
|
||||
targetCircuitId: string;
|
||||
};
|
||||
}): CircuitGroupMovePlan {
|
||||
const sourceGroup = input.sections.find((section) =>
|
||||
section.circuits.some(
|
||||
(circuit) => circuit.id === input.circuitId
|
||||
)
|
||||
);
|
||||
const targetGroup = input.sections.find(
|
||||
(section) => section.id === input.targetSectionId
|
||||
);
|
||||
const circuit = sourceGroup?.circuits.find(
|
||||
(entry) => entry.id === input.circuitId
|
||||
);
|
||||
if (
|
||||
!sourceGroup?.category ||
|
||||
!sourceGroup.groupNumber ||
|
||||
!targetGroup?.category ||
|
||||
!targetGroup.groupNumber ||
|
||||
!circuit ||
|
||||
sourceGroup.id === targetGroup.id ||
|
||||
sourceGroup.category !== targetGroup.category
|
||||
) {
|
||||
throw new Error(
|
||||
"Stromkreise können nur zwischen verschiedenen Gruppen derselben Kategorie verschoben werden."
|
||||
);
|
||||
}
|
||||
parseCircuitNumber(
|
||||
circuit.equipmentIdentifier,
|
||||
sourceGroup.category,
|
||||
sourceGroup.groupNumber
|
||||
);
|
||||
const targetCircuitNumbers = targetGroup.circuits.map((entry) =>
|
||||
parseCircuitNumber(
|
||||
entry.equipmentIdentifier,
|
||||
targetGroup.category!,
|
||||
targetGroup.groupNumber!
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
circuitId: circuit.id,
|
||||
circuitListId: circuit.circuitListId,
|
||||
expectedSectionId: sourceGroup.id,
|
||||
targetSectionId: targetGroup.id,
|
||||
expectedEquipmentIdentifier: circuit.equipmentIdentifier,
|
||||
targetEquipmentIdentifier: formatCircuitIdentifier(
|
||||
targetGroup.category,
|
||||
targetGroup.groupNumber,
|
||||
Math.max(0, ...targetCircuitNumbers) + 1
|
||||
),
|
||||
expectedSortOrder: circuit.sortOrder,
|
||||
targetSortOrder: getCircuitMoveSortOrder(
|
||||
targetGroup.circuits,
|
||||
input.placement
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function getNewGroupSortOrder(
|
||||
category: CircuitGroupCategory,
|
||||
sections: readonly (CircuitTreeSectionDto & {
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
})[]
|
||||
): number {
|
||||
const ordered = [...sections].sort(
|
||||
(left, right) => left.sortOrder - right.sortOrder
|
||||
);
|
||||
const sameCategory = ordered.filter(
|
||||
(section) => section.category === category
|
||||
);
|
||||
const previous = sameCategory[sameCategory.length - 1];
|
||||
if (!previous) {
|
||||
return ordered.length === 0
|
||||
? 10
|
||||
: Math.max(...ordered.map((section) => section.sortOrder)) + 10;
|
||||
}
|
||||
const next = ordered.find(
|
||||
(section) => section.sortOrder > previous.sortOrder
|
||||
);
|
||||
return next
|
||||
? previous.sortOrder + (next.sortOrder - previous.sortOrder) / 2
|
||||
: previous.sortOrder + 10;
|
||||
}
|
||||
|
||||
function formatGroupPrefix(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number
|
||||
) {
|
||||
return `-${circuitGroupCategoryNumbers[category]}F${groupNumber}.`;
|
||||
}
|
||||
|
||||
function getOrderedCategoryGroups(
|
||||
sections: readonly CircuitTreeSectionDto[],
|
||||
category: CircuitGroupCategory
|
||||
) {
|
||||
return sections
|
||||
.filter(
|
||||
(section): section is CircuitTreeSectionDto & {
|
||||
category: CircuitGroupCategory;
|
||||
groupNumber: number;
|
||||
} =>
|
||||
section.category === category &&
|
||||
Number.isInteger(section.groupNumber) &&
|
||||
(section.groupNumber ?? 0) > 0
|
||||
)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
}
|
||||
|
||||
function formatCircuitIdentifier(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number,
|
||||
circuitNumber: number
|
||||
) {
|
||||
return `${formatGroupPrefix(category, groupNumber)}${circuitNumber}`;
|
||||
}
|
||||
|
||||
function parseCircuitNumber(
|
||||
equipmentIdentifier: string,
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number
|
||||
) {
|
||||
const prefix = formatGroupPrefix(category, groupNumber);
|
||||
const suffix = equipmentIdentifier.startsWith(prefix)
|
||||
? equipmentIdentifier.slice(prefix.length)
|
||||
: "";
|
||||
const circuitNumber = Number(suffix);
|
||||
if (
|
||||
!/^[1-9]\d*$/.test(suffix) ||
|
||||
!Number.isSafeInteger(circuitNumber)
|
||||
) {
|
||||
throw new Error(
|
||||
`Das Stromkreis-BMK „${equipmentIdentifier}“ passt nicht zu seiner Gruppe.`
|
||||
);
|
||||
}
|
||||
return circuitNumber;
|
||||
}
|
||||
|
||||
function formatGroupComponentIdentifier(
|
||||
category: CircuitGroupCategory,
|
||||
groupNumber: number,
|
||||
role: CircuitTreeSectionDto["components"][number]["role"]
|
||||
) {
|
||||
const categoryNumber = circuitGroupCategoryNumbers[category];
|
||||
if (role === "group_upstream_protection") {
|
||||
return `-${categoryNumber}F${groupNumber}.0`;
|
||||
}
|
||||
if (role === "group_residual_current_protection") {
|
||||
return `-${categoryNumber}Q${groupNumber}.0`;
|
||||
}
|
||||
throw new Error(
|
||||
"Die Stromkreisgruppe enthält eine nicht unterstützte Verteilerkomponente."
|
||||
);
|
||||
}
|
||||
|
||||
function getCircuitMoveSortOrder(
|
||||
circuits: CircuitTreeSectionDto["circuits"],
|
||||
placement:
|
||||
| { kind: "end" }
|
||||
| {
|
||||
kind: "before" | "after";
|
||||
targetCircuitId: string;
|
||||
}
|
||||
) {
|
||||
const ordered = [...circuits].sort(
|
||||
(left, right) =>
|
||||
left.sortOrder - right.sortOrder ||
|
||||
left.id.localeCompare(right.id)
|
||||
);
|
||||
if (placement.kind === "end") {
|
||||
return ordered.length === 0
|
||||
? 10
|
||||
: ordered[ordered.length - 1].sortOrder + 10;
|
||||
}
|
||||
const index = ordered.findIndex(
|
||||
(circuit) => circuit.id === placement.targetCircuitId
|
||||
);
|
||||
if (index < 0) {
|
||||
throw new Error("Der Zielstromkreis wurde nicht gefunden.");
|
||||
}
|
||||
const current = ordered[index].sortOrder;
|
||||
if (placement.kind === "before") {
|
||||
const previous = ordered[index - 1]?.sortOrder;
|
||||
return previous === undefined
|
||||
? current - 10
|
||||
: previous + (current - previous) / 2;
|
||||
}
|
||||
const next = ordered[index + 1]?.sortOrder;
|
||||
return next === undefined
|
||||
? current + 10
|
||||
: current + (next - current) / 2;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
CircuitProtectionSnapshot,
|
||||
} from "../../domain/models/circuit-protection-project-command.model";
|
||||
import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults";
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
|
||||
import type {
|
||||
CircuitTreeProtectionDeviceDto,
|
||||
} from "../types";
|
||||
|
||||
export function toCircuitProtectionSnapshot(
|
||||
circuitId: string,
|
||||
protection: CircuitTreeProtectionDeviceDto
|
||||
): CircuitProtectionSnapshot {
|
||||
return {
|
||||
circuitId,
|
||||
type: protection.type,
|
||||
ratedCurrentA: protection.ratedCurrentA,
|
||||
fuseUtilizationCategory:
|
||||
protection.fuseUtilizationCategory ?? null,
|
||||
tripCharacteristic: protection.tripCharacteristic ?? null,
|
||||
rcdType: protection.rcdType ?? null,
|
||||
ratedResidualCurrentMa:
|
||||
protection.ratedResidualCurrentMa ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getCircuitProtectionEditorInitialValue(
|
||||
category: CircuitGroupCategory | undefined,
|
||||
protection: CircuitTreeProtectionDeviceDto | undefined
|
||||
): CircuitTreeProtectionDeviceDto {
|
||||
if (protection) {
|
||||
return protection;
|
||||
}
|
||||
const fallback = createDefaultCircuitProtection(category ?? "lighting");
|
||||
return { ...fallback };
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import type {
|
||||
CreateCircuitDeviceRowInputDto,
|
||||
CreateCircuitInputDto,
|
||||
} from "../types.js";
|
||||
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
|
||||
import { createDefaultCircuitProtection } from "../../domain/services/protection-device-defaults";
|
||||
|
||||
export function buildCircuitDeviceRowInsertSnapshot(input: {
|
||||
id: string;
|
||||
@@ -46,8 +48,12 @@ export function buildCircuitInsertSnapshot(input: {
|
||||
circuitListId: string;
|
||||
values: CreateCircuitInputDto;
|
||||
deviceRows: CircuitDeviceRowSnapshot[];
|
||||
category?: CircuitGroupCategory;
|
||||
}): CircuitSnapshot {
|
||||
const { id, circuitListId, values, deviceRows } = input;
|
||||
const { id, circuitListId, values, deviceRows, category } = input;
|
||||
const defaultProtection = category
|
||||
? createDefaultCircuitProtection(category)
|
||||
: null;
|
||||
return {
|
||||
id,
|
||||
circuitListId,
|
||||
@@ -69,5 +75,30 @@ export function buildCircuitInsertSnapshot(input: {
|
||||
isReserve: deviceRows.length === 0,
|
||||
remark: values.remark ?? null,
|
||||
deviceRows,
|
||||
...(defaultProtection
|
||||
? {
|
||||
protectionDevice: {
|
||||
circuitId: id,
|
||||
type: defaultProtection.type,
|
||||
ratedCurrentA: defaultProtection.ratedCurrentA,
|
||||
fuseUtilizationCategory:
|
||||
"fuseUtilizationCategory" in defaultProtection
|
||||
? defaultProtection.fuseUtilizationCategory
|
||||
: null,
|
||||
tripCharacteristic:
|
||||
"tripCharacteristic" in defaultProtection
|
||||
? defaultProtection.tripCharacteristic
|
||||
: null,
|
||||
rcdType:
|
||||
"rcdType" in defaultProtection
|
||||
? defaultProtection.rcdType
|
||||
: null,
|
||||
ratedResidualCurrentMa:
|
||||
"ratedResidualCurrentMa" in defaultProtection
|
||||
? defaultProtection.ratedResidualCurrentMa
|
||||
: null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function buildCircuitStructureProjection(
|
||||
zone: "group",
|
||||
section,
|
||||
});
|
||||
for (const component of ordered(section.components)) {
|
||||
for (const component of ordered(section.components ?? [])) {
|
||||
rows.push({
|
||||
rowKey: `group-component:${component.id}`,
|
||||
rowType: "groupComponent",
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type {
|
||||
DistributionBoardComponentSnapshot,
|
||||
} from "../../domain/models/distribution-board-component-structure-project-command.model";
|
||||
import type {
|
||||
CircuitTreeComponentDto,
|
||||
CircuitTreeProtectionDeviceDto,
|
||||
CircuitTreeSectionDto,
|
||||
} from "../types";
|
||||
import { circuitGroupCategoryNumbers } from "../../shared/constants/circuit-group";
|
||||
|
||||
export type MutableDistributionBoardComponentRole =
|
||||
| "group_upstream_protection"
|
||||
| "group_residual_current_protection"
|
||||
| "auxiliary";
|
||||
|
||||
export interface DistributionBoardComponentEditorValues {
|
||||
equipmentIdentifier: string;
|
||||
name: string;
|
||||
protectionDevice?: CircuitTreeProtectionDeviceDto;
|
||||
}
|
||||
|
||||
export function toDistributionBoardComponentSnapshot(
|
||||
component: CircuitTreeComponentDto
|
||||
): DistributionBoardComponentSnapshot {
|
||||
if (
|
||||
component.role !== "group_upstream_protection" &&
|
||||
component.role !== "group_residual_current_protection" &&
|
||||
component.role !== "auxiliary"
|
||||
) {
|
||||
throw new Error("Feste Verteilerkomponenten können hier nicht bearbeitet werden.");
|
||||
}
|
||||
return {
|
||||
component: {
|
||||
id: component.id,
|
||||
circuitListId: component.circuitListId,
|
||||
sectionId: component.sectionId ?? null,
|
||||
equipmentIdentifier: component.equipmentIdentifier,
|
||||
name: component.name,
|
||||
role: component.role,
|
||||
placement: component.placement,
|
||||
sortOrder: component.sortOrder,
|
||||
},
|
||||
protectionDevice: component.protectionDevice
|
||||
? {
|
||||
componentId: component.id,
|
||||
type: component.protectionDevice.type,
|
||||
ratedCurrentA: component.protectionDevice.ratedCurrentA,
|
||||
fuseUtilizationCategory:
|
||||
component.protectionDevice.fuseUtilizationCategory ?? null,
|
||||
tripCharacteristic:
|
||||
component.protectionDevice.tripCharacteristic ?? null,
|
||||
rcdType: component.protectionDevice.rcdType ?? null,
|
||||
ratedResidualCurrentMa:
|
||||
component.protectionDevice.ratedResidualCurrentMa ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildDistributionBoardComponentSnapshot(input: {
|
||||
id: string;
|
||||
circuitListId: string;
|
||||
role: MutableDistributionBoardComponentRole;
|
||||
sectionId?: string;
|
||||
sortOrder: number;
|
||||
values: DistributionBoardComponentEditorValues;
|
||||
}): DistributionBoardComponentSnapshot {
|
||||
const groupComponent = input.role !== "auxiliary";
|
||||
if (groupComponent && !input.sectionId) {
|
||||
throw new Error("Für den Gruppenschutz fehlt die Stromkreisgruppe.");
|
||||
}
|
||||
if (groupComponent && !input.values.protectionDevice) {
|
||||
throw new Error("Für den Gruppenschutz fehlt die Schutzgerätekonfiguration.");
|
||||
}
|
||||
return {
|
||||
component: {
|
||||
id: input.id,
|
||||
circuitListId: input.circuitListId,
|
||||
sectionId: groupComponent ? input.sectionId! : null,
|
||||
equipmentIdentifier: input.values.equipmentIdentifier.trim(),
|
||||
name: input.values.name.trim(),
|
||||
role: input.role,
|
||||
placement: groupComponent ? "group" : "footer",
|
||||
sortOrder: input.sortOrder,
|
||||
},
|
||||
protectionDevice:
|
||||
groupComponent && input.values.protectionDevice
|
||||
? {
|
||||
componentId: input.id,
|
||||
type: input.values.protectionDevice.type,
|
||||
ratedCurrentA: input.values.protectionDevice.ratedCurrentA,
|
||||
fuseUtilizationCategory:
|
||||
input.values.protectionDevice.fuseUtilizationCategory ?? null,
|
||||
tripCharacteristic:
|
||||
input.values.protectionDevice.tripCharacteristic ?? null,
|
||||
rcdType: input.values.protectionDevice.rcdType ?? null,
|
||||
ratedResidualCurrentMa:
|
||||
input.values.protectionDevice.ratedResidualCurrentMa ?? null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateDistributionBoardComponentSnapshot(
|
||||
expected: DistributionBoardComponentSnapshot,
|
||||
values: DistributionBoardComponentEditorValues
|
||||
): DistributionBoardComponentSnapshot {
|
||||
return buildDistributionBoardComponentSnapshot({
|
||||
id: expected.component.id,
|
||||
circuitListId: expected.component.circuitListId,
|
||||
role: expected.component.role,
|
||||
sectionId: expected.component.sectionId ?? undefined,
|
||||
sortOrder: expected.component.sortOrder,
|
||||
values,
|
||||
});
|
||||
}
|
||||
|
||||
export function getSuggestedGroupComponentIdentifier(
|
||||
section: CircuitTreeSectionDto,
|
||||
role:
|
||||
| "group_upstream_protection"
|
||||
| "group_residual_current_protection"
|
||||
): string {
|
||||
if (!section.category || !section.groupNumber) {
|
||||
return "";
|
||||
}
|
||||
const categoryNumber = circuitGroupCategoryNumbers[section.category];
|
||||
const deviceLetter =
|
||||
role === "group_upstream_protection" ? "F" : "Q";
|
||||
return `-${categoryNumber}${deviceLetter}${section.groupNumber}.0`;
|
||||
}
|
||||
|
||||
export function getNextComponentSortOrder(
|
||||
components: readonly { sortOrder: number }[]
|
||||
): number {
|
||||
return components.length === 0
|
||||
? 10
|
||||
: Math.max(...components.map((component) => component.sortOrder)) + 10;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { CircuitProjectCommandRepository } from "../../db/repositories/circuit-p
|
||||
import { CircuitSectionReorderProjectCommandRepository } from "../../db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||
import { CircuitSectionRenumberProjectCommandRepository } from "../../db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||
import { CircuitStructureProjectCommandRepository } from "../../db/repositories/circuit-structure-project-command.repository.js";
|
||||
import { CircuitProtectionProjectCommandRepository } from "../../db/repositories/circuit-protection-project-command.repository.js";
|
||||
import { CircuitGroupStructureProjectCommandRepository } from "../../db/repositories/circuit-group-structure-project-command.repository.js";
|
||||
import { CircuitGroupRenumberProjectCommandRepository } from "../../db/repositories/circuit-group-renumber-project-command.repository.js";
|
||||
import { CircuitGroupMoveProjectCommandRepository } from "../../db/repositories/circuit-group-move-project-command.repository.js";
|
||||
@@ -30,6 +31,8 @@ export const circuitDeviceRowMoveProjectCommandStore =
|
||||
new CircuitDeviceRowMoveProjectCommandRepository(db);
|
||||
export const circuitStructureProjectCommandStore =
|
||||
new CircuitStructureProjectCommandRepository(db);
|
||||
export const circuitProtectionProjectCommandStore =
|
||||
new CircuitProtectionProjectCommandRepository(db);
|
||||
export const circuitGroupStructureProjectCommandStore =
|
||||
new CircuitGroupStructureProjectCommandRepository(db);
|
||||
export const circuitGroupRenumberProjectCommandStore =
|
||||
@@ -79,5 +82,6 @@ export const projectCommandService = new ProjectCommandService(
|
||||
circuitGroupRenumberProjectCommandStore,
|
||||
circuitGroupMoveProjectCommandStore,
|
||||
circuitGroupSubtreeProjectCommandStore,
|
||||
circuitProtectionProjectCommandStore,
|
||||
projectHistoryStore
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CircuitDeviceRowMoveProjectCommandRepository } from "../src/db/reposito
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
@@ -193,6 +194,15 @@ function createMoveTargetSnapshot(
|
||||
isReserve: true,
|
||||
remark: null,
|
||||
deviceRows: [],
|
||||
protectionDevice: {
|
||||
circuitId: "circuit-new",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -520,6 +530,14 @@ describe("circuit device-row move project-command repository", () => {
|
||||
"-1F4"
|
||||
);
|
||||
assert.equal(isReserve(fixture.context, "circuit-new"), false);
|
||||
assert.equal(
|
||||
fixture.context.db
|
||||
.select({ ratedCurrentA: circuitProtectionDevices.ratedCurrentA })
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-new"))
|
||||
.get()?.ratedCurrentA,
|
||||
10
|
||||
);
|
||||
assert.equal(isReserve(fixture.context, "circuit-1"), false);
|
||||
assert.equal(isReserve(fixture.context, "circuit-2"), true);
|
||||
|
||||
@@ -538,6 +556,14 @@ describe("circuit device-row move project-command repository", () => {
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
assert.equal(
|
||||
fixture.context.db
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-new"))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
assert.deepEqual(
|
||||
[
|
||||
getRow(fixture.context, "row-1"),
|
||||
@@ -562,6 +588,14 @@ describe("circuit device-row move project-command repository", () => {
|
||||
getRow(fixture.context, "row-2")?.circuitId,
|
||||
"circuit-new"
|
||||
);
|
||||
assert.equal(
|
||||
fixture.context.db
|
||||
.select({ type: circuitProtectionDevices.type })
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-new"))
|
||||
.get()?.type,
|
||||
"LS"
|
||||
);
|
||||
assert.deepEqual(
|
||||
new ProjectHistoryRepository(fixture.context.db).getState(
|
||||
"project-1"
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
getAdjacentInsertionSortOrder,
|
||||
getInsertionSortOrder,
|
||||
isGridInsertionRowType,
|
||||
resolveGridInsertionIntent,
|
||||
} from "../src/frontend/utils/circuit-grid-insertion.js";
|
||||
|
||||
describe("circuit grid insertion", () => {
|
||||
it("excludes structural rows from insertion and deletion commands", () => {
|
||||
assert.equal(isGridInsertionRowType("headerComponent"), false);
|
||||
assert.equal(isGridInsertionRowType("groupComponent"), false);
|
||||
assert.equal(isGridInsertionRowType("footerComponent"), false);
|
||||
assert.equal(isGridInsertionRowType("circuitCompact"), true);
|
||||
});
|
||||
|
||||
it("inserts a circuit after circuit-level selections", () => {
|
||||
assert.deepEqual(
|
||||
resolveGridInsertionIntent(
|
||||
@@ -81,5 +90,26 @@ describe("circuit grid insertion", () => {
|
||||
assert.equal(getInsertionSortOrder(entries, "c"), 40);
|
||||
assert.equal(getInsertionSortOrder(entries), 40);
|
||||
assert.equal(getInsertionSortOrder([], "missing"), 10);
|
||||
assert.equal(
|
||||
getAdjacentInsertionSortOrder(entries, "a", "before"),
|
||||
0
|
||||
);
|
||||
assert.equal(
|
||||
getAdjacentInsertionSortOrder(entries, "b", "before"),
|
||||
15
|
||||
);
|
||||
assert.equal(
|
||||
getAdjacentInsertionSortOrder(entries, "b", "after"),
|
||||
25
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
getAdjacentInsertionSortOrder(
|
||||
entries,
|
||||
"missing",
|
||||
"before"
|
||||
),
|
||||
/nicht gefunden/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,6 +101,14 @@ describe("circuit grid model", () => {
|
||||
});
|
||||
|
||||
it("shows stable circuit sections with German labels", () => {
|
||||
assert.equal(
|
||||
getCircuitSectionLabel({
|
||||
key: "lighting",
|
||||
displayName: "Lighting",
|
||||
category: "lighting",
|
||||
}),
|
||||
"Lighting"
|
||||
);
|
||||
assert.equal(
|
||||
getCircuitSectionLabel({
|
||||
key: "lighting",
|
||||
@@ -128,12 +136,29 @@ describe("circuit grid model", () => {
|
||||
assert.equal(getCellKind("circuitSummary", "controlRequirement"), "circuitField");
|
||||
assert.equal(getCellKind("deviceRow", "controlRequirement"), "readonly");
|
||||
assert.equal(getCellKind("circuitCompact", "rowTotalPower"), "computed");
|
||||
assert.equal(getCellKind("circuitCompact", "protectionType"), "computed");
|
||||
});
|
||||
|
||||
it("projects circuit and device values without mixing ownership", () => {
|
||||
assert.equal(getDeviceValue(device, "roomSummary"), "1.01 Office");
|
||||
assert.equal(getDeviceValue(device, "rowTotalPower"), 0.08);
|
||||
assert.equal(getCircuitValue(circuit, "protectionSummary"), "MCB 16A B");
|
||||
assert.equal(
|
||||
getCircuitValue(
|
||||
{
|
||||
...circuit,
|
||||
protectionDevice: {
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
},
|
||||
"protectionSummary"
|
||||
),
|
||||
"FI/LS · 16 A · B · Typ A · 30 mA"
|
||||
);
|
||||
assert.equal(getCircuitValue(circuit, "voltage"), 230);
|
||||
assert.equal(getCircuitValue(circuit, "controlRequirement"), "DALI");
|
||||
assert.equal(getCircuitValue(circuit, "cableSummary"), "NYM-J, 1.5 mm², 20 m");
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
buildVisibleGridRows,
|
||||
buildVisibleGridRowsWithStructure,
|
||||
filterAndSortCircuitSections,
|
||||
getDistinctFilterValues,
|
||||
} from "../src/frontend/utils/circuit-grid-projection.js";
|
||||
@@ -176,6 +177,66 @@ describe("circuit grid projection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes structural component rows around complete circuit groups", () => {
|
||||
const component = {
|
||||
circuitListId: "list-1",
|
||||
equipmentIdentifier: "-Q0",
|
||||
name: "Hauptschalter",
|
||||
role: "main_switch" as const,
|
||||
placement: "header" as const,
|
||||
sortOrder: 10,
|
||||
};
|
||||
const groupedSections = sections.map((section) => ({
|
||||
...section,
|
||||
components: [
|
||||
{
|
||||
...component,
|
||||
id: "group-rcd",
|
||||
sectionId: section.id,
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection" as const,
|
||||
placement: "group" as const,
|
||||
},
|
||||
],
|
||||
}));
|
||||
const rows = buildVisibleGridRowsWithStructure(groupedSections, {
|
||||
headerComponents: [{ ...component, id: "main" }],
|
||||
footerComponents: [
|
||||
{
|
||||
...component,
|
||||
id: "actor",
|
||||
equipmentIdentifier: "-K1",
|
||||
name: "KNX Aktor",
|
||||
role: "auxiliary",
|
||||
placement: "footer",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.rowType),
|
||||
[
|
||||
"headerComponent",
|
||||
"section",
|
||||
"groupComponent",
|
||||
"circuitCompact",
|
||||
"circuitSummary",
|
||||
"deviceRow",
|
||||
"deviceRow",
|
||||
"reserveCircuit",
|
||||
"placeholder",
|
||||
"footerComponent",
|
||||
]
|
||||
);
|
||||
assert.equal(
|
||||
rows.find((row) => row.rowType === "groupComponent")?.cells.some(
|
||||
(cell) => cell.editable
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("collects filter options from both circuit and device values", () => {
|
||||
const values = getDistinctFilterValues(sections, [
|
||||
{ key: "displayName", label: "Display name" },
|
||||
|
||||
@@ -15,8 +15,374 @@ import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group
|
||||
import "./circuit-group-move-project-command.repository.test.js";
|
||||
import "./circuit-group-subtree-snapshot.test.js";
|
||||
import "./circuit-group-subtree-project-command.repository.test.js";
|
||||
import {
|
||||
buildCircuitGroupMovePlan,
|
||||
buildCircuitGroupRenumberPlan,
|
||||
buildCircuitGroupReorderAssignments,
|
||||
buildCircuitGroupSubtreeSnapshot,
|
||||
buildNewCircuitGroupSnapshot,
|
||||
canMoveCircuitToGroup,
|
||||
canRenumberCircuitGroups,
|
||||
canDeleteCircuitGroup,
|
||||
renameCircuitGroupSnapshot,
|
||||
summarizeCircuitGroupSubtree,
|
||||
} from "../src/frontend/utils/circuit-group-editing.js";
|
||||
|
||||
describe("circuit group numbering", () => {
|
||||
it("builds the next group without filling gaps or renumbering existing groups", () => {
|
||||
const sections = [
|
||||
groupSection("lighting-1", "lighting", 1, 10),
|
||||
groupSection("single-1", "single_phase", 1, 20),
|
||||
groupSection("lighting-3", "lighting", 3, 15),
|
||||
];
|
||||
const snapshot = buildNewCircuitGroupSnapshot({
|
||||
id: "lighting-4",
|
||||
circuitListId: "list-1",
|
||||
category: "lighting",
|
||||
sections,
|
||||
});
|
||||
assert.deepEqual(snapshot, {
|
||||
id: "lighting-4",
|
||||
circuitListId: "list-1",
|
||||
key: "lighting_4",
|
||||
displayName: "Beleuchtung 4",
|
||||
prefix: "-1F4.",
|
||||
sortOrder: 17.5,
|
||||
category: "lighting",
|
||||
groupNumber: 4,
|
||||
});
|
||||
assert.equal(
|
||||
renameCircuitGroupSnapshot(snapshot, " Beleuchtung Nord ").displayName,
|
||||
"Beleuchtung Nord"
|
||||
);
|
||||
assert.equal(canDeleteCircuitGroup(sections[0]), true);
|
||||
assert.equal(
|
||||
canDeleteCircuitGroup({
|
||||
...sections[0],
|
||||
components: [
|
||||
{
|
||||
id: "component-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: sections[0].id,
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 10,
|
||||
},
|
||||
],
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("reorders complete group lists only between category peers", () => {
|
||||
const sections = [
|
||||
groupSection("lighting-1", "lighting", 1, 10),
|
||||
groupSection("lighting-2", "lighting", 2, 15),
|
||||
groupSection("single-1", "single_phase", 1, 20),
|
||||
];
|
||||
assert.deepEqual(
|
||||
buildCircuitGroupReorderAssignments(
|
||||
sections,
|
||||
"lighting-1",
|
||||
1
|
||||
),
|
||||
[
|
||||
{
|
||||
groupId: "lighting-1",
|
||||
expectedSortOrder: 10,
|
||||
targetSortOrder: 15,
|
||||
},
|
||||
{
|
||||
groupId: "lighting-2",
|
||||
expectedSortOrder: 15,
|
||||
targetSortOrder: 10,
|
||||
},
|
||||
{
|
||||
groupId: "single-1",
|
||||
expectedSortOrder: 20,
|
||||
targetSortOrder: 20,
|
||||
},
|
||||
]
|
||||
);
|
||||
assert.equal(
|
||||
buildCircuitGroupReorderAssignments(
|
||||
sections,
|
||||
"lighting-1",
|
||||
-1
|
||||
),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
buildCircuitGroupReorderAssignments(
|
||||
sections,
|
||||
"single-1",
|
||||
1
|
||||
),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
it("renumbers a complete category by group order and preserves circuit suffixes", () => {
|
||||
const sections = [
|
||||
{
|
||||
...groupSection("lighting-3", "lighting", 3, 10),
|
||||
prefix: "-1F3.",
|
||||
circuits: [
|
||||
{
|
||||
id: "circuit-7",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-3",
|
||||
equipmentIdentifier: "-1F3.7",
|
||||
sortOrder: 10,
|
||||
isReserve: false,
|
||||
circuitTotalPower: 0,
|
||||
deviceRows: [],
|
||||
},
|
||||
],
|
||||
components: [
|
||||
{
|
||||
id: "rcd-3",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-3",
|
||||
equipmentIdentifier: "-1Q3.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection" as const,
|
||||
placement: "group" as const,
|
||||
sortOrder: 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
...groupSection("lighting-1", "lighting", 1, 20),
|
||||
prefix: "-1F1.",
|
||||
},
|
||||
groupSection("single-1", "single_phase", 1, 30),
|
||||
];
|
||||
|
||||
assert.deepEqual(buildCircuitGroupRenumberPlan(sections, "lighting"), {
|
||||
groups: [
|
||||
{
|
||||
groupId: "lighting-3",
|
||||
category: "lighting",
|
||||
expectedGroupNumber: 3,
|
||||
targetGroupNumber: 1,
|
||||
expectedPrefix: "-1F3.",
|
||||
targetPrefix: "-1F1.",
|
||||
circuits: [
|
||||
{
|
||||
circuitId: "circuit-7",
|
||||
expectedEquipmentIdentifier: "-1F3.7",
|
||||
targetEquipmentIdentifier: "-1F1.7",
|
||||
},
|
||||
],
|
||||
components: [
|
||||
{
|
||||
componentId: "rcd-3",
|
||||
expectedEquipmentIdentifier: "-1Q3.0",
|
||||
targetEquipmentIdentifier: "-1Q1.0",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
groupId: "lighting-1",
|
||||
category: "lighting",
|
||||
expectedGroupNumber: 1,
|
||||
targetGroupNumber: 2,
|
||||
expectedPrefix: "-1F1.",
|
||||
targetPrefix: "-1F2.",
|
||||
circuits: [],
|
||||
components: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.equal(
|
||||
buildCircuitGroupRenumberPlan(
|
||||
[
|
||||
groupSection("lighting-1", "lighting", 1, 10),
|
||||
groupSection("lighting-2", "lighting", 2, 20),
|
||||
],
|
||||
"lighting"
|
||||
),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
canRenumberCircuitGroups(sections, "lighting"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
canRenumberCircuitGroups(
|
||||
[
|
||||
groupSection("lighting-1", "lighting", 1, 10),
|
||||
groupSection("lighting-2", "lighting", 2, 20),
|
||||
],
|
||||
"lighting"
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("plans a same-category circuit drop with the next target BMK", () => {
|
||||
const source = {
|
||||
...groupSection("lighting-1", "lighting", 1, 10),
|
||||
circuits: [
|
||||
{
|
||||
id: "circuit-source",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-1",
|
||||
equipmentIdentifier: "-1F1.5",
|
||||
sortOrder: 10,
|
||||
isReserve: false,
|
||||
circuitTotalPower: 0,
|
||||
deviceRows: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const target = {
|
||||
...groupSection("lighting-2", "lighting", 2, 20),
|
||||
circuits: [
|
||||
{
|
||||
id: "circuit-target",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-2",
|
||||
equipmentIdentifier: "-1F2.3",
|
||||
sortOrder: 20,
|
||||
isReserve: false,
|
||||
circuitTotalPower: 0,
|
||||
deviceRows: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.equal(
|
||||
canMoveCircuitToGroup(
|
||||
[source, target],
|
||||
"circuit-source",
|
||||
"lighting-2"
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.deepEqual(
|
||||
buildCircuitGroupMovePlan({
|
||||
sections: [source, target],
|
||||
circuitId: "circuit-source",
|
||||
targetSectionId: "lighting-2",
|
||||
placement: {
|
||||
kind: "before",
|
||||
targetCircuitId: "circuit-target",
|
||||
},
|
||||
}),
|
||||
{
|
||||
circuitId: "circuit-source",
|
||||
circuitListId: "list-1",
|
||||
expectedSectionId: "lighting-1",
|
||||
targetSectionId: "lighting-2",
|
||||
expectedEquipmentIdentifier: "-1F1.5",
|
||||
targetEquipmentIdentifier: "-1F2.4",
|
||||
expectedSortOrder: 10,
|
||||
targetSortOrder: 10,
|
||||
}
|
||||
);
|
||||
assert.equal(
|
||||
canMoveCircuitToGroup(
|
||||
[
|
||||
source,
|
||||
groupSection("single-1", "single_phase", 1, 20),
|
||||
],
|
||||
"circuit-source",
|
||||
"single-1"
|
||||
),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("captures a complete populated group for warned deletion", () => {
|
||||
const section = {
|
||||
...groupSection("lighting-1", "lighting", 1, 10),
|
||||
components: [
|
||||
{
|
||||
id: "rcd-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-1",
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection" as const,
|
||||
placement: "group" as const,
|
||||
sortOrder: 10,
|
||||
protectionDevice: {
|
||||
type: "FI" as const,
|
||||
ratedCurrentA: 40,
|
||||
rcdType: "A" as const,
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
},
|
||||
],
|
||||
circuits: [
|
||||
{
|
||||
id: "circuit-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "lighting-1",
|
||||
equipmentIdentifier: "-1F1.1",
|
||||
displayName: "Flurlicht",
|
||||
sortOrder: 10,
|
||||
voltage: 230,
|
||||
isReserve: false,
|
||||
circuitTotalPower: 0.1,
|
||||
protectionDevice: {
|
||||
type: "LS" as const,
|
||||
ratedCurrentA: 10,
|
||||
tripCharacteristic: "B" as const,
|
||||
},
|
||||
deviceRows: [
|
||||
{
|
||||
id: "row-1",
|
||||
linkedProjectDeviceId: "device-1",
|
||||
sortOrder: 10,
|
||||
name: "Leuchte",
|
||||
displayName: "Leuchte Flur",
|
||||
phaseType: "single_phase",
|
||||
quantity: 2,
|
||||
powerPerUnit: 0.05,
|
||||
simultaneityFactor: 1,
|
||||
cosPhi: 0.95,
|
||||
overriddenFields: "[\"displayName\"]",
|
||||
rowTotalPower: 0.1,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const snapshot = buildCircuitGroupSubtreeSnapshot(
|
||||
section,
|
||||
"list-1"
|
||||
);
|
||||
|
||||
assert.deepEqual(summarizeCircuitGroupSubtree(snapshot), {
|
||||
circuitCount: 1,
|
||||
deviceRowCount: 1,
|
||||
protectionComponentCount: 1,
|
||||
});
|
||||
assert.equal(
|
||||
snapshot.circuits[0].circuit.deviceRows[0]
|
||||
.linkedProjectDeviceId,
|
||||
"device-1"
|
||||
);
|
||||
assert.equal(
|
||||
snapshot.circuits[0].circuit.deviceRows[0]
|
||||
.overriddenFields,
|
||||
"[\"displayName\"]"
|
||||
);
|
||||
assert.deepEqual(snapshot.circuits[0].protectionDevice, {
|
||||
circuitId: "circuit-1",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("formats the agreed identifiers including the leading hyphen", () => {
|
||||
assert.equal(
|
||||
formatGroupUpstreamProtectionIdentifier("lighting", 1),
|
||||
@@ -110,6 +476,26 @@ describe("circuit group numbering", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function groupSection(
|
||||
id: string,
|
||||
category: "lighting" | "single_phase" | "three_phase",
|
||||
groupNumber: number,
|
||||
sortOrder: number
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
key: id,
|
||||
displayName: id,
|
||||
prefix: `-${category === "lighting" ? 1 : category === "single_phase" ? 2 : 3}F${groupNumber}.`,
|
||||
sortOrder,
|
||||
category,
|
||||
groupNumber,
|
||||
sectionTotalPower: 0,
|
||||
components: [],
|
||||
circuits: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("circuit group renumber planning", () => {
|
||||
const groups = [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
||||
import {
|
||||
createDatabaseContext,
|
||||
type DatabaseContext,
|
||||
} from "../src/db/database-context.js";
|
||||
import { CircuitProtectionProjectCommandRepository } from "../src/db/repositories/circuit-protection-project-command.repository.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitLists } from "../src/db/schema/circuit-lists.js";
|
||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import {
|
||||
createCircuitProtectionUpdateProjectCommand,
|
||||
type CircuitProtectionSnapshot,
|
||||
} from "../src/domain/models/circuit-protection-project-command.model.js";
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
|
||||
function createTestDatabase(): DatabaseContext {
|
||||
const context = createDatabaseContext(":memory:");
|
||||
migrate(context.db, {
|
||||
migrationsFolder: path.resolve("src", "db", "migrations"),
|
||||
});
|
||||
context.db
|
||||
.insert(projects)
|
||||
.values([
|
||||
{ id: "project-1", name: "Projekt" },
|
||||
{ id: "project-2", name: "Fremdprojekt" },
|
||||
])
|
||||
.run();
|
||||
new DistributionBoardFixtureRepository(
|
||||
context.db
|
||||
).createWithCircuitListAndDefaultSections("project-1", "UV-01");
|
||||
const list = context.db.select().from(circuitLists).get();
|
||||
assert.ok(list);
|
||||
const section = context.db
|
||||
.select()
|
||||
.from(circuitSections)
|
||||
.where(eq(circuitSections.circuitListId, list.id))
|
||||
.get();
|
||||
assert.ok(section);
|
||||
context.db
|
||||
.insert(circuits)
|
||||
.values({
|
||||
id: "circuit-1",
|
||||
circuitListId: list.id,
|
||||
sectionId: section.id,
|
||||
equipmentIdentifier: "-1F1.1",
|
||||
sortOrder: 10,
|
||||
})
|
||||
.run();
|
||||
return context;
|
||||
}
|
||||
|
||||
const protection: CircuitProtectionSnapshot = {
|
||||
circuitId: "circuit-1",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
};
|
||||
|
||||
describe("circuit-protection project command", () => {
|
||||
it("inserts, updates and restores protection through exact inverse commands", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository = new CircuitProtectionProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
const history = new ProjectHistoryRepository(context.db);
|
||||
const inserted = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitProtectionUpdateProjectCommand(
|
||||
"circuit-1",
|
||||
null,
|
||||
protection
|
||||
),
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db.select().from(circuitProtectionDevices).get(),
|
||||
protection
|
||||
);
|
||||
const target = { ...protection, ratedCurrentA: 16 };
|
||||
const updated = repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 1,
|
||||
source: "user",
|
||||
command: createCircuitProtectionUpdateProjectCommand(
|
||||
"circuit-1",
|
||||
protection,
|
||||
target
|
||||
),
|
||||
});
|
||||
assert.equal(
|
||||
context.db.select().from(circuitProtectionDevices).get()
|
||||
?.ratedCurrentA,
|
||||
16
|
||||
);
|
||||
const updateUndo = history.getNextCommand("project-1", "undo");
|
||||
assert.ok(updateUndo);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 2,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: updateUndo.changeSetId,
|
||||
command: updated.inverse,
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db.select().from(circuitProtectionDevices).get(),
|
||||
protection
|
||||
);
|
||||
const insertUndo = history.getNextCommand("project-1", "undo");
|
||||
assert.ok(insertUndo);
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 3,
|
||||
source: "undo",
|
||||
historyTargetChangeSetId: insertUndo.changeSetId,
|
||||
command: inserted.inverse,
|
||||
});
|
||||
assert.equal(
|
||||
context.db.select().from(circuitProtectionDevices).get(),
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects stale state, foreign ownership and user removal", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const repository = new CircuitProtectionProjectCommandRepository(
|
||||
context.db
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-2",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitProtectionUpdateProjectCommand(
|
||||
"circuit-1",
|
||||
null,
|
||||
protection
|
||||
),
|
||||
}),
|
||||
/does not belong/
|
||||
);
|
||||
context.db.insert(circuitProtectionDevices).values(protection).run();
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitProtectionUpdateProjectCommand(
|
||||
"circuit-1",
|
||||
null,
|
||||
protection
|
||||
),
|
||||
}),
|
||||
/changed before/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
repository.execute({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
source: "user",
|
||||
command: createCircuitProtectionUpdateProjectCommand(
|
||||
"circuit-1",
|
||||
protection,
|
||||
null
|
||||
),
|
||||
}),
|
||||
/cannot remove/
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -65,12 +65,22 @@ describe("circuit structure command adapters", () => {
|
||||
controlRequirement: "DALI",
|
||||
},
|
||||
deviceRows: [],
|
||||
category: "single_phase",
|
||||
});
|
||||
|
||||
assert.equal(reserve.isReserve, true);
|
||||
assert.equal(reserve.displayName, null);
|
||||
assert.equal(reserve.voltage, 230);
|
||||
assert.equal(reserve.controlRequirement, "DALI");
|
||||
assert.deepEqual(reserve.protectionDevice, {
|
||||
circuitId: "circuit-1",
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
});
|
||||
|
||||
const row = buildCircuitDeviceRowInsertSnapshot({
|
||||
id: "row-1",
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CircuitStructureProjectCommandRepository } from "../src/db/repositories
|
||||
import { DistributionBoardFixtureRepository } from "./support/distribution-board-fixture.js";
|
||||
import { ProjectHistoryRepository } from "../src/db/repositories/project-history.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { projectDevices } from "../src/db/schema/project-devices.js";
|
||||
@@ -165,6 +166,18 @@ function createTestDatabase(): TestFixture {
|
||||
},
|
||||
])
|
||||
.run();
|
||||
context.db
|
||||
.insert(circuitProtectionDevices)
|
||||
.values({
|
||||
circuitId: "circuit-existing",
|
||||
type: "LS",
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
})
|
||||
.run();
|
||||
|
||||
return {
|
||||
context,
|
||||
@@ -377,6 +390,11 @@ describe("circuit structure project-command repository", () => {
|
||||
"circuit-existing"
|
||||
);
|
||||
const beforeRows = getRows(fixture.context, "circuit-existing");
|
||||
const beforeProtection = fixture.context.db
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
|
||||
.get();
|
||||
assert.ok(beforeCircuit);
|
||||
const store = new CircuitStructureProjectCommandRepository(
|
||||
fixture.context.db
|
||||
@@ -395,6 +413,14 @@ describe("circuit structure project-command repository", () => {
|
||||
undefined
|
||||
);
|
||||
assert.equal(getRows(fixture.context, "circuit-existing").length, 0);
|
||||
assert.equal(
|
||||
fixture.context.db
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
|
||||
.get(),
|
||||
undefined
|
||||
);
|
||||
|
||||
store.execute({
|
||||
projectId: "project-1",
|
||||
@@ -411,6 +437,14 @@ describe("circuit structure project-command repository", () => {
|
||||
getRows(fixture.context, "circuit-existing"),
|
||||
beforeRows
|
||||
);
|
||||
assert.deepEqual(
|
||||
fixture.context.db
|
||||
.select()
|
||||
.from(circuitProtectionDevices)
|
||||
.where(eq(circuitProtectionDevices.circuitId, "circuit-existing"))
|
||||
.get(),
|
||||
beforeProtection
|
||||
);
|
||||
} finally {
|
||||
fixture.context.close();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,13 @@ import {
|
||||
distributionBoardComponentRoles,
|
||||
} from "../src/shared/constants/distribution-board-component.js";
|
||||
import { circuitGroupCategories } from "../src/shared/constants/circuit-group.js";
|
||||
import {
|
||||
buildDistributionBoardComponentSnapshot,
|
||||
getNextComponentSortOrder,
|
||||
getSuggestedGroupComponentIdentifier,
|
||||
toDistributionBoardComponentSnapshot,
|
||||
updateDistributionBoardComponentSnapshot,
|
||||
} from "../src/frontend/utils/distribution-board-component-editing.js";
|
||||
import "./distribution-board-component-structure-project-command.repository.test.js";
|
||||
|
||||
describe("distribution board component catalog", () => {
|
||||
@@ -47,4 +54,92 @@ describe("distribution board component catalog", () => {
|
||||
placement: "header",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds exact editable component snapshots for persistent commands", () => {
|
||||
const groupRcd = buildDistributionBoardComponentSnapshot({
|
||||
id: "component-1",
|
||||
circuitListId: "list-1",
|
||||
role: "group_residual_current_protection",
|
||||
sectionId: "section-1",
|
||||
sortOrder: 20,
|
||||
values: {
|
||||
equipmentIdentifier: " -1Q1.0 ",
|
||||
name: " Gruppen-FI ",
|
||||
protectionDevice: {
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(groupRcd, {
|
||||
component: {
|
||||
id: "component-1",
|
||||
circuitListId: "list-1",
|
||||
sectionId: "section-1",
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "Gruppen-FI",
|
||||
role: "group_residual_current_protection",
|
||||
placement: "group",
|
||||
sortOrder: 20,
|
||||
},
|
||||
protectionDevice: {
|
||||
componentId: "component-1",
|
||||
type: "FI",
|
||||
ratedCurrentA: 40,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: null,
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
updateDistributionBoardComponentSnapshot(groupRcd, {
|
||||
equipmentIdentifier: "-1Q1.0",
|
||||
name: "FI Beleuchtung",
|
||||
protectionDevice: {
|
||||
type: "FI",
|
||||
ratedCurrentA: 63,
|
||||
rcdType: "B+",
|
||||
ratedResidualCurrentMa: 300,
|
||||
},
|
||||
}).component,
|
||||
{ ...groupRcd.component, name: "FI Beleuchtung" }
|
||||
);
|
||||
});
|
||||
|
||||
it("maps tree DTOs and derives stable group suggestions and sort positions", () => {
|
||||
const snapshot = toDistributionBoardComponentSnapshot({
|
||||
id: "actor-1",
|
||||
circuitListId: "list-1",
|
||||
equipmentIdentifier: "-K1",
|
||||
name: "KNX Aktor",
|
||||
role: "auxiliary",
|
||||
placement: "footer",
|
||||
sortOrder: 10,
|
||||
});
|
||||
assert.equal(snapshot.component.sectionId, null);
|
||||
assert.equal(snapshot.protectionDevice, null);
|
||||
assert.equal(getNextComponentSortOrder([{ sortOrder: 10 }, { sortOrder: 40 }]), 50);
|
||||
assert.equal(
|
||||
getSuggestedGroupComponentIdentifier(
|
||||
{
|
||||
id: "section-1",
|
||||
key: "lighting",
|
||||
displayName: "Beleuchtung 2",
|
||||
prefix: "-1F2",
|
||||
sortOrder: 10,
|
||||
category: "lighting",
|
||||
groupNumber: 2,
|
||||
sectionTotalPower: 0,
|
||||
components: [],
|
||||
circuits: [],
|
||||
},
|
||||
"group_upstream_protection"
|
||||
),
|
||||
"-1F2.0"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-
|
||||
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||
import { CircuitProtectionProjectCommandRepository } from "../src/db/repositories/circuit-protection-project-command.repository.js";
|
||||
import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js";
|
||||
import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js";
|
||||
import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js";
|
||||
@@ -29,6 +30,7 @@ import { ProjectDeviceStructureProjectCommandRepository } from "../src/db/reposi
|
||||
import { ProjectStateRestoreCommandRepository } from "../src/db/repositories/project-state-restore-command.repository.js";
|
||||
import { ProjectSettingsProjectCommandRepository } from "../src/db/repositories/project-settings-project-command.repository.js";
|
||||
import { circuitDeviceRows } from "../src/db/schema/circuit-device-rows.js";
|
||||
import { circuitProtectionDevices } from "../src/db/schema/circuit-protection-devices.js";
|
||||
import { circuitSections } from "../src/db/schema/circuit-sections.js";
|
||||
import { circuits } from "../src/db/schema/circuits.js";
|
||||
import { distributionBoardComponents } from "../src/db/schema/distribution-board-components.js";
|
||||
@@ -38,6 +40,7 @@ import { projectRevisions } from "../src/db/schema/project-revisions.js";
|
||||
import { projects } from "../src/db/schema/projects.js";
|
||||
import { floors } from "../src/db/schema/floors.js";
|
||||
import { rooms } from "../src/db/schema/rooms.js";
|
||||
import { createCircuitProtectionUpdateProjectCommand } from "../src/domain/models/circuit-protection-project-command.model.js";
|
||||
import { ProjectHistoryOperationUnavailableError } from "../src/domain/errors/project-history-operation-unavailable.error.js";
|
||||
import { ProjectRevisionConflictError } from "../src/domain/errors/project-revision-conflict.error.js";
|
||||
import { createCircuitDeviceRowUpdateProjectCommand } from "../src/domain/models/circuit-device-row-project-command.model.js";
|
||||
@@ -149,6 +152,7 @@ function createService(context: DatabaseContext) {
|
||||
new CircuitGroupRenumberProjectCommandRepository(context.db),
|
||||
new CircuitGroupMoveProjectCommandRepository(context.db),
|
||||
new CircuitGroupSubtreeProjectCommandRepository(context.db),
|
||||
new CircuitProtectionProjectCommandRepository(context.db),
|
||||
new ProjectHistoryRepository(context.db)
|
||||
);
|
||||
}
|
||||
@@ -170,6 +174,47 @@ function getRowQuantity(context: DatabaseContext) {
|
||||
}
|
||||
|
||||
describe("project command service", () => {
|
||||
it("dispatches circuit-protection updates through project history", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
const service = createService(context);
|
||||
const protection = {
|
||||
circuitId: "circuit-1",
|
||||
type: "LS" as const,
|
||||
ratedCurrentA: 10,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B" as const,
|
||||
rcdType: null,
|
||||
ratedResidualCurrentMa: null,
|
||||
};
|
||||
service.executeUser({
|
||||
projectId: "project-1",
|
||||
expectedRevision: 0,
|
||||
command: createCircuitProtectionUpdateProjectCommand(
|
||||
"circuit-1",
|
||||
null,
|
||||
protection
|
||||
),
|
||||
});
|
||||
assert.deepEqual(
|
||||
context.db.select().from(circuitProtectionDevices).get(),
|
||||
protection
|
||||
);
|
||||
service.undo({ projectId: "project-1", expectedRevision: 1 });
|
||||
assert.equal(
|
||||
context.db.select().from(circuitProtectionDevices).get(),
|
||||
undefined
|
||||
);
|
||||
service.redo({ projectId: "project-1", expectedRevision: 2 });
|
||||
assert.deepEqual(
|
||||
context.db.select().from(circuitProtectionDevices).get(),
|
||||
protection
|
||||
);
|
||||
} finally {
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("dispatches supported commands and persists undo/redo across service instances", () => {
|
||||
const context = createTestDatabase();
|
||||
try {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { CircuitProjectCommandRepository } from "../src/db/repositories/circuit-
|
||||
import { CircuitSectionRenumberProjectCommandRepository } from "../src/db/repositories/circuit-section-renumber-project-command.repository.js";
|
||||
import { CircuitSectionReorderProjectCommandRepository } from "../src/db/repositories/circuit-section-reorder-project-command.repository.js";
|
||||
import { CircuitStructureProjectCommandRepository } from "../src/db/repositories/circuit-structure-project-command.repository.js";
|
||||
import { CircuitProtectionProjectCommandRepository } from "../src/db/repositories/circuit-protection-project-command.repository.js";
|
||||
import { CircuitGroupStructureProjectCommandRepository } from "../src/db/repositories/circuit-group-structure-project-command.repository.js";
|
||||
import { CircuitGroupRenumberProjectCommandRepository } from "../src/db/repositories/circuit-group-renumber-project-command.repository.js";
|
||||
import { CircuitGroupMoveProjectCommandRepository } from "../src/db/repositories/circuit-group-move-project-command.repository.js";
|
||||
@@ -203,6 +204,7 @@ function createService(context: DatabaseContext) {
|
||||
new CircuitGroupRenumberProjectCommandRepository(context.db),
|
||||
new CircuitGroupMoveProjectCommandRepository(context.db),
|
||||
new CircuitGroupSubtreeProjectCommandRepository(context.db),
|
||||
new CircuitProtectionProjectCommandRepository(context.db),
|
||||
new ProjectHistoryRepository(context.db)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
createDefaultGroupRcd,
|
||||
} from "../src/domain/services/protection-device-defaults.js";
|
||||
import { protectionDeviceConfigurationSchema } from "../src/shared/validation/protection-device.schemas.js";
|
||||
import "./circuit-protection-project-command.repository.test.js";
|
||||
import {
|
||||
getCircuitProtectionEditorInitialValue,
|
||||
toCircuitProtectionSnapshot,
|
||||
} from "../src/frontend/utils/circuit-protection-editing.js";
|
||||
|
||||
describe("protection device catalog", () => {
|
||||
it("contains exactly the agreed protection device types", () => {
|
||||
@@ -174,6 +179,32 @@ describe("protection device validation", () => {
|
||||
});
|
||||
|
||||
describe("protection device defaults", () => {
|
||||
it("maps editor values to complete circuit-owned snapshots", () => {
|
||||
const initial = getCircuitProtectionEditorInitialValue(
|
||||
"single_phase",
|
||||
undefined
|
||||
);
|
||||
assert.deepEqual(initial, {
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
});
|
||||
assert.deepEqual(
|
||||
toCircuitProtectionSnapshot("circuit-1", initial),
|
||||
{
|
||||
circuitId: "circuit-1",
|
||||
type: "FI_LS",
|
||||
ratedCurrentA: 16,
|
||||
fuseUtilizationCategory: null,
|
||||
tripCharacteristic: "B",
|
||||
rcdType: "A",
|
||||
ratedResidualCurrentMa: 30,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the agreed circuit defaults", () => {
|
||||
assert.deepEqual(createDefaultCircuitProtection("lighting"), {
|
||||
type: "LS",
|
||||
|
||||
Reference in New Issue
Block a user