Configure circuit protection

This commit is contained in:
Julian Appel 2026-07-31 07:45:48 +02:00
parent 18ca5eb3d4
commit 8898117ed4
18 changed files with 642 additions and 14 deletions

View file

@ -0,0 +1,233 @@
"use client";
import { type FormEvent, useState } from "react";
import {
allowedRatedCurrentsAByProtectionDeviceType,
breakerTripCharacteristics,
fuseProtectionDeviceTypes,
fuseUtilizationCategories,
protectionDeviceTypeLabels,
protectionDeviceTypes,
ratedResidualCurrentsMa,
rcdTypes,
type ProtectionDeviceType,
} from "../../shared/constants/protection-device";
import type { CircuitTreeProtectionDeviceDto } from "../types";
import { FormModal } from "./form-modal";
interface CircuitProtectionModalProps {
equipmentIdentifier: string;
initialProtection: CircuitTreeProtectionDeviceDto;
isSaving: boolean;
onClose: () => void;
onSave: (protection: CircuitTreeProtectionDeviceDto) => Promise<void>;
}
export function CircuitProtectionModal({
equipmentIdentifier,
initialProtection,
isSaving,
onClose,
onSave,
}: CircuitProtectionModalProps) {
const [type, setType] = useState(initialProtection.type);
const [ratedCurrentA, setRatedCurrentA] = useState(
initialProtection.ratedCurrentA
);
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 usesFuseCategory = (
fuseProtectionDeviceTypes as readonly string[]
).includes(type);
const usesTripCharacteristic =
type === "LS" || type === "FI_LS" || type === "AFDD";
const usesResidualCurrent = type === "FI" || type === "FI_LS";
const allowedRatedCurrents =
allowedRatedCurrentsAByProtectionDeviceType[type];
function handleTypeChange(nextType: ProtectionDeviceType) {
setType(nextType);
setRatedCurrentA(
allowedRatedCurrentsAByProtectionDeviceType[nextType][0]
);
setFuseUtilizationCategory("gG");
setTripCharacteristic("B");
setRcdType("A");
setRatedResidualCurrentMa(30);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
await onSave({
type,
ratedCurrentA,
...(usesFuseCategory ? { fuseUtilizationCategory } : {}),
...(usesTripCharacteristic ? { tripCharacteristic } : {}),
...(usesResidualCurrent
? { rcdType, ratedResidualCurrentMa }
: {}),
});
}
return (
<FormModal
description="Die Auswahl ist planerisch gesetzt. Eine spätere Dimensionierung wird nur Empfehlungen und Warnungen ergänzen."
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={
!(allowedRatedCurrents as readonly number[]).includes(
ratedCurrentA
)
}
submitLabel="Schutzgerät speichern"
title={`Schutzgerät bearbeiten ${equipmentIdentifier}`}
>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-protection-type">
Schutzgerät
</label>
<select
autoFocus
className="form-select"
id="circuit-protection-type"
onChange={(event) =>
handleTypeChange(
event.target.value as ProtectionDeviceType
)
}
value={type}
>
{protectionDeviceTypes.map((entry) => (
<option key={entry} value={entry}>
{protectionDeviceTypeLabels[entry]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-protection-current">
Bemessungsstrom
</label>
<select
className="form-select"
id="circuit-protection-current"
onChange={(event) =>
setRatedCurrentA(Number(event.target.value))
}
value={ratedCurrentA}
>
{allowedRatedCurrents.map((current) => (
<option key={current} value={current}>
{current} A
</option>
))}
</select>
</div>
{usesFuseCategory ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-fuse-category">
Sicherungscharakteristik
</label>
<select
className="form-select"
id="circuit-fuse-category"
onChange={(event) =>
setFuseUtilizationCategory(
event.target
.value as (typeof fuseUtilizationCategories)[number]
)
}
value={fuseUtilizationCategory}
>
{fuseUtilizationCategories.map((entry) => (
<option key={entry} value={entry}>
{entry}
</option>
))}
</select>
</div>
) : null}
{usesTripCharacteristic ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-trip-characteristic">
Auslösecharakteristik
</label>
<select
className="form-select"
id="circuit-trip-characteristic"
onChange={(event) =>
setTripCharacteristic(
event.target
.value as (typeof breakerTripCharacteristics)[number]
)
}
value={tripCharacteristic}
>
{breakerTripCharacteristics.map((entry) => (
<option key={entry} value={entry}>
{entry}
</option>
))}
</select>
</div>
) : null}
{usesResidualCurrent ? (
<>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-rcd-type">
FI-Typ
</label>
<select
className="form-select"
id="circuit-rcd-type"
onChange={(event) =>
setRcdType(
event.target.value as (typeof rcdTypes)[number]
)
}
value={rcdType}
>
{rcdTypes.map((entry) => (
<option key={entry} value={entry}>
Typ {entry}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="circuit-residual-current">
Bemessungsdifferenzstrom
</label>
<select
className="form-select"
id="circuit-residual-current"
onChange={(event) =>
setRatedResidualCurrentMa(Number(event.target.value))
}
value={ratedResidualCurrentMa}
>
{ratedResidualCurrentsMa.map((entry) => (
<option key={entry} value={entry}>
{entry} mA
</option>
))}
</select>
</div>
</>
) : null}
</div>
</FormModal>
);
}

View file

@ -38,6 +38,10 @@ import {
buildCircuitDeviceRowInsertSnapshot,
buildCircuitInsertSnapshot,
} from "../utils/circuit-structure-command";
import {
getCircuitProtectionEditorInitialValue,
toCircuitProtectionSnapshot,
} from "../utils/circuit-protection-editing";
import {
buildCircuitDeviceRowMoveAssignments,
} from "../utils/circuit-device-row-move-command";
@ -95,6 +99,7 @@ import {
redoProjectCommand,
updateCircuitById,
updateCircuitDeviceRowById,
updateCircuitProtectionCommand,
updateCircuitGroupCommand,
updateDistributionBoardComponentCommand,
undoProjectCommand,
@ -102,6 +107,7 @@ import {
import type {
CircuitTreeCircuitDto,
CircuitTreeComponentDto,
CircuitTreeProtectionDeviceDto,
CircuitTreeResponseDto,
CreateCircuitDeviceRowInputDto,
CreateCircuitInputDto,
@ -117,6 +123,7 @@ import type {
} from "../../domain/models/circuit-structure-project-command.model";
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
import { CircuitGroupModal } from "./circuit-group-modal";
import { CircuitProtectionModal } from "./circuit-protection-modal";
import type { CircuitGroupCategory } from "../../shared/constants/circuit-group";
type SaveDirection = "stay" | "next" | "prev";
@ -246,6 +253,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
useState<StructureComponentEditorIntent | null>(null);
const [circuitGroupEditorIntent, setCircuitGroupEditorIntent] =
useState<CircuitGroupEditorIntent | null>(null);
const [protectionEditorCircuit, setProtectionEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
useState(false);
@ -962,6 +971,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
circuitListId,
values: { ...values, voltage },
deviceRows,
category: section.category,
});
}
@ -1225,6 +1235,35 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}
async function handleSaveCircuitProtection(
protection: CircuitTreeProtectionDeviceDto
) {
const circuit = protectionEditorCircuit;
if (!circuit) {
return;
}
await runCommand({
label: "Stromkreisschutz bearbeiten",
redo: async () => {
const result = await updateCircuitProtectionCommand(
projectId,
getExpectedProjectRevision(),
circuit.id,
circuit.protectionDevice
? toCircuitProtectionSnapshot(
circuit.id,
circuit.protectionDevice
)
: null,
toCircuitProtectionSnapshot(circuit.id, protection)
);
applyProjectCommandResult(result);
setProtectionEditorCircuit(null);
return null;
},
});
}
async function handleRedo() {
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
return;
@ -2834,6 +2873,23 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onSave={handleSaveCircuitGroup}
/>
) : null}
{protectionEditorCircuit ? (
<CircuitProtectionModal
equipmentIdentifier={
protectionEditorCircuit.equipmentIdentifier
}
initialProtection={getCircuitProtectionEditorInitialValue(
data.sections.find(
(section) =>
section.id === protectionEditorCircuit.sectionId
)?.category,
protectionEditorCircuit.protectionDevice
)}
isSaving={isSaving}
onClose={() => setProtectionEditorCircuit(null)}
onSave={handleSaveCircuitProtection}
/>
) : null}
<div className="editor-toolbar">
<button
type="button"
@ -4033,6 +4089,15 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
>
{row.circuit && row.rowType !== "deviceRow" ? (
<>
<button
type="button"
tabIndex={-1}
onClick={() =>
setProtectionEditorCircuit(row.circuit!)
}
>
Schutzgerät
</button>
<button
type="button"
tabIndex={-1}