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; +}