// ----------------------------- // Projektübersicht — Dashboard ("Meine Aufgaben"-Tab) // ----------------------------- // Modul für den ersten Tab der Hauptseite (App.html, Ladereihenfolge: // ... → load → dashboard → gantt → ...). Das Dashboard ist die Startübersicht // nach dem Login; alles darin stammt aus einem einzigen GET /my-dashboard. // // Der Aufbau folgt der Frage, die beim Blick darauf zuerst ansteht: "Was muss // ich heute anpacken?" // // 1. KOPF Begrüßung und Datum links, Sitzung und Uhr klein rechts // (renderDashboardKopf(), gefüllt von startDashboardClock()). // 2. BAND Der Befund des Tages als SATZ, daneben vier Kennzahlen // (renderDashboardBand()). Ist nichts überfällig und nichts // heute fällig, bleibt das Band farblos. // 3. LINKS Der eigene Arbeitsstrom: "Meine Aufgaben" (persönlich // zugewiesene offene TODOs, als Zeilen — // renderDashboardAufgabe()) und "Termine meiner Projekte" // (Aufgaben und Meilensteine der Projekte, denen man zugeordnet // ist, nach Dringlichkeit gegliedert — renderDashboardFristen()). // 4. RECHTS Was man wissen muss, ohne selbst tätig zu werden: ausstehende // Freigaben und Abwesenheiten der Kollegen, beide in derselben // schmalen Zeilenform. // // Das war zuvor anders und schlechter: Die Uhrzeit war die größte Zahl auf // dem Bildschirm, vier gleich große Kennzahlkacheln zeigten meist drei Nullen // und gaben der einen wichtigen Zahl kein Gewicht, eine einzelne // Aufgabenkarte belegte eine halbe Bildschirmbreite für vier Zeilen Inhalt, // und alles lag untereinander — der Urlaub eines Kollegen stand gleichrangig // unter den eigenen offenen Aufgaben. // // Nutzt baseUrl() aus js/server.js, currentUser aus js/auth.js, PLAN_SLUG aus // js/globals.js, esc()/formatWeekDate()/heuteIso()/isoPlusTage() aus // js/utils.js. // ABSENCE_TYPE_CLASS: ordnet den Abwesenheitstypen (siehe absenceEditType in // Projektbereich: Urlaub/Krank/Fortbildung/Sonstiges) eine Farbklasse für // das Badge in der Kollegen-Abwesenheitsliste zu; "Sonstiges" fällt auf die // neutrale .absence-chip-Standardfarbe zurück (kein Eintrag nötig). const ABSENCE_TYPE_CLASS = { 'Urlaub': 'absence-type-urlaub', 'Krank': 'absence-type-krank', 'Fortbildung': 'absence-type-fortbildung', }; // dashboardClockTimer: Intervall-Handle der Begrüßungs-Uhr. Wird bei jedem // renderDashboard()-Aufruf neu gestartet (vorheriges Intervall zuerst // gestoppt), damit beim wiederholten Tab-Wechsel nicht mehrere Intervalle // parallel laufen. let dashboardClockTimer = null; // renderDashboard: lädt GET /my-dashboard und rendert Begrüßung/Uhr, // Kennzahlen sowie die drei Bereiche in #dashboardContent. Wird nur beim // Wechsel auf den Tab aufgerufen (showTab()), nicht beim initialen // Seitenaufbau. Keine Parameter, keine Rückgabe. async function renderDashboard(){ const container = document.getElementById('dashboardContent'); if(!container) return; container.innerHTML = '
Lade Dashboard…
'; let data; try { const res = await fetch(baseUrl() + '/my-dashboard'); data = await res.json(); if(!res.ok || !data.ok) throw new Error((data && data.error) || ('HTTP ' + res.status)); } catch(err){ container.innerHTML = '
Dashboard konnte nicht geladen werden: ' + esc(err.message || String(err)) + '
'; return; } const weekLabel = data.weekLabel || null; const tasks = Array.isArray(data.tasks) ? data.tasks : []; const projectDates = Array.isArray(data.projectDates) ? data.projectDates : []; const pending = Array.isArray(data.pendingReviews) ? data.pendingReviews : []; const absences = Array.isArray(data.colleagueAbsences) ? data.colleagueAbsences : []; const noPersonId = !!(currentUser && !currentUser.personId); // Personen zählen, nicht Einträge: Wer heute "Urlaub" UND "krank" erfasst // hat, ist eine abwesende Person, nicht zwei. const absencesToday = new Set(absences.filter(a => a.current).map(a => a.personId || a.userId || a.name)).size; // ---------- Aufbau ---------- // // Die Anordnung folgt der Frage, die beim Blick aufs Dashboard zuerst // ansteht: "Was muss ich heute anpacken?" Deshalb steht oben ein Befund im // Klartext statt vier gleich großer Kacheln, darunter links der // Arbeitsstrom (was ich tue) und rechts der Rand (was ich wissen muss). // // Zuvor war die Uhrzeit die größte Zahl auf dem Bildschirm, drei von vier // Kennzahlen zeigten eine Null, eine einzelne Aufgabenkarte belegte eine // halbe Bildschirmbreite für vier Zeilen Inhalt, und der Urlaub eines // Kollegen stand gleichrangig unter den eigenen offenen Aufgaben. const heute = heuteIso(); const eigeneUeberfaellig = tasks.filter(t => t.end && t.end < heute).length; const ueberfaellig = eigeneUeberfaellig + projectDates.filter(d => d.ueberfaellig).length; const heuteFaellig = tasks.filter(t => t.end === heute).length + projectDates.filter(d => d.heute).length; let html = ''; html += renderDashboardKopf(weekLabel); html += renderDashboardBand({ ueberfaellig, heuteFaellig, aufgaben: tasks.length, termine: projectDates.length, freigaben: pending.length, abwesend: absencesToday, }); if(noPersonId){ html += '
Ihrem Benutzerkonto ist keine Person zugeordnet — es können daher keine Aufgaben angezeigt werden. Ein Administrator kann die Zuordnung in der Benutzerverwaltung ergänzen.
'; } html += '
'; // Linke Zone: der eigene Arbeitsstrom html += '
'; html += dashAbschnitt('Meine Aufgaben', tasks.length, 'mir persönlich zugewiesen', !weekLabel ? dashLeer('Keine Sitzung vorhanden.') : (tasks.length === 0 ? dashLeer('Keine offenen Aufgaben — alles abgearbeitet.') : '
' + tasks.map(t => renderDashboardAufgabe(weekLabel, t)).join('') + '
')); html += dashAbschnitt('Termine meiner Projekte', projectDates.length, 'nächste drei Wochen', renderDashboardFristen(weekLabel, projectDates)); html += '
'; // Rechte Zone: was man wissen muss, ohne es selbst zu tun html += '
'; html += dashAbschnitt('Ausstehende Freigaben', pending.length, null, pending.length === 0 ? dashLeer('Keine ausstehenden Freigaben.') : '
' + pending.map(renderDashboardFreigabe).join('') + '
'); html += dashAbschnitt('Abwesenheiten', absences.length, null, renderDashboardAbsences(absences)); html += '
'; html += '
'; container.innerHTML = html; startDashboardClock(); } // renderDashboardKopf: Begrüßung und Datum links, Sitzung und Uhr klein // rechts. Die Uhrzeit war zuvor die größte Zahl auf dem Bildschirm — sie ist // die Angabe, nach der man am wenigsten handelt, und steht deshalb am Rand. // Die Inhalte füllt startDashboardClock() live nach. // Parameter: weekLabel. Rückgabe: HTML-String. function renderDashboardKopf(weekLabel){ return '
' + '

