Files
projektuebersicht/public/js/plan/plan-gantt.js
T
erikandClaude Sonnet 5 6ba93b2a92 Plan Checker, Wochenbericht-Fokus-Modus und Wandtafel-Dashboard-Feinschliff
Plan Checker (neues Studenten-Werkzeug, Seitenleiste + Admin → Plan-Checker-
Merkmale): lädt einen Bauplan als PDF hoch und lässt ChatGPT oder Claude ihn
gegen eine gepflegte Merkmalsliste prüfen. PDF-Rendering (pdf.js +
@napi-rs/canvas) berücksichtigt jetzt Ebenen (Optional Content Groups) und
WASM-Decoder korrekt, sendet eine Übersicht plus mehrere hochaufgelöste
Kachel-Ausschnitte statt eines einzelnen, zu groben Bilds, und beide Anbieter
bekommen dasselbe Bildmaterial. Jedes Prüfergebnis kann auf eines der
gesendeten Bilder als Beleg verweisen (bildIndex) — als Screenshot im
Ergebnis anklickbar, öffnet vergrößert in einem Modal statt eines von Chrome
blockierten target=_blank-Links auf eine data:-URL. KI-Modellname ist jetzt
pro Konto überschreibbar (Einstellungen → Mein Konto) für Drittanbieter mit
abweichendem Modell-Katalog.

Wochenbericht: dichte Kartenansicht bekommt einen Fokus-Modus — nur der
Kommentar liegt offen, Leistungsphasen und Beteiligte stecken in
aufklappbaren Abschnitten statt dauerhaft nebeneinander zu stehen.

Projektbereich: Terminplan-Auslastung rechnet jetzt exakt wie der
Auslastungs-Tab/die Wandtafel (statt einer unabhängigen, tageweisen
Schätzung); die LPH-Fortschrittsliste im Dashboard zeigt nur noch die
beauftragten Leistungsphasen.

Wandtafel: Dashboard-Kopf mit Kennzahlen-Reihe und Panel-Reihe neu
strukturiert, in den Blautönen des Firmenlogos; dieselbe Palette wurde
anschließend auf den gesamten App-weiten Dark Mode übernommen.

Dateibereinigung: nicht mehr benötigte Test-Uploads/Backups entfernt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-05 14:01:41 +02:00

