Files
projektuebersicht/public/js/dashboard/dashboard.js
erik 5b20adee48 All new
alle new
2026-10-01 06:46:51 +02:00

426 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// Projektübersicht — Dashboard ("Meine Aufgaben"-Tab)
// -----------------------------
// Modul für den ersten Tab der Hauptseite (Test.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 = '<div class="hint">Lade Dashboard…</div>';
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 = '<div class="hint">Dashboard konnte nicht geladen werden: ' + esc(err.message || String(err)) + '</div>';
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 += '<div class="hint">Ihrem Benutzerkonto ist keine Person zugeordnet — es können daher keine Aufgaben angezeigt werden. Ein Administrator kann die Zuordnung in der Benutzerverwaltung ergänzen.</div>';
}
html += '<div class="dash-zonen">';
// Linke Zone: der eigene Arbeitsstrom
html += '<div>';
html += dashAbschnitt('Meine Aufgaben', tasks.length, 'mir persönlich zugewiesen',
!weekLabel ? dashLeer('Keine Sitzung vorhanden.')
: (tasks.length === 0 ? dashLeer('Keine offenen Aufgaben — alles abgearbeitet.')
: '<div class="dash-karte">' + tasks.map(t => renderDashboardAufgabe(weekLabel, t)).join('') + '</div>'));
html += dashAbschnitt('Termine meiner Projekte', projectDates.length, 'nächste drei Wochen',
renderDashboardFristen(weekLabel, projectDates));
html += '</div>';
// Rechte Zone: was man wissen muss, ohne es selbst zu tun
html += '<div>';
html += dashAbschnitt('Ausstehende Freigaben', pending.length, null,
pending.length === 0 ? dashLeer('Keine ausstehenden Freigaben.')
: '<div class="dash-karte">' + pending.map(renderDashboardFreigabe).join('') + '</div>');
html += dashAbschnitt('Abwesenheiten', absences.length, null,
renderDashboardAbsences(absences));
html += '</div>';
html += '</div>';
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 '<div class="dash-kopf">'
+ '<p class="dash-gruss"><span id="dashboardGreetingText"></span>'
+ '<em id="dashboardGreetingDate"></em></p>'
+ '<div class="dash-kopf-rechts">'
+ (weekLabel ? '<span class="dash-sitzung">' + esc(weekLabel) + '</span>' : '')
+ '<span class="dash-uhr" id="dashboardClock"></span>'
+ '</div>'
+ '</div>';
}
// 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 = '<b class="ist-rot">' + z.ueberfaellig + '</b> '
+ (z.ueberfaellig === 1 ? 'Termin ist überfällig' : 'Termine sind überfällig')
+ (z.heuteFaellig ? ', <b>' + z.heuteFaellig + '</b> ' + (z.heuteFaellig === 1 ? 'wird' : 'werden') + ' heute fällig' : '')
+ '.';
} else if(z.heuteFaellig > 0){
satz = '<b>' + z.heuteFaellig + '</b> ' + (z.heuteFaellig === 1 ? 'Termin wird' : 'Termine werden')
+ ' heute fällig, nichts ist überfällig.';
} else if(z.aufgaben > 0){
satz = '<b class="ist-ruhig">Nichts überfällig.</b> ' + z.aufgaben + ' offene '
+ (z.aufgaben === 1 ? 'Aufgabe' : 'Aufgaben') + ' in Arbeit.';
} else {
satz = '<b class="ist-ruhig">Nichts offen, nichts überfällig.</b>';
}
const zahl = (wert, label) =>
'<div class="dash-zahl' + (wert === 0 ? ' ist-null' : '') + '"><b>' + wert + '</b><span>' + label + '</span></div>';
const ruhig = z.ueberfaellig === 0 && z.heuteFaellig === 0;
return '<div class="dash-band' + (ruhig ? ' ist-ruhig' : '') + '">'
+ '<p class="dash-befund">' + satz + '</p>'
+ '<div class="dash-band-zahlen">'
+ zahl(z.aufgaben, 'offene Aufgaben')
+ zahl(z.termine, 'Termine im Blick')
+ zahl(z.freigaben, 'Freigaben')
+ zahl(z.abwesend, 'heute abwesend')
+ '</div></div>';
}
// 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 '<section class="dash-abschnitt">'
+ '<div class="dash-abschnitt-kopf">'
+ '<h3 class="dash-abschnitt-titel">' + esc(titel) + '</h3>'
+ (zahl != null ? '<span class="dash-abschnitt-zahl">' + zahl + '</span>' : '')
+ (zusatz ? '<span class="dash-abschnitt-zusatz">' + esc(zusatz) + '</span>' : '')
+ '</div>' + inhalt + '</section>';
}
function dashLeer(text){
return '<div class="dash-karte"><div class="dash-leer">' + esc(text) + '</div></div>';
}
// 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 => '<span class="dash-auf-tag">' + esc(t) + '</span>').join('');
const projekt = (task.projectNr ? task.projectNr + ' ' : '') + (task.projectName || '');
return '<a class="dash-auf" href="' + url.toString() + '" target="_blank" rel="noopener">'
+ '<span class="dash-auf-text">'
+ '<span class="dash-auf-titel">' + esc(task.title || '(ohne Titel)') + '</span>'
+ '<span class="dash-auf-meta">' + esc(projekt) + tags + '</span>'
+ '</span>'
+ '<span class="dash-auf-fortschritt">'
+ '<span class="dash-auf-bar"><i style="width:' + Math.min(100, Math.max(0, progress)) + '%"></i></span>'
+ '<span class="dash-auf-pct">' + progress + '%</span>'
+ '</span>'
+ '<span class="dash-auf-frist' + klasse + '">' + esc(datum) + '<em>' + esc(relativ) + '</em></span>'
+ '</a>';
}
// 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 = '<div class="dash-karte">';
gruppen.forEach(g => {
const teil = eintraege.filter(g.treffer);
if(!teil.length) return;
html += '<div class="fristen-gruppe ' + g.klasse + '">'
+ '<div class="fristen-gruppe-titel">' + esc(g.titel) + '<span>' + teil.length + '</span></div>';
teil.forEach(d => { html += renderFristenZeile(weekLabel, d); });
html += '</div>';
});
return html + '</div>';
}
// 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(', '))
: '<span class="hint" style="margin:0">niemand zugewiesen</span>';
return '<a class="fristen-zeile" href="' + url.toString() + '" target="_blank" rel="noopener">'
+ '<span class="fristen-datum"><b>' + esc(datum) + '</b><em>' + esc(relativ) + '</em></span>'
+ '<span class="fristen-art' + (d.art === 'meilenstein' ? ' ist-meilenstein' : '') + '">'
+ (d.art === 'meilenstein' ? 'Meilenstein' : 'Aufgabe') + '</span>'
+ '<span class="fristen-titel">' + esc(d.titel || '(ohne Titel)') + '</span>'
+ '<span class="fristen-projekt">' + esc(d.projectNr ? d.projectNr + ' ' + d.projectName : d.projectName) + '</span>'
+ '<span class="fristen-wer">' + wer + '</span>'
+ '</a>';
}
// 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 '<div class="dash-rand-zeile">'
+ '<span class="dash-avatar">' + esc(initialsFor(pr.uploadedBy || '?')) + '</span>'
+ '<span class="dash-rand-text">'
+ '<span class="dash-rand-name">' + esc(pr.originalFilename || '') + '</span>'
+ '<span class="dash-rand-meta">' + esc(projekt) + ' · ' + esc(pr.uploadedBy || '') + '</span>'
+ '</span></div>';
}
// 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
? '<span class="dash-chip ist-heute">heute</span>'
: '<span class="dash-chip ' + typKlasse + '">' + esc(a.type || 'Abwesend') + '</span>';
return '<div class="dash-rand-zeile">'
+ '<span class="dash-avatar">' + esc(initialsFor(a.name)) + '</span>'
+ '<span class="dash-rand-text">'
+ '<span class="dash-rand-name">' + esc(a.name || '') + '</span>'
+ '<span class="dash-rand-meta">' + esc(a.type || '') + ' · ' + esc(zeitraum)
+ (a.note ? ' · ' + esc(a.note) : '') + '</span>'
+ '</span>' + chip + '</div>';
}).join('');
return '<div class="dash-karte">' + zeilen + '</div>';
}
// 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();
}