// ----------------------------- // Projektplan — Terminplan aus dem Honorar errechnen // ----------------------------- // Eigener Dialog (#honorarPlanModal) mit eigener Schaltfläche "+ Terminplan" // in der GANTT-Toolbar: statt fester Vorlagen-Schritte (das macht der // Nachbardialog, js/plan/plan-templates.js) wird hier aus dem vereinbarten // Honorar ein kompletter Terminplan über die beauftragten Leistungsphasen // gerechnet. Beide Verfahren lagen früher als Reiter in einem gemeinsamen // Dialog — getrennt sind sie, weil so auf jeder Schaltfläche steht, was // dahinter passiert, und keine Maske die Eingaben der anderen mitträgt. // // Rechenweg (jede Stufe ist im Dialog als Tabellenspalte sichtbar, damit die // Zahl nachvollziehbar bleibt und nicht aus einer Blackbox kommt): // // 1. Honorar (netto, €) -> im Dialog eingegeben, am Projekt gespeichert (projects.honorar) // 2. Anteil je Leistungsphase (%) -> firmenweit gepflegt (lph_honorar_anteile) // 3. ggf. Normierung -> siehe unten // 4. Honorar je Phase (€) = Honorar × Anteil // 5. Stunden je Phase = Honorar je Phase ÷ Stundensatz (firmenweit einstellbar, Vorgabe 135 €) // 6. Dauer je Phase (Werktage) = Stunden ÷ (Arbeitsstunden je Tag × Personen je Phase), aufgerundet // 7. Termine = nahtlos nacheinander ab Startdatum, nur Arbeitstage // (Wochenenden und gepflegte Feiertage fallen heraus) // // NORMIERUNG (wichtig, im Dialog umschaltbar): Sind nur einzelne Phasen // beauftragt — etwa LPH 5–8 mit zusammen 69 % —, dann ist das vereinbarte // Honorar das Honorar für GENAU DIESE Phasen. Ohne Normierung würden nur 69 % // des Honorars in Stunden umgerechnet und rund ein Drittel der Arbeitszeit // fiele unter den Tisch. Standardmäßig werden die Anteile deshalb auf die // beauftragten Phasen hochskaliert (jeder Anteil ÷ Summe der beauftragten // Anteile). Wer das Honorar als Gesamthonorar über alle Phasen versteht, // schaltet die Normierung im Dialog ab. // // Die erzeugten Aufgaben sind danach ganz normale plan_todos ohne Verbindung // zur Berechnung — wie bei den Vorlagen (plan-templates.js) wirkt sich eine // spätere Honorar- oder Satzänderung NICHT rückwirkend auf bereits erzeugte // Balken aus. // // Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp // (plan-state.js). (function(){ const app = window.PlanApp; // stundenProTag: angenommene Arbeitsstunden je Werktag und Person. War eine // Konstante (8) und ist seit dem Ausbau der Einstellungen firmenweit // gepflegt — der Wert geht direkt in jede errechnete Phasendauer ein. function stundenProTag(){ return Number(APP_SETTINGS.arbeitsstundenProTag) || 8; } // honorarConfig: zuletzt geladene firmenweite Konfiguration // ({ anteile: {LPH->%}, stundensatz: Number }); null = noch nicht geladen. let honorarConfig = null; // personenProPhase: im Dialog eingestellte Personenzahl je Leistungsphase // (LPH -> Anzahl). Lebt nur für die Dauer des Dialogs, wird nicht gespeichert. let personenProPhase = {}; // loadHonorarConfig: holt Anteile und Stundensatz vom Server und rendert // danach neu. Fehler werden in der Tabelle gemeldet, nicht per alert. function loadHonorarConfig(){ return fetch(baseUrl() + '/honorar-config') .then(r => { if(!r.ok) throw new Error('Server HTTP ' + r.status); return r.json(); }) .then(data => { honorarConfig = { anteile: data.anteile || {}, stundensatz: Number(data.stundensatz) || 135 }; return honorarConfig; }); } // beauftragteLphs: die beauftragten Leistungsphasen des Projekts, in der // Reihenfolge von LPHS (js/globals.js) — nicht in Eingabereihenfolge, damit // der erzeugte Terminplan immer chronologisch sinnvoll aufsteigt. // Rückgabe: Array von LPH-Schlüsseln. function beauftragteLphs(){ const set = new Set(Array.isArray(app.project.beauftragteLP) ? app.project.beauftragteLP : []); return LPHS.filter(l => set.has(l)); } // berechneHonorarPlan: führt die Rechnung aus und liefert je beauftragter // Leistungsphase eine Zeile mit allen Zwischenwerten. Parameter: startDate // (Date, Beginn der ersten Phase), normieren (Boolean). // Rückgabe: { zeilen: [...], summe: {...}, problem: String|null }. function berechneHonorarPlan(startDate, normieren){ const honorar = Number(app.project.honorar) || 0; const phasen = beauftragteLphs(); if(!honorarConfig) return { zeilen: [], summe: null, problem: 'Konfiguration noch nicht geladen.' }; if(honorar <= 0) return { zeilen: [], summe: null, problem: 'Bitte oben das vereinbarte Netto-Honorar eintragen.' }; if(phasen.length === 0) return { zeilen: [], summe: null, problem: 'Bitte oben mindestens eine beauftragte Leistungsphase ankreuzen.' }; const anteile = honorarConfig.anteile || {}; const summeBeauftragt = phasen.reduce((s, l) => s + (Number(anteile[l]) || 0), 0); if(summeBeauftragt <= 0){ return { zeilen: [], summe: null, problem: 'Für die beauftragten Leistungsphasen sind keine Honorar-Anteile hinterlegt. Ein Administrator kann sie unten pflegen.' }; } const stundensatz = honorarConfig.stundensatz; // Beginnt der Plan an einem Wochenende/Feiertag, auf den nächsten // Arbeitstag schieben — wie der Assistent "Neues Projekt". Sonst stand der // Balken ab Samstag, gezählt wurde aber erst ab Montag. let cursor = startDate; while(!isWorkday(cursor)) cursor = addDays(cursor, 1); let summeStunden = 0, summeTage = 0, summeHonorar = 0; const zeilen = phasen.map(lph => { const rohAnteil = Number(anteile[lph]) || 0; // Normierung: Anteil relativ zur Summe der beauftragten Phasen const anteil = normieren ? (rohAnteil / summeBeauftragt * 100) : rohAnteil; const phasenHonorar = honorar * anteil / 100; const stunden = phasenHonorar / stundensatz; const personen = Math.max(1, Number(personenProPhase[lph]) || 1); const tage = Math.max(1, Math.ceil(stunden / (stundenProTag() * personen))); const start = cursor; const ende = addWorkdays(start, tage - 1); cursor = addWorkdays(ende, 1); // nächste Phase beginnt am Folgewerktag summeStunden += stunden; summeTage += tage; summeHonorar += phasenHonorar; return { lph, rohAnteil, anteil, phasenHonorar, stunden, personen, tage, start, ende }; }); return { zeilen, summe: { stunden: summeStunden, tage: summeTage, honorar: summeHonorar, stundensatz, summeBeauftragt }, problem: null, }; } // fmtEur / fmtNum: einheitliche deutsche Zahlenformate für die Tabelle. function fmtEur(v){ return (Math.round(v * 100) / 100).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'; } function fmtNum(v, digits){ return Number(v).toLocaleString('de-DE', { minimumFractionDigits: digits || 0, maximumFractionDigits: digits || 0 }); } // setHonorarBetrag: übernimmt das vereinbarte Netto-Honorar des Projekts. // Das Feld steht bewusst hier im Rechen-Dialog und nicht mehr im // Projektinfo: dort gehören nur allgemeine Projektangaben und der // Wochenkommentar hin, das Honorar ist eine Eingabe dieser Berechnung. // Gespeichert wird es trotzdem dauerhaft am Projekt (projects.honorar), // bleibt also über Sitzungen hinweg erhalten. Leere oder unbrauchbare // Eingaben gelten als 0 = "kein Honorar hinterlegt". // Parameter: val (Eingabewert). Keine Rückgabe. window.setHonorarBetrag = function(val){ if(app.stammGesperrt()) return; const v = parseFloat(String(val).replace(',', '.')); app.project.honorar = Number.isFinite(v) && v > 0 ? v : 0; app.savePlan(); renderHonorarPlan(); // Dasselbe Feld steht auch im Einstellungen-Bereich (plan-settings.js) — // beide Masken nachziehen, damit sie nicht auseinanderlaufen. if(typeof renderPlanSettings === 'function') renderPlanSettings(); }; // toggleBeauftragteLp: nimmt eine Leistungsphase in die Liste der // beauftragten Phasen auf bzw. entfernt sie. Die Liste wird in der // Reihenfolge von LPHS gehalten, damit der erzeugte Terminplan // chronologisch aufsteigt. Parameter: lph, checked (Boolean). window.toggleBeauftragteLp = function(lph, checked){ if(app.stammGesperrt()) return; const p = app.project; const set = new Set(Array.isArray(p.beauftragteLP) ? p.beauftragteLP : []); if(checked) set.add(lph); else set.delete(lph); p.beauftragteLP = LPHS.filter(l => set.has(l)); app.savePlan(); renderHonorarPlan(); // Die beauftragten Phasen stehen auch im Einstellungen-Bereich // (plan-settings.js) und im Wochenbericht — dort wird beim nächsten // Laden gelesen, hier genügt das Nachziehen der zweiten Maske. if(typeof renderPlanSettings === 'function') renderPlanSettings(); }; // renderHonorarEingaben: hält Honorar-Feld und die Auswahl der beauftragten // Leistungsphasen auf dem Stand des Projekts. Das fokussierte Honorar-Feld // wird nicht überschrieben, sonst springt beim Tippen der Cursor. function renderHonorarEingaben(){ const betragEl = document.getElementById('honorarBetrag'); if(betragEl && document.activeElement !== betragEl){ betragEl.value = Number(app.project.honorar) > 0 ? app.project.honorar : ''; } const lpEl = document.getElementById('honorarBeauftragteLp'); if(lpEl){ const gesetzt = new Set(Array.isArray(app.project.beauftragteLP) ? app.project.beauftragteLP : []); lpEl.innerHTML = LPHS.map(l => ``).join(''); } } // renderHonorarPlan: baut die Eingaben und die Vorschau-Tabelle im Dialog // neu auf. Wird bei jeder Änderung von Honorar, beauftragten Phasen, // Startdatum, Normierung oder Personenzahl aufgerufen. // Keine Parameter, keine Rückgabe. window.renderHonorarPlan = function(){ const el = document.getElementById('honorarPlanTable'); if(!el || !app.project) return; renderHonorarEingaben(); const startIso = (document.getElementById('honorarStart') || {}).value || ''; const startDate = parseDateISO(startIso); const normieren = !!(document.getElementById('honorarNormieren') || {}).checked; if(!startDate){ el.innerHTML = 'Bitte ein Startdatum wählen.'; return; } const { zeilen, summe, problem } = berechneHonorarPlan(startDate, normieren); if(problem){ el.innerHTML = '' + esc(problem) + ''; return; } const rows = zeilen.map(z => ` ${esc(z.lph)} ${fmtNum(z.anteil, 1)} % ${fmtEur(z.phasenHonorar)} ${fmtNum(z.stunden, 1)} h ${z.tage} AT ${formatDateDE(z.start)}–${formatDateDE(z.ende)} `).join(''); const normHint = normieren ? `Anteile auf die ${zeilen.length} beauftragten Phasen normiert (Rohsumme ${fmtNum(summe.summeBeauftragt, 1)} % → 100 %).` : `Rohe HOAI-Anteile, nicht normiert — es werden nur ${fmtNum(summe.summeBeauftragt, 1)} % des Honorars verplant.`; el.innerHTML = `${rows}
PhaseAnteilHonorar StundenPersonenDauerZeitraum
Summe ${fmtNum(zeilen.reduce((s,z)=>s+z.anteil,0), 1)} % ${fmtEur(summe.honorar)} ${fmtNum(summe.stunden, 1)} h ${summe.tage} AT ${formatDateDE(zeilen[0].start)}–${formatDateDE(zeilen[zeilen.length-1].ende)}
${esc(normHint)} Stundensatz ${fmtEur(summe.stundensatz)}, ${fmtNum(stundenProTag(), 1)} h je Person und Werktag. AT = Arbeitstage (ohne Wochenenden und gepflegte Feiertage).
`; }; // setHonorarPersonen: übernimmt die im Tabellenfeld eingestellte // Personenzahl einer Phase und rechnet neu. Parameter: lph, val. window.setHonorarPersonen = function(lph, val){ personenProPhase[lph] = Math.max(1, parseInt(val, 10) || 1); renderHonorarPlan(); }; // renderHonorarHint: blendet für Administratoren den Verweis auf den // Bereich „Einstellungen" ein. Die Pflege-Maske für Anteile und Stundensatz // stand bis zum Umbau hier im Dialog — an einer Stelle also, die man nur // fand, wenn man ohnehin gerade einen Terminplan rechnen wollte. Sie liegt // jetzt in js/settings/settings.js; dieser Dialog RECHNET nur noch damit. // Keine Parameter, keine Rückgabe. function renderHonorarHint(){ const el = document.getElementById('honorarAdminHint'); if(!el) return; el.style.display = hatRecht('a.honorar') ? '' : 'none'; } // invalidateHonorarConfig: verwirft den Zwischenspeicher, damit der nächste // Aufruf des Dialogs die Konfiguration frisch holt. Gerufen von // saveHonorarConfig() (js/settings/settings.js) nach jeder Änderung — sonst // rechnete ein in derselben Sitzung danach geöffneter Dialog mit den alten // Anteilen weiter, weil die Konfiguration nur einmal je Seitenaufruf // geladen wird. Keine Parameter, keine Rückgabe. window.invalidateHonorarConfig = function(){ honorarConfig = null; }; // openHonorarModal: öffnet den Honorar-Dialog (#honorarPlanModal, eigene // Schaltfläche "+ Terminplan" in der GANTT-Toolbar). Lädt die firmenweite // Konfiguration beim ersten Aufruf, belegt das Startdatum vor und rendert. // Bei gesperrter Sitzung wird gar nicht erst geöffnet, wie bei den anderen // Bearbeitungs-Dialogen. window.openHonorarModal = function(){ if(app.isLocked()) return; document.getElementById('honorarPlanModal').style.display = 'flex'; const startInput = document.getElementById('honorarStart'); const startDisplay = document.getElementById('honorarStartDisplay'); if(startInput && !startInput.value){ const today = formatDateISO(new Date()); startInput.value = today; if(startDisplay) startDisplay.value = formatDateDE(parseDateISO(today)); } personenProPhase = {}; renderHonorarHint(); if(honorarConfig){ renderHonorarPlan(); return; } const el = document.getElementById('honorarPlanTable'); if(el) el.innerHTML = 'Lade Honorar-Einstellungen…'; loadHonorarConfig() .then(() => renderHonorarPlan()) .catch(err => { console.error('Honorar-Konfiguration laden fehlgeschlagen:', err); if(el) el.innerHTML = 'Honorar-Einstellungen konnten nicht geladen werden.'; }); }; window.closeHonorarModal = function(){ document.getElementById('honorarPlanModal').style.display = 'none'; }; // applyHonorarPlan: erzeugt aus den berechneten Zeilen je Leistungsphase // eine Aufgabe im Projektplan. Titel ist die Leistungsphase mit ihrem // Stundenbudget, damit im GANTT sofort erkennbar ist, worauf der Balken // beruht. Wie bei den Vorlagen ohne Zuständige und Abhängigkeiten — das // wird danach im TODO-Dialog ergänzt. window.applyHonorarPlan = function(){ if(app.isLocked()) return; const startIso = (document.getElementById('honorarStart') || {}).value || ''; const startDate = parseDateISO(startIso); if(!startDate){ alert('Bitte ein Startdatum wählen.'); return; } const normieren = !!(document.getElementById('honorarNormieren') || {}).checked; const { zeilen, problem } = berechneHonorarPlan(startDate, normieren); if(problem){ alert(problem); return; } const plan = app.ensurePlanLocal(); zeilen.forEach(z => { plan.todos.push({ id: uid(), title: `${z.lph} (${fmtNum(z.stunden, 0)} h)`, // Kennzeichen für die Auslastung: Die Reststunden dieser Phase // werden auf die Wochen dieses Balkens verteilt (load.js). lph: z.lph, start: formatDateISO(z.start), end: formatDateISO(z.ende), done: false, progress: 0, dependsOn: [], assignees: [], }); }); app.savePlan(); closeHonorarModal(); fitPlanGanttRange(); app.showToast(zeilen.length + ' Phase(n) als Terminplan eingefügt.'); }; })();