' + '

' + '
' + (weekLabel ? '' + esc(weekLabel) + '' : '') + '' + '
' + '
'; } // renderDashboardBand: der Befund des Tages als Satz, daneben die Kennzahlen. // // Zuvor standen hier vier gleich große Kacheln, von denen meist drei eine // Null zeigten — die eine Zahl, auf die es ankommt, bekam dadurch kein // Gewicht. Der Satz benennt den Befund, die Zahlen bleiben daneben stehen. // Ist nichts überfällig und nichts heute fällig, wird das Band farblos: // Eine Null in Alarmfarbe behauptet ein Problem, das es nicht gibt. // Parameter: z (Zählungen). Rückgabe: HTML-String. function renderDashboardBand(z){ let satz; if(z.ueberfaellig > 0){ satz = '' + z.ueberfaellig + ' ' + (z.ueberfaellig === 1 ? 'Termin ist überfällig' : 'Termine sind überfällig') + (z.heuteFaellig ? ', ' + z.heuteFaellig + ' ' + (z.heuteFaellig === 1 ? 'wird' : 'werden') + ' heute fällig' : '') + '.'; } else if(z.heuteFaellig > 0){ satz = '' + z.heuteFaellig + ' ' + (z.heuteFaellig === 1 ? 'Termin wird' : 'Termine werden') + ' heute fällig, nichts ist überfällig.'; } else if(z.aufgaben > 0){ satz = 'Nichts überfällig. ' + z.aufgaben + ' offene ' + (z.aufgaben === 1 ? 'Aufgabe' : 'Aufgaben') + ' in Arbeit.'; } else { satz = 'Nichts offen, nichts überfällig.'; } const zahl = (wert, label) => '
' + wert + '' + label + '
'; const ruhig = z.ueberfaellig === 0 && z.heuteFaellig === 0; return '
' + '

