Confirm cross-section device moves

This commit is contained in:
2026-07-22 20:31:55 +02:00
parent f8802fecdb
commit b3a7ff79da
6 changed files with 129 additions and 15 deletions
+105 -14
View File
@@ -12,6 +12,7 @@ import {
import {
findDuplicateEquipmentIdentifierCircuitIds,
hasEquipmentIdentifierConflict,
requiresCrossSectionMoveConfirmation,
resolveGridDeleteIntent,
} from "../utils/circuit-grid-safety";
import {
@@ -143,8 +144,8 @@ type ProjectDeviceDropIntent =
| { kind: "add-to-circuit"; circuitId: string; sectionId: string; valid: boolean };
type DeviceRowMoveDropIntent =
| { kind: "move-to-circuit"; circuitId: string; sectionId: string }
| { kind: "move-to-new-circuit"; sectionId: string };
| { kind: "move-to-circuit"; circuitId: string; sectionId: string; requiresConfirmation: boolean }
| { kind: "move-to-new-circuit"; sectionId: string; requiresConfirmation: boolean };
type CircuitReorderDropIntent =
| { kind: "before-circuit"; sectionId: string; targetCircuitId: string; valid: boolean }
@@ -2140,6 +2141,36 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return null;
}
function getDeviceRowSourceSectionIds(rowIds: string[]) {
const sectionIds = new Set<string>();
for (const rowId of rowIds) {
const circuitId = findDeviceRowCircuitId(rowId);
const sectionId = circuitId ? findCircuitSectionId(circuitId) : null;
if (sectionId) {
sectionIds.add(sectionId);
}
}
return [...sectionIds];
}
function confirmCrossSectionDeviceMove(rowIds: string[], targetSectionId: string) {
const sourceSectionIds = getDeviceRowSourceSectionIds(rowIds);
if (!requiresCrossSectionMoveConfirmation(sourceSectionIds, targetSectionId)) {
return true;
}
const sourceNames = sourceSectionIds
.map((sectionId) => data?.sections.find((section) => section.id === sectionId)?.displayName ?? sectionId)
.join(", ");
const targetName =
data?.sections.find((section) => section.id === targetSectionId)?.displayName ?? targetSectionId;
return confirm(
`Move ${rowIds.length} device row(s) from ${sourceNames} to ${targetName}?\n\n` +
"This crosses a section boundary. Phase type, category, project-device links and local values remain unchanged. " +
"The target section's technical and numbering classification may therefore need manual review.\n\n" +
"Existing circuit identifiers will not be renumbered."
);
}
// Applies same-section circuit reorder as explicit id ordering.
// No implicit renumbering is performed here.
async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitIds: string[]) {
@@ -2274,12 +2305,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return;
}
if (intent.kind === "move-to-circuit" && rowIds.length === 1 && intent.circuitId === sourceCircuitId) {
return;
}
if (!confirmCrossSectionDeviceMove(rowIds, intent.sectionId)) {
return;
}
// Device-row drag supports bulk selection, but target intent is always explicit:
// move into existing circuit or create new circuit in a target section.
if (intent.kind === "move-to-circuit") {
if (rowIds.length === 1 && intent.circuitId === sourceCircuitId) {
return;
}
const groupedBySource = new Map<string, string[]>();
for (const rowId of rowIds) {
const source = sourceByRowId.get(rowId)!;
@@ -2748,7 +2783,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
// Renumbering is intentionally blocked while sort/filter overlays are active so users
// cannot renumber against a transformed view that has not been persisted yet.
const hasActiveSortOrFilter = isSortedView || hasActiveFilters;
const draggingDeviceCount = draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds.length : draggingDeviceRowId ? 1 : 0;
const activeDraggedDeviceRowIds =
draggingDeviceRowIds.length > 0
? draggingDeviceRowIds
: draggingDeviceRowId
? [draggingDeviceRowId]
: [];
const draggingDeviceCount = activeDraggedDeviceRowIds.length;
const activeDraggedCircuitIds =
draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : [];
const draggingCircuitCount = activeDraggedCircuitIds.length;
@@ -3167,10 +3208,26 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} ${
row.rowType === "placeholder" &&
((dropIntent?.kind === "new-circuit" && dropIntent.sectionId === row.sectionId && dropIntent.valid) ||
(deviceMoveIntent?.kind === "move-to-new-circuit" && deviceMoveIntent.sectionId === row.sectionId) ||
(deviceMoveIntent?.kind === "move-to-new-circuit" &&
deviceMoveIntent.sectionId === row.sectionId &&
!deviceMoveIntent.requiresConfirmation) ||
(circuitReorderIntent?.kind === "section-end" && circuitReorderIntent.sectionId === row.sectionId))
? "drop-target-active"
: ""
} ${
row.rowType === "placeholder" &&
deviceMoveIntent?.kind === "move-to-new-circuit" &&
deviceMoveIntent.sectionId === row.sectionId &&
deviceMoveIntent.requiresConfirmation
? "drop-target-confirm"
: ""
} ${
row.circuit &&
deviceMoveIntent?.kind === "move-to-circuit" &&
deviceMoveIntent.circuitId === row.circuit.id &&
deviceMoveIntent.requiresConfirmation
? "drop-target-confirm"
: ""
} ${
row.rowType === "placeholder" &&
dropIntent?.kind === "new-circuit" &&
@@ -3291,20 +3348,33 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return;
}
if (draggingDeviceCount > 0) {
const requiresConfirmation = requiresCrossSectionMoveConfirmation(
getDeviceRowSourceSectionIds(activeDraggedDeviceRowIds),
row.sectionId
);
if (row.rowType === "placeholder") {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setDeviceMoveIntent({ kind: "move-to-new-circuit", sectionId: row.sectionId });
setDeviceMoveIntent({
kind: "move-to-new-circuit",
sectionId: row.sectionId,
requiresConfirmation,
});
return;
}
if (row.circuit && row.rowType !== "deviceRow") {
const sourceCircuitIds = (draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds : draggingDeviceRowId ? [draggingDeviceRowId] : [])
const sourceCircuitIds = activeDraggedDeviceRowIds
.map((id) => findDeviceRowCircuitId(id))
.filter((id): id is string => Boolean(id));
if (sourceCircuitIds.some((sourceCircuitId) => sourceCircuitId !== row.circuit!.id)) {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setDeviceMoveIntent({ kind: "move-to-circuit", circuitId: row.circuit.id, sectionId: row.sectionId });
setDeviceMoveIntent({
kind: "move-to-circuit",
circuitId: row.circuit.id,
sectionId: row.sectionId,
requiresConfirmation,
});
}
}
}
@@ -3400,8 +3470,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return;
}
if (draggingDeviceCount > 0) {
const requiresConfirmation = requiresCrossSectionMoveConfirmation(
getDeviceRowSourceSectionIds(activeDraggedDeviceRowIds),
row.sectionId
);
if (row.rowType === "placeholder") {
void handleDeviceRowDropWithIntent(event, { kind: "move-to-new-circuit", sectionId: row.sectionId });
void handleDeviceRowDropWithIntent(event, {
kind: "move-to-new-circuit",
sectionId: row.sectionId,
requiresConfirmation,
});
return;
}
if (row.circuit && row.rowType !== "deviceRow") {
@@ -3409,6 +3487,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
kind: "move-to-circuit",
circuitId: row.circuit.id,
sectionId: row.sectionId,
requiresConfirmation,
});
}
}
@@ -3570,7 +3649,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
(dropIntent?.kind === "add-to-circuit" &&
dropIntent.circuitId === row.circuit?.id &&
dropIntent.valid) ||
(deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id)
(deviceMoveIntent?.kind === "move-to-circuit" &&
deviceMoveIntent.circuitId === row.circuit?.id &&
!deviceMoveIntent.requiresConfirmation)
? "drop-target-active"
: ""
} ${
@@ -3579,6 +3660,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
!dropIntent.valid
? "drop-target-invalid"
: ""
} ${
deviceMoveIntent?.kind === "move-to-circuit" &&
deviceMoveIntent.circuitId === row.circuit?.id &&
deviceMoveIntent.requiresConfirmation
? "drop-target-confirm"
: ""
}`}
>
{row.circuit && row.rowType !== "deviceRow" ? (
@@ -3617,10 +3704,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
{deviceMoveIntent?.kind === "move-to-new-circuit" &&
row.rowType === "placeholder" &&
deviceMoveIntent.sectionId === row.sectionId ? (
<span className="drop-hint">{`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to new circuit`}</span>
<span className="drop-hint">
{`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to new circuit${deviceMoveIntent.requiresConfirmation ? " (confirmation required)" : ""}`}
</span>
) : null}
{deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? (
<span className="drop-hint">{`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to this circuit`}</span>
<span className="drop-hint">
{`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to this circuit${deviceMoveIntent.requiresConfirmation ? " (confirmation required)" : ""}`}
</span>
) : null}
{circuitReorderIntent?.kind === "section-end" &&
row.rowType === "placeholder" &&
@@ -72,3 +72,10 @@ export function findDuplicateEquipmentIdentifierCircuitIds(
}
return [...idsByIdentifier.values()].filter((ids) => ids.length > 1).flat();
}
export function requiresCrossSectionMoveConfirmation(
sourceSectionIds: string[],
targetSectionId: string
): boolean {
return sourceSectionIds.some((sectionId) => sectionId !== targetSectionId);
}