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

344 lines
16 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.
// -----------------------------
// 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 => `<label class="beauftragte-lp-item">
<input type="checkbox" value="${esc(l)}" ${gesetzt.has(l) ? 'checked' : ''}
onchange="toggleBeauftragteLp('${esc(l)}', this.checked)">
${esc(l)}
</label>`).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 = '<span class="hint">Bitte ein Startdatum wählen.</span>';
return;
}
const { zeilen, summe, problem } = berechneHonorarPlan(startDate, normieren);
if(problem){
el.innerHTML = '<span class="hint">' + esc(problem) + '</span>';
return;
}
const rows = zeilen.map(z => `<tr>
<th class="lp-lph">${esc(z.lph)}</th>
<td class="num">${fmtNum(z.anteil, 1)} %</td>
<td class="num">${fmtEur(z.phasenHonorar)}</td>
<td class="num">${fmtNum(z.stunden, 1)} h</td>
<td><input type="number" min="1" step="1" value="${z.personen}"
onchange="setHonorarPersonen('${esc(z.lph)}', this.value)"
title="Wie viele Personen arbeiten parallel an dieser Phase?"></td>
<td class="num">${z.tage} AT</td>
<td class="nowrap">${formatDateDE(z.start)}–${formatDateDE(z.ende)}</td>
</tr>`).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 = `<table class="lp-matrix honorar-table">
<thead><tr>
<th>Phase</th><th class="num">Anteil</th><th class="num">Honorar</th>
<th class="num">Stunden</th><th>Personen</th><th class="num">Dauer</th><th>Zeitraum</th>
</tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr>
<th class="lp-lph">Summe</th>
<td class="num">${fmtNum(zeilen.reduce((s,z)=>s+z.anteil,0), 1)} %</td>
<td class="num">${fmtEur(summe.honorar)}</td>
<td class="num">${fmtNum(summe.stunden, 1)} h</td>
<td></td>
<td class="num">${summe.tage} AT</td>
<td class="nowrap">${formatDateDE(zeilen[0].start)}–${formatDateDE(zeilen[zeilen.length-1].ende)}</td>
</tr></tfoot>
</table>
<div class="hint" style="margin-top:6px">${esc(normHint)} Stundensatz ${fmtEur(summe.stundensatz)}, ${fmtNum(stundenProTag(), 1)} h je Person und Werktag. AT = Arbeitstage (ohne Wochenenden und gepflegte Feiertage).</div>`;
};
// 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 = (currentUser && currentUser.role === 'admin') ? '' : '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 = '<span class="hint">Lade Honorar-Einstellungen…</span>';
loadHonorarConfig()
.then(() => renderHonorarPlan())
.catch(err => {
console.error('Honorar-Konfiguration laden fehlgeschlagen:', err);
if(el) el.innerHTML = '<span class="hint">Honorar-Einstellungen konnten nicht geladen werden.</span>';
});
};
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)`,
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.');
};
})();