Manage distribution board components

This commit is contained in:
2026-07-31 07:29:25 +02:00
parent 31589875b5
commit 756e307bd8
9 changed files with 877 additions and 8 deletions
+11 -5
View File
@@ -357,9 +357,14 @@ vollständige Stromkreisblöcke und der freie Einfügeplatz. Leere Gruppen bleib
sichtbar; Komponenten werden stabil nach `sortOrder` und ID sortiert.
`circuit-grid-projection.ts` normalisiert diese Struktur zusammen mit den
bisherigen kompakten und mehrzeiligen Stromkreisblöcken für den Editor. Kopf-,
Gruppen- und Fußkomponenten erscheinen dort zunächst als schreibgeschützte
Zeilen über die volle Tabellenbreite und nehmen nicht an Zellauswahl,
Tastatur-Einfügen oder Löschen teil.
Gruppen- und Fußkomponenten erscheinen als Zeilen über die volle Tabellenbreite
und nehmen nicht an Zellauswahl oder Tastatur-Einfügen teil. Hauptschalter und
Überspannungsableiter bleiben feste, schreibgeschützte Kopfkomponenten.
Optionale Gruppenvorsicherungen, Gruppen-FIs und zusätzliche Verteilergeräte
werden über beschriftete Modale angelegt, bearbeitet und nach Bestätigung
entfernt. Der Frontend-API-Adapter serialisiert dafür die vorhandenen
`distribution-board-component.*`-Commands; die verbindliche
geräteabhängige Validierung und atomare Revision bleiben serverseitig.
`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
@@ -433,8 +438,9 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für
veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter
Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
zeigt die geschützte Struktur bereits schreibgeschützt an; dedizierte
Bearbeitungsfunktionen folgen in einem abgegrenzten Arbeitspaket.
zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und
Fußkomponenten über dedizierte Command-Modale. Gruppenverwaltung und
Stromkreisschutz folgen in weiteren abgegrenzten Arbeitspaketen.
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen
+2
View File
@@ -34,6 +34,8 @@ requirements and intended sequencing, not proof of implementation.
- [x] Phase E1: tree read model for components, groups and protection devices.
- [x] Phase E2a: pure header/group/footer structure projection.
- [x] Phase E2b: render the structure projection in the editor grid.
- [x] Phase E3a: create, edit and delete mutable group and footer components.
- [ ] Phase E3b: group management and circuit-protection editing.
- [ ] Phase E: editor projection and editing.
- [ ] Phase F: documentation and full GUI verification.
- [ ] Keep full electrical sizing and cable-dimensioning rules separate until
@@ -740,8 +740,8 @@ Acceptance:
### E. Editor Projection and Editing
Status: In progress. The complete tree read model E1, pure structural
projection E2a and read-only grid rendering E2b are complete. Editing remains
pending.
projection E2a, grid rendering E2b and mutable component editing E3a are
complete. Group management and circuit-protection editing remain pending.
- render fixed header components
- render group components and circuit blocks
@@ -750,7 +750,7 @@ pending.
- add type-dependent protection editing
- keep spreadsheet selection, keyboard and drag behavior coherent
Implemented in E1/E2a/E2b:
Implemented in E1/E2a/E2b/E3a:
- the tree response exposes fixed header, group and auxiliary footer
components plus both one-to-one protection layers
@@ -764,6 +764,12 @@ Implemented in E1/E2a/E2b:
components as read-only full-width rows
- structural component rows are excluded from spreadsheet cell navigation,
insertion and deletion commands until their dedicated editing controls exist
- group headings offer at most one optional upstream protection device and one
optional group RCD; the agreed group-RCD default is applied only when it is
created
- mutable group protection and auxiliary footer components use labeled modals,
type-dependent protection fields and persistent project commands
- fixed main-switch and surge-protection header components remain read-only
Acceptance:
+17
View File
@@ -691,6 +691,23 @@ body {
font-size: 0.78rem;
}
.tree-grid .structure-component-actions {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-left: auto;
}
.tree-grid .structure-component-actions button {
font-size: 0.75rem;
padding: 0.18rem 0.45rem;
}
.tree-grid .structure-component-fixed {
color: #64748b;
font-size: 0.75rem;
}
.tree-grid .section-drop-cell {
padding: 0.45rem 0.6rem;
}
@@ -47,6 +47,15 @@ import {
import {
buildCircuitSectionRenumberAssignments,
} from "../utils/circuit-section-renumber-command";
import {
buildDistributionBoardComponentSnapshot,
getNextComponentSortOrder,
getSuggestedGroupComponentIdentifier,
toDistributionBoardComponentSnapshot,
updateDistributionBoardComponentSnapshot,
type DistributionBoardComponentEditorValues,
type MutableDistributionBoardComponentRole,
} from "../utils/distribution-board-component-editing";
import { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
import type {
CellKey,
@@ -62,11 +71,13 @@ import type { VisibleGridRow } from "../utils/circuit-grid-projection";
import {
deleteCircuitCommand,
deleteCircuitDeviceRowCommand,
deleteDistributionBoardComponentCommand,
getCircuitTree,
getNextCircuitIdentifier,
getProjectHistory,
insertCircuitCommand,
insertCircuitDeviceRowCommand,
insertDistributionBoardComponentCommand,
listProjectDevices,
moveCircuitDeviceRowsCommand,
moveCircuitDeviceRowsToNewCircuitCommand,
@@ -76,10 +87,12 @@ import {
redoProjectCommand,
updateCircuitById,
updateCircuitDeviceRowById,
updateDistributionBoardComponentCommand,
undoProjectCommand,
} from "../utils/api";
import type {
CircuitTreeCircuitDto,
CircuitTreeComponentDto,
CircuitTreeResponseDto,
CreateCircuitDeviceRowInputDto,
CreateCircuitInputDto,
@@ -93,6 +106,7 @@ import type {
import type {
CircuitSnapshot,
} from "../../domain/models/circuit-structure-project-command.model";
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
type SaveDirection = "stay" | "next" | "prev";
type StartEditMode = "selectExisting" | "replaceWithTypedChar";
@@ -161,6 +175,18 @@ function normalizeUiError(err: unknown): string {
return message;
}
type StructureComponentEditorIntent =
| {
kind: "create";
role: MutableDistributionBoardComponentRole;
sectionId?: string;
initialEquipmentIdentifier?: string;
}
| {
kind: "edit";
component: CircuitTreeComponentDto;
};
function getFullColumnLabel(column: ColumnDef): string {
return column.fullLabel ?? column.label;
}
@@ -201,6 +227,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [componentEditorIntent, setComponentEditorIntent] =
useState<StructureComponentEditorIntent | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
useState(false);
@@ -1031,6 +1059,85 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await applyHistory("undo");
}
async function handleSaveDistributionBoardComponent(
values: DistributionBoardComponentEditorValues
) {
const intent = componentEditorIntent;
if (!intent || !data) {
return;
}
await runCommand({
label:
intent.kind === "edit"
? "Verteilerkomponente bearbeiten"
: "Verteilerkomponente hinzufügen",
redo: async () => {
const result =
intent.kind === "edit"
? await (() => {
const expected = toDistributionBoardComponentSnapshot(
intent.component
);
return updateDistributionBoardComponentCommand(
projectId,
getExpectedProjectRevision(),
expected,
updateDistributionBoardComponentSnapshot(expected, values)
);
})()
: await (() => {
const components =
intent.role === "auxiliary"
? data.footerComponents
: data.sections.find(
(section) => section.id === intent.sectionId
)?.components ?? [];
const snapshot = buildDistributionBoardComponentSnapshot({
id: crypto.randomUUID(),
circuitListId,
role: intent.role,
sectionId: intent.sectionId,
sortOrder: getNextComponentSortOrder(components),
values,
});
return insertDistributionBoardComponentCommand(
projectId,
getExpectedProjectRevision(),
snapshot
);
})();
applyProjectCommandResult(result);
setComponentEditorIntent(null);
return null;
},
});
}
async function handleDeleteDistributionBoardComponent(
component: CircuitTreeComponentDto
) {
if (
!confirm(
`Verteilerkomponente „${component.equipmentIdentifier} ${component.name}“ entfernen?`
)
) {
return;
}
await runCommand({
label: "Verteilerkomponente entfernen",
redo: async () => {
const result = await deleteDistributionBoardComponentCommand(
projectId,
getExpectedProjectRevision(),
toDistributionBoardComponentSnapshot(component)
);
applyProjectCommandResult(result);
setComponentEditorIntent(null);
return null;
},
});
}
async function handleRedo() {
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
return;
@@ -2605,6 +2712,29 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return (
<div className="tree-editor-shell">
{componentEditorIntent ? (
<DistributionBoardComponentModal
initialComponent={
componentEditorIntent.kind === "edit"
? componentEditorIntent.component
: undefined
}
initialEquipmentIdentifier={
componentEditorIntent.kind === "create"
? componentEditorIntent.initialEquipmentIdentifier
: undefined
}
isSaving={isSaving}
onClose={() => setComponentEditorIntent(null)}
onSave={handleSaveDistributionBoardComponent}
role={
componentEditorIntent.kind === "edit"
? (componentEditorIntent.component
.role as MutableDistributionBoardComponentRole)
: componentEditorIntent.role
}
/>
) : null}
<div className="editor-toolbar">
<button
type="button"
@@ -2656,6 +2786,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
? "Projektgeräte schließen"
: `Projektgeräte öffnen (${projectDevices.length})`}
</button>
<button
type="button"
disabled={isSaving || historyBusy}
onClick={() =>
setComponentEditorIntent({
kind: "create",
role: "auxiliary",
})
}
>
Verteilergerät hinzufügen
</button>
<button
type="button"
onClick={clearSortAndFilters}
@@ -2996,6 +3138,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
row.rowType === "footerComponent"
) {
const component = row.component!;
const isMutableComponent =
component.role === "group_upstream_protection" ||
component.role === "group_residual_current_protection" ||
component.role === "auxiliary";
const protection = component.protectionDevice;
const protectionSummary = protection
? [
@@ -3033,6 +3179,39 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
{protectionSummary}
</span>
) : null}
<span className="structure-component-actions">
{isMutableComponent ? (
<>
<button
type="button"
disabled={isSaving}
onClick={() =>
setComponentEditorIntent({
kind: "edit",
component,
})
}
>
Bearbeiten
</button>
<button
type="button"
disabled={isSaving}
onClick={() =>
void handleDeleteDistributionBoardComponent(
component
)
}
>
Entfernen
</button>
</>
) : (
<span className="structure-component-fixed">
Feste Verteilerkomponente
</span>
)}
</span>
</div>
</td>
</tr>
@@ -3040,6 +3219,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
if (row.rowType === "section") {
const section = data.sections.find((entry) => entry.id === row.sectionId)!;
const hasUpstreamProtection = section.components.some(
(component) =>
component.role === "group_upstream_protection"
);
const hasGroupRcd = section.components.some(
(component) =>
component.role === "group_residual_current_protection"
);
return (
<tr
key={row.rowKey}
@@ -3115,6 +3302,52 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
</span>
</div>
<div className="section-actions">
<button
type="button"
tabIndex={-1}
disabled={
hasUpstreamProtection ||
!section.category ||
!section.groupNumber
}
onClick={() =>
setComponentEditorIntent({
kind: "create",
role: "group_upstream_protection",
sectionId: section.id,
initialEquipmentIdentifier:
getSuggestedGroupComponentIdentifier(
section,
"group_upstream_protection"
),
})
}
>
Vorsicherung hinzufügen
</button>
<button
type="button"
tabIndex={-1}
disabled={
hasGroupRcd ||
!section.category ||
!section.groupNumber
}
onClick={() =>
setComponentEditorIntent({
kind: "create",
role: "group_residual_current_protection",
sectionId: section.id,
initialEquipmentIdentifier:
getSuggestedGroupComponentIdentifier(
section,
"group_residual_current_protection"
),
})
}
>
Gruppen-FI hinzufügen
</button>
<button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
Stromkreis hinzufügen
</button>
@@ -0,0 +1,316 @@
"use client";
import { type FormEvent, useState } from "react";
import {
allowedRatedCurrentsAByProtectionDeviceType,
breakerTripCharacteristics,
fuseProtectionDeviceTypes,
fuseUtilizationCategories,
protectionDeviceTypeLabels,
ratedResidualCurrentsMa,
rcdTypes,
type ProtectionDeviceType,
} from "../../shared/constants/protection-device";
import type { CircuitTreeComponentDto } from "../types";
import type {
DistributionBoardComponentEditorValues,
MutableDistributionBoardComponentRole,
} from "../utils/distribution-board-component-editing";
import { FormModal } from "./form-modal";
interface DistributionBoardComponentModalProps {
initialComponent?: CircuitTreeComponentDto;
initialEquipmentIdentifier?: string;
isSaving: boolean;
onClose: () => void;
onSave: (values: DistributionBoardComponentEditorValues) => Promise<void>;
role: MutableDistributionBoardComponentRole;
}
const upstreamProtectionTypes: readonly ProtectionDeviceType[] = [
...fuseProtectionDeviceTypes,
"LS",
];
export function DistributionBoardComponentModal({
initialComponent,
initialEquipmentIdentifier = "",
isSaving,
onClose,
onSave,
role,
}: DistributionBoardComponentModalProps) {
const [equipmentIdentifier, setEquipmentIdentifier] = useState(
initialComponent?.equipmentIdentifier ?? initialEquipmentIdentifier
);
const [name, setName] = useState(
initialComponent?.name ?? defaultName(role)
);
const initialProtection = initialComponent?.protectionDevice;
const [protectionType, setProtectionType] = useState<
ProtectionDeviceType | ""
>(
initialProtection?.type ??
(role === "group_residual_current_protection" ? "FI" : "")
);
const [ratedCurrentA, setRatedCurrentA] = useState(
initialProtection?.ratedCurrentA ??
(role === "group_residual_current_protection" ? 40 : 0)
);
const [fuseUtilizationCategory, setFuseUtilizationCategory] = useState(
initialProtection?.fuseUtilizationCategory ?? "gG"
);
const [tripCharacteristic, setTripCharacteristic] = useState(
initialProtection?.tripCharacteristic ?? "B"
);
const [rcdType, setRcdType] = useState(
initialProtection?.rcdType ?? "A"
);
const [ratedResidualCurrentMa, setRatedResidualCurrentMa] = useState(
initialProtection?.ratedResidualCurrentMa ?? 30
);
const isAuxiliary = role === "auxiliary";
const availableProtectionTypes =
role === "group_residual_current_protection"
? (["FI"] as const)
: upstreamProtectionTypes;
const usesFuseCategory =
protectionType !== "" &&
(fuseProtectionDeviceTypes as readonly string[]).includes(protectionType);
const usesTripCharacteristic = protectionType === "LS";
const usesResidualCurrent = protectionType === "FI";
const allowedRatedCurrents =
protectionType === ""
? []
: allowedRatedCurrentsAByProtectionDeviceType[protectionType];
function handleProtectionTypeChange(type: ProtectionDeviceType) {
setProtectionType(type);
setRatedCurrentA(allowedRatedCurrentsAByProtectionDeviceType[type][0]);
setFuseUtilizationCategory("gG");
setTripCharacteristic("B");
setRcdType("A");
setRatedResidualCurrentMa(30);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSave({
equipmentIdentifier,
name,
...(!isAuxiliary && protectionType
? {
protectionDevice: {
type: protectionType,
ratedCurrentA,
...(usesFuseCategory
? { fuseUtilizationCategory }
: {}),
...(usesTripCharacteristic
? { tripCharacteristic }
: {}),
...(usesResidualCurrent
? { rcdType, ratedResidualCurrentMa }
: {}),
},
}
: {}),
});
}
const isValid =
equipmentIdentifier.trim().length > 0 &&
name.trim().length > 0 &&
(isAuxiliary ||
(protectionType !== "" &&
(allowedRatedCurrents as readonly number[]).includes(ratedCurrentA)));
return (
<FormModal
description={
isAuxiliary
? "Zusätzliches Gerät im Verteiler. BMK und Name werden manuell vergeben."
: "Schutzgerät dieser Stromkreisgruppe konfigurieren."
}
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!isValid}
submitLabel={initialComponent ? "Änderungen speichern" : "Komponente anlegen"}
title={`${initialComponent ? "Komponente bearbeiten" : "Komponente hinzufügen"} ${defaultName(role)}`}
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-equipment-identifier">
Betriebsmittelkennzeichen
</label>
<input
autoFocus
className="form-control"
id="component-equipment-identifier"
onChange={(event) => setEquipmentIdentifier(event.target.value)}
required
value={equipmentIdentifier}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-name">
Name
</label>
<input
className="form-control"
id="component-name"
onChange={(event) => setName(event.target.value)}
required
value={name}
/>
</div>
{!isAuxiliary ? (
<>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-protection-type">
Schutzgerät
</label>
<select
className="form-select"
disabled={role === "group_residual_current_protection"}
id="component-protection-type"
onChange={(event) =>
handleProtectionTypeChange(
event.target.value as ProtectionDeviceType
)
}
required
value={protectionType}
>
<option value="">Schutzgerät auswählen</option>
{availableProtectionTypes.map((type) => (
<option key={type} value={type}>
{protectionDeviceTypeLabels[type]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-rated-current">
Bemessungsstrom
</label>
<select
className="form-select"
disabled={!protectionType}
id="component-rated-current"
onChange={(event) => setRatedCurrentA(Number(event.target.value))}
value={ratedCurrentA}
>
{allowedRatedCurrents.map((ratedCurrent) => (
<option key={ratedCurrent} value={ratedCurrent}>
{ratedCurrent} A
</option>
))}
</select>
</div>
{usesFuseCategory ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-fuse-category">
Sicherungscharakteristik
</label>
<select
className="form-select"
id="component-fuse-category"
onChange={(event) =>
setFuseUtilizationCategory(
event.target.value as (typeof fuseUtilizationCategories)[number]
)
}
value={fuseUtilizationCategory}
>
{fuseUtilizationCategories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
) : null}
{usesTripCharacteristic ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-trip-characteristic">
Auslösecharakteristik
</label>
<select
className="form-select"
id="component-trip-characteristic"
onChange={(event) =>
setTripCharacteristic(
event.target.value as (typeof breakerTripCharacteristics)[number]
)
}
value={tripCharacteristic}
>
{breakerTripCharacteristics.map((characteristic) => (
<option key={characteristic} value={characteristic}>
{characteristic}
</option>
))}
</select>
</div>
) : null}
{usesResidualCurrent ? (
<>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-rcd-type">
FI-Typ
</label>
<select
className="form-select"
id="component-rcd-type"
onChange={(event) =>
setRcdType(event.target.value as (typeof rcdTypes)[number])
}
value={rcdType}
>
{rcdTypes.map((type) => (
<option key={type} value={type}>
Typ {type}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="component-residual-current">
Bemessungsdifferenzstrom
</label>
<select
className="form-select"
id="component-residual-current"
onChange={(event) =>
setRatedResidualCurrentMa(Number(event.target.value))
}
value={ratedResidualCurrentMa}
>
{ratedResidualCurrentsMa.map((ratedResidualCurrent) => (
<option key={ratedResidualCurrent} value={ratedResidualCurrent}>
{ratedResidualCurrent} mA
</option>
))}
</select>
</div>
</>
) : null}
</>
) : null}
</div>
</FormModal>
);
}
function defaultName(role: MutableDistributionBoardComponentRole): string {
if (role === "group_upstream_protection") {
return "Gruppenvorsicherung";
}
if (role === "group_residual_current_protection") {
return "Gruppen-FI";
}
return "Verteilergerät";
}
+55
View File
@@ -52,6 +52,9 @@ import type {
import type {
CircuitSectionRenumberAssignment,
} from "../../domain/models/circuit-section-renumber-project-command.model";
import type {
DistributionBoardComponentSnapshot,
} from "../../domain/models/distribution-board-component-structure-project-command.model";
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, {
@@ -320,6 +323,58 @@ export function getCircuitTree(projectId: string, circuitListId: string) {
return request<CircuitTreeResponseDto>(`/api/projects/${projectId}/circuit-lists/${circuitListId}/tree`);
}
export function insertDistributionBoardComponentCommand(
projectId: string,
expectedRevision: number,
snapshot: DistributionBoardComponentSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "distribution-board-component.insert",
payload: { snapshot },
},
"Verteilerkomponente hinzufügen"
);
}
export function updateDistributionBoardComponentCommand(
projectId: string,
expectedRevision: number,
expected: DistributionBoardComponentSnapshot,
target: DistributionBoardComponentSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "distribution-board-component.update",
payload: { expected, target },
},
"Verteilerkomponente bearbeiten"
);
}
export function deleteDistributionBoardComponentCommand(
projectId: string,
expectedRevision: number,
snapshot: DistributionBoardComponentSnapshot
) {
return executeProjectCommand(
projectId,
expectedRevision,
{
schemaVersion: 1,
type: "distribution-board-component.delete",
payload: { snapshot },
},
"Verteilerkomponente entfernen"
);
}
export function updateCircuitById(
projectId: string,
expectedRevision: number,
@@ -0,0 +1,139 @@
import type {
DistributionBoardComponentSnapshot,
} from "../../domain/models/distribution-board-component-structure-project-command.model";
import type {
CircuitTreeComponentDto,
CircuitTreeProtectionDeviceDto,
CircuitTreeSectionDto,
} from "../types";
import { circuitGroupCategoryNumbers } from "../../shared/constants/circuit-group";
export type MutableDistributionBoardComponentRole =
| "group_upstream_protection"
| "group_residual_current_protection"
| "auxiliary";
export interface DistributionBoardComponentEditorValues {
equipmentIdentifier: string;
name: string;
protectionDevice?: CircuitTreeProtectionDeviceDto;
}
export function toDistributionBoardComponentSnapshot(
component: CircuitTreeComponentDto
): DistributionBoardComponentSnapshot {
if (
component.role !== "group_upstream_protection" &&
component.role !== "group_residual_current_protection" &&
component.role !== "auxiliary"
) {
throw new Error("Feste Verteilerkomponenten können hier nicht bearbeitet werden.");
}
return {
component: {
id: component.id,
circuitListId: component.circuitListId,
sectionId: component.sectionId ?? null,
equipmentIdentifier: component.equipmentIdentifier,
name: component.name,
role: component.role,
placement: component.placement,
sortOrder: component.sortOrder,
},
protectionDevice: component.protectionDevice
? {
componentId: component.id,
type: component.protectionDevice.type,
ratedCurrentA: component.protectionDevice.ratedCurrentA,
fuseUtilizationCategory:
component.protectionDevice.fuseUtilizationCategory ?? null,
tripCharacteristic:
component.protectionDevice.tripCharacteristic ?? null,
rcdType: component.protectionDevice.rcdType ?? null,
ratedResidualCurrentMa:
component.protectionDevice.ratedResidualCurrentMa ?? null,
}
: null,
};
}
export function buildDistributionBoardComponentSnapshot(input: {
id: string;
circuitListId: string;
role: MutableDistributionBoardComponentRole;
sectionId?: string;
sortOrder: number;
values: DistributionBoardComponentEditorValues;
}): DistributionBoardComponentSnapshot {
const groupComponent = input.role !== "auxiliary";
if (groupComponent && !input.sectionId) {
throw new Error("Für den Gruppenschutz fehlt die Stromkreisgruppe.");
}
if (groupComponent && !input.values.protectionDevice) {
throw new Error("Für den Gruppenschutz fehlt die Schutzgerätekonfiguration.");
}
return {
component: {
id: input.id,
circuitListId: input.circuitListId,
sectionId: groupComponent ? input.sectionId! : null,
equipmentIdentifier: input.values.equipmentIdentifier.trim(),
name: input.values.name.trim(),
role: input.role,
placement: groupComponent ? "group" : "footer",
sortOrder: input.sortOrder,
},
protectionDevice:
groupComponent && input.values.protectionDevice
? {
componentId: input.id,
type: input.values.protectionDevice.type,
ratedCurrentA: input.values.protectionDevice.ratedCurrentA,
fuseUtilizationCategory:
input.values.protectionDevice.fuseUtilizationCategory ?? null,
tripCharacteristic:
input.values.protectionDevice.tripCharacteristic ?? null,
rcdType: input.values.protectionDevice.rcdType ?? null,
ratedResidualCurrentMa:
input.values.protectionDevice.ratedResidualCurrentMa ?? null,
}
: null,
};
}
export function updateDistributionBoardComponentSnapshot(
expected: DistributionBoardComponentSnapshot,
values: DistributionBoardComponentEditorValues
): DistributionBoardComponentSnapshot {
return buildDistributionBoardComponentSnapshot({
id: expected.component.id,
circuitListId: expected.component.circuitListId,
role: expected.component.role,
sectionId: expected.component.sectionId ?? undefined,
sortOrder: expected.component.sortOrder,
values,
});
}
export function getSuggestedGroupComponentIdentifier(
section: CircuitTreeSectionDto,
role:
| "group_upstream_protection"
| "group_residual_current_protection"
): string {
if (!section.category || !section.groupNumber) {
return "";
}
const categoryNumber = circuitGroupCategoryNumbers[section.category];
const deviceLetter =
role === "group_upstream_protection" ? "F" : "Q";
return `-${categoryNumber}${deviceLetter}${section.groupNumber}.0`;
}
export function getNextComponentSortOrder(
components: readonly { sortOrder: number }[]
): number {
return components.length === 0
? 10
: Math.max(...components.map((component) => component.sortOrder)) + 10;
}
@@ -7,6 +7,13 @@ import {
distributionBoardComponentRoles,
} from "../src/shared/constants/distribution-board-component.js";
import { circuitGroupCategories } from "../src/shared/constants/circuit-group.js";
import {
buildDistributionBoardComponentSnapshot,
getNextComponentSortOrder,
getSuggestedGroupComponentIdentifier,
toDistributionBoardComponentSnapshot,
updateDistributionBoardComponentSnapshot,
} from "../src/frontend/utils/distribution-board-component-editing.js";
import "./distribution-board-component-structure-project-command.repository.test.js";
describe("distribution board component catalog", () => {
@@ -47,4 +54,92 @@ describe("distribution board component catalog", () => {
placement: "header",
});
});
it("builds exact editable component snapshots for persistent commands", () => {
const groupRcd = buildDistributionBoardComponentSnapshot({
id: "component-1",
circuitListId: "list-1",
role: "group_residual_current_protection",
sectionId: "section-1",
sortOrder: 20,
values: {
equipmentIdentifier: " -1Q1.0 ",
name: " Gruppen-FI ",
protectionDevice: {
type: "FI",
ratedCurrentA: 40,
rcdType: "A",
ratedResidualCurrentMa: 30,
},
},
});
assert.deepEqual(groupRcd, {
component: {
id: "component-1",
circuitListId: "list-1",
sectionId: "section-1",
equipmentIdentifier: "-1Q1.0",
name: "Gruppen-FI",
role: "group_residual_current_protection",
placement: "group",
sortOrder: 20,
},
protectionDevice: {
componentId: "component-1",
type: "FI",
ratedCurrentA: 40,
fuseUtilizationCategory: null,
tripCharacteristic: null,
rcdType: "A",
ratedResidualCurrentMa: 30,
},
});
assert.deepEqual(
updateDistributionBoardComponentSnapshot(groupRcd, {
equipmentIdentifier: "-1Q1.0",
name: "FI Beleuchtung",
protectionDevice: {
type: "FI",
ratedCurrentA: 63,
rcdType: "B+",
ratedResidualCurrentMa: 300,
},
}).component,
{ ...groupRcd.component, name: "FI Beleuchtung" }
);
});
it("maps tree DTOs and derives stable group suggestions and sort positions", () => {
const snapshot = toDistributionBoardComponentSnapshot({
id: "actor-1",
circuitListId: "list-1",
equipmentIdentifier: "-K1",
name: "KNX Aktor",
role: "auxiliary",
placement: "footer",
sortOrder: 10,
});
assert.equal(snapshot.component.sectionId, null);
assert.equal(snapshot.protectionDevice, null);
assert.equal(getNextComponentSortOrder([{ sortOrder: 10 }, { sortOrder: 40 }]), 50);
assert.equal(
getSuggestedGroupComponentIdentifier(
{
id: "section-1",
key: "lighting",
displayName: "Beleuchtung 2",
prefix: "-1F2",
sortOrder: 10,
category: "lighting",
groupNumber: 2,
sectionTotalPower: 0,
components: [],
circuits: [],
},
"group_upstream_protection"
),
"-1F2.0"
);
});
});