766 lines
38 KiB
JavaScript
766 lines
38 KiB
JavaScript
// -----------------------------
|
||
// 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, stundenFormat) 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 — 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');
|
||
|
||
// aufwandVorlage: die firmenweite Gewichtung je Leistungsphase und Rolle
|
||
// (0,0–1,0). Sie greift überall dort, wo ein Projekt in seiner Aufwandsmaske
|
||
// keinen eigenen Wert trägt — also bei den meisten — und bestimmt damit still
|
||
// einen Großteil der Auslastungszahlen. Genau deshalb steht sie seit dem
|
||
// Ausbau der Einstellungen nicht mehr als Konstante hier, sondern wird
|
||
// firmenweit gepflegt (Bereich „Einstellungen", nur Administratoren) und über
|
||
// APP_SETTINGS geliefert (js/globals.js, gefüllt in js/store.js). LPH 9 steht
|
||
// in der Vorgabe bewusst auf 0 — die Faktoren sind fachlich nicht abgestimmt.
|
||
// Rückgabe: Objekt LPH -> { Rolle: Faktor }.
|
||
function aufwandVorlage(){
|
||
return (APP_SETTINGS && APP_SETTINGS.aufwandVorlage) || {};
|
||
}
|
||
|
||
// 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 [];
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// ---------- Auslastung in Stunden ----------
|
||
//
|
||
// Die Auslastung wird als WOCHENSTUNDEN gerechnet und erst zum Schluss auf
|
||
// die Kapazität der Person bezogen. Vorher war sie eine Summe dimensionsloser
|
||
// Gewichte: Ein Projekt in LPH 5 zählte für die Planung 1,0 — ob es 20.000 €
|
||
// oder 2 Mio. € umfasste. Zwei kleine Projekte wogen damit schwerer als ein
|
||
// großes, und die Zahl sagte nichts darüber, wie viel Arbeit tatsächlich
|
||
// anliegt.
|
||
//
|
||
// Der Weg von einem Projekt zu den Wochenstunden einer Person:
|
||
//
|
||
// 1. BASIS Honorar des Projekts. Fehlt es, tritt die am Projekt
|
||
// gepflegte Größe (0–1) mal dem Referenzhonorar an seine
|
||
// Stelle. Fehlt beides, ist das Projekt NICHT BEWERTBAR —
|
||
// es wird als Lücke ausgewiesen, nicht als Null gezählt.
|
||
// 2. PHASE Anteil der amtierenden Leistungsphase am Honorar
|
||
// (HONORAR_ANTEILE, auf die beauftragten Phasen normiert —
|
||
// dieselbe Rechnung wie im Leistungsstand des
|
||
// Projektbereichs), abzüglich des bereits erbrachten
|
||
// Fortschritts dieser Phase. Übrig bleibt der OFFENE Wert.
|
||
// 3. STUNDEN Offener Wert geteilt durch den Stundensatz.
|
||
// 4. ROLLE Aufteilung auf die Rollen über dieselbe Gewichtung wie
|
||
// bisher (Projektmatrix, sonst firmenweite Vorlage), hier
|
||
// aber als ANTEIL: Gewicht der Rolle durch die Summe aller
|
||
// Rollengewichte dieser Phase.
|
||
// 5. KÖPFE Aufteilung auf die Personen, die dieser Rolle im Projekt
|
||
// zugeordnet sind. Ohne diesen Schritt zählte jeder von drei
|
||
// Planern die vollen Phasenstunden — der Aufwand verdreifacht
|
||
// sich nicht dadurch, dass mehr Leute daran arbeiten.
|
||
// Hauptverantwortung wiegt 1, Unterstützung 0,5.
|
||
// 6. ZEIT Verteilung der Reststunden über die Wochen bis zum Ende
|
||
// des Terminplans; ohne Terminplan über den firmenweiten
|
||
// Verteilzeitraum. Ohne Horizont wäre die Wochenlast nicht
|
||
// bestimmt: Dieselben Reststunden bedeuten über vier Wochen
|
||
// etwas anderes als über vierzig.
|
||
//
|
||
// "In Anbahnung" bleibt wie bisher ausgeklammert.
|
||
|
||
// projektBasisHonorar: die Geldgrundlage eines Projekts für die
|
||
// Auslastungsrechnung. Parameter: projekt. Rückgabe:
|
||
// { betrag, quelle: 'honorar'|'groesse'|null }.
|
||
function projektBasisHonorar(projekt){
|
||
const honorar = Number(projekt && projekt.honorar) || 0;
|
||
if(honorar > 0) return { betrag: honorar, quelle: 'honorar' };
|
||
const groesse = Number(projekt && projekt.groesse) || 0;
|
||
if(groesse > 0){
|
||
const referenz = Number(APP_SETTINGS.referenzHonorar) || 250000;
|
||
return { betrag: Math.min(1, groesse) * referenz, quelle: 'groesse' };
|
||
}
|
||
return { betrag: 0, quelle: null };
|
||
}
|
||
|
||
// phasenAnteilProzent: Anteil einer Leistungsphase am Honorar des Projekts,
|
||
// normiert auf die BEAUFTRAGTEN Phasen — ein Projekt, das nur für drei Phasen
|
||
// beauftragt ist, verteilt sein Honorar auch nur auf diese drei. Sind keine
|
||
// beauftragt, gelten die firmenweiten Anteile unnormiert (sie ergeben
|
||
// zusammen 100 %). Parameter: projekt, lph. Rückgabe: Prozent (0 = unbekannt).
|
||
function phasenAnteilProzent(projekt, lph){
|
||
const roh = Number(HONORAR_ANTEILE[lph]) || 0;
|
||
if(roh <= 0) return 0;
|
||
const beauftragt = Array.isArray(projekt.beauftragteLP) ? projekt.beauftragteLP : [];
|
||
if(beauftragt.length === 0) return roh;
|
||
if(!beauftragt.includes(lph)) return 0;
|
||
const summe = beauftragt.reduce((acc, l) => acc + (Number(HONORAR_ANTEILE[l]) || 0), 0);
|
||
return summe > 0 ? roh / summe * 100 : 0;
|
||
}
|
||
|
||
// rollenGewicht: Aufwandsgewicht einer Rolle in einer Phase — Wert der
|
||
// Projektmatrix, sonst firmenweite Vorlage, sonst 1,0. Unverändert
|
||
// gegenüber der bisherigen Rechnung; neu ist nur, wofür er dient (Anteil
|
||
// statt absoluter Last). Parameter: projekt, lph, rolle. Rückgabe: Zahl.
|
||
function rollenGewicht(projekt, lph, rolle){
|
||
const wert = aufwandMatrixWert(projekt, lph, rolle);
|
||
if(wert !== null) return wert;
|
||
const vorlage = aufwandVorlage()[lph];
|
||
const v = vorlage ? parseFloat(vorlage[rolle]) : NaN;
|
||
return isNaN(v) ? 1.0 : v;
|
||
}
|
||
|
||
// zuordnungsGewicht: Wie schwer eine Zuordnung wiegt. 1 = Hauptverantwortung,
|
||
// 2 = Unterstützung. Die Unterstützung zählt halb, weil sie sonst dieselbe
|
||
// Stundenzahl trüge wie die Hauptverantwortung und die Arbeit rechnerisch
|
||
// gleichmäßig verteilt würde, obwohl sie es nicht ist.
|
||
function zuordnungsGewicht(status){
|
||
return status === 2 ? 0.5 : status === 1 ? 1 : 0;
|
||
}
|
||
|
||
// projektVerteilWochen: über wie viele Wochen die offenen Stunden eines
|
||
// Projekts verteilt werden. Aus dem Terminplan das späteste Datum (dieselbe
|
||
// Quelle, aus der auch der Verlauf das Projektende liest), gerechnet ab
|
||
// heute; ohne Terminplan oder bei bereits verstrichenem Ende gilt der
|
||
// firmenweite Verteilzeitraum. Parameter: projekt. Rückgabe: Wochen (>= 1).
|
||
function projektVerteilWochen(projekt){
|
||
const vorgabe = Math.max(1, Number(APP_SETTINGS.verteilzeitraumWochen) || 12);
|
||
const plan = projekt && projekt.plan;
|
||
if(!plan) return vorgabe;
|
||
const daten = [];
|
||
(Array.isArray(plan.todos) ? plan.todos : []).forEach(t => { if(t && t.end) daten.push(t.end); });
|
||
(Array.isArray(plan.milestones) ? plan.milestones : []).forEach(m => { if(m && m.date) daten.push(m.date); });
|
||
if(daten.length === 0) return vorgabe;
|
||
const ende = new Date(daten.sort().pop());
|
||
if(isNaN(ende.getTime())) return vorgabe;
|
||
const heute = new Date();
|
||
heute.setHours(0, 0, 0, 0);
|
||
const tage = daysBetween(heute, ende);
|
||
// Ein Ende in der Vergangenheit sagt nichts über die kommenden Wochen —
|
||
// dann gilt wieder die Vorgabe, statt die Restarbeit auf null Wochen zu
|
||
// verteilen und eine unendliche Wochenlast zu erzeugen.
|
||
if(!Number.isFinite(tage) || tage <= 0) return vorgabe;
|
||
return Math.max(1, tage / 7);
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
// berechneAuslastung: Wochenstunden EINER Zuordnung (Person/Rolle) zu einem
|
||
// Projekt. Parameter: mitarbeiter (ungenutzt, Signatur beibehalten), projekt,
|
||
// aktuelleLP, rolle, status (1 = Haupt, 2 = Unterstützung), alleZuordnungen
|
||
// (optional: das assign-Objekt des Projekts, für die Aufteilung auf mehrere
|
||
// Köpfe derselben Rolle).
|
||
// Rückgabe: { stunden, load, anbahnung?, unbewertbar?, grund? }.
|
||
// stunden = Wochenstunden dieser Person aus diesem Projekt
|
||
// load = dieselbe Größe als Anteil einer Vollzeitstelle (Rückwärts-
|
||
// kompatibilität für js/pdf.js, das den Wert weiterhin so nennt)
|
||
function berechneAuslastung(mitarbeiter, projekt, aktuelleLP, rolle, status, alleZuordnungen){
|
||
const leer = { stunden: 0, load: 0 };
|
||
if(!projekt || projekt.group === 'In Anbahnung') return { stunden: 0, load: 0, anbahnung: true };
|
||
if(!aktuelleLP) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne amtierende Leistungsphase' });
|
||
|
||
const basis = projektBasisHonorar(projekt);
|
||
if(basis.betrag <= 0) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne Honorar und ohne Projektgröße' });
|
||
|
||
// Gerechnet wird mit der Phase, in der tatsächlich gearbeitet wird — der
|
||
// amtierenden oder, wenn die fertig ist, der nächsten offenen.
|
||
const phase = arbeitsPhase(projekt, aktuelleLP);
|
||
if(!phase) return Object.assign({}, leer, { fertig: true });
|
||
|
||
const anteil = phasenAnteilProzent(projekt, phase);
|
||
if(anteil <= 0) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne Honorar-Anteil für diese Phase' });
|
||
|
||
const offenerWert = basis.betrag * anteil / 100 * (1 - fortschrittProzent(projekt, phase) / 100);
|
||
if(offenerWert <= 0) return Object.assign({}, leer, { fertig: true });
|
||
|
||
const satz = Number(APP_SETTINGS.stundensatz) || 100;
|
||
const gesamtStunden = offenerWert / satz;
|
||
|
||
// Rollenanteil — nach der Phase, in der gearbeitet wird
|
||
const gewichte = ROLES.map(r => rollenGewicht(projekt, phase, r));
|
||
const summeGewichte = gewichte.reduce((a, b) => a + b, 0);
|
||
const eigenesGewicht = rollenGewicht(projekt, phase, rolle);
|
||
if(summeGewichte <= 0 || eigenesGewicht <= 0) return leer;
|
||
const rollenStunden = gesamtStunden * (eigenesGewicht / summeGewichte);
|
||
|
||
// Aufteilung auf die Köpfe dieser Rolle
|
||
const eigenAnteil = zuordnungsGewicht(status);
|
||
if(eigenAnteil <= 0) return leer;
|
||
let summeKoepfe = eigenAnteil;
|
||
if(alleZuordnungen && typeof alleZuordnungen === 'object'){
|
||
summeKoepfe = 0;
|
||
Object.keys(alleZuordnungen).forEach(key => {
|
||
if(key.slice(key.indexOf('|') + 1) !== rolle) return;
|
||
summeKoepfe += zuordnungsGewicht(alleZuordnungen[key]);
|
||
});
|
||
if(summeKoepfe <= 0) summeKoepfe = eigenAnteil;
|
||
}
|
||
const personStunden = rollenStunden * (eigenAnteil / summeKoepfe);
|
||
|
||
const wochen = projektVerteilWochen(projekt);
|
||
const stunden = personStunden / wochen;
|
||
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
|
||
|
||
return { stunden, load: stunden / vollzeit, quelle: basis.quelle, wochen, phase };
|
||
}
|
||
|
||
// groesseText: beschriftet die Projektgröße für die Eingabemasken.
|
||
//
|
||
// Nur die Zahl, KEIN Eurobetrag daneben. Intern wird die Größe zwar über das
|
||
// Referenzhonorar in einen Betrag umgerechnet (projektBasisHonorar()) — die
|
||
// Zahl neben dem Feld auszuweisen, gäbe der Schätzung aber eine Genauigkeit,
|
||
// die sie nicht hat: "0,40 · rund 100.000 €" liest sich wie ein Honorar,
|
||
// obwohl es ein grober Umfang ist.
|
||
// Parameter: wert (0–1). Rückgabe: String.
|
||
function groesseText(wert){
|
||
const n = Math.max(0, Math.min(1, parseFloat(wert) || 0));
|
||
if(n <= 0) return '— keine Angabe';
|
||
return n.toFixed(2).replace('.', ',');
|
||
}
|
||
|
||
// 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). Für den PDF-Export gilt
|
||
// weiterhin pdfLoadAmpelColor() mit druckfreundlichen Festwerten — ein PDF
|
||
// hat kein Thema.
|
||
return x >= schwelleRot() ? 'var(--red)' : x >= schwelleGelb() ? 'var(--amber)' : 'var(--green)';
|
||
}
|
||
// 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 >= schwelleRot() ? '#D70015' : x >= schwelleGelb() ? '#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 (alle ohne "W+M"), 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 Werten der firmenweiten 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 = aufwandVorlage()[l] ? aufwandVorlage()[l][r] : undefined;
|
||
const base = (raw !== undefined && raw !== null && raw !== '') ? raw
|
||
: (vorlage !== undefined && vorlage !== null && vorlage !== '') ? vorlage : '';
|
||
const val = base !== '' ? String(parseFloat(base)).replace('.', ',') : '';
|
||
// Platzhalter zeigt den Vorgabewert dieser Phase/Rolle, denn genau der
|
||
// gilt, solange das Feld leer bleibt (siehe berechneAuslastung()).
|
||
const ph = (vorlage !== undefined && vorlage !== null && vorlage !== '') ? String(parseFloat(vorlage)).replace('.', ',') : '1,0';
|
||
return `<td><input type="text" inputmode="decimal" id="${prefix}Val${idx}R${ri}" value="${val}" placeholder="${ph}" title="Leer = Vorgabewert ${ph}"></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;
|
||
// Strenge Prüfung statt parseFloat: Das nahm auch "0.5.5" (→ 0.5) oder
|
||
// "1x" (→ 1) kommentarlos an.
|
||
const v = /^\d+(\.\d+)?$/.test(raw) ? parseFloat(raw) : NaN;
|
||
if(isNaN(v) || v < 0 || v > 1){
|
||
alert('Bitte Werte zwischen 0 und 1 eintragen (z. B. 0,4). (' + l + ', ' + r + ')\n\n'
|
||
+ 'Ein leeres Feld bedeutet: es gilt der Vorgabewert dieser Leistungsphase. '
|
||
+ 'Für "kein Aufwand" bitte ausdrücklich 0 eintragen.');
|
||
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 Werten der firmenweiten 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) ----------
|
||
|
||
// 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.
|
||
//
|
||
// collectLpRows()/applyLpRows()/lpRowsHtml() bleiben: Sie tragen weiterhin
|
||
// die Aufwandsmaske openLpModal(), die aus dem Projektbereich aufgerufen wird.
|
||
|
||
// 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.
|
||
function renderLoad(){
|
||
const scope = document.getElementById('loadScope').value;
|
||
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 = {};
|
||
const personen = {}; // fullName -> { person, stunden, rollen:Set }
|
||
ROLES.forEach(r=>{
|
||
grouped[r].forEach(entry=>{
|
||
stats[assignKey(entry.person.id, r)] = {stunden:0,projects:{},anbahnung:0,weeks:0};
|
||
});
|
||
});
|
||
|
||
// Je Person zählen, in wie vielen der ausgewerteten Sitzungen sie im
|
||
// Teilnehmerkreis stand. Ohne das wurde durch ALLE Sitzungen geteilt: Ein
|
||
// neuer Kollege, der seit zwei von zwanzig Sitzungen voll eingeplant ist,
|
||
// erschien mit 0,30 statt 3,00 — also als praktisch unbeschäftigt.
|
||
weeks.forEach(w => {
|
||
(Array.isArray(w.people) ? w.people : []).forEach(person => {
|
||
ROLES.forEach(r => {
|
||
const key = assignKey(person.id, r);
|
||
if(stats[key]) stats[key].weeks++;
|
||
});
|
||
});
|
||
});
|
||
|
||
// Nicht bewertbare Projekte werden MITGEZÄHLT statt übergangen: Ein Projekt
|
||
// ohne amtierende Phase, ohne Honorar und ohne Größe trug bisher still 0
|
||
// bei und war von echter Unterbeschäftigung nicht zu unterscheiden. Die
|
||
// Seite weist die Lücke jetzt oben aus.
|
||
const luecken = {}; // Projektname -> Grund
|
||
let bewertbareProjekte = 0, alleProjekte = 0;
|
||
|
||
weeks.forEach(w=>{
|
||
w.projects.forEach(pr=>{
|
||
if(pr.group === 'In Anbahnung') return;
|
||
alleProjekte++;
|
||
const aktivePhase = lpActiveList(pr)[0];
|
||
const probe = berechneAuslastung(null, pr, aktivePhase, ROLES[0], 1, null);
|
||
const projektName = pr.nr ? pr.nr + ' ' + pr.name : pr.name;
|
||
if(probe.unbewertbar){ luecken[projektName] = probe.grund; }
|
||
else bewertbareProjekte++;
|
||
|
||
// Vergebene Stunden je Projekt mitzählen: Ein Projekt kann bewertbar
|
||
// sein und trotzdem bei niemandem ankommen — etwa wenn es in LPH 8
|
||
// steht (die die Bauüberwachung trägt), aber nur ein Planer zugeordnet
|
||
// ist. Die Arbeit fällt an, wird aber niemandem zugerechnet; das ist
|
||
// eine Lücke und keine Null.
|
||
let vergeben = 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, pr.assign);
|
||
if(al.anbahnung){
|
||
if(st===1 || st===2) s.anbahnung++;
|
||
return;
|
||
}
|
||
if(al.stunden > 0){
|
||
vergeben += al.stunden;
|
||
s.stunden += al.stunden;
|
||
// Der Stundenbeitrag des einzelnen Projekts wird behalten, nicht
|
||
// nur gezählt: Das Lastband zeigt die Summe aufgeteilt nach
|
||
// Projekten. Gerechnet wird nichts Zusätzliches — derselbe Wert,
|
||
// der ohnehin aufaddiert wird, wurde bisher nur verworfen.
|
||
const eintrag = s.projects[pr.name] || (s.projects[pr.name] = { anzahl:0, stunden:0, nr: pr.nr, quelle: al.quelle });
|
||
eintrag.anzahl++;
|
||
eintrag.stunden += al.stunden;
|
||
if(!eintrag.nr && pr.nr) eintrag.nr = pr.nr;
|
||
}
|
||
});
|
||
|
||
if(!probe.unbewertbar && !probe.fertig && vergeben === 0 && !luecken[projektName]){
|
||
// Welche Rolle trüge die Arbeit? Die mit dem höchsten Gewicht in der
|
||
// Phase, in der gerade gearbeitet wird.
|
||
const phase = arbeitsPhase(pr, aktivePhase);
|
||
const tragend = phase
|
||
? ROLES.slice().sort((a,b)=> rollenGewicht(pr, phase, b) - rollenGewicht(pr, phase, a))[0]
|
||
: null;
|
||
luecken[projektName] = tragend
|
||
? 'niemand für ' + tragend + ' zugeordnet (' + phase + ')'
|
||
: 'niemand zugeordnet';
|
||
bewertbareProjekte--;
|
||
}
|
||
});
|
||
});
|
||
|
||
// Wochenstunden: über die Sitzungen gemittelt, in denen die Person im
|
||
// Teilnehmerkreis stand.
|
||
const wochenStunden = k => stats[k].stunden / Math.max(1, stats[k].weeks || weeks.length);
|
||
|
||
// Kapazität einer Person in Stunden je Woche (aus der Benutzerverwaltung,
|
||
// sonst die firmenweite Vollzeit).
|
||
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
|
||
const kapazitaetStunden = person => kapazitaetsFaktor(person) * vollzeit;
|
||
|
||
// Summe je MENSCH über alle Rollen. Bisher fehlte sie: Wer in drei Rollen
|
||
// arbeitet, hat trotzdem nur eine Arbeitswoche — jede Rollenzeile blieb
|
||
// unter der Schwelle, die Überlastung wurde nirgends rot. Ein Mensch mit
|
||
// 0,80 Planung und 0,40 Projektleitung ist bei 1,20, und das gehört
|
||
// sichtbar an den Anfang.
|
||
const koepfe = {};
|
||
ROLES.forEach(r=>{
|
||
grouped[r].forEach(entry=>{
|
||
const schluessel = entry.person.fullName || entry.person.name;
|
||
const k = koepfe[schluessel] || (koepfe[schluessel] = { person: entry.person, stunden: 0, rollen: [] });
|
||
const std = wochenStunden(assignKey(entry.person.id, r));
|
||
k.stunden += std;
|
||
if(std > 0) k.rollen.push(r);
|
||
});
|
||
});
|
||
|
||
let html = "";
|
||
|
||
// --- Kopf: was die Zahlen abdecken ---
|
||
const luckenNamen = Object.keys(luecken);
|
||
const ueberlastet = Object.values(koepfe).filter(k => k.stunden / kapazitaetStunden(k.person) >= schwelleRot());
|
||
html += `<div class="load-kopf">
|
||
<div class="load-kopf-zahl">
|
||
<strong>${bewertbareProjekte}</strong> von ${alleProjekte} Projekten bewertet
|
||
</div>
|
||
${ueberlastet.length ? `<div class="load-kopf-warnung">${ueberlastet.length} ${ueberlastet.length === 1 ? 'Person ist' : 'Personen sind'} über der Kapazität</div>` : ''}
|
||
${luckenNamen.length ? `<button type="button" class="secondary small" onclick="toggleLoadLuecken()">${luckenNamen.length} nicht bewertbar…</button>` : ''}
|
||
</div>`;
|
||
if(luckenNamen.length){
|
||
html += `<div class="load-luecken" id="loadLuecken" style="display:none">
|
||
<p class="hint" style="margin:0 0 8px">Diese Projekte fließen in keine Auslastung ein. Sie erscheinen nicht als Null, sondern hier — eine fehlende Angabe ist keine fehlende Arbeit.</p>
|
||
${luckenNamen.sort().map(n => `<div class="load-luecke"><span>${esc(n)}</span><b>${esc(luecken[n])}</b></div>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
// --- Summe je Mensch ---
|
||
const kopfListe = Object.values(koepfe)
|
||
.filter(k => k.stunden > 0)
|
||
.sort((a,b)=> b.stunden / kapazitaetStunden(b.person) - a.stunden / kapazitaetStunden(a.person));
|
||
if(kopfListe.length){
|
||
html += `<div class="load-rolle"><div class="load-rolle-titel">Gesamt je Person</div>`;
|
||
kopfListe.forEach(k=>{
|
||
const kap = kapazitaetStunden(k.person);
|
||
const quote = kap > 0 ? k.stunden / kap : 0;
|
||
const ampel = loadAmpelColor(quote);
|
||
const pct = Math.min(100, quote * 100);
|
||
html += `<div class="load-zeile load-zeile-summe">
|
||
<div class="load-wer">
|
||
<div class="load-wer-name">${esc(k.person.fullName || k.person.name)}</div>
|
||
<div class="load-wer-meta">${esc(k.rollen.join(' · ') || '—')}</div>
|
||
</div>
|
||
<div class="load-band" title="Kapazität: ${stundenFormat(kap)} h/Woche">
|
||
<div class="load-seg load-seg-summe" style="width:${pct}%;background:${ampel}"></div>
|
||
<div class="load-kapazitaet" title="Volle Kapazität"></div>
|
||
</div>
|
||
<div class="load-wert" style="color:${ampel}">${stundenFormat(k.stunden)}<span class="load-einheit">h</span></div>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
}
|
||
|
||
// --- Je Rolle ---
|
||
const benutzteProjekte = {};
|
||
ROLES.forEach(r=>{
|
||
html += `<div class="load-rolle"><div class="load-rolle-titel">${r}</div>`;
|
||
if(grouped[r].length===0){
|
||
html += `<div class="hint">Keine Personen.</div></div>`;
|
||
return;
|
||
}
|
||
const sortiert = grouped[r].slice().sort((a,b)=>
|
||
wochenStunden(assignKey(b.person.id, r)) - wochenStunden(assignKey(a.person.id, r)));
|
||
|
||
sortiert.forEach(entry=>{
|
||
const s = stats[assignKey(entry.person.id, r)];
|
||
const kap = kapazitaetStunden(entry.person);
|
||
const nWeeks = Math.max(1, s.weeks || weeks.length);
|
||
const std = wochenStunden(assignKey(entry.person.id, r));
|
||
const quote = kap > 0 ? std / kap : 0;
|
||
// Ohne jede Zuordnung KEINE Ampelfarbe: Grün hieße "gut ausgelastet",
|
||
// die Null heißt aber "nichts zugeordnet".
|
||
const ampel = std > 0 ? loadAmpelColor(quote) : 'var(--text-subtle)';
|
||
|
||
const stelleBadge = Math.abs(kapazitaetsFaktor(entry.person) - 1) < 0.005 ? ''
|
||
: `<span class="load-capacity" title="Vertragliche Wochenstunden aus der Benutzerverwaltung">${stundenFormat(kap)} h/Woche</span>`;
|
||
const anbahnungBadge = (s.anbahnung && s.anbahnung > 0)
|
||
? `<span class="load-anbahnung">${s.anbahnung} in Anbahnung</span>` : '';
|
||
|
||
// Segmente: je Projekt eines, Breite als Anteil der KAPAZITÄT. Die
|
||
// Skala ist damit nicht mehr willkürlich (früher fest 8, während die
|
||
// Ampel bei 1,0 rot wurde — ein roter Balken war zu 12,5 % gefüllt),
|
||
// sondern: volles Band = volle Woche.
|
||
const namen = Object.keys(s.projects).sort((a,b)=> s.projects[b].stunden - s.projects[a].stunden);
|
||
const segmente = namen.map(name=>{
|
||
const eintrag = s.projects[name];
|
||
const anteilStd = eintrag.stunden / nWeeks;
|
||
const breite = kap > 0 ? Math.min(100, anteilStd / kap * 100) : 0;
|
||
const farbe = projektFarbe(name);
|
||
if(!benutzteProjekte[name]) benutzteProjekte[name] = { nr: eintrag.nr, farbe, quelle: eintrag.quelle };
|
||
const titel = (eintrag.nr ? eintrag.nr + ' · ' : '') + name
|
||
+ ' · ' + stundenFormat(anteilStd) + ' h/Woche'
|
||
+ (eintrag.quelle === 'groesse' ? ' · aus der Projektgröße' : '')
|
||
+ (eintrag.anzahl > 1 ? ' · in ' + eintrag.anzahl + ' Sitzungen' : '');
|
||
const beschriftung = breite >= 7 ? `<span>${esc(eintrag.nr || name)}</span>` : '';
|
||
return `<div class="load-seg" style="width:${breite}%;background:${farbe}" title="${esc(titel)}">${beschriftung}</div>`;
|
||
}).join('');
|
||
|
||
html += `<div class="load-zeile">
|
||
<div class="load-wer">
|
||
<div class="load-wer-name">${esc(entry.person.fullName || entry.person.name)}</div>
|
||
<div class="load-wer-meta">${esc(entry.person.name)}${stelleBadge}${anbahnungBadge}</div>
|
||
</div>
|
||
<div class="load-band" title="Volles Band = ${stundenFormat(kap)} h/Woche">
|
||
${segmente || '<span class="load-band-leer">keine Zuordnung</span>'}
|
||
</div>
|
||
<div class="load-wert" style="color:${ampel}">${stundenFormat(std)}<span class="load-einheit">h</span></div>
|
||
</div>`;
|
||
});
|
||
html += `</div>`;
|
||
});
|
||
|
||
const legende = Object.keys(benutzteProjekte).sort().map(name=>{
|
||
const e = benutzteProjekte[name];
|
||
return `<span class="load-legende-item"><i style="background:${e.farbe}"></i>${esc(e.nr ? e.nr + ' ' + name : name)}${e.quelle === 'groesse' ? '<em>≈</em>' : ''}</span>`;
|
||
}).join('');
|
||
if(legende){
|
||
const hatSchaetzung = Object.values(benutzteProjekte).some(e => e.quelle === 'groesse');
|
||
html += `<div class="load-legende">${legende}</div>`;
|
||
if(hatSchaetzung) html += `<p class="hint" style="margin:8px 0 0">≈ aus der Projektgröße gerechnet, nicht aus einem hinterlegten Honorar.</p>`;
|
||
}
|
||
|
||
document.getElementById('loadGrid').innerHTML=html;
|
||
} |