// ----------------------------- // 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. // 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…
'; // Notizen je Projekt (Anzahl) parallel — fällt sie aus, fehlt nur die Angabe. const notizenAnfrage = fetch(baseUrl() + '/projekt-notizen/anzahl') .then(r => r.ok ? r.json() : null).catch(() => null); 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 nz = await notizenAnfrage; dashNotizen = (nz && nz.ok && nz.anzahl) || {}; 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; const daten = dashDaten(); const sonntag = formatDateISO(new Date(dashWochenMontag().getTime() + 6 * 86400000)); const dieseWoche = daten.eintraege.filter(e => e.datum >= heute && e.datum <= sonntag).length; let html = ''; html += renderDashboardKopf(weekLabel); html += renderDashboardBand({ ueberfaellig, heuteFaellig, dieseWoche, aufgaben: tasks.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 += renderDashWoche(daten); html += '
'; // Linke Zone: die eigenen Projekte mit ihren Terminen 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(); } // ---------- 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 // dashNotizen: { project_uid: {anzahl, zuletzt} } aus GET /projekt-notizen/anzahl. let dashNotizen = {}; // 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: [], erreicht: !!m.erreicht }); }); const hatEigene = termine.some(t => t.meins); if(!rollen.length && !hatEigene) return; // Erreichte Meilensteine fallen weg (im Kalender bleiben sie stehen); // nicht erreichte vor heute gelten wie offene Aufgaben als überfällig — // dieselbe Regel wie die Zahl im Band (GET /my-dashboard). const relevant = termine.filter(t => !(t.art === 'meilenstein' && t.erreicht)); 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(); } // dashImTabOeffnen: öffnet den Projektbereich im SELBEN Tab (Eriks Wunsch), // ohne die Seite neu zu laden. Strg/Cmd/Umschalt-Klick und Mittelklick // behalten das Browser-Verhalten (neuer Tab), dafür bleibt das href stehen. // Der Abschnitt (#aufgaben …) wird wie in springZuEinstellungen() VOR // showTab('plan') gesetzt, dessen updateRouteUrl() ihn übernimmt. function dashImTabOeffnen(ev, a){ if(ev.button !== 0 || ev.ctrlKey || ev.metaKey || ev.shiftKey || ev.altKey) return true; ev.preventDefault(); const url = new URL(a.href); const name = decodeURIComponent(url.pathname.split('/').pop() || ''); setPlanTarget(url.searchParams.get('weekLabel') || '', url.searchParams.get('projNr') || '', name); location.hash = url.hash.slice(1) || 'dashboard'; showTab('plan'); window.scrollTo(0, 0); return false; } // 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) + (r.state === 2 ? ', Unterstützung' : '')).join(' / ') : 'nur eigene 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 + '' + 'Öffnen' + '
' + '
' + renderDashProjektInfo(x, rollen) + '
' + phase + '
' + '
' + zeilen + mehr + '
' + '
' + '
'; }).join('') + '
'; } // renderDashWoche: Band „Diese Woche“ (Entwurf „Bauplan“): Mo bis Fr als // Spalten mit den Terminen der eigenen Projekte an diesem Tag — eigene // Aufgaben, Aufgaben im Projekt, Meilensteine, Überfälliges. Je Tag höchstens // DASH_WOCHE_JE_TAG, der Rest als „+ N weitere“. Parameter: daten (dashDaten()). const DASH_WOCHE_JE_TAG = 4; function renderDashWoche(daten){ if(!daten.geladen || !daten.projekte.length) return ''; const heute = heuteIso(); const mo = dashWochenMontag(); const TAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr']; let spalten = ''; for(let i = 0; i < 5; i++){ const d = new Date(mo); d.setDate(mo.getDate() + i); const iso = formatDateISO(d); const e = daten.eintraege.filter(x => x.datum === iso) .sort((a, b) => (b.meins ? 1 : 0) - (a.meins ? 1 : 0)); const sicht = e.slice(0, DASH_WOCHE_JE_TAG); const rest = e.length - sicht.length; spalten += '
' + '
' + TAGE[i] + '' + d.getDate() + '.' + (d.getMonth() + 1) + '.
' + '
' + (sicht.map(x => { const art = x.art === 'meilenstein' ? 'ist-ms' : (x.datum < heute ? 'ist-ueber' : (x.meins ? 'ist-meins' : 'ist-fremd')); return '' + '' + esc(x.titel) + '' + '' + esc([x.projekt.nr, x.projekt.name].filter(Boolean).join(' ')) + ''; }).join('') || 'keine Termine') + (rest > 0 ? '+ ' + rest + ' weitere' : '') + '
'; } const legende = '
' + 'meine AufgabeAufgabe im Projekt' + 'Meilensteinüberfällig
'; return dashAbschnitt('Diese Woche', null, 'eigene Aufgaben und Termine meiner Projekte', '
' + spalten + '
' + legende + '
'); } // renderDashProjektInfo: Zeile „Meine Rolle | Team | Notizen“ im Projektblock // (Eriks Wunsch): die eigene Rolle, die übrigen Beteiligten als Kürzel-Kreise // in den Rollenfarben (wie Projektliste, beteiligtenAvatarHtml()) und — nur // wenn vorhanden — die Zahl der Notizen mit Sprung in den Reiter „Notizen“. // Parameter: x (Eintrag aus dashDaten()), rollenText. Rückgabe: HTML-String. const DASH_TEAM_MAX = 5; function renderDashProjektInfo(x, rollenText){ const p = x.p; const w = currentWeek(); const ich = currentUser && currentUser.personId; const personen = {}; ((w && w.people) || []).forEach(q => { personen[q.id] = q; }); const eintraege = []; Object.keys(p.assign || {}).forEach(key => { const [pid, role] = key.split('|'); const state = Number(p.assign[key]) || 0; if(state > 0) eintraege.push({ person: personen[pid] || { id: pid, name: pid, fullName: pid }, role, state }); }); const alle = beteiligteJePerson(eintraege); const ichB = alle.find(b => b.person.id === ich); const team = alle.filter(b => b.person.id !== ich); const kreis = b => beteiligtenAvatarHtml(b, typeof roleClass === 'function' ? roleClass(b.role) : ''); const kurzName = b => { const teile = String(b.person.fullName || b.person.name || '').trim().split(/\s+/); return teile.length > 1 ? teile[0] + ' ' + teile[teile.length - 1][0] + '.' : teile[0]; }; const namen = team.length > 2 ? team.slice(0, 2).map(kurzName).join(', ') + ' +' + (team.length - 2) : team.map(kurzName).join(', '); let html = '
' + '
Meine Rolle' + '' + (ichB ? kreis(ichB) : '') + '' + rollenText + '
' + '
Team' + '' + (team.length ? '' + team.slice(0, DASH_TEAM_MAX).map(kreis).join('') + '' + esc(namen) + '' : 'niemand weiter') + '
'; const n = dashNotizen[p.projectUid]; if(n && n.anzahl > 0){ html += '' + 'Notizen' + '' + icon('fileText') + '' + n.anzahl + ' ' + (n.anzahl === 1 ? 'Notiz' : 'Notizen') + '' + (n.zuletzt ? '' + esc(dashZuletzt(n.zuletzt)) + '' : '') + ''; } return html + '
'; } // dashZuletzt: „zuletzt heute/gestern“ oder „vom 28.10.“ zu einem Zeitstempel. function dashZuletzt(zeit){ const d = new Date(zeit); if(isNaN(d)) return ''; const tage = -tageBis(formatDateISO(d)); if(tage <= 0) return 'zuletzt heute'; if(tage === 1) return 'zuletzt gestern'; return 'vom ' + d.getDate() + '.' + (d.getMonth() + 1) + '.'; } // 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: Titel und Kalenderwoche statt Begrüßung (Entwurf // „Bauplan“, Eriks Wahl): „KW 45, Mo 2. bis Fr 6. November 2026“, rechts die // Sitzung. Parameter: weekLabel. Rückgabe: HTML-String. function renderDashboardKopf(weekLabel){ const MONATE = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember']; const mo = dashWochenMontag(); const fr = new Date(mo); fr.setDate(mo.getDate() + 4); const zeile = 'KW ' + getWeekNumber(mo) + ', Mo ' + mo.getDate() + '.' + (mo.getMonth() !== fr.getMonth() ? ' ' + MONATE[mo.getMonth()] : '') + ' bis Fr ' + fr.getDate() + '. ' + MONATE[fr.getMonth()] + ' ' + fr.getFullYear(); return '
' + '' + (weekLabel ? '
Sitzung' + esc(weekLabel) + '
' : '') + '
'; } // dashWochenMontag: Montag der laufenden Woche (lokale Zeit, 0 Uhr). function dashWochenMontag(){ const d = parseDateISO(heuteIso()); d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); return d; } // 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, rot) => '
' + label + '' + wert + '
'; const ruhig = z.ueberfaellig === 0 && z.heuteFaellig === 0; return '
' + '

' + satz + '

' + '
' + zahl(z.aufgaben, 'offene Aufgaben') + zahl(z.ueberfaellig, 'überfällig', z.ueberfaellig > 0) + zahl(z.dieseWoche, 'diese Woche fällig') + zahl(z.freigaben, 'Freigaben') + '
'; } // 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) + '
'; } // 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(); }