diff --git a/src/app/globals.css b/src/app/globals.css index 589c389..560cc7c 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -106,6 +106,11 @@ body { background: #edf3ff; } +.project-device-item.dragging { + opacity: 0.55; + border-style: dashed; +} + .sidebar-actions { display: flex; flex-direction: column; @@ -158,6 +163,30 @@ body { font-weight: 600; } +.tree-grid .section-drop-cell { + padding: 0.45rem 0.6rem; +} + +.tree-grid .section-content { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.tree-grid .section-actions { + display: flex; + gap: 0.35rem; +} + +.tree-grid .section-actions button { + border: 1px solid #c4cddc; + background: #fff; + padding: 0.2rem 0.45rem; + border-radius: 3px; + font-size: 0.78rem; +} + .tree-grid .summary-row td { background: #f3f7fd; } @@ -180,6 +209,14 @@ body { cursor: text; } +.tree-grid .device-drag-handle { + cursor: grab; +} + +.tree-grid .device-drag-handle.device-dragging { + opacity: 0.55; +} + .tree-grid .cell-selected { outline: 2px solid #4c7dd9; outline-offset: -2px; @@ -206,6 +243,17 @@ body { font-size: 0.78rem; } +.tree-grid .drop-target-active { + box-shadow: inset 0 0 0 2px #4c7dd9; + background: #eef4ff !important; +} + +.drop-hint { + font-size: 0.75rem; + color: #1f4ea3; + font-weight: 600; +} + .notice { padding: 0.5rem 0.75rem; border-radius: 4px; diff --git a/src/db/repositories/circuit-device-row.repository.ts b/src/db/repositories/circuit-device-row.repository.ts index 38bdbc9..ad94294 100644 --- a/src/db/repositories/circuit-device-row.repository.ts +++ b/src/db/repositories/circuit-device-row.repository.ts @@ -123,4 +123,14 @@ export class CircuitDeviceRowRepository { async delete(rowId: string) { await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)); } + + async moveToCircuit(rowId: string, targetCircuitId: string, sortOrder: number) { + await db + .update(circuitDeviceRows) + .set({ + circuitId: targetCircuitId, + sortOrder, + }) + .where(eq(circuitDeviceRows.id, rowId)); + } } diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts index aaf0dbd..5b31a59 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -6,6 +6,7 @@ import { ProjectDeviceRepository } from "../../db/repositories/project-device.re import type { CreateCircuitDeviceRowInput, CreateCircuitInput, + MoveCircuitDeviceRowInput, UpdateCircuitDeviceRowInput, UpdateCircuitInput, } from "../../shared/validation/circuit.schemas.js"; @@ -265,6 +266,103 @@ export class CircuitWriteService { } } + async moveDeviceRow(rowId: string, input: MoveCircuitDeviceRowInput) { + const row = await this.deviceRowRepository.findById(rowId); + if (!row) { + throw new Error("Invalid device row id."); + } + + const sourceCircuit = await this.circuitRepository.findById(row.circuitId); + if (!sourceCircuit) { + throw new Error("Invalid circuit id."); + } + + 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, sourceCircuit.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: sourceCircuit.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."); + } + } + + if (targetCircuit.circuitListId !== sourceCircuit.circuitListId) { + throw new Error("Target circuit does not belong to same circuit list."); + } + if (targetCircuit.id === sourceCircuit.id) { + return this.deviceRowRepository.findById(rowId); + } + + const targetCount = await this.deviceRowRepository.countByCircuit(targetCircuit.id); + await this.deviceRowRepository.moveToCircuit(rowId, targetCircuit.id, (targetCount + 1) * 10); + + const sourceRemaining = await this.deviceRowRepository.countByCircuit(sourceCircuit.id); + if (sourceRemaining === 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 this.deviceRowRepository.findById(rowId); + } + 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 429933f..154ae2d 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -1,6 +1,6 @@ "use client"; -import { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; +import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; import { createCircuit, createCircuitDeviceRow, @@ -9,6 +9,7 @@ import { getCircuitTree, getNextCircuitIdentifier, listProjectDevices, + moveCircuitDeviceRowById, renumberCircuitSection, updateCircuitById, updateCircuitDeviceRowById, @@ -74,6 +75,14 @@ interface VisibleGridRow { cells: VisibleGridCell[]; } +type ProjectDeviceDropIntent = + | { kind: "new-circuit"; sectionId: string } + | { kind: "add-to-circuit"; circuitId: string; sectionId: string }; + +type DeviceRowMoveDropIntent = + | { kind: "move-to-circuit"; circuitId: string; sectionId: string } + | { kind: "move-to-new-circuit"; sectionId: string }; + const columns: Array<{ key: CellKey; label: string; numeric?: boolean }> = [ { key: "equipmentIdentifier", label: "Equipment identifier" }, { key: "displayName", label: "Display name" }, @@ -261,6 +270,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const [selectedProjectDeviceId, setSelectedProjectDeviceId] = useState(null); const [targetSectionId, setTargetSectionId] = useState(null); const [targetCircuitId, setTargetCircuitId] = useState(null); + const [draggingProjectDeviceId, setDraggingProjectDeviceId] = useState(null); + const [dropIntent, setDropIntent] = useState(null); + const [draggingDeviceRowId, setDraggingDeviceRowId] = useState(null); + const [deviceMoveIntent, setDeviceMoveIntent] = useState(null); const [pendingFocus, setPendingFocus] = useState(null); const pendingSelectionAfterReload = useRef(null); const containerRef = useRef(null); @@ -828,37 +841,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str try { setError(null); setIsSaving(true); - const section = data?.sections.find((entry) => entry.id === targetSectionId); - if (!section) { - throw new Error("Invalid target section."); - } - const next = await getNextCircuitIdentifier(targetSectionId); - const sortOrder = - section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; - const createdCircuit = (await createCircuit(projectId, circuitListId, { - sectionId: targetSectionId, - equipmentIdentifier: next.nextIdentifier, - displayName: device.displayName || device.name, - sortOrder, - isReserve: false, - })) as CircuitTreeCircuitDto; - - const createdRow = (await createCircuitDeviceRow(createdCircuit.id, { - linkedProjectDeviceId: device.id, - name: device.name, - displayName: device.displayName, - phaseType: resolvePhaseType(device), - quantity: device.quantity, - powerPerUnit: device.installedPowerPerUnitKw, - simultaneityFactor: device.demandFactor, - cosPhi: device.powerFactor ?? undefined, - category: device.category ?? undefined, - })) as { id: string }; - - await loadTree({ showLoading: false }); - setActiveSectionId(targetSectionId); - setTargetCircuitId(createdCircuit.id); - setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" }); + await insertProjectDeviceAsNewCircuit(device, targetSectionId); } catch (err) { setError(normalizeUiError(err)); } finally { @@ -887,19 +870,146 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str try { setError(null); setIsSaving(true); - const createdRow = (await createCircuitDeviceRow(circuitId, { - linkedProjectDeviceId: device.id, - name: device.name, - displayName: device.displayName, - phaseType: resolvePhaseType(device), - quantity: device.quantity, - powerPerUnit: device.installedPowerPerUnitKw, - simultaneityFactor: device.demandFactor, - cosPhi: device.powerFactor ?? undefined, - category: device.category ?? undefined, - })) as { id: string }; + await insertProjectDeviceToCircuit(device, circuitId); + } catch (err) { + setError(normalizeUiError(err)); + } finally { + setIsSaving(false); + } + } + + async function insertProjectDeviceAsNewCircuit(device: ProjectDeviceDto, sectionId: string) { + const section = data?.sections.find((entry) => entry.id === sectionId); + if (!section) { + throw new Error("Invalid target section."); + } + const next = await getNextCircuitIdentifier(sectionId); + const sortOrder = + section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; + const createdCircuit = (await createCircuit(projectId, circuitListId, { + sectionId, + equipmentIdentifier: next.nextIdentifier, + displayName: device.displayName || device.name, + sortOrder, + isReserve: false, + })) as CircuitTreeCircuitDto; + + const createdRow = (await createCircuitDeviceRow(createdCircuit.id, { + linkedProjectDeviceId: device.id, + name: device.name, + displayName: device.displayName, + phaseType: resolvePhaseType(device), + quantity: device.quantity, + powerPerUnit: device.installedPowerPerUnitKw, + simultaneityFactor: device.demandFactor, + cosPhi: device.powerFactor ?? undefined, + category: device.category ?? undefined, + })) as { id: string }; + + await loadTree({ showLoading: false }); + setActiveSectionId(sectionId); + setTargetCircuitId(createdCircuit.id); + setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" }); + } + + async function insertProjectDeviceToCircuit(device: ProjectDeviceDto, circuitId: string) { + const createdRow = (await createCircuitDeviceRow(circuitId, { + linkedProjectDeviceId: device.id, + name: device.name, + displayName: device.displayName, + phaseType: resolvePhaseType(device), + quantity: device.quantity, + powerPerUnit: device.installedPowerPerUnitKw, + simultaneityFactor: device.demandFactor, + cosPhi: device.powerFactor ?? undefined, + category: device.category ?? undefined, + })) as { id: string }; + await loadTree({ showLoading: false }); + setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" }); + } + + function parseDraggedProjectDeviceId(event: DragEvent) { + return event.dataTransfer.getData("application/x-project-device-id") || null; + } + + function parseDraggedDeviceRowId(event: DragEvent) { + return event.dataTransfer.getData("application/x-circuit-device-row-id") || null; + } + + function findDeviceRowCircuitId(deviceRowId: string) { + for (const section of data?.sections ?? []) { + for (const circuit of section.circuits) { + if (circuit.deviceRows.some((row) => row.id === deviceRowId)) { + return circuit.id; + } + } + } + return null; + } + + async function handleDropWithIntent(event: DragEvent, intent: ProjectDeviceDropIntent) { + event.preventDefault(); + event.stopPropagation(); + const draggedId = parseDraggedProjectDeviceId(event) ?? draggingProjectDeviceId; + setDropIntent(null); + setDraggingProjectDeviceId(null); + if (!draggedId) { + setError("Missing dragged project device."); + return; + } + const device = projectDevices.find((entry) => entry.id === draggedId); + if (!device) { + setError("Invalid project device drop source."); + return; + } + try { + setError(null); + setIsSaving(true); + if (intent.kind === "new-circuit") { + await insertProjectDeviceAsNewCircuit(device, intent.sectionId); + } else { + await insertProjectDeviceToCircuit(device, intent.circuitId); + } + } catch (err) { + setError(normalizeUiError(err)); + } finally { + setIsSaving(false); + } + } + + async function handleDeviceRowDropWithIntent(event: DragEvent, intent: DeviceRowMoveDropIntent) { + event.preventDefault(); + event.stopPropagation(); + const rowId = parseDraggedDeviceRowId(event) ?? draggingDeviceRowId; + setDeviceMoveIntent(null); + setDraggingDeviceRowId(null); + if (!rowId) { + setError("Missing dragged device row."); + return; + } + + const sourceCircuitId = findDeviceRowCircuitId(rowId); + if (!sourceCircuitId) { + setError("Invalid dragged device row."); + return; + } + + try { + setError(null); + setIsSaving(true); + if (intent.kind === "move-to-circuit") { + if (intent.circuitId === sourceCircuitId) { + return; + } + await moveCircuitDeviceRowById(rowId, { targetCircuitId: intent.circuitId }); + } else { + await moveCircuitDeviceRowById(rowId, { + targetSectionId: intent.sectionId, + createNewCircuit: true, + }); + } await loadTree({ showLoading: false }); - setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" }); + setPendingFocus({ rowKey: `device:${rowId}`, cellKey: "displayName" }); } catch (err) { setError(normalizeUiError(err)); } finally { @@ -1057,8 +1167,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str - + { + if (!draggingProjectDeviceId) { + return; + } + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setDropIntent({ kind: "new-circuit", sectionId: section.id }); + }} + onDragLeave={() => { + if (dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id) { + setDropIntent(null); + } + }} + onDrop={(event) => void handleDropWithIntent(event, { kind: "new-circuit", sectionId: section.id })} + > + +
+ {section.displayName} +
+ + +
+
+ {dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? ( + drop here to create new circuit + ) : null} ); @@ -1151,7 +1298,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return ( setActiveSectionId(row.sectionId)} + onDragOver={(event) => { + if (draggingProjectDeviceId) { + if (row.rowType === "placeholder") { + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setDropIntent({ kind: "new-circuit", sectionId: row.sectionId }); + return; + } + if (row.circuit && row.rowType !== "deviceRow") { + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + setDropIntent({ kind: "add-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId }); + } + return; + } + if (draggingDeviceRowId) { + if (row.rowType === "placeholder") { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + setDeviceMoveIntent({ kind: "move-to-new-circuit", sectionId: row.sectionId }); + return; + } + if (row.circuit && row.rowType !== "deviceRow") { + const sourceCircuitId = findDeviceRowCircuitId(draggingDeviceRowId); + if (sourceCircuitId && sourceCircuitId !== row.circuit.id) { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + setDeviceMoveIntent({ kind: "move-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId }); + } + } + } + }} + onDragLeave={() => { + setDropIntent((current) => { + if (!current) { + return null; + } + if (current.kind === "new-circuit" && row.rowType === "placeholder" && current.sectionId === row.sectionId) { + return null; + } + if (current.kind === "add-to-circuit" && current.circuitId === row.circuit?.id) { + return null; + } + return current; + }); + setDeviceMoveIntent((current) => { + if (!current) { + return null; + } + if (current.kind === "move-to-new-circuit" && row.rowType === "placeholder" && current.sectionId === row.sectionId) { + return null; + } + if (current.kind === "move-to-circuit" && current.circuitId === row.circuit?.id) { + return null; + } + return current; + }); + }} + onDrop={(event) => { + if (draggingProjectDeviceId) { + if (row.rowType === "placeholder") { + void handleDropWithIntent(event, { kind: "new-circuit", sectionId: row.sectionId }); + return; + } + if (row.circuit && row.rowType !== "deviceRow") { + void handleDropWithIntent(event, { + kind: "add-to-circuit", + circuitId: row.circuit.id, + sectionId: row.sectionId, + }); + } + return; + } + if (draggingDeviceRowId) { + if (row.rowType === "placeholder") { + void handleDeviceRowDropWithIntent(event, { kind: "move-to-new-circuit", sectionId: row.sectionId }); + return; + } + if (row.circuit && row.rowType !== "deviceRow") { + void handleDeviceRowDropWithIntent(event, { + kind: "move-to-circuit", + circuitId: row.circuit.id, + sectionId: row.sectionId, + }); + } + } + }} > {columns.map((column) => { const cell = row.cells.find((entry) => entry.cellKey === column.key)!; @@ -1173,7 +1413,38 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return ( { + if ( + !row.device || + column.key !== "displayName" || + (row.rowType !== "deviceRow" && row.rowType !== "circuitCompact") + ) { + return; + } + setDraggingProjectDeviceId(null); + setDropIntent(null); + setDraggingDeviceRowId(row.device.id); + setDeviceMoveIntent(null); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("application/x-circuit-device-row-id", row.device.id); + }} + onDragEnd={() => { + setDraggingDeviceRowId(null); + setDeviceMoveIntent(null); + }} onClick={() => { if (cell.editable) { setSelectedCell({ rowKey: row.rowKey, cellKey: column.key }); @@ -1223,7 +1494,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ); })} - + {row.circuit && row.rowType !== "deviceRow" ? ( <>