leistungsbilanz-ts/src/frontend/components/Sidebar.tsx
Julian Appel 07bc52a134 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>
2026-10-30 07:37:41 +01:00

372 lines
14 KiB
TypeScript

"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
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 = [
{ id: "verlauf", label: "Verlauf", icon: "↺" },
{ id: "verteilungen", label: "Verteilungen", icon: "⌁" },
{ id: "projektgeraete", label: "Projektgeräte", icon: "⚙" },
] as const;
// Separate project sub-routes (/projects/[id]/<segment>).
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;
onToggleCollapsed: () => void;
}
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
const pathname = usePathname();
const { user, isAdmin, logout } = useAuth();
const configEntry = getConfigEntry(isAdmin);
const projectMatch = pathname?.match(PROJECT_PATH_PATTERN);
const activeProjectId = projectMatch ? projectMatch[1] : null;
const projectSubPath = projectMatch?.[2] ?? "";
// Section anchors only exist (and are observed) on the overview itself.
const overviewProjectId =
activeProjectId && (projectSubPath === "" || projectSubPath === "/")
? activeProjectId
: null;
const activeCircuitListId =
projectSubPath.match(CIRCUIT_LIST_PATH_PATTERN)?.[1] ?? null;
const [projects, setProjects] = useState<ProjectDto[]>([]);
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(() => {
function refreshProjects() {
listProjects()
.then(setProjects)
.catch(() => setProjects([]));
}
refreshProjects();
window.addEventListener("leistungsbilanz:projects-changed", refreshProjects);
return () =>
window.removeEventListener("leistungsbilanz:projects-changed", refreshProjects);
}, []);
useEffect(() => {
if (!overviewProjectId) {
setActiveSection(null);
return;
}
const elements = PROJECT_SECTIONS.map((section) =>
document.getElementById(section.id)
).filter((element): element is HTMLElement => element !== null);
if (!elements.length) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible[0]) {
setActiveSection(visible[0].target.id);
}
},
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 }
);
elements.forEach((element) => observer.observe(element));
return () => observer.disconnect();
}, [overviewProjectId]);
return (
<aside className={isCollapsed ? "sidebar sidebar-collapsed" : "sidebar"}>
<div className="sidebar-brand">
<div className="sidebar-brand-text">
<div className="sidebar-wordmark">Leistungsbilanz</div>
<div className="sidebar-bar" />
<div className="sidebar-tag">Stromkreis- &amp; Lastberechnung</div>
</div>
<button
aria-label={isCollapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
className="sidebar-toggle"
onClick={onToggleCollapsed}
title={isCollapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
type="button"
>
{isCollapsed ? "»" : "«"}
</button>
</div>
<nav className="sidebar-nav">
<Link
href="/projects"
className={pathname === "/projects" ? "sidebar-link active" : "sidebar-link"}
title="Alle Projekte"
>
<span className="sidebar-icon">▦</span>
<span className="sidebar-label">Alle Projekte</span>
</Link>
{activeProjectId ? (
<>
<div className="sidebar-section-label">Aktives Projekt</div>
<Link
href={`/projects/${activeProjectId}`}
className={overviewProjectId ? "sidebar-link active" : "sidebar-link"}
title={activeProject?.name ?? "Projekt"}
>
<span className="sidebar-icon">▣</span>
<span className="sidebar-label sidebar-project-name">
{activeProject?.name ?? "Projekt"}
</span>
</Link>
{PROJECT_SECTIONS.map(({ id, label, icon }) => (
<div key={id}>
<div className="sidebar-link-row">
{overviewProjectId ? (
<a
href={`#${id}`}
className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
title={label}
>
<span className="sidebar-icon">{icon}</span>
<span className="sidebar-label">{label}</span>
</a>
) : (
<Link
href={`/projects/${activeProjectId}#${id}`}
className="sidebar-link"
title={label}
>
<span className="sidebar-icon">{icon}</span>
<span className="sidebar-label">{label}</span>
</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 }) => {
const href = `/projects/${activeProjectId}/${segment}`;
const isActive =
pathname === href || Boolean(pathname?.startsWith(`${href}/`));
return (
<Link
key={segment}
href={href}
aria-current={isActive ? "page" : undefined}
className={isActive ? "sidebar-link active" : "sidebar-link"}
title={label}
>
<span className="sidebar-icon">{icon}</span>
<span className="sidebar-label">{label}</span>
</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 className="sidebar-nav sidebar-nav-bottom">
<Link
href={configEntry.href}
className={pathname?.startsWith("/config") ? "sidebar-link active" : "sidebar-link"}
title={configEntry.label}
>
<span className="sidebar-icon">⚙</span>
<span className="sidebar-label">{configEntry.label}</span>
</Link>
</nav>
<div className="sidebar-footer">
{user && !isCollapsed && (
<div className="sidebar-identity" title={`Angemeldet als ${user.name}`}>
<span className="sidebar-identity-label">Angemeldet als</span>
<span className="sidebar-identity-name">{user.name}</span>
</div>
)}
{/* Toggle and Abmelden share one row: the toggle stays a fixed-size
square, Abmelden takes the rest of the width. Collapsed, only
the square toggle remains (centered). */}
<div className="sidebar-bottom-row">
<ThemeToggle />
{user && !isCollapsed && (
<button type="button" className="sidebar-logout" onClick={() => void logout()}>
Abmelden
</button>
)}
</div>
</div>
</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,
}));
}