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:
+4
-4
@@ -1,8 +1,8 @@
|
||||
.git
|
||||
.agents
|
||||
.codex
|
||||
.next
|
||||
data
|
||||
dist
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
data
|
||||
*.db
|
||||
.env
|
||||
.env.*
|
||||
|
||||
+8
-6
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+34
-12
@@ -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() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-6 col-md-1">
|
||||
<input
|
||||
className="form-control"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={globalDeviceForm.demandFactor}
|
||||
onChange={(event) =>
|
||||
setGlobalDeviceForm((current) => ({ ...current, demandFactor: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<div className="col-6 col-md-2">
|
||||
<div className="input-group">
|
||||
<input
|
||||
className="form-control"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
value={globalDeviceForm.demandFactor}
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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-wordmark">Leistungsbilanz</div>
|
||||
<div className="sidebar-bar" />
|
||||
<div className="sidebar-tag">Stromkreis- & Lastberechnung</div>
|
||||
<div className="sidebar-brand-text">
|
||||
<div className="sidebar-wordmark">Leistungsbilanz</div>
|
||||
<div className="sidebar-bar" />
|
||||
<div className="sidebar-tag">Stromkreis- & 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
|
||||
id="device-simultaneity"
|
||||
label="Gleichzeitigkeitsfaktor"
|
||||
max="1"
|
||||
min="0"
|
||||
onChange={(value) => update("simultaneityFactor", value)}
|
||||
step="0.01"
|
||||
value={values.simultaneityFactor}
|
||||
/>
|
||||
<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"
|
||||
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
|
||||
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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
Reference in New Issue
Block a user