Files
projektuebersicht/public/js/plan/plan-build-gantt.js
T
erikandClaude Opus 5.5 b7e0bd0d92 Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb
- Termin aus Mail: Mailtext oder .eml per Drag & Drop/Strg+V auf Terminplan
  oder Aufgaben, KI schlägt Aufgaben vor, Prüfung im Aufgaben-Panel; neues
  Notizfeld je Aufgabe (plan_todos.notiz)
- Terminplan: Meilensteine ohne Aufgabenbezug je in eigener Zeile,
  "+ Neu"-Menü klappt neben schmaler Seitenleiste nach rechts, Leistungsphasen
- Rollen & Rechte, Abwesenheiten-Seite, Benachrichtigungen, Rundgang
- Glas-Darstellung, Logo als Favicon
- Pi-Betrieb: PDF-Arbeit im Kindprozess, Wächter, Einrichtungs- und
  Prüfskript inkl. WLAN-Wächter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:16:52 +01:00

414 lines
24 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). 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 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' : ''}${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>`
: '';
// Ruhigere Namensspalte (Eriks Wahl "A"): Vorgänger stehen nicht mehr
// als Schildchen im Namen, sondern im Tooltip (und als Linie im
// Diagramm); rechts die Kürzel der Zuständigen.
const vorgaengerText = depBadge ? '\nVorgänger: ' + (depBadge.match(/title="Vorgänger: ([^"]*)"/) || [,''])[1] : '';
const vorgaengerKonflikt = depBadge.indexOf('conflict') !== -1;
const leute = (Array.isArray(todo.assignees) ? todo.assignees : []).map(id => {
const pers = ((app.week && app.week.people) || []).find(x => x.id === id);
return pers ? pers.name : '';
}).filter(Boolean);
const leuteHtml = leute.length
? `<span class="gantt-leute">${leute.slice(0, 3).map(k => `<span>${esc(k)}</span>`).join('')}${leute.length > 3 ? `<span>+${leute.length - 3}</span>` : ''}</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')}${vorgaengerText}">
${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')}</span>
${vorgaengerKonflikt ? '<span class="gantt-vorgaenger-warn" title="Beginnt vor dem Ende eines Vorgängers">!</span>' : ''}
${conflictBadge}${leuteHtml}
</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;
// Die Projektzeile trägt die Gesamtdauer und Meilensteine ohne eigene
// Zeile; sie heißt jetzt schlicht so, statt den Projektnamen ein
// zweites Mal zu wiederholen.
projectRow.classList.add('tp-projektzeile');
const projektLabel = projectRow.querySelector('.gantt-label');
if(projektLabel) projektLabel.textContent = 'Projekt gesamt';
let insertAfter = projectRow;
const insertedMilestoneIds = new Set();
// Gliederung nach Leistungsphasen (Eriks Wahl "A"): Phasenbalken
// (balkenPhase(), load.js) werden Gruppenköpfe; jede andere Aufgabe
// steht unter der Phase, in deren Balken sie beginnt. Nur die
// ANZEIGE wird umsortiert — plan.todos übernimmt die Reihenfolge erst,
// wenn jemand eine Zeile zieht (reorderPlanRows(), plan-drag.js).
const istPhase = t => typeof balkenPhase === 'function' && !!balkenPhase(t);
const hatBalken = t => t.start && t.end && t.showInGantt !== false;
const phasenKoepfe = todos.filter(t => hatBalken(t) && istPhase(t))
.sort((a, b) => String(a.start).localeCompare(String(b.start)));
const gruppeVon = t => {
if(istPhase(t)) return t.id;
const s0 = parseDateISO(t.start);
const k = s0 && phasenKoepfe.find(p => s0 >= parseDateISO(p.start) && s0 <= parseDateISO(p.end));
return k ? k.id : '';
};
let reihe = todos;
if(phasenKoepfe.length){
reihe = [];
phasenKoepfe.forEach(p => { reihe.push(p); todos.forEach(t => { if(!istPhase(t) && gruppeVon(t) === p.id) reihe.push(t); }); });
todos.forEach(t => { if(reihe.indexOf(t) === -1) reihe.push(t); });
}
const heuteIsoWert = formatDateISO(new Date());
const filter = options.filter || 'alle';
const passtFilter = t => filter === 'alle'
|| (filter === 'meine' && options.eigenePersonId && Array.isArray(t.assignees) && t.assignees.indexOf(options.eigenePersonId) !== -1)
|| (filter === 'ueberfaellig' && !t.done && t.end && t.end < heuteIsoWert);
const phasenZu = options.phasenZu || {};
let aktuelleGruppeZu = false;
let restKopfGesetzt = false;
const einfuegen = row => { insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); insertAfter = row; };
// Meilensteine ohne Aufgabenbezug: je eine eigene Zeile direkt unter
// "Projekt gesamt", nach Datum sortiert (Eriks Meldung: ohne Aufgaben
// standen alle Meilensteine übereinander in derselben Zeile).
milestones.filter(m => !m.position)
.sort((a, b) => String(a.date || '').localeCompare(String(b.date || '')))
.forEach(m => einfuegen(buildMilestoneRow(m, milestones.indexOf(m))));
reihe.forEach(todo => {
const tIdx = todos.indexOf(todo);
if(phasenKoepfe.length && hatBalken(todo)){
if(istPhase(todo)) aktuelleGruppeZu = !!phasenZu[balkenPhase(todo)];
else if(!gruppeVon(todo) && !restKopfGesetzt){
// Aufgaben außerhalb jedes Phasenbalkens bekommen einen eigenen Kopf.
const kopf = document.createElement('div');
kopf.className = 'gantt-row tp-gruppe';
kopf.innerHTML = '<div class="gantt-label">Weitere Aufgaben</div><div class="gantt-timeline"></div>';
einfuegen(kopf);
restKopfGesetzt = true;
aktuelleGruppeZu = false;
}
}
// 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);
if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu');
einfuegen(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).
// UND showInGantt darf nicht ausdrücklich false sein — die Checkbox
// "Im Terminplan anzeigen" im Aufgaben-Dialog (plan-modals.js) blendet
// eine terminierte Aufgabe trotz gültigem Zeitraum gezielt aus dem
// GANTT aus (Erik: ein einzelner "TODOs aus Protokoll"-Import,
// plan-protokoll-todos.js, legt leicht 15-25 datierte Aufgaben auf
// einmal an, die sonst alle zwingend als Balken erschienen). Fehlt
// das Feld (ältere Aufgaben vor dieser Spalte), gilt weiterhin
// sichtbar — !== false statt === true, damit kein Bestandsverhalten
// kippt.
if(todo.start && todo.end && todo.showInGantt !== false){
const todoRow = buildTodoRow(todo, tIdx);
if(phasenKoepfe.length && istPhase(todo)){
const ph = balkenPhase(todo);
const zu = !!phasenZu[ph];
const kinder = todos.filter(t => !istPhase(t) && hatBalken(t) && gruppeVon(t) === todo.id);
todoRow.classList.add('gantt-phase');
const label = todoRow.querySelector('.gantt-label');
if(label){
const knopf = document.createElement('button');
knopf.type = 'button';
knopf.className = 'tp-klappen';
knopf.setAttribute('aria-expanded', String(!zu));
knopf.setAttribute('aria-label', (zu ? 'Aufklappen: ' : 'Zuklappen: ') + ph);
knopf.setAttribute('onclick', `event.stopPropagation(); tpPhaseUmschalten('${ph.replace(/'/g, '')}')`);
knopf.textContent = zu ? '▶' : '▼';
label.insertBefore(knopf, label.firstChild);
const info = document.createElement('span');
info.className = 'tp-phase-info';
info.textContent = kinder.length + ' · ' + (parseInt(todo.progress, 10) || 0) + ' %';
info.title = (kinder.length === 1 ? '1 Aufgabe' : kinder.length + ' Aufgaben') + ', Phase zu ' + (parseInt(todo.progress, 10) || 0) + ' % erledigt';
label.appendChild(info);
}
} else {
if(aktuelleGruppeZu) todoRow.classList.add('gantt-zu');
if(!passtFilter(todo)) todoRow.classList.add('gantt-gefiltert');
}
einfuegen(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);
if(aktuelleGruppeZu && !istPhase(todo)) row.classList.add('gantt-zu');
einfuegen(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;
};
})();