Add optional cable-sizing module

Per-circuit cross-section recommendation as a separate module attached to
the circuit list. It never writes project data itself: "Übernehmen" applies
cable type, cross-section and length through the regular circuit.update
command, so revisions and Undo/Redo stay intact.

- Pure domain module (src/cable-sizing) with IEC 60364-5-52:2009 Annex B
  tables for PVC and XLPE, copper and aluminium, methods A1, A2, B1, B2, C,
  D1, D2 and E. Every value was cross-checked against public reproductions;
  where sources differ the lower value is used (docs/cable-sizing-module.md).
- Temperature and grouping between table values round to the safer value;
  values outside the tables return no result instead of a factor of 1.
  Ground methods use B.52.18 (D2) and B.52.19 A (D1).
- Overload protection: circuit-breakers In <= Iz; gG fuses additionally
  I2 <= 1.45 Iz with I2 = 1.6/1.9/2.1 In.
- Inputs prefilled from the circuit: power, phase (group or device rows),
  project voltage, power-weighted cos phi, length and protective device.
- Opened from a "Leitungsberechnung" button in the row actions, not from a
  grid cell, so cable cells keep the spreadsheet behavior.
- Stateless API under /api/cable-sizing; no database table or migration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Grovy311 2026-09-26 10:59:41 +02:00
parent d95f0bf5c0
commit 49488f4e1b
17 changed files with 1999 additions and 4 deletions

View file

@ -0,0 +1,37 @@
// Thin fetch client for the cable-sizing module, kept separate from
// ../utils/api.ts so the module stays self-contained (see
// docs/cable-sizing-module.md).
import type {
CableSizingAlert,
CableSizingInput,
CableSizingResult,
} from "../../cable-sizing/domain/cable-sizing-calculation";
export interface CalculateCableSizingResponse {
result: CableSizingResult;
alerts: CableSizingAlert[];
}
export async function calculateCableSizing(
input: CableSizingInput
): Promise<CalculateCableSizingResponse> {
const response = await fetch("/api/cable-sizing/calculate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
cache: "no-store",
});
if (!response.ok) {
let details = await response.text();
try {
const parsed = JSON.parse(details) as { error?: unknown };
if (typeof parsed.error === "string") {
details = parsed.error;
}
} catch {
// keep raw text
}
throw new Error(details || `Anfrage fehlgeschlagen (Status ${response.status})`);
}
return response.json() as Promise<CalculateCableSizingResponse>;
}

View file

@ -0,0 +1,54 @@
// Derives the cable-sizing inputs that follow from the circuit itself, so the
// planner starts from the circuit's real values instead of fixed defaults.
import type {
CircuitCategory,
ExistingProtection,
} from "../../cable-sizing/domain/cable-sizing-calculation";
import type { CircuitTreeCircuitDto, CircuitTreeProtectionDeviceDto } from "../types";
/** Three-phase if the circuit group or any device row is three-phase. */
export function deriveCircuitPhase(
circuit: CircuitTreeCircuitDto,
circuitCategory?: CircuitCategory
): 1 | 3 {
return circuitCategory === "three_phase" ||
circuit.deviceRows.some((row) => row.phaseType === "three_phase")
? 3
: 1;
}
/**
* Power-weighted cos φ of the circuit: P / S with S = Σ P_row / cos φ_row.
* Rows without cos φ count as 1; a circuit without load yields 1.
*/
export function deriveCircuitCosPhi(circuit: CircuitTreeCircuitDto): number {
let activePowerKw = 0;
let apparentPowerKva = 0;
for (const row of circuit.deviceRows) {
activePowerKw += row.rowTotalPower;
apparentPowerKva += row.rowTotalPower / (row.cosPhi || 1);
}
if (activePowerKw <= 0 || apparentPowerKva <= 0) {
return 1;
}
return Math.round((activePowerKw / apparentPowerKva) * 100) / 100;
}
/**
* Maps the circuit's protective device to the overload-protection kind of the
* calculation. Circuit-breakers (LS, FI/LS, AFDD) are "breaker", D0 and NH
* fuse links "fuse"; a residual-current device without overcurrent release
* (FI) provides no overload protection and is ignored.
*/
export function toExistingProtection(
protection: CircuitTreeProtectionDeviceDto | undefined
): ExistingProtection | undefined {
if (!protection || protection.type === "FI") {
return undefined;
}
const kind =
protection.type === "LS" || protection.type === "FI_LS" || protection.type === "AFDD"
? "breaker"
: "fuse";
return { ratedCurrentA: protection.ratedCurrentA, kind };
}

