1 Commits

Author SHA1 Message Date
Grovy311 9bd22dfcb7 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)
2026-08-03 17:07:14 +02:00
11 changed files with 349 additions and 41 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
.git .git
.agents
.codex
.next .next
data
dist dist
node_modules 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 WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1 COPY package*.json ./
COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . 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); 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;
}
+34 -12
View File
@@ -14,6 +14,7 @@ import type {
CreateGlobalDeviceInput, CreateGlobalDeviceInput,
GlobalDeviceDto, GlobalDeviceDto,
} from "../../frontend/types"; } from "../../frontend/types";
import { GzfPickerModal } from "../../frontend/components/gzf-picker-modal";
const PROJECTS_CHANGED_EVENT = "leistungsbilanz:projects-changed"; const PROJECTS_CHANGED_EVENT = "leistungsbilanz:projects-changed";
@@ -67,6 +68,7 @@ export default function ProjectsPage() {
id: string; id: string;
name: string; name: string;
} | null>(null); } | null>(null);
const [isGzfPickerOpen, setIsGzfPickerOpen] = useState(false);
async function loadData() { async function loadData() {
setError(null); setError(null);
@@ -380,18 +382,28 @@ export default function ProjectsPage() {
} }
/> />
</div> </div>
<div className="col-6 col-md-1"> <div className="col-6 col-md-2">
<input <div className="input-group">
className="form-control" <input
type="number" className="form-control"
step="0.01" type="number"
min="0" step="0.01"
max="1" min="0"
value={globalDeviceForm.demandFactor} max="1"
onChange={(event) => value={globalDeviceForm.demandFactor}
setGlobalDeviceForm((current) => ({ ...current, demandFactor: event.target.value })) onChange={(event) =>
} 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>
<div className="col-6 col-md-1"> <div className="col-6 col-md-1">
<select <select
@@ -505,6 +517,16 @@ export default function ProjectsPage() {
</div> </div>
</section> </section>
</div> </div>
{isGzfPickerOpen ? (
<GzfPickerModal
onClose={() => setIsGzfPickerOpen(false)}
onSelect={(value) => {
setGlobalDeviceForm((current) => ({ ...current, demandFactor: String(value) }));
setIsGzfPickerOpen(false);
}}
/>
) : null}
</main> </main>
); );
} }
+17 -2
View File
@@ -1,21 +1,36 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
const PROJECT_AREA_PATTERN = /^\/projects(\/[^/]+)?$/; const PROJECT_AREA_PATTERN = /^\/projects(\/[^/]+)?$/;
const SIDEBAR_COLLAPSED_STORAGE_KEY = "leistungsbilanz:sidebar-collapsed";
export function AppShell({ children }: { children: React.ReactNode }) { export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname(); const pathname = usePathname();
const showSidebar = PROJECT_AREA_PATTERN.test(pathname ?? ""); 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) { if (!showSidebar) {
return <>{children}</>; return <>{children}</>;
} }
return ( return (
<div className="app-shell"> <div className={isSidebarCollapsed ? "app-shell app-shell-collapsed" : "app-shell"}>
<Sidebar /> <Sidebar isCollapsed={isSidebarCollapsed} onToggleCollapsed={toggleSidebarCollapsed} />
<div className="app-shell-content">{children}</div> <div className="app-shell-content">{children}</div>
</div> </div>
); );
+27 -8
View File
@@ -14,7 +14,12 @@ const PROJECT_SECTIONS = [
{ id: "projektgeraete", label: "Projektgeräte", icon: "⚙" }, { id: "projektgeraete", label: "Projektgeräte", icon: "⚙" },
] as const; ] as const;
export function Sidebar() { interface SidebarProps {
isCollapsed: boolean;
onToggleCollapsed: () => void;
}
export function Sidebar({ isCollapsed, onToggleCollapsed }: SidebarProps) {
const pathname = usePathname(); const pathname = usePathname();
const projectMatch = pathname?.match(/^\/projects\/([^/]+)$/); const projectMatch = pathname?.match(/^\/projects\/([^/]+)$/);
const activeProjectId = projectMatch ? projectMatch[1] : null; const activeProjectId = projectMatch ? projectMatch[1] : null;
@@ -66,19 +71,31 @@ export function Sidebar() {
}, [activeProjectId]); }, [activeProjectId]);
return ( return (
<aside className="sidebar"> <aside className={isCollapsed ? "sidebar sidebar-collapsed" : "sidebar"}>
<div className="sidebar-brand"> <div className="sidebar-brand">
<div className="sidebar-wordmark">Leistungsbilanz</div> <div className="sidebar-brand-text">
<div className="sidebar-bar" /> <div className="sidebar-wordmark">Leistungsbilanz</div>
<div className="sidebar-tag">Stromkreis- &amp; Lastberechnung</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> </div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
<Link <Link
href="/projects" href="/projects"
className={pathname === "/projects" ? "sidebar-link active" : "sidebar-link"} className={pathname === "/projects" ? "sidebar-link active" : "sidebar-link"}
title="Alle Projekte"
> >
<span className="sidebar-icon"></span> <span className="sidebar-icon"></span>
Alle Projekte <span className="sidebar-label">Alle Projekte</span>
</Link> </Link>
<div className="sidebar-section-label">Projekte</div> <div className="sidebar-section-label">Projekte</div>
@@ -89,9 +106,10 @@ export function Sidebar() {
className={ className={
project.id === activeProjectId ? "sidebar-link active" : "sidebar-link" project.id === activeProjectId ? "sidebar-link active" : "sidebar-link"
} }
title={project.name}
> >
<span className="sidebar-icon"></span> <span className="sidebar-icon"></span>
{project.name} <span className="sidebar-label">{project.name}</span>
</Link> </Link>
))} ))}
{!projects.length ? ( {!projects.length ? (
@@ -108,9 +126,10 @@ export function Sidebar() {
key={id} key={id}
href={`#${id}`} href={`#${id}`}
className={activeSection === id ? "sidebar-link active" : "sidebar-link"} className={activeSection === id ? "sidebar-link active" : "sidebar-link"}
title={label}
> >
<span className="sidebar-icon">{icon}</span> <span className="sidebar-icon">{icon}</span>
{label} <span className="sidebar-label">{label}</span>
</a> </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, circuitGroupCategoryLabels,
type CircuitGroupCategory, type CircuitGroupCategory,
} from "../../shared/constants/circuit-group"; } from "../../shared/constants/circuit-group";
import { GzfPickerModal } from "./gzf-picker-modal";
interface ProjectDeviceModalProps { interface ProjectDeviceModalProps {
globalDevices: GlobalDeviceDto[]; globalDevices: GlobalDeviceDto[];
@@ -34,6 +35,7 @@ export function ProjectDeviceModal({
}: ProjectDeviceModalProps) { }: ProjectDeviceModalProps) {
const [values, setValues] = useState(() => toFormValues(initialDevice, initialValues)); const [values, setValues] = useState(() => toFormValues(initialDevice, initialValues));
const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState(""); const [selectedGlobalDeviceId, setSelectedGlobalDeviceId] = useState("");
const [isGzfPickerOpen, setIsGzfPickerOpen] = useState(false);
function update(key: keyof typeof values, value: string) { function update(key: keyof typeof values, value: string) {
setValues((current) => ({ ...current, [key]: value })); setValues((current) => ({ ...current, [key]: value }));
@@ -63,6 +65,7 @@ export function ProjectDeviceModal({
(!values.cosPhi.trim() || isNumberInRange(values.cosPhi, 0, 1)); (!values.cosPhi.trim() || isNumberInRange(values.cosPhi, 0, 1));
return ( return (
<>
<FormModal <FormModal
description={ description={
initialDevice initialDevice
@@ -167,15 +170,31 @@ export function ProjectDeviceModal({
step="0.01" step="0.01"
value={values.powerPerUnit} value={values.powerPerUnit}
/> />
<NumberField <div className="col-12 col-md-6">
id="device-simultaneity" <label className="form-label" htmlFor="device-simultaneity">
label="Gleichzeitigkeitsfaktor" Gleichzeitigkeitsfaktor
max="1" </label>
min="0" <div className="input-group">
onChange={(value) => update("simultaneityFactor", value)} <input
step="0.01" className="form-control"
value={values.simultaneityFactor} id="device-simultaneity"
/> max="1"
min="0"
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 <NumberField
id="device-cos-phi" id="device-cos-phi"
label="cos Phi" label="cos Phi"
@@ -199,6 +218,16 @@ export function ProjectDeviceModal({
</div> </div>
</div> </div>
</FormModal> </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 },
];