Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb

- Termin aus Mail: Mailtext oder .eml per Drag & Drop/Strg+V auf Terminplan
  oder Aufgaben, KI schlägt Aufgaben vor, Prüfung im Aufgaben-Panel; neues
  Notizfeld je Aufgabe (plan_todos.notiz)
- Terminplan: Meilensteine ohne Aufgabenbezug je in eigener Zeile,
  "+ Neu"-Menü klappt neben schmaler Seitenleiste nach rechts, Leistungsphasen
- Rollen & Rechte, Abwesenheiten-Seite, Benachrichtigungen, Rundgang
- Glas-Darstellung, Logo als Favicon
- Pi-Betrieb: PDF-Arbeit im Kindprozess, Wächter, Einrichtungs- und
  Prüfskript inkl. WLAN-Wächter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-10-29 14:16:52 +01:00
1 parent f4fb3c550b
commit b7e0bd0d92
56 files changed
+12265 -2974

No files matched your search

+313 -541
View File
@@ -1,39 +1,10 @@
// -----------------------------
// Projektübersicht — Auslastung (Load) & Aufwand je Leistungsphase
// -----------------------------
// Modul für den Auslastungs-Tab der Hauptseite (App.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,
// 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.
// 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
@@ -41,19 +12,6 @@
// 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)
@@ -98,128 +56,17 @@ function lpActiveList(pr){
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 ----------
// ---------- Auslastung aus Aufgaben ----------
//
// 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);
}
// 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"
@@ -254,63 +101,138 @@ function arbeitsPhase(projekt, amtierend){
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' });
// ---------------------------------------------------------------------------
// 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;
const basis = projektBasisHonorar(projekt);
if(basis.betrag <= 0) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne Honorar und ohne Projektgröße' });
// 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;
}
// 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 });
// 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) };
});
}
const anteil = phasenAnteilProzent(projekt, phase);
if(anteil <= 0) return Object.assign({}, leer, { unbewertbar: true, grund: 'ohne Honorar-Anteil für diese Phase' });
// 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) || '';
}
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;
// 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));
}
const personStunden = rollenStunden * (eigenAnteil / summeKoepfe);
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;
});
}
const wochen = projektVerteilWochen(projekt);
const stunden = personStunden / wochen;
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
// ---------------------------------------------------------------------------
// 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.
// ---------------------------------------------------------------------------
return { stunden, load: stunden / vollzeit, quelle: basis.quelle, wochen, phase };
// 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
@@ -352,121 +274,6 @@ function loadAmpelColor(x){
return x >= schwelleRot() ? 'var(--red)' : x >= schwelleGelb() ? 'var(--amber)' : 'var(--green)';
}
// ---------- 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(),
@@ -476,9 +283,6 @@ function saveLpModal(){
// 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,
@@ -512,236 +316,204 @@ function projektFarbe(name){
// 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 selectedWeek = currentWeek();
const 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>';
const w = currentWeek();
const grid = document.getElementById('loadGrid');
if(!grid) return;
if(!w){
grid.innerHTML = '<div class="hint">Keine Sitzung ausgewählt oder die Daten werden noch geladen.</div>';
return;
}
const grouped = 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};
});
});
const wochen = vorschauWochen();
const grouped = peopleByRole(w);
// 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++;
});
// Je MENSCH (über alle Rollen): Stunden je Woche, Projekte, Rollen.
const koepfe = {};
const kopfVon = person => {
const schluessel = person.fullName || person.name;
return koepfe[schluessel] || (koepfe[schluessel] = {
person, ids: new Set(), rollen: new Set(), verlauf: new Array(VORSCHAU_WOCHEN).fill(0),
projekte: {}, anbahnung: new Set()
});
});
};
ROLES.forEach(r => grouped[r].forEach(entry => {
const k = kopfVon(entry.person);
k.ids.add(entry.person.id);
k.rollen.add(r);
}));
const kopfZuId = {};
Object.values(koepfe).forEach(k => k.ids.forEach(id => { kopfZuId[id] = k; }));
// Wer keine Fachrolle hat, aber eine Aufgabe, bekommt ebenfalls eine
// Karte — sonst wäre seine Aufgabe nirgends sichtbar.
const kopfFuerId = id => {
if(kopfZuId[id]) return kopfZuId[id];
const person = (w.people || []).find(x => x.id === id);
if(!person) return null;
const k = kopfVon(person);
k.ids.add(id);
kopfZuId[id] = k;
return k;
};
// 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
// Nicht bewertbare Projekte werden ausgewiesen statt still als 0 gezählt.
const luecken = {};
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++;
(w.projects || []).forEach(pr => {
const anbahnung = pr.group === 'In Anbahnung';
const assign = pr.assign || {};
const projektName = pr.nr ? pr.nr + ' ' + pr.name : pr.name;
// 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;
// Projektanzahl je Person: jede Zuordnung zählt, unabhängig davon, ob
// das Projekt bewertbar ist — "wer hat wie viele Projekte".
Object.keys(assign).forEach(key => {
const st = assign[key];
if(st !== 1 && st !== 2) return;
const k = kopfZuId[key.slice(0, key.indexOf('|'))];
if(!k) return;
if(anbahnung){ k.anbahnung.add(projektName); return; }
const e = k.projekte[projektName] || (k.projekte[projektName] = { haupt: false, stunden: 0 });
if(st === 1) e.haupt = true;
});
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;
}
// A: Zuständige offener Aufgaben zählen als Beteiligte des Projekts,
// auch wenn sie unter "Beteiligte" nicht eingetragen sind.
const todos = (pr.plan && Array.isArray(pr.plan.todos)) ? pr.plan.todos : [];
todos.forEach(t => {
if(!t || t.done || balkenPhase(t)) return;
(Array.isArray(t.assignees) ? t.assignees : []).forEach(id => {
const k = kopfFuerId(id);
if(!k) return;
if(anbahnung){ k.anbahnung.add(projektName); return; }
if(!k.projekte[projektName]) k.projekte[projektName] = { haupt: false, stunden: 0 };
});
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--;
}
});
// B: Aufgaben mit Aufwand bringen ihre Stunden mit — auch in Projekten,
// die (noch) nicht bewertbar oder schon fertig gerechnet sind.
const aufgabenStd = aufgabenVerlaufJePerson(pr);
Object.keys(aufgabenStd).forEach(id => {
const k = kopfFuerId(id);
if(!k) return;
aufgabenStd[id].forEach((h, i) => { k.verlauf[i] += h; });
const e = k.projekte[projektName];
if(e) e.stunden += aufgabenStd[id].reduce((x, y) => x + y, 0);
});
if(anbahnung) return;
// Bewertet ist ein Projekt, wenn mindestens eine offene Aufgabe mit
// Aufwand und Zuständigen darin steht — sonst trägt es nichts bei, und
// das wird ausgewiesen statt still als Null gezählt.
alleProjekte++;
if(offeneAufwandsAufgaben(pr).length) bewertbareProjekte++;
else {
const offen = todos.filter(t => t && !t.done && !balkenPhase(t));
luecken[projektName] = offen.length === 0 ? 'keine offenen Aufgaben'
: offen.some(t => Number(t.aufwand) > 0) ? 'Aufgaben mit Aufwand ohne Zuständige'
: 'keine Aufgabe mit Aufwand';
}
});
// 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);
// Karten vorbereiten: Kapazität je Woche (ohne Urlaub/Feiertage), Quote,
// Spitze.
const MAX_QUOTE = 1.4;
const karten = Object.values(koepfe).map(k => {
const kap = wochenKapazitaet(k.person);
const quoten = k.verlauf.map((h, i) => kap[i].stunden > 0 ? h / kap[i].stunden : null);
let spitze = -1, spitzeIdx = -1;
quoten.forEach((q, i) => { if(q !== null && q > spitze){ spitze = q; spitzeIdx = i; } });
const projektNamen = Object.keys(k.projekte);
return { k, kap, quoten, spitze: Math.max(0, spitze), spitzeIdx, projektNamen,
haupt: projektNamen.filter(n => k.projekte[n].haupt).length };
}).sort((a, b) => (b.projektNamen.length > 0) - (a.projektNamen.length > 0) || b.spitze - a.spitze);
// 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 prozent = q => Math.round(q * 100) + ' %';
const ueberlastet = karten.filter(c => c.spitze >= schwelleRot());
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>` : ''}
let html = `<div class="load-kopf">
<div class="load-kopf-zahl"><strong>${bewertbareProjekte}</strong> von ${alleProjekte} Projekten mit Aufwand in Aufgaben</div>
${ueberlastet.length ? `<div class="load-kopf-warnung">${ueberlastet.length} ${ueberlastet.length === 1 ? 'Person ist' : 'Personen sind'} in den nächsten ${VORSCHAU_WOCHEN} Wochen über der Kapazität</div>` : ''}
${luckenNamen.length ? `<button type="button" class="secondary small" onclick="toggleLoadLuecken()">${luckenNamen.length} ohne Aufwand…</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>
<p class="hint" style="margin:0 0 8px">Diese Projekte haben keine offene Aufgabe mit Aufwand und fließen deshalb in keine Auslastung ein.</p>
${luckenNamen.sort().map(n => `<div class="load-luecke"><span>${esc(n)}</span><b>${esc(luecken[n])}</b></div>`).join('')}
</div>`;
}
html += `<p class="hint load-erklaerung">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.</p>`;
// --- 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>
html += '<div class="load-karten">' + karten.map(c => {
const k = c.k;
const name = k.person.fullName || k.person.name;
const anzahl = c.projektNamen.length;
const jetztKap = c.kap[0].stunden;
const jetzt = c.quoten[0];
const jetztText = jetzt === null ? 'abwesend' : prozent(jetzt);
const jetztFarbe = jetzt === null ? 'var(--text-muted)' : (k.verlauf[0] > 0 ? loadAmpelColor(jetzt) : 'var(--text-subtle)');
const teilzeit = Math.abs(kapazitaetsFaktor(k.person) - 1) < 0.005 ? ''
: `<span class="load-capacity" title="Vertragliche Wochenstunden aus der Benutzerverwaltung">${stundenFormat(kapazitaetsFaktor(k.person) * (Number(APP_SETTINGS.vollzeitStundenProWoche) || 40))} h/Woche</span>`;
const saeulen = c.quoten.map((q, i) => {
const kap = c.kap[i];
const h = k.verlauf[i];
const titel = 'KW ' + wochen[i].kw + ': ' + (kap.stunden > 0
? stundenFormat(h) + ' h von ' + stundenFormat(kap.stunden) + ' h (' + prozent(q) + ')' + (kap.abwesend ? ', ' + kap.abwesend + ' Tag(e) abwesend' : '')
: 'abwesend' + (h > 0 ? ', ' + stundenFormat(h) + ' h geplant' : ''));
if(q === null) return `<span class="load-saeule ist-abwesend" title="${esc(titel)}"></span>`;
const hoehe = Math.max(h > 0 ? 3 : 0, Math.min(q, MAX_QUOTE) / MAX_QUOTE * 100);
return `<span class="load-saeule${i === 0 ? ' ist-jetzt' : ''}${kap.abwesend ? ' ist-teil' : ''}" style="height:${hoehe}%;background:${loadAmpelColor(q)}" title="${esc(titel)}"></span>`;
}).join('');
const kws = wochen.map((wo, i) => `<span${i === 0 ? ' class="ist-jetzt"' : ''}>${wo.kw}</span>`).join('');
const hinweis = anzahl === 0 ? '<div class="load-hinweis ist-leer">Keinem Projekt zugeordnet.</div>'
: c.spitze >= schwelleRot()
? `<div class="load-hinweis ist-rot">Spitze ${prozent(c.spitze)} in KW ${wochen[c.spitzeIdx].kw}: Aufgaben verschieben oder abgeben.</div>`
: c.spitze >= schwelleGelb()
? `<div class="load-hinweis ist-gelb">Höchstens ${prozent(c.spitze)} (KW ${wochen[c.spitzeIdx].kw}): ausgelastet, aber im Rahmen.</div>`
: `<div class="load-hinweis ist-ok">Keine Überlastung in den nächsten ${VORSCHAU_WOCHEN} Wochen.</div>`;
const top = c.projektNamen.slice().sort((a, b) => k.projekte[b].stunden - k.projekte[a].stunden);
// Alle Projekte stehen im Markup; ab dem dritten sind sie eingeklappt
// und lassen sich über den Knopf aufklappen (Eriks Wunsch). Der Zustand
// je Person überlebt das Neuzeichnen (loadProjekteOffen).
const offen = loadProjekteOffen.has(name);
const chipTitel = n => n + (k.projekte[n].stunden > 0 ? ' · ' + stundenFormat(k.projekte[n].stunden) + ' h in 12 Wochen' : '');
const chips = top.map((n, i) => `<span class="load-chip${i >= 2 ? ' ist-weitere' : ''}" title="${esc(chipTitel(n))}">${esc(n)}</span>`).join('')
+ (top.length > 2 ? `<button type="button" class="load-chip ist-mehr" aria-expanded="${offen}"
data-person="${esc(name)}" onclick="loadProjekteUmschalten(this)">${offen ? 'weniger' : '+ ' + (top.length - 2) + ' weitere'}</button>` : '');
const anbahnung = k.anbahnung.size ? `, ${k.anbahnung.size} in Anbahnung` : '';
return `<article class="load-karte${anzahl === 0 ? ' ist-ohne' : ''}">
<div class="load-karte-kopf">
<span class="load-ava">${esc(k.person.name)}</span>
<span class="load-karte-wer"><b>${esc(name)}</b>
<span>${anzahl === 1 ? '1 Projekt' : anzahl + ' Projekte'}${anzahl ? `, davon ${c.haupt} in Hauptverantwortung` : ''}${anbahnung}</span>
${teilzeit}</span>
<span class="load-jetzt"><b style="color:${jetztFarbe}">${jetztText}</b><span>diese Woche${jetztKap > 0 ? ', ' + stundenFormat(k.verlauf[0]) + ' h' : ''}</span></span>
</div>
<div class="load-band" title="Volles Band = ${stundenFormat(kap)} h/Woche">
${segmente || '<span class="load-band-leer">keine Zuordnung</span>'}
<div class="load-saeulen" role="img" aria-label="${esc('Auslastung ' + name + ' der nächsten ' + VORSCHAU_WOCHEN + ' Wochen')}">
<span class="load-linie" style="bottom:${100 / MAX_QUOTE}%"></span>${saeulen}
</div>
<div class="load-wert" style="color:${ampel}">${stundenFormat(std)}<span class="load-einheit">h</span></div>
</div>`;
});
html += `</div>`;
});
<div class="load-kws" aria-hidden="true">${kws}</div>
${hinweis}
${chips ? `<div class="load-chips${offen ? ' ist-offen' : ''}">${chips}</div>` : ''}
</article>`;
}).join('') + '</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;
grid.innerHTML = html;
}