175 lines
9.6 KiB
JavaScript
175 lines
9.6 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.
|
||
//
|
||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||
// (plan-state.js).
|
||
|
||
(function(){
|
||
const app = window.PlanApp;
|
||
|
||
// 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;
|
||
|
||
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;
|
||
|
||
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));
|
||
barHtml = `<div class="gantt-bar todo${todo.done ? ' done' : ''}${isCritical ? ' critical' : ''}"${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)}">${handles}<div class="gantt-progress" style="width:${progress}%"></div></div>`;
|
||
}
|
||
}
|
||
|
||
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);
|
||
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>`;
|
||
}
|
||
}
|
||
|
||
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">⋮⋮</span>' : ''}
|
||
<span class="gantt-todo-title"${editable ? ' onclick="openTodoModal(' + tIdx + ')" style="cursor:pointer"' : ''}>${esc(todo.title || 'TODO')}${depBadge}</span>
|
||
</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;
|
||
}
|
||
}
|
||
row.innerHTML = `<div class="gantt-label" style="padding-left:24px;font-weight:400;color:var(--text-muted)">
|
||
${editable ? '<span class="gantt-todo-drag">⋮⋮</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
|
||
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;
|
||
};
|
||
})(); |