Files
projektuebersicht/public/js/plan/plan-modals.js
T
erikandClaude Opus 5.5 b7e0bd0d92 Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb
- Termin aus Mail: Mailtext oder .eml per Drag & Drop/Strg+V auf Terminplan
  oder Aufgaben, KI schlägt Aufgaben vor, Prüfung im Aufgaben-Panel; neues
  Notizfeld je Aufgabe (plan_todos.notiz)
- Terminplan: Meilensteine ohne Aufgabenbezug je in eigener Zeile,
  "+ Neu"-Menü klappt neben schmaler Seitenleiste nach rechts, Leistungsphasen
- Rollen & Rechte, Abwesenheiten-Seite, Benachrichtigungen, Rundgang
- Glas-Darstellung, Logo als Favicon
- Pi-Betrieb: PDF-Arbeit im Kindprozess, Wächter, Einrichtungs- und
  Prüfskript inkl. WLAN-Wächter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:16:52 +01:00

1021 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// Projektplan — 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 = '<option value="">Oben, eigene Zeile</option>';
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 += `<option value="${esc(beforeVal)}" ${beforeSel}>Vor: ${esc(t.title || 'TODO')}</option>`;
opts += `<option value="${esc(afterVal)}" ${afterSel}>Nach: ${esc(t.title || 'TODO')}</option>`;
});
}
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 += '<div class="todo-depends-group"><b>Meilensteine</b></div>';
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 += `<label class="todo-depends-item${msBlocked.has(mId) ? ' is-blocked' : ''}"><input type="checkbox" value="${esc(mId)}" ${checked}${sperre}> ${icon('flag')} ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})</label>`;
});
}
// TODOs als Vorgänger anbieten
if(plan.todos && plan.todos.length > 0){
listHtml += '<div class="todo-depends-group"><b>Aufgaben</b></div>';
(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 += `<label class="todo-depends-item${msBlocked.has(t.id) ? ' is-blocked' : ''}"><input type="checkbox" value="${esc(t.id)}" ${checked}${sperre}> ${esc(t.title || 'TODO')}${datesStr}</label>`;
});
}
if(listHtml === '') listHtml = '<span class="hint">Keine anderen Aufgaben oder Meilensteine vorhanden.</span>';
dependsOnList.innerHTML = listHtml;
}
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;
};
// 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 = '';
// 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';
}
// 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 <span class="field-error" id="<feldId>Error">
// 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; });
};
// ---------------------------------------------------------------------------
// 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;
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 `<option value="${esc(l)}"${vergeben.has(l) ? ' disabled' : ''}>${esc(phasenTitel(l) + zusatz)}</option>`;
}).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.
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) : '';
}
todoPhasenModusSetzen(isNew ? !!pre.phase : !!currentTodo.lph, isNew ? null : currentTodo, isNew, isNew ? pre.lph : null);
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 += '<div class="todo-depends-group"><b>Meilensteine</b></div>';
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 += '<div class="todo-depends-group"><b>Aufgaben</b></div>';
(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 = '<span class="hint">Keine anderen Aufgaben oder Meilensteine vorhanden.</span>';
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);
}
});
// Bereits Zuständige, die (nicht mehr) dem Projekt zugeordnet sind —
// etwa über "Aufgabe aus Kommentar" aus dem ganzen Teilnehmerkreis
// gewählt — ebenfalls als Chip zeigen. Ohne sie gingen sie beim
// Speichern stillschweigend verloren, weil saveTodoEdit() nur die
// gedrückten Chips liest.
currentAssignees.forEach(id => {
if(seenPeople.has(id)) return;
seenPeople.add(id);
const p = ((app.week && app.week.people) || []).find(x => x.id === id) || { id, name: id, fullName: id };
uniquePeople.push(Object.assign({}, p, { nichtImProjekt: true }));
});
// 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 || '';
const fremd = person.nichtImProjekt ? ' title="Nicht (mehr) dem Projekt zugeordnet"' : '';
return `<button type="button" class="person-chip${person.nichtImProjekt ? ' ist-fremd' : ''}"${fremd} data-person-id="${esc(person.id)}" aria-pressed="${pressed}" onclick="toggleTodoAssignee(this)">`
+ `<span class="person-chip-ini">${esc(short)}</span>${esc(full)}</button>`;
}).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
}
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 `<label class="todo-depends-item is-blocked" title="Würde einen Zirkelbezug erzeugen">`
+ `<input type="checkbox" value="${esc(value)}" disabled> ${label}`
+ `<span class="todo-depends-note">Zirkelbezug</span></label>`;
}
return `<label class="todo-depends-item"><input type="checkbox" value="${esc(value)}" ${checked}> ${label}</label>`;
}
// 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);
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;
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) : [];
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 = '<span class="hint">Keine Abwesenheiten eingetragen.</span>';
} else {
listDiv.innerHTML = '<div class="absence-list">' + absences.map(a => `
<div class="absence-chip">
<span>${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))}</span>
${a.note ? `<small>${esc(a.note)}</small>` : ''}
${locked ? '' : `<button class="danger small" onclick="deletePlanAbsence('${esc(a.id)}')">×</button>`}
</div>`).join('') + '</div>';
}
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 <textarea> wäre das ein Zeilenumbruch).
if(e.key === 'Enter' && modal.save){
const target = e.target;
// Das Suchfeld der Vorgängerliste ist ausgenommen: Enter ist dort der
// Reflex zum Filtern, schloss aber den ganzen Dialog — ohne dass ein
// Vorgänger angehakt war.
const isSearchField = target && target.id === 'todoEditDependsSearch';
const inTextInput = target && target.tagName === 'INPUT' && target.type === 'text' && !isSearchField;
if((e.metaKey || e.ctrlKey) || inTextInput){
modal.save();
e.preventDefault();
}
}
});
// Klick auf den abgedunkelten Hintergrund schließt den Dialog; Klicks
// innerhalb des Dialogs (e.target !== Hintergrund) bleiben unberührt.
MODAL_HANDLERS.forEach(entry => {
const el = document.getElementById(entry.id);
if(!el) return;
el.addEventListener('mousedown', function(e){
if(e.target === el) entry.close(true);
});
});
})();