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

+542 -128
View File
@@ -8,7 +8,7 @@
// 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, LOAD_BAR_MAX) wird zusätzlich von js/pdf.js für den
// pdfLoadAmpelColor, stundenFormat) wird zusätzlich von js/pdf.js für den
// PDF-Export wiederverwendet.
//
// Datenmodell je Projekt (rollen- und leistungsphasenspezifisch):
@@ -35,34 +35,61 @@
// fachlich abgestimmt werden.
// - Mitarbeiterkapazität (Teilzeit/Vollzeit) existiert nicht – offener Punkt.
// AUFWAND_LPS: Phasen des Aufwandsmodells (LPH 1–8; "W+M" ist bewusst nicht
// enthalten, entspricht der Entscheidung "LP-Schema: lp1-8").
const AUFWAND_LPS = LPHS.slice(0, 8);
// 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');
// LP_AUFWAND_VORLAGE: Standardwerte der Maske "Aufwand je Leistungsphase".
// Vorbefüllung beim Anlegen neuer Projekte sowie beim Öffnen der Maske für
// bestehende Projekte, die in einzelnen Feldern keine Werte haben – beim
// Speichern werden sie übernommen. Werte 0.0–1.0 je Rolle; 0 = kein Aufwand.
// Nicht gelistete Phasen/Rollen bleiben leer und zählen als Standard-Faktor 1,0.
// Hier im Code anpassen.
const LP_AUFWAND_VORLAGE = {
'LPH 1': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 },
'LPH 2': { Planung: 0.4, Projektleitung: 0.2, Bauüberwachung: 0 },
'LPH 3': { Planung: 1.0, Projektleitung: 0.3, Bauüberwachung: 0 },
'LPH 4': { Planung: 0.0, Projektleitung: 0.3, Bauüberwachung: 0 },
'LPH 5': { Planung: 1.0, Projektleitung: 0.4, Bauüberwachung: 0 },
'LPH 6': { Planung: 0.2, Projektleitung: 0.7, Bauüberwachung: 0.3 },
'LPH 7': { Planung: 0.1, Projektleitung: 0.3, Bauüberwachung: 0.5 },
'LPH 8': { Planung: 0, Projektleitung: 0.2, Bauüberwachung: 1.0 }
};
// 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) || {};
}
// PLATZHALTER! Ampel-Schwellen als Anteil des Maximalwerts LOAD_BAR_MAX
// (gelb ab 50 %, rot ab 75 %). Müssen vom Fachplaner bestätigt werden.
const THRESHOLD_RATIO_CRITICAL = 0.75;
const THRESHOLD_RATIO_ELEVATED = 0.50;
// 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 (LPH 1–8). Parameter: pr = Projektobjekt.
// 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;
@@ -82,19 +109,239 @@ function aufwandMatrixWert(projekt, lp, rolle){
return isNaN(v) ? null : v;
}
// berechneAuslastung: zentrale Auslastungsberechnung einer Zuordnung
// (Person/Rolle) zu einem Projekt für die amtierende Leistungsphase.
// Ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet).
// Mit aktiver LP: Liegt aufwandProLP[aktuelleLP][rolle] vor, zählt dieser
// Faktor 1:1 – die Zuordnungsart (Haupt/Optional) skaliert bewusst nicht mehr.
// Fehlt der Matrix-Wert, gilt der Standard-Faktor 1,0.
// "In Anbahnung" wird nicht eingerechnet.
function berechneAuslastung(mitarbeiter, projekt, aktuelleLP, rolle, status){
if(!projekt || projekt.group === 'In Anbahnung') return { load: 0, anbahnung: true };
if(!aktuelleLP) return { load: 0 };
const wert = aufwandMatrixWert(projekt, aktuelleLP, rolle);
if(wert !== null) return { load: wert };
return { load: 1.0 };
// ---------- 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
@@ -104,15 +351,23 @@ function faktorFormat(x){
return n.toFixed(2).replace('.', ',');
}
// loadAmpelColor: Ampel-Farbe relativ zum Maximalwert (gelb ab 50 %,
// rot ab 75 % von LOAD_BAR_MAX; PLATZHALTER).
// 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){
return x >= LOAD_BAR_MAX * THRESHOLD_RATIO_CRITICAL ? '#c53030' : x >= LOAD_BAR_MAX * THRESHOLD_RATIO_ELEVATED ? '#d69e2e' : '#2f855a';
// 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 >= LOAD_BAR_MAX * THRESHOLD_RATIO_CRITICAL ? '#D70015' : x >= LOAD_BAR_MAX * THRESHOLD_RATIO_ELEVATED ? '#B25000' : '#248A3D';
return x >= schwelleRot() ? '#D70015' : x >= schwelleGelb() ? '#B25000' : '#248A3D';
}
// ---------- LP-Aufwand Eingabemaske ----------
@@ -121,11 +376,11 @@ function pdfLoadAmpelColor(x){
let lpModalProjectId = null;
// lpRowsHtml: erzeugt die Aufwand-Maske als Matrix-Tabelle.
// Zeilen = Leistungsphasen (LPH 1–8), Spalten = Rollen (Planung/Projektleitung/
// 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 Standardwerten aus LP_AUFWAND_VORLAGE vorbefüllt.
// 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('');
@@ -134,11 +389,14 @@ function lpRowsHtml(p, prefix){
const row = (p.aufwandProLP && typeof p.aufwandProLP[l] === 'object') ? p.aufwandProLP[l] : {};
const cells = ROLES.map((r, ri) => {
const raw = row[r];
const vorlage = LP_AUFWAND_VORLAGE && LP_AUFWAND_VORLAGE[l] ? LP_AUFWAND_VORLAGE[l][r] : undefined;
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('.', ',') : '';
return `<td><input type="text" inputmode="decimal" id="${prefix}Val${idx}R${ri}" value="${val}" placeholder="1,0"></td>`;
// 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('');
@@ -164,9 +422,13 @@ function collectLpRows(prefix){
if(!input) continue;
const raw = input.value.trim().replace(',', '.');
if(raw === '') continue;
const v = parseFloat(raw);
// 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) – oder das Feld leer lassen. (' + l + ', ' + r + ')');
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;
}
@@ -180,7 +442,7 @@ function collectLpRows(prefix){
}
// applyLpRows: übernimmt das Ergebnis von collectLpRows auf ein Projekt.
// Die Maske ist mit den Standardwerten (LP_AUFWAND_VORLAGE) vorbefüllt;
// 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.
@@ -225,66 +487,51 @@ function saveLpModal(){
// ---------- Neues Projekt (mit LP-Aufwand beim Anlegen) ----------
// openNewProjModal: öffnet das Modal "Neues Projekt" mit geleerten Eingaben,
// der Auswahl der aktiven LP und einer leeren Aufwand-Maske (prefix 'np').
function openNewProjModal(){
if(guardWeekLocked()) return;
document.getElementById('newProjNr').value = '';
document.getElementById('newProjName').value = '';
document.getElementById('newProjGroup').value = 'Aktive Projekte';
const lpSel = document.getElementById('newProjActiveLP');
if(lpSel){
lpSel.innerHTML = '<option value="">Keine</option>' + AUFWAND_LPS.map(l => `<option value="${l}">${l}</option>`).join('');
lpSel.value = '';
}
const rowsEl = document.getElementById('newProjLpRows');
if(rowsEl) rowsEl.innerHTML = lpRowsHtml({ aufwandProLP: {}, aktuelleLP: [] }, 'np');
document.getElementById('newProjModal').style.display = 'flex';
}
// 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.
// closeNewProjModal: blendet das Neues-Projekt-Modal aus.
function closeNewProjModal(){
document.getElementById('newProjModal').style.display = 'none';
}
// 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'];
// saveNewProj: legt ein neues Projekt an (Nr., Name, Gruppe, aktive LP,
// optionaler LP-Aufwand), berechnet seine Reihenfolge in der Gruppe, speichert
// und rendert Projekte und Board neu. Kein Rückgabewert.
function saveNewProj(){
if(guardWeekLocked()) return;
const nr = document.getElementById('newProjNr').value.trim();
const name = document.getElementById('newProjName').value.trim();
if(!name){ alert("Bitte Projektbezeichnung eingeben."); return; }
const group = document.getElementById('newProjGroup').value;
const activeLP = document.getElementById('newProjActiveLP').value;
const lp = collectLpRows('np');
if(lp === null) return;
const w = currentWeek();
const nextOrder = w.projects
.filter(p=>p.group===group)
.reduce((max,p)=> Math.max(max, p.order!==undefined ? p.order : 0), -1) + 1;
const pr = {id:uid(),nr,name,group,status:'',lph:{},assign:{}, statusState: undefined, order: nextOrder, plan: null};
if(activeLP) pr.aktuelleLP = [activeLP];
applyLpRows(pr, lp);
w.projects.push(pr);
save(); renderProjects(); renderBoard();
closeNewProjModal();
// 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];
}
// LOAD_BAR_MAX: Maximalwert der Balken-Skala im Auslastungs-Tab (und im
// PDF-Export). 1,0 = 100 % Kapazität; Werte > 1,0 = Überlastung (rot).
const LOAD_BAR_MAX = 8;
// renderLoad: berechnet die Belastung der Personen auf Basis des
// rollenspezifischen LP-Gewichtungsfaktors je amtierender Phase (0,0–1,0,
// Standard 1,0). Leere Matrix-Werte zählen bei gesetzter aktiver LP als 1,0;
// ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet).
// Ampel: Schwellen relativ zum Maximalwert LOAD_BAR_MAX
// (gelb ab 50 %, rot ab 75 % – PLATZHALTER).
// Balken: Skala auf das feste Maximum LOAD_BAR_MAX (Standard 8).
// 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 barMax = LOAD_BAR_MAX;
const selectedWeek = currentWeek();
const weeks = scope==='all' ? db.weeks : (selectedWeek ? [selectedWeek] : []);
if(weeks.length === 0){
@@ -293,60 +540,227 @@ function renderLoad(){
}
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)] = {load:0,projects:{},anbahnung:0};
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);
const al = berechneAuslastung(null, pr, aktivePhase, rolle, st, pr.assign);
if(al.anbahnung){
if(st===1 || st===2) s.anbahnung++;
return;
}
if(al.load > 0){
s.load += al.load;
s.projects[pr.name] = (s.projects[pr.name]||0)+1;
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--;
}
});
});
// Hochgerechnete Auslastung: durchschnittliche Wochenlast als Faktor
// der Kapazität (1,0 = 100 %). Werte > 1,0 = Überlastung.
const nWeeks = Math.max(1, weeks.length);
const weekAvg = k => stats[k].load / nWeeks;
let html="";
// 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=>{
html += `<div class="load-row"><div class="load-row-title">${r}</div>`;
if(grouped[r].length===0){
html += `<div class="hint">Keine Personen.</div>`;
}
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 avg = weekAvg(assignKey(entry.person.id, r));
// Balken relativ zur einstellbaren Skala (Standard 8); Überlastung > 1,0
// bleibt über die Ampel-Farbe sichtbar, der Zahlenwert bleibt ungedeckelt.
const pct = Math.min(100, Math.round(avg / barMax * 100));
const detail = Object.keys(s.projects).map(k=>`<div><span>${esc(k)}</span><b>${s.projects[k]}×</b></div>`).join('') || '<div class="hint">keine Zuordnung</div>';
const anbahnungBadge = (s.anbahnung && s.anbahnung>0) ? `<span style="font-size:11px;color:#3182ce;font-weight:normal;margin-left:0px">(${s.anbahnung} in Anbahnung)</span>` : '';
html += `<div class="load-card">
<h3>${esc(entry.person.fullName)} <span style="font-size:11px;color:#888;font-weight:normal">(${r})</span></h3>
<h3>${anbahnungBadge}</h3>
<div class="load-num">${faktorFormat(avg)}</div>
<div class="bar" style="height:10px;margin-top:6px" title="Skala: 0 – ${faktorFormat(barMax)}"><div style="width:${pct}%;background:${loadAmpelColor(avg)}"></div></div>
<div class="load-detail">${detail}</div>
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;
}