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
+352 -60
View File
@@ -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,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<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:${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
<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}>
<strong>{section.displayName}</strong>
</td>
<td className="action-cell">
<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>
<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>
<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>
);