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;
|
||||
}
|
||||
|
||||
.tree-grid tr.row-selected td {
|
||||
background: #eaf1ff;
|
||||
}
|
||||
|
||||
.tree-grid .placeholder-row td {
|
||||
background: #f7f7f7;
|
||||
color: #6b7280;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CreateCircuitDeviceRowInput,
|
||||
CreateCircuitInput,
|
||||
MoveCircuitDeviceRowInput,
|
||||
MoveCircuitDeviceRowsBulkInput,
|
||||
ReorderSectionCircuitsInput,
|
||||
UpdateSectionEquipmentIdentifiersInput,
|
||||
UpdateCircuitDeviceRowInput,
|
||||
@@ -365,6 +366,128 @@ export class CircuitWriteService {
|
||||
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) {
|
||||
return this.numberingService.getNextIdentifier(sectionId);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getCircuitTree,
|
||||
getNextCircuitIdentifier,
|
||||
listProjectDevices,
|
||||
moveCircuitDeviceRowsBulk,
|
||||
moveCircuitDeviceRowById,
|
||||
reorderSectionCircuits,
|
||||
renumberCircuitSection,
|
||||
@@ -467,6 +468,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | 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 [activeSectionId, setActiveSectionId] = useState<string | null>(null);
|
||||
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 [dropIntent, setDropIntent] = useState<ProjectDeviceDropIntent | null>(null);
|
||||
const [draggingDeviceRowId, setDraggingDeviceRowId] = useState<string | null>(null);
|
||||
const [draggingDeviceRowIds, setDraggingDeviceRowIds] = useState<string[]>([]);
|
||||
const [deviceMoveIntent, setDeviceMoveIntent] = useState<DeviceRowMoveDropIntent | null>(null);
|
||||
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
|
||||
const [draggingCircuitIds, setDraggingCircuitIds] = useState<string[]>([]);
|
||||
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null);
|
||||
const [undoStack, setUndoStack] = 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 [pendingFocus, setPendingFocus] = useState<SelectedCell | 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 inputRef = useRef<HTMLInputElement | null>(null);
|
||||
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, 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(() => {
|
||||
if (!selectedCell && editableCells.length > 0) {
|
||||
@@ -819,6 +839,64 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}
|
||||
}, [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 {
|
||||
const row = findRow(cell.rowKey);
|
||||
if (!row) {
|
||||
@@ -1102,6 +1180,46 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
requestAnimationFrame(() => containerRef.current?.focus());
|
||||
}, [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(() => {
|
||||
if (!pendingFocus) {
|
||||
return;
|
||||
@@ -1745,6 +1863,38 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
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) {
|
||||
for (const section of data?.sections ?? []) {
|
||||
for (const circuit of section.circuits) {
|
||||
@@ -1756,6 +1906,46 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
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) {
|
||||
for (const section of data?.sections ?? []) {
|
||||
if (section.circuits.some((circuit) => circuit.id === circuitId)) {
|
||||
@@ -1765,28 +1955,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
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);
|
||||
if (!section) {
|
||||
throw new Error("Invalid section id.");
|
||||
}
|
||||
const ids = section.circuits.map((circuit) => circuit.id);
|
||||
const fromIndex = ids.indexOf(sourceCircuitId);
|
||||
if (fromIndex < 0) {
|
||||
const block = sourceCircuitIds.filter((id) => ids.includes(id));
|
||||
if (block.length === 0) {
|
||||
throw new Error("Invalid source circuit.");
|
||||
}
|
||||
const nextIds = [...ids];
|
||||
nextIds.splice(fromIndex, 1);
|
||||
const blockSet = new Set(block);
|
||||
const nextIds = ids.filter((id) => !blockSet.has(id));
|
||||
|
||||
if (intent.kind === "section-end") {
|
||||
nextIds.push(sourceCircuitId);
|
||||
nextIds.push(...block);
|
||||
} else {
|
||||
const targetIndex = nextIds.indexOf(intent.targetCircuitId);
|
||||
if (targetIndex < 0) {
|
||||
throw new Error("Invalid target circuit.");
|
||||
}
|
||||
const insertIndex = intent.kind === "after-circuit" ? targetIndex + 1 : targetIndex;
|
||||
nextIds.splice(insertIndex, 0, sourceCircuitId);
|
||||
nextIds.splice(insertIndex, 0, ...block);
|
||||
}
|
||||
await reorderSectionCircuits(section.id, nextIds);
|
||||
}
|
||||
@@ -1797,6 +1987,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
const draggedId = parseDraggedProjectDeviceId(event) ?? draggingProjectDeviceId;
|
||||
setDropIntent(null);
|
||||
setDraggingProjectDeviceId(null);
|
||||
setDraggingDeviceRowIds([]);
|
||||
setDraggingCircuitIds([]);
|
||||
if (!draggedId) {
|
||||
setError("Missing dragged project device.");
|
||||
return;
|
||||
@@ -1858,32 +2050,57 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
async function handleDeviceRowDropWithIntent(event: DragEvent<HTMLElement>, intent: DeviceRowMoveDropIntent) {
|
||||
event.preventDefault();
|
||||
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);
|
||||
setDraggingDeviceRowId(null);
|
||||
if (!rowId) {
|
||||
setDraggingDeviceRowIds([]);
|
||||
setDraggingCircuitIds([]);
|
||||
if (rowIds.length === 0) {
|
||||
setError("Missing dragged device row.");
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceByRowId = new Map<string, string>();
|
||||
for (const rowId of rowIds) {
|
||||
const sourceCircuitId = findDeviceRowCircuitId(rowId);
|
||||
if (!sourceCircuitId) {
|
||||
setError("Invalid dragged device row.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (intent.kind === "move-to-circuit") {
|
||||
if (intent.circuitId === sourceCircuitId) {
|
||||
sourceByRowId.set(rowId, sourceCircuitId);
|
||||
}
|
||||
const sourceCircuitId = sourceByRowId.get(rowIds[0]);
|
||||
if (!sourceCircuitId) {
|
||||
setError("Invalid dragged device row source.");
|
||||
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({
|
||||
label: "Move device row",
|
||||
label: rowIds.length > 1 ? `Move ${rowIds.length} device rows` : "Move device row",
|
||||
redo: async () => {
|
||||
await moveCircuitDeviceRowById(rowId, { targetCircuitId: intent.circuitId });
|
||||
await moveCircuitDeviceRowsBulk({ rowIds, targetCircuitId: intent.circuitId });
|
||||
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||
return null;
|
||||
},
|
||||
undo: async () => {
|
||||
await moveCircuitDeviceRowById(rowId, { targetCircuitId: sourceCircuitId });
|
||||
for (const [source, ids] of groupedBySource) {
|
||||
await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source });
|
||||
}
|
||||
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@@ -1891,21 +2108,34 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}
|
||||
|
||||
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({
|
||||
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 () => {
|
||||
const moved = (await moveCircuitDeviceRowById(rowId, {
|
||||
const moved = await moveCircuitDeviceRowsBulk({
|
||||
rowIds,
|
||||
targetSectionId: intent.sectionId,
|
||||
createNewCircuit: true,
|
||||
})) as { circuitId?: string };
|
||||
createdCircuitId = moved.circuitId ?? null;
|
||||
});
|
||||
createdCircuitId = moved.createdCircuitId ?? null;
|
||||
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||
return null;
|
||||
},
|
||||
undo: async () => {
|
||||
await moveCircuitDeviceRowById(rowId, { targetCircuitId: sourceCircuitId });
|
||||
for (const [source, ids] of groupedBySource) {
|
||||
await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source });
|
||||
}
|
||||
if (createdCircuitId) {
|
||||
await deleteCircuitById(createdCircuitId);
|
||||
}
|
||||
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@@ -1914,10 +2144,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
async function handleCircuitReorderDrop(event: DragEvent<HTMLElement>, intent: CircuitReorderDropIntent) {
|
||||
event.preventDefault();
|
||||
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);
|
||||
setDraggingCircuitId(null);
|
||||
if (!sourceCircuitId) {
|
||||
setDraggingCircuitIds([]);
|
||||
if (sourceCircuitIds.length === 0) {
|
||||
setError("Missing dragged circuit.");
|
||||
return;
|
||||
}
|
||||
@@ -1930,27 +2163,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
setError("Invalid section id.");
|
||||
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 primaryCircuitId = sourceCircuitIds[0];
|
||||
await runCommand({
|
||||
label: "Reorder circuits",
|
||||
label: sourceCircuitIds.length > 1 ? `Reorder ${sourceCircuitIds.length} circuits` : "Reorder circuits",
|
||||
redo: async () => {
|
||||
await applyCircuitReorder(intent, sourceCircuitId);
|
||||
await applyCircuitReorder(intent, sourceCircuitIds);
|
||||
pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds;
|
||||
return {
|
||||
rowKey: `circuitSummary:${sourceCircuitId}`,
|
||||
rowKey: `circuitSummary:${primaryCircuitId}`,
|
||||
cellKey: "equipmentIdentifier",
|
||||
rowType: "circuitSummary",
|
||||
sectionId: intent.sectionId,
|
||||
circuitId: sourceCircuitId,
|
||||
circuitId: primaryCircuitId,
|
||||
};
|
||||
},
|
||||
undo: async () => {
|
||||
await reorderSectionCircuits(intent.sectionId, beforeOrder);
|
||||
pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds;
|
||||
return {
|
||||
rowKey: `circuitSummary:${sourceCircuitId}`,
|
||||
rowKey: `circuitSummary:${primaryCircuitId}`,
|
||||
cellKey: "equipmentIdentifier",
|
||||
rowType: "circuitSummary",
|
||||
sectionId: intent.sectionId,
|
||||
circuitId: sourceCircuitId,
|
||||
circuitId: primaryCircuitId,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -2077,6 +2318,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
if (selectedRowKeys.length > 0) {
|
||||
event.preventDefault();
|
||||
setSelectedRowKeys([]);
|
||||
setAnchorRowKey(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.ctrlKey && event.key.toLowerCase() === "z") {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
@@ -2229,6 +2478,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
|
||||
const isSortedView = Boolean(sortState);
|
||||
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 (
|
||||
<div className="tree-editor-shell">
|
||||
@@ -2335,7 +2588,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
setSelectedProjectDeviceId(device.id);
|
||||
setDraggingProjectDeviceId(device.id);
|
||||
setDraggingDeviceRowId(null);
|
||||
setDraggingDeviceRowIds([]);
|
||||
setDraggingCircuitId(null);
|
||||
setDraggingCircuitIds([]);
|
||||
setDeviceMoveIntent(null);
|
||||
setCircuitReorderIntent(null);
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
@@ -2344,6 +2599,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
onDragEnd={() => {
|
||||
setDraggingProjectDeviceId(null);
|
||||
setDropIntent(null);
|
||||
setDraggingDeviceRowIds([]);
|
||||
setDraggingCircuitIds([]);
|
||||
}}
|
||||
>
|
||||
<strong>{device.displayName || device.name}</strong>
|
||||
@@ -2401,6 +2658,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
tabIndex={0}
|
||||
onFocus={handleContainerFocus}
|
||||
onKeyDown={handleContainerKeyDown}
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
setSelectedRowKeys([]);
|
||||
setAnchorRowKey(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<table className="tree-grid">
|
||||
<thead>
|
||||
@@ -2490,7 +2753,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? "drop-target-active" : ""
|
||||
}`}
|
||||
onDragOver={(event) => {
|
||||
if (draggingCircuitId) {
|
||||
if (draggingCircuitCount > 0) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "none";
|
||||
setCircuitReorderIntent({
|
||||
@@ -2520,7 +2783,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: section.id });
|
||||
return;
|
||||
}
|
||||
if (draggingCircuitId) {
|
||||
if (draggingCircuitCount > 0) {
|
||||
void handleCircuitReorderDrop(event, {
|
||||
kind: "section-end",
|
||||
sectionId: section.id,
|
||||
@@ -2613,26 +2876,31 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
? "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)}
|
||||
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") {
|
||||
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
|
||||
const valid = sourceSectionId === row.sectionId;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = valid ? "move" : "none";
|
||||
setCircuitReorderIntent({ kind: "section-end", sectionId: row.sectionId, valid });
|
||||
return;
|
||||
}
|
||||
if (row.circuit && row.rowType !== "deviceRow") {
|
||||
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
|
||||
if (row.circuit.id === draggingCircuitId) {
|
||||
if (activeDraggedCircuitIds.includes(row.circuit.id)) {
|
||||
setCircuitReorderIntent(null);
|
||||
return;
|
||||
}
|
||||
const valid = sourceSectionId === row.sectionId;
|
||||
const rect = (event.currentTarget as HTMLTableRowElement).getBoundingClientRect();
|
||||
const isAfter = event.clientY > rect.top + rect.height / 2;
|
||||
event.preventDefault();
|
||||
@@ -2660,7 +2928,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (draggingDeviceRowId) {
|
||||
if (draggingDeviceCount > 0) {
|
||||
if (row.rowType === "placeholder") {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
@@ -2668,8 +2936,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
return;
|
||||
}
|
||||
if (row.circuit && row.rowType !== "deviceRow") {
|
||||
const sourceCircuitId = findDeviceRowCircuitId(draggingDeviceRowId);
|
||||
if (sourceCircuitId && sourceCircuitId !== row.circuit.id) {
|
||||
const sourceCircuitIds = (draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds : draggingDeviceRowId ? [draggingDeviceRowId] : [])
|
||||
.map((id) => findDeviceRowCircuitId(id))
|
||||
.filter((id): id is string => Boolean(id));
|
||||
if (sourceCircuitIds.some((sourceCircuitId) => sourceCircuitId !== row.circuit!.id)) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
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) => {
|
||||
if (draggingCircuitId) {
|
||||
if (draggingCircuitCount > 0) {
|
||||
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, {
|
||||
kind: "section-end",
|
||||
sectionId: row.sectionId,
|
||||
valid: sourceSectionId === row.sectionId,
|
||||
valid: sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === row.sectionId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (row.circuit && row.rowType !== "deviceRow") {
|
||||
if (row.circuit.id === draggingCircuitId) {
|
||||
if (activeDraggedCircuitIds.includes(row.circuit.id)) {
|
||||
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 isAfter = event.clientY > rect.top + rect.height / 2;
|
||||
void handleCircuitReorderDrop(event, {
|
||||
kind: isAfter ? "after-circuit" : "before-circuit",
|
||||
sectionId: row.sectionId,
|
||||
targetCircuitId: row.circuit.id,
|
||||
valid: sourceSectionId === row.sectionId,
|
||||
valid: sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === row.sectionId),
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -2759,7 +3033,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (draggingDeviceRowId) {
|
||||
if (draggingDeviceCount > 0) {
|
||||
if (row.rowType === "placeholder") {
|
||||
void handleDeviceRowDropWithIntent(event, { kind: "move-to-new-circuit", sectionId: row.sectionId });
|
||||
return;
|
||||
@@ -2794,7 +3068,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
? "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={
|
||||
!isEditing &&
|
||||
@@ -2815,14 +3093,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
row.rowType === "circuitSummary" ||
|
||||
row.rowType === "reserveCircuit")
|
||||
) {
|
||||
const circuitIds = getSelectedEligibleCircuitIds(row.circuit.id);
|
||||
setDraggingProjectDeviceId(null);
|
||||
setDropIntent(null);
|
||||
setDraggingDeviceRowId(null);
|
||||
setDraggingDeviceRowIds([]);
|
||||
setDeviceMoveIntent(null);
|
||||
setDraggingCircuitId(row.circuit.id);
|
||||
setDraggingCircuitIds(circuitIds);
|
||||
setCircuitReorderIntent(null);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("application/x-circuit-id", row.circuit.id);
|
||||
event.dataTransfer.setData("application/x-circuit-ids", JSON.stringify(circuitIds));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
@@ -2830,26 +3112,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
column.key === "displayName" &&
|
||||
(row.rowType === "deviceRow" || row.rowType === "circuitCompact")
|
||||
) {
|
||||
const rowIds = getSelectedEligibleDeviceRowIds(row.device.id);
|
||||
setDraggingProjectDeviceId(null);
|
||||
setDropIntent(null);
|
||||
setDraggingCircuitId(null);
|
||||
setDraggingCircuitIds([]);
|
||||
setCircuitReorderIntent(null);
|
||||
setDraggingDeviceRowId(row.device.id);
|
||||
setDraggingDeviceRowIds(rowIds);
|
||||
setDeviceMoveIntent(null);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
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={() => {
|
||||
setDraggingDeviceRowId(null);
|
||||
setDraggingDeviceRowIds([]);
|
||||
setDeviceMoveIntent(null);
|
||||
setDraggingCircuitId(null);
|
||||
setDraggingCircuitIds([]);
|
||||
setCircuitReorderIntent(null);
|
||||
}}
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
if (cell.editable) {
|
||||
setSelectedCell({ rowKey: row.rowKey, cellKey: column.key });
|
||||
requestAnimationFrame(() => containerRef.current?.focus());
|
||||
handleRowSelectionClick(row, column.key, {
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
shiftKey: event.shiftKey,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
@@ -2935,16 +3226,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
{deviceMoveIntent?.kind === "move-to-new-circuit" &&
|
||||
row.rowType === "placeholder" &&
|
||||
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}
|
||||
{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}
|
||||
{circuitReorderIntent?.kind === "section-end" &&
|
||||
row.rowType === "placeholder" &&
|
||||
circuitReorderIntent.sectionId === row.sectionId ? (
|
||||
<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>
|
||||
) : null}
|
||||
{circuitReorderIntent &&
|
||||
@@ -2953,8 +3246,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
<span className="drop-hint">
|
||||
{circuitReorderIntent.valid
|
||||
? circuitReorderIntent.kind === "before-circuit"
|
||||
? "move circuit before this circuit"
|
||||
: "move circuit after this circuit"
|
||||
? `move ${draggingCircuitCount || 1} circuit${draggingCircuitCount === 1 ? "" : "s"} before this circuit`
|
||||
: `move ${draggingCircuitCount || 1} circuit${draggingCircuitCount === 1 ? "" : "s"} after this circuit`
|
||||
: "cross-section move not allowed"}
|
||||
</span>
|
||||
) : 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) {
|
||||
return request(`/api/circuit-sections/${sectionId}/renumber`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Request, Response } from "express";
|
||||
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
|
||||
import {
|
||||
createCircuitDeviceRowSchema,
|
||||
moveCircuitDeviceRowsBulkSchema,
|
||||
moveCircuitDeviceRowSchema,
|
||||
updateCircuitDeviceRowSchema,
|
||||
} 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." });
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
deleteCircuitDeviceRow,
|
||||
moveCircuitDeviceRowsBulk,
|
||||
moveCircuitDeviceRow,
|
||||
updateCircuitDeviceRow,
|
||||
} from "../controllers/circuit-device-row.controller.js";
|
||||
|
||||
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/move", moveCircuitDeviceRow);
|
||||
circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow);
|
||||
|
||||
@@ -62,6 +62,20 @@ export const moveCircuitDeviceRowSchema = z
|
||||
{ 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({
|
||||
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 UpdateCircuitDeviceRowInput = z.infer<typeof updateCircuitDeviceRowSchema>;
|
||||
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
|
||||
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
|
||||
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
|
||||
export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>;
|
||||
|
||||
@@ -373,6 +373,114 @@ describe("circuit write service rules", () => {
|
||||
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 () => {
|
||||
const updates: Array<{ id: string; sortOrder: number; equipmentIdentifier: string }> = [];
|
||||
const service = new CircuitWriteService({
|
||||
|
||||
Reference in New Issue
Block a user