Drag and drop working
This commit is contained in:
@@ -217,6 +217,14 @@ body {
|
|||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-grid .circuit-drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .circuit-dragging-block td {
|
||||||
|
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;
|
||||||
@@ -248,6 +256,62 @@ body {
|
|||||||
background: #eef4ff !important;
|
background: #eef4ff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-grid .drop-target-invalid {
|
||||||
|
box-shadow: inset 0 0 0 2px #d97706;
|
||||||
|
background: #fff7ed !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid tr.circuit-insert-before td,
|
||||||
|
.tree-grid tr.circuit-insert-after td {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid tr.circuit-insert-before td::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -1px;
|
||||||
|
right: -1px;
|
||||||
|
top: -2px;
|
||||||
|
border-top: 4px solid #2563eb;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid tr.circuit-insert-before td:first-child::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -7px;
|
||||||
|
top: -7px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-top: 7px solid transparent;
|
||||||
|
border-bottom: 7px solid transparent;
|
||||||
|
border-left: 10px solid #2563eb;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid tr.circuit-insert-after td::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -1px;
|
||||||
|
right: -1px;
|
||||||
|
bottom: -2px;
|
||||||
|
border-bottom: 4px solid #2563eb;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid tr.circuit-insert-after td:first-child::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: -7px;
|
||||||
|
bottom: -7px;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-top: 7px solid transparent;
|
||||||
|
border-bottom: 7px solid transparent;
|
||||||
|
border-left: 10px solid #2563eb;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.drop-hint {
|
.drop-hint {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: #1f4ea3;
|
color: #1f4ea3;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
CreateCircuitDeviceRowInput,
|
CreateCircuitDeviceRowInput,
|
||||||
CreateCircuitInput,
|
CreateCircuitInput,
|
||||||
MoveCircuitDeviceRowInput,
|
MoveCircuitDeviceRowInput,
|
||||||
|
ReorderSectionCircuitsInput,
|
||||||
UpdateCircuitDeviceRowInput,
|
UpdateCircuitDeviceRowInput,
|
||||||
UpdateCircuitInput,
|
UpdateCircuitInput,
|
||||||
} from "../../shared/validation/circuit.schemas.js";
|
} from "../../shared/validation/circuit.schemas.js";
|
||||||
@@ -408,4 +409,48 @@ export class CircuitWriteService {
|
|||||||
|
|
||||||
return this.circuitRepository.listBySection(sectionId);
|
return this.circuitRepository.listBySection(sectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reorderCircuitsInSection(sectionId: string, input: ReorderSectionCircuitsInput) {
|
||||||
|
const section = await this.circuitSectionRepository.findById(sectionId);
|
||||||
|
if (!section) {
|
||||||
|
throw new Error("Invalid section id.");
|
||||||
|
}
|
||||||
|
const sectionCircuits = await this.circuitRepository.listBySection(sectionId);
|
||||||
|
const sectionIds = new Set(sectionCircuits.map((circuit) => circuit.id));
|
||||||
|
if (sectionCircuits.length !== input.orderedCircuitIds.length) {
|
||||||
|
throw new Error("orderedCircuitIds must include all circuits of the section.");
|
||||||
|
}
|
||||||
|
for (const circuitId of input.orderedCircuitIds) {
|
||||||
|
if (!sectionIds.has(circuitId)) {
|
||||||
|
throw new Error("Circuit id does not belong to section.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < input.orderedCircuitIds.length; index += 1) {
|
||||||
|
const circuitId = input.orderedCircuitIds[index];
|
||||||
|
const circuit = sectionCircuits.find((entry) => entry.id === circuitId);
|
||||||
|
if (!circuit) {
|
||||||
|
throw new Error("Invalid circuit id.");
|
||||||
|
}
|
||||||
|
await this.circuitRepository.update(circuit.id, {
|
||||||
|
sectionId: circuit.sectionId,
|
||||||
|
equipmentIdentifier: circuit.equipmentIdentifier,
|
||||||
|
displayName: circuit.displayName ?? undefined,
|
||||||
|
sortOrder: (index + 1) * 10,
|
||||||
|
protectionType: circuit.protectionType ?? undefined,
|
||||||
|
protectionRatedCurrent: circuit.protectionRatedCurrent ?? undefined,
|
||||||
|
protectionCharacteristic: circuit.protectionCharacteristic ?? undefined,
|
||||||
|
cableType: circuit.cableType ?? undefined,
|
||||||
|
cableCrossSection: circuit.cableCrossSection ?? undefined,
|
||||||
|
cableLength: circuit.cableLength ?? undefined,
|
||||||
|
rcdAssignment: circuit.rcdAssignment ?? undefined,
|
||||||
|
terminalDesignation: circuit.terminalDesignation ?? undefined,
|
||||||
|
voltage: circuit.voltage ?? undefined,
|
||||||
|
status: circuit.status ?? undefined,
|
||||||
|
isReserve: Boolean(circuit.isReserve),
|
||||||
|
remark: circuit.remark ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.circuitRepository.listBySection(sectionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
getNextCircuitIdentifier,
|
getNextCircuitIdentifier,
|
||||||
listProjectDevices,
|
listProjectDevices,
|
||||||
moveCircuitDeviceRowById,
|
moveCircuitDeviceRowById,
|
||||||
|
reorderSectionCircuits,
|
||||||
renumberCircuitSection,
|
renumberCircuitSection,
|
||||||
updateCircuitById,
|
updateCircuitById,
|
||||||
updateCircuitDeviceRowById,
|
updateCircuitDeviceRowById,
|
||||||
@@ -83,6 +84,11 @@ type DeviceRowMoveDropIntent =
|
|||||||
| { kind: "move-to-circuit"; circuitId: string; sectionId: string }
|
| { kind: "move-to-circuit"; circuitId: string; sectionId: string }
|
||||||
| { kind: "move-to-new-circuit"; sectionId: string };
|
| { kind: "move-to-new-circuit"; sectionId: string };
|
||||||
|
|
||||||
|
type CircuitReorderDropIntent =
|
||||||
|
| { kind: "before-circuit"; sectionId: string; targetCircuitId: string; valid: boolean }
|
||||||
|
| { kind: "after-circuit"; sectionId: string; targetCircuitId: string; valid: boolean }
|
||||||
|
| { kind: "section-end"; sectionId: string; valid: boolean };
|
||||||
|
|
||||||
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" },
|
||||||
@@ -274,6 +280,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
const [dropIntent, setDropIntent] = useState<ProjectDeviceDropIntent | null>(null);
|
const [dropIntent, setDropIntent] = useState<ProjectDeviceDropIntent | null>(null);
|
||||||
const [draggingDeviceRowId, setDraggingDeviceRowId] = useState<string | null>(null);
|
const [draggingDeviceRowId, setDraggingDeviceRowId] = useState<string | null>(null);
|
||||||
const [deviceMoveIntent, setDeviceMoveIntent] = useState<DeviceRowMoveDropIntent | null>(null);
|
const [deviceMoveIntent, setDeviceMoveIntent] = useState<DeviceRowMoveDropIntent | null>(null);
|
||||||
|
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
|
||||||
|
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | 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);
|
||||||
@@ -947,6 +955,41 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findCircuitSectionId(circuitId: string) {
|
||||||
|
for (const section of data?.sections ?? []) {
|
||||||
|
if (section.circuits.some((circuit) => circuit.id === circuitId)) {
|
||||||
|
return section.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitId: string) {
|
||||||
|
const section = data?.sections.find((entry) => entry.id === intent.sectionId);
|
||||||
|
if (!section) {
|
||||||
|
throw new Error("Invalid section id.");
|
||||||
|
}
|
||||||
|
const ids = section.circuits.map((circuit) => circuit.id);
|
||||||
|
const fromIndex = ids.indexOf(sourceCircuitId);
|
||||||
|
if (fromIndex < 0) {
|
||||||
|
throw new Error("Invalid source circuit.");
|
||||||
|
}
|
||||||
|
const nextIds = [...ids];
|
||||||
|
nextIds.splice(fromIndex, 1);
|
||||||
|
|
||||||
|
if (intent.kind === "section-end") {
|
||||||
|
nextIds.push(sourceCircuitId);
|
||||||
|
} else {
|
||||||
|
const targetIndex = nextIds.indexOf(intent.targetCircuitId);
|
||||||
|
if (targetIndex < 0) {
|
||||||
|
throw new Error("Invalid target circuit.");
|
||||||
|
}
|
||||||
|
const insertIndex = intent.kind === "after-circuit" ? targetIndex + 1 : targetIndex;
|
||||||
|
nextIds.splice(insertIndex, 0, sourceCircuitId);
|
||||||
|
}
|
||||||
|
await reorderSectionCircuits(section.id, nextIds);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDropWithIntent(event: DragEvent<HTMLElement>, intent: ProjectDeviceDropIntent) {
|
async function handleDropWithIntent(event: DragEvent<HTMLElement>, intent: ProjectDeviceDropIntent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -1017,6 +1060,39 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCircuitReorderDrop(event: DragEvent<HTMLElement>, intent: CircuitReorderDropIntent) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
const sourceCircuitId = event.dataTransfer.getData("application/x-circuit-id") || draggingCircuitId;
|
||||||
|
setCircuitReorderIntent(null);
|
||||||
|
setDraggingCircuitId(null);
|
||||||
|
if (!sourceCircuitId) {
|
||||||
|
setError("Missing dragged circuit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!intent.valid) {
|
||||||
|
setError("Cross-section circuit move is not allowed in this phase.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
setIsSaving(true);
|
||||||
|
await applyCircuitReorder(intent, sourceCircuitId);
|
||||||
|
pendingSelectionAfterReload.current = {
|
||||||
|
rowKey: `circuitSummary:${sourceCircuitId}`,
|
||||||
|
cellKey: "equipmentIdentifier",
|
||||||
|
rowType: "circuitSummary",
|
||||||
|
sectionId: intent.sectionId,
|
||||||
|
circuitId: sourceCircuitId,
|
||||||
|
};
|
||||||
|
await loadTree({ showLoading: false });
|
||||||
|
} catch (err) {
|
||||||
|
setError(normalizeUiError(err));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDeleteDevice(rowId: string) {
|
async function handleDeleteDevice(rowId: string) {
|
||||||
if (!confirm("Delete this device row?")) {
|
if (!confirm("Delete this device row?")) {
|
||||||
return;
|
return;
|
||||||
@@ -1174,7 +1250,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
setSelectedProjectDeviceId(device.id);
|
setSelectedProjectDeviceId(device.id);
|
||||||
setDraggingProjectDeviceId(device.id);
|
setDraggingProjectDeviceId(device.id);
|
||||||
setDraggingDeviceRowId(null);
|
setDraggingDeviceRowId(null);
|
||||||
|
setDraggingCircuitId(null);
|
||||||
setDeviceMoveIntent(null);
|
setDeviceMoveIntent(null);
|
||||||
|
setCircuitReorderIntent(null);
|
||||||
event.dataTransfer.effectAllowed = "copy";
|
event.dataTransfer.effectAllowed = "copy";
|
||||||
event.dataTransfer.setData("application/x-project-device-id", device.id);
|
event.dataTransfer.setData("application/x-project-device-id", device.id);
|
||||||
}}
|
}}
|
||||||
@@ -1261,6 +1339,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? "drop-target-active" : ""
|
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? "drop-target-active" : ""
|
||||||
}`}
|
}`}
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
|
if (draggingCircuitId) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "none";
|
||||||
|
setCircuitReorderIntent({
|
||||||
|
kind: "section-end",
|
||||||
|
sectionId: section.id,
|
||||||
|
valid: false,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!draggingProjectDeviceId) {
|
if (!draggingProjectDeviceId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1272,8 +1360,23 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
if (dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id) {
|
if (dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id) {
|
||||||
setDropIntent(null);
|
setDropIntent(null);
|
||||||
}
|
}
|
||||||
|
if (circuitReorderIntent?.kind === "section-end" && circuitReorderIntent.sectionId === section.id) {
|
||||||
|
setCircuitReorderIntent(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDrop={(event) => {
|
||||||
|
if (draggingProjectDeviceId) {
|
||||||
|
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: section.id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (draggingCircuitId) {
|
||||||
|
void handleCircuitReorderDrop(event, {
|
||||||
|
kind: "section-end",
|
||||||
|
sectionId: section.id,
|
||||||
|
valid: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onDrop={(event) => void handleDropWithIntent(event, { kind: "new-circuit", sectionId: section.id })}
|
|
||||||
>
|
>
|
||||||
<td colSpan={columns.length + 1} className="section-drop-cell">
|
<td colSpan={columns.length + 1} className="section-drop-cell">
|
||||||
<div className="section-content">
|
<div className="section-content">
|
||||||
@@ -1311,12 +1414,81 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
} ${
|
} ${
|
||||||
row.rowType === "placeholder" &&
|
row.rowType === "placeholder" &&
|
||||||
((dropIntent?.kind === "new-circuit" && dropIntent.sectionId === row.sectionId) ||
|
((dropIntent?.kind === "new-circuit" && dropIntent.sectionId === row.sectionId) ||
|
||||||
(deviceMoveIntent?.kind === "move-to-new-circuit" && deviceMoveIntent.sectionId === row.sectionId))
|
(deviceMoveIntent?.kind === "move-to-new-circuit" && deviceMoveIntent.sectionId === row.sectionId) ||
|
||||||
|
(circuitReorderIntent?.kind === "section-end" && circuitReorderIntent.sectionId === row.sectionId))
|
||||||
? "drop-target-active"
|
? "drop-target-active"
|
||||||
: ""
|
: ""
|
||||||
|
} ${
|
||||||
|
row.rowType === "placeholder" &&
|
||||||
|
circuitReorderIntent?.kind === "section-end" &&
|
||||||
|
circuitReorderIntent.sectionId === row.sectionId &&
|
||||||
|
!circuitReorderIntent.valid
|
||||||
|
? "drop-target-invalid"
|
||||||
|
: ""
|
||||||
|
} ${
|
||||||
|
row.circuit &&
|
||||||
|
circuitReorderIntent &&
|
||||||
|
(circuitReorderIntent.kind === "before-circuit" || circuitReorderIntent.kind === "after-circuit") &&
|
||||||
|
circuitReorderIntent.targetCircuitId === row.circuit.id &&
|
||||||
|
circuitReorderIntent.valid
|
||||||
|
? "drop-target-active"
|
||||||
|
: ""
|
||||||
|
} ${
|
||||||
|
row.circuit &&
|
||||||
|
circuitReorderIntent?.kind === "before-circuit" &&
|
||||||
|
circuitReorderIntent.targetCircuitId === row.circuit.id &&
|
||||||
|
circuitReorderIntent.valid
|
||||||
|
? "circuit-insert-before"
|
||||||
|
: ""
|
||||||
|
} ${
|
||||||
|
row.circuit &&
|
||||||
|
circuitReorderIntent?.kind === "after-circuit" &&
|
||||||
|
circuitReorderIntent.targetCircuitId === row.circuit.id &&
|
||||||
|
circuitReorderIntent.valid
|
||||||
|
? "circuit-insert-after"
|
||||||
|
: ""
|
||||||
|
} ${
|
||||||
|
row.circuit &&
|
||||||
|
circuitReorderIntent &&
|
||||||
|
(circuitReorderIntent.kind === "before-circuit" || circuitReorderIntent.kind === "after-circuit") &&
|
||||||
|
circuitReorderIntent.targetCircuitId === row.circuit.id &&
|
||||||
|
!circuitReorderIntent.valid
|
||||||
|
? "drop-target-invalid"
|
||||||
|
: ""
|
||||||
|
} ${
|
||||||
|
row.circuit?.id && draggingCircuitId && row.circuit.id === draggingCircuitId ? "circuit-dragging-block" : ""
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setActiveSectionId(row.sectionId)}
|
onClick={() => setActiveSectionId(row.sectionId)}
|
||||||
onDragOver={(event) => {
|
onDragOver={(event) => {
|
||||||
|
if (draggingCircuitId) {
|
||||||
|
if (row.rowType === "placeholder") {
|
||||||
|
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
|
||||||
|
const valid = sourceSectionId === row.sectionId;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = valid ? "move" : "none";
|
||||||
|
setCircuitReorderIntent({ kind: "section-end", sectionId: row.sectionId, valid });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.circuit && row.rowType !== "deviceRow") {
|
||||||
|
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
|
||||||
|
if (row.circuit.id === draggingCircuitId) {
|
||||||
|
setCircuitReorderIntent(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const valid = sourceSectionId === row.sectionId;
|
||||||
|
const rect = (event.currentTarget as HTMLTableRowElement).getBoundingClientRect();
|
||||||
|
const isAfter = event.clientY > rect.top + rect.height / 2;
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = valid ? "move" : "none";
|
||||||
|
setCircuitReorderIntent({
|
||||||
|
kind: isAfter ? "after-circuit" : "before-circuit",
|
||||||
|
sectionId: row.sectionId,
|
||||||
|
targetCircuitId: row.circuit.id,
|
||||||
|
valid,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (draggingProjectDeviceId) {
|
if (draggingProjectDeviceId) {
|
||||||
if (row.rowType === "placeholder") {
|
if (row.rowType === "placeholder") {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1373,8 +1545,49 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
}
|
}
|
||||||
return current;
|
return current;
|
||||||
});
|
});
|
||||||
|
setCircuitReorderIntent((current) => {
|
||||||
|
if (!current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (current.kind === "section-end" && row.rowType === "placeholder" && current.sectionId === row.sectionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(current.kind === "before-circuit" || current.kind === "after-circuit") &&
|
||||||
|
current.targetCircuitId === row.circuit?.id
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
onDrop={(event) => {
|
onDrop={(event) => {
|
||||||
|
if (draggingCircuitId) {
|
||||||
|
if (row.rowType === "placeholder") {
|
||||||
|
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
|
||||||
|
void handleCircuitReorderDrop(event, {
|
||||||
|
kind: "section-end",
|
||||||
|
sectionId: row.sectionId,
|
||||||
|
valid: sourceSectionId === row.sectionId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (row.circuit && row.rowType !== "deviceRow") {
|
||||||
|
if (row.circuit.id === draggingCircuitId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
|
||||||
|
const rect = (event.currentTarget as HTMLTableRowElement).getBoundingClientRect();
|
||||||
|
const isAfter = event.clientY > rect.top + rect.height / 2;
|
||||||
|
void handleCircuitReorderDrop(event, {
|
||||||
|
kind: isAfter ? "after-circuit" : "before-circuit",
|
||||||
|
sectionId: row.sectionId,
|
||||||
|
targetCircuitId: row.circuit.id,
|
||||||
|
valid: sourceSectionId === row.sectionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (draggingProjectDeviceId) {
|
if (draggingProjectDeviceId) {
|
||||||
if (row.rowType === "placeholder") {
|
if (row.rowType === "placeholder") {
|
||||||
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: row.sectionId });
|
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: row.sectionId });
|
||||||
@@ -1417,33 +1630,64 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")
|
Boolean(row.device) && column.key === "displayName" && (row.rowType === "deviceRow" || row.rowType === "circuitCompact")
|
||||||
? "device-drag-handle"
|
? "device-drag-handle"
|
||||||
: ""
|
: ""
|
||||||
|
} ${
|
||||||
|
column.key === "equipmentIdentifier" &&
|
||||||
|
Boolean(row.circuit) &&
|
||||||
|
(row.rowType === "circuitCompact" || row.rowType === "circuitSummary" || row.rowType === "reserveCircuit")
|
||||||
|
? "circuit-drag-handle"
|
||||||
|
: ""
|
||||||
} ${
|
} ${
|
||||||
draggingDeviceRowId && row.device?.id === draggingDeviceRowId ? "device-dragging" : ""
|
draggingDeviceRowId && row.device?.id === draggingDeviceRowId ? "device-dragging" : ""
|
||||||
}`}
|
}`}
|
||||||
draggable={
|
draggable={
|
||||||
!isEditing &&
|
!isEditing &&
|
||||||
Boolean(row.device) &&
|
((Boolean(row.device) &&
|
||||||
column.key === "displayName" &&
|
column.key === "displayName" &&
|
||||||
(row.rowType === "deviceRow" || row.rowType === "circuitCompact")
|
(row.rowType === "deviceRow" || row.rowType === "circuitCompact")) ||
|
||||||
|
(Boolean(row.circuit) &&
|
||||||
|
column.key === "equipmentIdentifier" &&
|
||||||
|
(row.rowType === "circuitCompact" ||
|
||||||
|
row.rowType === "circuitSummary" ||
|
||||||
|
row.rowType === "reserveCircuit")))
|
||||||
}
|
}
|
||||||
onDragStart={(event) => {
|
onDragStart={(event) => {
|
||||||
if (
|
if (
|
||||||
!row.device ||
|
row.circuit &&
|
||||||
column.key !== "displayName" ||
|
column.key === "equipmentIdentifier" &&
|
||||||
(row.rowType !== "deviceRow" && row.rowType !== "circuitCompact")
|
(row.rowType === "circuitCompact" ||
|
||||||
|
row.rowType === "circuitSummary" ||
|
||||||
|
row.rowType === "reserveCircuit")
|
||||||
) {
|
) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDraggingProjectDeviceId(null);
|
setDraggingProjectDeviceId(null);
|
||||||
setDropIntent(null);
|
setDropIntent(null);
|
||||||
|
setDraggingDeviceRowId(null);
|
||||||
|
setDeviceMoveIntent(null);
|
||||||
|
setDraggingCircuitId(row.circuit.id);
|
||||||
|
setCircuitReorderIntent(null);
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("application/x-circuit-id", row.circuit.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
row.device &&
|
||||||
|
column.key === "displayName" &&
|
||||||
|
(row.rowType === "deviceRow" || row.rowType === "circuitCompact")
|
||||||
|
) {
|
||||||
|
setDraggingProjectDeviceId(null);
|
||||||
|
setDropIntent(null);
|
||||||
|
setDraggingCircuitId(null);
|
||||||
|
setCircuitReorderIntent(null);
|
||||||
setDraggingDeviceRowId(row.device.id);
|
setDraggingDeviceRowId(row.device.id);
|
||||||
setDeviceMoveIntent(null);
|
setDeviceMoveIntent(null);
|
||||||
event.dataTransfer.effectAllowed = "move";
|
event.dataTransfer.effectAllowed = "move";
|
||||||
event.dataTransfer.setData("application/x-circuit-device-row-id", row.device.id);
|
event.dataTransfer.setData("application/x-circuit-device-row-id", row.device.id);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onDragEnd={() => {
|
onDragEnd={() => {
|
||||||
setDraggingDeviceRowId(null);
|
setDraggingDeviceRowId(null);
|
||||||
setDeviceMoveIntent(null);
|
setDeviceMoveIntent(null);
|
||||||
|
setDraggingCircuitId(null);
|
||||||
|
setCircuitReorderIntent(null);
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (cell.editable) {
|
if (cell.editable) {
|
||||||
@@ -1539,6 +1783,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
|||||||
{deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? (
|
{deviceMoveIntent?.kind === "move-to-circuit" && deviceMoveIntent.circuitId === row.circuit?.id ? (
|
||||||
<span className="drop-hint">move device to this circuit</span>
|
<span className="drop-hint">move device to this circuit</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
{circuitReorderIntent?.kind === "section-end" &&
|
||||||
|
row.rowType === "placeholder" &&
|
||||||
|
circuitReorderIntent.sectionId === row.sectionId ? (
|
||||||
|
<span className="drop-hint">
|
||||||
|
{circuitReorderIntent.valid ? "move circuit to section end" : "cross-section move not allowed"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{circuitReorderIntent &&
|
||||||
|
(circuitReorderIntent.kind === "before-circuit" || circuitReorderIntent.kind === "after-circuit") &&
|
||||||
|
circuitReorderIntent.targetCircuitId === row.circuit?.id ? (
|
||||||
|
<span className="drop-hint">
|
||||||
|
{circuitReorderIntent.valid
|
||||||
|
? circuitReorderIntent.kind === "before-circuit"
|
||||||
|
? "move circuit before this circuit"
|
||||||
|
: "move circuit after this circuit"
|
||||||
|
: "cross-section move not allowed"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -148,6 +148,13 @@ export function renumberCircuitSection(sectionId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reorderSectionCircuits(sectionId: string, orderedCircuitIds: string[]) {
|
||||||
|
return request(`/api/circuit-sections/${sectionId}/circuits/reorder`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ orderedCircuitIds }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function listFloors(projectId: string) {
|
export function listFloors(projectId: string) {
|
||||||
return request<FloorDto[]>(`/api/projects/${projectId}/floors`);
|
return request<FloorDto[]>(`/api/projects/${projectId}/floors`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Request, Response } from "express";
|
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 { reorderSectionCircuitsSchema } from "../../shared/validation/circuit.schemas.js";
|
||||||
|
|
||||||
const circuitWriteService = new CircuitWriteService();
|
const circuitWriteService = new CircuitWriteService();
|
||||||
|
|
||||||
@@ -17,3 +18,21 @@ export async function renumberCircuitSection(req: Request, res: Response) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reorderSectionCircuits(req: Request, res: Response) {
|
||||||
|
const { sectionId } = req.params;
|
||||||
|
if (typeof sectionId !== "string") {
|
||||||
|
return res.status(400).json({ error: "Invalid sectionId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = reorderSectionCircuitsSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return res.status(400).json({ error: parsed.error.flatten() });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updatedCircuits = await circuitWriteService.reorderCircuitsInSection(sectionId, parsed.data);
|
||||||
|
return res.json({ sectionId, circuits: updatedCircuits });
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to reorder circuits." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import { renumberCircuitSection } from "../controllers/circuit-section.controller.js";
|
import { renumberCircuitSection, reorderSectionCircuits } from "../controllers/circuit-section.controller.js";
|
||||||
|
|
||||||
export const circuitSectionRouter = Router();
|
export const circuitSectionRouter = Router();
|
||||||
|
|
||||||
circuitSectionRouter.post("/circuit-sections/:sectionId/renumber", renumberCircuitSection);
|
circuitSectionRouter.post("/circuit-sections/:sectionId/renumber", renumberCircuitSection);
|
||||||
|
circuitSectionRouter.patch("/circuit-sections/:sectionId/circuits/reorder", reorderSectionCircuits);
|
||||||
|
|||||||
@@ -62,8 +62,13 @@ export const moveCircuitDeviceRowSchema = z
|
|||||||
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
|
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const reorderSectionCircuitsSchema = z.object({
|
||||||
|
orderedCircuitIds: z.array(z.string().min(1)).min(1),
|
||||||
|
});
|
||||||
|
|
||||||
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>;
|
export type MoveCircuitDeviceRowInput = z.infer<typeof moveCircuitDeviceRowSchema>;
|
||||||
|
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
|
||||||
|
|||||||
@@ -300,4 +300,34 @@ describe("circuit write service rules", () => {
|
|||||||
assert.equal(createdCircuitPayload?.equipmentIdentifier, "-2F8");
|
assert.equal(createdCircuitPayload?.equipmentIdentifier, "-2F8");
|
||||||
assert.equal(createdCircuitPayload?.isReserve, false);
|
assert.equal(createdCircuitPayload?.isReserve, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reorders circuits inside one section without renumbering identifiers", async () => {
|
||||||
|
const updates: Array<{ id: string; sortOrder: number; equipmentIdentifier: string }> = [];
|
||||||
|
const service = new CircuitWriteService({
|
||||||
|
circuitSectionRepository: {
|
||||||
|
async findById() {
|
||||||
|
return { id: "s1", circuitListId: "l1" } as never;
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
circuitRepository: {
|
||||||
|
async listBySection() {
|
||||||
|
return [
|
||||||
|
{ id: "c1", sectionId: "s1", equipmentIdentifier: "-2F7", sortOrder: 10, isReserve: 0 },
|
||||||
|
{ id: "c2", sectionId: "s1", equipmentIdentifier: "-2F9", sortOrder: 20, isReserve: 0 },
|
||||||
|
{ id: "c3", sectionId: "s1", equipmentIdentifier: "-2F5", sortOrder: 30, isReserve: 1 },
|
||||||
|
] as never[];
|
||||||
|
},
|
||||||
|
async update(id: string, payload: { sortOrder: number; equipmentIdentifier: string }) {
|
||||||
|
updates.push({ id, sortOrder: payload.sortOrder, equipmentIdentifier: payload.equipmentIdentifier });
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.reorderCircuitsInSection("s1", { orderedCircuitIds: ["c3", "c1", "c2"] });
|
||||||
|
assert.deepEqual(updates, [
|
||||||
|
{ id: "c3", sortOrder: 10, equipmentIdentifier: "-2F5" },
|
||||||
|
{ id: "c1", sortOrder: 20, equipmentIdentifier: "-2F7" },
|
||||||
|
{ id: "c2", sortOrder: 30, equipmentIdentifier: "-2F9" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user