alle new
This commit is contained in:
erik committed 2026-10-01 06:46:51 +02:00
1 parent da64472251
commit 5b20adee48
1605 files changed
+396517 -1674

No files matched your search

+343
View File
@@ -0,0 +1,343 @@
// -----------------------------
// 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.');
};
})();