Move circuits between groups
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user