Files
erikandClaude Opus 5.5 b7e0bd0d92 Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb
- 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>
2026-10-29 14:16:52 +01:00

521 lines
23 KiB
JavaScript
Raw Permalink 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
// (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);
})();