// -----------------------------
// Projektplan — GANTT-Rendering & Zoom
// -----------------------------
// Dieses Modul rendert das GANTT-Diagramm der Projektplan-Seite inklusive
// der TODO- und Meilensteinzeilen, die buildGanttHtml aus plan-build-gantt.js
// liefert, und zeichnet darauf die Abhängigkeitslinien zwischen den Aufgaben.
// Es verwaltet außerdem Zoom (pxPerDay in PlanApp), das Anpassen des
// Datumsbereichs (fitPlanGanttRange) sowie die Ressourcen-Auslastung
// (computeResourceLoads/renderResourceLoad/openResourceModal, Cockpit-Layout:
// Detailkarten im Auslastungs-Modal, Vorschau in der Kompakt-Karte darunter)
// und die KPI-Kachelzeile über dem GANTT (renderPlanKpis — Gesamtfortschritt,
// offene Aufgaben und nächster Meilenstein aus denselben Plan-Daten;
// Team-Auslastung dagegen über aufgabenVerlaufJePerson() aus js/load/load.js,
// siehe computeResourceLoads() weiter unten — dieselbe Rechnung wie im
// Auslastungs-Tab, nicht aus den Plan-Daten selbst). renderPlanGantt ist die
// zentrale Neu-Render-Funktion (rendert am Ende auch Auslastung und KPIs
// neu) und wird nach
// Änderungen von plan-modals.js, plan-drag.js und plan-schedule.js erneut
// aufgerufen.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// renderPlanGantt: baut das komplette GANTT-Diagramm des aktuellen Projekts
// neu auf. Nutzt die Datumseingaben der Seite (oder berechnet den
// Projektzeitraum), berücksichtigt Urlaub, Sperrstatus und
// Zoomstufe und ruft buildGanttHtml auf. Im Anschluss werden die
// Abhängigkeitslinien gezeichnet und die Ressourcen-Auslastung aktualisiert.
window.renderPlanGantt = function(){
const container = document.getElementById('planGanttContainer');
if(!app.project || !container) return;
const prevScroll = container.scrollLeft;
let start = parseDateISO(document.getElementById('planGanttStart').value);
let end = parseDateISO(document.getElementById('planGanttEnd').value);
if(!start || !end){
const range = ganttRangeFromProjects([app.project], 7, projectAssignees(app.project, app.week).map(a => a.person.id));
start = range.start; end = range.end;
document.getElementById('planGanttStart').value = formatDateISO(start);
document.getElementById('planGanttEnd').value = formatDateISO(end);
}
// Zeitraum auch sichtbar ins Feld schreiben (es zeigte sonst nur den
// Platzhalter, solange niemand einen Zeitraum gewählt hatte).
const zeitraumAnzeige = document.getElementById('planGanttRange');
if(zeitraumAnzeige) zeitraumAnzeige.value = formatDateDE(start) + ' – ' + formatDateDE(end);
const showVacation = document.getElementById('planShowVacation').checked;
const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id);
const ghost = collectGhostDates();
container.innerHTML = buildGanttHtml([app.project], start, end, {
showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id,
showTodos: true, vacationPeople, pxPerDay: app.pxPerDay,
ghostTodos: ghost.todos, ghostMilestones: ghost.milestones, ghostLabel: ghost.label,
filter: app.ganttFilter || 'alle', phasenZu: app.phasenZu || (app.phasenZu = {}),
eigenePersonId: (typeof currentUser !== 'undefined' && currentUser && currentUser.personId) || '',
});
renderOhneTermin();
container.scrollLeft = prevScroll;
if(typeof ganttGriffeEinsetzen === 'function') ganttGriffeEinsetzen(container);
requestAnimationFrame(() => renderGanttDependencies(container));
renderPlanWarnings();
renderResourceLoad();
renderPlanKpis();
// Aufgabenliste und Dashboard zeigen dieselben Aufgaben-/Meilensteindaten
// in anderer Form (js/plan/plan-tasks.js, js/plan/plan-sections.js). Sie
// hängen sich hier mit ein, weil renderPlanGantt() die zentrale
// Neu-Render-Funktion nach jeder Änderung ist — so bleiben alle drei
// Bereiche ohne zusätzliche Aufrufe in den ändernden Modulen gleich.
if(typeof renderPlanTasks === 'function') renderPlanTasks();
if(typeof renderPlanDashboard === 'function') renderPlanDashboard();
};
// collectGhostDates: liest die Termine derselben Aufgaben/Meilensteine aus
// der letzten früheren Sitzung MIT Planinhalt (app.previousPlannedProjectVersion()
// aus plan-history.js) und gibt sie als Maps id -> Termin zurück, die
// buildGanttHtml als ghostTodos/ghostMilestones für die gestrichelten
// Vergleichs-Umrisse nutzt. Das Label der gefundenen Sitzung wird
// mitgeliefert, damit die Tooltips sie namentlich nennen können statt
// pauschal "Vorwoche" zu behaupten.
// Leere Maps, wenn der Schalter aus ist, es keine frühere Fassung mit
// Terminplan gibt oder dem Projekt die projectUid fehlt (Altdaten).
// Rückgabe: { todos: Map, milestones: Map, label: String }.
function collectGhostDates(){
const empty = { todos: new Map(), milestones: new Map(), label: '' };
const toggle = document.getElementById('planShowGhost');
if(toggle && !toggle.checked) return empty;
if(typeof app.previousPlannedProjectVersion !== 'function') return empty;
const prev = app.previousPlannedProjectVersion();
if(!prev || !prev.project || !prev.project.plan) return empty;
const todos = new Map();
(prev.project.plan.todos || []).forEach(t => {
if(t.id && t.start && t.end) todos.set(t.id, { start: t.start, end: t.end });
});
const milestones = new Map();
(prev.project.plan.milestones || []).forEach(m => {
if(m.id && m.date) milestones.set(m.id, m.date);
});
return { todos, milestones, label: prev.week.label };
}
// renderPlanWarnings: füllt das Hinweisband über dem GANTT
// (#planWarnings in App.html) mit den Urlaubs-Kollisionen des
// Projekts — Aufgaben, deren Zeitraum in die Abwesenheit einer zugeordneten
// Person fällt. Die Prüfung selbst liegt in plan-build-gantt.js
// (app.absenceConflictsForTodo), damit Balken-Markierung und diese Liste
// garantiert dasselbe melden. Keine Parameter, keine Rückgabe.
window.renderPlanWarnings = function(){
const el = document.getElementById('planWarnings');
if(!el || !app.project) return;
if(typeof app.absenceConflictsForTodo !== 'function'){ el.innerHTML = ''; return; }
const plan = app.ensurePlanLocal();
const rows = [];
(plan.todos || []).forEach(t => {
if(t.done) return; // erledigte Aufgaben sind kein Planungsproblem mehr
const conflicts = app.absenceConflictsForTodo(t);
conflicts.forEach(c => {
rows.push(`
${esc(t.title || 'TODO')} — ${esc(c.name)} ist ${esc(c.type.toLowerCase())} (${formatDateDE(c.start)}–${formatDateDE(c.end)})`);
});
});
if(rows.length === 0){ el.innerHTML = ''; return; }
el.innerHTML = `
${icon('warning')} ${rows.length} Terminkonflikt${rows.length === 1 ? '' : 'e'} mit Abwesenheiten
`;
};
// bandZelle: eine Kennzahl im Lageband des Dashboards. Parameter: label,
// value, sub (Texte), warn (Unterzeile rot). Rückgabe: HTML-String.
function bandZelle(label, value, sub, warn){
return `
${esc(label)}
${esc(value)}
${esc(sub)}
`;
}
// renderPlanKpis: füllt das Lageband oben im Dashboard (#planKpiRow,
// Anordnung "Lagebild"): links die laufende Leistungsphase mit Ring
// (amtierende LPH und ihr Fortschritt aus dem Wochenbericht), daneben offene
// Aufgaben (mit Überfälligem), nächster Meilenstein und Team-Auslastung
// (computeResourceLoads(), dieselbe Zahl wie im Auslastungs-Tab). Der
// frühere "Gesamtfortschritt" (Ø der Aufgaben) ist entfallen — für den
// Stand eines Projekts zählt die Leistungsphase. Wird nach jedem
// GANTT-Rendering aufgerufen, damit es sofort aktuell ist.
window.renderPlanKpis = function(){
const row = document.getElementById('planKpiRow');
if(!row || !app.project) return;
const p = app.project;
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const milestones = Array.isArray(plan.milestones) ? plan.milestones : [];
const today = new Date();
today.setHours(0, 0, 0, 0);
const lph = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0] || '';
const roh = lph && p.lph ? p.lph[lph] : '';
const pct = roh === undefined || roh === '' ? null : Math.max(0, Math.min(100, Number(roh) || 0));
const phase = `
Laufende Phase
${lph ? esc(lph) + (pct === null ? '' : ' · ' + pct + ' %') : '–'}
${lph ? (pct === 100 ? 'abgeschlossen' : 'Fortschritt laut Wochenbericht') : 'Keine amtierende Phase gesetzt'}
`;
const open = todos.filter(t => !t.done);
const overdue = open.filter(t => t.end && parseDateISO(t.end) < today);
const openSub = overdue.length > 0 ? overdue.length + ' überfällig' : (open.length > 0 ? 'im Zeitplan' : 'alle erledigt');
const upcoming = milestones
.filter(m => m.date && !m.erreicht && parseDateISO(m.date) >= today)
.sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date));
const msZelle = upcoming.length > 0
? bandZelle('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein')
: bandZelle('Nächster Meilenstein', '–', milestones.length > 0 ? 'keine anstehenden' : 'keine angelegt');
const { loads } = computeResourceLoads();
const loadValues = Object.values(loads);
let loadZelle;
if(loadValues.length === 0){
loadZelle = bandZelle('Team-Auslastung', '–', 'niemand zugeordnet');
} else {
const avgPct = Math.round(loadValues.reduce((s, l) => s + l.quote * 100, 0) / loadValues.length);
const top = loadValues.slice().sort((a, b) => b.quote - a.quote)[0];
loadZelle = bandZelle('Team-Auslastung', avgPct + ' %', top.stunden > 0
? 'höchste: ' + (top.person.fullName || top.person.name) + ' ' + Math.round(top.quote * 100) + ' %' : 'noch nicht bewertbar');
}
row.innerHTML = phase + bandZelle('Offene Aufgaben', String(open.length), openSub, overdue.length > 0) + msZelle + loadZelle;
};
// renderGanttDependencies: zeichnet für alle TODO-/Meilenstein-Balken die
// Abhängigkeits-Pfade als SVG-Kurven mit Pfeilspitzen in das Diagramm.
// Bereits vorhandene Linien werden zuvor entfernt; Konflikt- und
// erledigte-Kanten erhalten eigene Farben. Parameter: container (optional,
// GANTT-Container). Rückgabe: keine.
window.renderGanttDependencies = function(container){
const c = container || document.getElementById('planGanttContainer');
if(!c) return;
const chart = c.querySelector('.gantt-chart');
if(!chart) return;
let svg = chart.querySelector('.gantt-dependencies-svg');
if(svg) svg.remove();
// Linien abgeschaltet (Menü "Mehr") — dann nichts zeichnen.
const pfeile = document.getElementById('planShowPfeile');
if(pfeile && !pfeile.checked) return;
// Ausgeblendete Zeilen (zugeklappte Phase, Filter) haben keine Lage —
// Linien dorthin würden ins Leere zeigen.
const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]'))
.filter(b => b.offsetParent !== null);
if(nodeBars.length === 0) return;
const chartRect = chart.getBoundingClientRect();
const barMap = new Map();
nodeBars.forEach(b => {
if(b.dataset.todoId) barMap.set(b.dataset.todoId, b);
});
const paths = [];
nodeBars.forEach(targetBar => {
const dependsOnStr = targetBar.dataset.todoDepends;
if(!dependsOnStr) return;
const depIds = dependsOnStr.split(',').map(s => s.trim()).filter(Boolean);
depIds.forEach(depId => {
const sourceBar = barMap.get(depId);
if(!sourceBar) return;
const sourceRect = sourceBar.getBoundingClientRect();
const targetRect = targetBar.getBoundingClientRect();
const x1 = sourceRect.right - chartRect.left;
const y1 = sourceRect.top + sourceRect.height / 2 - chartRect.top;
const x2 = targetRect.left - chartRect.left;
const y2 = targetRect.top + targetRect.height / 2 - chartRect.top;
const isConflict = x2 < x1;
const isDone = targetBar.classList.contains('done') && sourceBar.classList.contains('done');
let d = '';
if(x2 >= x1 + 10){
const midX = x1 + (x2 - x1) / 2;
d = `M ${x1} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${x2} ${y2}`;
} else {
const r = 16;
d = `M ${x1} ${y1} C ${x1 + r} ${y1}, ${x1 + r} ${(y1 + y2)/2}, ${(x1 + x2)/2} ${(y1 + y2)/2} C ${x2 - r} ${(y1 + y2)/2}, ${x2 - r} ${y2}, ${x2} ${y2}`;
}
let pathClass = 'gantt-dep-path';
let markerId = 'gantt-arrow-normal';
if(isConflict){ pathClass += ' conflict'; markerId = 'gantt-arrow-conflict'; }
else if(isDone){ pathClass += ' done'; markerId = 'gantt-arrow-done'; }
paths.push(``);
});
});
if(paths.length === 0) return;
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'gantt-dependencies-svg');
svg.innerHTML = `
${paths.join('')}
`;
chart.appendChild(svg);
};
// fitPlanGanttRange: setzt die Datumsfelder der Seite auf den Projektzeitraum
// (inkl. 7 Tage Puffer) zurück und rendert das Diagramm neu. Wird auch nach
// Drags außerhalb des sichtbaren Bereichs von plan-drag.js genutzt.
window.fitPlanGanttRange = function(){
// Nur die Abwesenheiten der Projektbeteiligten dürfen den Zeitraum
// aufziehen — alle anderen Urlaubszeilen werden ohnehin nicht gezeichnet.
const range = ganttRangeFromProjects([app.project], 7, projectAssignees(app.project, app.week).map(a => a.person.id));
document.getElementById('planGanttStart').value = formatDateISO(range.start);
document.getElementById('planGanttEnd').value = formatDateISO(range.end);
renderPlanGantt();
};
// ---------------------------------------------------------------------------
// Ruhige Leiste (Eriks Wahl "A"): Maßstab, Heute, Filter, Menüs.
// ---------------------------------------------------------------------------
const TP_STUFEN = { fit: null, woche: 8, tag: 32 };
// tpMassstab: Maßstab "Übersicht" (an die Breite angepasst), "Wochen" oder
// "Tage" — feste Pixel je Tag statt der früheren −/+-Knöpfe.
window.tpMassstab = function(stufe){
app.pxPerDay = TP_STUFEN[stufe] === undefined ? null : TP_STUFEN[stufe];
document.querySelectorAll('#tpMassstab button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.stufe === stufe)));
renderPlanGantt();
if(app.pxPerDay) tpZuHeuteScrollen();
};
function tpZuHeuteScrollen(){
const c = document.getElementById('planGanttContainer');
const heute = c && c.querySelector('.gantt-grid-today');
if(!c || !heute) return;
const r = heute.getBoundingClientRect(), cr = c.getBoundingClientRect();
c.scrollLeft += (r.left - cr.left) - c.clientWidth / 3;
}
// tpHeute: Zeitraum ab zwei Wochen vor heute, gleiche Länge wie bisher
// (mindestens 12 Wochen), und heute ins Bild holen.
window.tpHeute = function(){
const s = parseDateISO(document.getElementById('planGanttStart').value);
const e = parseDateISO(document.getElementById('planGanttEnd').value);
const tage = s && e ? Math.max(84, daysBetween(s, e)) : 84;
const von = new Date(); von.setHours(0, 0, 0, 0); von.setDate(von.getDate() - 14);
const bis = new Date(von); bis.setDate(bis.getDate() + tage);
document.getElementById('planGanttStart').value = formatDateISO(von);
document.getElementById('planGanttEnd').value = formatDateISO(bis);
const anzeige = document.getElementById('planGanttRange');
if(anzeige) anzeige.value = formatDateDE(von) + ' – ' + formatDateDE(bis);
renderPlanGantt();
if(app.pxPerDay) tpZuHeuteScrollen();
};
// tpFilter: Alle / Meine (eigene Zuständigkeit) / Überfällig. Gefilterte
// Zeilen werden nur ausgeblendet, nicht weggelassen — das Umsortieren per
// Ziehen (plan-drag.js) zählt die Zeilen im DOM.
window.tpFilter = function(filter){
app.ganttFilter = filter;
document.querySelectorAll('#tpFilter button').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.filter === filter)));
renderPlanGantt();
};
// tpPhaseUmschalten: klappt eine Leistungsphase im Terminplan auf/zu.
window.tpPhaseUmschalten = function(phase){
if(!app.phasenZu) app.phasenZu = {};
app.phasenZu[phase] = !app.phasenZu[phase];
renderPlanGantt();
};
// tpMenueZu: schließt die Menüs "Mehr" und "+ Neu".
window.tpMenueZu = function(){
document.querySelectorAll('.tp-menue[open]').forEach(d => d.removeAttribute('open'));
};
// Ein Klick außerhalb schließt ein offenes Menü; ein Menü öffnen schließt
// das andere.
document.addEventListener('click', e => {
document.querySelectorAll('.tp-menue[open]').forEach(d => { if(!d.contains(e.target)) d.removeAttribute('open'); });
});
document.addEventListener('keydown', e => { if(e.key === 'Escape') window.tpMenueZu(); });
// Die Liste hängt rechtsbündig unter dem Knopf. Bricht die Leiste in einem
// schmalen Fenster um, steht "+ Neu" links und die Liste rutschte unter die
// Seitenleiste — dann linksbündig aufklappen (Klasse .nach-rechts).
document.addEventListener('toggle', e => {
const d = e.target;
if(!d.classList || !d.classList.contains('tp-menue') || !d.open) return;
const liste = d.querySelector('.tp-menue-liste');
if(!liste) return;
liste.classList.remove('nach-rechts');
const seite = document.querySelector('.side-menu');
const r = seite ? seite.getBoundingClientRect() : null;
const linkeGrenze = r && r.width && getComputedStyle(seite).position !== 'fixed' ? r.right : 0;
if(liste.getBoundingClientRect().left < linkeGrenze + 8) liste.classList.add('nach-rechts');
}, true);
// renderOhneTermin: Hinweis unter dem Diagramm auf Aufgaben, die keinen
// Balken haben (ohne Zeitraum oder bewusst ausgeblendet) — sie stehen nur
// unter "Aufgaben". Vorher fehlte jeder Hinweis, dass es sie gibt.
function renderOhneTermin(){
const el = document.getElementById('planOhneTermin');
if(!el) return;
const todos = (app.project && app.project.plan && Array.isArray(app.project.plan.todos)) ? app.project.plan.todos : [];
const ohne = todos.filter(t => !t.done && !(t.start && t.end && t.showInGantt !== false));
el.hidden = ohne.length === 0;
el.innerHTML = ohne.length ? `${ohne.length} ${ohne.length === 1 ? 'offene Aufgabe' : 'offene Aufgaben'} ohne Termin stehen nur unter „Aufgaben“ →` : '';
}
// zoomPlanGantt: ändert die Pixel-pro-Tag-Stufe des Diagramms.
// Nach unten hinaus wird wieder auf die Containerbreite angepasst (fit).
window.zoomPlanGantt = function(dir){
const container = document.getElementById('planGanttContainer');
let start = parseDateISO(document.getElementById('planGanttStart').value);
let end = parseDateISO(document.getElementById('planGanttEnd').value);
if(!start || !end) return;
const totalDays = Math.max(1, daysBetween(start, end) + 1);
const cw = container ? Math.max(200, container.clientWidth - (ganttSpaltenBreite() + 30)) : 900;
const fitPx = Math.max(3, cw / totalDays);
const base = app.pxPerDay || fitPx;
let next;
if(dir > 0){
next = Math.min(120, Math.round(base * 1.35));
} else {
next = Math.round(base / 1.35);
if(next <= fitPx * 0.7) next = null;
}
app.pxPerDay = next;
renderPlanGantt();
};
// ---------- Ressourcen-Auslastung ----------
// computeResourceLoads: berechnet die Auslastung der Projektbeteiligten
// NACH DERSELBEN RECHNUNG wie der Auslastungs-Tab und die Wandtafel:
// Stunden dieser Woche aus Aufgaben mit Aufwand, gleich geteilt auf die
// Zuständigen (aufgabenVerlaufJePerson(), js/load/load.js), bezogen auf die
// Kapazität der Person. Für JEDES Projekt der Sitzung, aufgeteilt in den
// Anteil DIESES Projekts und den der übrigen — sonst sähe jemand hier
// entspannt aus, der in vier anderen Projekten ausgebucht ist.
//
// Gemeinsam genutzt von renderResourceLoad() (Detailkarten im
// Terminplan-Bereich) und renderPlanKpis() (Team-Auslastung-Kachel), damit
// beide dieselbe Zahl zeigen. Rückgabe: { loads } — loads ist ein Objekt
// Person-ID -> { person, roles, kapazitaet, stundenProjekt, stundenAndere,
// stunden (Summe), quote (stunden/kapazitaet), andereProjekte, todos
// (Titel der eigenen Aufgaben in DIESEM Projekt, ohne Phasenbalken),
// offenProjekt (offene Aufgabenstunden dieses Projekts, alle Wochen).
function computeResourceLoads(){
const plan = app.ensurePlanLocal();
const todos = Array.isArray(plan.todos) ? plan.todos : [];
const assignees = projectAssignees(app.project, app.week);
const vollzeit = Number(APP_SETTINGS.vollzeitStundenProWoche) || 40;
const loads = {};
assignees.forEach(a => {
if(!loads[a.person.id]){
loads[a.person.id] = {
person: a.person, roles: [], todos: [],
kapazitaet: kapazitaetsFaktor(a.person) * vollzeit,
stundenProjekt: 0, stundenAndere: 0, andereProjekte: [],
};
}
if(!loads[a.person.id].roles.includes(a.role)) loads[a.person.id].roles.push(a.role);
});
// Zuständige einer Aufgabe zählen mit, auch wenn sie unter „Beteiligte“
// fehlen — wie auf der Auslastungsseite. Sonst fielen ihre
// Aufgabenstunden hier stillschweigend weg.
const personen = (app.week && app.week.people) || [];
todos.forEach(t => {
if(!Array.isArray(t.assignees) || balkenPhase(t)) return;
t.assignees.forEach(pid => {
if(!loads[pid]){
const person = personen.find(x => x.id === pid);
if(!person) return;
loads[pid] = {
person, roles: ['nur Aufgabe'], todos: [],
kapazitaet: kapazitaetsFaktor(person) * vollzeit,
stundenProjekt: 0, stundenAndere: 0, andereProjekte: [],
};
}
loads[pid].todos.push(t.title);
});
});
// Offene Aufgabenstunden dieses Projekts insgesamt (nicht nur diese
// Woche) — liegt eine Aufgabe erst später, zeigte die Wochenzahl sonst 0.
Object.values(loads).forEach(l => { l.offenProjekt = 0; });
offeneAufwandsAufgaben(app.project).forEach(({ t, rest }) => {
t.assignees.forEach(pid => { if(loads[pid]) loads[pid].offenProjekt += rest / t.assignees.length; });
});
// Stunden dieser Woche allein aus Aufgaben mit Aufwand — dieselbe
// Rechnung wie auf der Auslastungsseite (aufgabenVerlaufJePerson(), load.js).
((app.week && app.week.projects) || []).forEach(pr => {
const av = typeof aufgabenVerlaufJePerson === 'function' ? aufgabenVerlaufJePerson(pr) : {};
Object.keys(av).forEach(pid => {
if(!loads[pid] || !(av[pid][0] > 0)) return;
if(pr.id === app.project.id) loads[pid].stundenProjekt += av[pid][0];
else {
loads[pid].stundenAndere += av[pid][0];
const label = pr.nr ? (pr.nr + ' ' + (pr.name || '')) : (pr.name || 'Projekt');
if(!loads[pid].andereProjekte.includes(label)) loads[pid].andereProjekte.push(label);
}
});
});
Object.values(loads).forEach(l => {
l.stunden = l.stundenProjekt + l.stundenAndere;
l.quote = l.kapazitaet > 0 ? l.stunden / l.kapazitaet : 0;
});
return { loads };
}
// openResourceModal: springt in den Bereich "Terminplan", in dem die
// Auslastungskarten stehen (#resourceLoadContainer). Früher öffnete die
// Funktion ein eigenes Modal; seit die Seite in Bereiche gegliedert ist
// (js/plan/plan-sections.js), ist der Inhalt fester Bestandteil des
// Terminplan-Bereichs. Der Name bleibt, weil die Kompakt-Karte
// "Ressourcen-Auslastung" im Dashboard darauf verweist.
window.openResourceModal = function(){
if(typeof showPlanSection === 'function') showPlanSection('terminplan');
const container = document.getElementById('resourceLoadContainer');
if(container) container.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
// renderResourceLoad: rendert pro Person eine Auslastungskarte mit Balken
// und Abwesenheits-Hinweis in #resourceLoadContainer (Inhalt des
// Auslastungs-Modals) sowie eine kompakte Vorschau (Balkenreihe) in
// #planResourceSummary (die Kompakt-Karte selbst). Ein Klick auf eine
// Detailkarte öffnet das Abwesenheits-Modal aus plan-modals.js.
window.renderResourceLoad = function(){
const container = document.getElementById('resourceLoadContainer');
const summaryEl = document.getElementById('planResourceSummary');
if(!container || !app.project) return;
const { loads } = computeResourceLoads();
const loadValues = Object.values(loads);
if(loadValues.length === 0){
container.innerHTML = 'Keine Beteiligten zugeordnet.';
if(summaryEl) summaryEl.innerHTML = 'Keine Beteiligten zugeordnet.';
return;
}
if(summaryEl){
const cells = loadValues.map(l => {
const otherHint = l.stundenAndere > 0 ? ` (+${stundenFormat(l.stundenAndere)} h anderswo)` : '';
return ``;
}).join('');
const alsoBooked = loadValues.filter(l => l.stundenAndere > 0).length;
const chip = alsoBooked > 0
? `${alsoBooked} auch in anderen Projekten`
: `nur in diesem Projekt`;
summaryEl.innerHTML = `${cells}
${chip}
`;
}
let html = 'Ressourcen-Auslastung (Wochenstunden, wie im Auslastungs-Tab)
';
html += '';
Object.values(loads).forEach(l => {
const ampel = loadAmpelColor(l.quote);
const breite = Math.min(100, l.quote * 100);
const absCount = app.countPersonAbsences(l.person.id);
const absBadge = absCount > 0 ? `
${absCount} Abwesenheit(en)
` : '';
let todoListHtml;
if(l.todos.length === 0){
todoListHtml = '
keine TODOs
';
} else {
todoListHtml = l.todos.slice(0,3).map(t => `
${esc(t)}
`).join('');
if(l.todos.length > 3){
todoListHtml += '
+' + (l.todos.length - 3) + ' weitere
';
}
}
// Gegenüberstellung: Stunden aus diesem Projekt vs. aus den übrigen
// Projekten der Sitzung (siehe computeResourceLoads) — macht sichtbar,
// wenn jemand hier entspannt aussieht, aber anderswo ausgebucht ist.
const otherBadge = l.stundenAndere > 0
? `
+${stundenFormat(l.stundenAndere)} h in ${l.andereProjekte.length} weiteren Projekt${l.andereProjekte.length === 1 ? '' : 'en'}
`
: `
keine weiteren Projekte
`;
const offenText = l.offenProjekt > 0
? `
${stundenFormat(l.offenProjekt)} h offen in Aufgaben dieses Projekts
`
: `
keine Aufgaben mit Aufwand
`;
html += `
${esc(l.person.name)} (${esc(l.roles.join(' / '))})
${stundenFormat(l.stunden)}h diese Woche
${offenText}
${otherBadge}
${todoListHtml}
${absBadge}
`;
});
html += '
';
container.innerHTML = html;
};
// Abhängigkeitslinien hängen von Pixel-Positionen ab -> bei Fenstergröße neu zeichnen
// Debounce, damit während des Ziehens nicht unnötig neu gerendert wird.
// Seit der Projektbereich ein Bereich der Hauptseite ist, läuft dieser
// Lauscher auch, während ein ganz anderer Bereich sichtbar ist. In einem
// ausgeblendeten Container sind alle gemessenen Positionen 0 — das Zeichnen
// wäre nicht nur überflüssig, sondern falsch. Deshalb der Bereichs-Test.
window.addEventListener('resize', () => {
if(currentTab !== 'plan' || !app.project) return;
if(app.resizeTimer) clearTimeout(app.resizeTimer);
app.resizeTimer = setTimeout(() => renderGanttDependencies(), 150);
});
})();