Files
projektuebersicht/public/js/plan/plan-modals.js
T
erikandClaude Opus 5 93505e0e48 Beteiligte in die Projekteinstellungen, TODOs aus Protokoll, Bewegung
Beteiligten-Zuordnung verlagert: p.assign ist jetzt ein Stammdatum und
nur noch in den Projekteinstellungen editierbar (planCycleAssign()).
Wochenbericht und Projektboard zeigen sie nur noch an, mit Verweis
dorthin (springZuEinstellungen()). cycleAssign() ist damit entfallen.

Neu "TODOs aus Protokoll": ein externes Besprechungsprotokoll als PDF
wird per KI eingelesen und daraus in einem zweiten KI-Schritt Aufgaben
abgeleitet. Eigener Bereich in der Aufgaben-/GANTT-Leiste, bewusst
unabhaengig von den gespeicherten Protokollen - nichts davon wird
abgelegt. Drei Schritte in einem Dialog mit fester Fusszeile; die
Vorschlagsliste als kompakte, aufklappbare Zeilen, weil ein echter
Import leicht 20 Vorschlaege liefert.

Sichtbarkeit je Aufgabe im GANTT: neue Spalte plan_todos.show_in_gantt
(Vorgabe 1, idempotente Migration) und eine Checkbox im Aufgaben-Dialog.
Eine terminierte Aufgabe kann damit gezielt aus dem Terminplan
herausgehalten werden, bleibt sonst aber eine vollwertige Aufgabe.
plan-build-gantt.js und plan-drag.js pruefen dieselbe Bedingung, damit
Zeilenzahl und Umsortierung nicht auseinanderlaufen.

KI-Timeout dritte Stufe: ein abweichender, aber entfernter Endpunkt
(Drittanbieter-Proxy) bekommt 2 Minuten statt der 45 Sekunden der
echten Anbieter-API. Gemessen: die Punkte-Erkennung eines realen
Protokolls brauchte dort 39 Sekunden, lag also stets knapp unter dem
alten Limit und brach bei jeder Schwankung ab.

Bewegung: Hover- und Klick-Rueckmeldung als Token-Satz in :root,
angewandt auf die anklickbaren Zeilen, Schalter und Dialoge. Die
Akzentleiste ist ein inset-Schatten statt eines positionierten
Elements, damit Drag-and-Drop unberuehrt bleibt; prefers-reduced-motion
war bisher gar nicht vorhanden und kippt jetzt einfach die Tokens.

Plan Checker: optionaler Projektabgleich des Plankopfs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-17 15:14:26 +02:00

