// ----------------------------- // Projektbereich — Bereichs-Navigation & Dashboard // ----------------------------- // Der Projektbereich ist der EIGENE BEREICH eines Projekts — seit dem Umbau // kein eigenes Dokument mehr, sondern der Bereich #plan der Hauptseite // App.html. Er gliedert sich in sieben Unterbereiche, zwischen denen die // Leiste .plan-nav umschaltet (Markup in App.html): // // dashboard — Kennzahlen, Sitzungskommentar, Leistungsphasen- // Fortschritt, nächste Termine, Team, Kompakt-Karten // terminplan — GANTT mit Werkzeugleiste und Ressourcen-Auslastung // aufgaben — Aufgaben- und Meilensteinliste (js/plan/plan-tasks.js) // leistung — Leistungsstand & Abrechnung (js/plan/plan-leistung.js) // dateien — Datei-Upload/-Freigabe (js/plan/plan-files.js) // protokolle — Besprechungsprotokolle (js/plan/plan-protokoll.js) // einstellungen — Projektangaben, Honorar, beauftragte Leistungsphasen, // Beteiligte (js/plan/plan-settings.js) // // Alle Bereiche liegen gleichzeitig im DOM und werden nur über die Klasse // .is-active ein- bzw. ausgeblendet. Das ist Absicht: die vorhandenen // Render-Funktionen (renderPlanInfo, renderResourceLoad, renderProjectFiles …) // befüllen ihre Elemente unabhängig davon, ob der Bereich gerade sichtbar ist, // und brauchten für den Umbau keine Änderung. // // Der gewählte Bereich steht im URL-Fragment (z. B. …/Projektplan/Haus%20A // ?weekLabel=…&projNr=…#aufgaben), damit ein Link auf einen bestimmten Bereich // verschickt werden kann. Ein unbekanntes Fragment fällt auf "dashboard" // zurück. // // Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp // (plan-state.js). (function(){ const app = window.PlanApp; // PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der // Navigationsleiste. Muss zu den data-section-Attributen in // App.html passen. const PLAN_SECTIONS = ['dashboard', 'terminplan', 'notizen', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen']; // showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen // Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist // beim Terminplan zwingend: die Abhängigkeitslinien im GANTT werden aus // Pixel-Positionen berechnet, die in einem ausgeblendeten Container alle 0 // wären. Parameter: key (Bereichsschlüssel). Keine Rückgabe. window.showPlanSection = function(key){ if(PLAN_SECTIONS.indexOf(key) === -1) key = 'dashboard'; app.section = key; PLAN_SECTIONS.forEach(k => { const sec = document.getElementById('planSection-' + k); if(sec) sec.classList.toggle('is-active', k === key); }); document.querySelectorAll('#planNav .plan-nav-item').forEach(btn => { btn.classList.toggle('is-active', btn.dataset.section === key); }); // Bereich im URL-Fragment festhalten (teilbarer Link), ohne einen // History-Eintrag je Klick zu erzeugen. try { history.replaceState(null, '', location.pathname + location.search + '#' + key); } catch(e) { /* z. B. file:// — dann bleibt die Adresse eben unverändert */ } if(!app.project) return; if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt(); if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks(); if(key === 'notizen' && typeof renderPlanNotizen === 'function') renderPlanNotizen(); if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung(); if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle(); if(key === 'einstellungen' && typeof renderPlanSettings === 'function') renderPlanSettings(); if(key === 'dashboard' && typeof window.renderPlanDashboard === 'function') window.renderPlanDashboard(); }; // initPlanSections: wählt beim Seitenaufbau den Bereich aus dem // URL-Fragment (sonst "dashboard"). Wird von renderPlanPage() // (plan-state.js) aufgerufen, also erst wenn Woche und Projekt geladen // sind. Keine Parameter, keine Rückgabe. function initPlanSections(){ const fromHash = (location.hash || '').replace(/^#/, ''); // Ein Link auf #dateien/#leistung/#protokolle eines Sandbox-Plans (dessen // Knöpfe ausgeblendet sind, siehe renderPlanPage(), plan-state.js) fiele // sonst auf einen Bereich zurück, der zwar existiert, aber keinen // sichtbaren Weg mehr dorthin hätte. Protokolle hängen wie Dateien an // project.nr, den ein Sandbox-Plan nicht hat. const gesperrtFuerSandbox = app.isSandboxPlan() && (fromHash === 'dateien' || fromHash === 'leistung' || fromHash === 'protokolle'); window.showPlanSection(!gesperrtFuerSandbox && PLAN_SECTIONS.indexOf(fromHash) !== -1 ? fromHash : 'dashboard'); } // hidePlanNav: blendet die Bereichsleiste aus. Wird von plan-state.js // gerufen, wenn gar kein Projekt geladen werden konnte (fehlende // URL-Parameter, unbekannte Sitzung, Ladefehler) — dann führte jeder // Bereich nur in eine leere Maske. Keine Parameter, keine Rückgabe. function hidePlanNav(){ const nav = document.getElementById('planNav'); if(nav) nav.style.display = 'none'; } // showPlanNav: Gegenstück zu hidePlanNav(). Nötig, seit der Projektbereich // ein Bereich der Hauptseite ist und nacheinander mehrere Projekte zeigen // kann: Nach einem Fehlschlag (hidePlanNav) muss die Leiste beim nächsten, // erfolgreichen openPlanView() wieder erscheinen. Auf der früheren // eigenständigen Seite war das unnötig, weil jeder Aufruf eine frische // Seite lud. Keine Parameter, keine Rückgabe. function showPlanNav(){ const nav = document.getElementById('planNav'); if(nav) nav.style.display = ''; } // upcomingEntries: offene Aufgaben mit Endtermin und Meilensteine ab heute // (vergangene Meilensteine haben kein Erledigt-Kennzeichen und gelten als // gewesen), chronologisch. idx verweist in plan.todos/plan.milestones, damit // ein Klick denselben Dialog öffnet wie im Terminplan. // Rückgabe: Array von { art, idx, titel, datum, iso, wer, meins }. function upcomingEntries(){ const plan = app.ensurePlanLocal(); const heute = heuteIso(); const ich = currentUser && currentUser.personId; const namen = {}; ((app.week && app.week.people) || []).forEach(x => { namen[x.id] = x.fullName || x.name; }); const rows = []; (plan.todos || []).forEach((t, idx) => { if(t.done || !t.end) return; const wer = (t.assignees || []).map(id => namen[id] || id); rows.push({ art: 'aufgabe', idx, titel: t.title || 'Aufgabe', iso: t.end, wer, meins: !!ich && (t.assignees || []).includes(ich) }); }); (plan.milestones || []).forEach((m, idx) => { if(!m.date || m.date < heute) return; rows.push({ art: 'meilenstein', idx, titel: m.name || 'Meilenstein', iso: m.date, wer: [], meins: false }); }); rows.sort((a, b) => a.iso.localeCompare(b.iso)); return rows; } // PLAN_DASH_ANTEIL: wie viele Einträge je Gruppe offen dastehen — jede // Gruppe bekommt ihren eigenen Anteil, sonst füllten 25 überfällige // Aufgaben die Liste und "Diese Woche" stand ohne Einträge da. Der Rest // steckt hinter "… weitere in Aufgaben". const PLAN_DASH_ANTEIL = { ueber: 4, woche: 4, spaeter: 3 }; // renderPlanDashboard: füllt "Termine und Aufgaben" (gegliedert nach // Überfällig / Diese Woche / Später) und "Team" (eine Zeile je PERSON mit // ihren Rollen als Chips — vorher stand jemand mit drei Rollen dreimal da). // Kennzahlenband, Kommentar, Leistungsphasen und Unterlagen füllen // renderPlanKpis()/renderPlanInfo()/renderResourceLoad()/ // renderProjectFiles(). Keine Parameter. window.renderPlanDashboard = function(){ if(!app.project) return; const locked = app.isLocked(); const upcomingEl = document.getElementById('planDashUpcoming'); if(upcomingEl){ const heute = heuteIso(); const wocheEnde = isoPlusTage(7); const alle = upcomingEntries(); if(alle.length === 0){ upcomingEl.innerHTML = '
Keine offenen Termine. Aufgaben und Meilensteine entstehen im Terminplan.
'; } else { const gruppen = [ { key: 'ueber', titel: 'Überfällig', klasse: 'ist-rot', treffer: r => r.art === 'aufgabe' && r.iso < heute }, { key: 'woche', titel: 'Diese Woche', klasse: '', treffer: r => r.iso >= heute && r.iso <= wocheEnde }, { key: 'spaeter', titel: 'Später', klasse: '', treffer: r => r.iso > wocheEnde }, ]; let gezeigt = 0; let html = ''; gruppen.forEach(g => { const alleDerGruppe = alle.filter(g.treffer); const sichtbar = alleDerGruppe.slice(0, PLAN_DASH_ANTEIL[g.key]); gezeigt += sichtbar.length; if(!alleDerGruppe.length) return; html += `
${esc(g.titel)}${alleDerGruppe.length}
`; html += sichtbar.map(r => { const klick = locked ? '' : ` onclick="${r.art === 'meilenstein' ? 'openMilestoneModal' : 'openTodoModal'}(${r.idx})"`; const zeichen = r.art === 'meilenstein' ? '◆' : (r.meins ? 'A' : 'a'); const klasse = r.art === 'meilenstein' ? 'ist-ms' : (r.meins ? 'ist-meins' : ''); const wer = r.art === 'meilenstein' ? 'Meilenstein' : (r.wer.length ? r.wer.join(', ') : 'niemand zugewiesen'); const rot = r.art === 'aufgabe' && r.iso < heute; return `
${zeichen} ${esc(r.titel)}${esc(wer)} ${esc(formatDateDE(parseDateISO(r.iso)))}${esc(relativerTag(r.iso))}
`; }).join(''); }); const verborgen = alle.length - gezeigt; if(verborgen > 0){ html += ``; } upcomingEl.innerHTML = html; } } const teamEl = document.getElementById('planDashTeam'); if(teamEl){ const assignees = projectAssignees(app.project, app.week); if(assignees.length === 0){ teamEl.innerHTML = '
Diesem Projekt ist noch niemand zugeordnet. Zuordnung im Bereich „Einstellungen“.
'; } else { const plan = app.ensurePlanLocal(); const jePerson = new Map(); assignees.forEach(a => { if(!jePerson.has(a.person.id)) jePerson.set(a.person.id, { person: a.person, rollen: [] }); jePerson.get(a.person.id).rollen.push({ rolle: a.role, state: a.state }); }); const zeile = e => { const offen = (plan.todos || []).filter(t => !t.done && Array.isArray(t.assignees) && t.assignees.indexOf(e.person.id) !== -1).length; const haupt = e.rollen.find(r => r.state === 1) || e.rollen[0]; return `
${esc(e.person.name)} ${esc(e.person.fullName || e.person.name)} ${e.rollen.map(r => `${esc(r.rolle || '')}`).join('')} ${offen} offen
`; }; // Nach Gewerk gruppiert (Eriks Wahl "A"): erst die Gewerke des // Projekts in fester Reihenfolge — auch unbesetzte, mit Hinweis —, // dann Gewerke von Beteiligten, die nicht zum Projekt gehören, zuletzt // Personen ohne bekanntes Gewerk. Das Gewerk einer Person kommt aus // ihrem Konto (personGewerk(), users.department). const reihenfolge = Object.keys(DEPARTMENTS); const projGewerke = reihenfolge.filter(g => (app.project.gewerke || []).indexOf(g) !== -1); const gruppen = new Map(projGewerke.map(g => [g, []])); [...jePerson.values()].forEach(e => { const g = personGewerk(e.person) || ''; if(!gruppen.has(g)) gruppen.set(g, []); gruppen.get(g).push(e); }); const schluessel = [...gruppen.keys()].sort((x, y) => { const rang = g => g === '' ? 99 : (projGewerke.indexOf(g) !== -1 ? projGewerke.indexOf(g) : 10 + reihenfolge.indexOf(g)); return rang(x) - rang(y); }); teamEl.innerHTML = '
' + schluessel.map(g => { const leute = gruppen.get(g); const fremd = g !== '' && projGewerke.indexOf(g) === -1; const titel = g === '' ? 'Ohne Gewerk' : ((DEPARTMENTS[g] && DEPARTMENTS[g].label !== g) ? DEPARTMENTS[g].label : ''); return `
${g ? `${esc(g)}` : ''} ${esc(titel)}${fremd ? (titel ? ' · ' : '') + 'kein Gewerk des Projekts' : ''} ${leute.length === 1 ? '1 Person' : leute.length + ' Personen'}
${leute.length ? leute.map(zeile).join('') : '
Noch niemand aus diesem Gewerk zugeordnet.
'}
`; }).join('') + '
'; } } }; // Wird das Fragment von außen geändert (Vor-/Zurück-Taste, von Hand // getippter Anker, Link auf dieselbe Seite mit anderem Bereich), dann // mitziehen. showPlanSection() selbst benutzt history.replaceState() und // löst dieses Ereignis daher nicht aus. window.addEventListener('hashchange', () => { if(currentTab !== 'plan') return; const key = (location.hash || '').replace(/^#/, ''); if(PLAN_SECTIONS.indexOf(key) !== -1 && key !== app.section) window.showPlanSection(key); }); app.initPlanSections = initPlanSections; app.hidePlanNav = hidePlanNav; app.showPlanNav = showPlanNav; })();