Files

362 lines
17 KiB
JavaScript
Raw Permalink 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.
// -----------------------------
// Projektübersicht — GANTT-Diagramm (gemeinsam genutzt)
// -----------------------------
// Gemeinsames Modul für das GANTT-Diagramm: Es wird von Test.html (nach
// js/load/load.js) UND von project-plan.html geladen – dort zusammen mit globals,
// utils, ui, date-picker, csv, server und den js/plan/*-Modulen, aber OHNE
// js/store.js, js/weeks.js, js/board/board.js usw. Es darf daher auf der Plan-Seite
// keine Hauptseiten-spezifischen Funktionen voraussetzen; Funktionen, die
// currentWeek()/isWeekLocked() aus js/weeks.js brauchen (renderGantt,
// fitGanttRange, openProjectPlan), werden nur vom Hauptfenster aufgerufen.
// Das Modul stellt u. a. buildGanttHtml(), renderGantt(), fitGanttRange(),
// projectAssignees(), ganttRangeFromProjects() sowie openProjectPlan()/
// findProjectForPlan() bereit. Die Plan-Seite erweitert buildGanttHtml() über
// js/plan/plan-build-gantt.js und bezieht Beteiligte/Range über
// projectAssignees()/ganttRangeFromProjects() aus diesem Modul.
// 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[currentDepartment] || [];
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.
function ganttRangeFromProjects(projects, padDays = 7){
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
(db.globalPeople[currentDepartment] || []).forEach(person => {
(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) };
}
// fitGanttRange: setzt die Datumsfelder (ganttStart/ganttEnd) auf den
// automatisch berechneten Projekt-Zeitrahmen und rendert das Diagramm neu.
function fitGanttRange(){
const w = currentWeek(); if(!w) return;
const range = ganttRangeFromProjects(w.projects, 7);
document.getElementById('ganttStart').value = formatDateISO(range.start);
document.getElementById('ganttEnd').value = formatDateISO(range.end);
renderGantt();
}
// renderGantt: rendert das GANTT-Diagramm der aktuellen Woche in
// ganttContainer. Nutzt die Datumsfelder bzw. fällt auf den Projekt-Zeitrahmen
// zurück; bei gesperrter Woche ist die Ansicht nicht editierbar.
function renderGantt(){
const w = currentWeek();
const container = document.getElementById('ganttContainer');
if(!w || !container){
if(container) container.innerHTML = '<div class="hint">Keine Sitzung ausgewählt.</div>';
return;
}
let start = parseDateISO(document.getElementById('ganttStart').value);
let end = parseDateISO(document.getElementById('ganttEnd').value);
if(!start || !end){
const range = ganttRangeFromProjects(w.projects, 7);
start = range.start; end = range.end;
document.getElementById('ganttStart').value = formatDateISO(start);
document.getElementById('ganttEnd').value = formatDateISO(end);
}
const showVacation = document.getElementById('ganttShowVacation').checked;
container.innerHTML = buildGanttHtml(w.projects, start, end, { showVacation, editable: !isWeekLocked(w) });
}
// 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 `<div class="gantt-timeline">${content}</div>`;
}
// 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 `<span class="gantt-resize gantt-resize-left" data-resize="left"></span><span class="gantt-resize gantt-resize-right" data-resize="right"></span>`;
}
// Kopfzeile: Monate und Tage
let headerMonths = '<div class="gantt-row gantt-header">';
headerMonths += '<div class="gantt-label">Projekt</div>';
let monthsHtml = '';
let current = new Date(start.getTime());
let lastMonth = -1;
let monthCells = [];
for(let i = 0; i < totalDays; i++){
if(current.getMonth() !== lastMonth){
monthCells.push({ month: current.getMonth(), year: current.getFullYear(), startIdx: i });
lastMonth = current.getMonth();
}
current = addDays(current, 1);
}
monthCells.forEach((m, idx) => {
const next = monthCells[idx + 1];
const span = next ? next.startIdx - m.startIdx : totalDays - m.startIdx;
const names = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'];
monthsHtml += `<div class="gantt-month" style="left:${(m.startIdx/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%">${names[m.month]} ${m.year}</div>`;
});
headerMonths += timeline(monthsHtml) + '</div>';
let headerWeeks = '<div class="gantt-row gantt-days">';
headerWeeks += '<div class="gantt-label"></div>';
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 += `<div class="gantt-day gantt-week" style="left:${(w.startIdx/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%"><span>KW ${w.week}</span></div>`;
});
headerWeeks += timeline(weeksHtml) + '</div>';
// 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 = '<div class="gantt-grid-overlay">';
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 += `<div class="gantt-grid-weekend" style="left:${(weekendStartIdx/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%"></div>`;
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 += `<div class="gantt-grid-today" style="left:${((todayIdx + 0.5)/totalDays*100).toFixed(4)}%" title="Heute: ${formatDateDE(todayMid)}"></div>`;
}
gridHtml += '</div>';
let body = gridHtml + headerMonths + headerWeeks;
// Urlaubszeilen
if(showVacation){
let allPeople = db.globalPeople[currentDepartment] || [];
// 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 += `<div class="gantt-bar vacation"${dragAttrs('vacation',{person: person.id, absence: a.id, start: formatDateISO(s), end: formatDateISO(e)})} style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${esc(a.type || 'Abwesenheit')}: ${formatDateDE(s)}–${formatDateDE(e)}${a.note ? ' – ' + esc(a.note) : ''}">${resizeHandles()}</div>`;
});
if(!barsHtml) return; // keine Abwesenheit im sichtbaren Zeitraum -> keine Zeile
body += `<div class="gantt-row gantt-vacation">`;
body += `<div class="gantt-label" title="${esc(person.fullName || person.name)}"><span class="gantt-vacation-dot"></span> ${esc(person.name)}</div>`;
body += timeline(barsHtml);
body += `</div>`;
});
}
// 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 += `<div class="gantt-row gantt-project${highlightProjectId === p.id ? ' highlighted' : ''}">`;
body += `<div class="gantt-label">${esc(p.nr ? p.nr + ' ' : '')}${esc(p.name)}</div>`;
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 += `<div class="gantt-bar project"${dragAttrs('project',{project: p.id, start: formatDateISO(s), end: formatDateISO(e)})} style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%;background:${color}">`;
rowContent += `${resizeHandles()}<div class="gantt-progress" style="width:${progress}%"></div>`;
rowContent += `<span class="gantt-bar-label">${esc(p.name)}</span>`;
rowContent += `</div>`;
}
}
// 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 += `<div class="gantt-milestone"${dragAttrs('milestone',{project: p.id, index: mIdx, date: formatDateISO(d)})} style="left:${(offset/totalDays*100).toFixed(4)}%" title="${esc(m.name)}: ${formatDateDE(d)}"></div>`;
rowContent += `<div class="gantt-milestone-label" style="left:${(offset/totalDays*100).toFixed(4)}%">${esc(m.name || '')}</div>`;
});
body += timeline(rowContent) + `</div>`;
});
// 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 `<div class="gantt-chart"${chartStyle}>${body}</div>`;
}
// ---------- Projektplan-Navigation ----------
// openProjectPlan: öffnet project-plan.html in einem neuen Tab. Weil
// Projekt-IDs nicht persistent sind, werden Wochen-Label und Proj.-Nr. als
// URL-Parameter übergeben; Abteilung und Projektname stehen im Pfad nach dem
// Schema "/<Abteilung>/Projektplan/<Projektname>". Parameter: pid =
// Projekt-ID der aktuellen Woche. Kein Rückgabewert.
function openProjectPlan(pid){
const w = currentWeek(); if(!w) return;
const p = w.projects.find(x => x.id === pid); if(!p) return;
// Projekt-IDs sind in der DB stabil, aber die Plan-Seite ist eine separate
// Route. Daher werden stabile Werte (Label, Projekt-Nr.) in der URL übergeben.
// Absoluter Pfad mit Abteilung und Projektname als Route-Segment, damit der
// Aufruf auch von Deep-Link-URLs wie "/HLS/Auslastung" funktioniert.
const url = new URL('/' + currentDepartment + '/' + PLAN_SLUG + '/' + encodeURIComponent(p.name || ''), window.location.origin);
url.searchParams.set('weekLabel', w.label);
url.searchParams.set('projNr', p.nr || '');
window.open(url.toString(), '_blank');
}
// Diese Funktion wird von project-plan.html aufgerufen, um das aktuelle
// Wochen-/Projekt-Objekt im Hauptfenster zu finden.
// Parameter: department, weekId, projectId; Rückgabe: { week, project } oder
// null, wenn die Woche/Abteilung nicht (synchron) auflösbar ist.
function findProjectForPlan(department, weekId, projectId){
if(currentDepartment !== department){
const targetWeek = db.weeks.find(w => w.id === weekId);
if(!targetWeek) return null;
// temporär umschalten ist unpraktisch, daher versuchen wir über load-all
return null;
}
const w = db.weeks.find(x => x.id === weekId);
if(!w) return null;
return { week: w, project: w.projects.find(x => x.id === projectId) };
}