"use client"; import { type FormEvent, useMemo, useState } from "react"; import { CONDUCTOR_MATERIALS, CROSS_SECTIONS_MM2, GROUPING_LABELS, GROUPING_OPTIONS, HARMONIC_NEUTRAL_LOAD_LABELS, HARMONIC_NEUTRAL_LOAD_OPTIONS, INSULATION_MATERIALS, INSULATION_MATERIAL_LABELS, LAYING_METHODS, LAYING_METHOD_GROUP, LAYING_METHOD_LABELS, PRACTICAL_MINIMUM_CROSS_SECTION_MM2, TEMPERATURE_FACTOR_AIR, TEMPERATURE_FACTOR_GROUND, 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 { FormModal } from "./form-modal"; interface CableSizingModalProps { circuit: CircuitTreeCircuitDto; circuitCategory?: CircuitCategory; isSaving: boolean; projectId: string; onClose: () => void; onApply: (patch: { cableCrossSection: string; cableType?: string; cableLength?: number; }) => Promise; } function temperatureOptions(method: LayingMethod) { const table = LAYING_METHOD_GROUP[method] === "air" ? TEMPERATURE_FACTOR_AIR : TEMPERATURE_FACTOR_GROUND; return Object.keys(table).map(Number).sort((a, b) => a - b); } // Extracts the first decimal number from a free-text cross-section entry // like "2.5 mm²" or "2,5". Returns null if nothing parseable is found - // used only for the manual-entry sanity check below, never persisted. 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; } export function CableSizingModal({ circuit, circuitCategory, isSaving, projectId, onClose, onApply, }: CableSizingModalProps) { const [layingMethod, setLayingMethod] = useState("C"); const [insulation, setInsulation] = useState("pvc"); const [conductorMaterial, setConductorMaterial] = useState("copper"); const [ambientTemperatureC, setAmbientTemperatureC] = useState(30); const [groupingCircuits, setGroupingCircuits] = useState(1); const [cosPhi, setCosPhi] = useState(1); const [maxVoltageDropPercent, setMaxVoltageDropPercent] = useState(3); const [harmonicNeutralLoad, setHarmonicNeutralLoad] = useState("none"); const [lengthM, setLengthM] = useState(circuit.cableLength ?? 0); const [phase, setPhase] = useState<1 | 3>(circuit.voltage === 400 ? 3 : 1); // Manual override: applying this never requires a calculation. Prefilled // from the circuit's current values so opening the modal on an already // specified cable doesn't lose that data. const [manualCableType, setManualCableType] = useState(circuit.cableType ?? ""); const [manualCrossSection, setManualCrossSection] = useState(circuit.cableCrossSection ?? ""); const [calculating, setCalculating] = useState(false); const [calculationId, setCalculationId] = useState(null); const [result, setResult] = useState(null); const [alerts, setAlerts] = useState([]); const [error, setError] = useState(null); const voltage = circuit.voltage ?? (phase === 1 ? 230 : 400); const input: CableSizingInput = useMemo( () => ({ phase, mode: "power", powerKw: circuit.circuitTotalPower, cosPhi, voltage, lengthM, layingMethod, conductorMaterial, insulation, ambientTemperatureC, groupingCircuits, maxVoltageDropPercent, harmonicNeutralLoad, existingProtectionRatedCurrentA: circuit.protectionDevice?.ratedCurrentA, circuitCategory, }), [ phase, circuit.circuitTotalPower, cosPhi, voltage, lengthM, layingMethod, conductorMaterial, insulation, ambientTemperatureC, groupingCircuits, maxVoltageDropPercent, harmonicNeutralLoad, circuit.protectionDevice?.ratedCurrentA, 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 `${parsed} mm² ist kein Standard-Querschnitt (${CROSS_SECTIONS_MM2.join(", ")}).`; } if ( result?.dataVerified && result.recommendedCrossSectionMm2 != null && parsed < result.recommendedCrossSectionMm2 ) { return `${parsed} mm² ist kleiner als die zuletzt berechnete Empfehlung (${result.recommendedCrossSectionMm2} mm²) - passt nicht zu Last${circuit.protectionDevice ? "/Sicherung" : ""} und Verlegeart.`; } const practicalMinimum = circuitCategory ? PRACTICAL_MINIMUM_CROSS_SECTION_MM2[circuitCategory] : undefined; if (practicalMinimum != null && parsed < practicalMinimum) { return `${parsed} mm² liegt unter dem Praxis-Mindestquerschnitt für 1-phasige Stromkreise (${practicalMinimum} mm²).`; } return null; }, [manualCrossSection, result, circuit.protectionDevice, circuitCategory]); function handleLayingMethodChange(method: LayingMethod) { setLayingMethod(method); const options = temperatureOptions(method); const reference = LAYING_METHOD_GROUP[method] === "air" ? 30 : 20; setAmbientTemperatureC(options.includes(reference) ? reference : options[0]); setResult(null); setCalculationId(null); } async function handleCalculate() { setCalculating(true); setError(null); try { const response = await calculateCableSizing(input, { projectId, circuitId: circuit.id, equipmentIdentifier: circuit.equipmentIdentifier, }); setResult(response.result); setAlerts(response.alerts); setCalculationId(response.calculationId); // Calculating fills the manual field as a suggestion, but that field // stays the single source of truth for what gets applied - the user // can still edit it by hand before submitting. if (response.result.dataVerified && response.result.recommendedCrossSectionMm2) { setManualCrossSection(`${response.result.recommendedCrossSectionMm2} mm²`); } } catch (err) { setError(err instanceof Error ? err.message : "Berechnung fehlgeschlagen"); } finally { setCalculating(false); } } async function handleSubmit(event: FormEvent) { event.preventDefault(); if (!manualCrossSection.trim()) return; await onApply({ cableCrossSection: manualCrossSection.trim(), cableType: manualCableType.trim() || undefined, cableLength: lengthM, }); } return (
setLengthM(Number(event.target.value))} type="number" value={lengthM} />
{phase === 3 && (
)}
setCosPhi(Number(event.target.value))} step={0.05} type="range" value={cosPhi} /> {cosPhi.toFixed(2)}
setMaxVoltageDropPercent(Number(event.target.value))} step={0.5} type="range" value={maxVoltageDropPercent} /> {maxVoltageDropPercent.toFixed(1)} %
{error &&

{error}

} {result && !result.dataVerified && (
Für Verlegeart {layingMethod} mit Isolierstoff {insulation.toUpperCase()} liegen keine geprüften Strombelastbarkeits-Tabellenwerte vor - keine Dimensionierung möglich. Bitte eine andere Verlegeart/Isolierstoff-Kombination wählen.
)} {result && result.dataVerified && (
Betriebsstrom Ib
{result.operatingCurrentA.toFixed(1)} A
Bemessungsstrom (für Querschnittswahl){circuit.protectionDevice ? " *" : ""}
{result.designCurrentA.toFixed(1)} A
Empfohlener Querschnitt
{result.recommendedCrossSectionMm2 ? `${result.recommendedCrossSectionMm2} mm²` : "-"}
ΔU bei Empfehlung
{result.voltageDropAtRecommendedPercent != null ? `${result.voltageDropAtRecommendedPercent.toFixed(2)} %` : "-"}
Korrekturfaktor
{result.combinedDerationFactor.toFixed(2)}
Max. Länge (ΔU-Grenze)
{result.rows.find((row) => row.recommended)?.maxLengthForVoltageDropM != null ? `${result.rows .find((row) => row.recommended)! .maxLengthForVoltageDropM!.toFixed(0)} m` : "-"}
{alerts.map((alert, index) => (
{alert.text}
))} {circuit.protectionDevice && (

* Bemessungsstrom = Maximum aus Betriebsstrom und vorhandener Sicherung ({circuit.protectionDevice.ratedCurrentA} A) - die Sicherung löst erst bei ihrem eigenen Bemessungsstrom aus, das Kabel muss also dafür ausgelegt sein, nicht nur für die tatsächliche Last.

)}
)}

Querschnitt manuell eingeben oder eine Berechnung oben übernehmen - beides schreibt in dasselbe Feld, du kannst es vor dem Übernehmen frei anpassen.

setManualCableType(event.target.value)} placeholder="z.B. NYM-J 3x1.5" value={manualCableType} />
setManualCrossSection(event.target.value)} placeholder="z.B. 4 mm²" value={manualCrossSection} />
{manualCrossSectionWarning && (
{manualCrossSectionWarning}
)}
); }