alle new
This commit is contained in:
erik committed 2026-10-01 06:46:51 +02:00
1 parent da64472251
commit 5b20adee48
1605 files changed
+396517 -1674

No files matched your search

+146 -8
View File
@@ -9,12 +9,74 @@
// (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;
@@ -36,6 +98,31 @@
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 : [];
@@ -61,6 +148,18 @@
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;
@@ -72,9 +171,14 @@
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>`;
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){
@@ -83,7 +187,9 @@
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 };
// 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){
@@ -93,9 +199,15 @@
}
}
// 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">⋮⋮</span>' : ''}
${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;
@@ -122,8 +234,25 @@
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">⋮⋮</span>' : ''}
${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>`;
@@ -144,10 +273,19 @@
insertedMilestoneIds.add(m.id);
});
// Aufgabenzeile
const todoRow = buildTodoRow(todo, tIdx);
insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling);
insertAfter = todoRow;
// 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 => {