- 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>
210 lines
10 KiB
JavaScript
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) + '\'])">×</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);
|
|
}
|