// ----------------------------- // Projektbereich — Leistungsstand & Abrechnung // ----------------------------- // Die vier Zahlen dieser Rechnung sind alle längst gepflegt, standen aber an // vier verschiedenen Stellen und wurden nirgends zusammengeführt: // // * p.honorar — vereinbartes Netto-Honorar (Bereich // "Einstellungen" bzw. Honorar-Dialog) // * p.beauftragteLP — welche Leistungsphasen beauftragt sind // * lph_honorar_anteile — firmenweite HOAI-Anteile je Phase in Prozent // (GET /honorar-config, Pflege im Admin-Bereich) // * p.lph[] — Fortschritt der Phase in Prozent // (Prozentliste im Dashboard, plan-info.js) // // Daraus ergibt sich der ERBRACHTE LEISTUNGSWERT, also der Teil des Honorars, // der bereits abgearbeitet ist: // // Wert je Phase = Honorar × Anteil der Phase ÷ 100 × Fortschritt ÷ 100 // Erbracht = Summe über alle beauftragten Phasen // // NORMIERUNG — bewusst identisch zu berechneHonorarPlan() in // js/plan/plan-honorar.js: Sind nur einzelne Phasen beauftragt (etwa LPH 1, 4 // und 7 mit zusammen 9 %), dann ist das vereinbarte Honorar das Honorar für // GENAU DIESE Phasen. Ungenormt käme ein zu 100 % abgearbeitetes Projekt nur // auf 9 % Leistungsstand — die restlichen 91 % gehören zu Phasen, die nie // beauftragt waren. Die Anteile werden deshalb auf die beauftragten Phasen // hochskaliert (jeder Anteil ÷ Summe der beauftragten Anteile × 100), genau // wie es der Honorar-Dialog in seiner Voreinstellung tut. Rechneten die beiden // Masken hier unterschiedlich, widersprächen sich Terminplan und Abrechnung // für dasselbe Projekt. // // Diese Anzeige RECHNET nur; sie speichert nichts und legt keine Daten an. // Geändert wird das Honorar im Bereich "Einstellungen" (plan-settings.js) oder // im Honorar-Dialog (plan-honorar.js), die Fortschritte in der // Prozentliste des Dashboards (plan-info.js). // // Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp // (plan-state.js). (function(){ const app = window.PlanApp; // honorarConfig: zuletzt geladene firmenweite Konfiguration // ({ anteile: {LPH->%} }); null = noch nicht geladen. Eigener // Zwischenspeicher, weil der des Honorar-Dialogs (plan-honorar.js) modulweit // privat ist — beide holen dieselbe Antwort, aber je einmal je Seitenaufruf. let honorarConfig = null; // ladeLaeuft / ladeFehler: verhindern, dass jeder Dashboard-Aufbau eine // weitere Anfrage auslöst. Ohne das Fehler-Merkmal würde ein ausgefallener // Server bei jedem Bereichswechsel erneut angefragt. let ladeLaeuft = false; let ladeFehler = false; // fmtEur / fmtNum: deutsche Zahlenformate, gleiche Schreibweise wie in der // Tabelle des Honorar-Dialogs, damit dieselbe Zahl in beiden Masken gleich // aussieht. 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 }); } // beauftragteLphs: die beauftragten Leistungsphasen in der Reihenfolge von // LPHS (js/globals.js), nicht in Eingabereihenfolge — die Aufschlüsselung // soll immer chronologisch aufsteigen. 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)); } // fortschrittProzent: liest den Fortschritt einer Phase aus p.lph. Die Werte // stammen aus einem Textfeld (setPlanLph() in plan-info.js speichert den // getippten String), kommen also auch mal als "80 %" oder "80,5" an; ein // fehlender Eintrag bedeutet "noch nicht begonnen", nicht "unbekannt". // Über 100 wird abgeschnitten, damit ein Tippfehler nicht mehr Honorar // erbracht erscheinen lässt als vereinbart. Rückgabe: Number 0–100. function fortschrittProzent(lph){ const lphWerte = app.project.lph || {}; const roh = lphWerte[lph]; if(roh === undefined || roh === null || roh === '') return 0; const v = parseFloat(String(roh).replace('%', '').replace(',', '.')); if(!Number.isFinite(v) || v <= 0) return 0; return Math.min(100, v); } // berechneLeistungsstand: führt die Rechnung aus und liefert je beauftragter // Leistungsphase eine Zeile mit allen Zwischenwerten — jede Stufe steht // später als Tabellenspalte in der Maske, damit die Summe nachvollziehbar // bleibt und nicht aus einer Blackbox kommt. // Keine Parameter (rechnet auf app.project und honorarConfig). // Rückgabe: { honorar, zeilen, erbracht, prozent, offen, summeBeauftragt, // problem: String|null }. function berechneLeistungsstand(){ const leer = { honorar: 0, zeilen: [], erbracht: 0, prozent: 0, offen: 0, summeBeauftragt: 0 }; const honorar = Number(app.project.honorar) || 0; const phasen = beauftragteLphs(); if(honorar <= 0){ return Object.assign({}, leer, { problem: 'Für dieses Projekt ist kein Honorar hinterlegt — der erbrachte Leistungswert lässt sich daraus nicht errechnen. Das vereinbarte Netto-Honorar steht im Bereich „Einstellungen“.' }); } if(phasen.length === 0){ return Object.assign({}, leer, { honorar, problem: 'Für dieses Projekt ist keine Leistungsphase als beauftragt angekreuzt. Ohne beauftragte Phasen ist nicht bestimmt, welcher Anteil des Honorars worauf entfällt. Die Auswahl steht im Bereich „Einstellungen“.' }); } const anteile = (honorarConfig && honorarConfig.anteile) || {}; const summeBeauftragt = phasen.reduce((s, l) => s + (Number(anteile[l]) || 0), 0); if(summeBeauftragt <= 0){ return Object.assign({}, leer, { honorar, problem: 'Für die beauftragten Leistungsphasen sind keine Honorar-Anteile hinterlegt. Ein Administrator pflegt sie im Admin-Bereich unter „Honorar“.' }); } let erbracht = 0; const zeilen = phasen.map(lph => { const rohAnteil = Number(anteile[lph]) || 0; // Normierung auf die beauftragten Phasen — siehe Kopfkommentar. const anteil = rohAnteil / summeBeauftragt * 100; const phasenHonorar = honorar * anteil / 100; const fortschritt = fortschrittProzent(lph); const wert = phasenHonorar * fortschritt / 100; erbracht += wert; return { lph, rohAnteil, anteil, phasenHonorar, fortschritt, wert }; }); return { honorar, zeilen, erbracht, prozent: honorar > 0 ? erbracht / honorar * 100 : 0, offen: honorar - erbracht, summeBeauftragt, problem: null, }; } // ladeHonorarConfig: holt die firmenweiten Anteile und rendert danach neu. // Nur die Anteile werden gebraucht — der Stundensatz gehört zur // Terminplan-Rechnung, nicht zur Abrechnung. function ladeHonorarConfig(){ ladeLaeuft = true; 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 || {} }; ladeFehler = false; }) .catch(err => { console.error('Honorar-Anteile für den Leistungsstand laden fehlgeschlagen:', err); ladeFehler = true; }) .then(() => { ladeLaeuft = false; window.renderPlanLeistung(); }); } // retryLeistungConfig: erneuter Versuch nach einem Ladefehler. Ohne diese // Schaltfläche bliebe die Karte bis zum nächsten Seitenaufbau leer, denn // ladeFehler unterdrückt absichtlich jede automatische Wiederholung. window.retryLeistungConfig = function(){ ladeFehler = false; window.renderPlanLeistung(); }; // schreibeInZiele: legt dieselbe Maske in JEDES Anzeigefeld, das sie haben // will — das ist #planLeistungWrap (die Karte im Dashboard) und jedes // Element mit der Klasse .plan-leistung-wrap. Absichtlich nicht auf eine // einzige feste ID festgelegt: ob der Leistungsstand als Karte im Dashboard // oder als eigener Unterbereich der .plan-nav steht, ist eine Frage des // Markups, und diese Rechnung soll dafür nicht angefasst werden müssen. // Parameter: html (String). Rückgabe: Anzahl der befüllten Felder. function schreibeInZiele(html){ const ziele = document.querySelectorAll('#planLeistungWrap, .plan-leistung-wrap'); ziele.forEach(el => { el.innerHTML = html; }); return ziele.length; } // renderPlanLeistung: baut die Maske "Leistungsstand & Abrechnung" neu auf. // Fehlt eine der Voraussetzungen (Honorar, beauftragte Phasen, Anteile), // steht dort ein Hinweis, WAS fehlt — eine Summe von 0 € wäre eine // Falschaussage, weil unbekannt nicht dasselbe ist wie nichts erbracht. // Keine Parameter, keine Rückgabe. window.renderPlanLeistung = function(){ if(!app.project) return; if(document.querySelectorAll('#planLeistungWrap, .plan-leistung-wrap').length === 0) return; if(ladeFehler){ schreibeInZiele('
Die firmenweiten Honorar-Anteile konnten nicht geladen werden. ' + '
'); return; } if(!honorarConfig){ schreibeInZiele('
Honorar-Anteile werden geladen…
'); if(!ladeLaeuft) ladeHonorarConfig(); return; } const s = berechneLeistungsstand(); if(s.problem){ schreibeInZiele('
' + esc(s.problem) + '
'); return; } const rows = s.zeilen.map(z => ` ${esc(z.lph)} ${esc(fmtNum(z.anteil, 1))} % ${esc(fmtEur(z.phasenHonorar))} ${esc(fmtNum(z.fortschritt, 0))} % ${esc(fmtEur(z.wert))} `).join(''); // Der Balken zeigt denselben Prozentwert wie die Kennzahl daneben; der // Anschlag bei 100 % ist nur Schutz gegen Rundungsreste. const balken = Math.max(0, Math.min(100, s.prozent)); schreibeInZiele(`
Erbrachte Leistung ${esc(fmtEur(s.erbracht))} ${esc(fmtNum(s.prozent, 1))} % des Honorars
Noch nicht erbracht ${esc(fmtEur(s.offen))} ${esc(fmtNum(100 - s.prozent, 1))} % des Honorars
Honorar (netto) ${esc(fmtEur(s.honorar))} ${esc(fmtNum(s.zeilen.length, 0))} beauftragte Leistungsphase${s.zeilen.length === 1 ? '' : 'n'}
${rows}
PhaseAnteilPhasenhonorar FortschrittErbracht
Summe ${esc(fmtNum(s.zeilen.reduce((a, z) => a + z.anteil, 0), 1))} % ${esc(fmtEur(s.honorar))} ${esc(fmtNum(s.prozent, 1))} % ${esc(fmtEur(s.erbracht))}
Anteile auf die ${esc(fmtNum(s.zeilen.length, 0))} beauftragten Phasen normiert (Rohsumme der HOAI-Anteile ${esc(fmtNum(s.summeBeauftragt, 1))} % → 100 %), wie in der Voreinstellung von „Terminplan aus Honorar“. Fortschritt ohne Eintrag zählt als 0 %. Netto-Werte, keine Rechnungsstellung.
`); }; // Auslöser: renderPlanDashboard() (plan-sections.js) baut die übrigen Karten // desselben Bereichs. Hier umgehängt statt dort hineingeschrieben, damit // dieses Modul für sich steht — und weil derselbe Aufruf alle Wege abdeckt: // Seitenaufbau (renderPlanPage), Wechsel in den Dashboard-Bereich // (showPlanSection) und jede Änderung am Plan (renderPlanGantt ruft am Ende // renderPlanDashboard). Honorar und beauftragte Phasen werden in einem // ANDEREN Bereich bearbeitet; sichtbar wird die Karte also ohnehin erst // beim Rücksprung ins Dashboard — und der rendert neu. Ein zusätzlicher // ausdrücklicher Aufruf (etwa für einen eigenen Unterbereich "leistung") // stört nicht: renderPlanLeistung() rechnet nur und schreibt Markup, ein // doppelter Aufruf ergibt genau dasselbe Bild. const originalRenderPlanDashboard = window.renderPlanDashboard; window.renderPlanDashboard = function(){ if(typeof originalRenderPlanDashboard === 'function') originalRenderPlanDashboard.apply(this, arguments); window.renderPlanLeistung(); }; // Ändert ein Administrator die Anteile, verwirft plan-honorar.js seinen // Zwischenspeicher über invalidateHonorarConfig(). Derselbe Ruf muss auch // hier ankommen, sonst rechnet diese Karte den Rest der Sitzung mit den // alten Anteilen weiter. const originalInvalidate = window.invalidateHonorarConfig; window.invalidateHonorarConfig = function(){ if(typeof originalInvalidate === 'function') originalInvalidate.apply(this, arguments); honorarConfig = null; ladeFehler = false; }; // Für Nachbarmodule und zum Nachrechnen von außen erreichbar halten — die // Zahl gehört fachlich zum Projekt, nicht zu dieser einen Karte. app.berechneLeistungsstand = berechneLeistungsstand; })();