// ----------------------------- // 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; let cursor = startDate; 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.isLocked()) 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.isLocked()) 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 => `
| Phase | Anteil | Honorar | Stunden | Personen | Dauer | Zeitraum |
|---|---|---|---|---|---|---|
| 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)} |