- 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>
453 lines
21 KiB
JavaScript
453 lines
21 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">';
|
||
// Den Griff zum Ändern der Spaltenbreite setzt ganttGriffeEinsetzen() nach
|
||
// dem Rendern in JEDE Zeile (auch die nachträglich eingefügten Aufgaben-
|
||
// und Meilensteinzeilen aus js/plan/plan-build-gantt.js).
|
||
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 in der Projektzeile nur ohne showTodos. Im Projektplan
|
||
// bekommt jeder Meilenstein eine eigene Zeile (plan-build-gantt.js) —
|
||
// sonst lagen mehrere ohne Aufgabenbezug übereinander in einer Zeile.
|
||
milestones.forEach((m, mIdx) => {
|
||
if(options.showTodos) 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:${(ganttSpaltenBreite() + 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);
|
||
}
|
||
|
||
// ---------- Breite der Spalte "Projekt" (Eriks Wunsch: "wie in Excel") ----------
|
||
//
|
||
// Die Breite steht in der CSS-Variable --gantt-label-w (styles.css,
|
||
// .gantt-label), damit alle Zeilen beim Ziehen gleichzeitig mitgehen, ohne
|
||
// das Diagramm neu aufzubauen. Gemerkt wird sie je Browser in localStorage —
|
||
// reiner Darstellungszustand wie die eingeklappten Dashboard-Projekte.
|
||
const GANTT_SPALTE_KEY = 'projuebersicht-gantt-spalte';
|
||
const GANTT_SPALTE_MIN = 120, GANTT_SPALTE_MAX = 640, GANTT_SPALTE_STANDARD = 220;
|
||
|
||
// ganttSpaltenBreite: aktuelle Breite in Pixeln (gespeichert oder Standard).
|
||
function ganttSpaltenBreite(){
|
||
let w = GANTT_SPALTE_STANDARD;
|
||
try { w = Number(localStorage.getItem(GANTT_SPALTE_KEY)) || GANTT_SPALTE_STANDARD; } catch(e){ /* Standard */ }
|
||
return Math.max(GANTT_SPALTE_MIN, Math.min(GANTT_SPALTE_MAX, w));
|
||
}
|
||
// ganttSpalteSetzen: Breite anwenden; speichern=true merkt sie dauerhaft.
|
||
function ganttSpalteSetzen(w, speichern){
|
||
w = Math.round(Math.max(GANTT_SPALTE_MIN, Math.min(GANTT_SPALTE_MAX, w)));
|
||
document.documentElement.style.setProperty('--gantt-label-w', w + 'px');
|
||
if(speichern){ try { localStorage.setItem(GANTT_SPALTE_KEY, String(w)); } catch(e){ /* nur Komfort */ } }
|
||
}
|
||
ganttSpalteSetzen(ganttSpaltenBreite(), false);
|
||
|
||
// ganttGriffeEinsetzen: hängt an den rechten Rand jeder Namenszelle einen
|
||
// Griff — die Spaltengrenze lässt sich so in jeder Zeile fassen, nicht nur in
|
||
// der Kopfzeile (Eriks Wunsch). click/dblclick werden abgefangen, damit ein
|
||
// Klick auf die Grenze nicht zusätzlich die Aufgabe der Zeile öffnet.
|
||
// Parameter: root (Element mit dem Diagramm). Keine Rückgabe.
|
||
function ganttGriffeEinsetzen(root){
|
||
if(!root) return;
|
||
root.querySelectorAll('.gantt-chart .gantt-label').forEach(label => {
|
||
if(label.querySelector(':scope > .gantt-spalten-griff')) return;
|
||
const g = document.createElement('span');
|
||
g.className = 'gantt-spalten-griff';
|
||
g.setAttribute('role', 'separator');
|
||
g.setAttribute('aria-orientation', 'vertical');
|
||
g.title = 'Spaltenbreite ziehen, Doppelklick passt sie an';
|
||
g.addEventListener('pointerdown', ganttSpalteZiehen);
|
||
g.addEventListener('mousedown', e => e.stopPropagation());
|
||
g.addEventListener('click', e => { e.stopPropagation(); e.preventDefault(); });
|
||
g.addEventListener('dblclick', e => { e.stopPropagation(); e.preventDefault(); ganttSpalteAnpassen(); });
|
||
label.appendChild(g);
|
||
});
|
||
}
|
||
|
||
// ganttPfeileNachziehen: zeichnet die Abhängigkeitspfeile neu, höchstens
|
||
// einmal je Bild. Die Pfeile sind ein SVG mit festen Pixelkoordinaten; ohne
|
||
// das blieben sie beim Ziehen der Spalte an der alten Stelle stehen, während
|
||
// die Balken mitwandern.
|
||
let ganttPfeileGeplant = false;
|
||
function ganttPfeileNachziehen(){
|
||
if(ganttPfeileGeplant || typeof renderGanttDependencies !== 'function') return;
|
||
ganttPfeileGeplant = true;
|
||
requestAnimationFrame(() => { ganttPfeileGeplant = false; renderGanttDependencies(); });
|
||
}
|
||
|
||
// ganttNeuZeichnen: nach dem Ändern einmal neu aufbauen — die Mindestbreite
|
||
// des Diagramms bei hohem Zoom hängt von der Spaltenbreite ab.
|
||
function ganttNeuZeichnen(){
|
||
if(typeof renderPlanGantt === 'function' && typeof currentTab !== 'undefined' && currentTab === 'plan') renderPlanGantt();
|
||
}
|
||
|
||
// ganttSpalteZiehen: Ziehen am Griff (Maus, Stift, Touch). Parameter: e
|
||
// (pointerdown-Ereignis). Keine Rückgabe.
|
||
function ganttSpalteZiehen(e){
|
||
if(e.button !== undefined && e.button !== 0) return;
|
||
e.preventDefault(); e.stopPropagation();
|
||
const griff = e.currentTarget;
|
||
const startX = e.clientX, startW = ganttSpaltenBreite();
|
||
griff.setPointerCapture && griff.setPointerCapture(e.pointerId);
|
||
document.body.classList.add('gantt-spalte-zieht');
|
||
let w = startW;
|
||
const bewegen = ev => { w = startW + (ev.clientX - startX); ganttSpalteSetzen(w, false); ganttPfeileNachziehen(); };
|
||
const ende = () => {
|
||
griff.removeEventListener('pointermove', bewegen);
|
||
griff.removeEventListener('pointerup', ende);
|
||
griff.removeEventListener('pointercancel', ende);
|
||
document.body.classList.remove('gantt-spalte-zieht');
|
||
ganttSpalteSetzen(w, true);
|
||
ganttNeuZeichnen();
|
||
};
|
||
griff.addEventListener('pointermove', bewegen);
|
||
griff.addEventListener('pointerup', ende);
|
||
griff.addEventListener('pointercancel', ende);
|
||
}
|
||
|
||
// ganttSpalteAnpassen: Doppelklick auf den Griff — Breite an den längsten
|
||
// Eintrag anpassen (wie der Doppelklick auf die Spaltengrenze in Excel).
|
||
function ganttSpalteAnpassen(){
|
||
let max = 0;
|
||
document.querySelectorAll('.gantt-chart .gantt-label').forEach(el => {
|
||
// scrollWidth = volle Inhaltsbreite samt Innenabstand, auch wenn der
|
||
// Text gerade mit "…" abgeschnitten ist.
|
||
max = Math.max(max, el.scrollWidth);
|
||
});
|
||
if(!max) return;
|
||
ganttSpalteSetzen(max + 6, true);
|
||
ganttNeuZeichnen();
|
||
}
|