// ----------------------------- // 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. // msNachSchliessen: wie todoNachSchliessen — "Termin aus Mail" öffnet // damit nach dem Schließen den nächsten Vorschlag. let msNachSchliessen = null; // openMilestoneModal: Parameter index (null = neu), prefill (optional, nur // bei neu: name, date, notiz, mailHinweis, nachSchliessen). window.openMilestoneModal = function(index, prefill){ if(app.isLocked()) return; const pre = prefill || {}; 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 ? (pre.name || '') : (currentMs.name || ''); const msNotiz = document.getElementById('milestoneEditNotiz'); if(msNotiz) msNotiz.value = isNew ? (pre.notiz || '') : (currentMs.notiz || ''); const msMailHinweis = document.getElementById('milestoneEditMailHinweis'); if(msMailHinweis){ msMailHinweis.hidden = !(isNew && pre.mailHinweis); msMailHinweis.textContent = (isNew && pre.mailHinweis) || ''; } msNachSchliessen = isNew && typeof pre.nachSchliessen === 'function' ? pre.nachSchliessen : null; // Verstecktes ISO-Feld + sichtbares Anzeigefeld (deutsches Format) — // dieselbe Aufteilung wie bei allen anderen Datumseingaben der Seite. const msDateIso = isNew ? (pre.date || '') : (currentMs.date || ''); document.getElementById('milestoneEditDate').value = msDateIso; const msDateDisplay = document.getElementById('milestoneEditDateDisplay'); if(msDateDisplay) msDateDisplay.value = msDateIso ? formatDateDE(parseDateISO(msDateIso)) : ''; const msErreicht = document.getElementById('milestoneEditErreicht'); if(msErreicht) msErreicht.checked = !isNew && !!currentMs.erreicht; panelWoSetzen('milestoneModalWo', ''); 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; } window.msLageSync(); // Seitenpanel: Überschrift und "Löschen" nur beim Bearbeiten — vorher // stand "Löschen" auch beim Anlegen eines neuen Meilensteins. const msTitel = document.getElementById('milestoneModalTitle'); if(msTitel) msTitel.textContent = isNew ? 'Neuer Meilenstein' : 'Meilenstein bearbeiten'; const msLoeschen = document.getElementById('milestoneEditDelete'); if(msLoeschen) msLoeschen.hidden = isNew; 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; } msLageBildNeu(); document.getElementById('milestoneModal').style.display = 'flex'; document.getElementById('milestoneEditName').focus(); }; // msLageSync/msLageSetzen: "Wo im Terminplan" als Umschalter "In der // Projektzeile | Bei einer Aufgabe" über dem bisherigen Auswahlfeld // (#milestoneEditPosition, Wert '' = Projektzeile, sonst before:/after: // einer Aufgabe). Gespeichert wird weiter allein der Wert des Felds. window.msLageSync = function(){ const sel = document.getElementById('milestoneEditPosition'); const seg = document.getElementById('milestoneEditLage'); if(!sel || !seg) return; const beiAufgabe = !!sel.value; const hatAufgaben = sel.options.length > 1; seg.querySelectorAll('button').forEach(b => { b.setAttribute('aria-pressed', String((b.dataset.lage === 'aufgabe') === beiAufgabe)); if(b.dataset.lage === 'aufgabe') b.disabled = !hatAufgaben; }); sel.hidden = !beiAufgabe; // Die Option "Oben, eigene Zeile" steht schon im Umschalter. if(sel.options[0] && sel.options[0].value === '') sel.options[0].hidden = true; }; window.msLageSetzen = function(art){ const sel = document.getElementById('milestoneEditPosition'); if(!sel) return; if(art === 'zeile') sel.value = ''; else if(!sel.value){ const nachOptionen = Array.from(sel.options).filter(o => o.value.indexOf('after:') === 0); if(nachOptionen.length) sel.value = nachOptionen[nachOptionen.length - 1].value; } window.msLageSync(); }; // closeMilestoneModal: blendet das Meilenstein-Modal aus und setzt den // Bearbeitungsindex zurück. window.closeMilestoneModal = function(){ document.getElementById('milestoneModal').style.display = 'none'; app.currentMilestoneIndex = null; if(currentTab === 'wochenbericht') berichtAufgabenNeu(); const danach = msNachSchliessen; msNachSchliessen = null; if(danach) setTimeout(danach, 0); }; // 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 msNotizEl = document.getElementById('milestoneEditNotiz'); const notiz = msNotizEl ? msNotizEl.value.trim() : ''; const msErreichtEl = document.getElementById('milestoneEditErreicht'); const erreicht = !!(msErreichtEl && msErreichtEl.checked); const plan = app.ensurePlanLocal(); if(!Array.isArray(plan.milestones)) plan.milestones = []; if(app.currentMilestoneIndex === null){ plan.milestones.push({ id: uid(), name, date, dependsOn, position, notiz, erreicht }); } else if(plan.milestones[app.currentMilestoneIndex]){ const m = plan.milestones[app.currentMilestoneIndex]; m.name = name; m.date = date; m.dependsOn = dependsOn; m.position = position; m.notiz = notiz; m.erreicht = erreicht; } 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 = ''; // todoNachSchliessen: Rückruf nach dem Schließen des Panels (gespeichert // oder verworfen) — "Termin aus Mail" öffnet damit den nächsten Vorschlag. let todoNachSchliessen = null; // 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'; } // ---------- Planblatt: Kopfzeile, Lage im Terminplan, Stand ---------- // kurzDatum: "2026-11-19" -> "19.11." Parameter: iso. Rückgabe: String. function kurzDatum(iso){ return formatDateDE(parseDateISO(iso)).slice(0, 6); } // planKnoten: Aufgabe oder Meilenstein zu einer Knoten-id (Meilensteine mit // Präfix "m_", wie in dependsOn). Rückgabe: {titel, start, end, ms, fertig} oder null. function planKnoten(plan, id){ id = String(id || ''); if(id.indexOf('m_') === 0){ const m = (plan.milestones || []).find(x => 'm_' + x.id === id); return m ? { id, titel: m.name || 'Meilenstein', start: m.date || '', end: m.date || '', ms: true, fertig: !!m.erreicht } : null; } const t = (plan.todos || []).find(x => String(x.id) === id); return t ? { id, titel: t.title || 'Aufgabe', start: t.start || t.end || '', end: t.end || t.start || '', ms: false, fertig: !!t.done } : null; } // panelWoSetzen: Projekt (Nummer, Name) und optional Phase in der Kopfzeile. function panelWoSetzen(elId, phase){ const el = document.getElementById(elId); if(!el) return; const p = app.project || {}; el.innerHTML = (p.nr ? `${esc(p.nr)} ` : '') + esc(p.name || '') + (phase ? ' · ' + esc(phase) : ''); } // LAGE_TAGE: der Ausschnitt im Panelkopf — sechs Kalenderwochen ab dem // Montag der Woche VOR dem Eintrag. const LAGE_TAGE = 42; // lageBildHtml: Ausschnitt des Terminplans um einen Eintrag. Oben die // Vorgänger (grau, wenn erledigt), in der Mitte der Eintrag selbst, unten // die Nachfolger; dazu die Heute-Linie. Nur Anzeige. // e = {id (Knoten-id oder null), start, end, ms, progress, fertig, deps}. // Rückgabe: HTML. function lageBildHtml(e){ const plan = app.ensurePlanLocal(); const s = parseDateISO(e.start || e.end), en = parseDateISO(e.end || e.start); if(!s || !en) return '
Noch kein Termin gewählt
'; const vor = (e.deps || []).map(id => planKnoten(plan, id)).filter(k => k && k.start); // Ausschnitt beginnt eine Woche vor dem Eintrag, früher, wenn ein // Vorgänger davor endet (höchstens drei Wochen), damit er sichtbar bleibt. const mo = new Date(s); mo.setDate(mo.getDate() - 7); const grenze = new Date(s); grenze.setDate(grenze.getDate() - 21); vor.forEach(k => { const v = parseDateISO(k.end); if(v && v < mo) mo.setTime(Math.max(v.getTime() - 2 * 864e5, grenze.getTime())); }); mo.setDate(mo.getDate() - ((mo.getDay() + 6) % 7)); const tag = 100 / LAGE_TAGE; const x = d => daysBetween(mo, d) * tag; const nach = e.id ? (plan.todos || []).filter(t => (t.dependsOn || []).includes(e.id)).map(t => planKnoten(plan, t.id)) .concat((plan.milestones || []).filter(m => (m.dependsOn || []).includes(e.id)).map(m => planKnoten(plan, 'm_' + m.id))) .filter(k => k && k.start) : []; const form = (k, y, klasse, progress) => { const a = parseDateISO(k.start), b = parseDateISO(k.end) || a; if(k.ms){ const l = x(a) + tag / 2; return l < 0 || l > 100 ? '' : ``; } const l = Math.max(0, x(a)), r = Math.min(100, x(b) + tag); if(r <= l) return ''; return `` + (progress > 0 ? `` : '') + ''; }; const ich = { titel: '', start: e.start || e.end, end: e.end || e.start, ms: e.ms }; const ichKlasse = 'is-diese' + (e.fertig ? ' is-fertig' : '') + (e.ueber ? ' is-ueber' : ''); let flaeche = vor.slice(0, 3).map(k => form(k, 4, k.fertig ? 'is-fertig' : 'is-vor', 0)).join('') + form(ich, e.ms ? 18 : 20, ichKlasse, e.ms ? 0 : e.progress) + nach.slice(0, 3).map(k => form(k, 36, 'is-nach', 0)).join(''); const heute = parseDateISO(heuteIso()); const hx = x(heute) + tag / 2; if(hx >= 0 && hx <= 100) flaeche += ``; const skala = [0, 1, 2, 3, 4, 5].map(i => { const d = new Date(mo); d.setDate(d.getDate() + i * 7); return `KW ${String(getWeekNumber(d)).padStart(2, '0')}`; }).join(''); const legende = []; if(vor[0]) legende.push(`${esc(vor[0].titel)}${vor[0].fertig ? ', erledigt' : ''}`); if(hx >= 0 && hx <= 100) legende.push(`heute ${esc(kurzDatum(heuteIso()))}`); if(nach[0]) legende.push(`danach ${esc(nach[0].titel)} ${esc(kurzDatum(nach[0].start))}`); return `
${skala}
${flaeche}
` + (legende.length ? `
${legende.join('')}
` : ''); } // todoLageNeu: zeichnet die Lage der Aufgabe aus den aktuellen Eingaben. window.todoLageNeu = function(){ const el = document.getElementById('todoLageBild'); if(!el || !app.project) return; const plan = app.ensurePlanLocal(); const t = app.currentTodoIndex !== null ? plan.todos[app.currentTodoIndex] : null; const status = currentTodoEditStatus(); const slider = document.getElementById('todoEditProgress'); el.innerHTML = lageBildHtml({ id: t ? t.id : null, start: document.getElementById('todoEditStart').value, end: document.getElementById('todoEditEnd').value, ms: false, fertig: status === 'fertig', progress: status === 'fertig' ? 100 : (status === 'arbeit' && slider ? Number(slider.value) : 0), deps: Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value) }); }; // msLageBildNeu: Lage, KW-Marke und Überfällig-Hinweis des Meilensteins. window.msLageBildNeu = function(){ const el = document.getElementById('msLageBild'); if(!el || !app.project) return; const plan = app.ensurePlanLocal(); const m = app.currentMilestoneIndex !== null ? plan.milestones[app.currentMilestoneIndex] : null; const datum = document.getElementById('milestoneEditDate').value; const erreicht = document.getElementById('milestoneEditErreicht').checked; const heute = heuteIso(); const ueber = !!datum && !erreicht && datum < heute; el.innerHTML = lageBildHtml({ id: m ? 'm_' + m.id : null, start: datum, end: datum, ms: true, fertig: erreicht, ueber, deps: Array.from(document.querySelectorAll('#milestoneEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value) }); const kw = document.getElementById('milestoneEditKw'); const d = parseDateISO(datum); if(kw){ kw.hidden = !d; kw.textContent = d ? 'KW ' + String(getWeekNumber(d)).padStart(2, '0') : ''; } document.querySelectorAll('#milestoneEditStand button').forEach(b => b.setAttribute('aria-pressed', String((b.dataset.stand === 'erreicht') === erreicht))); const warn = document.getElementById('milestoneEditWarnung'); if(warn){ const tage = ueber ? daysBetween(parseDateISO(datum), parseDateISO(heute)) : 0; warn.hidden = !ueber; warn.textContent = ueber ? 'seit ' + (tage === 1 ? '1 Tag' : tage + ' Tagen') + ' überfällig' : ''; } }; // msStandSetzen: Umschalter "Offen | Erreicht" — schreibt das (versteckte) // Feld #milestoneEditErreicht, das saveMilestoneEdit() liest. window.msStandSetzen = function(erreicht){ document.getElementById('milestoneEditErreicht').checked = !!erreicht; msLageBildNeu(); }; // 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 + ' %'; } todoLageNeu(); } // 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 + ' %'; todoLageNeu(); }; // 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'); todoLageNeu(); }; // 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); const summary = document.getElementById('todoEditDependsSummary'); if(summary){ const plan = app.ensurePlanLocal(); summary.innerHTML = Array.from(list.querySelectorAll('input[type="checkbox"]:checked')) .map(cb => planKnoten(plan, cb.value)).filter(Boolean) .map(k => `
` + `${esc(k.titel)}` + `${k.end ? 'bis ' + esc(kurzDatum(k.end)) : 'ohne Termin'}
`).join(''); summary.hidden = !summary.innerHTML; } todoLageNeu(); }; // 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; }); }; // --------------------------------------------------------------------------- // Leistungsphase als Balken (Eriks Wunsch): "+ Neu" → "Leistungsphase" // öffnet dasselbe Panel im Phasen-Modus. Ein Balken ist NUR über das Feld // t.lph eine Phase (balkenPhase(), load.js) — nicht mehr über seinen Titel. // --------------------------------------------------------------------------- const LPH_NAMEN = { 'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung', 'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe', 'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung', 'W+M': 'Wartung und Monitoring' }; let todoPhasenModus = false; let todoPhaseAutoTitel = ''; const phasenTitel = l => l ? (l + (LPH_NAMEN[l] ? ' ' + LPH_NAMEN[l] : '')) : ''; // addPlanPhase: "+ Neu" → "Leistungsphase". // Optional lph: diese Phase vorwählen (Bereich "Leistungsphasen", Kachel // ohne Zeitraum). window.addPlanPhase = function(lph){ openTodoModal(null, { phase: true, lph: typeof lph === 'string' ? lph : '' }); }; // todoPhaseGewechselt: Auswahl der Phase geändert — den vorgeschlagenen // Titel mitziehen, solange niemand ihn von Hand geändert hat. window.todoPhaseGewechselt = function(){ const sel = document.getElementById('todoEditLph'); const titel = document.getElementById('todoEditTitle'); if(!sel || !titel) return; if(!titel.value.trim() || titel.value === todoPhaseAutoTitel){ todoPhaseAutoTitel = phasenTitel(sel.value); titel.value = todoPhaseAutoTitel; } }; // todoPhasenModusSetzen: Panel für eine Leistungsphase herrichten (Auswahl // füllen, "Wer"/"Speichern & neu" ausblenden) oder zurück zur Aufgabe. function todoPhasenModusSetzen(an, todo, isNew, vorgabeLph){ todoPhasenModus = an; const abs = document.getElementById('todoEditPhaseAbs'); const wer = document.getElementById('todoEditWerAbs'); const neu = document.getElementById('todoEditSaveNew'); if(abs) abs.hidden = !an; if(wer) wer.hidden = an; const aufwandAbs = document.getElementById('todoEditAufwandAbs'); if(aufwandAbs) aufwandAbs.hidden = an; if(neu) neu.hidden = an; const titelEl = document.getElementById('todoModalTitle'); if(titelEl) titelEl.textContent = an ? (isNew ? 'Neue Leistungsphase' : 'Leistungsphase bearbeiten') : (isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten'); if(!an) return; const plan = app.ensurePlanLocal(); const vergeben = new Set((plan.todos || []).filter(t => t !== todo && t.lph).map(t => t.lph)); const beauftragt = Array.isArray(app.project.beauftragteLP) ? app.project.beauftragteLP : []; const sel = document.getElementById('todoEditLph'); if(!sel) return; sel.innerHTML = LPHS.map(l => { const zusatz = vergeben.has(l) ? ' (schon im Plan)' : (beauftragt.length && !beauftragt.includes(l) ? ' (nicht beauftragt)' : ''); return ``; }).join(''); const frei = l => !vergeben.has(l); sel.value = (todo && todo.lph) || (vorgabeLph && frei(vorgabeLph) ? vorgabeLph : '') || LPHS.find(l => frei(l) && (!beauftragt.length || beauftragt.includes(l))) || LPHS.find(frei) || ''; if(isNew){ todoPhaseAutoTitel = phasenTitel(sel.value); document.getElementById('todoEditTitle').value = todoPhaseAutoTitel; } else { todoPhaseAutoTitel = ''; } } // 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. // ---------- Aufwand: −/+, Schnellwahl, Umrechnung ---------- // Ein Arbeitstag = APP_SETTINGS.arbeitsstundenProTag (Admin → Termine). function stundenProTag(){ return Number(APP_SETTINGS && APP_SETTINGS.arbeitsstundenProTag) || 8; } function aufwandLesen(){ const el = document.getElementById('todoEditAufwand'); return el ? Math.max(0, parseFloat(String(el.value).replace(',', '.')) || 0) : 0; } function aufwandSchreiben(h){ const el = document.getElementById('todoEditAufwand'); if(!el) return; const w = Math.max(0, Math.round(h * 2) / 2); el.value = w > 0 ? String(w) : ''; todoAufwandInfo(); } window.todoAufwandAendern = function(delta){ aufwandSchreiben(aufwandLesen() + delta); }; // todoAufwandSetzen(h) oder todoAufwandSetzen('tag', n): fester Wert; ein // zweiter Klick auf den gewählten Wert leert das Feld. window.todoAufwandSetzen = function(a, n){ const h = a === 'tag' ? n * stundenProTag() : Number(a); aufwandSchreiben(aufwandLesen() === h ? 0 : h); }; // todoAufwandInfo: Umrechnung in Arbeitstage und Markierung der Schnellwahl. window.todoAufwandInfo = function(){ const h = aufwandLesen(); const tag = stundenProTag(); const info = document.getElementById('todoAufwandInfoText'); if(info){ const tage = h / tag; const zahl = x => (Math.round(x * 10) / 10).toLocaleString('de-DE'); info.textContent = h > 0 ? '≈ ' + zahl(tage) + (Math.abs(tage - 1) < 0.05 ? ' Arbeitstag' : ' Arbeitstage') + ' (' + zahl(tag) + ' h je Tag)' : 'Ohne Aufwand zählt die Aufgabe nicht zur Auslastung.'; } document.querySelectorAll('#todoAufwandSchnell button').forEach(b => { const wert = b.dataset.tage ? Number(b.dataset.tage) * tag : Number(b.dataset.h); b.setAttribute('aria-pressed', String(h > 0 && Math.abs(wert - h) < 0.01)); }); }; // ---------- Zuständige: Projekt und „Externe Personen“ ---------- // todoProjektPersonen: die dem Projekt zugeordneten Personen (je einmal). function todoProjektPersonen(){ const gesehen = new Set(); const liste = []; projectAssignees(app.project, app.week).forEach(a => { if(gesehen.has(a.person.id)) return; gesehen.add(a.person.id); liste.push(a.person); }); return liste; } function todoPersonById(id){ return ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id }; } function todoPersonChip(person, gedrueckt, extern){ const short = person.name || initialsForName(person.fullName || '') || '?'; const full = person.fullName || person.name || ''; const klick = extern ? `todoExternEntfernen('${esc(person.id)}')` : 'toggleTodoAssignee(this)'; const titel = extern ? ' title="Nicht im Projekt — Klick entfernt die Person"' : ''; return ``; } // todoExterneSetzen: zeichnet die gewählten externen Personen und die // Auswahl der übrigen Kollegen (Teilnehmerkreis der Sitzung ohne Projekt // und ohne bereits gewählte), nach Gewerk gruppiert. function todoExterneSetzen(ids){ const box = document.getElementById('todoEditExterne'); const wahl = document.getElementById('todoEditExterneWahl'); if(!box || !wahl) return; box.innerHTML = ids.map(id => todoPersonChip(todoPersonById(id), true, true)).join(''); const projektIds = new Set(todoProjektPersonen().map(p => p.id)); const frei = ((app.week && app.week.people) || []) .filter(p => p && p.id && !projektIds.has(p.id) && !ids.includes(p.id)) .sort((a, b) => String(a.fullName || a.name).localeCompare(String(b.fullName || b.name), 'de')); const gruppen = new Map(); frei.forEach(p => { const g = (typeof personGewerk === 'function' && personGewerk(p)) || 'Ohne Gewerk'; if(!gruppen.has(g)) gruppen.set(g, []); gruppen.get(g).push(p); }); const reihenfolge = [...(typeof DEPARTMENTS !== 'undefined' ? Object.keys(DEPARTMENTS) : []), 'Ohne Gewerk']; const schluessel = [...gruppen.keys()].sort((a, b) => reihenfolge.indexOf(a) - reihenfolge.indexOf(b)); wahl.innerHTML = `` + schluessel.map(g => `${gruppen.get(g).map(p => ``).join('')}`).join(''); wahl.disabled = !frei.length; } function todoExterneIds(){ return Array.from(document.querySelectorAll('#todoEditExterne .person-chip')).map(b => b.dataset.personId); } window.todoExternHinzufuegen = function(id){ if(!id) return; const ids = todoExterneIds(); if(!ids.includes(id)) ids.push(id); todoExterneSetzen(ids); }; window.todoExternEntfernen = function(id){ todoExterneSetzen(todoExterneIds().filter(x => x !== id)); }; 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 ? (pre.title || '') : (currentTodo.title || ''); const notizEl = document.getElementById('todoEditNotiz'); if(notizEl) notizEl.value = isNew ? (pre.notiz || '') : (currentTodo.notiz || ''); // Hinweis über der Notiz, nur bei einem Vorschlag aus "Termin aus Mail". const mailHinweisEl = document.getElementById('todoEditMailHinweis'); if(mailHinweisEl){ mailHinweisEl.hidden = !(isNew && pre.mailHinweis); mailHinweisEl.textContent = (isNew && pre.mailHinweis) || ''; } todoNachSchliessen = isNew && typeof pre.nachSchliessen === 'function' ? pre.nachSchliessen : null; const saveNewBtn = document.getElementById('todoEditSaveNew'); if(saveNewBtn) saveNewBtn.hidden = !!todoNachSchliessen; // 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 aufwandEl = document.getElementById('todoEditAufwand'); if(aufwandEl){ const aufwandWert = isNew ? Number(pre.aufwand) : Number(currentTodo.aufwand); aufwandEl.value = aufwandWert > 0 ? String(aufwandWert) : ''; } todoAufwandInfo(); todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null); panelWoSetzen('todoModalWo', currentTodo && typeof aufgabenPhase === 'function' ? aufgabenPhase(currentTodo, plan.todos) : ''); 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(); // Gewählte Vorgänger stehen offen darüber (#todoEditDependsSummary), die // Auswahlliste bleibt zu, bis man sie ändern will. const fold = document.getElementById('todoEditDependsFold'); if(fold) fold.open = false; const assigneesDiv = document.getElementById('todoEditAssignees'); if(assigneesDiv){ const currentAssignees = currentTodo ? (Array.isArray(currentTodo.assignees) ? currentTodo.assignees : []) : (Array.isArray(pre.assignees) ? pre.assignees : []); const projektLeute = todoProjektPersonen(); const projektIds = new Set(projektLeute.map(p => p.id)); // Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die // Personenspalten im Projektboard. assigneesDiv.innerHTML = projektLeute.map(person => todoPersonChip(person, currentAssignees.includes(person.id), false) ).join('') || 'Keine Projektzuordnungen im Board.'; // Zuständige außerhalb des Projekts stehen unter „Externe Personen“ — // auch solche, die das Projekt inzwischen verlassen haben (sonst gingen // sie beim Speichern stillschweigend verloren). todoExterneSetzen(currentAssignees.filter(id => !projektIds.has(id))); } 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"], #todoEditExterne .person-chip')).map(b => b.dataset.personId); const gantt = document.getElementById('todoEditShowInGantt'); return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'), currentTodoEditStatus(), read('todoEditProgress'), deps, people, gantt ? gantt.checked : true, read('todoEditAufwand'), read('todoEditLph'), read('todoEditNotiz')]); } // 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; if(currentTab === 'wochenbericht') berichtAufgabenNeu(); todoModalSnapshot = ''; const danach = todoNachSchliessen; todoNachSchliessen = null; if(danach) setTimeout(danach, 0); 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) : []) .concat(todoExterneIds()); const showInGanttEl = document.getElementById('todoEditShowInGantt'); const showInGantt = showInGanttEl ? showInGanttEl.checked : true; // Aufwand in Stunden (optional): zählt zur Auslastung der Zuständigen. const aufwandEl = document.getElementById('todoEditAufwand'); const aufwand = aufwandEl ? Math.max(0, parseFloat(String(aufwandEl.value).replace(',', '.')) || 0) : 0; // Phasen-Modus: welche Leistungsphase dieser Balken ist. const lphSel = document.getElementById('todoEditLph'); const lph = todoPhasenModus && lphSel ? lphSel.value : ''; const notizEl = document.getElementById('todoEditNotiz'); const notiz = notizEl ? notizEl.value.trim() : ''; if(todoPhasenModus && !lph){ alert('Bitte eine Leistungsphase wählen.'); return; } const plan = app.ensurePlanLocal(); if(!Array.isArray(plan.todos)) plan.todos = []; if(app.currentTodoIndex === null){ plan.todos.push(Object.assign({ id: uid(), title, start, end, done, progress, dependsOn, assignees, showInGantt, aufwand, notiz }, lph ? { lph, assignees: [], aufwand: 0 } : {})); } 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; t.aufwand = aufwand; t.notiz = notiz; if(todoPhasenModus) t.lph = lph; } 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; const t = plan.todos[idx]; t.done = !t.done; // Fortschritt mitführen: Sonst blieb nach dem Zurücknehmen "100 %" stehen, // und der Dialog las daraus wieder "fertig" (todoStatusFrom()). if(t.done) t.progress = 100; else if(Number(t.progress) >= 100) t.progress = 0; 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