Files
projektuebersicht/public/js/benachrichtigungen.js
T
erikandClaude Opus 5.5 b7e0bd0d92 Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb
- Termin aus Mail: Mailtext oder .eml per Drag & Drop/Strg+V auf Terminplan
  oder Aufgaben, KI schlägt Aufgaben vor, Prüfung im Aufgaben-Panel; neues
  Notizfeld je Aufgabe (plan_todos.notiz)
- Terminplan: Meilensteine ohne Aufgabenbezug je in eigener Zeile,
  "+ Neu"-Menü klappt neben schmaler Seitenleiste nach rechts, Leistungsphasen
- Rollen & Rechte, Abwesenheiten-Seite, Benachrichtigungen, Rundgang
- Glas-Darstellung, Logo als Favicon
- Pi-Betrieb: PDF-Arbeit im Kindprozess, Wächter, Einrichtungs- und
  Prüfskript inkl. WLAN-Wächter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:16:52 +01:00

210 lines
10 KiB
JavaScript

// -----------------------------
// 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 '<div class="glocke-zeile' + (b.gelesen ? '' : ' ist-neu') + '">'
+ '<button type="button" class="glocke-eintrag' + (b.gelesen ? '' : ' ist-neu') + '" onclick="oeffneBenachrichtigung(\'' + esc(b.id) + '\')">'
+ '<span class="glocke-punkt" aria-hidden="true"></span>'
+ '<span class="glocke-text">'
+ '<span class="glocke-titel">Neue Aufgabe: ' + esc(b.titel) + '</span>'
+ '<span class="glocke-meta">' + esc(projekt) + faellig + '</span>'
+ '<span class="glocke-meta">' + (b.von ? 'von ' + esc(b.von) + ', ' : '') + esc(zeitVor(b.erstellt)) + '</span>'
+ '</span></button>'
+ '<button type="button" class="glocke-loeschen" title="Benachrichtigung löschen" aria-label="Benachrichtigung löschen" onclick="loescheBenachrichtigungen([\'' + esc(b.id) + '\'])">&times;</button>'
+ '</div>';
}
// renderGlockeMenue: die Liste in der Glocke.
function renderGlockeMenue(){
const m = document.getElementById('glockeMenue');
if(!m) return;
const e = benachrichtigungen.eintraege;
m.innerHTML = '<div class="glocke-kopf"><b>Benachrichtigungen</b><span class="glocke-aktionen">'
+ (benachrichtigungen.ungelesen ? '<button type="button" class="glocke-alle" onclick="alleBenachrichtigungenGelesen()">Alle gelesen</button>' : '')
+ (e.length ? '<button type="button" class="glocke-alle" onclick="loescheBenachrichtigungen(null)">Alle löschen</button>' : '')
+ '</span></div>'
+ (e.length ? '<div class="glocke-liste">' + e.map(benachrichtigungZeile).join('') + '</div>'
: '<div class="glocke-leer">Keine Benachrichtigungen. Neue Aufgaben, die Ihnen jemand zuweist, erscheinen hier.</div>');
}
// 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 = '<section class="dash-abschnitt"><div class="dash-abschnitt-kopf">'
+ '<h3 class="dash-abschnitt-titel">Neu zugewiesen</h3><span class="dash-abschnitt-zahl">' + neu.length + '</span>'
+ '<button type="button" class="dash-proj-alle" onclick="alleBenachrichtigungenGelesen()">Alle als gelesen markieren</button></div>'
+ '<div class="dash-karte dash-neu">' + neu.slice(0, 6).map(benachrichtigungZeile).join('') + '</div></section>';
}
// 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);
}