Started multiline manipulations

This commit is contained in:
2026-05-05 21:20:09 +02:00
parent 47dec0df39
commit b1e19a88d5
10 changed files with 631 additions and 790 deletions
+4
View File
@@ -347,6 +347,10 @@ body {
background: #f8fbff;
}
.tree-grid tr.row-selected td {
background: #eaf1ff;
}
.tree-grid .placeholder-row td {
background: #f7f7f7;
color: #6b7280;
@@ -7,6 +7,7 @@ import type {
CreateCircuitDeviceRowInput,
CreateCircuitInput,
MoveCircuitDeviceRowInput,
MoveCircuitDeviceRowsBulkInput,
ReorderSectionCircuitsInput,
UpdateSectionEquipmentIdentifiersInput,
UpdateCircuitDeviceRowInput,
@@ -365,6 +366,128 @@ export class CircuitWriteService {
return this.deviceRowRepository.findById(rowId);
}
async moveDeviceRowsBulk(input: MoveCircuitDeviceRowsBulkInput) {
const uniqueRowIds = [...new Set(input.rowIds)];
if (uniqueRowIds.length === 0) {
throw new Error("No device rows provided.");
}
const rows = [];
for (const rowId of uniqueRowIds) {
const row = await this.deviceRowRepository.findById(rowId);
if (!row) {
throw new Error("Invalid device row id.");
}
rows.push(row);
}
const sourceCircuits = new Map<string, Awaited<ReturnType<CircuitRepository["findById"]>>>();
for (const row of rows) {
if (!sourceCircuits.has(row.circuitId)) {
const circuit = await this.circuitRepository.findById(row.circuitId);
if (!circuit) {
throw new Error("Invalid circuit id.");
}
sourceCircuits.set(row.circuitId, circuit);
}
}
const referenceSourceCircuit = sourceCircuits.get(rows[0].circuitId)!;
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, referenceSourceCircuit.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: referenceSourceCircuit.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.");
}
}
for (const sourceCircuit of sourceCircuits.values()) {
if (sourceCircuit.circuitListId !== targetCircuit.circuitListId) {
throw new Error("All moved rows must belong to same circuit list as target.");
}
}
const targetCount = await this.deviceRowRepository.countByCircuit(targetCircuit.id);
let offset = 1;
for (const row of rows) {
await this.deviceRowRepository.moveToCircuit(row.id, targetCircuit.id, (targetCount + offset) * 10);
offset += 1;
}
for (const sourceCircuit of sourceCircuits.values()) {
const remaining = await this.deviceRowRepository.countByCircuit(sourceCircuit.id);
if (remaining === 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 {
movedRowIds: rows.map((row) => row.id),
targetCircuitId: targetCircuit.id,
createdCircuitId: input.targetCircuitId ? undefined : targetCircuit.id,
};
}
async getNextIdentifier(sectionId: string) {
return this.numberingService.getNextIdentifier(sectionId);
}
+349 -56
View File
@@ -9,6 +9,7 @@ import {
getCircuitTree,
getNextCircuitIdentifier,
listProjectDevices,
moveCircuitDeviceRowsBulk,
moveCircuitDeviceRowById,
reorderSectionCircuits,
renumberCircuitSection,
@@ -467,6 +468,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
const [anchorRowKey, setAnchorRowKey] = useState<string | null>(null);
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
@@ -478,8 +481,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [draggingProjectDeviceId, setDraggingProjectDeviceId] = useState<string | null>(null);
const [dropIntent, setDropIntent] = useState<ProjectDeviceDropIntent | null>(null);
const [draggingDeviceRowId, setDraggingDeviceRowId] = useState<string | null>(null);
const [draggingDeviceRowIds, setDraggingDeviceRowIds] = useState<string[]>([]);
const [deviceMoveIntent, setDeviceMoveIntent] = useState<DeviceRowMoveDropIntent | null>(null);
const [draggingCircuitId, setDraggingCircuitId] = useState<string | null>(null);
const [draggingCircuitIds, setDraggingCircuitIds] = useState<string[]>([]);
const [circuitReorderIntent, setCircuitReorderIntent] = useState<CircuitReorderDropIntent | null>(null);
const [undoStack, setUndoStack] = useState<HistoryCommand[]>([]);
const [redoStack, setRedoStack] = useState<HistoryCommand[]>([]);
@@ -494,6 +499,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [columnDropTargetKey, setColumnDropTargetKey] = useState<CellKey | null>(null);
const [pendingFocus, setPendingFocus] = useState<SelectedCell | null>(null);
const pendingSelectionAfterReload = useRef<SelectionIntent | null>(null);
const pendingSelectedDeviceRowIdsAfterReload = useRef<string[] | null>(null);
const pendingSelectedCircuitIdsAfterReload = useRef<string[] | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const focusTokenRef = useRef(1);
@@ -803,6 +810,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
() => visibleRows.filter((row) => rowCellMap.has(row.rowKey)).map((row) => row.rowKey),
[visibleRows, rowCellMap]
);
const selectableRowOrder = useMemo(
() =>
visibleRows
.filter(
(row) =>
row.rowType === "circuitCompact" ||
row.rowType === "circuitSummary" ||
row.rowType === "deviceRow" ||
row.rowType === "reserveCircuit"
)
.map((row) => row.rowKey),
[visibleRows]
);
useEffect(() => {
if (!selectedCell && editableCells.length > 0) {
@@ -819,6 +839,64 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
}, [editableCells, selectedCell]);
useEffect(() => {
setSelectedRowKeys((current) => current.filter((rowKey) => selectableRowOrder.includes(rowKey)));
setAnchorRowKey((current) => (current && selectableRowOrder.includes(current) ? current : null));
}, [selectableRowOrder]);
function isSelectableRowType(rowType: RowType) {
return (
rowType === "circuitCompact" ||
rowType === "circuitSummary" ||
rowType === "deviceRow" ||
rowType === "reserveCircuit"
);
}
function selectRowRange(targetRowKey: string) {
const anchor = anchorRowKey ?? selectedRowKeys[selectedRowKeys.length - 1] ?? targetRowKey;
const start = selectableRowOrder.indexOf(anchor);
const end = selectableRowOrder.indexOf(targetRowKey);
if (start < 0 || end < 0) {
setSelectedRowKeys([targetRowKey]);
setAnchorRowKey(targetRowKey);
return;
}
const from = Math.min(start, end);
const to = Math.max(start, end);
setSelectedRowKeys(selectableRowOrder.slice(from, to + 1));
setAnchorRowKey(anchor);
}
function handleRowSelectionClick(
row: VisibleGridRow,
cellKey: CellKey,
options?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }
) {
if (isSelectableRowType(row.rowType)) {
const ctrlOrMeta = Boolean(options?.ctrlKey || options?.metaKey);
if (options?.shiftKey) {
selectRowRange(row.rowKey);
} else if (ctrlOrMeta) {
setSelectedRowKeys((current) => {
if (current.includes(row.rowKey)) {
return current.filter((key) => key !== row.rowKey);
}
return [...current, row.rowKey];
});
setAnchorRowKey(row.rowKey);
} else {
setSelectedRowKeys([row.rowKey]);
setAnchorRowKey(row.rowKey);
}
} else if (!options?.ctrlKey && !options?.metaKey && !options?.shiftKey) {
setSelectedRowKeys([]);
setAnchorRowKey(null);
}
setSelectedCell({ rowKey: row.rowKey, cellKey });
requestAnimationFrame(() => containerRef.current?.focus());
}
function buildSelectionIntent(cell: SelectedCell): SelectionIntent | null {
const row = findRow(cell.rowKey);
if (!row) {
@@ -1102,6 +1180,46 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
requestAnimationFrame(() => containerRef.current?.focus());
}, [data, editableCells, visibleRows]);
useEffect(() => {
const pending = pendingSelectedDeviceRowIdsAfterReload.current;
if (!pending || pending.length === 0) {
return;
}
const rowKeys: string[] = [];
for (const row of visibleRows) {
if ((row.rowType === "deviceRow" || row.rowType === "circuitCompact") && row.device?.id && pending.includes(row.device.id)) {
rowKeys.push(row.rowKey);
}
}
if (rowKeys.length > 0) {
setSelectedRowKeys(rowKeys);
setAnchorRowKey(rowKeys[0]);
}
pendingSelectedDeviceRowIdsAfterReload.current = null;
}, [visibleRows]);
useEffect(() => {
const pending = pendingSelectedCircuitIdsAfterReload.current;
if (!pending || pending.length === 0) {
return;
}
const rowKeys: string[] = [];
for (const row of visibleRows) {
if (
(row.rowType === "circuitCompact" || row.rowType === "circuitSummary" || row.rowType === "reserveCircuit") &&
row.circuit?.id &&
pending.includes(row.circuit.id)
) {
rowKeys.push(row.rowKey);
}
}
if (rowKeys.length > 0) {
setSelectedRowKeys(rowKeys);
setAnchorRowKey(rowKeys[0]);
}
pendingSelectedCircuitIdsAfterReload.current = null;
}, [visibleRows]);
useEffect(() => {
if (!pendingFocus) {
return;
@@ -1745,6 +1863,38 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return event.dataTransfer.getData("application/x-circuit-device-row-id") || null;
}
function parseDraggedDeviceRowIds(event: DragEvent<HTMLElement>) {
const raw = event.dataTransfer.getData("application/x-circuit-device-row-ids");
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
} catch {
return [];
}
}
function parseDraggedCircuitIds(event: DragEvent<HTMLElement>) {
const raw = event.dataTransfer.getData("application/x-circuit-ids");
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
} catch {
return [];
}
}
function findDeviceRowCircuitId(deviceRowId: string) {
for (const section of data?.sections ?? []) {
for (const circuit of section.circuits) {
@@ -1756,6 +1906,46 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return null;
}
function getSelectedEligibleDeviceRowIds(primaryRowId?: string | null) {
const selectedSet = new Set(selectedRowKeys);
const ids: string[] = [];
for (const row of visibleRows) {
if (!selectedSet.has(row.rowKey)) {
continue;
}
if ((row.rowType === "deviceRow" || row.rowType === "circuitCompact") && row.device?.id) {
ids.push(row.device.id);
}
}
if (ids.length > 1 && primaryRowId && ids.includes(primaryRowId)) {
return ids;
}
return primaryRowId ? [primaryRowId] : ids;
}
function getSelectedEligibleCircuitIds(primaryCircuitId?: string | null) {
const selectedSet = new Set(selectedRowKeys);
const ids: string[] = [];
const seen = new Set<string>();
for (const row of visibleRows) {
if (!selectedSet.has(row.rowKey)) {
continue;
}
if (
(row.rowType === "circuitCompact" || row.rowType === "circuitSummary" || row.rowType === "reserveCircuit") &&
row.circuit?.id &&
!seen.has(row.circuit.id)
) {
seen.add(row.circuit.id);
ids.push(row.circuit.id);
}
}
if (ids.length > 1 && primaryCircuitId && ids.includes(primaryCircuitId)) {
return ids;
}
return primaryCircuitId ? [primaryCircuitId] : ids;
}
function findCircuitSectionId(circuitId: string) {
for (const section of data?.sections ?? []) {
if (section.circuits.some((circuit) => circuit.id === circuitId)) {
@@ -1765,28 +1955,28 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return null;
}
async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitId: string) {
async function applyCircuitReorder(intent: CircuitReorderDropIntent, sourceCircuitIds: 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) {
const block = sourceCircuitIds.filter((id) => ids.includes(id));
if (block.length === 0) {
throw new Error("Invalid source circuit.");
}
const nextIds = [...ids];
nextIds.splice(fromIndex, 1);
const blockSet = new Set(block);
const nextIds = ids.filter((id) => !blockSet.has(id));
if (intent.kind === "section-end") {
nextIds.push(sourceCircuitId);
nextIds.push(...block);
} 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);
nextIds.splice(insertIndex, 0, ...block);
}
await reorderSectionCircuits(section.id, nextIds);
}
@@ -1797,6 +1987,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const draggedId = parseDraggedProjectDeviceId(event) ?? draggingProjectDeviceId;
setDropIntent(null);
setDraggingProjectDeviceId(null);
setDraggingDeviceRowIds([]);
setDraggingCircuitIds([]);
if (!draggedId) {
setError("Missing dragged project device.");
return;
@@ -1858,32 +2050,57 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
async function handleDeviceRowDropWithIntent(event: DragEvent<HTMLElement>, intent: DeviceRowMoveDropIntent) {
event.preventDefault();
event.stopPropagation();
const rowId = parseDraggedDeviceRowId(event) ?? draggingDeviceRowId;
const draggedRowIds = parseDraggedDeviceRowIds(event);
const singleRowId = parseDraggedDeviceRowId(event) ?? draggingDeviceRowId;
const rowIds = draggedRowIds.length > 0 ? draggedRowIds : singleRowId ? [singleRowId] : [];
setDeviceMoveIntent(null);
setDraggingDeviceRowId(null);
if (!rowId) {
setDraggingDeviceRowIds([]);
setDraggingCircuitIds([]);
if (rowIds.length === 0) {
setError("Missing dragged device row.");
return;
}
const sourceCircuitId = findDeviceRowCircuitId(rowId);
const sourceByRowId = new Map<string, string>();
for (const rowId of rowIds) {
const sourceCircuitId = findDeviceRowCircuitId(rowId);
if (!sourceCircuitId) {
setError("Invalid dragged device row.");
return;
}
sourceByRowId.set(rowId, sourceCircuitId);
}
const sourceCircuitId = sourceByRowId.get(rowIds[0]);
if (!sourceCircuitId) {
setError("Invalid dragged device row.");
setError("Invalid dragged device row source.");
return;
}
if (intent.kind === "move-to-circuit") {
if (intent.circuitId === sourceCircuitId) {
if (rowIds.length === 1 && intent.circuitId === sourceCircuitId) {
return;
}
const groupedBySource = new Map<string, string[]>();
for (const rowId of rowIds) {
const source = sourceByRowId.get(rowId)!;
if (!groupedBySource.has(source)) {
groupedBySource.set(source, []);
}
groupedBySource.get(source)!.push(rowId);
}
await runCommand({
label: "Move device row",
label: rowIds.length > 1 ? `Move ${rowIds.length} device rows` : "Move device row",
redo: async () => {
await moveCircuitDeviceRowById(rowId, { targetCircuitId: intent.circuitId });
await moveCircuitDeviceRowsBulk({ rowIds, targetCircuitId: intent.circuitId });
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
undo: async () => {
await moveCircuitDeviceRowById(rowId, { targetCircuitId: sourceCircuitId });
for (const [source, ids] of groupedBySource) {
await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source });
}
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
});
@@ -1891,21 +2108,34 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
let createdCircuitId: string | null = null;
const groupedBySource = new Map<string, string[]>();
for (const rowId of rowIds) {
const source = sourceByRowId.get(rowId)!;
if (!groupedBySource.has(source)) {
groupedBySource.set(source, []);
}
groupedBySource.get(source)!.push(rowId);
}
await runCommand({
label: "Move device row to new circuit",
label: rowIds.length > 1 ? `Move ${rowIds.length} device rows to new circuit` : "Move device row to new circuit",
redo: async () => {
const moved = (await moveCircuitDeviceRowById(rowId, {
const moved = await moveCircuitDeviceRowsBulk({
rowIds,
targetSectionId: intent.sectionId,
createNewCircuit: true,
})) as { circuitId?: string };
createdCircuitId = moved.circuitId ?? null;
});
createdCircuitId = moved.createdCircuitId ?? null;
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
undo: async () => {
await moveCircuitDeviceRowById(rowId, { targetCircuitId: sourceCircuitId });
for (const [source, ids] of groupedBySource) {
await moveCircuitDeviceRowsBulk({ rowIds: ids, targetCircuitId: source });
}
if (createdCircuitId) {
await deleteCircuitById(createdCircuitId);
}
pendingSelectedDeviceRowIdsAfterReload.current = rowIds;
return null;
},
});
@@ -1914,10 +2144,13 @@ 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;
const draggedCircuitIds = parseDraggedCircuitIds(event);
const singleCircuitId = event.dataTransfer.getData("application/x-circuit-id") || draggingCircuitId;
const sourceCircuitIds = draggedCircuitIds.length > 0 ? draggedCircuitIds : singleCircuitId ? [singleCircuitId] : [];
setCircuitReorderIntent(null);
setDraggingCircuitId(null);
if (!sourceCircuitId) {
setDraggingCircuitIds([]);
if (sourceCircuitIds.length === 0) {
setError("Missing dragged circuit.");
return;
}
@@ -1930,27 +2163,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setError("Invalid section id.");
return;
}
const sectionCircuitIds = new Set(section.circuits.map((circuit) => circuit.id));
if (sourceCircuitIds.some((id) => !sectionCircuitIds.has(id))) {
setError("Cross-section circuit move is not allowed in this phase.");
return;
}
const beforeOrder = section.circuits.map((circuit) => circuit.id);
const primaryCircuitId = sourceCircuitIds[0];
await runCommand({
label: "Reorder circuits",
label: sourceCircuitIds.length > 1 ? `Reorder ${sourceCircuitIds.length} circuits` : "Reorder circuits",
redo: async () => {
await applyCircuitReorder(intent, sourceCircuitId);
await applyCircuitReorder(intent, sourceCircuitIds);
pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds;
return {
rowKey: `circuitSummary:${sourceCircuitId}`,
rowKey: `circuitSummary:${primaryCircuitId}`,
cellKey: "equipmentIdentifier",
rowType: "circuitSummary",
sectionId: intent.sectionId,
circuitId: sourceCircuitId,
circuitId: primaryCircuitId,
};
},
undo: async () => {
await reorderSectionCircuits(intent.sectionId, beforeOrder);
pendingSelectedCircuitIdsAfterReload.current = sourceCircuitIds;
return {
rowKey: `circuitSummary:${sourceCircuitId}`,
rowKey: `circuitSummary:${primaryCircuitId}`,
cellKey: "equipmentIdentifier",
rowType: "circuitSummary",
sectionId: intent.sectionId,
circuitId: sourceCircuitId,
circuitId: primaryCircuitId,
};
},
});
@@ -2077,6 +2318,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
return;
}
if (event.key === "Escape") {
if (selectedRowKeys.length > 0) {
event.preventDefault();
setSelectedRowKeys([]);
setAnchorRowKey(null);
}
return;
}
if (event.ctrlKey && event.key.toLowerCase() === "z") {
event.preventDefault();
if (event.shiftKey) {
@@ -2229,6 +2478,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const isSortedView = Boolean(sortState);
const hasActiveSortOrFilter = isSortedView || hasActiveFilters;
const draggingDeviceCount = draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds.length : draggingDeviceRowId ? 1 : 0;
const activeDraggedCircuitIds =
draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : [];
const draggingCircuitCount = activeDraggedCircuitIds.length;
return (
<div className="tree-editor-shell">
@@ -2335,7 +2588,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
setSelectedProjectDeviceId(device.id);
setDraggingProjectDeviceId(device.id);
setDraggingDeviceRowId(null);
setDraggingDeviceRowIds([]);
setDraggingCircuitId(null);
setDraggingCircuitIds([]);
setDeviceMoveIntent(null);
setCircuitReorderIntent(null);
event.dataTransfer.effectAllowed = "copy";
@@ -2344,6 +2599,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onDragEnd={() => {
setDraggingProjectDeviceId(null);
setDropIntent(null);
setDraggingDeviceRowIds([]);
setDraggingCircuitIds([]);
}}
>
<strong>{device.displayName || device.name}</strong>
@@ -2401,6 +2658,12 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
tabIndex={0}
onFocus={handleContainerFocus}
onKeyDown={handleContainerKeyDown}
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
setSelectedRowKeys([]);
setAnchorRowKey(null);
}
}}
>
<table className="tree-grid">
<thead>
@@ -2490,7 +2753,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
dropIntent?.kind === "new-circuit" && dropIntent.sectionId === section.id ? "drop-target-active" : ""
}`}
onDragOver={(event) => {
if (draggingCircuitId) {
if (draggingCircuitCount > 0) {
event.preventDefault();
event.dataTransfer.dropEffect = "none";
setCircuitReorderIntent({
@@ -2520,7 +2783,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
void handleDropWithIntent(event, { kind: "new-circuit", sectionId: section.id });
return;
}
if (draggingCircuitId) {
if (draggingCircuitCount > 0) {
void handleCircuitReorderDrop(event, {
kind: "section-end",
sectionId: section.id,
@@ -2613,26 +2876,31 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
? "drop-target-invalid"
: ""
} ${
row.circuit?.id && draggingCircuitId && row.circuit.id === draggingCircuitId ? "circuit-dragging-block" : ""
row.circuit?.id &&
((draggingCircuitIds.length > 0 && draggingCircuitIds.includes(row.circuit.id)) ||
(draggingCircuitIds.length === 0 && draggingCircuitId && row.circuit.id === draggingCircuitId))
? "circuit-dragging-block"
: ""
} ${selectedRowKeys.includes(row.rowKey) ? "row-selected" : ""}
}`}
onClick={() => setActiveSectionId(row.sectionId)}
onDragOver={(event) => {
if (draggingCircuitId) {
if (draggingCircuitCount > 0) {
const sourceSectionIds = activeDraggedCircuitIds
.map((id) => findCircuitSectionId(id))
.filter((id): id is string => Boolean(id));
const valid = sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === row.sectionId);
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) {
if (activeDraggedCircuitIds.includes(row.circuit.id)) {
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();
@@ -2660,7 +2928,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
return;
}
if (draggingDeviceRowId) {
if (draggingDeviceCount > 0) {
if (row.rowType === "placeholder") {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
@@ -2668,8 +2936,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return;
}
if (row.circuit && row.rowType !== "deviceRow") {
const sourceCircuitId = findDeviceRowCircuitId(draggingDeviceRowId);
if (sourceCircuitId && sourceCircuitId !== row.circuit.id) {
const sourceCircuitIds = (draggingDeviceRowIds.length > 0 ? draggingDeviceRowIds : draggingDeviceRowId ? [draggingDeviceRowId] : [])
.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 });
@@ -2719,28 +2989,32 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}}
onDrop={(event) => {
if (draggingCircuitId) {
if (draggingCircuitCount > 0) {
if (row.rowType === "placeholder") {
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
const sourceSectionIds = activeDraggedCircuitIds
.map((id) => findCircuitSectionId(id))
.filter((id): id is string => Boolean(id));
void handleCircuitReorderDrop(event, {
kind: "section-end",
sectionId: row.sectionId,
valid: sourceSectionId === row.sectionId,
valid: sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === row.sectionId),
});
return;
}
if (row.circuit && row.rowType !== "deviceRow") {
if (row.circuit.id === draggingCircuitId) {
if (activeDraggedCircuitIds.includes(row.circuit.id)) {
return;
}
const sourceSectionId = findCircuitSectionId(draggingCircuitId);
const sourceSectionIds = activeDraggedCircuitIds
.map((id) => findCircuitSectionId(id))
.filter((id): id is string => Boolean(id));
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,
valid: sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === row.sectionId),
});
}
return;
@@ -2759,7 +3033,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
return;
}
if (draggingDeviceRowId) {
if (draggingDeviceCount > 0) {
if (row.rowType === "placeholder") {
void handleDeviceRowDropWithIntent(event, { kind: "move-to-new-circuit", sectionId: row.sectionId });
return;
@@ -2794,7 +3068,11 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
? "circuit-drag-handle"
: ""
} ${
draggingDeviceRowId && row.device?.id === draggingDeviceRowId ? "device-dragging" : ""
row.device?.id &&
((draggingDeviceRowIds.length > 0 && draggingDeviceRowIds.includes(row.device.id)) ||
(draggingDeviceRowIds.length === 0 && draggingDeviceRowId === row.device.id))
? "device-dragging"
: ""
}`}
draggable={
!isEditing &&
@@ -2815,14 +3093,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
row.rowType === "circuitSummary" ||
row.rowType === "reserveCircuit")
) {
const circuitIds = getSelectedEligibleCircuitIds(row.circuit.id);
setDraggingProjectDeviceId(null);
setDropIntent(null);
setDraggingDeviceRowId(null);
setDraggingDeviceRowIds([]);
setDeviceMoveIntent(null);
setDraggingCircuitId(row.circuit.id);
setDraggingCircuitIds(circuitIds);
setCircuitReorderIntent(null);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-circuit-id", row.circuit.id);
event.dataTransfer.setData("application/x-circuit-ids", JSON.stringify(circuitIds));
return;
}
if (
@@ -2830,26 +3112,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
column.key === "displayName" &&
(row.rowType === "deviceRow" || row.rowType === "circuitCompact")
) {
const rowIds = getSelectedEligibleDeviceRowIds(row.device.id);
setDraggingProjectDeviceId(null);
setDropIntent(null);
setDraggingCircuitId(null);
setDraggingCircuitIds([]);
setCircuitReorderIntent(null);
setDraggingDeviceRowId(row.device.id);
setDraggingDeviceRowIds(rowIds);
setDeviceMoveIntent(null);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-circuit-device-row-id", row.device.id);
event.dataTransfer.setData("application/x-circuit-device-row-ids", JSON.stringify(rowIds));
}
}}
onDragEnd={() => {
setDraggingDeviceRowId(null);
setDraggingDeviceRowIds([]);
setDeviceMoveIntent(null);
setDraggingCircuitId(null);
setDraggingCircuitIds([]);
setCircuitReorderIntent(null);
}}
onClick={() => {
onClick={(event) => {
if (cell.editable) {
setSelectedCell({ rowKey: row.rowKey, cellKey: column.key });
requestAnimationFrame(() => containerRef.current?.focus());
handleRowSelectionClick(row, column.key, {
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
});
}
}}
onDoubleClick={() => {
@@ -2935,16 +3226,18 @@ 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 device to new circuit</span>
<span className="drop-hint">{`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} 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>
<span className="drop-hint">{`move ${draggingDeviceCount || 1} device${draggingDeviceCount === 1 ? "" : "s"} to this circuit`}</span>
) : 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"}
{circuitReorderIntent.valid
? `move ${draggingCircuitCount || 1} circuit${draggingCircuitCount === 1 ? "" : "s"} to section end`
: "cross-section move not allowed"}
</span>
) : null}
{circuitReorderIntent &&
@@ -2953,8 +3246,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
<span className="drop-hint">
{circuitReorderIntent.valid
? circuitReorderIntent.kind === "before-circuit"
? "move circuit before this circuit"
: "move circuit after this circuit"
? `move ${draggingCircuitCount || 1} circuit${draggingCircuitCount === 1 ? "" : "s"} before this circuit`
: `move ${draggingCircuitCount || 1} circuit${draggingCircuitCount === 1 ? "" : "s"} after this circuit`
: "cross-section move not allowed"}
</span>
) : null}
+15
View File
@@ -142,6 +142,21 @@ export function moveCircuitDeviceRowById(
});
}
export function moveCircuitDeviceRowsBulk(input: {
rowIds: string[];
targetCircuitId?: string;
targetSectionId?: string;
createNewCircuit?: boolean;
}) {
return request<{ movedRowIds: string[]; targetCircuitId: string; createdCircuitId?: string }>(
"/api/circuit-device-rows/move-bulk",
{
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,
moveCircuitDeviceRowsBulkSchema,
moveCircuitDeviceRowSchema,
updateCircuitDeviceRowSchema,
} from "../../shared/validation/circuit.schemas.js";
@@ -78,3 +79,17 @@ export async function moveCircuitDeviceRow(req: Request, res: Response) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device row." });
}
}
export async function moveCircuitDeviceRowsBulk(req: Request, res: Response) {
const parsed = moveCircuitDeviceRowsBulkSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: parsed.error.flatten() });
}
try {
const moved = await circuitWriteService.moveDeviceRowsBulk(parsed.data);
return res.json(moved);
} catch (error) {
return res.status(400).json({ error: error instanceof Error ? error.message : "Failed to move device rows." });
}
}
@@ -1,12 +1,14 @@
import { Router } from "express";
import {
deleteCircuitDeviceRow,
moveCircuitDeviceRowsBulk,
moveCircuitDeviceRow,
updateCircuitDeviceRow,
} from "../controllers/circuit-device-row.controller.js";
export const circuitDeviceRowRouter = Router();
circuitDeviceRowRouter.patch("/circuit-device-rows/move-bulk", moveCircuitDeviceRowsBulk);
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId", updateCircuitDeviceRow);
circuitDeviceRowRouter.patch("/circuit-device-rows/:rowId/move", moveCircuitDeviceRow);
circuitDeviceRowRouter.delete("/circuit-device-rows/:rowId", deleteCircuitDeviceRow);
+15
View File
@@ -62,6 +62,20 @@ export const moveCircuitDeviceRowSchema = z
{ message: "Either targetCircuitId or targetSectionId+createNewCircuit=true is required." }
);
export const moveCircuitDeviceRowsBulkSchema = z
.object({
rowIds: z.array(z.string().min(1)).min(1),
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 const reorderSectionCircuitsSchema = z.object({
orderedCircuitIds: z.array(z.string().min(1)).min(1),
});
@@ -82,5 +96,6 @@ 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>;
export type MoveCircuitDeviceRowsBulkInput = z.infer<typeof moveCircuitDeviceRowsBulkSchema>;
export type ReorderSectionCircuitsInput = z.infer<typeof reorderSectionCircuitsSchema>;
export type UpdateSectionEquipmentIdentifiersInput = z.infer<typeof updateSectionEquipmentIdentifiersSchema>;