code auf einzelne Datein aufgeteilt
This commit is contained in:
1 parent
7451e0bf78
commit
8c94fc91f6
30 files changed
+5765
-27
No files matched your search
@@ -0,0 +1,433 @@
|
||||
// -----------------------------
|
||||
// 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.
|
||||
// Rückgabe: Element oder undefined (dann wäre die Zeile am Chart-Ende).
|
||||
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;
|
||||
}
|
||||
|
||||
// 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-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;
|
||||
});
|
||||
|
||||
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);
|
||||
})();
|
||||
Reference in new issue
Block a user