Files
projektuebersicht/public/js/gantt.js
T
erikandClaude Sonnet 5 db63a29365 Wandtafel-Dashboard, Dateibereinigung und Auslastungs-Vereinfachung
Neue Wandtafel (Touch-Dashboard für Kiosk-Anzeige): Auslastung/Besprochen je
Gewerk, Aufgaben-Kennzahlen, Ereignis-Feed nach Art gruppiert, vollständige
Projektliste zum Scrollen, automatischer Rücksprung nach Untätigkeit mit
sichtbarem Countdown. Eigene Kiosk-Rolle ohne Fachrolle und ohne Schreibrechte.
Auslastung: Möglichkeit zum Wechsel der ausgewerteten Sitzung entfernt.
Aufräumen nicht mehr benötigter Dateien, README auf aktuellen Stand gebracht.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 15:14:47 +02:00

335 lines
16 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.
// -----------------------------
// 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 `<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 || [];
// 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 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/<Projektname>?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);
}
// 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);
}