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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:16:52 +01:00

265 lines
14 KiB
JavaScript

// -----------------------------
// Projektbereich — Bereichs-Navigation & Dashboard
// -----------------------------
// Der Projektbereich ist der EIGENE BEREICH eines Projekts — seit dem Umbau
// kein eigenes Dokument mehr, sondern der Bereich #plan der Hauptseite
// App.html. Er gliedert sich in sieben Unterbereiche, zwischen denen die
// Leiste .plan-nav umschaltet (Markup in App.html):
//
// dashboard — Kennzahlen, Sitzungskommentar, Leistungsphasen-
// Fortschritt, nächste Termine, Team, Kompakt-Karten
// terminplan — GANTT mit Werkzeugleiste und Ressourcen-Auslastung
// aufgaben — Aufgaben- und Meilensteinliste (js/plan/plan-tasks.js)
// leistung — Leistungsstand & Abrechnung (js/plan/plan-leistung.js)
// dateien — Datei-Upload/-Freigabe (js/plan/plan-files.js)
// protokolle — Besprechungsprotokolle (js/plan/plan-protokoll.js)
// einstellungen — Projektangaben, Honorar, beauftragte Leistungsphasen,
// Beteiligte (js/plan/plan-settings.js)
//
// Alle Bereiche liegen gleichzeitig im DOM und werden nur über die Klasse
// .is-active ein- bzw. ausgeblendet. Das ist Absicht: die vorhandenen
// Render-Funktionen (renderPlanInfo, renderResourceLoad, renderProjectFiles …)
// befüllen ihre Elemente unabhängig davon, ob der Bereich gerade sichtbar ist,
// und brauchten für den Umbau keine Änderung.
//
// Der gewählte Bereich steht im URL-Fragment (z. B. …/Projektplan/Haus%20A
// ?weekLabel=…&projNr=…#aufgaben), damit ein Link auf einen bestimmten Bereich
// verschickt werden kann. Ein unbekanntes Fragment fällt auf "dashboard"
// zurück.
//
// Läuft wie alle plan-Module in einem IIFE; Zustand: window.PlanApp
// (plan-state.js).
(function(){
const app = window.PlanApp;
// PLAN_SECTIONS: gültige Bereichsschlüssel in der Reihenfolge der
// Navigationsleiste. Muss zu den data-section-Attributen in
// App.html passen.
const PLAN_SECTIONS = ['dashboard', 'terminplan', 'phasen', 'aufgaben', 'leistung', 'dateien', 'protokolle', 'einstellungen'];
// showPlanSection: schaltet auf einen Bereich um, hebt den zugehörigen
// Knopf hervor und rendert den Inhalt des Bereichs neu. Das Neu-Rendern ist
// beim Terminplan zwingend: die Abhängigkeitslinien im GANTT werden aus
// Pixel-Positionen berechnet, die in einem ausgeblendeten Container alle 0
// wären. Parameter: key (Bereichsschlüssel). Keine Rückgabe.
window.showPlanSection = function(key){
if(PLAN_SECTIONS.indexOf(key) === -1) key = 'dashboard';
app.section = key;
PLAN_SECTIONS.forEach(k => {
const sec = document.getElementById('planSection-' + k);
if(sec) sec.classList.toggle('is-active', k === key);
});
document.querySelectorAll('#planNav .plan-nav-item').forEach(btn => {
btn.classList.toggle('is-active', btn.dataset.section === key);
});
// Bereich im URL-Fragment festhalten (teilbarer Link), ohne einen
// History-Eintrag je Klick zu erzeugen.
try {
history.replaceState(null, '', location.pathname + location.search + '#' + key);
} catch(e) { /* z. B. file:// — dann bleibt die Adresse eben unverändert */ }
if(!app.project) return;
if(key === 'terminplan' && typeof renderPlanGantt === 'function') renderPlanGantt();
if(key === 'phasen' && typeof renderPlanPhasen === 'function') renderPlanPhasen();
if(key === 'aufgaben' && typeof renderPlanTasks === 'function') renderPlanTasks();
if(key === 'leistung' && typeof renderPlanLeistung === 'function') renderPlanLeistung();
if(key === 'protokolle' && typeof renderPlanProtokolle === 'function') renderPlanProtokolle();
if(key === 'einstellungen' && typeof renderPlanSettings === 'function') renderPlanSettings();
if(key === 'dashboard' && typeof window.renderPlanDashboard === 'function') window.renderPlanDashboard();
};
// initPlanSections: wählt beim Seitenaufbau den Bereich aus dem
// URL-Fragment (sonst "dashboard"). Wird von renderPlanPage()
// (plan-state.js) aufgerufen, also erst wenn Woche und Projekt geladen
// sind. Keine Parameter, keine Rückgabe.
function initPlanSections(){
const fromHash = (location.hash || '').replace(/^#/, '');
// Ein Link auf #dateien/#leistung/#protokolle eines Sandbox-Plans (dessen
// Knöpfe ausgeblendet sind, siehe renderPlanPage(), plan-state.js) fiele
// sonst auf einen Bereich zurück, der zwar existiert, aber keinen
// sichtbaren Weg mehr dorthin hätte. Protokolle hängen wie Dateien an
// project.nr, den ein Sandbox-Plan nicht hat.
const gesperrtFuerSandbox = app.isSandboxPlan()
&& (fromHash === 'dateien' || fromHash === 'leistung' || fromHash === 'protokolle');
window.showPlanSection(!gesperrtFuerSandbox && PLAN_SECTIONS.indexOf(fromHash) !== -1 ? fromHash : 'dashboard');
}
// hidePlanNav: blendet die Bereichsleiste aus. Wird von plan-state.js
// gerufen, wenn gar kein Projekt geladen werden konnte (fehlende
// URL-Parameter, unbekannte Sitzung, Ladefehler) — dann führte jeder
// Bereich nur in eine leere Maske. Keine Parameter, keine Rückgabe.
function hidePlanNav(){
const nav = document.getElementById('planNav');
if(nav) nav.style.display = 'none';
}
// showPlanNav: Gegenstück zu hidePlanNav(). Nötig, seit der Projektbereich
// ein Bereich der Hauptseite ist und nacheinander mehrere Projekte zeigen
// kann: Nach einem Fehlschlag (hidePlanNav) muss die Leiste beim nächsten,
// erfolgreichen openPlanView() wieder erscheinen. Auf der früheren
// eigenständigen Seite war das unnötig, weil jeder Aufruf eine frische
// Seite lud. Keine Parameter, keine Rückgabe.
function showPlanNav(){
const nav = document.getElementById('planNav');
if(nav) nav.style.display = '';
}
// upcomingEntries: offene Aufgaben mit Endtermin und Meilensteine ab heute
// (vergangene Meilensteine haben kein Erledigt-Kennzeichen und gelten als
// gewesen), chronologisch. idx verweist in plan.todos/plan.milestones, damit
// ein Klick denselben Dialog öffnet wie im Terminplan.
// Rückgabe: Array von { art, idx, titel, datum, iso, wer, meins }.
function upcomingEntries(){
const plan = app.ensurePlanLocal();
const heute = heuteIso();
const ich = currentUser && currentUser.personId;
const namen = {};
((app.week && app.week.people) || []).forEach(x => { namen[x.id] = x.fullName || x.name; });
const rows = [];
(plan.todos || []).forEach((t, idx) => {
if(t.done || !t.end) return;
const wer = (t.assignees || []).map(id => namen[id] || id);
rows.push({ art: 'aufgabe', idx, titel: t.title || 'Aufgabe', iso: t.end, wer,
meins: !!ich && (t.assignees || []).includes(ich) });
});
(plan.milestones || []).forEach((m, idx) => {
if(!m.date || m.date < heute) return;
rows.push({ art: 'meilenstein', idx, titel: m.name || 'Meilenstein', iso: m.date, wer: [], meins: false });
});
rows.sort((a, b) => a.iso.localeCompare(b.iso));
return rows;
}
// PLAN_DASH_ANTEIL: wie viele Einträge je Gruppe offen dastehen — jede
// Gruppe bekommt ihren eigenen Anteil, sonst füllten 25 überfällige
// Aufgaben die Liste und "Diese Woche" stand ohne Einträge da. Der Rest
// steckt hinter "… weitere in Aufgaben".
const PLAN_DASH_ANTEIL = { ueber: 4, woche: 4, spaeter: 3 };
// renderPlanDashboard: füllt "Termine und Aufgaben" (gegliedert nach
// Überfällig / Diese Woche / Später) und "Team" (eine Zeile je PERSON mit
// ihren Rollen als Chips — vorher stand jemand mit drei Rollen dreimal da).
// Kennzahlenband, Kommentar, Leistungsphasen und Unterlagen füllen
// renderPlanKpis()/renderPlanInfo()/renderResourceLoad()/
// renderProjectFiles(). Keine Parameter.
window.renderPlanDashboard = function(){
if(!app.project) return;
const locked = app.isLocked();
const upcomingEl = document.getElementById('planDashUpcoming');
if(upcomingEl){
const heute = heuteIso();
const wocheEnde = isoPlusTage(7);
const alle = upcomingEntries();
if(alle.length === 0){
upcomingEl.innerHTML = '<div class="plan-task-empty">Keine offenen Termine. Aufgaben und Meilensteine entstehen im Terminplan.</div>';
} else {
const gruppen = [
{ key: 'ueber', titel: 'Überfällig', klasse: 'ist-rot', treffer: r => r.art === 'aufgabe' && r.iso < heute },
{ key: 'woche', titel: 'Diese Woche', klasse: '', treffer: r => r.iso >= heute && r.iso <= wocheEnde },
{ key: 'spaeter', titel: 'Später', klasse: '', treffer: r => r.iso > wocheEnde },
];
let gezeigt = 0;
let html = '';
gruppen.forEach(g => {
const alleDerGruppe = alle.filter(g.treffer);
const sichtbar = alleDerGruppe.slice(0, PLAN_DASH_ANTEIL[g.key]);
gezeigt += sichtbar.length;
if(!alleDerGruppe.length) return;
html += `<div class="plan-termin-gruppe ${g.klasse}">${esc(g.titel)}<span>${alleDerGruppe.length}</span></div>`;
html += sichtbar.map(r => {
const klick = locked ? '' : ` onclick="${r.art === 'meilenstein' ? 'openMilestoneModal' : 'openTodoModal'}(${r.idx})"`;
const zeichen = r.art === 'meilenstein' ? '◆' : (r.meins ? 'A' : 'a');
const klasse = r.art === 'meilenstein' ? 'ist-ms' : (r.meins ? 'ist-meins' : '');
const wer = r.art === 'meilenstein' ? 'Meilenstein' : (r.wer.length ? r.wer.join(', ') : 'niemand zugewiesen');
const rot = r.art === 'aufgabe' && r.iso < heute;
return `<div class="plan-termin${locked ? '' : ' ist-klickbar'}"${klick}>
<span class="plan-termin-art ${klasse}">${zeichen}</span>
<span class="plan-termin-text"><b>${esc(r.titel)}</b><span>${esc(wer)}</span></span>
<span class="plan-termin-datum${rot ? ' ist-rot' : ''}">${esc(formatDateDE(parseDateISO(r.iso)))}<em>${esc(relativerTag(r.iso))}</em></span>
</div>`;
}).join('');
});
const verborgen = alle.length - gezeigt;
if(verborgen > 0){
html += `<button type="button" class="plan-panel-link plan-termin-mehr" onclick="showPlanSection('aufgaben')">${verborgen} weitere in „Aufgaben“</button>`;
}
upcomingEl.innerHTML = html;
}
}
const teamEl = document.getElementById('planDashTeam');
if(teamEl){
const assignees = projectAssignees(app.project, app.week);
if(assignees.length === 0){
teamEl.innerHTML = '<div class="plan-task-empty">Diesem Projekt ist noch niemand zugeordnet. Zuordnung im Bereich „Einstellungen“.</div>';
} else {
const plan = app.ensurePlanLocal();
const jePerson = new Map();
assignees.forEach(a => {
if(!jePerson.has(a.person.id)) jePerson.set(a.person.id, { person: a.person, rollen: [] });
jePerson.get(a.person.id).rollen.push({ rolle: a.role, state: a.state });
});
const zeile = e => {
const offen = (plan.todos || []).filter(t => !t.done && Array.isArray(t.assignees) && t.assignees.indexOf(e.person.id) !== -1).length;
const haupt = e.rollen.find(r => r.state === 1) || e.rollen[0];
return `<div class="plan-team-zeile">
<span class="board-avatar ${app.planRoleClass(haupt.rolle)} state-${haupt.state}">${esc(e.person.name)}</span>
<span class="plan-team-text"><b>${esc(e.person.fullName || e.person.name)}</b>
<span class="plan-team-rollen">${e.rollen.map(r => `<span class="plan-rolle${r.state === 2 ? ' ist-neben' : ''}" title="${r.state === 2 ? 'Unterstützung' : 'Hauptverantwortung'}">${esc(r.rolle || '')}</span>`).join('')}</span></span>
<span class="plan-team-offen">${offen} offen</span>
</div>`;
};
// Nach Gewerk gruppiert (Eriks Wahl "A"): erst die Gewerke des
// Projekts in fester Reihenfolge — auch unbesetzte, mit Hinweis —,
// dann Gewerke von Beteiligten, die nicht zum Projekt gehören, zuletzt
// Personen ohne bekanntes Gewerk. Das Gewerk einer Person kommt aus
// ihrem Konto (personGewerk(), users.department).
const reihenfolge = Object.keys(DEPARTMENTS);
const projGewerke = reihenfolge.filter(g => (app.project.gewerke || []).indexOf(g) !== -1);
const gruppen = new Map(projGewerke.map(g => [g, []]));
[...jePerson.values()].forEach(e => {
const g = personGewerk(e.person) || '';
if(!gruppen.has(g)) gruppen.set(g, []);
gruppen.get(g).push(e);
});
const schluessel = [...gruppen.keys()].sort((x, y) => {
const rang = g => g === '' ? 99 : (projGewerke.indexOf(g) !== -1 ? projGewerke.indexOf(g) : 10 + reihenfolge.indexOf(g));
return rang(x) - rang(y);
});
teamEl.innerHTML = '<div class="plan-team">' + schluessel.map(g => {
const leute = gruppen.get(g);
const fremd = g !== '' && projGewerke.indexOf(g) === -1;
const titel = g === '' ? 'Ohne Gewerk' : ((DEPARTMENTS[g] && DEPARTMENTS[g].label !== g) ? DEPARTMENTS[g].label : '');
return `<div class="plan-team-gruppe">
<div class="plan-team-gewerk">
${g ? `<span class="plan-gewerk-schild">${esc(g)}</span>` : ''}
<span class="plan-gewerk-titel">${esc(titel)}${fremd ? (titel ? ' · ' : '') + 'kein Gewerk des Projekts' : ''}</span>
<span class="plan-gewerk-linie"></span>
<span class="plan-gewerk-anzahl">${leute.length === 1 ? '1 Person' : leute.length + ' Personen'}</span>
</div>
${leute.length ? leute.map(zeile).join('') : '<div class="plan-team-luecke">Noch niemand aus diesem Gewerk zugeordnet.</div>'}
</div>`;
}).join('') + '</div>';
}
}
};
// Wird das Fragment von außen geändert (Vor-/Zurück-Taste, von Hand
// getippter Anker, Link auf dieselbe Seite mit anderem Bereich), dann
// mitziehen. showPlanSection() selbst benutzt history.replaceState() und
// löst dieses Ereignis daher nicht aus.
window.addEventListener('hashchange', () => {
if(currentTab !== 'plan') return;
const key = (location.hash || '').replace(/^#/, '');
if(PLAN_SECTIONS.indexOf(key) !== -1 && key !== app.section) window.showPlanSection(key);
});
app.initPlanSections = initPlanSections;
app.hidePlanNav = hidePlanNav;
app.showPlanNav = showPlanNav;
})();