1529 lines
64 KiB
JavaScript
1529 lines
64 KiB
JavaScript
// 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);
|
||
})();
|