From 9b9e67bf0ce1eef2faeecc86a6619e8cc172efe9 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Tue, 5 May 2026 09:55:08 +0200 Subject: [PATCH] Undo Redo working --- src/app/globals.css | 17 + src/db/repositories/circuit.repository.ts | 42 +- src/domain/services/circuit-write.service.ts | 53 +- .../components/circuit-tree-editor.tsx | 811 ++++++++++++++---- src/frontend/utils/api.ts | 10 + .../controllers/circuit-section.controller.ts | 24 +- src/server/routes/circuit-section.routes.ts | 7 +- src/shared/validation/circuit.schemas.ts | 12 + tests/circuit-write.rules.test.ts | 175 +++- 9 files changed, 938 insertions(+), 213 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index a9021c9..e6b1d72 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -46,6 +46,23 @@ body { gap: 0.75rem; } +.editor-toolbar { + display: flex; + gap: 0.5rem; +} + +.editor-toolbar button { + border: 1px solid #c4cddc; + background: #fff; + padding: 0.28rem 0.6rem; + border-radius: 4px; + font-size: 0.82rem; +} + +.editor-toolbar button:disabled { + opacity: 0.45; +} + .tree-grid-wrap { overflow: auto; border: 1px solid #d9dee8; diff --git a/src/db/repositories/circuit.repository.ts b/src/db/repositories/circuit.repository.ts index 99d3bc5..06ae1a0 100644 --- a/src/db/repositories/circuit.repository.ts +++ b/src/db/repositories/circuit.repository.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { and, asc, eq, ne } from "drizzle-orm"; +import { and, asc, eq, inArray, ne } from "drizzle-orm"; import { db } from "../client.js"; import { circuits } from "../schema/circuits.js"; @@ -132,4 +132,44 @@ export class CircuitRepository { .where(eq(circuits.sectionId, sectionId)) .orderBy(asc(circuits.sortOrder), asc(circuits.equipmentIdentifier)); } + + async updateEquipmentIdentifiersSafely( + circuitListId: string, + updates: Array<{ id: string; equipmentIdentifier: string }>, + tempNamespace: string + ) { + if (updates.length === 0) { + return; + } + db.transaction((tx) => { + const ids = updates.map((entry) => entry.id); + const existing = tx + .select({ id: circuits.id }) + .from(circuits) + .where(and(eq(circuits.circuitListId, circuitListId), inArray(circuits.id, ids))) + .all(); + if (existing.length !== ids.length) { + throw new Error("One or more circuit ids are invalid for circuit list."); + } + + const stamp = Date.now(); + for (let index = 0; index < updates.length; index += 1) { + const entry = updates[index]; + const tempIdentifier = `__tmp_renumber_${tempNamespace}_${stamp}_${index}`; + tx + .update(circuits) + .set({ equipmentIdentifier: tempIdentifier }) + .where(and(eq(circuits.circuitListId, circuitListId), eq(circuits.id, entry.id))) + .run(); + } + + for (const entry of updates) { + tx + .update(circuits) + .set({ equipmentIdentifier: entry.equipmentIdentifier }) + .where(and(eq(circuits.circuitListId, circuitListId), eq(circuits.id, entry.id))) + .run(); + } + }); + } } diff --git a/src/domain/services/circuit-write.service.ts b/src/domain/services/circuit-write.service.ts index b253547..bd0c212 100644 --- a/src/domain/services/circuit-write.service.ts +++ b/src/domain/services/circuit-write.service.ts @@ -8,6 +8,7 @@ import type { CreateCircuitInput, MoveCircuitDeviceRowInput, ReorderSectionCircuitsInput, + UpdateSectionEquipmentIdentifiersInput, UpdateCircuitDeviceRowInput, UpdateCircuitInput, } from "../../shared/validation/circuit.schemas.js"; @@ -379,6 +380,7 @@ export class CircuitWriteService { ); const otherIdentifiers = new Set(otherCircuits.map((circuit) => circuit.equipmentIdentifier)); + const finalAssignments: Array<{ id: string; equipmentIdentifier: string }> = []; let index = 1; for (const circuit of sectionCircuits) { let candidate = `${section.prefix}${index}`; @@ -386,30 +388,45 @@ export class CircuitWriteService { index += 1; candidate = `${section.prefix}${index}`; } - await this.circuitRepository.update(circuit.id, { - sectionId: circuit.sectionId, - equipmentIdentifier: candidate, - displayName: circuit.displayName ?? undefined, - sortOrder: circuit.sortOrder, - 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, - }); + finalAssignments.push({ id: circuit.id, equipmentIdentifier: candidate }); index += 1; } + await this.circuitRepository.updateEquipmentIdentifiersSafely( + section.circuitListId, + finalAssignments, + sectionId + ); return this.circuitRepository.listBySection(sectionId); } + async updateSectionEquipmentIdentifiers( + sectionId: string, + input: UpdateSectionEquipmentIdentifiersInput + ) { + 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 (input.identifiers.length !== sectionCircuits.length) { + throw new Error("identifiers must include all circuits in the section."); + } + for (const entry of input.identifiers) { + if (!sectionIds.has(entry.circuitId)) { + throw new Error("Circuit id does not belong to section."); + } + } + + await this.circuitRepository.updateEquipmentIdentifiersSafely( + section.circuitListId, + input.identifiers.map((entry) => ({ id: entry.circuitId, equipmentIdentifier: entry.equipmentIdentifier })), + sectionId + ); + return this.circuitRepository.listBySection(sectionId); + } + async reorderCircuitsInSection(sectionId: string, input: ReorderSectionCircuitsInput) { const section = await this.circuitSectionRepository.findById(sectionId); if (!section) { diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 8119f15..485de4d 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -12,6 +12,7 @@ import { moveCircuitDeviceRowById, reorderSectionCircuits, renumberCircuitSection, + updateSectionEquipmentIdentifiers, updateCircuitById, updateCircuitDeviceRowById, } from "../utils/api"; @@ -76,6 +77,33 @@ interface VisibleGridRow { cells: VisibleGridCell[]; } +interface HistoryCommand { + label: string; + redo: () => Promise; + undo: () => Promise; +} + +interface CircuitSnapshot { + id: string; + sectionId: string; + equipmentIdentifier: string; + displayName?: string; + sortOrder: number; + protectionType?: string; + protectionRatedCurrent?: number; + protectionCharacteristic?: string; + cableType?: string; + cableCrossSection?: string; + cableLength?: number; + rcdAssignment?: string; + terminalDesignation?: string; + voltage?: number; + status?: string; + isReserve: boolean; + remark?: string; + deviceRows: CircuitTreeDeviceRowDto[]; +} + type ProjectDeviceDropIntent = | { kind: "new-circuit"; sectionId: string } | { kind: "add-to-circuit"; circuitId: string; sectionId: string }; @@ -282,6 +310,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const [deviceMoveIntent, setDeviceMoveIntent] = useState(null); const [draggingCircuitId, setDraggingCircuitId] = useState(null); const [circuitReorderIntent, setCircuitReorderIntent] = useState(null); + const [undoStack, setUndoStack] = useState([]); + const [redoStack, setRedoStack] = useState([]); + const [historyBusy, setHistoryBusy] = useState(false); const [pendingFocus, setPendingFocus] = useState(null); const pendingSelectionAfterReload = useRef(null); const containerRef = useRef(null); @@ -511,6 +542,69 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return editableCells[0] ?? null; } + async function reloadWithIntent(intent?: SelectionIntent | null) { + if (intent) { + pendingSelectionAfterReload.current = intent; + } + await loadTree({ showLoading: false }); + if (!intent) { + requestAnimationFrame(() => containerRef.current?.focus()); + } + } + + async function runCommand(command: HistoryCommand) { + try { + setError(null); + setIsSaving(true); + const intent = await command.redo(); + await reloadWithIntent(intent ?? null); + setUndoStack((current) => [...current, command]); + setRedoStack([]); + } catch (err) { + setError(normalizeUiError(err)); + } finally { + setIsSaving(false); + } + } + + async function applyHistory(command: HistoryCommand, mode: "undo" | "redo") { + try { + setError(null); + setHistoryBusy(true); + setIsSaving(true); + const intent = mode === "undo" ? await command.undo() : await command.redo(); + await reloadWithIntent(intent ?? null); + if (mode === "undo") { + setUndoStack((current) => current.slice(0, -1)); + setRedoStack((current) => [...current, command]); + } else { + setRedoStack((current) => current.slice(0, -1)); + setUndoStack((current) => [...current, command]); + } + } catch (err) { + setError(normalizeUiError(err)); + } finally { + setIsSaving(false); + setHistoryBusy(false); + } + } + + async function handleUndo() { + if (historyBusy || isSaving || undoStack.length === 0) { + return; + } + const command = undoStack[undoStack.length - 1]; + await applyHistory(command, "undo"); + } + + async function handleRedo() { + if (historyBusy || isSaving || redoStack.length === 0) { + return; + } + const command = redoStack[redoStack.length - 1]; + await applyHistory(command, "redo"); + } + useEffect(() => { const intent = pendingSelectionAfterReload.current; if (!intent || !data) { @@ -693,6 +787,16 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return { rowKey: `reserveCircuit:${createdCircuit.id}`, cellKey: key }; } + function getCurrentDraftForCell(row: VisibleGridRow, cellKey: CellKey, kind: CellKind) { + if (kind === "deviceField" && row.device) { + return String(getDeviceValue(row.device, cellKey) ?? ""); + } + if (kind === "circuitField" && row.circuit) { + return String(getCircuitValue(row.circuit, cellKey) ?? ""); + } + return ""; + } + async function commitEdit(direction: SaveDirection = "stay") { if (!editingCell) { return; @@ -704,20 +808,93 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return; } - try { - setIsSaving(true); - setError(null); - let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey }; + let targetSelection: SelectedCell = { rowKey: editingCell.rowKey, cellKey: editingCell.cellKey }; + const nextSelectionIntent = () => { + let selected = targetSelection; + if (direction !== "stay") { + const idx = editableCells.findIndex( + (entry) => entry.rowKey === selected.rowKey && entry.cellKey === selected.cellKey + ); + if (idx >= 0) { + const targetIdx = + direction === "next" ? Math.min(editableCells.length - 1, idx + 1) : Math.max(0, idx - 1); + selected = editableCells[targetIdx]; + } + } + return buildSelectionIntent(selected); + }; - if (row.rowType === "placeholder") { - targetSelection = await createFromPlaceholder(row.sectionId, editingCell.cellKey, editingCell.draft); - } else if (cell.kind === "circuitField" && row.circuit) { - await patchCircuit(row.circuit.id, editingCell.cellKey, editingCell.draft); - } else if (cell.kind === "deviceField") { - if (row.device) { - await patchDeviceRow(row.device.id, editingCell.cellKey, editingCell.draft); - } else if (row.rowType === "reserveCircuit" && row.circuit) { - const created = (await createCircuitDeviceRow(row.circuit.id, { + if (row.rowType === "placeholder") { + let createdCircuitId: string | null = null; + const command: HistoryCommand = { + label: "Create from placeholder", + redo: async () => { + const selection = await createFromPlaceholder(row.sectionId, editingCell.cellKey, editingCell.draft); + targetSelection = selection; + const createdRow = selection.rowKey.startsWith("circuitCompact:") + ? selection.rowKey.replace("circuitCompact:", "") + : selection.rowKey.replace("reserveCircuit:", ""); + createdCircuitId = createdRow; + return nextSelectionIntent(); + }, + undo: async () => { + if (!createdCircuitId) { + return null; + } + await deleteCircuitById(createdCircuitId); + return buildSelectionIntent({ rowKey: `placeholder:${row.sectionId}`, cellKey: editingCell.cellKey }); + }, + }; + setEditingCell(null); + await runCommand(command); + return; + } + + if (cell.kind === "circuitField" && row.circuit) { + const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind); + const next = editingCell.draft; + const command: HistoryCommand = { + label: "Edit circuit field", + redo: async () => { + await patchCircuit(row.circuit!.id, editingCell.cellKey, next); + return nextSelectionIntent(); + }, + undo: async () => { + await patchCircuit(row.circuit!.id, editingCell.cellKey, previous); + return buildSelectionIntent({ rowKey: row.rowKey, cellKey: editingCell.cellKey }); + }, + }; + setEditingCell(null); + await runCommand(command); + return; + } + + if (cell.kind === "deviceField" && row.device) { + const previous = getCurrentDraftForCell(row, editingCell.cellKey, cell.kind); + const next = editingCell.draft; + const command: HistoryCommand = { + label: "Edit device field", + redo: async () => { + await patchDeviceRow(row.device!.id, editingCell.cellKey, next); + return nextSelectionIntent(); + }, + undo: async () => { + await patchDeviceRow(row.device!.id, editingCell.cellKey, previous); + return buildSelectionIntent({ rowKey: `device:${row.device!.id}`, cellKey: editingCell.cellKey }); + }, + }; + setEditingCell(null); + await runCommand(command); + return; + } + + if (cell.kind === "deviceField" && row.rowType === "reserveCircuit" && row.circuit) { + const next = editingCell.draft; + let createdRowId: string | null = null; + const command: HistoryCommand = { + label: "Create reserve device row", + redo: async () => { + const created = (await createCircuitDeviceRow(row.circuit!.id, { name: "Reserve load", displayName: "Reserve load", phaseType: "single_phase", @@ -726,37 +903,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str simultaneityFactor: 1, cosPhi: 1, })) as { id: string }; - await patchDeviceRow(created.id, editingCell.cellKey, editingCell.draft); - targetSelection = { rowKey: `circuitCompact:${row.circuit.id}`, cellKey: editingCell.cellKey }; - } - } - - if (direction !== "stay") { - const idx = editableCells.findIndex( - (entry) => entry.rowKey === targetSelection.rowKey && entry.cellKey === targetSelection.cellKey - ); - if (idx >= 0) { - const targetIdx = - direction === "next" - ? Math.min(editableCells.length - 1, idx + 1) - : Math.max(0, idx - 1); - targetSelection = editableCells[targetIdx]; - } - } - - const intent = buildSelectionIntent(targetSelection); + createdRowId = created.id; + await patchDeviceRow(created.id, editingCell.cellKey, next); + targetSelection = { rowKey: `circuitCompact:${row.circuit!.id}`, cellKey: editingCell.cellKey }; + return nextSelectionIntent(); + }, + undo: async () => { + if (!createdRowId) { + return null; + } + await deleteCircuitDeviceRowById(createdRowId); + return buildSelectionIntent({ rowKey: `reserveCircuit:${row.circuit!.id}`, cellKey: editingCell.cellKey }); + }, + }; setEditingCell(null); - if (intent) { - pendingSelectionAfterReload.current = intent; - } - await loadTree({ showLoading: false }); - if (!intent) { - requestAnimationFrame(() => containerRef.current?.focus()); - } - } catch (err) { - setError(normalizeUiError(err)); - } finally { - setIsSaving(false); + await runCommand(command); } } @@ -770,56 +931,86 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } async function handleAddReserveCircuit(sectionId: string) { - try { - setError(null); - setIsSaving(true); - const section = data?.sections.find((entry) => entry.id === sectionId); - if (!section) { - return; - } - const next = await getNextCircuitIdentifier(sectionId); - const sortOrder = - section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; - await createCircuit(projectId, circuitListId, { - sectionId, - equipmentIdentifier: next.nextIdentifier, - displayName: "Reserve", - sortOrder, - isReserve: true, - }); - await loadTree({ showLoading: false }); - setActiveSectionId(sectionId); - } catch (err) { - setError(normalizeUiError(err)); - } finally { - setIsSaving(false); + const section = data?.sections.find((entry) => entry.id === sectionId); + if (!section) { + return; } + let createdCircuitId: string | null = null; + await runCommand({ + label: "Add circuit", + redo: async () => { + const next = await getNextCircuitIdentifier(sectionId); + const sortOrder = + section.circuits.length > 0 ? Math.max(...section.circuits.map((circuit) => circuit.sortOrder)) + 10 : 10; + const created = (await createCircuit(projectId, circuitListId, { + sectionId, + equipmentIdentifier: next.nextIdentifier, + displayName: "Reserve", + sortOrder, + isReserve: true, + })) as CircuitTreeCircuitDto; + createdCircuitId = created.id; + setActiveSectionId(sectionId); + return { + rowKey: `reserveCircuit:${created.id}`, + cellKey: "equipmentIdentifier", + rowType: "reserveCircuit", + sectionId, + circuitId: created.id, + }; + }, + undo: async () => { + if (createdCircuitId) { + await deleteCircuitById(createdCircuitId); + } + return { + rowKey: `placeholder:${sectionId}`, + cellKey: "equipmentIdentifier", + rowType: "placeholder", + sectionId, + }; + }, + }); } async function handleAddManualDevice(circuit: CircuitTreeCircuitDto, sectionId: string) { - try { - setError(null); - setIsSaving(true); - const created = (await createCircuitDeviceRow(circuit.id, { - name: "Manual device", - displayName: "Manual device", - phaseType: "single_phase", - quantity: 1, - powerPerUnit: 0, - simultaneityFactor: 1, - cosPhi: 1, - })) as { id: string }; - await loadTree({ showLoading: false }); - setActiveSectionId(sectionId); - setPendingFocus({ - rowKey: circuit.deviceRows.length === 0 ? `circuitCompact:${circuit.id}` : `device:${created.id}`, - cellKey: "displayName", - }); - } catch (err) { - setError(normalizeUiError(err)); - } finally { - setIsSaving(false); - } + let createdRowId: string | null = null; + await runCommand({ + label: "Add manual device", + redo: async () => { + const created = (await createCircuitDeviceRow(circuit.id, { + name: "Manual device", + displayName: "Manual device", + phaseType: "single_phase", + quantity: 1, + powerPerUnit: 0, + simultaneityFactor: 1, + cosPhi: 1, + })) as { id: string }; + createdRowId = created.id; + setActiveSectionId(sectionId); + return { + rowKey: `device:${created.id}`, + cellKey: "displayName", + rowType: "deviceRow", + sectionId, + circuitId: circuit.id, + deviceId: created.id, + }; + }, + undo: async () => { + if (createdRowId) { + await deleteCircuitDeviceRowById(createdRowId); + } + return { + rowKey: `reserveCircuit:${circuit.id}`, + cellKey: "displayName", + rowType: "reserveCircuit", + sectionId, + circuitId: circuit.id, + }; + }, + }); } function resolvePhaseType(device: ProjectDeviceDto): string { @@ -846,15 +1037,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setError("Please select a target section."); return; } - try { - setError(null); - setIsSaving(true); - await insertProjectDeviceAsNewCircuit(device, targetSectionId); - } catch (err) { - setError(normalizeUiError(err)); - } finally { - setIsSaving(false); - } + let createdCircuitId: string | null = null; + let createdRowId: string | null = null; + await runCommand({ + label: "Add project device as new circuit", + redo: async () => { + const created = await insertProjectDeviceAsNewCircuit(device, targetSectionId); + createdCircuitId = created.circuitId; + createdRowId = created.rowId; + return { + rowKey: `device:${created.rowId}`, + cellKey: "displayName", + rowType: "deviceRow", + sectionId: targetSectionId, + circuitId: created.circuitId, + deviceId: created.rowId, + }; + }, + undo: async () => { + if (createdCircuitId) { + await deleteCircuitById(createdCircuitId); + } + return { + rowKey: `placeholder:${targetSectionId}`, + cellKey: "displayName", + rowType: "placeholder", + sectionId: targetSectionId, + }; + }, + }); } async function handleAddProjectDeviceToCircuit() { @@ -875,15 +1086,34 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return; } - try { - setError(null); - setIsSaving(true); - await insertProjectDeviceToCircuit(device, circuitId); - } catch (err) { - setError(normalizeUiError(err)); - } finally { - setIsSaving(false); - } + let createdRowId: string | null = null; + await runCommand({ + label: "Add project device to circuit", + redo: async () => { + const created = await insertProjectDeviceToCircuit(device, circuitId); + createdRowId = created.rowId; + return { + rowKey: `device:${created.rowId}`, + cellKey: "displayName", + rowType: "deviceRow", + sectionId: findCircuitSectionId(circuitId) ?? "", + circuitId, + deviceId: created.rowId, + }; + }, + undo: async () => { + if (createdRowId) { + await deleteCircuitDeviceRowById(createdRowId); + } + return { + rowKey: `circuitSummary:${circuitId}`, + cellKey: "displayName", + rowType: "circuitSummary", + sectionId: findCircuitSectionId(circuitId) ?? "", + circuitId, + }; + }, + }); } async function insertProjectDeviceAsNewCircuit(device: ProjectDeviceDto, sectionId: string) { @@ -914,10 +1144,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str category: device.category ?? undefined, })) as { id: string }; - await loadTree({ showLoading: false }); setActiveSectionId(sectionId); setTargetCircuitId(createdCircuit.id); - setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" }); + return { circuitId: createdCircuit.id, rowId: createdRow.id }; } async function insertProjectDeviceToCircuit(device: ProjectDeviceDto, circuitId: string) { @@ -932,8 +1161,84 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str cosPhi: device.powerFactor ?? undefined, category: device.category ?? undefined, })) as { id: string }; - await loadTree({ showLoading: false }); - setPendingFocus({ rowKey: `device:${createdRow.id}`, cellKey: "displayName" }); + return { rowId: createdRow.id }; + } + + function getCircuitSnapshot(circuitId: string): CircuitSnapshot | null { + for (const section of data?.sections ?? []) { + const found = section.circuits.find((entry) => entry.id === circuitId); + if (!found) { + continue; + } + return { + id: found.id, + sectionId: found.sectionId, + equipmentIdentifier: found.equipmentIdentifier, + displayName: found.displayName ?? undefined, + sortOrder: found.sortOrder, + protectionType: found.protectionType ?? undefined, + protectionRatedCurrent: found.protectionRatedCurrent ?? undefined, + protectionCharacteristic: found.protectionCharacteristic ?? undefined, + cableType: found.cableType ?? undefined, + cableCrossSection: found.cableCrossSection ?? undefined, + cableLength: found.cableLength ?? undefined, + rcdAssignment: found.rcdAssignment ?? undefined, + terminalDesignation: found.terminalDesignation ?? undefined, + voltage: found.voltage ?? undefined, + status: found.status ?? undefined, + isReserve: found.isReserve, + remark: found.remark ?? undefined, + deviceRows: found.deviceRows.map((row) => ({ ...row })), + }; + } + return null; + } + + async function recreateCircuit(snapshot: CircuitSnapshot) { + const createdCircuit = (await createCircuit(projectId, circuitListId, { + sectionId: snapshot.sectionId, + equipmentIdentifier: snapshot.equipmentIdentifier, + displayName: snapshot.displayName, + sortOrder: snapshot.sortOrder, + protectionType: snapshot.protectionType, + protectionRatedCurrent: snapshot.protectionRatedCurrent, + protectionCharacteristic: snapshot.protectionCharacteristic, + cableType: snapshot.cableType, + cableCrossSection: snapshot.cableCrossSection, + cableLength: snapshot.cableLength, + rcdAssignment: snapshot.rcdAssignment, + terminalDesignation: snapshot.terminalDesignation, + voltage: snapshot.voltage, + status: snapshot.status, + isReserve: snapshot.isReserve, + remark: snapshot.remark, + })) as CircuitTreeCircuitDto; + + const createdRowIds: string[] = []; + for (const row of snapshot.deviceRows) { + const created = (await createCircuitDeviceRow(createdCircuit.id, { + linkedProjectDeviceId: row.linkedProjectDeviceId, + name: row.name, + displayName: row.displayName, + phaseType: row.phaseType, + connectionKind: row.connectionKind, + costGroup: row.costGroup, + category: row.category, + level: row.level, + roomId: row.roomId, + roomNumberSnapshot: row.roomNumberSnapshot, + roomNameSnapshot: row.roomNameSnapshot, + quantity: row.quantity, + powerPerUnit: row.powerPerUnit, + simultaneityFactor: row.simultaneityFactor, + cosPhi: row.cosPhi, + remark: row.remark, + overriddenFields: row.overriddenFields, + sortOrder: row.sortOrder, + })) as { id: string }; + createdRowIds.push(created.id); + } + return { circuitId: createdCircuit.id, rowIds: createdRowIds }; } function parseDraggedProjectDeviceId(event: DragEvent) { @@ -1005,19 +1310,53 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str 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); + if (intent.kind === "new-circuit") { + let createdCircuitId: string | null = null; + await runCommand({ + label: "Drop project device to new circuit", + redo: async () => { + const created = await insertProjectDeviceAsNewCircuit(device, intent.sectionId); + createdCircuitId = created.circuitId; + return { + rowKey: `device:${created.rowId}`, + cellKey: "displayName", + rowType: "deviceRow", + sectionId: intent.sectionId, + circuitId: created.circuitId, + deviceId: created.rowId, + }; + }, + undo: async () => { + if (createdCircuitId) { + await deleteCircuitById(createdCircuitId); + } + return null; + }, + }); + return; } + let createdRowId: string | null = null; + await runCommand({ + label: "Drop project device to circuit", + redo: async () => { + const created = await insertProjectDeviceToCircuit(device, intent.circuitId); + createdRowId = created.rowId; + return { + rowKey: `device:${created.rowId}`, + cellKey: "displayName", + rowType: "deviceRow", + sectionId: intent.sectionId, + circuitId: intent.circuitId, + deviceId: created.rowId, + }; + }, + undo: async () => { + if (createdRowId) { + await deleteCircuitDeviceRowById(createdRowId); + } + return null; + }, + }); } async function handleDeviceRowDropWithIntent(event: DragEvent, intent: DeviceRowMoveDropIntent) { @@ -1037,27 +1376,43 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str 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, { + if (intent.kind === "move-to-circuit") { + if (intent.circuitId === sourceCircuitId) { + return; + } + await runCommand({ + label: "Move device row", + redo: async () => { + await moveCircuitDeviceRowById(rowId, { targetCircuitId: intent.circuitId }); + return null; + }, + undo: async () => { + await moveCircuitDeviceRowById(rowId, { targetCircuitId: sourceCircuitId }); + return null; + }, + }); + return; + } + + let createdCircuitId: string | null = null; + await runCommand({ + label: "Move device row to new circuit", + redo: async () => { + const moved = (await moveCircuitDeviceRowById(rowId, { targetSectionId: intent.sectionId, createNewCircuit: true, - }); - } - await loadTree({ showLoading: false }); - setPendingFocus({ rowKey: `device:${rowId}`, cellKey: "displayName" }); - } catch (err) { - setError(normalizeUiError(err)); - } finally { - setIsSaving(false); - } + })) as { circuitId?: string }; + createdCircuitId = moved.circuitId ?? null; + return null; + }, + undo: async () => { + await moveCircuitDeviceRowById(rowId, { targetCircuitId: sourceCircuitId }); + if (createdCircuitId) { + await deleteCircuitById(createdCircuitId); + } + return null; + }, + }); } async function handleCircuitReorderDrop(event: DragEvent, intent: CircuitReorderDropIntent) { @@ -1074,71 +1429,135 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str 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); + const section = data?.sections.find((entry) => entry.id === intent.sectionId); + if (!section) { + setError("Invalid section id."); + return; } + const beforeOrder = section.circuits.map((circuit) => circuit.id); + await runCommand({ + label: "Reorder circuits", + redo: async () => { + await applyCircuitReorder(intent, sourceCircuitId); + return { + rowKey: `circuitSummary:${sourceCircuitId}`, + cellKey: "equipmentIdentifier", + rowType: "circuitSummary", + sectionId: intent.sectionId, + circuitId: sourceCircuitId, + }; + }, + undo: async () => { + await reorderSectionCircuits(intent.sectionId, beforeOrder); + return { + rowKey: `circuitSummary:${sourceCircuitId}`, + cellKey: "equipmentIdentifier", + rowType: "circuitSummary", + sectionId: intent.sectionId, + circuitId: sourceCircuitId, + }; + }, + }); } async function handleDeleteDevice(rowId: string) { if (!confirm("Delete this device row?")) { return; } - try { - setError(null); - setIsSaving(true); - await deleteCircuitDeviceRowById(rowId); - await loadTree({ showLoading: false }); - } catch (err) { - setError(normalizeUiError(err)); - } finally { - setIsSaving(false); + const sourceCircuitId = findDeviceRowCircuitId(rowId); + const sourceCircuit = sourceCircuitId ? getCircuitSnapshot(sourceCircuitId) : null; + const rowSnapshot = sourceCircuit?.deviceRows.find((row) => row.id === rowId); + if (!sourceCircuitId || !rowSnapshot) { + setError("Invalid device row id."); + return; } + let recreatedRowId: string | null = null; + await runCommand({ + label: "Delete device row", + redo: async () => { + await deleteCircuitDeviceRowById(recreatedRowId ?? rowId); + return null; + }, + undo: async () => { + const created = (await createCircuitDeviceRow(sourceCircuitId, { + linkedProjectDeviceId: rowSnapshot.linkedProjectDeviceId, + name: rowSnapshot.name, + displayName: rowSnapshot.displayName, + phaseType: rowSnapshot.phaseType, + connectionKind: rowSnapshot.connectionKind, + costGroup: rowSnapshot.costGroup, + category: rowSnapshot.category, + level: rowSnapshot.level, + roomId: rowSnapshot.roomId, + roomNumberSnapshot: rowSnapshot.roomNumberSnapshot, + roomNameSnapshot: rowSnapshot.roomNameSnapshot, + quantity: rowSnapshot.quantity, + powerPerUnit: rowSnapshot.powerPerUnit, + simultaneityFactor: rowSnapshot.simultaneityFactor, + cosPhi: rowSnapshot.cosPhi, + remark: rowSnapshot.remark, + overriddenFields: rowSnapshot.overriddenFields, + sortOrder: rowSnapshot.sortOrder, + })) as { id: string }; + recreatedRowId = created.id; + return null; + }, + }); } async function handleDeleteCircuit(circuitId: string) { if (!confirm("Delete this circuit and all assigned device rows?")) { return; } - try { - setError(null); - setIsSaving(true); - await deleteCircuitById(circuitId); - await loadTree({ showLoading: false }); - } catch (err) { - setError(normalizeUiError(err)); - } finally { - setIsSaving(false); + const snapshot = getCircuitSnapshot(circuitId); + if (!snapshot) { + setError("Invalid circuit id."); + return; } + let recreatedCircuitId: string | null = null; + await runCommand({ + label: "Delete circuit", + redo: async () => { + await deleteCircuitById(recreatedCircuitId ?? circuitId); + return null; + }, + undo: async () => { + const recreated = await recreateCircuit(snapshot); + recreatedCircuitId = recreated.circuitId; + return null; + }, + }); } async function handleRenumberSection(sectionId: string) { if (!confirm("Renumber this section? Only circuits in this section will change.")) { return; } - try { - setError(null); - setIsSaving(true); - await renumberCircuitSection(sectionId); - await loadTree({ showLoading: false }); - } catch (err) { - setError(normalizeUiError(err)); - } finally { - setIsSaving(false); + const section = data?.sections.find((entry) => entry.id === sectionId); + if (!section) { + return; } + const before = section.circuits.map((circuit) => ({ + id: circuit.id, + equipmentIdentifier: circuit.equipmentIdentifier, + })); + await runCommand({ + label: "Renumber section", + redo: async () => { + await renumberCircuitSection(sectionId); + return null; + }, + undo: async () => { + await updateSectionEquipmentIdentifiers( + sectionId, + before.map((entry) => ({ + circuitId: entry.id, + equipmentIdentifier: entry.equipmentIdentifier, + })) + ); + return null; + }, + }); } function handleContainerFocus() { @@ -1162,6 +1581,20 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } return; } + if (event.ctrlKey && event.key.toLowerCase() === "z") { + event.preventDefault(); + if (event.shiftKey) { + void handleRedo(); + } else { + void handleUndo(); + } + return; + } + if (event.ctrlKey && event.key.toLowerCase() === "y") { + event.preventDefault(); + void handleRedo(); + return; + } const isCtrlPlus = event.ctrlKey && (event.key === "+" || (event.shiftKey && event.key === "=") || event.code === "NumpadAdd"); @@ -1228,6 +1661,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str return (
+
+ + +
{isSaving ?
Saving...
: null}