Compact circuit editor header, pin grid headers, scope sidebar to project

The circuit editor page now fills the viewport so only the grid scrolls
and its sticky column headers stay visible. The large page title is
replaced by a compact title bar with back link, board number/name, the
feeding reference, the toolbar and a condensed power summary.

Inside a project the sidebar shows only the active project with its
sections, an expandable board list ordered by logical feed (current board
highlighted), Revit and the print view. The feed ordering is shared with
the print view through orderDistributionBoardsByFeed.

Refs #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-10-30 07:37:41 +01:00
parent 898be43ebc
commit 07bc52a134
7 changed files with 444 additions and 156 deletions

View file

@ -753,6 +753,7 @@ a.kpi:hover {
.editor-toolbar { .editor-toolbar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
gap: 0.5rem; gap: 0.5rem;
position: sticky; position: sticky;
top: 0; top: 0;
@ -762,6 +763,41 @@ a.kpi:hover {
box-shadow: 0 1px 0 var(--panel-border-strong); box-shadow: 0 1px 0 var(--panel-border-strong);
} }
/* Back link and board title at the start of the editor toolbar. */
.editor-heading {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.editor-heading-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.editor-heading-kicker {
color: var(--text-subtle);
font-size: 0.68rem;
line-height: 1.15;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.editor-heading-title {
display: flex;
gap: 0.4rem;
margin: 0;
font-size: 1.05rem;
line-height: 1.2;
white-space: nowrap;
}
.editor-heading-title span {
font-weight: 500;
}
.editor-toolbar button { .editor-toolbar button {
border: 1px solid var(--panel-border-strong); border: 1px solid var(--panel-border-strong);
background: var(--panel-bg); background: var(--panel-bg);
@ -868,11 +904,14 @@ a.kpi:hover {
font-weight: 600; font-weight: 600;
} }
/* Compact board power figures at the right end of the editor toolbar. */
.distribution-power-summary { .distribution-power-summary {
display: grid; display: flex;
grid-template-columns: repeat(3, minmax(12rem, 1fr)); flex-wrap: wrap;
gap: 0.5rem; align-items: center;
padding: 0.55rem; gap: 0.25rem 0.9rem;
margin-left: auto;
padding: 0.2rem 0.6rem;
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
border-radius: 5px; border-radius: 5px;
background: var(--panel-bg-subtle); background: var(--panel-bg-subtle);
@ -881,24 +920,20 @@ a.kpi:hover {
.distribution-power-summary > div { .distribution-power-summary > div {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.15rem; gap: 0.05rem;
} }
.distribution-power-summary span { .distribution-power-summary span {
color: var(--text-subtle); color: var(--text-subtle);
font-size: 0.74rem; font-size: 0.68rem;
line-height: 1.2; line-height: 1.1;
} }
.distribution-power-summary strong { .distribution-power-summary strong {
color: var(--text-strong); color: var(--text-strong);
font-size: 0.95rem; font-size: 0.86rem;
} line-height: 1.15;
white-space: nowrap;
@media (max-width: 900px) {
.distribution-power-summary {
grid-template-columns: 1fr;
}
} }
.column-settings-menu { .column-settings-menu {
@ -1072,6 +1107,48 @@ a.kpi:hover {
position: relative; position: relative;
} }
/* The editor page fills the viewport; only the grid scrolls, so its sticky
column headers stay visible. Small screens fall back to page scrolling. */
.circuit-editor-page {
display: flex;
flex-direction: column;
height: 100vh;
height: 100dvh;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
overflow: hidden;
}
.circuit-editor-page > .tree-editor-shell {
flex: 1 1 auto;
min-height: 0;
gap: 0.5rem;
}
.circuit-editor-page .tree-editor-layout {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
}
.circuit-editor-page .tree-grid-wrap {
flex: 1 1 auto;
min-height: 0;
max-height: none;
}
@media (max-width: 720px), (max-height: 540px) {
.circuit-editor-page {
height: auto;
overflow: visible;
}
.circuit-editor-page .tree-grid-wrap {
max-height: max(28rem, calc(100vh - 8rem));
}
}
.project-device-sidebar { .project-device-sidebar {
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
background: var(--panel-bg); background: var(--panel-bg);

View file

@ -1,38 +1,17 @@
"use client"; "use client";
import { ArrowLeft } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor"; import { CircuitTreeEditor } from "../../../../../../frontend/components/circuit-tree-editor";
// The editor renders its own compact title bar (back link, board name and
// toolbar) and fills the viewport so only the grid scrolls and its column
// headers stay visible.
export default function CircuitTreeEditPage() { export default function CircuitTreeEditPage() {
const params = useParams<{ projectId: string; circuitListId: string }>(); const params = useParams<{ projectId: string; circuitListId: string }>();
return ( return (
<main className="container-fluid px-3 px-xxl-4 py-4"> <main className="circuit-editor-page container-fluid px-3 px-xxl-4">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div className="d-flex align-items-center gap-2">
<Link
aria-label="Zurück zur Verteilerübersicht"
className="btn btn-outline-secondary btn-sm"
href={`/projects/${params.projectId}`}
title="Zurück zur Verteilerübersicht"
>
<ArrowLeft aria-hidden="true" size={18} />
</Link>
<div>
<h1 className="h4 mb-1">Stromkreisliste</h1>
<p className="text-secondary mb-0">Stromkreise und zugeordnete Geräte bearbeiten.</p>
</div>
</div>
<div className="d-flex gap-2">
<Link href={`/projects/${params.projectId}`} className="btn btn-outline-secondary btn-sm">
Zum Projekt
</Link>
</div>
</div>
<CircuitTreeEditor projectId={params.projectId} circuitListId={params.circuitListId} /> <CircuitTreeEditor projectId={params.projectId} circuitListId={params.circuitListId} />
</main> </main>
); );
} }

View file

@ -153,6 +153,9 @@ const projectDeviceFilterCollapsedStorageKey =
"leistungsbilanz:overview-filter-collapsed:project-devices"; "leistungsbilanz:overview-filter-collapsed:project-devices";
const createFeederCircuitSelectValue = "__create_feeder_circuit__"; const createFeederCircuitSelectValue = "__create_feeder_circuit__";
// Lets the sidebar refresh its board list after boards change here.
const DISTRIBUTION_BOARDS_CHANGED_EVENT = "leistungsbilanz:distribution-boards-changed";
export default function ProjectDetailPage() { export default function ProjectDetailPage() {
const params = useParams<{ projectId: string }>(); const params = useParams<{ projectId: string }>();
const [projectId, setProjectId] = useState(""); const [projectId, setProjectId] = useState("");
@ -217,6 +220,10 @@ export default function ProjectDetailPage() {
setProjectId(params.projectId); setProjectId(params.projectId);
}, [params.projectId]); }, [params.projectId]);
useEffect(() => {
window.dispatchEvent(new Event(DISTRIBUTION_BOARDS_CHANGED_EVENT));
}, [boards]);
useEffect(() => { useEffect(() => {
if (!projectId) { if (!projectId) {
return; return;

View file

@ -3,11 +3,12 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { listProjects } from "../utils/api"; import { listCircuitLists, listDistributionBoards, listProjects } from "../utils/api";
import type { ProjectDto } from "../types"; import type { CircuitListDto, DistributionBoardDto, ProjectDto } from "../types";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
import { useAuth } from "./AuthProvider"; import { useAuth } from "./AuthProvider";
import { getConfigEntry } from "../utils/config-access"; import { getConfigEntry } from "../utils/config-access";
import { orderDistributionBoardsByFeed } from "../utils/distribution-board-hierarchy";
// Anchors on the project overview page (/projects/[id]). // Anchors on the project overview page (/projects/[id]).
const PROJECT_SECTIONS = [ const PROJECT_SECTIONS = [
@ -20,10 +21,22 @@ const PROJECT_SECTIONS = [
const PROJECT_ROUTES = [ const PROJECT_ROUTES = [
{ segment: "struktur", label: "Gebäudestruktur", icon: "▥" }, { segment: "struktur", label: "Gebäudestruktur", icon: "▥" },
{ segment: "grobbilanz", label: "Grobe Leistungsbilanz", icon: "Σ" }, { segment: "grobbilanz", label: "Grobe Leistungsbilanz", icon: "Σ" },
{ segment: "revit", label: "Revit", icon: "⇄" },
] as const; ] as const;
// Matches the project overview and all of its sub-routes (group 2 = rest). // Matches the project overview and all of its sub-routes (group 2 = rest).
const PROJECT_PATH_PATTERN = /^\/projects\/([^/]+)(\/.*)?$/; const PROJECT_PATH_PATTERN = /^\/projects\/([^/]+)(\/.*)?$/;
const CIRCUIT_LIST_PATH_PATTERN = /^\/circuit-lists\/([^/]+)(\/|$)/;
const BOARDS_EXPANDED_STORAGE_KEY = "leistungsbilanz:sidebar-boards-expanded";
// Dispatched by the project page whenever its board list changes.
const DISTRIBUTION_BOARDS_CHANGED_EVENT =
"leistungsbilanz:distribution-boards-changed";
interface SidebarBoardLink {
board: DistributionBoardDto;
depth: number;
circuitListId: string | null;
}
interface SidebarProps { interface SidebarProps {
isCollapsed: boolean; isCollapsed: boolean;
@ -43,8 +56,65 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
? activeProjectId ? activeProjectId
: null; : null;
const activeCircuitListId =
projectSubPath.match(CIRCUIT_LIST_PATH_PATTERN)?.[1] ?? null;
const [projects, setProjects] = useState<ProjectDto[]>([]); const [projects, setProjects] = useState<ProjectDto[]>([]);
const [activeSection, setActiveSection] = useState<string | null>(null); const [activeSection, setActiveSection] = useState<string | null>(null);
const [boardLinks, setBoardLinks] = useState<SidebarBoardLink[]>([]);
const [isBoardListExpanded, setIsBoardListExpanded] = useState(true);
const activeProject = projects.find((project) => project.id === activeProjectId);
useEffect(() => {
try {
setIsBoardListExpanded(
window.localStorage.getItem(BOARDS_EXPANDED_STORAGE_KEY) !== "0"
);
} catch {
// Storage may be unavailable; keep the list expanded.
}
}, []);
useEffect(() => {
if (!activeProjectId) {
setBoardLinks([]);
return;
}
let isCurrent = true;
function refreshBoards(projectId: string) {
Promise.all([listDistributionBoards(projectId), listCircuitLists(projectId)])
.then(([boards, circuitLists]) => {
if (isCurrent) {
setBoardLinks(buildBoardLinks(boards, circuitLists));
}
})
.catch(() => {
if (isCurrent) {
setBoardLinks([]);
}
});
}
const projectId = activeProjectId;
const handleBoardsChanged = () => refreshBoards(projectId);
refreshBoards(projectId);
window.addEventListener(DISTRIBUTION_BOARDS_CHANGED_EVENT, handleBoardsChanged);
return () => {
isCurrent = false;
window.removeEventListener(DISTRIBUTION_BOARDS_CHANGED_EVENT, handleBoardsChanged);
};
}, [activeProjectId]);
function toggleBoardList() {
setIsBoardListExpanded((current) => {
const next = !current;
try {
window.localStorage.setItem(BOARDS_EXPANDED_STORAGE_KEY, next ? "1" : "0");
} catch {
// Ignore unavailable storage; the toggle still works for this page.
}
return next;
});
}
useEffect(() => { useEffect(() => {
function refreshProjects() { function refreshProjects() {
@ -117,33 +187,24 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
<span className="sidebar-label">Alle Projekte</span> <span className="sidebar-label">Alle Projekte</span>
</Link> </Link>
<div className="sidebar-section-label">Projekte</div> {activeProjectId ? (
{projects.map((project) => ( <>
<div className="sidebar-section-label">Aktives Projekt</div>
<Link <Link
key={project.id} href={`/projects/${activeProjectId}`}
href={`/projects/${project.id}`} className={overviewProjectId ? "sidebar-link active" : "sidebar-link"}
className={ title={activeProject?.name ?? "Projekt"}
project.id === activeProjectId ? "sidebar-link active" : "sidebar-link"
}
title={project.name}
> >
<span className="sidebar-icon">▣</span> <span className="sidebar-icon">▣</span>
<span className="sidebar-label">{project.name}</span> <span className="sidebar-label sidebar-project-name">
{activeProject?.name ?? "Projekt"}
</span>
</Link> </Link>
))} {PROJECT_SECTIONS.map(({ id, label, icon }) => (
{!projects.length ? ( <div key={id}>
<div className="sidebar-section-label" style={{ opacity: 0.6 }}> <div className="sidebar-link-row">
Noch keine Projekte {overviewProjectId ? (
</div>
) : null}
{activeProjectId && (
<>
<div className="sidebar-section-label">In diesem Projekt</div>
{PROJECT_SECTIONS.map(({ id, label, icon }) =>
overviewProjectId ? (
<a <a
key={id}
href={`#${id}`} href={`#${id}`}
className={activeSection === id ? "sidebar-link active" : "sidebar-link"} className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
title={label} title={label}
@ -153,7 +214,6 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
</a> </a>
) : ( ) : (
<Link <Link
key={id}
href={`/projects/${activeProjectId}#${id}`} href={`/projects/${activeProjectId}#${id}`}
className="sidebar-link" className="sidebar-link"
title={label} title={label}
@ -161,8 +221,59 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
<span className="sidebar-icon">{icon}</span> <span className="sidebar-icon">{icon}</span>
<span className="sidebar-label">{label}</span> <span className="sidebar-label">{label}</span>
</Link> </Link>
)
)} )}
{id === "verteilungen" && boardLinks.length > 0 && !isCollapsed ? (
<button
aria-controls="sidebar-board-list"
aria-expanded={isBoardListExpanded}
aria-label={
isBoardListExpanded ? "Verteilerliste einklappen" : "Verteilerliste ausklappen"
}
className="sidebar-subtoggle"
onClick={toggleBoardList}
title={
isBoardListExpanded ? "Verteilerliste einklappen" : "Verteilerliste ausklappen"
}
type="button"
>
{isBoardListExpanded ? "▾" : "▸"}
</button>
) : null}
</div>
{id === "verteilungen" && isBoardListExpanded && !isCollapsed ? (
<div className="sidebar-board-list" id="sidebar-board-list">
{boardLinks.map(({ board, depth, circuitListId }) => {
const label = board.boardNumber
? `${board.boardNumber} ${board.name}`
: board.name;
const isActive =
circuitListId !== null && circuitListId === activeCircuitListId;
return circuitListId ? (
<Link
key={board.id}
aria-current={isActive ? "page" : undefined}
className={isActive ? "sidebar-board-link active" : "sidebar-board-link"}
href={`/projects/${activeProjectId}/circuit-lists/${circuitListId}/tree-edit`}
style={{ paddingLeft: `${0.9 + depth * 0.8}rem` }}
title={label}
>
{label}
</Link>
) : (
<span
key={board.id}
className="sidebar-board-link disabled"
style={{ paddingLeft: `${0.9 + depth * 0.8}rem` }}
title={`${label} (keine Stromkreisliste)`}
>
{label}
</span>
);
})}
</div>
) : null}
</div>
))}
{PROJECT_ROUTES.map(({ segment, label, icon }) => { {PROJECT_ROUTES.map(({ segment, label, icon }) => {
const href = `/projects/${activeProjectId}/${segment}`; const href = `/projects/${activeProjectId}/${segment}`;
const isActive = const isActive =
@ -180,6 +291,36 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
</Link> </Link>
); );
})} })}
<a
href={`/projects/${activeProjectId}/print`}
className="sidebar-link"
rel="noreferrer"
target="_blank"
title="Seitenansicht (neuer Tab)"
>
<span className="sidebar-icon">⎙</span>
<span className="sidebar-label">Seitenansicht</span>
</a>
</>
) : (
<>
<div className="sidebar-section-label">Projekte</div>
{projects.map((project) => (
<Link
key={project.id}
href={`/projects/${project.id}`}
className="sidebar-link"
title={project.name}
>
<span className="sidebar-icon">▣</span>
<span className="sidebar-label">{project.name}</span>
</Link>
))}
{!projects.length ? (
<div className="sidebar-section-label" style={{ opacity: 0.6 }}>
Noch keine Projekte
</div>
) : null}
</> </>
)} )}
</nav> </nav>
@ -215,3 +356,17 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
</aside> </aside>
); );
} }
function buildBoardLinks(
boards: DistributionBoardDto[],
circuitLists: CircuitListDto[]
): SidebarBoardLink[] {
const circuitListIdByBoardId = new Map(
circuitLists.map((circuitList) => [circuitList.distributionBoardId, circuitList.id])
);
return orderDistributionBoardsByFeed(boards).map(({ board, depth }) => ({
board,
depth,
circuitListId: circuitListIdByBoardId.get(board.id) ?? null,
}));
}

View file

@ -1,10 +1,11 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { DragEvent, KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; import { DragEvent, KeyboardEvent, type ReactNode, useEffect, useMemo, useRef, useState } from "react";
import { import {
ArrowDown, ArrowDown,
ArrowDown01, ArrowDown01,
ArrowLeft,
ArrowUp, ArrowUp,
Calculator, Calculator,
Check, Check,
@ -27,6 +28,7 @@ import {
Undo2, Undo2,
} from "lucide-react"; } from "lucide-react";
import { IconButton, IconButtonSeparator } from "./icon-button"; import { IconButton, IconButtonSeparator } from "./icon-button";
import { IconLink } from "./icon-link";
import { import {
inferProjectDeviceSectionKey, inferProjectDeviceSectionKey,
isProjectDevicePlacementValid, isProjectDevicePlacementValid,
@ -652,6 +654,66 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
); );
} }
// Compact title block at the start of the toolbar: back link, board
// number/name and the optional feeding reference in one line.
function renderEditorHeading() {
const feedingReference = data?.feedingReference;
return (
<div className="editor-heading">
<IconLink
href={`/projects/${projectId}`}
icon={ArrowLeft}
label="Zurück zum Projekt"
/>
<div className="editor-heading-text">
<span className="editor-heading-kicker">
Stromkreisliste
{feedingReference ? (
<>
{" · gespeist von "}
<Link
href={`/projects/${projectId}/circuit-lists/${feedingReference.circuitListId}/tree-edit`}
title={
feedingReference.circuitId
? undefined
: "Speisender Stromkreis noch nicht festgelegt"
}
>
{feedingReference.distributionBoardNumber ??
feedingReference.distributionBoardName}
{feedingReference.circuitEquipmentIdentifier ?? ""}
</Link>
{feedingReference.circuitId ? null : " (Stromkreis offen)"}
</>
) : null}
</span>
<h1 className="editor-heading-title">
{data ? (
<>
{data.distributionBoardNumber ? (
<strong>{data.distributionBoardNumber}</strong>
) : null}
<span>{data.distributionBoardName}</span>
</>
) : (
"Verteiler"
)}
</h1>
</div>
</div>
);
}
// Loading/error states keep the title bar so the back link is always there.
function renderEditorStatus(notice: ReactNode) {
return (
<div className="tree-editor-shell">
<div className="editor-toolbar">{renderEditorHeading()}</div>
{notice}
</div>
);
}
function renderDistributionPowerSummary() { function renderDistributionPowerSummary() {
if (!data) { if (!data) {
return null; return null;
@ -661,8 +723,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
className="distribution-power-summary" className="distribution-power-summary"
aria-label="Leistungszusammenfassung des Verteilers" aria-label="Leistungszusammenfassung des Verteilers"
> >
<div> <div title="Gesamtleistung Verteiler">
<span>Gesamtleistung Verteiler</span> <span>Gesamtleistung</span>
<strong> <strong>
{formatValue( {formatValue(
data.distributionBoardTotalPower, data.distributionBoardTotalPower,
@ -671,8 +733,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
kW kW
</strong> </strong>
</div> </div>
<div> <div title="Verteilerweiter Gleichzeitigkeitsfaktor">
<span>Verteilerweiter Gleichzeitigkeitsfaktor</span> <span>GZF Verteiler</span>
<strong> <strong>
{formatValue( {formatValue(
data.distributionBoardSimultaneityFactor, data.distributionBoardSimultaneityFactor,
@ -680,11 +742,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
)} )}
</strong> </strong>
</div> </div>
<div> <div title="Gesamtleistung Verteiler unter Berücksichtigung des Gleichzeitigkeitsfaktors">
<span> <span>Leistung mit GZF</span>
Gesamtleistung Verteiler unter Berücksichtigung des
Gleichzeitigkeitsfaktors
</span>
<strong> <strong>
{formatValue( {formatValue(
data.distributionBoardTotalPowerWithSimultaneityFactor, data.distributionBoardTotalPowerWithSimultaneityFactor,
@ -3217,18 +3276,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
if (isLoading && !data) { if (isLoading && !data) {
return <div className="notice info">Stromkreislisteneditor wird geladen …</div>; return renderEditorStatus(
<div className="notice info">Stromkreislisteneditor wird geladen …</div>
);
} }
if (error && !data) { if (error && !data) {
return <div className="notice error">{error}</div>; return renderEditorStatus(<div className="notice error">{error}</div>);
} }
if (!data || data.sections.length === 0) { if (!data || data.sections.length === 0) {
return <div className="notice muted">Keine Bereiche oder Stromkreise vorhanden.</div>; return renderEditorStatus(
<div className="notice muted">Keine Bereiche oder Stromkreise vorhanden.</div>
);
} }
if (filteredSortedSections.length === 0) { if (filteredSortedSections.length === 0) {
return ( return (
<div className="tree-editor-shell"> <div className="tree-editor-shell">
<div className="editor-toolbar"> <div className="editor-toolbar">
{renderEditorHeading()}
<IconButtonSeparator />
<IconButton <IconButton
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving} disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
icon={Undo2} icon={Undo2}
@ -3258,10 +3323,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
onClick={clearSortAndFilters} onClick={clearSortAndFilters}
showLabel showLabel
/> />
{renderDistributionPowerSummary()}
{renderColumnSettingsMenu("col-empty")} {renderColumnSettingsMenu("col-empty")}
</div> </div>
{renderActiveViewSummary()} {renderActiveViewSummary()}
{renderDistributionPowerSummary()}
{error ? ( {error ? (
<div className="notice error editor-error-notice" role="alert"> <div className="notice error editor-error-notice" role="alert">
<span>{error}</span> <span>{error}</span>
@ -3396,24 +3461,9 @@ 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">
{renderEditorHeading()}
<IconButtonSeparator />
<IconButton <IconButton
disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving} disabled={!historyState || historyState.undoDepth === 0 || historyBusy || isSaving}
icon={Undo2} icon={Undo2}
@ -3507,10 +3557,10 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str
} }
showLabel showLabel
/> />
{renderDistributionPowerSummary()}
{renderColumnSettingsMenu("col")} {renderColumnSettingsMenu("col")}
</div> </div>
{renderActiveViewSummary()} {renderActiveViewSummary()}
{renderDistributionPowerSummary()}
{hasActiveSortOrFilter ? ( {hasActiveSortOrFilter ? (
<div className="notice muted">Sortierung und Filter verändern nur die Ansicht. Die Neunummerierung ist währenddessen deaktiviert.</div> <div className="notice muted">Sortierung und Filter verändern nur die Ansicht. Die Neunummerierung ist währenddessen deaktiviert.</div>
) : null} ) : null}

View file

@ -1,4 +1,5 @@
import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device"; import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device";
import { orderDistributionBoardsByFeed } from "../utils/distribution-board-hierarchy";
import type { import type {
CircuitTreeCircuitDto, CircuitTreeCircuitDto,
CircuitTreeProtectionDeviceDto, CircuitTreeProtectionDeviceDto,
@ -92,44 +93,13 @@ export function buildBoardHierarchy(
const summaryById = new Map( const summaryById = new Map(
summaries.map((summary) => [summary.board.id, summary]) summaries.map((summary) => [summary.board.id, summary])
); );
const parentById = new Map<string, string | null>(); return orderDistributionBoardsByFeed(
for (const summary of summaries) { summaries.map((summary) => summary.board)
const parentId = summary.board.feedingDistributionBoardId; ).map(({ board, depth, parentBoardId }) => ({
parentById.set( summary: summaryById.get(board.id)!,
summary.board.id,
parentId && parentId !== summary.board.id && summaryById.has(parentId)
? parentId
: null
);
}
for (const summary of summaries) {
const visited = new Set<string>([summary.board.id]);
let current = parentById.get(summary.board.id) ?? null;
while (current !== null) {
if (visited.has(current)) {
parentById.set(summary.board.id, null);
break;
}
visited.add(current);
current = parentById.get(current) ?? null;
}
}
const childrenOf = (boardId: string | null) =>
summaries.filter(
(summary) => parentById.get(summary.board.id) === boardId
);
const entries: PrintBoardHierarchyEntry[] = [];
const walk = (summary: PrintBoardSummary, depth: number) => {
entries.push({
summary,
depth, depth,
parentBoardId: parentById.get(summary.board.id) ?? null, parentBoardId,
}); }));
childrenOf(summary.board.id).forEach((child) => walk(child, depth + 1));
};
childrenOf(null).forEach((root) => walk(root, 0));
return entries;
} }
export function formatFeedingReference(summary: PrintBoardSummary): string { export function formatFeedingReference(summary: PrintBoardSummary): string {

View file

@ -0,0 +1,50 @@
export interface DistributionBoardHierarchyEntry<TBoard> {
board: TBoard;
depth: number;
parentBoardId: string | null;
}
// Orders boards depth-first along their logical feed. Missing parents,
// self-links and cycles fall back to root boards so every board appears
// exactly once; siblings keep their input order.
export function orderDistributionBoardsByFeed<
TBoard extends { id: string; feedingDistributionBoardId: string | null },
>(boards: readonly TBoard[]): DistributionBoardHierarchyEntry<TBoard>[] {
const boardIds = new Set(boards.map((board) => board.id));
const parentById = new Map<string, string | null>();
for (const board of boards) {
const parentId = board.feedingDistributionBoardId;
parentById.set(
board.id,
parentId && parentId !== board.id && boardIds.has(parentId)
? parentId
: null
);
}
for (const board of boards) {
const visited = new Set<string>([board.id]);
let current = parentById.get(board.id) ?? null;
while (current !== null) {
if (visited.has(current)) {
parentById.set(board.id, null);
break;
}
visited.add(current);
current = parentById.get(current) ?? null;
}
}
const childrenOf = (boardId: string | null) =>
boards.filter((board) => parentById.get(board.id) === boardId);
const entries: DistributionBoardHierarchyEntry<TBoard>[] = [];
const walk = (board: TBoard, depth: number) => {
entries.push({
board,
depth,
parentBoardId: parentById.get(board.id) ?? null,
});
childrenOf(board.id).forEach((child) => walk(child, depth + 1));
};
childrenOf(null).forEach((root) => walk(root, 0));
return entries;
}