// ----------------------------- // Projektbereich — Zustand, Einstieg & Projektauswahl // ----------------------------- // Dieses Modul wird als erstes der plan-*-Module von public/App.html geladen // und definiert window.PlanApp als gemeinsamen Zustand aller Module des // Projektbereichs. // // WICHTIG — geändert gegenüber der früheren Fassung: Der Projektbereich war // einmal eine eigene Seite (public/project-plan.html) mit eigenem // Einstiegspunkt. Sie las Sitzung und Projekt aus der URL und lud sich ihren // Datenbestand über GET /load-all selbst. Beides ist entfallen: Der // Projektbereich ist jetzt ein Bereich der Hauptseite (#plan in App.html), // der über showTab('plan') eingeblendet wird wie jeder andere. Er arbeitet // deshalb auf dem bereits geladenen In-Memory-Bestand db.weeks (js/store.js) // und braucht weder einen eigenen Ladevorgang noch eine eigene // Anmeldeprüfung. // // Einstieg sind zwei Schritte: setPlanTarget(weekLabel, projNr, projName) // merkt sich das Ziel, openPlanView() sucht es in db.weeks und rendert den // Bereich. Getrennt sind sie, weil showTab('plan') (js/tabs-auth.js) aus dem // Ziel zuerst die Adresszeile schreibt und den Bereich einblendet — das GANTT // kann sich erst vermessen, wenn sein Container sichtbar ist. Gesetzt wird // das Ziel von openProjectPlan() (js/gantt.js) und beim Seitenstart aus einem // Deep-Link "/Projektplan/?weekLabel=…&projNr=…" // (applyUrlRoute() in js/ui.js, ausgewertet in js/init.js). // Die hier definierten Funktionen (savePlan, isLocked, showToast u. a.) // stellen sich über app.* den anderen plan-Modulen bereit, die alle in einem // IIFE mit window.PlanApp laufen. // PlanApp: gemeinsamer, modulübergreifender Zustand des Projektbereichs. window.PlanApp = { weekLabel: '', projNr: '', projName: '', sandboxPlanId: '', // gesetzt von openSandboxPlan() — Sandbox-Pläne werden über id adressiert, nicht über nr/name 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; // planFail: zeigt einen Fehler in der Kopfzeile des Projektbereichs an und // blendet die Bereichsleiste aus, weil ohne Projekt jeder Unterbereich nur // in eine leere Maske führte. Parameter: title, subtitle (Strings). function planFail(title, subtitle){ const t = document.getElementById('planTitle'); const sub = document.getElementById('planSubtitle'); if(t) t.textContent = title; if(sub) sub.textContent = subtitle; const badge = document.getElementById('planIdBadge'); if(badge) badge.textContent = ''; app.project = null; app.hidePlanNav(); } // setPlanTarget: merkt sich, welches Projekt der Bereich zeigen soll — // adressiert über Sitzungs-Label und Projekt-Nr./-Bezeichnung, NICHT über // project.id, die beim Duplizieren einer Sitzung (cloneWeek(), js/weeks.js) // neu vergeben wird. Bewusst getrennt vom Rendern: showTab('plan') // (js/tabs-auth.js) schreibt aus diesen Werten zuerst die Adresszeile und // blendet den Bereich ein, und erst der sichtbare Bereich lässt sich // vermessen — das GANTT braucht die Containerbreite. Parameter: // weekLabel, projNr, projName (Strings). Keine Rückgabe. window.setPlanTarget = function(weekLabel, projNr, projName){ app.weekLabel = weekLabel || ''; app.projNr = projNr || ''; app.projName = projName || ''; app.sandboxPlanId = ''; }; // openSandboxPlan: das Gegenstück zu setPlanTarget()+showTab('plan') für // einen Terminplan der Planung-Sandbox (js/planung/planung.js). Sandbox- // Pläne haben keine Projektnummer und werden nie geklont (cloneWeek() // betrifft nur echte Wochen) — ihre id bleibt also stabil und ist die // einzige verlässliche Adressierung, anders als bei echten Projekten // (dort nr/name, siehe setPlanTarget()). Parameter: planId. Keine Rückgabe. window.openSandboxPlan = function(planId){ app.weekLabel = SANDBOX_WEEK_LABEL; app.projNr = ''; app.projName = ''; app.sandboxPlanId = planId || ''; showTab('plan'); }; // openPlanView: sucht Sitzung und Projekt im bereits geladenen Bestand // db.weeks (js/store.js) und rendert den Bereich. Einen eigenen // Ladevorgang gibt es nicht mehr — der Projektbereich teilt sich den // Datenbestand mit allen anderen Bereichen der Seite. Wird von // showTab('plan') gerufen (und von dort aus auch nach jedem erneuten // Laden, siehe load() in js/store.js). Rückgabe: true, wenn das Projekt // gefunden und gerendert wurde. window.openPlanView = function(){ app.showPlanNav(); // Planung-Sandbox: eigener, einfacherer Zweig — eine feste, reservierte // "Sitzung" (db.sandboxWeek, nie Teil von db.weeks, siehe js/store.js) // und Adressierung über die stabile Plan-id statt über nr/name. if(app.weekLabel === SANDBOX_WEEK_LABEL){ if(!app.sandboxPlanId){ planFail('Kein Terminplan gewählt', 'Diesen Bereich öffnet ein Klick auf einen Terminplan im Bereich „Planung".'); return false; } app.week = db.sandboxWeek || null; app.project = (app.week && Array.isArray(app.week.projects)) ? app.week.projects.find(p => p.id === app.sandboxPlanId) || null : null; if(!app.project){ planFail('Nicht gefunden', 'Dieser Terminplan wurde nicht gefunden — er wurde möglicherweise gelöscht.'); return false; } app.projNr = ''; app.projName = app.project.name || ''; renderPlanPage(); return true; } if(!app.weekLabel || (!app.projNr && !app.projName)){ planFail('Kein Projekt gewählt', 'Diesen Bereich öffnet ein Klick auf ein Projekt im Projektboard oder im Tab „Projekte".'); return false; } // Vor dem ersten erfolgreichen load() ist db.weeks noch leer (Deep-Link // direkt auf "/Projektplan/…"). Dann ist das kein Fehler, sondern nur // noch nicht so weit: load() ruft showTab(currentTab) erneut auf. if(!Array.isArray(db.weeks) || db.weeks.length === 0){ const t = document.getElementById('planTitle'); const sub = document.getElementById('planSubtitle'); if(t) t.textContent = app.projName || 'Projektbereich'; if(sub) sub.textContent = 'Sitzungsdaten werden geladen…'; return false; } app.week = db.weeks.find(w => w.label === app.weekLabel) || null; if(!app.week){ planFail('Nicht gefunden', 'Sitzung "' + app.weekLabel + '" konnte nicht geladen werden.'); return false; } // Projekt-Nr. ist der stabile Schlüssel; die Bezeichnung dient nur als // Rückfallebene für Projekte, die (noch) keine Nummer haben. const projects = app.week.projects || []; app.project = projects.find(p => (p.nr || '') === app.projNr && (p.name || '') === app.projName) || (app.projNr ? projects.find(p => (p.nr || '') === app.projNr) : null) || projects.find(p => (p.name || '') === app.projName) || null; if(!app.project){ planFail('Nicht gefunden', 'Projekt nicht in dieser Sitzung vorhanden.'); return false; } app.projNr = app.project.nr || ''; app.projName = app.project.name || ''; renderPlanPage(); return true; }; // 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; } // isSandboxPlan: liefert true, wenn der aktuell geöffnete Plan zur // Planung-Sandbox gehört statt zu einem echten Projekt. Zentraler Merker // für alle plan-*-Module, die dort Abschnitte ausblenden, die nur bei // einem echten Projekt Sinn ergeben (Dateien, Leistung, Auftragsstatus, // Projektnummer, Gewerke, Projektgröße, Kommentar/Verlauf — siehe CLAUDE.md // "Planung — projektunabhängige Terminplan-Sandbox"). Rückgabe: Boolescher Wert. function isSandboxPlan(){ return !!app.week && app.week.label === SANDBOX_WEEK_LABEL; } // renderPlanPage: setzt die kompakte, einzeilige Titelzeile aus Projekt und // Woche (Nr.-Badge, Name, Tags, Sitzungslabel, Beteiligte als // überlappender Avatar-Stapel — siehe renderProjectAssignees()), baut das // Projektinfo-Panel auf (renderPlanInfo(), js/plan/plan-info.js: Status, // Gruppe, amtierende Leistungsphase, LPH-Prozentliste) sowie den // Dateien-Bereich (initPlanFiles(), js/plan/plan-files.js: Upload, Freigabe, // Kommentare) und den Einstellungen-Bereich (renderPlanSettings(), // js/plan/plan-settings.js: Honorar, beauftragte Leistungsphasen), blendet // die Schaltflächen zum Anlegen neuer Aufgaben bei gesperrter Woche aus, // startet die GANTT-Anzeige über fitPlanGanttRange() und schaltet zuletzt // auf den sichtbaren Bereich (app.initPlanSections(), js/plan/plan-sections.js). function renderPlanPage(){ const p = app.project; const plan = ensurePlanLocal(); const sandbox = isSandboxPlan(); const planIdBadge = document.getElementById('planIdBadge'); if(planIdBadge) planIdBadge.textContent = p.nr || ''; document.getElementById('planTitle').textContent = p.name; // Für die Sandbox NICHT das rohe week.label ("__sandbox__") anzeigen — // das ist ein interner Marker, kein für Nutzer bestimmter Sitzungsname. document.getElementById('planSubtitle').textContent = sandbox ? 'Planung — unabhängig von einem laufenden Projekt' : app.week.label + (app.week.date ? ' · ' + formatWeekDate(app.week.date) : ''); const planPeopleList = document.getElementById('planPeopleList'); if(planPeopleList) planPeopleList.innerHTML = renderProjectAssignees(); const tags = Array.isArray(p.tags) ? p.tags : []; const planTagsList = document.getElementById('planTagsList'); if(planTagsList){ planTagsList.innerHTML = tags.map(t => `${esc(t)}`).join(''); } // Projektinfo-Panel (Status, Gruppe, amtierende Leistungsphase, LPH- // Prozentliste) — siehe js/plan/plan-info.js. renderPlanInfo(); // Dateien-Bereich (Upload, Freigabe, Kommentare) — siehe js/plan/plan-files.js. initPlanFiles(); // Protokolle-Bereich (Besprechungsprotokolle) — siehe js/plan/plan-protokoll.js. // Wie initPlanFiles() unbedingt aufgerufen (nicht erst beim Öffnen des // Bereichs), lädt bei vorhandener Projektnummer die Protokollliste. if(typeof initPlanProtokoll === 'function') initPlanProtokoll(); // Verlauf über alle Sitzungen (Kommentar-Historie) — siehe js/plan/plan-history.js. renderPlanHistory(); // Einstellungen-Bereich (Honorar, beauftragte Leistungsphasen, // Projektangaben) — siehe js/plan/plan-settings.js. renderPlanSettings(); // Schaltflächen, die neue Aufgaben/Meilensteine erzeugen — in gesperrten // Sitzungen nicht möglich. Die Liste enthält die Knöpfe beider Bereiche: // Terminplan (…Btn) und Aufgaben (…BtnList), siehe App.html. const locked = isLocked(); ['addMilestoneBtn', 'addTodoBtn', 'templateBtn', 'honorarBtn', 'protokollTodosBtn', 'autoScheduleBtn', 'addTodoBtnList', 'addMilestoneBtnList', 'templateBtnList', 'honorarBtnList', 'protokollTodosBtnList', 'autoScheduleBtnList'] .forEach(id => { const btn = document.getElementById(id); if(btn) btn.style.display = locked ? 'none' : ''; }); // "Dateien" (keine Projektnummer, kein sinnvoller Prüf-Workflow), // "Leistung" (echte Abrechnung/Auftragsstatus-abhängig) und "Protokolle" // (hängt wie Dateien an project.nr) ergeben für einen Terminplan ohne // echtes Projekt keinen Sinn — Bereichsknöpfe ausblenden. document.querySelectorAll('#planNav .plan-nav-item[data-section="dateien"], #planNav .plan-nav-item[data-section="leistung"], #planNav .plan-nav-item[data-section="protokolle"]') .forEach(btn => { btn.style.display = sandbox ? 'none' : ''; }); // Gantt-PDF-Export (js/pdf.js, exportSandboxPlanPDF()) gibt es nur für // Sandbox-Pläne der Planung — ein echtes Projekt hat kein Äquivalent. const sandboxPdfBtn = document.getElementById('sandboxPdfBtn'); if(sandboxPdfBtn) sandboxPdfBtn.style.display = sandbox ? '' : 'none'; // GANTT inkl. TODOs (rendert am Ende auch Aufgabenliste und Dashboard mit) fitPlanGanttRange(); // Sichtbaren Bereich wählen (URL-Fragment, sonst Dashboard) — siehe // js/plan/plan-sections.js. Steht bewusst am Ende: erst wenn alle // Bereiche befüllt sind, wird einer davon eingeblendet. app.initPlanSections(); } // 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); }); } // planRoleClass: Kopie von roleClass() aus js/board/board.js (dort nicht // geladen) — erzeugt dieselben CSS-Klassen wie im Board, damit die // Avatar-Kreise hier dieselben Rollenfarben (.board-avatar in styles.css) // nutzen. Parameter: role (String). Rückgabe: Klassen-String. function planRoleClass(role){ return 'role-' + String(role || '').toLowerCase().replace(/\s+/g, '-'); } // Beteiligte des Projekts (Haupt + Optional) mit E-Mail aus der globalen // Liste, als überlappender Avatar-Stapel für die kompakte Titelzeile // (dieselben Kreise/Farben wie im Projektboard-Detailpanel, siehe // .board-avatar in styles.css). Voller Name + Rolle stehen im Tooltip. function renderProjectAssignees(){ const assignees = projectAssignees(app.project, app.week); if(assignees.length === 0) return ''; return assignees.map(a => { const title = (a.person.fullName || a.person.name) + (a.role ? ' · ' + a.role : ''); return `${esc(a.person.name)}`; }).join(''); } // savePlan: merkt die Sitzung des Projektbereichs als geändert vor und // stößt den gewöhnlichen, entprellten Speicherlauf an (js/store.js). // // Früher schrieb der Projektbereich mit einem eigenen sendWeekToServer() // direkt zum Server, weil er eine eigene Seite war und store.js dort gar // nicht geladen wurde. In einer Seite wären daraus zwei Schreiber auf // dieselbe Sitzung geworden: beide senden dieselbe Revisions-Basis, und der // langsamere bekäme einen scheinbaren 409-Konflikt gemeldet. Deshalb geht // jetzt auch der Projektbereich durch syncAllToServer() — mitsamt dessen // In-Flight-Schutz, Wiederholung nach Netzfehlern, Konfliktbehandlung und // Anmelde-Prüfung. markWeekDirty() bekommt bewusst app.week.label statt // save() zu rufen: save() markiert die AKTUELL gewählte Sitzung, und ein // Deep-Link kann den Projektbereich auf eine andere gestellt haben. function savePlan(){ if(!app.week || !app.project) return; markWeekDirty(app.week.label); scheduleSessionSync(); } // userIdByPersonIdPromise: Cache der Zuordnung Person-ID (String, z. B. // "p_max_mustermann") -> numerische users.id. Seit der Ablösung von // people/person_roles/person_absences gibt es keinen /save-people-Schreibpfad // mehr; Abwesenheiten werden direkt gegen /users/:id/absences geschrieben // (siehe plan-modals.js/plan-drag.js), wofür die numerische User-ID benötigt // wird. Lazy geladen über GET /users (liefert seit Phase 1 auch personId) // und für die Lebensdauer der Seite zwischengespeichert. let userIdByPersonIdPromise = null; function loadUserIdByPersonId(){ if(!userIdByPersonIdPromise){ userIdByPersonIdPromise = fetch(baseUrl() + '/users') .then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }) .then(data => { const map = {}; (Array.isArray(data.users) ? data.users : []).forEach(u => { if(u.personId) map[u.personId] = u.id; }); return map; }) .catch(err => { // Cache zurücksetzen, damit ein späterer Aufruf es erneut versucht. userIdByPersonIdPromise = null; throw err; }); } return userIdByPersonIdPromise; } // getUserIdForPersonId: liefert die numerische users.id für eine Personen-ID // oder null, falls keine passende (aktive) Benutzer-Zuordnung existiert (z. B. // eine Person ohne Login-Konto). Rückgabe: Promise. function getUserIdForPersonId(personId){ return loadUserIdByPersonId().then(map => (map && Object.prototype.hasOwnProperty.call(map, personId)) ? map[personId] : null); } // Person in der globalen Liste finden (dort werden Abwesenheiten gepflegt) function findGlobalPerson(personId){ return (db.globalPeople || []).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.isSandboxPlan = isSandboxPlan; app.removeDependencyRefs = removeDependencyRefs; app.renderProjectAssignees = renderProjectAssignees; app.planRoleClass = planRoleClass; app.savePlan = savePlan; app.getUserIdForPersonId = getUserIdForPersonId; app.findGlobalPerson = findGlobalPerson; app.countPersonAbsences = countPersonAbsences; app.showToast = showToast; })();