// ----------------------------- // Projektplan — Aufgaben-Vorlagen je Leistungsphase // ----------------------------- // In einem Ingenieurbüro wiederholt sich der Ablauf einer Leistungsphase über // alle Projekte hinweg. Dieses Modul macht daraus wiederverwendbare Vorlagen: // Der Button "+ Vorlage" in der GANTT-Toolbar öffnet #lphTemplateModal, dort // wählt man Gewerk, Leistungsphase und Startdatum (Vorlagen gibt es je // Gewerk), hakt die gewünschten // Schritte an und fügt sie mit einem Klick als TODOs in den Projektplan ein. // // Der Honorar-Terminplan (js/plan/plan-honorar.js) ist ein eigener Dialog mit // eigener Schaltfläche "+ Terminplan" — beide teilten sich früher einen Dialog // mit Modus-Umschalter, was aber bedeutete, dass auf dem Knopf nur eines der // beiden Verfahren stand. // // Datenquelle sind die Routen /lph-templates (server.js, Tabelle // lph_templates): GET für alle angemeldeten Nutzer, POST/DELETE nur für // Admins — das Einfügen darf jeder, das Pflegen der firmenweiten Vorlagen // nicht. Die Pflege-Maske selbst steht seit dem Umbau NICHT mehr in diesem // Dialog, sondern im Admin-Bereich „Aufgaben-Vorlagen" (js/admin/admin.js) — // hier bleibt für Administratoren nur ein Verweis dorthin. // // WICHTIG: Eingefügte Aufgaben sind danach ganz normale plan_todos ohne // Verbindung zur Vorlage. Eine später geänderte Vorlage verändert bereits // eingefügte Aufgaben also NICHT — ein laufender Projektplan soll sich nicht // rückwirkend unter der Hand ändern. // // Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp // (plan-state.js). (function(){ const app = window.PlanApp; // templateCache: zuletzt geladene Vorlagen (flaches Array), damit das // Umschalten der Leistungsphase im Modal ohne erneuten Serveraufruf geht. let templateCache = []; // TEMPLATE_LPHS: Leistungsphasen, für die Vorlagen angelegt werden können — // dieselbe Liste wie LPHS (js/globals.js) und wie VALID_LPHS in server.js. const TEMPLATE_LPHS = LPHS; // openTemplateModal: öffnet das Vorlagen-Modal, lädt die Vorlagen (einmal // pro Seitenaufruf) und baut Leistungsphasen-Auswahl sowie Startdatum auf. // Bei gesperrter Sitzung wird gar nicht erst geöffnet (wie bei den anderen // Bearbeitungs-Dialogen, siehe plan-modals.js). window.openTemplateModal = function(){ if(app.isLocked()) return; const lphSelect = document.getElementById('templateLphSelect'); // Bei JEDEM Öffnen neu vorbelegen: Der Projektbereich zeigt nacheinander // verschiedene Projekte im selben Dokument — sonst blieb die amtierende // Leistungsphase des zuerst geöffneten Projekts vorgewählt. if(lphSelect){ // Vorbelegung: die amtierende Leistungsphase des Projekts, sonst LPH 1 const aktLp = Array.isArray(app.project.aktuelleLP) ? app.project.aktuelleLP[0] : ''; lphSelect.innerHTML = TEMPLATE_LPHS.map(l => `` ).join(''); } // Gewerk vorbelegen: das eigene, wenn das Projekt es hat, sonst das // erste Gewerk des Projekts, sonst das eigene (Vorlagen gibt es je // Gewerk, Eriks Wunsch). const gewSelect = document.getElementById('templateGewerkSelect'); if(gewSelect){ const projGew = Array.isArray(app.project.gewerke) ? app.project.gewerke : []; const eigen = currentUser && Object.keys(DEPARTMENTS).includes(currentUser.department) ? currentUser.department : ''; const vorwahl = (eigen && projGew.includes(eigen)) ? eigen : (projGew[0] || eigen || Object.keys(DEPARTMENTS)[0]); gewSelect.innerHTML = Object.keys(DEPARTMENTS).map(d => `` ).join(''); } // Startdatum vorbelegen: heute const startInput = document.getElementById('templateStart'); const startDisplay = document.getElementById('templateStartDisplay'); if(startInput && !startInput.value){ const today = formatDateISO(new Date()); startInput.value = today; if(startDisplay) startDisplay.value = formatDateDE(parseDateISO(today)); } templatePhaseVorbelegen(); document.getElementById('lphTemplateModal').style.display = 'flex'; loadTemplates(); }; window.closeTemplateModal = function(){ document.getElementById('lphTemplateModal').style.display = 'none'; }; // loadTemplates: holt die Vorlagen vom Server und rendert danach die Liste. // Fehler werden im Listenbereich gemeldet (kein alert, damit das Modal // benutzbar bleibt). function loadTemplates(){ const listEl = document.getElementById('templateTaskList'); if(listEl) listEl.innerHTML = 'Lade Vorlagen…'; fetch(baseUrl() + '/lph-templates') .then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); }) .then(data => { templateCache = Array.isArray(data.templates) ? data.templates : []; renderTemplateList(); }) .catch(err => { console.error('Vorlagen laden fehlgeschlagen:', err); if(listEl) listEl.innerHTML = 'Vorlagen konnten nicht geladen werden.'; }); } // templatePhaseVorbelegen: Hat das Projekt einen Phasenbalken der gewählten // Leistungsphase, übernehmen Startdatum und „Dauer der Phase“ seinen // Zeitraum (in Arbeitstagen). Sonst bleibt das Startdatum, die Dauer ist // von Hand anzugeben. Keine Parameter. window.templatePhaseVorbelegen = function(){ const lph = (document.getElementById('templateLphSelect') || {}).value || ''; const todos = (app.ensurePlanLocal().todos || []); const balken = todos.find(t => typeof balkenPhase === 'function' && balkenPhase(t) === lph && t.start && t.end); const tageEl = document.getElementById('templatePhasenTage'); const hinweis = document.getElementById('templatePhasenHinweis'); if(balken){ const s = parseDateISO(balken.start), e = parseDateISO(balken.end); const startInput = document.getElementById('templateStart'); const startDisplay = document.getElementById('templateStartDisplay'); if(startInput) startInput.value = balken.start; if(startDisplay) startDisplay.value = formatDateDE(s); if(tageEl) tageEl.value = Math.max(1, workdaysBetween(s, e)); if(hinweis) hinweis.textContent = 'aus dem Phasenbalken ' + lph + ' (' + formatDateDE(s) + ' – ' + formatDateDE(e) + ')'; } else { if(tageEl) tageEl.value = ''; if(hinweis) hinweis.textContent = 'Kein Phasenbalken für ' + lph + ' — Dauer bitte angeben.'; } }; // templatePlan: Termine der Schritte nach ihrem Anteil (Eriks Wunsch, z. B. // Siemon-Tabelle): Jeder Schritt mit Anteil bekommt Anteil ÷ Summe aller // Anteile der Phase an deren Dauer, nacheinander ab dem Startdatum in der // gepflegten Reihenfolge (Arbeitstage, ohne Wochenenden/Feiertage). Die // Grenzen werden über die laufende Summe gerundet, damit sich keine // Rundungsfehler aufaddieren. Schritte ohne Anteil: Start/Dauer der // Vorlage, ohne Dauer ohne Termin. Parameter: entries (alle Schritte der // Phase), startDate, phasenTage. Rückgabe: Map id → {s, e} oder null. function templatePlan(entries, startDate, phasenTage){ const out = new Map(); const summe = entries.reduce((a, t) => a + (Number(t.anteil) || 0), 0); let kum = 0; entries.forEach(t => { const ant = Number(t.anteil) || 0; if(ant > 0 && summe > 0){ if(!startDate || !(phasenTage > 0)){ out.set(t.id, null); return; } const von = Math.round(phasenTage * kum / summe); const bis = Math.round(phasenTage * (kum + ant) / summe); kum += ant; const s = addWorkdays(startDate, von); const e = addWorkdays(s, Math.max(1, bis - von) - 1); out.set(t.id, { s, e, anteil: ant / summe }); } else if(t.enthaltenIn){ out.set(t.id, null); // zweiter Durchgang unten } else if(t.durationDays > 0 && startDate){ const s = addDays(startDate, t.offsetDays); out.set(t.id, { s, e: addDays(s, t.durationDays - 1) }); } else { out.set(t.id, null); } }); // „in a) enthalten“ (Siemon): zeitgleich mit den genannten Schritten — // vom frühesten Beginn bis zum spätesten Ende. Der Buchstabe eines // Schritts steht am Ende seines Titels („… (a)“). const buchstabe = t => (String(t.title).match(/\(([a-z])\)\s*$/i) || [])[1]; entries.filter(t => t.enthaltenIn).forEach(t => { const wirte = t.enthaltenIn.split(',') .map(b => entries.find(x => !x.enthaltenIn && (buchstabe(x) || '').toLowerCase() === b)) .map(x => x && out.get(x.id)).filter(Boolean); if(!wirte.length) return; out.set(t.id, { s: new Date(Math.min(...wirte.map(w => w.s.getTime()))), e: new Date(Math.max(...wirte.map(w => w.e.getTime()))), enthaltenIn: t.enthaltenIn }); }); return out; } // templatesForSelectedLph: die Vorlagen-Schritte der gerade gewählten // Leistungsphase, in ihrer gepflegten Reihenfolge. Rückgabe: Array. function templatesForSelectedLph(){ const lphSelect = document.getElementById('templateLphSelect'); const gewSelect = document.getElementById('templateGewerkSelect'); const lph = lphSelect ? lphSelect.value : ''; const g = gewSelect ? gewSelect.value : ''; // Vorlagen ohne Gewerk (Altbestand) gelten für alle Gewerke return templateCache.filter(t => t.lph === lph && (!t.gewerk || t.gewerk === g)); } // renderTemplateList: baut die Auswahlliste der Schritte (Checkbox je // Schritt, standardmäßig alle angehakt) samt Vorschau der Termine, die sich // aus dem gewählten Startdatum ergeben. Für Admins wird darunter der // Pflege-Bereich gerendert. Wird bei jedem Wechsel von Leistungsphase oder // Startdatum erneut aufgerufen. Keine Parameter, keine Rückgabe. window.renderTemplateList = function(){ const listEl = document.getElementById('templateTaskList'); if(!listEl) return; const entries = templatesForSelectedLph(); const startIso = (document.getElementById('templateStart') || {}).value || ''; const startDate = parseDateISO(startIso); const phasenTage = parseInt((document.getElementById('templatePhasenTage') || {}).value, 10) || 0; const plan = templatePlan(entries, startDate, phasenTage); if(entries.length === 0){ listEl.innerHTML = 'Für dieses Gewerk und diese Leistungsphase ist noch keine Vorlage hinterlegt.'; } else { listEl.innerHTML = entries.map(t => { // Vorschau der konkreten Termine (nur wenn ein Startdatum gesetzt ist) const termin = plan.get(t.id); let dateHint; if(termin) dateHint = formatDateDE(termin.s) + '–' + formatDateDE(termin.e) + (termin.anteil ? ' · ' + String(t.anteil).replace('.', ',') + ' %' : '') + (termin.enthaltenIn ? ' · in ' + termin.enthaltenIn.split(',').map(x => x + ')').join(' + ') : ''); else if(t.enthaltenIn) dateHint = 'in ' + t.enthaltenIn.split(',').map(x => x + ')').join(' + ') + ' — Dauer der Phase fehlt'; else if(Number(t.anteil) > 0) dateHint = String(t.anteil).replace('.', ',') + ' % — Dauer der Phase fehlt'; else if(t.durationDays > 0) dateHint = `Tag +${t.offsetDays}, ${t.durationDays} Tag${t.durationDays === 1 ? '' : 'e'}`; else dateHint = 'ohne Termin'; return ``; }).join(''); } renderTemplateHint(); }; // renderTemplateHint: blendet für Administratoren den Verweis auf den // Admin-Bereich „Aufgaben-Vorlagen" ein. Das Anlegen und Löschen von Schritten stand // bis zum Umbau hier im Dialog; es liegt jetzt in js/admin/admin.js. // Dieser Dialog FÜGT Vorlagen nur noch ein. Keine Parameter, keine Rückgabe. function renderTemplateHint(){ const el = document.getElementById('templateAdminHint'); if(!el) return; el.style.display = hatRecht('a.vorlagen') ? '' : 'none'; } // applyTemplate: erzeugt aus den angehakten Vorlagen-Schritten neue TODOs im // aktuellen Projektplan. Termine ergeben sich aus dem gewählten Startdatum // plus offsetDays bzw. durationDays. Die neuen Aufgaben werden ans Ende der // Aufgabenliste gehängt, ohne Zuständige und ohne Abhängigkeiten — das wird // anschließend im TODO-Dialog ergänzt. window.applyTemplate = function(){ if(app.isLocked()) return; const startIso = (document.getElementById('templateStart') || {}).value || ''; const startDate = parseDateISO(startIso); const listEl = document.getElementById('templateTaskList'); const checkedIds = listEl ? Array.from(listEl.querySelectorAll('input[type="checkbox"]:checked')).map(cb => cb.value) : []; if(checkedIds.length === 0){ alert('Bitte mindestens einen Schritt auswählen.'); return; } const alle = templatesForSelectedLph(); const entries = alle.filter(t => checkedIds.includes(t.id)); // Ein Startdatum braucht es nur für Schritte mit Anteil oder Dauer — // ohne beides „ohne Termin“ (Aufgabe nur in der Liste, nicht im Gantt). if(!startDate && entries.some(t => t.durationDays > 0 || Number(t.anteil) > 0 || t.enthaltenIn)){ alert('Bitte ein Startdatum wählen.'); return; } const phasenTage = parseInt((document.getElementById('templatePhasenTage') || {}).value, 10) || 0; if(!phasenTage && entries.some(t => Number(t.anteil) > 0 || t.enthaltenIn)){ alert('Bitte die Dauer der Phase in Arbeitstagen angeben — die Schritte bekommen ihren Anteil davon.'); return; } // Anteile beziehen sich auf ALLE Schritte der Phase: Abgewählte lassen // ihre Lücke, die übrigen behalten ihren Platz im Ablauf. const termine = templatePlan(alle, startDate, phasenTage); const plan = app.ensurePlanLocal(); entries.forEach(t => { const termin = termine.get(t.id); const s = termin ? termin.s : null; const e = termin ? termin.e : null; plan.todos.push({ id: uid(), title: t.title, start: s ? formatDateISO(s) : '', end: e ? formatDateISO(e) : '', done: false, progress: 0, dependsOn: [], assignees: [], }); }); app.savePlan(); closeTemplateModal(); fitPlanGanttRange(); app.showToast(entries.length + ' Aufgabe(n) aus der Vorlage eingefügt.'); }; })();