867 lines
41 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 in der Projektzeile</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;
}
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();
};
// closeMilestoneModal: blendet das Meilenstein-Modal aus und setzt den
// Bearbeitungsindex zurück.
window.closeMilestoneModal = function(){
document.getElementById('milestoneModal').style.display = 'none';
app.currentMilestoneIndex = null;
};
// saveMilestoneEdit: übernimmt die Modal-Eingaben (Name, Datum, Position,
// Vorgänger) in den Plan – als neuer Eintrag oder als Update des
// aktuell bearbeiteten Meilensteins. Sortiert danach nach Datum, speichert
// und rendert neu.
window.saveMilestoneEdit = function(){
if(app.isLocked()) return;
const name = document.getElementById('milestoneEditName').value.trim();
const date = document.getElementById('milestoneEditDate').value;
if(!name || !date){ alert('Bitte Name und Datum angeben.'); return; }
const dependsOnList = document.getElementById('milestoneEditDependsOn');
const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : [];
const positionSelect = document.getElementById('milestoneEditPosition');
const position = positionSelect ? positionSelect.value : '';
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = [];
if(app.currentMilestoneIndex === null){
plan.milestones.push({ id: uid(), name, date, dependsOn, position });
} else if(plan.milestones[app.currentMilestoneIndex]){
const m = plan.milestones[app.currentMilestoneIndex];
m.name = name;
m.date = date;
m.dependsOn = dependsOn;
m.position = position;
}
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
app.savePlan();
closeMilestoneModal();
renderPlanGantt();
};
// deleteMilestoneEdit: löscht nach Bestätigung den aktuell bearbeiteten
// Meilenstein samt seiner Vorgänger-Verweise (app.removeDependencyRefs),
// speichert und rendert neu.
window.deleteMilestoneEdit = function(){
if(app.isLocked()) return;
if(app.currentMilestoneIndex === null){ closeMilestoneModal(); return; }
if(!confirm('Meilenstein löschen?')) return;
const plan = app.ensurePlanLocal();
if(plan.milestones && plan.milestones[app.currentMilestoneIndex]){
const deletedId = 'm_' + plan.milestones[app.currentMilestoneIndex].id;
plan.milestones.splice(app.currentMilestoneIndex, 1);
app.removeDependencyRefs(plan, deletedId);
app.savePlan();
}
closeMilestoneModal();
renderPlanGantt();
};
// addPlanMilestone: öffnet das Meilenstein-Modal für einen neuen Eintrag.
window.addPlanMilestone = function(){
openMilestoneModal(null);
};
// ---------- TODO-Modal ----------
// TODO_STATUS_VALUES: die drei Zustände des Status-Segments. Sie ersetzen im
// Dialog das frühere Paar aus "Erledigt"-Haken und Fortschrittsregler, das
// denselben Sachverhalt zweimal abgebildet hat (und dabei auseinanderlaufen
// konnte). Gespeichert wird unverändert das bisherige Datenmodell aus
// done (Boolean) + progress (0–100) — siehe todoStatusFrom()/applyTodoStatus().
const TODO_STATUS_VALUES = ['offen', 'arbeit', 'fertig'];
// todoModalSnapshot: Stand der Eingaben beim Öffnen — Vergleichsbasis für
// die Rückfrage beim Abbrechen (siehe todoFormSnapshot()).
let todoModalSnapshot = '';
// todoProgressMemo: zuletzt im Dialog eingestellter Fortschritt. "Offen" und
// "Erledigt" haben feste Werte (0/100); wer von dort zurück auf "In Arbeit"
// wechselt, bekommt darüber seinen vorherigen Wert wieder statt eines
// Ersatzwerts. Gilt nur für den geöffneten Dialog.
let todoProgressMemo = 50;
// todoStatusFrom: leitet den Segment-Status aus dem gespeicherten TODO ab.
// done gewinnt immer; sonst entscheidet der Fortschritt (0 % = offen,
// 100 % = fertig, dazwischen = in Arbeit). Parameter: todo (Objekt oder
// null für eine neue Aufgabe). Rückgabe: String aus TODO_STATUS_VALUES.
function todoStatusFrom(todo){
if(!todo) return 'offen';
if(todo.done) return 'fertig';
const progress = parseInt(todo.progress, 10) || 0;
if(progress >= 100) return 'fertig';
return progress > 0 ? 'arbeit' : 'offen';
}
// applyTodoStatus: schreibt den Status in das Segment und blendet den
// Fortschrittsregler nur im Zustand "In Arbeit" ein (offen = 0 %,
// erledigt = 100 % sind implizit). Parameter: status (String),
// progress (optional, nur für "arbeit" relevant). Rückgabe: keine.
function applyTodoStatus(status, progress){
const segment = document.getElementById('todoEditStatus');
if(segment){
segment.querySelectorAll('button[data-status]').forEach(btn => {
btn.setAttribute('aria-pressed', String(btn.dataset.status === status));
});
}
const row = document.getElementById('todoEditProgressRow');
if(row) row.hidden = status !== 'arbeit';
const slider = document.getElementById('todoEditProgress');
const label = document.getElementById('todoEditProgressVal');
if(slider){
let value;
if(status === 'offen') value = 0;
else if(status === 'fertig') value = 100;
else {
value = progress === undefined ? todoProgressMemo : progress;
// "In Arbeit" mit 0 % oder 100 % wäre widersprüchlich
if(value <= 0) value = 50;
if(value >= 100) value = 95;
todoProgressMemo = value;
}
slider.value = value;
if(label) label.textContent = value + ' %';
}
}
// setTodoEditStatus: Klick-Handler des Status-Segments im TODO-Modal.
// Parameter: status (String aus TODO_STATUS_VALUES). Rückgabe: keine.
window.setTodoEditStatus = function(status){
if(!TODO_STATUS_VALUES.includes(status)) return;
applyTodoStatus(status);
};
// onTodoProgressInput: Eingabe-Handler des Fortschrittsreglers. Schreibt den
// Wert neben den Regler und merkt ihn für einen späteren Rücksprung auf
// "In Arbeit". Parameter: slider (das Regler-Element). Rückgabe: keine.
window.onTodoProgressInput = function(slider){
const value = parseInt(slider.value, 10) || 0;
todoProgressMemo = value;
const label = document.getElementById('todoEditProgressVal');
if(label) label.textContent = value + ' %';
};
// currentTodoEditStatus: liest den aktuell gewählten Segment-Status aus dem
// DOM. Parameter: keine. Rückgabe: String aus TODO_STATUS_VALUES.
function currentTodoEditStatus(){
const pressed = document.querySelector('#todoEditStatus button[aria-pressed="true"]');
return pressed ? pressed.dataset.status : 'offen';
}
// refreshTodoDuration: aktualisiert das Dauer-Badge neben dem Zeitraum-Feld
// (Arbeitstage Mo–Fr über workdaysBetween(), js/utils.js) und wird als
// onApply-Callback des Bereichspickers aufgerufen. Parameter: keine.
// Rückgabe: keine.
window.refreshTodoDuration = function(){
const badge = document.getElementById('todoEditDuration');
if(!badge) return;
const start = parseDateISO(document.getElementById('todoEditStart').value);
const end = parseDateISO(document.getElementById('todoEditEnd').value);
const days = (start && end) ? workdaysBetween(start, end) : 0;
badge.hidden = days <= 0;
badge.textContent = days > 0 ? (days + ' AT') : '';
clearFieldError('todoEditRange');
};
// setFieldError / clearFieldError: zeigen einen Validierungshinweis direkt
// am betroffenen Feld statt in einem alert(), damit die übrigen Eingaben
// sichtbar bleiben. Erwartet ein <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), prefill (optional, Vorbelegung für "Speichern &
// Neu": { start, end, assignees }). Befüllt Titel, Zeitraum samt sichtbarer
// Anzeige und Dauer-Badge, Status-Segment, Zuständigen-Chips und die
// Vorgänger-Liste. "Löschen" erscheint nur beim Bearbeiten, nicht beim
// Anlegen.
window.openTodoModal = function(index, prefill){
if(app.isLocked()) return;
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.todos)) plan.todos = [];
app.currentTodoIndex = index;
const isNew = index === null || plan.todos[index] === undefined;
const currentTodo = !isNew ? plan.todos[index] : null;
const pre = prefill || {};
clearFieldError('todoEditTitle');
clearFieldError('todoEditRange');
const titleEl = document.getElementById('todoModalTitle');
if(titleEl) titleEl.textContent = isNew ? 'Neue Aufgabe' : 'Aufgabe bearbeiten';
document.getElementById('todoEditTitle').value = isNew ? '' : (currentTodo.title || '');
// Zeitraum: neben den versteckten ISO-Feldern muss auch die sichtbare
// Anzeige gesetzt werden — sonst stünde dort der Platzhalter bzw. der
// Zeitraum der zuvor geöffneten Aufgabe.
const start = isNew ? (pre.start || '') : (currentTodo.start || '');
const end = isNew ? (pre.end || '') : (currentTodo.end || '');
document.getElementById('todoEditStart').value = start;
document.getElementById('todoEditEnd').value = end;
const rangeDisplay = document.getElementById('todoEditRange');
if(rangeDisplay){
const from = formatDateDE(parseDateISO(start));
const to = formatDateDE(parseDateISO(end)) || from;
rangeDisplay.value = from ? (from + ' – ' + to) : '';
}
refreshTodoDuration();
// Im Terminplan anzeigen: Vorgabe sichtbar (!== false, nicht === true —
// eine Aufgabe ohne das Feld, z. B. aus einem alten Bestand, gilt
// weiterhin als sichtbar, kein Bestandsverhalten kippt rückwirkend).
// Bei "Speichern & Neu" übernimmt pre.showInGantt den zuletzt gesetzten
// Wert (dieselben Rahmendaten wie Zeitraum/Zuständige, s. saveTodoEdit()).
const showInGanttEl = document.getElementById('todoEditShowInGantt');
if(showInGanttEl) showInGanttEl.checked = isNew ? (pre.showInGantt !== undefined ? pre.showInGantt : true) : currentTodo.showInGantt !== false;
const storedProgress = isNew ? 0 : (parseInt(currentTodo.progress, 10) || 0);
todoProgressMemo = (storedProgress > 0 && storedProgress < 100) ? storedProgress : 50;
applyTodoStatus(todoStatusFrom(currentTodo), storedProgress);
const dependsOnList = document.getElementById('todoEditDependsOn');
if(dependsOnList){
const currentDeps = currentTodo && Array.isArray(currentTodo.dependsOn) ? currentTodo.dependsOn : [];
// Vorgänger, die einen Zirkelbezug erzeugen würden, gar nicht erst
// anbieten — bisher fiel das erst beim Auto-Terminieren auf.
const blocked = cyclicDependencyIds(plan, currentTodo ? currentTodo.id : null);
let listHtml = '';
// Meilensteine als Vorgänger anbieten
if(Array.isArray(plan.milestones) && plan.milestones.length > 0){
listHtml += '<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);
}
});
// Chips statt Checkbox-Reihe: Kürzel + Name, dieselbe Lesart wie die
// Personenspalten im Projektboard.
assigneesDiv.innerHTML = uniquePeople.map(person => {
const pressed = currentAssignees.includes(person.id) ? 'true' : 'false';
const short = person.name || initialsForName(person.fullName || '') || '?';
const full = person.fullName || person.name || '';
return `<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();
};
// 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);
return JSON.stringify([read('todoEditTitle'), read('todoEditStart'), read('todoEditEnd'),
currentTodoEditStatus(), read('todoEditProgress'), deps, people]);
}
// closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex
// zurück. Parameter: askIfDirty (optional, true = bei geänderten Eingaben
// zuerst nachfragen; wird von Esc und dem Klick auf den Hintergrund genutzt).
// Rückgabe: true, wenn tatsächlich geschlossen wurde.
window.closeTodoModal = function(askIfDirty){
if(askIfDirty && todoFormSnapshot() !== todoModalSnapshot){
if(!confirm('Die Änderungen an dieser Aufgabe verwerfen?')) return false;
}
document.getElementById('todoModal').style.display = 'none';
app.currentTodoIndex = null;
todoModalSnapshot = '';
return true;
};
// saveTodoEdit: übernimmt die Modal-Eingaben in den Plan – als neue Aufgabe
// oder Update der aktuell bearbeiteten. Fehlende End-/Startwerte werden als
// eintägige Aufgabe ergänzt; done/progress ergeben sich aus dem
// Status-Segment. Parameter: andNew (optional, true = nach dem Speichern
// gleich das Modal für die nächste Aufgabe öffnen, mit Zeitraum und
// Zuständigen als Vorbelegung). Rückgabe: keine.
window.saveTodoEdit = function(andNew){
if(app.isLocked()) return;
const title = document.getElementById('todoEditTitle').value.trim();
let start = document.getElementById('todoEditStart').value;
let end = document.getElementById('todoEditEnd').value;
// Nur ein Datum angegeben: als eintägige Aufgabe behandeln,
// sonst würde kein Balken im GANTT erscheinen.
if(start && !end) end = start;
if(end && !start) start = end;
const status = currentTodoEditStatus();
const done = status === 'fertig';
let progress;
if(status === 'offen') progress = 0;
else if(status === 'fertig') progress = 100;
else progress = parseInt(document.getElementById('todoEditProgress').value, 10) || 0;
clearFieldError('todoEditTitle');
clearFieldError('todoEditRange');
if(!title){
setFieldError('todoEditTitle', 'Bitte einen Titel eingeben.');
document.getElementById('todoEditTitle').focus();
return;
}
if(start && end && parseDateISO(end) < parseDateISO(start)){
setFieldError('todoEditRange', 'Das Enddatum liegt vor dem Startdatum.');
return;
}
const dependsOnList = document.getElementById('todoEditDependsOn');
const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : [];
const assigneesDiv = document.getElementById('todoEditAssignees');
const assignees = assigneesDiv ? Array.from(assigneesDiv.querySelectorAll('.person-chip[aria-pressed="true"]')).map(btn => btn.dataset.personId) : [];
const showInGanttEl = document.getElementById('todoEditShowInGantt');
const showInGantt = showInGanttEl ? showInGanttEl.checked : true;
const plan = app.ensurePlanLocal();
if(!Array.isArray(plan.todos)) plan.todos = [];
if(app.currentTodoIndex === null){
plan.todos.push({ id: uid(), title, start, end, done, progress, dependsOn, assignees, showInGantt });
} else if(plan.todos[app.currentTodoIndex]){
const t = plan.todos[app.currentTodoIndex];
t.title = title;
t.start = start;
t.end = end;
t.done = done;
t.progress = progress;
t.dependsOn = dependsOn;
t.assignees = assignees;
t.showInGantt = showInGantt;
}
app.savePlan();
// Erst schließen (setzt den Bearbeitungsindex zurück), dann ggf. direkt
// die nächste Aufgabe mit denselben Rahmendaten öffnen.
todoModalSnapshot = todoFormSnapshot();
closeTodoModal();
renderPlanGantt();
if(andNew) openTodoModal(null, { start, end, assignees, showInGantt });
};
// deleteTodoEdit: löscht nach Bestätigung die aktuell bearbeitete Aufgabe,
// entfernt ihre Vorgänger-Verweise und setzt die relative Position
// betroffener Meilensteine zurück. Speichert und rendert neu.
window.deleteTodoEdit = function(){
if(app.isLocked()) return;
if(app.currentTodoIndex === null){ closeTodoModal(); return; }
if(!confirm('Aufgabe löschen?')) return;
const plan = app.ensurePlanLocal();
if(plan.todos && plan.todos[app.currentTodoIndex]){
const deletedId = plan.todos[app.currentTodoIndex].id;
plan.todos.splice(app.currentTodoIndex, 1);
app.removeDependencyRefs(plan, deletedId);
// Meilensteine, die relativ zu dieser TODO positioniert waren, zurücksetzen
(plan.milestones || []).forEach(m => {
if(m.position === 'before:' + deletedId || m.position === 'after:' + deletedId) m.position = '';
});
app.savePlan();
}
todoModalSnapshot = todoFormSnapshot();
closeTodoModal();
renderPlanGantt();
};
// addTodo: öffnet das TODO-Modal für eine neue Aufgabe.
window.addTodo = function(){
openTodoModal(null);
};
// toggleTodoDone: schaltet den Erledigt-Status der Aufgabe am Index um,
// speichert und rendert neu.
window.toggleTodoDone = function(idx){
if(app.isLocked()) return;
const plan = app.ensurePlanLocal();
if(!plan.todos[idx]) return;
plan.todos[idx].done = !plan.todos[idx].done;
app.savePlan();
renderPlanGantt();
};
// deleteTodo: löscht nach Bestätigung die Aufgabe am Index, bereinigt die
// Vorgänger-Verweise und die relative Meilenstein-Position. Speichert und
// rendert neu.
window.deleteTodo = function(idx){
if(app.isLocked()) return;
if(!confirm('TODO löschen?')) return;
const plan = app.ensurePlanLocal();
if(!plan.todos[idx]) return;
const deletedId = plan.todos[idx].id;
plan.todos.splice(idx, 1);
app.removeDependencyRefs(plan, deletedId);
(plan.milestones || []).forEach(m => {
if(m.position === 'before:' + deletedId || m.position === 'after:' + deletedId) m.position = '';
});
app.savePlan();
renderPlanGantt();
};
// ---------- Abwesenheits-Modal (Urlaube der Projektbeteiligten) ----------
// openAbsenceModal: öffnet das Abwesenheits-Modal für die übergebene
// Person-ID, setzt den Titel, leert die Formularfelder und rendert die
// Liste der bestehenden Abwesenheiten.
window.openAbsenceModal = function(personId){
const person = app.findGlobalPerson(personId);
if(!person) return;
app.currentAbsencePersonId = personId;
document.getElementById('absenceModalTitle').textContent = 'Abwesenheiten – ' + (person.fullName || person.name);
// Formularfelder zurücksetzen
const startInput = document.getElementById('absenceEditStart');
const endInput = document.getElementById('absenceEditEnd');
const rangeDisplay = document.getElementById('absenceEditRange');
if(startInput) startInput.value = '';
if(endInput) endInput.value = '';
if(rangeDisplay) rangeDisplay.value = '';
renderAbsenceModalList();
document.getElementById('absenceModal').style.display = 'flex';
};
// closeAbsenceModal: blendet das Abwesenheits-Modal aus und setzt die
// aktuelle Person-ID zurück.
window.closeAbsenceModal = function(){
document.getElementById('absenceModal').style.display = 'none';
app.currentAbsencePersonId = null;
};
// renderAbsenceModalList: rendert die Liste der Abwesenheiten der aktuellen
// Person im Modal (jeweils Typ, Zeitraum, Notiz und Lösch-Button). Bei
// gesperrter Woche wird das Formular ausgeblendet.
function renderAbsenceModalList(){
const listDiv = document.getElementById('absenceModalList');
const person = app.findGlobalPerson(app.currentAbsencePersonId);
if(!listDiv || !person) return;
const absences = Array.isArray(person.absences) ? person.absences : [];
const locked = app.isLocked();
if(absences.length === 0){
listDiv.innerHTML = '<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);
});
});
})();