629 lines
18 KiB
TypeScript
629 lines
18 KiB
TypeScript
import type { CircuitTreeCircuitDto, CircuitTreeDeviceRowDto } from "../types.js";
|
|
import type { CircuitUpdatePatch } from "../../domain/models/circuit-project-command.model.js";
|
|
import type { CircuitDeviceRowUpdatePatch } from "../../domain/models/circuit-device-row-project-command.model.js";
|
|
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device";
|
|
|
|
export type CellKey =
|
|
| "equipmentIdentifier"
|
|
| "displayName"
|
|
| "quantity"
|
|
| "powerPerUnit"
|
|
| "simultaneityFactor"
|
|
| "rowTotalPower"
|
|
| "circuitTotalPower"
|
|
| "protectionSummary"
|
|
| "cableSummary"
|
|
| "roomSummary"
|
|
| "remark"
|
|
| "technicalName"
|
|
| "connectionKind"
|
|
| "phaseType"
|
|
| "costGroup"
|
|
| "category"
|
|
| "level"
|
|
| "roomNumberSnapshot"
|
|
| "roomNameSnapshot"
|
|
| "cosPhi"
|
|
| "protectionType"
|
|
| "protectionRatedCurrent"
|
|
| "protectionCharacteristic"
|
|
| "cableType"
|
|
| "cableCrossSection"
|
|
| "cableLength"
|
|
| "rcdAssignment"
|
|
| "terminalDesignation"
|
|
| "voltage"
|
|
| "controlRequirement"
|
|
| "status"
|
|
| "isReserve";
|
|
|
|
export type RowType =
|
|
| "headerComponent"
|
|
| "section"
|
|
| "groupComponent"
|
|
| "circuitCompact"
|
|
| "circuitSummary"
|
|
| "deviceRow"
|
|
| "downstreamBoardLoad"
|
|
| "reserveCircuit"
|
|
| "placeholder"
|
|
| "footerComponent";
|
|
export type CellKind = "circuitField" | "deviceField" | "computed" | "readonly";
|
|
export type GridValue = string | number | boolean | undefined;
|
|
|
|
export interface ColumnDef {
|
|
key: CellKey;
|
|
label: string;
|
|
fullLabel?: string;
|
|
numeric?: boolean;
|
|
defaultVisible?: boolean;
|
|
locked?: boolean;
|
|
}
|
|
|
|
// BMK stays locked as the first column because circuit identity and circuit drag
|
|
// handles depend on an always-visible equipment identifier.
|
|
export const allColumns: ColumnDef[] = [
|
|
{
|
|
key: "equipmentIdentifier",
|
|
label: "SK-Nr.",
|
|
fullLabel: "Stromkreisnummer / Betriebsmittelkennzeichen",
|
|
defaultVisible: true,
|
|
locked: true,
|
|
},
|
|
{ key: "displayName", label: "Anzeigename", defaultVisible: true },
|
|
{ key: "quantity", label: "Anzahl", numeric: true, defaultVisible: true },
|
|
{
|
|
key: "powerPerUnit",
|
|
label: "P/Stk. [kW]",
|
|
fullLabel: "Leistung je Gerät [kW]",
|
|
numeric: true,
|
|
defaultVisible: true,
|
|
},
|
|
{
|
|
key: "simultaneityFactor",
|
|
label: "GZF",
|
|
fullLabel: "Gleichzeitigkeitsfaktor",
|
|
numeric: true,
|
|
defaultVisible: true,
|
|
},
|
|
{
|
|
key: "rowTotalPower",
|
|
label: "Ges.-Leistung [kW]",
|
|
fullLabel: "Gesamtleistung [kW]",
|
|
numeric: true,
|
|
defaultVisible: true,
|
|
},
|
|
{ key: "protectionSummary", label: "Schutz", defaultVisible: true },
|
|
{ key: "cableSummary", label: "Kabel", defaultVisible: true },
|
|
{ key: "roomSummary", label: "Raum", defaultVisible: true },
|
|
{ key: "remark", label: "Bemerkung", defaultVisible: true },
|
|
{ key: "technicalName", label: "Techn. Name", fullLabel: "Technischer Name" },
|
|
{ key: "connectionKind", label: "Anschluss", fullLabel: "Anschlussart" },
|
|
{ key: "phaseType", label: "Phasenart" },
|
|
{ key: "costGroup", label: "KG", fullLabel: "Kostengruppe" },
|
|
{ key: "category", label: "Kategorie" },
|
|
{ key: "level", label: "Ebene" },
|
|
{ key: "roomNumberSnapshot", label: "Raum-Nr.", fullLabel: "Raumnummer" },
|
|
{ key: "roomNameSnapshot", label: "Raumname" },
|
|
{ key: "cosPhi", label: "cos φ", numeric: true },
|
|
{ key: "protectionType", label: "Schutzart" },
|
|
{
|
|
key: "protectionRatedCurrent",
|
|
label: "Bem.-Strom",
|
|
fullLabel: "Bemessungsstrom",
|
|
numeric: true,
|
|
},
|
|
{ key: "protectionCharacteristic", label: "Charakteristik" },
|
|
{ key: "cableType", label: "Kabeltyp" },
|
|
{ key: "cableCrossSection", label: "Querschnitt", fullLabel: "Kabelquerschnitt" },
|
|
{ key: "cableLength", label: "Kabellänge", numeric: true },
|
|
{ key: "rcdAssignment", label: "RCD-Zuordnung" },
|
|
{ key: "terminalDesignation", label: "Klemme", fullLabel: "Klemmenbezeichnung" },
|
|
{ key: "voltage", label: "Spannung", numeric: true },
|
|
{ key: "controlRequirement", label: "Steuerung", fullLabel: "Steuerungsanforderung" },
|
|
{ key: "status", label: "Status" },
|
|
{ key: "isReserve", label: "Reserve" },
|
|
];
|
|
|
|
export const defaultVisibleColumnKeys = allColumns
|
|
.filter((column) => column.defaultVisible)
|
|
.map((column) => column.key);
|
|
|
|
const projectColumnLayoutStoragePrefix =
|
|
"circuitTreeEditor.columnLayout.v3";
|
|
|
|
export interface CircuitColumnLayout {
|
|
order: CellKey[];
|
|
visible: CellKey[];
|
|
}
|
|
|
|
const circuitSectionLabels: Record<string, string> = {
|
|
lighting: "Licht",
|
|
single_phase: "Einphasig",
|
|
three_phase: "Dreiphasig",
|
|
unassigned: "Nicht zugeordnet",
|
|
};
|
|
|
|
export function getCircuitSectionLabel(
|
|
section: {
|
|
key: string;
|
|
displayName: string;
|
|
category?: string;
|
|
}
|
|
): string {
|
|
if (section.category) {
|
|
return section.displayName;
|
|
}
|
|
return circuitSectionLabels[section.key] ?? section.displayName;
|
|
}
|
|
|
|
export function getProjectColumnLayoutStorageKey(
|
|
projectId: string
|
|
): string {
|
|
return `${projectColumnLayoutStoragePrefix}.${projectId}`;
|
|
}
|
|
|
|
export function normalizeColumnOrder(keys: CellKey[]): CellKey[] {
|
|
const unique = [...new Set(keys)];
|
|
const allKeys = allColumns.map((column) => column.key);
|
|
const merged = [
|
|
...unique,
|
|
...allKeys.filter((key) => !unique.includes(key)),
|
|
];
|
|
return [
|
|
"equipmentIdentifier",
|
|
...merged.filter((key) => key !== "equipmentIdentifier"),
|
|
];
|
|
}
|
|
|
|
export function parseStoredColumnLayout(
|
|
serialized: string | null
|
|
): CircuitColumnLayout | null {
|
|
if (!serialized) {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = JSON.parse(serialized) as {
|
|
order?: unknown;
|
|
visible?: unknown;
|
|
};
|
|
if (
|
|
!Array.isArray(parsed.order) ||
|
|
!Array.isArray(parsed.visible)
|
|
) {
|
|
return null;
|
|
}
|
|
const validKeys = new Set(
|
|
allColumns.map((column) => column.key)
|
|
);
|
|
const order = parsed.order.filter(
|
|
(key): key is CellKey =>
|
|
typeof key === "string" &&
|
|
validKeys.has(key as CellKey)
|
|
);
|
|
const visible = [
|
|
...new Set(
|
|
parsed.visible.filter(
|
|
(key): key is CellKey =>
|
|
typeof key === "string" &&
|
|
validKeys.has(key as CellKey)
|
|
)
|
|
),
|
|
];
|
|
if (
|
|
!order.length ||
|
|
!visible.length ||
|
|
!visible.includes("equipmentIdentifier")
|
|
) {
|
|
return null;
|
|
}
|
|
return {
|
|
order: normalizeColumnOrder(order),
|
|
visible,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const deviceOnlyColumns = new Set<CellKey>([
|
|
"quantity",
|
|
"powerPerUnit",
|
|
"simultaneityFactor",
|
|
"rowTotalPower",
|
|
"roomSummary",
|
|
"technicalName",
|
|
"connectionKind",
|
|
"phaseType",
|
|
"costGroup",
|
|
"category",
|
|
"level",
|
|
"roomNumberSnapshot",
|
|
"roomNameSnapshot",
|
|
"cosPhi",
|
|
]);
|
|
|
|
export const circuitOnlyColumns = new Set<CellKey>([
|
|
"equipmentIdentifier",
|
|
"protectionSummary",
|
|
"cableSummary",
|
|
"circuitTotalPower",
|
|
"protectionType",
|
|
"protectionRatedCurrent",
|
|
"protectionCharacteristic",
|
|
"cableType",
|
|
"cableCrossSection",
|
|
"cableLength",
|
|
"rcdAssignment",
|
|
"terminalDesignation",
|
|
"voltage",
|
|
"controlRequirement",
|
|
"status",
|
|
"isReserve",
|
|
]);
|
|
|
|
export const deviceFieldKeys = new Set<CellKey>([
|
|
"displayName",
|
|
"technicalName",
|
|
"connectionKind",
|
|
"phaseType",
|
|
"costGroup",
|
|
"category",
|
|
"level",
|
|
"roomSummary",
|
|
"roomNumberSnapshot",
|
|
"roomNameSnapshot",
|
|
"quantity",
|
|
"powerPerUnit",
|
|
"simultaneityFactor",
|
|
"cosPhi",
|
|
"remark",
|
|
]);
|
|
|
|
const circuitFieldKeys = new Set<CellKey>([
|
|
"equipmentIdentifier",
|
|
"displayName",
|
|
"protectionType",
|
|
"protectionRatedCurrent",
|
|
"protectionCharacteristic",
|
|
"protectionSummary",
|
|
"cableType",
|
|
"cableCrossSection",
|
|
"cableLength",
|
|
"cableSummary",
|
|
"rcdAssignment",
|
|
"terminalDesignation",
|
|
"controlRequirement",
|
|
"status",
|
|
"isReserve",
|
|
"remark",
|
|
]);
|
|
|
|
const numberFormatters = new Map<number, Intl.NumberFormat>();
|
|
|
|
function formatNumber(value: number, maximumFractionDigits: number): string {
|
|
let formatter = numberFormatters.get(maximumFractionDigits);
|
|
if (!formatter) {
|
|
formatter = new Intl.NumberFormat("de-DE", {
|
|
maximumFractionDigits,
|
|
minimumFractionDigits: 0,
|
|
useGrouping: false,
|
|
});
|
|
numberFormatters.set(maximumFractionDigits, formatter);
|
|
}
|
|
return formatter.format(value);
|
|
}
|
|
|
|
function getMaximumFractionDigits(key: CellKey | undefined): number {
|
|
if (
|
|
key === "powerPerUnit" ||
|
|
key === "rowTotalPower" ||
|
|
key === "circuitTotalPower"
|
|
) {
|
|
return 3;
|
|
}
|
|
if (
|
|
key === "simultaneityFactor" ||
|
|
key === "cosPhi" ||
|
|
key === "protectionRatedCurrent" ||
|
|
key === "cableLength"
|
|
) {
|
|
return 2;
|
|
}
|
|
if (key === "voltage") {
|
|
return 0;
|
|
}
|
|
return 3;
|
|
}
|
|
|
|
export function formatValue(value: GridValue, key?: CellKey): string {
|
|
if (value === undefined || value === null || value === "") {
|
|
return "-";
|
|
}
|
|
if (typeof value === "boolean") {
|
|
return value ? "Ja" : "Nein";
|
|
}
|
|
if (typeof value === "number") {
|
|
return formatNumber(value, getMaximumFractionDigits(key));
|
|
}
|
|
if (key === "phaseType") {
|
|
return formatPhaseTypeLabel(String(value));
|
|
}
|
|
return String(value);
|
|
}
|
|
|
|
export function formatCellValue(value: GridValue, key: CellKey): string {
|
|
const formatted = formatValue(value, key);
|
|
if (formatted === "-" || typeof value !== "number") {
|
|
return formatted;
|
|
}
|
|
if (key === "quantity") {
|
|
return `${formatted} St.`;
|
|
}
|
|
if (
|
|
key === "powerPerUnit" ||
|
|
key === "rowTotalPower" ||
|
|
key === "circuitTotalPower"
|
|
) {
|
|
return `${formatted} kW`;
|
|
}
|
|
return formatted;
|
|
}
|
|
|
|
export function formatPhaseTypeLabel(value: string): string {
|
|
if (value === "single_phase") {
|
|
return "1-phasig";
|
|
}
|
|
if (value === "three_phase") {
|
|
return "3-phasig";
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function isGridEditorControlTarget(target: unknown): boolean {
|
|
if (
|
|
target === null ||
|
|
typeof target !== "object" ||
|
|
!("closest" in target) ||
|
|
typeof target.closest !== "function"
|
|
) {
|
|
return false;
|
|
}
|
|
return Boolean(target.closest("input, select, textarea"));
|
|
}
|
|
|
|
export function parseNumeric(cellKey: CellKey, draft: string): number | undefined {
|
|
const trimmed = draft.trim();
|
|
if (trimmed === "") {
|
|
return undefined;
|
|
}
|
|
// A "." is ambiguous in German number entry: it could be a decimal point
|
|
// (English convention) or a thousands separator (e.g. "1.500" meaning
|
|
// one thousand five hundred). Silently guessing either way risks a wrong
|
|
// value entering the power balance without any visible error, so "."
|
|
// is rejected outright and "," is the only accepted decimal separator.
|
|
if (trimmed.includes(".")) {
|
|
throw new Error(
|
|
`Ungültiger Zahlenwert in ${cellKey}: Dezimalstellen mit Komma eingeben.`
|
|
);
|
|
}
|
|
const parsed = Number(trimmed.replace(",", "."));
|
|
if (Number.isNaN(parsed)) {
|
|
throw new Error(`Ungültiger Zahlenwert in ${cellKey}`);
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
export function buildCircuitEditPatch(
|
|
key: CellKey,
|
|
draft: string
|
|
): CircuitUpdatePatch {
|
|
if (key === "protectionSummary" || key === "cableSummary") {
|
|
return { remark: draft.trim() || null };
|
|
}
|
|
if (key === "cableLength" || key === "voltage") {
|
|
return { [key]: parseNumeric(key, draft) ?? null };
|
|
}
|
|
if (key === "isReserve") {
|
|
const normalized = draft.trim().toLowerCase();
|
|
return {
|
|
isReserve: ["1", "true", "yes", "ja"].includes(normalized),
|
|
};
|
|
}
|
|
if (key === "equipmentIdentifier") {
|
|
return { equipmentIdentifier: draft.trim() };
|
|
}
|
|
return { [key]: draft.trim() || null } as CircuitUpdatePatch;
|
|
}
|
|
|
|
export function buildDeviceRowEditPatch(
|
|
key: CellKey,
|
|
draft: string
|
|
): CircuitDeviceRowUpdatePatch {
|
|
if (
|
|
key === "quantity" ||
|
|
key === "powerPerUnit" ||
|
|
key === "simultaneityFactor"
|
|
) {
|
|
const value = parseNumeric(key, draft);
|
|
if (value === undefined) {
|
|
throw new Error("Dieses Zahlenfeld darf nicht leer sein.");
|
|
}
|
|
return { [key]: value };
|
|
}
|
|
if (key === "cosPhi") {
|
|
return { cosPhi: parseNumeric(key, draft) ?? null };
|
|
}
|
|
if (key === "roomSummary") {
|
|
const trimmed = draft.trim();
|
|
if (!trimmed) {
|
|
return {
|
|
roomNumberSnapshot: null,
|
|
roomNameSnapshot: null,
|
|
};
|
|
}
|
|
const firstSpace = trimmed.indexOf(" ");
|
|
return firstSpace > 0
|
|
? {
|
|
roomNumberSnapshot: trimmed.slice(0, firstSpace).trim(),
|
|
roomNameSnapshot: trimmed.slice(firstSpace + 1).trim() || null,
|
|
}
|
|
: {
|
|
roomNumberSnapshot: trimmed,
|
|
roomNameSnapshot: null,
|
|
};
|
|
}
|
|
if (key === "technicalName") {
|
|
return { name: draft.trim() };
|
|
}
|
|
if (key === "displayName") {
|
|
return { displayName: draft.trim() };
|
|
}
|
|
return { [key]: draft.trim() || null } as CircuitDeviceRowUpdatePatch;
|
|
}
|
|
|
|
export function getDeviceValue(device: CircuitTreeDeviceRowDto, key: CellKey): GridValue {
|
|
switch (key) {
|
|
case "technicalName": return device.name;
|
|
case "displayName": return device.displayName || device.name;
|
|
case "phaseType": return device.phaseType;
|
|
case "connectionKind": return device.connectionKind;
|
|
case "costGroup": return device.costGroup;
|
|
case "category": return device.category;
|
|
case "level": return device.level;
|
|
case "roomSummary": return [device.roomNumberSnapshot, device.roomNameSnapshot].filter(Boolean).join(" ").trim() || undefined;
|
|
case "roomNumberSnapshot": return device.roomNumberSnapshot;
|
|
case "roomNameSnapshot": return device.roomNameSnapshot;
|
|
case "quantity": return device.quantity;
|
|
case "powerPerUnit": return device.powerPerUnit;
|
|
case "simultaneityFactor": return device.simultaneityFactor;
|
|
case "cosPhi": return device.cosPhi;
|
|
case "rowTotalPower": return device.rowTotalPower;
|
|
case "remark": return device.remark;
|
|
default: return undefined;
|
|
}
|
|
}
|
|
|
|
export function getCircuitValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue {
|
|
const protection = circuit.protectionDevice;
|
|
const protectionCharacteristic = protection
|
|
? [
|
|
protection.fuseUtilizationCategory,
|
|
protection.tripCharacteristic,
|
|
protection.rcdType ? `Typ ${protection.rcdType}` : undefined,
|
|
protection.ratedResidualCurrentMa !== undefined
|
|
? `${protection.ratedResidualCurrentMa} mA`
|
|
: undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join(" · ")
|
|
: undefined;
|
|
switch (key) {
|
|
case "equipmentIdentifier": return circuit.equipmentIdentifier;
|
|
case "displayName": return circuit.displayName;
|
|
case "circuitTotalPower": return circuit.circuitTotalPower;
|
|
case "protectionType":
|
|
return protection
|
|
? protectionDeviceTypeLabels[protection.type]
|
|
: undefined;
|
|
case "protectionRatedCurrent":
|
|
return protection?.ratedCurrentA;
|
|
case "protectionCharacteristic":
|
|
return protectionCharacteristic || undefined;
|
|
case "protectionSummary": {
|
|
if (!protection) {
|
|
return undefined;
|
|
}
|
|
const currentValue =
|
|
protection.ratedCurrentA;
|
|
const current = currentValue !== undefined && currentValue !== null
|
|
? `${currentValue} A`
|
|
: "";
|
|
return [
|
|
protectionDeviceTypeLabels[protection.type],
|
|
current,
|
|
protectionCharacteristic,
|
|
].filter(Boolean).join(" · ").trim() || undefined;
|
|
}
|
|
case "cableSummary": {
|
|
const length = circuit.cableLength !== undefined && circuit.cableLength !== null ? `${circuit.cableLength} m` : "";
|
|
return [circuit.cableType, circuit.cableCrossSection, length].filter(Boolean).join(", ").trim() || undefined;
|
|
}
|
|
case "cableType": return circuit.cableType;
|
|
case "cableCrossSection": return circuit.cableCrossSection;
|
|
case "cableLength": return circuit.cableLength;
|
|
case "rcdAssignment": return circuit.rcdAssignment;
|
|
case "terminalDesignation": return circuit.terminalDesignation;
|
|
case "voltage": return circuit.voltage;
|
|
case "controlRequirement": return circuit.controlRequirement;
|
|
case "status": return circuit.status;
|
|
case "isReserve": return circuit.isReserve;
|
|
case "remark": return circuit.remark;
|
|
default: return undefined;
|
|
}
|
|
}
|
|
|
|
export function normalizeFilterValue(key: CellKey, value: GridValue): string {
|
|
return formatValue(value, key);
|
|
}
|
|
|
|
export function getBlockSortValue(circuit: CircuitTreeCircuitDto, key: CellKey): GridValue {
|
|
const firstRow = circuit.deviceRows[0];
|
|
if (key === "rowTotalPower") {
|
|
return circuit.circuitTotalPower;
|
|
}
|
|
if (circuitOnlyColumns.has(key)) {
|
|
return getCircuitValue(circuit, key);
|
|
}
|
|
if (deviceOnlyColumns.has(key)) {
|
|
return firstRow ? getDeviceValue(firstRow, key) : undefined;
|
|
}
|
|
if (key === "displayName") {
|
|
if (circuit.displayName) return circuit.displayName;
|
|
return firstRow ? getDeviceValue(firstRow, key) : undefined;
|
|
}
|
|
if (key === "remark") {
|
|
if (circuit.remark) return circuit.remark;
|
|
return firstRow ? getDeviceValue(firstRow, key) : undefined;
|
|
}
|
|
return getCircuitValue(circuit, key);
|
|
}
|
|
|
|
export function compareSortValues(left: GridValue, right: GridValue): number {
|
|
const leftValue = left === undefined || left === null ? "" : left;
|
|
const rightValue = right === undefined || right === null ? "" : right;
|
|
if (typeof leftValue === "number" && typeof rightValue === "number") {
|
|
return leftValue - rightValue;
|
|
}
|
|
return String(leftValue).localeCompare(String(rightValue), undefined, { sensitivity: "base", numeric: true });
|
|
}
|
|
|
|
export function getCellKind(rowType: RowType, key: CellKey): CellKind {
|
|
if (
|
|
key === "rowTotalPower" ||
|
|
key === "circuitTotalPower" ||
|
|
key === "protectionType" ||
|
|
key === "protectionRatedCurrent" ||
|
|
key === "protectionCharacteristic" ||
|
|
key === "protectionSummary"
|
|
) return "computed";
|
|
if (key === "voltage") return "readonly";
|
|
if (rowType === "section") return "readonly";
|
|
if (rowType === "placeholder") {
|
|
if (deviceFieldKeys.has(key)) return "deviceField";
|
|
if (circuitFieldKeys.has(key)) return "circuitField";
|
|
return "readonly";
|
|
}
|
|
if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "readonly";
|
|
if (rowType === "downstreamBoardLoad") return "readonly";
|
|
if (rowType === "circuitSummary") return circuitFieldKeys.has(key) ? "circuitField" : "readonly";
|
|
if (rowType === "reserveCircuit") {
|
|
if (circuitFieldKeys.has(key)) return "circuitField";
|
|
if (deviceFieldKeys.has(key)) return "deviceField";
|
|
}
|
|
if (rowType === "circuitCompact") {
|
|
if (deviceFieldKeys.has(key)) return "deviceField";
|
|
if (circuitFieldKeys.has(key)) return "circuitField";
|
|
}
|
|
return "readonly";
|
|
}
|