Merge branch 'main' into feature/multiplesections
This commit is contained in:
+296
-2
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+35
-54
@@ -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,20 +225,20 @@ 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="d-flex flex-column gap-4">
|
||||
<section className="card shadow-sm">
|
||||
<div className="card-header">Neues Projekt</div>
|
||||
<div className="card-body">
|
||||
<div className="card-body" style={{ maxWidth: 480 }}>
|
||||
<form className="vstack gap-3" onSubmit={handleCreateProject}>
|
||||
<div>
|
||||
<label className="form-label">Projektname</label>
|
||||
@@ -250,11 +253,25 @@ export default function ProjectsPage() {
|
||||
Projekt erstellen
|
||||
</button>
|
||||
</form>
|
||||
{createdProject ? (
|
||||
<div className="alert alert-success mt-3 mb-0">
|
||||
<div className="small mb-2">
|
||||
„{createdProject.name}“ wurde angelegt.
|
||||
</div>
|
||||
<Link
|
||||
className="btn btn-sm btn-success"
|
||||
href={`/projects/${createdProject.id}`}
|
||||
>
|
||||
Projekt öffnen
|
||||
</Link>
|
||||
</div>
|
||||
<div className="card shadow-sm">
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card shadow-sm">
|
||||
<div className="card-header">Projekt importieren</div>
|
||||
<div className="card-body">
|
||||
<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.
|
||||
@@ -305,46 +322,9 @@ export default function ProjectsPage() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="card shadow-sm mt-4">
|
||||
<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}>
|
||||
@@ -524,6 +504,7 @@ export default function ProjectsPage() {
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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- & 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user