From 07bc52a134a0611758e895a9e35b10d790d01f92 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 30 Oct 2026 07:37:41 +0100 Subject: [PATCH] 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 --- src/app/globals.css | 105 +++++++- .../[circuitListId]/tree-edit/page.tsx | 29 +- src/app/projects/[projectId]/page.tsx | 7 + src/frontend/components/Sidebar.tsx | 249 ++++++++++++++---- .../components/circuit-tree-editor.tsx | 114 +++++--- src/frontend/print/print-document-model.ts | 46 +--- .../utils/distribution-board-hierarchy.ts | 50 ++++ 7 files changed, 444 insertions(+), 156 deletions(-) create mode 100644 src/frontend/utils/distribution-board-hierarchy.ts diff --git a/src/app/globals.css b/src/app/globals.css index 67985f4..20d83b5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -753,6 +753,7 @@ a.kpi:hover { .editor-toolbar { display: flex; flex-wrap: wrap; + align-items: center; gap: 0.5rem; position: sticky; top: 0; @@ -762,6 +763,41 @@ a.kpi:hover { 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 { border: 1px solid var(--panel-border-strong); background: var(--panel-bg); @@ -868,11 +904,14 @@ a.kpi:hover { font-weight: 600; } +/* Compact board power figures at the right end of the editor toolbar. */ .distribution-power-summary { - display: grid; - grid-template-columns: repeat(3, minmax(12rem, 1fr)); - gap: 0.5rem; - padding: 0.55rem; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem 0.9rem; + margin-left: auto; + padding: 0.2rem 0.6rem; border: 1px solid var(--panel-border); border-radius: 5px; background: var(--panel-bg-subtle); @@ -881,24 +920,20 @@ a.kpi:hover { .distribution-power-summary > div { display: flex; flex-direction: column; - gap: 0.15rem; + gap: 0.05rem; } .distribution-power-summary span { color: var(--text-subtle); - font-size: 0.74rem; - line-height: 1.2; + font-size: 0.68rem; + line-height: 1.1; } .distribution-power-summary strong { color: var(--text-strong); - font-size: 0.95rem; -} - -@media (max-width: 900px) { - .distribution-power-summary { - grid-template-columns: 1fr; - } + font-size: 0.86rem; + line-height: 1.15; + white-space: nowrap; } .column-settings-menu { @@ -1072,6 +1107,48 @@ a.kpi:hover { 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 { border: 1px solid var(--panel-border); background: var(--panel-bg); diff --git a/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx b/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx index 90893f6..4173d01 100644 --- a/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx +++ b/src/app/projects/[projectId]/circuit-lists/[circuitListId]/tree-edit/page.tsx @@ -1,38 +1,17 @@ "use client"; -import { ArrowLeft } from "lucide-react"; -import Link from "next/link"; import { useParams } from "next/navigation"; 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() { const params = useParams<{ projectId: string; circuitListId: string }>(); return ( -
-
-
- -
-
- - Zum Projekt - -
-
+
); } - diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index 5eb864e..45725b7 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -153,6 +153,9 @@ const projectDeviceFilterCollapsedStorageKey = "leistungsbilanz:overview-filter-collapsed:project-devices"; 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() { const params = useParams<{ projectId: string }>(); const [projectId, setProjectId] = useState(""); @@ -217,6 +220,10 @@ export default function ProjectDetailPage() { setProjectId(params.projectId); }, [params.projectId]); + useEffect(() => { + window.dispatchEvent(new Event(DISTRIBUTION_BOARDS_CHANGED_EVENT)); + }, [boards]); + useEffect(() => { if (!projectId) { return; diff --git a/src/frontend/components/Sidebar.tsx b/src/frontend/components/Sidebar.tsx index e99b493..43b5f32 100644 --- a/src/frontend/components/Sidebar.tsx +++ b/src/frontend/components/Sidebar.tsx @@ -3,11 +3,12 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; -import { listProjects } from "../utils/api"; -import type { ProjectDto } from "../types"; +import { listCircuitLists, listDistributionBoards, listProjects } from "../utils/api"; +import type { CircuitListDto, DistributionBoardDto, ProjectDto } from "../types"; import { ThemeToggle } from "./ThemeToggle"; import { useAuth } from "./AuthProvider"; import { getConfigEntry } from "../utils/config-access"; +import { orderDistributionBoardsByFeed } from "../utils/distribution-board-hierarchy"; // Anchors on the project overview page (/projects/[id]). const PROJECT_SECTIONS = [ @@ -20,10 +21,22 @@ const PROJECT_SECTIONS = [ const PROJECT_ROUTES = [ { segment: "struktur", label: "Gebäudestruktur", icon: "▥" }, { segment: "grobbilanz", label: "Grobe Leistungsbilanz", icon: "Σ" }, + { segment: "revit", label: "Revit", icon: "⇄" }, ] as const; // Matches the project overview and all of its sub-routes (group 2 = rest). 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 { isCollapsed: boolean; @@ -43,8 +56,65 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { ? activeProjectId : null; + const activeCircuitListId = + projectSubPath.match(CIRCUIT_LIST_PATH_PATTERN)?.[1] ?? null; + const [projects, setProjects] = useState([]); const [activeSection, setActiveSection] = useState(null); + const [boardLinks, setBoardLinks] = useState([]); + 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(() => { function refreshProjects() { @@ -117,52 +187,93 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { Alle Projekte -
Projekte
- {projects.map((project) => ( - - ▣ - {project.name} - - ))} - {!projects.length ? ( -
- Noch keine Projekte -
- ) : null} - - {activeProjectId && ( + {activeProjectId ? ( <> -
In diesem Projekt
- {PROJECT_SECTIONS.map(({ id, label, icon }) => - overviewProjectId ? ( - - {icon} - {label} - - ) : ( - - {icon} - {label} - - ) - )} +
Aktives Projekt
+ + ▣ + + {activeProject?.name ?? "Projekt"} + + + {PROJECT_SECTIONS.map(({ id, label, icon }) => ( +
+
+ {overviewProjectId ? ( + + {icon} + {label} + + ) : ( + + {icon} + {label} + + )} + {id === "verteilungen" && boardLinks.length > 0 && !isCollapsed ? ( + + ) : null} +
+ {id === "verteilungen" && isBoardListExpanded && !isCollapsed ? ( + + ) : null} +
+ ))} {PROJECT_ROUTES.map(({ segment, label, icon }) => { const href = `/projects/${activeProjectId}/${segment}`; const isActive = @@ -180,6 +291,36 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { ); })} + + ⎙ + Seitenansicht + + + ) : ( + <> +
Projekte
+ {projects.map((project) => ( + + ▣ + {project.name} + + ))} + {!projects.length ? ( +
+ Noch keine Projekte +
+ ) : null} )} @@ -215,3 +356,17 @@ export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) { ); } + +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, + })); +} diff --git a/src/frontend/components/circuit-tree-editor.tsx b/src/frontend/components/circuit-tree-editor.tsx index 399b4c6..fedf3f9 100644 --- a/src/frontend/components/circuit-tree-editor.tsx +++ b/src/frontend/components/circuit-tree-editor.tsx @@ -1,10 +1,11 @@ "use client"; 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 { ArrowDown, ArrowDown01, + ArrowLeft, ArrowUp, Calculator, Check, @@ -27,6 +28,7 @@ import { Undo2, } from "lucide-react"; import { IconButton, IconButtonSeparator } from "./icon-button"; +import { IconLink } from "./icon-link"; import { inferProjectDeviceSectionKey, 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 ( +
+ +
+ + Stromkreisliste + {feedingReference ? ( + <> + {" · gespeist von "} + + {feedingReference.distributionBoardNumber ?? + feedingReference.distributionBoardName} + {feedingReference.circuitEquipmentIdentifier ?? ""} + + {feedingReference.circuitId ? null : " (Stromkreis offen)"} + + ) : null} + +

+ {data ? ( + <> + {data.distributionBoardNumber ? ( + {data.distributionBoardNumber} + ) : null} + {data.distributionBoardName} + + ) : ( + "Verteiler" + )} +

+
+
+ ); + } + + // Loading/error states keep the title bar so the back link is always there. + function renderEditorStatus(notice: ReactNode) { + return ( +
+
{renderEditorHeading()}
+ {notice} +
+ ); + } + function renderDistributionPowerSummary() { if (!data) { return null; @@ -661,8 +723,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str className="distribution-power-summary" aria-label="Leistungszusammenfassung des Verteilers" > -
- Gesamtleistung Verteiler +
+ Gesamtleistung {formatValue( data.distributionBoardTotalPower, @@ -671,8 +733,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str kW
-
- Verteilerweiter Gleichzeitigkeitsfaktor +
+ GZF Verteiler {formatValue( data.distributionBoardSimultaneityFactor, @@ -680,11 +742,8 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str )}
-
- - Gesamtleistung Verteiler unter Berücksichtigung des - Gleichzeitigkeitsfaktors - +
+ Leistung mit GZF {formatValue( data.distributionBoardTotalPowerWithSimultaneityFactor, @@ -3217,18 +3276,24 @@ export function CircuitTreeEditor(props: { projectId: string; circuitListId: str } if (isLoading && !data) { - return
Stromkreislisteneditor wird geladen …
; + return renderEditorStatus( +
Stromkreislisteneditor wird geladen …
+ ); } if (error && !data) { - return
{error}
; + return renderEditorStatus(
{error}
); } if (!data || data.sections.length === 0) { - return
Keine Bereiche oder Stromkreise vorhanden.
; + return renderEditorStatus( +
Keine Bereiche oder Stromkreise vorhanden.
+ ); } if (filteredSortedSections.length === 0) { return (
+ {renderEditorHeading()} + + {renderDistributionPowerSummary()} {renderColumnSettingsMenu("col-empty")}
{renderActiveViewSummary()} - {renderDistributionPowerSummary()} {error ? (
{error} @@ -3396,24 +3461,9 @@ 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}
+ {renderEditorHeading()} + + {renderDistributionPowerSummary()} {renderColumnSettingsMenu("col")}
{renderActiveViewSummary()} - {renderDistributionPowerSummary()} {hasActiveSortOrFilter ? (
Sortierung und Filter verändern nur die Ansicht. Die Neunummerierung ist währenddessen deaktiviert.
) : null} diff --git a/src/frontend/print/print-document-model.ts b/src/frontend/print/print-document-model.ts index d3e4724..e6ab1ca 100644 --- a/src/frontend/print/print-document-model.ts +++ b/src/frontend/print/print-document-model.ts @@ -1,4 +1,5 @@ import { protectionDeviceTypeLabels } from "../../shared/constants/protection-device"; +import { orderDistributionBoardsByFeed } from "../utils/distribution-board-hierarchy"; import type { CircuitTreeCircuitDto, CircuitTreeProtectionDeviceDto, @@ -92,44 +93,13 @@ export function buildBoardHierarchy( const summaryById = new Map( summaries.map((summary) => [summary.board.id, summary]) ); - const parentById = new Map(); - for (const summary of summaries) { - const parentId = summary.board.feedingDistributionBoardId; - parentById.set( - summary.board.id, - parentId && parentId !== summary.board.id && summaryById.has(parentId) - ? parentId - : null - ); - } - for (const summary of summaries) { - const visited = new Set([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, - parentBoardId: parentById.get(summary.board.id) ?? null, - }); - childrenOf(summary.board.id).forEach((child) => walk(child, depth + 1)); - }; - childrenOf(null).forEach((root) => walk(root, 0)); - return entries; + return orderDistributionBoardsByFeed( + summaries.map((summary) => summary.board) + ).map(({ board, depth, parentBoardId }) => ({ + summary: summaryById.get(board.id)!, + depth, + parentBoardId, + })); } export function formatFeedingReference(summary: PrintBoardSummary): string { diff --git a/src/frontend/utils/distribution-board-hierarchy.ts b/src/frontend/utils/distribution-board-hierarchy.ts new file mode 100644 index 0000000..7c60cf8 --- /dev/null +++ b/src/frontend/utils/distribution-board-hierarchy.ts @@ -0,0 +1,50 @@ +export interface DistributionBoardHierarchyEntry { + 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[] { + const boardIds = new Set(boards.map((board) => board.id)); + const parentById = new Map(); + 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([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[] = []; + 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; +}