Auslastungsberechung angepasst

This commit is contained in:
erik committed 2026-08-13 14:39:07 +02:00
1 parent eb71a9160f
commit eeb2e63f89
12 files changed
+646 -297

No files matched your search

+334 -89
View File
@@ -610,9 +610,9 @@ function weekToCsv(w){
// Der volle Name wird immer als dritter Teil gespeichert (auch leer),
// damit er beim Bearbeiten nicht versehentlich verloren geht.
const peopleMeta = allPeople.map(e => `${e.person.name}|${e.role}|${e.person.fullName || ''}`);
rows.push(['PERSONEN', '', '', ...peopleMeta, ...LPHS.map(() => ''), '','']);
rows.push(['Pr.-Nr.','Bezeichnung','Status', ...allPeople.map(e => e.person.name), ...LPHS, 'Gruppe','Index','Plan']);
rows.push(['ANWESENHEIT','', ...allPeople.map(e => w.attendance && w.attendance[e.person.id] ? 'X' : ''), ...LPHS.map(() => ''),'','','']);
rows.push(['PERSONEN', '', '', ...peopleMeta, ...LPHS.map(() => ''), '','','','','']);
rows.push(['Pr.-Nr.','Bezeichnung','Status', ...allPeople.map(e => e.person.name), ...LPHS, 'Gruppe','Index','Plan','Aufwand','Aktive Phasen']);
rows.push(['ANWESENHEIT','', ...allPeople.map(e => w.attendance && w.attendance[e.person.id] ? 'X' : ''), ...LPHS.map(() => ''),'','','','','']);
w.projects.forEach(p => {
const arr = [p.nr || '', p.name || '', p.status || ''];
allPeople.forEach(entry => {
@@ -626,6 +626,8 @@ function weekToCsv(w){
arr.push(p.group || '');
arr.push(p.order !== undefined ? String(p.order) : '');
arr.push(p.plan ? JSON.stringify(p.plan) : '');
arr.push(p.aufwandProLP ? JSON.stringify(p.aufwandProLP) : '');
arr.push(p.aktuelleLP ? JSON.stringify(p.aktuelleLP) : '');
rows.push(arr);
});
return '\ufeff' + rows.map(r => r.map(csvEscape).join(';')).join('\n');
@@ -736,6 +738,8 @@ function parseSessionCsv(filename, rows){
const groupIdx = header.indexOf('Gruppe');
const orderIdx = header.indexOf('Index');
const planIdx = header.indexOf('Plan');
const aufwandIdx = header.indexOf('Aufwand');
const aktiveIdx = header.indexOf('Aktive Phasen');
const peopleCols = header.slice(3, lphStart === -1 ? header.length : lphStart);
const people = [];
@@ -831,7 +835,28 @@ function parseSessionCsv(filename, rows){
}
}
projects.push({ id: uid(), nr, name, status, group, lph, assign, order, plan });
let aufwandProLP;
if(aufwandIdx >= 0 && row[aufwandIdx]){
try {
const parsed = JSON.parse(row[aufwandIdx]);
if(parsed && typeof parsed === 'object') aufwandProLP = parsed;
} catch(e) {
console.warn('LP-Aufwand für Projekt konnten nicht geparst werden:', nr, name, e);
}
}
let aktuelleLP;
if(aktiveIdx >= 0 && row[aktiveIdx]){
try {
const parsed = JSON.parse(row[aktiveIdx]);
if(Array.isArray(parsed)) aktuelleLP = parsed;
else if(typeof parsed === 'string') aktuelleLP = [parsed];
} catch(e) {
console.warn('Aktive Phasen für Projekt konnten nicht geparst werden:', nr, name, e);
}
}
projects.push({ id: uid(), nr, name, status, group, lph, assign, order, plan, aufwandProLP, aktuelleLP });
}
const attendance = {};
@@ -896,7 +921,7 @@ function seed(){
// demoProj: Helfer zum Erzeugen eines Projekt-Objekts (für seed/Tests)
function demoProj(nr,name,group,status,lph){
return {id:uid(), nr, name, group, status, lph: {...lph}, assign:{}, plan: null};
return {id:uid(), nr, name, group, status, lph: {...lph}, assign:{}, plan: null, aufwandProLP: undefined, aktuelleLP: undefined};
}
// ---------- Week management ----------
@@ -1173,11 +1198,14 @@ function renderBoard(){
body += `<td class="person-cell ${roleClass(entry.role)} ${cls} ${locked ? 'locked-cell' : ''}" ${click}>${txt}</td>`;
});
// LPH-Spalten mit Prozentwerten und Fortschrittsbalken
// LPH-Spalten mit Prozentwerten und Fortschrittsbalken;
// die amtierende Leistungsphase (Tab "Projekte") wird farblich markiert.
const aktLp = lpActiveList(p)[0];
LPHS.forEach(l=>{
const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : "";
const aktiv = l === aktLp;
const inputAttr = locked ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`;
body += `<td class="lph-cell ${locked ? 'locked-cell' : ''}"><input type="text" value="${v}" ${inputAttr} placeholder="-">`;
body += `<td class="lph-cell ${locked ? 'locked-cell' : ''}${aktiv ? ' lp-aktiv' : ''}"${aktiv ? ' title="Aktive Leistungsphase"' : ''}><input type="text" value="${v}" ${inputAttr} placeholder="-">`;
if(v!=""){ const pct = Math.min(100, parseFloat(v)||0);
body += `<div class="bar"><div style="width:${pct}%;background:${getProgressColor(pct)}"></div></div>`; }
body += `</td>`;
@@ -1611,23 +1639,6 @@ function reorderPeople(){
// ---------- Projekte (Listen-Ansicht) ----------
// addProjectToList: fügt ein Projekt über Eingabefelder in die aktuelle Sitzung
function addProjectToList(){
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 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;
w.projects.push({id:uid(),nr,name,group,status:'',lph:{},assign:{}, statusState: undefined, order: nextOrder, plan: null});
document.getElementById('newProjNr').value='';
document.getElementById('newProjName').value='';
save(); renderProjects(); renderBoard();
}
// delProjectFromList: löscht ein Projekt in der Listen-Ansicht
function delProjectFromList(id){
if(guardWeekLocked()) return;
@@ -1644,6 +1655,16 @@ function editProjectField(id,field,val){
if(p){ p[field]=val; save(); renderProjects(); renderBoard(); }
}
// editProjectActiveLP: setzt/hebt die amtierende Leistungsphase eines Projekts auf.
function editProjectActiveLP(id, value){
if(guardWeekLocked()) return;
const p = currentWeek().projects.find(x=>x.id===id);
if(!p) return;
if(value) p.aktuelleLP = [value];
else delete p.aktuelleLP;
save(); renderProjects(); renderBoard(); renderLoad();
}
// renderProjects: baut die HTML-Liste der Projekte (gruppiert nach group, mit Drag-and-Drop)
function renderProjects(){
const w = currentWeek();
@@ -1669,6 +1690,11 @@ function renderProjects(){
<option ${p.group==='In Anbahnung'?'selected':''}>In Anbahnung</option>
<option ${p.group==='Allgemein'?'selected':''}>Allgemein</option>
</select>
<select onchange="editProjectActiveLP('${p.id}',this.value)" title="Aktive Leistungsphase wählen">
<option value="" ${lpActiveList(p).length===0?'selected':''}>Aktive LP: keine</option>
${AUFWAND_LPS.map(l => `<option value="${l}" ${lpActiveList(p)[0]===l?'selected':''}>${l}</option>`).join('')}
</select>
<button class="secondary small" onclick="openLpModal('${p.id}')">✎ LP-Aufwand</button>
<button class="danger small" onclick="delProjectFromList('${p.id}')">Löschen</button>
</div>`;
});
@@ -1746,10 +1772,246 @@ function reorderProjects(){
// ---------- Auslastung (Load) ----------
// renderLoad: berechnet die Belastung der Personen (Haupt=1, Optional=0,5)
// und zeigt eine einfache Visualisierung pro Rolle
// ---------- Aufwand je Leistungsphase (LP) ----------
//
// 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"). Es gibt keine
// Default-Vorlage: neue Projekte starten leer und zählen bei gesetzter aktiver
// LP je Rolle zu 1,0; ohne aktive LP zu 0.
const AUFWAND_LPS = LPHS.slice(0, 8);
// 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;
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';
}
function pdfLoadAmpelColor(x){
return x >= LOAD_BAR_MAX * THRESHOLD_RATIO_CRITICAL ? '#D70015' : x >= LOAD_BAR_MAX * THRESHOLD_RATIO_ELEVATED ? '#B25000' : '#248A3D';
}
// ---------- LP-Aufwand Eingabemaske ----------
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).
// Die Feld-IDs bleiben identisch, damit collectLpRows() unverändert funkioniert.
function lpRowsHtml(p, prefix){
const head = ROLES.map(r => `<th>${esc(r)}</th>`).join('');
const rows = AUFWAND_LPS.map(l => {
const idx = l.replace(/[^\d]/g,'');
const row = (p.aufwandProLP && typeof p.aufwandProLP[l] === 'object') ? p.aufwandProLP[l] : {};
const cells = ROLES.map((r, ri) => {
const raw = row[r];
const val = (raw !== undefined && raw !== null && raw !== '')
? String(parseFloat(raw)).replace('.', ',') : '';
return `<td><input type="text" inputmode="decimal" id="${prefix}Val${idx}R${ri}" value="${val}" placeholder="1,0"></td>`;
}).join('');
return `<tr><th class="lp-lph">${l}</th>${cells}</tr>`;
}).join('');
return `<table class="lp-matrix">
<thead><tr><th class="lp-lph">Leistungsphase</th>${head}</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
// collectLpRows: liest die Aufwand-Maske aus und validiert die Werte (0–1).
// Rückgabe: { aufwand, hasValue } oder null bei Eingabefehler.
function collectLpRows(prefix){
const aufwand = {};
let hasValue = false;
for(const l of AUFWAND_LPS){
const idx = l.replace(/[^\d]/g,'');
const entry = {};
let rowHasValue = false;
let invalid = false;
for(let ri = 0; ri < ROLES.length; ri++){
const r = ROLES[ri];
const input = document.getElementById(prefix + 'Val' + idx + 'R' + ri);
if(!input) continue;
const raw = input.value.trim().replace(',', '.');
if(raw === '') continue;
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.
// Ohne gepflegte Rollen-Matrix (leer) zählt bei gesetzter aktiver LP später der
// 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;
}
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';
}
function closeLpModal(){
document.getElementById('lpModal').style.display = 'none';
lpModalProjectId = null;
}
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) ----------
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';
}
function closeNewProjModal(){
document.getElementById('newProjModal').style.display = 'none';
}
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){
@@ -1760,41 +2022,35 @@ function renderLoad(){
const stats = {};
ROLES.forEach(r=>{
grouped[r].forEach(entry=>{
stats[assignKey(entry.person.id, r)] = {main:0,opt:0,kleine:0,projects:{},anbahnung:0};
stats[assignKey(entry.person.id, r)] = {load:0,projects:{},anbahnung:0};
});
});
weeks.forEach(w=>{
w.projects.forEach(pr=>{
const isAnbahnung = pr.group === 'In Anbahnung';
const isKleine = pr.group === 'Kleine Projekte';
const aktivePhase = lpActiveList(pr)[0];
Object.keys(pr.assign).forEach(aKey=>{
if(!stats[aKey]) return;
if(isAnbahnung){
if(pr.assign[aKey]===1 || pr.assign[aKey]===2) stats[aKey].anbahnung++;
return;
}
const s = stats[aKey];
if(!s) return;
const st = pr.assign[aKey];
if(isKleine){
if(st===1 || st===2){
stats[aKey].kleine = (stats[aKey].kleine||0) + 1;
const key = pr.name;
stats[aKey].projects[key]=(stats[aKey].projects[key]||0)+1;
}
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(st===1) stats[aKey].main++;
else if(st===2) stats[aKey].opt++;
const key = pr.name;
stats[aKey].projects[key]=(stats[aKey].projects[key]||0)+1;
if(al.load > 0){
s.load += al.load;
s.projects[pr.name] = (s.projects[pr.name]||0)+1;
}
});
});
});
// Bestimme Maximalwert für Skalierung
let maxLoad=1;
Object.keys(stats).forEach(k=>{ const l=stats[k].main+stats[k].opt*0.5+(stats[k].kleine||0)*0.3; if(l>maxLoad)maxLoad=l; });
// 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 += `<div class="load-row"><div class="load-row-title">${r}</div>`;
@@ -1803,15 +2059,17 @@ function renderLoad(){
}
grouped[r].forEach(entry=>{
const s = stats[assignKey(entry.person.id, r)];
const load = s.main + s.opt*0.5 + (s.kleine||0)*0.3;
const pct = Math.round(load/maxLoad*100);
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">${load.toString().replace('.',',')}</div>
<div class="bar" style="height:10px;margin-top:6px"><div style="width:${pct}%;background:${pct>75?'#c53030':pct>45?'#d69e2e':'#2f855a'}"></div></div>
<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>
</div>`;
});
@@ -2584,71 +2842,58 @@ function pdfAttendanceBlock(w){
function pdfLoadSection(w, grouped){
const stats = {};
ROLES.forEach(r => (grouped[r] || []).forEach(e => {
stats[assignKey(e.person.id, r)] =
{ main:0, opt:0, kleine:0, anbahnung:0, projects:{} };
stats[assignKey(e.person.id, r)] = { load:0, anbahnung:0, projects:{} };
}));
(w.projects || []).forEach(pr => {
const isAnbahnung = pr.group === 'In Anbahnung';
const isKleine = pr.group === 'Kleine Projekte';
const aktivePhase = lpActiveList(pr)[0];
Object.keys(pr.assign || {}).forEach(aKey => {
const s = stats[aKey];
if(!s) return;
const st = pr.assign[aKey];
if(isAnbahnung){
if(st === 1 || st === 2) s.anbahnung++;
const rolle = aKey.slice(aKey.indexOf('|') + 1);
const al = berechneAuslastung(null, pr, aktivePhase, rolle, st);
if(al.anbahnung){
s.anbahnung++;
return;
}
if(isKleine){
if(st === 1 || st === 2){
s.kleine++;
s.projects[pr.name] = (s.projects[pr.name] || 0) + 1;
}
return;
}
if(st === 1) s.main++;
else if(st === 2) s.opt++;
if(st === 1 || st === 2){
if(al.load > 0){
s.load += al.load;
s.projects[pr.name] = (s.projects[pr.name] || 0) + 1;
}
});
});
const loadOf = s => s.main + s.opt * 0.5 + s.kleine * 0.3;
let maxLoad = 1;
Object.keys(stats).forEach(k => {
const l = loadOf(stats[k]);
if(l > maxLoad) maxLoad = l;
});
const BAR_W = 140;
const barMax = LOAD_BAR_MAX;
const body = [[
{ text: 'Rolle', style: 'th' },
{ text: 'Kürzel', style: 'th' },
{ text: 'Punkte', style: 'th' },
{ text: 'Auslastung', style: 'th' },
{ text: 'Projektdetails', style: 'th' }
{ text: 'Rolle', style: 'th' },
{ text: 'Kürzel', style: 'th' },
{ text: 'Auslastung', style: 'th' },
{ text: 'Auslastung (Faktor)',style: 'th' },
{ text: 'Projektdetails', style: 'th' }
]];
ROLES.forEach(r => (grouped[r] || []).forEach(entry => {
const s = stats[assignKey(entry.person.id, r)];
const load = loadOf(s);
const pct = Math.round(load / maxLoad * 100);
const col = pct > 75 ? '#D70015' : pct > 45 ? '#B25000' : '#248A3D';
const load = s.load; // aktuelle Sitzung = 1 Woche
// Balken relativ zur einstellbaren Skala (Standard 8); die Ampel-Farbe
// und der Zahlenwert bleiben ungedeckelt (Überlastung > 1,0).
const col = pdfLoadAmpelColor(load);
const det = Object.keys(s.projects)
.map(k => k + ' (' + s.projects[k] + '\u00D7)').join(', ') || '—';
body.push([
{ text: r, style: 'td' },
{ text: entry.person.name, style: 'td', bold: true },
{ text: String(load).replace('.', ','), style: 'tdCenter' },
{ text: faktorFormat(load), style: 'tdCenter' },
{ stack: [
{ canvas: [
{ type:'rect', x:0, y:0, w:BAR_W, h:7, color:'#F0F0F0' },
{ type:'rect', x:0, y:0, w:Math.max(0, BAR_W * pct / 100),
{ type:'rect', x:0, y:0, w:Math.max(0, BAR_W * Math.min(1, load / barMax)),
h:7, color:col }
]},
{ text: pct + '%', fontSize: 6, color: '#6E6E73',
{ text: faktorFormat(load), fontSize: 6, color: '#6E6E73',
alignment: 'right', width: BAR_W, margin: [0, 1, 0, 0] }
], margin: [3, 3, 3, 3] },
{ text: s.anbahnung
@@ -2661,10 +2906,10 @@ function pdfLoadSection(w, grouped){
return [
{ text: 'Auslastung der Projektbeteiligten (aktuelle Sitzung)',
style: 'h2', margin: [0, 0, 0, 6] },
{ table: { headerRows: 1, widths: [80, 40, 35, BAR_W + 6, '*'], body },
{ table: { headerRows: 1, widths: [80, 40, 45, BAR_W + 6, '*'], body },
layout: pdfTableLayout() },
{ text: 'Haupt = 1 · Optional = 0,5 · Kleine Projekte = 0,3 · '
+ '„In Anbahnung" wird nicht in die Punktzahl eingerechnet.',
{ text: 'Rollenbezogener Gewichtungsfaktor je Leistungsphase. Aktueller Wert: '
+ faktorFormat(LOAD_BAR_MAX),
fontSize: 6.5, color: '#6E6E73', margin: [0, 4, 0, 0] }
];
}