diff --git a/.dockerignore b/.dockerignore
index 69951f8..80054d0 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,8 +1,8 @@
.git
-.agents
-.codex
.next
-data
dist
node_modules
-npm-debug.log*
+data
+*.db
+.env
+.env.*
diff --git a/Dockerfile b/Dockerfile
index 70e7761..41a08fd 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,12 +1,14 @@
-FROM node:22-bookworm-slim
-
+FROM node:22
WORKDIR /app
-ENV NEXT_TELEMETRY_DISABLED=1
-
-COPY package.json package-lock.json ./
+COPY package*.json ./
RUN npm ci
COPY . .
+RUN npm run build:api && npm run build:web
-EXPOSE 3000 3001
+RUN mkdir -p data && chmod +x scripts/docker-start.sh
+
+EXPOSE 3001
+
+CMD ["sh", "scripts/docker-start.sh"]
diff --git a/scripts/docker-start.sh b/scripts/docker-start.sh
new file mode 100644
index 0000000..bf0d856
--- /dev/null
+++ b/scripts/docker-start.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+set -e
+
+echo "Running migrations..."
+node scripts/run-migrations.js
+
+echo "Starting API server on :3000..."
+node dist/server/index.js &
+
+echo "Waiting for API..."
+until node -e "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" 2>/dev/null; do
+ sleep 1
+done
+
+echo "Starting Next.js on :3001..."
+exec node_modules/.bin/next start -p 3001
diff --git a/scripts/run-migrations.js b/scripts/run-migrations.js
new file mode 100644
index 0000000..ebbed8a
--- /dev/null
+++ b/scripts/run-migrations.js
@@ -0,0 +1,16 @@
+const { migrate } = require('drizzle-orm/better-sqlite3/migrator');
+const { drizzle } = require('drizzle-orm/better-sqlite3');
+const Database = require('better-sqlite3');
+const fs = require('node:fs');
+const path = require('node:path');
+
+const dataDir = path.resolve('data');
+if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
+
+const sqlite = new Database(path.join(dataDir, 'leistungsbilanz.db'));
+const db = drizzle(sqlite);
+
+migrate(db, { migrationsFolder: path.resolve('src/db/migrations') });
+sqlite.close();
+
+console.log('Migrations applied');
diff --git a/src/app/globals.css b/src/app/globals.css
index 27b2d63..9c07a40 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1219,3 +1219,68 @@ a.kpi:hover {
max-height: calc(100vh - 5.5rem);
}
}
+
+/* ── Seitenleiste: eingeklappter Zustand ─────────────────────────────── */
+
+.app-shell {
+ transition: grid-template-columns 0.18s ease;
+}
+
+.app-shell-collapsed {
+ grid-template-columns: 64px 1fr;
+}
+
+.sidebar {
+ transition: padding 0.18s ease;
+}
+
+.sidebar-brand {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.sidebar-toggle {
+ background: rgba(255, 255, 255, 0.08);
+ border: 0;
+ border-radius: 6px;
+ color: #fff;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ font-size: 0.8rem;
+ line-height: 1;
+ width: 26px;
+ height: 26px;
+}
+
+.sidebar-toggle:hover {
+ background: rgba(255, 255, 255, 0.18);
+}
+
+.sidebar-collapsed .sidebar-brand {
+ padding: 16px 10px;
+ flex-direction: column;
+ align-items: center;
+}
+
+.sidebar-collapsed .sidebar-brand-text {
+ display: none;
+}
+
+.sidebar-collapsed .sidebar-nav {
+ padding: 12px 8px;
+}
+
+.sidebar-collapsed .sidebar-link {
+ justify-content: center;
+ padding: 10px;
+}
+
+.sidebar-collapsed .sidebar-section-label,
+.sidebar-collapsed .sidebar-label {
+ display: none;
+}
diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx
index 93a2d14..21409fd 100644
--- a/src/app/projects/page.tsx
+++ b/src/app/projects/page.tsx
@@ -14,6 +14,7 @@ import type {
CreateGlobalDeviceInput,
GlobalDeviceDto,
} from "../../frontend/types";
+import { GzfPickerModal } from "../../frontend/components/gzf-picker-modal";
const PROJECTS_CHANGED_EVENT = "leistungsbilanz:projects-changed";
@@ -67,6 +68,7 @@ export default function ProjectsPage() {
id: string;
name: string;
} | null>(null);
+ const [isGzfPickerOpen, setIsGzfPickerOpen] = useState(false);
async function loadData() {
setError(null);
@@ -380,18 +382,28 @@ export default function ProjectsPage() {
}
/>
-
-
- setGlobalDeviceForm((current) => ({ ...current, demandFactor: event.target.value }))
- }
- />
+
+
+ {isGzfPickerOpen ? (
+
setIsGzfPickerOpen(false)}
+ onSelect={(value) => {
+ setGlobalDeviceForm((current) => ({ ...current, demandFactor: String(value) }));
+ setIsGzfPickerOpen(false);
+ }}
+ />
+ ) : null}
);
}
diff --git a/src/frontend/components/AppShell.tsx b/src/frontend/components/AppShell.tsx
index ba08175..bbfa0ae 100644
--- a/src/frontend/components/AppShell.tsx
+++ b/src/frontend/components/AppShell.tsx
@@ -1,21 +1,36 @@
"use client";
+import { useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { Sidebar } from "./Sidebar";
const PROJECT_AREA_PATTERN = /^\/projects(\/[^/]+)?$/;
+const SIDEBAR_COLLAPSED_STORAGE_KEY = "leistungsbilanz:sidebar-collapsed";
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const showSidebar = PROJECT_AREA_PATTERN.test(pathname ?? "");
+ const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
+
+ useEffect(() => {
+ setIsSidebarCollapsed(window.localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === "1");
+ }, []);
+
+ function toggleSidebarCollapsed() {
+ setIsSidebarCollapsed((current) => {
+ const next = !current;
+ window.localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, next ? "1" : "0");
+ return next;
+ });
+ }
if (!showSidebar) {
return <>{children}>;
}
return (
-
-
+
);
diff --git a/src/frontend/components/Sidebar.tsx b/src/frontend/components/Sidebar.tsx
index f1c0dbc..f58c4f6 100644
--- a/src/frontend/components/Sidebar.tsx
+++ b/src/frontend/components/Sidebar.tsx
@@ -14,7 +14,12 @@ const PROJECT_SECTIONS = [
{ id: "projektgeraete", label: "Projektgeräte", icon: "⚙" },
] as const;
-export function Sidebar() {
+interface SidebarProps {
+ isCollapsed: boolean;
+ onToggleCollapsed: () => void;
+}
+
+export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
const pathname = usePathname();
const projectMatch = pathname?.match(/^\/projects\/([^/]+)$/);
const activeProjectId = projectMatch ? projectMatch[1] : null;
@@ -66,19 +71,31 @@ export function Sidebar() {
}, [activeProjectId]);
return (
-
+ {isGzfPickerOpen ? (
+ setIsGzfPickerOpen(false)}
+ onSelect={(value) => {
+ update("simultaneityFactor", String(value));
+ setIsGzfPickerOpen(false);
+ }}
+ />
+ ) : null}
+ >
);
}
diff --git a/src/shared/constants/amev-gzf.ts b/src/shared/constants/amev-gzf.ts
new file mode 100644
index 0000000..0833091
--- /dev/null
+++ b/src/shared/constants/amev-gzf.ts
@@ -0,0 +1,38 @@
+export interface AmevGzfEntry {
+ group: string;
+ range: string;
+ mean: number;
+ overallRange?: string;
+}
+
+// Quelle: AMEV „Planungshilfe für elektrische Leistungsbilanzen" (rev. 4),
+// Tabelle 3 – Gleichzeitigkeitsfaktoren für Verbrauchergruppen.
+export const AMEV_GZF_TABLE: AmevGzfEntry[] = [
+ { group: "Absaugung, Digestorien", range: "0,7", mean: 0.7 },
+ { group: "Aufzüge/Rolltreppen", range: "0,2 – 0,7", mean: 0.5, overallRange: "0,2" },
+ { group: "Beleuchtungsanlagen", range: "0,7 – 0,8", mean: 0.75, overallRange: "0,4" },
+ { group: "Beleuchtungsanlagen in innenliegenden Räumen", range: "0,7 – 0,9", mean: 0.8 },
+ { group: "IuK-Anlagen", range: "1", mean: 1, overallRange: "1" },
+ { group: "Experimentieranlagen", range: "0,2 – 0,4", mean: 0.3 },
+ { group: "Heizung", range: "0,7 – 1", mean: 0.85 },
+ { group: "Kälteanlagen", range: "0,8 – 1", mean: 0.9, overallRange: "0,4 – 0,5" },
+ { group: "Küchen elektrisch ohne Energieoptimierung", range: "0,3 – 0,7", mean: 0.6 },
+ { group: "Küchen elektrisch mit Energieoptimierung", range: "0,2 – 0,6", mean: 0.4 },
+ { group: "Lastenaufzüge, Krananlagen", range: "0,2", mean: 0.2 },
+ {
+ group: "Lüftungsanlagen, kontrollierte Lüftung",
+ range: "0,7",
+ mean: 0.7,
+ overallRange: "0,4 – 0,5",
+ },
+ {
+ group: "Steckdosen 230 V allg. Verbraucher (100 W/Steckdose)",
+ range: "0,1 – 0,3",
+ mean: 0.2,
+ },
+ { group: "Steckdosen 230 V für IuK (100 W/Steckdose)", range: "0,7 – 0,9", mean: 0.8 },
+ { group: "Steckdosen 400 V (1000 W/Steckdose)", range: "0,1 – 0,5", mean: 0.35 },
+ { group: "Schmutz-, Warmwasserpumpen", range: "0,2 – 0,4", mean: 0.3 },
+ { group: "Umwälzpumpen", range: "0,6 – 1", mean: 0.8 },
+ { group: "Werkstätten", range: "0,2 – 0,4", mean: 0.3 },
+];