code auf einzelne Datein aufgeteilt

This commit is contained in:
erik committed 2026-08-13 16:07:29 +02:00
1 parent 7451e0bf78
commit 8c94fc91f6
30 files changed
+5765 -27

No files matched your search

+352
View File
@@ -0,0 +1,352 @@
// -----------------------------
// 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/board.js/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 => `<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 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 `<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.
// 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 = '<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';
}
// 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 = '<div class="hint">Keine Sitzung ausgewählt oder die Daten werden noch geladen.</div>';
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 += `<div class="load-row"><div class="load-row-title">${r}</div>`;
if(grouped[r].length===0){
html += `<div class="hint">Keine Personen.</div>`;
}
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=>`<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">${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>`;
});
html += `</div>`;
});
document.getElementById('loadGrid').innerHTML=html;
}