Files
projektuebersicht/public/js/plan/plan-gantt.js
T

233 lines
11 KiB
JavaScript

// -----------------------------
// Projektplan — GANTT-Rendering & Zoom
// -----------------------------
// Dieses Modul rendert das GANTT-Diagramm der Projektplan-Seite inklusive
// der TODO- und Meilensteinzeilen, die buildGanttHtml aus plan-build-gantt.js
// liefert, und zeichnet darauf die Abhängigkeitslinien zwischen den Aufgaben.
// Es verwaltet außerdem Zoom (pxPerDay in PlanApp), das Anpassen des
// Datumsbereichs (fitPlanGanttRange) sowie die Anzeige der Ressourcen-
// Auslastung. renderPlanGantt ist die zentrale Neu-Render-Funktion und wird
// nach Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js
// erneut aufgerufen.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// renderPlanGantt: baut das komplette GANTT-Diagramm des aktuellen Projekts
// neu auf. Nutzt die Datumseingaben der Seite (oder berechnet den
// Projektzeitraum), berücksichtigt Urlaub, kritischen Pfad, Sperrstatus und
// Zoomstufe und ruft buildGanttHtml auf. Im Anschluss werden die
// Abhängigkeitslinien gezeichnet und die Ressourcen-Auslastung aktualisiert.
window.renderPlanGantt = function(){
const container = document.getElementById('planGanttContainer');
if(!app.project || !container) return;
const prevScroll = container.scrollLeft;
let start = parseDateISO(document.getElementById('planGanttStart').value);
let end = parseDateISO(document.getElementById('planGanttEnd').value);
if(!start || !end){
const range = ganttRangeFromProjects([app.project], 7);
start = range.start; end = range.end;
document.getElementById('planGanttStart').value = formatDateISO(start);
document.getElementById('planGanttEnd').value = formatDateISO(end);
}
const showVacation = document.getElementById('planShowVacation').checked;
const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id);
const criticalPath = app.criticalPathActive ? computeCriticalPath(app.project) : [];
container.innerHTML = buildGanttHtml([app.project], start, end, { showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id, showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath });
container.scrollLeft = prevScroll;
requestAnimationFrame(() => renderGanttDependencies(container));
renderResourceLoad();
};
// renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die
// Abhängigkeits-Pfade als SVG-Kurven mit Pfeilspitzen in das Diagramm.
// Bereits vorhandene Linien werden zuvor entfernt; Konflikt- und
// erledigte-Kanten erhalten eigene Farben. Parameter: container (optional,
// GANTT-Container). Rückgabe: keine.
window.renderGanttDependencies = function(container){
const c = container || document.getElementById('planGanttContainer');
if(!c) return;
const chart = c.querySelector('.gantt-chart');
if(!chart) return;
let svg = chart.querySelector('.gantt-dependencies-svg');
if(svg) svg.remove();
const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]'));
if(nodeBars.length === 0) return;
const chartRect = chart.getBoundingClientRect();
const barMap = new Map();
nodeBars.forEach(b => {
if(b.dataset.todoId) barMap.set(b.dataset.todoId, b);
});
const paths = [];
nodeBars.forEach(targetBar => {
const dependsOnStr = targetBar.dataset.todoDepends;
if(!dependsOnStr) return;
const depIds = dependsOnStr.split(',').map(s => s.trim()).filter(Boolean);
depIds.forEach(depId => {
const sourceBar = barMap.get(depId);
if(!sourceBar) return;
const sourceRect = sourceBar.getBoundingClientRect();
const targetRect = targetBar.getBoundingClientRect();
const x1 = sourceRect.right - chartRect.left;
const y1 = sourceRect.top + sourceRect.height / 2 - chartRect.top;
const x2 = targetRect.left - chartRect.left;
const y2 = targetRect.top + targetRect.height / 2 - chartRect.top;
const isConflict = x2 < x1;
const isDone = targetBar.classList.contains('done') && sourceBar.classList.contains('done');
let d = '';
if(x2 >= x1 + 10){
const midX = x1 + (x2 - x1) / 2;
d = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
} else {
const r = 16;
d = `M ${x1} ${y1} C ${x1 + r} ${y1}, ${x1 + r} ${(y1 + y2)/2}, ${(x1 + x2)/2} ${(y1 + y2)/2} C ${x2 - r} ${(y1 + y2)/2}, ${x2 - r} ${y2}, ${x2} ${y2}`;
}
let pathClass = 'gantt-dep-path';
let markerId = 'gantt-arrow-normal';
if(isConflict){ pathClass += ' conflict'; markerId = 'gantt-arrow-conflict'; }
else if(isDone){ pathClass += ' done'; markerId = 'gantt-arrow-done'; }
paths.push(`<path class="${pathClass}" d="${d}" marker-end="url(#${markerId})" data-source="${esc(sourceBar.dataset.todoId)}" data-target="${esc(targetBar.dataset.todoId)}" />`);
});
});
if(paths.length === 0) return;
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'gantt-dependencies-svg');
svg.innerHTML = `
<defs>
<marker id="gantt-arrow-normal" viewBox="0 0 10 10" refX="7" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="var(--accent)" />
</marker>
<marker id="gantt-arrow-conflict" viewBox="0 0 10 10" refX="7" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="var(--red)" />
</marker>
<marker id="gantt-arrow-done" viewBox="0 0 10 10" refX="7" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="var(--green)" />
</marker>
</defs>
${paths.join('')}
`;
chart.appendChild(svg);
};
// fitPlanGanttRange: setzt die Datumsfelder der Seite auf den Projektzeitraum
// (inkl. 7 Tage Puffer) zurück und rendert das Diagramm neu. Wird auch nach
// Drags außerhalb des sichtbaren Bereichs von plan-drag.js genutzt.
window.fitPlanGanttRange = function(){
const range = ganttRangeFromProjects([app.project], 7);
document.getElementById('planGanttStart').value = formatDateISO(range.start);
document.getElementById('planGanttEnd').value = formatDateISO(range.end);
renderPlanGantt();
};
// zoomPlanGantt: ändert die Pixel-pro-Tag-Stufe des Diagramms.
// Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit).
window.zoomPlanGantt = function(dir){
const container = document.getElementById('planGanttContainer');
let start = parseDateISO(document.getElementById('planGanttStart').value);
let end = parseDateISO(document.getElementById('planGanttEnd').value);
if(!start || !end) return;
const totalDays = Math.max(1, daysBetween(start, end) + 1);
const cw = container ? Math.max(200, container.clientWidth - 250) : 900;
const fitPx = Math.max(3, cw / totalDays);
const base = app.pxPerDay || fitPx;
let next;
if(dir > 0){
next = Math.min(120, Math.round(base * 1.35));
} else {
next = Math.round(base / 1.35);
if(next <= fitPx * 0.7) next = null;
}
app.pxPerDay = next;
renderPlanGantt();
};
// ---------- Ressourcen-Auslastung ----------
// renderResourceLoad: berechnet die Auslastung der Projektbeteiligten in
// Werktagen (ohne Wochenenden) anhand der ihnen zugeordneten TODOs und
// rendert pro Person eine Auslastungskarte mit Balken und Abwesenheits-
// Hinweis. Ein Klick auf eine Karte öffnet das Abwesenheits-Modal aus
// plan-modals.js.
window.renderResourceLoad = function(){
const container = document.getElementById('resourceLoadContainer');
if(!container || !app.project) return;
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos.filter(t => t.start && t.end) : [];
const assignees = projectAssignees(app.project, app.week);
if(assignees.length === 0){
container.innerHTML = '';
return;
}
const loads = {};
assignees.forEach(a => {
if(!loads[a.person.id]){
loads[a.person.id] = { person: a.person, roles: [], days: 0, todos: [] };
}
if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role);
});
todos.forEach(t => {
if(!Array.isArray(t.assignees) || t.assignees.length === 0) return;
const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end));
t.assignees.forEach(pid => {
if(!loads[pid]) return;
loads[pid].days += days;
loads[pid].todos.push(t.title);
});
});
const maxDays = Math.max(1, Object.values(loads).reduce((m, l) => Math.max(m, l.days), 0));
let html = '<div class="section-title" style="margin-bottom:8px;font-size:13px">Ressourcen-Auslastung (Aufgabentage, ohne Wochenenden)</div>';
html += '<div class="load-grid plan-resource-grid">';
Object.values(loads).forEach(l => {
const pct = Math.round(l.days / maxDays * 100);
const absCount = app.countPersonAbsences(l.person.id);
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">🏖 ${absCount} Abwesenheit(en)</div>` : '';
let todoListHtml;
if(l.todos.length === 0){
todoListHtml = '<div class="hint">keine TODOs</div>';
} else {
todoListHtml = l.todos.slice(0,3).map(t => `<div><span>${esc(t)}</span></div>`).join('');
if(l.todos.length > 3){
todoListHtml += '<div class="hint">+' + (l.todos.length - 3) + ' weitere</div>';
}
}
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
<h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3>
<div class="load-num" style="font-size:18px">${l.days}</div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${pct}%;background:${pct>75?'#c53030':pct>45?'#d69e2e':'#2f855a'}"></div></div>
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
${absBadge}
</div>`;
});
html += '</div>';
container.innerHTML = html;
};
// Abhängigkeitslinien hängen von Pixel-Positionen ab -> bei Fenstergröße neu zeichnen
// Debounce, damit während des Ziehens nicht unnötig neu gerendert wird.
window.addEventListener('resize', () => {
if(app.resizeTimer) clearTimeout(app.resizeTimer);
app.resizeTimer = setTimeout(() => renderGanttDependencies(), 150);
});
})();