Files
projektuebersicht/public/js/plan/plan-build-gantt.js
T
erik 5b20adee48 All new
alle new
2026-10-01 06:46:51 +02:00

313 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// 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 `<div class="gantt-bar ghost" style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${title}"></div>`;
}
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 ? `<span class="gantt-resize gantt-resize-left" data-resize="left"></span><span class="gantt-resize gantt-resize-right" data-resize="right"></span>` : '';
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 = `<div class="gantt-bar todo${todo.done ? ' done' : ''}${isCritical ? ' critical' : ''}${conflicts.length > 0 ? ' conflict-absence' : ''}"${dragAttrs} data-todo-id="${esc(todo.id)}" data-todo-depends="${esc((todo.dependsOn || []).join(','))}" style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${esc(todo.title || 'TODO')}: ${formatDateDE(s)}–${formatDateDE(e)}${esc(conflictTitle)}">${handles}<div class="gantt-progress" style="width:${progress}%"></div></div>`;
}
}
// 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 = `<span class="gantt-dep-badge${isConflict ? ' conflict' : ''}" title="Vorgänger: ${esc(names)}">↳ ${preds.length === 1 ? esc(preds[0].title) : esc(preds.length + ' Vorgänger')}</span>`;
}
}
// Warnsymbol bei Urlaubs-Kollision, mit den betroffenen Personen im Tooltip
const conflictBadge = conflicts.length > 0
? `<span class="gantt-conflict-badge" title="${esc(conflicts.map(c => `${c.name}: ${c.type} ${formatDateDE(c.start)}–${formatDateDE(c.end)}`).join('\n'))}">${icon('warning')}</span>`
: '';
row.innerHTML = `<div class="gantt-label" data-todo-id="${esc(todo.id)}" style="padding-left:24px;font-weight:400;color:var(--text-muted)" title="${esc(todo.title || 'TODO')}">
${editable ? '<span class="gantt-todo-drag">' + icon('grip') + '</span>' : ''}
<span class="gantt-todo-title"${editable ? ' onclick="openTodoModal(' + tIdx + ')" style="cursor:pointer"' : ''}>${esc(todo.title || 'TODO')}${depBadge}</span>
${conflictBadge}
</div>
<div class="gantt-timeline">${barHtml}</div>`;
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 = `<div class="gantt-milestone"${dragAttrs} data-todo-id="m_${esc(m.id)}" data-todo-depends="${deps}" style="left:${left}" title="${esc(m.name)}: ${formatDateDE(d)}"></div>`;
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 = `<div class="gantt-milestone ghost" style="left:${gLeft}" title="${ref}: ${esc(m.name || 'Meilenstein')} ${formatDateDE(gd)}${shiftText}"></div>` + diamond;
}
}
row.innerHTML = `<div class="gantt-label" style="padding-left:24px;font-weight:400;color:var(--text-muted)">
${editable ? '<span class="gantt-todo-drag">' + icon('grip') + '</span>' : ''}
<span class="gantt-milestone-title"${editable ? ' onclick="openMilestoneModal(' + mIdx + ')" style="cursor:pointer"' : ''}>${esc(m.name || 'Meilenstein')}</span>
</div>
<div class="gantt-timeline">${diamond}<div class="gantt-milestone-label" style="${labelStyle}">${esc(m.name || '')}</div></div>`;
return row;
}
if(!projectRow) return;
let insertAfter = projectRow;
const insertedMilestoneIds = new Set();
todos.forEach((todo, tIdx) => {
// 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);
insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling);
insertAfter = 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).
if(todo.start && todo.end){
const todoRow = buildTodoRow(todo, tIdx);
insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling);
insertAfter = 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);
insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling);
insertAfter = 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;
};
})();