Show logical feeder loads in project and circuit views

This commit is contained in:
Julian Appel 2026-09-27 22:37:04 +02:00
parent dfeb0e3cb6
commit 042a050876
12 changed files with 319 additions and 11 deletions

View file

@ -1541,6 +1541,16 @@ a.kpi:hover {
color: var(--text-faint); 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 { .tree-grid .empty-circuit-row td {
background: var(--panel-bg-subtle); background: var(--panel-bg-subtle);
} }

View file

@ -56,6 +56,7 @@ import {
disconnectProjectDeviceRows, disconnectProjectDeviceRows,
exportProjectTransfer, exportProjectTransfer,
getProject, getProject,
getProjectCircuitTrees,
getProjectDeviceSyncPreview, getProjectDeviceSyncPreview,
listBuildings, listBuildings,
listCircuitLists, listCircuitLists,
@ -73,6 +74,7 @@ import {
import type { import type {
BuildingDto, BuildingDto,
CircuitListDto, CircuitListDto,
CircuitTreeResponseDto,
CreateRoomInput, CreateRoomInput,
CreateProjectDeviceInput, CreateProjectDeviceInput,
DistributionBoardDto, DistributionBoardDto,
@ -151,6 +153,7 @@ export default function ProjectDetailPage() {
const [project, setProject] = useState<ProjectDto | null>(null); const [project, setProject] = useState<ProjectDto | null>(null);
const [boards, setBoards] = useState<DistributionBoardDto[]>([]); const [boards, setBoards] = useState<DistributionBoardDto[]>([]);
const [circuitLists, setCircuitLists] = useState<CircuitListDto[]>([]); const [circuitLists, setCircuitLists] = useState<CircuitListDto[]>([]);
const [circuitTrees, setCircuitTrees] = useState<CircuitTreeResponseDto[]>([]);
const [buildings, setBuildings] = useState<BuildingDto[]>([]); const [buildings, setBuildings] = useState<BuildingDto[]>([]);
const [floors, setFloors] = useState<FloorDto[]>([]); const [floors, setFloors] = useState<FloorDto[]>([]);
const [rooms, setRooms] = useState<RoomDto[]>([]); const [rooms, setRooms] = useState<RoomDto[]>([]);
@ -170,6 +173,8 @@ export default function ProjectDetailPage() {
setEditingBoardSimultaneityFactor, setEditingBoardSimultaneityFactor,
] = useState("1"); ] = useState("1");
const [editingBoardCopyName, setEditingBoardCopyName] = useState(""); const [editingBoardCopyName, setEditingBoardCopyName] = useState("");
const [editingFeedingBoardId, setEditingFeedingBoardId] = useState("");
const [editingFeedingCircuitId, setEditingFeedingCircuitId] = useState("");
const [editingBoardInfo, setEditingBoardInfo] = const [editingBoardInfo, setEditingBoardInfo] =
useState<DistributionBoardInfoDraft>( useState<DistributionBoardInfoDraft>(
toDistributionBoardInfoDraft({ toDistributionBoardInfoDraft({
@ -209,6 +214,7 @@ export default function ProjectDetailPage() {
getProject(projectId), getProject(projectId),
listDistributionBoards(projectId), listDistributionBoards(projectId),
listCircuitLists(projectId), listCircuitLists(projectId),
getProjectCircuitTrees(projectId),
listBuildings(projectId), listBuildings(projectId),
listFloors(projectId), listFloors(projectId),
listRooms(projectId), listRooms(projectId),
@ -219,6 +225,7 @@ export default function ProjectDetailPage() {
currentProject, currentProject,
distributionBoards, distributionBoards,
loadedCircuitLists, loadedCircuitLists,
loadedCircuitTrees,
loadedBuildings, loadedBuildings,
loadedFloors, loadedFloors,
loadedRooms, loadedRooms,
@ -228,6 +235,7 @@ export default function ProjectDetailPage() {
setProject(currentProject); setProject(currentProject);
setBoards(distributionBoards); setBoards(distributionBoards);
setCircuitLists(loadedCircuitLists); setCircuitLists(loadedCircuitLists);
setCircuitTrees(loadedCircuitTrees.trees);
setBuildings(loadedBuildings); setBuildings(loadedBuildings);
setFloors(loadedFloors); setFloors(loadedFloors);
setRooms(loadedRooms); setRooms(loadedRooms);
@ -310,6 +318,48 @@ export default function ProjectDetailPage() {
() => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])), () => new Map(circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList])),
[circuitLists] [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<string>();
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 = const enabledBoardSupplyTypes =
project?.enabledDistributionBoardSupplyTypes ?? []; project?.enabledDistributionBoardSupplyTypes ?? [];
const usedBoardSupplyTypes = useMemo( const usedBoardSupplyTypes = useMemo(
@ -329,6 +379,21 @@ export default function ProjectDetailPage() {
[boards] [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) { function applyProjectRevision(currentRevision: number) {
setProject((current) => setProject((current) =>
current ? { ...current, currentRevision } : current current ? { ...current, currentRevision } : current
@ -358,6 +423,7 @@ export default function ProjectDetailPage() {
]); ]);
setCircuitLists(await listCircuitLists(projectId)); setCircuitLists(await listCircuitLists(projectId));
applyProjectRevision(result.history.currentRevision); applyProjectRevision(result.history.currentRevision);
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
setBoardName(""); setBoardName("");
setBoardFloorId(""); setBoardFloorId("");
setBoardSupplyType( setBoardSupplyType(
@ -488,6 +554,8 @@ export default function ProjectDetailPage() {
); );
setEditingBoardCopyName(`${board.name} Kopie`); setEditingBoardCopyName(`${board.name} Kopie`);
setEditingBoardInfo(toDistributionBoardInfoDraft(board)); setEditingBoardInfo(toDistributionBoardInfoDraft(board));
setEditingFeedingBoardId(board.feedingDistributionBoardId ?? "");
setEditingFeedingCircuitId(board.feedingCircuitId ?? "");
} }
function openBoardCreator() { function openBoardCreator() {
@ -523,6 +591,8 @@ export default function ProjectDetailPage() {
floorId: editingBoardFloorId || null, floorId: editingBoardFloorId || null,
supplyType: editingBoardSupplyType, supplyType: editingBoardSupplyType,
simultaneityFactor, simultaneityFactor,
feedingDistributionBoardId: editingFeedingBoardId || null,
feedingCircuitId: editingFeedingCircuitId || null,
...fromDistributionBoardInfoDraft(editingBoardInfo), ...fromDistributionBoardInfoDraft(editingBoardInfo),
}, },
project.currentRevision project.currentRevision
@ -535,6 +605,7 @@ export default function ProjectDetailPage() {
) )
); );
applyProjectRevision(result.history.currentRevision); applyProjectRevision(result.history.currentRevision);
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
setEditingBoard(null); setEditingBoard(null);
} catch (err) { } catch (err) {
setError( setError(
@ -590,6 +661,7 @@ export default function ProjectDetailPage() {
result.circuitList, result.circuitList,
]); ]);
applyProjectRevision(result.history.currentRevision); applyProjectRevision(result.history.currentRevision);
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
setEditingBoard(null); setEditingBoard(null);
} catch (err) { } catch (err) {
setError( setError(
@ -633,6 +705,7 @@ export default function ProjectDetailPage() {
) )
); );
applyProjectRevision(result.history.currentRevision); applyProjectRevision(result.history.currentRevision);
setCircuitTrees((await getProjectCircuitTrees(projectId)).trees);
setEditingBoard(null); setEditingBoard(null);
} catch (err) { } catch (err) {
setError( setError(
@ -989,6 +1062,7 @@ export default function ProjectDetailPage() {
<th>Örtlichkeit</th> <th>Örtlichkeit</th>
<th>Etage</th> <th>Etage</th>
<th>Netzart</th> <th>Netzart</th>
<th>Gespeist von</th>
<th>GZF</th> <th>GZF</th>
<th className="text-end">Aktionen</th> <th className="text-end">Aktionen</th>
</tr> </tr>
@ -1015,6 +1089,7 @@ export default function ProjectDetailPage() {
] ]
: "Nicht festgelegt"} : "Nicht festgelegt"}
</td> </td>
<td>{formatBoardFeedingReference(board)}</td>
<td> <td>
{board.simultaneityFactor.toLocaleString( {board.simultaneityFactor.toLocaleString(
"de-DE", "de-DE",
@ -1392,7 +1467,7 @@ export default function ProjectDetailPage() {
{editingBoard ? ( {editingBoard ? (
<FormModal <FormModal
description={`${editingBoard.name}: Etagenzuordnung, Netzart und Gleichzeitigkeitsfaktor ändern.`} description={`${editingBoard.name}: Etagenzuordnung, Netzart, Einspeisung und Gleichzeitigkeitsfaktor ändern.`}
isSaving={isSaving} isSaving={isSaving}
onClose={() => setEditingBoard(null)} onClose={() => setEditingBoard(null)}
onSubmit={handleUpdateBoard} onSubmit={handleUpdateBoard}
@ -1479,6 +1554,91 @@ export default function ProjectDetailPage() {
</div> </div>
</div> </div>
</div> </div>
<section className="card mt-4" aria-labelledby="feeding-reference-heading">
<div className="card-header" id="feeding-reference-heading">
Logische Einspeisung
</div>
<div className="card-body row g-3">
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="feeding-distribution-board">
Speisende Verteilung
</label>
<select
className="form-select"
id="feeding-distribution-board"
onChange={(event) => {
setEditingFeedingBoardId(event.target.value);
setEditingFeedingCircuitId("");
}}
value={editingFeedingBoardId}
>
<option value="">Keine Projektverteilung</option>
{feedingBoardOptions.map((board) => (
<option key={board.id} value={board.id}>
{board.boardNumber ?? board.name}
</option>
))}
</select>
</div>
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="feeding-circuit">
Speisender Abgang
</label>
<select
className="form-select"
disabled={!editingFeedingBoardId}
id="feeding-circuit"
onChange={(event) => setEditingFeedingCircuitId(event.target.value)}
value={editingFeedingCircuitId}
>
<option value="">Nur Verteilung bekannt</option>
{selectedFeedingCircuits.map(({ circuit }) => (
<option key={circuit.id} value={circuit.id}>
{circuit.equipmentIdentifier}
{circuit.displayName ? ` · ${circuit.displayName}` : ""}
</option>
))}
</select>
</div>
{editingFeedingBoardId && !editingFeedingCircuitId ? (
<div className="col-12">
<div className="alert alert-warning mb-0 py-2">
Ohne speisenden Abgang wird die Hierarchie angezeigt, die
Leistung dieser Verteilung aber keinem Stromkreis zugerechnet.
</div>
</div>
) : null}
{selectedFeedingBoard?.supplyType &&
editingBoardSupplyType !== selectedFeedingBoard.supplyType ? (
<div className="col-12">
<div className="alert alert-warning mb-0 py-2">
Die Netzarten weichen ab: {selectedFeedingBoard.supplyType}
{" → "}{editingBoardSupplyType}. Bitte Umschaltung und
Versorgungskonzept prüfen.
</div>
</div>
) : null}
{selectedFeedingCircuit &&
selectedFeedingCircuit.section.category !== "three_phase" ? (
<div className="col-12">
<div className="alert alert-warning mb-0 py-2">
Der gewählte Abgang ist 1-phasig. Bitte prüfen, ob diese
Einspeisung für die Unterverteilung geeignet ist.
</div>
</div>
) : null}
{selectedFeedingCircuitUseCount > 0 ? (
<div className="col-12">
<div className="alert alert-info mb-0 py-2">
Dieser Abgang speist bereits {selectedFeedingCircuitUseCount}
{selectedFeedingCircuitUseCount === 1
? " weitere Verteilung."
: " weitere Verteilungen."}
</div>
</div>
) : null}
</div>
</section>
<div className="mt-4"> <div className="mt-4">
<DistributionBoardInfoCard <DistributionBoardInfoCard
draft={editingBoardInfo} draft={editingBoardInfo}

View file

@ -90,6 +90,7 @@ export interface CircuitTreeDownstreamBoardLoad {
export interface CircuitTreeFeedingReference { export interface CircuitTreeFeedingReference {
distributionBoardId: string; distributionBoardId: string;
circuitListId: string;
distributionBoardName: string; distributionBoardName: string;
distributionBoardNumber?: string; distributionBoardNumber?: string;
circuitId?: string; circuitId?: string;

View file

@ -22,6 +22,9 @@ export function deriveCircuitPhase(
* Rows without cos φ count as 1; a circuit without load yields 1. * Rows without cos φ count as 1; a circuit without load yields 1.
*/ */
export function deriveCircuitCosPhi(circuit: CircuitTreeCircuitDto): number { export function deriveCircuitCosPhi(circuit: CircuitTreeCircuitDto): number {
if (Number.isFinite(circuit.circuitCosPhi) && circuit.circuitCosPhi > 0) {
return Math.round(circuit.circuitCosPhi * 100) / 100;
}
let activePowerKw = 0; let activePowerKw = 0;
let apparentPowerKva = 0; let apparentPowerKva = 0;
for (const row of circuit.deviceRows) { for (const row of circuit.deviceRows) {

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import Link from "next/link";
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import { import {
ArrowDown, ArrowDown,
@ -3293,6 +3294,23 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
threePhaseVoltageV={data.threePhaseVoltageV} threePhaseVoltageV={data.threePhaseVoltageV}
/> />
) : null} ) : null}
{data.feedingReference ? (
<div className="alert alert-info py-2" role="status">
Gespeist von{" "}
<Link
href={`/projects/${projectId}/circuit-lists/${
data.feedingReference.circuitListId
}/tree-edit`}
>
{data.feedingReference.distributionBoardNumber ??
data.feedingReference.distributionBoardName}
{data.feedingReference.circuitEquipmentIdentifier ?? ""}
</Link>
{data.feedingReference.circuitId
? null
: " (speisender Stromkreis noch nicht festgelegt)"}
</div>
) : null}
<div className="editor-toolbar"> <div className="editor-toolbar">
<IconButton <IconButton
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving} disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
@ -4062,6 +4080,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
? "summary-row" ? "summary-row"
: row.rowType === "deviceRow" : row.rowType === "deviceRow"
? "device-row" ? "device-row"
: row.rowType === "downstreamBoardLoad"
? "downstream-board-load-row"
: row.rowType === "reserveCircuit" : row.rowType === "reserveCircuit"
? "empty-circuit-row" ? "empty-circuit-row"
: row.rowType === "placeholder" : row.rowType === "placeholder"
@ -4667,7 +4687,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
: "" : ""
}`} }`}
> >
{row.circuit && row.rowType !== "deviceRow" ? ( {row.circuit &&
(row.rowType === "circuitCompact" ||
row.rowType === "circuitSummary" ||
row.rowType === "reserveCircuit") ? (
<> <>
<IconButton <IconButton
disabled={isSaving} disabled={isSaving}

View file

@ -13,7 +13,7 @@ export type DistributionBoardInfoDraft = Record<DistributionBoardInfoField, stri
const placeholders: Record<DistributionBoardInfoField, string> = { const placeholders: Record<DistributionBoardInfoField, string> = {
boardNumber: "z. B. UV 1.1", boardNumber: "z. B. UV 1.1",
location: "z. B. Raum 003 Technik", 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: "", remark: "",
}; };
@ -98,8 +98,8 @@ export function DistributionBoardInfoCard({
)} )}
{field === "fedFrom" ? ( {field === "fedFrom" ? (
<div className="form-text"> <div className="form-text">
Vorerst Freitext. Die logische Verknüpfung mit dem Für externe Einspeisungen oder ergänzende Hinweise. Eine
speisenden Verteiler folgt. speisende Projektverteilung wird oben logisch verknüpft.
</div> </div>
) : null} ) : null}
</div> </div>

View file

@ -494,6 +494,7 @@ export interface CircuitTreeDownstreamBoardLoadDto {
export interface CircuitTreeFeedingReferenceDto { export interface CircuitTreeFeedingReferenceDto {
distributionBoardId: string; distributionBoardId: string;
circuitListId: string;
distributionBoardName: string; distributionBoardName: string;
distributionBoardNumber?: string; distributionBoardNumber?: string;
circuitId?: string; circuitId?: string;

View file

@ -44,6 +44,7 @@ export type RowType =
| "circuitCompact" | "circuitCompact"
| "circuitSummary" | "circuitSummary"
| "deviceRow" | "deviceRow"
| "downstreamBoardLoad"
| "reserveCircuit" | "reserveCircuit"
| "placeholder" | "placeholder"
| "footerComponent"; | "footerComponent";
@ -614,6 +615,7 @@ export function getCellKind(rowType: RowType, key: CellKey): CellKind {
return "readonly"; return "readonly";
} }
if (rowType === "deviceRow") return deviceFieldKeys.has(key) ? "deviceField" : "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 === "circuitSummary") return circuitFieldKeys.has(key) ? "circuitField" : "readonly";
if (rowType === "reserveCircuit" || rowType === "circuitCompact") { if (rowType === "reserveCircuit" || rowType === "circuitCompact") {
if (deviceFieldKeys.has(key)) return "deviceField"; if (deviceFieldKeys.has(key)) return "deviceField";

View file

@ -2,6 +2,7 @@ import type {
CircuitTreeCircuitDto, CircuitTreeCircuitDto,
CircuitTreeComponentDto, CircuitTreeComponentDto,
CircuitTreeDeviceRowDto, CircuitTreeDeviceRowDto,
CircuitTreeDownstreamBoardLoadDto,
CircuitTreeSectionDto, CircuitTreeSectionDto,
} from "../types.js"; } from "../types.js";
import { buildCircuitStructureProjection } from "./circuit-structure-projection"; import { buildCircuitStructureProjection } from "./circuit-structure-projection";
@ -45,6 +46,7 @@ export interface VisibleGridRow {
circuit?: CircuitTreeCircuitDto; circuit?: CircuitTreeCircuitDto;
device?: CircuitTreeDeviceRowDto; device?: CircuitTreeDeviceRowDto;
component?: CircuitTreeComponentDto; component?: CircuitTreeComponentDto;
downstreamBoardLoad?: CircuitTreeDownstreamBoardLoadDto;
cells: VisibleGridCell[]; cells: VisibleGridCell[];
} }
@ -126,13 +128,16 @@ function makeVisibleGridRow(
rowType: RowType, rowType: RowType,
sectionId: string, sectionId: string,
circuit?: CircuitTreeCircuitDto, circuit?: CircuitTreeCircuitDto,
device?: CircuitTreeDeviceRowDto device?: CircuitTreeDeviceRowDto,
downstreamBoardLoad?: CircuitTreeDownstreamBoardLoadDto
): VisibleGridRow { ): VisibleGridRow {
const rowKey = const rowKey =
rowType === "placeholder" rowType === "placeholder"
? `placeholder:${sectionId}` ? `placeholder:${sectionId}`
: rowType === "deviceRow" && device : rowType === "deviceRow" && device
? `device:${device.id}` ? `device:${device.id}`
: rowType === "downstreamBoardLoad" && downstreamBoardLoad
? `downstream-board:${circuit?.id}:${downstreamBoardLoad.distributionBoardId}`
: `${rowType}:${circuit?.id ?? sectionId}`; : `${rowType}:${circuit?.id ?? sectionId}`;
const cells = allColumns.map((column): VisibleGridCell => { const cells = allColumns.map((column): VisibleGridCell => {
@ -140,7 +145,17 @@ function makeVisibleGridRow(
const editable = kind === "circuitField" || kind === "deviceField"; const editable = kind === "circuitField" || kind === "deviceField";
let value: GridValue; 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; value = column.key === "equipmentIdentifier" ? "-frei-" : undefined;
} else if (kind === "deviceField" && device) { } else if (kind === "deviceField" && device) {
value = getDeviceValue(device, column.key); value = getDeviceValue(device, column.key);
@ -166,7 +181,15 @@ function makeVisibleGridRow(
return { cellKey: column.key, editable, kind, value }; 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( export function buildVisibleGridRowsWithStructure(
@ -221,15 +244,27 @@ export function buildVisibleGridRowsWithStructure(
} }
if (row.rowType === "circuitBlock") { if (row.rowType === "circuitBlock") {
const circuit = row.circuit; 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)); 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])); rows.push(makeVisibleGridRow("circuitCompact", section.id, circuit, circuit.deviceRows[0]));
} else { } else {
rows.push(makeVisibleGridRow("circuitSummary", section.id, circuit)); rows.push(makeVisibleGridRow("circuitSummary", section.id, circuit));
for (const device of circuit.deviceRows) { for (const device of circuit.deviceRows) {
rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device)); rows.push(makeVisibleGridRow("deviceRow", section.id, circuit, device));
} }
for (const load of downstreamLoads) {
rows.push(
makeVisibleGridRow(
"downstreamBoardLoad",
section.id,
circuit,
undefined,
load
)
);
}
} }
continue; continue;
} }

View file

@ -104,6 +104,9 @@ export async function buildProjectCircuitTrees(
const boardById = new Map(boards.map((board) => [board.id, board])); const boardById = new Map(boards.map((board) => [board.id, board]));
const listById = new Map(lists.map((list) => [list.id, list])); 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 sectionById = new Map(sections.map((section) => [section.id, section]));
const circuitById = new Map(circuits.map((circuit) => [circuit.id, circuit])); const circuitById = new Map(circuits.map((circuit) => [circuit.id, circuit]));
const rowsByCircuitId = groupBy(rows, (row) => row.circuitId); const rowsByCircuitId = groupBy(rows, (row) => row.circuitId);
@ -158,6 +161,7 @@ export async function buildProjectCircuitTrees(
feedingReference: resolveFeedingReference( feedingReference: resolveFeedingReference(
board, board,
boardById, boardById,
listByBoardId,
circuitById, circuitById,
sectionById sectionById
), ),
@ -252,7 +256,9 @@ export async function buildProjectCircuitTrees(
voltage: circuit.voltage ?? undefined, voltage: circuit.voltage ?? undefined,
controlRequirement: circuit.controlRequirement ?? undefined, controlRequirement: circuit.controlRequirement ?? undefined,
status: circuit.status ?? undefined, status: circuit.status ?? undefined,
isReserve: Boolean(circuit.isReserve), isReserve:
Boolean(circuit.isReserve) &&
circuitLoad.downstreamBoards.length === 0,
remark: circuit.remark ?? undefined, remark: circuit.remark ?? undefined,
circuitTotalPower: circuitLoad.activePowerKw, circuitTotalPower: circuitLoad.activePowerKw,
circuitApparentPower: circuitLoad.apparentPowerKva, circuitApparentPower: circuitLoad.apparentPowerKva,
@ -292,17 +298,21 @@ export async function buildProjectCircuitTrees(
function resolveFeedingReference( function resolveFeedingReference(
board: Awaited<ReturnType<typeof distributionBoardRepository.listByProject>>[number], board: Awaited<ReturnType<typeof distributionBoardRepository.listByProject>>[number],
boardById: Map<string, Awaited<ReturnType<typeof distributionBoardRepository.listByProject>>[number]>, boardById: Map<string, Awaited<ReturnType<typeof distributionBoardRepository.listByProject>>[number]>,
listByBoardId: Map<string, Awaited<ReturnType<typeof circuitListRepository.listByProject>>[number]>,
circuitById: Map<string, Awaited<ReturnType<typeof circuitRepository.listByCircuitLists>>[number]>, circuitById: Map<string, Awaited<ReturnType<typeof circuitRepository.listByCircuitLists>>[number]>,
sectionById: Map<string, Awaited<ReturnType<typeof circuitSectionRepository.listByCircuitLists>>[number]> sectionById: Map<string, Awaited<ReturnType<typeof circuitSectionRepository.listByCircuitLists>>[number]>
) { ) {
if (board.feedingDistributionBoardId === null) return undefined; if (board.feedingDistributionBoardId === null) return undefined;
const feedingBoard = boardById.get(board.feedingDistributionBoardId); const feedingBoard = boardById.get(board.feedingDistributionBoardId);
if (!feedingBoard) throw new Error("Feeding distribution board is missing."); 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 const feedingCircuit = board.feedingCircuitId === null
? undefined ? undefined
: circuitById.get(board.feedingCircuitId); : circuitById.get(board.feedingCircuitId);
return { return {
distributionBoardId: feedingBoard.id, distributionBoardId: feedingBoard.id,
circuitListId: feedingList.id,
distributionBoardName: feedingBoard.name, distributionBoardName: feedingBoard.name,
distributionBoardNumber: feedingBoard.boardNumber ?? undefined, distributionBoardNumber: feedingBoard.boardNumber ?? undefined,
circuitId: feedingCircuit?.id, circuitId: feedingCircuit?.id,

View file

@ -267,6 +267,15 @@ describe("cable sizing defaults from the circuit", () => {
sortOrder: 1, sortOrder: 1,
isReserve: rows.length === 0, isReserve: rows.length === 0,
circuitTotalPower: rows.reduce((sum, row) => sum + row.power, 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) => ({ deviceRows: rows.map((row, index) => ({
id: `row-${index}`, id: `row-${index}`,
sortOrder: index, sortOrder: index,
@ -288,6 +297,19 @@ describe("cable sizing defaults from the circuit", () => {
assert.equal(deriveCircuitCosPhi(circuit([])), 1); 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", () => { 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: "single_phase" }]), "single_phase"), 1);
assert.equal(deriveCircuitPhase(circuit([{ power: 1, phaseType: "three_phase" }]), "single_phase"), 3); assert.equal(deriveCircuitPhase(circuit([{ power: 1, phaseType: "three_phase" }]), "single_phase"), 3);

View file

@ -47,7 +47,10 @@ function makeCircuit(
sortOrder, sortOrder,
isReserve: deviceRows.length === 0, isReserve: deviceRows.length === 0,
circuitTotalPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0), circuitTotalPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0),
circuitApparentPower: deviceRows.reduce((total, row) => total + row.rowTotalPower, 0),
circuitCosPhi: 1,
deviceRows, 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", () => { it("shows circuit protection once on each visible circuit row", () => {
const protectedSections = sections.map((section) => ({ const protectedSections = sections.map((section) => ({
...section, ...section,