// ----------------------------- // 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 => `
| Leistungsphase | ${head}
|---|
≈ aus der Projektgröße gerechnet, nicht aus einem hinterlegten Honorar.
`; } document.getElementById('loadGrid').innerHTML=html; }