Merge branch 'main' into feature/multiplesections

This commit is contained in:
2026-07-31 09:00:39 +02:00
6 changed files with 568 additions and 127 deletions
+296 -2
View File
@@ -1,9 +1,303 @@
/* Design-System "Petrol · Volt · Kupfer" v2.1 (H:\design-system-econsult.md),
projektübergreifendes Update für die Econsult-Familie (siehe elt-planung-suite).
Bootstrap bleibt Basis für Formulare/Tabellen/Modals; wir tönen die
vorhandenen Bootstrap-Komponenten über deren eigene --bs-btn-*-Variablen
um (globals.css lädt nach bootstrap.min.css, gleiche Spezifität gewinnt
per Ladereihenfolge) statt Bootstrap zu ersetzen. */
:root {
--color-primary: #1c3f52; /* Petrol */
--color-primary-dark: #0f2733; /* Petrol Dark */
--color-primary-deep: #0a1920; /* Petrol Deep */
--color-accent: #4e7e93; /* Petrol Light */
--color-accent-pale: #9dbac9; /* Petrol Pale */
--color-signal: #3fa66b; /* Volt - generelle aktiv/positiv-Farbe */
--color-signal-dark: #237a47;
--color-attention: #c97a3b; /* Kupfer - bewusst selten, "braucht Aufmerksamkeit" */
--color-attention-dark: #9c5a26;
--color-ink: #182229; /* Graphite */
--color-ink-soft: #59666d; /* Slate */
--color-bg: #ffffff;
--color-surface: #f1f2ee; /* Fog */
--color-border: #dee2dc;
--color-danger: #b04035;
--color-danger-bg: #f7e6e3;
--color-warn: #a9791e;
--font-head: system-ui, "Segoe UI", "Helvetica Neue", sans-serif;
--font-body: system-ui, "Segoe UI", "Helvetica Neue", sans-serif;
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
--radius: 12px;
}
html {
scroll-behavior: smooth;
}
body {
background-color: #f5f7fb;
background-color: var(--color-surface);
color: var(--color-ink);
}
a {
color: var(--color-primary);
}
/* ── Bootstrap-Einfärbung auf Petrol/Volt/Kupfer ────────────────────────── */
.btn-primary {
--bs-btn-bg: var(--color-primary);
--bs-btn-border-color: var(--color-primary);
--bs-btn-hover-bg: var(--color-primary-dark);
--bs-btn-hover-border-color: var(--color-primary-dark);
--bs-btn-active-bg: var(--color-primary-deep);
--bs-btn-active-border-color: var(--color-primary-deep);
--bs-btn-disabled-bg: var(--color-primary);
--bs-btn-disabled-border-color: var(--color-primary);
}
.btn-outline-primary {
--bs-btn-color: var(--color-primary);
--bs-btn-border-color: var(--color-primary);
--bs-btn-hover-bg: var(--color-primary);
--bs-btn-hover-border-color: var(--color-primary);
--bs-btn-active-bg: var(--color-primary-dark);
--bs-btn-active-border-color: var(--color-primary-dark);
}
.btn-success,
.btn-outline-success {
--bs-btn-bg: var(--color-signal);
--bs-btn-border-color: var(--color-signal);
--bs-btn-color: #fff;
--bs-btn-hover-bg: var(--color-signal-dark);
--bs-btn-hover-border-color: var(--color-signal-dark);
--bs-btn-active-bg: var(--color-signal-dark);
--bs-btn-active-border-color: var(--color-signal-dark);
}
.btn-outline-success {
--bs-btn-bg: transparent;
--bs-btn-color: var(--color-signal-dark);
--bs-btn-hover-color: #fff;
}
.form-control:focus,
.form-select:focus {
border-color: var(--color-accent);
box-shadow: 0 0 0 0.25rem rgba(28, 63, 82, 0.15);
}
.form-check-input:checked {
background-color: var(--color-primary);
border-color: var(--color-primary);
}
.text-bg-secondary {
background-color: var(--color-surface) !important;
color: var(--color-ink-soft) !important;
}
.alert-success {
--bs-alert-color: var(--color-signal-dark);
--bs-alert-bg: #eaf6f0;
--bs-alert-border-color: #b6e2ce;
}
/* ── App-Shell / Seitenleiste (nur auf der Projektseite) ────────────────── */
.app-shell {
display: grid;
grid-template-columns: 260px 1fr;
min-height: 100vh;
}
.sidebar {
background: linear-gradient(
160deg,
var(--color-primary-deep) 0%,
var(--color-primary-dark) 45%,
var(--color-primary) 100%
);
color: #fff;
display: flex;
flex-direction: column;
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
.sidebar-brand {
padding: 22px 22px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
}
.sidebar-wordmark {
font-size: 1.2rem;
font-weight: 700;
color: #fff;
}
.sidebar-bar {
height: 3px;
background: linear-gradient(90deg, var(--color-signal), var(--color-attention));
margin-top: 9px;
width: 42px;
border-radius: 2px;
}
.sidebar-tag {
font-size: 0.72rem;
letter-spacing: 0.13em;
text-transform: uppercase;
color: var(--color-accent-pale);
margin-top: 10px;
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px;
}
.sidebar-section-label {
font-size: 0.66rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: rgba(199, 214, 224, 0.7);
padding: 16px 14px 4px;
}
.sidebar-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
color: #c7d6e0;
text-decoration: none;
font-size: 0.92rem;
font-weight: 500;
border: 0;
background: transparent;
width: 100%;
text-align: left;
cursor: pointer;
}
.sidebar-link:hover {
background: rgba(255, 255, 255, 0.08);
color: #fff;
text-decoration: none;
}
.sidebar-link.active {
background: rgba(255, 255, 255, 0.14);
color: #fff;
box-shadow: inset 3px 0 0 var(--color-signal);
}
.sidebar-icon {
width: 1.1em;
display: inline-flex;
justify-content: center;
}
.app-shell-content {
width: 100%;
min-width: 0;
}
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--space-md);
margin-bottom: var(--space-lg);
flex-wrap: wrap;
}
.page-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-primary);
}
.kicker {
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-accent);
margin-bottom: 2px;
font-weight: 600;
}
/* ── KPI-Kacheln ─────────────────────────────────────────────────────── */
.kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--space-md);
margin-bottom: var(--space-lg);
}
.kpi {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 9px;
padding: 13px 15px;
display: block;
color: inherit;
text-decoration: none;
transition: box-shadow 0.15s, transform 0.15s;
}
a.kpi:hover {
color: inherit;
text-decoration: none;
box-shadow: 0 3px 8px rgba(15, 39, 51, 0.07), 0 16px 32px -14px rgba(15, 39, 51, 0.28);
transform: translateY(-1px);
}
.kpi-label {
font-size: 0.66rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-ink-soft);
margin-bottom: var(--space-xs);
font-weight: 600;
display: flex;
align-items: center;
gap: 6px;
}
.kpi-value {
font-size: 1.2rem;
font-weight: 700;
line-height: 1.1;
color: var(--color-primary);
}
.card {
border-radius: 0.5rem;
border-radius: var(--radius);
border-top: 3px solid var(--color-primary);
box-shadow:
0 1px 2px rgba(15, 39, 51, 0.05),
0 10px 24px -14px rgba(15, 39, 51, 0.22);
}
.card-header {
font-weight: 700;
color: var(--color-primary);
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
}
.table td input.form-control-sm,
+4 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import "bootstrap/dist/css/bootstrap.min.css";
import "./globals.css";
import { AppShell } from "../frontend/components/AppShell";
export const metadata: Metadata = {
title: "Leistungsbilanz",
@@ -10,7 +11,9 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="de">
<body>{children}</body>
<body>
<AppShell>{children}</AppShell>
</body>
</html>
);
}
+27 -7
View File
@@ -661,9 +661,10 @@ export default function ProjectDetailPage() {
return (
<main className="container py-4">
<div className="d-flex flex-wrap justify-content-between align-items-center gap-3 mb-3">
<div className="page-header">
<div>
<h1 className="h3 mb-1">{project?.name ?? "Projekt"}</h1>
<div className="kicker">Projekt</div>
<h1>{project?.name ?? "Projekt"}</h1>
<p className="text-secondary mb-0">
{project?.internalProjectNumber
? `Projektnummer ${project.internalProjectNumber} · `
@@ -688,9 +689,28 @@ export default function ProjectDetailPage() {
{error ? <div className="alert alert-warning">{error}</div> : null}
<div className="kpi-grid">
<a className="kpi" href="#verteilungen">
<div className="kpi-label">Verteilungen</div>
<div className="kpi-value">{boardCount}</div>
</a>
<a className="kpi" href="#etagen">
<div className="kpi-label">Etagen</div>
<div className="kpi-value">{floorCount}</div>
</a>
<a className="kpi" href="#raeume">
<div className="kpi-label">Räume</div>
<div className="kpi-value">{roomCount}</div>
</a>
<a className="kpi" href="#projektgeraete">
<div className="kpi-label">Projektgeräte</div>
<div className="kpi-value">{projectDeviceCount}</div>
</a>
</div>
<div className="row g-4">
{project ? (
<section className="col-12">
<section className="col-12" id="verlauf">
<ProjectVersionHistory
currentRevision={project.currentRevision}
onProjectStateChange={() => window.location.reload()}
@@ -699,7 +719,7 @@ export default function ProjectDetailPage() {
</section>
) : null}
<section className="col-12">
<section className="col-12" id="verteilungen">
<div className="card shadow-sm">
<div className="card-header d-flex justify-content-between align-items-center">
<div>
@@ -792,7 +812,7 @@ export default function ProjectDetailPage() {
</div>
</section>
<section className="col-12 col-lg-5">
<section className="col-12 col-lg-5" id="etagen">
<div className="card shadow-sm h-100">
<div className="card-header d-flex justify-content-between align-items-center">
<div>
@@ -820,7 +840,7 @@ export default function ProjectDetailPage() {
</div>
</section>
<section className="col-12 col-lg-7">
<section className="col-12 col-lg-7" id="raeume">
<div className="card shadow-sm">
<div className="card-header d-flex justify-content-between align-items-center">
<div>
@@ -866,7 +886,7 @@ export default function ProjectDetailPage() {
</section>
</div>
<section className="card shadow-sm mt-4">
<section className="card shadow-sm mt-4" id="projektgeraete">
<div className="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<span>Projektgeräte</span>
+98 -117
View File
@@ -8,15 +8,15 @@ import {
deleteGlobalDevice,
importProjectTransferAsNew,
listGlobalDevices,
listProjects,
updateGlobalDevice,
} from "../../frontend/utils/api";
import type {
CreateGlobalDeviceInput,
GlobalDeviceDto,
ProjectDto,
} from "../../frontend/types";
const PROJECTS_CHANGED_EVENT = "leistungsbilanz:projects-changed";
const emptyGlobalDevice: CreateGlobalDeviceInput = {
name: "",
displayName: "",
@@ -38,7 +38,6 @@ function toOptionalNumber(value: string) {
}
export default function ProjectsPage() {
const [projects, setProjects] = useState<ProjectDto[]>([]);
const [globalDevices, setGlobalDevices] = useState<GlobalDeviceDto[]>([]);
const [projectName, setProjectName] = useState("");
const [globalDeviceForm, setGlobalDeviceForm] = useState<Record<string, string>>({
@@ -64,12 +63,14 @@ export default function ProjectsPage() {
projectId: string;
name: string;
} | null>(null);
const [createdProject, setCreatedProject] = useState<{
id: string;
name: string;
} | null>(null);
async function loadData() {
setError(null);
const [loadedProjects, loadedGlobalDevices] = await Promise.all([listProjects(), listGlobalDevices()]);
setProjects(loadedProjects);
setGlobalDevices(loadedGlobalDevices);
setGlobalDevices(await listGlobalDevices());
}
useEffect(() => {
@@ -85,10 +86,12 @@ export default function ProjectsPage() {
}
setIsSaving(true);
setError(null);
setCreatedProject(null);
try {
const created = await createProject(projectName.trim());
setProjects((current) => [...current, created]);
setCreatedProject({ id: created.id, name: created.name });
setProjectName("");
window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT));
} catch (err) {
setError(err instanceof Error ? err.message : "Projekt konnte nicht erstellt werden.");
} finally {
@@ -123,11 +126,11 @@ export default function ProjectsPage() {
setImportedProject(null);
try {
const imported = await importProjectTransferAsNew(projectImport);
setProjects(await listProjects());
setImportedProject(imported);
setProjectImport(null);
setProjectImportFilename("");
setProjectImportInputKey((current) => current + 1);
window.dispatchEvent(new Event(PROJECTS_CHANGED_EVENT));
} catch (err) {
setError(
err instanceof Error
@@ -222,130 +225,107 @@ export default function ProjectsPage() {
return (
<main className="container py-4">
<div className="d-flex justify-content-between align-items-center mb-4">
<div className="page-header">
<div>
<h1 className="h3 mb-1">Projekte</h1>
<div className="kicker">Leistungsbilanz</div>
<h1>Projekte</h1>
<p className="text-secondary mb-0">Projektübersicht und globale Geräteverwaltung</p>
</div>
</div>
{error ? <div className="alert alert-warning">{error}</div> : null}
<div className="row g-4">
<section className="col-12 col-lg-4">
<div className="card shadow-sm mb-4">
<div className="card-header">Neues Projekt</div>
<div className="card-body">
<form className="vstack gap-3" onSubmit={handleCreateProject}>
<div>
<label className="form-label">Projektname</label>
<input
className="form-control"
value={projectName}
onChange={(event) => setProjectName(event.target.value)}
placeholder="z. B. Neubau Schule"
/>
</div>
<button className="btn btn-primary" type="submit" disabled={isSaving}>
Projekt erstellen
</button>
</form>
</div>
</div>
<div className="card shadow-sm">
<div className="card-header">Projekt importieren</div>
<div className="card-body">
<p className="text-secondary small">
Eine exportierte JSON-Projektdatei wird als eigenständige Kopie
mit neuen internen IDs angelegt.
</p>
<label className="form-label" htmlFor="project-overview-import">
Projektdatei auswählen
</label>
<input
accept=".json,application/json"
className="form-control"
disabled={isSaving}
id="project-overview-import"
key={projectImportInputKey}
onChange={(event) =>
void handleProjectImportFile(event.target.files?.[0])
}
type="file"
/>
{projectImportFilename ? (
<div className="form-text">
{projectImportFilename} ist bereit.
</div>
) : null}
{projectImportFileError ? (
<div className="text-danger small mt-1">
{projectImportFileError}
</div>
) : null}
<button
className="btn btn-outline-primary mt-3 w-100"
disabled={isSaving || projectImport === null}
onClick={() => void handleImportProject()}
type="button"
>
Als neues Projekt importieren
<div className="d-flex flex-column gap-4">
<section className="card shadow-sm">
<div className="card-header">Neues Projekt</div>
<div className="card-body" style={{ maxWidth: 480 }}>
<form className="vstack gap-3" onSubmit={handleCreateProject}>
<div>
<label className="form-label">Projektname</label>
<input
className="form-control"
value={projectName}
onChange={(event) => setProjectName(event.target.value)}
placeholder="z. B. Neubau Schule"
/>
</div>
<button className="btn btn-primary" type="submit" disabled={isSaving}>
Projekt erstellen
</button>
{importedProject ? (
<div className="alert alert-success mt-3 mb-0">
<div className="small mb-2">
{importedProject.name} wurde angelegt.
</div>
<Link
className="btn btn-sm btn-success"
href={`/projects/${importedProject.projectId}`}
>
Importiertes Projekt öffnen
</Link>
</form>
{createdProject ? (
<div className="alert alert-success mt-3 mb-0">
<div className="small mb-2">
{createdProject.name} wurde angelegt.
</div>
) : null}
</div>
<Link
className="btn btn-sm btn-success"
href={`/projects/${createdProject.id}`}
>
Projekt öffnen
</Link>
</div>
) : null}
</div>
</section>
<section className="col-12 col-lg-8">
<div className="card shadow-sm">
<div className="card-header">Alle Projekte</div>
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Projekt</th>
<th className="text-end">Aktionen</th>
</tr>
</thead>
<tbody>
{projects.map((project) => (
<tr key={project.id}>
<td>{project.name}</td>
<td className="text-end">
<Link className="btn btn-sm btn-outline-primary" href={`/projects/${project.id}`}>
Projekt öffnen
</Link>
</td>
</tr>
))}
{!projects.length ? (
<tr>
<td colSpan={2} className="text-center text-secondary py-4">
Noch keine Projekte vorhanden.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<section className="card shadow-sm">
<div className="card-header">Projekt importieren</div>
<div className="card-body" style={{ maxWidth: 480 }}>
<p className="text-secondary small">
Eine exportierte JSON-Projektdatei wird als eigenständige Kopie
mit neuen internen IDs angelegt.
</p>
<label className="form-label" htmlFor="project-overview-import">
Projektdatei auswählen
</label>
<input
accept=".json,application/json"
className="form-control"
disabled={isSaving}
id="project-overview-import"
key={projectImportInputKey}
onChange={(event) =>
void handleProjectImportFile(event.target.files?.[0])
}
type="file"
/>
{projectImportFilename ? (
<div className="form-text">
{projectImportFilename} ist bereit.
</div>
) : null}
{projectImportFileError ? (
<div className="text-danger small mt-1">
{projectImportFileError}
</div>
) : null}
<button
className="btn btn-outline-primary mt-3 w-100"
disabled={isSaving || projectImport === null}
onClick={() => void handleImportProject()}
type="button"
>
Als neues Projekt importieren
</button>
{importedProject ? (
<div className="alert alert-success mt-3 mb-0">
<div className="small mb-2">
{importedProject.name} wurde angelegt.
</div>
<Link
className="btn btn-sm btn-success"
href={`/projects/${importedProject.projectId}`}
>
Importiertes Projekt öffnen
</Link>
</div>
) : null}
</div>
</section>
</div>
<section className="card shadow-sm mt-4">
<div className="card-header">Globale Geräte / Verbraucher</div>
<section className="card shadow-sm">
<div className="card-header">Globale Geräte / Verbraucher</div>
<div className="card-body border-bottom">
<form className="row g-2" onSubmit={handleCreateGlobalDevice}>
<div className="col-12 col-md-3">
@@ -524,6 +504,7 @@ export default function ProjectsPage() {
</table>
</div>
</section>
</div>
</main>
);
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import { usePathname } from "next/navigation";
import { Sidebar } from "./Sidebar";
const PROJECT_AREA_PATTERN = /^\/projects(\/[^/]+)?$/;
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const showSidebar = PROJECT_AREA_PATTERN.test(pathname ?? "");
if (!showSidebar) {
return <>{children}</>;
}
return (
<div className="app-shell">
<Sidebar />
<div className="app-shell-content">{children}</div>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
"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";
const PROJECT_SECTIONS = [
{ id: "verlauf", label: "Verlauf", icon: "↺" },
{ id: "verteilungen", label: "Verteilungen", icon: "⌁" },
{ id: "etagen", label: "Etagen", icon: "▥" },
{ id: "raeume", label: "Räume", icon: "⌂" },
{ id: "projektgeraete", label: "Projektgeräte", icon: "⚙" },
] as const;
export function Sidebar() {
const pathname = usePathname();
const projectMatch = pathname?.match(/^\/projects\/([^/]+)$/);
const activeProjectId = projectMatch ? projectMatch[1] : 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 (!activeProjectId) {
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();
}, [activeProjectId]);
return (
<aside className="sidebar">
<div className="sidebar-brand">
<div className="sidebar-wordmark">Leistungsbilanz</div>
<div className="sidebar-bar" />
<div className="sidebar-tag">Stromkreis- &amp; Lastberechnung</div>
</div>
<nav className="sidebar-nav">
<Link
href="/projects"
className={pathname === "/projects" ? "sidebar-link active" : "sidebar-link"}
>
<span className="sidebar-icon"></span>
Alle Projekte
</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"
}
>
<span className="sidebar-icon"></span>
{project.name}
</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 }) => (
<a
key={id}
href={`#${id}`}
className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
>
<span className="sidebar-icon">{icon}</span>
{label}
</a>
))}
</>
)}
</nav>
</aside>
);
}