All new
alle new
This commit is contained in:
1 parent
da64472251
commit
5b20adee48
1605 files changed
+396517
-1674
No files matched your search
+542
-128
@@ -8,7 +8,7 @@
|
||||
// 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, LOAD_BAR_MAX) wird zusätzlich von js/pdf.js für den
|
||||
// pdfLoadAmpelColor, stundenFormat) wird zusätzlich von js/pdf.js für den
|
||||
// PDF-Export wiederverwendet.
|
||||
//
|
||||
// Datenmodell je Projekt (rollen- und leistungsphasenspezifisch):
|
||||
@@ -35,34 +35,61 @@
|
||||
// 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);
|
||||
// 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');
|
||||
|
||||
// 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 }
|
||||
};
|
||||
// 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) || {};
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 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 (LPH 1–8). Parameter: pr = Projektobjekt.
|
||||
// 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;
|
||||
@@ -82,19 +109,239 @@ function aufwandMatrixWert(projekt, lp, 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 };
|
||||
// ---------- 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
|
||||
@@ -104,15 +351,23 @@ function faktorFormat(x){
|
||||
return n.toFixed(2).replace('.', ',');
|
||||
}
|
||||
|
||||
// loadAmpelColor: Ampel-Farbe relativ zum Maximalwert (gelb ab 50 %,
|
||||
// rot ab 75 % von LOAD_BAR_MAX; PLATZHALTER).
|
||||
// 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){
|
||||
return x >= LOAD_BAR_MAX * THRESHOLD_RATIO_CRITICAL ? '#c53030' : x >= LOAD_BAR_MAX * THRESHOLD_RATIO_ELEVATED ? '#d69e2e' : '#2f855a';
|
||||
// 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 >= LOAD_BAR_MAX * THRESHOLD_RATIO_CRITICAL ? '#D70015' : x >= LOAD_BAR_MAX * THRESHOLD_RATIO_ELEVATED ? '#B25000' : '#248A3D';
|
||||
return x >= schwelleRot() ? '#D70015' : x >= schwelleGelb() ? '#B25000' : '#248A3D';
|
||||
}
|
||||
|
||||
// ---------- LP-Aufwand Eingabemaske ----------
|
||||
@@ -121,11 +376,11 @@ function pdfLoadAmpelColor(x){
|
||||
let lpModalProjectId = null;
|
||||
|
||||
// lpRowsHtml: erzeugt die Aufwand-Maske als Matrix-Tabelle.
|
||||
// Zeilen = Leistungsphasen (LPH 1–8), Spalten = Rollen (Planung/Projektleitung/
|
||||
// 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 Standardwerten aus LP_AUFWAND_VORLAGE vorbefüllt.
|
||||
// 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('');
|
||||
@@ -134,11 +389,14 @@ function lpRowsHtml(p, prefix){
|
||||
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 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('.', ',') : '';
|
||||
return `<td><input type="text" inputmode="decimal" id="${prefix}Val${idx}R${ri}" value="${val}" placeholder="1,0"></td>`;
|
||||
// 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('');
|
||||
@@ -164,9 +422,13 @@ function collectLpRows(prefix){
|
||||
if(!input) continue;
|
||||
const raw = input.value.trim().replace(',', '.');
|
||||
if(raw === '') continue;
|
||||
const v = parseFloat(raw);
|
||||
// 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) – oder das Feld leer lassen. (' + l + ', ' + r + ')');
|
||||
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;
|
||||
}
|
||||
@@ -180,7 +442,7 @@ function collectLpRows(prefix){
|
||||
}
|
||||
|
||||
// applyLpRows: übernimmt das Ergebnis von collectLpRows auf ein Projekt.
|
||||
// Die Maske ist mit den Standardwerten (LP_AUFWAND_VORLAGE) vorbefüllt;
|
||||
// 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.
|
||||
@@ -225,66 +487,51 @@ function saveLpModal(){
|
||||
|
||||
// ---------- 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';
|
||||
}
|
||||
// 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.
|
||||
|
||||
// closeNewProjModal: blendet das Neues-Projekt-Modal aus.
|
||||
function closeNewProjModal(){
|
||||
document.getElementById('newProjModal').style.display = 'none';
|
||||
}
|
||||
// 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'];
|
||||
|
||||
// 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();
|
||||
// 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];
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 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 barMax = LOAD_BAR_MAX;
|
||||
const selectedWeek = currentWeek();
|
||||
const weeks = scope==='all' ? db.weeks : (selectedWeek ? [selectedWeek] : []);
|
||||
if(weeks.length === 0){
|
||||
@@ -293,60 +540,227 @@ function renderLoad(){
|
||||
}
|
||||
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)] = {load:0,projects:{},anbahnung:0};
|
||||
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);
|
||||
const al = berechneAuslastung(null, pr, aktivePhase, rolle, st, pr.assign);
|
||||
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;
|
||||
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--;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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="";
|
||||
// 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=>{
|
||||
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 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 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>
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user