Files

260 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// 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') || '';
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. Die Buttons binden ihre Handler bereits über das
// onclick-Attribut in project-plan.html; hier werden nur Lücken gefüllt,
// falls ein Button-Element fehlt.
function bindToolbarButtons(){
}
// loadPlanData: lädt alle Sitzungen und die globale Personenliste der
// Abteilung über die Server-API (JSON-Dokumente), 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] = Array.isArray(data.people) ? data.people : [];
db.weeks = (Array.isArray(data.weeks) ? data.weeks : [])
.filter(w => w && typeof w.label === 'string')
.map(w => {
if(!Array.isArray(w.people)) w.people = [];
if(!Array.isArray(w.projects)) w.projects = [];
if(!w.attendance) w.attendance = {};
return 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.
// In-Flight-Schutz: läuft bereits ein Save, wird kein zweiter POST mit
// identischer Revisions-Basis abgeschickt (der würde fälschlich als 409-
// Konflikt gemeldet). Stattdessen wird ein Folgesave eingereiht, der nach
// Abschluss des laufenden Saves mit der dann frischen Revision nachzieht.
let savePlanRunning = false;
let savePlanQueued = false;
function savePlan(){
if(!app.week || !app.project) return;
if(savePlanRunning){ savePlanQueued = true; return; }
savePlanRunning = true;
sendWeekToServer(app.week)
.then(res => {
// Lokale Revisions-Basis aktualisieren, damit spätere eigene Saves
// nicht fälschlich als Konflikt gemeldet werden.
if(res && res.updatedAt) app.week.updatedAt = res.updatedAt;
})
.catch(err => {
if(err && err.conflict){
showToast('Konflikt: Die Sitzung wurde zwischenzeitlich geändert. Bitte Seite neu laden.');
console.error('Speichern fehlgeschlagen (Konflikt):', err.message || err);
} else {
console.error('Speichern fehlgeschlagen:', err);
}
})
.finally(() => {
savePlanRunning = false;
if(savePlanQueued){ savePlanQueued = false; savePlan(); }
});
}
// saveGlobalPeople: schreibt die globale Personenliste der Abteilung als
// JSON-Array auf den Server. Auch eine leere Liste wird gespeichert, damit
// das Löschen der letzten Person erhalten bleibt.
function saveGlobalPeople(){
const people = db.globalPeople[app.department] || [];
sendPeopleToServer(people).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);
})();