- Termin aus Mail: Mailtext oder .eml per Drag & Drop/Strg+V auf Terminplan oder Aufgaben, KI schlägt Aufgaben vor, Prüfung im Aufgaben-Panel; neues Notizfeld je Aufgabe (plan_todos.notiz) - Terminplan: Meilensteine ohne Aufgabenbezug je in eigener Zeile, "+ Neu"-Menü klappt neben schmaler Seitenleiste nach rechts, Leistungsphasen - Rollen & Rechte, Abwesenheiten-Seite, Benachrichtigungen, Rundgang - Glas-Darstellung, Logo als Favicon - Pi-Betrieb: PDF-Arbeit im Kindprozess, Wächter, Einrichtungs- und Prüfskript inkl. WLAN-Wächter Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
521 lines
23 KiB
JavaScript
521 lines
23 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
|
||
// (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. Jeder Meilenstein hat eine
|
||
// eigene .gantt-milestone-row (auch ohne Position, dann oben unter
|
||
// "Projekt gesamt").
|
||
if(todoRowIds.length !== scheduledIds.size) return;
|
||
if(milestoneRowIds.length !== milestones.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 -> '' (eigene Zeile oben)
|
||
// - 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];
|
||
|
||
const todoDavor = rows.slice(0, idx).some(r => r.classList.contains('gantt-todo'));
|
||
if(!todoDavor){
|
||
// Vor der ersten Aufgabe: ohne Position (eigene Zeile oben).
|
||
position = '';
|
||
} else 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;
|
||
// Abwesenheit einer anderen Person: Art und Notiz kennt nur sie selbst
|
||
// (loadGlobalPeople() schickt sie verdeckt). Das Verschieben legt neu an
|
||
// und bräuchte die Art — sonst wäre der Eintrag nach dem Löschen weg.
|
||
if(globalAbsence.verdeckt){
|
||
app.showToast('Abwesenheiten anderer Personen lassen sich hier nicht verschieben.');
|
||
renderPlanGantt();
|
||
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);
|
||
})(); |