diff --git a/docs/anforderungs-abgleich.md b/docs/anforderungs-abgleich.md deleted file mode 100644 index 0490baa..0000000 --- a/docs/anforderungs-abgleich.md +++ /dev/null @@ -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. diff --git a/docs/electrical-load-balance-requirements-context-dump.md b/docs/electrical-load-balance-requirements-context-dump.md deleted file mode 100644 index 8a47b9b..0000000 --- a/docs/electrical-load-balance-requirements-context-dump.md +++ /dev/null @@ -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 -- 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 -``` - -## 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 -``` - -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 -- projectDeviceList: DeviceList - -DistributionBoard -- id -- name -- circuitList: CircuitList - -CircuitList -- id -- name -- entries: List - -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 -``` - -## 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. diff --git a/src/app/globals.css b/src/app/globals.css index 6fb8f82..c1bfb11 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -347,6 +347,10 @@ body { background: #f8fbff; } +.tree-grid tr.row-selected td { + background: #eaf1ff; +} + .tree-grid .placeholder-row td { background: #f7f7f7; color: #6b7280; diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts index bd0c212..563fb4f 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -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>>(); + 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); } diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 24ac1ec..c9c5add 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -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(null); const [selectedCell, setSelectedCell] = useState(null); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + const [anchorRowKey, setAnchorRowKey] = useState(null); const [editingCell, setEditingCell] = useState(null); const [activeSectionId, setActiveSectionId] = useState(null); const [isSaving, setIsSaving] = useState(false); @@ -478,8 +481,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const [draggingProjectDeviceId, setDraggingProjectDeviceId] = useState(null); const [dropIntent, setDropIntent] = useState(null); const [draggingDeviceRowId, setDraggingDeviceRowId] = useState(null); + const [draggingDeviceRowIds, setDraggingDeviceRowIds] = useState([]); const [deviceMoveIntent, setDeviceMoveIntent] = useState(null); const [draggingCircuitId, setDraggingCircuitId] = useState(null); + const [draggingCircuitIds, setDraggingCircuitIds] = useState([]); const [circuitReorderIntent, setCircuitReorderIntent] = useState(null); const [undoStack, setUndoStack] = useState([]); const [redoStack, setRedoStack] = useState([]); @@ -494,6 +499,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const [columnDropTargetKey, setColumnDropTargetKey] = useState(null); const [pendingFocus, setPendingFocus] = useState(null); const pendingSelectionAfterReload = useRef(null); + const pendingSelectedDeviceRowIdsAfterReload = useRef(null); + const pendingSelectedCircuitIdsAfterReload = useRef(null); const containerRef = useRef(null); const inputRef = useRef(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) { + 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) { + 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(); + 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, 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 sourceCircuitId = findDeviceRowCircuitId(rowId); + const sourceByRowId = new Map(); + for (const rowId of rowIds) { + const sourceCircuitId = findDeviceRowCircuitId(rowId); + if (!sourceCircuitId) { + setError("Invalid dragged device row."); + return; + } + sourceByRowId.set(rowId, sourceCircuitId); + } + const sourceCircuitId = sourceByRowId.get(rowIds[0]); if (!sourceCircuitId) { - setError("Invalid dragged device row."); + setError("Invalid dragged device row source."); return; } if (intent.kind === "move-to-circuit") { - if (intent.circuitId === sourceCircuitId) { + if (rowIds.length === 1 && intent.circuitId === sourceCircuitId) { return; } + const groupedBySource = new Map(); + 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(); + 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, 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 (
@@ -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([]); }} > {device.displayName || device.name} @@ -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); + } + }} > @@ -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 ? ( - move device to new circuit + {`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to new circuit`} ) : null} {deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? ( - move device to this circuit + {`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to this circuit`} ) : null} {circuitReorderIntent?.kind === "section-end" && row.rowType === "placeholder" && circuitReorderIntent.sectionId === row.sectionId ? ( - {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"} ) : null} {circuitReorderIntent && @@ -2953,8 +3246,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str {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"} ) : null} diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 4357c05..d176e95 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -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", diff --git a/src/server/controllers/circuit-device-row.controller.ts b/src/server/controllers/circuit-device-row.controller.ts index d0d25dc..17a0000 100644 --- a/src/server/controllers/circuit-device-row.controller.ts +++ b/src/server/controllers/circuit-device-row.controller.ts @@ -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." }); + } +} diff --git a/src/server/routes/circuit-device-row.routes.ts b/src/server/routes/circuit-device-row.routes.ts index 4591ad6..cb2dbab 100644 --- a/src/server/routes/circuit-device-row.routes.ts +++ b/src/server/routes/circuit-device-row.routes.ts @@ -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); diff --git a/src/shared/validation/circuit.schemas.ts b/src/shared/validation/circuit.schemas.ts index 5483e8e..b78afcd 100644 --- a/src/shared/validation/circuit.schemas.ts +++ b/src/shared/validation/circuit.schemas.ts @@ -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; export type CreateCircuitDeviceRowInput = z.infer; export type UpdateCircuitDeviceRowInput = z.infer; export type MoveCircuitDeviceRowInput = z.infer; +export type MoveCircuitDeviceRowsBulkInput = z.infer; export type ReorderSectionCircuitsInput = z.infer; export type UpdateSectionEquipmentIdentifiersInput = z.infer; diff --git a/tests/circuit-write.rules.test.ts b/tests/circuit-write.rules.test.ts index 4acab38..1ff7591 100644 --- a/tests/circuit-write.rules.test.ts +++ b/tests/circuit-write.rules.test.ts @@ -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({