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

766 lines
38 KiB
JavaScript
Raw 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.
// -----------------------------
// Projektübersicht — Auslastung (Load) & Aufwand je Leistungsphase
// -----------------------------
// Modul für den Auslastungs-Tab der Hauptseite (Test.html, Ladereihenfolge:
// globals → utils → ui → date-picker → csv → server → store → weeks → board →
// people → projects → load → gantt → …). Es stellt renderLoad(), das LP-
// Aufwand-Modal und das Neues-Projekt-Modal bereit. Genutzt werden LPHS/ROLES
// aus js/globals.js, currentWeek()/save() aus js/store.js, guardWeekLocked()
// aus js/weeks.js sowie peopleByRole()/assignKey() aus js/utils.js.
// Die Auslastungs-Logik (berechneAuslastung, lpActiveList, faktorFormat,
// pdfLoadAmpelColor, stundenFormat) wird zusätzlich von js/pdf.js für den
// PDF-Export wiederverwendet.
//
// Datenmodell je Projekt (rollen- und leistungsphasenspezifisch):
// pr.aufwandProLP = {
// 'LPH 1': { Planung: 0.4, Projektleitung: 0.6, Bauüberwachung: 0.0 },
// ...
// } // Gewichtungsfaktor je Rolle (0.0–1.0)
// pr.aktuelleLP = ['LPH 6'] // aktuell amtierende Phase (max. 1)
//
// Es zählt genau eine amtierende Phase je Projekt ("Nur amtierende Einzelphase",
// Absprache mit dem Auftraggeber). Altdaten mit mehreren aktiven Phasen
// verwenden deterministisch die erste aktive Phase der Liste.
//
// Berechnungslogik:
// Auslastung(Mitarbeiter) = Σ Faktor der amtierenden LP je Projekt,
// in dem der Mitarbeiter (in seiner Rolle) zugewiesen ist.
// Der Faktor zählt 1:1 – die Zuordnungsart (Haupt/Optional) skaliert nicht.
// Leere Matrix-Felder zählen bei gesetzter aktiver LP mit dem Standard-Faktor
// 1,0; ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet).
// "In Anbahnung" wird nicht eingerechnet.
//
// HINWEIS (Fachplaner-Fragen, bewusst NICHT eigenständig entschieden):
// - Ampel-Schwellen (50 % / 75 % des Maximalwerts) sind PLATZHALTER und müssen
// fachlich abgestimmt werden.
// - Mitarbeiterkapazität (Teilzeit/Vollzeit) existiert nicht – offener Punkt.
// AUFWAND_LPS: Phasen des Aufwandsmodells — alle echten Leistungsphasen
// (inkl. LPH 9), ohne die Sammelkategorie "W+M". Bewusst per Filter statt per
// Index-Schnitt, damit eine Ergänzung von LPHS nicht stillschweigend durchs
// Raster fällt.
const AUFWAND_LPS = LPHS.filter(l => l !== 'W+M');
// aufwandVorlage: die firmenweite Gewichtung je Leistungsphase und Rolle
// (0,0–1,0). Sie greift überall dort, wo ein Projekt in seiner Aufwandsmaske
// keinen eigenen Wert trägt — also bei den meisten — und bestimmt damit still
// einen Großteil der Auslastungszahlen. Genau deshalb steht sie seit dem
// Ausbau der Einstellungen nicht mehr als Konstante hier, sondern wird
// firmenweit gepflegt (Bereich „Einstellungen", nur Administratoren) und über
// APP_SETTINGS geliefert (js/globals.js, gefüllt in js/store.js). LPH 9 steht
// in der Vorgabe bewusst auf 0 — die Faktoren sind fachlich nicht abgestimmt.
// Rückgabe: Objekt LPH -> { Rolle: Faktor }.
function aufwandVorlage(){
return (APP_SETTINGS && APP_SETTINGS.aufwandVorlage) || {};
}
// Ampel-Schwellen und Balkenskala. Die Schwellen beziehen sich auf die
// KAPAZITÄT der Person (1,0 = ihre volle Stelle), nicht auf das Maximum der
// Balkenskala: Mit einer Skala bis 8 lagen sie vorher faktisch bei 6,0 (rot)
// und 4,0 (gelb) — eine voll ausgelastete Person erschien grün, Überlastung
// meldete die Ampel praktisch nie. Alle drei Werte sind firmenweit einstellbar.
function schwelleGelb(){ return Number(APP_SETTINGS.auslastungSchwelleGelb) || 0.75; }
function schwelleRot(){ return Number(APP_SETTINGS.auslastungSchwelleRot) || 1.0; }
// kapazitaetsFaktor: Anteil einer vollen Stelle, den eine Person vertraglich
// arbeitet (0,5 = halbe Stelle). Bis zum Ausbau der Einstellungen kannte die
// Rechnung keine Teilzeit und maß jede Person an einer vollen Stelle — eine
// halbe Stelle erschien dadurch dauerhaft unterbeschäftigt (hier oben als
// offener Punkt vermerkt). Die Wochenstunden stehen am Benutzerkonto
// (Benutzerverwaltung, users.capacity_hours); 0 bzw. keine auffindbare
// Zuordnung zählt als volle Stelle, damit sich das Bild bestehender Konten
// nicht unangekündigt verschiebt.
//
// Der Parameter ist bewusst das PERSONENOBJEKT der Sitzung und nicht nur
// dessen id: Die Teilnehmer einer Sitzung sind eingefrorene Kopien
// (week_people) und ihre id stammt aus der Zeit, als sie angelegt wurden —
// im Altbestand steht dort z. B. "p_ap", während das Konto heute
// "p_alexander_plum" trägt. Über die id allein fände man die Person also
// nicht wieder; der volle Name ist die zweite Brücke.
// Parameter: person (Personenobjekt der Sitzung). Rückgabe: Faktor > 0.
function kapazitaetsFaktor(person){
const list = db.globalPeople || [];
const global = list.find(p => p.id === person.id)
|| (person.fullName ? list.find(p => p.fullName === person.fullName) : null);
const stunden = global ? Number(global.capacityHours) : 0;
const voll = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
if(!Number.isFinite(stunden) || stunden <= 0) return 1;
return stunden / voll;
}
// lpActiveList: liefert die amtierenden Leistungsphasen eines Projekts,
// gefiltert auf das Aufwandsmodell (alle LPH ohne "W+M"). Parameter: pr = Projektobjekt.
// Rückgabe: Array von LP-Namen (leer, wenn keine aktive Phase passt).
function lpActiveList(pr){
const a = pr && pr.aktuelleLP;
if(Array.isArray(a)) return a.filter(l => AUFWAND_LPS.includes(l));
if(typeof a === 'string' && AUFWAND_LPS.includes(a)) return [a];
return [];
}
// aufwandMatrixWert: Rollenwert eines Projekts für eine Leistungsphase
// (Gewichtungsfaktor, 0,0–1,0). Liefert null, wenn keine (rollenspezifische) Matrix
// bzw. kein Wert für diese Phase/Rolle hinterlegt ist. 0 ist ein gültiger Wert.
function aufwandMatrixWert(projekt, lp, rolle){
const m = projekt && projekt.aufwandProLP;
const entry = (m && typeof m === 'object') ? m[lp] : undefined;
if(!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
const v = parseFloat(entry[rolle]);
return isNaN(v) ? null : v;
}
// ---------- Auslastung in Stunden ----------
//
// Die Auslastung wird als WOCHENSTUNDEN gerechnet und erst zum Schluss auf
// die Kapazität der Person bezogen. Vorher war sie eine Summe dimensionsloser
// Gewichte: Ein Projekt in LPH 5 zählte für die Planung 1,0 — ob es 20.000 €
// oder 2 Mio. € umfasste. Zwei kleine Projekte wogen damit schwerer als ein
// großes, und die Zahl sagte nichts darüber, wie viel Arbeit tatsächlich
// anliegt.
//
// Der Weg von einem Projekt zu den Wochenstunden einer Person:
//
// 1. BASIS Honorar des Projekts. Fehlt es, tritt die am Projekt
// gepflegte Größe (0–1) mal dem Referenzhonorar an seine
// Stelle. Fehlt beides, ist das Projekt NICHT BEWERTBAR —
// es wird als Lücke ausgewiesen, nicht als Null gezählt.
// 2. PHASE Anteil der amtierenden Leistungsphase am Honorar
// (HONORAR_ANTEILE, auf die beauftragten Phasen normiert —
// dieselbe Rechnung wie im Leistungsstand des
// Projektbereichs), abzüglich des bereits erbrachten
// Fortschritts dieser Phase. Übrig bleibt der OFFENE Wert.
// 3. STUNDEN Offener Wert geteilt durch den Stundensatz.
// 4. ROLLE Aufteilung auf die Rollen über dieselbe Gewichtung wie
// bisher (Projektmatrix, sonst firmenweite Vorlage), hier
// aber als ANTEIL: Gewicht der Rolle durch die Summe aller
// Rollengewichte dieser Phase.
// 5. KÖPFE Aufteilung auf die Personen, die dieser Rolle im Projekt
// zugeordnet sind. Ohne diesen Schritt zählte jeder von drei
// Planern die vollen Phasenstunden — der Aufwand verdreifacht
// sich nicht dadurch, dass mehr Leute daran arbeiten.
// Hauptverantwortung wiegt 1, Unterstützung 0,5.
// 6. ZEIT Verteilung der Reststunden über die Wochen bis zum Ende
// des Terminplans; ohne Terminplan über den firmenweiten
// Verteilzeitraum. Ohne Horizont wäre die Wochenlast nicht
// bestimmt: Dieselben Reststunden bedeuten über vier Wochen
// etwas anderes als über vierzig.
//
// "In Anbahnung" bleibt wie bisher ausgeklammert.
// projektBasisHonorar: die Geldgrundlage eines Projekts für die
// Auslastungsrechnung. Parameter: projekt. Rückgabe:
// { betrag, quelle: 'honorar'|'groesse'|null }.
function projektBasisHonorar(projekt){
const honorar = Number(projekt && projekt.honorar) || 0;
if(honorar > 0) return { betrag: honorar, quelle: 'honorar' };
const groesse = Number(projekt && projekt.groesse) || 0;
if(groesse > 0){
const referenz = Number(APP_SETTINGS.referenzHonorar) || 250000;
return { betrag: Math.min(1, groesse) * referenz, quelle: 'groesse' };
}
return { betrag: 0, quelle: null };
}
// phasenAnteilProzent: Anteil einer Leistungsphase am Honorar des Projekts,
// normiert auf die BEAUFTRAGTEN Phasen — ein Projekt, das nur für drei Phasen
// beauftragt ist, verteilt sein Honorar auch nur auf diese drei. Sind keine
// beauftragt, gelten die firmenweiten Anteile unnormiert (sie ergeben
// zusammen 100 %). Parameter: projekt, lph. Rückgabe: Prozent (0 = unbekannt).
function phasenAnteilProzent(projekt, lph){
const roh = Number(HONORAR_ANTEILE[lph]) || 0;
if(roh <= 0) return 0;
const beauftragt = Array.isArray(projekt.beauftragteLP) ? projekt.beauftragteLP : [];
if(beauftragt.length === 0) return roh;
if(!beauftragt.includes(lph)) return 0;
const summe = beauftragt.reduce((acc, l) => acc + (Number(HONORAR_ANTEILE[l]) || 0), 0);
return summe > 0 ? roh / summe * 100 : 0;
}
// rollenGewicht: Aufwandsgewicht einer Rolle in einer Phase — Wert der
// Projektmatrix, sonst firmenweite Vorlage, sonst 1,0. Unverändert
// gegenüber der bisherigen Rechnung; neu ist nur, wofür er dient (Anteil
// statt absoluter Last). Parameter: projekt, lph, rolle. Rückgabe: Zahl.
function rollenGewicht(projekt, lph, rolle){
const wert = aufwandMatrixWert(projekt, lph, rolle);
if(wert !== null) return wert;
const vorlage = aufwandVorlage()[lph];
const v = vorlage ? parseFloat(vorlage[rolle]) : NaN;
return isNaN(v) ? 1.0 : v;
}
// zuordnungsGewicht: Wie schwer eine Zuordnung wiegt. 1 = Hauptverantwortung,
// 2 = Unterstützung. Die Unterstützung zählt halb, weil sie sonst dieselbe
// Stundenzahl trüge wie die Hauptverantwortung und die Arbeit rechnerisch
// gleichmäßig verteilt würde, obwohl sie es nicht ist.
function zuordnungsGewicht(status){
return status === 2 ? 0.5 : status === 1 ? 1 : 0;
}
// projektVerteilWochen: über wie viele Wochen die offenen Stunden eines
// Projekts verteilt werden. Aus dem Terminplan das späteste Datum (dieselbe
// Quelle, aus der auch der Verlauf das Projektende liest), gerechnet ab
// heute; ohne Terminplan oder bei bereits verstrichenem Ende gilt der
// firmenweite Verteilzeitraum. Parameter: projekt. Rückgabe: Wochen (>= 1).
function projektVerteilWochen(projekt){
const vorgabe = Math.max(1, Number(APP_SETTINGS.verteilzeitraumWochen) || 12);
const plan = projekt && projekt.plan;
if(!plan) return vorgabe;
const daten = [];
(Array.isArray(plan.todos) ? plan.todos : []).forEach(t => { if(t && t.end) daten.push(t.end); });
(Array.isArray(plan.milestones) ? plan.milestones : []).forEach(m => { if(m && m.date) daten.push(m.date); });
if(daten.length === 0) return vorgabe;
const ende = new Date(daten.sort().pop());
if(isNaN(ende.getTime())) return vorgabe;
const heute = new Date();
heute.setHours(0, 0, 0, 0);
const tage = daysBetween(heute, ende);
// Ein Ende in der Vergangenheit sagt nichts über die kommenden Wochen —
// dann gilt wieder die Vorgabe, statt die Restarbeit auf null Wochen zu
// verteilen und eine unendliche Wochenlast zu erzeugen.
if(!Number.isFinite(tage) || tage <= 0) return vorgabe;
return Math.max(1, tage / 7);
}
// fortschrittProzent: Fortschritt einer Leistungsphase am Projekt (0–100).
// Die Werte kommen aus einem Textfeld und können "80", "80 %" oder "12,5"
// lauten. Parameter: projekt, lph. Rückgabe: Zahl 0–100.
function fortschrittProzent(projekt, lph){
const roh = (projekt && projekt.lph) ? projekt.lph[lph] : undefined;
const n = parseFloat(String(roh ?? '').replace(',', '.'));
if(!Number.isFinite(n)) return 0;
return Math.min(100, Math.max(0, n));
}
// arbeitsPhase: Welche Leistungsphase die Auslastung eines Projekts
// bestimmt — die amtierende, und wenn die bereits zu 100 % erledigt ist, die
// nächste beauftragte, die noch offen ist.
//
// Ohne diesen Schritt fiel ein Projekt aus der Auslastung, sobald jemand
// seine amtierende Phase auf 100 % setzte, ohne die Phase weiterzuschalten —
// im Bestand der Normalfall. Rechnerisch stimmte das (diese eine Phase ist
// fertig), fachlich nicht: Das Projekt läuft weiter, nur eben in der
// nächsten Phase. Sind alle beauftragten Phasen fertig, ist das Projekt
// fertig und bindet zu Recht keine Kapazität mehr.
// Parameter: projekt, amtierend. Rückgabe: Phasenname oder null.
function arbeitsPhase(projekt, amtierend){
if(!amtierend) return null;
if(fortschrittProzent(projekt, amtierend) < 100) return amtierend;
const beauftragt = Array.isArray(projekt.beauftragteLP) && projekt.beauftragteLP.length
? projekt.beauftragteLP : AUFWAND_LPS;
// In der Reihenfolge der Leistungsphasen weitersuchen, beginnend hinter
// der amtierenden.
const abIndex = AUFWAND_LPS.indexOf(amtierend);
const folgende = AUFWAND_LPS.filter((l, i) => i > abIndex && beauftragt.includes(l));
return folgende.find(l => fortschrittProzent(projekt, l) < 100) || null;
}
// berechneAuslastung: Wochenstunden EINER Zuordnung (Person/Rolle) zu einem
// Projekt. Parameter: mitarbeiter (ungenutzt, Signatur beibehalten), projekt,
// aktuelleLP, rolle, status (1 = Haupt, 2 = Unterstützung), alleZuordnungen
// (optional: das assign-Objekt des Projekts, für die Aufteilung auf mehrere
// Köpfe derselben Rolle).
// Rückgabe: { stunden, load, anbahnung?, unbewertbar?, grund? }.
// stunden = Wochenstunden dieser Person aus diesem Projekt
// load = dieselbe Größe als Anteil einer Vollzeitstelle (Rückwärts-
// kompatibilität für js/pdf.js, das den Wert weiterhin so nennt)
function berechneAuslastung(mitarbeiter, projekt, aktuelleLP, rolle, status, alleZuordnungen){
const leer = { stunden: 0, load: 0 };
if(!projekt || projekt.group === 'In Anbahnung') return { stunden: 0, load: 0, anbahnung: true };
if(!aktuelleLP) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne amtierende Leistungsphase' });
const basis = projektBasisHonorar(projekt);
if(basis.betrag <= 0) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne Honorar und ohne Projektgröße' });
// Gerechnet wird mit der Phase, in der tatsächlich gearbeitet wird — der
// amtierenden oder, wenn die fertig ist, der nächsten offenen.
const phase = arbeitsPhase(projekt, aktuelleLP);
if(!phase) return Object.assign({}, leer, { fertig: true });
const anteil = phasenAnteilProzent(projekt, phase);
if(anteil <= 0) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne Honorar-Anteil für diese Phase' });
const offenerWert = basis.betrag * anteil / 100 * (1 - fortschrittProzent(projekt, phase) / 100);
if(offenerWert <= 0) return Object.assign({}, leer, { fertig: true });
const satz = Number(APP_SETTINGS.stundensatz) || 100;
const gesamtStunden = offenerWert / satz;
// Rollenanteil — nach der Phase, in der gearbeitet wird
const gewichte = ROLES.map(r => rollenGewicht(projekt, phase, r));
const summeGewichte = gewichte.reduce((a, b) => a + b, 0);
const eigenesGewicht = rollenGewicht(projekt, phase, rolle);
if(summeGewichte <= 0 || eigenesGewicht <= 0) return leer;
const rollenStunden = gesamtStunden * (eigenesGewicht / summeGewichte);
// Aufteilung auf die Köpfe dieser Rolle
const eigenAnteil = zuordnungsGewicht(status);
if(eigenAnteil <= 0) return leer;
let summeKoepfe = eigenAnteil;
if(alleZuordnungen && typeof alleZuordnungen === 'object'){
summeKoepfe = 0;
Object.keys(alleZuordnungen).forEach(key => {
if(key.slice(key.indexOf('|') + 1) !== rolle) return;
summeKoepfe += zuordnungsGewicht(alleZuordnungen[key]);
});
if(summeKoepfe <= 0) summeKoepfe = eigenAnteil;
}
const personStunden = rollenStunden * (eigenAnteil / summeKoepfe);
const wochen = projektVerteilWochen(projekt);
const stunden = personStunden / wochen;
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
return { stunden, load: stunden / vollzeit, quelle: basis.quelle, wochen, phase };
}
// groesseText: beschriftet die Projektgröße für die Eingabemasken.
//
// Nur die Zahl, KEIN Eurobetrag daneben. Intern wird die Größe zwar über das
// Referenzhonorar in einen Betrag umgerechnet (projektBasisHonorar()) — die
// Zahl neben dem Feld auszuweisen, gäbe der Schätzung aber eine Genauigkeit,
// die sie nicht hat: "0,40 · rund 100.000 €" liest sich wie ein Honorar,
// obwohl es ein grober Umfang ist.
// Parameter: wert (0–1). Rückgabe: String.
function groesseText(wert){
const n = Math.max(0, Math.min(1, parseFloat(wert) || 0));
if(n <= 0) return '— keine Angabe';
return n.toFixed(2).replace('.', ',');
}
// stundenFormat: Wochenstunden in deutscher Schreibweise, eine
// Nachkommastelle. Ab 100 h ohne Nachkomma — die Genauigkeit einer
// Hochrechnung reicht dort nicht für Zehntel.
// Parameter: x (Zahl). Rückgabe: String.
function stundenFormat(x){
const n = Number(x) || 0;
if(n >= 100) return String(Math.round(n));
return (Math.round(n * 10) / 10).toFixed(1).replace('.', ',');
}
// toggleLoadLuecken: klappt die Liste der nicht bewertbaren Projekte auf und
// zu. Keine Parameter, keine Rückgabe.
function toggleLoadLuecken(){
const el = document.getElementById('loadLuecken');
if(el) el.style.display = el.style.display === 'none' ? '' : 'none';
}
// faktorFormat: formatiert einen Auslastungs-Faktor in deutscher Notation
// (Komma, max. 2 Nachkommastellen), z. B. 1.1 → "1,10". 1,0 = 100 % Kapazität.
function faktorFormat(x){
const n = Math.round((x || 0) * 100) / 100;
return n.toFixed(2).replace('.', ',');
}
// loadAmpelColor: Ampel-Farbe eines Auslastungswerts. Der übergebene Wert ist
// bereits auf die Kapazität der Person bezogen (1,0 = ihre volle Stelle); die
// Schwellen sind firmenweit einstellbar.
function loadAmpelColor(x){
// Design-Tokens statt fester Farbwerte: Die Ampel färbt seit dem Lastband
// nicht mehr nur einen Balken, sondern die große Zahl am Zeilenende. Die
// früheren Festwerte (#c53030/#d69e2e/#2f855a) waren für den hellen Grund
// gewählt und standen im dunklen Thema zu dunkel; --red/--amber/--green
// sind dort hellere Töne (siehe styles.css). Für den PDF-Export gilt
// weiterhin pdfLoadAmpelColor() mit druckfreundlichen Festwerten — ein PDF
// hat kein Thema.
return x >= schwelleRot() ? 'var(--red)' : x >= schwelleGelb() ? 'var(--amber)' : 'var(--green)';
}
// pdfLoadAmpelColor: Ampel-Farbe für den PDF-Export (js/pdf.js) mit gleichen
// Schwellen wie loadAmpelColor, aber dunkleren, druckfreundlichen Farbtönen.
function pdfLoadAmpelColor(x){
return x >= schwelleRot() ? '#D70015' : x >= schwelleGelb() ? '#B25000' : '#248A3D';
}
// ---------- LP-Aufwand Eingabemaske ----------
// lpModalProjectId: Projekt-ID des aktuell offenen LP-Aufwand-Modals (null = geschlossen).
let lpModalProjectId = null;
// lpRowsHtml: erzeugt die Aufwand-Maske als Matrix-Tabelle.
// Zeilen = Leistungsphasen (alle ohne "W+M"), Spalten = Rollen (Planung/Projektleitung/
// Bauüberwachung). Die amtierende Leistungsphase wird pro Projekt im Tab
// "Projekte" gewählt (renderProjects / editProjectActiveLP).
// prefix: 'lp' = Bearbeiten-Modal, 'np' = Neues-Projekt-Modal (eindeutige IDs).
// Leere Felder werden mit den Werten der firmenweiten Vorlage vorbefüllt.
// Die Feld-IDs bleiben identisch, damit collectLpRows() unverändert funkioniert.
function lpRowsHtml(p, prefix){
const head = ROLES.map(r => `<th>${esc(r)}</th>`).join('');
const rows = AUFWAND_LPS.map(l => {
const idx = l.replace(/[^\d]/g,'');
const row = (p.aufwandProLP && typeof p.aufwandProLP[l] === 'object') ? p.aufwandProLP[l] : {};
const cells = ROLES.map((r, ri) => {
const raw = row[r];
const vorlage = aufwandVorlage()[l] ? aufwandVorlage()[l][r] : undefined;
const base = (raw !== undefined && raw !== null && raw !== '') ? raw
: (vorlage !== undefined && vorlage !== null && vorlage !== '') ? vorlage : '';
const val = base !== '' ? String(parseFloat(base)).replace('.', ',') : '';
// Platzhalter zeigt den Vorgabewert dieser Phase/Rolle, denn genau der
// gilt, solange das Feld leer bleibt (siehe berechneAuslastung()).
const ph = (vorlage !== undefined && vorlage !== null && vorlage !== '') ? String(parseFloat(vorlage)).replace('.', ',') : '1,0';
return `<td><input type="text" inputmode="decimal" id="${prefix}Val${idx}R${ri}" value="${val}" placeholder="${ph}" title="Leer = Vorgabewert ${ph}"></td>`;
}).join('');
return `<tr><th class="lp-lph">${l}</th>${cells}</tr>`;
}).join('');
return `<table class="lp-matrix">
<thead><tr><th class="lp-lph">Leistungsphase</th>${head}</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
// collectLpRows: liest die Aufwand-Maske aus und validiert die Werte (0–1).
// Rückgabe: { aufwand, hasValue } oder null bei Eingabefehler.
function collectLpRows(prefix){
const aufwand = {};
let hasValue = false;
for(const l of AUFWAND_LPS){
const idx = l.replace(/[^\d]/g,'');
const entry = {};
let rowHasValue = false;
let invalid = false;
for(let ri = 0; ri < ROLES.length; ri++){
const r = ROLES[ri];
const input = document.getElementById(prefix + 'Val' + idx + 'R' + ri);
if(!input) continue;
const raw = input.value.trim().replace(',', '.');
if(raw === '') continue;
// Strenge Prüfung statt parseFloat: Das nahm auch "0.5.5" (→ 0.5) oder
// "1x" (→ 1) kommentarlos an.
const v = /^\d+(\.\d+)?$/.test(raw) ? parseFloat(raw) : NaN;
if(isNaN(v) || v < 0 || v > 1){
alert('Bitte Werte zwischen 0 und 1 eintragen (z. B. 0,4). (' + l + ', ' + r + ')\n\n'
+ 'Ein leeres Feld bedeutet: es gilt der Vorgabewert dieser Leistungsphase. '
+ 'Für "kein Aufwand" bitte ausdrücklich 0 eintragen.');
invalid = true;
break;
}
entry[r] = v;
rowHasValue = true;
}
if(invalid) return null;
if(rowHasValue){ aufwand[l] = entry; hasValue = true; }
}
return { aufwand, hasValue };
}
// applyLpRows: übernimmt das Ergebnis von collectLpRows auf ein Projekt.
// Die Maske ist mit den Werten der firmenweiten Vorlage vorbefüllt;
// Felder ohne Vorbefüllung, die leer bleiben, zählen bei gesetzter aktiver LP
// später als Standard-Faktor 1,0; ohne aktive LP zählt das Projekt 0.
// Die amtierende Leistungsphase wird separat über den Tab "Projekte" gesetzt.
function applyLpRows(p, lp){
if(!lp) return false;
p.aufwandProLP = lp.hasValue ? lp.aufwand : undefined;
return true;
}
// openLpModal: öffnet das Modal "Aufwand je Leistungsphase" für ein Projekt.
// Parameter: pid = Projekt-ID; gesperrte Wochen werden abgewiesen. Kein Rückgabewert.
function openLpModal(pid){
if(guardWeekLocked()) return;
const p = currentWeek().projects.find(x=>x.id===pid);
if(!p) return;
lpModalProjectId = pid;
const titleEl = document.getElementById('lpModalProject');
if(titleEl) titleEl.textContent = (p.nr ? p.nr + ' – ' : '') + (p.name || '');
const rowsEl = document.getElementById('lpModalRows');
if(rowsEl) rowsEl.innerHTML = lpRowsHtml(p, 'lp');
document.getElementById('lpModal').style.display = 'flex';
}
// closeLpModal: blendet das LP-Aufwand-Modal aus und setzt lpModalProjectId zurück.
function closeLpModal(){
document.getElementById('lpModal').style.display = 'none';
lpModalProjectId = null;
}
// saveLpModal: liest die LP-Maske aus, übernimmt sie auf das Projekt, speichert
// und rendert Board, Projekte und Auslastung neu; danach schließt sich das Modal.
function saveLpModal(){
if(guardWeekLocked()) return;
const p = currentWeek().projects.find(x=>x.id===lpModalProjectId);
if(!p) return;
const lp = collectLpRows('lp');
if(lp === null) return;
if(applyLpRows(p, lp)) save();
renderBoard(); renderProjects(); renderLoad();
closeLpModal();
}
// ---------- Neues Projekt (mit LP-Aufwand beim Anlegen) ----------
// Der Dialog "Neues Projekt" ist entfallen — openNewProjModal(),
// closeNewProjModal(), newProjStatusGewechselt() und saveNewProj() hatten
// danach keinen Aufrufer mehr. An seine Stelle tritt der Assistent in
// js/projekt-wizard.js (openNewProjWizard()), der in sechs Schritten führt
// und jeden davon erst nach vollständiger Eingabe freigibt. Der Grund für den
// Wechsel: Honorar, Team und Schlagwörter mussten zuvor NACH dem Anlegen an
// drei weiteren Stellen nachgetragen werden — und wurden es meist nicht.
//
// collectLpRows()/applyLpRows()/lpRowsHtml() bleiben: Sie tragen weiterhin
// die Aufwandsmaske openLpModal(), die aus dem Projektbereich aufgerufen wird.
// PROJEKT_FARBEN: Palette der Lastband-Segmente. Bewusst gedämpfte Mitteltöne
// und vom Ampelspektrum (grün/gelb/rot) getrennt — ein Segment sagt aus,
// WELCHES Projekt, nicht ob es gut oder schlecht steht; kräftige Ampelfarben
// im Band würden als zweite Wertung gelesen. Alle Töne tragen weiße Schrift
// mit mindestens 4,5:1 und stehen auf hellem wie dunklem Grund.
const PROJEKT_FARBEN = ['#4a6b8f','#6a5f96','#3f7a73','#8c6837','#77596a','#4c7550',
'#69697e','#8a5533','#3f6885','#77663c','#5b5f91','#48755e','#85504f'];
// projektFarbe: ordnet einem Projekt dauerhaft dieselbe Farbe zu. Über den
// Namen gehasht statt über die Position in einer Liste, weil die Auswertung
// über ALLE Sitzungen läuft und ein Projekt dort an wechselnden Stellen
// steht — eine Farbe, die je nach Sitzung springt, wäre schlimmer als gar
// keine. Zwei Projekte können sich eine Farbe teilen; sie stehen dann in
// derselben Zeile nebeneinander getrennt und tragen ihre Nummer.
// Parameter: name (Projektname). Rückgabe: Hex-Farbe.
function projektFarbe(name){
let h = 0;
const t = String(name || '');
for(let i = 0; i < t.length; i++) h = (h * 31 + t.charCodeAt(i)) >>> 0;
return PROJEKT_FARBEN[h % PROJEKT_FARBEN.length];
}
// renderLoad: berechnet die Belastung der Personen auf Basis des
// rollenspezifischen LP-Gewichtungsfaktors je amtierender Phase (0,0–1,0).
// Fehlt am Projekt ein Wert, gilt die firmenweite Vorlage (aufwandVorlage())
// für diese Phase/Rolle; ohne gesetzte aktive LP zählt das Projekt 0.
// Der ausgewiesene Wert ist auf die KAPAZITÄT der Person bezogen: Wer eine
// halbe Stelle hat, ist mit der halben Last schon voll ausgelastet
// (kapazitaetsFaktor(), gespeist aus users.capacity_hours). Ampel-Schwellen
// und Balkenskala sind firmenweit einstellbar (Bereich „Einstellungen").
// Mittelwert: geteilt wird durch die Zahl der Sitzungen, in denen die Person
// überhaupt im Teilnehmerkreis stand — nicht durch alle vorhandenen.
function renderLoad(){
const scope = document.getElementById('loadScope').value;
const selectedWeek = currentWeek();
const weeks = scope==='all' ? db.weeks : (selectedWeek ? [selectedWeek] : []);
if(weeks.length === 0){
document.getElementById('loadGrid').innerHTML = '<div class="hint">Keine Sitzung ausgewählt oder die Daten werden noch geladen.</div>';
return;
}
const grouped = scope==='all' ? peopleByRole(db.weeks) : peopleByRole(selectedWeek);
const stats = {};
const personen = {}; // fullName -> { person, stunden, rollen:Set }
ROLES.forEach(r=>{
grouped[r].forEach(entry=>{
stats[assignKey(entry.person.id, r)] = {stunden:0,projects:{},anbahnung:0,weeks:0};
});
});
// Je Person zählen, in wie vielen der ausgewerteten Sitzungen sie im
// Teilnehmerkreis stand. Ohne das wurde durch ALLE Sitzungen geteilt: Ein
// neuer Kollege, der seit zwei von zwanzig Sitzungen voll eingeplant ist,
// erschien mit 0,30 statt 3,00 — also als praktisch unbeschäftigt.
weeks.forEach(w => {
(Array.isArray(w.people) ? w.people : []).forEach(person => {
ROLES.forEach(r => {
const key = assignKey(person.id, r);
if(stats[key]) stats[key].weeks++;
});
});
});
// Nicht bewertbare Projekte werden MITGEZÄHLT statt übergangen: Ein Projekt
// ohne amtierende Phase, ohne Honorar und ohne Größe trug bisher still 0
// bei und war von echter Unterbeschäftigung nicht zu unterscheiden. Die
// Seite weist die Lücke jetzt oben aus.
const luecken = {}; // Projektname -> Grund
let bewertbareProjekte = 0, alleProjekte = 0;
weeks.forEach(w=>{
w.projects.forEach(pr=>{
if(pr.group === 'In Anbahnung') return;
alleProjekte++;
const aktivePhase = lpActiveList(pr)[0];
const probe = berechneAuslastung(null, pr, aktivePhase, ROLES[0], 1, null);
const projektName = pr.nr ? pr.nr + ' ' + pr.name : pr.name;
if(probe.unbewertbar){ luecken[projektName] = probe.grund; }
else bewertbareProjekte++;
// Vergebene Stunden je Projekt mitzählen: Ein Projekt kann bewertbar
// sein und trotzdem bei niemandem ankommen — etwa wenn es in LPH 8
// steht (die die Bauüberwachung trägt), aber nur ein Planer zugeordnet
// ist. Die Arbeit fällt an, wird aber niemandem zugerechnet; das ist
// eine Lücke und keine Null.
let vergeben = 0;
Object.keys(pr.assign).forEach(aKey=>{
const s = stats[aKey];
if(!s) return;
const st = pr.assign[aKey];
const rolle = aKey.slice(aKey.indexOf('|') + 1);
const al = berechneAuslastung(null, pr, aktivePhase, rolle, st, pr.assign);
if(al.anbahnung){
if(st===1 || st===2) s.anbahnung++;
return;
}
if(al.stunden > 0){
vergeben += al.stunden;
s.stunden += al.stunden;
// Der Stundenbeitrag des einzelnen Projekts wird behalten, nicht
// nur gezählt: Das Lastband zeigt die Summe aufgeteilt nach
// Projekten. Gerechnet wird nichts Zusätzliches — derselbe Wert,
// der ohnehin aufaddiert wird, wurde bisher nur verworfen.
const eintrag = s.projects[pr.name] || (s.projects[pr.name] = { anzahl:0, stunden:0, nr: pr.nr, quelle: al.quelle });
eintrag.anzahl++;
eintrag.stunden += al.stunden;
if(!eintrag.nr && pr.nr) eintrag.nr = pr.nr;
}
});
if(!probe.unbewertbar && !probe.fertig && vergeben === 0 && !luecken[projektName]){
// Welche Rolle trüge die Arbeit? Die mit dem höchsten Gewicht in der
// Phase, in der gerade gearbeitet wird.
const phase = arbeitsPhase(pr, aktivePhase);
const tragend = phase
? ROLES.slice().sort((a,b)=> rollenGewicht(pr, phase, b) - rollenGewicht(pr, phase, a))[0]
: null;
luecken[projektName] = tragend
? 'niemand für ' + tragend + ' zugeordnet (' + phase + ')'
: 'niemand zugeordnet';
bewertbareProjekte--;
}
});
});
// Wochenstunden: über die Sitzungen gemittelt, in denen die Person im
// Teilnehmerkreis stand.
const wochenStunden = k => stats[k].stunden / Math.max(1, stats[k].weeks || weeks.length);
// Kapazität einer Person in Stunden je Woche (aus der Benutzerverwaltung,
// sonst die firmenweite Vollzeit).
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
const kapazitaetStunden = person => kapazitaetsFaktor(person) * vollzeit;
// Summe je MENSCH über alle Rollen. Bisher fehlte sie: Wer in drei Rollen
// arbeitet, hat trotzdem nur eine Arbeitswoche — jede Rollenzeile blieb
// unter der Schwelle, die Überlastung wurde nirgends rot. Ein Mensch mit
// 0,80 Planung und 0,40 Projektleitung ist bei 1,20, und das gehört
// sichtbar an den Anfang.
const koepfe = {};
ROLES.forEach(r=>{
grouped[r].forEach(entry=>{
const schluessel = entry.person.fullName || entry.person.name;
const k = koepfe[schluessel] || (koepfe[schluessel] = { person: entry.person, stunden: 0, rollen: [] });
const std = wochenStunden(assignKey(entry.person.id, r));
k.stunden += std;
if(std > 0) k.rollen.push(r);
});
});
let html = "";
// --- Kopf: was die Zahlen abdecken ---
const luckenNamen = Object.keys(luecken);
const ueberlastet = Object.values(koepfe).filter(k => k.stunden / kapazitaetStunden(k.person) >= schwelleRot());
html += `<div class="load-kopf">
<div class="load-kopf-zahl">
<strong>${bewertbareProjekte}</strong> von ${alleProjekte} Projekten bewertet
</div>
${ueberlastet.length ? `<div class="load-kopf-warnung">${ueberlastet.length} ${ueberlastet.length === 1 ? 'Person ist' : 'Personen sind'} über der Kapazität</div>` : ''}
${luckenNamen.length ? `<button type="button" class="secondary small" onclick="toggleLoadLuecken()">${luckenNamen.length} nicht bewertbar…</button>` : ''}
</div>`;
if(luckenNamen.length){
html += `<div class="load-luecken" id="loadLuecken" style="display:none">
<p class="hint" style="margin:0 0 8px">Diese Projekte fließen in keine Auslastung ein. Sie erscheinen nicht als Null, sondern hier — eine fehlende Angabe ist keine fehlende Arbeit.</p>
${luckenNamen.sort().map(n => `<div class="load-luecke"><span>${esc(n)}</span><b>${esc(luecken[n])}</b></div>`).join('')}
</div>`;
}
// --- Summe je Mensch ---
const kopfListe = Object.values(koepfe)
.filter(k => k.stunden > 0)
.sort((a,b)=> b.stunden / kapazitaetStunden(b.person) - a.stunden / kapazitaetStunden(a.person));
if(kopfListe.length){
html += `<div class="load-rolle"><div class="load-rolle-titel">Gesamt je Person</div>`;
kopfListe.forEach(k=>{
const kap = kapazitaetStunden(k.person);
const quote = kap > 0 ? k.stunden / kap : 0;
const ampel = loadAmpelColor(quote);
const pct = Math.min(100, quote * 100);
html += `<div class="load-zeile load-zeile-summe">
<div class="load-wer">
<div class="load-wer-name">${esc(k.person.fullName || k.person.name)}</div>
<div class="load-wer-meta">${esc(k.rollen.join(' · ') || '—')}</div>
</div>
<div class="load-band" title="Kapazität: ${stundenFormat(kap)} h/Woche">
<div class="load-seg load-seg-summe" style="width:${pct}%;background:${ampel}"></div>
<div class="load-kapazitaet" title="Volle Kapazität"></div>
</div>
<div class="load-wert" style="color:${ampel}">${stundenFormat(k.stunden)}<span class="load-einheit">h</span></div>
</div>`;
});
html += `</div>`;
}
// --- Je Rolle ---
const benutzteProjekte = {};
ROLES.forEach(r=>{
html += `<div class="load-rolle"><div class="load-rolle-titel">${r}</div>`;
if(grouped[r].length===0){
html += `<div class="hint">Keine Personen.</div></div>`;
return;
}
const sortiert = grouped[r].slice().sort((a,b)=>
wochenStunden(assignKey(b.person.id, r)) - wochenStunden(assignKey(a.person.id, r)));
sortiert.forEach(entry=>{
const s = stats[assignKey(entry.person.id, r)];
const kap = kapazitaetStunden(entry.person);
const nWeeks = Math.max(1, s.weeks || weeks.length);
const std = wochenStunden(assignKey(entry.person.id, r));
const quote = kap > 0 ? std / kap : 0;
// Ohne jede Zuordnung KEINE Ampelfarbe: Grün hieße "gut ausgelastet",
// die Null heißt aber "nichts zugeordnet".
const ampel = std > 0 ? loadAmpelColor(quote) : 'var(--text-subtle)';
const stelleBadge = Math.abs(kapazitaetsFaktor(entry.person) - 1) < 0.005 ? ''
: `<span class="load-capacity" title="Vertragliche Wochenstunden aus der Benutzerverwaltung">${stundenFormat(kap)} h/Woche</span>`;
const anbahnungBadge = (s.anbahnung && s.anbahnung > 0)
? `<span class="load-anbahnung">${s.anbahnung} in Anbahnung</span>` : '';
// Segmente: je Projekt eines, Breite als Anteil der KAPAZITÄT. Die
// Skala ist damit nicht mehr willkürlich (früher fest 8, während die
// Ampel bei 1,0 rot wurde — ein roter Balken war zu 12,5 % gefüllt),
// sondern: volles Band = volle Woche.
const namen = Object.keys(s.projects).sort((a,b)=> s.projects[b].stunden - s.projects[a].stunden);
const segmente = namen.map(name=>{
const eintrag = s.projects[name];
const anteilStd = eintrag.stunden / nWeeks;
const breite = kap > 0 ? Math.min(100, anteilStd / kap * 100) : 0;
const farbe = projektFarbe(name);
if(!benutzteProjekte[name]) benutzteProjekte[name] = { nr: eintrag.nr, farbe, quelle: eintrag.quelle };
const titel = (eintrag.nr ? eintrag.nr + ' · ' : '') + name
+ ' · ' + stundenFormat(anteilStd) + ' h/Woche'
+ (eintrag.quelle === 'groesse' ? ' · aus der Projektgröße' : '')
+ (eintrag.anzahl > 1 ? ' · in ' + eintrag.anzahl + ' Sitzungen' : '');
const beschriftung = breite >= 7 ? `<span>${esc(eintrag.nr || name)}</span>` : '';
return `<div class="load-seg" style="width:${breite}%;background:${farbe}" title="${esc(titel)}">${beschriftung}</div>`;
}).join('');
html += `<div class="load-zeile">
<div class="load-wer">
<div class="load-wer-name">${esc(entry.person.fullName || entry.person.name)}</div>
<div class="load-wer-meta">${esc(entry.person.name)}${stelleBadge}${anbahnungBadge}</div>
</div>
<div class="load-band" title="Volles Band = ${stundenFormat(kap)} h/Woche">
${segmente || '<span class="load-band-leer">keine Zuordnung</span>'}
</div>
<div class="load-wert" style="color:${ampel}">${stundenFormat(std)}<span class="load-einheit">h</span></div>
</div>`;
});
html += `</div>`;
});
const legende = Object.keys(benutzteProjekte).sort().map(name=>{
const e = benutzteProjekte[name];
return `<span class="load-legende-item"><i style="background:${e.farbe}"></i>${esc(e.nr ? e.nr + ' ' + name : name)}${e.quelle === 'groesse' ? '<em>≈</em>' : ''}</span>`;
}).join('');
if(legende){
const hatSchaetzung = Object.values(benutzteProjekte).some(e => e.quelle === 'groesse');
html += `<div class="load-legende">${legende}</div>`;
if(hatSchaetzung) html += `<p class="hint" style="margin:8px 0 0">≈ aus der Projektgröße gerechnet, nicht aus einem hinterlegten Honorar.</p>`;
}
document.getElementById('loadGrid').innerHTML=html;
}