forked from jappel/leistungsbilanz-ts
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>
372 lines
14 KiB
TypeScript
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- & 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,
|
|
}));
|
|
}
|