diff --git a/AGENTS.md b/AGENTS.md index fdee43a..3470390 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -301,6 +301,13 @@ inverse removes only the same unchanged and still-empty structure; the POST route requires `expectedRevision` and returns the updated history state. Stored schema-version 1 and 2 setup commands remain executable with their four legacy sections and without invented components. +Complete populated distribution-board copying and deletion use +`distribution-board.insert-subtree` and `distribution-board.delete-subtree`. +Their exact snapshots include the board, circuit list, groups, circuits, +device rows, components and all one-to-one protection data. Copy remaps all +owning ids but preserves project-device and room links. Both operations are +atomic persistent commands with restart-safe Undo/Redo; deletion requires an +explicit UI warning. Mutable group-protection and auxiliary distribution-board components use `distribution-board-component.insert` and `distribution-board-component.delete`; edits and footer reordering use diff --git a/docs/circuit-list-editor-api.md b/docs/circuit-list-editor-api.md index b7b303e..ff92fbf 100644 --- a/docs/circuit-list-editor-api.md +++ b/docs/circuit-list-editor-api.md @@ -233,6 +233,21 @@ returns HTTP `409` with `PROJECT_HISTORY_OPERATION_UNAVAILABLE`. `{ "floorId": null, "supplyType": "AV", "expectedRevision": 13 }` - executes `distribution-board.update`; floor and supply type are restored together by persistent Undo/Redo +- `POST /projects/:projectId/distribution-boards/:distributionBoardId/copy` + - body: `{ "name": "UV-02 Kopie", "expectedRevision": 14 }` + - duplicates the complete distribution-board subtree with new owning UUIDs + while preserving project-device and room links + - response includes the new `distributionBoard`, its `circuitList`, the + revision and current history state +- `DELETE /projects/:projectId/distribution-boards/:distributionBoardId` + - body: `{ "expectedRevision": 15 }` + - removes the complete, exactly captured distribution-board subtree in one + transaction + - response includes `distributionBoardId`, the revision and current history + state +- Copy and deletion use `distribution-board.insert-subtree` and + `distribution-board.delete-subtree`. Persistent Undo/Redo restores or + removes the same complete subtree, including protection devices. ### Project Floors and Rooms diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 358d9c4..9b17158 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -285,6 +285,16 @@ nachträgliche Bearbeitung prüfen die Projektzugehörigkeit der Etage und die Freigabe der Netzart in den Projekteinstellungen. Gespeicherte Anlage-Commands der Schemas 1 und 2 bleiben mit ihren vier Legacy-Abschnitten und ohne nachträglich erfundene Komponenten ausführbar. +Bereits befüllte Verteilungen werden über +`distribution-board.insert-subtree` und `distribution-board.delete-subtree` +als vollständiger Unterbaum kopiert beziehungsweise gelöscht. Der Snapshot +umfasst Verteilung, Stromkreisliste, Gruppen, Stromkreise, Gerätezeilen, +Verteilerkomponenten und alle zugehörigen Schutzgeräte. Beim Kopieren werden +sämtliche besitzenden UUIDs neu vergeben, während fachliche Verknüpfungen zu +Projektgeräten und Räumen erhalten bleiben. Beide Aktionen sind atomare +Projektrevisionen und bleiben nach einem Neustart über Undo/Redo umkehrbar. Die +Projektseite bietet sie im Einstellungsmodal der jeweiligen Verteilung an; +Löschen verlangt dort eine ausdrückliche Bestätigung. `distribution-board-component.insert` und `distribution-board-component.delete` sowie `distribution-board-component.update` versionieren Anlage, Entfernung, diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index f7945df..a4933fd 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -5,12 +5,14 @@ import { useParams } from "next/navigation"; import { FormEvent, useEffect, useMemo, useState } from "react"; import { copyGlobalDeviceToProject, + copyDistributionBoard, copyProjectDeviceToGlobal, createDistributionBoard, createFloor, createProjectDevice, createRoom, deleteProjectDevice, + deleteDistributionBoard, disconnectProjectDeviceRows, exportProjectTransfer, getProjectDeviceSyncPreview, @@ -91,6 +93,7 @@ export default function ProjectDetailPage() { editingBoardSimultaneityFactor, setEditingBoardSimultaneityFactor, ] = useState("1"); + const [editingBoardCopyName, setEditingBoardCopyName] = useState(""); const [floorName, setFloorName] = useState(""); const [roomNumber, setRoomNumber] = useState(""); const [roomName, setRoomName] = useState(""); @@ -405,6 +408,7 @@ export default function ProjectDetailPage() { setEditingBoardSimultaneityFactor( String(board.simultaneityFactor) ); + setEditingBoardCopyName(`${board.name} Kopie`); } function openBoardCreator() { @@ -463,6 +467,88 @@ export default function ProjectDetailPage() { } } + async function handleCopyBoard() { + if ( + !projectId || + !project || + !editingBoard || + !editingBoardCopyName.trim() + ) { + return; + } + setIsSaving(true); + setError(null); + try { + const result = await copyDistributionBoard( + projectId, + editingBoard.id, + editingBoardCopyName.trim(), + project.currentRevision + ); + setBoards((current) => [ + ...current, + result.distributionBoard, + ]); + setCircuitLists((current) => [ + ...current, + result.circuitList, + ]); + applyProjectRevision(result.history.currentRevision); + setEditingBoard(null); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Verteilung konnte nicht kopiert werden." + ); + } finally { + setIsSaving(false); + } + } + + async function handleDeleteBoard() { + if (!projectId || !project || !editingBoard) { + return; + } + const confirmed = window.confirm( + `Verteilung „${editingBoard.name}“ wirklich löschen?\n\n` + + "Dabei werden die Stromkreisliste, alle Gruppen, Stromkreise, Gerätezeilen, Schutzgeräte und weiteren Verteilergeräte entfernt. " + + "Der Vorgang kann anschließend über die projektweite Historie rückgängig gemacht werden." + ); + if (!confirmed) { + return; + } + setIsSaving(true); + setError(null); + try { + const result = await deleteDistributionBoard( + projectId, + editingBoard.id, + project.currentRevision + ); + setBoards((current) => + current.filter((board) => board.id !== result.distributionBoardId) + ); + setCircuitLists((current) => + current.filter( + (circuitList) => + circuitList.distributionBoardId !== + result.distributionBoardId + ) + ); + applyProjectRevision(result.history.currentRevision); + setEditingBoard(null); + } catch (err) { + setError( + err instanceof Error + ? err.message + : "Verteilung konnte nicht gelöscht werden." + ); + } finally { + setIsSaving(false); + } + } + async function handleExportProject() { if (!project) { return; @@ -1111,7 +1197,7 @@ export default function ProjectDetailPage() { {structureModal === "board" ? ( setStructureModal(null)} onSubmit={handleCreateBoard} @@ -1271,6 +1357,75 @@ export default function ProjectDetailPage() { +
+
+

