leistungsbilanz-ts/src/frontend/components/Sidebar.tsx
Grovy311 37a70b6b36 Add the rough power balance module for early design phases
In LPH 2/3 there are no circuits yet, but the office needs a power
balance by DIN 276 cost group, as in the Excel template "Leistungsbilanz -
KG400": each trade enters consumers by piece or by area (W/m²) with GLF
and cos phi; the balance sums AV and SV per cost group, adds a reserve and
derives the maximum current.

The module is separate from the circuit structure: own tables
(migration 0010), own folder src/estimate-balance and an import-free
shared calculation used by API and UI. Writes go through project commands
(estimate-balance.insert/.update/.delete with complete documents), so
revision checks and Undo/Redo apply; it is not part of project snapshots
or transfer yet. A comparison endpoint sums the device rows of all circuit
lists by cost group (longest prefix) against the estimate.

The UI adds a sidebar entry, a list of Stände (template KG 400, empty or
copy) and a spreadsheet-like editor with autosave, AMEV picker for the
GLF, totals, reserve, maximum current, comparison table and print layout.
The tests reproduce the template example exactly (143.24 kW, 148.48 kVA,
157.56 kW / 163.33 kVA with 10 % reserve, 235.75 A). See
docs/estimate-balance.md and issue #7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 17:39:28 +02:00

187 lines
6.3 KiB
TypeScript

"use client";
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 { ThemeToggle } from "./ThemeToggle";
// 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: "Σ" },
] as const;
// Matches the project overview and all of its sub-routes (group 2 = rest).
const PROJECT_PATH_PATTERN = /^\/projects\/([^/]+)(\/.*)?$/;
interface SidebarProps {
isCollapsed: boolean;
onToggleCollapsed: () => void;
}
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
const pathname = usePathname();
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 [projects, setProjects] = useState<ProjectDto[]>([]);
const [activeSection, setActiveSection] = useState<string | null>(null);
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>
<div className="sidebar-section-label">Projekte</div>
{projects.map((project) => (
<Link
key={project.id}
href={`/projects/${project.id}`}
className={
project.id === activeProjectId ? "sidebar-link active" : "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}
{activeProjectId && (
<>
<div className="sidebar-section-label">In diesem Projekt</div>
{PROJECT_SECTIONS.map(({ id, label, icon }) =>
overviewProjectId ? (
<a
key={id}
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
key={id}
href={`/projects/${activeProjectId}#${id}`}
className="sidebar-link"
title={label}
>
<span className="sidebar-icon">{icon}</span>
<span className="sidebar-label">{label}</span>
</Link>
)
)}
{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>
);
})}
</>
)}
</nav>
<div className="sidebar-footer">
<ThemeToggle />
</div>
</aside>
);
}