Beteiligten-Zuordnung verlagert: p.assign ist jetzt ein Stammdatum und nur noch in den Projekteinstellungen editierbar (planCycleAssign()). Wochenbericht und Projektboard zeigen sie nur noch an, mit Verweis dorthin (springZuEinstellungen()). cycleAssign() ist damit entfallen. Neu "TODOs aus Protokoll": ein externes Besprechungsprotokoll als PDF wird per KI eingelesen und daraus in einem zweiten KI-Schritt Aufgaben abgeleitet. Eigener Bereich in der Aufgaben-/GANTT-Leiste, bewusst unabhaengig von den gespeicherten Protokollen - nichts davon wird abgelegt. Drei Schritte in einem Dialog mit fester Fusszeile; die Vorschlagsliste als kompakte, aufklappbare Zeilen, weil ein echter Import leicht 20 Vorschlaege liefert. Sichtbarkeit je Aufgabe im GANTT: neue Spalte plan_todos.show_in_gantt (Vorgabe 1, idempotente Migration) und eine Checkbox im Aufgaben-Dialog. Eine terminierte Aufgabe kann damit gezielt aus dem Terminplan herausgehalten werden, bleibt sonst aber eine vollwertige Aufgabe. plan-build-gantt.js und plan-drag.js pruefen dieselbe Bedingung, damit Zeilenzahl und Umsortierung nicht auseinanderlaufen. KI-Timeout dritte Stufe: ein abweichender, aber entfernter Endpunkt (Drittanbieter-Proxy) bekommt 2 Minuten statt der 45 Sekunden der echten Anbieter-API. Gemessen: die Punkte-Erkennung eines realen Protokolls brauchte dort 39 Sekunden, lag also stets knapp unter dem alten Limit und brach bei jeder Schwankung ab. Bewegung: Hover- und Klick-Rueckmeldung als Token-Satz in :root, angewandt auf die anklickbaren Zeilen, Schalter und Dialoge. Die Akzentleiste ist ein inset-Schatten statt eines positionierten Elements, damit Drag-and-Drop unberuehrt bleibt; prefers-reduced-motion war bisher gar nicht vorhanden und kippt jetzt einfach die Tokens. Plan Checker: optionaler Projektabgleich des Plankopfs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
348 lines
16 KiB
JavaScript
348 lines
16 KiB
JavaScript
// -----------------------------
|
||
// 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);
|
||
}
|
||
|
||
// 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);
|
||
} |