// ----------------------------- // Projektübersicht — Benachrichtigungen (Glocke im Menüband + Dashboard) // ----------------------------- // Eriks Wunsch: Wer eine neue Aufgabe zugewiesen bekommt, soll das in der // Übersicht sehen. Der Server legt die Einträge beim Speichern einer Sitzung // an (neueZuweisungenMelden(), server.js); dieses Modul zeigt sie: // // - die GLOCKE im Menüband (#glockeBtn) mit der Zahl der ungelesenen, ein // Klick öffnet die Liste (#glockeMenue). Ein Eintrag führt in den // Bereich "Aufgaben" seines Projekts und gilt damit als gelesen. // - auf "Meine Aufgaben" (#dashNeu, js/dashboard/dashboard.js) die // ungelesenen als eigener Block "Neu zugewiesen". // // Abgefragt wird beim Laden der Daten (renderAll()) und danach jede Minute — // eine Zuweisung durch einen Kollegen erscheint also spätestens nach einer // Minute, ohne neu zu laden. Kiosk-Konten fragen nicht (der Server würde sie // ohnehin mit 403 abweisen). // // Nutzt baseUrl() (js/server.js), currentUser (js/auth.js), esc()/ // formatWeekDate() (js/utils.js), currentWeek(), openProjectPlan() und // openSandboxPlan(). let benachrichtigungen = { ungelesen: 0, eintraege: [] }; let benachrichtigungenZuletzt = 0; const BENACHRICHTIGUNG_INTERVALL_MS = 60 * 1000; // ladeBenachrichtigungen: holt GET /benachrichtigungen und zeichnet Glocke, // offene Liste und Dashboard-Block neu. Mehrfachaufrufe innerhalb von fünf // Sekunden werden zusammengefasst (renderAll() läuft oft mehrmals hintereinander). // Parameter: erzwingen (Boolean, optional). Keine Rückgabe. async function ladeBenachrichtigungen(erzwingen){ if(!currentUser || !hatRecht('app')) return; if(!erzwingen && Date.now() - benachrichtigungenZuletzt < 5000) return; benachrichtigungenZuletzt = Date.now(); // Die Glocke erscheint für jedes angemeldete Konto, auch wenn der Abruf // scheitert (etwa ein noch nicht neu gestarteter Server ohne die Route) — // sie dann still auszublenden, sah aus, als fehle die Funktion ganz. try { const res = await fetch(baseUrl() + '/benachrichtigungen'); const data = await res.json().catch(() => null); if(res.ok && data && data.ok){ benachrichtigungen = { ungelesen: data.ungelesen || 0, eintraege: Array.isArray(data.eintraege) ? data.eintraege : [] }; } } catch(e){ /* still — die Glocke ist kein Grund für eine Fehlermeldung */ } renderGlocke(); if(glockeOffen()) renderGlockeMenue(); renderDashNeu(); } setInterval(() => ladeBenachrichtigungen(true), BENACHRICHTIGUNG_INTERVALL_MS); // renderGlocke: Zahl am Glockensymbol (ausgeblendet bei 0). function renderGlocke(){ const btn = document.getElementById('glockeBtn'); const zahl = document.getElementById('glockeZahl'); if(!btn || !zahl) return; btn.hidden = !currentUser || !hatRecht('app'); const n = benachrichtigungen.ungelesen; zahl.textContent = n > 9 ? '9+' : String(n); zahl.hidden = n === 0; btn.title = n ? n + (n === 1 ? ' neue Benachrichtigung' : ' neue Benachrichtigungen') : 'Benachrichtigungen'; btn.classList.toggle('hat-neue', n > 0); } function glockeOffen(){ const m = document.getElementById('glockeMenue'); return !!(m && !m.hidden); } // toggleGlocke: Liste öffnen/schließen. Beim Öffnen frisch laden. function toggleGlocke(){ const m = document.getElementById('glockeMenue'); const btn = document.getElementById('glockeBtn'); if(!m) return; if(glockeOffen()){ schliesseGlocke(); return; } m.hidden = false; if(btn) btn.setAttribute('aria-expanded', 'true'); renderGlockeMenue(); ladeBenachrichtigungen(true); setTimeout(() => { document.addEventListener('click', glockeAussenKlick); document.addEventListener('keydown', glockeEscape); }, 0); } function schliesseGlocke(){ const m = document.getElementById('glockeMenue'); const btn = document.getElementById('glockeBtn'); if(m) m.hidden = true; if(btn) btn.setAttribute('aria-expanded', 'false'); document.removeEventListener('click', glockeAussenKlick); document.removeEventListener('keydown', glockeEscape); } function glockeAussenKlick(e){ // composedPath() statt e.target.closest(): Ein Klick auf „ד oder „Alle // löschen“ zeichnet die Liste neu, bevor dieser Lauscher läuft — das // angeklickte Element hängt dann nicht mehr im Menü, closest() fände es // nicht, und das Fenster ginge zu. Der Pfad wird beim Auslösen festgehalten. const pfad = e.composedPath ? e.composedPath() : []; const drin = el => el && (pfad.includes(el) || e.target.closest && e.target.closest('#' + el.id)); if(drin(document.getElementById('glockeMenue')) || drin(document.getElementById('glockeBtn'))) return; schliesseGlocke(); } function glockeEscape(e){ if(e.key === 'Escape') schliesseGlocke(); } // zeitVor: "gerade eben", "vor 5 Min.", "vor 3 Std.", sonst das Datum. function zeitVor(iso){ const t = Date.parse(iso); if(isNaN(t)) return ''; const min = Math.round((Date.now() - t) / 60000); if(min < 1) return 'gerade eben'; if(min < 60) return 'vor ' + min + ' Min.'; const std = Math.round(min / 60); if(std < 24) return 'vor ' + std + ' Std.'; return new Date(t).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); } // benachrichtigungZeile: eine Zeile für Glocke und Dashboard. function benachrichtigungZeile(b){ const projekt = (b.projectNr ? b.projectNr + ' ' : '') + (b.projectName || ''); const faellig = b.faellig ? ' · fällig ' + esc(formatWeekDate(b.faellig)) : ''; // Zeile = Öffnen-Knopf + Löschen-Knopf daneben (Knöpfe dürfen nicht // ineinander stecken). return '
' + '' + '' + '
'; } // renderGlockeMenue: die Liste in der Glocke. function renderGlockeMenue(){ const m = document.getElementById('glockeMenue'); if(!m) return; const e = benachrichtigungen.eintraege; m.innerHTML = '
Benachrichtigungen' + (benachrichtigungen.ungelesen ? '' : '') + (e.length ? '' : '') + '
' + (e.length ? '
' + e.map(benachrichtigungZeile).join('') + '
' : '
Keine Benachrichtigungen. Neue Aufgaben, die Ihnen jemand zuweist, erscheinen hier.
'); } // renderDashNeu: Block "Neu zugewiesen" auf dem Dashboard — nur, solange es // Ungelesenes gibt; sonst bleibt der Platz leer. function renderDashNeu(){ const el = document.getElementById('dashNeu'); if(!el) return; const neu = benachrichtigungen.eintraege.filter(b => !b.gelesen); if(!neu.length){ el.innerHTML = ''; return; } el.innerHTML = '
' + '

