// -----------------------------
// Projektplan — Modals (Meilenstein, TODO, Abwesenheit)
// -----------------------------
// Dieses Modul stellt die Öffnen-/Schließen-/Speichern-/Löschen-Funktionen
// der drei Dialoge der Projektplan-Seite bereit: Meilenstein, TODO und
// Abwesenheiten. Der TODO-Dialog fasst "Erledigt" und Fortschritt in einem
// Status-Segment zusammen (offen/arbeit/fertig, siehe todoStatusFrom() —
// gespeichert wird unverändert done + progress), zeigt die Zuständigen als
// Personen-Chips, klappt die Vorgänger-Liste mit Anzahl und Suche ein und
// sperrt darin Einträge, die einen Zirkelbezug erzeugen würden
// (cyclicDependencyIds()). Validierungshinweise stehen am Feld statt in einem
// alert(). Esc, Strg/Cmd+Enter und der Klick auf den Hintergrund sind am Ende
// der Datei einmal für alle drei Dialoge registriert (MODAL_HANDLERS). Der Bearbeitungszustand liegt in window.PlanApp
// (plan-state.js): currentMilestoneIndex, currentTodoIndex und
// currentAbsencePersonId. Meilenstein-/TODO-Änderungen werden über
// app.savePlan gespeichert; Abwesenheiten haben seit der Ablösung von
// people/person_roles/person_absences keinen /save-people-Schreibpfad mehr
// und werden direkt gegen POST/DELETE /users/:id/absences geschrieben (die
// numerische User-ID kommt aus app.getUserIdForPersonId(), siehe
// plan-state.js). Das GANTT wird danach über renderPlanGantt() (plan-gantt.js)
// neu gezeichnet. Bei gesperrten Wochen (app.isLocked) sind alle
// Bearbeitungsfunktionen deaktiviert.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// ---------- Meilenstein-Modal ----------
// openMilestoneModal: öffnet das Meilenstein-Modal. Parameter: index
// (Index des Meilensteins oder null für einen neuen). Befüllt Name, Datum,
// Positions-Auswahl und die Vorgänger-Checkboxen (andere Meilensteine und
// TODOs) aus dem Plan.
window.openMilestoneModal = function(index){
if(app.isLocked()) return;
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = [];
// Migration alter Meilensteine
plan.milestones.forEach(m => { if(!Array.isArray(m.dependsOn)) m.dependsOn = []; });
app.currentMilestoneIndex = index;
const isNew = index === null || plan.milestones[index] === undefined;
const currentMs = isNew ? null : plan.milestones[index];
document.getElementById('milestoneEditName').value = isNew ? '' : (currentMs.name || '');
// Verstecktes ISO-Feld + sichtbares Anzeigefeld (deutsches Format) —
// dieselbe Aufteilung wie bei allen anderen Datumseingaben der Seite.
const msDateIso = isNew ? '' : (currentMs.date || '');
document.getElementById('milestoneEditDate').value = msDateIso;
const msDateDisplay = document.getElementById('milestoneEditDateDisplay');
if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : '';
const positionSelect = document.getElementById('milestoneEditPosition');
if(positionSelect){
const currentPosition = currentMs && currentMs.position ? currentMs.position : '';
const defaultAfterTodo = isNew && plan.todos && plan.todos.length > 0 ? plan.todos[plan.todos.length - 1].id : null;
let opts = '';
if(plan.todos && plan.todos.length > 0){
plan.todos.forEach(t => {
const beforeVal = 'before:' + t.id;
const afterVal = 'after:' + t.id;
const beforeSel = currentPosition === beforeVal ? 'selected' : '';
let afterSel = currentPosition === afterVal ? 'selected' : '';
if(isNew && defaultAfterTodo === t.id && !currentPosition) afterSel = 'selected';
opts += ``;
opts += ``;
});
}
positionSelect.innerHTML = opts;
}
const dependsOnList = document.getElementById('milestoneEditDependsOn');
if(dependsOnList){
const currentDeps = currentMs && Array.isArray(currentMs.dependsOn) ? currentMs.dependsOn : [];
let listHtml = '';
// Andere Meilensteine als Vorgänger anbieten
// Zirkelbezüge sperren — dieselbe Prüfung wie im Aufgaben-Dialog.
// Ohne sie ließ sich hier ein Kreis schließen (A hängt an M, M an A),
// woraufhin Auto-Schedule und kritischer Pfad dauerhaft aufgaben.
const msSelfId = currentMs ? 'm_' + currentMs.id : null;
const msBlocked = msSelfId ? cyclicDependencyIds(plan, msSelfId) : new Set();
if(plan.milestones.length >= 1){
listHtml += '
';
(plan.todos || []).forEach(t => {
const checked = currentDeps.includes(t.id) ? 'checked' : '';
const datesStr = (t.start || t.end) ? ` (${formatDateDE(parseDateISO(t.start))}–${formatDateDE(parseDateISO(t.end))})` : '';
const sperre = msBlocked.has(t.id) ? ' disabled title="Würde einen Zirkelbezug erzeugen"' : '';
listHtml += ``;
});
}
if(listHtml === '') listHtml = 'Keine anderen Aufgaben oder Meilensteine vorhanden.';
dependsOnList.innerHTML = listHtml;
}
document.getElementById('milestoneModal').style.display = 'flex';
document.getElementById('milestoneEditName').focus();
};
// closeMilestoneModal: blendet das Meilenstein-Modal aus und setzt den
// Bearbeitungsindex zurück.
window.closeMilestoneModal = function(){
document.getElementById('milestoneModal').style.display = 'none';
app.currentMilestoneIndex = null;
};
// saveMilestoneEdit: übernimmt die Modal-Eingaben (Name, Datum, Position,
// Vorgänger) in den Plan – als neuer Eintrag oder als Update des
// aktuell bearbeiteten Meilensteins. Sortiert danach nach Datum, speichert
// und rendert neu.
window.saveMilestoneEdit = function(){
if(app.isLocked()) return;
const name = document.getElementById('milestoneEditName').value.trim();
const date = document.getElementById('milestoneEditDate').value;
if(!name || !date){ alert('Bitte Name und Datum angeben.'); return; }
const dependsOnList = document.getElementById('milestoneEditDependsOn');
const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : [];
const positionSelect = document.getElementById('milestoneEditPosition');
const position = positionSelect ? positionSelect.value : '';
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = [];
if(app.currentMilestoneIndex === null){
plan.milestones.push({ id: uid(), name, date, dependsOn, position });
} else if(plan.milestones[app.currentMilestoneIndex]){
const m = plan.milestones[app.currentMilestoneIndex];
m.name = name;
m.date = date;
m.dependsOn = dependsOn;
m.position = position;
}
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
app.savePlan();
closeMilestoneModal();
renderPlanGantt();
};
// deleteMilestoneEdit: löscht nach Bestätigung den aktuell bearbeiteten
// Meilenstein samt seiner Vorgänger-Verweise (app.removeDependencyRefs),
// speichert und rendert neu.
window.deleteMilestoneEdit = function(){
if(app.isLocked()) return;
if(app.currentMilestoneIndex === null){ closeMilestoneModal(); return; }
if(!confirm('Meilenstein löschen?')) return;
const plan = app.ensurePlanLocal();
if(plan.milestones && plan.milestones[app.currentMilestoneIndex]){
const deletedId = 'm_' + plan.milestones[app.currentMilestoneIndex].id;
plan.milestones.splice(app.currentMilestoneIndex, 1);
app.removeDependencyRefs(plan, deletedId);
app.savePlan();
}
closeMilestoneModal();
renderPlanGantt();
};
// addPlanMilestone: öffnet das Meilenstein-Modal für einen neuen Eintrag.
window.addPlanMilestone = function(){
openMilestoneModal(null);
};
// ---------- TODO-Modal ----------
// TODO_STATUS_VALUES: die drei Zustände des Status-Segments. Sie ersetzen im
// Dialog das frühere Paar aus "Erledigt"-Haken und Fortschrittsregler, das
// denselben Sachverhalt zweimal abgebildet hat (und dabei auseinanderlaufen
// konnte). Gespeichert wird unverändert das bisherige Datenmodell aus
// done (Boolean) + progress (0–100) — siehe todoStatusFrom()/applyTodoStatus().
const TODO_STATUS_VALUES = ['offen', 'arbeit', 'fertig'];
// todoModalSnapshot: Stand der Eingaben beim Öffnen — Vergleichsbasis für
// die Rückfrage beim Abbrechen (siehe todoFormSnapshot()).
let todoModalSnapshot = '';
// todoProgressMemo: zuletzt im Dialog eingestellter Fortschritt. "Offen" und
// "Erledigt" haben feste Werte (0/100); wer von dort zurück auf "In Arbeit"
// wechselt, bekommt darüber seinen vorherigen Wert wieder statt eines
// Ersatzwerts. Gilt nur für den geöffneten Dialog.
let todoProgressMemo = 50;
// todoStatusFrom: leitet den Segment-Status aus dem gespeicherten TODO ab.
// done gewinnt immer; sonst entscheidet der Fortschritt (0 % = offen,
// 100 % = fertig, dazwischen = in Arbeit). Parameter: todo (Objekt oder
// null für eine neue Aufgabe). Rückgabe: String aus TODO_STATUS_VALUES.
function todoStatusFrom(todo){
if(!todo) return 'offen';
if(todo.done) return 'fertig';
const progress = parseInt(todo.progress, 10) || 0;
if(progress >= 100) return 'fertig';
return progress > 0 ? 'arbeit' : 'offen';
}
// applyTodoStatus: schreibt den Status in das Segment und blendet den
// Fortschrittsregler nur im Zustand "In Arbeit" ein (offen = 0 %,
// erledigt = 100 % sind implizit). Parameter: status (String),
// progress (optional, nur für "arbeit" relevant). Rückgabe: keine.
function applyTodoStatus(status, progress){
const segment = document.getElementById('todoEditStatus');
if(segment){
segment.querySelectorAll('button[data-status]').forEach(btn => {
btn.setAttribute('aria-pressed', String(btn.dataset.status === status));
});
}
const row = document.getElementById('todoEditProgressRow');
if(row) row.hidden = status !== 'arbeit';
const slider = document.getElementById('todoEditProgress');
const label = document.getElementById('todoEditProgressVal');
if(slider){
let value;
if(status === 'offen') value = 0;
else if(status === 'fertig') value = 100;
else {
value = progress === undefined ? todoProgressMemo : progress;
// "In Arbeit" mit 0 % oder 100 % wäre widersprüchlich
if(value <= 0) value = 50;
if(value >= 100) value = 95;
todoProgressMemo = value;
}
slider.value = value;
if(label) label.textContent = value + ' %';
}
}
// setTodoEditStatus: Klick-Handler des Status-Segments im TODO-Modal.
// Parameter: status (String aus TODO_STATUS_VALUES). Rückgabe: keine.
window.setTodoEditStatus = function(status){
if(!TODO_STATUS_VALUES.includes(status)) return;
applyTodoStatus(status);
};
// onTodoProgressInput: Eingabe-Handler des Fortschrittsreglers. Schreibt den
// Wert neben den Regler und merkt ihn für einen späteren Rücksprung auf
// "In Arbeit". Parameter: slider (das Regler-Element). Rückgabe: keine.
window.onTodoProgressInput = function(slider){
const value = parseInt(slider.value, 10) || 0;
todoProgressMemo = value;
const label = document.getElementById('todoEditProgressVal');
if(label) label.textContent = value + ' %';
};
// currentTodoEditStatus: liest den aktuell gewählten Segment-Status aus dem
// DOM. Parameter: keine. Rückgabe: String aus TODO_STATUS_VALUES.
function currentTodoEditStatus(){
const pressed = document.querySelector('#todoEditStatus button[aria-pressed="true"]');
return pressed ? pressed.dataset.status : 'offen';
}
// refreshTodoDuration: aktualisiert das Dauer-Badge neben dem Zeitraum-Feld
// (Arbeitstage Mo–Fr über workdaysBetween(), js/utils.js) und wird als
// onApply-Callback des Bereichspickers aufgerufen. Parameter: keine.
// Rückgabe: keine.
window.refreshTodoDuration = function(){
const badge = document.getElementById('todoEditDuration');
if(!badge) return;
const start = parseDateISO(document.getElementById('todoEditStart').value);
const end = parseDateISO(document.getElementById('todoEditEnd').value);
const days = (start && end) ? workdaysBetween(start, end) : 0;
badge.hidden = days <= 0;
badge.textContent = days > 0 ? (days + ' AT') : '';
clearFieldError('todoEditRange');
};
// setFieldError / clearFieldError: zeigen einen Validierungshinweis direkt
// am betroffenen Feld statt in einem alert(), damit die übrigen Eingaben
// sichtbar bleiben. Erwartet ein
// im Markup. Parameter: fieldId (String), message (String).
function setFieldError(fieldId, message){
const field = document.getElementById(fieldId);
const out = document.getElementById(fieldId + 'Error');
if(field) field.classList.add('is-error');
if(out){ out.textContent = message; out.hidden = false; }
}
function clearFieldError(fieldId){
const field = document.getElementById(fieldId);
const out = document.getElementById(fieldId + 'Error');
if(field) field.classList.remove('is-error');
if(out){ out.textContent = ''; out.hidden = true; }
}
// cyclicDependencyIds: liefert alle Knoten-IDs, die den angegebenen Knoten
// direkt oder über eine Kette als Vorgänger haben — genau diese dürfen
// nicht ihrerseits sein Vorgänger werden, weil daraus ein Zirkelbezug
// entstünde. Ermittelt per Breitensuche über den umgekehrten
// Abhängigkeitsgraphen (Kante: Vorgänger → Nachfolger). Zyklen, die schon
// in den Daten stecken, sind unproblematisch: besuchte Knoten werden nur
// einmal verfolgt. Meilenstein-IDs tragen wie überall das Präfix "m_".
// Parameter: plan (Plan-Objekt), nodeId (String oder null). Rückgabe: Set.
function cyclicDependencyIds(plan, nodeId){
const blocked = new Set();
if(!nodeId) return blocked;
// Nachfolger-Liste aufbauen: successors[Vorgänger] = [Nachfolger, …]
const successors = new Map();
const addEdge = (from, to) => {
if(!successors.has(from)) successors.set(from, []);
successors.get(from).push(to);
};
(plan.todos || []).forEach(t => {
(Array.isArray(t.dependsOn) ? t.dependsOn : []).forEach(dep => addEdge(dep, t.id));
});
(plan.milestones || []).forEach(m => {
(Array.isArray(m.dependsOn) ? m.dependsOn : []).forEach(dep => addEdge(dep, 'm_' + m.id));
});
const queue = [nodeId];
while(queue.length){
const current = queue.shift();
(successors.get(current) || []).forEach(next => {
if(blocked.has(next)) return;
blocked.add(next);
queue.push(next);
});
}
return blocked;
}
// refreshTodoDependsCount: schreibt die Anzahl gewählter Vorgänger in den
// Kopf des eingeklappten Bereichs, damit sie auch zugeklappt sichtbar ist.
// Parameter: keine. Rückgabe: keine.
window.refreshTodoDependsCount = function(){
const badge = document.getElementById('todoEditDependsCount');
const list = document.getElementById('todoEditDependsOn');
if(!badge || !list) return;
const count = list.querySelectorAll('input[type="checkbox"]:checked').length;
badge.textContent = count;
badge.classList.toggle('zero', count === 0);
};
// filterTodoDepends: blendet in der Vorgänger-Liste alle Einträge aus, die
// nicht zum Suchbegriff passen. Bei aktiver Suche verschwinden auch die
// Gruppenüberschriften, weil sie dann keine Gliederung mehr leisten.
// Parameter: keine (liest #todoEditDependsSearch). Rückgabe: keine.
window.filterTodoDepends = function(){
const input = document.getElementById('todoEditDependsSearch');
const list = document.getElementById('todoEditDependsOn');
if(!input || !list) return;
const query = input.value.trim().toLowerCase();
list.querySelectorAll('.todo-depends-item').forEach(item => {
const match = !query || item.textContent.toLowerCase().includes(query);
item.hidden = !match;
});
list.querySelectorAll('.todo-depends-group').forEach(group => { group.hidden = !!query; });
};
// openTodoModal: öffnet das TODO-Modal. Parameter: index (Index der Aufgabe
// oder null für eine neue), prefill (optional, Vorbelegung für "Speichern &
// Neu": { start, end, assignees }). Befüllt Titel, Zeitraum samt sichtbarer
// Anzeige und Dauer-Badge, Status-Segment, Zuständigen-Chips und die
// Vorgänger-Liste. "Löschen" erscheint nur beim Bearbeiten, nicht beim
// Anlegen.
window.openTodoModal = function(index, prefill){
if(app.isLocked()) return;
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.todos)) plan.todos = [];
app.currentTodoIndex = index;
const isNew = index === null || plan.todos[index] === undefined;
const currentTodo = !isNew ? plan.todos[index] : null;
const pre = prefill || {};
clearFieldError('todoEditTitle');
clearFieldError('todoEditRange');
const titleEl = document.getElementById('todoModalTitle');
if(titleEl) titleEl.textContent = isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten';
document.getElementById('todoEditTitle').value = isNew ? '' : (currentTodo.title || '');
// Zeitraum: neben den versteckten ISO-Feldern muss auch die sichtbare
// Anzeige gesetzt werden — sonst stünde dort der Platzhalter bzw. der
// Zeitraum der zuvor geöffneten Aufgabe.
const start = isNew ? (pre.start || '') : (currentTodo.start || '');
const end = isNew ? (pre.end || '') : (currentTodo.end || '');
document.getElementById('todoEditStart').value = start;
document.getElementById('todoEditEnd').value = end;
const rangeDisplay = document.getElementById('todoEditRange');
if(rangeDisplay){
const from = formatDateDE(parseDateISO(start));
const to = formatDateDE(parseDateISO(end)) || from;
rangeDisplay.value = from ? (from + ' – ' + to) : '';
}
refreshTodoDuration();
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50;
applyTodoStatus(todoStatusFrom(currentTodo), storedProgress);
const dependsOnList = document.getElementById('todoEditDependsOn');
if(dependsOnList){
const currentDeps = currentTodo && Array.isArray(currentTodo.dependsOn) ? currentTodo.dependsOn : [];
// Vorgänger, die einen Zirkelbezug erzeugen würden, gar nicht erst
// anbieten — bisher fiel das erst beim Auto-Terminieren auf.
const blocked = cyclicDependencyIds(plan, currentTodo ? currentTodo.id : null);
let listHtml = '';
// Meilensteine als Vorgänger anbieten
if(Array.isArray(plan.milestones) && plan.milestones.length > 0){
listHtml += '
Meilensteine
';
plan.milestones.forEach(m => {
const mId = 'm_' + m.id;
const checked = currentDeps.includes(mId) ? 'checked' : '';
listHtml += dependsItemHtml(mId, `${icon('flag')} ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})`, checked, blocked.has(mId));
});
}
// Andere TODOs als Vorgänger anbieten
// >= 1 statt > 1: Bei einer NEUEN Aufgabe wird aus der Liste nichts
// herausgefiltert, die einzige vorhandene Aufgabe war damit nicht als
// Vorgänger wählbar (erst ab der dritten funktionierte die Auswahl).
if(plan.todos && plan.todos.length >= 1){
listHtml += '
Aufgaben
';
(plan.todos || []).forEach(t => {
if(currentTodo && t.id === currentTodo.id) return;
const checked = currentDeps.includes(t.id) ? 'checked' : '';
const datesStr = (t.start || t.end) ? ` (${formatDateDE(parseDateISO(t.start))}–${formatDateDE(parseDateISO(t.end))})` : '';
listHtml += dependsItemHtml(t.id, `${esc(t.title || 'TODO')}${datesStr}`, checked, blocked.has(t.id));
});
}
if(listHtml === '') listHtml = 'Keine anderen Aufgaben oder Meilensteine vorhanden.';
dependsOnList.innerHTML = listHtml;
}
const searchInput = document.getElementById('todoEditDependsSearch');
if(searchInput) searchInput.value = '';
refreshTodoDependsCount();
// Bereich nur aufklappen, wenn es dort auch etwas zu sehen gibt
const fold = document.getElementById('todoEditDependsFold');
if(fold) fold.open = !!(currentTodo && Array.isArray(currentTodo.dependsOn) && currentTodo.dependsOn.length > 0);
const assigneesDiv = document.getElementById('todoEditAssignees');
if(assigneesDiv){
const assignees = projectAssignees(app.project, app.week);
const currentAssignees = currentTodo
? (Array.isArray(currentTodo.assignees) ? currentTodo.assignees : [])
: (Array.isArray(pre.assignees) ? pre.assignees : []);
const seenPeople = new Set();
const uniquePeople = [];
assignees.forEach(a => {
if(!seenPeople.has(a.person.id)){
seenPeople.add(a.person.id);
uniquePeople.push(a.person);
}
});
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
// Personenspalten im Projektboard.
assigneesDiv.innerHTML = uniquePeople.map(person => {
const pressed = currentAssignees.includes(person.id) ? 'true' : 'false';
const short = person.name || initialsForName(person.fullName || '') || '?';
const full = person.fullName || person.name || '';
return ``;
}).join('') || 'Keine Projektzuordnungen im Board.';
}
const deleteBtn = document.getElementById('todoEditDelete');
if(deleteBtn) deleteBtn.hidden = isNew;
document.getElementById('todoModal').style.display = 'flex';
todoModalSnapshot = todoFormSnapshot();
document.getElementById('todoEditTitle').focus();
};
// dependsItemHtml: baut eine Zeile der Vorgänger-Liste. Gesperrte Einträge
// (Zirkelbezug) werden ausgegraut und deaktiviert statt ausgeblendet, damit
// nachvollziehbar bleibt, warum sie nicht wählbar sind. Parameter: value
// (Knoten-ID), label (bereits escaptes HTML), checked (String), blocked
// (Boolean). Rückgabe: HTML-String.
function dependsItemHtml(value, label, checked, blocked){
if(blocked){
return ``;
}
return ``;
}
// toggleTodoAssignee: schaltet einen Zuständigen-Chip um. Parameter: btn
// (das angeklickte Chip-Element). Rückgabe: keine.
window.toggleTodoAssignee = function(btn){
btn.setAttribute('aria-pressed', btn.getAttribute('aria-pressed') === 'true' ? 'false' : 'true');
};
// todoFormSnapshot: friert die aktuellen Eingaben als Vergleichsstring ein.
// Dient allein der Rückfrage beim Abbrechen (Esc/Klick auf den Hintergrund);
// gespeichert wird ausschließlich über saveTodoEdit(). Parameter: keine.
// Rückgabe: String.
function todoFormSnapshot(){
const read = id => { const el = document.getElementById(id); return el ? el.value : ''; };
const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value);
const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"]')).map(b => b.dataset.personId);
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
currentTodoEditStatus(), read('todoEditProgress'), deps, people]);
}
// closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex
// zurück. Parameter: askIfDirty (optional, true = bei geänderten Eingaben
// zuerst nachfragen; wird von Esc und dem Klick auf den Hintergrund genutzt).
// Rückgabe: true, wenn tatsächlich geschlossen wurde.
window.closeTodoModal = function(askIfDirty){
if(askIfDirty && todoFormSnapshot() !== todoModalSnapshot){
if(!confirm('Die Änderungen an dieser Aufgabe verwerfen?')) return false;
}
document.getElementById('todoModal').style.display = 'none';
app.currentTodoIndex = null;
todoModalSnapshot = '';
return true;
};
// saveTodoEdit: übernimmt die Modal-Eingaben in den Plan – als neue Aufgabe
// oder Update der aktuell bearbeiteten. Fehlende End-/Startwerte werden als
// eintägige Aufgabe ergänzt; done/progress ergeben sich aus dem
// Status-Segment. Parameter: andNew (optional, true = nach dem Speichern
// gleich das Modal für die nächste Aufgabe öffnen, mit Zeitraum und
// Zuständigen als Vorbelegung). Rückgabe: keine.
window.saveTodoEdit = function(andNew){
if(app.isLocked()) return;
const title = document.getElementById('todoEditTitle').value.trim();
let start = document.getElementById('todoEditStart').value;
let end = document.getElementById('todoEditEnd').value;
// Nur ein Datum angegeben: als eintägige Aufgabe behandeln,
// sonst würde kein Balken im GANTT erscheinen.
if(start && !end) end = start;
if(end && !start) start = end;
const status = currentTodoEditStatus();
const done = status === 'fertig';
let progress;
if(status === 'offen') progress = 0;
else if(status === 'fertig') progress = 100;
else progress = parseInt(document.getElementById('todoEditProgress').value, 10) || 0;
clearFieldError('todoEditTitle');
clearFieldError('todoEditRange');
if(!title){
setFieldError('todoEditTitle', 'Bitte einen Titel eingeben.');
document.getElementById('todoEditTitle').focus();
return;
}
if(start && end && parseDateISO(end) < parseDateISO(start)){
setFieldError('todoEditRange', 'Das Enddatum liegt vor dem Startdatum.');
return;
}
const dependsOnList = document.getElementById('todoEditDependsOn');
const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : [];
const assigneesDiv = document.getElementById('todoEditAssignees');
const assignees = assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('.person-chip[aria-pressed="true"]')).map(btn => btn.dataset.personId) : [];
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.todos)) plan.todos = [];
if(app.currentTodoIndex === null){
plan.todos.push({ id: uid(), title, start, end, done, progress, dependsOn, assignees });
} else if(plan.todos[app.currentTodoIndex]){
const t = plan.todos[app.currentTodoIndex];
t.title = title;
t.start = start;
t.end = end;
t.done = done;
t.progress = progress;
t.dependsOn = dependsOn;
t.assignees = assignees;
}
app.savePlan();
// Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt
// die nächste Aufgabe mit denselben Rahmendaten öffnen.
todoModalSnapshot = todoFormSnapshot();
closeTodoModal();
renderPlanGantt();
if(andNew) openTodoModal(null, { start, end, assignees });
};
// deleteTodoEdit: löscht nach Bestätigung die aktuell bearbeitete Aufgabe,
// entfernt ihre Vorgänger-Verweise und setzt die relative Position
// betroffener Meilensteine zurück. Speichert und rendert neu.
window.deleteTodoEdit = function(){
if(app.isLocked()) return;
if(app.currentTodoIndex === null){ closeTodoModal(); return; }
if(!confirm('Aufgabe löschen?')) return;
const plan = app.ensurePlanLocal();
if(plan.todos && plan.todos[app.currentTodoIndex]){
const deletedId = plan.todos[app.currentTodoIndex].id;
plan.todos.splice(app.currentTodoIndex, 1);
app.removeDependencyRefs(plan, deletedId);
// Meilensteine, die relativ zu dieser TODO positioniert waren, zurücksetzen
(plan.milestones || []).forEach(m => {
if(m.position === 'before:' + deletedId || m.position === 'after:' + deletedId) m.position = '';
});
app.savePlan();
}
todoModalSnapshot = todoFormSnapshot();
closeTodoModal();
renderPlanGantt();
};
// addTodo: öffnet das TODO-Modal für eine neue Aufgabe.
window.addTodo = function(){
openTodoModal(null);
};
// toggleTodoDone: schaltet den Erledigt-Status der Aufgabe am Index um,
// speichert und rendert neu.
window.toggleTodoDone = function(idx){
if(app.isLocked()) return;
const plan = app.ensurePlanLocal();
if(!plan.todos[idx]) return;
plan.todos[idx].done = !plan.todos[idx].done;
app.savePlan();
renderPlanGantt();
};
// deleteTodo: löscht nach Bestätigung die Aufgabe am Index, bereinigt die
// Vorgänger-Verweise und die relative Meilenstein-Position. Speichert und
// rendert neu.
window.deleteTodo = function(idx){
if(app.isLocked()) return;
if(!confirm('TODO löschen?')) return;
const plan = app.ensurePlanLocal();
if(!plan.todos[idx]) return;
const deletedId = plan.todos[idx].id;
plan.todos.splice(idx, 1);
app.removeDependencyRefs(plan, deletedId);
(plan.milestones || []).forEach(m => {
if(m.position === 'before:' + deletedId || m.position === 'after:' + deletedId) m.position = '';
});
app.savePlan();
renderPlanGantt();
};
// ---------- Abwesenheits-Modal (Urlaube der Projektbeteiligten) ----------
// openAbsenceModal: öffnet das Abwesenheits-Modal für die übergebene
// Person-ID, setzt den Titel, leert die Formularfelder und rendert die
// Liste der bestehenden Abwesenheiten.
window.openAbsenceModal = function(personId){
const person = app.findGlobalPerson(personId);
if(!person) return;
app.currentAbsencePersonId = personId;
document.getElementById('absenceModalTitle').textContent = 'Abwesenheiten – ' + (person.fullName || person.name);
// Formularfelder zurücksetzen
const startInput = document.getElementById('absenceEditStart');
const endInput = document.getElementById('absenceEditEnd');
const rangeDisplay = document.getElementById('absenceEditRange');
if(startInput) startInput.value = '';
if(endInput) endInput.value = '';
if(rangeDisplay) rangeDisplay.value = '';
renderAbsenceModalList();
document.getElementById('absenceModal').style.display = 'flex';
};
// closeAbsenceModal: blendet das Abwesenheits-Modal aus und setzt die
// aktuelle Person-ID zurück.
window.closeAbsenceModal = function(){
document.getElementById('absenceModal').style.display = 'none';
app.currentAbsencePersonId = null;
};
// renderAbsenceModalList: rendert die Liste der Abwesenheiten der aktuellen
// Person im Modal (jeweils Typ, Zeitraum, Notiz und Lösch-Button). Bei
// gesperrter Woche wird das Formular ausgeblendet.
function renderAbsenceModalList(){
const listDiv = document.getElementById('absenceModalList');
const person = app.findGlobalPerson(app.currentAbsencePersonId);
if(!listDiv || !person) return;
const absences = Array.isArray(person.absences) ? person.absences : [];
const locked = app.isLocked();
if(absences.length === 0){
listDiv.innerHTML = 'Keine Abwesenheiten eingetragen.';
} else {
listDiv.innerHTML = '