Files
projektuebersicht/public/js/plan/plan-gantt.js
T
erikandClaude Opus 5.5 e12936444d Aufgaben im Wochenbericht, Meilensteine „Erreicht“, Panels als Planblatt
- Wochenbericht: Abschnitt „Aufgaben“ je Projekt (erledigt seit Vorwoche,
  überfällig, nächste zwei Wochen), rote Zahl in der Tagesordnung,
  Tabelle in der PDF-Akte; Klick öffnet das Aufgaben-Panel
- Meilensteine lassen sich als erreicht markieren (plan_milestones.erreicht);
  nur nicht erreichte vergangene gelten als überfällig
- Aufgabe- und Meilenstein-Panel neu aufgebaut: Stand oben, Lage im
  Terminplan, Angaben als Zeilen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:41:44 +01:00

593 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// 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(`<li><b>${esc(t.title || 'TODO')}</b> — ${esc(c.name)} ist ${esc(c.type.toLowerCase())} (${formatDateDE(c.start)}–${formatDateDE(c.end)})</li>`);
});
});
if(rows.length === 0){ el.innerHTML = ''; return; }
el.innerHTML = `<div class="plan-warning-band">
<div class="plan-warning-head">${icon('warning')} ${rows.length} Terminkonflikt${rows.length === 1 ? '' : 'e'} mit Abwesenheiten</div>
<ul class="plan-warning-list">${rows.join('')}</ul>
</div>`;
};
// 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 `<div class="plan-lage-zelle">
<div class="kpi-label">${esc(label)}</div>
<div class="kpi-value">${esc(value)}</div>
<div class="kpi-sub${warn ? ' ist-rot' : ''}">${esc(sub)}</div>
</div>`;
}
// 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 = `<div class="plan-lage-zelle plan-lage-phase">
<span class="plan-lage-ring" style="--p:${pct || 0}"></span>
<div><div class="kpi-label">Laufende Phase</div>
<div class="kpi-value">${lph ? esc(lph) + (pct === null ? '' : ' · ' + pct + ' %') : '–'}</div>
<div class="kpi-sub">${lph ? (pct === 100 ? 'abgeschlossen' : 'Fortschritt laut Wochenbericht') : 'Keine amtierende Phase gesetzt'}</div></div>
</div>`;
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(`<path class="${pathClass}" d="${d}" marker-end="url(#${markerId})" data-source="${esc(sourceBar.dataset.todoId)}" data-target="${esc(targetBar.dataset.todoId)}" />`);
});
});
if(paths.length === 0) return;
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('class', 'gantt-dependencies-svg');
svg.innerHTML = `
<defs>
<marker id="gantt-arrow-normal" viewBox="0 0 10 10" refX="7" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="var(--accent)" />
</marker>
<marker id="gantt-arrow-conflict" viewBox="0 0 10 10" refX="7" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="var(--red)" />
</marker>
<marker id="gantt-arrow-done" viewBox="0 0 10 10" refX="7" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="var(--green)" />
</marker>
</defs>
${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 ? `<span class="tp-ohne-zahl">${ohne.length}</span> ${ohne.length === 1 ? 'offene Aufgabe' : 'offene Aufgaben'} ohne Termin <span class="tp-ohne-hinweis">stehen nur unter „Aufgaben“ →</span>` : '';
}
// 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 = '<span class="hint">Keine Beteiligten zugeordnet.</span>';
if(summaryEl) summaryEl.innerHTML = '<span class="hint">Keine Beteiligten zugeordnet.</span>';
return;
}
if(summaryEl){
const cells = loadValues.map(l => {
const otherHint = l.stundenAndere > 0 ? ` (+${stundenFormat(l.stundenAndere)} h anderswo)` : '';
return `<div class="plan-heat-cell" style="opacity:${Math.max(.15, Math.min(1, l.quote))}" title="${esc(l.person.name)}: ${stundenFormat(l.stunden)} h${otherHint}"></div>`;
}).join('');
const alsoBooked = loadValues.filter(l => l.stundenAndere > 0).length;
const chip = alsoBooked > 0
? `<span class="plan-mini-chip warn">${alsoBooked} auch in anderen Projekten</span>`
: `<span class="plan-mini-chip">nur in diesem Projekt</span>`;
summaryEl.innerHTML = `<div class="plan-heat-row">${cells}</div>
<div class="plan-mini-chip-row">${chip}</div>`;
}
let html = '<div class="section-title" style="margin-bottom:8px;font-size:13px">Ressourcen-Auslastung (Wochenstunden, wie im Auslastungs-Tab)</div>';
html += '<div class="load-grid plan-resource-grid">';
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 ? `<div class="hint" style="font-size:11px;margin-top:4px">${absCount} Abwesenheit(en)</div>` : '';
let todoListHtml;
if(l.todos.length === 0){
todoListHtml = '<div class="hint">keine TODOs</div>';
} else {
todoListHtml = l.todos.slice(0,3).map(t => `<div><span>${esc(t)}</span></div>`).join('');
if(l.todos.length > 3){
todoListHtml += '<div class="hint">+' + (l.todos.length - 3) + ' weitere</div>';
}
}
// 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
? `<div class="plan-resource-other" title="${esc(l.andereProjekte.join('\n'))}">
+${stundenFormat(l.stundenAndere)} h in ${l.andereProjekte.length} weiteren Projekt${l.andereProjekte.length === 1 ? '' : 'en'}
</div>`
: `<div class="plan-resource-other muted">keine weiteren Projekte</div>`;
const offenText = l.offenProjekt > 0
? `<div class="plan-resource-other muted">${stundenFormat(l.offenProjekt)} h offen in Aufgaben dieses Projekts</div>`
: `<div class="plan-resource-other muted">keine Aufgaben mit Aufwand</div>`;
html += `<div class="load-card plan-resource-card plan-resource-clickable" onclick="openAbsenceModal('${esc(l.person.id)}')" title="Klicken, um Urlaube/Abwesenheiten zu bearbeiten">
<h3>${esc(l.person.name)} <span style="font-size:11px;color:var(--text-subtle);font-weight:400">(${esc(l.roles.join(' / '))})</span></h3>
<div class="load-num" style="font-size:18px;color:${ampel}">${stundenFormat(l.stunden)}<span style="font-size:11px;font-weight:400">h diese Woche</span></div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div>
${offenText}
${otherBadge}
<div class="load-detail" style="font-size:11px;margin-top:4px">${todoListHtml}</div>
${absBadge}
</div>`;
});
html += '</div>';
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);
});
})();