// 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 'Keine Zuordnungen im Projektboard.';
return assignees.map(a => {
const cls = a.state === 1 ? 'main' : a.state === 2 ? 'optional' : '';
return `${esc(a.person.name)}${a.role ? ' (' + esc(a.role) + ')' : ''}`;
}).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 = '';
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 += ``;
opts += ``;
});
}
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 += '
Meilensteine
';
plan.milestones.forEach(m => {
if(currentMs && m.id === currentMs.id) return;
const mId = 'm_' + m.id;
const checked = currentDeps.includes(mId) ? 'checked' : '';
listHtml += ``;
});
}
// TODOs als Vorgänger anbieten
if(plan.todos && plan.todos.length > 0){
listHtml += 'Aufgaben
';
(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 += ``;
});
}
if(listHtml === '') listHtml = 'Keine anderen Aufgaben oder Meilensteine vorhanden.';
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 += 'Meilensteine
';
plan.milestones.forEach(m => {
const mId = 'm_' + m.id;
const checked = currentDeps.includes(mId) ? 'checked' : '';
listHtml += ``;
});
}
// Andere TODOs als Vorgänger anbieten
if(plan.todos && plan.todos.length > 1){
listHtml += 'Aufgaben
';
(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 += ``;
});
}
if(listHtml === '') listHtml = 'Keine anderen Aufgaben oder Meilensteine vorhanden.';
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 ``;
}).join('') || 'Keine Projektzuordnungen im Board.';
}
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(``);
});
});
if(paths.length === 0) return;
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'gantt-dependencies-svg');
svg.innerHTML = `
${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 ? `` : '';
const isCritical = options.criticalPath && options.criticalPath.includes(todo.id);
const progress = Math.max(0, Math.min(100, parseInt(todo.progress, 10) || 0));
barHtml = ``;
}
}
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 = `↳ ${preds.length === 1 ? esc(preds[0].title) : esc(preds.length + ' Vorgänger')}`;
}
}
row.innerHTML = `
${editable ? '⋮⋮' : ''}
${esc(todo.title || 'TODO')}${depBadge}
${barHtml}
`;
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 = ``;
labelStyle = 'left:' + left;
}
}
row.innerHTML = `
${editable ? '⋮⋮' : ''}
${esc(m.name || 'Meilenstein')}
${diamond}
${esc(m.name || '')}
`;
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 = 'Ressourcen-Auslastung (Aufgabentage, ohne Wochenenden)
';
html += '';
Object.values(loads).forEach(l => {
const pct = Math.round(l.days / maxDays * 100);
const absCount = countPersonAbsences(l.person.id);
const absBadge = absCount > 0 ? `
🏖 ${absCount} Abwesenheit(en)
` : '';
let todoListHtml;
if(l.todos.length === 0){
todoListHtml = '
keine TODOs
';
} else {
todoListHtml = l.todos.slice(0,3).map(t => `
${esc(t)}
`).join('');
if(l.todos.length > 3){
todoListHtml += '
+' + (l.todos.length - 3) + ' weitere
';
}
}
html += `
${esc(l.person.name)} (${esc(l.roles.join(' / '))})
${l.days}
${todoListHtml}
${absBadge}
`;
});
html += '
';
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 = 'Keine Abwesenheiten eingetragen.';
} else {
listDiv.innerHTML = '' + absences.map(a => `
${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))}
${a.note ? `${esc(a.note)}` : ''}
${locked ? '' : ``}
`).join('') + '
';
}
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);
})();