Add distribution power summaries

This commit is contained in:
Julian Appel 2026-07-29 19:02:18 +02:00
parent dcb303284c
commit 9ea081179d
28 changed files with 2668 additions and 77 deletions

View file

@ -27,7 +27,11 @@ import {
deviceFieldKeys,
formatPhaseTypeLabel,
formatValue,
getCircuitSectionLabel,
getProjectColumnLayoutStorageKey,
isGridEditorControlTarget,
normalizeColumnOrder,
parseStoredColumnLayout,
} from "../utils/circuit-grid-model";
import {
buildCircuitDeviceRowInsertSnapshot,
@ -131,7 +135,8 @@ type CircuitReorderDropIntent =
| { kind: "after-circuit"; sectionId: string; targetCircuitId: string; valid: boolean }
| { kind: "section-end"; sectionId: string; valid: boolean };
const COLUMN_LAYOUT_STORAGE_KEY = "circuitTreeEditor.columnLayout.v1";
const LEGACY_COLUMN_LAYOUT_STORAGE_KEY =
"circuitTreeEditor.columnLayout.v1";
function normalizeUiError(err: unknown): string {
const message = err instanceof Error ? err.message : "Der Vorgang ist fehlgeschlagen.";
@ -222,6 +227,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const [filterValueSearch, setFilterValueSearch] = useState("");
const [visibleColumnKeys, setVisibleColumnKeys] = useState<CellKey[]>(defaultVisibleColumnKeys);
const [columnOrder, setColumnOrder] = useState<CellKey[]>(allColumns.map((column) => column.key));
const [
loadedColumnLayoutProjectId,
setLoadedColumnLayoutProjectId,
] = useState<string | null>(null);
const [isColumnMenuOpen, setIsColumnMenuOpen] = useState(false);
const [columnSearch, setColumnSearch] = useState("");
const [draggingColumnKey, setDraggingColumnKey] = useState<CellKey | null>(null);
@ -267,13 +276,6 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}
}
function normalizeColumnOrder(keys: CellKey[]) {
const unique = [...new Set(keys)];
const allKeys = allColumns.map((column) => column.key);
const merged = [...unique, ...allKeys.filter((key) => !unique.includes(key))];
return ["equipmentIdentifier" as CellKey, ...merged.filter((key) => key !== "equipmentIdentifier")];
}
// Initial/identity-change tree load for current route context.
useEffect(() => {
void loadTree({ showLoading: true });
@ -292,32 +294,48 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
}, [projectId]);
useEffect(() => {
setLoadedColumnLayoutProjectId(null);
setColumnOrder(allColumns.map((column) => column.key));
setVisibleColumnKeys(defaultVisibleColumnKeys);
try {
const raw = localStorage.getItem(COLUMN_LAYOUT_STORAGE_KEY);
if (!raw) {
const parsed = parseStoredColumnLayout(
localStorage.getItem(
getProjectColumnLayoutStorageKey(projectId)
) ??
localStorage.getItem(
LEGACY_COLUMN_LAYOUT_STORAGE_KEY
)
);
if (!parsed) {
return;
}
const parsed = JSON.parse(raw) as { order?: string[]; visible?: string[] };
const validKeys = new Set(allColumns.map((column) => column.key));
const parsedOrder = (parsed.order ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey));
const parsedVisible = (parsed.visible ?? []).filter((key): key is CellKey => validKeys.has(key as CellKey));
if (!parsedOrder.length || !parsedVisible.length || !parsedVisible.includes("equipmentIdentifier")) {
return;
}
setColumnOrder(normalizeColumnOrder(parsedOrder));
setVisibleColumnKeys(parsedVisible);
setColumnOrder(parsed.order);
setVisibleColumnKeys(parsed.visible);
} catch {
// ignore invalid local storage and use defaults
} finally {
setLoadedColumnLayoutProjectId(projectId);
}
}, []);
}, [projectId]);
useEffect(() => {
if (loadedColumnLayoutProjectId !== projectId) {
return;
}
const payload = {
order: columnOrder,
visible: visibleColumnKeys,
};
localStorage.setItem(COLUMN_LAYOUT_STORAGE_KEY, JSON.stringify(payload));
}, [columnOrder, visibleColumnKeys]);
localStorage.setItem(
getProjectColumnLayoutStorageKey(projectId),
JSON.stringify(payload)
);
}, [
columnOrder,
loadedColumnLayoutProjectId,
projectId,
visibleColumnKeys,
]);
useEffect(() => {
const visible = new Set(visibleColumnKeys);
@ -474,6 +492,51 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
);
}
function renderDistributionPowerSummary() {
if (!data) {
return null;
}
return (
<section
className="distribution-power-summary"
aria-label="Leistungszusammenfassung des Verteilers"
>
<div>
<span>Gesamtleistung Verteiler</span>
<strong>
{formatValue(
data.distributionBoardTotalPower,
"rowTotalPower"
)}{" "}
kW
</strong>
</div>
<div>
<span>Verteilerweiter Gleichzeitigkeitsfaktor</span>
<strong>
{formatValue(
data.distributionBoardSimultaneityFactor,
"simultaneityFactor"
)}
</strong>
</div>
<div>
<span>
Gesamtleistung Verteiler unter Berücksichtigung des
Gleichzeitigkeitsfaktors
</span>
<strong>
{formatValue(
data.distributionBoardTotalPowerWithSimultaneityFactor,
"rowTotalPower"
)}{" "}
kW
</strong>
</div>
</section>
);
}
function closeColumnSettingsMenu() {
setIsColumnMenuOpen(false);
setColumnSearch("");
@ -2490,6 +2553,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
{renderColumnSettingsMenu("col-empty")}
</div>
{renderActiveViewSummary()}
{renderDistributionPowerSummary()}
{error ? (
<div className="notice error editor-error-notice" role="alert">
<span>{error}</span>
@ -2598,6 +2662,7 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
{renderColumnSettingsMenu("col")}
</div>
{renderActiveViewSummary()}
{renderDistributionPowerSummary()}
{hasActiveSortOrFilter ? (
<div className="notice muted">Sortierung und Filter verändern nur die Ansicht. Die Neunummerierung ist währenddessen deaktiviert.</div>
) : null}
@ -2701,14 +2766,18 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
const valid = !selectedProjectDevice || isProjectDevicePlacementValid(selectedProjectDevice, section);
return (
<option key={section.id} value={section.id} disabled={!valid}>
{section.displayName}{valid ? "" : " (nicht zulässig)"}
{getCircuitSectionLabel(section)}
{valid ? "" : " (nicht zulässig)"}
</option>
);
})}
</select>
</label>
{selectedProjectDevice && suggestedSection ? (
<p className="notice muted">Vorgeschlagener Bereich: {suggestedSection.displayName}</p>
<p className="notice muted">
Vorgeschlagener Bereich:{" "}
{getCircuitSectionLabel(suggestedSection)}
</p>
) : null}
<button
type="button"
@ -2980,7 +3049,19 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
>
<td colSpan={visibleColumns.length + 1} className="section-drop-cell">
<div className="section-content">
<strong>{section.displayName}</strong>
<div className="section-title">
<strong>
{getCircuitSectionLabel(section)}
</strong>
<span>
Gesamtleistung Abschnitt:{" "}
{formatValue(
section.sectionTotalPower,
"rowTotalPower"
)}{" "}
kW
</span>
</div>
<div className="section-actions">
<button type="button" tabIndex={-1} onClick={() => void handleAddReserveCircuit(section.id)}>
Stromkreis hinzufügen

