Neue Wandtafel (Touch-Dashboard für Kiosk-Anzeige): Auslastung/Besprochen je Gewerk, Aufgaben-Kennzahlen, Ereignis-Feed nach Art gruppiert, vollständige Projektliste zum Scrollen, automatischer Rücksprung nach Untätigkeit mit sichtbarem Countdown. Eigene Kiosk-Rolle ohne Fachrolle und ohne Schreibrechte. Auslastung: Möglichkeit zum Wechsel der ausgewerteten Sitzung entfernt. Aufräumen nicht mehr benötigter Dateien, README auf aktuellen Stand gebracht. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
855 lines
41 KiB
JavaScript
855 lines
41 KiB
JavaScript
// -----------------------------
|
||
// 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();
|
||
|
||
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 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 });
|
||
} 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;
|
||
}
|
||
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,
|
||
// 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);
|
||
});
|
||
});
|
||
|
||
})(); |