' + satz + '

' + '
' + zahl(z.aufgaben, 'offene Aufgaben') + zahl(z.termine, 'Termine im Blick') + zahl(z.freigaben, 'Freigaben') + zahl(z.abwesend, 'heute abwesend') + '
'; } // dashAbschnitt / dashLeer: die immer gleiche Umrahmung eines Blocks — // Überschrift, Anzahl, ein erklärender Zusatz am rechten Rand. // Parameter: titel, zahl (oder null), zusatz (oder null), inhalt. function dashAbschnitt(titel, zahl, zusatz, inhalt){ return '
' + '
' + '

' + esc(titel) + '

' + (zahl != null ? '' + zahl + '' : '') + (zusatz ? '' + esc(zusatz) + '' : '') + '
' + inhalt + '
'; } function dashLeer(text){ return '
' + esc(text) + '
'; } // greetingForHour:// greetingForHour: liefert je Tageszeit eine passende Begrüßung. Parameter: // hour (Number, 0-23). Rückgabe: String. function greetingForHour(hour){ if(hour < 5) return 'Gute Nacht'; if(hour < 11) return 'Guten Morgen'; if(hour < 18) return 'Guten Tag'; if(hour < 22) return 'Guten Abend'; return 'Gute Nacht'; } // startDashboardClock: (re-)startet das Intervall, das Begrüßung, Datum und // Uhrzeit im Dashboard-Header aktualisiert. Bricht sich selbst ab, sobald die // Zielelemente nicht mehr im DOM sind (z. B. weil das Dashboard neu gerendert // wurde). Keine Parameter, keine Rückgabe. function startDashboardClock(){ if(dashboardClockTimer) clearInterval(dashboardClockTimer); const name = (currentUser && (currentUser.displayName || currentUser.username)) || ''; const tick = () => { const textEl = document.getElementById('dashboardGreetingText'); const dateEl = document.getElementById('dashboardGreetingDate'); const clockEl = document.getElementById('dashboardClock'); if(!textEl || !dateEl || !clockEl){ clearInterval(dashboardClockTimer); dashboardClockTimer = null; return; } const now = new Date(); // Begrüßung und Datum stehen in EINER Zeile (früher untereinander), das // Datum als gedämpfter Zusatz — deshalb der führende Gedankenstrich. textEl.textContent = greetingForHour(now.getHours()) + (name ? ', ' + name : ''); dateEl.textContent = ' — ' + now.toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: 'long', year: 'numeric' }); clockEl.textContent = now.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); }; tick(); dashboardClockTimer = setInterval(tick, 30000); } // renderDashboardAufgabe: eine eigene Aufgabe als ZEILE — Titel und Projekt // links, Fortschritt in der Mitte, Fälligkeit rechts. // // Zuvor war das eine Karte, die eine halbe Bildschirmbreite für vier Zeilen // Inhalt belegte; zwei davon füllten den sichtbaren Bereich. Dieselben // Angaben stehen jetzt in einer Zeile, acht davon auf derselben Fläche. // Verlinkt wie bisher auf den Projektbereich (gleiches URL-Schema wie // openProjectPlan() in js/gantt.js). // Parameter: weekLabel, task. Rückgabe: HTML-String. function renderDashboardAufgabe(weekLabel, task){ const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(task.projectName || ''), window.location.origin); url.searchParams.set('weekLabel', weekLabel); url.searchParams.set('projNr', task.projectNr || ''); url.hash = 'aufgaben'; const heute = heuteIso(); const bald = isoPlusTage(2); // Dieselbe Kennzeichnung wie in der Terminliste darunter, damit die Kachel // "überfällig" und die beiden Listen zusammenpassen. const klasse = !task.end ? '' : (task.end < heute ? ' ist-ueberfaellig' : (task.end <= bald ? ' ist-bald' : '')); const datum = task.end ? formatWeekDate(task.end) : '—'; const relativ = task.end ? relativerTag(task.end) : 'ohne Frist'; const progress = Number(task.progress || 0); const tags = (Array.isArray(task.tags) ? task.tags : []) .map(t => '' + esc(t) + '').join(''); const projekt = (task.projectNr ? task.projectNr + ' ' : '') + (task.projectName || ''); return '' + '' + '' + esc(task.title || '(ohne Titel)') + '' + '' + esc(projekt) + tags + '' + '' + '' + '' + '' + progress + '%' + '' + '' + esc(datum) + '' + esc(relativ) + '' + ''; } // relativerTag: "heute", "morgen", "in 5 Tagen", "seit 3 Tagen" zu einem // ISO-Datum. Die Angabe steht neben dem Datum, weil "12.08." allein nicht // sagt, ob das morgen oder in drei Wochen ist. // Parameter: iso. Rückgabe: String. function relativerTag(iso){ const d = tageBis(iso); if(d === 0) return 'heute'; if(d === 1) return 'morgen'; if(d === -1) return 'seit 1 Tag'; if(d < 0) return 'seit ' + Math.abs(d) + ' Tagen'; return 'in ' + d + ' Tagen'; } // renderDashboardFristen:// renderDashboardFristen: die Termine der Projekte, denen man zugeordnet ist // (GET /my-dashboard, Feld projectDates — bereits nach Datum sortiert, auf // 21 Tage begrenzt und um die eigenen Aufgaben bereinigt). // // Bewusst eine LISTE und keine Kartenwand wie "Meine Aufgaben": Hier geht es // um Reihenfolge in der Zeit, und Termine, die untereinander stehen, lassen // sich der Reihe nach abarbeiten. Gegliedert wird nach Dringlichkeit statt // nach Projekt — in der Woche, in der etwas fällig wird, ist das Datum die // Frage, nicht das Projekt. // Parameter: weekLabel, eintraege (Array). Rückgabe: HTML-String. function renderDashboardFristen(weekLabel, eintraege){ if(!weekLabel) return dashLeer('Keine Sitzung vorhanden.'); if(!eintraege.length){ return dashLeer('In Ihren Projekten steht in den nächsten drei Wochen kein Termin an.'); } const wocheEnde = isoPlusTage(7); const gruppen = [ { titel: 'Überfällig', klasse: 'ist-ueberfaellig', treffer: d => d.ueberfaellig }, { titel: 'Diese Woche', klasse: 'ist-bald', treffer: d => !d.ueberfaellig && d.datum <= wocheEnde }, { titel: 'Danach', klasse: '', treffer: d => !d.ueberfaellig && d.datum > wocheEnde }, ]; // In derselben Karte wie "Meine Aufgaben" darüber: Beides ist der eigene // Arbeitsstrom und soll gleich aussehen. let html = '
'; gruppen.forEach(g => { const teil = eintraege.filter(g.treffer); if(!teil.length) return; html += '
' + '
' + esc(g.titel) + '' + teil.length + '
'; teil.forEach(d => { html += renderFristenZeile(weekLabel, d); }); html += '
'; }); return html + '
'; } // renderFristenZeile: eine Zeile der Terminliste. Der ganze Eintrag führt in // den Terminplan des Projekts — dorthin, wo sich der Termin ändern lässt. // Parameter: weekLabel, d (Eintrag). Rückgabe: HTML-String. function renderFristenZeile(weekLabel, d){ const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(d.projectName || ''), window.location.origin); url.searchParams.set('weekLabel', weekLabel); url.searchParams.set('projNr', d.projectNr || ''); url.hash = 'aufgaben'; const datum = d.datum ? formatWeekDate(d.datum) : ''; const tage = tageBis(d.datum); // Die Angabe in Tagen steht neben dem Datum, weil "12.08." allein nicht // sagt, ob das morgen oder in drei Wochen ist. const relativ = d.heute ? 'heute' : (tage < 0 ? (Math.abs(tage) === 1 ? 'seit 1 Tag' : 'seit ' + Math.abs(tage) + ' Tagen') : (tage === 1 ? 'morgen' : 'in ' + tage + ' Tagen')); const wer = Array.isArray(d.zustaendig) && d.zustaendig.length ? esc(d.zustaendig.join(', ')) : 'niemand zugewiesen'; return '' + '' + esc(datum) + '' + esc(relativ) + '' + '' + (d.art === 'meilenstein' ? 'Meilenstein' : 'Aufgabe') + '' + '' + esc(d.titel || '(ohne Titel)') + '' + '' + esc(d.projectNr ? d.projectNr + ' ' + d.projectName : d.projectName) + '' + '' + wer + '' + ''; } // tageBis: Kalendertage von heute bis zu einem ISO-Datum (negativ = liegt // zurück). Parameter: iso. Rückgabe: ganze Zahl. function tageBis(iso){ if(!iso) return 0; const heute = new Date(); heute.setHours(0, 0, 0, 0); const ziel = new Date(iso); ziel.setHours(0, 0, 0, 0); return Math.round((ziel.getTime() - heute.getTime()) / 86400000); } // renderDashboardFreigabe: eine ausstehende Datei-Freigabe als schmale // Zeile für die rechte Spalte. Parameter: pr. Rückgabe: HTML-String. function renderDashboardFreigabe(pr){ const projekt = (pr.projectNr ? pr.projectNr + ' ' : '') + (pr.projectName || ''); return '
' + '' + esc(initialsFor(pr.uploadedBy || '?')) + '' + '' + '' + esc(pr.originalFilename || '') + '' + '' + esc(projekt) + ' · ' + esc(pr.uploadedBy || '') + '' + '
'; } // renderDashboardAbsences: die Abwesenheiten der Kollegen (aktuelle sowie in // den nächsten 14 Tagen beginnende, siehe GET /my-dashboard). Dieselbe // Zeilenform wie die Freigaben daneben — beides ist Randinformation, die man // zur Kenntnis nimmt, ohne selbst tätig zu werden. // Parameter: absences (Array). Rückgabe: HTML-String. function renderDashboardAbsences(absences){ if(absences.length === 0){ return dashLeer('Keine Abwesenheiten in den nächsten 14 Tagen.'); } const zeilen = absences.map(a => { const zeitraum = a.start === a.end ? formatWeekDate(a.start) : formatWeekDate(a.start) + ' – ' + formatWeekDate(a.end); const typKlasse = ABSENCE_TYPE_CLASS[a.type] || ''; // Wer heute fehlt, ist die eigentliche Nachricht; die Art der Abwesenheit // steht dann in der Zeile darunter. const chip = a.current ? 'heute' : '' + esc(a.type || 'Abwesend') + ''; return '
' + '' + esc(initialsFor(a.name)) + '' + '' + '' + esc(a.name || '') + '' + '' + esc(a.type || '') + ' · ' + esc(zeitraum) + (a.note ? ' · ' + esc(a.note) : '') + '' + '' + chip + '
'; }).join(''); return '
' + zeilen + '
'; } // initialsFor:// initialsFor: liefert bis zu zwei Buchstaben Initialen für einen Namen, als // Avatar-Kürzel in der Kollegen-Abwesenheitsliste. Parameter: name (String). // Rückgabe: String. function initialsFor(name){ const parts = String(name || '').trim().split(/\s+/).filter(Boolean); if(parts.length === 0) return '?'; if(parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); }