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. sichtbar; Komponenten werden stabil nach `sortOrder` und ID sortiert.
`circuit-grid-projection.ts` normalisiert diese Struktur zusammen mit den `circuit-grid-projection.ts` normalisiert diese Struktur zusammen mit den
bisherigen kompakten und mehrzeiligen Stromkreisblöcken für den Editor. Kopf-, bisherigen kompakten und mehrzeiligen Stromkreisblöcken für den Editor. Kopf-,
Gruppen- und Fußkomponenten erscheinen dort zunächst als schreibgeschützte Gruppen- und Fußkomponenten erscheinen als Zeilen über die volle Tabellenbreite
Zeilen über die volle Tabellenbreite und nehmen nicht an Zellauswahl, und nehmen nicht an Zellauswahl oder Tastatur-Einfügen teil. Hauptschalter und
Tastatur-Einfügen oder Löschen teil. Ü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 `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
@@ -433,8 +438,9 @@ Kopieren in ein Projekt erzeugt ein eigenständiges Projektgerät.
Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für Snapshot-Schema 7. Persistente Insert/Delete/Update-Commands für
veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter veränderliche Verteilerkomponenten, Gruppen einschließlich befüllter
Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor Unterbäume sowie vollständige Gruppensortierung sind integriert. Der Editor
zeigt die geschützte Struktur bereits schreibgeschützt an; dedizierte zeigt die geschützte Struktur an und bearbeitet veränderliche Gruppen- und
Bearbeitungsfunktionen folgen in einem abgegrenzten Arbeitspaket. Fußkomponenten über dedizierte Command-Modale. Gruppenverwaltung und
Stromkreisschutz folgen in weiteren abgegrenzten Arbeitspaketen.
PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und PostgreSQL ist bewusst nicht implementiert. Die Domainregeln und
Transaktionsgrenzen sollen portabel bleiben; Schema und Betriebsmodell benötigen 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 E1: tree read model for components, groups and protection devices.
- [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.
- [ ] Phase E3b: group management and circuit-protection editing.
- [ ] 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
@@ -740,8 +740,8 @@ Acceptance:
### E. Editor Projection and Editing ### E. Editor Projection and Editing
Status: In progress. The complete tree read model E1, pure structural Status: In progress. The complete tree read model E1, pure structural
projection E2a and read-only grid rendering E2b are complete. Editing remains projection E2a, grid rendering E2b and mutable component editing E3a are
pending. complete. Group management and circuit-protection editing remain pending.
- render fixed header components - render fixed header components
- render group components and circuit blocks - render group components and circuit blocks
@@ -750,7 +750,7 @@ pending.
- add type-dependent protection editing - add type-dependent protection editing
- keep spreadsheet selection, keyboard and drag behavior coherent - 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 - the tree response exposes fixed header, group and auxiliary footer
components plus both one-to-one protection layers components plus both one-to-one protection layers
@@ -764,6 +764,12 @@ Implemented in E1/E2a/E2b:
components as read-only full-width rows components as read-only full-width rows
- structural component rows are excluded from spreadsheet cell navigation, - structural component rows are excluded from spreadsheet cell navigation,
insertion and deletion commands until their dedicated editing controls exist 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: Acceptance:
+17
View File
@@ -691,6 +691,23 @@ body {
font-size: 0.78rem; 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 { .tree-grid .section-drop-cell {
padding: 0.45rem 0.6rem; padding: 0.45rem 0.6rem;
} }
@@ -47,6 +47,15 @@ import {
import { import {
buildCircuitSectionRenumberAssignments, buildCircuitSectionRenumberAssignments,
} from "../utils/circuit-section-renumber-command"; } 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 { loadCircuitEditorSnapshot } from "../utils/circuit-editor-history";
import type { import type {
CellKey, CellKey,
@@ -62,11 +71,13 @@ import type { VisibleGridRow } from "../utils/circuit-grid-projection";
import { import {
deleteCircuitCommand, deleteCircuitCommand,
deleteCircuitDeviceRowCommand, deleteCircuitDeviceRowCommand,
deleteDistributionBoardComponentCommand,
getCircuitTree, getCircuitTree,
getNextCircuitIdentifier, getNextCircuitIdentifier,
getProjectHistory, getProjectHistory,
insertCircuitCommand, insertCircuitCommand,
insertCircuitDeviceRowCommand, insertCircuitDeviceRowCommand,
insertDistributionBoardComponentCommand,
listProjectDevices, listProjectDevices,
moveCircuitDeviceRowsCommand, moveCircuitDeviceRowsCommand,
moveCircuitDeviceRowsToNewCircuitCommand, moveCircuitDeviceRowsToNewCircuitCommand,
@@ -76,10 +87,12 @@ import {
redoProjectCommand, redoProjectCommand,
updateCircuitById, updateCircuitById,
updateCircuitDeviceRowById, updateCircuitDeviceRowById,
updateDistributionBoardComponentCommand,
undoProjectCommand, undoProjectCommand,
} from "../utils/api"; } from "../utils/api";
import type { import type {
CircuitTreeCircuitDto, CircuitTreeCircuitDto,
CircuitTreeComponentDto,
CircuitTreeResponseDto, CircuitTreeResponseDto,
CreateCircuitDeviceRowInputDto, CreateCircuitDeviceRowInputDto,
CreateCircuitInputDto, CreateCircuitInputDto,
@@ -93,6 +106,7 @@ import type {
import type { 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";
type SaveDirection = "stay" | "next" | "prev"; type SaveDirection = "stay" | "next" | "prev";
type StartEditMode = "selectExisting" | "replaceWithTypedChar"; type StartEditMode = "selectExisting" | "replaceWithTypedChar";
@@ -161,6 +175,18 @@ function normalizeUiError(err: unknown): string {
return message; return message;
} }
type StructureComponentEditorIntent =
| {
kind: "create";
role: MutableDistributionBoardComponentRole;
sectionId?: string;
initialEquipmentIdentifier?: string;
}
| {
kind: "edit";
component: CircuitTreeComponentDto;
};
function getFullColumnLabel(column: ColumnDef): string { function getFullColumnLabel(column: ColumnDef): string {
return column.fullLabel ?? column.label; 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 [editingCell, setEditingCell] = useState<EditingCell | null>(null);
const [activeSectionId, setActiveSectionId] = useState<string | null>(null); const [activeSectionId, setActiveSectionId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [componentEditorIntent, setComponentEditorIntent] =
useState<StructureComponentEditorIntent | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]); const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] = const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
useState(false); useState(false);
@@ -1031,6 +1059,85 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
await applyHistory("undo"); 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() { async function handleRedo() {
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) { if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
return; return;
@@ -2605,6 +2712,29 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
return ( return (
<div className="tree-editor-shell"> <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"> <div className="editor-toolbar">
<button <button
type="button" type="button"
@@ -2656,6 +2786,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
? "Projektgeräte schließen" ? "Projektgeräte schließen"
: `Projektgeräte öffnen (${projectDevices.length})`} : `Projektgeräte öffnen (${projectDevices.length})`}
</button> </button>
<button
type="button"
disabled={isSaving || historyBusy}
onClick={() =>
setComponentEditorIntent({
kind: "create",
role: "auxiliary",
})
}
>
Verteilergerät hinzufügen
</button>
<button <button
type="button" type="button"
onClick={clearSortAndFilters} onClick={clearSortAndFilters}
@@ -2996,6 +3138,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
row.rowType === "footerComponent" row.rowType === "footerComponent"
) { ) {
const component = row.component!; 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 protection = component.protectionDevice;
const protectionSummary = protection const protectionSummary = protection
? [ ? [
@@ -3033,6 +3179,39 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
{protectionSummary} {protectionSummary}
</span> </span>
) : null} ) : 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> </div>
</td> </td>
</tr> </tr>
@@ -3040,6 +3219,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
if (row.rowType === "section") { if (row.rowType === "section") {
const section = data.sections.find((entry) => entry.id === row.sectionId)!; 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 ( return (
<tr <tr
key={row.rowKey} key={row.rowKey}
@@ -3115,6 +3302,52 @@ 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={
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)}> <button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
Stromkreis hinzufügen Stromkreis hinzufügen
</button> </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 { import type {
CircuitSectionRenumberAssignment, CircuitSectionRenumberAssignment,
} from "../../domain/models/circuit-section-renumber-project-command.model"; } 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> { async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, { 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`); 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( export function updateCircuitById(
projectId: string, projectId: string,
expectedRevision: number, 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, distributionBoardComponentRoles,
} from "../src/shared/constants/distribution-board-component.js"; } from "../src/shared/constants/distribution-board-component.js";
import { circuitGroupCategories } from "../src/shared/constants/circuit-group.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"; import "./distribution-board-component-structure-project-command.repository.test.js";
describe("distribution board component catalog", () => { describe("distribution board component catalog", () => {
@@ -47,4 +54,92 @@ describe("distribution board component catalog", () => {
placement: "header", 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"
);
});
}); });