All new
alle new
This commit is contained in:
1 parent
da64472251
commit
5b20adee48
1605 files changed
+396517
-1674
No files matched your search
+494
-100
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
Reference in new issue
Block a user