View file

@ -4,7 +4,10 @@ import { useEffect, useState } from "react";
import { Fragment } from "react";
import { getCircuitTree } from "../utils/api";
import type { CircuitTreeCircuitDto, CircuitTreeResponseDto } from "../types";
import { formatValue } from "../utils/circuit-grid-model";
import {
formatValue,
getCircuitSectionLabel,
} from "../utils/circuit-grid-model";
function renderCircuitSummaryLabel(circuit: CircuitTreeCircuitDto) {
if (circuit.displayName?.trim()) {
@ -98,7 +101,7 @@ function SectionRows(props: { section: CircuitTreeResponseDto["sections"][number
<>
<tr className="section-row">
<td colSpan={21}>
<strong>{section.displayName}</strong>
<strong>{getCircuitSectionLabel(section)}</strong>
</td>
</tr>
{section.circuits.map((circuit) => {

View file

@ -95,6 +95,7 @@ export interface DistributionBoardDto {
name: string;
floorId: string | null;
supplyType: DistributionBoardSupplyType | null;
simultaneityFactor: number;
}
export interface DistributionBoardCommandResultDto
@ -296,6 +297,7 @@ export interface CircuitTreeSectionDto {
displayName: string;
prefix: string;
sortOrder: number;
sectionTotalPower: number;
circuits: CircuitTreeCircuitDto[];
}
@ -315,6 +317,9 @@ export interface CircuitTreeResponseDto {
currentRevision: number;
singlePhaseVoltageV: number;
threePhaseVoltageV: number;
distributionBoardSimultaneityFactor: number;
distributionBoardTotalPower: number;
distributionBoardTotalPowerWithSimultaneityFactor: number;
sections: CircuitTreeSectionDto[];
migrationReport?: CircuitTreeMigrationReportDto;
}

View file

@ -299,6 +299,7 @@ export function updateDistributionBoard(
input: {
floorId: string | null;
supplyType: NonNullable<DistributionBoardDto["supplyType"]>;
simultaneityFactor: number;
},
expectedRevision: number
) {

View file

@ -124,6 +124,96 @@ export const defaultVisibleColumnKeys = allColumns
.filter((column) => column.defaultVisible)
.map((column) => column.key);
const projectColumnLayoutStoragePrefix =
"circuitTreeEditor.columnLayout.v2";
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 }
): string {
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",