From 93ec294759fef1e2ea0d744918a287d5bd753d5e Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 31 Jul 2026 07:58:42 +0200 Subject: [PATCH] Move circuits between groups --- docs/current-architecture.md | 5 + docs/spec/08-current-product-backlog.md | 2 +- .../components/circuit-tree-editor.tsx | 128 +++++++++++++++--- src/frontend/utils/api.ts | 20 +++ src/frontend/utils/circuit-group-editing.ts | 127 +++++++++++++++++ tests/circuit-group-numbering.test.ts | 75 ++++++++++ 6 files changed, 336 insertions(+), 21 deletions(-) diff --git a/docs/current-architecture.md b/docs/current-architecture.md index 45d3977..deec590 100644 --- a/docs/current-architecture.md +++ b/docs/current-architecture.md @@ -395,6 +395,11 @@ vollständigen `circuit-group.renumber`-Plan für genau eine Kategorie. Sie nummeriert deren Gruppen gemäß aktueller Reihenfolge ab eins und ändert Gruppenpräfixe, optionale Gruppen-Schutz-BMK und Stromkreis-BMK gemeinsam; die Stromkreisnummer hinter dem letzten Punkt bleibt erhalten. +Das vorhandene Stromkreis-Drag-Handle kann außerdem genau einen vollständigen +Stromkreis in eine andere Gruppe derselben Kategorie verschieben. Der atomare +`circuit.move-group`-Befehl erhält Gerätezeilen und Schutzdaten, setzt die +Zielposition und vergibt dort die höchste vorhandene Stromkreisnummer plus eins; +Lücken werden nicht automatisch gefüllt. `distribution-board.update` versioniert Etage, Netzart und den verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und diff --git a/docs/spec/08-current-product-backlog.md b/docs/spec/08-current-product-backlog.md index 5d2f48b..07d9664 100644 --- a/docs/spec/08-current-product-backlog.md +++ b/docs/spec/08-current-product-backlog.md @@ -41,7 +41,7 @@ requirements and intended sequencing, not proof of implementation. - [x] Phase E4a: persistent same-category group reorder controls. - [ ] Phase E4b: explicit group renumber, circuit moves and populated-delete warning. - [x] Explicit same-category group renumbering updates prefixes and all child BMK atomically. - - [ ] Circuit moves between same-category groups. + - [x] Single-circuit moves between same-category groups with automatic next-free BMK assignment. - [ ] Populated-group delete warning and explicit subtree deletion. - [ ] Phase E: editor projection and editing. - [ ] Phase F: documentation and full GUI verification. diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 6480857..029c317 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -52,9 +52,11 @@ import { buildCircuitSectionRenumberAssignments, } from "../utils/circuit-section-renumber-command"; import { + buildCircuitGroupMovePlan, buildCircuitGroupRenumberPlan, buildCircuitGroupReorderAssignments, buildNewCircuitGroupSnapshot, + canMoveCircuitToGroup, canRenumberCircuitGroups, canDeleteCircuitGroup, renameCircuitGroupSnapshot, @@ -96,6 +98,7 @@ import { listProjectDevices, moveCircuitDeviceRowsCommand, moveCircuitDeviceRowsToNewCircuitCommand, + moveCircuitToGroupCommand, reorderCircuitSectionCommand, reorderCircuitSectionsCommand, reorderCircuitGroupsCommand, @@ -2524,7 +2527,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }); } - // Handles circuit drag intent and reorders whole circuit blocks within one section only. + // Reorders whole circuit blocks within a group or moves one complete circuit + // to another group of the same category. async function handleCircuitReorderDrop(event: DragEvent, intent: CircuitReorderDropIntent) { event.preventDefault(); event.stopPropagation(); @@ -2538,8 +2542,75 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str setError("Der gezogene Stromkreis fehlt."); return; } + const sourceSectionIds = sourceCircuitIds + .map((id) => findCircuitSectionId(id)) + .filter((id): id is string => Boolean(id)); + const isCrossGroupMove = + sourceSectionIds.length === 1 && + sourceSectionIds[0] !== intent.sectionId; + if (isCrossGroupMove) { + if ( + sourceCircuitIds.length !== 1 || + !canMoveCircuitToGroup( + data?.sections ?? [], + sourceCircuitIds[0], + intent.sectionId + ) + ) { + setError( + "Gruppenübergreifend kann genau ein Stromkreis in eine Gruppe derselben Kategorie verschoben werden." + ); + return; + } + let plan: ReturnType; + try { + plan = buildCircuitGroupMovePlan({ + sections: data?.sections ?? [], + circuitId: sourceCircuitIds[0], + targetSectionId: intent.sectionId, + placement: + intent.kind === "section-end" + ? { kind: "end" } + : { + kind: + intent.kind === "before-circuit" + ? "before" + : "after", + targetCircuitId: intent.targetCircuitId, + }, + }); + } catch (err) { + setError(normalizeUiError(err)); + return; + } + const selectionIntent: SelectionIntent = { + rowKey: `circuitSummary:${plan.circuitId}`, + cellKey: "equipmentIdentifier", + rowType: "circuitSummary", + sectionId: plan.targetSectionId, + circuitId: plan.circuitId, + }; + await runCommand({ + label: "Stromkreis in andere Gruppe verschieben", + redo: async () => { + const result = await moveCircuitToGroupCommand( + projectId, + getExpectedProjectRevision(), + plan + ); + applyProjectCommandResult(result); + pendingSelectedCircuitIdsAfterReload.current = [ + plan.circuitId, + ]; + return selectionIntent; + }, + }); + return; + } if (!intent.valid) { - setError("Stromkreise können derzeit nicht bereichsübergreifend verschoben werden."); + setError( + "Stromkreise können nur innerhalb einer Gruppe oder einzeln zwischen Gruppen derselben Kategorie verschoben werden." + ); return; } const section = data?.sections.find((entry) => entry.id === intent.sectionId); @@ -2549,7 +2620,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } const sectionCircuitIds = new Set(section.circuits.map((circuit) => circuit.id)); if (sourceCircuitIds.some((id) => !sectionCircuitIds.has(id))) { - setError("Stromkreise können derzeit nicht bereichsübergreifend verschoben werden."); + setError( + "Die ausgewählten Stromkreise gehören nicht zur Zielgruppe." + ); return; } const primaryCircuitId = sourceCircuitIds[0]; @@ -2892,6 +2965,23 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str const activeDraggedCircuitIds = draggingCircuitIds.length > 0 ? draggingCircuitIds : draggingCircuitId ? [draggingCircuitId] : []; const draggingCircuitCount = activeDraggedCircuitIds.length; + const isCircuitDropTargetValid = (targetSectionId: string) => { + const sourceSectionIds = activeDraggedCircuitIds + .map((id) => findCircuitSectionId(id)) + .filter((id): id is string => Boolean(id)); + return ( + (sourceSectionIds.length > 0 && + sourceSectionIds.every( + (sectionId) => sectionId === targetSectionId + )) || + (activeDraggedCircuitIds.length === 1 && + canMoveCircuitToGroup( + data.sections, + activeDraggedCircuitIds[0], + targetSectionId + )) + ); + }; const selectedProjectDevice = resolveSelectedProjectDevice(); const suggestedSection = selectedProjectDevice ? data.sections.find((section) => section.key === inferProjectDeviceSectionKey(selectedProjectDevice)) @@ -3479,12 +3569,17 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str }`} onDragOver={(event) => { if (draggingCircuitCount > 0) { + const valid = isCircuitDropTargetValid( + section.id + ); event.preventDefault(); - event.dataTransfer.dropEffect = "none"; + event.dataTransfer.dropEffect = valid + ? "move" + : "none"; setCircuitReorderIntent({ kind: "section-end", sectionId: section.id, - valid: false, + valid, }); return; } @@ -3519,7 +3614,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str void handleCircuitReorderDrop(event, { kind: "section-end", sectionId: section.id, - valid: false, + valid: isCircuitDropTargetValid(section.id), }); } }} @@ -3803,10 +3898,9 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str onClick={() => setActiveSectionId(row.sectionId)} onDragOver={(event) => { 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); + const valid = isCircuitDropTargetValid( + row.sectionId + ); if (row.rowType === "placeholder") { event.preventDefault(); event.dataTransfer.dropEffect = valid ? "move" : "none"; @@ -3929,13 +4023,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str onDrop={(event) => { if (draggingCircuitCount > 0) { if (row.rowType === "placeholder") { - const sourceSectionIds = activeDraggedCircuitIds - .map((id) => findCircuitSectionId(id)) - .filter((id): id is string => Boolean(id)); void handleCircuitReorderDrop(event, { kind: "section-end", sectionId: row.sectionId, - valid: sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === row.sectionId), + valid: isCircuitDropTargetValid(row.sectionId), }); return; } @@ -3943,16 +4034,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str if (activeDraggedCircuitIds.includes(row.circuit.id)) { return; } - 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: sourceSectionIds.length > 0 && sourceSectionIds.every((sectionId) => sectionId === row.sectionId), + valid: isCircuitDropTargetValid(row.sectionId), }); } return; @@ -4283,7 +4371,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str {circuitReorderIntent.valid ? `${draggingCircuitCount || 1} Stromkreis(e) ans Bereichsende verschieben` - : "Bereichsübergreifendes Verschieben nicht zulässig"} + : "Nur ein Stromkreis kann zwischen Gruppen derselben Kategorie verschoben werden"} ) : null} {circuitReorderIntent && @@ -4294,7 +4382,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str ? circuitReorderIntent.kind === "before-circuit" ? `${draggingCircuitCount || 1} Stromkreis(e) vor diesen Stromkreis verschieben` : `${draggingCircuitCount || 1} Stromkreis(e) hinter diesen Stromkreis verschieben` - : "Bereichsübergreifendes Verschieben nicht zulässig"} + : "Nur ein Stromkreis kann zwischen Gruppen derselben Kategorie verschoben werden"} ) : null} diff --git a/src/frontend/utils/api.ts b/src/frontend/utils/api.ts index 6e98bb5..4eddb42 100644 --- a/src/frontend/utils/api.ts +++ b/src/frontend/utils/api.ts @@ -65,6 +65,9 @@ import type { import type { CircuitGroupRenumberPlan, } from "../../domain/services/circuit-group-renumbering"; +import type { + CircuitGroupMovePlan, +} from "../../domain/services/circuit-group-move-planning"; async function request(url: string, init?: RequestInit): Promise { const response = await fetch(url, { @@ -473,6 +476,23 @@ export function renumberCircuitGroupsCommand( ); } +export function moveCircuitToGroupCommand( + projectId: string, + expectedRevision: number, + plan: CircuitGroupMovePlan +) { + return executeProjectCommand( + projectId, + expectedRevision, + { + schemaVersion: 1, + type: "circuit.move-group", + payload: plan, + }, + "Stromkreis in andere Gruppe verschieben" + ); +} + export function updateCircuitProtectionCommand( projectId: string, expectedRevision: number, diff --git a/src/frontend/utils/circuit-group-editing.ts b/src/frontend/utils/circuit-group-editing.ts index 2561391..579beee 100644 --- a/src/frontend/utils/circuit-group-editing.ts +++ b/src/frontend/utils/circuit-group-editing.ts @@ -13,6 +13,9 @@ import type { import type { CircuitGroupRenumberPlan, } from "../../domain/services/circuit-group-renumbering"; +import type { + CircuitGroupMovePlan, +} from "../../domain/services/circuit-group-move-planning"; export function toCircuitGroupSnapshot( section: CircuitTreeSectionDto, @@ -187,6 +190,92 @@ export function canRenumberCircuitGroups( ); } +export function canMoveCircuitToGroup( + sections: readonly CircuitTreeSectionDto[], + circuitId: string, + targetSectionId: string +) { + const source = sections.find((section) => + section.circuits.some((circuit) => circuit.id === circuitId) + ); + const target = sections.find( + (section) => section.id === targetSectionId + ); + return Boolean( + source?.category && + target?.category && + source.id !== target.id && + source.category === target.category + ); +} + +export function buildCircuitGroupMovePlan(input: { + sections: readonly CircuitTreeSectionDto[]; + circuitId: string; + targetSectionId: string; + placement: + | { kind: "end" } + | { + kind: "before" | "after"; + targetCircuitId: string; + }; +}): CircuitGroupMovePlan { + const sourceGroup = input.sections.find((section) => + section.circuits.some( + (circuit) => circuit.id === input.circuitId + ) + ); + const targetGroup = input.sections.find( + (section) => section.id === input.targetSectionId + ); + const circuit = sourceGroup?.circuits.find( + (entry) => entry.id === input.circuitId + ); + if ( + !sourceGroup?.category || + !sourceGroup.groupNumber || + !targetGroup?.category || + !targetGroup.groupNumber || + !circuit || + sourceGroup.id === targetGroup.id || + sourceGroup.category !== targetGroup.category + ) { + throw new Error( + "Stromkreise können nur zwischen verschiedenen Gruppen derselben Kategorie verschoben werden." + ); + } + parseCircuitNumber( + circuit.equipmentIdentifier, + sourceGroup.category, + sourceGroup.groupNumber + ); + const targetCircuitNumbers = targetGroup.circuits.map((entry) => + parseCircuitNumber( + entry.equipmentIdentifier, + targetGroup.category!, + targetGroup.groupNumber! + ) + ); + + return { + circuitId: circuit.id, + circuitListId: circuit.circuitListId, + expectedSectionId: sourceGroup.id, + targetSectionId: targetGroup.id, + expectedEquipmentIdentifier: circuit.equipmentIdentifier, + targetEquipmentIdentifier: formatCircuitIdentifier( + targetGroup.category, + targetGroup.groupNumber, + Math.max(0, ...targetCircuitNumbers) + 1 + ), + expectedSortOrder: circuit.sortOrder, + targetSortOrder: getCircuitMoveSortOrder( + targetGroup.circuits, + input.placement + ), + }; +} + function getNewGroupSortOrder( category: CircuitGroupCategory, sections: readonly (CircuitTreeSectionDto & { @@ -287,3 +376,41 @@ function formatGroupComponentIdentifier( "Die Stromkreisgruppe enthält eine nicht unterstützte Verteilerkomponente." ); } + +function getCircuitMoveSortOrder( + circuits: CircuitTreeSectionDto["circuits"], + placement: + | { kind: "end" } + | { + kind: "before" | "after"; + targetCircuitId: string; + } +) { + const ordered = [...circuits].sort( + (left, right) => + left.sortOrder - right.sortOrder || + left.id.localeCompare(right.id) + ); + if (placement.kind === "end") { + return ordered.length === 0 + ? 10 + : ordered[ordered.length - 1].sortOrder + 10; + } + const index = ordered.findIndex( + (circuit) => circuit.id === placement.targetCircuitId + ); + if (index < 0) { + throw new Error("Der Zielstromkreis wurde nicht gefunden."); + } + const current = ordered[index].sortOrder; + if (placement.kind === "before") { + const previous = ordered[index - 1]?.sortOrder; + return previous === undefined + ? current - 10 + : previous + (current - previous) / 2; + } + const next = ordered[index + 1]?.sortOrder; + return next === undefined + ? current + 10 + : current + (next - current) / 2; +} diff --git a/tests/circuit-group-numbering.test.ts b/tests/circuit-group-numbering.test.ts index 64006b4..81f6ac1 100644 --- a/tests/circuit-group-numbering.test.ts +++ b/tests/circuit-group-numbering.test.ts @@ -16,9 +16,11 @@ import "./circuit-group-move-project-command.repository.test.js"; import "./circuit-group-subtree-snapshot.test.js"; import "./circuit-group-subtree-project-command.repository.test.js"; import { + buildCircuitGroupMovePlan, buildCircuitGroupRenumberPlan, buildCircuitGroupReorderAssignments, buildNewCircuitGroupSnapshot, + canMoveCircuitToGroup, canRenumberCircuitGroups, canDeleteCircuitGroup, renameCircuitGroupSnapshot, @@ -219,6 +221,79 @@ describe("circuit group numbering", () => { ); }); + it("plans a same-category circuit drop with the next target BMK", () => { + const source = { + ...groupSection("lighting-1", "lighting", 1, 10), + circuits: [ + { + id: "circuit-source", + circuitListId: "list-1", + sectionId: "lighting-1", + equipmentIdentifier: "-1F1.5", + sortOrder: 10, + isReserve: false, + circuitTotalPower: 0, + deviceRows: [], + }, + ], + }; + const target = { + ...groupSection("lighting-2", "lighting", 2, 20), + circuits: [ + { + id: "circuit-target", + circuitListId: "list-1", + sectionId: "lighting-2", + equipmentIdentifier: "-1F2.3", + sortOrder: 20, + isReserve: false, + circuitTotalPower: 0, + deviceRows: [], + }, + ], + }; + assert.equal( + canMoveCircuitToGroup( + [source, target], + "circuit-source", + "lighting-2" + ), + true + ); + assert.deepEqual( + buildCircuitGroupMovePlan({ + sections: [source, target], + circuitId: "circuit-source", + targetSectionId: "lighting-2", + placement: { + kind: "before", + targetCircuitId: "circuit-target", + }, + }), + { + circuitId: "circuit-source", + circuitListId: "list-1", + expectedSectionId: "lighting-1", + targetSectionId: "lighting-2", + expectedEquipmentIdentifier: "-1F1.5", + targetEquipmentIdentifier: "-1F2.4", + expectedSortOrder: 10, + targetSortOrder: 10, + } + ); + assert.equal( + canMoveCircuitToGroup( + [ + source, + groupSection("single-1", "single_phase", 1, 20), + ], + "circuit-source", + "single-1" + ), + false + ); + }); + it("formats the agreed identifiers including the leading hyphen", () => { assert.equal( formatGroupUpstreamProtectionIdentifier("lighting", 1),