diff --git a/src/app/globals.css b/src/app/globals.css index de46d37..d14431b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1541,6 +1541,16 @@ a.kpi:hover { color: var(--text-faint); } +.tree-grid .downstream-board-load-row td { + background: var(--panel-bg-subtle); + color: var(--text-muted); + font-style: italic; +} + +.tree-grid .downstream-board-load-row td:nth-child(2) { + padding-left: 1.5rem; +} + .tree-grid .empty-circuit-row td { background: var(--panel-bg-subtle); } diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index ed03cd9..4a99cf0 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -56,6 +56,7 @@ import { disconnectProjectDeviceRows, exportProjectTransfer, getProject, + getProjectCircuitTrees, getProjectDeviceSyncPreview, listBuildings, listCircuitLists, @@ -73,6 +74,7 @@ import { import type { BuildingDto, CircuitListDto, + CircuitTreeResponseDto, CreateRoomInput, CreateProjectDeviceInput, DistributionBoardDto, @@ -151,6 +153,7 @@ export default function ProjectDetailPage() { const [project, setProject] = useState(null); const [boards, setBoards] = useState([]); const [circuitLists, setCircuitLists] = useState([]); + const [circuitTrees, setCircuitTrees] = useState([]); const [buildings, setBuildings] = useState([]); const [floors, setFloors] = useState([]); const [rooms, setRooms] = useState([]); @@ -170,6 +173,8 @@ export default function ProjectDetailPage() { setEditingBoardSimultaneityFactor, ] = useState("1"); const [editingBoardCopyName, setEditingBoardCopyName] = useState(""); + const [editingFeedingBoardId, setEditingFeedingBoardId] = useState(""); + const [editingFeedingCircuitId, setEditingFeedingCircuitId] = useState(""); const [editingBoardInfo, setEditingBoardInfo] = useState( toDistributionBoardInfoDraft({ @@ -209,6 +214,7 @@ export default function ProjectDetailPage() { getProject(projectId), listDistributionBoards(projectId), listCircuitLists(projectId), + getProjectCircuitTrees(projectId), listBuildings(projectId), listFloors(projectId), listRooms(projectId), @@ -219,6 +225,7 @@ export default function ProjectDetailPage() { currentProject, distributionBoards, loadedCircuitLists, + loadedCircuitTrees, loadedBuildings, loadedFloors, loadedRooms, @@ -228,6 +235,7 @@ export default function ProjectDetailPage() { setProject(currentProject); setBoards(distributionBoards); setCircuitLists(loadedCircuitLists); + setCircuitTrees(loadedCircuitTrees.trees); setBuildings(loadedBuildings); setFloors(loadedFloors); setRooms(loadedRooms); @@ -310,6 +318,48 @@ export default function ProjectDetailPage() { () => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])), [circuitLists] ); + const circuitTreeByBoardId = useMemo( + () => new Map(circuitTrees.map((tree) => [tree.distributionBoardId, tree])), + [circuitTrees] + ); + const feedingBoardOptions = useMemo(() => { + if (!editingBoard) return []; + const boardById = new Map(boards.map((board) => [board.id, board])); + return boards.filter((candidate) => { + if (candidate.id === editingBoard.id) return false; + const visited = new Set(); + let current: DistributionBoardDto | undefined = candidate; + while (current && current.feedingDistributionBoardId && !visited.has(current.id)) { + if (current.feedingDistributionBoardId === editingBoard.id) return false; + visited.add(current.id); + current = boardById.get(current.feedingDistributionBoardId); + } + return true; + }); + }, [boards, editingBoard]); + const selectedFeedingTree = editingFeedingBoardId + ? circuitTreeByBoardId.get(editingFeedingBoardId) + : undefined; + const selectedFeedingCircuits = useMemo( + () => + selectedFeedingTree?.sections.flatMap((section) => + section.circuits.map((circuit) => ({ circuit, section })) + ) ?? [], + [selectedFeedingTree] + ); + const selectedFeedingCircuit = selectedFeedingCircuits.find( + ({ circuit }) => circuit.id === editingFeedingCircuitId + ); + const selectedFeedingBoard = boards.find( + (board) => board.id === editingFeedingBoardId + ); + const selectedFeedingCircuitUseCount = editingFeedingCircuitId + ? boards.filter( + (board) => + board.id !== editingBoard?.id && + board.feedingCircuitId === editingFeedingCircuitId + ).length + : 0; const enabledBoardSupplyTypes = project?.enabledDistributionBoardSupplyTypes ?? []; const usedBoardSupplyTypes = useMemo( @@ -329,6 +379,21 @@ export default function ProjectDetailPage() { [boards] ); + function formatBoardFeedingReference(board: DistributionBoardDto) { + if (!board.feedingDistributionBoardId) return board.fedFrom ?? "–"; + const feedingBoard = boards.find( + (candidate) => candidate.id === board.feedingDistributionBoardId + ); + if (!feedingBoard) return "Unbekannte Verteilung"; + const boardLabel = feedingBoard.boardNumber ?? feedingBoard.name; + if (!board.feedingCircuitId) return boardLabel; + const circuit = circuitTreeByBoardId + .get(feedingBoard.id) + ?.sections.flatMap((section) => section.circuits) + .find((candidate) => candidate.id === board.feedingCircuitId); + return `${boardLabel}${circuit?.equipmentIdentifier ?? ""}`; + } + function applyProjectRevision(currentRevision: number) { setProject((current) => current ? { ...current, currentRevision } : current @@ -358,6 +423,7 @@ export default function ProjectDetailPage() { ]); setCircuitLists(await listCircuitLists(projectId)); applyProjectRevision(result.history.currentRevision); + setCircuitTrees((await getProjectCircuitTrees(projectId)).trees); setBoardName(""); setBoardFloorId(""); setBoardSupplyType( @@ -488,6 +554,8 @@ export default function ProjectDetailPage() { ); setEditingBoardCopyName(`${board.name} Kopie`); setEditingBoardInfo(toDistributionBoardInfoDraft(board)); + setEditingFeedingBoardId(board.feedingDistributionBoardId ?? ""); + setEditingFeedingCircuitId(board.feedingCircuitId ?? ""); } function openBoardCreator() { @@ -523,6 +591,8 @@ export default function ProjectDetailPage() { floorId: editingBoardFloorId || null, supplyType: editingBoardSupplyType, simultaneityFactor, + feedingDistributionBoardId: editingFeedingBoardId || null, + feedingCircuitId: editingFeedingCircuitId || null, ...fromDistributionBoardInfoDraft(editingBoardInfo), }, project.currentRevision @@ -535,6 +605,7 @@ export default function ProjectDetailPage() { ) ); applyProjectRevision(result.history.currentRevision); + setCircuitTrees((await getProjectCircuitTrees(projectId)).trees); setEditingBoard(null); } catch (err) { setError( @@ -590,6 +661,7 @@ export default function ProjectDetailPage() { result.circuitList, ]); applyProjectRevision(result.history.currentRevision); + setCircuitTrees((await getProjectCircuitTrees(projectId)).trees); setEditingBoard(null); } catch (err) { setError( @@ -633,6 +705,7 @@ export default function ProjectDetailPage() { ) ); applyProjectRevision(result.history.currentRevision); + setCircuitTrees((await getProjectCircuitTrees(projectId)).trees); setEditingBoard(null); } catch (err) { setError( @@ -989,6 +1062,7 @@ export default function ProjectDetailPage() { Örtlichkeit Etage Netzart + Gespeist von GZF Aktionen @@ -1015,6 +1089,7 @@ export default function ProjectDetailPage() { ] : "Nicht festgelegt"} + {formatBoardFeedingReference(board)} {board.simultaneityFactor.toLocaleString( "de-DE", @@ -1392,7 +1467,7 @@ export default function ProjectDetailPage() { {editingBoard ? ( setEditingBoard(null)} onSubmit={handleUpdateBoard} @@ -1479,6 +1554,91 @@ export default function ProjectDetailPage() { +
+
+ Logische Einspeisung +
+
+
+ + +
+
+ + +
+ {editingFeedingBoardId && !editingFeedingCircuitId ? ( +
+
+ Ohne speisenden Abgang wird die Hierarchie angezeigt, die + Leistung dieser Verteilung aber keinem Stromkreis zugerechnet. +
+
+ ) : null} + {selectedFeedingBoard?.supplyType && + editingBoardSupplyType !== selectedFeedingBoard.supplyType ? ( +
+
+ Die Netzarten weichen ab: {selectedFeedingBoard.supplyType} + {" → "}{editingBoardSupplyType}. Bitte Umschaltung und + Versorgungskonzept prüfen. +
+
+ ) : null} + {selectedFeedingCircuit && + selectedFeedingCircuit.section.category !== "three_phase" ? ( +
+
+ Der gewählte Abgang ist 1-phasig. Bitte prüfen, ob diese + Einspeisung für die Unterverteilung geeignet ist. +
+
+ ) : null} + {selectedFeedingCircuitUseCount > 0 ? ( +
+
+ Dieser Abgang speist bereits {selectedFeedingCircuitUseCount} + {selectedFeedingCircuitUseCount === 1 + ? " weitere Verteilung." + : " weitere Verteilungen."} +
+
+ ) : null} +
+
0) { + return Math.round(circuit.circuitCosPhi * 100) / 100; + } let activePowerKw = 0; let apparentPowerKva = 0; for (const row of circuit.deviceRows) { diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index e8a2f23..bac08ce 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; import { ArrowDown, @@ -3293,6 +3294,23 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str threePhaseVoltageV={data.threePhaseVoltageV} /> ) : null} + {data.feedingReference ? ( +
+ Gespeist von{" "} + + {data.feedingReference.distributionBoardNumber ?? + data.feedingReference.distributionBoardName} + {data.feedingReference.circuitEquipmentIdentifier ?? ""} + + {data.feedingReference.circuitId + ? null + : " (speisender Stromkreis noch nicht festgelegt)"} +
+ ) : null}
- {row.circuit && row.rowType !== "deviceRow" ? ( + {row.circuit && + (row.rowType === "circuitCompact" || + row.rowType === "circuitSummary" || + row.rowType === "reserveCircuit") ? ( <> = { boardNumber: "z. B. UV 1.1", location: "z. B. Raum 003 Technik", - fedFrom: "z. B. HV 0.1 oder HAK / Zähleranlage", + fedFrom: "z. B. HAK, Zähleranlage, Trafo oder NEA", remark: "", }; @@ -98,8 +98,8 @@ export function DistributionBoardInfoCard({ )} {field === "fedFrom" ? (
- Vorerst Freitext. Die logische Verknüpfung mit dem - speisenden Verteiler folgt. + Für externe Einspeisungen oder ergänzende Hinweise. Eine + speisende Projektverteilung wird oben logisch verknüpft.
) : null}
diff --git a/src/frontend/types.ts b/src/frontend/types.ts index 8a2dec4..572182b 100644 --- a/src/frontend/types.ts +++ b/src/frontend/types.ts @@ -494,6 +494,7 @@ export interface CircuitTreeDownstreamBoardLoadDto { export interface CircuitTreeFeedingReferenceDto { distributionBoardId: string; + circuitListId: string; distributionBoardName: string; distributionBoardNumber?: string; circuitId?: string; diff --git a/src/frontend/utils/circuit-grid-model.ts b/src/frontend/utils/circuit-grid-model.ts index ba4be98..faa4a11 100644 --- a/src/frontend/utils/circuit-grid-model.ts +++ b/src/frontend/utils/circuit-grid-model.ts @@ -44,6 +44,7 @@ export type RowType = | "circuitCompact" | "circuitSummary" | "deviceRow" + | "downstreamBoardLoad" | "reserveCircuit" | "placeholder" | "footerComponent"; @@ -614,6 +615,7 @@ export function getCellKind(rowType: RowType, key: CellKey): CellKind { 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" || rowType === "circuitCompact") { if (deviceFieldKeys.has(key)) return "deviceField"; diff --git a/src/frontend/utils/circuit-grid-projection.ts b/src/frontend/utils/circuit-grid-projection.ts index fb2a846..a9b11de 100644 --- a/src/frontend/utils/circuit-grid-projection.ts +++ b/src/frontend/utils/circuit-grid-projection.ts @@ -2,6 +2,7 @@ import type { CircuitTreeCircuitDto, CircuitTreeComponentDto, CircuitTreeDeviceRowDto, + CircuitTreeDownstreamBoardLoadDto, CircuitTreeSectionDto, } from "../types.js"; import { buildCircuitStructureProjection } from "./circuit-structure-projection"; @@ -45,6 +46,7 @@ export interface VisibleGridRow { circuit?: CircuitTreeCircuitDto; device?: CircuitTreeDeviceRowDto; component?: CircuitTreeComponentDto; + downstreamBoardLoad?: CircuitTreeDownstreamBoardLoadDto; cells: VisibleGridCell[]; } @@ -126,13 +128,16 @@ function makeVisibleGridRow( rowType: RowType, sectionId: string, circuit?: CircuitTreeCircuitDto, - device?: CircuitTreeDeviceRowDto + device?: CircuitTreeDeviceRowDto, + downstreamBoardLoad?: CircuitTreeDownstreamBoardLoadDto ): VisibleGridRow { const rowKey = rowType === "placeholder" ? `placeholder:${sectionId}` : rowType === "deviceRow" && device ? `device:${device.id}` + : rowType === "downstreamBoardLoad" && downstreamBoardLoad + ? `downstream-board:${circuit?.id}:${downstreamBoardLoad.distributionBoardId}` : `${rowType}:${circuit?.id ?? sectionId}`; const cells = allColumns.map((column): VisibleGridCell => { @@ -140,7 +145,17 @@ function makeVisibleGridRow( const editable = kind === "circuitField" || kind === "deviceField"; let value: GridValue; - if (rowType === "placeholder") { + if (rowType === "downstreamBoardLoad" && downstreamBoardLoad) { + value = column.key === "displayName" + ? `Unterverteilung ${downstreamBoardLoad.boardNumber ?? downstreamBoardLoad.name}` + : column.key === "rowTotalPower" + ? downstreamBoardLoad.activePowerKw + : column.key === "cosPhi" + ? downstreamBoardLoad.cosPhi + : column.key === "remark" + ? "Automatisch aus der verknüpften Verteilung" + : undefined; + } else if (rowType === "placeholder") { value = column.key === "equipmentIdentifier" ? "-frei-" : undefined; } else if (kind === "deviceField" && device) { value = getDeviceValue(device, column.key); @@ -166,7 +181,15 @@ function makeVisibleGridRow( return { cellKey: column.key, editable, kind, value }; }); - return { rowKey, rowType, sectionId, circuit, device, cells }; + return { + rowKey, + rowType, + sectionId, + circuit, + device, + downstreamBoardLoad, + cells, + }; } export function buildVisibleGridRowsWithStructure( @@ -221,15 +244,27 @@ export function buildVisibleGridRowsWithStructure( } if (row.rowType === "circuitBlock") { const circuit = row.circuit; - if (circuit.deviceRows.length === 0) { + const downstreamLoads = circuit.downstreamBoardLoads ?? []; + if (circuit.deviceRows.length === 0 && downstreamLoads.length === 0) { rows.push(makeVisibleGridRow("reserveCircuit", section.id, circuit)); - } else if (circuit.deviceRows.length === 1) { + } else if (circuit.deviceRows.length === 1 && downstreamLoads.length === 0) { rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0])); } else { rows.push(makeVisibleGridRow("circuitSummary", section.id, circuit)); for (const device of circuit.deviceRows) { rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device)); } + for (const load of downstreamLoads) { + rows.push( + makeVisibleGridRow( + "downstreamBoardLoad", + section.id, + circuit, + undefined, + load + ) + ); + } } continue; } diff --git a/src/server/controllers/circuit-tree.controller.ts b/src/server/controllers/circuit-tree.controller.ts index 22b4a73..17ac134 100644 --- a/src/server/controllers/circuit-tree.controller.ts +++ b/src/server/controllers/circuit-tree.controller.ts @@ -104,6 +104,9 @@ export async function buildProjectCircuitTrees( const boardById = new Map(boards.map((board) => [board.id, board])); const listById = new Map(lists.map((list) => [list.id, list])); + const listByBoardId = new Map( + lists.map((list) => [list.distributionBoardId, list]) + ); const sectionById = new Map(sections.map((section) => [section.id, section])); const circuitById = new Map(circuits.map((circuit) => [circuit.id, circuit])); const rowsByCircuitId = groupBy(rows, (row) => row.circuitId); @@ -158,6 +161,7 @@ export async function buildProjectCircuitTrees( feedingReference: resolveFeedingReference( board, boardById, + listByBoardId, circuitById, sectionById ), @@ -252,7 +256,9 @@ export async function buildProjectCircuitTrees( voltage: circuit.voltage ?? undefined, controlRequirement: circuit.controlRequirement ?? undefined, status: circuit.status ?? undefined, - isReserve: Boolean(circuit.isReserve), + isReserve: + Boolean(circuit.isReserve) && + circuitLoad.downstreamBoards.length === 0, remark: circuit.remark ?? undefined, circuitTotalPower: circuitLoad.activePowerKw, circuitApparentPower: circuitLoad.apparentPowerKva, @@ -292,17 +298,21 @@ export async function buildProjectCircuitTrees( function resolveFeedingReference( board: Awaited>[number], boardById: Map>[number]>, + listByBoardId: Map>[number]>, circuitById: Map>[number]>, sectionById: Map>[number]> ) { if (board.feedingDistributionBoardId === null) return undefined; const feedingBoard = boardById.get(board.feedingDistributionBoardId); if (!feedingBoard) throw new Error("Feeding distribution board is missing."); + const feedingList = listByBoardId.get(feedingBoard.id); + if (!feedingList) throw new Error("Feeding circuit list is missing."); const feedingCircuit = board.feedingCircuitId === null ? undefined : circuitById.get(board.feedingCircuitId); return { distributionBoardId: feedingBoard.id, + circuitListId: feedingList.id, distributionBoardName: feedingBoard.name, distributionBoardNumber: feedingBoard.boardNumber ?? undefined, circuitId: feedingCircuit?.id, diff --git a/tests/cable-sizing-calculation.test.ts b/tests/cable-sizing-calculation.test.ts index 39cfbb1..53d81a1 100644 --- a/tests/cable-sizing-calculation.test.ts +++ b/tests/cable-sizing-calculation.test.ts @@ -267,6 +267,15 @@ describe("cable sizing defaults from the circuit", () => { sortOrder: 1, isReserve: rows.length === 0, circuitTotalPower: rows.reduce((sum, row) => sum + row.power, 0), + circuitApparentPower: rows.reduce( + (sum, row) => sum + row.power / (row.cosPhi ?? 1), + 0 + ), + circuitCosPhi: rows.length + ? rows.reduce((sum, row) => sum + row.power, 0) / + rows.reduce((sum, row) => sum + row.power / (row.cosPhi ?? 1), 0) + : 1, + downstreamBoardLoads: [], deviceRows: rows.map((row, index) => ({ id: `row-${index}`, sortOrder: index, @@ -288,6 +297,19 @@ describe("cable sizing defaults from the circuit", () => { assert.equal(deriveCircuitCosPhi(circuit([])), 1); }); + it("uses the projected cos φ including downstream boards", () => { + const feeder = circuit([{ power: 1, cosPhi: 1 }]); + feeder.circuitCosPhi = 0.67; + feeder.downstreamBoardLoads = [{ + distributionBoardId: "uv-2", + name: "UV 2", + activePowerKw: 3, + apparentPowerKva: 5, + cosPhi: 0.6, + }]; + assert.equal(deriveCircuitCosPhi(feeder), 0.67); + }); + it("derives the phase from the group category or three-phase rows", () => { assert.equal(deriveCircuitPhase(circuit([{ power: 1, phaseType: "single_phase" }]), "single_phase"), 1); assert.equal(deriveCircuitPhase(circuit([{ power: 1, phaseType: "three_phase" }]), "single_phase"), 3); diff --git a/tests/circuit-grid-projection.test.ts b/tests/circuit-grid-projection.test.ts index 952e3d6..1a16913 100644 --- a/tests/circuit-grid-projection.test.ts +++ b/tests/circuit-grid-projection.test.ts @@ -47,7 +47,10 @@ function makeCircuit( sortOrder, isReserve: deviceRows.length === 0, circuitTotalPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0), + circuitApparentPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0), + circuitCosPhi: 1, deviceRows, + downstreamBoardLoads: [], }; } @@ -176,6 +179,44 @@ describe("circuit grid projection", () => { ); }); + it("renders linked downstream boards as separate read-only rows", () => { + const feeder = { + ...reserveCircuit, + isReserve: false, + circuitTotalPower: 4, + circuitApparentPower: 5, + circuitCosPhi: 0.8, + downstreamBoardLoads: [{ + distributionBoardId: "board-child", + name: "Unterverteilung", + boardNumber: "UV-02", + activePowerKw: 4, + apparentPowerKva: 5, + cosPhi: 0.8, + }], + }; + const rows = buildVisibleGridRowsWithStructure( + [{ ...sections[0], circuits: [feeder] }], + { headerComponents: [], footerComponents: [] } + ); + assert.deepEqual(rows.map((row) => row.rowType), [ + "section", + "circuitSummary", + "downstreamBoardLoad", + "placeholder", + ]); + const loadRow = rows[2]; + assert.equal( + loadRow.cells.find((cell) => cell.cellKey === "displayName")?.value, + "Unterverteilung UV-02" + ); + assert.equal( + loadRow.cells.find((cell) => cell.cellKey === "rowTotalPower")?.value, + 4 + ); + assert.equal(loadRow.cells.every((cell) => !cell.editable), true); + }); + it("shows circuit protection once on each visible circuit row", () => { const protectedSections = sections.map((section) => ({ ...section,