// ----------------------------- // 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, LOAD_BAR_MAX) 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 (LPH 1–8; "W+M" ist bewusst nicht // enthalten, entspricht der Entscheidung "LP-Schema: lp1-8"). const AUFWAND_LPS = LPHS.slice(0, 8); // 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 } }; // 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; // lpActiveList: liefert die amtierenden Leistungsphasen eines Projekts, // gefiltert auf das Aufwandsmodell (LPH 1–8). 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; } // 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 }; } // 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 relativ zum Maximalwert (gelb ab 50 %, // rot ab 75 % von LOAD_BAR_MAX; PLATZHALTER). function loadAmpelColor(x){ return x >= LOAD_BAR_MAX * THRESHOLD_RATIO_CRITICAL ? '#c53030' : x >= LOAD_BAR_MAX * THRESHOLD_RATIO_ELEVATED ? '#d69e2e' : '#2f855a'; } // 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'; } // ---------- 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 (LPH 1–8), 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. // 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 = LP_AUFWAND_VORLAGE && LP_AUFWAND_VORLAGE[l] ? LP_AUFWAND_VORLAGE[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 ``; }).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; const v = parseFloat(raw); 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 + ')'); 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 Standardwerten (LP_AUFWAND_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) ---------- // 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 = '' + AUFWAND_LPS.map(l => ``).join(''); lpSel.value = ''; } const rowsEl = document.getElementById('newProjLpRows'); if(rowsEl) rowsEl.innerHTML = lpRowsHtml({ aufwandProLP: {}, aktuelleLP: [] }, 'np'); document.getElementById('newProjModal').style.display = 'flex'; } // closeNewProjModal: blendet das Neues-Projekt-Modal aus. function closeNewProjModal(){ document.getElementById('newProjModal').style.display = 'none'; } // 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(); } // 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). 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){ 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 = {}; ROLES.forEach(r=>{ grouped[r].forEach(entry=>{ stats[assignKey(entry.person.id, r)] = {load:0,projects:{},anbahnung:0}; }); }); weeks.forEach(w=>{ w.projects.forEach(pr=>{ const aktivePhase = lpActiveList(pr)[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); 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; } }); }); }); // 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=""; ROLES.forEach(r=>{ html += `
${r}
`; if(grouped[r].length===0){ html += `
Keine Personen.
`; } grouped[r].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=>`
${esc(k)}${s.projects[k]}×
`).join('') || '
keine Zuordnung
'; const anbahnungBadge = (s.anbahnung && s.anbahnung>0) ? `(${s.anbahnung} in Anbahnung)` : ''; html += `

${esc(entry.person.fullName)} (${r})

${anbahnungBadge}

${faktorFormat(avg)}
${detail}
`; }); html += `
`; }); document.getElementById('loadGrid').innerHTML=html; }