Auslastungsberechung angepasst
This commit is contained in:
1 parent
eb71a9160f
commit
eeb2e63f89
12 files changed
+646
-297
No files matched your search
+55
-10
@@ -106,7 +106,7 @@
|
||||
<option value="current">Aktuelle Sitzung</option>
|
||||
<option value="all">Alle Sitzungen (Historie)</option>
|
||||
</select>
|
||||
<span class="hint">Zählt Projektzuordnungen (Haupt=1 · Optional=0,5 · Kleine Projekte=0,3 · In Anbahnung wird nicht gezählt).</span>
|
||||
<span class="hint">Auswertung über den rollenspezifischen Gewichtungsfaktor je Leistungsphase.</span>
|
||||
</div>
|
||||
<div class="load-grid" id="loadGrid"></div>
|
||||
</div>
|
||||
@@ -115,15 +115,7 @@
|
||||
<div id="projects" style="display:none">
|
||||
<div class="section-title">Projekte verwalten</div>
|
||||
<div class="toolbar" style="margin-bottom:12px">
|
||||
<input type="text" id="newProjNr" placeholder="Projekt-Nr. (z.B. 26-4001)">
|
||||
<input type="text" id="newProjName" placeholder="Projektbezeichnung">
|
||||
<select id="newProjGroup">
|
||||
<option>Aktive Projekte</option>
|
||||
<option>Kleine Projekte</option>
|
||||
<option>In Anbahnung</option>
|
||||
<option>Allgemein</option>
|
||||
</select>
|
||||
<button onclick="addProjectToList()">+ Projekt hinzufügen</button>
|
||||
<button onclick="openNewProjModal()">+ Neues Projekt</button>
|
||||
</div>
|
||||
<div id="projectsList"></div>
|
||||
</div>
|
||||
@@ -162,6 +154,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Aufwand je Leistungsphase Modal -->
|
||||
<div class="modal-bg" id="lpModal" style="display:none">
|
||||
<div class="modal">
|
||||
<h2>Aufwand je Leistungsphase</h2>
|
||||
<p style="color:#888;margin-bottom:6px" id="lpModalProject"></p>
|
||||
<p class="hint" style="margin-bottom:10px">
|
||||
Rollenbezogene Gewichtungsfaktoren je Leistungsphase (Standard 1,0).
|
||||
Die amtierende Leistungsphase wird pro Projekt im Tab „Projekte" gewählt.
|
||||
</p>
|
||||
<div id="lpModalRows"></div>
|
||||
<div class="toolbar" style="margin-top:12px">
|
||||
<button onclick="saveLpModal()">Speichern</button>
|
||||
<button class="secondary" onclick="closeLpModal()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Neues Projekt Modal (inkl. LP-Aufwand) -->
|
||||
<div class="modal-bg" id="newProjModal" style="display:none">
|
||||
<div class="modal">
|
||||
<h2>Neues Projekt</h2>
|
||||
<div class="form-row">
|
||||
<label>Projekt-Nr.</label>
|
||||
<input type="text" id="newProjNr" placeholder="z. B. 26-4001" style="width:100%">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Bezeichnung</label>
|
||||
<input type="text" id="newProjName" placeholder="Projektbezeichnung" style="width:100%">
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Gruppe</label>
|
||||
<select id="newProjGroup" style="width:100%">
|
||||
<option>Aktive Projekte</option>
|
||||
<option>Kleine Projekte</option>
|
||||
<option>In Anbahnung</option>
|
||||
<option>Allgemein</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Aktive Leistungsphase</label>
|
||||
<select id="newProjActiveLP" style="width:100%"></select>
|
||||
</div>
|
||||
<p class="hint" style="margin:8px 0 10px">
|
||||
Rollenbezogener Gewichtungsfaktor je Leistungsphase (Standard 1,0).
|
||||
</p>
|
||||
<div id="newProjLpRows"></div>
|
||||
<div class="toolbar" style="margin-top:12px">
|
||||
<button onclick="saveNewProj()">Anlegen</button>
|
||||
<button class="secondary" onclick="closeNewProjModal()">Abbrechen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="main.v2.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+334
-89
@@ -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] }
|
||||
];
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
.gantt-row.gantt-todo.dragging,
|
||||
.gantt-row.gantt-milestone-row.dragging { opacity: .5; }
|
||||
.gantt-todo-drag:active { cursor: grabbing; }
|
||||
.gantt-row.gantt-milestone-row .gantt-todo-drag { color: var(--red); }
|
||||
.gantt-row.gantt-milestone-row .gantt-todo-drag { color: var(--text-subtle); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.plan-body { flex-direction: column; overflow: auto; }
|
||||
|
||||
@@ -856,6 +856,17 @@ th.role-bauüberwachung { color: var(--text); }
|
||||
transition: width .3s var(--ease);
|
||||
}
|
||||
|
||||
/* Markierung der amtierenden Leistungsphase (gesetzt im Tab "Projekte") */
|
||||
.lph-cell.lp-aktiv {
|
||||
background: var(--accent-soft);
|
||||
box-shadow: inset 0 2px 0 var(--accent);
|
||||
}
|
||||
|
||||
.lph-cell.lp-aktiv input {
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
@@ -983,6 +994,55 @@ th.role-bauüberwachung { color: var(--text); }
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* === LP-Aufwand-Maske als Matrix-Tabelle ===
|
||||
Zeilen = Leistungsphasen, Spalten = Rollen + "amtierend" (Radio). */
|
||||
.lp-matrix {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 6px 0 12px;
|
||||
}
|
||||
.lp-matrix thead th {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-align: left;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.lp-matrix th.lp-lph {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 6px 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lp-matrix td {
|
||||
padding: 3px 8px;
|
||||
}
|
||||
.lp-matrix td input[type=text] {
|
||||
width: 64px;
|
||||
padding: 4px 7px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.lp-matrix tbody th.lp-lph,
|
||||
.lp-matrix tbody td {
|
||||
border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.lp-matrix tbody tr:last-child th.lp-lph,
|
||||
.lp-matrix tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.lp-matrix tbody tr:nth-child(even) th.lp-lph,
|
||||
.lp-matrix tbody tr:nth-child(even) td {
|
||||
background: var(--surface-alt);
|
||||
}
|
||||
|
||||
/* LP-Aufwand- und Neues-Projekt-Modal etwas breiter für die Matrix-Tabelle */
|
||||
#lpModal .modal,
|
||||
#newProjModal .modal {
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.person-list-item,
|
||||
.person-card {
|
||||
display: flex;
|
||||
|
||||
Reference in new issue
Block a user