483 lines
24 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 berechneAuslastung() 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, kritischen Pfad, 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);
}
const showVacation = document.getElementById('planShowVacation').checked;
const vacationPeople = projectAssignees(app.project, app.week).map(a => a.person.id);
const criticalPath = app.criticalPathActive ? computeCriticalPath(app.project) : [];
const ghost = collectGhostDates();
container.innerHTML = buildGanttHtml([app.project], start, end, {
showVacation, editable: !app.isLocked(), highlightProjectId: app.project.id,
showTodos: true, vacationPeople, pxPerDay: app.pxPerDay, criticalPath,
ghostTodos: ghost.todos, ghostMilestones: ghost.milestones, ghostLabel: ghost.label,
});
container.scrollLeft = prevScroll;
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>`;
};
// kpiTile: baut das HTML einer einzelnen Kachel für renderPlanKpis().
// Parameter: label (Kachel-Titel), value (Hauptzahl), sub (Unterzeile),
// warn (optional, hebt den Wert farblich hervor, z. B. bei überfälligen
// Aufgaben). Rückgabe: HTML-String.
function kpiTile(label, value, sub, warn){
return `<div class="plan-kpi-tile${warn ? ' warn' : ''}">
<div class="kpi-label">${esc(label)}</div>
<div class="kpi-value">${esc(value)}</div>
<div class="kpi-sub">${esc(sub)}</div>
</div>`;
}
// renderPlanKpis: füllt die KPI-Kachelzeile über dem GANTT (Cockpit-Layout,
// #planKpiRow in App.html) mit vier Kennzahlen, die alle aus den
// bereits vorhandenen Plan-Daten abgeleitet sind (keine neue Datenquelle):
// Gesamtfortschritt (Ø-Fortschritt aller TODOs), offene Aufgaben (inkl.
// überfällige), nächster anstehender Meilenstein und Team-Auslastung
// (Ø-Prozentwert aus computeResourceLoads(), dieselbe Zahl wie im
// Auslastungs-Modal). Wird nach jedem GANTT-Rendering aufgerufen, damit sie
// nach TODO-/Meilenstein-/Zuordnungsänderungen sofort aktuell ist.
window.renderPlanKpis = function(){
const row = document.getElementById('planKpiRow');
if(!row || !app.project) return;
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);
let progressTile;
if(todos.length === 0){
progressTile = kpiTile('Gesamtfortschritt', '–', 'Keine Aufgaben angelegt');
} else {
const avg = Math.round(todos.reduce((s, t) => s + (parseInt(t.progress, 10) || 0), 0) / todos.length);
const doneCount = todos.filter(t => t.done).length;
progressTile = kpiTile('Gesamtfortschritt', avg + '%', doneCount + ' von ' + todos.length + ' Aufgaben erledigt');
}
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 Aufgaben erledigt');
const openTile = kpiTile('Offene Aufgaben', String(open.length), openSub, overdue.length > 0);
const upcoming = milestones
.filter(m => m.date && parseDateISO(m.date) >= today)
.sort((a, b) => parseDateISO(a.date) - parseDateISO(b.date));
let milestoneTile;
if(upcoming.length > 0){
milestoneTile = kpiTile('Nächster Meilenstein', formatDateDE(parseDateISO(upcoming[0].date)), upcoming[0].name || 'Meilenstein');
} else {
milestoneTile = kpiTile('Nächster Meilenstein', '–', milestones.length > 0 ? 'Keine anstehenden Meilensteine' : 'Keine Meilensteine angelegt');
}
const { loads } = computeResourceLoads();
const loadValues = Object.values(loads);
let loadTile;
if(loadValues.length === 0){
loadTile = kpiTile('Team-Auslastung', '–', 'Keine Beteiligten 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];
loadTile = kpiTile('Team-Auslastung', avgPct + '%', top.stunden > 0 ? 'Höchste Auslastung: ' + top.person.name : 'Noch nicht bewertbar');
}
row.innerHTML = progressTile + openTile + milestoneTile + loadTile;
};
// 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();
const nodeBars = Array.from(chart.querySelectorAll('.gantt-bar.todo[data-todo-id], .gantt-milestone[data-todo-id]'));
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();
};
// 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 - 250) : 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
// (berechneAuslastung()/loadAmpelColor()/kapazitaetsFaktor(),
// js/load/load.js) — Wochenstunden aus Honorar × Phasenanteil ×
// Restfortschritt, bezogen auf die Kapazität der Person.
//
// Vorher zählte diese Stelle etwas ganz anderes: Aufgabentage aus den
// TODO-Zeiträumen DIESES Terminplans, ohne jeden Bezug zu Honorar oder
// Kapazität — ein Blick auf dieselbe Person zeigte hier eine andere Zahl
// als im Auslastungs-Tab. Erik: "Die Auslastung der Projektbeteiligten im
// Terminplan ist eine andere als die in der sonstigen Anwendung... gleich-
// setzen mit der in der sonstigen Anwendung." Jetzt wird für JEDES Projekt
// der Sitzung (dieses UND alle übrigen) genau derselbe
// berechneAuslastung()-Aufruf gemacht wie in renderLoad() — dieselbe
// Person zeigt hier also exakt dieselbe Gesamtzahl wie im Auslastungs-Tab,
// nur aufgeteilt in den Anteil DIESES Projekts und den Anteil der übrigen
// Projekte der Sitzung (sonst wäre eine Überbuchung hier unsichtbar:
// jemand kann in diesem Projekt entspannt aussehen und trotzdem in vier
// weiteren ausgebucht sein — dieselbe Überlegung wie zuvor, nur jetzt in
// Stunden statt Tagen).
//
// 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 TODOs in DIESEM Projekt, rein informativ — zählt
// nicht mehr in die Stundenrechnung mit ein).
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);
});
todos.forEach(t => {
if(!Array.isArray(t.assignees)) return;
t.assignees.forEach(pid => {
if(loads[pid]) loads[pid].todos.push(t.title);
});
});
((app.week && app.week.projects) || []).forEach(pr => {
const aktivePhase = lpActiveList(pr)[0];
Object.keys(pr.assign || {}).forEach(aKey => {
const pid = aKey.slice(0, aKey.indexOf('|'));
if(!loads[pid]) return; // nur Personen, die auch in DIESEM Projekt beteiligt sind
const rolle = aKey.slice(aKey.indexOf('|') + 1);
const al = berechneAuslastung(null, pr, aktivePhase, rolle, pr.assign[aKey], pr.assign);
if(al.stunden <= 0) return;
if(pr.id === app.project.id){
loads[pid].stundenProjekt += al.stunden;
} else {
loads[pid].stundenAndere += al.stunden;
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:10px;margin-top:2px">${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>`;
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:10px;color:#888;font-weight:normal">(${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</span></div>
<div class="bar" style="height:6px;margin-top:4px"><div style="width:${breite}%;background:${ampel}"></div></div>
${otherBadge}
<div class="load-detail" style="font-size:10px;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);
});
})();