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>
This commit is contained in:
1 parent
f4fb3c550b
commit
b7e0bd0d92
56 files changed
+12265
-2974
No files matched your search
@@ -0,0 +1,209 @@
|
||||
// -----------------------------
|
||||
// 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);
|
||||
}
|
||||
Reference in new issue
Block a user