Drag and drop working
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [targetSectionId, setTargetSectionId] = useState<string | null>(null);
|
||||
const [targetCircuitId, setTargetCircuitId] = useState<string | null>(null);
|
||||
const [draggingProjectDeviceId, setDraggingProjectDeviceId] = useState<string | null>(null);
|
||||
const [dropIntent, setDropIntent] = useState<ProjectDeviceDropIntent | null>(null);
|
||||
const [draggingDeviceRowId, setDraggingDeviceRowId] = useState<string | null>(null);
|
||||
const [deviceMoveIntent, setDeviceMoveIntent] = useState<DeviceRowMoveDropIntent | null>(null);
|
||||
const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
|
||||
const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement | null>(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,6 +870,49 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
try {
|
||||
setError(null);
|
||||
setIsSaving(true);
|
||||
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,
|
||||
@@ -900,6 +926,90 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
})) as { id: string };
|
||||
await loadTree({ showLoading: false });
|
||||
setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" });
|
||||
}
|
||||
|
||||
function parseDraggedProjectDeviceId(event: DragEvent<HTMLElement>) {
|
||||
return event.dataTransfer.getData("application/x-project-device-id") || null;
|
||||
}
|
||||
|
||||
function parseDraggedDeviceRowId(event: DragEvent<HTMLElement>) {
|
||||
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<HTMLElement>, 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<HTMLElement>, 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:${rowId}`, cellKey: "displayName" });
|
||||
} catch (err) {
|
||||
setError(normalizeUiError(err));
|
||||
} finally {
|
||||
@@ -1057,8 +1167,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
<button
|
||||
key={device.id}
|
||||
type="button"
|
||||
className={`project-device-item ${selectedProjectDeviceId === device.id ? "selected" : ""}`}
|
||||
className={`project-device-item ${selectedProjectDeviceId === device.id ? "selected" : ""} ${draggingProjectDeviceId === device.id ? "dragging" : ""}`}
|
||||
onClick={() => setSelectedProjectDeviceId(device.id)}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
setSelectedProjectDeviceId(device.id);
|
||||
setDraggingProjectDeviceId(device.id);
|
||||
setDraggingDeviceRowId(null);
|
||||
setDeviceMoveIntent(null);
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
event.dataTransfer.setData("application/x-project-device-id", device.id);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDraggingProjectDeviceId(null);
|
||||
setDropIntent(null);
|
||||
}}
|
||||
>
|
||||
<strong>{device.displayName || device.name}</strong>
|
||||
<span>Name: {device.name}</span>
|
||||
@@ -1132,17 +1255,41 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
if (row.rowType === "section") {
|
||||
const section = data.sections.find((entry) => entry.id === row.sectionId)!;
|
||||
return (
|
||||
<tr key={row.rowKey} className="section-row">
|
||||
<td colSpan={columns.length}>
|
||||
<tr
|
||||
key={row.rowKey}
|
||||
className={`section-row ${
|
||||
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? "drop-target-active" : ""
|
||||
}`}
|
||||
onDragOver={(event) => {
|
||||
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 })}
|
||||
>
|
||||
<td colSpan={columns.length + 1} className="section-drop-cell">
|
||||
<div className="section-content">
|
||||
<strong>{section.displayName}</strong>
|
||||
</td>
|
||||
<td className="action-cell">
|
||||
<div className="section-actions">
|
||||
<button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
|
||||
Add circuit
|
||||
</button>
|
||||
<button type="button" tabIndex={-1} onClick={() => void handleRenumberSection(section.id)}>
|
||||
Renumber section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
|
||||
<span className="drop-hint">drop here to create new circuit</span>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -1151,7 +1298,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
return (
|
||||
<tr
|
||||
key={row.rowKey}
|
||||
className={
|
||||
className={`${
|
||||
row.rowType === "circuitSummary"
|
||||
? "summary-row"
|
||||
: row.rowType === "deviceRow"
|
||||
@@ -1161,8 +1308,101 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
: row.rowType === "placeholder"
|
||||
? "placeholder-row"
|
||||
: ""
|
||||
}
|
||||
} ${
|
||||
row.rowType === "placeholder" &&
|
||||
((dropIntent?.kind === "new-circuit" && dropIntent.sectionId === row.sectionId) ||
|
||||
(deviceMoveIntent?.kind === "move-to-new-circuit" && deviceMoveIntent.sectionId === row.sectionId))
|
||||
? "drop-target-active"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => 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 (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isSelected ? "cell-selected" : ""}`}
|
||||
className={`${column.numeric ? "num" : ""} ${cell.editable ? "cell-editable" : ""} ${isSelected ? "cell-selected" : ""} ${
|
||||
Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")
|
||||
? "device-drag-handle"
|
||||
: ""
|
||||
} ${
|
||||
draggingDeviceRowId && row.device?.id === draggingDeviceRowId ? "device-dragging" : ""
|
||||
}`}
|
||||
draggable={
|
||||
!isEditing &&
|
||||
Boolean(row.device) &&
|
||||
column.key === "displayName" &&
|
||||
(row.rowType === "deviceRow" || row.rowType === "circuitCompact")
|
||||
}
|
||||
onDragStart={(event) => {
|
||||
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
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="action-cell">
|
||||
<td
|
||||
className={`action-cell ${
|
||||
(dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id) ||
|
||||
(deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id)
|
||||
? "drop-target-active"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{row.circuit && row.rowType !== "deviceRow" ? (
|
||||
<>
|
||||
<button
|
||||
@@ -1247,6 +1525,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||
Delete device
|
||||
</button>
|
||||
) : null}
|
||||
{dropIntent?.kind === "new-circuit" && row.rowType === "placeholder" && dropIntent.sectionId === row.sectionId ? (
|
||||
<span className="drop-hint">new circuit in this section</span>
|
||||
) : null}
|
||||
{dropIntent?.kind === "add-to-circuit" && dropIntent.circuitId === row.circuit?.id ? (
|
||||
<span className="drop-hint">add to this circuit</span>
|
||||
) : null}
|
||||
{deviceMoveIntent?.kind === "move-to-new-circuit" &&
|
||||
row.rowType === "placeholder" &&
|
||||
deviceMoveIntent.sectionId === row.sectionId ? (
|
||||
<span className="drop-hint">move device 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>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -132,6 +132,16 @@ export function deleteCircuitDeviceRowById(rowId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function moveCircuitDeviceRowById(
|
||||
rowId: string,
|
||||
input: { targetCircuitId?: string; targetSectionId?: string; createNewCircuit?: boolean }
|
||||
) {
|
||||
return request(`/api/circuit-device-rows/${rowId}/move`, {
|
||||
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,
|
||||
moveCircuitDeviceRowSchema,
|
||||
updateCircuitDeviceRowSchema,
|
||||
} from "../../shared/validation/circuit.schemas.js";
|
||||
|
||||
@@ -59,3 +60,21 @@ export async function deleteCircuitDeviceRow(req: Request, res: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function moveCircuitDeviceRow(req: Request, res: Response) {
|
||||
const { rowId } = req.params;
|
||||
if (typeof rowId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid rowId" });
|
||||
}
|
||||
|
||||
const parsed = moveCircuitDeviceRowSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
try {
|
||||
const moved = await circuitWriteService.moveDeviceRow(rowId, parsed.data);
|
||||
return res.json(moved);
|
||||
} catch (error) {
|
||||
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device row." });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Router } from "express";
|
||||
import {
|
||||
deleteCircuitDeviceRow,
|
||||
moveCircuitDeviceRow,
|
||||
updateCircuitDeviceRow,
|
||||
} from "../controllers/circuit-device-row.controller.js";
|
||||
|
||||
export const circuitDeviceRowRouter = Router();
|
||||
|
||||
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId", updateCircuitDeviceRow);
|
||||
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
|
||||
circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow);
|
||||
|
||||
|
||||
@@ -49,8 +49,21 @@ export const createCircuitDeviceRowSchema = z.object({
|
||||
|
||||
export const updateCircuitDeviceRowSchema = createCircuitDeviceRowSchema.partial();
|
||||
|
||||
export const moveCircuitDeviceRowSchema = z
|
||||
.object({
|
||||
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 type CreateCircuitInput = z.infer<typeof createCircuitSchema>;
|
||||
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>;
|
||||
|
||||
@@ -174,5 +174,130 @@ describe("circuit write service rules", () => {
|
||||
assert.deepEqual(updatedIds, ["c1", "c2"]);
|
||||
assert.equal(result.length, 2);
|
||||
});
|
||||
|
||||
it("moving a device row to another circuit preserves row and toggles reserve flags", async () => {
|
||||
const updatedReserve: Array<{ id: string; isReserve: boolean }> = [];
|
||||
const movedCalls: Array<{ rowId: string; targetCircuitId: string; sortOrder: number }> = [];
|
||||
const service = new CircuitWriteService({
|
||||
deviceRowRepository: {
|
||||
async findById() {
|
||||
return { id: "r1", circuitId: "c1" } as never;
|
||||
},
|
||||
async countByCircuit(circuitId: string) {
|
||||
if (circuitId === "c2") {
|
||||
return 2;
|
||||
}
|
||||
if (circuitId === "c1") {
|
||||
return 0;
|
||||
}
|
||||
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;
|
||||
}
|
||||
return {
|
||||
id: "c2",
|
||||
sectionId: "s1",
|
||||
circuitListId: "l1",
|
||||
equipmentIdentifier: "-1F2",
|
||||
sortOrder: 20,
|
||||
isReserve: 1,
|
||||
} as never;
|
||||
},
|
||||
async update(id: string, payload: { isReserve: boolean }) {
|
||||
updatedReserve.push({ id, isReserve: payload.isReserve });
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
await service.moveDeviceRow("r1", { targetCircuitId: "c2" });
|
||||
assert.deepEqual(movedCalls, [{ rowId: "r1", targetCircuitId: "c2", sortOrder: 30 }]);
|
||||
assert.deepEqual(updatedReserve, [
|
||||
{ id: "c1", isReserve: true },
|
||||
{ id: "c2", isReserve: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("moving a device row to placeholder creates a new circuit in target section", async () => {
|
||||
let createdCircuitPayload: { sectionId: string; equipmentIdentifier: string; isReserve?: boolean } | null = null;
|
||||
const service = new CircuitWriteService({
|
||||
deviceRowRepository: {
|
||||
async findById() {
|
||||
return { id: "r1", circuitId: "c1" } 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") {
|
||||
return {
|
||||
id: "c1",
|
||||
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: 50,
|
||||
isReserve: 0,
|
||||
} as never;
|
||||
}
|
||||
return null as never;
|
||||
},
|
||||
async listBySection() {
|
||||
return [{ sortOrder: 40 }] as never[];
|
||||
},
|
||||
async create(payload: { sectionId: string; equipmentIdentifier: string; isReserve?: boolean }) {
|
||||
createdCircuitPayload = payload;
|
||||
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,
|
||||
});
|
||||
|
||||
await service.moveDeviceRow("r1", { targetSectionId: "s2", createNewCircuit: true });
|
||||
assert.equal(createdCircuitPayload?.sectionId, "s2");
|
||||
assert.equal(createdCircuitPayload?.equipmentIdentifier, "-2F8");
|
||||
assert.equal(createdCircuitPayload?.isReserve, false);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user