// ----------------------------- // 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 += '
Meilensteine
'; plan.milestones.forEach(m => { if(currentMs && m.id === currentMs.id) return; const mId = 'm_' + m.id; const checked = currentDeps.includes(mId) ? 'checked' : ''; const sperre = msBlocked.has(mId) ? ' disabled title="Würde einen Zirkelbezug erzeugen"' : ''; listHtml += ``; }); } // TODOs als Vorgänger anbieten if(plan.todos && plan.todos.length > 0){ listHtml += '
Aufgaben
'; (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(); // Im Terminplan anzeigen: Vorgabe sichtbar (!== false, nicht === true — // eine Aufgabe ohne das Feld, z. B. aus einem alten Bestand, gilt // weiterhin als sichtbar, kein Bestandsverhalten kippt rückwirkend). // Bei "Speichern & Neu" übernimmt pre.showInGantt den zuletzt gesetzten // Wert (dieselben Rahmendaten wie Zeitraum/Zuständige, s. saveTodoEdit()). const showInGanttEl = document.getElementById('todoEditShowInGantt'); if(showInGanttEl) showInGanttEl.checked = isNew ? (pre.showInGantt !== undefined ? pre.showInGantt : true) : currentTodo.showInGantt !== false; 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 showInGanttEl = document.getElementById('todoEditShowInGantt'); const showInGantt = showInGanttEl ? showInGanttEl.checked : true; 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, showInGantt }); } 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; t.showInGantt = showInGantt; } 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, showInGantt }); }; // 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 = '
' + absences.map(a => `
${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))} ${a.note ? `${esc(a.note)}` : ''} ${locked ? '' : ``}
`).join('') + '
'; } const form = document.getElementById('absenceModalForm'); if(form) form.style.display = locked ? 'none' : ''; } // addPlanAbsence: legt aus den Formularfeldern eine neue Abwesenheit für die // aktuelle Person an. Schreibt direkt über POST /users/:id/absences (die // numerische User-ID wird über app.getUserIdForPersonId() aufgelöst); hat // die Person kein Login-Konto, wird eine klare Fehlermeldung gezeigt statt // eines stillen Fehlschlags. Bei Erfolg werden globale Personenliste und // lokale Sitzungs-Kopie mit der vom Server vergebenen ID aktualisiert, Liste // und GANTT neu gerendert. window.addPlanAbsence = function(){ if(app.isLocked()) return; const personId = app.currentAbsencePersonId; const person = app.findGlobalPerson(personId); if(!person) return; const start = document.getElementById('absenceEditStart').value; const end = document.getElementById('absenceEditEnd').value; const type = document.getElementById('absenceEditType').value; const note = document.getElementById('absenceEditNote').value.trim(); if(!start || !end){ alert('Bitte Start- und Enddatum angeben.'); return; } if(parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; } app.getUserIdForPersonId(personId).then(userId => { if(!userId){ alert('Für diese Person existiert kein Benutzerkonto. Abwesenheiten können nur für Personen mit Benutzerkonto gepflegt werden (siehe Benutzerverwaltung).'); return; } 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){ alert('Abwesenheit konnte nicht gespeichert werden: ' + ((json && json.error) || 'unbekannter Fehler')); return; } if(!Array.isArray(person.absences)) person.absences = []; person.absences.push(json.absence); person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start)); const localPerson = (app.week.people || []).find(p => p.id === personId); if(localPerson){ if(!Array.isArray(localPerson.absences)) localPerson.absences = []; localPerson.absences.push({ ...json.absence }); localPerson.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start)); } document.getElementById('absenceEditStart').value = ''; document.getElementById('absenceEditEnd').value = ''; const rangeDisplay = document.getElementById('absenceEditRange'); if(rangeDisplay) rangeDisplay.value = ''; document.getElementById('absenceEditNote').value = ''; renderAbsenceModalList(); renderPlanGantt(); }); }).catch(err => { console.error('Abwesenheit speichern fehlgeschlagen:', err); alert('Abwesenheit konnte nicht gespeichert werden: ' + (err.message || err)); }); }; // deletePlanAbsence: entfernt die Abwesenheit mit der übergebenen ID direkt // über DELETE /users/:id/absences/:absenceId. Kein Benutzerkonto gefunden -> // klare Fehlermeldung statt stillem Fehlschlag. Bei Erfolg werden globale // Personenliste und lokale Sitzungs-Kopie aktualisiert, Liste und GANTT neu // gerendert. window.deletePlanAbsence = function(absenceId){ if(app.isLocked()) return; const personId = app.currentAbsencePersonId; const person = app.findGlobalPerson(personId); if(!person || !Array.isArray(person.absences)) return; app.getUserIdForPersonId(personId).then(userId => { if(!userId){ alert('Für diese Person existiert kein Benutzerkonto. Abwesenheiten können nur für Personen mit Benutzerkonto gepflegt werden (siehe Benutzerverwaltung).'); return; } return fetch(baseUrl() + '/users/' + userId + '/absences/' + encodeURIComponent(absenceId), { method: 'DELETE' }) .then(r => r.json().then(json => ({ ok: r.ok, json }))).then(({ ok, json }) => { if(!ok || !json || !json.ok){ alert('Abwesenheit konnte nicht gelöscht werden: ' + ((json && json.error) || 'unbekannter Fehler')); return; } person.absences = person.absences.filter(a => a.id !== absenceId); const localPerson = (app.week.people || []).find(p => p.id === personId); if(localPerson && Array.isArray(localPerson.absences)){ localPerson.absences = localPerson.absences.filter(a => a.id !== absenceId); } renderAbsenceModalList(); renderPlanGantt(); }); }).catch(err => { console.error('Abwesenheit löschen fehlgeschlagen:', err); alert('Abwesenheit konnte nicht gelöscht werden: ' + (err.message || err)); }); }; // ---------- Tastatur- und Hintergrundbedienung (alle drei Dialoge) ---------- // MODAL_HANDLERS: Zuordnung Dialog → Schließen/Speichern. Zentral registriert, // damit Esc, Strg/Cmd+Enter und der Klick auf den Hintergrund in allen drei // Dialogen dieser Seite gleich funktionieren und nicht je Dialog nachgebaut // werden müssen. dirtyCheck = true: vor dem Schließen bei geänderten // Eingaben nachfragen (bislang nur im TODO-Dialog umgesetzt). const MODAL_HANDLERS = [ { id: 'todoModal', close: askIfDirty => closeTodoModal(askIfDirty), save: () => saveTodoEdit() }, { id: 'milestoneModal', close: () => closeMilestoneModal(), save: () => saveMilestoneEdit() }, { id: 'absenceModal', close: () => closeAbsenceModal(), save: null } ]; // visibleModal: liefert den Eintrag aus MODAL_HANDLERS, dessen Dialog gerade // sichtbar ist (es ist immer höchstens einer offen). Parameter: keine. // Rückgabe: Eintrag oder null. function visibleModal(){ for(const entry of MODAL_HANDLERS){ const el = document.getElementById(entry.id); if(el && el.style.display !== 'none' && el.style.display !== '') return entry; } return null; } document.addEventListener('keydown', function(e){ // Der Datums-/Bereichspicker liegt über dem Dialog und bekommt Escape // zuerst — sonst würde der ganze Dialog geschlossen, obwohl der Nutzer // nur den Kalender wegklicken wollte. if(e.key === 'Escape' && document.getElementById('rangePickerPopup')){ closeRangePicker(); e.preventDefault(); return; } const modal = visibleModal(); if(!modal) return; if(e.key === 'Escape'){ modal.close(true); e.preventDefault(); return; } // Speichern: Strg/Cmd+Enter überall, einfaches Enter nur in einzeiligen // Textfeldern (in einem