// ----------------------------- // Projektplan — Drag-and-Drop (GANTT-Balken & TODO/Meilenstein-Zeilen) // ----------------------------- // Dieses Modul macht das Projektplan-GANTT interaktiv: TODO- und // Meilenstein-Zeilen lassen sich per Greif-Handler umsortieren und GANTT- // Balken, Meilenstein-Rauten sowie Urlaubsblöcke per Mausziehen verschieben // bzw. an den Rändern resizen. Die neuen Daten schreibt es über app.savePlan // (plan-state.js) und lässt das Diagramm über renderPlanGantt // (plan-gantt.js) neu zeichnen; Doppelklicks öffnen die Modals aus // plan-modals.js. Bei gesperrter Woche (app.isLocked) ist alles deaktiviert. // // Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp // (plan-state.js). (function(){ const app = window.PlanApp; // Selektor für alle per Zeilen-Drag umsortierbaren Zeilen (TODO und // Meilenstein) im GANTT. const DRAGGABLE_GANTT_ROW_SELECTOR = '.gantt-row.gantt-todo, .gantt-row.gantt-milestone-row'; // ---------- Drag-and-Drop für TODO- und Meilenstein-Zeilen direkt im GANTT ---------- // initGanttTodoDrag: registriert die Maus-Events (mousedown/mousemove/mouseup) // für das Umsortieren von TODO- und Meilenstein-Zeilen am GANTT-Container. function initGanttTodoDrag(){ const container = document.getElementById('planGanttContainer'); if(!container) return; container.addEventListener('mousedown', onGanttTodoMouseDown); document.addEventListener('mousemove', onGanttTodoMouseMove); document.addEventListener('mouseup', onGanttTodoMouseUp); } // onGanttTodoMouseDown: startet das Zeilen-Ziehen, wenn der Greif-Handler // (Icon "grip", .gantt-todo-drag) einer TODO-/Meilenstein-Zeile gedrückt // wird, und merkt sich Zeile und Startposition in app.todoDrag. function onGanttTodoMouseDown(e){ if(app.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(); app.todoDrag = { row, startY: e.clientY, moved: false }; row.classList.add('dragging'); } // onGanttTodoMouseMove: verschiebt bei aktiver Ziehung (app.todoDrag) die // Zeile live im Chart – entweder ans Ende oder vor das dem Mauszeiger // nächstliegende Element (getGanttRowDragAfterElement). function onGanttTodoMouseMove(e){ if(!app.todoDrag) return; const chart = app.todoDrag.row.closest('.gantt-chart'); if(!chart) return; const dy = e.clientY - app.todoDrag.startY; if(Math.abs(dy) > 3) app.todoDrag.moved = true; if(!app.todoDrag.moved) return; const afterElement = getGanttRowDragAfterElement(chart, e.clientY); if(app.todoDrag.row && afterElement == null){ chart.appendChild(app.todoDrag.row); } else if(app.todoDrag.row && afterElement && afterElement !== app.todoDrag.row){ chart.insertBefore(app.todoDrag.row, afterElement); } } // onGanttTodoMouseUp: beendet das Zeilen-Ziehen. Bei einer echten Bewegung // (moved) wird die neue Reihenfolge über reorderPlanRows übernommen. function onGanttTodoMouseUp(e){ if(!app.todoDrag) return; const drag = app.todoDrag; app.todoDrag = null; drag.row.classList.remove('dragging'); if(drag.moved){ reorderPlanRows(); } } // getGanttRowDragAfterElement: ermittelt unter allen nicht gezogenen // Zeilen jenes Element, das der übergebenen y-Position am nächsten liegt // und dessen Mitte unterhalb des Cursors beginnt. Rückgabe: Element oder // null, wenn keine Zeile mehr unter dem Cursor liegt (Curser unterhalb der // letzten Zeile → die gezogene Zeile gehört ans Chart-Ende). function getGanttRowDragAfterElement(chart, y){ const draggableElements = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR + ':not(.dragging)')]; const closest = draggableElements.reduce((acc, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if(offset < 0 && offset > acc.offset){ return { offset: offset, element: child }; } return acc; }, { offset: Number.NEGATIVE_INFINITY }); return closest.element || null; } // reorderPlanRows: übernimmt die im DOM gefundene Zeilen-Reihenfolge in den // Plan: TODO-Liste wird umsortiert, Meilenstein-Positionen (before:/after: // einer TODO) werden gemäß der Regeln im Code neu berechnet. Anschließend // Speichern über app.savePlan und Neu-Rendern über renderPlanGantt. function reorderPlanRows(){ if(app.isLocked()) return; const chart = document.querySelector('#planGanttContainer .gantt-chart'); if(!chart) return; const plan = app.ensurePlanLocal(); const todos = Array.isArray(plan.todos) ? plan.todos : []; const milestones = Array.isArray(plan.milestones) ? plan.milestones : []; // Nur terminierte Aufgaben (Start UND Ende gesetzt) MIT showInGantt !== // false haben überhaupt eine eigene GANTT-Zeile (exakt dieselbe // Bedingung wie in plan-build-gantt.js) und damit einen Ziehgriff — // Aufgaben ohne Termin oder bewusst aus dem GANTT ausgeblendete bleiben // an ihrer Stelle in plan.todos stehen, sie können hier gar nicht bewegt // worden sein. Ohne diesen zweiten Teil der Bedingung würde scheduledIds // mehr Einträge zählen als tatsächlich .gantt-todo-Zeilen im DOM stehen, // und der Sicherheitscheck unten (todoRowIds.length !== scheduledIds.size) // würde JEDE Umsortierung stillschweigend verwerfen, sobald auch nur eine // Aufgabe ausgeblendet ist. const scheduledIds = new Set(todos.filter(t => t.start && t.end && t.showInGantt !== false).map(t => t.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. Nicht jedes Meilenstein wird als // eigene .gantt-milestone-row gerendert – nur die mit einer TODO-basierten // Position (before:/after:) sind eigene Zeilen. Meilensteine ohne Position // liegen in der Projektzeile und werden hier nicht umsortiert, deshalb nur // die positionierten zählen. if(todoRowIds.length !== scheduledIds.size) return; const positionedIds = milestones.filter(m => m.position && /^(before|after):/.test(m.position)).map(m => m.id); if(milestoneRowIds.length !== positionedIds.length) return; // TODOs neu ordnen: nur die Positionen der terminierten Aufgaben werden // gemäß der neuen DOM-Reihenfolge neu belegt (todoRowIds); Aufgaben ohne // Termin behalten exakt ihren bisherigen Platz in der Liste. let nextRowIdIdx = 0; plan.todos = todos.map(t => { if(!scheduledIds.has(t.id)) return t; const id = todoRowIds[nextRowIdIdx++]; return todos.find(x => x.id === id) || t; }); // 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-todo')) && (!nextRow || !nextRow.classList.contains('gantt-todo'))){ // Weder vor noch hinter diesem Meilenstein steht direkt eine TODO – // das ist der Fall für einen führenden Meilenstein-Block (vor der // ersten TODO) oder mehrere Meilensteine hintereinander. Beziehe dich // auf die nächste TODO danach oder die letzte TODO davor. 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 { 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; }); app.savePlan(); renderPlanGantt(); } // ---------- GANTT Drag-Editing (Balken, Meilensteine, Urlaub) ---------- // initGanttDrag: registriert die Maus- und Doppelklick-Events für das // Verschieben/Resizen von GANTT-Balken, Meilensteinen und Urlaubsblöcken. // Ein Klick ohne Bewegung wird in onGanttMouseUp behandelt (kein eigener // click-Handler, um doppeltes Öffnen zu vermeiden). 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. } // onGanttMouseDown: startet den Drag auf einem Balken (data-gantt-type). // Erkennt anhand der Resize-Griffe, ob verschoben oder links/rechts // resized wird, und speichert alle Ausgangsdaten inkl. Original-Daten in // app.ganttDrag. function onGanttMouseDown(e){ if(app.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(); app.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(); } // onGanttMouseMove: aktualisiert während des Ziehens live die Position // (left) bzw. Größe (left/width) des Balkens oder die Position der // Meilenstein-Raute samt zugehörigem Label anhand des Tagesversatzes. function onGanttMouseMove(e){ if(!app.ganttDrag) return; const totalDays = daysBetween(app.ganttDrag.rangeStart, app.ganttDrag.rangeEnd) + 1; const pxPerDay = app.ganttDrag.timelineWidth / totalDays; const dx = e.clientX - app.ganttDrag.startX; const dayDelta = Math.round(dx / pxPerDay); if(Math.abs(dx) > 2) app.ganttDrag.moved = true; if(app.ganttDrag.type === 'milestone'){ const newDate = addDays(app.ganttDrag.originalStart, dayDelta); const offset = daysBetween(app.ganttDrag.rangeStart, newDate); const left = (offset / totalDays * 100).toFixed(4) + '%'; app.ganttDrag.bar.style.left = left; // Zugehöriges Label (nächstes Geschwister-Element) mitbewegen const label = app.ganttDrag.bar.nextElementSibling; if(label && label.classList.contains('gantt-milestone-label')) label.style.left = left; return; } let newStart, newEnd; if(app.ganttDrag.action === 'move'){ newStart = addDays(app.ganttDrag.originalStart, dayDelta); newEnd = addDays(app.ganttDrag.originalEnd, dayDelta); } else if(app.ganttDrag.action === 'left'){ newStart = addDays(app.ganttDrag.originalStart, dayDelta); newEnd = app.ganttDrag.originalEnd; if(newStart > newEnd) newStart = newEnd; } else if(app.ganttDrag.action === 'right'){ newStart = app.ganttDrag.originalStart; newEnd = addDays(app.ganttDrag.originalEnd, dayDelta); if(newEnd < newStart) newEnd = newStart; } const barStart = newStart < app.ganttDrag.rangeStart ? app.ganttDrag.rangeStart : newStart; const barEnd = newEnd > app.ganttDrag.rangeEnd ? app.ganttDrag.rangeEnd : newEnd; const offset = daysBetween(app.ganttDrag.rangeStart, barStart); const span = daysBetween(barStart, barEnd) + 1; app.ganttDrag.bar.style.left = (offset / totalDays * 100).toFixed(4) + '%'; app.ganttDrag.bar.style.width = (span / totalDays * 100).toFixed(4) + '%'; } // onGanttMouseUp: beendet den Drag. Ein Klick ohne Bewegung öffnet das // zugehörige Modal (TODO oder Meilenstein); sonst wird der Tagesversatz // berechnet und je nach Typ die neuen Daten gespeichert (Projekt, TODO, // Meilenstein, Urlaub). Liegt das Ergebnis außerhalb des Datumsbereichs, // wird fitPlanGanttRange aufgerufen, sonst neu gerendert. function onGanttMouseUp(e){ if(!app.ganttDrag) return; const drag = app.ganttDrag; app.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(); } // updateTodoDates: schreibt neue Start-/Enddaten in die TODO an Position // idx und speichert den Plan. function updateTodoDates(idx, start, end){ const plan = app.ensurePlanLocal(); if(!plan.todos[idx]) return; plan.todos[idx].start = start; plan.todos[idx].end = end; app.savePlan(); } // onGanttDoubleClick: öffnet bei einem Doppelklick auf eine freie Stelle der // Timeline das TODO-Modal für eine neue Aufgabe und übernimmt das // angeklickte Datum in Start- und Endfeld. function onGanttDoubleClick(e){ if(app.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)); // Über prefill statt nachträglich in die versteckten Felder schreiben: // openTodoModal() setzt damit auch die sichtbare Zeitraum-Anzeige, das // Dauer-Kennzeichen und den Vergleichsstand für "ungespeicherte // Änderungen" (sonst stand im Feld "Start – Ende wählen…", und Esc fragte // grundlos nach dem Verwerfen). openTodoModal(null, { start: date, end: date }); } // updateProjectDates: setzt Start und Ende des Projekt-Plans und speichert. function updateProjectDates(projectId, start, end){ const p = app.week.projects.find(x => x.id === projectId); if(!p) return; ensurePlan(p); p.plan.start = start; p.plan.end = end; app.savePlan(); } // updateMilestoneDate: setzt das Datum des Meilensteins am übergebenen Index // und speichert. Sind TODOs von diesem Meilenstein abhängig, werden sie // automatisch neu terminiert (autoScheduleTodos aus plan-schedule.js). function updateMilestoneDate(projectId, index, date){ const p = app.week.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; app.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(); } } // updateVacationDates: übernimmt die per Drag verschobenen Urlaubsdaten // sofort optimistisch in lokale Sitzungs-Kopie und globale Personenliste // (damit das GANTT unmittelbar danach die neue Position zeichnet), und // persistiert sie asynchron über die /users/:id/absences-Endpunkte — es // gibt seit der Ablösung von people/person_roles/person_absences keinen // /save-people-Schreibpfad und kein PATCH auf Abwesenheiten mehr, daher: // alte Abwesenheit löschen, neue mit den verschobenen Daten anlegen (dabei // vergibt der Server eine neue ID, die hier übernommen wird). Kein // Benutzerkonto für die Person gefunden -> klare Fehlermeldung statt // stillem Fehlschlag. function updateVacationDates(personId, absenceId, start, end){ const localPerson = (app.week.people || []).find(p => p.id === personId); const localAbsence = localPerson && Array.isArray(localPerson.absences) ? localPerson.absences.find(x => x.id === absenceId) : null; const globalPeople = db.globalPeople || []; const globalPerson = globalPeople.find(p => p.id === personId); const globalAbsence = globalPerson && Array.isArray(globalPerson.absences) ? globalPerson.absences.find(x => x.id === absenceId) : null; if(!globalAbsence) return; const type = globalAbsence.type || ''; const note = globalAbsence.note || ''; // Ursprungstermine merken: Scheitert das Speichern (Netz, fehlende // Berechtigung), wird die optimistisch verschobene Anzeige wieder auf den // Server-Stand zurückgesetzt, statt etwas zu zeigen, das dort nicht gilt. const vorher = { start: globalAbsence.start, end: globalAbsence.end }; const zuruecksetzen = () => { if(localAbsence){ localAbsence.start = vorher.start; localAbsence.end = vorher.end; } globalAbsence.start = vorher.start; globalAbsence.end = vorher.end; renderPlanGantt(); }; if(localAbsence){ localAbsence.start = start; localAbsence.end = end; } globalAbsence.start = start; globalAbsence.end = end; app.getUserIdForPersonId(personId).then(userId => { if(!userId){ app.showToast('Für diese Person existiert kein Benutzerkonto – Abwesenheit konnte nicht gespeichert werden.'); zuruecksetzen(); return; } // Ergebnis des Löschens prüfen, BEVOR neu angelegt wird: Schlug das // Anlegen danach fehl (Netz, 400), war die alte Abwesenheit bereits weg // — die Anzeige zeigte sie aber weiter, und nach dem nächsten Neuladen // fehlte sie ersatzlos. return fetch(baseUrl() + '/users/' + userId + '/absences/' + encodeURIComponent(absenceId), { method: 'DELETE' }) .then(r => { if(!r.ok){ // 403 heißt: Der Eintrag stammt von jemand anderem (siehe // DELETE /users/:id/absences/:absenceId in server.js). return r.json().catch(() => ({})).then(json => { throw new Error((json && json.error) || ('Alte Abwesenheit konnte nicht entfernt werden (HTTP ' + r.status + ')')); }); } return fetch(baseUrl() + '/users/' + userId + '/absences', { method: 'POST', headers: {'Content-Type':'application/json; charset=utf-8'}, body: JSON.stringify({ start, end, type, note }) }); }) .then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => { if(!ok || !json || !json.ok){ app.showToast('Abwesenheit konnte nicht gespeichert werden: ' + ((json && json.error) || 'unbekannter Fehler') + ' — bitte die Seite neu laden.'); return; } globalAbsence.id = json.absence.id; if(localAbsence) localAbsence.id = json.absence.id; renderPlanGantt(); }); }).catch(err => { console.error('Abwesenheit verschieben fehlgeschlagen:', err); app.showToast('Abwesenheit konnte nicht verschoben werden: ' + (err.message || err)); zuruecksetzen(); }); } // Drag-Funktionalität erst nach geladenem DOM registrieren document.addEventListener('DOMContentLoaded', initGanttDrag); document.addEventListener('DOMContentLoaded', initGanttTodoDrag); })();