+ Verteilung kopieren +

+

+ Erstellt eine vollständige Kopie des zuletzt gespeicherten + Stands einschließlich Gruppen, Stromkreisen, Gerätezeilen und + Schutzgeräten. Noch nicht gespeicherte Änderungen oben werden + nicht übernommen. +

+
+
+ + + setEditingBoardCopyName(event.target.value) + } + value={editingBoardCopyName} + /> +
+
+ +
+
+
+
+
+

+ Gefahrenbereich +

+

+ Entfernt die vollständige Verteilung. Die Aktion wird erst + nach einer ausdrücklichen Warnung ausgeführt und kann über die + projektweite Historie rückgängig gemacht werden. +

+ +
) : null} diff --git a/src/frontend/types.ts b/src/frontend/types.ts index baf92c6..71c832f 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -114,6 +114,11 @@ export interface DistributionBoardCommandResultDto distributionBoard: DistributionBoardDto; } +export interface DistributionBoardCopyCommandResultDto + extends DistributionBoardCommandResultDto { + circuitList: CircuitListDto; +} + export interface DistributionBoardDeleteCommandResultDto extends ProjectCommandResultDto { distributionBoardId: string; diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index defe0cd..5b6b624 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -6,6 +6,7 @@ import type { CreateGlobalDeviceInput, DistributionBoardDto, DistributionBoardCommandResultDto, + DistributionBoardCopyCommandResultDto, DistributionBoardDeleteCommandResultDto, FloorDto, GlobalDeviceDto, @@ -338,7 +339,7 @@ export function copyDistributionBoard( name: string, expectedRevision: number ) { - return request( + return request( `/api/projects/${projectId}/distribution-boards/${distributionBoardId}/copy`, { method: "POST", diff --git a/src/server/controllers/distribution-board.controller.ts b/src/server/controllers/distribution-board.controller.ts index 70ab7e9..1ca8b1b 100644 --- a/src/server/controllers/distribution-board.controller.ts +++ b/src/server/controllers/distribution-board.controller.ts @@ -140,6 +140,7 @@ export async function copyDistributionBoard( return res.status(201).json({ ...result, distributionBoard: copy.distributionBoard, + circuitList: copy.circuitList, }); } catch (error) { return respondWithProjectCommandError(error, res);