234 lines
11 KiB
JavaScript
234 lines
11 KiB
JavaScript
// -----------------------------
|
||
// 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
|
||
// "/<Abteilung>/Projektplan/<Projektname>" 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') || '';
|
||
app.projName = routeMatch && routeMatch[2] ? decodeURIComponent(routeMatch[2]) : (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);
|
||
})(); |