All new
alle new
This commit is contained in:
1 parent
da64472251
commit
5b20adee48
1605 files changed
+396517
-1674
No files matched your search
@@ -0,0 +1,284 @@
|
||||
// -----------------------------
|
||||
// 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;
|
||||
})();
|
||||
Reference in new issue
Block a user