Manage distribution board components
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user