View file

@ -0,0 +1,571 @@
"use client";
import { type FormEvent, useMemo, useState } from "react";
import {
CONDUCTOR_MATERIALS,
CROSS_SECTIONS_MM2,
HARMONIC_NEUTRAL_LOAD_LABELS,
HARMONIC_NEUTRAL_LOAD_OPTIONS,
INSULATION_MATERIALS,
INSULATION_MATERIAL_LABELS,
LAYING_METHODS,
LAYING_METHOD_GROUP,
LAYING_METHOD_LABELS,
MAX_GROUPING_CIRCUITS,
PRACTICAL_MINIMUM_CROSS_SECTION_MM2,
REFERENCE_TEMPERATURE_C,
temperatureOptions,
type CableSizingAlert,
type CableSizingInput,
type CableSizingResult,
type CircuitCategory,
type ConductorMaterial,
type HarmonicNeutralLoad,
type InsulationMaterial,
type LayingMethod,
} from "../../cable-sizing/domain/cable-sizing-calculation";
import { calculateCableSizing } from "./cable-sizing-api";
import type { CircuitTreeCircuitDto } from "../types";
import {
deriveCircuitCosPhi,
deriveCircuitPhase,
toExistingProtection,
} from "./cable-sizing-defaults";
import { FormModal } from "./form-modal";
interface CableSizingModalProps {
circuit: CircuitTreeCircuitDto;
circuitCategory?: CircuitCategory;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
isSaving: boolean;
onClose: () => void;
onApply: (patch: {
cableCrossSection: string;
cableType?: string;
cableLength?: number;
}) => Promise<void>;
}
// Extracts the first decimal number from a free-text cross-section entry
// like "2.5 mm²" or "2,5"; used only for the manual-entry sanity check.
function parseCrossSectionMm2(text: string): number | null {
const match = text.match(/([0-9]+(?:[.,][0-9]+)?)/);
if (!match) return null;
const value = Number(match[1].replace(",", "."));
return Number.isFinite(value) ? value : null;
}
const formatNumber = (value: number, digits: number) =>
value.toLocaleString("de-DE", { minimumFractionDigits: digits, maximumFractionDigits: digits });
export function CableSizingModal({
circuit,
circuitCategory,
singlePhaseVoltageV,
threePhaseVoltageV,
isSaving,
onClose,
onApply,
}: CableSizingModalProps) {
const derivedPhase = deriveCircuitPhase(circuit, circuitCategory);
const derivedCosPhi = deriveCircuitCosPhi(circuit);
const existingProtection = toExistingProtection(circuit.protectionDevice);
const [layingMethod, setLayingMethod] = useState<LayingMethod>("C");
const [insulation, setInsulation] = useState<InsulationMaterial>("pvc");
const [conductorMaterial, setConductorMaterial] = useState<ConductorMaterial>("copper");
const [ambientTemperatureC, setAmbientTemperatureC] = useState(30);
const [groupingCircuits, setGroupingCircuits] = useState(1);
const [cosPhi, setCosPhi] = useState(derivedCosPhi);
const [maxVoltageDropPercent, setMaxVoltageDropPercent] = useState(3);
const [harmonicNeutralLoad, setHarmonicNeutralLoad] = useState<HarmonicNeutralLoad>("none");
const [lengthM, setLengthM] = useState(circuit.cableLength ?? 0);
const [phase, setPhase] = useState<1 | 3>(derivedPhase);
// Manual entry: applying never requires a calculation. Prefilled from the
// circuit so an existing cable specification is not lost.
const [manualCableType, setManualCableType] = useState(circuit.cableType ?? "");
const [manualCrossSection, setManualCrossSection] = useState(circuit.cableCrossSection ?? "");
const [calculating, setCalculating] = useState(false);
const [result, setResult] = useState<CableSizingResult | null>(null);
const [alerts, setAlerts] = useState<CableSizingAlert[]>([]);
const [error, setError] = useState<string | null>(null);
const voltage =
phase === derivedPhase && circuit.voltage
? circuit.voltage
: phase === 3
? threePhaseVoltageV
: singlePhaseVoltageV;
const group = LAYING_METHOD_GROUP[layingMethod];
const availableTemperatures = temperatureOptions(layingMethod, insulation);
const input: CableSizingInput = useMemo(
() => ({
phase,
mode: "power",
powerKw: circuit.circuitTotalPower,
cosPhi,
voltage,
lengthM,
layingMethod,
conductorMaterial,
insulation,
ambientTemperatureC,
groupingCircuits,
maxVoltageDropPercent,
harmonicNeutralLoad,
existingProtection,
circuitCategory,
}),
[
phase,
circuit.circuitTotalPower,
cosPhi,
voltage,
lengthM,
layingMethod,
conductorMaterial,
insulation,
ambientTemperatureC,
groupingCircuits,
maxVoltageDropPercent,
harmonicNeutralLoad,
existingProtection?.ratedCurrentA,
existingProtection?.kind,
circuitCategory,
]
);
const manualCrossSectionWarning = useMemo(() => {
const trimmed = manualCrossSection.trim();
if (!trimmed) return null;
const parsed = parseCrossSectionMm2(trimmed);
if (parsed == null) return null;
if (!(CROSS_SECTIONS_MM2 as readonly number[]).includes(parsed)) {
return `${formatNumber(parsed, parsed % 1 ? 1 : 0)} mm² ist kein Normquerschnitt (${CROSS_SECTIONS_MM2.map((value) => formatNumber(value, value % 1 ? 1 : 0)).join(", ")}).`;
}
if (
result?.supported &&
result.recommendedCrossSectionMm2 != null &&
parsed < result.recommendedCrossSectionMm2
) {
return `${formatNumber(parsed, parsed % 1 ? 1 : 0)} mm² ist kleiner als die zuletzt berechnete Empfehlung (${formatNumber(result.recommendedCrossSectionMm2, result.recommendedCrossSectionMm2 % 1 ? 1 : 0)} mm²).`;
}
const practicalMinimum = circuitCategory
? PRACTICAL_MINIMUM_CROSS_SECTION_MM2[circuitCategory]
: undefined;
if (practicalMinimum != null && parsed < practicalMinimum) {
return `${formatNumber(parsed, parsed % 1 ? 1 : 0)} mm² liegt unter dem Praxis-Mindestquerschnitt für 1-phasige Stromkreise (${formatNumber(practicalMinimum, 1)} mm²).`;
}
return null;
}, [manualCrossSection, result, circuitCategory]);
function resetResult() {
setResult(null);
setAlerts([]);
}
function applyReferenceTemperature(method: LayingMethod, material: InsulationMaterial) {
const options = temperatureOptions(method, material);
const reference = REFERENCE_TEMPERATURE_C[LAYING_METHOD_GROUP[method]];
setAmbientTemperatureC(options.includes(reference) ? reference : options[0]);
}
async function handleCalculate() {
setCalculating(true);
setError(null);
try {
const response = await calculateCableSizing(input);
setResult(response.result);
setAlerts(response.alerts);
// The suggestion fills the manual field, which stays the single source
// of truth for what gets applied.
if (response.result.supported && response.result.recommendedCrossSectionMm2) {
const size = response.result.recommendedCrossSectionMm2;
setManualCrossSection(`${formatNumber(size, size % 1 ? 1 : 0)} mm²`);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Berechnung fehlgeschlagen");
} finally {
setCalculating(false);
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!manualCrossSection.trim()) return;
await onApply({
cableCrossSection: manualCrossSection.trim(),
cableType: manualCableType.trim() || undefined,
cableLength: lengthM,
});
}
const recommendedRow = result?.rows.find((row) => row.recommended);
return (
<FormModal
description="Querschnittsempfehlung nach IEC 60364-5-52 Anhang B (Belastbarkeit, Temperatur, Häufung, Spannungsfall, Schutzorgan) oder direkte Eingabe. Die Empfehlung vor der Übernahme fachlich prüfen; nationale Tabellen (DIN VDE 0298-4) können abweichen."
isSaving={isSaving}
onClose={onClose}
onSubmit={handleSubmit}
submitDisabled={!manualCrossSection.trim()}
submitLabel="Übernehmen"
title={`Leitungsberechnung – ${circuit.equipmentIdentifier}${
circuit.displayName ? ` (${circuit.displayName})` : ""
}`}
>
<div className="row g-3">
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-length">
Leitungslänge [m]
</label>
<input
className="form-control"
id="cable-sizing-length"
min={0}
onChange={(event) => {
setLengthM(Number(event.target.value));
resetResult();
}}
type="number"
value={lengthM}
/>
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-phase">
Phasen
</label>
<select
className="form-select"
id="cable-sizing-phase"
onChange={(event) => {
setPhase(Number(event.target.value) as 1 | 3);
resetResult();
}}
value={phase}
>
<option value={1}>1~ ({derivedPhase === 1 && circuit.voltage ? circuit.voltage : singlePhaseVoltageV} V)</option>
<option value={3}>3~ ({derivedPhase === 3 && circuit.voltage ? circuit.voltage : threePhaseVoltageV} V)</option>
</select>
{phase !== derivedPhase ? (
<div className="form-text text-warning">
Abweichend vom Stromkreis ({derivedPhase === 3 ? "3~" : "1~"}).
</div>
) : null}
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-power">
Leistung (aus Stromkreis)
</label>
<input
className="form-control"
disabled
id="cable-sizing-power"
value={`${formatNumber(circuit.circuitTotalPower, 2)} kW`}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-method">
Verlegeart
</label>
<select
autoFocus
className="form-select"
id="cable-sizing-method"
onChange={(event) => {
const method = event.target.value as LayingMethod;
setLayingMethod(method);
applyReferenceTemperature(method, insulation);
resetResult();
}}
value={layingMethod}
>
{LAYING_METHODS.map((method) => (
<option key={method} value={method}>
{LAYING_METHOD_LABELS[method]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-insulation">
Isolierstoff
</label>
<select
className="form-select"
id="cable-sizing-insulation"
onChange={(event) => {
const material = event.target.value as InsulationMaterial;
setInsulation(material);
if (!temperatureOptions(layingMethod, material).includes(ambientTemperatureC)) {
applyReferenceTemperature(layingMethod, material);
}
resetResult();
}}
value={insulation}
>
{INSULATION_MATERIALS.map((material) => (
<option key={material} value={material}>
{INSULATION_MATERIAL_LABELS[material]}
</option>
))}
</select>
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-material">
Leitermaterial
</label>
<select
className="form-select"
id="cable-sizing-material"
onChange={(event) => {
setConductorMaterial(event.target.value as ConductorMaterial);
resetResult();
}}
value={conductorMaterial}
>
{CONDUCTOR_MATERIALS.map((material) => (
<option key={material} value={material}>
{material === "copper" ? "Kupfer" : "Aluminium"}
</option>
))}
</select>
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-temperature">
{group === "air" ? "Umgebungstemperatur" : "Bodentemperatur"}
</label>
<select
className="form-select"
id="cable-sizing-temperature"
onChange={(event) => {
setAmbientTemperatureC(Number(event.target.value));
resetResult();
}}
value={ambientTemperatureC}
>
{availableTemperatures.map((temperature) => (
<option key={temperature} value={temperature}>
{temperature} °C
</option>
))}
</select>
</div>
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="cable-sizing-grouping">
Häufung (Stromkreise)
</label>
<input
className="form-control"
id="cable-sizing-grouping"
max={MAX_GROUPING_CIRCUITS}
min={1}
onChange={(event) => {
setGroupingCircuits(Math.max(1, Math.round(Number(event.target.value) || 1)));
resetResult();
}}
step={1}
type="number"
value={groupingCircuits}
/>
<div className="form-text">
Anzahl gemeinsam verlegter Stromkreise, einschließlich dieses.
</div>
</div>
{phase === 3 ? (
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-harmonics">
Oberschwingungen im Neutralleiter
</label>
<select
className="form-select"
id="cable-sizing-harmonics"
onChange={(event) => {
setHarmonicNeutralLoad(event.target.value as HarmonicNeutralLoad);
resetResult();
}}
value={harmonicNeutralLoad}
>
{HARMONIC_NEUTRAL_LOAD_OPTIONS.map((option) => (
<option key={option} value={option}>
{HARMONIC_NEUTRAL_LOAD_LABELS[option]}
</option>
))}
</select>
</div>
) : null}
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="cable-sizing-cosphi">
cos φ
</label>
<input
className="form-control"
id="cable-sizing-cosphi"
max={1}
min={0.1}
onChange={(event) => {
setCosPhi(Math.min(1, Math.max(0.1, Number(event.target.value) || 1)));
resetResult();
}}
step={0.01}
type="number"
value={cosPhi}
/>
<div className="form-text">
Aus den Gerätezeilen: {formatNumber(derivedCosPhi, 2)}
</div>
</div>
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="cable-sizing-voltage-drop">
Max. ΔU [%]
</label>
<input
className="form-control"
id="cable-sizing-voltage-drop"
max={10}
min={0.5}
onChange={(event) => {
setMaxVoltageDropPercent(Number(event.target.value) || 3);
resetResult();
}}
step={0.5}
type="number"
value={maxVoltageDropPercent}
/>
</div>
<div className="col-12">
<div className="form-text">
Schutzorgan:{" "}
{existingProtection
? `${existingProtection.kind === "breaker" ? "Leitungsschutzschalter" : "Sicherung gG"} ${existingProtection.ratedCurrentA} A (aus dem Stromkreis, In ≤ Iz und I₂ ≤ 1,45 · Iz)`
: "keines mit Überstromschutz hinterlegt – Bemessung nur nach Betriebsstrom"}
</div>
</div>
</div>
<button
className="btn btn-secondary mt-3"
disabled={calculating}
onClick={handleCalculate}
type="button"
>
{calculating ? "Berechnet …" : "Berechnen"}
</button>
{error ? <p className="text-danger mt-2">{error}</p> : null}
{result ? (
<div className="mt-3">
{result.supported ? (
<div className="row g-2">
<div className="col-6 col-md-3">
<div className="text-muted small">Betriebsstrom I<sub>B</sub></div>
<div>{formatNumber(result.operatingCurrentA, 1)} A</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Erforderliches I<sub>z</sub></div>
<div>
<strong>{formatNumber(result.designCurrentA, 1)} A</strong>
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Empfohlener Querschnitt</div>
<div>
<strong>
{result.recommendedCrossSectionMm2 != null
? `${formatNumber(result.recommendedCrossSectionMm2, result.recommendedCrossSectionMm2 % 1 ? 1 : 0)} mm²`
: "–"}
</strong>
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">ΔU bei Empfehlung</div>
<div>
{result.voltageDropAtRecommendedPercent != null
? `${formatNumber(result.voltageDropAtRecommendedPercent, 2)} %`
: "–"}
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Korrekturfaktor gesamt</div>
<div>{formatNumber(result.combinedDerationFactor, 2)}</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Temperatur / Häufung (gerechnet)</div>
<div>
{result.temperatureUsedC} °C ({formatNumber(result.temperatureFactor ?? 1, 2)}) /{" "}
{result.groupingUsed} ({formatNumber(result.groupingFactor ?? 1, 2)})
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Max. Länge für ΔU</div>
<div>
{recommendedRow?.maxLengthForVoltageDropM != null
? `${recommendedRow.maxLengthForVoltageDropM.toFixed(0)} m`
: "–"}
</div>
</div>
</div>
) : null}
{alerts.map((alert, index) => (
<div
className={`alert mt-2 mb-0 alert-${
alert.kind === "critical"
? "danger"
: alert.kind === "warn"
? "warning"
: alert.kind === "ok"
? "success"
: "info"
}`}
key={index}
>
{alert.text}
</div>
))}
</div>
) : null}
<hr className="my-3" />
<p className="text-muted small mb-2">
Kabeltyp und Querschnitt direkt eingeben oder die Empfehlung übernehmen.
Beides landet in denselben Feldern und kann vor dem Übernehmen angepasst
werden.
</p>
<div className="row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-manual-type">
Kabeltyp
</label>
<input
className="form-control"
id="cable-sizing-manual-type"
onChange={(event) => setManualCableType(event.target.value)}
placeholder="z. B. NYM-J 3x2,5"
value={manualCableType}
/>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="cable-sizing-manual-cross-section">
Querschnitt
</label>
<input
className="form-control"
id="cable-sizing-manual-cross-section"
onChange={(event) => setManualCrossSection(event.target.value)}
placeholder="z. B. 4 mm²"
value={manualCrossSection}
/>
</div>
{manualCrossSectionWarning ? (
<div className="col-12">
<div className="alert alert-warning mb-0">{manualCrossSectionWarning}</div>
</div>
) : null}
</div>
</FormModal>
);
}

View file

@ -5,6 +5,7 @@ import {
ArrowDown,
ArrowDown01,
ArrowUp,
Calculator,
Check,
Columns3,
FilterX,
@ -160,6 +161,7 @@ import type {
import { DistributionBoardComponentModal } from "./distribution-board-component-modal";
import { CircuitGroupModal } from "./circuit-group-modal";
import { CircuitProtectionModal } from "./circuit-protection-modal";
import { CableSizingModal } from "./cable-sizing-modal";
import {
circuitGroupCategoryLabels,
type CircuitGroupCategory,
@ -319,6 +321,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
useState<CircuitGroupEditorIntent | null>(null);
const [protectionEditorCircuit, setProtectionEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null);
const [cableSizingEditorCircuit, setCableSizingEditorCircuit] =
useState<CircuitTreeCircuitDto | null>(null);
const [projectDevices, setProjectDevices] = useState<ProjectDeviceDto[]>([]);
const [isProjectDeviceDrawerOpen, setIsProjectDeviceDrawerOpen] =
useState(false);
@ -1439,6 +1443,31 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
});
}
async function handleApplyCableSizing(patch: {
cableCrossSection: string;
cableType?: string;
cableLength?: number;
}) {
const circuit = cableSizingEditorCircuit;
if (!circuit) {
return;
}
await runCommand({
label: "Leitungsberechnung übernehmen",
redo: async () => {
const result = await updateCircuitById(
projectId,
getExpectedProjectRevision(),
circuit.id,
patch
);
applyProjectCommandResult(result);
setCableSizingEditorCircuit(null);
return null;
},
});
}
async function handleRedo() {
if (historyBusy || isSaving || !historyState || historyState.redoDepth === 0) {
return;
@ -3185,6 +3214,21 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onSave={handleSaveCircuitProtection}
/>
) : null}
{cableSizingEditorCircuit ? (
<CableSizingModal
circuit={cableSizingEditorCircuit}
circuitCategory={
data.sections.find(
(section) => section.id === cableSizingEditorCircuit.sectionId
)?.category
}
isSaving={isSaving}
onApply={handleApplyCableSizing}
onClose={() => setCableSizingEditorCircuit(null)}
singlePhaseVoltageV={data.singlePhaseVoltageV}
threePhaseVoltageV={data.threePhaseVoltageV}
/>
) : null}
<div className="editor-toolbar">
<IconButton
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
@ -4530,6 +4574,14 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
>
{row.circuit && row.rowType !== "deviceRow" ? (
<>
<IconButton
disabled={isSaving}
icon={Calculator}
label="Leitungsberechnung"
onClick={() => setCableSizingEditorCircuit(row.circuit!)}
tabIndex={-1}
title="Leitungsberechnung: Querschnitt ermitteln oder Kabel eintragen"
/>
<IconButton
disabled={isSaving}
icon={ListPlus}