// ----------------------------- // Projektplan — buildGanttHtml-Erweiterung (TODOs & Meilensteine im GANTT) // ----------------------------- // Dieses Modul erweitert die in gantt.js definierte Funktion buildGanttHtml, // damit im Projektplan TODOs und Meilensteine als eigene Zeilen unter der // Projektzeile dargestellt werden. Mit der Option showTodos wird zuerst das // Basis-GANTT erzeugt und dann per DOM-Bearbeitung um die Zusatzzeilen // ergänzt. Die Abhängigkeitslinien zeichnet danach plan-gantt.js // (renderGanttDependencies), die Drag-/Resize- und Klick-Attribute der // Balken nutzen plan-drag.js und plan-modals.js. // // Zwei Zusatzanzeigen werden hier ebenfalls erzeugt: // // 1. GEISTER-BALKEN (Option ghostTodos/ghostMilestones): hinter jedem Balken // bzw. jeder Meilenstein-Raute der Termin aus der VORHERIGEN Sitzung als // gestrichelter Umriss, sobald er sich unterscheidet. Klassische // Projektwerkzeuge brauchen dafür eine eigens eingefrorene "Baseline" — // hier existiert sie kostenlos, weil jede Wochenkopie (cloneWeek() in // js/weeks.js) bereits ein vollständiger Schnappschuss des damaligen Plans // ist. Zusammengeführt wird über todo.id bzw. milestone.id: die bleiben // beim Klonen unverändert (cloneWeek() vergibt nur project.id neu), sind // also über Sitzungen hinweg stabil. Die Vorwochen-Daten liefert // renderPlanGantt() (plan-gantt.js) über app.previousProjectVersion() // aus plan-history.js. // // 2. URLAUBS-KOLLISION: überschneidet sich eine Aufgabe zeitlich mit einer // Abwesenheit einer ihr zugeordneten Person, wird der Balken markiert und // die Zeile bekommt ein Warnsymbol. Die Urlaubsbalken selbst zeichnet // gantt.js schon länger, aber rein dekorativ — die Kollision musste bisher // mit dem Auge gefunden werden. // // Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp // (plan-state.js). (function(){ const app = window.PlanApp; // rangesOverlap: prüft, ob sich zwei Datumsbereiche (jeweils einschließlich // Start und Ende) überschneiden. Parameter: vier Date-Objekte. // Rückgabe: Boolean (false, sobald ein Datum fehlt). function rangesOverlap(aStart, aEnd, bStart, bEnd){ if(!aStart || !aEnd || !bStart || !bEnd) return false; return aStart <= bEnd && bStart <= aEnd; } // absenceConflictsForTodo: sucht alle Abwesenheiten der einer Aufgabe // zugeordneten Personen, die sich mit deren Zeitraum überschneiden. Die // Abwesenheiten kommen aus der globalen Personenliste db.globalPeople // (gefüllt aus users/user_absences, siehe loadGlobalPeople() in server.js) — // dieselbe Quelle, aus der auch die Urlaubszeilen im GANTT gezeichnet // werden. Parameter: todo (Aufgabenobjekt). Rückgabe: Array von // { name, type, start, end } (leer, wenn keine Kollision). function absenceConflictsForTodo(todo){ const s = parseDateISO(todo.start), e = parseDateISO(todo.end); if(!s || !e) return []; const assignees = Array.isArray(todo.assignees) ? todo.assignees : []; if(assignees.length === 0) return []; const conflicts = []; assignees.forEach(personId => { const person = (db.globalPeople || []).find(p => p.id === personId); if(!person) return; (person.absences || []).forEach(a => { const as = parseDateISO(a.start), ae = parseDateISO(a.end); if(!rangesOverlap(s, e, as, ae)) return; conflicts.push({ name: person.fullName || person.name, type: a.type || 'Abwesenheit', start: as, end: ae, }); }); }); return conflicts; } // Export, damit plan-gantt.js dieselbe Prüfung für die Warnliste über dem // Diagramm nutzen kann (eine Quelle für beide Anzeigen). app.absenceConflictsForTodo = absenceConflictsForTodo; // Referenz auf die Original-Funktion aus gantt.js, die für das Basis-GANTT // weiterhin verwendet wird. const originalBuildGanttHtml = window.buildGanttHtml; // window.buildGanttHtml (überschrieben): Erweiterte Version der Funktion // aus gantt.js. Ohne Option showTodos wird unverändert die Original-Funktion // aufgerufen. Mit showTodos wird deren HTML per DOM-Bearbeitung um die // TODO- und Meilensteinzeilen aller übergebenen Projekte ergänzt und als // HTML-String zurückgegeben. Parameter: projects (Array), start/end (ISO- // Datum), options (Optionen inkl. editable). Rückgabe: // String mit dem kompletten GANTT-HTML. window.buildGanttHtml = function(projects, start, end, options = {}){ if(!options.showTodos) return originalBuildGanttHtml(projects, start, end, options); const baseHtml = originalBuildGanttHtml(projects, start, end, options); const temp = document.createElement('div'); temp.innerHTML = baseHtml; const chart = temp.querySelector('.gantt-chart'); if(!chart) return baseHtml; const totalDays = Math.max(1, daysBetween(start, end) + 1); const editable = options.editable; // ghostBarHtml: zeichnet einen gestrichelten Umriss für den Termin aus der // Vorwoche. Liefert '' (nichts), wenn keine Vorwochen-Daten vorliegen, der // Termin unverändert ist oder er komplett außerhalb des sichtbaren // Zeitraums liegt. Parameter: prevStartIso/prevEndIso (ISO-Datum aus der // Vorwoche), curStartIso/curEndIso (aktuelle Termine), label (für den // Tooltip). Rückgabe: HTML-String. function ghostBarHtml(prevStartIso, prevEndIso, curStartIso, curEndIso, label){ if(!prevStartIso || !prevEndIso) return ''; if(prevStartIso === curStartIso && prevEndIso === curEndIso) return ''; const gs = parseDateISO(prevStartIso), ge = parseDateISO(prevEndIso); if(!gs || !ge) return ''; const barStart = gs < start ? start : gs; const barEnd = ge > end ? end : ge; if(barEnd < start || barStart > end) return ''; const offset = daysBetween(start, barStart); const span = daysBetween(barStart, barEnd) + 1; // Verschiebung in Tagen, positiv = nach hinten gerutscht const shift = curStartIso ? daysBetween(gs, parseDateISO(curStartIso)) : 0; const shiftText = shift > 0 ? ` (${shift} Tag${shift === 1 ? '' : 'e'} später)` : shift < 0 ? ` (${-shift} Tag${-shift === 1 ? '' : 'e'} früher)` : ''; const ref = options.ghostLabel ? esc(options.ghostLabel) : 'vorherige Sitzung'; const title = `${ref}: ${esc(label)} ${formatDateDE(gs)}–${formatDateDE(ge)}${shiftText}`; return `
`; } projects.forEach(p => { const plan = p.plan || {}; const todos = Array.isArray(plan.todos) ? plan.todos : []; const milestones = Array.isArray(plan.milestones) ? plan.milestones : []; // Projektzeile finden, unter der TODOs und Meilensteine eingehängt werden const projectRow = chart.querySelector('.gantt-row.gantt-project.highlighted') || Array.from(chart.querySelectorAll('.gantt-row.gantt-project')).find(r => { const label = r.querySelector('.gantt-label'); return label && label.textContent.includes(p.name); }); // buildTodoRow: baut die DOM-Zeile einer TODO-Aufgabe mit Label, ggf. // Abhängigkeits-Badge und (im Bearbeitungsmodus) verschiebbarem Balken // mit Resize-Griffen und Fortschrittsanzeige. Vergibt fehlende IDs beim // ersten Rendern. Parameter: todo (Aufgabenobjekt), tIdx (Index in der // Aufgabenliste). Rückgabe: Zeilen-DOM-Element. function buildTodoRow(todo, tIdx){ if(!todo.id) todo.id = uid(); const s = parseDateISO(todo.start), e = parseDateISO(todo.end); const row = document.createElement('div'); row.className = 'gantt-row gantt-todo' + (todo.done ? ' done' : ''); row.dataset.todoId = todo.id; row.dataset.todoIndex = tIdx; // Urlaubs-Kollision: Aufgabe überschneidet sich mit einer Abwesenheit // einer zugeordneten Person (siehe absenceConflictsForTodo oben). const conflicts = absenceConflictsForTodo(todo); if(conflicts.length > 0) row.className += ' absence-conflict'; // Geister-Balken: Termin derselben Aufgabe (gleiche todo.id) aus der // vorherigen Sitzung, sofern er sich unterscheidet. const ghost = options.ghostTodos ? options.ghostTodos.get(todo.id) : null; const ghostHtml = ghost ? ghostBarHtml(ghost.start, ghost.end, todo.start, todo.end, todo.title || 'TODO') : ''; let barHtml = ''; 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 dragAttrs = editable ? ` data-gantt-type="todo" data-gantt-project="${esc(p.id)}" data-gantt-index="${tIdx}" data-gantt-start="${formatDateISO(s)}" data-gantt-end="${formatDateISO(e)}" draggable="false"` : ''; const handles = editable ? `` : ''; const progress = Math.max(0, Math.min(100, parseInt(todo.progress, 10) || 0)); const conflictTitle = conflicts.length > 0 ? '\nAchtung: ' + conflicts.map(c => `${c.name} ${c.type.toLowerCase()} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('; ') : ''; barHtml = `
${handles}
`; } } // Geister-Balken zuerst einfügen, damit der aktuelle Balken darüber liegt. barHtml = ghostHtml + barHtml; let depBadge = ''; if(Array.isArray(todo.dependsOn) && todo.dependsOn.length > 0){ // Vorgänger können TODOs oder Meilensteine (Prefix "m_") sein const preds = todo.dependsOn.map(id => { const t = todos.find(x => x.id === id); if(t) return { title: t.title || 'TODO', end: t.end }; const m = milestones.find(x => 'm_' + x.id === id); // Hinweis: title landet weiter unten via esc() in reinem Textkontext // (kein HTML-Rendering) -> kein Icon-Präfix möglich, nur Text. if(m) return { title: (m.name || 'Meilenstein'), end: m.date }; return null; }).filter(Boolean); if(preds.length > 0){ const isConflict = preds.some(pred => pred.end && todo.start && parseDateISO(todo.start) <= parseDateISO(pred.end)); const names = preds.map(pr => pr.title).join(', '); depBadge = `↳ ${preds.length === 1 ? esc(preds[0].title) : esc(preds.length + ' Vorgänger')}`; } } // Warnsymbol bei Urlaubs-Kollision, mit den betroffenen Personen im Tooltip const conflictBadge = conflicts.length > 0 ? `${icon('warning')}` : ''; // Ruhigere Namensspalte (Eriks Wahl "A"): Vorgänger stehen nicht mehr // als Schildchen im Namen, sondern im Tooltip (und als Linie im // Diagramm); rechts die Kürzel der Zuständigen. const vorgaengerText = depBadge ? '\nVorgänger: ' + (depBadge.match(/title="Vorgänger: ([^"]*)"/) || [,''])[1] : ''; const vorgaengerKonflikt = depBadge.indexOf('conflict') !== -1; const leute = (Array.isArray(todo.assignees) ? todo.assignees : []).map(id => { const pers = ((app.week && app.week.people) || []).find(x => x.id === id); return pers ? pers.name : ''; }).filter(Boolean); const leuteHtml = leute.length ? `${leute.slice(0, 3).map(k => `${esc(k)}`).join('')}${leute.length > 3 ? `+${leute.length - 3}` : ''}` : ''; row.innerHTML = `
${editable ? '' + icon('grip') + '' : ''} ${esc(todo.title || 'TODO')} ${vorgaengerKonflikt ? '!' : ''} ${conflictBadge}${leuteHtml}
${barHtml}
`; return row; } // buildMilestoneRow: baut die DOM-Zeile eines Meilensteins mit Rauten-Symbol // an der Datumsposition, Label und ggf. Datumskennzeichnung. Parameter: // m (Meilensteinobjekt), mIdx (Index in der Meilensteinliste). Rückgabe: // Zeilen-DOM-Element. function buildMilestoneRow(m, mIdx){ const d = parseDateISO(m.date); const row = document.createElement('div'); row.className = 'gantt-row gantt-milestone-row' + (m.position ? ' positioned' : ''); row.dataset.milestoneId = m.id; row.dataset.milestoneIndex = mIdx; let diamond = '', labelStyle = 'display:none'; if(d){ if(d >= start && d <= end){ const offset = daysBetween(start, d); const left = (offset / totalDays * 100).toFixed(4) + '%'; const dragAttrs = editable ? ` data-gantt-type="milestone" data-gantt-project="${esc(p.id)}" data-gantt-index="${mIdx}" data-gantt-date="${formatDateISO(d)}" draggable="false"` : ''; const deps = esc((m.dependsOn || []).join(',')); diamond = `
`; labelStyle = 'left:' + left; } } // Geister-Raute: Termin desselben Meilensteins (gleiche milestone.id) // aus der vorherigen Sitzung, sofern er sich verschoben hat. Gerade // bei Meilensteinen ist das die Information, die im Wochenmeeting auf // den Tisch gehört — sie sind die nach außen zugesagten Termine. const ghostDate = options.ghostMilestones ? options.ghostMilestones.get(m.id) : null; if(ghostDate && ghostDate !== m.date){ const gd = parseDateISO(ghostDate); if(gd && gd >= start && gd <= end){ const gLeft = (daysBetween(start, gd) / totalDays * 100).toFixed(4) + '%'; const shift = d ? daysBetween(gd, d) : 0; const shiftText = shift > 0 ? ` (${shift} Tag${shift === 1 ? '' : 'e'} später)` : shift < 0 ? ` (${-shift} Tag${-shift === 1 ? '' : 'e'} früher)` : ''; const ref = options.ghostLabel ? esc(options.ghostLabel) : 'vorherige Sitzung'; diamond = `
` + diamond; } } row.innerHTML = `
${editable ? '' + icon('grip') + '' : ''} ${esc(m.name || 'Meilenstein')}
${diamond}
${esc(m.name || '')}
`; return row; } if(!projectRow) return; // Die Projektzeile trägt die Gesamtdauer und Meilensteine ohne eigene // Zeile; sie heißt jetzt schlicht so, statt den Projektnamen ein // zweites Mal zu wiederholen. projectRow.classList.add('tp-projektzeile'); const projektLabel = projectRow.querySelector('.gantt-label'); if(projektLabel) projektLabel.textContent = 'Projekt gesamt'; let insertAfter = projectRow; const insertedMilestoneIds = new Set(); // Gliederung nach Leistungsphasen (Eriks Wahl "A"): Phasenbalken // (balkenPhase(), load.js) werden Gruppenköpfe; jede andere Aufgabe // steht unter der Phase, in deren Balken sie beginnt. Nur die // ANZEIGE wird umsortiert — plan.todos übernimmt die Reihenfolge erst, // wenn jemand eine Zeile zieht (reorderPlanRows(), plan-drag.js). const istPhase = t => typeof balkenPhase === 'function' && !!balkenPhase(t); const hatBalken = t => t.start && t.end && t.showInGantt !== false; const phasenKoepfe = todos.filter(t => hatBalken(t) && istPhase(t)) .sort((a, b) => String(a.start).localeCompare(String(b.start))); const gruppeVon = t => { if(istPhase(t)) return t.id; const s0 = parseDateISO(t.start); const k = s0 && phasenKoepfe.find(p => s0 >= parseDateISO(p.start) && s0 <= parseDateISO(p.end)); return k ? k.id : ''; }; let reihe = todos; if(phasenKoepfe.length){ reihe = []; phasenKoepfe.forEach(p => { reihe.push(p); todos.forEach(t => { if(!istPhase(t) && gruppeVon(t) === p.id) reihe.push(t); }); }); todos.forEach(t => { if(reihe.indexOf(t) === -1) reihe.push(t); }); } const heuteIsoWert = formatDateISO(new Date()); const filter = options.filter || 'alle'; const passtFilter = t => filter === 'alle' || (filter === 'meine' && options.eigenePersonId && Array.isArray(t.assignees) && t.assignees.indexOf(options.eigenePersonId) !== -1) || (filter === 'ueberfaellig' && !t.done && t.end && t.end < heuteIsoWert); const phasenZu = options.phasenZu || {}; let aktuelleGruppeZu = false; let restKopfGesetzt = false; const einfuegen = row => { insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); insertAfter = row; }; // Meilensteine ohne Aufgabenbezug: je eine eigene Zeile direkt unter // "Projekt gesamt", nach Datum sortiert (Eriks Meldung: ohne Aufgaben // standen alle Meilensteine übereinander in derselben Zeile). milestones.filter(m => !m.position) .sort((a, b) => String(a.date || '').localeCompare(String(b.date || ''))) .forEach(m => einfuegen(buildMilestoneRow(m, milestones.indexOf(m)))); reihe.forEach(todo => { const tIdx = todos.indexOf(todo); if(phasenKoepfe.length && hatBalken(todo)){ if(istPhase(todo)) aktuelleGruppeZu = !!phasenZu[balkenPhase(todo)]; else if(!gruppeVon(todo) && !restKopfGesetzt){ // Aufgaben außerhalb jedes Phasenbalkens bekommen einen eigenen Kopf. const kopf = document.createElement('div'); kopf.className = 'gantt-row tp-gruppe'; kopf.innerHTML = '
Weitere Aufgaben
'; einfuegen(kopf); restKopfGesetzt = true; aktuelleGruppeZu = false; } } // Meilensteine, die VOR dieser Aufgabe positioniert sind milestones.filter(m => m.position === 'before:' + todo.id).forEach(m => { const mIdx = milestones.indexOf(m); const row = buildMilestoneRow(m, mIdx); if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu'); einfuegen(row); insertedMilestoneIds.add(m.id); }); // Aufgabenzeile — nur wenn die Aufgabe terminiert ist (Start UND Ende // gesetzt). Eine Aufgabe ohne Termin bleibt ganz normal Teil von // plan.todos und erscheint im Bereich "Aufgaben" (renderPlanTasks(), // js/plan/plan-tasks.js) — hier im Terminplan aber bewusst nicht: sie // hat keinen Zeitraum, den ein Balken abbilden könnte, und soll den // Terminplan nicht mit leeren Zeilen ohne Balken zustellen. Nicht // jede Aufgabe aus "Aufgaben" landet also im GANTT, wohl aber jede // Aufgabe aus dem GANTT in "Aufgaben" (dieselbe Liste, siehe oben). // UND showInGantt darf nicht ausdrücklich false sein — die Checkbox // "Im Terminplan anzeigen" im Aufgaben-Dialog (plan-modals.js) blendet // eine terminierte Aufgabe trotz gültigem Zeitraum gezielt aus dem // GANTT aus (Erik: ein einzelner "TODOs aus Protokoll"-Import, // plan-protokoll-todos.js, legt leicht 15-25 datierte Aufgaben auf // einmal an, die sonst alle zwingend als Balken erschienen). Fehlt // das Feld (ältere Aufgaben vor dieser Spalte), gilt weiterhin // sichtbar — !== false statt === true, damit kein Bestandsverhalten // kippt. if(todo.start && todo.end && todo.showInGantt !== false){ const todoRow = buildTodoRow(todo, tIdx); if(phasenKoepfe.length && istPhase(todo)){ const ph = balkenPhase(todo); const zu = !!phasenZu[ph]; const kinder = todos.filter(t => !istPhase(t) && hatBalken(t) && gruppeVon(t) === todo.id); todoRow.classList.add('gantt-phase'); const label = todoRow.querySelector('.gantt-label'); if(label){ const knopf = document.createElement('button'); knopf.type = 'button'; knopf.className = 'tp-klappen'; knopf.setAttribute('aria-expanded', String(!zu)); knopf.setAttribute('aria-label', (zu ? 'Aufklappen: ' : 'Zuklappen: ') + ph); knopf.setAttribute('onclick', `event.stopPropagation(); tpPhaseUmschalten('${ph.replace(/'/g, '')}')`); knopf.textContent = zu ? '▶' : '▼'; label.insertBefore(knopf, label.firstChild); const info = document.createElement('span'); info.className = 'tp-phase-info'; info.textContent = kinder.length + ' · ' + (parseInt(todo.progress, 10) || 0) + ' %'; info.title = (kinder.length === 1 ? '1 Aufgabe' : kinder.length + ' Aufgaben') + ', Phase zu ' + (parseInt(todo.progress, 10) || 0) + ' % erledigt'; label.appendChild(info); } } else { if(aktuelleGruppeZu) todoRow.classList.add('gantt-zu'); if(!passtFilter(todo)) todoRow.classList.add('gantt-gefiltert'); } einfuegen(todoRow); } // Meilensteine, die NACH dieser Aufgabe positioniert sind milestones.filter(m => m.position === 'after:' + todo.id).forEach(m => { const mIdx = milestones.indexOf(m); const row = buildMilestoneRow(m, mIdx); if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu'); einfuegen(row); insertedMilestoneIds.add(m.id); }); }); // Positionierte Meilensteine, deren referenzierte Aufgabe nicht mehr existiert, // am Ende der Aufgabenliste anhängen, damit sie nicht verloren gehen. milestones.filter(m => m.position && !insertedMilestoneIds.has(m.id)).forEach(m => { const mIdx = milestones.indexOf(m); const row = buildMilestoneRow(m, mIdx); insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); insertAfter = row; }); }); return temp.innerHTML; }; })();