Files
projektuebersicht/public/js/plan/plan-drag.js
T
2026-08-14 11:47:19 +02:00

436 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// 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
// (⋮⋮) 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 : [];
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-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));
openTodoModal(null);
document.getElementById('todoEditStart').value = date;
document.getElementById('todoEditEnd').value = 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: schreibt die verschobenen Urlaubsdaten sowohl in die
// lokale Sitzungs-Kopie der Person als auch in die globale Personenliste
// und speichert beides.
function updateVacationDates(personId, absenceId, start, end){
const localPerson = (app.week.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[app.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; }
}
app.savePlan();
app.saveGlobalPeople();
}
// Drag-Funktionalität erst nach geladenem DOM registrieren
document.addEventListener('DOMContentLoaded', initGanttDrag);
document.addEventListener('DOMContentLoaded', initGanttTodoDrag);
})();