// ----------------------------- // Projektübersicht — Auslastung (Load) & Aufwand je Leistungsphase // ----------------------------- // Modul für den Auslastungs-Tab (renderLoad()). Die Auslastung kommt allein // aus Aufgaben mit Aufwand, gleich geteilt auf ihre Zuständigen (siehe // "Auslastung aus Aufgaben" unten). aufgabenVerlaufJePerson() nutzen auch // die Ressourcenansicht im Terminplan (plan-gantt.js) und die Wandtafel. // 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'); // 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 []; } // ---------- Auslastung aus Aufgaben ---------- // // Eriks Vorgabe (10/2026): Die Auslastung entsteht ALLEIN aus Aufgaben mit // eingetragenem Aufwand (t.aufwand, Stunden). Hat eine Aufgabe 8 h und eine // zuständige Person, ist diese Person mit 8 h ausgelastet; bei zwei // Zuständigen je 4 h, bei drei je ein Drittel. Aufgaben ohne Aufwand zählen // nicht. Das frühere Phasenbudget (Honorar × Phasenanteil ÷ Stundensatz, // verteilt über eine Gewichtungstabelle Leistungsphase × Rolle) ist // entfallen — samt Tabelle (firmenweit im Admin-Bereich und je Projekt). // Die Stunden verteilen sich auf die Arbeitstage des Aufgabenzeitraums // (verteileAufBalken()); ohne Termine auf die nächsten UEBERFAELLIG_WOCHEN. // 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; } // --------------------------------------------------------------------------- // Wochenvorschau: VORSCHAU_WOCHEN Kalenderwochen ab dieser Woche; "diese // Woche" ist Woche 0 — die Zahl, die Wandtafel und Projektbereich zeigen. // --------------------------------------------------------------------------- const VORSCHAU_WOCHEN = 12; // Eine offene Aufgabe, deren Ende schon vorbei ist (oder die keine Termine // hat): ihre Reststunden fallen in die nächsten Wochen an. const UEBERFAELLIG_WOCHEN = 4; // vorschauMontag: Montag der aktuellen Woche, 0 Uhr Ortszeit. function vorschauMontag(){ const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); return d; } // vorschauWochen: die Kalenderwochen der Vorschau. Rückgabe: [{montag, kw}]. function vorschauWochen(){ const m = vorschauMontag(); return Array.from({ length: VORSCHAU_WOCHEN }, (_, i) => { const d = new Date(m); d.setDate(d.getDate() + 7 * i); return { montag: d, kw: getWeekNumber(d) }; }); } // balkenPhase: zu welcher Leistungsphase ein Terminplan-Balken gehört — // allein über das Feld lph (Eriks Wunsch: nicht mehr über den Titel). Gesetzt // von "+ Neu" → "Leistungsphase", "Terminplan aus Honorar" und dem // Projekt-Assistenten; ältere Balken hat der Server einmalig anhand ihres // Titels gekennzeichnet (migrateAddPlanTodoLeistungsphase()). function balkenPhase(t){ return (t && t.lph) || ''; } // verteileAufBalken: legt Stunden gleichmäßig auf die Arbeitstage eines // Zeitraums (ohne Wochenenden und Feiertage) und summiert sie je Woche der // Vorschau. Was vor heute läge, rückt nach vorn; ein schon abgelaufener // Balken verteilt seinen Rest auf die nächsten UEBERFAELLIG_WOCHEN. function verteileAufBalken(verlauf, stunden, start, ende){ const heute = new Date(); heute.setHours(0, 0, 0, 0); let von = start, bis = ende; if(bis < heute){ von = heute; bis = new Date(heute); bis.setDate(bis.getDate() + UEBERFAELLIG_WOCHEN * 7 - 1); } else if(von < heute) von = heute; const montag = vorschauMontag(); const tage = []; const d = new Date(von); for(let n = 0; d <= bis && n < 1200; n++, d.setDate(d.getDate() + 1)){ if(isWorkday(d)) tage.push(new Date(d)); } if(!tage.length) tage.push(new Date(von)); const proTag = stunden / tage.length; tage.forEach(t => { const i = Math.floor((t - montag) / (7 * 864e5)); if(i >= 0 && i < VORSCHAU_WOCHEN) verlauf[i] += proTag; }); } // --------------------------------------------------------------------------- // Aufgaben mit Aufwand: Der noch offene Teil (Aufwand × (1 − Fortschritt)) // verteilt sich zu gleichen Teilen auf die Zuständigen und auf die // Arbeitstage des Aufgabenzeitraums. Phasenbalken selbst sind keine Aufgaben // in diesem Sinn. // --------------------------------------------------------------------------- // offeneAufwandsAufgaben: offene Aufgaben eines Projekts mit Aufwand und // mindestens einem Zuständigen. Rückgabe: [{t, rest}]. function offeneAufwandsAufgaben(projekt){ const todos = (projekt && projekt.plan && Array.isArray(projekt.plan.todos)) ? projekt.plan.todos : []; return todos .filter(t => t && !t.done && !balkenPhase(t) && Number(t.aufwand) > 0 && Array.isArray(t.assignees) && t.assignees.length) .map(t => ({ t, rest: Number(t.aufwand) * (1 - Math.min(100, Math.max(0, Number(t.progress) || 0)) / 100) })) .filter(a => a.rest > 0); } // aufgabenZeitraum: Zeitraum einer Aufgabe; ohne Termine die nächsten // UEBERFAELLIG_WOCHEN ab heute. function aufgabenZeitraum(t){ const a = t.start ? parseDateISO(t.start) : null; const e = t.end ? parseDateISO(t.end) : a; if(a && e && e >= a) return { start: a, ende: e }; const heute = new Date(); heute.setHours(0, 0, 0, 0); const bis = new Date(heute); bis.setDate(bis.getDate() + UEBERFAELLIG_WOCHEN * 7 - 1); return { start: heute, ende: bis }; } // aufgabenVerlaufJePerson: Aufgabenstunden je Person und Woche der // Vorschau. Rückgabe: { personId: [Stunden je Woche] }. function aufgabenVerlaufJePerson(projekt){ const out = {}; offeneAufwandsAufgaben(projekt).forEach(({ t, rest }) => { const z = aufgabenZeitraum(t); const jeKopf = rest / t.assignees.length; t.assignees.forEach(id => { const v = out[id] || (out[id] = new Array(VORSCHAU_WOCHEN).fill(0)); verteileAufBalken(v, jeKopf, z.start, z.ende); }); }); return out; } // globalePerson: der Eintrag aus db.globalPeople (Konto) zu einer Person der // Sitzung — per id, sonst über den vollen Namen (siehe kapazitaetsFaktor()). function globalePerson(person){ const list = db.globalPeople || []; return list.find(p => p.id === person.id) || (person.fullName ? list.find(p => p.fullName === person.fullName) : null); } // wochenKapazitaet: Kapazität einer Person je Woche der Vorschau in // Stunden — Vertragsstunden je Arbeitstag mal verfügbare Tage (ohne // Wochenende, Feiertage und eingetragene Abwesenheiten). // Rückgabe: [{ stunden, tage, abwesend }]. function wochenKapazitaet(person){ const voll = (Number(APP_SETTINGS.vollzeitStundenProWoche) || 40) * kapazitaetsFaktor(person); const proTag = voll / 5; const g = globalePerson(person); const abw = (g && Array.isArray(g.absences) ? g.absences : []) .map(a => ({ von: a.start || '', bis: a.end || a.start || '' })).filter(a => a.von); return vorschauWochen().map(w => { let tage = 0, abwesend = 0; for(let i = 0; i < 5; i++){ const d = new Date(w.montag); d.setDate(d.getDate() + i); if(!isWorkday(d)) continue; const iso = formatDateISO(d); if(abw.some(a => iso >= a.von && iso <= a.bis)){ abwesend++; continue; } tage++; } return { stunden: tage * proTag, tage, abwesend }; }); } // 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). Ein Gegenstück mit // druckfreundlichen Festwerten gab es für den PDF-Export (pdfLoadAmpelColor); // es ist mit dem gewerkeübergreifenden Sitzungs-PDF entfallen — die // verbliebenen Exporte zeigen keine Auslastung mehr. return x >= schwelleRot() ? 'var(--red)' : x >= schwelleGelb() ? 'var(--amber)' : 'var(--green)'; } // ---------- 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. // 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. // loadProjekteOffen: Personen (Name), deren Projektliste aufgeklappt ist. const loadProjekteOffen = new Set(); // loadProjekteUmschalten: klappt die Projekte einer Personenkarte auf/zu, // ohne die Seite neu zu zeichnen. Parameter: knopf (trägt data-person). function loadProjekteUmschalten(knopf){ const name = knopf.dataset.person || ''; const box = knopf.closest('.load-chips'); if(!box) return; const offen = !box.classList.contains('ist-offen'); box.classList.toggle('ist-offen', offen); if(offen) loadProjekteOffen.add(name); else loadProjekteOffen.delete(name); knopf.setAttribute('aria-expanded', String(offen)); const anzahl = box.querySelectorAll('.ist-weitere').length; knopf.textContent = offen ? 'weniger' : '+ ' + anzahl + ' weitere'; } function renderLoad(){ const w = currentWeek(); const grid = document.getElementById('loadGrid'); if(!grid) return; if(!w){ grid.innerHTML = '
Auslastung der nächsten ${VORSCHAU_WOCHEN} Wochen: Gezählt werden die offenen Stunden aller Aufgaben mit Aufwand, zu gleichen Teilen auf ihre Zuständigen und auf die Arbeitstage der Aufgabe verteilt. Gestrichelt ist die volle Kapazität, schraffiert sind Wochen mit Urlaub oder anderer Abwesenheit.
`; html += '