- calculateCableSizing now selects cross-section against designCurrentA = max(operatingCurrentA, existingProtectionRatedCurrentA) instead of the load current alone. A breaker only trips at its own rated current (In), so a cable sized for the actual load could overheat under sustained current below that threshold - the standard In <= Iz rule. Voltage-drop calculation still uses the real operating current, only the capacity check changed. Alerts and the ok-summary now name the breaker as the limiting factor when it is one. - Added a manual cable type/cross-section entry in the modal itself: running a calculation pre-fills these fields as a suggestion, but they are the single field that actually gets applied, and stay editable - this restores the direct manual-entry capability the modal replaced when it took over the cableSummary/cableCrossSection cell click. - Fixed calculator icon position from a trailing ::after (inconsistent position depending on cell text length, easy to miss on empty cells) to a fixed-position ::before, so it is always in the same place regardless of whether cable data is already filled in. - No new display for the existing protection device - it already has its own Schutz column in this app.
465 lines
16 KiB
TypeScript
465 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { type FormEvent, useMemo, useState } from "react";
|
|
import {
|
|
CONDUCTOR_MATERIALS,
|
|
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,
|
|
TEMPERATURE_FACTOR_AIR,
|
|
TEMPERATURE_FACTOR_GROUND,
|
|
type CableSizingAlert,
|
|
type CableSizingInput,
|
|
type CableSizingResult,
|
|
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;
|
|
isSaving: boolean;
|
|
projectId: string;
|
|
onClose: () => void;
|
|
onApply: (patch: {
|
|
cableCrossSection: string;
|
|
cableType?: string;
|
|
cableLength?: number;
|
|
}) => Promise<void>;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
export function CableSizingModal({
|
|
circuit,
|
|
isSaving,
|
|
projectId,
|
|
onClose,
|
|
onApply,
|
|
}: CableSizingModalProps) {
|
|
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(1);
|
|
const [maxVoltageDropPercent, setMaxVoltageDropPercent] = useState(3);
|
|
const [harmonicNeutralLoad, setHarmonicNeutralLoad] = useState<HarmonicNeutralLoad>("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<string | null>(null);
|
|
const [result, setResult] = useState<CableSizingResult | null>(null);
|
|
const [alerts, setAlerts] = useState<CableSizingAlert[]>([]);
|
|
const [error, setError] = useState<string | null>(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,
|
|
}),
|
|
[
|
|
phase,
|
|
circuit.circuitTotalPower,
|
|
cosPhi,
|
|
voltage,
|
|
lengthM,
|
|
layingMethod,
|
|
conductorMaterial,
|
|
insulation,
|
|
ambientTemperatureC,
|
|
groupingCircuits,
|
|
maxVoltageDropPercent,
|
|
harmonicNeutralLoad,
|
|
circuit.protectionDevice?.ratedCurrentA,
|
|
]
|
|
);
|
|
|
|
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<HTMLFormElement>) {
|
|
event.preventDefault();
|
|
if (!manualCrossSection.trim()) return;
|
|
await onApply({
|
|
cableCrossSection: manualCrossSection.trim(),
|
|
cableType: manualCableType.trim() || undefined,
|
|
cableLength: lengthM,
|
|
});
|
|
}
|
|
|
|
return (
|
|
<FormModal
|
|
description="Empfehlung auf Basis von Verlegeart, Isolierstoff, Temperatur und Häufung nach DIN VDE 0298-4, oder Kabeltyp/Querschnitt direkt manuell eintragen. Ersetzt keine vollständige Norm-Prüfung; nicht verifizierte Verlegearten liefern bewusst kein Ergebnis."
|
|
isSaving={isSaving}
|
|
onClose={onClose}
|
|
onSubmit={handleSubmit}
|
|
submitDisabled={!manualCrossSection.trim()}
|
|
submitLabel="Übernehmen"
|
|
title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}`}
|
|
>
|
|
<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))}
|
|
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)}
|
|
value={phase}
|
|
>
|
|
<option value={1}>1~ ({circuit.voltage ?? 230} V)</option>
|
|
<option value={3}>3~ ({circuit.voltage ?? 400} V)</option>
|
|
</select>
|
|
</div>
|
|
<div className="col-12 col-md-4">
|
|
<label className="form-label">Leistung (aus Stromkreis)</label>
|
|
<input
|
|
className="form-control"
|
|
disabled
|
|
value={`${circuit.circuitTotalPower.toFixed(2)} kW`}
|
|
/>
|
|
</div>
|
|
<div className="col-12 col-md-6">
|
|
<label className="form-label" htmlFor="cable-sizing-method">
|
|
Verlegeart (DIN VDE 0298-4)
|
|
</label>
|
|
<select
|
|
autoFocus
|
|
className="form-select"
|
|
id="cable-sizing-method"
|
|
onChange={(event) => handleLayingMethodChange(event.target.value as LayingMethod)}
|
|
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) => {
|
|
setInsulation(event.target.value as InsulationMaterial);
|
|
setResult(null);
|
|
}}
|
|
value={insulation}
|
|
>
|
|
{INSULATION_MATERIALS.map((material) => (
|
|
<option key={material} value={material}>
|
|
{INSULATION_MATERIAL_LABELS[material]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-12 col-md-6">
|
|
<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)}
|
|
value={conductorMaterial}
|
|
>
|
|
{CONDUCTOR_MATERIALS.map((material) => (
|
|
<option key={material} value={material}>
|
|
{material === "copper" ? "Kupfer" : "Aluminium"}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-12 col-md-6">
|
|
<label className="form-label" htmlFor="cable-sizing-temperature">
|
|
{LAYING_METHOD_GROUP[layingMethod] === "air"
|
|
? "Umgebungstemperatur"
|
|
: "Bodentemperatur"}
|
|
</label>
|
|
<select
|
|
className="form-select"
|
|
id="cable-sizing-temperature"
|
|
onChange={(event) => setAmbientTemperatureC(Number(event.target.value))}
|
|
value={ambientTemperatureC}
|
|
>
|
|
{temperatureOptions(layingMethod).map((temp) => (
|
|
<option key={temp} value={temp}>
|
|
{temp}°C
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-12 col-md-6">
|
|
<label className="form-label" htmlFor="cable-sizing-grouping">
|
|
Häufung (Stromkreise nebeneinander)
|
|
</label>
|
|
<select
|
|
className="form-select"
|
|
id="cable-sizing-grouping"
|
|
onChange={(event) => setGroupingCircuits(Number(event.target.value))}
|
|
value={groupingCircuits}
|
|
>
|
|
{GROUPING_OPTIONS.map((grouping) => (
|
|
<option key={grouping} value={grouping}>
|
|
{GROUPING_LABELS[grouping]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
{phase === 3 && (
|
|
<div className="col-12 col-md-6">
|
|
<label className="form-label" htmlFor="cable-sizing-harmonics">
|
|
Oberschwingungsanteil Neutralleiter
|
|
</label>
|
|
<select
|
|
className="form-select"
|
|
id="cable-sizing-harmonics"
|
|
onChange={(event) =>
|
|
setHarmonicNeutralLoad(event.target.value as HarmonicNeutralLoad)
|
|
}
|
|
value={harmonicNeutralLoad}
|
|
>
|
|
{HARMONIC_NEUTRAL_LOAD_OPTIONS.map((option) => (
|
|
<option key={option} value={option}>
|
|
{HARMONIC_NEUTRAL_LOAD_LABELS[option]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
<div className="col-12 col-md-6">
|
|
<label className="form-label" htmlFor="cable-sizing-cosphi">
|
|
cos φ
|
|
</label>
|
|
<input
|
|
className="form-range"
|
|
id="cable-sizing-cosphi"
|
|
max={1}
|
|
min={0.6}
|
|
onChange={(event) => setCosPhi(Number(event.target.value))}
|
|
step={0.05}
|
|
type="range"
|
|
value={cosPhi}
|
|
/>
|
|
<span>{cosPhi.toFixed(2)}</span>
|
|
</div>
|
|
<div className="col-12 col-md-6">
|
|
<label className="form-label" htmlFor="cable-sizing-voltage-drop">
|
|
Max. zulässiger Spannungsfall
|
|
</label>
|
|
<input
|
|
className="form-range"
|
|
id="cable-sizing-voltage-drop"
|
|
max={5}
|
|
min={1}
|
|
onChange={(event) => setMaxVoltageDropPercent(Number(event.target.value))}
|
|
step={0.5}
|
|
type="range"
|
|
value={maxVoltageDropPercent}
|
|
/>
|
|
<span>{maxVoltageDropPercent.toFixed(1)} %</span>
|
|
</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>}
|
|
|
|
{result && !result.dataVerified && (
|
|
<div className="alert alert-danger mt-3">
|
|
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.
|
|
</div>
|
|
)}
|
|
|
|
{result && result.dataVerified && (
|
|
<div className="mt-3">
|
|
<div className="row g-2">
|
|
<div className="col-6 col-md-3">
|
|
<div className="text-muted small">Betriebsstrom Ib</div>
|
|
<div>{result.operatingCurrentA.toFixed(1)} A</div>
|
|
</div>
|
|
<div className="col-6 col-md-3">
|
|
<div className="text-muted small">
|
|
Bemessungsstrom (für Querschnittswahl){circuit.protectionDevice ? " *" : ""}
|
|
</div>
|
|
<div>
|
|
<strong>{result.designCurrentA.toFixed(1)} A</strong>
|
|
</div>
|
|
</div>
|
|
<div className="col-6 col-md-3">
|
|
<div className="text-muted small">Empfohlener Querschnitt</div>
|
|
<div>
|
|
<strong>
|
|
{result.recommendedCrossSectionMm2
|
|
? `${result.recommendedCrossSectionMm2} mm²`
|
|
: "-"}
|
|
</strong>
|
|
</div>
|
|
</div>
|
|
<div className="col-6 col-md-3">
|
|
<div className="text-muted small">ΔU bei Empfehlung</div>
|
|
<div>
|
|
{result.voltageDropAtRecommendedPercent != null
|
|
? `${result.voltageDropAtRecommendedPercent.toFixed(2)} %`
|
|
: "-"}
|
|
</div>
|
|
</div>
|
|
<div className="col-6 col-md-3">
|
|
<div className="text-muted small">Korrekturfaktor</div>
|
|
<div>{result.combinedDerationFactor.toFixed(2)}</div>
|
|
</div>
|
|
</div>
|
|
{alerts.map((alert, index) => (
|
|
<div
|
|
className={`alert mt-2 alert-${
|
|
alert.kind === "critical"
|
|
? "danger"
|
|
: alert.kind === "warn"
|
|
? "warning"
|
|
: alert.kind === "ok"
|
|
? "success"
|
|
: "info"
|
|
}`}
|
|
key={index}
|
|
>
|
|
{alert.text}
|
|
</div>
|
|
))}
|
|
{circuit.protectionDevice && (
|
|
<p className="text-muted small mt-2 mb-0">
|
|
* 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.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<hr className="my-3" />
|
|
<p className="text-muted small mb-2">
|
|
Querschnitt manuell eingeben oder eine Berechnung oben übernehmen - beides schreibt in
|
|
dasselbe Feld, du kannst es vor dem Übernehmen frei anpassen.
|
|
</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 3x1.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>
|
|
</div>
|
|
</FormModal>
|
|
);
|
|
}
|