// ----------------------------- // Projektübersicht — GANTT-Diagramm (gemeinsam genutzt) // ----------------------------- // Gemeinsames Modul für das GANTT-Diagramm. Es wird von App.html nach // js/load/load.js geladen und ist die Grundlage sowohl der Übersichts- // Diagramme als auch des Terminplans im Projektbereich: die Module unter // js/plan/ setzen darauf auf und müssen deshalb NACH dieser Datei stehen. // Das Modul stellt u. a. buildGanttHtml(), projectAssignees(), // ganttRangeFromProjects() sowie openProjectPlan()/closePlanPage()/ // bereit. js/plan/plan-build-gantt.js umhüllt // buildGanttHtml() und ergänzt sie um TODO- und Meilensteinzeilen; diese // Erweiterung greift nur bei gesetzter Option showTodos, die Aufrufe der // Übersicht bleiben also unverändert. // ganttModalProjectId: reservierte Projekt-ID für GANTT-Modal-Dialoge (zurzeit ungenutzt). let ganttModalProjectId = null; // ensurePlan: stellt sicher, dass ein Projekt ein gültiges Plan-Objekt besitzt // (Start/Ende, todos, Meilensteine, Personen, Fortschritt). Parameter: p = // Projektobjekt; Rückgabe: das Plan-Objekt von p. function ensurePlan(p){ if(!p.plan) p.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 }; // Alte Meilensteine bleiben erhalten; neu sind todos die primären Planungselemente. if(!Array.isArray(p.plan.todos)) p.plan.todos = []; if(!Array.isArray(p.plan.milestones)) p.plan.milestones = []; return p.plan; } // Beteiligte eines Projekts aus dem Board ableiten (Haupt + Optional) // week optional: wenn die Funktion nicht im Kontext der aktuellen Hauptseite // aufgerufen wird (z.B. Projektplan-Seite), muss die passende Woche übergeben // werden, damit die Personenliste korrekt aufgelöst wird. function projectAssignees(p, week = null){ const w = week || currentWeek(); const people = w ? (w.people || []) : []; const globalList = db.globalPeople || []; const result = []; people.forEach(person => { const globalPerson = globalList.find(gp => gp.id === person.id); const email = globalPerson ? globalPerson.email : ''; (person.role || ['Planung']).forEach(role => { const key = assignKey(person.id, role); if(p.assign && p.assign[key]){ result.push({ person, role, state: p.assign[key], email}); } }); }); return result; } // projectColor: wählt die Balkenfarbe eines Projekts aus GANTT_COLORS // deterministisch anhand der Projekt-ID. Parameter: p = Projektobjekt; // Rückgabe: Hex-Farbstring. function projectColor(p){ const idx = Math.abs(String(p.id).split('').reduce((a,c)=>a+c.charCodeAt(0),0)) % GANTT_COLORS.length; return GANTT_COLORS[idx]; } // ganttRangeFromProjects: berechnet den sichtbaren Zeitraum aus allen // Projekt-/TODO-/Meilenstein-Terminen sowie den Abwesenheiten der globalen // Personen, jeweils erweitert um padDays Tage (Standard 7). Parameter: // projects = Projekt-Array, padDays = Puffer; Rückgabe: { start, end } als Date. // Parameter `personIds` (optional): Beschränkt die berücksichtigten // Abwesenheiten auf diese Personen. Ohne die Einschränkung zog die Elternzeit // eines völlig unbeteiligten Kollegen den Zeitraum jedes Projektbereichs bis // weit in die Zukunft — obwohl die zugehörige Urlaubszeile gar nicht gezeichnet // wird (renderPlanGantt() filtert sie auf die Projektbeteiligten). function ganttRangeFromProjects(projects, padDays = 7, personIds = null){ let min = null, max = null; projects.forEach(p => { const plan = p.plan; if(!plan) return; [plan.start, plan.end].forEach(d => { const date = parseDateISO(d); if(!date) return; if(!min || date < min) min = date; if(!max || date > max) max = date; }); (plan.milestones || []).forEach(m => { const date = parseDateISO(m.date); if(!date) return; if(!min || date < min) min = date; if(!max || date > max) max = date; }); (plan.todos || []).forEach(t => { [t.start, t.end].forEach(d => { const date = parseDateISO(d); if(!date) return; if(!min || date < min) min = date; if(!max || date > max) max = date; }); }); }); // Urlaub berücksichtigen (nur von Personen, deren Zeilen auch gezeichnet werden) const relevant = personIds ? new Set(personIds) : null; (db.globalPeople || []).forEach(person => { if(relevant && !relevant.has(person.id)) return; (person.absences || []).forEach(a => { const s = parseDateISO(a.start), e = parseDateISO(a.end); if(s){ if(!min || s < min) min = s; if(!max || s > max) max = s; } if(e){ if(!min || e < min) min = e; if(!max || e > max) max = e; } }); }); if(!min || !max){ const today = new Date(); min = new Date(today.getFullYear(), today.getMonth(), 1); max = addDays(min, 90); } return { start: addDays(min, -padDays), end: addDays(max, padDays) }; } // HINWEIS: Die frühere GANTT-Ansicht der Hauptseite (renderGantt/fitGanttRange) // ist entfallen. Sie griff auf die Elemente #ganttContainer/#ganttStart/ // #ganttEnd/#ganttShowVacation zu, die es in keiner der drei HTML-Seiten mehr // gibt — ein Aufruf hätte sofort geworfen. Das GANTT lebt heute ausschließlich // im Projektbereich (renderPlanGantt/fitPlanGanttRange in js/plan/plan-gantt.js). // buildGanttHtml: erzeugt das HTML des GANTT-Diagramms (Kopf mit Monaten/ // KW-Zellen, Wochenend-Hintergrund, Heute-Linie, Urlaubs- und Projektzeilen // inkl. Fortschritt, Meilensteinen und Drag/Resize-Markern). Parameter: // projects = Projekt-Array, start/end = sichtbarer Zeitraum, options = // { showVacation, editable, highlightProjectId, pxPerDay, showTodos, // vacationPeople }. Rückgabe: HTML-String für .gantt-chart. function buildGanttHtml(projects, start, end, options = {}){ const { showVacation = true, editable = false, highlightProjectId = null, pxPerDay = null } = options; const totalDays = Math.max(1, daysBetween(start, end) + 1); // timeline: umhüllt den Zeileninhalt einer GANTT-Zeile mit der Tagesspuren-Containerklasse. function timeline(content){ return `
${content}
`; } // dragAttrs: liefert die data-gantt-*-Attribute für Drag & Drop, leer wenn // nicht editierbar. type = Elementtyp, attrs = zusätzliche Attribute. function dragAttrs(type, attrs){ if(!editable) return ''; let s = ` data-gantt-type="${type}" draggable="false"`; for(const [k,v] of Object.entries(attrs)){ s += ` data-gantt-${k}="${esc(String(v))}"`; } return s; } // resizeHandles: liefert die beiden Ziehgriffe links/rechts eines Balkens, // sofern die Ansicht editierbar ist. function resizeHandles(){ if(!editable) return ''; return ``; } // Kopfzeile: Monate und Tage let headerMonths = '
'; // Den Griff zum Ändern der Spaltenbreite setzt ganttGriffeEinsetzen() nach // dem Rendern in JEDE Zeile (auch die nachträglich eingefügten Aufgaben- // und Meilensteinzeilen aus js/plan/plan-build-gantt.js). headerMonths += '
Projekt
'; let monthsHtml = ''; let current = new Date(start.getTime()); let lastMonth = -1; let monthCells = []; for(let i = 0; i < totalDays; i++){ if(current.getMonth() !== lastMonth){ monthCells.push({ month: current.getMonth(), year: current.getFullYear(), startIdx: i }); lastMonth = current.getMonth(); } current = addDays(current, 1); } monthCells.forEach((m, idx) => { const next = monthCells[idx + 1]; const span = next ? next.startIdx - m.startIdx : totalDays - m.startIdx; const names = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']; monthsHtml += `
${names[m.month]} ${m.year}
`; }); headerMonths += timeline(monthsHtml) + '
'; let headerWeeks = '
'; headerWeeks += '
'; let weeksHtml = ''; current = new Date(start.getTime()); let lastWeek = -1; let lastYear = -1; let weekStartIdx = 0; const weekCells = []; for(let i = 0; i < totalDays; i++){ const cw = getWeekNumber(current); const cy = current.getFullYear(); if(cw !== lastWeek || cy !== lastYear){ if(lastWeek !== -1){ weekCells.push({ week: lastWeek, year: lastYear, startIdx: weekStartIdx, endIdx: i - 1 }); } weekStartIdx = i; lastWeek = cw; lastYear = cy; } current = addDays(current, 1); } if(lastWeek !== -1){ weekCells.push({ week: lastWeek, year: lastYear, startIdx: weekStartIdx, endIdx: totalDays - 1 }); } weekCells.forEach(w => { const span = w.endIdx - w.startIdx + 1; weeksHtml += `
KW ${w.week}
`; }); headerWeeks += timeline(weeksHtml) + '
'; // Raster-Overlay: Wochenenden schattieren und Heute-Linie markieren. // Liegt als eine Ebene hinter allen Zeilen (erste Chart-Kindnode), // damit nicht jede Zeile eigene Tageszellen braucht. let gridHtml = '
'; current = new Date(start.getTime()); let weekendStartIdx = null; for(let i = 0; i <= totalDays; i++){ const isWeekend = i < totalDays && (current.getDay() === 0 || current.getDay() === 6); if(isWeekend && weekendStartIdx === null) weekendStartIdx = i; if(!isWeekend && weekendStartIdx !== null){ const span = i - weekendStartIdx; gridHtml += `
`; weekendStartIdx = null; } current = addDays(current, 1); } const today = new Date(); const todayMid = new Date(today.getFullYear(), today.getMonth(), today.getDate()); if(todayMid >= start && todayMid <= end){ const todayIdx = daysBetween(start, todayMid); gridHtml += `
`; } gridHtml += '
'; let body = gridHtml + headerMonths + headerWeeks; // Urlaubszeilen if(showVacation){ let allPeople = db.globalPeople || []; // Optional: nur Urlaub bestimmter Personen anzeigen if(options.vacationPeople && Array.isArray(options.vacationPeople)){ const allowed = new Set(options.vacationPeople); allPeople = allPeople.filter(person => allowed.has(person.id)); } // Pro Person eine Zeile: alle Abwesenheiten als Balken in derselben Timeline allPeople.forEach(person => { let barsHtml = ''; (person.absences || []).forEach(a => { const s = parseDateISO(a.start), e = parseDateISO(a.end); if(!s || !e || e < start || s > end) return; const rowStart = s < start ? start : s; const rowEnd = e > end ? end : e; const offset = daysBetween(start, rowStart); const span = daysBetween(rowStart, rowEnd) + 1; barsHtml += `
${resizeHandles()}
`; }); if(!barsHtml) return; // keine Abwesenheit im sichtbaren Zeitraum -> keine Zeile body += `
`; body += `
${esc(person.name)}
`; body += timeline(barsHtml); body += `
`; }); } // Projektzeilen projects.slice().sort((a,b) => (a.order||0)-(b.order||0)).forEach(p => { const plan = p.plan; const color = projectColor(p); const milestones = plan && Array.isArray(plan.milestones) ? plan.milestones : []; body += `
`; body += `
${esc(p.nr ? p.nr + ' ' : '')}${esc(p.name)}
`; let rowContent = ''; // Projekt-Balken const s = plan ? parseDateISO(plan.start) : null, e = plan ? parseDateISO(plan.end) : null; if(s && e){ const barStart = s < start ? start : s; const barEnd = e > end ? end : e; if(barEnd >= start && barStart <= end){ const offset = daysBetween(start, barStart); const span = daysBetween(barStart, barEnd) + 1; const progress = Math.max(0, Math.min(100, plan && parseFloat(plan.progress) ? parseFloat(plan.progress) : 0)); rowContent += `
`; rowContent += `${resizeHandles()}
`; rowContent += `${esc(p.name)}`; rowContent += `
`; } } // Meilensteine in der Projektzeile nur ohne showTodos. Im Projektplan // bekommt jeder Meilenstein eine eigene Zeile (plan-build-gantt.js) — // sonst lagen mehrere ohne Aufgabenbezug übereinander in einer Zeile. milestones.forEach((m, mIdx) => { if(options.showTodos) return; const d = parseDateISO(m.date); if(!d || d < start || d > end) return; const offset = daysBetween(start, d); rowContent += `
`; rowContent += `
${esc(m.name || '')}
`; }); body += timeline(rowContent) + `
`; }); // pxPerDay setzt eine feste Breite pro Tag, damit das Diagramm bei hohem // Zoom breiter als der Container wird und horizontal gescrollt werden kann. let chartStyle = ''; if(pxPerDay){ chartStyle = ` style="min-width:${(ganttSpaltenBreite() + Math.ceil(totalDays * pxPerDay)).toFixed(0)}px"`; } return `
${body}
`; } // ---------- Projektplan-Navigation ---------- // openProjectPlan: öffnet den Projektbereich EINES Projekts. Das war einmal // eine Navigation auf ein eigenes Dokument (project-plan.html); inzwischen // ist der Projektbereich der Bereich #plan derselben Seite, also genügt ein // gewöhnlicher Bereichswechsel — Menüband und Seitenleiste bleiben stehen, // die Sitzungsdaten sind schon geladen, nichts blitzt weiß auf. // Weil Projekt-IDs beim Duplizieren einer Sitzung neu vergeben werden, // merkt sich der Bereich stabile Werte (Sitzungs-Label, Projekt-Nr./-Name); // aus ihnen baut updateRouteUrl() auch die verschickbare Adresse // "/Projektplan/?weekLabel=…&projNr=…". // Parameter: pid = Projekt-ID der aktuellen Sitzung. Kein Rückgabewert. function openProjectPlan(pid){ const w = currentWeek(); if(!w) return; const p = w.projects.find(x => x.id === pid); if(!p) return; setPlanTarget(w.label, p.nr || '', p.name || ''); showTab('plan'); window.scrollTo(0, 0); } // springZuEinstellungen: wie openProjectPlan(), landet aber gezielt im // Bereich "Einstellungen" statt im Dashboard — der Verweis aus Board/ // Wochenbericht zur jetzt dort editierbaren Beteiligten-Matrix // (planCycleAssign(), js/plan/plan-settings.js). Der Hash muss VOR // showTab('plan') gesetzt werden: dessen eigener updateRouteUrl() behält // einen bereits vorhandenen location.hash bei (dasselbe Prinzip wie bei // wandSpringeZuProjekt(), js/wand/wand.js). Parameter: pid (Projekt-ID der // aktuellen Sitzung). Kein Rückgabewert. function springZuEinstellungen(pid){ location.hash = 'einstellungen'; openProjectPlan(pid); } // closePlanPage: Gegenstück zu openProjectPlan() für den "Schließen"-Knopf // in der Kopfzeile des Projektbereichs. Früher ging es hier in der // Browser-Historie zurück, weil der Projektbereich ein eigenes Dokument war. // Jetzt ist es ein schlichter Bereichswechsel zurück auf das Projektboard // (bzw. auf den Bereich, aus dem geöffnet wurde). Kein Parameter, keine // Rückgabe. function closePlanPage(){ const back = (typeof previousTab === 'string' && previousTab && previousTab !== 'plan') ? previousTab : 'board'; showTab(back); } // ---------- Breite der Spalte "Projekt" (Eriks Wunsch: "wie in Excel") ---------- // // Die Breite steht in der CSS-Variable --gantt-label-w (styles.css, // .gantt-label), damit alle Zeilen beim Ziehen gleichzeitig mitgehen, ohne // das Diagramm neu aufzubauen. Gemerkt wird sie je Browser in localStorage — // reiner Darstellungszustand wie die eingeklappten Dashboard-Projekte. const GANTT_SPALTE_KEY = 'projuebersicht-gantt-spalte'; const GANTT_SPALTE_MIN = 120, GANTT_SPALTE_MAX = 640, GANTT_SPALTE_STANDARD = 220; // ganttSpaltenBreite: aktuelle Breite in Pixeln (gespeichert oder Standard). function ganttSpaltenBreite(){ let w = GANTT_SPALTE_STANDARD; try { w = Number(localStorage.getItem(GANTT_SPALTE_KEY)) || GANTT_SPALTE_STANDARD; } catch(e){ /* Standard */ } return Math.max(GANTT_SPALTE_MIN, Math.min(GANTT_SPALTE_MAX, w)); } // ganttSpalteSetzen: Breite anwenden; speichern=true merkt sie dauerhaft. function ganttSpalteSetzen(w, speichern){ w = Math.round(Math.max(GANTT_SPALTE_MIN, Math.min(GANTT_SPALTE_MAX, w))); document.documentElement.style.setProperty('--gantt-label-w', w + 'px'); if(speichern){ try { localStorage.setItem(GANTT_SPALTE_KEY, String(w)); } catch(e){ /* nur Komfort */ } } } ganttSpalteSetzen(ganttSpaltenBreite(), false); // ganttGriffeEinsetzen: hängt an den rechten Rand jeder Namenszelle einen // Griff — die Spaltengrenze lässt sich so in jeder Zeile fassen, nicht nur in // der Kopfzeile (Eriks Wunsch). click/dblclick werden abgefangen, damit ein // Klick auf die Grenze nicht zusätzlich die Aufgabe der Zeile öffnet. // Parameter: root (Element mit dem Diagramm). Keine Rückgabe. function ganttGriffeEinsetzen(root){ if(!root) return; root.querySelectorAll('.gantt-chart .gantt-label').forEach(label => { if(label.querySelector(':scope > .gantt-spalten-griff')) return; const g = document.createElement('span'); g.className = 'gantt-spalten-griff'; g.setAttribute('role', 'separator'); g.setAttribute('aria-orientation', 'vertical'); g.title = 'Spaltenbreite ziehen, Doppelklick passt sie an'; g.addEventListener('pointerdown', ganttSpalteZiehen); g.addEventListener('mousedown', e => e.stopPropagation()); g.addEventListener('click', e => { e.stopPropagation(); e.preventDefault(); }); g.addEventListener('dblclick', e => { e.stopPropagation(); e.preventDefault(); ganttSpalteAnpassen(); }); label.appendChild(g); }); } // ganttPfeileNachziehen: zeichnet die Abhängigkeitspfeile neu, höchstens // einmal je Bild. Die Pfeile sind ein SVG mit festen Pixelkoordinaten; ohne // das blieben sie beim Ziehen der Spalte an der alten Stelle stehen, während // die Balken mitwandern. let ganttPfeileGeplant = false; function ganttPfeileNachziehen(){ if(ganttPfeileGeplant || typeof renderGanttDependencies !== 'function') return; ganttPfeileGeplant = true; requestAnimationFrame(() => { ganttPfeileGeplant = false; renderGanttDependencies(); }); } // ganttNeuZeichnen: nach dem Ändern einmal neu aufbauen — die Mindestbreite // des Diagramms bei hohem Zoom hängt von der Spaltenbreite ab. function ganttNeuZeichnen(){ if(typeof renderPlanGantt === 'function' && typeof currentTab !== 'undefined' && currentTab === 'plan') renderPlanGantt(); } // ganttSpalteZiehen: Ziehen am Griff (Maus, Stift, Touch). Parameter: e // (pointerdown-Ereignis). Keine Rückgabe. function ganttSpalteZiehen(e){ if(e.button !== undefined && e.button !== 0) return; e.preventDefault(); e.stopPropagation(); const griff = e.currentTarget; const startX = e.clientX, startW = ganttSpaltenBreite(); griff.setPointerCapture && griff.setPointerCapture(e.pointerId); document.body.classList.add('gantt-spalte-zieht'); let w = startW; const bewegen = ev => { w = startW + (ev.clientX - startX); ganttSpalteSetzen(w, false); ganttPfeileNachziehen(); }; const ende = () => { griff.removeEventListener('pointermove', bewegen); griff.removeEventListener('pointerup', ende); griff.removeEventListener('pointercancel', ende); document.body.classList.remove('gantt-spalte-zieht'); ganttSpalteSetzen(w, true); ganttNeuZeichnen(); }; griff.addEventListener('pointermove', bewegen); griff.addEventListener('pointerup', ende); griff.addEventListener('pointercancel', ende); } // ganttSpalteAnpassen: Doppelklick auf den Griff — Breite an den längsten // Eintrag anpassen (wie der Doppelklick auf die Spaltengrenze in Excel). function ganttSpalteAnpassen(){ let max = 0; document.querySelectorAll('.gantt-chart .gantt-label').forEach(el => { // scrollWidth = volle Inhaltsbreite samt Innenabstand, auch wenn der // Text gerade mit "…" abgeschnitten ist. max = Math.max(max, el.scrollWidth); }); if(!max) return; ganttSpalteSetzen(max + 6, true); ganttNeuZeichnen(); }