// -----------------------------
// Projektübersicht — GANTT-Diagramm (gemeinsam genutzt)
// -----------------------------
// Gemeinsames Modul für das GANTT-Diagramm. Es wird von App.html nach
// js/load/load.js geladen und ist die Grundlage sowohl der Übersichts-
// Diagramme als auch des Terminplans im Projektbereich: die Module unter
// js/plan/ setzen darauf auf und müssen deshalb NACH dieser Datei stehen.
// Das Modul stellt u. a. buildGanttHtml(), projectAssignees(),
// ganttRangeFromProjects() sowie openProjectPlan()/closePlanPage()/
// bereit. js/plan/plan-build-gantt.js umhüllt
// buildGanttHtml() und ergänzt sie um TODO- und Meilensteinzeilen; diese
// Erweiterung greift nur bei gesetzter Option showTodos, die Aufrufe der
// Übersicht bleiben also unverändert.
// ganttModalProjectId: reservierte Projekt-ID für GANTT-Modal-Dialoge (zurzeit ungenutzt).
let ganttModalProjectId = null;
// ensurePlan: stellt sicher, dass ein Projekt ein gültiges Plan-Objekt besitzt
// (Start/Ende, todos, Meilensteine, Personen, Fortschritt). Parameter: p =
// Projektobjekt; Rückgabe: das Plan-Objekt von p.
function ensurePlan(p){
if(!p.plan) p.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 };
// Alte Meilensteine bleiben erhalten; neu sind todos die primären Planungselemente.
if(!Array.isArray(p.plan.todos)) p.plan.todos = [];
if(!Array.isArray(p.plan.milestones)) p.plan.milestones = [];
return p.plan;
}
// Beteiligte eines Projekts aus dem Board ableiten (Haupt + Optional)
// week optional: wenn die Funktion nicht im Kontext der aktuellen Hauptseite
// aufgerufen wird (z.B. Projektplan-Seite), muss die passende Woche übergeben
// werden, damit die Personenliste korrekt aufgelöst wird.
function projectAssignees(p, week = null){
const w = week || currentWeek();
const people = w ? (w.people || []) : [];
const globalList = db.globalPeople || [];
const result = [];
people.forEach(person => {
const globalPerson = globalList.find(gp => gp.id === person.id);
const email = globalPerson ? globalPerson.email : '';
(person.role || ['Planung']).forEach(role => {
const key = assignKey(person.id, role);
if(p.assign && p.assign[key]){
result.push({ person, role, state: p.assign[key], email});
}
});
});
return result;
}
// projectColor: wählt die Balkenfarbe eines Projekts aus GANTT_COLORS
// deterministisch anhand der Projekt-ID. Parameter: p = Projektobjekt;
// Rückgabe: Hex-Farbstring.
function projectColor(p){
const idx = Math.abs(String(p.id).split('').reduce((a,c)=>a+c.charCodeAt(0),0)) % GANTT_COLORS.length;
return GANTT_COLORS[idx];
}
// ganttRangeFromProjects: berechnet den sichtbaren Zeitraum aus allen
// Projekt-/TODO-/Meilenstein-Terminen sowie den Abwesenheiten der globalen
// Personen, jeweils erweitert um padDays Tage (Standard 7). Parameter:
// projects = Projekt-Array, padDays = Puffer; Rückgabe: { start, end } als Date.
// Parameter `personIds` (optional): Beschränkt die berücksichtigten
// Abwesenheiten auf diese Personen. Ohne die Einschränkung zog die Elternzeit
// eines völlig unbeteiligten Kollegen den Zeitraum jedes Projektbereichs bis
// weit in die Zukunft — obwohl die zugehörige Urlaubszeile gar nicht gezeichnet
// wird (renderPlanGantt() filtert sie auf die Projektbeteiligten).
function ganttRangeFromProjects(projects, padDays = 7, personIds = null){
let min = null, max = null;
projects.forEach(p => {
const plan = p.plan;
if(!plan) return;
[plan.start, plan.end].forEach(d => {
const date = parseDateISO(d);
if(!date) return;
if(!min || date < min) min = date;
if(!max || date > max) max = date;
});
(plan.milestones || []).forEach(m => {
const date = parseDateISO(m.date);
if(!date) return;
if(!min || date < min) min = date;
if(!max || date > max) max = date;
});
(plan.todos || []).forEach(t => {
[t.start, t.end].forEach(d => {
const date = parseDateISO(d);
if(!date) return;
if(!min || date < min) min = date;
if(!max || date > max) max = date;
});
});
});
// Urlaub berücksichtigen (nur von Personen, deren Zeilen auch gezeichnet werden)
const relevant = personIds ? new Set(personIds) : null;
(db.globalPeople || []).forEach(person => {
if(relevant && !relevant.has(person.id)) return;
(person.absences || []).forEach(a => {
const s = parseDateISO(a.start), e = parseDateISO(a.end);
if(s){ if(!min || s < min) min = s; if(!max || s > max) max = s; }
if(e){ if(!min || e < min) min = e; if(!max || e > max) max = e; }
});
});
if(!min || !max){
const today = new Date();
min = new Date(today.getFullYear(), today.getMonth(), 1);
max = addDays(min, 90);
}
return { start: addDays(min, -padDays), end: addDays(max, padDays) };
}
// HINWEIS: Die frühere GANTT-Ansicht der Hauptseite (renderGantt/fitGanttRange)
// ist entfallen. Sie griff auf die Elemente #ganttContainer/#ganttStart/
// #ganttEnd/#ganttShowVacation zu, die es in keiner der drei HTML-Seiten mehr
// gibt — ein Aufruf hätte sofort geworfen. Das GANTT lebt heute ausschließlich
// im Projektbereich (renderPlanGantt/fitPlanGanttRange in js/plan/plan-gantt.js).
// buildGanttHtml: erzeugt das HTML des GANTT-Diagramms (Kopf mit Monaten/
// KW-Zellen, Wochenend-Hintergrund, Heute-Linie, Urlaubs- und Projektzeilen
// inkl. Fortschritt, Meilensteinen und Drag/Resize-Markern). Parameter:
// projects = Projekt-Array, start/end = sichtbarer Zeitraum, options =
// { showVacation, editable, highlightProjectId, pxPerDay, showTodos,
// vacationPeople }. Rückgabe: HTML-String für .gantt-chart.
function buildGanttHtml(projects, start, end, options = {}){
const { showVacation = true, editable = false, highlightProjectId = null, pxPerDay = null } = options;
const totalDays = Math.max(1, daysBetween(start, end) + 1);
// timeline: umhüllt den Zeileninhalt einer GANTT-Zeile mit der Tagesspuren-Containerklasse.
function timeline(content){
return `
${content}
`;
}
// dragAttrs: liefert die data-gantt-*-Attribute für Drag & Drop, leer wenn
// nicht editierbar. type = Elementtyp, attrs = zusätzliche Attribute.
function dragAttrs(type, attrs){
if(!editable) return '';
let s = ` data-gantt-type="${type}" draggable="false"`;
for(const [k,v] of Object.entries(attrs)){
s += ` data-gantt-${k}="${esc(String(v))}"`;
}
return s;
}
// resizeHandles: liefert die beiden Ziehgriffe links/rechts eines Balkens,
// sofern die Ansicht editierbar ist.
function resizeHandles(){
if(!editable) return '';
return ``;
}
// Kopfzeile: Monate und Tage
let headerMonths = '';
let headerWeeks = '';
headerWeeks += '
';
let weeksHtml = '';
current = new Date(start.getTime());
let lastWeek = -1;
let lastYear = -1;
let weekStartIdx = 0;
const weekCells = [];
for(let i = 0; i < totalDays; i++){
const cw = getWeekNumber(current);
const cy = current.getFullYear();
if(cw !== lastWeek || cy !== lastYear){
if(lastWeek !== -1){
weekCells.push({ week: lastWeek, year: lastYear, startIdx: weekStartIdx, endIdx: i - 1 });
}
weekStartIdx = i;
lastWeek = cw;
lastYear = cy;
}
current = addDays(current, 1);
}
if(lastWeek !== -1){
weekCells.push({ week: lastWeek, year: lastYear, startIdx: weekStartIdx, endIdx: totalDays - 1 });
}
weekCells.forEach(w => {
const span = w.endIdx - w.startIdx + 1;
weeksHtml += `
KW ${w.week}
`;
});
headerWeeks += timeline(weeksHtml) + '
';
// Raster-Overlay: Wochenenden schattieren und Heute-Linie markieren.
// Liegt als eine Ebene hinter allen Zeilen (erste Chart-Kindnode),
// damit nicht jede Zeile eigene Tageszellen braucht.
let gridHtml = '';
current = new Date(start.getTime());
let weekendStartIdx = null;
for(let i = 0; i <= totalDays; i++){
const isWeekend = i < totalDays && (current.getDay() === 0 || current.getDay() === 6);
if(isWeekend && weekendStartIdx === null) weekendStartIdx = i;
if(!isWeekend && weekendStartIdx !== null){
const span = i - weekendStartIdx;
gridHtml += `
`;
weekendStartIdx = null;
}
current = addDays(current, 1);
}
const today = new Date();
const todayMid = new Date(today.getFullYear(), today.getMonth(), today.getDate());
if(todayMid >= start && todayMid <= end){
const todayIdx = daysBetween(start, todayMid);
gridHtml += `
`;
}
gridHtml += '
';
let body = gridHtml + headerMonths + headerWeeks;
// Urlaubszeilen
if(showVacation){
let allPeople = db.globalPeople || [];
// Optional: nur Urlaub bestimmter Personen anzeigen
if(options.vacationPeople && Array.isArray(options.vacationPeople)){
const allowed = new Set(options.vacationPeople);
allPeople = allPeople.filter(person => allowed.has(person.id));
}
// Pro Person eine Zeile: alle Abwesenheiten als Balken in derselben Timeline
allPeople.forEach(person => {
let barsHtml = '';
(person.absences || []).forEach(a => {
const s = parseDateISO(a.start), e = parseDateISO(a.end);
if(!s || !e || e < start || s > end) return;
const rowStart = s < start ? start : s;
const rowEnd = e > end ? end : e;
const offset = daysBetween(start, rowStart);
const span = daysBetween(rowStart, rowEnd) + 1;
barsHtml += `${resizeHandles()}
`;
});
if(!barsHtml) return; // keine Abwesenheit im sichtbaren Zeitraum -> keine Zeile
body += ``;
body += `
${esc(person.name)}
`;
body += timeline(barsHtml);
body += `
`;
});
}
// Projektzeilen
projects.slice().sort((a,b) => (a.order||0)-(b.order||0)).forEach(p => {
const plan = p.plan;
const color = projectColor(p);
const milestones = plan && Array.isArray(plan.milestones) ? plan.milestones : [];
body += ``;
body += `
${esc(p.nr ? p.nr + ' ' : '')}${esc(p.name)}
`;
let rowContent = '';
// Projekt-Balken
const s = plan ? parseDateISO(plan.start) : null, e = plan ? parseDateISO(plan.end) : null;
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 progress = Math.max(0, Math.min(100, plan && parseFloat(plan.progress) ? parseFloat(plan.progress) : 0));
rowContent += `
`;
rowContent += `${resizeHandles()}
`;
rowContent += `
${esc(p.name)}`;
rowContent += `
`;
}
}
// Meilensteine: nur die nicht positionierten (keine position) in der Projektzeile anzeigen.
// Positionierte Meilensteine werden vom Aufrufer (z.B. Projektplan) als separate Zeilen eingefügt.
milestones.forEach((m, mIdx) => {
if(options.showTodos && m.position) return;
const d = parseDateISO(m.date);
if(!d || d < start || d > end) return;
const offset = daysBetween(start, d);
rowContent += `
`;
rowContent += `
${esc(m.name || '')}
`;
});
body += timeline(rowContent) + `
`;
});
// pxPerDay setzt eine feste Breite pro Tag, damit das Diagramm bei hohem
// Zoom breiter als der Container wird und horizontal gescrollt werden kann.
let chartStyle = '';
if(pxPerDay){
chartStyle = ` style="min-width:${(220 + Math.ceil(totalDays * pxPerDay)).toFixed(0)}px"`;
}
return `${body}
`;
}
// ---------- Projektplan-Navigation ----------
// openProjectPlan: öffnet den Projektbereich EINES Projekts. Das war einmal
// eine Navigation auf ein eigenes Dokument (project-plan.html); inzwischen
// ist der Projektbereich der Bereich #plan derselben Seite, also genügt ein
// gewöhnlicher Bereichswechsel — Menüband und Seitenleiste bleiben stehen,
// die Sitzungsdaten sind schon geladen, nichts blitzt weiß auf.
// Weil Projekt-IDs beim Duplizieren einer Sitzung neu vergeben werden,
// merkt sich der Bereich stabile Werte (Sitzungs-Label, Projekt-Nr./-Name);
// aus ihnen baut updateRouteUrl() auch die verschickbare Adresse
// "/Projektplan/?weekLabel=…&projNr=…".
// Parameter: pid = Projekt-ID der aktuellen Sitzung. Kein Rückgabewert.
function openProjectPlan(pid){
const w = currentWeek(); if(!w) return;
const p = w.projects.find(x => x.id === pid); if(!p) return;
setPlanTarget(w.label, p.nr || '', p.name || '');
showTab('plan');
window.scrollTo(0, 0);
}
// springZuEinstellungen: wie openProjectPlan(), landet aber gezielt im
// Bereich "Einstellungen" statt im Dashboard — der Verweis aus Board/
// Wochenbericht zur jetzt dort editierbaren Beteiligten-Matrix
// (planCycleAssign(), js/plan/plan-settings.js). Der Hash muss VOR
// showTab('plan') gesetzt werden: dessen eigener updateRouteUrl() behält
// einen bereits vorhandenen location.hash bei (dasselbe Prinzip wie bei
// wandSpringeZuProjekt(), js/wand/wand.js). Parameter: pid (Projekt-ID der
// aktuellen Sitzung). Kein Rückgabewert.
function springZuEinstellungen(pid){
location.hash = 'einstellungen';
openProjectPlan(pid);
}
// closePlanPage: Gegenstück zu openProjectPlan() für den "Schließen"-Knopf
// in der Kopfzeile des Projektbereichs. Früher ging es hier in der
// Browser-Historie zurück, weil der Projektbereich ein eigenes Dokument war.
// Jetzt ist es ein schlichter Bereichswechsel zurück auf das Projektboard
// (bzw. auf den Bereich, aus dem geöffnet wurde). Kein Parameter, keine
// Rückgabe.
function closePlanPage(){
const back = (typeof previousTab === 'string' && previousTab && previousTab !== 'plan') ? previousTab : 'board';
showTab(back);
}