diff --git a/src/app/globals.css b/src/app/globals.css
index e66489a..826bfcc 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}
-
-
-
-
-
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}
-