Add AMEV Gleichzeitigkeitsfaktor-Auswähler und einklappbare Seitenleiste

- Gzf-Picker-Modal mit AMEV-Tabelle 3 (Planungshilfe für elektrische
  Leistungsbilanzen) im Projektgerät-Modal und im globalen Geräteformular
- Docker-Deploy-Tooling (docker-start.sh, run-migrations.js) für den
  produktiven Container
- Seitenleiste ist jetzt einklappbar (Icon-only, Zustand in localStorage)
This commit is contained in:
Grovy311
2026-08-03 17:07:14 +02:00
parent 74bea1a3c9
commit 9bd22dfcb7
11 changed files with 349 additions and 41 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
.git
.agents
.codex
.next
data
dist
node_modules
npm-debug.log*
data
*.db
.env
.env.*
+8 -6
View File
@@ -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"]
+16
View File
@@ -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
+16
View File
@@ -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');
+65
View File
@@ -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;
}
+23 -1
View File
@@ -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,7 +382,8 @@ export default function ProjectsPage() {
}
/>
</div>
<div className="col-6 col-md-1">
<div className="col-6 col-md-2">
<div className="input-group">
<input
className="form-control"
type="number"
@@ -392,6 +395,15 @@ export default function ProjectsPage() {
setGlobalDeviceForm((current) => ({ ...current, demandFactor: event.target.value }))
}
/>
<button
className="btn btn-outline-primary"
onClick={() => setIsGzfPickerOpen(true)}
title="Gleichzeitigkeitsfaktor nach AMEV wählen"
type="button"
>
AMEV
</button>
</div>
</div>
<div className="col-6 col-md-1">
<select
@@ -505,6 +517,16 @@ export default function ProjectsPage() {
</div>
</section>
</div>
{isGzfPickerOpen ? (
<GzfPickerModal
onClose={() => setIsGzfPickerOpen(false)}
onSelect={(value) => {
setGlobalDeviceForm((current) => ({ ...current, demandFactor: String(value) }));
setIsGzfPickerOpen(false);
}}
/>
) : null}
</main>
);
}
+17 -2
View File
@@ -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 (
<div className="app-shell">
<Sidebar />
<div className={isSidebarCollapsed ? "app-shell app-shell-collapsed" : "app-shell"}>
<Sidebar isCollapsed={isSidebarCollapsed} onToggleCollapsed={toggleSidebarCollapsed} />
<div className="app-shell-content">{children}</div>
</div>
);
+24 -5
View File
@@ -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 (
<aside className="sidebar">
<aside className={isCollapsed ? "sidebar sidebar-collapsed" : "sidebar"}>
<div className="sidebar-brand">
<div className="sidebar-brand-text">
<div className="sidebar-wordmark">Leistungsbilanz</div>
<div className="sidebar-bar" />
<div className="sidebar-tag">Stromkreis- &amp; Lastberechnung</div>
</div>
<button
aria-label={isCollapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
className="sidebar-toggle"
onClick={onToggleCollapsed}
title={isCollapsed ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
type="button"
>
{isCollapsed ? "»" : "«"}
</button>
</div>
<nav className="sidebar-nav">
<Link
href="/projects"
className={pathname === "/projects" ? "sidebar-link active" : "sidebar-link"}
title="Alle Projekte"
>
<span className="sidebar-icon"></span>
Alle Projekte
<span className="sidebar-label">Alle Projekte</span>
</Link>
<div className="sidebar-section-label">Projekte</div>
@@ -89,9 +106,10 @@ export function Sidebar() {
className={
project.id === activeProjectId ? "sidebar-link active" : "sidebar-link"
}
title={project.name}
>
<span className="sidebar-icon"></span>
{project.name}
<span className="sidebar-label">{project.name}</span>
</Link>
))}
{!projects.length ? (
@@ -108,9 +126,10 @@ export function Sidebar() {
key={id}
href={`#${id}`}
className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
title={label}
>
<span className="sidebar-icon">{icon}</span>
{label}
<span className="sidebar-label">{label}</span>
</a>
))}
</>
@@ -0,0 +1,86 @@
"use client";
import { AMEV_GZF_TABLE } from "../../shared/constants/amev-gzf";
interface GzfPickerModalProps {
onClose: () => void;
onSelect: (value: number) => void;
}
function formatFactor(value: number) {
return value.toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export function GzfPickerModal({ onClose, onSelect }: GzfPickerModalProps) {
return (
<>
<div aria-modal="true" className="modal fade show d-block" role="dialog" tabIndex={-1}>
<div className="modal-dialog modal-lg modal-dialog-centered">
<div className="modal-content">
<div className="modal-header">
<div>
<h2 className="modal-title fs-5">Gleichzeitigkeitsfaktor nach AMEV</h2>
<p className="text-secondary small mb-0">
Tabelle 3 Gleichzeitigkeitsfaktoren für Verbrauchergruppen
</p>
</div>
<button
aria-label="Schließen"
className="btn-close"
onClick={onClose}
type="button"
/>
</div>
<div className="modal-body">
<div className="table-responsive">
<table className="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Verbrauchergruppe</th>
<th>Bereich</th>
<th className="text-end">Mittelwert</th>
<th className="text-end">Gfges</th>
<th className="text-end">Aktion</th>
</tr>
</thead>
<tbody>
{AMEV_GZF_TABLE.map((entry) => (
<tr key={entry.group}>
<td>{entry.group}</td>
<td>{entry.range}</td>
<td className="text-end">{formatFactor(entry.mean)}</td>
<td className="text-end text-secondary small">
{entry.overallRange ?? ""}
</td>
<td className="text-end">
<button
className="btn btn-sm btn-primary"
onClick={() => onSelect(entry.mean)}
type="button"
>
Übernehmen
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="text-secondary small mt-3 mb-0">
Quelle: AMEV Planungshilfe für elektrische Leistungsbilanzen", Tabelle 3.
Übernommen wird jeweils der Mittelwert Gf1 der angegebenen Bandbreite; „Gfges"
ist der alternative Gesamtgleichzeitigkeitsfaktor für Verwaltungsgebäude.
</p>
</div>
<div className="modal-footer">
<button className="btn btn-outline-secondary" onClick={onClose} type="button">
Abbrechen
</button>
</div>
</div>
</div>
</div>
<div className="modal-backdrop fade show" />
</>
);
}
@@ -12,6 +12,7 @@ import {
circuitGroupCategoryLabels,
type CircuitGroupCategory,
} from "../../shared/constants/circuit-group";
import { GzfPickerModal } from "./gzf-picker-modal";
interface ProjectDeviceModalProps {
globalDevices: GlobalDeviceDto[];
@@ -34,6 +35,7 @@ export function ProjectDeviceModal({
}: ProjectDeviceModalProps) {
const [values, setValues] = useState(() => toFormValues(initialDevice, initialValues));
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
const [isGzfPickerOpen, setIsGzfPickerOpen] = useState(false);
function update(key: keyof typeof values, value: string) {
setValues((current) => ({ ...current, [key]: value }));
@@ -63,6 +65,7 @@ export function ProjectDeviceModal({
(!values.cosPhi.trim() || isNumberInRange(values.cosPhi, 0, 1));
return (
<>
<FormModal
description={
initialDevice
@@ -167,15 +170,31 @@ export function ProjectDeviceModal({
step="0.01"
value={values.powerPerUnit}
/>
<NumberField
<div className="col-12 col-md-6">
<label className="form-label" htmlFor="device-simultaneity">
Gleichzeitigkeitsfaktor
</label>
<div className="input-group">
<input
className="form-control"
id="device-simultaneity"
label="Gleichzeitigkeitsfaktor"
max="1"
min="0"
onChange={(value) => update("simultaneityFactor", value)}
onChange={(event) => update("simultaneityFactor", event.target.value)}
step="0.01"
type="number"
value={values.simultaneityFactor}
/>
<button
className="btn btn-outline-primary"
onClick={() => setIsGzfPickerOpen(true)}
title="Gleichzeitigkeitsfaktor nach AMEV wählen"
type="button"
>
AMEV
</button>
</div>
</div>
<NumberField
id="device-cos-phi"
label="cos Phi"
@@ -199,6 +218,16 @@ export function ProjectDeviceModal({
</div>
</div>
</FormModal>
{isGzfPickerOpen ? (
<GzfPickerModal
onClose={() => setIsGzfPickerOpen(false)}
onSelect={(value) => {
update("simultaneityFactor", String(value));
setIsGzfPickerOpen(false);
}}
/>
) : null}
</>
);
}
+38
View File
@@ -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 },
];