Move circuits between groups

This commit is contained in:
2026-07-31 07:58:42 +02:00
parent be9c6f0d52
commit 93ec294759
6 changed files with 336 additions and 21 deletions
+5
View File
@@ -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
+1 -1
View File
@@ -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.
+108 -20
View File
@@ -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<HTMLElement>, 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<typeof buildCircuitGroupMovePlan>;
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
<span className="drop-hint">
{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"}
</span>
) : 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"}
</span>
) : null}
</td>
+20
View File
@@ -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<T>(url: string, init?: RequestInit): Promise<T> {
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,
+127
View File
@@ -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;
}
+75
View File
@@ -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),