Merge remote-tracking branch 'origin/feature/amev-picker-and-collapsible-sidebar'
This commit is contained in:
commit
a17e2e3f4b
11 changed files with 349 additions and 41 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,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;
|
||||
|
|
@ -67,19 +72,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>
|
||||
|
|
@ -90,9 +107,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 ? (
|
||||
|
|
@ -109,9 +127,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>
|
||||
))}
|
||||
</>
|
||||
|
|
|
|||
86
src/frontend/components/gzf-picker-modal.tsx
Normal file
86
src/frontend/components/gzf-picker-modal.tsx
Normal file
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue