Drag and drop working

This commit is contained in:
2026-05-04 23:00:22 +02:00
parent efbb81c13d
commit 897e506b74
9 changed files with 679 additions and 63 deletions
+48
View File
@@ -106,6 +106,11 @@ body {
background: #edf3ff; background: #edf3ff;
} }
.project-device-item.dragging {
opacity: 0.55;
border-style: dashed;
}
.sidebar-actions { .sidebar-actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -158,6 +163,30 @@ body {
font-weight: 600; 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 { .tree-grid .summary-row td {
background: #f3f7fd; background: #f3f7fd;
} }
@@ -180,6 +209,14 @@ body {
cursor: text; cursor: text;
} }
.tree-grid .device-drag-handle {
cursor: grab;
}
.tree-grid .device-drag-handle.device-dragging {
opacity: 0.55;
}
.tree-grid .cell-selected { .tree-grid .cell-selected {
outline: 2px solid #4c7dd9; outline: 2px solid #4c7dd9;
outline-offset: -2px; outline-offset: -2px;
@@ -206,6 +243,17 @@ body {
font-size: 0.78rem; 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 { .notice {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 4px; border-radius: 4px;
@@ -123,4 +123,14 @@ export class CircuitDeviceRowRepository {
async delete(rowId: string) { async delete(rowId: string) {
await db.delete(circuitDeviceRows).where(eq(circuitDeviceRows.id, rowId)); 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 { import type {
CreateCircuitDeviceRowInput, CreateCircuitDeviceRowInput,
CreateCircuitInput, CreateCircuitInput,
MoveCircuitDeviceRowInput,
UpdateCircuitDeviceRowInput, UpdateCircuitDeviceRowInput,
UpdateCircuitInput, UpdateCircuitInput,
} from "../../shared/validation/circuit.schemas.js"; } 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) { async getNextIdentifier(sectionId: string) {
return this.numberingService.getNextIdentifier(sectionId); return this.numberingService.getNextIdentifier(sectionId);
} }
+333 -41
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import { import {
createCircuit, createCircuit,
createCircuitDeviceRow, createCircuitDeviceRow,
@@ -9,6 +9,7 @@ import {
getCircuitTree, getCircuitTree,
getNextCircuitIdentifier, getNextCircuitIdentifier,
listProjectDevices, listProjectDevices,
moveCircuitDeviceRowById,
renumberCircuitSection, renumberCircuitSection,
updateCircuitById, updateCircuitById,
updateCircuitDeviceRowById, updateCircuitDeviceRowById,
@@ -74,6 +75,14 @@ interface VisibleGridRow {
cells: VisibleGridCell[]; 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 }> = [ const columns: Array<{ key: CellKey; label: string; numeric?: boolean }> = [
{ key: "equipmentIdentifier", label: "Equipment identifier" }, { key: "equipmentIdentifier", label: "Equipment identifier" },
{ key: "displayName", label: "Display name" }, { 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 [selectedProjectDeviceId, setSelectedProjectDeviceId] = useState<string | null>(null);
const [targetSectionId, setTargetSectionId] = useState<string | null>(null); const [targetSectionId, setTargetSectionId] = useState<string | null>(null);
const [targetCircuitId, setTargetCircuitId] = 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 [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null); const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@@ -828,37 +841,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
try { try {
setError(null); setError(null);
setIsSaving(true); setIsSaving(true);
const section = data?.sections.find((entry) => entry.id === targetSectionId); await insertProjectDeviceAsNewCircuit(device, 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" });
} catch (err) { } catch (err) {
setError(normalizeUiError(err)); setError(normalizeUiError(err));
} finally { } finally {
@@ -887,6 +870,49 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
try { try {
setError(null); setError(null);
setIsSaving(true); 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, { const createdRow = (await createCircuitDeviceRow(circuitId, {
linkedProjectDeviceId: device.id, linkedProjectDeviceId: device.id,
name: device.name, name: device.name,
@@ -900,6 +926,90 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
})) as { id: string }; })) as { id: string };
await loadTree({ showLoading: false }); await loadTree({ showLoading: false });
setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" }); 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) { } catch (err) {
setError(normalizeUiError(err)); setError(normalizeUiError(err));
} finally { } finally {
@@ -1057,8 +1167,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<button <button
key={device.id} key={device.id}
type="button" 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)} 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> <strong>{device.displayName || device.name}</strong>
<span>Name: {device.name}</span> <span>Name: {device.name}</span>
@@ -1132,17 +1255,41 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
if (row.rowType === "section") { if (row.rowType === "section") {
const section = data.sections.find((entry) => entry.id === row.sectionId)!; const section = data.sections.find((entry) => entry.id === row.sectionId)!;
return ( return (
<tr key={row.rowKey} className="section-row"> <tr
<td colSpan={columns.length}> 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> <strong>{section.displayName}</strong>
</td> <div className="section-actions">
<td className="action-cell">
<button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}> <button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
Add circuit Add circuit
</button> </button>
<button type="button" tabIndex={-1} onClick={() => void handleRenumberSection(section.id)}> <button type="button" tabIndex={-1} onClick={() => void handleRenumberSection(section.id)}>
Renumber section Renumber section
</button> </button>
</div>
</div>
{dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? (
<span className="drop-hint">drop here to create new circuit</span>
) : null}
</td> </td>
</tr> </tr>
); );
@@ -1151,7 +1298,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return ( return (
<tr <tr
key={row.rowKey} key={row.rowKey}
className={ className={`${
row.rowType === "circuitSummary" row.rowType === "circuitSummary"
? "summary-row" ? "summary-row"
: row.rowType === "deviceRow" : row.rowType === "deviceRow"
@@ -1161,8 +1308,101 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
: row.rowType === "placeholder" : row.rowType === "placeholder"
? "placeholder-row" ? "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)} 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) => { {columns.map((column) => {
const cell = row.cells.find((entry) => entry.cellKey === column.key)!; const cell = row.cells.find((entry) => entry.cellKey === column.key)!;
@@ -1173,7 +1413,38 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return ( return (
<td <td
key={column.key} 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={() => { onClick={() => {
if (cell.editable) { if (cell.editable) {
setSelectedCell({ rowKey: row.rowKey, cellKey: column.key }); setSelectedCell({ rowKey: row.rowKey, cellKey: column.key });
@@ -1223,7 +1494,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</td> </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" ? ( {row.circuit && row.rowType !== "deviceRow" ? (
<> <>
<button <button
@@ -1247,6 +1525,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
Delete device Delete device
</button> </button>
) : null} ) : 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> </td>
</tr> </tr>
); );
+10
View File
@@ -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) { export function renumberCircuitSection(sectionId: string) {
return request(`/api/circuit-sections/${sectionId}/renumber`, { return request(`/api/circuit-sections/${sectionId}/renumber`, {
method: "POST", method: "POST",
@@ -2,6 +2,7 @@ import type { Request, Response } from "express";
import { CircuitWriteService } from "../../domain/services/circuit-write.service.js"; import { CircuitWriteService } from "../../domain/services/circuit-write.service.js";
import { import {
createCircuitDeviceRowSchema, createCircuitDeviceRowSchema,
moveCircuitDeviceRowSchema,
updateCircuitDeviceRowSchema, updateCircuitDeviceRowSchema,
} from "../../shared/validation/circuit.schemas.js"; } 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 { Router } from "express";
import { import {
deleteCircuitDeviceRow, deleteCircuitDeviceRow,
moveCircuitDeviceRow,
updateCircuitDeviceRow, updateCircuitDeviceRow,
} from "../controllers/circuit-device-row.controller.js"; } from "../controllers/circuit-device-row.controller.js";
export const circuitDeviceRowRouter = Router(); export const circuitDeviceRowRouter = Router();
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId", updateCircuitDeviceRow); circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId", updateCircuitDeviceRow);
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow); circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow);
+14 -1
View File
@@ -49,8 +49,21 @@ export const createCircuitDeviceRowSchema = z.object({
export const updateCircuitDeviceRowSchema = createCircuitDeviceRowSchema.partial(); 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 CreateCircuitInput = z.infer<typeof createCircuitSchema>;
export type UpdateCircuitInput = z.infer<typeof updateCircuitSchema>; export type UpdateCircuitInput = z.infer<typeof updateCircuitSchema>;
export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>; export type CreateCircuitDeviceRowInput = z.infer<typeof createCircuitDeviceRowSchema>;
export type UpdateCircuitDeviceRowInput = z.infer<typeof updateCircuitDeviceRowSchema>; export type UpdateCircuitDeviceRowInput = z.infer<typeof updateCircuitDeviceRowSchema>;
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
+125
View File
@@ -174,5 +174,130 @@ describe("circuit write service rules", () => {
assert.deepEqual(updatedIds, ["c1", "c2"]); assert.deepEqual(updatedIds, ["c1", "c2"]);
assert.equal(result.length, 2); 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);
});
});