Manage circuit groups

This commit is contained in:
2026-07-31 07:32:46 +02:00
parent 756e307bd8
commit 0c92fbd09e
8 changed files with 480 additions and 2 deletions
+6
View File
@@ -365,6 +365,12 @@ werden über beschriftete Modale angelegt, bearbeitet und nach Bestätigung
entfernt. Der Frontend-API-Adapter serialisiert dafür die vorhandenen entfernt. Der Frontend-API-Adapter serialisiert dafür die vorhandenen
`distribution-board-component.*`-Commands; die verbindliche `distribution-board-component.*`-Commands; die verbindliche
geräteabhängige Validierung und atomare Revision bleiben serverseitig. geräteabhängige Validierung und atomare Revision bleiben serverseitig.
Neue Stromkreisgruppen erhalten im Editor die höchste vorhandene
Gruppennummer ihrer Kategorie plus eins und ein daraus abgeleitetes Präfix.
Das Bearbeiten ändert ausschließlich den Anzeigenamen. Nur vollständig leere
Gruppen können über `circuit-group.delete` entfernt werden; für befüllte
Gruppen bleibt die Aktion bis zum gesonderten Warn- und Unterbaumdialog
gesperrt.
`distribution-board.update` versioniert Etage, Netzart und den `distribution-board.update` versioniert Etage, Netzart und den
verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über verteilerweiten Gleichzeitigkeitsfaktor gemeinsam und stellt alle Werte über
dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und dauerhaftes Undo/Redo wieder her. Der Faktor liegt zwischen `0` und `1` und
+3 -1
View File
@@ -35,7 +35,9 @@ requirements and intended sequencing, not proof of implementation.
- [x] Phase E2a: pure header/group/footer structure projection. - [x] Phase E2a: pure header/group/footer structure projection.
- [x] Phase E2b: render the structure projection in the editor grid. - [x] Phase E2b: render the structure projection in the editor grid.
- [x] Phase E3a: create, edit and delete mutable group and footer components. - [x] Phase E3a: create, edit and delete mutable group and footer components.
- [ ] Phase E3b: group management and circuit-protection editing. - [x] Phase E3b1: create, rename and safely delete empty circuit groups.
- [ ] Phase E3b2: circuit-protection editing.
- [ ] Phase E4: group reorder, renumber, circuit moves and populated-delete warning.
- [ ] Phase E: editor projection and editing. - [ ] Phase E: editor projection and editing.
- [ ] Phase F: documentation and full GUI verification. - [ ] Phase F: documentation and full GUI verification.
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until - [ ] Keep full electrical sizing and cable-dimensioning rules separate until
@@ -741,7 +741,8 @@ Acceptance:
Status: In progress. The complete tree read model E1, pure structural Status: In progress. The complete tree read model E1, pure structural
projection E2a, grid rendering E2b and mutable component editing E3a are projection E2a, grid rendering E2b and mutable component editing E3a are
complete. Group management and circuit-protection editing remain pending. complete. Basic group management E3b1 is also complete. Circuit-protection
editing and structural drag/renumber/delete workflows remain pending.
- render fixed header components - render fixed header components
- render group components and circuit blocks - render group components and circuit blocks
@@ -770,6 +771,12 @@ Implemented in E1/E2a/E2b/E3a:
- mutable group protection and auxiliary footer components use labeled modals, - mutable group protection and auxiliary footer components use labeled modals,
type-dependent protection fields and persistent project commands type-dependent protection fields and persistent project commands
- fixed main-switch and surge-protection header components remain read-only - fixed main-switch and surge-protection header components remain read-only
- users can create a group in any supported category; its number is the
category's highest existing number plus one and its prefix is generated
- group editing changes only the display name; category, number, prefix and
child BMKs remain stable
- an exactly empty group can be removed through the persistent group command;
populated deletion stays disabled until the dedicated warning UI is present
Acceptance: Acceptance:
@@ -0,0 +1,97 @@
"use client";
import { type FormEvent, useState } from "react";
import {
circuitGroupCategories,
circuitGroupCategoryLabels,
type CircuitGroupCategory,
} from "../../shared/constants/circuit-group";
import type { CircuitTreeSectionDto } from "../types";
import { FormModal } from "./form-modal";
interface CircuitGroupModalProps {
initialSection?: CircuitTreeSectionDto;
isSaving: boolean;
onClose: () => void;
onSave: (values: {
category: CircuitGroupCategory;
displayName: string;
}) => Promise<void>;
}
export function CircuitGroupModal({
initialSection,
isSaving,
onClose,
onSave,
}: CircuitGroupModalProps) {
const [category, setCategory] = useState<CircuitGroupCategory>(
initialSection?.category ?? "lighting"
);
const [displayName, setDisplayName] = useState(
initialSection?.displayName ?? ""
);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSave({ category, displayName });
}
return (
<FormModal
description={
initialSection
? "Nur der Anzeigename wird geändert. Kategorie, Nummer, Präfix und vorhandene BMK bleiben stabil."
: "Die nächste freie Gruppennummer der gewählten Kategorie wird automatisch vergeben."
}
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={Boolean(initialSection && !displayName.trim())}
submitLabel={initialSection ? "Änderungen speichern" : "Gruppe anlegen"}
title={initialSection ? "Stromkreisgruppe bearbeiten" : "Stromkreisgruppe hinzufügen"}
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-group-category">
Kategorie
</label>
<select
autoFocus={!initialSection}
className="form-select"
disabled={Boolean(initialSection)}
id="circuit-group-category"
onChange={(event) =>
setCategory(event.target.value as CircuitGroupCategory)
}
value={category}
>
{circuitGroupCategories.map((entry) => (
<option key={entry} value={entry}>
{circuitGroupCategoryLabels[entry]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-group-name">
Anzeigename
</label>
<input
autoFocus={Boolean(initialSection)}
className="form-control"
id="circuit-group-name"
onChange={(event) => setDisplayName(event.target.value)}
placeholder={
initialSection
? undefined
: "Leer lassen für den automatisch erzeugten Namen"
}
required={Boolean(initialSection)}
value={displayName}
/>
</div>
</div>
</FormModal>
);
}
@@ -47,6 +47,12 @@ import {
import { import {
buildCircuitSectionRenumberAssignments, buildCircuitSectionRenumberAssignments,
} from "../utils/circuit-section-renumber-command"; } from "../utils/circuit-section-renumber-command";
import {
buildNewCircuitGroupSnapshot,
canDeleteCircuitGroup,
renameCircuitGroupSnapshot,
toCircuitGroupSnapshot,
} from "../utils/circuit-group-editing";
import { import {
buildDistributionBoardComponentSnapshot, buildDistributionBoardComponentSnapshot,
getNextComponentSortOrder, getNextComponentSortOrder,
@@ -71,12 +77,14 @@ import type { VisibleGridRow } from "../utils/circuit-grid-projection";
import { import {
deleteCircuitCommand, deleteCircuitCommand,
deleteCircuitDeviceRowCommand, deleteCircuitDeviceRowCommand,
deleteCircuitGroupCommand,
deleteDistributionBoardComponentCommand, deleteDistributionBoardComponentCommand,
getCircuitTree, getCircuitTree,
getNextCircuitIdentifier, getNextCircuitIdentifier,
getProjectHistory, getProjectHistory,
insertCircuitCommand, insertCircuitCommand,
insertCircuitDeviceRowCommand, insertCircuitDeviceRowCommand,
insertCircuitGroupCommand,
insertDistributionBoardComponentCommand, insertDistributionBoardComponentCommand,
listProjectDevices, listProjectDevices,
moveCircuitDeviceRowsCommand, moveCircuitDeviceRowsCommand,
@@ -87,6 +95,7 @@ import {
redoProjectCommand, redoProjectCommand,
updateCircuitById, updateCircuitById,
updateCircuitDeviceRowById, updateCircuitDeviceRowById,
updateCircuitGroupCommand,
updateDistributionBoardComponentCommand, updateDistributionBoardComponentCommand,
undoProjectCommand, undoProjectCommand,
} from "../utils/api"; } from "../utils/api";
@@ -107,6 +116,8 @@ import type {
CircuitSnapshot, CircuitSnapshot,
} from "../../domain/models/circuit-structure-project-command.model"; } from "../../domain/models/circuit-structure-project-command.model";
import { DistributionBoardComponentModal } from "./distribution-board-component-modal"; import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
import { CircuitGroupModal } from "./circuit-group-modal";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
type SaveDirection = "stay" | "next" | "prev"; type SaveDirection = "stay" | "next" | "prev";
type StartEditMode = "selectExisting" | "replaceWithTypedChar"; type StartEditMode = "selectExisting" | "replaceWithTypedChar";
@@ -187,6 +198,10 @@ type StructureComponentEditorIntent =
component: CircuitTreeComponentDto; component: CircuitTreeComponentDto;
}; };
type CircuitGroupEditorIntent =
| { kind: "create" }
| { kind: "edit"; section: CircuitTreeResponseDto["sections"][number] };
function getFullColumnLabel(column: ColumnDef): string { function getFullColumnLabel(column: ColumnDef): string {
return column.fullLabel ?? column.label; return column.fullLabel ?? column.label;
} }
@@ -229,6 +244,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [componentEditorIntent, setComponentEditorIntent] = const [componentEditorIntent, setComponentEditorIntent] =
useState<StructureComponentEditorIntent | null>(null); useState<StructureComponentEditorIntent | null>(null);
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
useState<CircuitGroupEditorIntent | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]); const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] = const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
useState(false); useState(false);
@@ -1138,6 +1155,76 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}); });
} }
async function handleSaveCircuitGroup(values: {
category: CircuitGroupCategory;
displayName: string;
}) {
const intent = circuitGroupEditorIntent;
if (!intent || !data) {
return;
}
await runCommand({
label:
intent.kind === "create"
? "Stromkreisgruppe hinzufügen"
: "Stromkreisgruppe bearbeiten",
redo: async () => {
const result =
intent.kind === "create"
? await insertCircuitGroupCommand(
projectId,
getExpectedProjectRevision(),
buildNewCircuitGroupSnapshot({
id: crypto.randomUUID(),
circuitListId,
category: values.category,
displayName: values.displayName,
sections: data.sections,
})
)
: await (() => {
const expected = toCircuitGroupSnapshot(
intent.section,
circuitListId
);
return updateCircuitGroupCommand(
projectId,
getExpectedProjectRevision(),
expected,
renameCircuitGroupSnapshot(expected, values.displayName)
);
})();
applyProjectCommandResult(result);
setCircuitGroupEditorIntent(null);
return null;
},
});
}
async function handleDeleteEmptyCircuitGroup(
section: CircuitTreeResponseDto["sections"][number]
) {
if (!canDeleteCircuitGroup(section)) {
return;
}
if (!confirm(`Leere Stromkreisgruppe „${section.displayName}“ entfernen?`)) {
return;
}
await runCommand({
label: "Leere Stromkreisgruppe entfernen",
redo: async () => {
const result = await deleteCircuitGroupCommand(
projectId,
getExpectedProjectRevision(),
toCircuitGroupSnapshot(section, circuitListId)
);
applyProjectCommandResult(result);
setCircuitGroupEditorIntent(null);
return null;
},
});
}
async function handleRedo() { async function handleRedo() {
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) { if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
return; return;
@@ -2735,6 +2822,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
/> />
) : null} ) : null}
{circuitGroupEditorIntent ? (
<CircuitGroupModal
initialSection={
circuitGroupEditorIntent.kind === "edit"
? circuitGroupEditorIntent.section
: undefined
}
isSaving={isSaving}
onClose={() => setCircuitGroupEditorIntent(null)}
onSave={handleSaveCircuitGroup}
/>
) : null}
<div className="editor-toolbar"> <div className="editor-toolbar">
<button <button
type="button" type="button"
@@ -2798,6 +2897,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
> >
Verteilergerät hinzufügen Verteilergerät hinzufügen
</button> </button>
<button
type="button"
disabled={isSaving || historyBusy}
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })}
>
Stromkreisgruppe hinzufügen
</button>
<button <button
type="button" type="button"
onClick={clearSortAndFilters} onClick={clearSortAndFilters}
@@ -3302,6 +3408,34 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</span> </span>
</div> </div>
<div className="section-actions"> <div className="section-actions">
<button
type="button"
tabIndex={-1}
disabled={!section.category || !section.groupNumber}
onClick={() =>
setCircuitGroupEditorIntent({
kind: "edit",
section,
})
}
>
Gruppe bearbeiten
</button>
<button
type="button"
tabIndex={-1}
disabled={!canDeleteCircuitGroup(section)}
title={
canDeleteCircuitGroup(section)
? undefined
: "Befüllte Gruppen werden später über einen eigenen Warn- und Löschdialog entfernt."
}
onClick={() =>
void handleDeleteEmptyCircuitGroup(section)
}
>
Gruppe entfernen
</button>
<button <button
type="button" type="button"
tabIndex={-1} tabIndex={-1}
+55
View File
@@ -55,6 +55,9 @@ import type {
import type { import type {
DistributionBoardComponentSnapshot, DistributionBoardComponentSnapshot,
} from "../../domain/models/distribution-board-component-structure-project-command.model"; } from "../../domain/models/distribution-board-component-structure-project-command.model";
import type {
CircuitGroupSnapshot,
} from "../../domain/models/circuit-group-structure-project-command.model";
async function request<T>(url: string, init?: RequestInit): Promise<T> { async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, { const response = await fetch(url, {
@@ -375,6 +378,58 @@ export function deleteDistributionBoardComponentCommand(
); );
} }
export function insertCircuitGroupCommand(
projectId: string,
expectedRevision: number,
snapshot: CircuitGroupSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-group.insert",
payload: { snapshot },
},
"Stromkreisgruppe hinzufügen"
);
}
export function updateCircuitGroupCommand(
projectId: string,
expectedRevision: number,
expected: CircuitGroupSnapshot,
target: CircuitGroupSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-group.update",
payload: { expected, target },
},
"Stromkreisgruppe bearbeiten"
);
}
export function deleteCircuitGroupCommand(
projectId: string,
expectedRevision: number,
snapshot: CircuitGroupSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "circuit-group.delete",
payload: { snapshot },
},
"Leere Stromkreisgruppe entfernen"
);
}
export function updateCircuitById( export function updateCircuitById(
projectId: string, projectId: string,
expectedRevision: number, expectedRevision: number,
+105
View File
@@ -0,0 +1,105 @@
import type {
CircuitGroupSnapshot,
} from "../../domain/models/circuit-group-structure-project-command.model";
import {
circuitGroupCategoryNumbers,
circuitGroupCategoryLabels,
type CircuitGroupCategory,
} from "../../shared/constants/circuit-group";
import type { CircuitTreeSectionDto } from "../types";
export function toCircuitGroupSnapshot(
section: CircuitTreeSectionDto,
circuitListId: string
): CircuitGroupSnapshot {
if (!section.category || !section.groupNumber) {
throw new Error("Der Bereich ist keine verwaltbare Stromkreisgruppe.");
}
return {
id: section.id,
circuitListId,
key: section.key,
displayName: section.displayName,
prefix: section.prefix,
sortOrder: section.sortOrder,
category: section.category,
groupNumber: section.groupNumber,
};
}
export function buildNewCircuitGroupSnapshot(input: {
id: string;
circuitListId: string;
category: CircuitGroupCategory;
displayName?: string;
sections: readonly CircuitTreeSectionDto[];
}): CircuitGroupSnapshot {
const groupedSections = input.sections.filter(
(section): section is CircuitTreeSectionDto & {
category: CircuitGroupCategory;
groupNumber: number;
} => Boolean(section.category && section.groupNumber)
);
const groupNumber =
Math.max(
0,
...groupedSections
.filter((section) => section.category === input.category)
.map((section) => section.groupNumber)
) + 1;
return {
id: input.id,
circuitListId: input.circuitListId,
key: `${input.category}_${groupNumber}`,
displayName:
input.displayName?.trim() ||
`${circuitGroupCategoryLabels[input.category]} ${groupNumber}`,
prefix: `-${circuitGroupCategoryNumbers[input.category]}F${groupNumber}.`,
sortOrder: getNewGroupSortOrder(
input.category,
groupedSections
),
category: input.category,
groupNumber,
};
}
export function renameCircuitGroupSnapshot(
expected: CircuitGroupSnapshot,
displayName: string
): CircuitGroupSnapshot {
return { ...expected, displayName: displayName.trim() };
}
export function canDeleteCircuitGroup(
section: CircuitTreeSectionDto
): boolean {
return section.circuits.length === 0 && section.components.length === 0;
}
function getNewGroupSortOrder(
category: CircuitGroupCategory,
sections: readonly (CircuitTreeSectionDto & {
category: CircuitGroupCategory;
groupNumber: number;
})[]
): number {
const ordered = [...sections].sort(
(left, right) => left.sortOrder - right.sortOrder
);
const sameCategory = ordered.filter(
(section) => section.category === category
);
const previous = sameCategory[sameCategory.length - 1];
if (!previous) {
return ordered.length === 0
? 10
: Math.max(...ordered.map((section) => section.sortOrder)) + 10;
}
const next = ordered.find(
(section) => section.sortOrder > previous.sortOrder
);
return next
? previous.sortOrder + (next.sortOrder - previous.sortOrder) / 2
: previous.sortOrder + 10;
}
+72
View File
@@ -15,8 +15,60 @@ import { createCircuitGroupMovePlan } from "../src/domain/services/circuit-group
import "./circuit-group-move-project-command.repository.test.js"; import "./circuit-group-move-project-command.repository.test.js";
import "./circuit-group-subtree-snapshot.test.js"; import "./circuit-group-subtree-snapshot.test.js";
import "./circuit-group-subtree-project-command.repository.test.js"; import "./circuit-group-subtree-project-command.repository.test.js";
import {
buildNewCircuitGroupSnapshot,
canDeleteCircuitGroup,
renameCircuitGroupSnapshot,
} from "../src/frontend/utils/circuit-group-editing.js";
describe("circuit group numbering", () => { describe("circuit group numbering", () => {
it("builds the next group without filling gaps or renumbering existing groups", () => {
const sections = [
groupSection("lighting-1", "lighting", 1, 10),
groupSection("single-1", "single_phase", 1, 20),
groupSection("lighting-3", "lighting", 3, 15),
];
const snapshot = buildNewCircuitGroupSnapshot({
id: "lighting-4",
circuitListId: "list-1",
category: "lighting",
sections,
});
assert.deepEqual(snapshot, {
id: "lighting-4",
circuitListId: "list-1",
key: "lighting_4",
displayName: "Beleuchtung 4",
prefix: "-1F4.",
sortOrder: 17.5,
category: "lighting",
groupNumber: 4,
});
assert.equal(
renameCircuitGroupSnapshot(snapshot, " Beleuchtung Nord ").displayName,
"Beleuchtung Nord"
);
assert.equal(canDeleteCircuitGroup(sections[0]), true);
assert.equal(
canDeleteCircuitGroup({
...sections[0],
components: [
{
id: "component-1",
circuitListId: "list-1",
sectionId: sections[0].id,
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection",
placement: "group",
sortOrder: 10,
},
],
}),
false
);
});
it("formats the agreed identifiers including the leading hyphen", () => { it("formats the agreed identifiers including the leading hyphen", () => {
assert.equal( assert.equal(
formatGroupUpstreamProtectionIdentifier("lighting", 1), formatGroupUpstreamProtectionIdentifier("lighting", 1),
@@ -110,6 +162,26 @@ describe("circuit group numbering", () => {
}); });
}); });
function groupSection(
id: string,
category: "lighting" | "single_phase" | "three_phase",
groupNumber: number,
sortOrder: number
) {
return {
id,
key: id,
displayName: id,
prefix: `-${category === "lighting" ? 1 : category === "single_phase" ? 2 : 3}F${groupNumber}.`,
sortOrder,
category,
groupNumber,
sectionTotalPower: 0,
components: [],
circuits: [],
};
}
describe("circuit group renumber planning", () => { describe("circuit group renumber planning", () => {
const groups = [ const groups = [
{ {