// ----------------------------- // 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-Route (Abteilung + Projektname, z. B. // "/HLS/Projektplan/Gymnasium%20Hemer") und die URL-Parameter (weekLabel, // projNr), ü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; // Abteilung und Projektname aus dem Deep-Link-Pfad // "//Projektplan/" lesen. Fallback auf die früheren // Query-Parameter ?department=/&projName= für alte project-plan.html-Links. const routeMatch = window.location.pathname.match(/^\/([^/]+)\/(?:Projektplan|project-plan\.html)(?:\/(.+))?\/?$/); app.department = routeMatch ? routeMatch[1] : (app.params.get('department') || 'ELT'); app.weekLabel = app.params.get('weekLabel'); app.projNr = app.params.get('projNr') || ''; let projNameFromRoute = ''; if(routeMatch && routeMatch[2]){ try { projNameFromRoute = decodeURIComponent(routeMatch[2]); } catch(e) { projNameFromRoute = app.params.get('projName') || ''; } } app.projName = projNameFromRoute || (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 'Keine Zuordnungen im Projektboard.'; return assignees.map(a => { const cls = a.state === 1 ? 'main' : a.state === 2 ? 'optional' : ''; return `${esc(a.person.name)}${a.role ? ' (' + esc(a.role) + ')' : ''}`; }).join(''); } // 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); })();