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
@@ -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 {
buildCircuitSectionRenumberAssignments,
} from "../utils/circuit-section-renumber-command";
import {
buildNewCircuitGroupSnapshot,
canDeleteCircuitGroup,
renameCircuitGroupSnapshot,
toCircuitGroupSnapshot,
} from "../utils/circuit-group-editing";
import {
buildDistributionBoardComponentSnapshot,
getNextComponentSortOrder,
@@ -71,12 +77,14 @@ import type { VisibleGridRow } from "../utils/circuit-grid-projection";
import {
deleteCircuitCommand,
deleteCircuitDeviceRowCommand,
deleteCircuitGroupCommand,
deleteDistributionBoardComponentCommand,
getCircuitTree,
getNextCircuitIdentifier,
getProjectHistory,
insertCircuitCommand,
insertCircuitDeviceRowCommand,
insertCircuitGroupCommand,
insertDistributionBoardComponentCommand,
listProjectDevices,
moveCircuitDeviceRowsCommand,
@@ -87,6 +95,7 @@ import {
redoProjectCommand,
updateCircuitById,
updateCircuitDeviceRowById,
updateCircuitGroupCommand,
updateDistributionBoardComponentCommand,
undoProjectCommand,
} from "../utils/api";
@@ -107,6 +116,8 @@ import type {
CircuitSnapshot,
} from "../../domain/models/circuit-structure-project-command.model";
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 StartEditMode = "selectExisting" | "replaceWithTypedChar";
@@ -187,6 +198,10 @@ type StructureComponentEditorIntent =
component: CircuitTreeComponentDto;
};
type CircuitGroupEditorIntent =
| { kind: "create" }
| { kind: "edit"; section: CircuitTreeResponseDto["sections"][number] };
function getFullColumnLabel(column: ColumnDef): string {
return column.fullLabel ?? column.label;
}
@@ -229,6 +244,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [isSaving, setIsSaving] = useState(false);
const [componentEditorIntent, setComponentEditorIntent] =
useState<StructureComponentEditorIntent | null>(null);
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
useState<CircuitGroupEditorIntent | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
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() {
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
return;
@@ -2735,6 +2822,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
/>
) : null}
{circuitGroupEditorIntent ? (
<CircuitGroupModal
initialSection={
circuitGroupEditorIntent.kind === "edit"
? circuitGroupEditorIntent.section
: undefined
}
isSaving={isSaving}
onClose={() => setCircuitGroupEditorIntent(null)}
onSave={handleSaveCircuitGroup}
/>
) : null}
<div className="editor-toolbar">
<button
type="button"
@@ -2798,6 +2897,13 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
>
Verteilergerät hinzufügen
</button>
<button
type="button"
disabled={isSaving || historyBusy}
onClick={() => setCircuitGroupEditorIntent({ kind: "create" })}
>
Stromkreisgruppe hinzufügen
</button>
<button
type="button"
onClick={clearSortAndFilters}
@@ -3302,6 +3408,34 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</span>
</div>
<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
type="button"
tabIndex={-1}
+55
View File
@@ -55,6 +55,9 @@ import type {
import type {
DistributionBoardComponentSnapshot,
} 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> {
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(
projectId: string,
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;
}