Neu zugewiesen

' + neu.length + '' + '
' + '
' + neu.slice(0, 6).map(benachrichtigungZeile).join('') + '
'; } // markiereGelesen: lokal und am Server. Parameter: ids (Array) oder null = alle. async function markiereGelesen(ids){ benachrichtigungen.eintraege.forEach(b => { if(!ids || ids.includes(b.id)) b.gelesen = true; }); benachrichtigungen.ungelesen = benachrichtigungen.eintraege.filter(b => !b.gelesen).length; renderGlocke(); if(glockeOffen()) renderGlockeMenue(); renderDashNeu(); try { await fetch(baseUrl() + '/benachrichtigungen/gelesen', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ids ? { ids } : { alle: true }), }); } catch(e){ /* beim nächsten Abruf stimmt der Stand wieder */ } ladeBenachrichtigungen(true); } function alleBenachrichtigungenGelesen(){ markiereGelesen(null); } // loescheBenachrichtigungen: entfernt Einträge lokal sofort und am Server // endgültig. Parameter: ids (Array) oder null = alle. Keine Rückgabe. async function loescheBenachrichtigungen(ids){ benachrichtigungen.eintraege = ids ? benachrichtigungen.eintraege.filter(b => !ids.includes(b.id)) : []; benachrichtigungen.ungelesen = benachrichtigungen.eintraege.filter(b => !b.gelesen).length; renderGlocke(); if(glockeOffen()) renderGlockeMenue(); renderDashNeu(); try { await fetch(baseUrl() + '/benachrichtigungen/loeschen', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(ids ? { ids } : { alle: true }), }); } catch(e){ /* beim nächsten Abruf stimmt der Stand wieder */ } ladeBenachrichtigungen(true); } // oeffneBenachrichtigung: als gelesen markieren und in die Aufgaben des // Projekts springen. Gesucht wird das Projekt in der AKTUELLEN Sitzung über // die Projektnummer (die Projekt-ID wechselt mit jeder Sitzung); Terminpläne // der Planung haben keine Nummer und werden über ihre ID geöffnet. function oeffneBenachrichtigung(id){ const b = benachrichtigungen.eintraege.find(x => x.id === id); if(!b) return; markiereGelesen([id]); schliesseGlocke(); if(b.weekLabel === SANDBOX_WEEK_LABEL){ if(typeof openSandboxPlan === 'function'){ location.hash = 'aufgaben'; openSandboxPlan(b.projectId); } return; } const w = typeof currentWeek === 'function' ? currentWeek() : null; const p = w && (w.projects || []).find(x => (b.projectNr && x.nr === b.projectNr) || x.id === b.projectId) || (w && (w.projects || []).find(x => x.name === b.projectName)); if(!p) return; location.hash = 'aufgaben'; openProjectPlan(p.id); }