alle new
This commit is contained in:
erik committed 2026-10-01 06:46:51 +02:00
1 parent da64472251
commit 5b20adee48
1605 files changed
+396517 -1674

No files matched your search

+250 -45
View File
@@ -1,65 +1,118 @@
// -----------------------------
// Projektübersicht — UI-Zustand (LocalStorage, Theme, Header)
// -----------------------------
// Diese Datei verwaltet den reinen UI-Zustand: gespeicherte Abteilung in
// LocalStorage, den kollabierbaren Header sowie das Light/Dark-Theme. Sie wird
// nach js/globals.js von beiden HTML-Einstiegspunkten geladen (Test.html und
// project-plan.html). Die bereitgestellten Funktionen werden von js/init.js
// beim Start sowie von js/tabs-auth.js (Tab-Wechsel, showTab) und js/store.js
// (Abteilungswechsel, switchDepartment) aufgerufen; im Theme-Umschalter der
// Diese Datei verwaltet den reinen UI-Zustand: den kollabierbaren Header sowie
// das Light/Dark-Theme. Sie wird nach js/globals.js von beiden HTML-
// Einstiegspunkt Test.html geladen. Die
// bereitgestellten Funktionen werden von js/init.js beim Start sowie von
// js/tabs-auth.js (Tab-Wechsel, showTab) aufgerufen; im Theme-Umschalter der
// Seite werden toggleTheme/applyTheme direkt eingebunden.
// Seit der Projektbereich eines Projekts kein eigenes Dokument mehr ist,
// sondern der Bereich #plan von Test.html, kennen applyUrlRoute() und
// updateRouteUrl() auch dessen Adresse "/Projektplan/<Projektname>".
// saveUiState: speichert Abteilung und aktiven Tab in den LocalStorage
// saveUiState: speichert den aktiven Tab in den LocalStorage
// Parameter: keine. Rückgabe: keine.
function saveUiState(){
try {
const state = { department: currentDepartment, tab: currentTab };
const state = { tab: currentTab };
window.localStorage.setItem(STORAGE, JSON.stringify(state));
} catch (err) {
console.warn('UI-State kann nicht gespeichert werden:', err);
}
}
// loadUiState: stellt die zuletzt gewählte Abteilung aus dem LocalStorage wieder her
// Parameter: keine. Rückgabe: keine (setzt currentDepartment aus js/globals.js).
// loadUiState: liest den persistierten UI-State aus dem LocalStorage.
// Parameter: keine. Rückgabe: keine.
function loadUiState(){
try {
const state = JSON.parse(window.localStorage.getItem(STORAGE) || '{}');
if(state.department && DEPARTMENTS[state.department]) currentDepartment = state.department;
JSON.parse(window.localStorage.getItem(STORAGE) || '{}');
// Tab wird nicht aus dem localStorage wiederhergestellt, damit nach dem
// Neuladen immer das Projektboard angezeigt wird.
// Neuladen immer DEFAULT_TAB (Dashboard "Meine Aufgaben") angezeigt wird.
} catch (err) {
console.warn('UI-State kann nicht geladen werden:', err);
}
}
// ---------- Deep-Link-Routen (Abteilung + Tab als URL) ----------
// ---------- Deep-Link-Routen (Tab als URL) ----------
// applyUrlRoute: wertet einen Deep-Link "/<Abteilung>/<Tab>" aus der URL aus,
// z. B. "/HLS/Auslastung" oder "/ELT/Projektübersicht". Gültige Routen liefern
// { department, tab }, alles andere (auch file://-Pfade) liefert null.
// applyUrlRoute: wertet einen Deep-Link aus der Adresszeile aus. Zwei
// Formen sind gültig:
// "/<Tab>" — z. B. "/Auslastung", "/Projektübersicht"
// "/Projektplan/<Projektname>" — der Projektbereich EINES Projekts,
// mit ?weekLabel=…&projNr=… als Parameter
// Die zweite Form führte früher auf ein eigenes Dokument
// (public/project-plan.html); seit der Projektbereich ein Bereich der
// Hauptseite ist, liefert sie schlicht den Bereich 'plan' mitsamt seinem
// Ziel. Rückgabe: { tab } bzw. { tab:'plan', weekLabel, projNr, projName },
// sonst null (auch bei file://-Pfaden).
function applyUrlRoute(){
try {
const m = window.location.pathname.match(/^\/([^/]+)\/([^/]+)\/?$/);
const planRe = new RegExp('^/' + PLAN_SLUG + '(?:/(.*))?/?$');
const planMatch = window.location.pathname.match(planRe);
if(planMatch){
const params = new URLSearchParams(window.location.search);
let projName = '';
try { projName = decodeURIComponent(planMatch[1] || ''); }
catch(e) { projName = params.get('projName') || ''; }
return {
tab: 'plan',
weekLabel: params.get('weekLabel') || '',
projNr: params.get('projNr') || '',
projName: projName || (params.get('projName') || ''),
planId: params.get('planId') || ''
};
}
const m = window.location.pathname.match(/^\/([^/]+)\/?$/);
if(!m) return null;
const dept = m[1];
const tab = TAB_BY_SLUG[decodeURIComponent(m[2])];
if(!DEPARTMENTS[dept] || !tab) return null;
return { department: dept, tab };
const tab = TAB_BY_SLUG[decodeURIComponent(m[1])];
if(!tab) return null;
return { tab };
} catch (err) {
return null;
}
}
// updateRouteUrl: schreibt Abteilung + aktiven Tab als sprechende URL in die
// Adresszeile (z. B. "/HLS/Auslastung"), ohne die Seite neu zu laden. Nur über
// HTTP (nicht per file://) und nur bei bekannter Abteilung/Tab. Keine Rückgabe.
function updateRouteUrl(dept, tab){
// updateRouteUrl: schreibt den aktiven Bereich als sprechende URL in die
// Adresszeile (z. B. "/Auslastung"), ohne die Seite neu zu laden. Nur über
// HTTP (nicht per file://) und nur bei bekanntem Bereich. Der Projektbereich
// ist der Sonderfall: seine Adresse trägt zusätzlich das geöffnete Projekt
// ("/Projektplan/<Name>?weekLabel=…&projNr=…"), damit sie sich wie bisher
// verschicken lässt; die Werte kommen aus window.PlanApp (setPlanTarget() in
// js/plan/plan-state.js, das showTab() vorher gefüllt hat). Keine Rückgabe.
function updateRouteUrl(tab){
try {
if(!window.location.protocol || window.location.protocol.indexOf('http') !== 0) return;
if(tab === 'plan'){
const app = window.PlanApp;
if(!app || !app.weekLabel) return;
const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(app.projName || ''), window.location.origin);
url.searchParams.set('weekLabel', app.weekLabel);
// Ein Sandbox-Plan der Planung hat keine Projektnummer — er wird über
// planId adressiert (die stabile id, siehe openSandboxPlan(),
// js/plan/plan-state.js), nicht über projNr wie ein echtes Projekt.
if(app.weekLabel === SANDBOX_WEEK_LABEL){
url.searchParams.set('planId', app.sandboxPlanId || '');
} else {
url.searchParams.set('projNr', app.projNr || '');
}
// Das Fragment bleibt stehen: Es trägt den gewählten Unterbereich
// (…#aufgaben, showPlanSection() in js/plan/plan-sections.js). Würde es
// hier abgeschnitten, landete ein verschickter Link auf einen
// bestimmten Unterbereich immer wieder im Dashboard.
window.history.replaceState(null, '', url.pathname + url.search + window.location.hash);
return;
}
const slug = TAB_SLUGS[tab];
if(!slug || !DEPARTMENTS[dept]) return;
window.history.replaceState(null, '', '/' + dept + '/' + slug);
if(!slug) return;
// Der Bereich „Admin" hält seinen Abschnitt im Fragment
// (…/Admin#auslastung, showAdminSection() in js/admin/admin.js). Es muss
// erhalten bleiben, weil showTab() diese Funktion VOR dem Rendern
// aufruft — abgeschnitten wäre der Abschnitt eines verschickten Links
// schon weg, bevor ihn jemand liest. Bei allen anderen Bereichen fällt
// das Fragment bewusst weg.
const fragment = (tab === 'admin') ? window.location.hash : '';
window.history.replaceState(null, '', '/' + slug + fragment);
} catch (err) {}
}
@@ -89,24 +142,44 @@ function saveHeaderState(collapsed){
} catch (err) {}
}
// setHeaderCollapsed: klappt das Header-Menü auf/zu und aktualisiert Icon und aria-Attribute
// Parameter: collapsed (Boolean), persist (Boolean, ob in LocalStorage gespeichert wird). Rückgabe: keine.
// isDesktopSidebarLayout: true ab der Breakpoint-Breite (769px, siehe
// styles.css), ab der die Sidebar per CSS dauerhaft sichtbar erzwungen wird
// (transform:none), unabhängig vom Off-Canvas-Zustand.
// Parameter: keine. Rückgabe: Boolean.
function isDesktopSidebarLayout(){
return window.matchMedia && window.matchMedia('(min-width: 769px)').matches;
}
// setHeaderCollapsed: klappt das Off-Canvas-Seiten-Menü auf/zu und
// aktualisiert das aria-expanded-Attribut des öffnenden Hamburger-Knopfs im
// Menüband (.mobile-nav-toggle). Öffnen- und Schließen-Knopf sind zwei
// getrennte Elemente mit je eigenem, festem Icon (Hamburger bzw. ×) — anders
// als früher gibt es keinen einzelnen Knopf mehr, dessen Icon umgeschaltet
// werden müsste. Wichtig: aria-hidden darf NUR gesetzt werden, wenn die
// Sidebar tatsächlich unsichtbar ist (Off-Canvas, < 769px) — ab 769px zeigt
// styles.css sie per CSS immer an (transform:none), ein dort weiterhin
// gesetztes aria-hidden="true" widerspräche dem sichtbaren, fokussierbaren
// Inhalt (führte zu "Blocked aria-hidden on an element because its
// descendant retained focus" in Chrome und verhinderte dort zuverlässige
// Klicks auf Elemente der Sidebar, u.a. den Einklapp-Knopf).
// Parameter: collapsed (Boolean), persist (Boolean, ob in LocalStorage
// gespeichert wird). Rückgabe: keine.
function setHeaderCollapsed(collapsed, persist){
const menu = document.getElementById('headerMenu');
const btn = document.querySelector('.header-bar .header-toggle');
const icon = document.querySelector('.header-bar .header-toggle-icon');
const btn = document.querySelector('.mobile-nav-toggle');
if(!menu) return;
if(collapsed){
menu.classList.remove('open');
menu.setAttribute('aria-hidden', 'true');
if(icon) icon.textContent = '☰';
if(btn) btn.setAttribute('aria-expanded', 'false');
} else {
menu.classList.add('open');
menu.setAttribute('aria-hidden', 'false');
if(icon) icon.textContent = '✕';
if(btn) btn.setAttribute('aria-expanded', 'true');
const hiddenNow = collapsed && !isDesktopSidebarLayout();
// Fokus vor dem Verstecken wegnehmen, falls er gerade auf einem Element
// innerhalb der Sidebar liegt (z. B. der zuletzt angeklickte Nav-Punkt,
// dessen Klick das Off-Canvas-Menü zugleich schließt) — sonst entsteht
// genau der "aria-hidden on focused descendant"-Konflikt aus der
// Browser-Warnung.
if(hiddenNow && document.activeElement && menu.contains(document.activeElement)){
document.activeElement.blur();
}
menu.classList.toggle('open', !collapsed);
menu.setAttribute('aria-hidden', hiddenNow ? 'true' : 'false');
if(btn) btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
if(persist) saveHeaderState(collapsed);
}
@@ -118,14 +191,146 @@ function toggleHeader(){
setHeaderCollapsed(isOpen, true);
}
// Beim Überschreiten der Sidebar-Breakpoint-Breite (769px) per Fenster-
// Resize wird aria-hidden neu bewertet (siehe setHeaderCollapsed()) — sonst
// bliebe z. B. nach einem Verkleinern-und-wieder-Vergrößern des Fensters ein
// aria-hidden="true" auf der jetzt wieder sichtbaren Desktop-Sidebar stehen.
if(window.matchMedia){
window.matchMedia('(min-width: 769px)').addEventListener('change', function(){
const menu = document.getElementById('headerMenu');
if(!menu) return;
setHeaderCollapsed(!menu.classList.contains('open'), false);
});
}
// ---------- Einklappbare Sidebar (Icon-Rail, nur Desktop ≥769px) ----------
// SIDEBAR_COLLAPSED_KEY: LocalStorage-Key für den Einklapp-Zustand der Sidebar
const SIDEBAR_COLLAPSED_KEY = 'projuebersicht-sidebar-collapsed';
// loadSidebarCollapseState: liest den gespeicherten Sidebar-Zustand aus dem
// LocalStorage. Parameter: keine. Rückgabe: keine (wendet den Zustand per
// setSidebarCollapsed an).
function loadSidebarCollapseState(){
let collapsed = false; // Default: Sidebar ist beim ersten Start aufgeklappt
try {
const stored = window.localStorage.getItem(SIDEBAR_COLLAPSED_KEY);
if(stored !== null){
collapsed = stored === 'true';
}
} catch (err) {}
setSidebarCollapsed(collapsed, false);
}
// saveSidebarCollapseState: speichert den Einklapp-Zustand der Sidebar im LocalStorage
// Parameter: collapsed (Boolean). Rückgabe: keine.
function saveSidebarCollapseState(collapsed){
try {
window.localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed ? 'true' : 'false');
} catch (err) {}
}
// setSidebarCollapsed: klappt die Sidebar zwischen voller Breite und
// Icon-Rail um (nur auf Desktop-Breite sichtbar, siehe styles.css) und
// aktualisiert das aria-pressed-Attribut des Umschalt-Knopfs.
// Parameter: collapsed (Boolean), persist (Boolean, ob in LocalStorage
// gespeichert wird). Rückgabe: keine.
function setSidebarCollapsed(collapsed, persist){
const menu = document.getElementById('headerMenu');
if(!menu) return;
menu.classList.toggle('collapsed', collapsed);
const btn = document.getElementById('sidebarCollapseToggle');
if(btn) btn.setAttribute('aria-pressed', collapsed ? 'true' : 'false');
if(persist) saveSidebarCollapseState(collapsed);
}
// toggleSidebarCollapse: wechselt die Sidebar zwischen aufgeklappt und
// Icon-Rail um und persistiert den neuen Zustand.
// Parameter: keine. Rückgabe: keine.
function toggleSidebarCollapse(){
const menu = document.getElementById('headerMenu');
const isCollapsed = menu && menu.classList.contains('collapsed');
setSidebarCollapsed(!isCollapsed, true);
}
// ---------- Aufklappbare Sidebar-Bereiche (Einstellungen/Admin) ----------
// toggleSidebarAccordion: klappt einen der beiden Aktions-Bereiche in der
// Seitenleiste (Einstellungen/Admin) auf oder zu. Erwartet die zugehörigen
// Elemente unter den IDs "<key>AccordionToggle" (Kopf-Button) und
// "<key>Subnav" (Liste der Aktionen), siehe Test.html. Kein Persistieren des
// Zustands — jeder Bereich startet beim Neuladen zugeklappt.
// Parameter: key ('settings'|'admin'). Rückgabe: keine.
function toggleSidebarAccordion(key){
const toggleBtn = document.getElementById(key + 'AccordionToggle');
const subnav = document.getElementById(key + 'Subnav');
if(!toggleBtn || !subnav) return;
const nowOpen = !subnav.classList.contains('open');
subnav.classList.toggle('open', nowOpen);
toggleBtn.classList.toggle('expanded', nowOpen);
toggleBtn.setAttribute('aria-expanded', nowOpen ? 'true' : 'false');
}
// ---------- Konto-Menü (Avatar-Dropdown im Menüband) ----------
// toggleUserMenu: öffnet/schließt das Konto-Dropdown (#userMenuDropdown) im
// Menüband. Parameter: keine. Rückgabe: keine.
function toggleUserMenu(){
const dropdown = document.getElementById('userMenuDropdown');
if(!dropdown) return;
if(dropdown.classList.contains('open')) closeUserMenu();
else openUserMenu();
}
// openUserMenu: öffnet das Konto-Dropdown und bindet den Klick-außerhalb-
// sowie Escape-Handler (gleiches Muster wie openRangePicker() in
// js/date-picker.js: setTimeout, damit der öffnende Klick selbst nicht
// sofort wieder schließt). Parameter: keine. Rückgabe: keine.
function openUserMenu(){
const dropdown = document.getElementById('userMenuDropdown');
const btn = document.getElementById('userMenuToggle');
if(!dropdown) return;
dropdown.classList.add('open');
if(btn) btn.setAttribute('aria-expanded', 'true');
setTimeout(() => {
document.addEventListener('mousedown', userMenuOutsideClick);
document.addEventListener('keydown', userMenuEscape);
}, 0);
}
// closeUserMenu: schließt das Konto-Dropdown und löst die Handler wieder.
// Parameter: keine. Rückgabe: keine.
function closeUserMenu(){
const dropdown = document.getElementById('userMenuDropdown');
const btn = document.getElementById('userMenuToggle');
if(dropdown) dropdown.classList.remove('open');
if(btn) btn.setAttribute('aria-expanded', 'false');
document.removeEventListener('mousedown', userMenuOutsideClick);
document.removeEventListener('keydown', userMenuEscape);
}
// userMenuOutsideClick: schließt das Dropdown bei Klick außerhalb von
// .topbar-user. Parameter: e (Mousedown-Event). Rückgabe: keine.
function userMenuOutsideClick(e){
const wrapper = document.querySelector('.topbar-user');
if(wrapper && !wrapper.contains(e.target)) closeUserMenu();
}
// userMenuEscape: schließt das Dropdown bei Escape. Parameter: e
// (Keydown-Event). Rückgabe: keine.
function userMenuEscape(e){
if(e.key === 'Escape') closeUserMenu();
}
// ---------- Theme (Light/Dark) ----------
// applyThemeIcon: setzt das Sonne-/Mond-Icon passend zum effektiven Theme
// Parameter: theme (String 'dark'/'light'). Rückgabe: keine.
function applyThemeIcon(theme){
const icon = document.getElementById('themeIcon');
if(!icon) return;
icon.textContent = theme === 'dark' ? '☀️' : '🌙';
// Bewusst `iconEl` statt `icon` benannt, siehe Kommentar in setHeaderCollapsed().
const iconEl = document.getElementById('themeIcon');
if(!iconEl) return;
iconEl.innerHTML = theme === 'dark' ? icon('sun') : icon('moon');
}
// loadTheme: liest das gespeicherte Theme aus dem LocalStorage und wendet es an