Files
erikandClaude Opus 5.5 a2266662ad Dashboard-Reihenfolge, Drei-Punkte-Menü, Mehrrollen-Kürzel, Login-Rechte
- Meine Aufgaben: Projekte in der Reihenfolge der Projektliste, je Konto
  per Ziehen umsortierbar (users.dashboard_reihenfolge)
- Projektliste: Öffnen/Löschen hinter einem "⋯"-Menü
- Avatare je Person statt je Rolle; mehrere Rollen als senkrechte
  Farbstreifen; Titelleiste zieht Rollenänderungen sofort nach
- Nach dem Anmelden vollständiges Konto über /whoami laden (Seitenleiste
  zeigte sonst erst nach Neuladen alle Einträge)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:21:29 +01:00

762 lines
38 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 (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 "Meine Projekte": je Projekt, dem man zugeordnet ist, ein
// Block mit der eigenen Rolle, der laufenden Leistungsphase und
// den offenen Terminen des Projekts — eigene Aufgaben
// hervorgehoben (renderDashProjekte()). Projekte, in denen man
// nur eine einzelne Aufgabe hat, stehen ebenfalls dort.
// 4. RECHTS Ein Monatskalender mit allen Terminen der eigenen Projekte,
// den eigenen Aufgaben, Feiertagen und den Abwesenheiten der
// Projektbeteiligten; ein Klick auf einen Tag listet ihn
// (renderDashKalender()). Darunter ausstehende Freigaben und
// Abwesenheiten.
//
// Projekte und Kalender rechnen mit der bereits geladenen Sitzung
// (currentWeek(), /load-all) statt mit GET /my-dashboard: Der Kalender soll
// beliebig weit blättern können, der Server liefert Projekttermine aber nur
// 21 Tage voraus. Kopf und Band bleiben bei den Zahlen des Servers.
// Entwurf "B — Monatsblatt" aus Eriks Vergleich dreier Varianten.
//
// 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>';
}
// Neu zugewiesene Aufgaben (js/benachrichtigungen.js) — vor allem anderen,
// weil es das ist, was sich seit dem letzten Blick geändert hat.
html += '<div id="dashNeu"></div>';
html += '<div class="dash-zonen">';
// Linke Zone: die eigenen Projekte mit ihren Terminen
const daten = dashDaten();
html += '<div>';
const zuAlle = dashZuLesen();
const alleOffen = daten.projekte.some(x => !zuAlle.has(dashProjKey(x.p)));
html += dashAbschnitt('Meine Projekte', daten.projekte.length, null,
renderDashProjekte(daten),
daten.projekte.length > 1
? '<button type="button" class="dash-proj-alle" id="dashReihenfolgeReset" onclick="dashReihenfolgeZuruecksetzen()"'
+ (dashEigeneReihenfolge().length ? '' : ' hidden') + ' title="Reihenfolge wieder wie in der Projektliste">Wie Projektliste</button>'
+ '<button type="button" class="dash-proj-alle" id="dashAlleKnopf" onclick="dashAlleUmschalten()">'
+ (alleOffen ? 'Alle einklappen' : 'Alle aufklappen') + '</button>'
: '');
html += '</div>';
// Rechte Zone: Kalender, dann was man wissen muss, ohne es selbst zu tun
html += '<div>';
html += dashAbschnitt('Kalender', null, null, '<div class="dash-karte" id="dashKalender"></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;
if(typeof renderDashNeu === 'function'){ renderDashNeu(); ladeBenachrichtigungen(true); }
dashKalDaten = daten;
renderDashKalender();
startDashboardClock();
}
// ---------- Meine Projekte + Kalender (Entwurf B "Monatsblatt") ----------
// dashKalDaten / dashKalMonat / dashKalTag: Zustand des Kalenders. Der Monat
// und der gewählte Tag überleben ein Neuladen des Dashboards nicht absichtlich
// nicht — wer wiederkommt, will "heute" sehen.
let dashKalDaten = null;
let dashKalMonat = null; // { jahr, monat } (monat 0–11)
let dashKalTag = null; // ISO-Datum
// dashDaten: sammelt aus der aktuellen Sitzung (currentWeek()) die Projekte
// des angemeldeten Kontos und alle Kalendereinträge. Ein Projekt gehört dazu,
// wenn die Person ihm zugeordnet ist (assign, Haupt 1 / Unterstützung 2) oder
// dort eine eigene offene Aufgabe hat.
// Rückgabe: { projekte:[{p, rollen:[{rolle,state}], termine:[…]}], eintraege:[…] }.
function dashDaten(){
// geladen: false, solange /load-all noch nicht da ist — das Dashboard wird
// beim Anmelden oft schon vorher aufgebaut; load() zeichnet es danach neu.
const geladen = Array.isArray(db.weeks) && db.weeks.length > 0;
const leer = { projekte: [], eintraege: [], abwesenheiten: [], geladen };
const w = (geladen && typeof currentWeek === 'function') ? currentWeek() : null;
const ich = currentUser && currentUser.personId;
if(!w || !ich) return leer;
const heute = heuteIso();
const namen = {};
(w.people || []).forEach(x => { namen[x.id] = x.fullName || x.name || x.id; });
const projekte = [];
(w.projects || []).forEach(p => {
const rollen = [];
Object.keys(p.assign || {}).forEach(key => {
const [pid, rolle] = key.split('|');
const state = Number(p.assign[key]) || 0;
if(pid === ich && state > 0) rollen.push({ rolle: rolle || '', state });
});
const plan = p.plan || {};
const termine = [];
(plan.todos || []).forEach(t => {
if(t.done) return;
const meins = Array.isArray(t.assignees) && t.assignees.includes(ich);
// Fremde Aufgaben ohne Datum gehören in keinen Kalender und keine Terminliste.
if(!t.end && !meins) return;
termine.push({
art: 'aufgabe', titel: t.title || '(ohne Titel)', datum: t.end || '',
meins, progress: Number(t.progress) || 0,
wer: (t.assignees || []).map(id => namen[id] || id),
});
});
(plan.milestones || []).forEach(m => {
if(!m.date) return;
termine.push({ art: 'meilenstein', titel: m.name || 'Meilenstein', datum: m.date, meins: false, wer: [] });
});
const hatEigene = termine.some(t => t.meins);
if(!rollen.length && !hatEigene) return;
// Erledigte Vergangenheit interessiert hier nicht: Meilensteine vor heute
// fallen weg (sie haben kein Erledigt-Kennzeichen), offene Aufgaben vor
// heute bleiben als überfällig stehen.
const relevant = termine.filter(t => !(t.art === 'meilenstein' && t.datum < heute));
relevant.sort((a, b) => (a.datum || '9999').localeCompare(b.datum || '9999'));
projekte.push({ p, rollen, termine: relevant, alleTermine: termine });
});
// Reihenfolge wie in der Projektliste (Gruppe, dann Position darin) —
// oder die eigene, auf dem Dashboard gezogene (dashSortieren()).
dashSortieren(projekte);
// Kalendereinträge: alle datierten Termine dieser Projekte (auch vergangene
// Meilensteine — beim Zurückblättern sollen sie da sein).
const eintraege = [];
projekte.forEach(x => x.alleTermine.forEach(t => {
if(t.datum) eintraege.push(Object.assign({ projekt: x.p }, t));
}));
// Abwesenheiten: das eigene Konto und alle, die einem der eigenen Projekte
// zugeordnet sind — wer sonst fehlt, betrifft die eigene Arbeit nicht.
const beteiligt = new Set([ich]);
projekte.forEach(x => Object.keys(x.p.assign || {}).forEach(key => {
if(Number(x.p.assign[key]) > 0) beteiligt.add(key.split('|')[0]);
}));
const abwesenheiten = [];
(Array.isArray(db.globalPeople) ? db.globalPeople : []).forEach(person => {
if(!beteiligt.has(person.id)) return;
(person.absences || []).forEach(a => {
if(a.start && a.end) abwesenheiten.push({ name: person.id === ich ? 'Sie' : (person.fullName || person.name), start: a.start, end: a.end, type: a.type || 'Abwesend' });
});
});
return { projekte, eintraege, abwesenheiten, geladen };
}
// dashProjektUrl: Adresse des Projektbereichs (gleiches Schema wie
// openProjectPlan() in js/gantt.js). Parameter: p, abschnitt. Rückgabe: String.
function dashProjektUrl(p, abschnitt){
const w = currentWeek();
const url = new URL('/' + PLAN_SLUG + '/' + encodeURIComponent(p.name || ''), window.location.origin);
url.searchParams.set('weekLabel', w ? w.label : '');
url.searchParams.set('projNr', p.nr || '');
url.hash = abschnitt || 'aufgaben';
return url.toString();
}
// DASH_TERMINE_JE_PROJEKT: so viele Termine stehen je Projekt offen da,
// Überfälliges zuerst. Der Rest steckt hinter "weitere Termine" — sonst
// verdrängt ein Projekt mit 25 gerissenen Fristen alle anderen aus dem Blick
// (so im Bestand gesehen). Die Zahl der überfälligen steht ohnehin im Kopf.
const DASH_TERMINE_JE_PROJEKT = 6;
// Eingeklappte Projektblöcke (Eriks Wunsch): je Projekt merkt sich der
// Browser den Zustand über die dauerhafte projectUid — die Projekt-id ändert
// sich mit jeder Sitzung. Reiner Darstellungszustand, deshalb localStorage
// (Schlüssel DASH_ZU_KEY); ohne Speicher (privates Fenster) ist alles offen.
const DASH_ZU_KEY = 'projuebersicht-dash-zu';
function dashZuLesen(){
try { return new Set(JSON.parse(localStorage.getItem(DASH_ZU_KEY) || '[]')); } catch(e){ return new Set(); }
}
function dashZuSchreiben(set){
try { localStorage.setItem(DASH_ZU_KEY, JSON.stringify([...set])); } catch(e){ /* nur Komfort */ }
}
function dashProjKey(p){ return p.projectUid || p.nr || p.id; }
// ---------- Reihenfolge der Projekte ----------
// Grundlage ist die Reihenfolge der Projektliste (GROUP_ORDER, dann p.order).
// Jedes Konto kann sie auf dem Dashboard durch Ziehen am Griff ändern; die
// eigene Reihenfolge liegt am Server (users.dashboard_reihenfolge, über
// currentUser.dashboardReihenfolge), gilt also auf jedem Gerät. Projekte,
// die darin noch fehlen (neu zugeordnet), stehen nach ihrer Stelle in der
// Projektliste dahinter.
function dashListenRang(p){
const g = GROUP_ORDER.indexOf(p.group);
return (g === -1 ? 999 : g) * 1e6 + (Number(p.order) || 0);
}
function dashEigeneReihenfolge(){
return (currentUser && Array.isArray(currentUser.dashboardReihenfolge)) ? currentUser.dashboardReihenfolge : [];
}
function dashSortieren(projekte){
const eigen = dashEigeneReihenfolge();
const pos = new Map(eigen.map((k, i) => [k, i]));
projekte.sort((a, b) => {
const ia = pos.has(dashProjKey(a.p)) ? pos.get(dashProjKey(a.p)) : Infinity;
const ib = pos.has(dashProjKey(b.p)) ? pos.get(dashProjKey(b.p)) : Infinity;
if(ia !== ib) return ia - ib;
return dashListenRang(a.p) - dashListenRang(b.p);
});
}
function dashReihenfolgeSpeichern(liste){
if(currentUser) currentUser.dashboardReihenfolge = liste;
fetch(baseUrl() + '/dashboard-reihenfolge', {
method: 'POST',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify({ reihenfolge: liste })
}).then(r => { if(!r.ok) throw new Error(r.status); })
.catch(() => alert('Die Reihenfolge konnte nicht gespeichert werden.'));
}
// dashReihenfolgeZuruecksetzen: wieder wie in der Projektliste.
function dashReihenfolgeZuruecksetzen(){
dashReihenfolgeSpeichern([]);
renderDashboard();
}
// Ziehen: Der Block wird nur ziehbar, solange der Griff gedrückt ist —
// sonst ließe sich Text in den Terminen nicht mehr markieren.
let dashGezogen = null;
function dashGriffDruck(griff){
const block = griff.closest('.dash-proj');
if(block) block.draggable = true;
}
document.addEventListener('mouseup', () => {
if(dashGezogen) return;
document.querySelectorAll('.dash-proj[draggable="true"]').forEach(b => { b.draggable = false; });
});
document.addEventListener('dragstart', e => {
const block = e.target && e.target.closest && e.target.closest('.dash-proj[draggable="true"]');
if(!block) return;
dashGezogen = block;
block.classList.add('ist-gezogen');
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', block.dataset.key || ''); } catch(err){ /* egal */ }
});
document.addEventListener('dragover', e => {
if(!dashGezogen) return;
const liste = dashGezogen.parentElement;
if(!liste || !liste.contains(e.target)) return;
e.preventDefault();
const andere = [...liste.querySelectorAll('.dash-proj:not(.ist-gezogen)')];
const danach = andere.find(b => { const r = b.getBoundingClientRect(); return e.clientY < r.top + r.height / 2; });
if(danach) liste.insertBefore(dashGezogen, danach); else liste.appendChild(dashGezogen);
});
document.addEventListener('drop', e => { if(dashGezogen) e.preventDefault(); });
document.addEventListener('dragend', () => {
if(!dashGezogen) return;
const block = dashGezogen;
dashGezogen = null;
block.classList.remove('ist-gezogen');
block.draggable = false;
const neu = [...block.parentElement.querySelectorAll('.dash-proj')].map(b => b.dataset.key);
const alt = dashEigeneReihenfolge();
// Schon bekannte, gerade nicht angezeigte Projekte (z. B. vorübergehend
// ohne Zuordnung) behalten ihren Platz am Ende.
const liste = neu.concat(alt.filter(k => !neu.includes(k)));
if(JSON.stringify(liste) !== JSON.stringify(alt)) dashReihenfolgeSpeichern(liste);
const reset = document.getElementById('dashReihenfolgeReset');
if(reset) reset.hidden = false;
});
// dashVerschieben: Tastatur-Alternative zum Ziehen (Griff fokussiert,
// Pfeil hoch/runter).
function dashGriffTaste(e, griff){
if(e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
e.preventDefault();
const block = griff.closest('.dash-proj');
const liste = block && block.parentElement;
if(!liste) return;
if(e.key === 'ArrowUp' && block.previousElementSibling) liste.insertBefore(block, block.previousElementSibling);
else if(e.key === 'ArrowDown' && block.nextElementSibling) liste.insertBefore(block.nextElementSibling, block);
else return;
griff.focus();
const neu = [...liste.querySelectorAll('.dash-proj')].map(b => b.dataset.key);
const alt = dashEigeneReihenfolge();
dashReihenfolgeSpeichern(neu.concat(alt.filter(k => !neu.includes(k))));
const reset = document.getElementById('dashReihenfolgeReset');
if(reset) reset.hidden = false;
}
// dashProjUmschalten: klappt einen Projektblock auf oder zu — direkt am DOM,
// ohne das Dashboard neu zu laden. Parameter: knopf (der Kopf-Button).
function dashProjUmschalten(knopf){
const block = knopf.closest('.dash-proj');
if(!block) return;
const zu = !block.classList.contains('ist-zu');
block.classList.toggle('ist-zu', zu);
knopf.setAttribute('aria-expanded', String(!zu));
const set = dashZuLesen();
if(zu) set.add(block.dataset.key); else set.delete(block.dataset.key);
dashZuSchreiben(set);
dashAlleKnopfAktualisieren();
}
// dashAlleUmschalten: alle Projektblöcke auf einmal ein- bzw. ausklappen.
function dashAlleUmschalten(){
const bloecke = [...document.querySelectorAll('#dashboardContent .dash-proj')];
const zuklappen = bloecke.some(b => !b.classList.contains('ist-zu'));
const set = dashZuLesen();
bloecke.forEach(b => {
b.classList.toggle('ist-zu', zuklappen);
const k = b.querySelector('.dash-proj-toggle');
if(k) k.setAttribute('aria-expanded', String(!zuklappen));
if(zuklappen) set.add(b.dataset.key); else set.delete(b.dataset.key);
});
dashZuSchreiben(set);
dashAlleKnopfAktualisieren();
}
function dashAlleKnopfAktualisieren(){
const knopf = document.getElementById('dashAlleKnopf');
if(!knopf) return;
const offen = [...document.querySelectorAll('#dashboardContent .dash-proj')].some(b => !b.classList.contains('ist-zu'));
knopf.textContent = offen ? 'Alle einklappen' : 'Alle aufklappen';
}
// renderDashProjekte: die linke Zone. Parameter: daten (dashDaten()).
// Rückgabe: HTML-String.
function renderDashProjekte(daten){
if(!currentUser || !currentUser.personId) return dashLeer('Ohne zugeordnete Person gibt es keine eigenen Projekte.');
if(!daten.geladen) return dashLeer('Projekte werden geladen…');
if(!daten.projekte.length) return dashLeer('Sie sind in der aktuellen Sitzung keinem Projekt zugeordnet und haben keine offenen Aufgaben.');
const heute = heuteIso();
const zu = dashZuLesen();
return '<div class="dash-proj-liste">' + daten.projekte.map(x => {
const p = x.p;
const key = dashProjKey(p);
const istZu = zu.has(key);
const rollen = x.rollen.length
? x.rollen.map(r => '<span class="dash-rolle' + (r.state === 2 ? ' ist-neben' : '') + '" title="'
+ (r.state === 2 ? 'Unterstützung' : 'Hauptverantwortung') + '">' + esc(r.rolle) + '</span>').join('')
: '<span class="dash-rolle ist-neben" title="Nicht zugeordnet, aber mit eigener Aufgabe">nur Aufgabe</span>';
const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0];
const pct = aktLp && p.lph && p.lph[aktLp] !== undefined && p.lph[aktLp] !== '' ? Math.max(0, Math.min(100, Number(p.lph[aktLp]) || 0)) : null;
const phase = aktLp
? '<span class="dash-proj-phase"><span>' + esc(aktLp) + '</span><span class="dash-auf-bar"><i style="width:' + (pct || 0) + '%"></i></span><span class="dash-auf-pct">' + (pct === null ? '—' : pct + '%') + '</span></span>'
: '<span class="dash-proj-phase is-leer">keine amtierende Leistungsphase</span>';
const ueber = x.termine.filter(t => t.datum && t.datum < heute);
const kommend = x.termine.filter(t => !t.datum || t.datum >= heute);
const alle = ueber.concat(kommend);
const sichtbar = alle.slice(0, DASH_TERMINE_JE_PROJEKT);
const rest = alle.length - sichtbar.length;
const zeilen = sichtbar.length
? sichtbar.map(t => renderDashTermin(p, t)).join('')
: '<div class="dash-leer">Keine offenen Termine.</div>';
const mehr = rest > 0
? '<a class="dash-proj-mehr" href="' + dashProjektUrl(p, 'aufgaben') + '" target="_blank" rel="noopener">' + rest + ' weitere Termine im Projekt</a>'
: '';
// Eingeklappt bleibt sichtbar, was Aufmerksamkeit braucht: wie viele
// Termine überfällig bzw. offen sind.
const kurz = (ueber.length ? '<span class="dash-proj-zahl ist-rot">' + ueber.length + ' überfällig</span>' : '')
+ '<span class="dash-proj-zahl">' + kommend.length + ' offen</span>';
return '<div class="dash-karte dash-proj' + (istZu ? ' ist-zu' : '') + '" data-key="' + esc(key) + '">'
+ '<div class="dash-proj-kopf">'
+ (daten.projekte.length > 1 ? '<span class="dash-proj-griff" tabindex="0" role="button" title="Ziehen, um die Reihenfolge zu ändern (oder Pfeiltasten)" aria-label="Reihenfolge ändern" onmousedown="dashGriffDruck(this)" ontouchstart="dashGriffDruck(this)" onkeydown="dashGriffTaste(event, this)">' + icon('grip') + '</span>' : '')
+ '<button type="button" class="dash-proj-toggle" aria-expanded="' + (!istZu) + '" onclick="dashProjUmschalten(this)" title="Ein-/ausklappen">'
+ '<span class="dash-proj-chev" aria-hidden="true">›</span>'
+ '<span class="dash-proj-nr">' + esc(p.nr || '') + '</span><span class="dash-proj-titel">' + esc(p.name || '') + '</span>'
+ '</button>'
+ '<span class="dash-proj-kurz">' + kurz + '</span>'
+ '<span class="dash-rollen">' + rollen + '</span>'
+ '<a class="dash-proj-oeffnen" href="' + dashProjektUrl(p, 'dashboard') + '" target="_blank" rel="noopener" title="Projekt öffnen">Öffnen</a>'
+ '</div>'
+ '<div class="dash-proj-inhalt">'
+ '<div class="dash-proj-sub">' + phase + '</div>'
+ '<div class="dash-proj-termine">' + zeilen + mehr + '</div>'
+ '</div>'
+ '</div>';
}).join('') + '</div>';
}
// renderDashTermin: eine Terminzeile im Projektblock und in der Tagesliste
// des Kalenders. Eigene Aufgaben tragen das Zeichen "A" in Akzentfarbe,
// fremde Aufgaben dazu die Zuständigen, Meilensteine eine Raute.
// Parameter: p (Projekt), t (Termin), mitProjekt (Projekt mit anzeigen).
// Rückgabe: HTML-String.
function renderDashTermin(p, t, mitProjekt){
const heute = heuteIso();
const ueber = t.art === 'aufgabe' && t.datum && t.datum < heute;
const bald = !ueber && t.datum && t.datum <= isoPlusTage(2);
const zeichen = t.art === 'meilenstein' ? '◆' : (t.meins ? 'A' : 'a');
const klasse = t.art === 'meilenstein' ? 'ist-ms' : (t.meins ? 'ist-meins' : 'ist-fremd');
const unter = mitProjekt
? (p.nr ? p.nr + ' ' : '') + (p.name || '')
: (t.art === 'meilenstein' ? 'Meilenstein' : (t.meins ? 'meine Aufgabe' : (t.wer.length ? t.wer.join(', ') : 'niemand zugewiesen')));
return '<a class="dash-termin" href="' + dashProjektUrl(p, t.art === 'meilenstein' ? 'terminplan' : 'aufgaben') + '" target="_blank" rel="noopener">'
+ '<span class="dash-termin-art ' + klasse + '">' + zeichen + '</span>'
+ '<span class="dash-termin-text"><span class="dash-termin-titel">' + esc(t.titel) + '</span>'
+ '<span class="dash-termin-meta">' + esc(unter) + '</span></span>'
+ '<span class="dash-termin-datum' + (ueber ? ' ist-ueberfaellig' : (bald ? ' ist-bald' : '')) + '">'
+ (t.datum ? esc(formatWeekDate(t.datum)) + '<em>' + esc(relativerTag(t.datum)) + '</em>' : 'ohne Frist')
+ '</span></a>';
}
// renderDashKalender: der Monatskalender der rechten Zone samt Tagesliste.
// Zeichnet nur #dashKalender neu (Blättern und Tageswahl lösen keinen neuen
// Abruf von /my-dashboard aus). Keine Parameter.
function renderDashKalender(){
const el = document.getElementById('dashKalender');
if(!el || !dashKalDaten) return;
const heute = heuteIso();
if(!dashKalTag) dashKalTag = heute;
if(!dashKalMonat){ const d = parseDateISO(heute); dashKalMonat = { jahr: d.getFullYear(), monat: d.getMonth() }; }
const { jahr, monat } = dashKalMonat;
const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember'];
// Einträge je Tag vorsortieren.
const jeTag = {};
dashKalDaten.eintraege.forEach(e => { (jeTag[e.datum] = jeTag[e.datum] || []).push(e); });
const abwesendAm = iso => dashKalDaten.abwesenheiten.filter(a => a.start <= iso && iso <= a.end);
const erster = new Date(jahr, monat, 1);
const start = new Date(erster); start.setDate(1 - ((erster.getDay() + 6) % 7));
let zellen = '';
for(let r = 0; r < 6; r++){
zellen += '<span class="dash-kal-kw">' + getWeekNumber(start) + '</span>';
for(let c = 0; c < 7; c++){
const iso = formatDateISO(start);
const e = jeTag[iso] || [];
const punkte = e.slice(0, 3).map(x => '<i class="' + (x.art === 'meilenstein' ? 'ist-ms'
: (x.datum < heute ? 'ist-rot' : (x.meins ? 'ist-meins' : 'ist-fremd'))) + '"></i>').join('')
+ (abwesendAm(iso).length ? '<i class="ist-ab"></i>' : '');
const feiertag = typeof HOLIDAYS !== 'undefined' && HOLIDAYS.has(iso);
const klassen = ['dash-kal-tag',
start.getMonth() !== monat ? 'ist-fremd' : '',
c > 4 || feiertag ? 'ist-frei' : '',
iso === heute ? 'ist-heute' : '',
iso === dashKalTag ? 'ist-gewaehlt' : ''].filter(Boolean).join(' ');
zellen += '<button type="button" class="' + klassen + '" onclick="dashKalWaehle(\'' + iso + '\')"'
+ ' aria-label="' + esc(formatWeekDate(iso)) + (e.length ? ', ' + e.length + ' Termine' : '') + '"'
+ (feiertag ? ' title="Feiertag"' : '') + '>'
+ start.getDate() + '<span class="dash-kal-punkte">' + punkte + '</span></button>';
start.setDate(start.getDate() + 1);
}
}
const tagE = (jeTag[dashKalTag] || []);
const tagA = abwesendAm(dashKalTag);
const feiertagHeute = typeof HOLIDAYS !== 'undefined' && HOLIDAYS.has(dashKalTag);
const tagD = parseDateISO(dashKalTag);
const tagTitel = tagD.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })
+ (dashKalTag === heute ? ' (heute)' : '');
const liste = (feiertagHeute ? '<div class="dash-kal-hinweis">Feiertag</div>' : '')
+ tagE.map(e => renderDashTermin(e.projekt, e, true)).join('')
+ tagA.map(a => '<div class="dash-termin"><span class="dash-termin-art ist-ab">–</span>'
+ '<span class="dash-termin-text"><span class="dash-termin-titel">' + esc(a.name) + '</span>'
+ '<span class="dash-termin-meta">' + esc(a.type) + ' bis ' + esc(formatWeekDate(a.end)) + '</span></span></div>').join('');
el.innerHTML = '<div class="dash-kal">'
+ '<div class="dash-kal-kopf">'
+ '<button type="button" class="dash-kal-nav" onclick="dashKalBlaettern(-1)" aria-label="Voriger Monat">‹</button>'
+ '<b>' + MONATE[monat] + ' ' + jahr + '</b>'
+ '<button type="button" class="dash-kal-nav" onclick="dashKalBlaettern(1)" aria-label="Nächster Monat">›</button>'
+ '</div>'
+ '<div class="dash-kal-raster"><span class="dash-kal-wt">KW</span>'
+ ['Mo','Di','Mi','Do','Fr','Sa','So'].map(t => '<span class="dash-kal-wt">' + t + '</span>').join('')
+ zellen + '</div>'
+ '<div class="dash-kal-legende"><span><i class="ist-meins"></i>meine Aufgabe</span><span><i class="ist-fremd"></i>Projektaufgabe</span>'
+ '<span><i class="ist-ms"></i>Meilenstein</span><span><i class="ist-rot"></i>überfällig</span><span><i class="ist-ab"></i>Abwesenheit</span></div>'
+ '</div>'
+ '<div class="dash-kal-liste"><div class="dash-kal-liste-titel">' + esc(tagTitel) + '</div>'
+ (liste || '<div class="dash-leer">Keine Termine an diesem Tag.</div>') + '</div>';
}
// dashKalWaehle / dashKalBlaettern: Tag wählen bzw. Monat wechseln. Ein Tag
// aus dem Vor- oder Folgemonat blättert dorthin.
function dashKalWaehle(iso){
dashKalTag = iso;
const d = parseDateISO(iso);
dashKalMonat = { jahr: d.getFullYear(), monat: d.getMonth() };
renderDashKalender();
}
function dashKalBlaettern(n){
let { jahr, monat } = dashKalMonat;
monat += n;
if(monat < 0){ monat = 11; jahr--; }
if(monat > 11){ monat = 0; jahr++; }
dashKalMonat = { jahr, monat };
renderDashKalender();
}
// 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.
// kopfHtml (optional): fertiges HTML am rechten Rand der Überschrift, etwa
// ein Bedienknopf.
function dashAbschnitt(titel, zahl, zusatz, inhalt, kopfHtml){
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>' : '')
+ (kopfHtml || '')
+ '</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);
}
// 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';
}
// 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();
}