Files
projektuebersicht/public/project-plan.js
T

1529 lines
64 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-Seite: TODOs und Meilensteine direkt im GANTT-Diagramm.
// Daten werden über denselben Server wie das Hauptfenster geladen.
(function(){
const params = new URLSearchParams(location.search);
const department = params.get('department') || 'ELT';
const weekLabel = params.get('weekLabel');
const projNr = params.get('projNr') || '';
const projName = params.get('projName') || '';
let planWeek = null;
let planProject = null;
// px pro Tag für die GANTT-Zoomstufe (null = an Containerbreite anpassen)
let planGanttPxPerDay = 14;
let criticalPathActive = false;
function init(){
loadTheme(); // Theme aus localStorage / System übernehmen
if(!weekLabel || !projName){
document.getElementById('planTitle').textContent = 'Fehler';
document.getElementById('planSubtitle').textContent = 'Kein Projekt ausgewählt.';
return;
}
currentDepartment = department; // global aus main.v2.js
bindToolbarButtons();
loadPlanData();
}
function bindToolbarButtons(){
const addTodoBtn = document.getElementById('addTodoBtn');
if(addTodoBtn) addTodoBtn.addEventListener('click', () => addTodo());
const autoScheduleBtn = document.getElementById('autoScheduleBtn');
if(autoScheduleBtn) autoScheduleBtn.addEventListener('click', () => autoScheduleTodos());
const criticalPathBtn = document.getElementById('criticalPathBtn');
if(criticalPathBtn) criticalPathBtn.addEventListener('click', () => toggleCriticalPath());
}
function loadPlanData(){
if(!window.fetch){
document.getElementById('planSubtitle').textContent = 'Netzwerk nicht verfügbar.';
return;
}
const url = baseUrl() + '/load-all?department=' + encodeURIComponent(department);
fetch(url)
.then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); })
.then(data => {
db = { weeks: [], currentWeek: null, globalPeople: {} };
db.globalPeople[department] = parseGlobalPeopleCsv(data.people);
const parsed = (data.sessions || []).map(s => ({ filename: s.filename, rows: parseCsvText(s.content) }));
parsed.forEach(s => {
const w = parseSessionCsv(s.filename, s.rows);
if(w) db.weeks.push(w);
});
db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label)));
planWeek = db.weeks.find(w => w.label === weekLabel);
if(!planWeek){
document.getElementById('planTitle').textContent = 'Nicht gefunden';
document.getElementById('planSubtitle').textContent = 'Sitzung "' + esc(weekLabel) + '" konnte nicht geladen werden.';
return;
}
planProject = planWeek.projects.find(p => (p.nr || '') === projNr && (p.name || '') === projName);
if(!planProject){
document.getElementById('planTitle').textContent = 'Nicht gefunden';
document.getElementById('planSubtitle').textContent = 'Projekt nicht in dieser Sitzung vorhanden.';
return;
}
renderPlanPage();
})
.catch(err => {
document.getElementById('planTitle').textContent = 'Fehler beim Laden';
document.getElementById('planSubtitle').textContent = err.message;
});
}
function ensurePlanLocal(){
if(!planProject.plan) planProject.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 };
if(!Array.isArray(planProject.plan.todos)) planProject.plan.todos = [];
// Migration alter Einzel-Abhängigkeiten zu Array
planProject.plan.todos.forEach(t => {
if(!Array.isArray(t.dependsOn)){
t.dependsOn = t.dependsOn ? [t.dependsOn] : [];
}
if(!Array.isArray(t.assignees)) t.assignees = [];
if(t.progress === undefined) t.progress = t.done ? 100 : 0;
});
return planProject.plan;
}
function isLocked(){
return !!planWeek.locked;
}
function renderPlanPage(){
const p = planProject;
const plan = ensurePlanLocal();
document.getElementById('planTitle').textContent = (p.nr ? p.nr + ' – ' : '') + p.name;
document.getElementById('planSubtitle').textContent = planWeek.label + (planWeek.date ? ' · ' + formatWeekDate(planWeek.date) : '');
// GANTT inkl. TODOs
const addMilestoneBtn = document.getElementById('addMilestoneBtn');
const locked = isLocked();
if(addMilestoneBtn) addMilestoneBtn.style.display = locked ? 'none' : '';
fitPlanGanttRange();
}
// ---------- Drag-and-Drop für TODO- und Meilenstein-Zeilen direkt im GANTT ----------
let ganttTodoDrag = null;
const DRAGGABLE_GANTT_ROW_SELECTOR = '.gantt-row.gantt-todo, .gantt-row.gantt-milestone-row';
function initGanttTodoDrag(){
const container = document.getElementById('planGanttContainer');
if(!container) return;
container.addEventListener('mousedown', onGanttTodoMouseDown);
document.addEventListener('mousemove', onGanttTodoMouseMove);
document.addEventListener('mouseup', onGanttTodoMouseUp);
}
function onGanttTodoMouseDown(e){
if(isLocked()) return;
const handle = e.target.closest('.gantt-todo-drag');
if(!handle) return;
const row = handle.closest(DRAGGABLE_GANTT_ROW_SELECTOR);
if(!row) return;
e.preventDefault();
e.stopPropagation();
ganttTodoDrag = {
row,
startY: e.clientY,
moved: false
};
row.classList.add('dragging');
}
function onGanttTodoMouseMove(e){
if(!ganttTodoDrag) return;
const chart = ganttTodoDrag.row.closest('.gantt-chart');
if(!chart) return;
const dy = e.clientY - ganttTodoDrag.startY;
if(Math.abs(dy) > 3) ganttTodoDrag.moved = true;
if(!ganttTodoDrag.moved) return;
const afterElement = getGanttRowDragAfterElement(chart, e.clientY);
if(ganttTodoDrag.row && afterElement == null){
chart.appendChild(ganttTodoDrag.row);
} else if(ganttTodoDrag.row && afterElement && afterElement !== ganttTodoDrag.row){
chart.insertBefore(ganttTodoDrag.row, afterElement);
}
}
function onGanttTodoMouseUp(e){
if(!ganttTodoDrag) return;
const drag = ganttTodoDrag;
ganttTodoDrag = null;
drag.row.classList.remove('dragging');
if(drag.moved){
reorderPlanRows();
}
}
function getGanttRowDragAfterElement(chart, y){
const draggableElements = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR + ':not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if(offset < 0 && offset > closest.offset){
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
function reorderPlanRows(){
if(isLocked()) return;
const chart = document.querySelector('#planGanttContainer .gantt-chart');
if(!chart) return;
const plan = ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
const todoIds = todos.map(t => t.id);
const milestoneIds = milestones.map(m => m.id);
// Alle beweglichen Zeilen in aktueller DOM-Reihenfolge
const rows = [...chart.querySelectorAll(DRAGGABLE_GANTT_ROW_SELECTOR)];
const todoRowIds = rows.filter(r => r.classList.contains('gantt-todo')).map(r => r.dataset.todoId).filter(Boolean);
const milestoneRowIds = rows.filter(r => r.classList.contains('gantt-milestone-row')).map(r => r.dataset.milestoneId).filter(Boolean);
// Sicherheitscheck: Anzahl muss stimmen
if(todoRowIds.length !== todoIds.length) return;
if(milestoneRowIds.length !== milestoneIds.length) return;
// TODOs neu ordnen
const newTodos = [];
todoRowIds.forEach(id => {
const t = todos.find(x => x.id === id);
if(t) newTodos.push(t);
});
plan.todos = newTodos;
// Meilenstein-Positionen neu berechnen
// Regeln:
// - Steht der Meilenstein direkt vor einer TODO -> before:todoId
// - Steht er direkt nach einer TODO -> after:todoId
// - Steht er ganz am Anfang (vor erster TODO) oder es gibt keine TODO -> '' (oben in Projektzeile)
// - Steht er ganz am Ende (nach letzter TODO) -> after:lastTodoId
rows.forEach((row, idx) => {
if(!row.classList.contains('gantt-milestone-row')) return;
const msId = row.dataset.milestoneId;
const ms = milestones.find(m => m.id === msId);
if(!ms) return;
let position = '';
const prevRow = rows[idx - 1];
const nextRow = rows[idx + 1];
if(prevRow && prevRow.classList.contains('gantt-todo')){
position = 'after:' + prevRow.dataset.todoId;
} else if(nextRow && nextRow.classList.contains('gantt-todo')){
position = 'before:' + nextRow.dataset.todoId;
} else if(prevRow && prevRow.classList.contains('gantt-milestone-row')){
// Zwei Meilensteine hintereinander: beziehe dich auf die nächste TODO danach oder letzte davor
// Suche nächste TODO nach diesem Block
let nextTodoId = null;
for(let i = idx + 1; i < rows.length; i++){
if(rows[i].classList.contains('gantt-todo')){ nextTodoId = rows[i].dataset.todoId; break; }
}
if(nextTodoId){
position = 'before:' + nextTodoId;
} else {
// Keine TODO danach -> letzte TODO davor suchen
let prevTodoId = null;
for(let i = idx - 1; i >= 0; i--){
if(rows[i].classList.contains('gantt-todo')){ prevTodoId = rows[i].dataset.todoId; break; }
}
if(prevTodoId) position = 'after:' + prevTodoId;
}
}
ms.position = position;
});
savePlan();
renderPlanGantt();
}
function renderProjectAssignees(){
const assignees = projectAssignees(planProject, planWeek);
if(assignees.length === 0) return '<span class="hint">Keine Zuordnungen im Projektboard.</span>';
return assignees.map(a => {
const cls = a.state === 1 ? 'main' : a.state === 2 ? 'optional' : '';
return `<span class="todo-assignee ${cls}">${esc(a.person.name)}${a.role ? ' (' + esc(a.role) + ')' : ''}</span>`;
}).join('');
}
// --------- Mail-Link für alle zugeordneten Personen ----------
window.todoMail = function() {
if (!planProject || !planWeek) {
console.warn('Kein aktuelles Projekt/keine aktuelle Woche gefunden – Mail kann nicht erstellt werden.');
return;
}
const assignees = projectAssignees(planProject, planWeek);
const empfaengerListe = assignees
.map(a => a.email || (a.person && a.person.email) || '')
.filter(email => email && email.trim() !== '');
const eindeutigeEmpfaenger = [...new Set(empfaengerListe)];
if (eindeutigeEmpfaenger.length === 0) {
console.warn('Keine gültigen E-Mail-Adressen der zugeordneten Personen gefunden.');
return;
}
const empfaenger = eindeutigeEmpfaenger.join(',');
const titleEl = document.getElementById('planTitle');
const projektname = titleEl ? titleEl.textContent.trim() : 'Projekt';
const seitenLink = window.location.href;
const betreff = 'Neue Aufgaben im Projekt ' + projektname;
const nachricht =
'Guten Tag,\n\n' +
'es wurden neue Aufgaben im Projekt ' + projektname + ' hinzugefügt.\n\n' +
'Link zum Projekt:\n' +
seitenLink + '\n\n' +
'Mit freundlichen Grüßen';
const mailtoLink =
'mailto:' + encodeURIComponent(empfaenger).replace(/%2C/g, ',') +
'?subject=' + encodeURIComponent(betreff) +
'&body=' + encodeURIComponent(nachricht);
if (mailtoLink.length > 1800) {
console.warn('mailto-Link ist sehr lang (' + mailtoLink.length + ' Zeichen) – manche Mail-Clients könnten ihn abschneiden.');
}
window.location.href = mailtoLink;
};
// ---------- Meilenstein-Modal ----------
let currentMilestoneIndex = null;
window.openMilestoneModal = function(index){
if(isLocked()) return;
const plan = ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = [];
// Migration alter Meilensteine
plan.milestones.forEach(m => { if(!Array.isArray(m.dependsOn)) m.dependsOn = []; });
currentMilestoneIndex = index;
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 || '');
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
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>`;
});
}
// 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))})` : '';
listHtml += `<label class="todo-depends-item"><input type="checkbox" value="${esc(t.id)}" ${checked}> ${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();
}
window.closeMilestoneModal = function(){
document.getElementById('milestoneModal').style.display = 'none';
currentMilestoneIndex = null;
};
window.saveMilestoneEdit = function(){
if(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 = ensurePlanLocal();
if(!Array.isArray(plan.milestones)) plan.milestones = [];
if(currentMilestoneIndex === null){
plan.milestones.push({ id: uid(), name, date, dependsOn, position });
} else if(plan.milestones[currentMilestoneIndex]){
const m = plan.milestones[currentMilestoneIndex];
m.name = name;
m.date = date;
m.dependsOn = dependsOn;
m.position = position;
}
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
savePlan();
closeMilestoneModal();
renderPlanGantt();
};
// Entfernt eine gelöschte ID aus allen dependsOn-Listen (TODOs und Meilensteine)
function removeDependencyRefs(plan, deletedId){
(plan.todos || []).forEach(t => {
if(Array.isArray(t.dependsOn)) t.dependsOn = t.dependsOn.filter(id => id !== deletedId);
});
(plan.milestones || []).forEach(m => {
if(Array.isArray(m.dependsOn)) m.dependsOn = m.dependsOn.filter(id => id !== deletedId);
});
}
window.deleteMilestoneEdit = function(){
if(isLocked()) return;
if(currentMilestoneIndex === null){ closeMilestoneModal(); return; }
if(!confirm('Meilenstein löschen?')) return;
const plan = ensurePlanLocal();
if(plan.milestones && plan.milestones[currentMilestoneIndex]){
const deletedId = 'm_' + plan.milestones[currentMilestoneIndex].id;
plan.milestones.splice(currentMilestoneIndex, 1);
removeDependencyRefs(plan, deletedId);
savePlan();
}
closeMilestoneModal();
renderPlanGantt();
};
window.addPlanMilestone = function(){
openMilestoneModal(null);
};
// ---------- TODO-Modal ----------
let currentTodoIndex = null;
window.openTodoModal = function(index){
if(isLocked()) return;
const plan = ensurePlanLocal();
if(!Array.isArray(plan.todos)) plan.todos = [];
currentTodoIndex = index;
const isNew = index === null || plan.todos[index] === undefined;
const currentTodo = !isNew ? plan.todos[index] : null;
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 + '%';
const dependsOnList = document.getElementById('todoEditDependsOn');
if(dependsOnList){
const currentDeps = currentTodo && Array.isArray(currentTodo.dependsOn) ? currentTodo.dependsOn : [];
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 += `<label class="todo-depends-item"><input type="checkbox" value="${esc(mId)}" ${checked}> 🚩 ${esc(m.name || 'Meilenstein')} (${formatDateDE(parseDateISO(m.date))})</label>`;
});
}
// Andere TODOs als Vorgänger anbieten
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>`;
});
}
if(listHtml === '') listHtml = '<span class="hint">Keine anderen Aufgaben oder Meilensteine vorhanden.</span>';
dependsOnList.innerHTML = listHtml;
}
const assigneesDiv = document.getElementById('todoEditAssignees');
if(assigneesDiv){
const assignees = projectAssignees(planProject, planWeek);
const currentAssignees = currentTodo && Array.isArray(currentTodo.assignees) ? currentTodo.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);
}
});
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>`;
}).join('') || '<span class="hint">Keine Projektzuordnungen im Board.</span>';
}
document.getElementById('todoModal').style.display = 'flex';
document.getElementById('todoEditTitle').focus();
};
window.closeTodoModal = function(){
document.getElementById('todoModal').style.display = 'none';
currentTodoIndex = null;
};
window.saveTodoEdit = function(){
if(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 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 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 plan = ensurePlanLocal();
if(!Array.isArray(plan.todos)) plan.todos = [];
if(currentTodoIndex === null){
plan.todos.push({ id: uid(), title, start, end, done, progress, dependsOn, assignees });
} else if(plan.todos[currentTodoIndex]){
const t = plan.todos[currentTodoIndex];
t.title = title;
t.start = start;
t.end = end;
t.done = done;
t.progress = progress;
t.dependsOn = dependsOn;
t.assignees = assignees;
}
savePlan();
closeTodoModal();
renderPlanGantt();
};
window.deleteTodoEdit = function(){
if(isLocked()) return;
if(currentTodoIndex === null){ closeTodoModal(); return; }
if(!confirm('TODO löschen?')) return;
const plan = ensurePlanLocal();
if(plan.todos && plan.todos[currentTodoIndex]){
const deletedId = plan.todos[currentTodoIndex].id;
plan.todos.splice(currentTodoIndex, 1);
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 = '';
});
savePlan();
}
closeTodoModal();
renderPlanGantt();
};
window.addTodoFromList = function(){
if(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 = 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 = '';
savePlan();
renderPlanGantt();
};
window.addTodo = function(){
openTodoModal(null);
};
function saveGlobalPeople(){
const people = db.globalPeople[department] || [];
if(people.length > 0){
const peopleCsv = globalPeopleToCsv(people);
sendCsvToServer('_people.csv', peopleCsv).catch(err => console.error('Speichern Personen fehlgeschlagen:', err));
}
}
window.updateTodo = function(idx, field, value){
if(isLocked()) return;
const plan = ensurePlanLocal();
if(!plan.todos[idx]) return;
plan.todos[idx][field] = value;
savePlan();
renderPlanGantt();
};
window.toggleTodoDone = function(idx){
if(isLocked()) return;
const plan = ensurePlanLocal();
if(!plan.todos[idx]) return;
plan.todos[idx].done = !plan.todos[idx].done;
savePlan();
renderPlanGantt();
};
window.deleteTodo = function(idx){
if(isLocked()) return;
if(!confirm('TODO löschen?')) return;
const plan = ensurePlanLocal();
if(!plan.todos[idx]) return;
const deletedId = plan.todos[idx].id;
plan.todos.splice(idx, 1);
removeDependencyRefs(plan, deletedId);
(plan.milestones || []).forEach(m => {
if(m.position === 'before:' + deletedId || m.position === 'after:' + deletedId) m.position = '';
});
savePlan();
renderPlanGantt();
};
function savePlan(){
if(!planWeek || !planProject) return;
// Speichere nur diese Sitzung; globale Personenliste nicht überschreiben
const filename = filenameForWeek(planWeek.label);
const csv = weekToCsv(planWeek);
sendCsvToServer(filename, csv).catch(err => console.error('Speichern fehlgeschlagen:', err));
}
window.renderPlanGantt = function(){
const container = document.getElementById('planGanttContainer');
if(!planProject || !container) return;
const prevScroll = container.scrollLeft;
let start = parseDateISO(document.getElementById('planGanttStart').value);
let end = parseDateISO(document.getElementById('planGanttEnd').value);
if(!start || !end){
const range = ganttRangeFromProjects([planProject], 7);
start = range.start; end = range.end;
document.getElementById('planGanttStart').value = formatDateISO(start);
document.getElementById('planGanttEnd').value = formatDateISO(end);
}
const showVacation = document.getElementById('planShowVacation').checked;
const vacationPeople = projectAssignees(planProject, planWeek).map(a => a.person.id);
const criticalPath = criticalPathActive ? computeCriticalPath(planProject) : [];
container.innerHTML = buildGanttHtml([planProject], start, end, { showVacation, editable: !isLocked(), highlightProjectId: planProject.id, showTodos: true, vacationPeople, pxPerDay: planGanttPxPerDay, criticalPath });
container.scrollLeft = prevScroll;
requestAnimationFrame(() => renderGanttDependencies(container));
renderResourceLoad();
};
window.renderGanttDependencies = function(container){
const c = container || document.getElementById('planGanttContainer');
if(!c) return;
const chart = c.querySelector('.gantt-chart');
if(!chart) return;
let svg = chart.querySelector('.gantt-dependencies-svg');
if(svg) svg.remove();
const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]'));
if(nodeBars.length === 0) return;
const chartRect = chart.getBoundingClientRect();
const barMap = new Map();
nodeBars.forEach(b => {
if(b.dataset.todoId) barMap.set(b.dataset.todoId, b);
});
const paths = [];
nodeBars.forEach(targetBar => {
const dependsOnStr = targetBar.dataset.todoDepends;
if(!dependsOnStr) return;
const depIds = dependsOnStr.split(',').map(s => s.trim()).filter(Boolean);
depIds.forEach(depId => {
const sourceBar = barMap.get(depId);
if(!sourceBar) return;
const sourceRect = sourceBar.getBoundingClientRect();
const targetRect = targetBar.getBoundingClientRect();
const x1 = sourceRect.right - chartRect.left;
const y1 = sourceRect.top + sourceRect.height / 2 - chartRect.top;
const x2 = targetRect.left - chartRect.left;
const y2 = targetRect.top + targetRect.height / 2 - chartRect.top;
const isConflict = x2 < x1;
const isDone = targetBar.classList.contains('done') && sourceBar.classList.contains('done');
let d = '';
if(x2 >= x1 + 10){
const midX = x1 + (x2 - x1) / 2;
d = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
} else {
const r = 16;
d = `M ${x1} ${y1} C ${x1 + r} ${y1}, ${x1 + r} ${(y1 + y2)/2}, ${(x1 + x2)/2} ${(y1 + y2)/2} C ${x2 - r} ${(y1 + y2)/2}, ${x2 - r} ${y2}, ${x2} ${y2}`;
}
let pathClass = 'gantt-dep-path';
let markerId = 'gantt-arrow-normal';
if(isConflict){ pathClass += ' conflict'; markerId = 'gantt-arrow-conflict'; }
else if(isDone){ pathClass += ' done'; markerId = 'gantt-arrow-done'; }
paths.push(`<path class="${pathClass}" d="${d}" marker-end="url(#${markerId})" data-source="${esc(sourceBar.dataset.todoId)}" data-target="${esc(targetBar.dataset.todoId)}" />`);
});
});
if(paths.length === 0) return;
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'gantt-dependencies-svg');
svg.innerHTML = `
<defs>
<marker id="gantt-arrow-normal" viewBox="0 0 10 10" refX="7" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="var(--accent)" />
</marker>
<marker id="gantt-arrow-conflict" viewBox="0 0 10 10" refX="7" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="var(--red)" />
</marker>
<marker id="gantt-arrow-done" viewBox="0 0 10 10" refX="7" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="var(--green)" />
</marker>
</defs>
${paths.join('')}
`;
chart.appendChild(svg);
};
window.fitPlanGanttRange = function(){
const range = ganttRangeFromProjects([planProject], 7);
document.getElementById('planGanttStart').value = formatDateISO(range.start);
document.getElementById('planGanttEnd').value = formatDateISO(range.end);
renderPlanGantt();
};
// zoomPlanGantt: ändert die Pixel-pro-Tag-Stufe des Diagramms.
// Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit).
window.zoomPlanGantt = function(dir){
const container = document.getElementById('planGanttContainer');
let start = parseDateISO(document.getElementById('planGanttStart').value);
let end = parseDateISO(document.getElementById('planGanttEnd').value);
if(!start || !end) return;
const totalDays = Math.max(1, daysBetween(start, end) + 1);
const cw = container ? Math.max(200, container.clientWidth - 250) : 900;
const fitPx = Math.max(3, cw / totalDays);
const base = planGanttPxPerDay || fitPx;
let next;
if(dir > 0){
next = Math.min(120, Math.round(base * 1.35));
} else {
next = Math.round(base / 1.35);
if(next <= fitPx * 0.7) next = null;
}
planGanttPxPerDay = next;
renderPlanGantt();
};
// buildGanttHtml in main.v2.js erweitern, damit TODOs als eigene Balken dargestellt
// werden und neue TODOs direkt unter der Projektzeile angelegt werden können.
const originalBuildGanttHtml = window.buildGanttHtml;
window.buildGanttHtml = function(projects, start, end, options = {}){
if(!options.showTodos) return originalBuildGanttHtml(projects, start, end, options);
const baseHtml = originalBuildGanttHtml(projects, start, end, options);
const temp = document.createElement('div');
temp.innerHTML = baseHtml;
const chart = temp.querySelector('.gantt-chart');
if(!chart) return baseHtml;
const totalDays = Math.max(1, daysBetween(start, end) + 1);
const editable = options.editable;
projects.forEach(p => {
const plan = p.plan || {};
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
// Projektzeile finden, unter der TODOs und Meilensteine eingehängt werden
const projectRow = chart.querySelector('.gantt-row.gantt-project.highlighted') ||
Array.from(chart.querySelectorAll('.gantt-row.gantt-project')).find(r => {
const label = r.querySelector('.gantt-label');
return label && label.textContent.includes(p.name);
});
function buildTodoRow(todo, tIdx){
if(!todo.id) todo.id = uid();
const s = parseDateISO(todo.start), e = parseDateISO(todo.end);
const row = document.createElement('div');
row.className = 'gantt-row gantt-todo' + (todo.done ? ' done' : '');
row.dataset.todoId = todo.id;
row.dataset.todoIndex = tIdx;
let barHtml = '';
if(s && e){
const barStart = s < start ? start : s;
const barEnd = e > end ? end : e;
if(barEnd >= start && barStart <= end){
const offset = daysBetween(start, barStart);
const span = daysBetween(barStart, barEnd) + 1;
const dragAttrs = editable ? ` data-gantt-type="todo" data-gantt-project="${esc(p.id)}" data-gantt-index="${tIdx}" data-gantt-start="${formatDateISO(s)}" data-gantt-end="${formatDateISO(e)}" draggable="false"` : '';
const handles = editable ? `<span class="gantt-resize gantt-resize-left" data-resize="left"></span><span class="gantt-resize gantt-resize-right" data-resize="right"></span>` : '';
const isCritical = options.criticalPath && options.criticalPath.includes(todo.id);
const progress = Math.max(0, Math.min(100, parseInt(todo.progress, 10) || 0));
barHtml = `<div class="gantt-bar todo${todo.done ? ' done' : ''}${isCritical ? ' critical' : ''}"${dragAttrs} data-todo-id="${esc(todo.id)}" data-todo-depends="${esc((todo.dependsOn || []).join(','))}" style="left:${(offset/totalDays*100).toFixed(4)}%;width:${(span/totalDays*100).toFixed(4)}%" title="${esc(todo.title || 'TODO')}: ${formatDateDE(s)}–${formatDateDE(e)}">${handles}<div class="gantt-progress" style="width:${progress}%"></div></div>`;
}
}
let depBadge = '';
if(Array.isArray(todo.dependsOn) && todo.dependsOn.length > 0){
// Vorgänger können TODOs oder Meilensteine (Prefix "m_") sein
const preds = todo.dependsOn.map(id => {
const t = todos.find(x => x.id === id);
if(t) return { title: t.title || 'TODO', end: t.end };
const m = milestones.find(x => 'm_' + x.id === id);
if(m) return { title: '🚩 ' + (m.name || 'Meilenstein'), end: m.date };
return null;
}).filter(Boolean);
if(preds.length > 0){
const isConflict = preds.some(pred => pred.end && todo.start && parseDateISO(todo.start) <= parseDateISO(pred.end));
const names = preds.map(pr => pr.title).join(', ');
depBadge = `<span class="gantt-dep-badge${isConflict ? ' conflict' : ''}" title="Vorgänger: ${esc(names)}">↳ ${preds.length === 1 ? esc(preds[0].title) : esc(preds.length + ' Vorgänger')}</span>`;
}
}
row.innerHTML = `<div class="gantt-label" data-todo-id="${esc(todo.id)}" style="padding-left:24px;font-weight:400;color:var(--text-muted)" title="${esc(todo.title || 'TODO')}">
${editable ? '<span class="gantt-todo-drag">⋮⋮</span>' : ''}
<span class="gantt-todo-title"${editable ? ' onclick="openTodoModal(' + tIdx + ')" style="cursor:pointer"' : ''}>${esc(todo.title || 'TODO')}${depBadge}</span>
</div>
<div class="gantt-timeline">${barHtml}</div>`;
return row;
}
function buildMilestoneRow(m, mIdx){
const d = parseDateISO(m.date);
const row = document.createElement('div');
row.className = 'gantt-row gantt-milestone-row' + (m.position ? ' positioned' : '');
row.dataset.milestoneId = m.id;
row.dataset.milestoneIndex = mIdx;
let diamond = '', labelStyle = 'display:none';
if(d){
if(d >= start && d <= end){
const offset = daysBetween(start, d);
const left = (offset / totalDays * 100).toFixed(4) + '%';
const dragAttrs = editable ? ` data-gantt-type="milestone" data-gantt-project="${esc(p.id)}" data-gantt-index="${mIdx}" data-gantt-date="${formatDateISO(d)}" draggable="false"` : '';
const deps = esc((m.dependsOn || []).join(','));
diamond = `<div class="gantt-milestone"${dragAttrs} data-todo-id="m_${esc(m.id)}" data-todo-depends="${deps}" style="left:${left}" title="${esc(m.name)}: ${formatDateDE(d)}"></div>`;
labelStyle = 'left:' + left;
}
}
row.innerHTML = `<div class="gantt-label" style="padding-left:24px;font-weight:400;color:var(--text-muted)">
${editable ? '<span class="gantt-todo-drag">⋮⋮</span>' : ''}
<span class="gantt-milestone-title"${editable ? ' onclick="openMilestoneModal(' + mIdx + ')" style="cursor:pointer"' : ''}>${esc(m.name || 'Meilenstein')}</span>
</div>
<div class="gantt-timeline">${diamond}<div class="gantt-milestone-label" style="${labelStyle}">${esc(m.name || '')}</div></div>`;
return row;
}
if(!projectRow) return;
let insertAfter = projectRow;
const insertedMilestoneIds = new Set();
todos.forEach((todo, tIdx) => {
// Meilensteine, die VOR dieser Aufgabe positioniert sind
milestones.filter(m => m.position === 'before:' + todo.id).forEach(m => {
const mIdx = milestones.indexOf(m);
const row = buildMilestoneRow(m, mIdx);
insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling);
insertAfter = row;
});
// Aufgabenzeile
const todoRow = buildTodoRow(todo, tIdx);
insertAfter.parentNode.insertBefore(todoRow, insertAfter.nextSibling);
insertAfter = todoRow;
// Meilensteine, die NACH dieser Aufgabe positioniert sind
milestones.filter(m => m.position === 'after:' + todo.id).forEach(m => {
const mIdx = milestones.indexOf(m);
const row = buildMilestoneRow(m, mIdx);
insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling);
insertAfter = row;
insertedMilestoneIds.add(m.id);
});
});
// Positionierte Meilensteine, deren referenzierte Aufgabe nicht mehr existiert,
// am Ende der Aufgabenliste anhängen, damit sie nicht verloren gehen.
milestones.filter(m => m.position && !insertedMilestoneIds.has(m.id)).forEach(m => {
const mIdx = milestones.indexOf(m);
const row = buildMilestoneRow(m, mIdx);
insertAfter.parentNode.insertBefore(row, insertAfter.nextSibling);
insertAfter = row;
});
});
return temp.innerHTML;
};
// ---------- GANTT Drag-Editing (Balken, Meilensteine, Urlaub) ----------
let ganttDrag = null;
function initGanttDrag(){
const container = document.getElementById('planGanttContainer');
if(!container) return;
container.addEventListener('mousedown', onGanttMouseDown);
container.addEventListener('dblclick', onGanttDoubleClick);
document.addEventListener('mousemove', onGanttMouseMove);
document.addEventListener('mouseup', onGanttMouseUp);
// Klick ohne Bewegung wird bereits in onGanttMouseUp behandelt (Modal öffnen).
// Ein zusätzlicher click-Handler würde das Modal doppelt bzw. nach einem
// Drag ungewollt öffnen.
}
function onGanttMouseDown(e){
if(isLocked()) return;
const bar = e.target.closest('[data-gantt-type]');
if(!bar) return;
const timeline = bar.closest('.gantt-timeline');
if(!timeline) return;
const rangeStart = parseDateISO(document.getElementById('planGanttStart').value);
const rangeEnd = parseDateISO(document.getElementById('planGanttEnd').value);
if(!rangeStart || !rangeEnd) return;
const resize = e.target.closest('.gantt-resize');
const rect = timeline.getBoundingClientRect();
ganttDrag = {
bar,
timeline,
type: bar.dataset.ganttType,
action: resize ? resize.dataset.resize : 'move',
startX: e.clientX,
startY: e.clientY,
timelineWidth: rect.width,
rangeStart,
rangeEnd,
moved: false,
originalStart: parseDateISO(bar.dataset.ganttStart || bar.dataset.ganttDate),
originalEnd: parseDateISO(bar.dataset.ganttEnd),
personId: bar.dataset.ganttPerson,
absenceId: bar.dataset.ganttAbsence,
projectId: bar.dataset.ganttProject,
milestoneIndex: bar.dataset.ganttType === 'milestone' && bar.dataset.ganttIndex !== undefined ? parseInt(bar.dataset.ganttIndex, 10) : null,
todoIndex: bar.dataset.ganttType === 'todo' && bar.dataset.ganttIndex !== undefined ? parseInt(bar.dataset.ganttIndex, 10) : null
};
e.preventDefault();
e.stopPropagation();
}
function onGanttMouseMove(e){
if(!ganttDrag) return;
const totalDays = daysBetween(ganttDrag.rangeStart, ganttDrag.rangeEnd) + 1;
const pxPerDay = ganttDrag.timelineWidth / totalDays;
const dx = e.clientX - ganttDrag.startX;
const dayDelta = Math.round(dx / pxPerDay);
if(Math.abs(dx) > 2) ganttDrag.moved = true;
if(ganttDrag.type === 'milestone'){
const newDate = addDays(ganttDrag.originalStart, dayDelta);
const offset = daysBetween(ganttDrag.rangeStart, newDate);
const left = (offset / totalDays * 100).toFixed(4) + '%';
ganttDrag.bar.style.left = left;
// Zugehöriges Label (nächstes Geschwister-Element) mitbewegen
const label = ganttDrag.bar.nextElementSibling;
if(label && label.classList.contains('gantt-milestone-label')) label.style.left = left;
return;
}
let newStart, newEnd;
if(ganttDrag.action === 'move'){
newStart = addDays(ganttDrag.originalStart, dayDelta);
newEnd = addDays(ganttDrag.originalEnd, dayDelta);
} else if(ganttDrag.action === 'left'){
newStart = addDays(ganttDrag.originalStart, dayDelta);
newEnd = ganttDrag.originalEnd;
if(newStart > newEnd) newStart = newEnd;
} else if(ganttDrag.action === 'right'){
newStart = ganttDrag.originalStart;
newEnd = addDays(ganttDrag.originalEnd, dayDelta);
if(newEnd < newStart) newEnd = newStart;
}
const barStart = newStart < ganttDrag.rangeStart ? ganttDrag.rangeStart : newStart;
const barEnd = newEnd > ganttDrag.rangeEnd ? ganttDrag.rangeEnd : newEnd;
const offset = daysBetween(ganttDrag.rangeStart, barStart);
const span = daysBetween(barStart, barEnd) + 1;
ganttDrag.bar.style.left = (offset / totalDays * 100).toFixed(4) + '%';
ganttDrag.bar.style.width = (span / totalDays * 100).toFixed(4) + '%';
}
function onGanttMouseUp(e){
if(!ganttDrag) return;
const drag = ganttDrag;
ganttDrag = null;
if(!drag.moved && drag.type === 'milestone'){
openMilestoneModal(drag.milestoneIndex);
return;
}
if(!drag.moved && drag.type === 'todo'){
openTodoModal(drag.todoIndex);
return;
}
const totalDays = daysBetween(drag.rangeStart, drag.rangeEnd) + 1;
const pxPerDay = drag.timelineWidth / totalDays;
const dx = e.clientX - drag.startX;
const dayDelta = Math.round(dx / pxPerDay);
if(dayDelta === 0){
renderPlanGantt();
return;
}
let newStartStr, newEndStr;
let needsRefit = false;
if(drag.type === 'milestone'){
const newDate = addDays(drag.originalStart, dayDelta);
newStartStr = formatDateISO(newDate);
updateMilestoneDate(drag.projectId, drag.milestoneIndex, newStartStr);
if(newDate < drag.rangeStart || newDate > drag.rangeEnd) needsRefit = true;
} else {
let newStart, newEnd;
if(drag.action === 'move'){
newStart = addDays(drag.originalStart, dayDelta);
newEnd = addDays(drag.originalEnd, dayDelta);
} else if(drag.action === 'left'){
newStart = addDays(drag.originalStart, dayDelta);
newEnd = drag.originalEnd;
if(newStart > newEnd) newStart = newEnd;
} else {
newStart = drag.originalStart;
newEnd = addDays(drag.originalEnd, dayDelta);
if(newEnd < newStart) newEnd = newStart;
}
newStartStr = formatDateISO(newStart);
newEndStr = formatDateISO(newEnd);
if(newStart < drag.rangeStart || newEnd > drag.rangeEnd) needsRefit = true;
if(drag.type === 'project'){
updateProjectDates(drag.projectId, newStartStr, newEndStr);
} else if(drag.type === 'vacation'){
updateVacationDates(drag.personId, drag.absenceId, newStartStr, newEndStr);
} else if(drag.type === 'todo'){
updateTodoDates(drag.todoIndex, newStartStr, newEndStr);
}
}
if(needsRefit) fitPlanGanttRange();
else renderPlanGantt();
}
function updateTodoDates(idx, start, end){
const plan = ensurePlanLocal();
if(!plan.todos[idx]) return;
plan.todos[idx].start = start;
plan.todos[idx].end = end;
savePlan();
}
function onGanttDoubleClick(e){
if(isLocked()) return;
const timeline = e.target.closest('.gantt-timeline');
if(!timeline) return;
// Nicht auslösen, wenn auf einen Balken geklickt wurde
if(e.target.closest('[data-gantt-type]')) return;
const rect = timeline.getBoundingClientRect();
const x = e.clientX - rect.left;
const rangeStart = parseDateISO(document.getElementById('planGanttStart').value);
const rangeEnd = parseDateISO(document.getElementById('planGanttEnd').value);
if(!rangeStart || !rangeEnd) return;
const totalDays = daysBetween(rangeStart, rangeEnd) + 1;
const dayOffset = Math.max(0, Math.min(totalDays - 1, Math.round(x / rect.width * totalDays)));
const date = formatDateISO(addDays(rangeStart, dayOffset));
openTodoModal(null);
document.getElementById('todoEditStart').value = date;
document.getElementById('todoEditEnd').value = date;
}
function updateProjectDates(projectId, start, end){
const p = planWeek.projects.find(x => x.id === projectId);
if(!p) return;
ensurePlan(p);
p.plan.start = start;
p.plan.end = end;
savePlan();
}
function updateMilestoneDate(projectId, index, date){
const p = planWeek.projects.find(x => x.id === projectId);
if(!p) return;
ensurePlan(p);
if(!Array.isArray(p.plan.milestones)) p.plan.milestones = [];
if(p.plan.milestones[index]){
p.plan.milestones[index].date = date;
savePlan();
// Optional: Abhängige TODOs automatisch nachziehen
const msId = 'm_' + p.plan.milestones[index].id;
const affected = (p.plan.todos || []).filter(t => Array.isArray(t.dependsOn) && t.dependsOn.includes(msId));
if(affected.length > 0) autoScheduleTodos();
}
}
function updateVacationDates(personId, absenceId, start, end){
const localPerson = (planWeek.people || []).find(p => p.id === personId);
if(localPerson && localPerson.absences){
const a = localPerson.absences.find(x => x.id === absenceId);
if(a){ a.start = start; a.end = end; }
}
const globalPeople = db.globalPeople[department] || [];
const globalPerson = globalPeople.find(p => p.id === personId);
if(globalPerson && globalPerson.absences){
const a = globalPerson.absences.find(x => x.id === absenceId);
if(a){ a.start = start; a.end = end; }
}
savePlan();
saveGlobalPeople();
}
// ---------- Kritischer Pfad & Auto-Schedule ----------
// computeCriticalPath: liefert Array der TODO-IDs, die auf einem kritischen Pfad liegen
// (längste Kette von Vorgänger-Abhängigkeiten, End-to-Start)
window.computeCriticalPath = function(project){
if(!project || !project.plan) return [];
const todos = (project.plan.todos || []).filter(t => t.start && t.end);
const milestones = (project.plan.milestones || []).filter(m => m.date);
if(todos.length === 0 && milestones.length === 0) return [];
// Einheitliche Knoten: TODOs und Meilensteine (Dauer 1)
const nodes = todos.map(t => ({ type: 'todo', id: t.id, obj: t, start: parseDateISO(t.start), end: parseDateISO(t.end), duration: Math.max(1, daysBetween(parseDateISO(t.start), parseDateISO(t.end)) + 1) }));
milestones.forEach(m => {
const d = parseDateISO(m.date);
nodes.push({ type: 'milestone', id: 'm_' + m.id, obj: m, start: d, end: d, duration: 1 });
});
const nodeMap = new Map(nodes.map(n => [n.id, n]));
function depsOf(n){
return Array.isArray(n.obj.dependsOn) ? n.obj.dependsOn : [];
}
// Frühester Start (ES) und frühestes Ende (EF) berechnen.
// visiting-Set schützt vor Endlos-Rekursion bei zyklischen Abhängigkeiten.
const es = {}, ef = {};
const esVisiting = new Set();
function computeES(n){
if(es[n.id] !== undefined) return es[n.id];
if(esVisiting.has(n.id)){ es[n.id] = null; ef[n.id] = null; return null; } // Zyklus
if(!n.start || !n.end){ es[n.id] = null; ef[n.id] = null; return null; }
esVisiting.add(n.id);
const deps = depsOf(n);
let maxEF = null;
deps.forEach(depId => {
const dep = nodeMap.get(depId);
if(!dep) return;
const depStart = computeES(dep);
if(!depStart) return;
const depEnd = addDays(depStart, dep.duration - 1);
if(maxEF === null || depEnd > maxEF) maxEF = depEnd;
});
esVisiting.delete(n.id);
es[n.id] = maxEF === null ? n.start : addDays(maxEF, 1);
ef[n.id] = addDays(es[n.id], n.duration - 1);
return es[n.id];
}
nodes.forEach(n => computeES(n));
const validNodes = nodes.filter(n => es[n.id]);
if(validNodes.length === 0) return [];
const maxEF = validNodes.reduce((max, n) => { const d = ef[n.id]; return !max || d > max ? d : max; }, null);
// Spätestes Ende (LF) und spätester Start (LS) rückwärts berechnen.
// Wichtig: LF eines Knotens ist der Tag VOR dem spätesten Start (LS)
// seines frühesten Nachfolgers – nicht vor dessen LF.
const lf = {}, ls = {};
const lfVisiting = new Set();
function computeLF(n){
if(lf[n.id] !== undefined) return lf[n.id];
if(lfVisiting.has(n.id)){ lf[n.id] = null; ls[n.id] = null; return null; } // Zyklus
if(!n.start || !n.end){ lf[n.id] = null; ls[n.id] = null; return null; }
lfVisiting.add(n.id);
const successors = validNodes.filter(succ => depsOf(succ).includes(n.id));
let minLF = null;
successors.forEach(succ => {
if(!computeLF(succ)) return;
const succLS = ls[succ.id];
if(!succLS) return;
const candidate = addDays(succLS, -1);
if(minLF === null || candidate < minLF) minLF = candidate;
});
lfVisiting.delete(n.id);
lf[n.id] = minLF === null ? maxEF : minLF;
ls[n.id] = addDays(lf[n.id], -(n.duration - 1));
return lf[n.id];
}
validNodes.forEach(n => computeLF(n));
return validNodes.filter(n => n.type === 'todo' && es[n.id] && ls[n.id] && daysBetween(es[n.id], ls[n.id]) === 0).map(n => n.id);
};
window.toggleCriticalPath = function(){
criticalPathActive = !criticalPathActive;
const btn = document.getElementById('criticalPathBtn');
if(btn) btn.classList.toggle('active', criticalPathActive);
renderPlanGantt();
if(criticalPathActive){
const path = computeCriticalPath(planProject);
if(path.length === 0){
showToast('Kein kritischer Pfad ermittelt (zu wenig Aufgaben oder Abhängigkeiten).');
} else {
showToast(path.length + ' Aufgabe(n) auf dem kritischen Pfad hervorgehoben.');
}
}
};
window.autoScheduleTodos = function(){
if(isLocked()) return;
const plan = ensurePlanLocal();
if(!Array.isArray(plan.todos) || plan.todos.length === 0) return;
const todos = plan.todos.filter(t => t.start && t.end);
const todoMap = new Map(todos.map(t => [t.id, t]));
const milestones = Array.isArray(plan.milestones) ? plan.milestones.filter(m => m.date) : [];
const milestoneMap = new Map(milestones.map(m => ['m_' + m.id, m]));
function duration(t){ const s = parseDateISO(t.start), e = parseDateISO(t.end); return s && e ? Math.max(1, daysBetween(s, e) + 1) : 1; }
// processed: bereits fertig eingeplante Knoten
// visiting: aktuell im Rekursionspfad -> schützt vor Endlos-Rekursion bei Zyklen
// moved: Knoten, deren Datum sich tatsächlich geändert hat
const processed = new Set();
const visiting = new Set();
const moved = new Set();
let hasCycle = false;
function scheduleTodo(t){
if(processed.has(t.id)) return;
if(visiting.has(t.id)){ hasCycle = true; return; }
const tStart = parseDateISO(t.start);
if(!tStart){ processed.add(t.id); return; }
visiting.add(t.id);
const deps = Array.isArray(t.dependsOn) ? t.dependsOn : [];
let earliest = tStart;
deps.forEach(depId => {
let depEnd = null;
const depTodo = todoMap.get(depId);
if(depTodo){
scheduleTodo(depTodo);
depEnd = parseDateISO(depTodo.end);
} else {
const depMs = milestoneMap.get(depId);
if(depMs) depEnd = parseDateISO(depMs.date);
}
if(!depEnd) return;
const candidate = addDays(depEnd, 1);
if(candidate > earliest) earliest = candidate;
});
visiting.delete(t.id);
const dur = duration(t);
const newStart = formatDateISO(earliest);
const newEnd = formatDateISO(addDays(earliest, dur - 1));
if(newStart !== t.start || newEnd !== t.end){
t.start = newStart;
t.end = newEnd;
moved.add(t.id);
}
processed.add(t.id);
}
function scheduleMilestone(m){
const mId = 'm_' + m.id;
if(processed.has(mId)) return;
if(visiting.has(mId)){ hasCycle = true; return; }
const deps = Array.isArray(m.dependsOn) ? m.dependsOn : [];
let earliest = parseDateISO(m.date);
if(!earliest){ processed.add(mId); return; }
visiting.add(mId);
deps.forEach(depId => {
let depEnd = null;
const depTodo = todoMap.get(depId);
if(depTodo){
scheduleTodo(depTodo);
depEnd = parseDateISO(depTodo.end);
} else {
const depMs = milestoneMap.get(depId);
if(depMs && depMs.id !== m.id){
scheduleMilestone(depMs);
depEnd = parseDateISO(depMs.date);
}
}
if(!depEnd) return;
const candidate = addDays(depEnd, 1);
if(candidate > earliest) earliest = candidate;
});
visiting.delete(mId);
const newDate = formatDateISO(earliest);
if(newDate !== m.date){
m.date = newDate;
moved.add(mId);
}
processed.add(mId);
}
todos.forEach(t => scheduleTodo(t));
milestones.forEach(m => scheduleMilestone(m));
if(moved.size > 0){
savePlan();
renderPlanGantt();
showToast(moved.size + ' Element(e) automatisch verschoben.' + (hasCycle ? ' Achtung: zyklische Abhängigkeit gefunden!' : ''));
} else if(hasCycle){
showToast('Achtung: zyklische Abhängigkeit gefunden – bitte Vorgänger prüfen.');
} else {
showToast('Keine Änderungen nötig – alle Termine passen zu den Abhängigkeiten.');
}
};
// ---------- Ressourcen-Auslastung ----------
window.renderResourceLoad = function(){
const container = document.getElementById('resourceLoadContainer');
if(!container || !planProject) return;
const plan = ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos.filter(t => t.start && t.end) : [];
const assignees = projectAssignees(planProject, planWeek);
if(assignees.length === 0){
container.innerHTML = '';
return;
}
const loads = {};
assignees.forEach(a => {
if(!loads[a.person.id]){
loads[a.person.id] = { person: a.person, roles: [], days: 0, todos: [] };
}
if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role);
});
todos.forEach(t => {
if(!Array.isArray(t.assignees) || t.assignees.length === 0) return;
const days = workdaysBetween(parseDateISO(t.start), parseDateISO(t.end));
t.assignees.forEach(pid => {
if(!loads[pid]) return;
loads[pid].days += days;
loads[pid].todos.push(t.title);
});
});
const maxDays = Math.max(1, Object.values(loads).reduce((m, l) => Math.max(m, l.days), 0));
let html = '<div class="section-title" style="margin-bottom:8px;font-size:13px">Ressourcen-Auslastung (Aufgabentage, ohne Wochenenden)</div>';
html += '<div class="load-grid plan-resource-grid">';
Object.values(loads).forEach(l => {
const pct = Math.round(l.days / maxDays * 100);
const absCount = countPersonAbsences(l.person.id);
const absBadge = absCount > 0 ? `<div class="hint" style="font-size:10px;margin-top:2px">🏖 ${absCount} Abwesenheit(en)</div>` : '';
let todoListHtml;
if(l.todos.length === 0){
todoListHtml = '<div class="hint">keine TODOs</div>';
} else {
todoListHtml = l.todos.slice(0,3).map(t => `<div><span>${esc(t)}</span></div>`).join('');
if(l.todos.length > 3){
todoListHtml += '<div class="hint">+' + (l.todos.length - 3) + ' weitere</div>';
}
}
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
<h3>${esc(l.person.name)} <span style="font-size:10px;color:#888;font-weight:normal">(${esc(l.roles.join(' / '))})</span></h3>
<div class="load-num" style="font-size:18px">${l.days}</div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${pct}%;background:${pct>75?'#c53030':pct>45?'#d69e2e':'#2f855a'}"></div></div>
<div class="load-detail" style="font-size:10px;margin-top:4px">${todoListHtml}</div>
${absBadge}
</div>`;
});
html += '</div>';
container.innerHTML = html;
};
// ---------- Abwesenheits-Modal (Urlaube der Projektbeteiligten) ----------
let currentAbsencePersonId = null;
// Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt)
function findGlobalPerson(personId){
return (db.globalPeople[department] || []).find(p => p.id === personId) || null;
}
function countPersonAbsences(personId){
const p = findGlobalPerson(personId);
return p && Array.isArray(p.absences) ? p.absences.length : 0;
}
window.openAbsenceModal = function(personId){
const person = findGlobalPerson(personId);
if(!person) return;
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';
};
window.closeAbsenceModal = function(){
document.getElementById('absenceModal').style.display = 'none';
currentAbsencePersonId = null;
};
function renderAbsenceModalList(){
const listDiv = document.getElementById('absenceModalList');
const person = findGlobalPerson(currentAbsencePersonId);
if(!listDiv || !person) return;
const absences = Array.isArray(person.absences) ? person.absences : [];
const locked = 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' : '';
}
window.addPlanAbsence = function(){
if(isLocked()) return;
const person = findGlobalPerson(currentAbsencePersonId);
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; }
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 = (planWeek.people || []).find(p => p.id === 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));
}
saveGlobalPeople();
savePlan();
document.getElementById('absenceEditStart').value = '';
document.getElementById('absenceEditEnd').value = '';
const rangeDisplay = document.getElementById('absenceEditRange');
if(rangeDisplay) rangeDisplay.value = '';
document.getElementById('absenceEditNote').value = '';
renderAbsenceModalList();
renderPlanGantt();
};
window.deletePlanAbsence = function(absenceId){
if(isLocked()) return;
const person = findGlobalPerson(currentAbsencePersonId);
if(!person || !Array.isArray(person.absences)) return;
person.absences = person.absences.filter(a => a.id !== absenceId);
const localPerson = (planWeek.people || []).find(p => p.id === currentAbsencePersonId);
if(localPerson && Array.isArray(localPerson.absences)){
localPerson.absences = localPerson.absences.filter(a => a.id !== absenceId);
}
saveGlobalPeople();
savePlan();
renderAbsenceModalList();
renderPlanGantt();
};
// Toast-Benachrichtigung
let toastTimer = null;
function showToast(message){
let toast = document.getElementById('planToast');
if(!toast){
toast = document.createElement('div');
toast.id = 'planToast';
toast.className = 'plan-toast';
document.body.appendChild(toast);
}
toast.textContent = message;
toast.classList.add('show');
// Vorherigen Timer abbrechen, sonst verschwindet ein neuer Toast zu früh
if(toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast.classList.remove('show'); toastTimer = null; }, 2500);
}
// Abhängigkeitslinien hängen von Pixel-Positionen ab -> bei Fenstergröße neu zeichnen
let resizeTimer = null;
window.addEventListener('resize', () => {
if(resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => renderGanttDependencies(), 150);
});
document.addEventListener('DOMContentLoaded', init);
document.addEventListener('DOMContentLoaded', initGanttDrag);
document.addEventListener('DOMContentLoaded', initGanttTodoDrag);
})();