// ----------------------------- // Projektplan — GANTT-Rendering & Zoom // ----------------------------- // Dieses Modul rendert das GANTT-Diagramm der Projektplan-Seite inklusive // der TODO- und Meilensteinzeilen, die buildGanttHtml aus plan-build-gantt.js // liefert, und zeichnet darauf die Abhängigkeitslinien zwischen den Aufgaben. // Es verwaltet außerdem Zoom (pxPerDay in PlanApp), das Anpassen des // Datumsbereichs (fitPlanGanttRange) sowie die Ressourcen-Auslastung // (computeResourceLoads/renderResourceLoad/openResourceModal, Cockpit-Layout: // Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter) // und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt, // offene Aufgaben und nächster Meilenstein aus denselben Plan-Daten; // Team-Auslastung dagegen über aufgabenVerlaufJePerson() aus js/load/load.js, // siehe computeResourceLoads() weiter unten — dieselbe Rechnung wie im // Auslastungs-Tab, nicht aus den Plan-Daten selbst). renderPlanGantt ist die // zentrale Neu-Render-Funktion (rendert am Ende auch Auslastung und KPIs // neu) und wird nach // Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js erneut // aufgerufen. // // Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp // (plan-state.js). (function(){ const app = window.PlanApp; // renderPlanGantt: baut das komplette GANTT-Diagramm des aktuellen Projekts // neu auf. Nutzt die Datumseingaben der Seite (oder berechnet den // Projektzeitraum), berücksichtigt Urlaub, Sperrstatus und // Zoomstufe und ruft buildGanttHtml auf. Im Anschluss werden die // Abhängigkeitslinien gezeichnet und die Ressourcen-Auslastung aktualisiert. window.renderPlanGantt = function(){ const container = document.getElementById('planGanttContainer'); if(!app.project || !container) return; const prevScroll = container.scrollLeft; let start = parseDateISO(document.getElementById('planGanttStart').value); let end = parseDateISO(document.getElementById('planGanttEnd').value); if(!start || !end){ const range = ganttRangeFromProjects([app.project], 7, projectAssignees(app.project, app.week).map(a => a.person.id)); start = range.start; end = range.end; document.getElementById('planGanttStart').value = formatDateISO(start); document.getElementById('planGanttEnd').value = formatDateISO(end); } // Zeitraum auch sichtbar ins Feld schreiben (es zeigte sonst nur den // Platzhalter, solange niemand einen Zeitraum gewählt hatte). const zeitraumAnzeige = document.getElementById('planGanttRange'); if(zeitraumAnzeige) zeitraumAnzeige.value = formatDateDE(start) + ' – ' + formatDateDE(end); const showVacation = document.getElementById('planShowVacation').checked; const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id); const ghost = collectGhostDates(); container.innerHTML = buildGanttHtml([app.project], start, end, { showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id, showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, ghostTodos: ghost.todos, ghostMilestones: ghost.milestones, ghostLabel: ghost.label, filter: app.ganttFilter || 'alle', phasenZu: app.phasenZu || (app.phasenZu = {}), eigenePersonId: (typeof currentUser !== 'undefined' && currentUser && currentUser.personId) || '', }); renderOhneTermin(); container.scrollLeft = prevScroll; if(typeof ganttGriffeEinsetzen === 'function') ganttGriffeEinsetzen(container); requestAnimationFrame(() => renderGanttDependencies(container)); renderPlanWarnings(); renderResourceLoad(); renderPlanKpis(); // Aufgabenliste und Dashboard zeigen dieselben Aufgaben-/Meilensteindaten // in anderer Form (js/plan/plan-tasks.js, js/plan/plan-sections.js). Sie // hängen sich hier mit ein, weil renderPlanGantt() die zentrale // Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei // Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich. if(typeof renderPlanTasks === 'function') renderPlanTasks(); if(typeof renderPlanDashboard === 'function') renderPlanDashboard(); }; // collectGhostDates: liest die Termine derselben Aufgaben/Meilensteine aus // der letzten früheren Sitzung MIT Planinhalt (app.previousPlannedProjectVersion() // aus plan-history.js) und gibt sie als Maps id -> Termin zurück, die // buildGanttHtml als ghostTodos/ghostMilestones für die gestrichelten // Vergleichs-Umrisse nutzt. Das Label der gefundenen Sitzung wird // mitgeliefert, damit die Tooltips sie namentlich nennen können statt // pauschal "Vorwoche" zu behaupten. // Leere Maps, wenn der Schalter aus ist, es keine frühere Fassung mit // Terminplan gibt oder dem Projekt die projectUid fehlt (Altdaten). // Rückgabe: { todos: Map, milestones: Map, label: String }. function collectGhostDates(){ const empty = { todos: new Map(), milestones: new Map(), label: '' }; const toggle = document.getElementById('planShowGhost'); if(toggle && !toggle.checked) return empty; if(typeof app.previousPlannedProjectVersion !== 'function') return empty; const prev = app.previousPlannedProjectVersion(); if(!prev || !prev.project || !prev.project.plan) return empty; const todos = new Map(); (prev.project.plan.todos || []).forEach(t => { if(t.id && t.start && t.end) todos.set(t.id, { start: t.start, end: t.end }); }); const milestones = new Map(); (prev.project.plan.milestones || []).forEach(m => { if(m.id && m.date) milestones.set(m.id, m.date); }); return { todos, milestones, label: prev.week.label }; } // renderPlanWarnings: füllt das Hinweisband über dem GANTT // (#planWarnings in App.html) mit den Urlaubs-Kollisionen des // Projekts — Aufgaben, deren Zeitraum in die Abwesenheit einer zugeordneten // Person fällt. Die Prüfung selbst liegt in plan-build-gantt.js // (app.absenceConflictsForTodo), damit Balken-Markierung und diese Liste // garantiert dasselbe melden. Keine Parameter, keine Rückgabe. window.renderPlanWarnings = function(){ const el = document.getElementById('planWarnings'); if(!el || !app.project) return; if(typeof app.absenceConflictsForTodo !== 'function'){ el.innerHTML = ''; return; } const plan = app.ensurePlanLocal(); const rows = []; (plan.todos || []).forEach(t => { if(t.done) return; // erledigte Aufgaben sind kein Planungsproblem mehr const conflicts = app.absenceConflictsForTodo(t); conflicts.forEach(c => { rows.push(`
  • ${esc(t.title || 'TODO')} — ${esc(c.name)} ist ${esc(c.type.toLowerCase())} (${formatDateDE(c.start)}–${formatDateDE(c.end)})
  • `); }); }); if(rows.length === 0){ el.innerHTML = ''; return; } el.innerHTML = `
    ${icon('warning')} ${rows.length} Terminkonflikt${rows.length === 1 ? '' : 'e'} mit Abwesenheiten
    `; }; // bandZelle: eine Kennzahl im Lageband des Dashboards. Parameter: label, // value, sub (Texte), warn (Unterzeile rot). Rückgabe: HTML-String. function bandZelle(label, value, sub, warn){ return `
    ${esc(label)}
    ${esc(value)}
    ${esc(sub)}
    `; } // renderPlanKpis: füllt das Lageband oben im Dashboard (#planKpiRow, // Anordnung "Lagebild"): links die laufende Leistungsphase mit Ring // (amtierende LPH und ihr Fortschritt aus dem Wochenbericht), daneben offene // Aufgaben (mit Überfälligem), nächster Meilenstein und Team-Auslastung // (computeResourceLoads(), dieselbe Zahl wie im Auslastungs-Tab). Der // frühere "Gesamtfortschritt" (Ø der Aufgaben) ist entfallen — für den // Stand eines Projekts zählt die Leistungsphase. Wird nach jedem // GANTT-Rendering aufgerufen, damit es sofort aktuell ist. window.renderPlanKpis = function(){ const row = document.getElementById('planKpiRow'); if(!row || !app.project) return; const p = app.project; const plan = app.ensurePlanLocal(); const todos = Array.isArray(plan.todos) ? plan.todos : []; const milestones = Array.isArray(plan.milestones) ? plan.milestones : []; const today = new Date(); today.setHours(0, 0, 0, 0); const lph = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || ''; const roh = lph && p.lph ? p.lph[lph] : ''; const pct = roh === undefined || roh === '' ? null : Math.max(0, Math.min(100, Number(roh) || 0)); const phase = `
    Laufende Phase
    ${lph ? esc(lph) + (pct === null ? '' : ' · ' + pct + ' %') : '–'}
    ${lph ? (pct === 100 ? 'abgeschlossen' : 'Fortschritt laut Wochenbericht') : 'Keine amtierende Phase gesetzt'}
    `; const open = todos.filter(t => !t.done); const overdue = open.filter(t => t.end && parseDateISO(t.end) < today); const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'im Zeitplan' : 'alle erledigt'); const upcoming = milestones .filter(m => m.date && parseDateISO(m.date) >= today) .sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date)); const msZelle = upcoming.length > 0 ? bandZelle('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein') : bandZelle('Nächster Meilenstein', '–', milestones.length > 0 ? 'keine anstehenden' : 'keine angelegt'); const { loads } = computeResourceLoads(); const loadValues = Object.values(loads); let loadZelle; if(loadValues.length === 0){ loadZelle = bandZelle('Team-Auslastung', '–', 'niemand zugeordnet'); } else { const avgPct = Math.round(loadValues.reduce((s, l) => s + l.quote * 100, 0) / loadValues.length); const top = loadValues.slice().sort((a, b) => b.quote - a.quote)[0]; loadZelle = bandZelle('Team-Auslastung', avgPct + ' %', top.stunden > 0 ? 'höchste: ' + (top.person.fullName || top.person.name) + ' ' + Math.round(top.quote * 100) + ' %' : 'noch nicht bewertbar'); } row.innerHTML = phase + bandZelle('Offene Aufgaben', String(open.length), openSub, overdue.length > 0) + msZelle + loadZelle; }; // renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die // Abhängigkeits-Pfade als SVG-Kurven mit Pfeilspitzen in das Diagramm. // Bereits vorhandene Linien werden zuvor entfernt; Konflikt- und // erledigte-Kanten erhalten eigene Farben. Parameter: container (optional, // GANTT-Container). Rückgabe: keine. window.renderGanttDependencies = function(container){ const c = container || document.getElementById('planGanttContainer'); if(!c) return; const chart = c.querySelector('.gantt-chart'); if(!chart) return; let svg = chart.querySelector('.gantt-dependencies-svg'); if(svg) svg.remove(); // Linien abgeschaltet (Menü "Mehr") — dann nichts zeichnen. const pfeile = document.getElementById('planShowPfeile'); if(pfeile && !pfeile.checked) return; // Ausgeblendete Zeilen (zugeklappte Phase, Filter) haben keine Lage — // Linien dorthin würden ins Leere zeigen. const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]')) .filter(b => b.offsetParent !== null); if(nodeBars.length === 0) return; const chartRect = chart.getBoundingClientRect(); const barMap = new Map(); nodeBars.forEach(b => { if(b.dataset.todoId) barMap.set(b.dataset.todoId, b); }); const paths = []; nodeBars.forEach(targetBar => { const dependsOnStr = targetBar.dataset.todoDepends; if(!dependsOnStr) return; const depIds = dependsOnStr.split(',').map(s => s.trim()).filter(Boolean); depIds.forEach(depId => { const sourceBar = barMap.get(depId); if(!sourceBar) return; const sourceRect = sourceBar.getBoundingClientRect(); const targetRect = targetBar.getBoundingClientRect(); const x1 = sourceRect.right - chartRect.left; const y1 = sourceRect.top + sourceRect.height / 2 - chartRect.top; const x2 = targetRect.left - chartRect.left; const y2 = targetRect.top + targetRect.height / 2 - chartRect.top; const isConflict = x2 < x1; const isDone = targetBar.classList.contains('done') && sourceBar.classList.contains('done'); let d = ''; if(x2 >= x1 + 10){ const midX = x1 + (x2 - x1) / 2; d = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`; } else { const r = 16; d = `M ${x1} ${y1} C ${x1 + r} ${y1}, ${x1 + r} ${(y1 + y2)/2}, ${(x1 + x2)/2} ${(y1 + y2)/2} C ${x2 - r} ${(y1 + y2)/2}, ${x2 - r} ${y2}, ${x2} ${y2}`; } let pathClass = 'gantt-dep-path'; let markerId = 'gantt-arrow-normal'; if(isConflict){ pathClass += ' conflict'; markerId = 'gantt-arrow-conflict'; } else if(isDone){ pathClass += ' done'; markerId = 'gantt-arrow-done'; } paths.push(``); }); }); if(paths.length === 0) return; svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'gantt-dependencies-svg'); svg.innerHTML = ` ${paths.join('')} `; chart.appendChild(svg); }; // fitPlanGanttRange: setzt die Datumsfelder der Seite auf den Projektzeitraum // (inkl. 7 Tage Puffer) zurück und rendert das Diagramm neu. Wird auch nach // Drags außerhalb des sichtbaren Bereichs von plan-drag.js genutzt. window.fitPlanGanttRange = function(){ // Nur die Abwesenheiten der Projektbeteiligten dürfen den Zeitraum // aufziehen — alle anderen Urlaubszeilen werden ohnehin nicht gezeichnet. const range = ganttRangeFromProjects([app.project], 7, projectAssignees(app.project, app.week).map(a => a.person.id)); document.getElementById('planGanttStart').value = formatDateISO(range.start); document.getElementById('planGanttEnd').value = formatDateISO(range.end); renderPlanGantt(); }; // --------------------------------------------------------------------------- // Ruhige Leiste (Eriks Wahl "A"): Maßstab, Heute, Filter, Menüs. // --------------------------------------------------------------------------- const TP_STUFEN = { fit: null, woche: 8, tag: 32 }; // tpMassstab: Maßstab "Übersicht" (an die Breite angepasst), "Wochen" oder // "Tage" — feste Pixel je Tag statt der früheren −/+-Knöpfe. window.tpMassstab = function(stufe){ app.pxPerDay = TP_STUFEN[stufe] === undefined ? null : TP_STUFEN[stufe]; document.querySelectorAll('#tpMassstab button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.stufe === stufe))); renderPlanGantt(); if(app.pxPerDay) tpZuHeuteScrollen(); }; function tpZuHeuteScrollen(){ const c = document.getElementById('planGanttContainer'); const heute = c && c.querySelector('.gantt-grid-today'); if(!c || !heute) return; const r = heute.getBoundingClientRect(), cr = c.getBoundingClientRect(); c.scrollLeft += (r.left - cr.left) - c.clientWidth / 3; } // tpHeute: Zeitraum ab zwei Wochen vor heute, gleiche Länge wie bisher // (mindestens 12 Wochen), und heute ins Bild holen. window.tpHeute = function(){ const s = parseDateISO(document.getElementById('planGanttStart').value); const e = parseDateISO(document.getElementById('planGanttEnd').value); const tage = s && e ? Math.max(84, daysBetween(s, e)) : 84; const von = new Date(); von.setHours(0, 0, 0, 0); von.setDate(von.getDate() - 14); const bis = new Date(von); bis.setDate(bis.getDate() + tage); document.getElementById('planGanttStart').value = formatDateISO(von); document.getElementById('planGanttEnd').value = formatDateISO(bis); const anzeige = document.getElementById('planGanttRange'); if(anzeige) anzeige.value = formatDateDE(von) + ' – ' + formatDateDE(bis); renderPlanGantt(); if(app.pxPerDay) tpZuHeuteScrollen(); }; // tpFilter: Alle / Meine (eigene Zuständigkeit) / Überfällig. Gefilterte // Zeilen werden nur ausgeblendet, nicht weggelassen — das Umsortieren per // Ziehen (plan-drag.js) zählt die Zeilen im DOM. window.tpFilter = function(filter){ app.ganttFilter = filter; document.querySelectorAll('#tpFilter button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.filter === filter))); renderPlanGantt(); }; // tpPhaseUmschalten: klappt eine Leistungsphase im Terminplan auf/zu. window.tpPhaseUmschalten = function(phase){ if(!app.phasenZu) app.phasenZu = {}; app.phasenZu[phase] = !app.phasenZu[phase]; renderPlanGantt(); }; // tpMenueZu: schließt die Menüs "Mehr" und "+ Neu". window.tpMenueZu = function(){ document.querySelectorAll('.tp-menue[open]').forEach(d => d.removeAttribute('open')); }; // Ein Klick außerhalb schließt ein offenes Menü; ein Menü öffnen schließt // das andere. document.addEventListener('click', e => { document.querySelectorAll('.tp-menue[open]').forEach(d => { if(!d.contains(e.target)) d.removeAttribute('open'); }); }); document.addEventListener('keydown', e => { if(e.key === 'Escape') window.tpMenueZu(); }); // Die Liste hängt rechtsbündig unter dem Knopf. Bricht die Leiste in einem // schmalen Fenster um, steht "+ Neu" links und die Liste rutschte unter die // Seitenleiste — dann linksbündig aufklappen (Klasse .nach-rechts). document.addEventListener('toggle', e => { const d = e.target; if(!d.classList || !d.classList.contains('tp-menue') || !d.open) return; const liste = d.querySelector('.tp-menue-liste'); if(!liste) return; liste.classList.remove('nach-rechts'); const seite = document.querySelector('.side-menu'); const r = seite ? seite.getBoundingClientRect() : null; const linkeGrenze = r && r.width && getComputedStyle(seite).position !== 'fixed' ? r.right : 0; if(liste.getBoundingClientRect().left < linkeGrenze + 8) liste.classList.add('nach-rechts'); }, true); // renderOhneTermin: Hinweis unter dem Diagramm auf Aufgaben, die keinen // Balken haben (ohne Zeitraum oder bewusst ausgeblendet) — sie stehen nur // unter "Aufgaben". Vorher fehlte jeder Hinweis, dass es sie gibt. function renderOhneTermin(){ const el = document.getElementById('planOhneTermin'); if(!el) return; const todos = (app.project && app.project.plan && Array.isArray(app.project.plan.todos)) ? app.project.plan.todos : []; const ohne = todos.filter(t => !t.done && !(t.start && t.end && t.showInGantt !== false)); el.hidden = ohne.length === 0; el.innerHTML = ohne.length ? `${ohne.length} ${ohne.length === 1 ? 'offene Aufgabe' : 'offene Aufgaben'} ohne Termin stehen nur unter „Aufgaben“ →` : ''; } // zoomPlanGantt: ändert die Pixel-pro-Tag-Stufe des Diagramms. // Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit). window.zoomPlanGantt = function(dir){ const container = document.getElementById('planGanttContainer'); let start = parseDateISO(document.getElementById('planGanttStart').value); let end = parseDateISO(document.getElementById('planGanttEnd').value); if(!start || !end) return; const totalDays = Math.max(1, daysBetween(start, end) + 1); const cw = container ? Math.max(200, container.clientWidth - (ganttSpaltenBreite() + 30)) : 900; const fitPx = Math.max(3, cw / totalDays); const base = app.pxPerDay || fitPx; let next; if(dir > 0){ next = Math.min(120, Math.round(base * 1.35)); } else { next = Math.round(base / 1.35); if(next <= fitPx * 0.7) next = null; } app.pxPerDay = next; renderPlanGantt(); }; // ---------- Ressourcen-Auslastung ---------- // computeResourceLoads: berechnet die Auslastung der Projektbeteiligten // NACH DERSELBEN RECHNUNG wie der Auslastungs-Tab und die Wandtafel: // Stunden dieser Woche aus Aufgaben mit Aufwand, gleich geteilt auf die // Zuständigen (aufgabenVerlaufJePerson(), js/load/load.js), bezogen auf die // Kapazität der Person. Für JEDES Projekt der Sitzung, aufgeteilt in den // Anteil DIESES Projekts und den der übrigen — sonst sähe jemand hier // entspannt aus, der in vier anderen Projekten ausgebucht ist. // // Gemeinsam genutzt von renderResourceLoad() (Detailkarten im // Terminplan-Bereich) und renderPlanKpis() (Team-Auslastung-Kachel), damit // beide dieselbe Zahl zeigen. Rückgabe: { loads } — loads ist ein Objekt // Person-ID -> { person, roles, kapazitaet, stundenProjekt, stundenAndere, // stunden (Summe), quote (stunden/kapazitaet), andereProjekte, todos // (Titel der eigenen Aufgaben in DIESEM Projekt, ohne Phasenbalken), // offenProjekt (offene Aufgabenstunden dieses Projekts, alle Wochen). function computeResourceLoads(){ const plan = app.ensurePlanLocal(); const todos = Array.isArray(plan.todos) ? plan.todos : []; const assignees = projectAssignees(app.project, app.week); const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40; const loads = {}; assignees.forEach(a => { if(!loads[a.person.id]){ loads[a.person.id] = { person: a.person, roles: [], todos: [], kapazitaet: kapazitaetsFaktor(a.person) * vollzeit, stundenProjekt: 0, stundenAndere: 0, andereProjekte: [], }; } if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role); }); // Zuständige einer Aufgabe zählen mit, auch wenn sie unter „Beteiligte“ // fehlen — wie auf der Auslastungsseite. Sonst fielen ihre // Aufgabenstunden hier stillschweigend weg. const personen = (app.week && app.week.people) || []; todos.forEach(t => { if(!Array.isArray(t.assignees) || balkenPhase(t)) return; t.assignees.forEach(pid => { if(!loads[pid]){ const person = personen.find(x => x.id === pid); if(!person) return; loads[pid] = { person, roles: ['nur Aufgabe'], todos: [], kapazitaet: kapazitaetsFaktor(person) * vollzeit, stundenProjekt: 0, stundenAndere: 0, andereProjekte: [], }; } loads[pid].todos.push(t.title); }); }); // Offene Aufgabenstunden dieses Projekts insgesamt (nicht nur diese // Woche) — liegt eine Aufgabe erst später, zeigte die Wochenzahl sonst 0. Object.values(loads).forEach(l => { l.offenProjekt = 0; }); offeneAufwandsAufgaben(app.project).forEach(({ t, rest }) => { t.assignees.forEach(pid => { if(loads[pid]) loads[pid].offenProjekt += rest / t.assignees.length; }); }); // Stunden dieser Woche allein aus Aufgaben mit Aufwand — dieselbe // Rechnung wie auf der Auslastungsseite (aufgabenVerlaufJePerson(), load.js). ((app.week && app.week.projects) || []).forEach(pr => { const av = typeof aufgabenVerlaufJePerson === 'function' ? aufgabenVerlaufJePerson(pr) : {}; Object.keys(av).forEach(pid => { if(!loads[pid] || !(av[pid][0] > 0)) return; if(pr.id === app.project.id) loads[pid].stundenProjekt += av[pid][0]; else { loads[pid].stundenAndere += av[pid][0]; const label = pr.nr ? (pr.nr + ' ' + (pr.name || '')) : (pr.name || 'Projekt'); if(!loads[pid].andereProjekte.includes(label)) loads[pid].andereProjekte.push(label); } }); }); Object.values(loads).forEach(l => { l.stunden = l.stundenProjekt + l.stundenAndere; l.quote = l.kapazitaet > 0 ? l.stunden / l.kapazitaet : 0; }); return { loads }; } // openResourceModal: springt in den Bereich "Terminplan", in dem die // Auslastungskarten stehen (#resourceLoadContainer). Früher öffnete die // Funktion ein eigenes Modal; seit die Seite in Bereiche gegliedert ist // (js/plan/plan-sections.js), ist der Inhalt fester Bestandteil des // Terminplan-Bereichs. Der Name bleibt, weil die Kompakt-Karte // "Ressourcen-Auslastung" im Dashboard darauf verweist. window.openResourceModal = function(){ if(typeof showPlanSection === 'function') showPlanSection('terminplan'); const container = document.getElementById('resourceLoadContainer'); if(container) container.scrollIntoView({ behavior: 'smooth', block: 'center' }); }; // renderResourceLoad: rendert pro Person eine Auslastungskarte mit Balken // und Abwesenheits-Hinweis in #resourceLoadContainer (Inhalt des // Auslastungs-Modals) sowie eine kompakte Vorschau (Balkenreihe) in // #planResourceSummary (die Kompakt-Karte selbst). Ein Klick auf eine // Detailkarte öffnet das Abwesenheits-Modal aus plan-modals.js. window.renderResourceLoad = function(){ const container = document.getElementById('resourceLoadContainer'); const summaryEl = document.getElementById('planResourceSummary'); if(!container || !app.project) return; const { loads } = computeResourceLoads(); const loadValues = Object.values(loads); if(loadValues.length === 0){ container.innerHTML = 'Keine Beteiligten zugeordnet.'; if(summaryEl) summaryEl.innerHTML = 'Keine Beteiligten zugeordnet.'; return; } if(summaryEl){ const cells = loadValues.map(l => { const otherHint = l.stundenAndere > 0 ? ` (+${stundenFormat(l.stundenAndere)} h anderswo)` : ''; return `
    `; }).join(''); const alsoBooked = loadValues.filter(l => l.stundenAndere > 0).length; const chip = alsoBooked > 0 ? `${alsoBooked} auch in anderen Projekten` : `nur in diesem Projekt`; summaryEl.innerHTML = `
    ${cells}
    ${chip}
    `; } let html = '
    Ressourcen-Auslastung (Wochenstunden, wie im Auslastungs-Tab)
    '; html += '
    '; Object.values(loads).forEach(l => { const ampel = loadAmpelColor(l.quote); const breite = Math.min(100, l.quote * 100); const absCount = app.countPersonAbsences(l.person.id); const absBadge = absCount > 0 ? `
    ${absCount} Abwesenheit(en)
    ` : ''; let todoListHtml; if(l.todos.length === 0){ todoListHtml = '
    keine TODOs
    '; } else { todoListHtml = l.todos.slice(0,3).map(t => `
    ${esc(t)}
    `).join(''); if(l.todos.length > 3){ todoListHtml += '
    +' + (l.todos.length - 3) + ' weitere
    '; } } // Gegenüberstellung: Stunden aus diesem Projekt vs. aus den übrigen // Projekten der Sitzung (siehe computeResourceLoads) — macht sichtbar, // wenn jemand hier entspannt aussieht, aber anderswo ausgebucht ist. const otherBadge = l.stundenAndere > 0 ? `
    +${stundenFormat(l.stundenAndere)} h in ${l.andereProjekte.length} weiteren Projekt${l.andereProjekte.length === 1 ? '' : 'en'}
    ` : `
    keine weiteren Projekte
    `; const offenText = l.offenProjekt > 0 ? `
    ${stundenFormat(l.offenProjekt)} h offen in Aufgaben dieses Projekts
    ` : `
    keine Aufgaben mit Aufwand
    `; html += `

    ${esc(l.person.name)} (${esc(l.roles.join(' / '))})

    ${stundenFormat(l.stunden)}h diese Woche
    ${offenText} ${otherBadge}
    ${todoListHtml}
    ${absBadge}
    `; }); html += '
    '; container.innerHTML = html; }; // Abhängigkeitslinien hängen von Pixel-Positionen ab -> bei Fenstergröße neu zeichnen // Debounce, damit während des Ziehens nicht unnötig neu gerendert wird. // Seit der Projektbereich ein Bereich der Hauptseite ist, läuft dieser // Lauscher auch, während ein ganz anderer Bereich sichtbar ist. In einem // ausgeblendeten Container sind alle gemessenen Positionen 0 — das Zeichnen // wäre nicht nur überflüssig, sondern falsch. Deshalb der Bereichs-Test. window.addEventListener('resize', () => { if(currentTab !== 'plan' || !app.project) return; if(app.resizeTimer) clearTimeout(app.resizeTimer); app.resizeTimer = setTimeout(() => renderGanttDependencies(), 150); }); })();