// ----------------------------- // 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 und criticalPath). 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 isCritical = options.criticalPath && options.criticalPath.includes(todo.id); 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 = ``; } } // 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')}` : ''; row.innerHTML = `