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>
483 lines
24 KiB
JavaScript
483 lines
24 KiB
JavaScript
// -----------------------------
|
||
// 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);
|
||
});
|
||
})(); |