// ----------------------------- // 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 = '
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.
'; } // Neu zugewiesene Aufgaben (js/benachrichtigungen.js) — vor allem anderen, // weil es das ist, was sich seit dem letzten Blick geändert hat. html += '
'; html += '
'; // Linke Zone: die eigenen Projekte mit ihren Terminen const daten = dashDaten(); html += '
'; 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 ? '' + '' : ''); html += '
'; // Rechte Zone: Kalender, dann was man wissen muss, ohne es selbst zu tun html += '
'; html += dashAbschnitt('Kalender', null, null, '
'); 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; 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 '
' + 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 => '' + esc(r.rolle) + '').join('') : 'nur Aufgabe'; 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 ? '' + esc(aktLp) + '' + (pct === null ? '—' : pct + '%') + '' : 'keine amtierende Leistungsphase'; 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('') : '
Keine offenen Termine.
'; const mehr = rest > 0 ? '' + rest + ' weitere Termine im Projekt' : ''; // Eingeklappt bleibt sichtbar, was Aufmerksamkeit braucht: wie viele // Termine überfällig bzw. offen sind. const kurz = (ueber.length ? '' + ueber.length + ' überfällig' : '') + '' + kommend.length + ' offen'; return '
' + '
' + (daten.projekte.length > 1 ? '' + icon('grip') + '' : '') + '' + '' + kurz + '' + '' + rollen + '' + 'Öffnen' + '
' + '
' + '
' + phase + '
' + '
' + zeilen + mehr + '
' + '
' + '
'; }).join('') + '
'; } // 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 '' + '' + zeichen + '' + '' + esc(t.titel) + '' + '' + esc(unter) + '' + '' + (t.datum ? esc(formatWeekDate(t.datum)) + '' + esc(relativerTag(t.datum)) + '' : 'ohne Frist') + ''; } // 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 += '' + getWeekNumber(start) + ''; for(let c = 0; c < 7; c++){ const iso = formatDateISO(start); const e = jeTag[iso] || []; const punkte = e.slice(0, 3).map(x => '').join('') + (abwesendAm(iso).length ? '' : ''); 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 += ''; 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 ? '
Feiertag
' : '') + tagE.map(e => renderDashTermin(e.projekt, e, true)).join('') + tagA.map(a => '
–' + '' + esc(a.name) + '' + '' + esc(a.type) + ' bis ' + esc(formatWeekDate(a.end)) + '
').join(''); el.innerHTML = '
' + '
' + '' + '' + MONATE[monat] + ' ' + jahr + '' + '' + '
' + '
KW' + ['Mo','Di','Mi','Do','Fr','Sa','So'].map(t => '' + t + '').join('') + zellen + '
' + '
meine AufgabeProjektaufgabe' + 'MeilensteinüberfälligAbwesenheit
' + '
' + '
' + esc(tagTitel) + '
' + (liste || '
Keine Termine an diesem Tag.
') + '
'; } // 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 '
' + '

' + '

' + '
' + (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. // kopfHtml (optional): fertiges HTML am rechten Rand der Überschrift, etwa // ein Bedienknopf. function dashAbschnitt(titel, zahl, zusatz, inhalt, kopfHtml){ return '
' + '
' + '

' + esc(titel) + '

' + (zahl != null ? '' + zahl + '' : '') + (zusatz ? '' + esc(zusatz) + '' : '') + (kopfHtml || '') + '
' + 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); } // 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 '
' + '' + 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(); }