313 lines
18 KiB
JavaScript
313 lines
18 KiB
JavaScript
// -----------------------------
|
||
// 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;
|
||
};
|
||
})(); |