Beteiligten-Zuordnung verlagert: p.assign ist jetzt ein Stammdatum und nur noch in den Projekteinstellungen editierbar (planCycleAssign()). Wochenbericht und Projektboard zeigen sie nur noch an, mit Verweis dorthin (springZuEinstellungen()). cycleAssign() ist damit entfallen. Neu "TODOs aus Protokoll": ein externes Besprechungsprotokoll als PDF wird per KI eingelesen und daraus in einem zweiten KI-Schritt Aufgaben abgeleitet. Eigener Bereich in der Aufgaben-/GANTT-Leiste, bewusst unabhaengig von den gespeicherten Protokollen - nichts davon wird abgelegt. Drei Schritte in einem Dialog mit fester Fusszeile; die Vorschlagsliste als kompakte, aufklappbare Zeilen, weil ein echter Import leicht 20 Vorschlaege liefert. Sichtbarkeit je Aufgabe im GANTT: neue Spalte plan_todos.show_in_gantt (Vorgabe 1, idempotente Migration) und eine Checkbox im Aufgaben-Dialog. Eine terminierte Aufgabe kann damit gezielt aus dem Terminplan herausgehalten werden, bleibt sonst aber eine vollwertige Aufgabe. plan-build-gantt.js und plan-drag.js pruefen dieselbe Bedingung, damit Zeilenzahl und Umsortierung nicht auseinanderlaufen. KI-Timeout dritte Stufe: ein abweichender, aber entfernter Endpunkt (Drittanbieter-Proxy) bekommt 2 Minuten statt der 45 Sekunden der echten Anbieter-API. Gemessen: die Punkte-Erkennung eines realen Protokolls brauchte dort 39 Sekunden, lag also stets knapp unter dem alten Limit und brach bei jeder Schwankung ab. Bewegung: Hover- und Klick-Rueckmeldung als Token-Satz in :root, angewandt auf die anklickbaren Zeilen, Schalter und Dialoge. Die Akzentleiste ist ein inset-Schatten statt eines positionierten Elements, damit Drag-and-Drop unberuehrt bleibt; prefers-reduced-motion war bisher gar nicht vorhanden und kippt jetzt einfach die Tokens. Plan Checker: optionaler Projektabgleich des Plankopfs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
512 lines
23 KiB
JavaScript
512 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. 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);
|
||
})(); |