440 lines
19 KiB
JavaScript
440 lines
19 KiB
JavaScript
// -----------------------------
|
||
// 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);
|
||
|
||
// 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 !== todoIds.length) 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
|
||
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);
|
||
})(); |