14 Commits

Author SHA1 Message Date
jappel f4bfef9071 Merge branch 'main' into feature/multiplesections 2026-07-31 09:00:39 +02:00
jappel 4a859f31b8 Complete protected circuit group phase 2026-07-31 08:59:12 +02:00
jappel 2d97fafce9 Restore circuit drag placement 2026-07-31 08:33:41 +02:00
jappel 7c8d4a7ddf Document protected circuit groups 2026-07-31 08:06:10 +02:00
jappel ab989cc637 Delete populated circuit groups safely 2026-07-31 08:01:55 +02:00
jappel 93ec294759 Move circuits between groups 2026-07-31 07:58:42 +02:00
jappel be9c6f0d52 Renumber circuit groups explicitly 2026-07-31 07:53:07 +02:00
jappel 0cb280ddb2 Reorder circuit groups 2026-07-31 07:47:45 +02:00
jappel 8898117ed4 Configure circuit protection 2026-07-31 07:45:48 +02:00
jappel 18ca5eb3d4 Persist circuit protection updates 2026-07-31 07:37:38 +02:00
jappel 0c92fbd09e Manage circuit groups 2026-07-31 07:32:46 +02:00
jappel 756e307bd8 Manage distribution board components 2026-07-31 07:29:25 +02:00
jappel 31589875b5 Render protected circuit structure 2026-07-31 07:22:26 +02:00
Grovy311 704f4df2a8 Apply Petrol/Volt/Kupfer design system and project sidebar
Bring the Econsult-family design tokens (see design-system-econsult.md)
into leistungsbilanz-ts and add a persistent sidebar for the projects
area, mirroring elt-planung-suite / Einsatz-Orga. The sidebar lists all
projects and, inside a project, jumps to its sections; the tree editor
stays sidebar-free since it needs the full width. The projects overview
page is decluttered to a single stacked column (create/import/global
devices) now that the project list lives in the sidebar.
2026-07-30 17:40:11 +02:00
48 changed files with 4744 additions and 259 deletions
+6 -1
View File
@@ -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
```
+35 -10
View File
@@ -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`.
+18 -10
View File
@@ -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.
+37 -7
View File
@@ -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.
+62 -7
View File
@@ -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
+8 -2
View File
@@ -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.
+13 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
+27 -7
View File
@@ -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
View File
@@ -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({
+22
View File
@@ -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>
);
}
+121
View File
@@ -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- &amp; 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";
}
+212
View File
@@ -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;
}
+63 -9
View File
@@ -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") {
+61 -15
View File
@@ -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;
+532
View File
@@ -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"
+30
View File
@@ -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/
);
});
});
+25
View File
@@ -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");
+61
View File
@@ -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" },
+386
View File
@@ -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();
}
});
});
+10
View File
@@ -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"
);
});
});
+45
View File
@@ -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)
);
}
+31
View File
@@ -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",