// -----------------------------
// 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 '';
}
// 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();
}