code auf einzelne Datein aufgeteilt
This commit is contained in:
1 parent
7451e0bf78
commit
8c94fc91f6
30 files changed
+5765
-27
No files matched your search
@@ -0,0 +1,174 @@
|
||||
// -----------------------------
|
||||
// Projektplan — buildGanttHtml-Erweiterung (TODOs & Meilensteine im GANTT)
|
||||
// -----------------------------
|
||||
// Dieses Modul erweitert die in gantt.js definierte Funktion buildGanttHtml,
|
||||
// damit im Projektplan TODOs und Meilensteine als eigene Zeilen unter der
|
||||
// Projektzeile dargestellt werden. Mit der Option showTodos wird zuerst das
|
||||
// Basis-GANTT erzeugt und dann per DOM-Bearbeitung um die Zusatzzeilen
|
||||
// ergänzt. Die Abhängigkeitslinien zeichnet danach plan-gantt.js
|
||||
// (renderGanttDependencies), die Drag-/Resize- und Klick-Attribute der
|
||||
// Balken nutzen plan-drag.js und plan-modals.js.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// Referenz auf die Original-Funktion aus gantt.js, die für das Basis-GANTT
|
||||
// weiterhin verwendet wird.
|
||||
const originalBuildGanttHtml = window.buildGanttHtml;
|
||||
|
||||
// window.buildGanttHtml (überschrieben): Erweiterte Version der Funktion
|
||||
// aus gantt.js. Ohne Option showTodos wird unverändert die Original-Funktion
|
||||
// aufgerufen. Mit showTodos wird deren HTML per DOM-Bearbeitung um die
|
||||
// TODO- und Meilensteinzeilen aller übergebenen Projekte ergänzt und als
|
||||
// HTML-String zurückgegeben. Parameter: projects (Array), start/end (ISO-
|
||||
// Datum), options (Optionen inkl. editable und criticalPath). Rückgabe:
|
||||
// String mit dem kompletten GANTT-HTML.
|
||||
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);
|
||||
});
|
||||
|
||||
// buildTodoRow: baut die DOM-Zeile einer TODO-Aufgabe mit Label, ggf.
|
||||
// Abhängigkeits-Badge und (im Bearbeitungsmodus) verschiebbarem Balken
|
||||
// mit Resize-Griffen und Fortschrittsanzeige. Vergibt fehlende IDs beim
|
||||
// ersten Rendern. Parameter: todo (Aufgabenobjekt), tIdx (Index in der
|
||||
// Aufgabenliste). Rückgabe: Zeilen-DOM-Element.
|
||||
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;
|
||||
}
|
||||
|
||||
// buildMilestoneRow: baut die DOM-Zeile eines Meilensteins mit Rauten-Symbol
|
||||
// an der Datumsposition, Label und ggf. Datumskennzeichnung. Parameter:
|
||||
// m (Meilensteinobjekt), mIdx (Index in der Meilensteinliste). Rückgabe:
|
||||
// Zeilen-DOM-Element.
|
||||
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;
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,433 @@
|
||||
// -----------------------------
|
||||
// Projektplan — Drag-and-Drop (GANTT-Balken & TODO/Meilenstein-Zeilen)
|
||||
// -----------------------------
|
||||
// Dieses Modul macht das Projektplan-GANTT interaktiv: TODO- und
|
||||
// Meilenstein-Zeilen lassen sich per Greif-Handler umsortieren und GANTT-
|
||||
// Balken, Meilenstein-Rauten sowie Urlaubsblöcke per Mausziehen verschieben
|
||||
// bzw. an den Rändern resizen. Die neuen Daten schreibt es über app.savePlan
|
||||
// (plan-state.js) und lässt das Diagramm über renderPlanGantt
|
||||
// (plan-gantt.js) neu zeichnen; Doppelklicks öffnen die Modals aus
|
||||
// plan-modals.js. Bei gesperrter Woche (app.isLocked) ist alles deaktiviert.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// Selektor für alle per Zeilen-Drag umsortierbaren Zeilen (TODO und
|
||||
// Meilenstein) im GANTT.
|
||||
const DRAGGABLE_GANTT_ROW_SELECTOR = '.gantt-row.gantt-todo, .gantt-row.gantt-milestone-row';
|
||||
|
||||
// ---------- Drag-and-Drop für TODO- und Meilenstein-Zeilen direkt im GANTT ----------
|
||||
|
||||
// initGanttTodoDrag: registriert die Maus-Events (mousedown/mousemove/mouseup)
|
||||
// für das Umsortieren von TODO- und Meilenstein-Zeilen am GANTT-Container.
|
||||
function initGanttTodoDrag(){
|
||||
const container = document.getElementById('planGanttContainer');
|
||||
if(!container) return;
|
||||
container.addEventListener('mousedown', onGanttTodoMouseDown);
|
||||
document.addEventListener('mousemove', onGanttTodoMouseMove);
|
||||
document.addEventListener('mouseup', onGanttTodoMouseUp);
|
||||
}
|
||||
|
||||
// onGanttTodoMouseDown: startet das Zeilen-Ziehen, wenn der Greif-Handler
|
||||
// (⋮⋮) einer TODO-/Meilenstein-Zeile gedrückt wird, und merkt sich Zeile
|
||||
// und Startposition in app.todoDrag.
|
||||
function onGanttTodoMouseDown(e){
|
||||
if(app.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();
|
||||
|
||||
app.todoDrag = {
|
||||
row,
|
||||
startY: e.clientY,
|
||||
moved: false
|
||||
};
|
||||
row.classList.add('dragging');
|
||||
}
|
||||
|
||||
// onGanttTodoMouseMove: verschiebt bei aktiver Ziehung (app.todoDrag) die
|
||||
// Zeile live im Chart – entweder ans Ende oder vor das dem Mauszeiger
|
||||
// nächstliegende Element (getGanttRowDragAfterElement).
|
||||
function onGanttTodoMouseMove(e){
|
||||
if(!app.todoDrag) return;
|
||||
const chart = app.todoDrag.row.closest('.gantt-chart');
|
||||
if(!chart) return;
|
||||
|
||||
const dy = e.clientY - app.todoDrag.startY;
|
||||
if(Math.abs(dy) > 3) app.todoDrag.moved = true;
|
||||
if(!app.todoDrag.moved) return;
|
||||
|
||||
const afterElement = getGanttRowDragAfterElement(chart, e.clientY);
|
||||
if(app.todoDrag.row && afterElement == null){
|
||||
chart.appendChild(app.todoDrag.row);
|
||||
} else if(app.todoDrag.row && afterElement && afterElement !== app.todoDrag.row){
|
||||
chart.insertBefore(app.todoDrag.row, afterElement);
|
||||
}
|
||||
}
|
||||
|
||||
// onGanttTodoMouseUp: beendet das Zeilen-Ziehen. Bei einer echten Bewegung
|
||||
// (moved) wird die neue Reihenfolge über reorderPlanRows übernommen.
|
||||
function onGanttTodoMouseUp(e){
|
||||
if(!app.todoDrag) return;
|
||||
const drag = app.todoDrag;
|
||||
app.todoDrag = null;
|
||||
drag.row.classList.remove('dragging');
|
||||
if(drag.moved){
|
||||
reorderPlanRows();
|
||||
}
|
||||
}
|
||||
|
||||
// getGanttRowDragAfterElement: ermittelt unter allen nicht gezogenen
|
||||
// Zeilen jenes Element, das der übergebenen y-Position am nächsten liegt.
|
||||
// Rückgabe: Element oder undefined (dann wäre die Zeile am Chart-Ende).
|
||||
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;
|
||||
}
|
||||
|
||||
// reorderPlanRows: übernimmt die im DOM gefundene Zeilen-Reihenfolge in den
|
||||
// Plan: TODO-Liste wird umsortiert, Meilenstein-Positionen (before:/after:
|
||||
// einer TODO) werden gemäß der Regeln im Code neu berechnet. Anschließend
|
||||
// Speichern über app.savePlan und Neu-Rendern über renderPlanGantt.
|
||||
function reorderPlanRows(){
|
||||
if(app.isLocked()) return;
|
||||
const chart = document.querySelector('#planGanttContainer .gantt-chart');
|
||||
if(!chart) return;
|
||||
const plan = app.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;
|
||||
});
|
||||
|
||||
app.savePlan();
|
||||
renderPlanGantt();
|
||||
}
|
||||
|
||||
// ---------- GANTT Drag-Editing (Balken, Meilensteine, Urlaub) ----------
|
||||
|
||||
// initGanttDrag: registriert die Maus- und Doppelklick-Events für das
|
||||
// Verschieben/Resizen von GANTT-Balken, Meilensteinen und Urlaubsblöcken.
|
||||
// Ein Klick ohne Bewegung wird in onGanttMouseUp behandelt (kein eigener
|
||||
// click-Handler, um doppeltes Öffnen zu vermeiden).
|
||||
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.
|
||||
}
|
||||
|
||||
// onGanttMouseDown: startet den Drag auf einem Balken (data-gantt-type).
|
||||
// Erkennt anhand der Resize-Griffe, ob verschoben oder links/rechts
|
||||
// resized wird, und speichert alle Ausgangsdaten inkl. Original-Daten in
|
||||
// app.ganttDrag.
|
||||
function onGanttMouseDown(e){
|
||||
if(app.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();
|
||||
|
||||
app.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();
|
||||
}
|
||||
|
||||
// onGanttMouseMove: aktualisiert während des Ziehens live die Position
|
||||
// (left) bzw. Größe (left/width) des Balkens oder die Position der
|
||||
// Meilenstein-Raute samt zugehörigem Label anhand des Tagesversatzes.
|
||||
function onGanttMouseMove(e){
|
||||
if(!app.ganttDrag) return;
|
||||
const totalDays = daysBetween(app.ganttDrag.rangeStart, app.ganttDrag.rangeEnd) + 1;
|
||||
const pxPerDay = app.ganttDrag.timelineWidth / totalDays;
|
||||
const dx = e.clientX - app.ganttDrag.startX;
|
||||
const dayDelta = Math.round(dx / pxPerDay);
|
||||
|
||||
if(Math.abs(dx) > 2) app.ganttDrag.moved = true;
|
||||
|
||||
if(app.ganttDrag.type === 'milestone'){
|
||||
const newDate = addDays(app.ganttDrag.originalStart, dayDelta);
|
||||
const offset = daysBetween(app.ganttDrag.rangeStart, newDate);
|
||||
const left = (offset / totalDays * 100).toFixed(4) + '%';
|
||||
app.ganttDrag.bar.style.left = left;
|
||||
// Zugehöriges Label (nächstes Geschwister-Element) mitbewegen
|
||||
const label = app.ganttDrag.bar.nextElementSibling;
|
||||
if(label && label.classList.contains('gantt-milestone-label')) label.style.left = left;
|
||||
return;
|
||||
}
|
||||
|
||||
let newStart, newEnd;
|
||||
if(app.ganttDrag.action === 'move'){
|
||||
newStart = addDays(app.ganttDrag.originalStart, dayDelta);
|
||||
newEnd = addDays(app.ganttDrag.originalEnd, dayDelta);
|
||||
} else if(app.ganttDrag.action === 'left'){
|
||||
newStart = addDays(app.ganttDrag.originalStart, dayDelta);
|
||||
newEnd = app.ganttDrag.originalEnd;
|
||||
if(newStart > newEnd) newStart = newEnd;
|
||||
} else if(app.ganttDrag.action === 'right'){
|
||||
newStart = app.ganttDrag.originalStart;
|
||||
newEnd = addDays(app.ganttDrag.originalEnd, dayDelta);
|
||||
if(newEnd < newStart) newEnd = newStart;
|
||||
}
|
||||
|
||||
const barStart = newStart < app.ganttDrag.rangeStart ? app.ganttDrag.rangeStart : newStart;
|
||||
const barEnd = newEnd > app.ganttDrag.rangeEnd ? app.ganttDrag.rangeEnd : newEnd;
|
||||
const offset = daysBetween(app.ganttDrag.rangeStart, barStart);
|
||||
const span = daysBetween(barStart, barEnd) + 1;
|
||||
app.ganttDrag.bar.style.left = (offset / totalDays * 100).toFixed(4) + '%';
|
||||
app.ganttDrag.bar.style.width = (span / totalDays * 100).toFixed(4) + '%';
|
||||
}
|
||||
|
||||
// onGanttMouseUp: beendet den Drag. Ein Klick ohne Bewegung öffnet das
|
||||
// zugehörige Modal (TODO oder Meilenstein); sonst wird der Tagesversatz
|
||||
// berechnet und je nach Typ die neuen Daten gespeichert (Projekt, TODO,
|
||||
// Meilenstein, Urlaub). Liegt das Ergebnis außerhalb des Datumsbereichs,
|
||||
// wird fitPlanGanttRange aufgerufen, sonst neu gerendert.
|
||||
function onGanttMouseUp(e){
|
||||
if(!app.ganttDrag) return;
|
||||
const drag = app.ganttDrag;
|
||||
app.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();
|
||||
}
|
||||
|
||||
// updateTodoDates: schreibt neue Start-/Enddaten in die TODO an Position
|
||||
// idx und speichert den Plan.
|
||||
function updateTodoDates(idx, start, end){
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!plan.todos[idx]) return;
|
||||
plan.todos[idx].start = start;
|
||||
plan.todos[idx].end = end;
|
||||
app.savePlan();
|
||||
}
|
||||
|
||||
// onGanttDoubleClick: öffnet bei einem Doppelklick auf eine freie Stelle der
|
||||
// Timeline das TODO-Modal für eine neue Aufgabe und übernimmt das
|
||||
// angeklickte Datum in Start- und Endfeld.
|
||||
function onGanttDoubleClick(e){
|
||||
if(app.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;
|
||||
}
|
||||
|
||||
// updateProjectDates: setzt Start und Ende des Projekt-Plans und speichert.
|
||||
function updateProjectDates(projectId, start, end){
|
||||
const p = app.week.projects.find(x => x.id === projectId);
|
||||
if(!p) return;
|
||||
ensurePlan(p);
|
||||
p.plan.start = start;
|
||||
p.plan.end = end;
|
||||
app.savePlan();
|
||||
}
|
||||
|
||||
// updateMilestoneDate: setzt das Datum des Meilensteins am übergebenen Index
|
||||
// und speichert. Sind TODOs von diesem Meilenstein abhängig, werden sie
|
||||
// automatisch neu terminiert (autoScheduleTodos aus plan-schedule.js).
|
||||
function updateMilestoneDate(projectId, index, date){
|
||||
const p = app.week.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;
|
||||
app.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();
|
||||
}
|
||||
}
|
||||
|
||||
// updateVacationDates: schreibt die verschobenen Urlaubsdaten sowohl in die
|
||||
// lokale Sitzungs-Kopie der Person als auch in die globale Personenliste
|
||||
// und speichert beides.
|
||||
function updateVacationDates(personId, absenceId, start, end){
|
||||
const localPerson = (app.week.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[app.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; }
|
||||
}
|
||||
app.savePlan();
|
||||
app.saveGlobalPeople();
|
||||
}
|
||||
|
||||
// Drag-Funktionalität erst nach geladenem DOM registrieren
|
||||
document.addEventListener('DOMContentLoaded', initGanttDrag);
|
||||
document.addEventListener('DOMContentLoaded', initGanttTodoDrag);
|
||||
})();
|
||||
@@ -0,0 +1,233 @@
|
||||
// -----------------------------
|
||||
// Projektplan — GANTT-Rendering & Zoom
|
||||
// -----------------------------
|
||||
// Dieses Modul rendert das GANTT-Diagramm der Projektplan-Seite inklusive
|
||||
// der TODO- und Meilensteinzeilen, die buildGanttHtml aus plan-build-gantt.js
|
||||
// liefert, und zeichnet darauf die Abhängigkeitslinien zwischen den Aufgaben.
|
||||
// Es verwaltet außerdem Zoom (pxPerDay in PlanApp), das Anpassen des
|
||||
// Datumsbereichs (fitPlanGanttRange) sowie die Anzeige der Ressourcen-
|
||||
// Auslastung. renderPlanGantt ist die zentrale Neu-Render-Funktion und wird
|
||||
// nach Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js
|
||||
// erneut aufgerufen.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// renderPlanGantt: baut das komplette GANTT-Diagramm des aktuellen Projekts
|
||||
// neu auf. Nutzt die Datumseingaben der Seite (oder berechnet den
|
||||
// Projektzeitraum), berücksichtigt Urlaub, kritischen Pfad, Sperrstatus und
|
||||
// Zoomstufe und ruft buildGanttHtml auf. Im Anschluss werden die
|
||||
// Abhängigkeitslinien gezeichnet und die Ressourcen-Auslastung aktualisiert.
|
||||
window.renderPlanGantt = function(){
|
||||
const container = document.getElementById('planGanttContainer');
|
||||
if(!app.project || !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([app.project], 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(app.project, app.week).map(a => a.person.id);
|
||||
const criticalPath = app.criticalPathActive ? computeCriticalPath(app.project) : [];
|
||||
container.innerHTML = buildGanttHtml([app.project], start, end, { showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id, showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath });
|
||||
container.scrollLeft = prevScroll;
|
||||
requestAnimationFrame(() => renderGanttDependencies(container));
|
||||
renderResourceLoad();
|
||||
};
|
||||
|
||||
// renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die
|
||||
// Abhängigkeits-Pfade als SVG-Kurven mit Pfeilspitzen in das Diagramm.
|
||||
// Bereits vorhandene Linien werden zuvor entfernt; Konflikt- und
|
||||
// erledigte-Kanten erhalten eigene Farben. Parameter: container (optional,
|
||||
// GANTT-Container). Rückgabe: keine.
|
||||
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);
|
||||
};
|
||||
|
||||
// fitPlanGanttRange: setzt die Datumsfelder der Seite auf den Projektzeitraum
|
||||
// (inkl. 7 Tage Puffer) zurück und rendert das Diagramm neu. Wird auch nach
|
||||
// Drags außerhalb des sichtbaren Bereichs von plan-drag.js genutzt.
|
||||
window.fitPlanGanttRange = function(){
|
||||
const range = ganttRangeFromProjects([app.project], 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 = app.pxPerDay || 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;
|
||||
}
|
||||
app.pxPerDay = next;
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// ---------- Ressourcen-Auslastung ----------
|
||||
|
||||
// renderResourceLoad: berechnet die Auslastung der Projektbeteiligten in
|
||||
// Werktagen (ohne Wochenenden) anhand der ihnen zugeordneten TODOs und
|
||||
// rendert pro Person eine Auslastungskarte mit Balken und Abwesenheits-
|
||||
// Hinweis. Ein Klick auf eine Karte öffnet das Abwesenheits-Modal aus
|
||||
// plan-modals.js.
|
||||
window.renderResourceLoad = function(){
|
||||
const container = document.getElementById('resourceLoadContainer');
|
||||
if(!container || !app.project) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
const todos = Array.isArray(plan.todos) ? plan.todos.filter(t => t.start && t.end) : [];
|
||||
const assignees = projectAssignees(app.project, app.week);
|
||||
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 = app.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;
|
||||
};
|
||||
|
||||
// Abhängigkeitslinien hängen von Pixel-Positionen ab -> bei Fenstergröße neu zeichnen
|
||||
// Debounce, damit während des Ziehens nicht unnötig neu gerendert wird.
|
||||
window.addEventListener('resize', () => {
|
||||
if(app.resizeTimer) clearTimeout(app.resizeTimer);
|
||||
app.resizeTimer = setTimeout(() => renderGanttDependencies(), 150);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,473 @@
|
||||
// -----------------------------
|
||||
// Projektplan — Modals (Meilenstein, TODO, Abwesenheit)
|
||||
// -----------------------------
|
||||
// Dieses Modul stellt die Öffnen-/Schließen-/Speichern-/Löschen-Funktionen
|
||||
// der drei Dialoge der Projektplan-Seite bereit: Meilenstein, TODO und
|
||||
// Abwesenheiten. Der Bearbeitungszustand liegt in window.PlanApp
|
||||
// (plan-state.js): currentMilestoneIndex, currentTodoIndex und
|
||||
// currentAbsencePersonId. Alle Änderungen werden über app.savePlan bzw.
|
||||
// app.saveGlobalPeople gespeichert und das GANTT über renderPlanGantt
|
||||
// (plan-gantt.js) neu gezeichnet. Bei gesperrten Wochen (app.isLocked) sind
|
||||
// alle Bearbeitungsfunktionen deaktiviert.
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// ---------- Meilenstein-Modal ----------
|
||||
|
||||
// openMilestoneModal: öffnet das Meilenstein-Modal. Parameter: index
|
||||
// (Index des Meilensteins oder null für einen neuen). Befüllt Name, Datum,
|
||||
// Positions-Auswahl und die Vorgänger-Checkboxen (andere Meilensteine und
|
||||
// TODOs) aus dem Plan.
|
||||
window.openMilestoneModal = function(index){
|
||||
if(app.isLocked()) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!Array.isArray(plan.milestones)) plan.milestones = [];
|
||||
// Migration alter Meilensteine
|
||||
plan.milestones.forEach(m => { if(!Array.isArray(m.dependsOn)) m.dependsOn = []; });
|
||||
app.currentMilestoneIndex = index;
|
||||
|
||||
const isNew = index === null || plan.milestones[index] === undefined;
|
||||
const currentMs = isNew ? null : plan.milestones[index];
|
||||
document.getElementById('milestoneEditName').value = isNew ? '' : (currentMs.name || '');
|
||||
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();
|
||||
};
|
||||
|
||||
// closeMilestoneModal: blendet das Meilenstein-Modal aus und setzt den
|
||||
// Bearbeitungsindex zurück.
|
||||
window.closeMilestoneModal = function(){
|
||||
document.getElementById('milestoneModal').style.display = 'none';
|
||||
app.currentMilestoneIndex = null;
|
||||
};
|
||||
|
||||
// saveMilestoneEdit: übernimmt die Modal-Eingaben (Name, Datum, Position,
|
||||
// Vorgänger) in den Plan – als neuer Eintrag oder als Update des
|
||||
// aktuell bearbeiteten Meilensteins. Sortiert danach nach Datum, speichert
|
||||
// und rendert neu.
|
||||
window.saveMilestoneEdit = function(){
|
||||
if(app.isLocked()) return;
|
||||
const name = document.getElementById('milestoneEditName').value.trim();
|
||||
const date = document.getElementById('milestoneEditDate').value;
|
||||
if(!name || !date){ alert('Bitte Name und Datum angeben.'); return; }
|
||||
|
||||
const dependsOnList = document.getElementById('milestoneEditDependsOn');
|
||||
const dependsOn = dependsOnList ? Array.from(dependsOnList.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value).filter(Boolean) : [];
|
||||
|
||||
const positionSelect = document.getElementById('milestoneEditPosition');
|
||||
const position = positionSelect ? positionSelect.value : '';
|
||||
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!Array.isArray(plan.milestones)) plan.milestones = [];
|
||||
|
||||
if(app.currentMilestoneIndex === null){
|
||||
plan.milestones.push({ id: uid(), name, date, dependsOn, position });
|
||||
} else if(plan.milestones[app.currentMilestoneIndex]){
|
||||
const m = plan.milestones[app.currentMilestoneIndex];
|
||||
m.name = name;
|
||||
m.date = date;
|
||||
m.dependsOn = dependsOn;
|
||||
m.position = position;
|
||||
}
|
||||
plan.milestones.sort((a,b) => parseDateISO(a.date) - parseDateISO(b.date));
|
||||
app.savePlan();
|
||||
closeMilestoneModal();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// deleteMilestoneEdit: löscht nach Bestätigung den aktuell bearbeiteten
|
||||
// Meilenstein samt seiner Vorgänger-Verweise (app.removeDependencyRefs),
|
||||
// speichert und rendert neu.
|
||||
window.deleteMilestoneEdit = function(){
|
||||
if(app.isLocked()) return;
|
||||
if(app.currentMilestoneIndex === null){ closeMilestoneModal(); return; }
|
||||
if(!confirm('Meilenstein löschen?')) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(plan.milestones && plan.milestones[app.currentMilestoneIndex]){
|
||||
const deletedId = 'm_' + plan.milestones[app.currentMilestoneIndex].id;
|
||||
plan.milestones.splice(app.currentMilestoneIndex, 1);
|
||||
app.removeDependencyRefs(plan, deletedId);
|
||||
app.savePlan();
|
||||
}
|
||||
closeMilestoneModal();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// addPlanMilestone: öffnet das Meilenstein-Modal für einen neuen Eintrag.
|
||||
window.addPlanMilestone = function(){
|
||||
openMilestoneModal(null);
|
||||
};
|
||||
|
||||
// ---------- TODO-Modal ----------
|
||||
|
||||
// openTodoModal: öffnet das TODO-Modal. Parameter: index (Index der Aufgabe
|
||||
// oder null für eine neue). Befüllt Titel, Start/Ende, Erledigt-Status,
|
||||
// Fortschritt, Vorgänger-Checkboxen und die aus dem Projektboard
|
||||
// stammenden Bearbeiter-Auswahl.
|
||||
window.openTodoModal = function(index){
|
||||
if(app.isLocked()) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!Array.isArray(plan.todos)) plan.todos = [];
|
||||
app.currentTodoIndex = index;
|
||||
|
||||
const isNew = index === null || plan.todos[index] === undefined;
|
||||
const currentTodo = !isNew ? plan.todos[index] : null;
|
||||
|
||||
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(app.project, app.week);
|
||||
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();
|
||||
};
|
||||
|
||||
// closeTodoModal: blendet das TODO-Modal aus und setzt den Bearbeitungsindex
|
||||
// zurück.
|
||||
window.closeTodoModal = function(){
|
||||
document.getElementById('todoModal').style.display = 'none';
|
||||
app.currentTodoIndex = null;
|
||||
};
|
||||
|
||||
// saveTodoEdit: übernimmt die Modal-Eingaben in den Plan – als neuer TODO
|
||||
// oder Update des aktuell bearbeiteten. Fehlende End-/Startwerte werden
|
||||
// als eintägige Aufgabe ergänzt, „Erledigt“ setzt den Fortschritt auf
|
||||
// 100 %. Speichert und rendert neu.
|
||||
window.saveTodoEdit = function(){
|
||||
if(app.isLocked()) return;
|
||||
const title = document.getElementById('todoEditTitle').value.trim();
|
||||
let start = document.getElementById('todoEditStart').value;
|
||||
let end = document.getElementById('todoEditEnd').value;
|
||||
// Nur ein Datum angegeben: als eintägige Aufgabe behandeln,
|
||||
// sonst würde kein Balken im GANTT erscheinen.
|
||||
if(start && !end) end = start;
|
||||
if(end && !start) start = end;
|
||||
const 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 = app.ensurePlanLocal();
|
||||
if(!Array.isArray(plan.todos)) plan.todos = [];
|
||||
|
||||
if(app.currentTodoIndex === null){
|
||||
plan.todos.push({ id: uid(), title, start, end, done, progress, dependsOn, assignees });
|
||||
} else if(plan.todos[app.currentTodoIndex]){
|
||||
const t = plan.todos[app.currentTodoIndex];
|
||||
t.title = title;
|
||||
t.start = start;
|
||||
t.end = end;
|
||||
t.done = done;
|
||||
t.progress = progress;
|
||||
t.dependsOn = dependsOn;
|
||||
t.assignees = assignees;
|
||||
}
|
||||
app.savePlan();
|
||||
closeTodoModal();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// deleteTodoEdit: löscht nach Bestätigung die aktuell bearbeitete Aufgabe,
|
||||
// entfernt ihre Vorgänger-Verweise und setzt die relative Position
|
||||
// betroffener Meilensteine zurück. Speichert und rendert neu.
|
||||
window.deleteTodoEdit = function(){
|
||||
if(app.isLocked()) return;
|
||||
if(app.currentTodoIndex === null){ closeTodoModal(); return; }
|
||||
if(!confirm('TODO löschen?')) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(plan.todos && plan.todos[app.currentTodoIndex]){
|
||||
const deletedId = plan.todos[app.currentTodoIndex].id;
|
||||
plan.todos.splice(app.currentTodoIndex, 1);
|
||||
app.removeDependencyRefs(plan, deletedId);
|
||||
// Meilensteine, die relativ zu dieser TODO positioniert waren, zurücksetzen
|
||||
(plan.milestones || []).forEach(m => {
|
||||
if(m.position === 'before:' + deletedId || m.position === 'after:' + deletedId) m.position = '';
|
||||
});
|
||||
app.savePlan();
|
||||
}
|
||||
closeTodoModal();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// addTodoFromList: legt eine neue Aufgabe aus den Eingabefeldern der
|
||||
// TODO-Liste an (Titel, Start/Ende), leert die Felder und rendert neu.
|
||||
window.addTodoFromList = function(){
|
||||
if(app.isLocked()) return;
|
||||
const title = document.getElementById('newTodoTitle').value.trim();
|
||||
let start = document.getElementById('newTodoStart').value;
|
||||
let end = document.getElementById('newTodoEnd').value;
|
||||
if(start && !end) end = start;
|
||||
if(end && !start) start = end;
|
||||
if(!title){ alert('Bitte einen Titel eingeben.'); return; }
|
||||
if(start && end && parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; }
|
||||
const plan = app.ensurePlanLocal();
|
||||
plan.todos.push({ id: uid(), title, start, end, done: false, progress: 0, dependsOn: [], assignees: [] });
|
||||
document.getElementById('newTodoTitle').value = '';
|
||||
document.getElementById('newTodoStart').value = '';
|
||||
document.getElementById('newTodoEnd').value = '';
|
||||
app.savePlan();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// addTodo: öffnet das TODO-Modal für eine neue Aufgabe.
|
||||
window.addTodo = function(){
|
||||
openTodoModal(null);
|
||||
};
|
||||
|
||||
// updateTodo: setzt ein einzelnes Feld (z. B. done) einer Aufgabe direkt
|
||||
// und speichert sowie rendert neu. Parameter: idx (Index), field
|
||||
// (Feldname), value (neuer Wert).
|
||||
window.updateTodo = function(idx, field, value){
|
||||
if(app.isLocked()) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!plan.todos[idx]) return;
|
||||
plan.todos[idx][field] = value;
|
||||
app.savePlan();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// toggleTodoDone: schaltet den Erledigt-Status der Aufgabe am Index um,
|
||||
// speichert und rendert neu.
|
||||
window.toggleTodoDone = function(idx){
|
||||
if(app.isLocked()) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!plan.todos[idx]) return;
|
||||
plan.todos[idx].done = !plan.todos[idx].done;
|
||||
app.savePlan();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// deleteTodo: löscht nach Bestätigung die Aufgabe am Index, bereinigt die
|
||||
// Vorgänger-Verweise und die relative Meilenstein-Position. Speichert und
|
||||
// rendert neu.
|
||||
window.deleteTodo = function(idx){
|
||||
if(app.isLocked()) return;
|
||||
if(!confirm('TODO löschen?')) return;
|
||||
const plan = app.ensurePlanLocal();
|
||||
if(!plan.todos[idx]) return;
|
||||
const deletedId = plan.todos[idx].id;
|
||||
plan.todos.splice(idx, 1);
|
||||
app.removeDependencyRefs(plan, deletedId);
|
||||
(plan.milestones || []).forEach(m => {
|
||||
if(m.position === 'before:' + deletedId || m.position === 'after:' + deletedId) m.position = '';
|
||||
});
|
||||
app.savePlan();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// ---------- Abwesenheits-Modal (Urlaube der Projektbeteiligten) ----------
|
||||
|
||||
// openAbsenceModal: öffnet das Abwesenheits-Modal für die übergebene
|
||||
// Person-ID, setzt den Titel, leert die Formularfelder und rendert die
|
||||
// Liste der bestehenden Abwesenheiten.
|
||||
window.openAbsenceModal = function(personId){
|
||||
const person = app.findGlobalPerson(personId);
|
||||
if(!person) return;
|
||||
app.currentAbsencePersonId = personId;
|
||||
document.getElementById('absenceModalTitle').textContent = 'Abwesenheiten – ' + (person.fullName || person.name);
|
||||
// Formularfelder zurücksetzen
|
||||
const startInput = document.getElementById('absenceEditStart');
|
||||
const endInput = document.getElementById('absenceEditEnd');
|
||||
const rangeDisplay = document.getElementById('absenceEditRange');
|
||||
if(startInput) startInput.value = '';
|
||||
if(endInput) endInput.value = '';
|
||||
if(rangeDisplay) rangeDisplay.value = '';
|
||||
renderAbsenceModalList();
|
||||
document.getElementById('absenceModal').style.display = 'flex';
|
||||
};
|
||||
|
||||
// closeAbsenceModal: blendet das Abwesenheits-Modal aus und setzt die
|
||||
// aktuelle Person-ID zurück.
|
||||
window.closeAbsenceModal = function(){
|
||||
document.getElementById('absenceModal').style.display = 'none';
|
||||
app.currentAbsencePersonId = null;
|
||||
};
|
||||
|
||||
// renderAbsenceModalList: rendert die Liste der Abwesenheiten der aktuellen
|
||||
// Person im Modal (jeweils Typ, Zeitraum, Notiz und Lösch-Button). Bei
|
||||
// gesperrter Woche wird das Formular ausgeblendet.
|
||||
function renderAbsenceModalList(){
|
||||
const listDiv = document.getElementById('absenceModalList');
|
||||
const person = app.findGlobalPerson(app.currentAbsencePersonId);
|
||||
if(!listDiv || !person) return;
|
||||
const absences = Array.isArray(person.absences) ? person.absences : [];
|
||||
const locked = app.isLocked();
|
||||
if(absences.length === 0){
|
||||
listDiv.innerHTML = '<span class="hint">Keine Abwesenheiten eingetragen.</span>';
|
||||
} else {
|
||||
listDiv.innerHTML = '<div class="absence-list">' + absences.map(a => `
|
||||
<div class="absence-chip">
|
||||
<span>${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))}</span>
|
||||
${a.note ? `<small>${esc(a.note)}</small>` : ''}
|
||||
${locked ? '' : `<button class="danger small" onclick="deletePlanAbsence('${esc(a.id)}')">×</button>`}
|
||||
</div>`).join('') + '</div>';
|
||||
}
|
||||
const form = document.getElementById('absenceModalForm');
|
||||
if(form) form.style.display = locked ? 'none' : '';
|
||||
}
|
||||
|
||||
// addPlanAbsence: legt aus den Formularfeldern eine neue Abwesenheit für die
|
||||
// aktuelle Person an – in der globalen Personenliste und in der lokalen
|
||||
// Sitzungs-Kopie. Speichert beides und rendert Liste sowie GANTT neu.
|
||||
window.addPlanAbsence = function(){
|
||||
if(app.isLocked()) return;
|
||||
const person = app.findGlobalPerson(app.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 = (app.week.people || []).find(p => p.id === app.currentAbsencePersonId);
|
||||
if(localPerson){
|
||||
if(!Array.isArray(localPerson.absences)) localPerson.absences = [];
|
||||
localPerson.absences.push({ ...absence });
|
||||
localPerson.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start));
|
||||
}
|
||||
app.saveGlobalPeople();
|
||||
app.savePlan();
|
||||
document.getElementById('absenceEditStart').value = '';
|
||||
document.getElementById('absenceEditEnd').value = '';
|
||||
const rangeDisplay = document.getElementById('absenceEditRange');
|
||||
if(rangeDisplay) rangeDisplay.value = '';
|
||||
document.getElementById('absenceEditNote').value = '';
|
||||
renderAbsenceModalList();
|
||||
renderPlanGantt();
|
||||
};
|
||||
|
||||
// deletePlanAbsence: entfernt die Abwesenheit mit der übergebenen ID aus
|
||||
// der globalen Personenliste und der lokalen Sitzungs-Kopie, speichert
|
||||
// beides und rendert Liste sowie GANTT neu.
|
||||
window.deletePlanAbsence = function(absenceId){
|
||||
if(app.isLocked()) return;
|
||||
const person = app.findGlobalPerson(app.currentAbsencePersonId);
|
||||
if(!person || !Array.isArray(person.absences)) return;
|
||||
person.absences = person.absences.filter(a => a.id !== absenceId);
|
||||
const localPerson = (app.week.people || []).find(p => p.id === app.currentAbsencePersonId);
|
||||
if(localPerson && Array.isArray(localPerson.absences)){
|
||||
localPerson.absences = localPerson.absences.filter(a => a.id !== absenceId);
|
||||
}
|
||||
app.saveGlobalPeople();
|
||||
app.savePlan();
|
||||
renderAbsenceModalList();
|
||||
renderPlanGantt();
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,288 @@
|
||||
// -----------------------------
|
||||
// Projektplan — Kritischer Pfad, Auto-Schedule & Mail-Link
|
||||
// -----------------------------
|
||||
// Dieses Modul berechnet über computeCriticalPath die Aufgabe(n) auf dem
|
||||
// kritischen Pfad (längste Vorgänger-Kette) und hebt sie im GANTT hervor,
|
||||
// verschiebt bei autoScheduleTodos TODOs und Meilensteine automatisch hinter
|
||||
// ihre Vorgänger und erstellt bei todoMail einen mailto-Link an alle
|
||||
// zugeordneten Personen. Die Hervorhebung übergibt renderPlanGantt als
|
||||
// Option criticalPath an buildGanttHtml (plan-build-gantt.js); den Schalter
|
||||
// criticalPathActive hält der Zustand in window.PlanApp (plan-state.js).
|
||||
//
|
||||
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
|
||||
// (plan-state.js).
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
|
||||
// ---------- 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]));
|
||||
|
||||
// depsOf: liefert die Vorgänger-Liste eines Knotens (leeres Array, falls
|
||||
// keine vorhanden).
|
||||
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();
|
||||
// computeES: berechnet rekursiv den frühesten Start (ES) und frühestes
|
||||
// Ende (EF) eines Knotens aus seinen Vorgängern. Liefert ES oder null
|
||||
// bei Zyklen (visiting-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();
|
||||
// computeLF: berechnet rekursiv den spätesten Start (LS) und das späteste
|
||||
// Ende (LF) eines Knotens rückwärts aus seinen Nachfolgern. Liefert LF
|
||||
// oder null bei Zyklen (visiting-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);
|
||||
};
|
||||
|
||||
// toggleCriticalPath: schaltet die Hervorhebung des kritischen Pfads im
|
||||
// GANTT ein/aus (Wert in app.criticalPathActive), aktualisiert den
|
||||
// Toolbar-Button und zeigt über einen Toast an, wie viele Aufgaben auf
|
||||
// dem kritischen Pfad liegen.
|
||||
window.toggleCriticalPath = function(){
|
||||
app.criticalPathActive = !app.criticalPathActive;
|
||||
const btn = document.getElementById('criticalPathBtn');
|
||||
if(btn) btn.classList.toggle('active', app.criticalPathActive);
|
||||
renderPlanGantt();
|
||||
if(app.criticalPathActive){
|
||||
const path = computeCriticalPath(app.project);
|
||||
if(path.length === 0){
|
||||
app.showToast('Kein kritischer Pfad ermittelt (zu wenig Aufgaben oder Abhängigkeiten).');
|
||||
} else {
|
||||
app.showToast(path.length + ' Aufgabe(n) auf dem kritischen Pfad hervorgehoben.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// autoScheduleTodos: verschiebt alle TODOs und Meilensteine, die von
|
||||
// anderen Elementen abhängen, automatisch auf den frühesten möglichen
|
||||
// Termin (einen Tag nach dem Ende des spätesten Vorgängers). Erkennt
|
||||
// zyklische Abhängigkeiten und verhindert Endlos-Rekursion; danach
|
||||
// Speichern, Neu-Rendern und Hinweis per Toast.
|
||||
window.autoScheduleTodos = function(){
|
||||
if(app.isLocked()) return;
|
||||
const plan = app.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]));
|
||||
|
||||
// duration: liefert die Dauer einer TODO in Tagen (mindestens 1 Tag).
|
||||
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;
|
||||
|
||||
// scheduleTodo: plant eine TODO rekursiv ein – nutzt den spätesten
|
||||
// Vorgänger-Termin (TODO oder Meilenstein) als frühesten Start und
|
||||
// verschiebt Start/Ende entsprechend (Eintrag in moved bei Änderung).
|
||||
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);
|
||||
}
|
||||
|
||||
// scheduleMilestone: plant einen Meilenstein ein, sofern er von anderen
|
||||
// TODOs/Meilensteinen abhängig ist, und verschiebt sein Datum auf den
|
||||
// frühestmöglichen Termin (Eintrag in moved bei Änderung).
|
||||
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){
|
||||
app.savePlan();
|
||||
renderPlanGantt();
|
||||
app.showToast(moved.size + ' Element(e) automatisch verschoben.' + (hasCycle ? ' Achtung: zyklische Abhängigkeit gefunden!' : ''));
|
||||
} else if(hasCycle){
|
||||
app.showToast('Achtung: zyklische Abhängigkeit gefunden – bitte Vorgänger prüfen.');
|
||||
} else {
|
||||
app.showToast('Keine Änderungen nötig – alle Termine passen zu den Abhängigkeiten.');
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- Mail-Link für alle zugeordneten Personen ----------
|
||||
|
||||
// todoMail: baut aus den E-Mail-Adressen der zugeordneten Personen und
|
||||
// Projektinformationen einen mailto-Link zusammen und öffnet ihn. Zeigt
|
||||
// Warnungen, wenn keine E-Mail-Adressen vorhanden sind oder der Link sehr
|
||||
// lang wird.
|
||||
window.todoMail = function(){
|
||||
if(!app.project || !app.week){
|
||||
console.warn('Kein aktuelles Projekt/keine aktuelle Woche gefunden – Mail kann nicht erstellt werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
const assignees = projectAssignees(app.project, app.week);
|
||||
|
||||
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;
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,229 @@
|
||||
// -----------------------------
|
||||
// Projektplan — Zustand, Initialisierung & Daten laden
|
||||
// -----------------------------
|
||||
// Dieses Modul wird als erstes der plan-*-Module von public/project-plan.html
|
||||
// geladen und definiert window.PlanApp als gemeinsamen Zustand aller
|
||||
// Projektplan-Module. Es liest die URL-Parameter (department, weekLabel,
|
||||
// projNr, projName), über die das zu bearbeitende Projekt adressiert wird.
|
||||
// Nach DOMContentLoaded werden Theme, Toolbar-Buttons und die Sitzungsdaten
|
||||
// über die Server-API geladen. Die hier definierten Funktionen (savePlan,
|
||||
// isLocked, showToast u. a.) werden über app.* den anderen plan-Modulen
|
||||
// bereitgestellt, die alle in einem IIFE mit window.PlanApp laufen.
|
||||
|
||||
// PlanApp: gemeinsamer, modulübergreifender Zustand der Projektplan-Seite.
|
||||
window.PlanApp = {
|
||||
params: new URLSearchParams(location.search),
|
||||
department: '',
|
||||
weekLabel: '',
|
||||
projNr: '',
|
||||
projName: '',
|
||||
week: null, // planWeek
|
||||
project: null, // planProject
|
||||
pxPerDay: 14, // Zoomstufe GANTT (null = an Containerbreite anpassen)
|
||||
criticalPathActive: false, // Hervorhebung des kritischen Pfads im GANTT aktiv
|
||||
// Drag-Zustände
|
||||
todoDrag: null, // Drag-and-Drop von TODO/Meilenstein-Zeilen
|
||||
ganttDrag: null, // Balken/Meilenstein/Urlaub verschieben
|
||||
currentMilestoneIndex: null, // Index des gerade bearbeiteten Meilensteins (Modal)
|
||||
currentTodoIndex: null, // Index der gerade bearbeiteten TODO (Modal)
|
||||
currentAbsencePersonId: null, // Person-ID des offenen Abwesenheits-Modals
|
||||
toastTimer: null, // Timer der Toast-Benachrichtigung (plan-state.js)
|
||||
resizeTimer: null // Debounce-Timer für Fenster-Resize (plan-gantt.js)
|
||||
};
|
||||
|
||||
(function(){
|
||||
const app = window.PlanApp;
|
||||
app.department = app.params.get('department') || 'ELT';
|
||||
app.weekLabel = app.params.get('weekLabel');
|
||||
app.projNr = app.params.get('projNr') || '';
|
||||
app.projName = app.params.get('projName') || '';
|
||||
|
||||
// init: Einstiegspunkt nach DOMContentLoaded. Übernimmt das Theme aus
|
||||
// localStorage/System, prüft die URL-Parameter und bricht mit Fehlermeldung
|
||||
// ab, falls kein Projekt ausgewählt wurde. Sonst bindet es die Toolbar und
|
||||
// startet das Laden der Daten.
|
||||
function init(){
|
||||
loadTheme(); // Theme aus localStorage / System übernehmen
|
||||
if(!app.weekLabel || !app.projName){
|
||||
document.getElementById('planTitle').textContent = 'Fehler';
|
||||
document.getElementById('planSubtitle').textContent = 'Kein Projekt ausgewählt.';
|
||||
return;
|
||||
}
|
||||
currentDepartment = app.department; // global aus globals.js
|
||||
bindToolbarButtons();
|
||||
loadPlanData();
|
||||
}
|
||||
|
||||
// bindToolbarButtons: verbindet die Toolbar-Buttons (TODO hinzufügen,
|
||||
// Auto-Schedule, Kritischer Pfad) mit ihren globalen Funktionen der
|
||||
// anderen plan-Module.
|
||||
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());
|
||||
}
|
||||
|
||||
// loadPlanData: lädt alle Sitzungen und die globale Personenliste der
|
||||
// Abteilung über die Server-API, sortiert die Wochen chronologisch und
|
||||
// sucht daraus die Ziel-Woche bzw. das Ziel-Projekt. Zeigt Fehlermeldungen
|
||||
// der Reihenfolge nach im Seitenkopf an und startet sonst renderPlanPage().
|
||||
function loadPlanData(){
|
||||
if(!window.fetch){
|
||||
document.getElementById('planSubtitle').textContent = 'Netzwerk nicht verfügbar.';
|
||||
return;
|
||||
}
|
||||
const url = baseUrl() + '/load-all?department=' + encodeURIComponent(app.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[app.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)));
|
||||
app.week = db.weeks.find(w => w.label === app.weekLabel);
|
||||
if(!app.week){
|
||||
document.getElementById('planTitle').textContent = 'Nicht gefunden';
|
||||
document.getElementById('planSubtitle').textContent = 'Sitzung "' + esc(app.weekLabel) + '" konnte nicht geladen werden.';
|
||||
return;
|
||||
}
|
||||
app.project = app.week.projects.find(p => (p.nr || '') === app.projNr && (p.name || '') === app.projName);
|
||||
if(!app.project){
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
// ensurePlanLocal: stellt sicher, dass das Plan-Objekt des Projekts die
|
||||
// erwartete Struktur hat (inkl. Migration alter Einzel-Abhängigkeiten).
|
||||
function ensurePlanLocal(){
|
||||
if(!app.project.plan) app.project.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 };
|
||||
if(!Array.isArray(app.project.plan.todos)) app.project.plan.todos = [];
|
||||
// Migration alter Einzel-Abhängigkeiten zu Array
|
||||
app.project.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 app.project.plan;
|
||||
}
|
||||
|
||||
// isLocked: liefert true, wenn die aktuelle Woche gesperrt ist (dann sind
|
||||
// Bearbeitungen im Plan gesperrt). Rückgabe: Boolescher Wert.
|
||||
function isLocked(){
|
||||
return !!app.week.locked;
|
||||
}
|
||||
|
||||
// renderPlanPage: setzt Titel und Untertitel der Seite aus Projekt und Woche,
|
||||
// blendet den Meilenstein-Button bei gesperrter Woche aus und startet die
|
||||
// GANTT-Anzeige über fitPlanGanttRange().
|
||||
function renderPlanPage(){
|
||||
const p = app.project;
|
||||
const plan = ensurePlanLocal();
|
||||
document.getElementById('planTitle').textContent = (p.nr ? p.nr + ' – ' : '') + p.name;
|
||||
document.getElementById('planSubtitle').textContent = app.week.label + (app.week.date ? ' · ' + formatWeekDate(app.week.date) : '');
|
||||
|
||||
// GANTT inkl. TODOs
|
||||
const addMilestoneBtn = document.getElementById('addMilestoneBtn');
|
||||
const locked = isLocked();
|
||||
if(addMilestoneBtn) addMilestoneBtn.style.display = locked ? 'none' : '';
|
||||
|
||||
fitPlanGanttRange();
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
// Beteiligte des Projekts (Haupt + Optional) mit E-Mail aus der globalen Liste
|
||||
function renderProjectAssignees(){
|
||||
const assignees = projectAssignees(app.project, app.week);
|
||||
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('');
|
||||
}
|
||||
|
||||
// Speichern: nur diese Sitzung; globale Personenliste nicht überschreiben
|
||||
function savePlan(){
|
||||
if(!app.week || !app.project) return;
|
||||
const filename = filenameForWeek(app.week.label);
|
||||
const csv = weekToCsv(app.week);
|
||||
sendCsvToServer(filename, csv).catch(err => console.error('Speichern fehlgeschlagen:', err));
|
||||
}
|
||||
|
||||
// saveGlobalPeople: schreibt die globale Personenliste der Abteilung als
|
||||
// _people.csv auf den Server, aber nur wenn sie nicht leer ist.
|
||||
function saveGlobalPeople(){
|
||||
const people = db.globalPeople[app.department] || [];
|
||||
if(people.length > 0){
|
||||
const peopleCsv = globalPeopleToCsv(people);
|
||||
sendCsvToServer('_people.csv', peopleCsv).catch(err => console.error('Speichern Personen fehlgeschlagen:', err));
|
||||
}
|
||||
}
|
||||
|
||||
// Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt)
|
||||
function findGlobalPerson(personId){
|
||||
return (db.globalPeople[app.department] || []).find(p => p.id === personId) || null;
|
||||
}
|
||||
|
||||
// countPersonAbsences: zählt die Abwesenheiten einer Person anhand der
|
||||
// globalen Liste. Rückgabe: Anzahl (0 bei fehlender Person).
|
||||
function countPersonAbsences(personId){
|
||||
const p = findGlobalPerson(personId);
|
||||
return p && Array.isArray(p.absences) ? p.absences.length : 0;
|
||||
}
|
||||
|
||||
// Toast-Benachrichtigung
|
||||
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(app.toastTimer) clearTimeout(app.toastTimer);
|
||||
app.toastTimer = setTimeout(() => { toast.classList.remove('show'); app.toastTimer = null; }, 2500);
|
||||
}
|
||||
|
||||
// Export der Hilfsfunktionen auf PlanApp, damit die anderen plan-Module
|
||||
// (plan-drag, plan-modals, plan-schedule) sie direkt nutzen können.
|
||||
app.ensurePlanLocal = ensurePlanLocal;
|
||||
app.isLocked = isLocked;
|
||||
app.removeDependencyRefs = removeDependencyRefs;
|
||||
app.renderProjectAssignees = renderProjectAssignees;
|
||||
app.savePlan = savePlan;
|
||||
app.saveGlobalPeople = saveGlobalPeople;
|
||||
app.findGlobalPerson = findGlobalPerson;
|
||||
app.countPersonAbsences = countPersonAbsences;
|
||||
app.showToast = showToast;
|
||||
|
||||
// Seitenstart: erst initialisieren, wenn das DOM geladen ist
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
})();
|
||||
Reference in new issue
Block a user