// ----------------------------- // 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 => `${esc(r)}`).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 ``; }).join(''); return `${l}${cells}`; }).join(''); return `${head}${rows}
Leistungsphase
`; } // 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 = '
Keine Sitzung ausgewählt oder die Daten werden noch geladen.
'; 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 += `
${bewertbareProjekte} von ${alleProjekte} Projekten bewertet
${ueberlastet.length ? `
${ueberlastet.length} ${ueberlastet.length === 1 ? 'Person ist' : 'Personen sind'} über der Kapazität
` : ''} ${luckenNamen.length ? `` : ''}
`; if(luckenNamen.length){ html += ``; } // --- 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 += `
Gesamt je Person
`; 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 += `
${esc(k.person.fullName || k.person.name)}
${esc(k.rollen.join(' · ') || '—')}
${stundenFormat(k.stunden)}h
`; }); html += `
`; } // --- Je Rolle --- const benutzteProjekte = {}; ROLES.forEach(r=>{ html += `
${r}
`; if(grouped[r].length===0){ html += `
Keine Personen.
`; 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 ? '' : `${stundenFormat(kap)} h/Woche`; const anbahnungBadge = (s.anbahnung && s.anbahnung > 0) ? `${s.anbahnung} in Anbahnung` : ''; // 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 ? `${esc(eintrag.nr || name)}` : ''; return `
${beschriftung}
`; }).join(''); html += `
${esc(entry.person.fullName || entry.person.name)}
${esc(entry.person.name)}${stelleBadge}${anbahnungBadge}
${segmente || 'keine Zuordnung'}
${stundenFormat(std)}h
`; }); html += ``; }); const legende = Object.keys(benutzteProjekte).sort().map(name=>{ const e = benutzteProjekte[name]; return `${esc(e.nr ? e.nr + ' ' + name : name)}${e.quelle === 'groesse' ? '≈' : ''}`; }).join(''); if(legende){ const hatSchaetzung = Object.values(benutzteProjekte).some(e => e.quelle === 'groesse'); html += `
${legende}
`; if(hatSchaetzung) html += `

≈ aus der Projektgröße gerechnet, nicht aus einem hinterlegten Honorar.

`; } document.getElementById('loadGrid').innerHTML=html; }