cable-sizing: max length, socket minimum, manual-entry warning, SVG icon

- Cross-section rows now include maxLengthForVoltageDropM, the inverse of
  the voltage-drop formula: the longest single run that stays within the
  requested max voltage drop at the given load/cosPhi/phase. Shown as a KPI
  and in the ok-summary alert.
- Added PRACTICAL_MINIMUM_CROSS_SECTION_MM2 (2.5 mm² for single_phase
  circuits) - a planning convention already named in this projects own
  docs/spec/06-future-sizing-and-calculations.md, not a thermal/
  voltage-drop requirement. Only ever raises a calculated recommendation,
  never lowers one that already needs more. circuitCategory is looked up
  from the circuits section and passed through from the editor.
- The manual cross-section field now warns (not blocks - manual override
  must stay possible per the same spec doc) when the typed value is not a
  standard cross-section, is smaller than the last calculation, or is
  below the practical minimum for single_phase circuits.
- Modal title now includes the circuit displayName next to the equipment
  identifier.
- Replaced the calculator emoji trigger icon with an inline SVG - the emoji
  did not render in at least one tested environment (missing font glyph),
  SVG has no such dependency.
This commit is contained in:
Grovy311 2026-08-07 18:13:27 +02:00
parent 68d2bad6e8
commit 287da1c0e7
6 changed files with 210 additions and 6 deletions

View file

@ -3,6 +3,7 @@
import { type FormEvent, useMemo, useState } from "react";
import {
CONDUCTOR_MATERIALS,
CROSS_SECTIONS_MM2,
GROUPING_LABELS,
GROUPING_OPTIONS,
HARMONIC_NEUTRAL_LOAD_LABELS,
@ -12,11 +13,13 @@ import {
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,
@ -28,6 +31,7 @@ import { FormModal } from "./form-modal";
interface CableSizingModalProps {
circuit: CircuitTreeCircuitDto;
circuitCategory?: CircuitCategory;
isSaving: boolean;
projectId: string;
onClose: () => void;
@ -44,8 +48,19 @@ function temperatureOptions(method: LayingMethod) {
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,
@ -92,6 +107,7 @@ export function CableSizingModal({
maxVoltageDropPercent,
harmonicNeutralLoad,
existingProtectionRatedCurrentA: circuit.protectionDevice?.ratedCurrentA,
circuitCategory,
}),
[
phase,
@ -107,9 +123,34 @@ export function CableSizingModal({
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);
@ -162,7 +203,9 @@ export function CableSizingModal({
onSubmit={handleSubmit}
submitDisabled={!manualCrossSection.trim()}
submitLabel="Übernehmen"
title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}`}
title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}${
circuit.displayName ? ` (${circuit.displayName})` : ""
}`}
>
<div className="row g-3">
<div className="col-12 col-md-4">
@ -401,6 +444,16 @@ export function CableSizingModal({
<div className="text-muted small">Korrekturfaktor</div>
<div>{result.combinedDerationFactor.toFixed(2)}</div>
</div>
<div className="col-6 col-md-3">
<div className="text-muted small">Max. Länge (ΔU-Grenze)</div>
<div>
{result.rows.find((row) => row.recommended)?.maxLengthForVoltageDropM != null
? `${result.rows
.find((row) => row.recommended)!
.maxLengthForVoltageDropM!.toFixed(0)} m`
: "-"}
</div>
</div>
</div>
{alerts.map((alert, index) => (
<div
@ -459,6 +512,11 @@ export function CableSizingModal({
value={manualCrossSection}
/>
</div>
{manualCrossSectionWarning && (
<div className="col-12">
<div className="alert alert-warning mb-0">{manualCrossSectionWarning}</div>
</div>
)}
</div>
</FormModal>
);