344 lines
16 KiB
JavaScript
344 lines
16 KiB
JavaScript
// -----------------------------
|
||
// 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.');
|
||
};
|
||
})();
|