Manage distribution board components

This commit is contained in:
Julian Appel 2026-07-31 07:29:25 +02:00
parent 31589875b5
commit 756e307bd8
9 changed files with 877 additions and 8 deletions

View file

@ -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>

View file

@ -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";
}