Started multiline manipulations
This commit is contained in:
@@ -1,68 +0,0 @@
|
|||||||
# Anforderungs-Abgleich (Dokumentation vs. Code)
|
|
||||||
|
|
||||||
Dieses Dokument ergänzt die Hauptdokumentation und listet transparent auf:
|
|
||||||
|
|
||||||
1. Was laut Anforderungen noch nicht vollständig umgesetzt ist.
|
|
||||||
2. Was bereits im Code existiert, aber im ursprünglichen Requirements-Dokument nicht oder nur als offener Punkt erwähnt war.
|
|
||||||
|
|
||||||
Basisvergleich:
|
|
||||||
- Anforderungen: `docs/electrical-load-balance-requirements-context-dump.md`
|
|
||||||
- Ist-Stand: aktueller Code in `src/`
|
|
||||||
|
|
||||||
## A) Anforderungen, die noch nicht vollständig umgesetzt sind
|
|
||||||
|
|
||||||
### 1) Bericht/Export/Print für Leistungsbilanzen
|
|
||||||
- Erwartung: später nutzbarer Report-Export/Print.
|
|
||||||
- Stand: Noch nicht umgesetzt.
|
|
||||||
- Wirkung: Der Fokus liegt aktuell auf Erfassung, Bearbeitung und Berechnung im Web-UI.
|
|
||||||
|
|
||||||
### 2) „Keine Pflichtfelder“ vollständig im UI-Flow
|
|
||||||
- Erwartung: Einträge sollen auch ohne zwingende Felder erfassbar sein.
|
|
||||||
- Stand: Backend-seitig weitgehend erfüllt (optionale Felder + Defaults), UI-Schnellerfassung verlangt derzeit einen Namen.
|
|
||||||
- Wirkung: Fachlich ist unvollständige Speicherung möglich, aber die Standard-UI bremst diesen speziellen Fall noch.
|
|
||||||
|
|
||||||
### 3) Einheitliche Textqualität (Mojibake-frei) im gesamten Frontend
|
|
||||||
- Erwartung: saubere Umlaute/UTF-8.
|
|
||||||
- Stand: In mehreren UI-Dateien sind weiterhin fehlerhafte Kodierungsreste vorhanden (z. B. „Gerät“).
|
|
||||||
- Wirkung: Funktionalität ist gegeben, UX/Textqualität ist noch nachzuziehen.
|
|
||||||
|
|
||||||
## B) Bereits umgesetzt, aber im ursprünglichen Requirements-Dokument nicht klar ausformuliert
|
|
||||||
|
|
||||||
### 1) Feste Domänen-Auswahllisten sind final definiert
|
|
||||||
- Im Requirements-Dokument war das als „noch festzulegen“ markiert.
|
|
||||||
- Im Code jetzt vorhanden:
|
|
||||||
- `src/shared/constants/consumer-option-lists.ts`
|
|
||||||
- Validierung in `src/shared/validation/consumer.schemas.ts`
|
|
||||||
- Select-Felder in `src/app/projects/[projectId]/circuit-lists/page.tsx`
|
|
||||||
|
|
||||||
### 2) Sortieren, Filtern und Bulk-Edit sind bereits implementiert
|
|
||||||
- Im Requirements-Dokument als mögliche Zusatzfunktion erwähnt.
|
|
||||||
- Im Code umgesetzt in `src/app/projects/[projectId]/circuit-lists/page.tsx`.
|
|
||||||
|
|
||||||
### 3) Projekt-Spannungsstandards (1-phasig / 3-phasig) inkl. Berechnungsintegration
|
|
||||||
- Im Requirements-Dokument nicht so detailliert als Projekt-Feature beschrieben.
|
|
||||||
- Im Code umgesetzt:
|
|
||||||
- Projekteinstellungen im Projekt-Detail
|
|
||||||
- Verwendung in Berechnungslogik (effektive Spannung je Verbraucher)
|
|
||||||
|
|
||||||
### 4) 1–3 parallele Stromkreislisten mit responsiver Aufteilung
|
|
||||||
- Im Requirements-Dokument nicht als konkrete UI-Regel spezifiziert.
|
|
||||||
- Im Code umgesetzt in `src/app/projects/[projectId]/circuit-lists/page.tsx`.
|
|
||||||
|
|
||||||
### 5) Gerätekopie global ↔ projektbezogen in beide Richtungen
|
|
||||||
- Fachlich erwähnt, aber technisch konkretisiert im API-Design:
|
|
||||||
- `POST /api/project-devices/projects/:projectId/import-global/:globalDeviceId`
|
|
||||||
- `POST /api/global-devices/import-project/:projectId/:projectDeviceId`
|
|
||||||
|
|
||||||
## C) Offene fachliche Entscheidungen mit bereits getroffener Code-Interpretation
|
|
||||||
|
|
||||||
### 1) Verhalten beim Kopieren von Stromkreiseinträgen zwischen Listen
|
|
||||||
- Offener Punkt in den Anforderungen: Link beibehalten oder trennen?
|
|
||||||
- Aktuelle Code-Interpretation: Link-Information wird mitkopiert (`projectDeviceId`, `isLinkedToDevice`).
|
|
||||||
- Relevanz: Sollte fachlich explizit bestätigt werden, damit spätere Änderungen keine Regression verursachen.
|
|
||||||
|
|
||||||
## D) Empfehlung für nächste Dokumentationsrunde
|
|
||||||
|
|
||||||
1. Das Requirements-Dokument um die inzwischen finalen Auswahllistenwerte ergänzen.
|
|
||||||
2. Die Kopierlogik (Link beibehalten/trennen) fachlich eindeutig festschreiben.
|
|
||||||
3. Einen eigenen Abschnitt für bekannte UX-/Textqualitätspunkte (Kodierung/Umlaute) ergänzen.
|
|
||||||
@@ -1,666 +0,0 @@
|
|||||||
# Context Dump – Requirements for Electrical Load Balance and Circuit List Web App
|
|
||||||
|
|
||||||
## 1. Purpose
|
|
||||||
|
|
||||||
The application shall be a web app for creating electrical load balances and combined load balance / circuit lists for electrical planning.
|
|
||||||
|
|
||||||
The application shall primarily work in a tabular way.
|
|
||||||
|
|
||||||
## 2. Basic Structure
|
|
||||||
|
|
||||||
The application manages projects.
|
|
||||||
|
|
||||||
A project contains:
|
|
||||||
|
|
||||||
- Multiple distribution boards
|
|
||||||
- One project-specific device list
|
|
||||||
|
|
||||||
Each distribution board contains:
|
|
||||||
|
|
||||||
- Exactly one circuit list
|
|
||||||
|
|
||||||
Additionally, there is:
|
|
||||||
|
|
||||||
- One global device list
|
|
||||||
- One project-specific device list per project
|
|
||||||
|
|
||||||
Basic hierarchy:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Project
|
|
||||||
├── ProjectDeviceList
|
|
||||||
└── DistributionBoard
|
|
||||||
└── CircuitList
|
|
||||||
└── CircuitEntries
|
|
||||||
|
|
||||||
Global
|
|
||||||
└── GlobalDeviceList
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Projects
|
|
||||||
|
|
||||||
A project is the top-level domain object.
|
|
||||||
|
|
||||||
A project can contain multiple distribution boards.
|
|
||||||
|
|
||||||
A project has one project-specific device list.
|
|
||||||
|
|
||||||
Devices from the project-specific device list can be used in the circuit lists of the distribution boards within that project.
|
|
||||||
|
|
||||||
### Project Attributes
|
|
||||||
|
|
||||||
```text
|
|
||||||
Project
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- distributionBoards: List<DistributionBoard>
|
|
||||||
- projectDeviceList: DeviceList
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Distribution Boards
|
|
||||||
|
|
||||||
A distribution board belongs to exactly one project.
|
|
||||||
|
|
||||||
A distribution board has exactly one circuit list.
|
|
||||||
|
|
||||||
A project can contain multiple distribution boards.
|
|
||||||
|
|
||||||
Each circuit list is therefore assigned to one distribution board.
|
|
||||||
|
|
||||||
### DistributionBoard Attributes
|
|
||||||
|
|
||||||
```text
|
|
||||||
DistributionBoard
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- circuitList: CircuitList
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Circuit Lists
|
|
||||||
|
|
||||||
A circuit list belongs to one distribution board.
|
|
||||||
|
|
||||||
The circuit list is table-based.
|
|
||||||
|
|
||||||
One row in the table is one circuit entry.
|
|
||||||
|
|
||||||
Multiple rows may reference the same circuit number.
|
|
||||||
|
|
||||||
This is required so that multiple different devices can be assigned to the same circuit.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Circuit 1 | Sockets | 6 pcs
|
|
||||||
Circuit 1 | Lights | 3 pcs
|
|
||||||
Circuit 1 | Fixed load | 1 pc
|
|
||||||
```
|
|
||||||
|
|
||||||
This means that one circuit can be represented by multiple table rows.
|
|
||||||
|
|
||||||
### CircuitList Attributes
|
|
||||||
|
|
||||||
```text
|
|
||||||
CircuitList
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- entries: List<CircuitEntry>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Circuit Entries
|
|
||||||
|
|
||||||
A circuit entry is one row in the circuit list.
|
|
||||||
|
|
||||||
A circuit entry can:
|
|
||||||
|
|
||||||
- Be linked to a device from the project-specific device list
|
|
||||||
- Exist without a device
|
|
||||||
- Exist without a device reference
|
|
||||||
- Be detached from the originally linked device
|
|
||||||
- Have its own description
|
|
||||||
- Have its own values
|
|
||||||
|
|
||||||
A circuit entry may be incomplete.
|
|
||||||
|
|
||||||
In principle, there are no required fields.
|
|
||||||
|
|
||||||
### CircuitEntry Attributes
|
|
||||||
|
|
||||||
```text
|
|
||||||
CircuitEntry
|
|
||||||
- id
|
|
||||||
- circuitNumber
|
|
||||||
- description
|
|
||||||
- roomNumber
|
|
||||||
- roomName
|
|
||||||
- device
|
|
||||||
- isLinkedToDevice
|
|
||||||
- quantity
|
|
||||||
- unitPower
|
|
||||||
- simultaneityFactor
|
|
||||||
- cosPhi
|
|
||||||
- totalPower
|
|
||||||
- deviceType
|
|
||||||
- phaseType
|
|
||||||
- tradeOrCostGroup
|
|
||||||
- group
|
|
||||||
- protectionType
|
|
||||||
- protectionRatedCurrent
|
|
||||||
- protectionCharacteristic
|
|
||||||
- cableType
|
|
||||||
- cableCrossSection
|
|
||||||
- comment
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Circuit Entry Description
|
|
||||||
|
|
||||||
The `description` of a circuit entry is independent from:
|
|
||||||
|
|
||||||
- The device name
|
|
||||||
- The device display name
|
|
||||||
- The device link
|
|
||||||
|
|
||||||
Even if a circuit entry is linked to a device, the circuit entry description can always be overwritten manually.
|
|
||||||
|
|
||||||
There are therefore three different naming fields:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device.name
|
|
||||||
Device.displayName
|
|
||||||
CircuitEntry.description
|
|
||||||
```
|
|
||||||
|
|
||||||
### Meaning
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device.name
|
|
||||||
```
|
|
||||||
|
|
||||||
Internal or unique name of the device.
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device.displayName
|
|
||||||
```
|
|
||||||
|
|
||||||
Default visible name of the device.
|
|
||||||
|
|
||||||
```text
|
|
||||||
CircuitEntry.description
|
|
||||||
```
|
|
||||||
|
|
||||||
Concrete description in the circuit list. This can be manually overwritten.
|
|
||||||
|
|
||||||
## 8. Devices
|
|
||||||
|
|
||||||
The application shall support configurable devices.
|
|
||||||
|
|
||||||
A device can represent either:
|
|
||||||
|
|
||||||
- A single device
|
|
||||||
- A collection/group of devices
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- Single pendant light
|
|
||||||
- Socket group with six sockets
|
|
||||||
- Workplace connection group
|
|
||||||
- Fixed load
|
|
||||||
|
|
||||||
### Device Attributes
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- displayName
|
|
||||||
- deviceType
|
|
||||||
- phaseType
|
|
||||||
- quantity
|
|
||||||
- unitPower
|
|
||||||
- cosPhi
|
|
||||||
- tradeOrCostGroup
|
|
||||||
```
|
|
||||||
|
|
||||||
### Attribute Meaning
|
|
||||||
|
|
||||||
#### `name`
|
|
||||||
|
|
||||||
Internal or unique name.
|
|
||||||
|
|
||||||
Used to distinguish similar devices.
|
|
||||||
|
|
||||||
#### `displayName`
|
|
||||||
|
|
||||||
Default visible name.
|
|
||||||
|
|
||||||
Multiple devices may have the same display name.
|
|
||||||
|
|
||||||
#### `deviceType`
|
|
||||||
|
|
||||||
The type of device.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- Light
|
|
||||||
- Socket
|
|
||||||
- Fixed connection
|
|
||||||
- Generic
|
|
||||||
- Other values to be defined later
|
|
||||||
|
|
||||||
#### `phaseType`
|
|
||||||
|
|
||||||
Defines whether the device is single-phase or three-phase.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
- Single-phase
|
|
||||||
- Three-phase
|
|
||||||
|
|
||||||
#### `quantity`
|
|
||||||
|
|
||||||
Default quantity of the device.
|
|
||||||
|
|
||||||
This allows a device to be used either as a single device or as a collection.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device: Socket group 6x
|
|
||||||
quantity: 6
|
|
||||||
unitPower: 100 W
|
|
||||||
```
|
|
||||||
|
|
||||||
#### `unitPower`
|
|
||||||
|
|
||||||
Power of one individual device or element.
|
|
||||||
|
|
||||||
#### `cosPhi`
|
|
||||||
|
|
||||||
Power factor.
|
|
||||||
|
|
||||||
#### `tradeOrCostGroup`
|
|
||||||
|
|
||||||
Assignment to a trade or cost group.
|
|
||||||
|
|
||||||
## 9. Device Name and Display Name
|
|
||||||
|
|
||||||
A device needs both a `name` and a `displayName`.
|
|
||||||
|
|
||||||
Reason:
|
|
||||||
|
|
||||||
Two devices may look the same in the circuit list but still be internally different.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device 1:
|
|
||||||
name: Pendant light high occupancy
|
|
||||||
displayName: Pendant light
|
|
||||||
|
|
||||||
Device 2:
|
|
||||||
name: Pendant light low occupancy
|
|
||||||
displayName: Pendant light
|
|
||||||
```
|
|
||||||
|
|
||||||
This allows internally different devices to be displayed with the same name in the circuit list.
|
|
||||||
|
|
||||||
## 10. Device Lists
|
|
||||||
|
|
||||||
There are two types of device lists:
|
|
||||||
|
|
||||||
1. Global device list
|
|
||||||
2. Project-specific device list
|
|
||||||
|
|
||||||
### Global Device List
|
|
||||||
|
|
||||||
The global device list contains generally reusable devices.
|
|
||||||
|
|
||||||
It is intended to be used across projects.
|
|
||||||
|
|
||||||
### Project-Specific Device List
|
|
||||||
|
|
||||||
The project-specific device list belongs to a specific project.
|
|
||||||
|
|
||||||
It contains devices that can be used within that project.
|
|
||||||
|
|
||||||
Devices from this list can be selected in the circuit lists of the project's distribution boards.
|
|
||||||
|
|
||||||
### DeviceList Attributes
|
|
||||||
|
|
||||||
```text
|
|
||||||
DeviceList
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- type
|
|
||||||
- devices: List<Device>
|
|
||||||
```
|
|
||||||
|
|
||||||
Possible values for `type`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
global
|
|
||||||
project
|
|
||||||
```
|
|
||||||
|
|
||||||
## 11. Copying Devices Between Device Lists
|
|
||||||
|
|
||||||
Devices shall be copyable between the global device list and project-specific device lists.
|
|
||||||
|
|
||||||
A device can be copied:
|
|
||||||
|
|
||||||
- From the global device list to a project-specific device list
|
|
||||||
- From a project-specific device list to the global device list
|
|
||||||
|
|
||||||
Copying means:
|
|
||||||
|
|
||||||
- A separate device is created in the target list
|
|
||||||
- Permanent synchronization between the original device and the copied device is not defined as a requirement
|
|
||||||
|
|
||||||
## 12. Device Link Between Device and Circuit Entry
|
|
||||||
|
|
||||||
A circuit entry can be linked to a device from the project-specific device list.
|
|
||||||
|
|
||||||
When the link is active, device values can be used in the circuit entry.
|
|
||||||
|
|
||||||
If the underlying device is changed, all still-linked circuit entries shall be updated.
|
|
||||||
|
|
||||||
The link can be enabled or disabled per circuit entry.
|
|
||||||
|
|
||||||
If a circuit entry is detached from the device:
|
|
||||||
|
|
||||||
- The circuit entry remains in place
|
|
||||||
- The circuit entry keeps its own values
|
|
||||||
- Future changes to the device no longer affect that circuit entry
|
|
||||||
|
|
||||||
A circuit entry may also exist without a device and without a device reference.
|
|
||||||
|
|
||||||
## 13. Quantity
|
|
||||||
|
|
||||||
There is a quantity on the device and a quantity on the circuit entry.
|
|
||||||
|
|
||||||
### Device Quantity
|
|
||||||
|
|
||||||
The device quantity describes the default scope of a device.
|
|
||||||
|
|
||||||
It allows devices to represent either single devices or collections.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device: Socket group 6x
|
|
||||||
quantity: 6
|
|
||||||
unitPower: 100 W
|
|
||||||
```
|
|
||||||
|
|
||||||
### Circuit Entry Quantity
|
|
||||||
|
|
||||||
The circuit entry quantity describes the concrete quantity used in the individual circuit entry.
|
|
||||||
|
|
||||||
It can be prefilled from the device quantity when a circuit entry is created from a device.
|
|
||||||
|
|
||||||
It can be changed in the circuit entry.
|
|
||||||
|
|
||||||
### Add Count
|
|
||||||
|
|
||||||
When adding a device to a circuit list, there shall be a separate value defining how many circuit entries shall be created.
|
|
||||||
|
|
||||||
Important distinction:
|
|
||||||
|
|
||||||
```text
|
|
||||||
addCount != quantity
|
|
||||||
```
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Device: Workplace socket
|
|
||||||
Add count: 5
|
|
||||||
Quantity per entry: 6
|
|
||||||
```
|
|
||||||
|
|
||||||
Result:
|
|
||||||
|
|
||||||
```text
|
|
||||||
5 circuit entries
|
|
||||||
Each circuit entry has quantity = 6
|
|
||||||
```
|
|
||||||
|
|
||||||
## 14. Total Power
|
|
||||||
|
|
||||||
The total power shall be calculated automatically.
|
|
||||||
|
|
||||||
The total power is an attribute of the circuit entry.
|
|
||||||
|
|
||||||
The calculation is based at least on:
|
|
||||||
|
|
||||||
- quantity
|
|
||||||
- unitPower
|
|
||||||
- simultaneityFactor
|
|
||||||
|
|
||||||
The exact calculation logic can be specified later.
|
|
||||||
|
|
||||||
## 15. Rooms
|
|
||||||
|
|
||||||
Rooms are represented by two fields:
|
|
||||||
|
|
||||||
```text
|
|
||||||
roomNumber
|
|
||||||
roomName
|
|
||||||
```
|
|
||||||
|
|
||||||
## 16. Trade / Cost Group and Group
|
|
||||||
|
|
||||||
Entries shall be groupable by domain-specific criteria.
|
|
||||||
|
|
||||||
The planned fields are:
|
|
||||||
|
|
||||||
```text
|
|
||||||
tradeOrCostGroup
|
|
||||||
group
|
|
||||||
```
|
|
||||||
|
|
||||||
These fields shall help treat similar elements together.
|
|
||||||
|
|
||||||
## 17. Table Functions
|
|
||||||
|
|
||||||
The circuit list shall support common table operations.
|
|
||||||
|
|
||||||
Defined operations are:
|
|
||||||
|
|
||||||
- Create entry
|
|
||||||
- Edit entry
|
|
||||||
- Delete entry
|
|
||||||
- Duplicate entry
|
|
||||||
- Copy entry to another circuit list
|
|
||||||
- Add device as entry
|
|
||||||
- Add device multiple times as entries
|
|
||||||
- Set quantity within an entry
|
|
||||||
|
|
||||||
General requirement:
|
|
||||||
|
|
||||||
- All common table manipulations shall be possible
|
|
||||||
|
|
||||||
Additional table functions may be specified later, for example:
|
|
||||||
|
|
||||||
- Sorting
|
|
||||||
- Filtering
|
|
||||||
- Reordering
|
|
||||||
- Multi-selection
|
|
||||||
- Bulk editing
|
|
||||||
|
|
||||||
## 18. Required Fields
|
|
||||||
|
|
||||||
In principle, there shall be no required fields.
|
|
||||||
|
|
||||||
This means:
|
|
||||||
|
|
||||||
- Entries may be incomplete
|
|
||||||
- A circuit entry may exist without a device
|
|
||||||
- A circuit entry may exist without a device reference
|
|
||||||
- Values can be completed later
|
|
||||||
|
|
||||||
## 19. Selection Lists
|
|
||||||
|
|
||||||
The application shall support fixed selection lists.
|
|
||||||
|
|
||||||
The exact selection lists will be defined later.
|
|
||||||
|
|
||||||
Current possible candidates based on the requirements are:
|
|
||||||
|
|
||||||
- deviceType
|
|
||||||
- phaseType
|
|
||||||
- tradeOrCostGroup
|
|
||||||
- group
|
|
||||||
- protectionType
|
|
||||||
- protectionCharacteristic
|
|
||||||
- cableType
|
|
||||||
- cableCrossSection
|
|
||||||
|
|
||||||
These lists are not yet finally specified.
|
|
||||||
|
|
||||||
## 20. Copying and Duplicating
|
|
||||||
|
|
||||||
Circuit entries shall be duplicatable.
|
|
||||||
|
|
||||||
Circuit entries shall be copyable to other circuit lists.
|
|
||||||
|
|
||||||
Devices shall be copyable between the global device list and project-specific device lists.
|
|
||||||
|
|
||||||
When adding a device to a circuit list, the user shall be able to define how many circuit entries shall be created from it.
|
|
||||||
|
|
||||||
Open point:
|
|
||||||
|
|
||||||
- Whether a device link is kept or detached when copying a circuit entry to another circuit list is not yet specified.
|
|
||||||
|
|
||||||
## 21. Simplified Class Model
|
|
||||||
|
|
||||||
The model shall use multiple classes but shall not be overly fragmented.
|
|
||||||
|
|
||||||
Current final class model:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Project
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- distributionBoards: List<DistributionBoard>
|
|
||||||
- projectDeviceList: DeviceList
|
|
||||||
|
|
||||||
DistributionBoard
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- circuitList: CircuitList
|
|
||||||
|
|
||||||
CircuitList
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- entries: List<CircuitEntry>
|
|
||||||
|
|
||||||
CircuitEntry
|
|
||||||
- id
|
|
||||||
- circuitNumber
|
|
||||||
- description
|
|
||||||
- roomNumber
|
|
||||||
- roomName
|
|
||||||
- device: Device
|
|
||||||
- isLinkedToDevice
|
|
||||||
- quantity
|
|
||||||
- unitPower
|
|
||||||
- simultaneityFactor
|
|
||||||
- cosPhi
|
|
||||||
- totalPower
|
|
||||||
- deviceType
|
|
||||||
- phaseType
|
|
||||||
- tradeOrCostGroup
|
|
||||||
- group
|
|
||||||
- protectionType
|
|
||||||
- protectionRatedCurrent
|
|
||||||
- protectionCharacteristic
|
|
||||||
- cableType
|
|
||||||
- cableCrossSection
|
|
||||||
- comment
|
|
||||||
|
|
||||||
Device
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- displayName
|
|
||||||
- deviceType
|
|
||||||
- phaseType
|
|
||||||
- quantity
|
|
||||||
- unitPower
|
|
||||||
- cosPhi
|
|
||||||
- tradeOrCostGroup
|
|
||||||
|
|
||||||
DeviceList
|
|
||||||
- id
|
|
||||||
- name
|
|
||||||
- type
|
|
||||||
- devices: List<Device>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 22. Central Domain Rules
|
|
||||||
|
|
||||||
1. A project contains multiple distribution boards.
|
|
||||||
|
|
||||||
2. Each distribution board contains exactly one circuit list.
|
|
||||||
|
|
||||||
3. A circuit list consists of circuit entries.
|
|
||||||
|
|
||||||
4. One row in the circuit list is one circuit entry.
|
|
||||||
|
|
||||||
5. Multiple circuit entries may have the same circuit number.
|
|
||||||
|
|
||||||
6. This allows multiple different devices to be assigned to one circuit.
|
|
||||||
|
|
||||||
7. A circuit entry can be linked to a device.
|
|
||||||
|
|
||||||
8. A circuit entry can exist without a device.
|
|
||||||
|
|
||||||
9. A circuit entry can exist without a device reference.
|
|
||||||
|
|
||||||
10. The link between a circuit entry and a device can be detached per entry.
|
|
||||||
|
|
||||||
11. If a device is changed, all still-linked circuit entries are updated.
|
|
||||||
|
|
||||||
12. Detached circuit entries are no longer changed by later device changes.
|
|
||||||
|
|
||||||
13. A device has a default quantity.
|
|
||||||
|
|
||||||
14. A circuit entry has its own quantity.
|
|
||||||
|
|
||||||
15. The quantity on the device can be used as a default value for new circuit entries.
|
|
||||||
|
|
||||||
16. The description of a circuit entry can be overwritten independently from the linked device.
|
|
||||||
|
|
||||||
17. The total power of a circuit entry is calculated automatically.
|
|
||||||
|
|
||||||
18. A project has a project-specific device list.
|
|
||||||
|
|
||||||
19. Additionally, there is a global device list.
|
|
||||||
|
|
||||||
20. Devices can be copied between the global device list and project-specific device lists.
|
|
||||||
|
|
||||||
21. Circuit entries can be duplicated.
|
|
||||||
|
|
||||||
22. Circuit entries can be copied to other circuit lists.
|
|
||||||
|
|
||||||
23. When adding a device, the user can define how many circuit entries shall be created.
|
|
||||||
|
|
||||||
24. In principle, there are no required fields.
|
|
||||||
|
|
||||||
25. The application shall support fixed selection lists. Details will be specified later.
|
|
||||||
|
|
||||||
## 23. Open Points
|
|
||||||
|
|
||||||
The following points are not yet finally specified:
|
|
||||||
|
|
||||||
1. Exact formula for calculating `totalPower`.
|
|
||||||
|
|
||||||
2. Exact list of fields synchronized while a circuit entry is linked to a device.
|
|
||||||
|
|
||||||
3. Whether the device link is kept or detached when copying a circuit entry to another circuit list.
|
|
||||||
|
|
||||||
4. Final selection lists and their values.
|
|
||||||
|
|
||||||
5. Exact behavior of table functions such as sorting, filtering, multi-selection, and bulk editing.
|
|
||||||
|
|
||||||
6. Whether global devices and project devices remain permanently independent after copying, or whether optional synchronization should be supported later.
|
|
||||||
@@ -347,6 +347,10 @@ body {
|
|||||||
background: #f8fbff;
|
background: #f8fbff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-grid tr.row-selected td {
|
||||||
|
background: #eaf1ff;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-grid .placeholder-row td {
|
.tree-grid .placeholder-row td {
|
||||||
background: #f7f7f7;
|
background: #f7f7f7;
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
CreateCircuitDeviceRowInput,
|
CreateCircuitDeviceRowInput,
|
||||||
CreateCircuitInput,
|
CreateCircuitInput,
|
||||||
MoveCircuitDeviceRowInput,
|
MoveCircuitDeviceRowInput,
|
||||||
|
MoveCircuitDeviceRowsBulkInput,
|
||||||
ReorderSectionCircuitsInput,
|
ReorderSectionCircuitsInput,
|
||||||
UpdateSectionEquipmentIdentifiersInput,
|
UpdateSectionEquipmentIdentifiersInput,
|
||||||
UpdateCircuitDeviceRowInput,
|
UpdateCircuitDeviceRowInput,
|
||||||
@@ -365,6 +366,128 @@ export class CircuitWriteService {
|
|||||||
return this.deviceRowRepository.findById(rowId);
|
return this.deviceRowRepository.findById(rowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async moveDeviceRowsBulk(input: MoveCircuitDeviceRowsBulkInput) {
|
||||||
|
const uniqueRowIds = [...new Set(input.rowIds)];
|
||||||
|
if (uniqueRowIds.length === 0) {
|
||||||
|
throw new Error("No device rows provided.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
for (const rowId of uniqueRowIds) {
|
||||||
|
const row = await this.deviceRowRepository.findById(rowId);
|
||||||
|
if (!row) {
|
||||||
|
throw new Error("Invalid device row id.");
|
||||||
|
}
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceCircuits = new Map<string, Awaited<ReturnType<CircuitRepository["findById"]>>>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!sourceCircuits.has(row.circuitId)) {
|
||||||
|
const circuit = await this.circuitRepository.findById(row.circuitId);
|
||||||
|
if (!circuit) {
|
||||||
|
throw new Error("Invalid circuit id.");
|
||||||
|
}
|
||||||
|
sourceCircuits.set(row.circuitId, circuit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const referenceSourceCircuit = sourceCircuits.get(rows[0].circuitId)!;
|
||||||
|
let targetCircuit = input.targetCircuitId
|
||||||
|
? await this.circuitRepository.findById(input.targetCircuitId)
|
||||||
|
: null;
|
||||||
|
if (input.targetCircuitId && !targetCircuit) {
|
||||||
|
throw new Error("Invalid target circuit id.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetCircuit) {
|
||||||
|
if (!input.targetSectionId || !input.createNewCircuit) {
|
||||||
|
throw new Error("Invalid move target.");
|
||||||
|
}
|
||||||
|
const section = await this.assertSectionInList(input.targetSectionId, referenceSourceCircuit.circuitListId);
|
||||||
|
const nextIdentifier = await this.numberingService.getNextIdentifier(section.id);
|
||||||
|
const sectionCircuits = await this.circuitRepository.listBySection(section.id);
|
||||||
|
const nextSortOrder =
|
||||||
|
sectionCircuits.length > 0 ? Math.max(...sectionCircuits.map((circuit) => circuit.sortOrder)) + 10 : 10;
|
||||||
|
const createdId = await this.circuitRepository.create({
|
||||||
|
circuitListId: referenceSourceCircuit.circuitListId,
|
||||||
|
sectionId: section.id,
|
||||||
|
equipmentIdentifier: nextIdentifier,
|
||||||
|
displayName: "New circuit",
|
||||||
|
sortOrder: nextSortOrder,
|
||||||
|
isReserve: false,
|
||||||
|
});
|
||||||
|
targetCircuit = await this.circuitRepository.findById(createdId);
|
||||||
|
if (!targetCircuit) {
|
||||||
|
throw new Error("Failed to create target circuit.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sourceCircuit of sourceCircuits.values()) {
|
||||||
|
if (sourceCircuit.circuitListId !== targetCircuit.circuitListId) {
|
||||||
|
throw new Error("All moved rows must belong to same circuit list as target.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetCount = await this.deviceRowRepository.countByCircuit(targetCircuit.id);
|
||||||
|
let offset = 1;
|
||||||
|
for (const row of rows) {
|
||||||
|
await this.deviceRowRepository.moveToCircuit(row.id, targetCircuit.id, (targetCount + offset) * 10);
|
||||||
|
offset += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sourceCircuit of sourceCircuits.values()) {
|
||||||
|
const remaining = await this.deviceRowRepository.countByCircuit(sourceCircuit.id);
|
||||||
|
if (remaining === 0) {
|
||||||
|
await this.circuitRepository.update(sourceCircuit.id, {
|
||||||
|
sectionId: sourceCircuit.sectionId,
|
||||||
|
equipmentIdentifier: sourceCircuit.equipmentIdentifier,
|
||||||
|
displayName: sourceCircuit.displayName ?? undefined,
|
||||||
|
sortOrder: sourceCircuit.sortOrder,
|
||||||
|
protectionType: sourceCircuit.protectionType ?? undefined,
|
||||||
|
protectionRatedCurrent: sourceCircuit.protectionRatedCurrent ?? undefined,
|
||||||
|
protectionCharacteristic: sourceCircuit.protectionCharacteristic ?? undefined,
|
||||||
|
cableType: sourceCircuit.cableType ?? undefined,
|
||||||
|
cableCrossSection: sourceCircuit.cableCrossSection ?? undefined,
|
||||||
|
cableLength: sourceCircuit.cableLength ?? undefined,
|
||||||
|
rcdAssignment: sourceCircuit.rcdAssignment ?? undefined,
|
||||||
|
terminalDesignation: sourceCircuit.terminalDesignation ?? undefined,
|
||||||
|
voltage: sourceCircuit.voltage ?? undefined,
|
||||||
|
status: sourceCircuit.status ?? undefined,
|
||||||
|
isReserve: true,
|
||||||
|
remark: sourceCircuit.remark ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Boolean(targetCircuit.isReserve)) {
|
||||||
|
await this.circuitRepository.update(targetCircuit.id, {
|
||||||
|
sectionId: targetCircuit.sectionId,
|
||||||
|
equipmentIdentifier: targetCircuit.equipmentIdentifier,
|
||||||
|
displayName: targetCircuit.displayName ?? undefined,
|
||||||
|
sortOrder: targetCircuit.sortOrder,
|
||||||
|
protectionType: targetCircuit.protectionType ?? undefined,
|
||||||
|
protectionRatedCurrent: targetCircuit.protectionRatedCurrent ?? undefined,
|
||||||
|
protectionCharacteristic: targetCircuit.protectionCharacteristic ?? undefined,
|
||||||
|
cableType: targetCircuit.cableType ?? undefined,
|
||||||
|
cableCrossSection: targetCircuit.cableCrossSection ?? undefined,
|
||||||
|
cableLength: targetCircuit.cableLength ?? undefined,
|
||||||
|
rcdAssignment: targetCircuit.rcdAssignment ?? undefined,
|
||||||
|
terminalDesignation: targetCircuit.terminalDesignation ?? undefined,
|
||||||
|
voltage: targetCircuit.voltage ?? undefined,
|
||||||
|
status: targetCircuit.status ?? undefined,
|
||||||
|
isReserve: false,
|
||||||
|
remark: targetCircuit.remark ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
movedRowIds: rows.map((row) => row.id),
|
||||||
|
targetCircuitId: targetCircuit.id,
|
||||||
|
createdCircuitId: input.targetCircuitId ? undefined : targetCircuit.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getNextIdentifier(sectionId: string) {
|
async getNextIdentifier(sectionId: string) {
|
||||||
return this.numberingService.getNextIdentifier(sectionId);
|
return this.numberingService.getNextIdentifier(sectionId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getCircuitTree,
|
getCircuitTree,
|
||||||
getNextCircuitIdentifier,
|
getNextCircuitIdentifier,
|
||||||
listProjectDevices,
|
listProjectDevices,
|
||||||
|
moveCircuitDeviceRowsBulk,
|
||||||
moveCircuitDeviceRowById,
|
moveCircuitDeviceRowById,
|
||||||
reorderSectionCircuits,
|
reorderSectionCircuits,
|
||||||
renumberCircuitSection,
|
renumberCircuitSection,
|
||||||
@@ -467,6 +468,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
|
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
|
||||||
|
const [anchorRowKey, setAnchorRowKey] = useState<string | null>(null);
|
||||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||||
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
|
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
@@ -478,8 +481,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const [draggingProjectDeviceId, setDraggingProjectDeviceId] = useState<string | null>(null);
|
const [draggingProjectDeviceId, setDraggingProjectDeviceId] = useState<string | null>(null);
|
||||||
const [dropIntent, setDropIntent] = useState<ProjectDeviceDropIntent | null>(null);
|
const [dropIntent, setDropIntent] = useState<ProjectDeviceDropIntent | null>(null);
|
||||||
const [draggingDeviceRowId, setDraggingDeviceRowId] = useState<string | null>(null);
|
const [draggingDeviceRowId, setDraggingDeviceRowId] = useState<string | null>(null);
|
||||||
|
const [draggingDeviceRowIds, setDraggingDeviceRowIds] = useState<string[]>([]);
|
||||||
const [deviceMoveIntent, setDeviceMoveIntent] = useState<DeviceRowMoveDropIntent | null>(null);
|
const [deviceMoveIntent, setDeviceMoveIntent] = useState<DeviceRowMoveDropIntent | null>(null);
|
||||||
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
|
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
|
||||||
|
const [draggingCircuitIds, setDraggingCircuitIds] = useState<string[]>([]);
|
||||||
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null);
|
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null);
|
||||||
const [undoStack, setUndoStack] = useState<HistoryCommand[]>([]);
|
const [undoStack, setUndoStack] = useState<HistoryCommand[]>([]);
|
||||||
const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]);
|
const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]);
|
||||||
@@ -494,6 +499,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const [columnDropTargetKey, setColumnDropTargetKey] = useState<CellKey | null>(null);
|
const [columnDropTargetKey, setColumnDropTargetKey] = useState<CellKey | null>(null);
|
||||||
const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
|
const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
|
||||||
const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
|
const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
|
||||||
|
const pendingSelectedDeviceRowIdsAfterReload = useRef<string[] | null>(null);
|
||||||
|
const pendingSelectedCircuitIdsAfterReload = useRef<string[] | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const focusTokenRef = useRef(1);
|
const focusTokenRef = useRef(1);
|
||||||
@@ -803,6 +810,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
() => visibleRows.filter((row) => rowCellMap.has(row.rowKey)).map((row) => row.rowKey),
|
() => visibleRows.filter((row) => rowCellMap.has(row.rowKey)).map((row) => row.rowKey),
|
||||||
[visibleRows, rowCellMap]
|
[visibleRows, rowCellMap]
|
||||||
);
|
);
|
||||||
|
const selectableRowOrder = useMemo(
|
||||||
|
() =>
|
||||||
|
visibleRows
|
||||||
|
.filter(
|
||||||
|
(row) =>
|
||||||
|
row.rowType === "circuitCompact" ||
|
||||||
|
row.rowType === "circuitSummary" ||
|
||||||
|
row.rowType === "deviceRow" ||
|
||||||
|
row.rowType === "reserveCircuit"
|
||||||
|
)
|
||||||
|
.map((row) => row.rowKey),
|
||||||
|
[visibleRows]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCell && editableCells.length > 0) {
|
if (!selectedCell && editableCells.length > 0) {
|
||||||
@@ -819,6 +839,64 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
}, [editableCells, selectedCell]);
|
}, [editableCells, selectedCell]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedRowKeys((current) => current.filter((rowKey) => selectableRowOrder.includes(rowKey)));
|
||||||
|
setAnchorRowKey((current) => (current && selectableRowOrder.includes(current) ? current : null));
|
||||||
|
}, [selectableRowOrder]);
|
||||||
|
|
||||||
|
function isSelectableRowType(rowType: RowType) {
|
||||||
|
return (
|
||||||
|
rowType === "circuitCompact" ||
|
||||||
|
rowType === "circuitSummary" ||
|
||||||
|
rowType === "deviceRow" ||
|
||||||
|
rowType === "reserveCircuit"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRowRange(targetRowKey: string) {
|
||||||
|
const anchor = anchorRowKey ?? selectedRowKeys[selectedRowKeys.length - 1] ?? targetRowKey;
|
||||||
|
const start = selectableRowOrder.indexOf(anchor);
|
||||||
|
const end = selectableRowOrder.indexOf(targetRowKey);
|
||||||
|
if (start < 0 || end < 0) {
|
||||||
|
setSelectedRowKeys([targetRowKey]);
|
||||||
|
setAnchorRowKey(targetRowKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const from = Math.min(start, end);
|
||||||
|
const to = Math.max(start, end);
|
||||||
|
setSelectedRowKeys(selectableRowOrder.slice(from, to + 1));
|
||||||
|
setAnchorRowKey(anchor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowSelectionClick(
|
||||||
|
row: VisibleGridRow,
|
||||||
|
cellKey: CellKey,
|
||||||
|
options?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }
|
||||||
|
) {
|
||||||
|
if (isSelectableRowType(row.rowType)) {
|
||||||
|
const ctrlOrMeta = Boolean(options?.ctrlKey || options?.metaKey);
|
||||||
|
if (options?.shiftKey) {
|
||||||
|
selectRowRange(row.rowKey);
|
||||||
|
} else if (ctrlOrMeta) {
|
||||||
|
setSelectedRowKeys((current) => {
|
||||||
|
if (current.includes(row.rowKey)) {
|
||||||
|
return current.filter((key) => key !== row.rowKey);
|
||||||
|
}
|
||||||
|
return [...current, row.rowKey];
|
||||||
|
});
|
||||||
|
setAnchorRowKey(row.rowKey);
|
||||||
|
} else {
|
||||||
|
setSelectedRowKeys([row.rowKey]);
|
||||||
|
setAnchorRowKey(row.rowKey);
|
||||||
|
}
|
||||||
|
} else if (!options?.ctrlKey && !options?.metaKey && !options?.shiftKey) {
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
setAnchorRowKey(null);
|
||||||
|
}
|
||||||
|
setSelectedCell({ rowKey: row.rowKey, cellKey });
|
||||||
|
requestAnimationFrame(() => containerRef.current?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
function buildSelectionIntent(cell: SelectedCell): SelectionIntent | null {
|
function buildSelectionIntent(cell: SelectedCell): SelectionIntent | null {
|
||||||
const row = findRow(cell.rowKey);
|
const row = findRow(cell.rowKey);
|
||||||
if (!row) {
|
if (!row) {
|
||||||
@@ -1102,6 +1180,46 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
requestAnimationFrame(() => containerRef.current?.focus());
|
requestAnimationFrame(() => containerRef.current?.focus());
|
||||||
}, [data, editableCells, visibleRows]);
|
}, [data, editableCells, visibleRows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pending = pendingSelectedDeviceRowIdsAfterReload.current;
|
||||||
|
if (!pending || pending.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rowKeys: string[] = [];
|
||||||
|
for (const row of visibleRows) {
|
||||||
|
if ((row.rowType === "deviceRow" || row.rowType === "circuitCompact") && row.device?.id && pending.includes(row.device.id)) {
|
||||||
|
rowKeys.push(row.rowKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rowKeys.length > 0) {
|
||||||
|
setSelectedRowKeys(rowKeys);
|
||||||
|
setAnchorRowKey(rowKeys[0]);
|
||||||
|
}
|
||||||
|
pendingSelectedDeviceRowIdsAfterReload.current = null;
|
||||||
|
}, [visibleRows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const pending = pendingSelectedCircuitIdsAfterReload.current;
|
||||||
|
if (!pending || pending.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rowKeys: string[] = [];
|
||||||
|
for (const row of visibleRows) {
|
||||||
|
if (
|
||||||
|
(row.rowType === "circuitCompact" || row.rowType === "circuitSummary" || row.rowType === "reserveCircuit") &&
|
||||||
|
row.circuit?.id &&
|
||||||
|
pending.includes(row.circuit.id)
|
||||||
|
) {
|
||||||
|
rowKeys.push(row.rowKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rowKeys.length > 0) {
|
||||||
|
setSelectedRowKeys(rowKeys);
|
||||||
|
setAnchorRowKey(rowKeys[0]);
|
||||||
|
}
|
||||||
|
pendingSelectedCircuitIdsAfterReload.current = null;
|
||||||
|
}, [visibleRows]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingFocus) {
|
if (!pendingFocus) {
|
||||||
return;
|
return;
|
||||||
@@ -1745,6 +1863,38 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return event.dataTransfer.getData("application/x-circuit-device-row-id") || null;
|
return event.dataTransfer.getData("application/x-circuit-device-row-id") || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDraggedDeviceRowIds(event: DragEvent<HTMLElement>) {
|
||||||
|
const raw = event.dataTransfer.getData("application/x-circuit-device-row-ids");
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDraggedCircuitIds(event: DragEvent<HTMLElement>) {
|
||||||
|
const raw = event.dataTransfer.getData("application/x-circuit-ids");
|
||||||
|
if (!raw) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function findDeviceRowCircuitId(deviceRowId: string) {
|
function findDeviceRowCircuitId(deviceRowId: string) {
|
||||||
for (const section of data?.sections ?? []) {
|
for (const section of data?.sections ?? []) {
|
||||||
for (const circuit of section.circuits) {
|
for (const circuit of section.circuits) {
|
||||||
@@ -1756,6 +1906,46 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSelectedEligibleDeviceRowIds(primaryRowId?: string | null) {
|
||||||
|
const selectedSet = new Set(selectedRowKeys);
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const row of visibleRows) {
|
||||||
|
if (!selectedSet.has(row.rowKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ((row.rowType === "deviceRow" || row.rowType === "circuitCompact") && row.device?.id) {
|
||||||
|
ids.push(row.device.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ids.length > 1 && primaryRowId && ids.includes(primaryRowId)) {
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
return primaryRowId ? [primaryRowId] : ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedEligibleCircuitIds(primaryCircuitId?: string | null) {
|
||||||
|
const selectedSet = new Set(selectedRowKeys);
|
||||||
|
const ids: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const row of visibleRows) {
|
||||||
|
if (!selectedSet.has(row.rowKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(row.rowType === "circuitCompact" || row.rowType === "circuitSummary" || row.rowType === "reserveCircuit") &&
|
||||||
|
row.circuit?.id &&
|
||||||
|
!seen.has(row.circuit.id)
|
||||||
|
) {
|
||||||
|
seen.add(row.circuit.id);
|
||||||
|
ids.push(row.circuit.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ids.length > 1 && primaryCircuitId && ids.includes(primaryCircuitId)) {
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
return primaryCircuitId ? [primaryCircuitId] : ids;
|
||||||
|
}
|
||||||
|
|
||||||
function findCircuitSectionId(circuitId: string) {
|
function findCircuitSectionId(circuitId: string) {
|
||||||
for (const section of data?.sections ?? []) {
|
for (const section of data?.sections ?? []) {
|
||||||
if (section.circuits.some((circuit) => circuit.id === circuitId)) {
|
if (section.circuits.some((circuit) => circuit.id === circuitId)) {
|
||||||
@@ -1765,28 +1955,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitId: string) {
|
async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) {
|
||||||
const section = data?.sections.find((entry) => entry.id === intent.sectionId);
|
const section = data?.sections.find((entry) => entry.id === intent.sectionId);
|
||||||
if (!section) {
|
if (!section) {
|
||||||
throw new Error("Invalid section id.");
|
throw new Error("Invalid section id.");
|
||||||
}
|
}
|
||||||
const ids = section.circuits.map((circuit) => circuit.id);
|
const ids = section.circuits.map((circuit) => circuit.id);
|
||||||
const fromIndex = ids.indexOf(sourceCircuitId);
|
const block = sourceCircuitIds.filter((id) => ids.includes(id));
|
||||||
if (fromIndex < 0) {
|
if (block.length === 0) {
|
||||||
throw new Error("Invalid source circuit.");
|
throw new Error("Invalid source circuit.");
|
||||||
}
|
}
|
||||||
const nextIds = [...ids];
|
const blockSet = new Set(block);
|
||||||
nextIds.splice(fromIndex, 1);
|
const nextIds = ids.filter((id) => !blockSet.has(id));
|
||||||
|
|
||||||
if (intent.kind === "section-end") {
|
if (intent.kind === "section-end") {
|
||||||
nextIds.push(sourceCircuitId);
|
nextIds.push(...block);
|
||||||
} else {
|
} else {
|
||||||
const targetIndex = nextIds.indexOf(intent.targetCircuitId);
|
const targetIndex = nextIds.indexOf(intent.targetCircuitId);
|
||||||
if (targetIndex < 0) {
|
if (targetIndex < 0) {
|
||||||
throw new Error("Invalid target circuit.");
|
throw new Error("Invalid target circuit.");
|
||||||
}
|
}
|
||||||
const insertIndex = intent.kind === "after-circuit" ? targetIndex + 1 : targetIndex;
|
const insertIndex = intent.kind === "after-circuit" ? targetIndex + 1 : targetIndex;
|
||||||
nextIds.splice(insertIndex, 0, sourceCircuitId);
|
nextIds.splice(insertIndex, 0, ...block);
|
||||||
}
|
}
|
||||||
await reorderSectionCircuits(section.id, nextIds);
|
await reorderSectionCircuits(section.id, nextIds);
|
||||||
}
|
}
|
||||||
@@ -1797,6 +1987,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const draggedId = parseDraggedProjectDeviceId(event) ?? draggingProjectDeviceId;
|
const draggedId = parseDraggedProjectDeviceId(event) ?? draggingProjectDeviceId;
|
||||||
setDropIntent(null);
|
setDropIntent(null);
|
||||||
setDraggingProjectDeviceId(null);
|
setDraggingProjectDeviceId(null);
|
||||||
|
setDraggingDeviceRowIds([]);
|
||||||
|
setDraggingCircuitIds([]);
|
||||||
if (!draggedId) {
|
if (!draggedId) {
|
||||||
setError("Missing dragged project device.");
|
setError("Missing dragged project device.");
|
||||||
return;
|
return;
|
||||||
@@ -1858,32 +2050,57 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
async function handleDeviceRowDropWithIntent(event: DragEvent<HTMLElement>, intent: DeviceRowMoveDropIntent) {
|
async function handleDeviceRowDropWithIntent(event: DragEvent<HTMLElement>, intent: DeviceRowMoveDropIntent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const rowId = parseDraggedDeviceRowId(event) ?? draggingDeviceRowId;
|
const draggedRowIds = parseDraggedDeviceRowIds(event);
|
||||||
|
const singleRowId = parseDraggedDeviceRowId(event) ?? draggingDeviceRowId;
|
||||||
|
const rowIds = draggedRowIds.length > 0 ? draggedRowIds : singleRowId ? [singleRowId] : [];
|
||||||
setDeviceMoveIntent(null);
|
setDeviceMoveIntent(null);
|
||||||
setDraggingDeviceRowId(null);
|
setDraggingDeviceRowId(null);
|
||||||
if (!rowId) {
|
setDraggingDeviceRowIds([]);
|
||||||
|
setDraggingCircuitIds([]);
|
||||||
|
if (rowIds.length === 0) {
|
||||||
setError("Missing dragged device row.");
|
setError("Missing dragged device row.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceByRowId = new Map<string, string>();
|
||||||
|
for (const rowId of rowIds) {
|
||||||
const sourceCircuitId = findDeviceRowCircuitId(rowId);
|
const sourceCircuitId = findDeviceRowCircuitId(rowId);
|
||||||
if (!sourceCircuitId) {
|
if (!sourceCircuitId) {
|
||||||
setError("Invalid dragged device row.");
|
setError("Invalid dragged device row.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
sourceByRowId.set(rowId, sourceCircuitId);
|
||||||
if (intent.kind === "move-to-circuit") {
|
}
|
||||||
if (intent.circuitId === sourceCircuitId) {
|
const sourceCircuitId = sourceByRowId.get(rowIds[0]);
|
||||||
|
if (!sourceCircuitId) {
|
||||||
|
setError("Invalid dragged device row source.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (intent.kind === "move-to-circuit") {
|
||||||
|
if (rowIds.length === 1 && intent.circuitId === sourceCircuitId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groupedBySource = new Map<string, string[]>();
|
||||||
|
for (const rowId of rowIds) {
|
||||||
|
const source = sourceByRowId.get(rowId)!;
|
||||||
|
if (!groupedBySource.has(source)) {
|
||||||
|
groupedBySource.set(source, []);
|
||||||
|
}
|
||||||
|
groupedBySource.get(source)!.push(rowId);
|
||||||
|
}
|
||||||
await runCommand({
|
await runCommand({
|
||||||
label: "Move device row",
|
label: rowIds.length > 1 ? `Move ${rowIds.length} device rows` : "Move device row",
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
await moveCircuitDeviceRowById(rowId, { targetCircuitId: intent.circuitId });
|
await moveCircuitDeviceRowsBulk({ rowIds, targetCircuitId: intent.circuitId });
|
||||||
|
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
undo: async () => {
|
undo: async () => {
|
||||||
await moveCircuitDeviceRowById(rowId, { targetCircuitId: sourceCircuitId });
|
for (const [source, ids] of groupedBySource) {
|
||||||
|
await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source });
|
||||||
|
}
|
||||||
|
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1891,21 +2108,34 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
let createdCircuitId: string | null = null;
|
let createdCircuitId: string | null = null;
|
||||||
|
const groupedBySource = new Map<string, string[]>();
|
||||||
|
for (const rowId of rowIds) {
|
||||||
|
const source = sourceByRowId.get(rowId)!;
|
||||||
|
if (!groupedBySource.has(source)) {
|
||||||
|
groupedBySource.set(source, []);
|
||||||
|
}
|
||||||
|
groupedBySource.get(source)!.push(rowId);
|
||||||
|
}
|
||||||
await runCommand({
|
await runCommand({
|
||||||
label: "Move device row to new circuit",
|
label: rowIds.length > 1 ? `Move ${rowIds.length} device rows to new circuit` : "Move device row to new circuit",
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
const moved = (await moveCircuitDeviceRowById(rowId, {
|
const moved = await moveCircuitDeviceRowsBulk({
|
||||||
|
rowIds,
|
||||||
targetSectionId: intent.sectionId,
|
targetSectionId: intent.sectionId,
|
||||||
createNewCircuit: true,
|
createNewCircuit: true,
|
||||||
})) as { circuitId?: string };
|
});
|
||||||
createdCircuitId = moved.circuitId ?? null;
|
createdCircuitId = moved.createdCircuitId ?? null;
|
||||||
|
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
undo: async () => {
|
undo: async () => {
|
||||||
await moveCircuitDeviceRowById(rowId, { targetCircuitId: sourceCircuitId });
|
for (const [source, ids] of groupedBySource) {
|
||||||
|
await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source });
|
||||||
|
}
|
||||||
if (createdCircuitId) {
|
if (createdCircuitId) {
|
||||||
await deleteCircuitById(createdCircuitId);
|
await deleteCircuitById(createdCircuitId);
|
||||||
}
|
}
|
||||||
|
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1914,10 +2144,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
async function handleCircuitReorderDrop(event: DragEvent<HTMLElement>, intent: CircuitReorderDropIntent) {
|
async function handleCircuitReorderDrop(event: DragEvent<HTMLElement>, intent: CircuitReorderDropIntent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
const sourceCircuitId = event.dataTransfer.getData("application/x-circuit-id") || draggingCircuitId;
|
const draggedCircuitIds = parseDraggedCircuitIds(event);
|
||||||
|
const singleCircuitId = event.dataTransfer.getData("application/x-circuit-id") || draggingCircuitId;
|
||||||
|
const sourceCircuitIds = draggedCircuitIds.length > 0 ? draggedCircuitIds : singleCircuitId ? [singleCircuitId] : [];
|
||||||
setCircuitReorderIntent(null);
|
setCircuitReorderIntent(null);
|
||||||
setDraggingCircuitId(null);
|
setDraggingCircuitId(null);
|
||||||
if (!sourceCircuitId) {
|
setDraggingCircuitIds([]);
|
||||||
|
if (sourceCircuitIds.length === 0) {
|
||||||
setError("Missing dragged circuit.");
|
setError("Missing dragged circuit.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1930,27 +2163,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
setError("Invalid section id.");
|
setError("Invalid section id.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const sectionCircuitIds = new Set(section.circuits.map((circuit) => circuit.id));
|
||||||
|
if (sourceCircuitIds.some((id) => !sectionCircuitIds.has(id))) {
|
||||||
|
setError("Cross-section circuit move is not allowed in this phase.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const beforeOrder = section.circuits.map((circuit) => circuit.id);
|
const beforeOrder = section.circuits.map((circuit) => circuit.id);
|
||||||
|
const primaryCircuitId = sourceCircuitIds[0];
|
||||||
await runCommand({
|
await runCommand({
|
||||||
label: "Reorder circuits",
|
label: sourceCircuitIds.length > 1 ? `Reorder ${sourceCircuitIds.length} circuits` : "Reorder circuits",
|
||||||
redo: async () => {
|
redo: async () => {
|
||||||
await applyCircuitReorder(intent, sourceCircuitId);
|
await applyCircuitReorder(intent, sourceCircuitIds);
|
||||||
|
pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds;
|
||||||
return {
|
return {
|
||||||
rowKey: `circuitSummary:${sourceCircuitId}`,
|
rowKey: `circuitSummary:${primaryCircuitId}`,
|
||||||
cellKey: "equipmentIdentifier",
|
cellKey: "equipmentIdentifier",
|
||||||
rowType: "circuitSummary",
|
rowType: "circuitSummary",
|
||||||
sectionId: intent.sectionId,
|
sectionId: intent.sectionId,
|
||||||
circuitId: sourceCircuitId,
|
circuitId: primaryCircuitId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
undo: async () => {
|
undo: async () => {
|
||||||
await reorderSectionCircuits(intent.sectionId, beforeOrder);
|
await reorderSectionCircuits(intent.sectionId, beforeOrder);
|
||||||
|
pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds;
|
||||||
return {
|
return {
|
||||||
rowKey: `circuitSummary:${sourceCircuitId}`,
|
rowKey: `circuitSummary:${primaryCircuitId}`,
|
||||||
cellKey: "equipmentIdentifier",
|
cellKey: "equipmentIdentifier",
|
||||||
rowType: "circuitSummary",
|
rowType: "circuitSummary",
|
||||||
sectionId: intent.sectionId,
|
sectionId: intent.sectionId,
|
||||||
circuitId: sourceCircuitId,
|
circuitId: primaryCircuitId,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -2077,6 +2318,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
if (selectedRowKeys.length > 0) {
|
||||||
|
event.preventDefault();
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
setAnchorRowKey(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.ctrlKey && event.key.toLowerCase() === "z") {
|
if (event.ctrlKey && event.key.toLowerCase() === "z") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
@@ -2229,6 +2478,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
|
|
||||||
const isSortedView = Boolean(sortState);
|
const isSortedView = Boolean(sortState);
|
||||||
const hasActiveSortOrFilter = isSortedView || hasActiveFilters;
|
const hasActiveSortOrFilter = isSortedView || hasActiveFilters;
|
||||||
|
const draggingDeviceCount = draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds.length : draggingDeviceRowId ? 1 : 0;
|
||||||
|
const activeDraggedCircuitIds =
|
||||||
|
draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : [];
|
||||||
|
const draggingCircuitCount = activeDraggedCircuitIds.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tree-editor-shell">
|
<div className="tree-editor-shell">
|
||||||
@@ -2335,7 +2588,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
setSelectedProjectDeviceId(device.id);
|
setSelectedProjectDeviceId(device.id);
|
||||||
setDraggingProjectDeviceId(device.id);
|
setDraggingProjectDeviceId(device.id);
|
||||||
setDraggingDeviceRowId(null);
|
setDraggingDeviceRowId(null);
|
||||||
|
setDraggingDeviceRowIds([]);
|
||||||
setDraggingCircuitId(null);
|
setDraggingCircuitId(null);
|
||||||
|
setDraggingCircuitIds([]);
|
||||||
setDeviceMoveIntent(null);
|
setDeviceMoveIntent(null);
|
||||||
setCircuitReorderIntent(null);
|
setCircuitReorderIntent(null);
|
||||||
event.dataTransfer.effectAllowed = "copy";
|
event.dataTransfer.effectAllowed = "copy";
|
||||||
@@ -2344,6 +2599,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
onDragEnd={() => {
|
onDragEnd={() => {
|
||||||
setDraggingProjectDeviceId(null);
|
setDraggingProjectDeviceId(null);
|
||||||
setDropIntent(null);
|
setDropIntent(null);
|
||||||
|
setDraggingDeviceRowIds([]);
|
||||||
|
setDraggingCircuitIds([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>{device.displayName || device.name}</strong>
|
<strong>{device.displayName || device.name}</strong>
|
||||||
@@ -2401,6 +2658,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onFocus={handleContainerFocus}
|
onFocus={handleContainerFocus}
|
||||||
onKeyDown={handleContainerKeyDown}
|
onKeyDown={handleContainerKeyDown}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
if (event.target === event.currentTarget) {
|
||||||
|
setSelectedRowKeys([]);
|
||||||
|
setAnchorRowKey(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<table className="tree-grid">
|
<table className="tree-grid">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -2490,7 +2753,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? "drop-target-active" : ""
|
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? "drop-target-active" : ""
|
||||||
}`}
|
}`}
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
if (draggingCircuitId) {
|
if (draggingCircuitCount > 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = "none";
|
event.dataTransfer.dropEffect = "none";
|
||||||
setCircuitReorderIntent({
|
setCircuitReorderIntent({
|
||||||
@@ -2520,7 +2783,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: section.id });
|
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: section.id });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (draggingCircuitId) {
|
if (draggingCircuitCount > 0) {
|
||||||
void handleCircuitReorderDrop(event, {
|
void handleCircuitReorderDrop(event, {
|
||||||
kind: "section-end",
|
kind: "section-end",
|
||||||
sectionId: section.id,
|
sectionId: section.id,
|
||||||
@@ -2613,26 +2876,31 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
? "drop-target-invalid"
|
? "drop-target-invalid"
|
||||||
: ""
|
: ""
|
||||||
} ${
|
} ${
|
||||||
row.circuit?.id && draggingCircuitId && row.circuit.id === draggingCircuitId ? "circuit-dragging-block" : ""
|
row.circuit?.id &&
|
||||||
|
((draggingCircuitIds.length > 0 && draggingCircuitIds.includes(row.circuit.id)) ||
|
||||||
|
(draggingCircuitIds.length === 0 && draggingCircuitId && row.circuit.id === draggingCircuitId))
|
||||||
|
? "circuit-dragging-block"
|
||||||
|
: ""
|
||||||
|
} ${selectedRowKeys.includes(row.rowKey) ? "row-selected" : ""}
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setActiveSectionId(row.sectionId)}
|
onClick={() => setActiveSectionId(row.sectionId)}
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
if (draggingCircuitId) {
|
if (draggingCircuitCount > 0) {
|
||||||
|
const sourceSectionIds = activeDraggedCircuitIds
|
||||||
|
.map((id) => findCircuitSectionId(id))
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
const valid = sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === row.sectionId);
|
||||||
if (row.rowType === "placeholder") {
|
if (row.rowType === "placeholder") {
|
||||||
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
|
|
||||||
const valid = sourceSectionId === row.sectionId;
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = valid ? "move" : "none";
|
event.dataTransfer.dropEffect = valid ? "move" : "none";
|
||||||
setCircuitReorderIntent({ kind: "section-end", sectionId: row.sectionId, valid });
|
setCircuitReorderIntent({ kind: "section-end", sectionId: row.sectionId, valid });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (row.circuit && row.rowType !== "deviceRow") {
|
if (row.circuit && row.rowType !== "deviceRow") {
|
||||||
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
|
if (activeDraggedCircuitIds.includes(row.circuit.id)) {
|
||||||
if (row.circuit.id === draggingCircuitId) {
|
|
||||||
setCircuitReorderIntent(null);
|
setCircuitReorderIntent(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const valid = sourceSectionId === row.sectionId;
|
|
||||||
const rect = (event.currentTarget as HTMLTableRowElement).getBoundingClientRect();
|
const rect = (event.currentTarget as HTMLTableRowElement).getBoundingClientRect();
|
||||||
const isAfter = event.clientY > rect.top + rect.height / 2;
|
const isAfter = event.clientY > rect.top + rect.height / 2;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -2660,7 +2928,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (draggingDeviceRowId) {
|
if (draggingDeviceCount > 0) {
|
||||||
if (row.rowType === "placeholder") {
|
if (row.rowType === "placeholder") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = "move";
|
event.dataTransfer.dropEffect = "move";
|
||||||
@@ -2668,8 +2936,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (row.circuit && row.rowType !== "deviceRow") {
|
if (row.circuit && row.rowType !== "deviceRow") {
|
||||||
const sourceCircuitId = findDeviceRowCircuitId(draggingDeviceRowId);
|
const sourceCircuitIds = (draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds : draggingDeviceRowId ? [draggingDeviceRowId] : [])
|
||||||
if (sourceCircuitId && sourceCircuitId !== row.circuit.id) {
|
.map((id) => findDeviceRowCircuitId(id))
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
|
if (sourceCircuitIds.some((sourceCircuitId) => sourceCircuitId !== row.circuit!.id)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = "move";
|
event.dataTransfer.dropEffect = "move";
|
||||||
setDeviceMoveIntent({ kind: "move-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId });
|
setDeviceMoveIntent({ kind: "move-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId });
|
||||||
@@ -2719,28 +2989,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
if (draggingCircuitId) {
|
if (draggingCircuitCount > 0) {
|
||||||
if (row.rowType === "placeholder") {
|
if (row.rowType === "placeholder") {
|
||||||
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
|
const sourceSectionIds = activeDraggedCircuitIds
|
||||||
|
.map((id) => findCircuitSectionId(id))
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
void handleCircuitReorderDrop(event, {
|
void handleCircuitReorderDrop(event, {
|
||||||
kind: "section-end",
|
kind: "section-end",
|
||||||
sectionId: row.sectionId,
|
sectionId: row.sectionId,
|
||||||
valid: sourceSectionId === row.sectionId,
|
valid: sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === row.sectionId),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (row.circuit && row.rowType !== "deviceRow") {
|
if (row.circuit && row.rowType !== "deviceRow") {
|
||||||
if (row.circuit.id === draggingCircuitId) {
|
if (activeDraggedCircuitIds.includes(row.circuit.id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
|
const sourceSectionIds = activeDraggedCircuitIds
|
||||||
|
.map((id) => findCircuitSectionId(id))
|
||||||
|
.filter((id): id is string => Boolean(id));
|
||||||
const rect = (event.currentTarget as HTMLTableRowElement).getBoundingClientRect();
|
const rect = (event.currentTarget as HTMLTableRowElement).getBoundingClientRect();
|
||||||
const isAfter = event.clientY > rect.top + rect.height / 2;
|
const isAfter = event.clientY > rect.top + rect.height / 2;
|
||||||
void handleCircuitReorderDrop(event, {
|
void handleCircuitReorderDrop(event, {
|
||||||
kind: isAfter ? "after-circuit" : "before-circuit",
|
kind: isAfter ? "after-circuit" : "before-circuit",
|
||||||
sectionId: row.sectionId,
|
sectionId: row.sectionId,
|
||||||
targetCircuitId: row.circuit.id,
|
targetCircuitId: row.circuit.id,
|
||||||
valid: sourceSectionId === row.sectionId,
|
valid: sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === row.sectionId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -2759,7 +3033,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (draggingDeviceRowId) {
|
if (draggingDeviceCount > 0) {
|
||||||
if (row.rowType === "placeholder") {
|
if (row.rowType === "placeholder") {
|
||||||
void handleDeviceRowDropWithIntent(event, { kind: "move-to-new-circuit", sectionId: row.sectionId });
|
void handleDeviceRowDropWithIntent(event, { kind: "move-to-new-circuit", sectionId: row.sectionId });
|
||||||
return;
|
return;
|
||||||
@@ -2794,7 +3068,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
? "circuit-drag-handle"
|
? "circuit-drag-handle"
|
||||||
: ""
|
: ""
|
||||||
} ${
|
} ${
|
||||||
draggingDeviceRowId && row.device?.id === draggingDeviceRowId ? "device-dragging" : ""
|
row.device?.id &&
|
||||||
|
((draggingDeviceRowIds.length > 0 && draggingDeviceRowIds.includes(row.device.id)) ||
|
||||||
|
(draggingDeviceRowIds.length === 0 && draggingDeviceRowId === row.device.id))
|
||||||
|
? "device-dragging"
|
||||||
|
: ""
|
||||||
}`}
|
}`}
|
||||||
draggable={
|
draggable={
|
||||||
!isEditing &&
|
!isEditing &&
|
||||||
@@ -2815,14 +3093,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
row.rowType === "circuitSummary" ||
|
row.rowType === "circuitSummary" ||
|
||||||
row.rowType === "reserveCircuit")
|
row.rowType === "reserveCircuit")
|
||||||
) {
|
) {
|
||||||
|
const circuitIds = getSelectedEligibleCircuitIds(row.circuit.id);
|
||||||
setDraggingProjectDeviceId(null);
|
setDraggingProjectDeviceId(null);
|
||||||
setDropIntent(null);
|
setDropIntent(null);
|
||||||
setDraggingDeviceRowId(null);
|
setDraggingDeviceRowId(null);
|
||||||
|
setDraggingDeviceRowIds([]);
|
||||||
setDeviceMoveIntent(null);
|
setDeviceMoveIntent(null);
|
||||||
setDraggingCircuitId(row.circuit.id);
|
setDraggingCircuitId(row.circuit.id);
|
||||||
|
setDraggingCircuitIds(circuitIds);
|
||||||
setCircuitReorderIntent(null);
|
setCircuitReorderIntent(null);
|
||||||
event.dataTransfer.effectAllowed = "move";
|
event.dataTransfer.effectAllowed = "move";
|
||||||
event.dataTransfer.setData("application/x-circuit-id", row.circuit.id);
|
event.dataTransfer.setData("application/x-circuit-id", row.circuit.id);
|
||||||
|
event.dataTransfer.setData("application/x-circuit-ids", JSON.stringify(circuitIds));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -2830,26 +3112,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
column.key === "displayName" &&
|
column.key === "displayName" &&
|
||||||
(row.rowType === "deviceRow" || row.rowType === "circuitCompact")
|
(row.rowType === "deviceRow" || row.rowType === "circuitCompact")
|
||||||
) {
|
) {
|
||||||
|
const rowIds = getSelectedEligibleDeviceRowIds(row.device.id);
|
||||||
setDraggingProjectDeviceId(null);
|
setDraggingProjectDeviceId(null);
|
||||||
setDropIntent(null);
|
setDropIntent(null);
|
||||||
setDraggingCircuitId(null);
|
setDraggingCircuitId(null);
|
||||||
|
setDraggingCircuitIds([]);
|
||||||
setCircuitReorderIntent(null);
|
setCircuitReorderIntent(null);
|
||||||
setDraggingDeviceRowId(row.device.id);
|
setDraggingDeviceRowId(row.device.id);
|
||||||
|
setDraggingDeviceRowIds(rowIds);
|
||||||
setDeviceMoveIntent(null);
|
setDeviceMoveIntent(null);
|
||||||
event.dataTransfer.effectAllowed = "move";
|
event.dataTransfer.effectAllowed = "move";
|
||||||
event.dataTransfer.setData("application/x-circuit-device-row-id", row.device.id);
|
event.dataTransfer.setData("application/x-circuit-device-row-id", row.device.id);
|
||||||
|
event.dataTransfer.setData("application/x-circuit-device-row-ids", JSON.stringify(rowIds));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragEnd={() => {
|
onDragEnd={() => {
|
||||||
setDraggingDeviceRowId(null);
|
setDraggingDeviceRowId(null);
|
||||||
|
setDraggingDeviceRowIds([]);
|
||||||
setDeviceMoveIntent(null);
|
setDeviceMoveIntent(null);
|
||||||
setDraggingCircuitId(null);
|
setDraggingCircuitId(null);
|
||||||
|
setDraggingCircuitIds([]);
|
||||||
setCircuitReorderIntent(null);
|
setCircuitReorderIntent(null);
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={(event) => {
|
||||||
if (cell.editable) {
|
if (cell.editable) {
|
||||||
setSelectedCell({ rowKey: row.rowKey, cellKey: column.key });
|
handleRowSelectionClick(row, column.key, {
|
||||||
requestAnimationFrame(() => containerRef.current?.focus());
|
ctrlKey: event.ctrlKey,
|
||||||
|
metaKey: event.metaKey,
|
||||||
|
shiftKey: event.shiftKey,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={() => {
|
||||||
@@ -2935,16 +3226,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
{deviceMoveIntent?.kind === "move-to-new-circuit" &&
|
{deviceMoveIntent?.kind === "move-to-new-circuit" &&
|
||||||
row.rowType === "placeholder" &&
|
row.rowType === "placeholder" &&
|
||||||
deviceMoveIntent.sectionId === row.sectionId ? (
|
deviceMoveIntent.sectionId === row.sectionId ? (
|
||||||
<span className="drop-hint">move device to new circuit</span>
|
<span className="drop-hint">{`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to new circuit`}</span>
|
||||||
) : null}
|
) : null}
|
||||||
{deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? (
|
{deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? (
|
||||||
<span className="drop-hint">move device to this circuit</span>
|
<span className="drop-hint">{`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to this circuit`}</span>
|
||||||
) : null}
|
) : null}
|
||||||
{circuitReorderIntent?.kind === "section-end" &&
|
{circuitReorderIntent?.kind === "section-end" &&
|
||||||
row.rowType === "placeholder" &&
|
row.rowType === "placeholder" &&
|
||||||
circuitReorderIntent.sectionId === row.sectionId ? (
|
circuitReorderIntent.sectionId === row.sectionId ? (
|
||||||
<span className="drop-hint">
|
<span className="drop-hint">
|
||||||
{circuitReorderIntent.valid ? "move circuit to section end" : "cross-section move not allowed"}
|
{circuitReorderIntent.valid
|
||||||
|
? `move ${draggingCircuitCount || 1} circuit${draggingCircuitCount === 1 ? "" : "s"} to section end`
|
||||||
|
: "cross-section move not allowed"}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{circuitReorderIntent &&
|
{circuitReorderIntent &&
|
||||||
@@ -2953,8 +3246,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
<span className="drop-hint">
|
<span className="drop-hint">
|
||||||
{circuitReorderIntent.valid
|
{circuitReorderIntent.valid
|
||||||
? circuitReorderIntent.kind === "before-circuit"
|
? circuitReorderIntent.kind === "before-circuit"
|
||||||
? "move circuit before this circuit"
|
? `move ${draggingCircuitCount || 1} circuit${draggingCircuitCount === 1 ? "" : "s"} before this circuit`
|
||||||
: "move circuit after this circuit"
|
: `move ${draggingCircuitCount || 1} circuit${draggingCircuitCount === 1 ? "" : "s"} after this circuit`
|
||||||
: "cross-section move not allowed"}
|
: "cross-section move not allowed"}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -142,6 +142,21 @@ export function moveCircuitDeviceRowById(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function moveCircuitDeviceRowsBulk(input: {
|
||||||
|
rowIds: string[];
|
||||||
|
targetCircuitId?: string;
|
||||||
|
targetSectionId?: string;
|
||||||
|
createNewCircuit?: boolean;
|
||||||
|
}) {
|
||||||
|
return request<{ movedRowIds: string[]; targetCircuitId: string; createdCircuitId?: string }>(
|
||||||
|
"/api/circuit-device-rows/move-bulk",
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function renumberCircuitSection(sectionId: string) {
|
export function renumberCircuitSection(sectionId: string) {
|
||||||
return request(`/api/circuit-sections/${sectionId}/renumber`, {
|
return request(`/api/circuit-sections/${sectionId}/renumber`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Request, Response } from "express";
|
|||||||
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
|
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
|
||||||
import {
|
import {
|
||||||
createCircuitDeviceRowSchema,
|
createCircuitDeviceRowSchema,
|
||||||
|
moveCircuitDeviceRowsBulkSchema,
|
||||||
moveCircuitDeviceRowSchema,
|
moveCircuitDeviceRowSchema,
|
||||||
updateCircuitDeviceRowSchema,
|
updateCircuitDeviceRowSchema,
|
||||||
} from "../../shared/validation/circuit.schemas.js";
|
} from "../../shared/validation/circuit.schemas.js";
|
||||||
@@ -78,3 +79,17 @@ export async function moveCircuitDeviceRow(req: Request, res: Response) {
|
|||||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device row." });
|
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device row." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function moveCircuitDeviceRowsBulk(req: Request, res: Response) {
|
||||||
|
const parsed = moveCircuitDeviceRowsBulkSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moved = await circuitWriteService.moveDeviceRowsBulk(parsed.data);
|
||||||
|
return res.json(moved);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device rows." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import {
|
import {
|
||||||
deleteCircuitDeviceRow,
|
deleteCircuitDeviceRow,
|
||||||
|
moveCircuitDeviceRowsBulk,
|
||||||
moveCircuitDeviceRow,
|
moveCircuitDeviceRow,
|
||||||
updateCircuitDeviceRow,
|
updateCircuitDeviceRow,
|
||||||
} from "../controllers/circuit-device-row.controller.js";
|
} from "../controllers/circuit-device-row.controller.js";
|
||||||
|
|
||||||
export const circuitDeviceRowRouter = Router();
|
export const circuitDeviceRowRouter = Router();
|
||||||
|
|
||||||
|
circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk);
|
||||||
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId", updateCircuitDeviceRow);
|
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId", updateCircuitDeviceRow);
|
||||||
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
|
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
|
||||||
circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow);
|
circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow);
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ export const moveCircuitDeviceRowSchema = z
|
|||||||
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
|
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const moveCircuitDeviceRowsBulkSchema = z
|
||||||
|
.object({
|
||||||
|
rowIds: z.array(z.string().min(1)).min(1),
|
||||||
|
targetCircuitId: z.string().min(1).optional(),
|
||||||
|
targetSectionId: z.string().min(1).optional(),
|
||||||
|
createNewCircuit: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(value) =>
|
||||||
|
Boolean(value.targetCircuitId) ||
|
||||||
|
(Boolean(value.targetSectionId) && value.createNewCircuit === true),
|
||||||
|
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
|
||||||
|
);
|
||||||
|
|
||||||
export const reorderSectionCircuitsSchema = z.object({
|
export const reorderSectionCircuitsSchema = z.object({
|
||||||
orderedCircuitIds: z.array(z.string().min(1)).min(1),
|
orderedCircuitIds: z.array(z.string().min(1)).min(1),
|
||||||
});
|
});
|
||||||
@@ -82,5 +96,6 @@ export type UpdateCircuitInput = z.infer<typeof updateCircuitSchema>;
|
|||||||
export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>;
|
export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>;
|
||||||
export type UpdateCircuitDeviceRowInput = z.infer<typeof updateCircuitDeviceRowSchema>;
|
export type UpdateCircuitDeviceRowInput = z.infer<typeof updateCircuitDeviceRowSchema>;
|
||||||
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
|
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
|
||||||
|
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
|
||||||
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
|
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
|
||||||
export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>;
|
export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>;
|
||||||
|
|||||||
@@ -373,6 +373,114 @@ describe("circuit write service rules", () => {
|
|||||||
assert.equal(createdCircuitPayload?.isReserve, false);
|
assert.equal(createdCircuitPayload?.isReserve, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("moving multiple device rows to a circuit preserves input order and toggles reserve", async () => {
|
||||||
|
const movedCalls: Array<{ rowId: string; targetCircuitId: string; sortOrder: number }> = [];
|
||||||
|
const reserveUpdates: Array<{ id: string; isReserve: boolean }> = [];
|
||||||
|
const service = new CircuitWriteService({
|
||||||
|
deviceRowRepository: {
|
||||||
|
async findById(rowId: string) {
|
||||||
|
if (rowId === "r1") {
|
||||||
|
return { id: "r1", circuitId: "c1" } as never;
|
||||||
|
}
|
||||||
|
return { id: "r2", circuitId: "c2" } as never;
|
||||||
|
},
|
||||||
|
async countByCircuit(circuitId: string) {
|
||||||
|
if (circuitId === "c3") {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
async moveToCircuit(rowId: string, targetCircuitId: string, sortOrder: number) {
|
||||||
|
movedCalls.push({ rowId, targetCircuitId, sortOrder });
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
circuitRepository: {
|
||||||
|
async findById(circuitId: string) {
|
||||||
|
if (circuitId === "c1") {
|
||||||
|
return { id: "c1", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 } as never;
|
||||||
|
}
|
||||||
|
if (circuitId === "c2") {
|
||||||
|
return { id: "c2", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F2", sortOrder: 20, isReserve: 0 } as never;
|
||||||
|
}
|
||||||
|
return { id: "c3", sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F3", sortOrder: 30, isReserve: 1 } as never;
|
||||||
|
},
|
||||||
|
async update(id: string, payload: { isReserve: boolean }) {
|
||||||
|
reserveUpdates.push({ id, isReserve: payload.isReserve });
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.moveDeviceRowsBulk({ rowIds: ["r1", "r2"], targetCircuitId: "c3" });
|
||||||
|
assert.deepEqual(movedCalls, [
|
||||||
|
{ rowId: "r1", targetCircuitId: "c3", sortOrder: 20 },
|
||||||
|
{ rowId: "r2", targetCircuitId: "c3", sortOrder: 30 },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(reserveUpdates, [
|
||||||
|
{ id: "c1", isReserve: true },
|
||||||
|
{ id: "c2", isReserve: true },
|
||||||
|
{ id: "c3", isReserve: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moving multiple device rows to placeholder creates exactly one new circuit", async () => {
|
||||||
|
let createCount = 0;
|
||||||
|
const service = new CircuitWriteService({
|
||||||
|
deviceRowRepository: {
|
||||||
|
async findById(rowId: string) {
|
||||||
|
return { id: rowId, circuitId: rowId === "r1" ? "c1" : "c2" } as never;
|
||||||
|
},
|
||||||
|
async countByCircuit(circuitId: string) {
|
||||||
|
if (circuitId === "c-new") {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
async moveToCircuit() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
circuitRepository: {
|
||||||
|
async findById(circuitId: string) {
|
||||||
|
if (circuitId === "c1" || circuitId === "c2") {
|
||||||
|
return { id: circuitId, sectionId: "s1", circuitListId: "l1", equipmentIdentifier: "-1F1", sortOrder: 10, isReserve: 0 } as never;
|
||||||
|
}
|
||||||
|
if (circuitId === "c-new") {
|
||||||
|
return { id: "c-new", sectionId: "s2", circuitListId: "l1", equipmentIdentifier: "-2F8", sortOrder: 40, isReserve: 0 } as never;
|
||||||
|
}
|
||||||
|
return null as never;
|
||||||
|
},
|
||||||
|
async listBySection() {
|
||||||
|
return [{ sortOrder: 30 }] as never[];
|
||||||
|
},
|
||||||
|
async create() {
|
||||||
|
createCount += 1;
|
||||||
|
return "c-new";
|
||||||
|
},
|
||||||
|
async update() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
circuitSectionRepository: {
|
||||||
|
async findById() {
|
||||||
|
return { id: "s2", circuitListId: "l1", prefix: "-2F" } as never;
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
numberingService: {
|
||||||
|
async getNextIdentifier() {
|
||||||
|
return "-2F8";
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.moveDeviceRowsBulk({
|
||||||
|
rowIds: ["r1", "r2"],
|
||||||
|
targetSectionId: "s2",
|
||||||
|
createNewCircuit: true,
|
||||||
|
});
|
||||||
|
assert.equal(createCount, 1);
|
||||||
|
assert.equal(result.createdCircuitId, "c-new");
|
||||||
|
});
|
||||||
|
|
||||||
it("reorders circuits inside one section without renumbering identifiers", async () => {
|
it("reorders circuits inside one section without renumbering identifiers", async () => {
|
||||||
const updates: Array<{ id: string; sortOrder: number; equipmentIdentifier: string }> = [];
|
const updates: Array<{ id: string; sortOrder: number; equipmentIdentifier: string }> = [];
|
||||||
const service = new CircuitWriteService({
|
const service = new CircuitWriteService({
|
||||||
|
|||||||
Reference in New Issue
Block a user