Files
projektuebersicht/public/js/plan/plan-leistung.js
erik 5b20adee48 All new
alle new
2026-10-01 06:46:51 +02:00

285 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// 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[<Phase>] — 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('<div class="hint">Die firmenweiten Honorar-Anteile konnten nicht geladen werden. '
+ '<button type="button" class="secondary" onclick="retryLeistungConfig()">Erneut versuchen</button></div>');
return;
}
if(!honorarConfig){
schreibeInZiele('<div class="hint">Honorar-Anteile werden geladen…</div>');
if(!ladeLaeuft) ladeHonorarConfig();
return;
}
const s = berechneLeistungsstand();
if(s.problem){
schreibeInZiele('<div class="leistung-leer">' + esc(s.problem) + '</div>');
return;
}
const rows = s.zeilen.map(z => `<tr>
<th class="lp-lph">${esc(z.lph)}</th>
<td class="num">${esc(fmtNum(z.anteil, 1))} %</td>
<td class="num">${esc(fmtEur(z.phasenHonorar))}</td>
<td class="num">${esc(fmtNum(z.fortschritt, 0))} %</td>
<td class="num strong">${esc(fmtEur(z.wert))}</td>
</tr>`).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(`<div class="leistung-kpis">
<div class="leistung-kpi">
<span class="leistung-kpi-label">Erbrachte Leistung</span>
<span class="leistung-kpi-value accent">${esc(fmtEur(s.erbracht))}</span>
<span class="leistung-kpi-sub">${esc(fmtNum(s.prozent, 1))} % des Honorars</span>
</div>
<div class="leistung-kpi">
<span class="leistung-kpi-label">Noch nicht erbracht</span>
<span class="leistung-kpi-value">${esc(fmtEur(s.offen))}</span>
<span class="leistung-kpi-sub">${esc(fmtNum(100 - s.prozent, 1))} % des Honorars</span>
</div>
<div class="leistung-kpi">
<span class="leistung-kpi-label">Honorar (netto)</span>
<span class="leistung-kpi-value">${esc(fmtEur(s.honorar))}</span>
<span class="leistung-kpi-sub">${esc(fmtNum(s.zeilen.length, 0))} beauftragte Leistungsphase${s.zeilen.length === 1 ? '' : 'n'}</span>
</div>
</div>
<div class="leistung-balken" role="img"
aria-label="Erbrachte Leistung ${esc(fmtNum(s.prozent, 1))} Prozent des Honorars">
<span style="width:${balken.toFixed(2)}%"></span>
</div>
<table class="lp-matrix leistung-table">
<thead><tr>
<th>Phase</th><th class="num">Anteil</th><th class="num">Phasenhonorar</th>
<th class="num">Fortschritt</th><th class="num">Erbracht</th>
</tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr>
<th class="lp-lph">Summe</th>
<td class="num">${esc(fmtNum(s.zeilen.reduce((a, z) => a + z.anteil, 0), 1))} %</td>
<td class="num">${esc(fmtEur(s.honorar))}</td>
<td class="num">${esc(fmtNum(s.prozent, 1))} %</td>
<td class="num strong">${esc(fmtEur(s.erbracht))}</td>
</tr></tfoot>
</table>
<div class="hint">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.</div>`);
};
// 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;
})();