alle new
This commit is contained in:
erik committed 2026-10-01 06:46:51 +02:00
1 parent da64472251
commit 5b20adee48
1605 files changed
+396517 -1674

No files matched your search

+494 -100
View File
@@ -3,12 +3,23 @@
// -----------------------------
// Dieses Modul stellt die Öffnen-/Schließen-/Speichern-/Löschen-Funktionen
// der drei Dialoge der Projektplan-Seite bereit: Meilenstein, TODO und
// Abwesenheiten. Der Bearbeitungszustand liegt in window.PlanApp
// 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. Alle Änderungen werden über app.savePlan bzw.
// app.saveGlobalPeople gespeichert und das GANTT über renderPlanGantt
// (plan-gantt.js) neu gezeichnet. Bei gesperrten Wochen (app.isLocked) sind
// alle Bearbeitungsfunktionen deaktiviert.
// 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).
@@ -33,7 +44,12 @@
const isNew = index === null || plan.milestones[index] === undefined;
const currentMs = isNew ? null : plan.milestones[index];
document.getElementById('milestoneEditName').value = isNew ? '' : (currentMs.name || '');
document.getElementById('milestoneEditDate').value = isNew ? '' : (currentMs.date || '');
// 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){
@@ -60,13 +76,19 @@
let listHtml = '';
// Andere Meilensteine als Vorgänger anbieten
if(plan.milestones.length > 1){
// 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' : '';
listHtml += `<label class="todo-depends-item"><input type="checkbox" value="${esc(mId)}" ${checked}> 🚩 ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})</label>`;
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>`;
});
}
@@ -76,7 +98,8 @@
(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))})` : '';
listHtml += `<label class="todo-depends-item"><input type="checkbox" value="${esc(t.id)}" ${checked}> ${esc(t.title || 'TODO')}${datesStr}</label>`;
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>';
@@ -153,11 +176,195 @@
// ---------- TODO-Modal ----------
// TODO_STATUS_VALUES: die drei Zustände des Status-Segments. Sie ersetzen im
// Dialog das frühere Paar aus "Erledigt"-Haken und Fortschrittsregler, das
// denselben Sachverhalt zweimal abgebildet hat (und dabei auseinanderlaufen
// konnte). Gespeichert wird unverändert das bisherige Datenmodell aus
// done (Boolean) + progress (0–100) — siehe todoStatusFrom()/applyTodoStatus().
const TODO_STATUS_VALUES = ['offen', 'arbeit', 'fertig'];
// todoModalSnapshot: Stand der Eingaben beim Öffnen — Vergleichsbasis für
// die Rückfrage beim Abbrechen (siehe todoFormSnapshot()).
let todoModalSnapshot = '';
// todoProgressMemo: zuletzt im Dialog eingestellter Fortschritt. "Offen" und
// "Erledigt" haben feste Werte (0/100); wer von dort zurück auf "In Arbeit"
// wechselt, bekommt darüber seinen vorherigen Wert wieder statt eines
// Ersatzwerts. Gilt nur für den geöffneten Dialog.
let todoProgressMemo = 50;
// todoStatusFrom: leitet den Segment-Status aus dem gespeicherten TODO ab.
// done gewinnt immer; sonst entscheidet der Fortschritt (0 % = offen,
// 100 % = fertig, dazwischen = in Arbeit). Parameter: todo (Objekt oder
// null für eine neue Aufgabe). Rückgabe: String aus TODO_STATUS_VALUES.
function todoStatusFrom(todo){
if(!todo) return 'offen';
if(todo.done) return 'fertig';
const progress = parseInt(todo.progress, 10) || 0;
if(progress >= 100) return 'fertig';
return progress > 0 ? 'arbeit' : 'offen';
}
// applyTodoStatus: schreibt den Status in das Segment und blendet den
// Fortschrittsregler nur im Zustand "In Arbeit" ein (offen = 0 %,
// erledigt = 100 % sind implizit). Parameter: status (String),
// progress (optional, nur für "arbeit" relevant). Rückgabe: keine.
function applyTodoStatus(status, progress){
const segment = document.getElementById('todoEditStatus');
if(segment){
segment.querySelectorAll('button[data-status]').forEach(btn => {
btn.setAttribute('aria-pressed', String(btn.dataset.status === status));
});
}
const row = document.getElementById('todoEditProgressRow');
if(row) row.hidden = status !== 'arbeit';
const slider = document.getElementById('todoEditProgress');
const label = document.getElementById('todoEditProgressVal');
if(slider){
let value;
if(status === 'offen') value = 0;
else if(status === 'fertig') value = 100;
else {
value = progress === undefined ? todoProgressMemo : progress;
// "In Arbeit" mit 0 % oder 100 % wäre widersprüchlich
if(value <= 0) value = 50;
if(value >= 100) value = 95;
todoProgressMemo = value;
}
slider.value = value;
if(label) label.textContent = value + ' %';
}
}
// setTodoEditStatus: Klick-Handler des Status-Segments im TODO-Modal.
// Parameter: status (String aus TODO_STATUS_VALUES). Rückgabe: keine.
window.setTodoEditStatus = function(status){
if(!TODO_STATUS_VALUES.includes(status)) return;
applyTodoStatus(status);
};
// onTodoProgressInput: Eingabe-Handler des Fortschrittsreglers. Schreibt den
// Wert neben den Regler und merkt ihn für einen späteren Rücksprung auf
// "In Arbeit". Parameter: slider (das Regler-Element). Rückgabe: keine.
window.onTodoProgressInput = function(slider){
const value = parseInt(slider.value, 10) || 0;
todoProgressMemo = value;
const label = document.getElementById('todoEditProgressVal');
if(label) label.textContent = value + ' %';
};
// currentTodoEditStatus: liest den aktuell gewählten Segment-Status aus dem
// DOM. Parameter: keine. Rückgabe: String aus TODO_STATUS_VALUES.
function currentTodoEditStatus(){
const pressed = document.querySelector('#todoEditStatus button[aria-pressed="true"]');
return pressed ? pressed.dataset.status : 'offen';
}
// refreshTodoDuration: aktualisiert das Dauer-Badge neben dem Zeitraum-Feld
// (Arbeitstage Mo–Fr über workdaysBetween(), js/utils.js) und wird als
// onApply-Callback des Bereichspickers aufgerufen. Parameter: keine.
// Rückgabe: keine.
window.refreshTodoDuration = function(){
const badge = document.getElementById('todoEditDuration');
if(!badge) return;
const start = parseDateISO(document.getElementById('todoEditStart').value);
const end = parseDateISO(document.getElementById('todoEditEnd').value);
const days = (start && end) ? workdaysBetween(start, end) : 0;
badge.hidden = days <= 0;
badge.textContent = days > 0 ? (days + ' AT') : '';
clearFieldError('todoEditRange');
};
// setFieldError / clearFieldError: zeigen einen Validierungshinweis direkt
// am betroffenen Feld statt in einem alert(), damit die übrigen Eingaben
// sichtbar bleiben. Erwartet ein <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; });
};
// openTodoModal: öffnet das TODO-Modal. Parameter: index (Index der Aufgabe
// oder null für eine neue). Befüllt Titel, Start/Ende, Erledigt-Status,
// Fortschritt, Vorgänger-Checkboxen und die aus dem Projektboard
// stammenden Bearbeiter-Auswahl.
window.openTodoModal = function(index){
// 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 = [];
@@ -165,18 +372,41 @@
const isNew = index === null || plan.todos[index] === undefined;
const currentTodo = !isNew ? plan.todos[index] : null;
const pre = prefill || {};
clearFieldError('todoEditTitle');
clearFieldError('todoEditRange');
const titleEl = document.getElementById('todoModalTitle');
if(titleEl) titleEl.textContent = isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten';
document.getElementById('todoEditTitle').value = isNew ? '' : (currentTodo.title || '');
document.getElementById('todoEditStart').value = isNew ? '' : (currentTodo.start || '');
document.getElementById('todoEditEnd').value = isNew ? '' : (currentTodo.end || '');
document.getElementById('todoEditDone').checked = isNew ? false : !!currentTodo.done;
const progress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
document.getElementById('todoEditProgress').value = progress;
document.getElementById('todoEditProgressVal').textContent = progress + '%';
// Zeitraum: neben den versteckten ISO-Feldern muss auch die sichtbare
// Anzeige gesetzt werden — sonst stünde dort der Platzhalter bzw. der
// Zeitraum der zuvor geöffneten Aufgabe.
const start = isNew ? (pre.start || '') : (currentTodo.start || '');
const end = isNew ? (pre.end || '') : (currentTodo.end || '');
document.getElementById('todoEditStart').value = start;
document.getElementById('todoEditEnd').value = end;
const rangeDisplay = document.getElementById('todoEditRange');
if(rangeDisplay){
const from = formatDateDE(parseDateISO(start));
const to = formatDateDE(parseDateISO(end)) || from;
rangeDisplay.value = from ? (from + ' – ' + to) : '';
}
refreshTodoDuration();
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50;
applyTodoStatus(todoStatusFrom(currentTodo), storedProgress);
const dependsOnList = document.getElementById('todoEditDependsOn');
if(dependsOnList){
const currentDeps = currentTodo && Array.isArray(currentTodo.dependsOn) ? currentTodo.dependsOn : [];
// Vorgänger, die einen Zirkelbezug erzeugen würden, gar nicht erst
// anbieten — bisher fiel das erst beim Auto-Terminieren auf.
const blocked = cyclicDependencyIds(plan, currentTodo ? currentTodo.id : null);
let listHtml = '';
// Meilensteine als Vorgänger anbieten
@@ -185,28 +415,40 @@
plan.milestones.forEach(m => {
const mId = 'm_' + m.id;
const checked = currentDeps.includes(mId) ? 'checked' : '';
listHtml += `<label class="todo-depends-item"><input type="checkbox" value="${esc(mId)}" ${checked}> 🚩 ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})</label>`;
listHtml += dependsItemHtml(mId, `${icon('flag')} ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})`, checked, blocked.has(mId));
});
}
// Andere TODOs als Vorgänger anbieten
if(plan.todos && plan.todos.length > 1){
// >= 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 += `<label class="todo-depends-item"><input type="checkbox" value="${esc(t.id)}" ${checked}> ${esc(t.title || 'TODO')}${datesStr}</label>`;
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 : [];
const currentAssignees = currentTodo
? (Array.isArray(currentTodo.assignees) ? currentTodo.assignees : [])
: (Array.isArray(pre.assignees) ? pre.assignees : []);
const seenPeople = new Set();
const uniquePeople = [];
assignees.forEach(a => {
@@ -215,28 +457,78 @@
uniquePeople.push(a.person);
}
});
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
// Personenspalten im Projektboard.
assigneesDiv.innerHTML = uniquePeople.map(person => {
const checked = currentAssignees.includes(person.id) ? 'checked' : '';
return `<label><input type="checkbox" value="${esc(person.id)}" ${checked}> ${esc(person.name)}</label>`;
const pressed = currentAssignees.includes(person.id) ? 'true' : 'false';
const short = person.name || initialsForName(person.fullName || '') || '?';
const full = person.fullName || person.name || '';
return `<button type="button" class="person-chip" 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();
};
// closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex
// zurück.
window.closeTodoModal = function(){
document.getElementById('todoModal').style.display = 'none';
app.currentTodoIndex = null;
// 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');
};
// saveTodoEdit: übernimmt die Modal-Eingaben in den Plan – als neuer TODO
// oder Update des aktuell bearbeiteten. Fehlende End-/Startwerte werden
// als eintägige Aufgabe ergänzt, „Erledigt“ setzt den Fortschritt auf
// 100 %. Speichert und rendert neu.
window.saveTodoEdit = function(){
// todoFormSnapshot: friert die aktuellen Eingaben als Vergleichsstring ein.
// Dient allein der Rückfrage beim Abbrechen (Esc/Klick auf den Hintergrund);
// gespeichert wird ausschließlich über saveTodoEdit(). Parameter: keine.
// Rückgabe: String.
function todoFormSnapshot(){
const read = id => { const el = document.getElementById(id); return el ? el.value : ''; };
const deps = Array.from(document.querySelectorAll('#todoEditDependsOn input[type="checkbox"]:checked')).map(cb => cb.value);
const people = Array.from(document.querySelectorAll('#todoEditAssignees .person-chip[aria-pressed="true"]')).map(b => b.dataset.personId);
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
currentTodoEditStatus(), read('todoEditProgress'), deps, people]);
}
// closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex
// zurück. Parameter: askIfDirty (optional, true = bei geänderten Eingaben
// zuerst nachfragen; wird von Esc und dem Klick auf den Hintergrund genutzt).
// Rückgabe: true, wenn tatsächlich geschlossen wurde.
window.closeTodoModal = function(askIfDirty){
if(askIfDirty && todoFormSnapshot() !== todoModalSnapshot){
if(!confirm('Die Änderungen an dieser Aufgabe verwerfen?')) return false;
}
document.getElementById('todoModal').style.display = 'none';
app.currentTodoIndex = null;
todoModalSnapshot = '';
return true;
};
// saveTodoEdit: übernimmt die Modal-Eingaben in den Plan – als neue Aufgabe
// oder Update der aktuell bearbeiteten. Fehlende End-/Startwerte werden als
// eintägige Aufgabe ergänzt; done/progress ergeben sich aus dem
// Status-Segment. Parameter: andNew (optional, true = nach dem Speichern
// gleich das Modal für die nächste Aufgabe öffnen, mit Zeitraum und
// Zuständigen als Vorbelegung). Rückgabe: keine.
window.saveTodoEdit = function(andNew){
if(app.isLocked()) return;
const title = document.getElementById('todoEditTitle').value.trim();
let start = document.getElementById('todoEditStart').value;
@@ -245,19 +537,31 @@
// sonst würde kein Balken im GANTT erscheinen.
if(start && !end) end = start;
if(end && !start) start = end;
const done = document.getElementById('todoEditDone').checked;
let progress = parseInt(document.getElementById('todoEditProgress').value, 10) || 0;
// "Erledigt" impliziert 100 % Fortschritt
if(done && progress < 100) progress = 100;
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('input:checked')).map(cb => cb.value) : [];
if(!title){ alert('Bitte einen Titel eingeben.'); return; }
if(start && end && parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; }
const assignees = assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('.person-chip[aria-pressed="true"]')).map(btn => btn.dataset.personId) : [];
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.todos)) plan.todos = [];
@@ -275,8 +579,12 @@
t.assignees = assignees;
}
app.savePlan();
// Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt
// die nächste Aufgabe mit denselben Rahmendaten öffnen.
todoModalSnapshot = todoFormSnapshot();
closeTodoModal();
renderPlanGantt();
if(andNew) openTodoModal(null, { start, end, assignees });
};
// deleteTodoEdit: löscht nach Bestätigung die aktuell bearbeitete Aufgabe,
@@ -285,7 +593,7 @@
window.deleteTodoEdit = function(){
if(app.isLocked()) return;
if(app.currentTodoIndex === null){ closeTodoModal(); return; }
if(!confirm('TODO löschen?')) 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;
@@ -297,30 +605,11 @@
});
app.savePlan();
}
todoModalSnapshot = todoFormSnapshot();
closeTodoModal();
renderPlanGantt();
};
// addTodoFromList: legt eine neue Aufgabe aus den Eingabefeldern der
// TODO-Liste an (Titel, Start/Ende), leert die Felder und rendert neu.
window.addTodoFromList = function(){
if(app.isLocked()) return;
const title = document.getElementById('newTodoTitle').value.trim();
let start = document.getElementById('newTodoStart').value;
let end = document.getElementById('newTodoEnd').value;
if(start && !end) end = start;
if(end && !start) start = end;
if(!title){ alert('Bitte einen Titel eingeben.'); return; }
if(start && end && parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; }
const plan = app.ensurePlanLocal();
plan.todos.push({ id: uid(), title, start, end, done: false, progress: 0, dependsOn: [], assignees: [] });
document.getElementById('newTodoTitle').value = '';
document.getElementById('newTodoStart').value = '';
document.getElementById('newTodoEnd').value = '';
app.savePlan();
renderPlanGantt();
};
// addTodo: öffnet das TODO-Modal für eine neue Aufgabe.
window.addTodo = function(){
openTodoModal(null);
@@ -419,11 +708,16 @@
}
// addPlanAbsence: legt aus den Formularfeldern eine neue Abwesenheit für die
// aktuelle Person an – in der globalen Personenliste und in der lokalen
// Sitzungs-Kopie. Speichert beides und rendert Liste sowie GANTT neu.
// 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 person = app.findGlobalPerson(app.currentAbsencePersonId);
const personId = app.currentAbsencePersonId;
const person = app.findGlobalPerson(personId);
if(!person) return;
const start = document.getElementById('absenceEditStart').value;
const end = document.getElementById('absenceEditEnd').value;
@@ -431,43 +725,143 @@
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; }
if(!Array.isArray(person.absences)) person.absences = [];
const absence = { id: uid(), start, end, type, note };
person.absences.push(absence);
person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
// Auch die Sitzungs-lokale Kopie aktualisieren, damit die Anzeige konsistent bleibt
const localPerson = (app.week.people || []).find(p => p.id === app.currentAbsencePersonId);
if(localPerson){
if(!Array.isArray(localPerson.absences)) localPerson.absences = [];
localPerson.absences.push({ ...absence });
localPerson.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
}
app.saveGlobalPeople();
app.savePlan();
document.getElementById('absenceEditStart').value = '';
document.getElementById('absenceEditEnd').value = '';
const rangeDisplay = document.getElementById('absenceEditRange');
if(rangeDisplay) rangeDisplay.value = '';
document.getElementById('absenceEditNote').value = '';
renderAbsenceModalList();
renderPlanGantt();
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 aus
// der globalen Personenliste und der lokalen Sitzungs-Kopie, speichert
// beides und rendert Liste sowie GANTT neu.
// 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 person = app.findGlobalPerson(app.currentAbsencePersonId);
const personId = app.currentAbsencePersonId;
const person = app.findGlobalPerson(personId);
if(!person || !Array.isArray(person.absences)) return;
person.absences = person.absences.filter(a => a.id !== absenceId);
const localPerson = (app.week.people || []).find(p => p.id === app.currentAbsencePersonId);
if(localPerson && Array.isArray(localPerson.absences)){
localPerson.absences = localPerson.absences.filter(a => a.id !== absenceId);
}
app.saveGlobalPeople();
app.savePlan();
renderAbsenceModalList();
renderPlanGantt();
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);
});
});
})();