// Projektplan-Seite: TODOs und Meilensteine direkt im GANTT-Diagramm. // Daten werden über denselben Server wie das Hauptfenster geladen. (function(){ const params = new URLSearchParams(location.search); const department = params.get('department') || 'ELT'; const weekLabel = params.get('weekLabel'); const projNr = params.get('projNr') || ''; const projName = params.get('projName') || ''; let planWeek = null; let planProject = null; // px pro Tag für die GANTT-Zoomstufe (null = an Containerbreite anpassen) let planGanttPxPerDay = 14; let criticalPathActive = false; function init(){ loadTheme(); // Theme aus localStorage / System übernehmen if(!weekLabel || !projName){ document.getElementById('planTitle').textContent = 'Fehler'; document.getElementById('planSubtitle').textContent = 'Kein Projekt ausgewählt.'; return; } currentDepartment = department; // global aus main.v2.js bindToolbarButtons(); loadPlanData(); } function bindToolbarButtons(){ const addTodoBtn = document.getElementById('addTodoBtn'); if(addTodoBtn) addTodoBtn.addEventListener('click', () => addTodo()); const autoScheduleBtn = document.getElementById('autoScheduleBtn'); if(autoScheduleBtn) autoScheduleBtn.addEventListener('click', () => autoScheduleTodos()); const criticalPathBtn = document.getElementById('criticalPathBtn'); if(criticalPathBtn) criticalPathBtn.addEventListener('click', () => toggleCriticalPath()); } function loadPlanData(){ if(!window.fetch){ document.getElementById('planSubtitle').textContent = 'Netzwerk nicht verfügbar.'; return; } const url = baseUrl() + '/load-all?department=' + encodeURIComponent(department); fetch(url) .then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }) .then(data => { db = { weeks: [], currentWeek: null, globalPeople: {} }; db.globalPeople[department] = parseGlobalPeopleCsv(data.people); const parsed = (data.sessions || []).map(s => ({ filename: s.filename, rows: parseCsvText(s.content) })); parsed.forEach(s => { const w = parseSessionCsv(s.filename, s.rows); if(w) db.weeks.push(w); }); db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))); planWeek = db.weeks.find(w => w.label === weekLabel); if(!planWeek){ document.getElementById('planTitle').textContent = 'Nicht gefunden'; document.getElementById('planSubtitle').textContent = 'Sitzung "' + esc(weekLabel) + '" konnte nicht geladen werden.'; return; } planProject = planWeek.projects.find(p => (p.nr || '') === projNr && (p.name || '') === projName); if(!planProject){ document.getElementById('planTitle').textContent = 'Nicht gefunden'; document.getElementById('planSubtitle').textContent = 'Projekt nicht in dieser Sitzung vorhanden.'; return; } renderPlanPage(); }) .catch(err => { document.getElementById('planTitle').textContent = 'Fehler beim Laden'; document.getElementById('planSubtitle').textContent = err.message; }); } function ensurePlanLocal(){ if(!planProject.plan) planProject.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 }; if(!Array.isArray(planProject.plan.todos)) planProject.plan.todos = []; // Migration alter Einzel-Abhängigkeiten zu Array planProject.plan.todos.forEach(t => { if(!Array.isArray(t.dependsOn)){ t.dependsOn = t.dependsOn ? [t.dependsOn] : []; } if(!Array.isArray(t.assignees)) t.assignees = []; if(t.progress === undefined) t.progress = t.done ? 100 : 0; }); return planProject.plan; } function isLocked(){ return !!planWeek.locked; } function renderPlanPage(){ const p = planProject; const plan = ensurePlanLocal(); document.getElementById('planTitle').textContent = (p.nr ? p.nr + ' – ' : '') + p.name; document.getElementById('planSubtitle').textContent = planWeek.label + (planWeek.date ? ' · ' + formatWeekDate(planWeek.date) : ''); // GANTT inkl. TODOs const addMilestoneBtn = document.getElementById('addMilestoneBtn'); const locked = isLocked(); if(addMilestoneBtn) addMilestoneBtn.style.display = locked ? 'none' : ''; fitPlanGanttRange(); } // ---------- Drag-and-Drop für TODO- und Meilenstein-Zeilen direkt im GANTT ---------- let ganttTodoDrag = null; const DRAGGABLE_GANTT_ROW_SELECTOR = '.gantt-row.gantt-todo, .gantt-row.gantt-milestone-row'; function initGanttTodoDrag(){ const container = document.getElementById('planGanttContainer'); if(!container) return; container.addEventListener('mousedown', onGanttTodoMouseDown); document.addEventListener('mousemove', onGanttTodoMouseMove); document.addEventListener('mouseup', onGanttTodoMouseUp); } function onGanttTodoMouseDown(e){ if(isLocked()) return; const handle = e.target.closest('.gantt-todo-drag'); if(!handle) return; const row = handle.closest(DRAGGABLE_GANTT_ROW_SELECTOR); if(!row) return; e.preventDefault(); e.stopPropagation(); ganttTodoDrag = { row, startY: e.clientY, moved: false }; row.classList.add('dragging'); } function onGanttTodoMouseMove(e){ if(!ganttTodoDrag) return; const chart = ganttTodoDrag.row.closest('.gantt-chart'); if(!chart) return; const dy = e.clientY - ganttTodoDrag.startY; if(Math.abs(dy) > 3) ganttTodoDrag.moved = true; if(!ganttTodoDrag.moved) return; const afterElement = getGanttRowDragAfterElement(chart, e.clientY); if(ganttTodoDrag.row && afterElement == null){ chart.appendChild(ganttTodoDrag.row); } else if(ganttTodoDrag.row && afterElement && afterElement !== ganttTodoDrag.row){ chart.insertBefore(ganttTodoDrag.row, afterElement); } } function onGanttTodoMouseUp(e){ if(!ganttTodoDrag) return; const drag = ganttTodoDrag; ganttTodoDrag = null; drag.row.classList.remove('dragging'); if(drag.moved){ reorderPlanRows(); } } function getGanttRowDragAfterElement(chart, y){ const draggableElements = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR + ':not(.dragging)')]; return draggableElements.reduce((closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if(offset < 0 && offset > closest.offset){ return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY }).element; } function reorderPlanRows(){ if(isLocked()) return; const chart = document.querySelector('#planGanttContainer .gantt-chart'); if(!chart) return; const plan = ensurePlanLocal(); const todos = Array.isArray(plan.todos) ? plan.todos : []; const milestones = Array.isArray(plan.milestones) ? plan.milestones : []; const todoIds = todos.map(t => t.id); const milestoneIds = milestones.map(m => m.id); // Alle beweglichen Zeilen in aktueller DOM-Reihenfolge const rows = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR)]; const todoRowIds = rows.filter(r => r.classList.contains('gantt-todo')).map(r => r.dataset.todoId).filter(Boolean); const milestoneRowIds = rows.filter(r => r.classList.contains('gantt-milestone-row')).map(r => r.dataset.milestoneId).filter(Boolean); // Sicherheitscheck: Anzahl muss stimmen if(todoRowIds.length !== todoIds.length) return; if(milestoneRowIds.length !== milestoneIds.length) return; // TODOs neu ordnen const newTodos = []; todoRowIds.forEach(id => { const t = todos.find(x => x.id === id); if(t) newTodos.push(t); }); plan.todos = newTodos; // Meilenstein-Positionen neu berechnen // Regeln: // - Steht der Meilenstein direkt vor einer TODO -> before:todoId // - Steht er direkt nach einer TODO -> after:todoId // - Steht er ganz am Anfang (vor erster TODO) oder es gibt keine TODO -> '' (oben in Projektzeile) // - Steht er ganz am Ende (nach letzter TODO) -> after:lastTodoId rows.forEach((row, idx) => { if(!row.classList.contains('gantt-milestone-row')) return; const msId = row.dataset.milestoneId; const ms = milestones.find(m => m.id === msId); if(!ms) return; let position = ''; const prevRow = rows[idx - 1]; const nextRow = rows[idx + 1]; if(prevRow && prevRow.classList.contains('gantt-todo')){ position = 'after:' + prevRow.dataset.todoId; } else if(nextRow && nextRow.classList.contains('gantt-todo')){ position = 'before:' + nextRow.dataset.todoId; } else if(prevRow && prevRow.classList.contains('gantt-milestone-row')){ // Zwei Meilensteine hintereinander: beziehe dich auf die nächste TODO danach oder letzte davor // Suche nächste TODO nach diesem Block let nextTodoId = null; for(let i = idx + 1; i < rows.length; i++){ if(rows[i].classList.contains('gantt-todo')){ nextTodoId = rows[i].dataset.todoId; break; } } if(nextTodoId){ position = 'before:' + nextTodoId; } else { // Keine TODO danach -> letzte TODO davor suchen let prevTodoId = null; for(let i = idx - 1; i >= 0; i--){ if(rows[i].classList.contains('gantt-todo')){ prevTodoId = rows[i].dataset.todoId; break; } } if(prevTodoId) position = 'after:' + prevTodoId; } } ms.position = position; }); savePlan(); renderPlanGantt(); } function renderProjectAssignees(){ const assignees = projectAssignees(planProject, planWeek); if(assignees.length === 0) return 'Keine Zuordnungen im Projektboard.'; return assignees.map(a => { const cls = a.state === 1 ? 'main' : a.state === 2 ? 'optional' : ''; return `${esc(a.person.name)}${a.role ? ' (' + esc(a.role) + ')' : ''}`; }).join(''); } // --------- Mail-Link für alle zugeordneten Personen ---------- window.todoMail = function() { if (!planProject || !planWeek) { console.warn('Kein aktuelles Projekt/keine aktuelle Woche gefunden – Mail kann nicht erstellt werden.'); return; } const assignees = projectAssignees(planProject, planWeek); const empfaengerListe = assignees .map(a => a.email || (a.person && a.person.email) || '') .filter(email => email && email.trim() !== ''); const eindeutigeEmpfaenger = [...new Set(empfaengerListe)]; if (eindeutigeEmpfaenger.length === 0) { console.warn('Keine gültigen E-Mail-Adressen der zugeordneten Personen gefunden.'); return; } const empfaenger = eindeutigeEmpfaenger.join(','); const titleEl = document.getElementById('planTitle'); const projektname = titleEl ? titleEl.textContent.trim() : 'Projekt'; const seitenLink = window.location.href; const betreff = 'Neue Aufgaben im Projekt ' + projektname; const nachricht = 'Guten Tag,\n\n' + 'es wurden neue Aufgaben im Projekt ' + projektname + ' hinzugefügt.\n\n' + 'Link zum Projekt:\n' + seitenLink + '\n\n' + 'Mit freundlichen Grüßen'; const mailtoLink = 'mailto:' + encodeURIComponent(empfaenger).replace(/%2C/g, ',') + '?subject=' + encodeURIComponent(betreff) + '&body=' + encodeURIComponent(nachricht); if (mailtoLink.length > 1800) { console.warn('mailto-Link ist sehr lang (' + mailtoLink.length + ' Zeichen) – manche Mail-Clients könnten ihn abschneiden.'); } window.location.href = mailtoLink; }; // ---------- Meilenstein-Modal ---------- let currentMilestoneIndex = null; window.openMilestoneModal = function(index){ if(isLocked()) return; const plan = ensurePlanLocal(); if(!Array.isArray(plan.milestones)) plan.milestones = []; // Migration alter Meilensteine plan.milestones.forEach(m => { if(!Array.isArray(m.dependsOn)) m.dependsOn = []; }); currentMilestoneIndex = index; const isNew = index === null || plan.milestones[index] === undefined; const currentMs = isNew ? null : plan.milestones[index]; document.getElementById('milestoneEditName').value = isNew ? '' : (currentMs.name || ''); document.getElementById('milestoneEditDate').value = isNew ? '' : (currentMs.date || ''); const positionSelect = document.getElementById('milestoneEditPosition'); if(positionSelect){ const currentPosition = currentMs && currentMs.position ? currentMs.position : ''; const defaultAfterTodo = isNew && plan.todos && plan.todos.length > 0 ? plan.todos[plan.todos.length - 1].id : null; let opts = ''; if(plan.todos && plan.todos.length > 0){ plan.todos.forEach(t => { const beforeVal = 'before:' + t.id; const afterVal = 'after:' + t.id; const beforeSel = currentPosition === beforeVal ? 'selected' : ''; let afterSel = currentPosition === afterVal ? 'selected' : ''; if(isNew && defaultAfterTodo === t.id && !currentPosition) afterSel = 'selected'; opts += ``; opts += ``; }); } positionSelect.innerHTML = opts; } const dependsOnList = document.getElementById('milestoneEditDependsOn'); if(dependsOnList){ const currentDeps = currentMs && Array.isArray(currentMs.dependsOn) ? currentMs.dependsOn : []; let listHtml = ''; // Andere Meilensteine als Vorgänger anbieten if(plan.milestones.length > 1){ listHtml += '
Meilensteine
'; plan.milestones.forEach(m => { if(currentMs && m.id === currentMs.id) return; const mId = 'm_' + m.id; const checked = currentDeps.includes(mId) ? 'checked' : ''; listHtml += ``; }); } // TODOs als Vorgänger anbieten if(plan.todos && plan.todos.length > 0){ listHtml += '
Aufgaben
'; (plan.todos || []).forEach(t => { const checked = currentDeps.includes(t.id) ? 'checked' : ''; const datesStr = (t.start || t.end) ? ` (${formatDateDE(parseDateISO(t.start))}–${formatDateDE(parseDateISO(t.end))})` : ''; listHtml += ``; }); } if(listHtml === '') listHtml = 'Keine anderen Aufgaben oder Meilensteine vorhanden.'; dependsOnList.innerHTML = listHtml; } document.getElementById('milestoneModal').style.display = 'flex'; document.getElementById('milestoneEditName').focus(); } window.closeMilestoneModal = function(){ document.getElementById('milestoneModal').style.display = 'none'; currentMilestoneIndex = null; }; window.saveMilestoneEdit = function(){ if(isLocked()) return; const name = document.getElementById('milestoneEditName').value.trim(); const date = document.getElementById('milestoneEditDate').value; if(!name || !date){ alert('Bitte Name und Datum angeben.'); return; } const dependsOnList = document.getElementById('milestoneEditDependsOn'); const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : []; const positionSelect = document.getElementById('milestoneEditPosition'); const position = positionSelect ? positionSelect.value : ''; const plan = ensurePlanLocal(); if(!Array.isArray(plan.milestones)) plan.milestones = []; if(currentMilestoneIndex === null){ plan.milestones.push({ id: uid(), name, date, dependsOn, position }); } else if(plan.milestones[currentMilestoneIndex]){ const m = plan.milestones[currentMilestoneIndex]; m.name = name; m.date = date; m.dependsOn = dependsOn; m.position = position; } plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date)); savePlan(); closeMilestoneModal(); renderPlanGantt(); }; // Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine) function removeDependencyRefs(plan, deletedId){ (plan.todos || []).forEach(t => { if(Array.isArray(t.dependsOn)) t.dependsOn = t.dependsOn.filter(id => id !== deletedId); }); (plan.milestones || []).forEach(m => { if(Array.isArray(m.dependsOn)) m.dependsOn = m.dependsOn.filter(id => id !== deletedId); }); } window.deleteMilestoneEdit = function(){ if(isLocked()) return; if(currentMilestoneIndex === null){ closeMilestoneModal(); return; } if(!confirm('Meilenstein löschen?')) return; const plan = ensurePlanLocal(); if(plan.milestones && plan.milestones[currentMilestoneIndex]){ const deletedId = 'm_' + plan.milestones[currentMilestoneIndex].id; plan.milestones.splice(currentMilestoneIndex, 1); removeDependencyRefs(plan, deletedId); savePlan(); } closeMilestoneModal(); renderPlanGantt(); }; window.addPlanMilestone = function(){ openMilestoneModal(null); }; // ---------- TODO-Modal ---------- let currentTodoIndex = null; window.openTodoModal = function(index){ if(isLocked()) return; const plan = ensurePlanLocal(); if(!Array.isArray(plan.todos)) plan.todos = []; currentTodoIndex = index; const isNew = index === null || plan.todos[index] === undefined; const currentTodo = !isNew ? plan.todos[index] : null; document.getElementById('todoEditTitle').value = isNew ? '' : (currentTodo.title || ''); document.getElementById('todoEditStart').value = isNew ? '' : (currentTodo.start || ''); document.getElementById('todoEditEnd').value = isNew ? '' : (currentTodo.end || ''); document.getElementById('todoEditDone').checked = isNew ? false : !!currentTodo.done; const progress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0); document.getElementById('todoEditProgress').value = progress; document.getElementById('todoEditProgressVal').textContent = progress + '%'; const dependsOnList = document.getElementById('todoEditDependsOn'); if(dependsOnList){ const currentDeps = currentTodo && Array.isArray(currentTodo.dependsOn) ? currentTodo.dependsOn : []; let listHtml = ''; // Meilensteine als Vorgänger anbieten if(Array.isArray(plan.milestones) && plan.milestones.length > 0){ listHtml += '
Meilensteine
'; plan.milestones.forEach(m => { const mId = 'm_' + m.id; const checked = currentDeps.includes(mId) ? 'checked' : ''; listHtml += ``; }); } // Andere TODOs als Vorgänger anbieten if(plan.todos && plan.todos.length > 1){ listHtml += '
Aufgaben
'; (plan.todos || []).forEach(t => { if(currentTodo && t.id === currentTodo.id) return; const checked = currentDeps.includes(t.id) ? 'checked' : ''; const datesStr = (t.start || t.end) ? ` (${formatDateDE(parseDateISO(t.start))}–${formatDateDE(parseDateISO(t.end))})` : ''; listHtml += ``; }); } if(listHtml === '') listHtml = 'Keine anderen Aufgaben oder Meilensteine vorhanden.'; dependsOnList.innerHTML = listHtml; } const assigneesDiv = document.getElementById('todoEditAssignees'); if(assigneesDiv){ const assignees = projectAssignees(planProject, planWeek); const currentAssignees = currentTodo && Array.isArray(currentTodo.assignees) ? currentTodo.assignees : []; const seenPeople = new Set(); const uniquePeople = []; assignees.forEach(a => { if(!seenPeople.has(a.person.id)){ seenPeople.add(a.person.id); uniquePeople.push(a.person); } }); assigneesDiv.innerHTML = uniquePeople.map(person => { const checked = currentAssignees.includes(person.id) ? 'checked' : ''; return ``; }).join('') || 'Keine Projektzuordnungen im Board.'; } document.getElementById('todoModal').style.display = 'flex'; document.getElementById('todoEditTitle').focus(); }; window.closeTodoModal = function(){ document.getElementById('todoModal').style.display = 'none'; currentTodoIndex = null; }; window.saveTodoEdit = function(){ if(isLocked()) return; const title = document.getElementById('todoEditTitle').value.trim(); let start = document.getElementById('todoEditStart').value; let end = document.getElementById('todoEditEnd').value; // Nur ein Datum angegeben: als eintägige Aufgabe behandeln, // sonst würde kein Balken im GANTT erscheinen. if(start && !end) end = start; if(end && !start) start = end; const done = document.getElementById('todoEditDone').checked; let progress = parseInt(document.getElementById('todoEditProgress').value, 10) || 0; // "Erledigt" impliziert 100 % Fortschritt if(done && progress < 100) progress = 100; const dependsOnList = document.getElementById('todoEditDependsOn'); const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : []; const assigneesDiv = document.getElementById('todoEditAssignees'); const assignees = assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('input:checked')).map(cb => cb.value) : []; if(!title){ alert('Bitte einen Titel eingeben.'); return; } if(start && end && parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; } const plan = ensurePlanLocal(); if(!Array.isArray(plan.todos)) plan.todos = []; if(currentTodoIndex === null){ plan.todos.push({ id: uid(), title, start, end, done, progress, dependsOn, assignees }); } else if(plan.todos[currentTodoIndex]){ const t = plan.todos[currentTodoIndex]; t.title = title; t.start = start; t.end = end; t.done = done; t.progress = progress; t.dependsOn = dependsOn; t.assignees = assignees; } savePlan(); closeTodoModal(); renderPlanGantt(); }; window.deleteTodoEdit = function(){ if(isLocked()) return; if(currentTodoIndex === null){ closeTodoModal(); return; } if(!confirm('TODO löschen?')) return; const plan = ensurePlanLocal(); if(plan.todos && plan.todos[currentTodoIndex]){ const deletedId = plan.todos[currentTodoIndex].id; plan.todos.splice(currentTodoIndex, 1); removeDependencyRefs(plan, deletedId); // Meilensteine, die relativ zu dieser TODO positioniert waren, zurücksetzen (plan.milestones || []).forEach(m => { if(m.position === 'before:' + deletedId || m.position === 'after:' + deletedId) m.position = ''; }); savePlan(); } closeTodoModal(); renderPlanGantt(); }; window.addTodoFromList = function(){ if(isLocked()) return; const title = document.getElementById('newTodoTitle').value.trim(); let start = document.getElementById('newTodoStart').value; let end = document.getElementById('newTodoEnd').value; if(start && !end) end = start; if(end && !start) start = end; if(!title){ alert('Bitte einen Titel eingeben.'); return; } if(start && end && parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; } const plan = ensurePlanLocal(); plan.todos.push({ id: uid(), title, start, end, done: false, progress: 0, dependsOn: [], assignees: [] }); document.getElementById('newTodoTitle').value = ''; document.getElementById('newTodoStart').value = ''; document.getElementById('newTodoEnd').value = ''; savePlan(); renderPlanGantt(); }; window.addTodo = function(){ openTodoModal(null); }; function saveGlobalPeople(){ const people = db.globalPeople[department] || []; if(people.length > 0){ const peopleCsv = globalPeopleToCsv(people); sendCsvToServer('_people.csv', peopleCsv).catch(err => console.error('Speichern Personen fehlgeschlagen:', err)); } } window.updateTodo = function(idx, field, value){ if(isLocked()) return; const plan = ensurePlanLocal(); if(!plan.todos[idx]) return; plan.todos[idx][field] = value; savePlan(); renderPlanGantt(); }; window.toggleTodoDone = function(idx){ if(isLocked()) return; const plan = ensurePlanLocal(); if(!plan.todos[idx]) return; plan.todos[idx].done = !plan.todos[idx].done; savePlan(); renderPlanGantt(); }; window.deleteTodo = function(idx){ if(isLocked()) return; if(!confirm('TODO löschen?')) return; const plan = ensurePlanLocal(); if(!plan.todos[idx]) return; const deletedId = plan.todos[idx].id; plan.todos.splice(idx, 1); removeDependencyRefs(plan, deletedId); (plan.milestones || []).forEach(m => { if(m.position === 'before:' + deletedId || m.position === 'after:' + deletedId) m.position = ''; }); savePlan(); renderPlanGantt(); }; function savePlan(){ if(!planWeek || !planProject) return; // Speichere nur diese Sitzung; globale Personenliste nicht überschreiben const filename = filenameForWeek(planWeek.label); const csv = weekToCsv(planWeek); sendCsvToServer(filename, csv).catch(err => console.error('Speichern fehlgeschlagen:', err)); } window.renderPlanGantt = function(){ const container = document.getElementById('planGanttContainer'); if(!planProject || !container) return; const prevScroll = container.scrollLeft; let start = parseDateISO(document.getElementById('planGanttStart').value); let end = parseDateISO(document.getElementById('planGanttEnd').value); if(!start || !end){ const range = ganttRangeFromProjects([planProject], 7); start = range.start; end = range.end; document.getElementById('planGanttStart').value = formatDateISO(start); document.getElementById('planGanttEnd').value = formatDateISO(end); } const showVacation = document.getElementById('planShowVacation').checked; const vacationPeople = projectAssignees(planProject, planWeek).map(a => a.person.id); const criticalPath = criticalPathActive ? computeCriticalPath(planProject) : []; container.innerHTML = buildGanttHtml([planProject], start, end, { showVacation, editable: !isLocked(), highlightProjectId: planProject.id, showTodos: true, vacationPeople, pxPerDay: planGanttPxPerDay, criticalPath }); container.scrollLeft = prevScroll; requestAnimationFrame(() => renderGanttDependencies(container)); renderResourceLoad(); }; window.renderGanttDependencies = function(container){ const c = container || document.getElementById('planGanttContainer'); if(!c) return; const chart = c.querySelector('.gantt-chart'); if(!chart) return; let svg = chart.querySelector('.gantt-dependencies-svg'); if(svg) svg.remove(); const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]')); if(nodeBars.length === 0) return; const chartRect = chart.getBoundingClientRect(); const barMap = new Map(); nodeBars.forEach(b => { if(b.dataset.todoId) barMap.set(b.dataset.todoId, b); }); const paths = []; nodeBars.forEach(targetBar => { const dependsOnStr = targetBar.dataset.todoDepends; if(!dependsOnStr) return; const depIds = dependsOnStr.split(',').map(s => s.trim()).filter(Boolean); depIds.forEach(depId => { const sourceBar = barMap.get(depId); if(!sourceBar) return; const sourceRect = sourceBar.getBoundingClientRect(); const targetRect = targetBar.getBoundingClientRect(); const x1 = sourceRect.right - chartRect.left; const y1 = sourceRect.top + sourceRect.height / 2 - chartRect.top; const x2 = targetRect.left - chartRect.left; const y2 = targetRect.top + targetRect.height / 2 - chartRect.top; const isConflict = x2 < x1; const isDone = targetBar.classList.contains('done') && sourceBar.classList.contains('done'); let d = ''; if(x2 >= x1 + 10){ const midX = x1 + (x2 - x1) / 2; d = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`; } else { const r = 16; d = `M ${x1} ${y1} C ${x1 + r} ${y1}, ${x1 + r} ${(y1 + y2)/2}, ${(x1 + x2)/2} ${(y1 + y2)/2} C ${x2 - r} ${(y1 + y2)/2}, ${x2 - r} ${y2}, ${x2} ${y2}`; } let pathClass = 'gantt-dep-path'; let markerId = 'gantt-arrow-normal'; if(isConflict){ pathClass += ' conflict'; markerId = 'gantt-arrow-conflict'; } else if(isDone){ pathClass += ' done'; markerId = 'gantt-arrow-done'; } paths.push(``); }); }); if(paths.length === 0) return; svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'gantt-dependencies-svg'); svg.innerHTML = ` ${paths.join('')} `; chart.appendChild(svg); }; window.fitPlanGanttRange = function(){ const range = ganttRangeFromProjects([planProject], 7); document.getElementById('planGanttStart').value = formatDateISO(range.start); document.getElementById('planGanttEnd').value = formatDateISO(range.end); renderPlanGantt(); }; // zoomPlanGantt: ändert die Pixel-pro-Tag-Stufe des Diagramms. // Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit). window.zoomPlanGantt = function(dir){ const container = document.getElementById('planGanttContainer'); let start = parseDateISO(document.getElementById('planGanttStart').value); let end = parseDateISO(document.getElementById('planGanttEnd').value); if(!start || !end) return; const totalDays = Math.max(1, daysBetween(start, end) + 1); const cw = container ? Math.max(200, container.clientWidth - 250) : 900; const fitPx = Math.max(3, cw / totalDays); const base = planGanttPxPerDay || fitPx; let next; if(dir > 0){ next = Math.min(120, Math.round(base * 1.35)); } else { next = Math.round(base / 1.35); if(next <= fitPx * 0.7) next = null; } planGanttPxPerDay = next; renderPlanGantt(); }; // buildGanttHtml in main.v2.js erweitern, damit TODOs als eigene Balken dargestellt // werden und neue TODOs direkt unter der Projektzeile angelegt werden können. const originalBuildGanttHtml = window.buildGanttHtml; window.buildGanttHtml = function(projects, start, end, options = {}){ if(!options.showTodos) return originalBuildGanttHtml(projects, start, end, options); const baseHtml = originalBuildGanttHtml(projects, start, end, options); const temp = document.createElement('div'); temp.innerHTML = baseHtml; const chart = temp.querySelector('.gantt-chart'); if(!chart) return baseHtml; const totalDays = Math.max(1, daysBetween(start, end) + 1); const editable = options.editable; projects.forEach(p => { const plan = p.plan || {}; const todos = Array.isArray(plan.todos) ? plan.todos : []; const milestones = Array.isArray(plan.milestones) ? plan.milestones : []; // Projektzeile finden, unter der TODOs und Meilensteine eingehängt werden const projectRow = chart.querySelector('.gantt-row.gantt-project.highlighted') || Array.from(chart.querySelectorAll('.gantt-row.gantt-project')).find(r => { const label = r.querySelector('.gantt-label'); return label && label.textContent.includes(p.name); }); function buildTodoRow(todo, tIdx){ if(!todo.id) todo.id = uid(); const s = parseDateISO(todo.start), e = parseDateISO(todo.end); const row = document.createElement('div'); row.className = 'gantt-row gantt-todo' + (todo.done ? ' done' : ''); row.dataset.todoId = todo.id; row.dataset.todoIndex = tIdx; let barHtml = ''; 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 dragAttrs = editable ? ` data-gantt-type="todo" data-gantt-project="${esc(p.id)}" data-gantt-index="${tIdx}" data-gantt-start="${formatDateISO(s)}" data-gantt-end="${formatDateISO(e)}" draggable="false"` : ''; const handles = editable ? `` : ''; const isCritical = options.criticalPath && options.criticalPath.includes(todo.id); const progress = Math.max(0, Math.min(100, parseInt(todo.progress, 10) || 0)); barHtml = `
${handles}
`; } } let depBadge = ''; if(Array.isArray(todo.dependsOn) && todo.dependsOn.length > 0){ // Vorgänger können TODOs oder Meilensteine (Prefix "m_") sein const preds = todo.dependsOn.map(id => { const t = todos.find(x => x.id === id); if(t) return { title: t.title || 'TODO', end: t.end }; const m = milestones.find(x => 'm_' + x.id === id); if(m) return { title: '🚩 ' + (m.name || 'Meilenstein'), end: m.date }; return null; }).filter(Boolean); if(preds.length > 0){ const isConflict = preds.some(pred => pred.end && todo.start && parseDateISO(todo.start) <= parseDateISO(pred.end)); const names = preds.map(pr => pr.title).join(', '); depBadge = `↳ ${preds.length === 1 ? esc(preds[0].title) : esc(preds.length + ' Vorgänger')}`; } } row.innerHTML = `
${editable ? '⋮⋮' : ''} ${esc(todo.title || 'TODO')}${depBadge}
${barHtml}
`; return row; } function buildMilestoneRow(m, mIdx){ const d = parseDateISO(m.date); const row = document.createElement('div'); row.className = 'gantt-row gantt-milestone-row' + (m.position ? ' positioned' : ''); row.dataset.milestoneId = m.id; row.dataset.milestoneIndex = mIdx; let diamond = '', labelStyle = 'display:none'; if(d){ if(d >= start && d <= end){ const offset = daysBetween(start, d); const left = (offset / totalDays * 100).toFixed(4) + '%'; const dragAttrs = editable ? ` data-gantt-type="milestone" data-gantt-project="${esc(p.id)}" data-gantt-index="${mIdx}" data-gantt-date="${formatDateISO(d)}" draggable="false"` : ''; const deps = esc((m.dependsOn || []).join(',')); diamond = `
`; labelStyle = 'left:' + left; } } row.innerHTML = `
${editable ? '⋮⋮' : ''} ${esc(m.name || 'Meilenstein')}
${diamond}
${esc(m.name || '')}
`; return row; } if(!projectRow) return; let insertAfter = projectRow; const insertedMilestoneIds = new Set(); todos.forEach((todo, tIdx) => { // Meilensteine, die VOR dieser Aufgabe positioniert sind milestones.filter(m => m.position === 'before:' + todo.id).forEach(m => { const mIdx = milestones.indexOf(m); const row = buildMilestoneRow(m, mIdx); insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); insertAfter = row; }); // Aufgabenzeile const todoRow = buildTodoRow(todo, tIdx); insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling); insertAfter = todoRow; // Meilensteine, die NACH dieser Aufgabe positioniert sind milestones.filter(m => m.position === 'after:' + todo.id).forEach(m => { const mIdx = milestones.indexOf(m); const row = buildMilestoneRow(m, mIdx); insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); insertAfter = row; insertedMilestoneIds.add(m.id); }); }); // Positionierte Meilensteine, deren referenzierte Aufgabe nicht mehr existiert, // am Ende der Aufgabenliste anhängen, damit sie nicht verloren gehen. milestones.filter(m => m.position && !insertedMilestoneIds.has(m.id)).forEach(m => { const mIdx = milestones.indexOf(m); const row = buildMilestoneRow(m, mIdx); insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling); insertAfter = row; }); }); return temp.innerHTML; }; // ---------- GANTT Drag-Editing (Balken, Meilensteine, Urlaub) ---------- let ganttDrag = null; function initGanttDrag(){ const container = document.getElementById('planGanttContainer'); if(!container) return; container.addEventListener('mousedown', onGanttMouseDown); container.addEventListener('dblclick', onGanttDoubleClick); document.addEventListener('mousemove', onGanttMouseMove); document.addEventListener('mouseup', onGanttMouseUp); // Klick ohne Bewegung wird bereits in onGanttMouseUp behandelt (Modal öffnen). // Ein zusätzlicher click-Handler würde das Modal doppelt bzw. nach einem // Drag ungewollt öffnen. } function onGanttMouseDown(e){ if(isLocked()) return; const bar = e.target.closest('[data-gantt-type]'); if(!bar) return; const timeline = bar.closest('.gantt-timeline'); if(!timeline) return; const rangeStart = parseDateISO(document.getElementById('planGanttStart').value); const rangeEnd = parseDateISO(document.getElementById('planGanttEnd').value); if(!rangeStart || !rangeEnd) return; const resize = e.target.closest('.gantt-resize'); const rect = timeline.getBoundingClientRect(); ganttDrag = { bar, timeline, type: bar.dataset.ganttType, action: resize ? resize.dataset.resize : 'move', startX: e.clientX, startY: e.clientY, timelineWidth: rect.width, rangeStart, rangeEnd, moved: false, originalStart: parseDateISO(bar.dataset.ganttStart || bar.dataset.ganttDate), originalEnd: parseDateISO(bar.dataset.ganttEnd), personId: bar.dataset.ganttPerson, absenceId: bar.dataset.ganttAbsence, projectId: bar.dataset.ganttProject, milestoneIndex: bar.dataset.ganttType === 'milestone' && bar.dataset.ganttIndex !== undefined ? parseInt(bar.dataset.ganttIndex, 10) : null, todoIndex: bar.dataset.ganttType === 'todo' && bar.dataset.ganttIndex !== undefined ? parseInt(bar.dataset.ganttIndex, 10) : null }; e.preventDefault(); e.stopPropagation(); } function onGanttMouseMove(e){ if(!ganttDrag) return; const totalDays = daysBetween(ganttDrag.rangeStart, ganttDrag.rangeEnd) + 1; const pxPerDay = ganttDrag.timelineWidth / totalDays; const dx = e.clientX - ganttDrag.startX; const dayDelta = Math.round(dx / pxPerDay); if(Math.abs(dx) > 2) ganttDrag.moved = true; if(ganttDrag.type === 'milestone'){ const newDate = addDays(ganttDrag.originalStart, dayDelta); const offset = daysBetween(ganttDrag.rangeStart, newDate); const left = (offset / totalDays * 100).toFixed(4) + '%'; ganttDrag.bar.style.left = left; // Zugehöriges Label (nächstes Geschwister-Element) mitbewegen const label = ganttDrag.bar.nextElementSibling; if(label && label.classList.contains('gantt-milestone-label')) label.style.left = left; return; } let newStart, newEnd; if(ganttDrag.action === 'move'){ newStart = addDays(ganttDrag.originalStart, dayDelta); newEnd = addDays(ganttDrag.originalEnd, dayDelta); } else if(ganttDrag.action === 'left'){ newStart = addDays(ganttDrag.originalStart, dayDelta); newEnd = ganttDrag.originalEnd; if(newStart > newEnd) newStart = newEnd; } else if(ganttDrag.action === 'right'){ newStart = ganttDrag.originalStart; newEnd = addDays(ganttDrag.originalEnd, dayDelta); if(newEnd < newStart) newEnd = newStart; } const barStart = newStart < ganttDrag.rangeStart ? ganttDrag.rangeStart : newStart; const barEnd = newEnd > ganttDrag.rangeEnd ? ganttDrag.rangeEnd : newEnd; const offset = daysBetween(ganttDrag.rangeStart, barStart); const span = daysBetween(barStart, barEnd) + 1; ganttDrag.bar.style.left = (offset / totalDays * 100).toFixed(4) + '%'; ganttDrag.bar.style.width = (span / totalDays * 100).toFixed(4) + '%'; } function onGanttMouseUp(e){ if(!ganttDrag) return; const drag = ganttDrag; ganttDrag = null; if(!drag.moved && drag.type === 'milestone'){ openMilestoneModal(drag.milestoneIndex); return; } if(!drag.moved && drag.type === 'todo'){ openTodoModal(drag.todoIndex); return; } const totalDays = daysBetween(drag.rangeStart, drag.rangeEnd) + 1; const pxPerDay = drag.timelineWidth / totalDays; const dx = e.clientX - drag.startX; const dayDelta = Math.round(dx / pxPerDay); if(dayDelta === 0){ renderPlanGantt(); return; } let newStartStr, newEndStr; let needsRefit = false; if(drag.type === 'milestone'){ const newDate = addDays(drag.originalStart, dayDelta); newStartStr = formatDateISO(newDate); updateMilestoneDate(drag.projectId, drag.milestoneIndex, newStartStr); if(newDate < drag.rangeStart || newDate > drag.rangeEnd) needsRefit = true; } else { let newStart, newEnd; if(drag.action === 'move'){ newStart = addDays(drag.originalStart, dayDelta); newEnd = addDays(drag.originalEnd, dayDelta); } else if(drag.action === 'left'){ newStart = addDays(drag.originalStart, dayDelta); newEnd = drag.originalEnd; if(newStart > newEnd) newStart = newEnd; } else { newStart = drag.originalStart; newEnd = addDays(drag.originalEnd, dayDelta); if(newEnd < newStart) newEnd = newStart; } newStartStr = formatDateISO(newStart); newEndStr = formatDateISO(newEnd); if(newStart < drag.rangeStart || newEnd > drag.rangeEnd) needsRefit = true; if(drag.type === 'project'){ updateProjectDates(drag.projectId, newStartStr, newEndStr); } else if(drag.type === 'vacation'){ updateVacationDates(drag.personId, drag.absenceId, newStartStr, newEndStr); } else if(drag.type === 'todo'){ updateTodoDates(drag.todoIndex, newStartStr, newEndStr); } } if(needsRefit) fitPlanGanttRange(); else renderPlanGantt(); } function updateTodoDates(idx, start, end){ const plan = ensurePlanLocal(); if(!plan.todos[idx]) return; plan.todos[idx].start = start; plan.todos[idx].end = end; savePlan(); } function onGanttDoubleClick(e){ if(isLocked()) return; const timeline = e.target.closest('.gantt-timeline'); if(!timeline) return; // Nicht auslösen, wenn auf einen Balken geklickt wurde if(e.target.closest('[data-gantt-type]')) return; const rect = timeline.getBoundingClientRect(); const x = e.clientX - rect.left; const rangeStart = parseDateISO(document.getElementById('planGanttStart').value); const rangeEnd = parseDateISO(document.getElementById('planGanttEnd').value); if(!rangeStart || !rangeEnd) return; const totalDays = daysBetween(rangeStart, rangeEnd) + 1; const dayOffset = Math.max(0, Math.min(totalDays - 1, Math.round(x / rect.width * totalDays))); const date = formatDateISO(addDays(rangeStart, dayOffset)); openTodoModal(null); document.getElementById('todoEditStart').value = date; document.getElementById('todoEditEnd').value = date; } function updateProjectDates(projectId, start, end){ const p = planWeek.projects.find(x => x.id === projectId); if(!p) return; ensurePlan(p); p.plan.start = start; p.plan.end = end; savePlan(); } function updateMilestoneDate(projectId, index, date){ const p = planWeek.projects.find(x => x.id === projectId); if(!p) return; ensurePlan(p); if(!Array.isArray(p.plan.milestones)) p.plan.milestones = []; if(p.plan.milestones[index]){ p.plan.milestones[index].date = date; savePlan(); // Optional: Abhängige TODOs automatisch nachziehen const msId = 'm_' + p.plan.milestones[index].id; const affected = (p.plan.todos || []).filter(t => Array.isArray(t.dependsOn) && t.dependsOn.includes(msId)); if(affected.length > 0) autoScheduleTodos(); } } function updateVacationDates(personId, absenceId, start, end){ const localPerson = (planWeek.people || []).find(p => p.id === personId); if(localPerson && localPerson.absences){ const a = localPerson.absences.find(x => x.id === absenceId); if(a){ a.start = start; a.end = end; } } const globalPeople = db.globalPeople[department] || []; const globalPerson = globalPeople.find(p => p.id === personId); if(globalPerson && globalPerson.absences){ const a = globalPerson.absences.find(x => x.id === absenceId); if(a){ a.start = start; a.end = end; } } savePlan(); saveGlobalPeople(); } // ---------- Kritischer Pfad & Auto-Schedule ---------- // computeCriticalPath: liefert Array der TODO-IDs, die auf einem kritischen Pfad liegen // (längste Kette von Vorgänger-Abhängigkeiten, End-to-Start) window.computeCriticalPath = function(project){ if(!project || !project.plan) return []; const todos = (project.plan.todos || []).filter(t => t.start && t.end); const milestones = (project.plan.milestones || []).filter(m => m.date); if(todos.length === 0 && milestones.length === 0) return []; // Einheitliche Knoten: TODOs und Meilensteine (Dauer 1) const nodes = todos.map(t => ({ type: 'todo', id: t.id, obj: t, start: parseDateISO(t.start), end: parseDateISO(t.end), duration: Math.max(1, daysBetween(parseDateISO(t.start), parseDateISO(t.end)) + 1) })); milestones.forEach(m => { const d = parseDateISO(m.date); nodes.push({ type: 'milestone', id: 'm_' + m.id, obj: m, start: d, end: d, duration: 1 }); }); const nodeMap = new Map(nodes.map(n => [n.id, n])); function depsOf(n){ return Array.isArray(n.obj.dependsOn) ? n.obj.dependsOn : []; } // Frühester Start (ES) und frühestes Ende (EF) berechnen. // visiting-Set schützt vor Endlos-Rekursion bei zyklischen Abhängigkeiten. const es = {}, ef = {}; const esVisiting = new Set(); function computeES(n){ if(es[n.id] !== undefined) return es[n.id]; if(esVisiting.has(n.id)){ es[n.id] = null; ef[n.id] = null; return null; } // Zyklus if(!n.start || !n.end){ es[n.id] = null; ef[n.id] = null; return null; } esVisiting.add(n.id); const deps = depsOf(n); let maxEF = null; deps.forEach(depId => { const dep = nodeMap.get(depId); if(!dep) return; const depStart = computeES(dep); if(!depStart) return; const depEnd = addDays(depStart, dep.duration - 1); if(maxEF === null || depEnd > maxEF) maxEF = depEnd; }); esVisiting.delete(n.id); es[n.id] = maxEF === null ? n.start : addDays(maxEF, 1); ef[n.id] = addDays(es[n.id], n.duration - 1); return es[n.id]; } nodes.forEach(n => computeES(n)); const validNodes = nodes.filter(n => es[n.id]); if(validNodes.length === 0) return []; const maxEF = validNodes.reduce((max, n) => { const d = ef[n.id]; return !max || d > max ? d : max; }, null); // Spätestes Ende (LF) und spätester Start (LS) rückwärts berechnen. // Wichtig: LF eines Knotens ist der Tag VOR dem spätesten Start (LS) // seines frühesten Nachfolgers – nicht vor dessen LF. const lf = {}, ls = {}; const lfVisiting = new Set(); function computeLF(n){ if(lf[n.id] !== undefined) return lf[n.id]; if(lfVisiting.has(n.id)){ lf[n.id] = null; ls[n.id] = null; return null; } // Zyklus if(!n.start || !n.end){ lf[n.id] = null; ls[n.id] = null; return null; } lfVisiting.add(n.id); const successors = validNodes.filter(succ => depsOf(succ).includes(n.id)); let minLF = null; successors.forEach(succ => { if(!computeLF(succ)) return; const succLS = ls[succ.id]; if(!succLS) return; const candidate = addDays(succLS, -1); if(minLF === null || candidate < minLF) minLF = candidate; }); lfVisiting.delete(n.id); lf[n.id] = minLF === null ? maxEF : minLF; ls[n.id] = addDays(lf[n.id], -(n.duration - 1)); return lf[n.id]; } validNodes.forEach(n => computeLF(n)); return validNodes.filter(n => n.type === 'todo' && es[n.id] && ls[n.id] && daysBetween(es[n.id], ls[n.id]) === 0).map(n => n.id); }; window.toggleCriticalPath = function(){ criticalPathActive = !criticalPathActive; const btn = document.getElementById('criticalPathBtn'); if(btn) btn.classList.toggle('active', criticalPathActive); renderPlanGantt(); if(criticalPathActive){ const path = computeCriticalPath(planProject); if(path.length === 0){ showToast('Kein kritischer Pfad ermittelt (zu wenig Aufgaben oder Abhängigkeiten).'); } else { showToast(path.length + ' Aufgabe(n) auf dem kritischen Pfad hervorgehoben.'); } } }; window.autoScheduleTodos = function(){ if(isLocked()) return; const plan = ensurePlanLocal(); if(!Array.isArray(plan.todos) || plan.todos.length === 0) return; const todos = plan.todos.filter(t => t.start && t.end); const todoMap = new Map(todos.map(t => [t.id, t])); const milestones = Array.isArray(plan.milestones) ? plan.milestones.filter(m => m.date) : []; const milestoneMap = new Map(milestones.map(m => ['m_' + m.id, m])); function duration(t){ const s = parseDateISO(t.start), e = parseDateISO(t.end); return s && e ? Math.max(1, daysBetween(s, e) + 1) : 1; } // processed: bereits fertig eingeplante Knoten // visiting: aktuell im Rekursionspfad -> schützt vor Endlos-Rekursion bei Zyklen // moved: Knoten, deren Datum sich tatsächlich geändert hat const processed = new Set(); const visiting = new Set(); const moved = new Set(); let hasCycle = false; function scheduleTodo(t){ if(processed.has(t.id)) return; if(visiting.has(t.id)){ hasCycle = true; return; } const tStart = parseDateISO(t.start); if(!tStart){ processed.add(t.id); return; } visiting.add(t.id); const deps = Array.isArray(t.dependsOn) ? t.dependsOn : []; let earliest = tStart; deps.forEach(depId => { let depEnd = null; const depTodo = todoMap.get(depId); if(depTodo){ scheduleTodo(depTodo); depEnd = parseDateISO(depTodo.end); } else { const depMs = milestoneMap.get(depId); if(depMs) depEnd = parseDateISO(depMs.date); } if(!depEnd) return; const candidate = addDays(depEnd, 1); if(candidate > earliest) earliest = candidate; }); visiting.delete(t.id); const dur = duration(t); const newStart = formatDateISO(earliest); const newEnd = formatDateISO(addDays(earliest, dur - 1)); if(newStart !== t.start || newEnd !== t.end){ t.start = newStart; t.end = newEnd; moved.add(t.id); } processed.add(t.id); } function scheduleMilestone(m){ const mId = 'm_' + m.id; if(processed.has(mId)) return; if(visiting.has(mId)){ hasCycle = true; return; } const deps = Array.isArray(m.dependsOn) ? m.dependsOn : []; let earliest = parseDateISO(m.date); if(!earliest){ processed.add(mId); return; } visiting.add(mId); deps.forEach(depId => { let depEnd = null; const depTodo = todoMap.get(depId); if(depTodo){ scheduleTodo(depTodo); depEnd = parseDateISO(depTodo.end); } else { const depMs = milestoneMap.get(depId); if(depMs && depMs.id !== m.id){ scheduleMilestone(depMs); depEnd = parseDateISO(depMs.date); } } if(!depEnd) return; const candidate = addDays(depEnd, 1); if(candidate > earliest) earliest = candidate; }); visiting.delete(mId); const newDate = formatDateISO(earliest); if(newDate !== m.date){ m.date = newDate; moved.add(mId); } processed.add(mId); } todos.forEach(t => scheduleTodo(t)); milestones.forEach(m => scheduleMilestone(m)); if(moved.size > 0){ savePlan(); renderPlanGantt(); showToast(moved.size + ' Element(e) automatisch verschoben.' + (hasCycle ? ' Achtung: zyklische Abhängigkeit gefunden!' : '')); } else if(hasCycle){ showToast('Achtung: zyklische Abhängigkeit gefunden – bitte Vorgänger prüfen.'); } else { showToast('Keine Änderungen nötig – alle Termine passen zu den Abhängigkeiten.'); } }; // ---------- Ressourcen-Auslastung ---------- window.renderResourceLoad = function(){ const container = document.getElementById('resourceLoadContainer'); if(!container || !planProject) return; const plan = ensurePlanLocal(); const todos = Array.isArray(plan.todos) ? plan.todos.filter(t => t.start && t.end) : []; const assignees = projectAssignees(planProject, planWeek); if(assignees.length === 0){ container.innerHTML = ''; return; } const loads = {}; assignees.forEach(a => { if(!loads[a.person.id]){ loads[a.person.id] = { person: a.person, roles: [], days: 0, todos: [] }; } if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role); }); todos.forEach(t => { if(!Array.isArray(t.assignees) || t.assignees.length === 0) return; const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end)); t.assignees.forEach(pid => { if(!loads[pid]) return; loads[pid].days += days; loads[pid].todos.push(t.title); }); }); const maxDays = Math.max(1, Object.values(loads).reduce((m, l) => Math.max(m, l.days), 0)); let html = '
Ressourcen-Auslastung (Aufgabentage, ohne Wochenenden)
'; html += '
'; Object.values(loads).forEach(l => { const pct = Math.round(l.days / maxDays * 100); const absCount = countPersonAbsences(l.person.id); const absBadge = absCount > 0 ? `
🏖 ${absCount} Abwesenheit(en)
` : ''; let todoListHtml; if(l.todos.length === 0){ todoListHtml = '
keine TODOs
'; } else { todoListHtml = l.todos.slice(0,3).map(t => `
${esc(t)}
`).join(''); if(l.todos.length > 3){ todoListHtml += '
+' + (l.todos.length - 3) + ' weitere
'; } } html += `

${esc(l.person.name)} (${esc(l.roles.join(' / '))})

${l.days}
${todoListHtml}
${absBadge}
`; }); html += '
'; container.innerHTML = html; }; // ---------- Abwesenheits-Modal (Urlaube der Projektbeteiligten) ---------- let currentAbsencePersonId = null; // Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt) function findGlobalPerson(personId){ return (db.globalPeople[department] || []).find(p => p.id === personId) || null; } function countPersonAbsences(personId){ const p = findGlobalPerson(personId); return p && Array.isArray(p.absences) ? p.absences.length : 0; } window.openAbsenceModal = function(personId){ const person = findGlobalPerson(personId); if(!person) return; currentAbsencePersonId = personId; document.getElementById('absenceModalTitle').textContent = 'Abwesenheiten – ' + (person.fullName || person.name); // Formularfelder zurücksetzen const startInput = document.getElementById('absenceEditStart'); const endInput = document.getElementById('absenceEditEnd'); const rangeDisplay = document.getElementById('absenceEditRange'); if(startInput) startInput.value = ''; if(endInput) endInput.value = ''; if(rangeDisplay) rangeDisplay.value = ''; renderAbsenceModalList(); document.getElementById('absenceModal').style.display = 'flex'; }; window.closeAbsenceModal = function(){ document.getElementById('absenceModal').style.display = 'none'; currentAbsencePersonId = null; }; function renderAbsenceModalList(){ const listDiv = document.getElementById('absenceModalList'); const person = findGlobalPerson(currentAbsencePersonId); if(!listDiv || !person) return; const absences = Array.isArray(person.absences) ? person.absences : []; const locked = isLocked(); if(absences.length === 0){ listDiv.innerHTML = 'Keine Abwesenheiten eingetragen.'; } else { listDiv.innerHTML = '
' + absences.map(a => `
${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))} ${a.note ? `${esc(a.note)}` : ''} ${locked ? '' : ``}
`).join('') + '
'; } const form = document.getElementById('absenceModalForm'); if(form) form.style.display = locked ? 'none' : ''; } window.addPlanAbsence = function(){ if(isLocked()) return; const person = findGlobalPerson(currentAbsencePersonId); if(!person) return; const start = document.getElementById('absenceEditStart').value; const end = document.getElementById('absenceEditEnd').value; const type = document.getElementById('absenceEditType').value; const note = document.getElementById('absenceEditNote').value.trim(); if(!start || !end){ alert('Bitte Start- und Enddatum angeben.'); return; } if(parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; } if(!Array.isArray(person.absences)) person.absences = []; const absence = { id: uid(), start, end, type, note }; person.absences.push(absence); person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start)); // Auch die Sitzungs-lokale Kopie aktualisieren, damit die Anzeige konsistent bleibt const localPerson = (planWeek.people || []).find(p => p.id === currentAbsencePersonId); if(localPerson){ if(!Array.isArray(localPerson.absences)) localPerson.absences = []; localPerson.absences.push({ ...absence }); localPerson.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start)); } saveGlobalPeople(); savePlan(); document.getElementById('absenceEditStart').value = ''; document.getElementById('absenceEditEnd').value = ''; const rangeDisplay = document.getElementById('absenceEditRange'); if(rangeDisplay) rangeDisplay.value = ''; document.getElementById('absenceEditNote').value = ''; renderAbsenceModalList(); renderPlanGantt(); }; window.deletePlanAbsence = function(absenceId){ if(isLocked()) return; const person = findGlobalPerson(currentAbsencePersonId); if(!person || !Array.isArray(person.absences)) return; person.absences = person.absences.filter(a => a.id !== absenceId); const localPerson = (planWeek.people || []).find(p => p.id === currentAbsencePersonId); if(localPerson && Array.isArray(localPerson.absences)){ localPerson.absences = localPerson.absences.filter(a => a.id !== absenceId); } saveGlobalPeople(); savePlan(); renderAbsenceModalList(); renderPlanGantt(); }; // Toast-Benachrichtigung let toastTimer = null; function showToast(message){ let toast = document.getElementById('planToast'); if(!toast){ toast = document.createElement('div'); toast.id = 'planToast'; toast.className = 'plan-toast'; document.body.appendChild(toast); } toast.textContent = message; toast.classList.add('show'); // Vorherigen Timer abbrechen, sonst verschwindet ein neuer Toast zu früh if(toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(() => { toast.classList.remove('show'); toastTimer = null; }, 2500); } // Abhängigkeitslinien hängen von Pixel-Positionen ab -> bei Fenstergröße neu zeichnen let resizeTimer = null; window.addEventListener('resize', () => { if(resizeTimer) clearTimeout(resizeTimer); resizeTimer = setTimeout(() => renderGanttDependencies(), 150); }); document.addEventListener('DOMContentLoaded', init); document.addEventListener('DOMContentLoaded', initGanttDrag); document.addEventListener('DOMContentLoaded', initGanttTodoDrag); })();