Proposal: cable-sizing module (on-demand DIN VDE 0298-4 cross-section calculator) #1
5 changed files with 145 additions and 29 deletions
|
|
@ -1227,10 +1227,17 @@ a.kpi:hover {
|
||||||
box-shadow: inset 0 0 0 1px var(--color-primary);
|
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .cell-cable-sizing-trigger::after {
|
.tree-grid .cell-cable-sizing-trigger {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-grid .cell-cable-sizing-trigger::before {
|
||||||
content: "🧮";
|
content: "🧮";
|
||||||
|
position: absolute;
|
||||||
|
left: 0.2em;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
opacity: 0.7;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree-grid .device-drag-handle {
|
.tree-grid .device-drag-handle {
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,13 @@ export interface CableSizingResult {
|
||||||
* combination - every field below is null/empty, never a guess. */
|
* combination - every field below is null/empty, never a guess. */
|
||||||
dataVerified: boolean;
|
dataVerified: boolean;
|
||||||
operatingCurrentA: number;
|
operatingCurrentA: number;
|
||||||
|
/** max(operatingCurrentA, existingProtectionRatedCurrentA). The cable
|
||||||
|
* capacity (Iz) must cover the protective device's rated current (In),
|
||||||
|
* not just the actual load current - a breaker only trips at In, so a
|
||||||
|
* cable sized for Ib alone could overheat under sustained load below the
|
||||||
|
* trip threshold. This is the value cross-section selection actually
|
||||||
|
* uses; operatingCurrentA is kept for display/voltage-drop only. */
|
||||||
|
designCurrentA: number;
|
||||||
crossSectionByCapacityMm2: number | null;
|
crossSectionByCapacityMm2: number | null;
|
||||||
crossSectionByVoltageDropMm2: number | null;
|
crossSectionByVoltageDropMm2: number | null;
|
||||||
recommendedCrossSectionMm2: number | null;
|
recommendedCrossSectionMm2: number | null;
|
||||||
|
|
@ -282,10 +289,16 @@ function operatingCurrentA(input: CableSizingInput): number {
|
||||||
export function calculateCableSizing(input: CableSizingInput): CableSizingResult {
|
export function calculateCableSizing(input: CableSizingInput): CableSizingResult {
|
||||||
const ib = operatingCurrentA(input);
|
const ib = operatingCurrentA(input);
|
||||||
|
|
||||||
|
const designCurrentA =
|
||||||
|
input.existingProtectionRatedCurrentA != null
|
||||||
|
? Math.max(ib, input.existingProtectionRatedCurrentA)
|
||||||
|
: ib;
|
||||||
|
|
||||||
if (!isCableSizingDataVerified(input.layingMethod, input.insulation)) {
|
if (!isCableSizingDataVerified(input.layingMethod, input.insulation)) {
|
||||||
return {
|
return {
|
||||||
dataVerified: false,
|
dataVerified: false,
|
||||||
operatingCurrentA: ib,
|
operatingCurrentA: ib,
|
||||||
|
designCurrentA,
|
||||||
crossSectionByCapacityMm2: null,
|
crossSectionByCapacityMm2: null,
|
||||||
crossSectionByVoltageDropMm2: null,
|
crossSectionByVoltageDropMm2: null,
|
||||||
recommendedCrossSectionMm2: null,
|
recommendedCrossSectionMm2: null,
|
||||||
|
|
@ -328,7 +341,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult
|
||||||
);
|
);
|
||||||
|
|
||||||
const indexByCapacity = correctedCurrents.findIndex(
|
const indexByCapacity = correctedCurrents.findIndex(
|
||||||
(value) => value != null && value >= ib
|
(value) => value != null && value >= designCurrentA
|
||||||
);
|
);
|
||||||
const indexByVoltageDrop = voltageDrops.findIndex(
|
const indexByVoltageDrop = voltageDrops.findIndex(
|
||||||
(value) => value <= input.maxVoltageDropPercent
|
(value) => value <= input.maxVoltageDropPercent
|
||||||
|
|
@ -341,7 +354,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult
|
||||||
const rows: CrossSectionRow[] = CROSS_SECTIONS_MM2.map((crossSection, i) => {
|
const rows: CrossSectionRow[] = CROSS_SECTIONS_MM2.map((crossSection, i) => {
|
||||||
const rated = ratedCurrents[i];
|
const rated = ratedCurrents[i];
|
||||||
const corrected = correctedCurrents[i];
|
const corrected = correctedCurrents[i];
|
||||||
const currentSufficient = corrected == null ? null : corrected >= ib;
|
const currentSufficient = corrected == null ? null : corrected >= designCurrentA;
|
||||||
const voltageDropSufficient = voltageDrops[i] <= input.maxVoltageDropPercent;
|
const voltageDropSufficient = voltageDrops[i] <= input.maxVoltageDropPercent;
|
||||||
return {
|
return {
|
||||||
crossSectionMm2: crossSection,
|
crossSectionMm2: crossSection,
|
||||||
|
|
@ -368,6 +381,7 @@ export function calculateCableSizing(input: CableSizingInput): CableSizingResult
|
||||||
return {
|
return {
|
||||||
dataVerified: true,
|
dataVerified: true,
|
||||||
operatingCurrentA: ib,
|
operatingCurrentA: ib,
|
||||||
|
designCurrentA,
|
||||||
crossSectionByCapacityMm2:
|
crossSectionByCapacityMm2:
|
||||||
indexByCapacity >= 0 ? CROSS_SECTIONS_MM2[indexByCapacity] : null,
|
indexByCapacity >= 0 ? CROSS_SECTIONS_MM2[indexByCapacity] : null,
|
||||||
crossSectionByVoltageDropMm2:
|
crossSectionByVoltageDropMm2:
|
||||||
|
|
@ -407,9 +421,14 @@ export function buildCableSizingAlerts(
|
||||||
const recommended = result.recommendedCrossSectionMm2;
|
const recommended = result.recommendedCrossSectionMm2;
|
||||||
|
|
||||||
if (byCapacity == null) {
|
if (byCapacity == null) {
|
||||||
|
const dueToBreaker =
|
||||||
|
input.existingProtectionRatedCurrentA != null &&
|
||||||
|
input.existingProtectionRatedCurrentA > result.operatingCurrentA;
|
||||||
alerts.push({
|
alerts.push({
|
||||||
kind: "critical",
|
kind: "critical",
|
||||||
text: `Betriebsstrom ${result.operatingCurrentA.toFixed(1)} A übersteigt die Belastbarkeit aller Standardquerschnitte bei Verlegeart ${input.layingMethod}.`,
|
text: dueToBreaker
|
||||||
|
? `Vorhandene Sicherung (${input.existingProtectionRatedCurrentA} A) übersteigt die Belastbarkeit aller Standardquerschnitte bei Verlegeart ${input.layingMethod} - nicht der Betriebsstrom (${result.operatingCurrentA.toFixed(1)} A). Größere Verlegeart oder kleinere Sicherung prüfen.`
|
||||||
|
: `Betriebsstrom ${result.operatingCurrentA.toFixed(1)} A übersteigt die Belastbarkeit aller Standardquerschnitte bei Verlegeart ${input.layingMethod}.`,
|
||||||
});
|
});
|
||||||
} else if (byDrop != null && byDrop > byCapacity) {
|
} else if (byDrop != null && byDrop > byCapacity) {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
|
|
@ -435,16 +454,23 @@ export function buildCableSizingAlerts(
|
||||||
text: `Korrekturfaktor angewendet: ${result.combinedDerationFactor.toFixed(2)} (Temperatur × Häufung${result.harmonicReductionApplied ? " × Oberschwingungen" : ""}).`,
|
text: `Korrekturfaktor angewendet: ${result.combinedDerationFactor.toFixed(2)} (Temperatur × Häufung${result.harmonicReductionApplied ? " × Oberschwingungen" : ""}).`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (result.protectionCoordination && !result.protectionCoordination.coordinated) {
|
if (result.protectionCoordination) {
|
||||||
alerts.push({
|
alerts.push(
|
||||||
|
result.protectionCoordination.coordinated
|
||||||
|
? {
|
||||||
|
kind: "info",
|
||||||
|
text: `Vorhandene Sicherung (${result.protectionCoordination.ratedCurrentA} A) ist bei der Dimensionierung berücksichtigt (In ≤ Iz, vereinfachte Prüfung, ersetzt keine vollständige Überlast-/Kurzschlussprüfung).`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
kind: "warn",
|
kind: "warn",
|
||||||
text: `Vorhandener Schutz (${result.protectionCoordination.ratedCurrentA} A) übersteigt die Belastbarkeit des empfohlenen Querschnitts - Koordination prüfen (vereinfachte Prüfung, ersetzt keine vollständige Überlast-/Kurzschlussprüfung).`,
|
text: `Vorhandene Sicherung (${result.protectionCoordination.ratedCurrentA} A) übersteigt die Belastbarkeit des empfohlenen Querschnitts - Koordination prüfen.`,
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (alerts.length === 0 && recommended != null) {
|
if (alerts.length === 0 && recommended != null) {
|
||||||
alerts.push({
|
alerts.push({
|
||||||
kind: "ok",
|
kind: "ok",
|
||||||
text: `${recommended} mm² (${input.conductorMaterial === "aluminum" ? "Alu" : "Cu"}) deckt bei ${input.lengthM} m Länge sowohl Belastbarkeit (${result.operatingCurrentA.toFixed(1)} A) als auch Spannungsfall (≤ ${input.maxVoltageDropPercent}%) ab.`,
|
text: `${recommended} mm² (${input.conductorMaterial === "aluminum" ? "Alu" : "Cu"}) deckt bei ${input.lengthM} m Länge sowohl Belastbarkeit (${result.designCurrentA.toFixed(1)} A${input.existingProtectionRatedCurrentA != null ? ", inkl. vorhandener Sicherung" : ""}) als auch Spannungsfall (≤ ${input.maxVoltageDropPercent}%) ab.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return alerts;
|
return alerts;
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,11 @@ interface CableSizingModalProps {
|
||||||
isSaving: boolean;
|
isSaving: boolean;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onApply: (patch: { cableCrossSection: string; cableLength?: number }) => Promise<void>;
|
onApply: (patch: {
|
||||||
|
cableCrossSection: string;
|
||||||
|
cableType?: string;
|
||||||
|
cableLength?: number;
|
||||||
|
}) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function temperatureOptions(method: LayingMethod) {
|
function temperatureOptions(method: LayingMethod) {
|
||||||
|
|
@ -58,6 +62,12 @@ export function CableSizingModal({
|
||||||
const [lengthM, setLengthM] = useState(circuit.cableLength ?? 0);
|
const [lengthM, setLengthM] = useState(circuit.cableLength ?? 0);
|
||||||
const [phase, setPhase] = useState<1 | 3>(circuit.voltage === 400 ? 3 : 1);
|
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 [calculating, setCalculating] = useState(false);
|
||||||
const [calculationId, setCalculationId] = useState<string | null>(null);
|
const [calculationId, setCalculationId] = useState<string | null>(null);
|
||||||
const [result, setResult] = useState<CableSizingResult | null>(null);
|
const [result, setResult] = useState<CableSizingResult | null>(null);
|
||||||
|
|
@ -121,6 +131,12 @@ export function CableSizingModal({
|
||||||
setResult(response.result);
|
setResult(response.result);
|
||||||
setAlerts(response.alerts);
|
setAlerts(response.alerts);
|
||||||
setCalculationId(response.calculationId);
|
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) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Berechnung fehlgeschlagen");
|
setError(err instanceof Error ? err.message : "Berechnung fehlgeschlagen");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -130,21 +146,22 @@ export function CableSizingModal({
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!result?.recommendedCrossSectionMm2) return;
|
if (!manualCrossSection.trim()) return;
|
||||||
await onApply({
|
await onApply({
|
||||||
cableCrossSection: `${result.recommendedCrossSectionMm2} mm²`,
|
cableCrossSection: manualCrossSection.trim(),
|
||||||
|
cableType: manualCableType.trim() || undefined,
|
||||||
cableLength: lengthM,
|
cableLength: lengthM,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormModal
|
<FormModal
|
||||||
description="Empfehlung auf Basis von Verlegeart, Isolierstoff, Temperatur und Häufung nach DIN VDE 0298-4. Ersetzt keine vollständige Norm-Prüfung; nicht verifizierte Verlegearten liefern bewusst kein Ergebnis. Du kannst das Ergebnis vor der Übernahme frei prüfen."
|
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}
|
isSaving={isSaving}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
submitDisabled={!result?.dataVerified || !result.recommendedCrossSectionMm2}
|
submitDisabled={!manualCrossSection.trim()}
|
||||||
submitLabel="Empfehlung übernehmen"
|
submitLabel="Übernehmen"
|
||||||
title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}`}
|
title={`Kabel dimensionieren - ${circuit.equipmentIdentifier}`}
|
||||||
>
|
>
|
||||||
<div className="row g-3">
|
<div className="row g-3">
|
||||||
|
|
@ -183,7 +200,6 @@ export function CableSizingModal({
|
||||||
value={`${circuit.circuitTotalPower.toFixed(2)} kW`}
|
value={`${circuit.circuitTotalPower.toFixed(2)} kW`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-12 col-md-6">
|
<div className="col-12 col-md-6">
|
||||||
<label className="form-label" htmlFor="cable-sizing-method">
|
<label className="form-label" htmlFor="cable-sizing-method">
|
||||||
Verlegeart (DIN VDE 0298-4)
|
Verlegeart (DIN VDE 0298-4)
|
||||||
|
|
@ -355,6 +371,14 @@ export function CableSizingModal({
|
||||||
<div className="text-muted small">Betriebsstrom Ib</div>
|
<div className="text-muted small">Betriebsstrom Ib</div>
|
||||||
<div>{result.operatingCurrentA.toFixed(1)} A</div>
|
<div>{result.operatingCurrentA.toFixed(1)} A</div>
|
||||||
</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="col-6 col-md-3">
|
||||||
<div className="text-muted small">Empfohlener Querschnitt</div>
|
<div className="text-muted small">Empfohlener Querschnitt</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -394,14 +418,48 @@ export function CableSizingModal({
|
||||||
{alert.text}
|
{alert.text}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
{circuit.protectionDevice && (
|
||||||
)}
|
<p className="text-muted small mt-2 mb-0">
|
||||||
{calculationId ? null : (
|
* Bemessungsstrom = Maximum aus Betriebsstrom und vorhandener Sicherung
|
||||||
<p className="text-muted small mt-2">
|
({circuit.protectionDevice.ratedCurrentA} A) - die Sicherung löst erst bei ihrem
|
||||||
Zuerst berechnen, dann prüfen und ggf. übernehmen - nichts wird automatisch
|
eigenen Bemessungsstrom aus, das Kabel muss also dafür ausgelegt sein, nicht nur
|
||||||
geschrieben.
|
für die tatsächliche Last.
|
||||||
</p>
|
</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>
|
</FormModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1403,6 +1403,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
|
||||||
|
|
||||||
async function handleApplyCableSizing(patch: {
|
async function handleApplyCableSizing(patch: {
|
||||||
cableCrossSection: string;
|
cableCrossSection: string;
|
||||||
|
cableType?: string;
|
||||||
cableLength?: number;
|
cableLength?: number;
|
||||||
}) {
|
}) {
|
||||||
const circuit = cableSizingEditorCircuit;
|
const circuit = cableSizingEditorCircuit;
|
||||||
|
|
|
||||||
|
|
@ -84,19 +84,43 @@ describe("calculateCableSizing", () => {
|
||||||
assert.equal(singlePhaseWithHarmonics.harmonicReductionApplied, false);
|
assert.equal(singlePhaseWithHarmonics.harmonicReductionApplied, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("flags a simplified protection coordination mismatch", () => {
|
it("uses max(operatingCurrentA, existingProtectionRatedCurrentA) as the design current for cross-section selection", () => {
|
||||||
|
// Load alone (21.7 A) would recommend 4 mm² (see the reference case
|
||||||
|
// above); a 32 A breaker on the same circuit must still be covered by
|
||||||
|
// the cable (In <= Iz), so the recommendation should grow accordingly.
|
||||||
|
const withoutBreaker = calculateCableSizing(BASE_INPUT);
|
||||||
|
const withBreaker = calculateCableSizing({
|
||||||
|
...BASE_INPUT,
|
||||||
|
existingProtectionRatedCurrentA: 32,
|
||||||
|
});
|
||||||
|
assert.equal(withoutBreaker.designCurrentA, withoutBreaker.operatingCurrentA);
|
||||||
|
assert.equal(withBreaker.designCurrentA, 32);
|
||||||
|
assert.ok(
|
||||||
|
(withBreaker.recommendedCrossSectionMm2 ?? 0) >=
|
||||||
|
(withoutBreaker.recommendedCrossSectionMm2 ?? 0)
|
||||||
|
);
|
||||||
|
assert.ok(withBreaker.protectionCoordination?.coordinated);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an oversized breaker as the limiting factor when no cross-section can cover it", () => {
|
||||||
const result = calculateCableSizing({
|
const result = calculateCableSizing({
|
||||||
...BASE_INPUT,
|
...BASE_INPUT,
|
||||||
existingProtectionRatedCurrentA: 1000,
|
existingProtectionRatedCurrentA: 1000,
|
||||||
});
|
});
|
||||||
assert.ok(result.protectionCoordination);
|
assert.equal(result.designCurrentA, 1000);
|
||||||
assert.equal(result.protectionCoordination?.coordinated, false);
|
assert.equal(result.recommendedCrossSectionMm2, null);
|
||||||
|
// No cross-section satisfies the design current at all, so there is no
|
||||||
|
// "recommended but under-protected" case to flag - protectionCoordination
|
||||||
|
// is only meaningful once a recommendation exists.
|
||||||
|
assert.equal(result.protectionCoordination, null);
|
||||||
|
|
||||||
const alerts = buildCableSizingAlerts(
|
const alerts = buildCableSizingAlerts(
|
||||||
{ ...BASE_INPUT, existingProtectionRatedCurrentA: 1000 },
|
{ ...BASE_INPUT, existingProtectionRatedCurrentA: 1000 },
|
||||||
result
|
result
|
||||||
);
|
);
|
||||||
assert.ok(alerts.some((alert) => alert.text.includes("Koordination prüfen")));
|
assert.equal(alerts.length, 1);
|
||||||
|
assert.equal(alerts[0].kind, "critical");
|
||||||
|
assert.ok(alerts[0].text.includes("Vorhandene Sicherung"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue