From 704f4df2a8b9e5b1e0685c2a61aeb361ff8940da Mon Sep 17 00:00:00 2001 From: Grovy311 Date: Thu, 30 Jul 2026 17:40:11 +0200 Subject: [PATCH] Apply Petrol/Volt/Kupfer design system and project sidebar Bring the Econsult-family design tokens (see design-system-econsult.md) into leistungsbilanz-ts and add a persistent sidebar for the projects area, mirroring elt-planung-suite / Einsatz-Orga. The sidebar lists all projects and, inside a project, jumps to its sections; the tree editor stays sidebar-free since it needs the full width. The projects overview page is decluttered to a single stacked column (create/import/global devices) now that the project list lives in the sidebar. --- src/app/globals.css | 298 +++++++++++++++++++++++++- src/app/layout.tsx | 5 +- src/app/projects/[projectId]/page.tsx | 34 ++- src/app/projects/page.tsx | 215 +++++++++---------- src/frontend/components/AppShell.tsx | 22 ++ src/frontend/components/Sidebar.tsx | 121 +++++++++++ 6 files changed, 568 insertions(+), 127 deletions(-) create mode 100644 src/frontend/components/AppShell.tsx create mode 100644 src/frontend/components/Sidebar.tsx diff --git a/src/app/globals.css b/src/app/globals.css index 351a429..a6c31c4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a67de86..01df63e 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - {children} + + {children} + ); } diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index b574312..f7945df 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -661,9 +661,10 @@ export default function ProjectDetailPage() { return (
-
+
-

{project?.name ?? "Projekt"}

+
Projekt
+

{project?.name ?? "Projekt"}

{project?.internalProjectNumber ? `Projektnummer ${project.internalProjectNumber} · ` @@ -688,9 +689,28 @@ export default function ProjectDetailPage() { {error ?

{error}
: null} + +
{project ? ( -
+
window.location.reload()} @@ -699,7 +719,7 @@ export default function ProjectDetailPage() {
) : null} -
+
@@ -792,7 +812,7 @@ export default function ProjectDetailPage() {
-
+
@@ -820,7 +840,7 @@ export default function ProjectDetailPage() {
-
+
@@ -866,7 +886,7 @@ export default function ProjectDetailPage() {
-
+
Projektgeräte diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 14a459d..93a2d14 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -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([]); const [globalDevices, setGlobalDevices] = useState([]); const [projectName, setProjectName] = useState(""); const [globalDeviceForm, setGlobalDeviceForm] = useState>({ @@ -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 (
-
+
-

Projekte

+
Leistungsbilanz
+

Projekte

Projektübersicht und globale Geräteverwaltung

{error ?
{error}
: null} -
-
-
-
Neues Projekt
-
-
-
- - setProjectName(event.target.value)} - placeholder="z. B. Neubau Schule" - /> -
- -
-
-
-
-
Projekt importieren
-
-

- Eine exportierte JSON-Projektdatei wird als eigenständige Kopie - mit neuen internen IDs angelegt. -

- - - void handleProjectImportFile(event.target.files?.[0]) - } - type="file" - /> - {projectImportFilename ? ( -
- {projectImportFilename} ist bereit. -
- ) : null} - {projectImportFileError ? ( -
- {projectImportFileError} -
- ) : null} - - {importedProject ? ( -
-
- „{importedProject.name}“ wurde angelegt. -
- - Importiertes Projekt öffnen - + + {createdProject ? ( +
+
+ „{createdProject.name}“ wurde angelegt.
- ) : null} -
+ + Projekt öffnen + +
+ ) : null}
-
-
-
Alle Projekte
-
- - - - - - - - - {projects.map((project) => ( - - - - - ))} - {!projects.length ? ( - - - - ) : null} - -
ProjektAktionen
{project.name} - - Projekt öffnen - -
- Noch keine Projekte vorhanden. -
-
+
+
Projekt importieren
+
+

+ Eine exportierte JSON-Projektdatei wird als eigenständige Kopie + mit neuen internen IDs angelegt. +

+ + + void handleProjectImportFile(event.target.files?.[0]) + } + type="file" + /> + {projectImportFilename ? ( +
+ {projectImportFilename} ist bereit. +
+ ) : null} + {projectImportFileError ? ( +
+ {projectImportFileError} +
+ ) : null} + + {importedProject ? ( +
+
+ „{importedProject.name}“ wurde angelegt. +
+ + Importiertes Projekt öffnen + +
+ ) : null}
-
-
-
Globale Geräte / Verbraucher
+
+
Globale Geräte / Verbraucher
@@ -524,6 +504,7 @@ export default function ProjectsPage() {
+
); } diff --git a/src/frontend/components/AppShell.tsx b/src/frontend/components/AppShell.tsx new file mode 100644 index 0000000..ba08175 --- /dev/null +++ b/src/frontend/components/AppShell.tsx @@ -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 ( +
+ +
{children}
+
+ ); +} diff --git a/src/frontend/components/Sidebar.tsx b/src/frontend/components/Sidebar.tsx new file mode 100644 index 0000000..f1c0dbc --- /dev/null +++ b/src/frontend/components/Sidebar.tsx @@ -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([]); + const [activeSection, setActiveSection] = useState(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 ( + + ); +}