// ----------------------------- // Projektübersicht — kommentierter Quellcode // Autor: (kommentiert automatisch) // Hinweis: Kommentare erklären die Struktur und Funktion der wichtigsten // Teile. Funktionaler Code wurde nicht verändert. // ----------------------------- // Konstanten: Leistungsphasen, Rollen und LocalStorage-Key const LPHS = ["LPH 1","LPH 2","LPH 3","LPH 4","LPH 5","LPH 6","LPH 7","LPH 8", "W+M"]; // Spalten für Leistungsphasen const ROLES = ["Planung","Projektleitung","Bauüberwachung"]; // Gruppierung der Personen const GROUP_ORDER = ["Aktive Projekte","Kleine Projekte","In Anbahnung","Allgemein"]; // Reihenfolge der Projektgruppen const STORAGE = "projuebersicht_v1"; // Durch lokale UI-State-Persistenz genutzter Key const DEFAULT_DEPARTMENT = 'ELT'; const DEFAULT_TAB = 'board'; // Farbpalette für GANTT-Balken (eine pro Projekt, wiederholend) const GANTT_COLORS = ['#0071e3','#248a3d','#b25000','#7c3aed','#d70015','#0ca678','#d9480f','#5f3dc4']; // Passwortschutz: SHA256-Hash des erlaubten Passworts. // Wenn Sie das Passwort ändern möchten, generieren Sie den SHA256-Hash (https://emn178.github.io/online-tools/sha256.html) // des neuen Passworts und ersetzen Sie den Wert hier. const PROTECTED_PASSWORD_HASH = "107bab579e8500e459690cf48ba77de32be43cadb8421bbe79fad20a55b39b6f"; // requestedAction speichert die Aktion, die nach erfolgreicher Passwort-Eingabe ausgeführt wird. let requestedAction = null; let saveSyncTimer = null; let isAuthenticated = false; // In-memory Datenbank: Sitzungen (weeks) und aktuell ausgewählte Sitzung let db = { weeks: [], currentWeek: null, globalPeople: {} }; // Aktuelle Abteilung (ELT, HLS, ARCH). Daten werden in sessions// getrennt gespeichert. let currentDepartment = DEFAULT_DEPARTMENT; let currentTab = DEFAULT_TAB; const DEPARTMENTS = { ELT: { label: 'Elektro', icon: '' }, HLS: { label: 'HLS', icon: '' }, ARCH: { label: 'Architektur', icon: '' } }; // departmentParam: gibt ein Query-String-Fragment fuer die aktuelle Abteilung zurueck. function departmentParam(){ return '?department=' + encodeURIComponent(currentDepartment); } function saveUiState(){ try { const state = { department: currentDepartment, tab: currentTab }; window.localStorage.setItem(STORAGE, JSON.stringify(state)); } catch (err) { console.warn('UI-State kann nicht gespeichert werden:', err); } } function loadUiState(){ try { const state = JSON.parse(window.localStorage.getItem(STORAGE) || '{}'); if(state.department && DEPARTMENTS[state.department]) currentDepartment = state.department; // Tab wird nicht aus dem localStorage wiederhergestellt, damit nach dem // Neuladen immer das Projektboard angezeigt wird. } catch (err) { console.warn('UI-State kann nicht geladen werden:', err); } } // ---------- Datumshilfsfunktionen (GANTT) ---------- function parseDateISO(d){ if(!d) return null; const m = String(d).match(/^(\d{4})-(\d{2})-(\d{2})$/); if(!m) return null; const date = new Date(parseInt(m[1]), parseInt(m[2])-1, parseInt(m[3])); if(isNaN(date.getTime())) return null; return date; } function formatDateISO(d){ if(!d || isNaN(d.getTime())) return ''; const y = d.getFullYear(); const m = String(d.getMonth()+1).padStart(2,'0'); const day = String(d.getDate()).padStart(2,'0'); return `${y}-${m}-${day}`; } function addDays(d, n){ const res = new Date(d.getTime()); res.setDate(res.getDate() + n); return res; } function daysBetween(a, b){ const ms = b.getTime() - a.getTime(); return Math.round(ms / (1000 * 60 * 60 * 24)); } // workdaysBetween: Anzahl der Werktage (Mo–Fr) im Zeitraum inkl. Start- und Endtag. // Wochenendtage (Sa/So) werden nicht mitgezählt – z.B. für die Auslastung. function workdaysBetween(a, b){ if(!a || !b || b < a) return 0; let count = 0; let current = new Date(a.getTime()); while(current <= b){ const day = current.getDay(); if(day !== 0 && day !== 6) count++; current = addDays(current, 1); } return count; } function formatDateDE(d){ if(!d || isNaN(d.getTime())) return ''; return `${String(d.getDate()).padStart(2,'0')}.${String(d.getMonth()+1).padStart(2,'0')}.${d.getFullYear()}`; } // ISO-Kalenderwoche nach https://en.wikipedia.org/wiki/ISO_week_date function getWeekNumber(d){ const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); const dayNum = date.getUTCDay() || 7; date.setUTCDate(date.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1)); return Math.ceil((((date - yearStart) / 86400000) + 1) / 7); } // ---------- Datumsbereich-Picker ---------- // Ein Kalender-Popup für Von+Bis: erster Klick setzt das Startdatum, // zweiter Klick das Enddatum (bei Klick vor dem Start wird getauscht). // Schreibt ISO-Werte in zwei (versteckte) Inputs und aktualisiert optional // ein Anzeige-Element. Verwendung: openRangePicker({ anchor, startInputId, endInputId, displayId, onApply }) let rangePickerState = null; function openDatePicker(opts){ closeRangePicker(); const input = document.getElementById(opts.inputId); if(!input) return; const preValue = parseDateISO(input.value); const today = new Date(); rangePickerState = { opts: Object.assign({}, opts, { mode: 'single' }), start: preValue, end: preValue, mode: 'single', // Ansichtsmonat: vorhandener Wert, sonst heutiger Monat viewYear: (preValue || today).getFullYear(), viewMonth: (preValue || today).getMonth() }; const pop = document.createElement('div'); pop.id = 'rangePickerPopup'; pop.className = 'range-picker'; document.body.appendChild(pop); const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 }; pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px'; pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px'; renderRangePicker(); setTimeout(() => { document.addEventListener('mousedown', rangePickerOutsideClick); }, 0); } function openRangePicker(opts){ closeRangePicker(); const isSingle = (opts.mode || 'range') === 'single'; const startInput = document.getElementById(isSingle ? (opts.inputId || opts.startInputId) : opts.startInputId); const endInput = isSingle ? null : document.getElementById(opts.endInputId); if(!startInput || (!isSingle && !endInput)) return; const preStart = parseDateISO(startInput.value); const preEnd = isSingle ? preStart : parseDateISO(endInput.value); const today = new Date(); rangePickerState = { opts: Object.assign({}, opts, { mode: isSingle ? 'single' : 'range' }), start: preStart, end: preEnd, mode: isSingle ? 'single' : 'range', // Ansichtsmonat: vorhandener Start, sonst heutiger Monat viewYear: (preStart || today).getFullYear(), viewMonth: (preStart || today).getMonth() }; const pop = document.createElement('div'); pop.id = 'rangePickerPopup'; pop.className = 'range-picker'; document.body.appendChild(pop); // Position: unterhalb des Ankers, innerhalb des Viewports halten const anchorRect = opts.anchor ? opts.anchor.getBoundingClientRect() : { left: window.innerWidth/2 - 140, bottom: window.innerHeight/2 - 160 }; pop.style.left = Math.max(8, Math.min(anchorRect.left, window.innerWidth - 296)) + 'px'; pop.style.top = Math.min(anchorRect.bottom + 6, window.innerHeight - 340) + 'px'; renderRangePicker(); // Klick außerhalb schließt das Popup (ohne Übernahme) setTimeout(() => { document.addEventListener('mousedown', rangePickerOutsideClick); }, 0); } function rangePickerOutsideClick(e){ const pop = document.getElementById('rangePickerPopup'); if(pop && !pop.contains(e.target)) closeRangePicker(); } function closeRangePicker(){ const pop = document.getElementById('rangePickerPopup'); if(pop) pop.remove(); document.removeEventListener('mousedown', rangePickerOutsideClick); rangePickerState = null; } function rangePickerNav(delta){ if(!rangePickerState) return; let m = rangePickerState.viewMonth + delta; let y = rangePickerState.viewYear; if(m < 0){ m = 11; y--; } if(m > 11){ m = 0; y++; } rangePickerState.viewMonth = m; rangePickerState.viewYear = y; renderRangePicker(); } function rangePickerSelect(iso){ if(!rangePickerState) return; const d = parseDateISO(iso); if(!d) return; const st = rangePickerState; if(st.mode === 'single'){ st.start = d; st.end = d; applyRangePicker(); return; } if(!st.start || (st.start && st.end)){ // Neuen Bereich beginnen st.start = d; st.end = null; } else { // Zweiter Klick: Ende setzen, bei Bedarf tauschen if(d < st.start){ st.end = st.start; st.start = d; } else st.end = d; applyRangePicker(); return; } renderRangePicker(); } function applyRangePicker(){ if(!rangePickerState) return; const { opts, start, end } = rangePickerState; const isSingle = (opts.mode || 'range') === 'single'; const startInput = document.getElementById(isSingle ? (opts.inputId || opts.startInputId) : opts.startInputId); const endInput = isSingle ? null : document.getElementById(opts.endInputId); const selected = start || end; if(startInput) startInput.value = isSingle ? (selected ? formatDateISO(selected) : '') : (start ? formatDateISO(start) : ''); if(endInput) endInput.value = end ? formatDateISO(end) : (start ? formatDateISO(start) : ''); if(opts.displayId){ const disp = document.getElementById(opts.displayId); if(disp){ const from = start ? formatDateDE(start) : ''; const to = end ? formatDateDE(end) : from; disp.value = isSingle ? (from || '') : (from ? (from + ' – ' + to) : ''); } } const cb = opts.onApply; closeRangePicker(); if(typeof cb === 'function') cb(); } function renderRangePicker(){ const pop = document.getElementById('rangePickerPopup'); if(!pop || !rangePickerState) return; const st = rangePickerState; const monthNames = ['Januar','Februar','März','April','Mai','Juni','Juli','August','September','Oktober','November','Dezember']; const first = new Date(st.viewYear, st.viewMonth, 1); // Wochentag des Monatsersten, Montag = 0 const firstWeekday = (first.getDay() + 6) % 7; const daysInMonth = new Date(st.viewYear, st.viewMonth + 1, 0).getDate(); const today = new Date(); const todayMid = new Date(today.getFullYear(), today.getMonth(), today.getDate()); let hint; if(st.mode === 'single') hint = 'Datum wählen'; else if(!st.start || (st.start && st.end)) hint = 'Startdatum wählen'; else hint = 'Enddatum wählen (Start: ' + formatDateDE(st.start) + ')'; let html = `
${monthNames[st.viewMonth]} ${st.viewYear}
${hint}
`; ['Mo','Di','Mi','Do','Fr','Sa','So'].forEach(w => html += `
${w}
`); for(let i = 0; i < firstWeekday; i++) html += '
'; for(let day = 1; day <= daysInMonth; day++){ const d = new Date(st.viewYear, st.viewMonth, day); const iso = formatDateISO(d); const classes = ['range-picker-day']; if(d.getDay() === 0 || d.getDay() === 6) classes.push('weekend'); if(d.getTime() === todayMid.getTime()) classes.push('today'); if(st.start && d.getTime() === st.start.getTime()) classes.push('sel-start'); if(st.end && d.getTime() === st.end.getTime()) classes.push('sel-end'); if(st.start && st.end && d > st.start && d < st.end) classes.push('in-range'); html += `
${day}
`; } html += '
'; html += ``; pop.innerHTML = html; } // ---------- Kollabierbarer Header ---------- const HEADER_COLLAPSED_KEY = 'projuebersicht-header-collapsed'; function loadHeaderState(){ let collapsed = true; // Default: Menü ist beim ersten Start geschlossen try { const stored = window.localStorage.getItem(HEADER_COLLAPSED_KEY); if(stored !== null){ collapsed = stored === 'true'; } } catch (err) {} setHeaderCollapsed(collapsed, false); } function saveHeaderState(collapsed){ try { window.localStorage.setItem(HEADER_COLLAPSED_KEY, collapsed ? 'true' : 'false'); } catch (err) {} } function setHeaderCollapsed(collapsed, persist){ const menu = document.getElementById('headerMenu'); const btn = document.querySelector('.header-bar .header-toggle'); const icon = document.querySelector('.header-bar .header-toggle-icon'); if(!menu) return; if(collapsed){ menu.classList.remove('open'); menu.setAttribute('aria-hidden', 'true'); if(icon) icon.textContent = '☰'; if(btn) btn.setAttribute('aria-expanded', 'false'); } else { menu.classList.add('open'); menu.setAttribute('aria-hidden', 'false'); if(icon) icon.textContent = '✕'; if(btn) btn.setAttribute('aria-expanded', 'true'); } if(persist) saveHeaderState(collapsed); } function toggleHeader(){ const menu = document.getElementById('headerMenu'); const isOpen = menu && menu.classList.contains('open'); setHeaderCollapsed(isOpen, true); } // ---------- Theme (Light/Dark) ---------- function applyThemeIcon(theme){ const icon = document.getElementById('themeIcon'); if(!icon) return; icon.textContent = theme === 'dark' ? '☀️' : '🌙'; } function loadTheme(){ let theme = 'auto'; try { theme = window.localStorage.getItem('projuebersicht-theme') || 'auto'; } catch (err) { console.warn('Theme kann nicht geladen werden:', err); } applyTheme(theme, false); } function saveTheme(theme){ try { window.localStorage.setItem('projuebersicht-theme', theme); } catch (err) { console.warn('Theme kann nicht gespeichert werden:', err); } } function effectiveTheme(theme){ if(theme === 'dark') return 'dark'; if(theme === 'light') return 'light'; return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } function applyTheme(theme, persist){ const html = document.documentElement; const eff = effectiveTheme(theme); if(theme === 'dark') html.setAttribute('data-theme', 'dark'); else if(theme === 'light') html.setAttribute('data-theme', 'light'); else html.removeAttribute('data-theme'); applyThemeIcon(eff); if(persist) saveTheme(theme); } function toggleTheme(){ const html = document.documentElement; const currentAttr = html.getAttribute('data-theme'); const currentTheme = currentAttr || 'auto'; const currentEffective = effectiveTheme(currentTheme); const next = currentEffective === 'dark' ? 'light' : 'dark'; applyTheme(next, true); } // Bei Änderung der Systemeinstellung im "Auto"-Modus sofort anpassen if(window.matchMedia){ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(){ let theme = 'auto'; try { theme = window.localStorage.getItem('projuebersicht-theme') || 'auto'; } catch (err) {} applyTheme(theme, false); }); } // ---------- Persistence (CSV-only via localhost server) ---------- // save: synchronisiert die aktuellen Daten als CSV-Dateien zum Server. // Jede Sitzung wird als eigene CSV-Datei gespeichert. Personen und Rollen werden // in der Sitzungscsv selbst abgelegt. function save(){ scheduleSessionSync(); } function scheduleSessionSync(){ if(saveSyncTimer){ clearTimeout(saveSyncTimer); } saveSyncTimer = setTimeout(() => { syncAllToServer(); saveSyncTimer = null; }, 500); } function syncAllToServer(){ // Zuerst globale Personenliste speichern saveGlobalPeopleToServer(); // Dann jede Sitzung speichern db.weeks.forEach(w => { const filename = filenameForWeek(w.label); const csv = weekToCsv(w); sendCsvToServer(filename, csv) .then(res => { if(res && res.ok) console.log('Sitzung gespeichert:', filename); }) .catch(err => { console.error('Sitzung konnte nicht gespeichert werden:', filename, err.message || err); }); }); } function saveGlobalPeopleToServer(){ const people = db.globalPeople[currentDepartment] || []; if(people.length === 0) return; const csv = globalPeopleToCsv(people); sendCsvToServer('_people.csv', csv) .then(res => { if(res && res.ok) console.log('Globale Personenliste gespeichert'); }) .catch(err => { console.error('Globale Personenliste konnte nicht gespeichert werden:', err.message || err); }); } // baseUrl: dynamische Server-URL, damit die App auch von anderen Geraeten im Netzwerk erreichbar ist. // Fallback auf localhost:3003, wenn die Seite als lokale Datei geladen wurde. function baseUrl(){ const origin = window.location.origin; if(typeof origin === 'string' && /^https?:\/\//.test(origin)){ return origin; } return 'http://localhost:3003'; } // switchDepartment: wechselt die aktive Abteilung und lädt deren Daten neu. function switchDepartment(dept){ if(!DEPARTMENTS[dept]) return; currentDepartment = dept; saveUiState(); // UI-Status der Abteilungs-Tabs aktualisieren document.querySelectorAll('.dept-tab').forEach(btn => { btn.classList.toggle('active', btn.dataset.dept === dept); }); // Seitentitel anpassen const info = DEPARTMENTS[dept]; document.title = 'Projektübersicht ' + info.label; const titleEl = document.getElementById('pageTitle'); if(titleEl) titleEl.textContent = info.icon + ' Projektübersicht ' + info.label; // Datenbank leeren und neu laden db = { weeks: [], currentWeek: null, globalPeople: db.globalPeople || {} }; load(); } // load: liest alle CSV-Dateien vom Server ein und baut daraus die Datenbank auf. function load(){ if(!window.fetch){ alert('Ihr Browser unterstützt keine Netzwerk-Anfragen. Bitte verwenden Sie einen aktuellen Browser.'); seed(); return; } const url = baseUrl() + '/load-all' + departmentParam(); console.log('Loading from', url); fetch(url) .then(r => { if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }) .then(data => { // Globale Personenliste laden db.globalPeople[currentDepartment] = parseGlobalPeopleCsv(data.people); const parsedSessions = (data.sessions || []).map(s => ({ filename: s.filename, rows: parseCsvText(s.content) })); db.weeks = []; parsedSessions.forEach(s => { const w = parseSessionCsv(s.filename, s.rows); if(w) db.weeks.push(w); }); if(db.weeks.length === 0){ console.warn('Server lieferte 0 Sitzungsdateien. Es werden Beispieldaten (seed) angezeigt. Pruefen Sie, ob der Ordner sessions/ mitkopiert wurde.'); seed(); } else { db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))); applyAutoLock(); const latest = db.weeks[db.weeks.length - 1]; if(!db.currentWeek || !db.weeks.some(w => w.id === db.currentWeek)){ db.currentWeek = latest.id; } console.log(`Server-Load erfolgreich: ${db.weeks.length} Sitzung(en).`); } // Geschützte Tabs dürfen nicht ohne Passwort angezeigt werden, auch wenn // sie im UI-State gespeichert waren. if(currentTab === 'projects' || currentTab === 'people') currentTab = DEFAULT_TAB; renderAll(); showTab(currentTab, true); }) .catch(err => { console.error('Laden vom Server fehlgeschlagen:', err); const serverUrl = baseUrl(); const msg = 'Server unter ' + serverUrl + ' nicht erreichbar.\n\n' + 'Mögliche Ursachen:\n' + '- Der Server wurde nicht gestartet\n' + '- Der Server läuft auf einem anderen Port (Standard: 3003)\n' + '- Firewall/Antivirus blockiert ' + serverUrl + '\n\n' + 'Es werden jetzt die Beispieldaten angezeigt.'; alert(msg); seed(); renderAll(); }); } // CSV-Hilfsfunktionen ---------------------------------------- function filenameForWeek(label){ return (label.replace(/[^\w]/g,'_'))+'.csv'; } function csvEscape(v){ const s = (v === null || v === undefined ? '' : String(v)); if(/[;"\n\r]/.test(s)) return '"' + s.replace(/"/g,'""') + '"'; return s; } function globalPeopleToCsv(people){ const rows = [['NAME', 'Rollen', 'Voller Name', 'E-Mail', 'Abwesenheiten']]; people.slice().sort((a,b) => a.name.localeCompare(b.name)).forEach(p => { const absences = Array.isArray(p.absences) ? JSON.stringify(p.absences) : ''; rows.push([p.name, (p.role || []).join(','), p.fullName || '', p.email || '', absences]); }); return '\ufeff' + rows.map(r => r.map(csvEscape).join(';')).join('\n'); } function parseGlobalPeopleCsv(text){ if(!text) return []; const rows = parseCsvText(text); if(!rows.length) return []; const people = []; rows.slice(1).forEach(row => { const name = (row[0] || '').trim(); if(!name) return; const roles = String(row[1] || 'Planung').split(',').map(x => x.trim()).filter(Boolean); const fullName = (row[2] || '').trim() || name; const email = (row[3] || '').trim(); let absences = []; if(row[4]){ try { const parsed = JSON.parse(row[4]); if(Array.isArray(parsed)) absences = parsed; } catch(e) { // Ignoriere fehlerhafte JSON-Daten } } people.push({ id: personIdForName(name), name, fullName, email, role: roles, absences }); }); return people; } // Persistente ID fuer eine Person aus dem Kuerzel, damit Anwesenheit und // Zuordnungen auch nach einem Neuladen stabil bleiben. function personIdForName(name){ return 'p_' + String(name).trim().toLowerCase().replace(/[^a-z0-9]/g,'_'); } function weekToCsv(w){ // allPeople enthält für das Board jede (Person, Rolle)-Kombination einmal. const grouped = peopleByRole(w); const allPeople = []; ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry))); const rows = []; // Metadaten-Zeile: Label, Datum und Sperrstatus der Sitzung rows.push(['WOCHE', w.label || '', w.date || '', w.locked ? '1' : '']); // PERSONEN-Zeile: Pro Spalte Name|Rolle|Voller Name, damit beim Laden // die Zuordnung pro Rolle eindeutig bleibt und der volle Name erhalten ist. // Der volle Name wird immer als dritter Teil gespeichert (auch leer), // damit er beim Bearbeiten nicht versehentlich verloren geht. const peopleMeta = allPeople.map(e => `${e.person.name}|${e.role}|${e.person.fullName || ''}`); rows.push(['PERSONEN', '', '', ...peopleMeta, ...LPHS.map(() => ''), '','','','','']); rows.push(['Pr.-Nr.','Bezeichnung','Status', ...allPeople.map(e => e.person.name), ...LPHS, 'Gruppe','Index','Plan','Aufwand','Aktive Phasen']); rows.push(['ANWESENHEIT','', ...allPeople.map(e => w.attendance && w.attendance[e.person.id] ? 'X' : ''), ...LPHS.map(() => ''),'','','','','']); w.projects.forEach(p => { const arr = [p.nr || '', p.name || '', p.status || '']; allPeople.forEach(entry => { const s = p.assign[assignKey(entry.person.id, entry.role)]; arr.push(s === 1 ? 'X' : s === 2 ? '(X)' : ''); }); LPHS.forEach(l => { const v = p.lph[l]; arr.push(v !== undefined && v !== '' ? String(v).replace(/%/g,'') + '%' : ''); }); arr.push(p.group || ''); arr.push(p.order !== undefined ? String(p.order) : ''); arr.push(p.plan ? JSON.stringify(p.plan) : ''); arr.push(p.aufwandProLP ? JSON.stringify(p.aufwandProLP) : ''); arr.push(p.aktuelleLP ? JSON.stringify(p.aktuelleLP) : ''); rows.push(arr); }); return '\ufeff' + rows.map(r => r.map(csvEscape).join(';')).join('\n'); } function parseCsvText(text){ // Manche ältere CSV-Dateien enthalten ein UTF-8-BOM gefolgt von einem // literalen "" (dreifach kodiert). Beides am Anfang entfernen. return text.replace(/^[\ufeff\u00EF\u00BB\u00BF]+/,'').split(/\r?\n/).filter(line => line.trim() !== '').map(parseCsvLine); } // Toleranter CSV-Zeilenparser. Versteht sowohl sauberes CSV als auch // das ältere Format, bei dem Felder nur ein öffnendes Anführungszeichen // hatten und das Semikolon innerhalb eines quoted Feldes gleichzeitig // als Trennzeichen diente. function parseCsvLine(line){ const result = []; let current = ''; let inQuotes = false; let hasUnclosedQuotes = false; let i = 0; while(i < line.length){ const c = line[i]; const n = line[i+1]; if(inQuotes){ if(c === '"' && n === '"'){ current += '"'; i += 2; } else if(c === ';' && n === '"'){ // Altes Trennzeichen \";\\" innerhalb eines quoted Feldes result.push(current); current = ''; inQuotes = false; i += 1; } else if(c === '"' && (n === ';' || n === undefined)){ result.push(current); current = ''; inQuotes = false; i += (n === ';' ? 2 : 1); } else { current += c; i++; } } else { if(c === ';'){ result.push(current); current = ''; i++; } else if(c === '"'){ inQuotes = true; i++; } else { current += c; i++; } } } if(inQuotes) hasUnclosedQuotes = true; result.push(current); if(hasUnclosedQuotes){ console.warn('CSV-Zeile enthält nicht geschlossene Anführungszeichen:', line.slice(0,80)); } return result; } function decodeWeekLabel(filename){ const base = filename.replace(/\.csv$/i,''); let label = base.replace(/_+/g,' ').trim(); if(/^KW \d+ \d+$/.test(label)){ label = label.replace(/^KW (\d+) (\d+)$/, 'KW $1 / $2'); } return label || base; } // Erzeugt einen Sortierschlüssel aus einem Wochenlabel, sodass z.B. // "KW 9 / 2026" vor "KW 10 / 2026" kommt. function weekSortKey(label){ const m = String(label || '').match(/KW\s*(\d+)[^\d]+(\d{4})/i); if(m){ const kw = m[1].padStart(2,'0'); return `${m[2]}-${kw}`; } return String(label || '').toLowerCase(); } function parseSessionCsv(filename, rows){ if(!rows || rows.length === 0) return null; let label, date, headerRowIdx = 0, attendanceRowIdx = 1, projectStartIdx = 2; let locked = false; let metaPeopleRow = null; if(rows[0][0] === 'WOCHE'){ label = rows[0][1] || decodeWeekLabel(filename); date = rows[0][2] || ''; locked = (rows[0][3] || '').trim() === '1'; if(rows[1] && rows[1][0] === 'PERSONEN'){ metaPeopleRow = rows[1]; headerRowIdx = 2; attendanceRowIdx = 3; } else { headerRowIdx = 1; attendanceRowIdx = 2; } } else { label = decodeWeekLabel(filename); date = ''; } projectStartIdx = attendanceRowIdx + 1; const header = rows[headerRowIdx]; if(!header || header.length < 4) return { id: uid(), label, date, people: [], projects: [], attendance: {} }; const lphStart = header.indexOf('LPH 1'); const groupIdx = header.indexOf('Gruppe'); const orderIdx = header.indexOf('Index'); const planIdx = header.indexOf('Plan'); const aufwandIdx = header.indexOf('Aufwand'); const aktiveIdx = header.indexOf('Aktive Phasen'); const peopleCols = header.slice(3, lphStart === -1 ? header.length : lphStart); const people = []; const colMap = []; const personMeta = metaPeopleRow ? metaPeopleRow.slice(3, lphStart === -1 ? metaPeopleRow.length : lphStart) : []; const personByName = {}; const nameOccurrences = {}; peopleCols.forEach((name, idx) => { const n = (name || '').trim(); if(!n){ colMap.push(null); return; } let roles = ['Planung']; let fullName = n; if(personMeta[idx]){ const parts = String(personMeta[idx]).split('|'); const rawRoles = parts[1] || ''; const parsedRoles = rawRoles.split(',').map(x => x.trim()).filter(x => ROLES.includes(x)); if(parsedRoles.length > 0) roles = parsedRoles; // Voller Name ist alles ab dem zweiten Pipe; damit sind auch Pipe-Zeichen // im Namen möglich. Leere Strings werden bewusst übernommen. if(parts.length >= 3) fullName = parts.slice(2).join('|').trim(); } else { const occurrence = nameOccurrences[n] || 0; roles = [ROLES[Math.min(occurrence, ROLES.length - 1)]]; nameOccurrences[n] = occurrence + 1; } // Gleiche Person (gleiches Kürzel) wird im Board pro Rolle mehrfach // dargestellt, soll aber im Datenmodell nur einmal existieren. let person = personByName[n]; if(!person){ person = { id: personIdForName(n), name: n, fullName: fullName, role: [] }; personByName[n] = person; people.push(person); } // Überschreibe den vollen Namen mit dem Wert aus der CSV; die CSV ist // hier die führende Datenquelle für den vollständigen Namen. person.fullName = fullName; // Sammle alle bekannten Rollen der Person; die aktuelle Spaltenrolle // wird für die Zuordnung über colMap verwendet. roles.forEach(r => { if(!person.role.includes(r)) person.role.push(r); }); colMap.push({ person, role: roles[0] }); }); const projects = []; for(let rIdx = projectStartIdx; rIdx < rows.length; rIdx++){ const row = rows[rIdx]; if(row.length < 4) continue; const nr = row[0] || ''; const name = row[1] || ''; const status = row[2] || ''; if(!nr.trim() && !name.trim()) continue; const assign = {}; peopleCols.forEach((_, i) => { const entry = colMap[i]; if(!entry) return; const v = (row[3+i] || '').trim(); const key = assignKey(entry.person.id, entry.role); if(v === 'X') assign[key] = 1; else if(v === '(X)') assign[key] = 2; }); const lph = {}; if(lphStart !== -1){ LPHS.forEach((l, j) => { const raw = row[lphStart + j]; if(raw !== undefined && raw !== ''){ const num = String(raw).replace(/%/g,''); if(!isNaN(parseFloat(num)) && isFinite(num)) lph[l] = num; } }); } let group = 'Aktive Projekte'; if(groupIdx >= 0 && row[groupIdx]) group = row[groupIdx].trim() || group; else if(row.length > 3) group = row[row.length - 1].trim() || group; let order = rIdx - projectStartIdx; if(orderIdx >= 0 && row[orderIdx] !== undefined && row[orderIdx] !== ''){ const parsed = parseInt(row[orderIdx], 10); if(!isNaN(parsed)) order = parsed; } let plan = null; if(planIdx >= 0 && row[planIdx]){ try { const parsed = JSON.parse(row[planIdx]); if(parsed && typeof parsed === 'object') plan = parsed; } catch(e) { console.warn('Plan-Daten für Projekt konnten nicht geparst werden:', nr, name, e); } } let aufwandProLP; if(aufwandIdx >= 0 && row[aufwandIdx]){ try { const parsed = JSON.parse(row[aufwandIdx]); if(parsed && typeof parsed === 'object') aufwandProLP = parsed; } catch(e) { console.warn('LP-Aufwand für Projekt konnten nicht geparst werden:', nr, name, e); } } let aktuelleLP; if(aktiveIdx >= 0 && row[aktiveIdx]){ try { const parsed = JSON.parse(row[aktiveIdx]); if(Array.isArray(parsed)) aktuelleLP = parsed; else if(typeof parsed === 'string') aktuelleLP = [parsed]; } catch(e) { console.warn('Aktive Phasen für Projekt konnten nicht geparst werden:', nr, name, e); } } projects.push({ id: uid(), nr, name, status, group, lph, assign, order, plan, aufwandProLP, aktuelleLP }); } const attendance = {}; const attRow = rows[attendanceRowIdx]; if(attRow && attRow[0] === 'ANWESENHEIT'){ peopleCols.forEach((_, i) => { const entry = colMap[i]; if(entry && (attRow[2+i] || '').trim() === 'X'){ attendance[entry.person.id] = true; } }); } return { id: uid(), label, date, people, projects, attendance, locked }; } // uid: generiert eine einfache, ausreichend eindeutige ID function uid(){ return Date.now().toString(36)+Math.random().toString(36).slice(2,7); } // assignKey: Schlüssel für Projektzuordnungen (Person + Rolle getrennt) function assignKey(personId, role){ return personId + '|' + role; } // sortGroups: sortiert Gruppen-Namen nach GROUP_ORDER, unbekannte ans Ende function sortGroups(arr){ return arr.sort((a,b)=>{ const ia=GROUP_ORDER.indexOf(a), ib=GROUP_ORDER.indexOf(b); return (ia===-1?999:ia)-(ib===-1?999:ib); }); } // seed: initialisiert Beispiel-Daten (Personen + eine Woche mit Projekten) function seed(){ const people = [ {id:personIdForName('AP'), name:"AP", fullName:"Anton Peters", role:["Planung"]}, {id:personIdForName('SK'), name:"SK", fullName:"Stefan Krause", role:["Planung"]}, {id:personIdForName('AK'), name:"AK", fullName:"Alexander Klein", role:["Planung"]}, {id:personIdForName('ED'), name:"ED", fullName:"Eva Dreyer", role:["Planung"]}, {id:personIdForName('SE'), name:"SE", fullName:"Stefan Eschweiler", role:["Planung"]}, {id:personIdForName('THK'), name:"THK", fullName:"Thorsten H. Klein", role:["Projektleitung"]}, {id:personIdForName('SP'), name:"SP", fullName:"Sven Peschke", role:["Projektleitung"]}, {id:personIdForName('SG'), name:"SG", fullName:"Sandra Gloag", role:["Projektleitung"]}, {id:personIdForName('TW'), name:"TW", fullName:"Thorsten Wörster", role:["Projektleitung"]}, {id:personIdForName('RR'), name:"RR", fullName:"Robert Rahn", role:["Bauüberwachung"]}, {id:personIdForName('MK'), name:"MK", fullName:"Martin Kluge", role:["Bauüberwachung"]}, {id:personIdForName('HR'), name:"HR", fullName:"Hartwig Rauch", role:["Bauüberwachung"]}, ]; db.globalPeople[currentDepartment] = JSON.parse(JSON.stringify(people)); const wid = uid(); db.weeks = [{ id: wid, label:"KW 30 / 2026", date:"2026-07-24", attendance:{}, people, projects: [ demoProj("18-4007","Janusz-Korczak-Gesamtsch. Bottrop","Aktive Projekte","LPH5 als Vorabzug raus. NW-Räume vorauss. August.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100,"LPH 7":100,"LPH 8":95}), demoProj("21-4030","NSHV Köln Immobilienprüfung in Hof","Aktive Projekte","Thorsten W. klärt und übernimmt Schriftverkehr. LV liegt zur Sichtung bei T.W.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100}), demoProj("22-4056","Gymnasium Hemer","Aktive Projekte","",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100,"LPH 7":100,"LPH 8":90}), demoProj("22-4066","DSW 21 – Umbau E-Mobilität","Aktive Projekte","Elt-Montagen ab KW 33.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100,"LPH 6":100,"LPH 7":100,"LPH 8":20}), demoProj("24-4043","S-K ODL-Heim Sanierung Krefeld","Aktive Projekte","Voraussichtlich LV-Abgabe.",{"LPH 1":100,"LPH 2":100,"LPH 3":100,"LPH 5":100}), ] }]; db.currentWeek = wid; save(); } // demoProj: Helfer zum Erzeugen eines Projekt-Objekts (für seed/Tests) function demoProj(nr,name,group,status,lph){ return {id:uid(), nr, name, group, status, lph: {...lph}, assign:{}, plan: null, aufwandProLP: undefined, aktuelleLP: undefined}; } // ---------- Week management ---------- // refreshWeekSelect: aktualisiert das Dropdown mit verfügbaren Sitzungen function refreshWeekSelect(){ const sel = document.getElementById('weekSelect'); sel.innerHTML = db.weeks.map(w=>``).join(''); } function formatWeekDate(dateString){ if(!dateString) return ''; const parts = dateString.split('-'); if(parts.length!==3) return dateString; return `${parts[2]}.${parts[1]}.${parts[0]}`; } function updateWeekDate(){ const disp = document.getElementById('weekDateDisplay'); const w = currentWeek(); if(!disp) return; disp.textContent = w?.date ? `Datum: ${formatWeekDate(w.date)}` : ''; } // currentWeek: liefert das Woche-Objekt zur aktuellen Auswahl (oder undefined) function currentWeek(){ return db.weeks.find(w=>w.id===db.currentWeek); } // switchWeek: setzt die aktuelle Woche und rendert die Ansicht neu function switchWeek(id){ db.currentWeek = id; save(); renderAll(); } // newWeek: Dialog zur Erzeugung einer neuen Sitzung function newWeek(){ const label = prompt("Bezeichnung der neuen Sitzung (z.B. KW 31 / 2026):","KW / 2026"); if(!label) return; const date = prompt("Datum (JJJJ-MM-TT):", new Date().toISOString().slice(0,10)) || ""; // Neue Woche übernimmt die aktuell globale Personenliste const people = JSON.parse(JSON.stringify(db.globalPeople[currentDepartment] || [])); const w = {id:uid(), label, date, people, projects:[], attendance:{}, locked:false}; db.weeks.push(w); applyAutoLock(); db.currentWeek=w.id; save(); renderAll(); } // cloneWeek: dupliziert die aktuelle Sitzung inkl. Projekten function cloneWeek(){ const cur = currentWeek(); if(!cur) return; const label = prompt("Bezeichnung der duplizierten Sitzung:", cur.label+" (Kopie)"); if(!label) return; const date = prompt("Datum (JJJJ-MM-TT):", new Date().toISOString().slice(0,10)) || ""; const w = JSON.parse(JSON.stringify(cur)); w.id=uid(); w.label=label; w.date=date; w.attendance={}; w.locked=false; // Neue IDs für duplizierte Projekte und Kennzeichnung w.projects.forEach(p=>{ p.id = uid(); if(p.status && p.status.trim() !== "") p.statusState = 'duplicated'; else delete p.statusState; }); db.weeks.push(w); applyAutoLock(); db.currentWeek=w.id; save(); renderAll(); } // applyAutoLock: sperrt alle Sitzungen bis auf die chronologisch // letzte (aktuellste). Wird beim Laden, Erstellen und Duplizieren verwendet, // damit historische Sitzungen nicht mehr unbeabsichtigt bearbeitet werden. function applyAutoLock(){ if(db.weeks.length === 0) return; const sorted = [...db.weeks].sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))); const latestId = sorted[sorted.length - 1].id; db.weeks.forEach(w => { w.locked = (w.id !== latestId); }); } // isWeekLocked: prüft, ob die aktuell ausgewählte Sitzung gesperrt ist function isWeekLocked(w){ return !!w && !!w.locked; } // guardWeekLocked: zeigt eine Warnung, wenn die aktuelle Sitzung gesperrt ist. // Wird von Bearbeitungsfunktionen verwendet, um versehentliche Änderungen an // historischen Sitzungen zu verhindern. function guardWeekLocked(){ const w = currentWeek(); if(isWeekLocked(w)){ alert('Diese Sitzung ist gesperrt. Bitte entsperren Sie sie zuerst.'); return true; } return false; } // unlockCurrentWeek: Entsperrt die aktuelle Sitzung nach Passwortprüfung function unlockCurrentWeek(skipAuth = false){ const w = currentWeek(); if(!w || !w.locked) return; if(!skipAuth && !isAuthenticated){ requestedAction = { type: 'unlockWeek' }; document.getElementById('passwordModal').style.display='flex'; document.getElementById('passwordInput').value=''; document.getElementById('passwordInput').focus(); return; } w.locked = false; save(); renderAll(); requestedAction = null; } // deleteWeek: löscht die aktuell ausgewählte Sitzung (wenn mehr als 1 vorhanden) function deleteWeek(skipAuth = false){ if(db.weeks.length<=1){ alert("Mindestens eine Sitzung muss bestehen bleiben."); return; } if(!skipAuth && !isAuthenticated){ requestedAction = { type: 'deleteWeek' }; document.getElementById('passwordModal').style.display='flex'; document.getElementById('passwordInput').value=''; document.getElementById('passwordInput').focus(); return; } if(!confirm("Diese Sitzung wirklich löschen?")) { requestedAction = null; return; } const current = currentWeek(); const filename = current ? filenameForWeek(current.label) : null; if(filename){ console.log('Lösche CSV-Datei auf Server:', filename); deleteCsvFromServer(filename).then(res=>{ if(res && res.ok){ console.log('CSV-Datei gelöscht:', filename); } else { alert('CSV-Löschung fehlgeschlagen: ' + (res && res.error ? res.error : 'unbekannter Fehler')); } }).catch(err=>{ alert('CSV-Löschung fehlgeschlagen: ' + (err.message || err)); }); } db.weeks = db.weeks.filter(w=>w.id!==db.currentWeek); db.weeks.sort((a,b) => weekSortKey(a.label).localeCompare(weekSortKey(b.label))); db.currentWeek = db.weeks[db.weeks.length - 1].id; applyAutoLock(); save(); renderAll(); requestedAction = null; } function deleteCsvFromServer(filename){ if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar')); return fetch(baseUrl() + '/delete-csv', { method: 'POST', headers: {'Content-Type':'application/json; charset=utf-8'}, body: JSON.stringify({ filename, department: currentDepartment }) }).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }); } let currentStatusEditor = null; let currentStatusPid = null; function statusFocus(el, pid){ currentStatusEditor = el; currentStatusPid = pid; } function statusBlur(el, pid){ const val = statusValueFromHtml(el.innerHTML); editProj(pid, 'status', val); if(currentStatusEditor === el){ currentStatusEditor = null; currentStatusPid = null; } } function statusValueFromHtml(html){ const tmp = document.createElement('div'); tmp.innerHTML = html; const text = (tmp.textContent || tmp.innerText || '').replace(/\u00A0/g, ' '); return text.trim() === '' ? '' : html; } function formatStatus(command, value){ if(!currentStatusEditor) return; currentStatusEditor.focus(); document.execCommand(command, false, value || null); } // ---------- Board (Haupttabelle) ---------- // peopleByRole: gruppiert Personen nach Rolle zurück in ein Objekt function peopleByRole(target){ const m = {}; ROLES.forEach(r=>m[r]=[]); const people = []; if(Array.isArray(target)){ target.forEach(w => { if(w && Array.isArray(w.people)) people.push(...w.people); }); } else if(target && Array.isArray(target.people)){ people.push(...target.people); } else if(Array.isArray(currentWeek()?.people)){ people.push(...currentWeek().people); } const seen = new Set(); people.forEach(p=>{ const personRoles = Array.isArray(p.role) ? p.role : [p.role]; personRoles.forEach(role=>{ if(!m[role]) m[role]=[]; const key = `${p.id}|${role}`; if(!seen.has(key)){ seen.add(key); m[role].push({person:p, role}); } }); }); return m; } function roleClass(role){ if(Array.isArray(role)){ return role.map(r=>'role-'+r.toLowerCase().replace(/\s+/g,'-')).join(' '); } return 'role-' + role.toLowerCase().replace(/\s+/g,'-'); } // renderBoard: baut die HTML-Tabelle für das Board auf (Kopf, Zeilen, Zellen) function renderBoard(){ const w = currentWeek(); const locked = isWeekLocked(w); const table = document.getElementById('boardTable'); const grouped = peopleByRole(w); // Kopfzeilen: Rollen und Leistungsphasen let h1 = `Pr.-Nr.BezeichnungStatus / Kommentar`; ROLES.forEach(r=>{ h1 += `${r}`; }); h1 += `Leistungsphasen (%)`; let h2 = ``; ROLES.forEach(r=>{ if(grouped[r].length===0) h2 += `–`; grouped[r].forEach(entry=> h2 += `${entry.person.name}`); }); LPHS.forEach(l=> h2 += `${l}`); h2 += ``; // Projekte nach Gruppen (z.B. "Aktive Projekte") zusammenfassen const groups = {}; w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); }); // Projekte nach ihrer Reihenfolge sortieren Object.keys(groups).forEach(g=>{ groups[g].sort((a, b) => (a.order || 0) - (b.order || 0)); }); let body = ""; const allPeople = []; ROLES.forEach(r=> grouped[r].forEach(entry=>allPeople.push(entry))); // Jede Gruppe als Abschnitt, dann Zeilen für Projekte sortGroups(Object.keys(groups)).forEach(g=>{ const colspan = 3 + allPeople.length + LPHS.length + 1; // Spaltenanzahl für Gruppentitel body += `${esc(g)}`; groups[g].forEach(p=>{ body += ``; body += `${esc(p.nr)}`; body += `${esc(p.name)}`; // Status-Editor: contenteditable mit kleiner Toolbar (Fett, Farben, Absätze mit Enter) const statusClass = p.statusState === 'new' ? 'status-new' : p.statusState === 'duplicated' ? 'status-duplicated' : ''; const statusEditable = locked ? 'false' : 'true'; const statusToolbar = locked ? '' : `
`; const statusClick = locked ? '' : `onfocus="statusFocus(this,'${p.id}')" onblur="statusBlur(this,'${p.id}')"`; const safeStatus = sanitizeStatusHtml(p.status || ''); body += ` ${statusToolbar}
${safeStatus}
`; // Personen-Spalten: Klick wechselt Zustand (nicht, optional, Hauptverantwortung) allPeople.forEach(entry=>{ const state = p.assign[assignKey(entry.person.id, entry.role)] || 0; // 0=keine,1=Haupt,2=optional const cls = state===1?'on':state===2?'optional':''; const txt = state===1?'X':state===2?'(X)':''; const click = locked ? '' : `onclick="cycleAssign('${p.id}','${entry.person.id}','${entry.role}')"`; body += `${txt}`; }); // LPH-Spalten mit Prozentwerten und Fortschrittsbalken; // die amtierende Leistungsphase (Tab "Projekte") wird farblich markiert. const aktLp = lpActiveList(p)[0]; LPHS.forEach(l=>{ const v = (p.lph[l]!==undefined && p.lph[l]!="") ? p.lph[l] : ""; const aktiv = l === aktLp; const inputAttr = locked ? 'readonly tabindex="-1"' : `onchange="editLph('${p.id}','${l}',this.value)"`; body += ``; if(v!=""){ const pct = Math.min(100, parseFloat(v)||0); body += `
`; } body += ``; }); body += ``; }); }); let lockNotice = ''; if(locked){ lockNotice = `
Diese Sitzung ist gesperrt. Bearbeiten nur nach Passworteingabe möglich.
`; } table.innerHTML = `${h1}${h2}${body}`; // Legende mit vollständigen Namen und Anwesenheitstracking let legendeHtml = lockNotice + `
Legende Projektkürzel${locked ? '' : ' (klick für Anwesenheit)'}:
`; const weekAttendance = w?.attendance || {}; const legendeItems = []; allPeople.forEach(entry=>{ if(!legendeItems.some(item=>item.id===entry.person.id)){ legendeItems.push({id:entry.person.id, name:entry.person.name, fullName:entry.person.fullName||entry.person.name}); } }); legendeHtml += `
` + legendeItems.map(item=>{ const isPresent = weekAttendance[item.id]; const cursor = locked ? 'not-allowed' : 'pointer'; const click = locked ? '' : `onclick="toggleAttendance('${item.id}')"`; return `
${esc(item.name)} = ${esc(item.fullName)}
`; }).join('') + `
`; document.getElementById('boardLegend').innerHTML = legendeHtml; adjustStickyOffsets(); } // Sanitizing: Erlaubt nur eine kleine Whitelist an HTML-Tags/Attributen, // damit Formatierung funktioniert, aber keine Skripte eingeschleust werden. function sanitizeStatusHtml(html){ if(!html) return ''; const div = document.createElement('div'); div.innerHTML = html; const allowedTags = new Set(['B','STRONG','I','EM','U','P','BR','DIV','SPAN','FONT']); const allowedAttrs = new Set(['style']); const walk = (node) => { for(let i = node.childNodes.length - 1; i >= 0; i--){ const child = node.childNodes[i]; if(child.nodeType === 3) continue; if(child.nodeType === 1){ if(!allowedTags.has(child.tagName.toUpperCase())){ while(child.firstChild) node.insertBefore(child.firstChild, child); node.removeChild(child); continue; } for(let j = child.attributes.length - 1; j >= 0; j--){ const attr = child.attributes[j].name; if(!allowedAttrs.has(attr.toLowerCase())) child.removeAttribute(attr); } if(child.hasAttribute('style')){ // Erlaubt nur color und font-weight im style-Attribut const style = child.getAttribute('style'); const safe = (style || '').split(';').map(s => s.trim()).filter(s => { const prop = s.split(':')[0].trim().toLowerCase(); return prop === 'color' || prop === 'font-weight'; }).join('; '); if(safe) child.setAttribute('style', safe); else child.removeAttribute('style'); } walk(child); } } }; walk(div); return div.innerHTML; } // getProgressColor: erzeugt eine Farbe basierend auf Prozent (grün→rot) function getProgressColor(pct){ const hue = Math.round(Math.max(0, Math.min(100, pct)) * 1.2); return `hsl(${hue},70%,40%)`; } // adjustStickyOffsets: berechnet die reale Höhe der ersten Kopfzeile // und setzt den top-Wert der zweiten Kopfzeile entsprechend, damit // beide sticky-Zeilen exakt aneinander anschließen (keine Lücke/Überlappung) function adjustStickyOffsets(){ const firstRow = document.querySelector('#boardTable thead tr:nth-child(1)'); if(!firstRow) return; const h = firstRow.getBoundingClientRect().height; document.querySelectorAll('#boardTable thead tr:nth-child(2) th').forEach(th=>{ th.style.top = h + 'px'; }); } // toggleAttendance: togglet den Anwesenheitsstatus eines Projektbeteiligten für die aktuelle Sitzung function toggleAttendance(personId){ if(guardWeekLocked()) return; const w = currentWeek(); if(!w) return; if(!w.attendance) w.attendance = {}; w.attendance[personId] = !w.attendance[personId]; save(); renderBoard(); } // esc: einfache Escape-Funktion für Ausgabe in HTML function esc(s){ return (s||"").replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // cycleAssign: wechselt die Zuweisung einer Person zu einem Projekt (0→1→2→0) function cycleAssign(pid, personId, role){ if(guardWeekLocked()) return; const p = currentWeek().projects.find(x=>x.id===pid); const key = assignKey(personId, role); const cur = p.assign[key]||0; const next = (cur+1)%3; if(next===0) delete p.assign[key]; else p.assign[key]=next; save(); renderBoard(); } // editLph: setzt oder entfernt den Prozentwert einer Leistungsphase für ein Projekt function editLph(pid,lph,val){ if(guardWeekLocked()) return; const p = currentWeek().projects.find(x=>x.id===pid); val = val.trim(); if(val==="") delete p.lph[lph]; else p.lph[lph]=val.replace('%',''); save(); renderBoard(); } // editProj: aktualisiert Felder eines Projekts; für Status wird zusätzlich // ein flag `statusState` gesetzt, wenn ein neuer Kommentar angelegt wird function editProj(pid,field,val){ if(guardWeekLocked()) return; const p = currentWeek().projects.find(x=>x.id===pid); if(!p) return; if(field==='status'){ const before = (p.status||"").trim(); const after = val.trim(); if(before === "" && after !== ""){ p.statusState = 'new'; } else if(after === ""){ delete p.statusState; } } p[field]=val; save(); } // delProj: löscht ein Projekt aus der aktuellen Sitzung function delProj(pid){ if(guardWeekLocked()) return; if(!confirm("Projekt aus dieser Sitzung löschen?")) return; const w = currentWeek(); w.projects = w.projects.filter(x=>x.id!==pid); save(); renderBoard(); } // ---------- Personen-Verwaltung (global pro Abteilung) ---------- // addPerson: fügt eine neue Person der globalen Liste hinzu und // synchronisiert sie in die aktuelle Sitzung. function addPerson(){ if(guardWeekLocked()) return; const w = currentWeek(); if(!w) return; const name = document.getElementById('newPersonName').value.trim(); const fullName = document.getElementById('newPersonFullName').value.trim(); const email = document.getElementById('newPersonEmail').value.trim(); if(!name){ alert("Kürzel eingeben."); return; } const roles = [...document.querySelectorAll('input[name="newPersonRoles"]:checked')].map(el=>el.value); if(roles.length===0){ alert("Bitte mindestens eine Rolle auswählen."); return; } const person = {id:personIdForName(name), name, fullName:fullName||name, email:email, role:roles}; // Globale Liste aktualisieren if(!Array.isArray(db.globalPeople[currentDepartment])) db.globalPeople[currentDepartment] = []; const existing = db.globalPeople[currentDepartment].find(p => p.id === person.id); if(existing){ alert('Eine Person mit diesem Kürzel existiert bereits.'); return; } db.globalPeople[currentDepartment].push(person); // Aktuelle Sitzung aktualisieren if(!Array.isArray(w.people)) w.people = []; if(!w.people.find(p => p.id === person.id)){ w.people.push(JSON.parse(JSON.stringify(person))); } document.getElementById('newPersonName').value=''; document.getElementById('newPersonFullName').value=''; document.getElementById('newPersonEmail').value=''; document.querySelectorAll('input[name="newPersonRoles"]').forEach(el=>el.checked=false); save(); renderPeople(); renderBoard(); } // delPerson: entfernt eine Person aus der globalen Liste. Bereits in // historischen Sitzungen verwendete Personen bleiben dort sichtbar, da // jede Sitzung ihre eigene Kopie der Personendaten speichert. function delPerson(id){ if(!confirm("Person global löschen? Sie bleibt in bereits existierenden Sitzungen sichtbar, wird aber in neuen Sitzungen nicht mehr angeboten.")) return; // Aus globaler Liste entfernen if(Array.isArray(db.globalPeople[currentDepartment])){ db.globalPeople[currentDepartment] = db.globalPeople[currentDepartment].filter(p=>p.id!==id); } // Aus aktueller Sitzung entfernen (nur wenn nicht in Zuordnungen genutzt) const w = currentWeek(); if(w){ const inUse = w.projects.some(p => Object.keys(p.assign).some(k => k.startsWith(id + '|'))); if(inUse){ alert('Die Person ist in der aktuellen Sitzung noch Projekten zugeordnet. Bitte zuerst die Zuordnungen entfernen.'); return; } w.people = (w.people || []).filter(p=>p.id!==id); } save(); renderPeople(); renderBoard(); } // editPerson: ändert Name/Vollname/Rolle sowohl global als auch in der // aktuellen Sitzung. Historische Sitzungen bleiben unverändert. function editPerson(id,field,val){ if(guardWeekLocked()) return; const w = currentWeek(); if(!w) return; // Global aktualisieren const globalPeople = db.globalPeople[currentDepartment] || []; const globalPerson = globalPeople.find(p => p.id === id); if(field === 'name'){ const newName = val.trim(); if(!newName) return; const newId = personIdForName(newName); if(newId !== id && globalPeople.some(p => p.id === newId)){ alert('Dieses Kürzel wird bereits verwendet.'); return; } if(globalPerson){ globalPerson.name = newName; globalPerson.id = newId; } // Aktuelle Sitzung aktualisieren und Zuordnungen migrieren const localPerson = (w.people || []).find(p => p.id === id); if(localPerson){ localPerson.name = newName; localPerson.id = newId; } w.projects.forEach(p => { const newAssign = {}; Object.keys(p.assign).forEach(k => { const [oldId, role] = k.split('|'); if(oldId === id){ newAssign[assignKey(newId, role)] = p.assign[k]; } else { newAssign[k] = p.assign[k]; } }); p.assign = newAssign; if(p.plan && Array.isArray(p.plan.people)){ p.plan.people = p.plan.people.map(pid => pid === id ? newId : pid); } }); if(w.attendance){ if(w.attendance[id]){ w.attendance[newId] = true; delete w.attendance[id]; } } save(); renderPeople(); renderBoard(); return; } if(field === 'role'){ const roles = Array.isArray(val) ? [...new Set(val)] : val ? [val] : []; if(globalPerson) globalPerson.role = roles; const localPerson = (w.people || []).find(p => p.id === id); if(localPerson) localPerson.role = roles; } else { if(globalPerson) globalPerson[field] = val; const localPerson = (w.people || []).find(p => p.id === id); if(localPerson) localPerson[field] = val; } save(); renderPeople(); renderBoard(); } function togglePersonRole(id, role, checked){ if(guardWeekLocked()) return; const w = currentWeek(); if(!w) return; const globalPeople = db.globalPeople[currentDepartment] || []; const globalPerson = globalPeople.find(p => p.id === id); const localPerson = (w.people || []).find(p => p.id === id); function updateRoles(person){ person.role = Array.isArray(person.role) ? [...person.role] : person.role ? [person.role] : []; if(checked){ if(!person.role.includes(role)) person.role.push(role); } else { person.role = person.role.filter(r=>r!==role); } } if(globalPerson) updateRoles(globalPerson); if(localPerson) updateRoles(localPerson); save(); renderPeople(); renderBoard(); } // renderPeople: baut die Liste der Personen für die UI function renderPeople(){ const people = db.globalPeople[currentDepartment] || []; const locked = isWeekLocked(currentWeek()); let html = `
`; people.forEach(p=>{ const isOpen = personAbsenceOpen[p.id]; html += `
`; html += `
⋮⋮ ${isOpen ? '▼' : '▶'}
${ROLES.map(rr=>``).join('')}
`; // Aufklappbare Abwesenheiten html += `
`; const absences = p.absences || []; if(absences.length > 0){ html += `
`; absences.forEach(a => { html += `
${esc(a.type || 'Abwesenheit')}: ${formatDateDE(parseDateISO(a.start))}–${formatDateDE(parseDateISO(a.end))} ${a.note ? `${esc(a.note)}` : ''} ${locked ? '' : ``}
`; }); html += `
`; } if(!locked){ html += `
`; } html += `
`; }); html += `
`; if(people.length===0) html = '
Keine Personen vorhanden.
'; document.getElementById('peopleList').innerHTML=html; } let personAbsenceOpen = {}; function togglePersonAbsences(personId){ personAbsenceOpen[personId] = !personAbsenceOpen[personId]; renderPeople(); } let draggedPerson = null; function getPersonCard(el){ return el && el.closest ? el.closest('.person-card') : null; } function handlePersonDragStart(event){ draggedPerson = getPersonCard(event.target); if(!draggedPerson) return; event.dataTransfer.effectAllowed = 'move'; draggedPerson.style.opacity = '0.5'; } function handlePersonDragOver(event){ event.preventDefault(); event.dataTransfer.dropEffect = 'move'; } function handlePersonDrop(event){ event.preventDefault(); const targetCard = getPersonCard(event.target); if(!draggedPerson || draggedPerson === targetCard) return; const sortable = document.querySelector('.people-sortable'); if(!sortable) return; const afterElement = getDragAfterElement(sortable, event.clientY); if(afterElement == null){ sortable.appendChild(draggedPerson); } else { sortable.insertBefore(draggedPerson, afterElement); } reorderPeople(); } function handlePersonDragEnd(event){ const card = getPersonCard(event.target); if(card) card.style.opacity = '1'; draggedPerson = null; } function reorderPeople(){ if(guardWeekLocked()) return; const sortable = document.querySelector('.people-sortable'); if(!sortable) return; const people = db.globalPeople[currentDepartment] || []; if(people.length === 0) return; const newPeople = []; [...sortable.querySelectorAll('[data-person-id]')].forEach(item => { const person = people.find(p => p.id === item.dataset.personId); if(person) newPeople.push(person); }); if(newPeople.length === people.length){ db.globalPeople[currentDepartment] = newPeople; save(); } } // ---------- Projekte (Listen-Ansicht) ---------- // delProjectFromList: löscht ein Projekt in der Listen-Ansicht function delProjectFromList(id){ if(guardWeekLocked()) return; if(!confirm("Projekt aus dieser Sitzung löschen?")) return; const w = currentWeek(); w.projects = w.projects.filter(p=>p.id!==id); save(); renderProjects(); renderBoard(); } // editProjectField: ändert ein Feld eines Projekts (z.B. Name oder Gruppe) function editProjectField(id,field,val){ if(guardWeekLocked()) return; const p = currentWeek().projects.find(x=>x.id===id); if(p){ p[field]=val; save(); renderProjects(); renderBoard(); } } // editProjectActiveLP: setzt/hebt die amtierende Leistungsphase eines Projekts auf. function editProjectActiveLP(id, value){ if(guardWeekLocked()) return; const p = currentWeek().projects.find(x=>x.id===id); if(!p) return; if(value) p.aktuelleLP = [value]; else delete p.aktuelleLP; save(); renderProjects(); renderBoard(); renderLoad(); } // renderProjects: baut die HTML-Liste der Projekte (gruppiert nach group, mit Drag-and-Drop) function renderProjects(){ const w = currentWeek(); if(!w) return; let html=""; const groups = {}; w.projects.forEach(p=>{ (groups[p.group]||(groups[p.group]=[])).push(p); }); Object.keys(groups).forEach(g=>{ groups[g].sort((a,b)=>(a.order||0)-(b.order||0)); }); sortGroups(Object.keys(groups)).forEach(g=>{ html += `
${esc(g)}
`; html += `
`; groups[g].forEach(p=>{ html += `
⋮⋮
`; }); html += `
`; if(groups[g].length===0) html+=`
Keine Projekte.
`; }); document.getElementById('projectsList').innerHTML=html; } // Drag-and-Drop Handling für Projekte let draggedProject = null; function handleDragStart(event){ draggedProject = event.currentTarget; event.dataTransfer.effectAllowed = 'move'; event.currentTarget.style.opacity = '0.5'; } function handleDragOver(event){ event.preventDefault(); event.dataTransfer.dropEffect = 'move'; } function handleDrop(event){ event.preventDefault(); if(!draggedProject || draggedProject === event.currentTarget) return; const sortable = event.currentTarget.parentElement; const afterElement = getDragAfterElement(sortable, event.clientY); if(afterElement == null){ sortable.appendChild(draggedProject); } else { sortable.insertBefore(draggedProject, afterElement); } reorderProjects(); } function handleDragEnd(event){ event.currentTarget.style.opacity = '1'; draggedProject = null; } function getDragAfterElement(container, y){ const draggableElements = [...container.querySelectorAll('.person-list-item:not([style*="opacity"])')]; return draggableElements.reduce((closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if(offset < 0 && offset > closest.offset){ return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY }).element; } function reorderProjects(){ if(guardWeekLocked()) return; const w = currentWeek(); const sortables = document.querySelectorAll('.projects-sortable'); sortables.forEach(sortable => { const group = sortable.dataset.group; const items = sortable.querySelectorAll('[data-project-id]'); items.forEach((item, index) => { const projectId = item.dataset.projectId; const project = w.projects.find(p => p.id === projectId); if(project){ project.order = index; project.group = group; } }); }); save(); renderProjects(); renderBoard(); } // ---------- Auslastung (Load) ---------- // ---------- Aufwand je Leistungsphase (LP) ---------- // // Datenmodell je Projekt (rollen- und leistungsphasenspezifisch): // pr.aufwandProLP = { // 'LPH 1': { Planung: 0.4, Projektleitung: 0.6, Bauüberwachung: 0.0 }, // ... // } // Gewichtungsfaktor je Rolle (0.0–1.0) // pr.aktuelleLP = ['LPH 6'] // aktuell amtierende Phase (max. 1) // // Es zählt genau eine amtierende Phase je Projekt ("Nur amtierende Einzelphase", // Absprache mit dem Auftraggeber). Altdaten mit mehreren aktiven Phasen // verwenden deterministisch die erste aktive Phase der Liste. // // Berechnungslogik: // Auslastung(Mitarbeiter) = Σ Faktor der amtierenden LP je Projekt, // in dem der Mitarbeiter (in seiner Rolle) zugewiesen ist. // Der Faktor zählt 1:1 – die Zuordnungsart (Haupt/Optional) skaliert nicht. // Leere Matrix-Felder zählen bei gesetzter aktiver LP mit dem Standard-Faktor // 1,0; ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet). // "In Anbahnung" wird nicht eingerechnet. // // HINWEIS (Fachplaner-Fragen, bewusst NICHT eigenständig entschieden): // - Ampel-Schwellen (50 % / 75 % des Maximalwerts) sind PLATZHALTER und müssen // fachlich abgestimmt werden. // - Mitarbeiterkapazität (Teilzeit/Vollzeit) existiert nicht – offener Punkt. // // AUFWAND_LPS: Phasen des Aufwandsmodells (LPH 1–8; "W+M" ist bewusst nicht // enthalten, entspricht der Entscheidung "LP-Schema: lp1-8"). Es gibt keine // Default-Vorlage: neue Projekte starten leer und zählen bei gesetzter aktiver // LP je Rolle zu 1,0; ohne aktive LP zu 0. const AUFWAND_LPS = LPHS.slice(0, 8); // PLATZHALTER! Ampel-Schwellen als Anteil des Maximalwerts LOAD_BAR_MAX // (gelb ab 50 %, rot ab 75 %). Müssen vom Fachplaner bestätigt werden. const THRESHOLD_RATIO_CRITICAL = 0.75; const THRESHOLD_RATIO_ELEVATED = 0.50; function lpActiveList(pr){ const a = pr && pr.aktuelleLP; if(Array.isArray(a)) return a.filter(l => AUFWAND_LPS.includes(l)); if(typeof a === 'string' && AUFWAND_LPS.includes(a)) return [a]; return []; } // aufwandMatrixWert: Rollenwert eines Projekts für eine Leistungsphase // (Gewichtungsfaktor, 0,0–1,0). Liefert null, wenn keine (rollenspezifische) Matrix // bzw. kein Wert für diese Phase/Rolle hinterlegt ist. 0 ist ein gültiger Wert. function aufwandMatrixWert(projekt, lp, rolle){ const m = projekt && projekt.aufwandProLP; const entry = (m && typeof m === 'object') ? m[lp] : undefined; if(!entry || typeof entry !== 'object' || Array.isArray(entry)) return null; const v = parseFloat(entry[rolle]); return isNaN(v) ? null : v; } // berechneAuslastung: zentrale Auslastungsberechnung einer Zuordnung // (Person/Rolle) zu einem Projekt für die amtierende Leistungsphase. // Ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet). // Mit aktiver LP: Liegt aufwandProLP[aktuelleLP][rolle] vor, zählt dieser // Faktor 1:1 – die Zuordnungsart (Haupt/Optional) skaliert bewusst nicht mehr. // Fehlt der Matrix-Wert, gilt der Standard-Faktor 1,0. // "In Anbahnung" wird nicht eingerechnet. function berechneAuslastung(mitarbeiter, projekt, aktuelleLP, rolle, status){ if(!projekt || projekt.group === 'In Anbahnung') return { load: 0, anbahnung: true }; if(!aktuelleLP) return { load: 0 }; const wert = aufwandMatrixWert(projekt, aktuelleLP, rolle); if(wert !== null) return { load: wert }; return { load: 1.0 }; } // faktorFormat: formatiert einen Auslastungs-Faktor in deutscher Notation // (Komma, max. 2 Nachkommastellen), z. B. 1.1 → "1,10". 1,0 = 100 % Kapazität. function faktorFormat(x){ const n = Math.round((x || 0) * 100) / 100; return n.toFixed(2).replace('.', ','); } // loadAmpelColor: Ampel-Farbe relativ zum Maximalwert (gelb ab 50 %, // rot ab 75 % von LOAD_BAR_MAX; PLATZHALTER). function loadAmpelColor(x){ return x >= LOAD_BAR_MAX * THRESHOLD_RATIO_CRITICAL ? '#c53030' : x >= LOAD_BAR_MAX * THRESHOLD_RATIO_ELEVATED ? '#d69e2e' : '#2f855a'; } function pdfLoadAmpelColor(x){ return x >= LOAD_BAR_MAX * THRESHOLD_RATIO_CRITICAL ? '#D70015' : x >= LOAD_BAR_MAX * THRESHOLD_RATIO_ELEVATED ? '#B25000' : '#248A3D'; } // ---------- LP-Aufwand Eingabemaske ---------- let lpModalProjectId = null; // lpRowsHtml: erzeugt die Aufwand-Maske als Matrix-Tabelle. // Zeilen = Leistungsphasen (LPH 1–8), Spalten = Rollen (Planung/Projektleitung/ // Bauüberwachung). Die amtierende Leistungsphase wird pro Projekt im Tab // "Projekte" gewählt (renderProjects / editProjectActiveLP). // prefix: 'lp' = Bearbeiten-Modal, 'np' = Neues-Projekt-Modal (eindeutige IDs). // Die Feld-IDs bleiben identisch, damit collectLpRows() unverändert funkioniert. function lpRowsHtml(p, prefix){ const head = ROLES.map(r => `${esc(r)}`).join(''); const rows = AUFWAND_LPS.map(l => { const idx = l.replace(/[^\d]/g,''); const row = (p.aufwandProLP && typeof p.aufwandProLP[l] === 'object') ? p.aufwandProLP[l] : {}; const cells = ROLES.map((r, ri) => { const raw = row[r]; const val = (raw !== undefined && raw !== null && raw !== '') ? String(parseFloat(raw)).replace('.', ',') : ''; return ``; }).join(''); return `${l}${cells}`; }).join(''); return `${head}${rows}
Leistungsphase
`; } // collectLpRows: liest die Aufwand-Maske aus und validiert die Werte (0–1). // Rückgabe: { aufwand, hasValue } oder null bei Eingabefehler. function collectLpRows(prefix){ const aufwand = {}; let hasValue = false; for(const l of AUFWAND_LPS){ const idx = l.replace(/[^\d]/g,''); const entry = {}; let rowHasValue = false; let invalid = false; for(let ri = 0; ri < ROLES.length; ri++){ const r = ROLES[ri]; const input = document.getElementById(prefix + 'Val' + idx + 'R' + ri); if(!input) continue; const raw = input.value.trim().replace(',', '.'); if(raw === '') continue; const v = parseFloat(raw); if(isNaN(v) || v < 0 || v > 1){ alert('Bitte Werte zwischen 0 und 1 eintragen (z. B. 0,4) – oder das Feld leer lassen. (' + l + ', ' + r + ')'); invalid = true; break; } entry[r] = v; rowHasValue = true; } if(invalid) return null; if(rowHasValue){ aufwand[l] = entry; hasValue = true; } } return { aufwand, hasValue }; } // applyLpRows: übernimmt das Ergebnis von collectLpRows auf ein Projekt. // Ohne gepflegte Rollen-Matrix (leer) zählt bei gesetzter aktiver LP später der // Standard-Faktor 1,0; ohne aktive LP zählt das Projekt 0. // Die amtierende Leistungsphase wird separat über den Tab "Projekte" gesetzt. function applyLpRows(p, lp){ if(!lp) return false; p.aufwandProLP = lp.hasValue ? lp.aufwand : undefined; return true; } function openLpModal(pid){ if(guardWeekLocked()) return; const p = currentWeek().projects.find(x=>x.id===pid); if(!p) return; lpModalProjectId = pid; const titleEl = document.getElementById('lpModalProject'); if(titleEl) titleEl.textContent = (p.nr ? p.nr + ' – ' : '') + (p.name || ''); const rowsEl = document.getElementById('lpModalRows'); if(rowsEl) rowsEl.innerHTML = lpRowsHtml(p, 'lp'); document.getElementById('lpModal').style.display = 'flex'; } function closeLpModal(){ document.getElementById('lpModal').style.display = 'none'; lpModalProjectId = null; } function saveLpModal(){ if(guardWeekLocked()) return; const p = currentWeek().projects.find(x=>x.id===lpModalProjectId); if(!p) return; const lp = collectLpRows('lp'); if(lp === null) return; if(applyLpRows(p, lp)) save(); renderBoard(); renderProjects(); renderLoad(); closeLpModal(); } // ---------- Neues Projekt (mit LP-Aufwand beim Anlegen) ---------- function openNewProjModal(){ if(guardWeekLocked()) return; document.getElementById('newProjNr').value = ''; document.getElementById('newProjName').value = ''; document.getElementById('newProjGroup').value = 'Aktive Projekte'; const lpSel = document.getElementById('newProjActiveLP'); if(lpSel){ lpSel.innerHTML = '' + AUFWAND_LPS.map(l => ``).join(''); lpSel.value = ''; } const rowsEl = document.getElementById('newProjLpRows'); if(rowsEl) rowsEl.innerHTML = lpRowsHtml({ aufwandProLP: {}, aktuelleLP: [] }, 'np'); document.getElementById('newProjModal').style.display = 'flex'; } function closeNewProjModal(){ document.getElementById('newProjModal').style.display = 'none'; } function saveNewProj(){ if(guardWeekLocked()) return; const nr = document.getElementById('newProjNr').value.trim(); const name = document.getElementById('newProjName').value.trim(); if(!name){ alert("Bitte Projektbezeichnung eingeben."); return; } const group = document.getElementById('newProjGroup').value; const activeLP = document.getElementById('newProjActiveLP').value; const lp = collectLpRows('np'); if(lp === null) return; const w = currentWeek(); const nextOrder = w.projects .filter(p=>p.group===group) .reduce((max,p)=> Math.max(max, p.order!==undefined ? p.order : 0), -1) + 1; const pr = {id:uid(),nr,name,group,status:'',lph:{},assign:{}, statusState: undefined, order: nextOrder, plan: null}; if(activeLP) pr.aktuelleLP = [activeLP]; applyLpRows(pr, lp); w.projects.push(pr); save(); renderProjects(); renderBoard(); closeNewProjModal(); } // LOAD_BAR_MAX: Maximalwert der Balken-Skala im Auslastungs-Tab (und im // PDF-Export). 1,0 = 100 % Kapazität; Werte > 1,0 = Überlastung (rot). const LOAD_BAR_MAX = 8; // renderLoad: berechnet die Belastung der Personen auf Basis des // rollenspezifischen LP-Gewichtungsfaktors je amtierender Phase (0,0–1,0, // Standard 1,0). Leere Matrix-Werte zählen bei gesetzter aktiver LP als 1,0; // ohne gesetzte aktive LP zählt das Projekt 0 (nicht eingerechnet). // Ampel: Schwellen relativ zum Maximalwert LOAD_BAR_MAX // (gelb ab 50 %, rot ab 75 % – PLATZHALTER). // Balken: Skala auf das feste Maximum LOAD_BAR_MAX (Standard 8). function renderLoad(){ const scope = document.getElementById('loadScope').value; const barMax = LOAD_BAR_MAX; const selectedWeek = currentWeek(); const weeks = scope==='all' ? db.weeks : (selectedWeek ? [selectedWeek] : []); if(weeks.length === 0){ document.getElementById('loadGrid').innerHTML = '
Keine Sitzung ausgewählt oder die Daten werden noch geladen.
'; return; } const grouped = scope==='all' ? peopleByRole(db.weeks) : peopleByRole(selectedWeek); const stats = {}; ROLES.forEach(r=>{ grouped[r].forEach(entry=>{ stats[assignKey(entry.person.id, r)] = {load:0,projects:{},anbahnung:0}; }); }); weeks.forEach(w=>{ w.projects.forEach(pr=>{ const aktivePhase = lpActiveList(pr)[0]; Object.keys(pr.assign).forEach(aKey=>{ const s = stats[aKey]; if(!s) return; const st = pr.assign[aKey]; const rolle = aKey.slice(aKey.indexOf('|') + 1); const al = berechneAuslastung(null, pr, aktivePhase, rolle, st); if(al.anbahnung){ if(st===1 || st===2) s.anbahnung++; return; } if(al.load > 0){ s.load += al.load; s.projects[pr.name] = (s.projects[pr.name]||0)+1; } }); }); }); // Hochgerechnete Auslastung: durchschnittliche Wochenlast als Faktor // der Kapazität (1,0 = 100 %). Werte > 1,0 = Überlastung. const nWeeks = Math.max(1, weeks.length); const weekAvg = k => stats[k].load / nWeeks; let html=""; ROLES.forEach(r=>{ html += `
${r}
`; if(grouped[r].length===0){ html += `
Keine Personen.
`; } grouped[r].forEach(entry=>{ const s = stats[assignKey(entry.person.id, r)]; const avg = weekAvg(assignKey(entry.person.id, r)); // Balken relativ zur einstellbaren Skala (Standard 8); Überlastung > 1,0 // bleibt über die Ampel-Farbe sichtbar, der Zahlenwert bleibt ungedeckelt. const pct = Math.min(100, Math.round(avg / barMax * 100)); const detail = Object.keys(s.projects).map(k=>`
${esc(k)}${s.projects[k]}×
`).join('') || '
keine Zuordnung
'; const anbahnungBadge = (s.anbahnung && s.anbahnung>0) ? `(${s.anbahnung} in Anbahnung)` : ''; html += `

${esc(entry.person.fullName)} (${r})

${anbahnungBadge}

${faktorFormat(avg)}
${detail}
`; }); html += `
`; }); document.getElementById('loadGrid').innerHTML=html; } // ---------- GANTT / Projektplan ---------- let ganttModalProjectId = null; function ensurePlan(p){ if(!p.plan) p.plan = { start:'', end:'', todos:[], milestones:[], people:[], progress:0 }; // Alte Meilensteine bleiben erhalten; neu sind todos die primären Planungselemente. if(!Array.isArray(p.plan.todos)) p.plan.todos = []; if(!Array.isArray(p.plan.milestones)) p.plan.milestones = []; return p.plan; } // Beteiligte eines Projekts aus dem Board ableiten (Haupt + Optional) // week optional: wenn die Funktion nicht im Kontext der aktuellen Hauptseite // aufgerufen wird (z.B. Projektplan-Seite), muss die passende Woche übergeben // werden, damit die Personenliste korrekt aufgelöst wird. function projectAssignees(p, week = null){ const w = week || currentWeek(); const people = w ? (w.people || []) : []; const globalList = db.globalPeople[currentDepartment] || []; const result = []; people.forEach(person => { const globalPerson = globalList.find(gp => gp.id === person.id); const email = globalPerson ? globalPerson.email : ''; (person.role || ['Planung']).forEach(role => { const key = assignKey(person.id, role); if(p.assign && p.assign[key]){ result.push({ person, role, state: p.assign[key], email}); } }); }); return result; } function projectColor(p){ const idx = Math.abs(String(p.id).split('').reduce((a,c)=>a+c.charCodeAt(0),0)) % GANTT_COLORS.length; return GANTT_COLORS[idx]; } function ganttRangeFromProjects(projects, padDays = 7){ let min = null, max = null; projects.forEach(p => { const plan = p.plan; if(!plan) return; [plan.start, plan.end].forEach(d => { const date = parseDateISO(d); if(!date) return; if(!min || date < min) min = date; if(!max || date > max) max = date; }); (plan.milestones || []).forEach(m => { const date = parseDateISO(m.date); if(!date) return; if(!min || date < min) min = date; if(!max || date > max) max = date; }); (plan.todos || []).forEach(t => { [t.start, t.end].forEach(d => { const date = parseDateISO(d); if(!date) return; if(!min || date < min) min = date; if(!max || date > max) max = date; }); }); }); // Urlaub berücksichtigen (db.globalPeople[currentDepartment] || []).forEach(person => { (person.absences || []).forEach(a => { const s = parseDateISO(a.start), e = parseDateISO(a.end); if(s){ if(!min || s < min) min = s; if(!max || s > max) max = s; } if(e){ if(!min || e < min) min = e; if(!max || e > max) max = e; } }); }); if(!min || !max){ const today = new Date(); min = new Date(today.getFullYear(), today.getMonth(), 1); max = addDays(min, 90); } return { start: addDays(min, -padDays), end: addDays(max, padDays) }; } function fitGanttRange(){ const w = currentWeek(); if(!w) return; const range = ganttRangeFromProjects(w.projects, 7); document.getElementById('ganttStart').value = formatDateISO(range.start); document.getElementById('ganttEnd').value = formatDateISO(range.end); renderGantt(); } function renderGantt(){ const w = currentWeek(); const container = document.getElementById('ganttContainer'); if(!w || !container){ if(container) container.innerHTML = '
Keine Sitzung ausgewählt.
'; return; } let start = parseDateISO(document.getElementById('ganttStart').value); let end = parseDateISO(document.getElementById('ganttEnd').value); if(!start || !end){ const range = ganttRangeFromProjects(w.projects, 7); start = range.start; end = range.end; document.getElementById('ganttStart').value = formatDateISO(start); document.getElementById('ganttEnd').value = formatDateISO(end); } const showVacation = document.getElementById('ganttShowVacation').checked; container.innerHTML = buildGanttHtml(w.projects, start, end, { showVacation, editable: !isWeekLocked(w) }); } function buildGanttHtml(projects, start, end, options = {}){ const { showVacation = true, editable = false, highlightProjectId = null, pxPerDay = null } = options; const totalDays = Math.max(1, daysBetween(start, end) + 1); function timeline(content){ return `
${content}
`; } function dragAttrs(type, attrs){ if(!editable) return ''; let s = ` data-gantt-type="${type}" draggable="false"`; for(const [k,v] of Object.entries(attrs)){ s += ` data-gantt-${k}="${esc(String(v))}"`; } return s; } function resizeHandles(){ if(!editable) return ''; return ``; } // Kopfzeile: Monate und Tage let headerMonths = '
'; headerMonths += '
Projekt
'; let monthsHtml = ''; let current = new Date(start.getTime()); let lastMonth = -1; let monthCells = []; for(let i = 0; i < totalDays; i++){ if(current.getMonth() !== lastMonth){ monthCells.push({ month: current.getMonth(), year: current.getFullYear(), startIdx: i }); lastMonth = current.getMonth(); } current = addDays(current, 1); } monthCells.forEach((m, idx) => { const next = monthCells[idx + 1]; const span = next ? next.startIdx - m.startIdx : totalDays - m.startIdx; const names = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez']; monthsHtml += `
${names[m.month]} ${m.year}
`; }); headerMonths += timeline(monthsHtml) + '
'; let headerWeeks = '
'; headerWeeks += '
'; let weeksHtml = ''; current = new Date(start.getTime()); let lastWeek = -1; let lastYear = -1; let weekStartIdx = 0; const weekCells = []; for(let i = 0; i < totalDays; i++){ const cw = getWeekNumber(current); const cy = current.getFullYear(); if(cw !== lastWeek || cy !== lastYear){ if(lastWeek !== -1){ weekCells.push({ week: lastWeek, year: lastYear, startIdx: weekStartIdx, endIdx: i - 1 }); } weekStartIdx = i; lastWeek = cw; lastYear = cy; } current = addDays(current, 1); } if(lastWeek !== -1){ weekCells.push({ week: lastWeek, year: lastYear, startIdx: weekStartIdx, endIdx: totalDays - 1 }); } weekCells.forEach(w => { const span = w.endIdx - w.startIdx + 1; weeksHtml += `
KW ${w.week}
`; }); headerWeeks += timeline(weeksHtml) + '
'; // Raster-Overlay: Wochenenden schattieren und Heute-Linie markieren. // Liegt als eine Ebene hinter allen Zeilen (erste Chart-Kindnode), // damit nicht jede Zeile eigene Tageszellen braucht. let gridHtml = '
'; current = new Date(start.getTime()); let weekendStartIdx = null; for(let i = 0; i <= totalDays; i++){ const isWeekend = i < totalDays && (current.getDay() === 0 || current.getDay() === 6); if(isWeekend && weekendStartIdx === null) weekendStartIdx = i; if(!isWeekend && weekendStartIdx !== null){ const span = i - weekendStartIdx; gridHtml += `
`; weekendStartIdx = null; } current = addDays(current, 1); } const today = new Date(); const todayMid = new Date(today.getFullYear(), today.getMonth(), today.getDate()); if(todayMid >= start && todayMid <= end){ const todayIdx = daysBetween(start, todayMid); gridHtml += `
`; } gridHtml += '
'; let body = gridHtml + headerMonths + headerWeeks; // Urlaubszeilen if(showVacation){ let allPeople = db.globalPeople[currentDepartment] || []; // Optional: nur Urlaub bestimmter Personen anzeigen if(options.vacationPeople && Array.isArray(options.vacationPeople)){ const allowed = new Set(options.vacationPeople); allPeople = allPeople.filter(person => allowed.has(person.id)); } // Pro Person eine Zeile: alle Abwesenheiten als Balken in derselben Timeline allPeople.forEach(person => { let barsHtml = ''; (person.absences || []).forEach(a => { const s = parseDateISO(a.start), e = parseDateISO(a.end); if(!s || !e || e < start || s > end) return; const rowStart = s < start ? start : s; const rowEnd = e > end ? end : e; const offset = daysBetween(start, rowStart); const span = daysBetween(rowStart, rowEnd) + 1; barsHtml += `
${resizeHandles()}
`; }); if(!barsHtml) return; // keine Abwesenheit im sichtbaren Zeitraum -> keine Zeile body += `
`; body += `
${esc(person.name)}
`; body += timeline(barsHtml); body += `
`; }); } // Projektzeilen projects.slice().sort((a,b) => (a.order||0)-(b.order||0)).forEach(p => { const plan = p.plan; const color = projectColor(p); const milestones = plan && Array.isArray(plan.milestones) ? plan.milestones : []; body += `
`; body += `
${esc(p.nr ? p.nr + ' ' : '')}${esc(p.name)}
`; let rowContent = ''; // Projekt-Balken const s = plan ? parseDateISO(plan.start) : null, e = plan ? parseDateISO(plan.end) : null; if(s && e){ const barStart = s < start ? start : s; const barEnd = e > end ? end : e; if(barEnd >= start && barStart <= end){ const offset = daysBetween(start, barStart); const span = daysBetween(barStart, barEnd) + 1; const progress = Math.max(0, Math.min(100, plan && parseFloat(plan.progress) ? parseFloat(plan.progress) : 0)); rowContent += `
`; rowContent += `${resizeHandles()}
`; rowContent += `${esc(p.name)}`; rowContent += `
`; } } // Meilensteine: nur die nicht positionierten (keine position) in der Projektzeile anzeigen. // Positionierte Meilensteine werden vom Aufrufer (z.B. Projektplan) als separate Zeilen eingefügt. milestones.forEach((m, mIdx) => { if(options.showTodos && m.position) return; const d = parseDateISO(m.date); if(!d || d < start || d > end) return; const offset = daysBetween(start, d); rowContent += `
`; rowContent += `
${esc(m.name || '')}
`; }); body += timeline(rowContent) + `
`; }); // pxPerDay setzt eine feste Breite pro Tag, damit das Diagramm bei hohem // Zoom breiter als der Container wird und horizontal gescrollt werden kann. let chartStyle = ''; if(pxPerDay){ chartStyle = ` style="min-width:${(220 + Math.ceil(totalDays * pxPerDay)).toFixed(0)}px"`; } return `
${body}
`; } // ---------- Projektplan-Seite (TODOs + GANTT) ---------- function openProjectPlan(pid){ const w = currentWeek(); if(!w) return; const p = w.projects.find(x => x.id === pid); if(!p) return; // IDs sind nicht persistent (werden bei jedem CSV-Laden neu erzeugt). // Daher werden stabile Werte (Label, Projekt-Nr., Name) in der URL übergeben. const url = new URL('project-plan.html', window.location.href); url.searchParams.set('department', currentDepartment); url.searchParams.set('weekLabel', w.label); url.searchParams.set('projNr', p.nr || ''); url.searchParams.set('projName', p.name || ''); window.open(url.toString(), '_blank'); } // Diese Funktion wird von project-plan.html aufgerufen, um das aktuelle // Wochen-/Projekt-Objekt im Hauptfenster zu finden. function findProjectForPlan(department, weekId, projectId){ if(currentDepartment !== department){ const targetWeek = db.weeks.find(w => w.id === weekId); if(!targetWeek) return null; // temporär umschalten ist unpraktisch, daher versuchen wir über load-all return null; } const w = db.weeks.find(x => x.id === weekId); if(!w) return null; return { week: w, project: w.projects.find(x => x.id === projectId) }; } // ---------- Urlaub / Abwesenheiten ---------- function addAbsence(personId){ if(guardWeekLocked()) return; const people = db.globalPeople[currentDepartment] || []; const person = people.find(p => p.id === personId); if(!person) return; const start = document.getElementById('absenceStart_' + personId).value; const end = document.getElementById('absenceEnd_' + personId).value; const type = document.getElementById('absenceType_' + personId).value; const note = document.getElementById('absenceNote_' + personId).value.trim(); if(!start || !end){ alert('Bitte Start- und Enddatum angeben.'); return; } if(parseDateISO(end) < parseDateISO(start)){ alert('Enddatum liegt vor Startdatum.'); return; } if(!Array.isArray(person.absences)) person.absences = []; person.absences.push({ id: uid(), start, end, type, note }); person.absences.sort((a,b) => parseDateISO(a.start) - parseDateISO(b.start)); save(); renderPeople(); if(currentTab === 'gantt') renderGantt(); } function deleteAbsence(personId, absenceId){ if(guardWeekLocked()) return; const people = db.globalPeople[currentDepartment] || []; const person = people.find(p => p.id === personId); if(!person || !person.absences) return; person.absences = person.absences.filter(a => a.id !== absenceId); save(); renderPeople(); if(currentTab === 'gantt') renderGantt(); } // ---------- Tabs (Navigation) ---------- // showTab: wechselt zwischen Ansichten; für sensible Tabs wird ein Passwort verlangt function showTab(t, skipAuth = false){ // Für die Tabs 'projects' und 'people' ist ein Passwortschutz vorgesehen if((t==='projects' || t==='people') && !skipAuth && !isAuthenticated){ requestedAction = { type: 'tab', tab: t }; document.getElementById('passwordModal').style.display='flex'; document.getElementById('passwordInput').value=''; document.getElementById('passwordInput').focus(); return; } currentTab = t; saveUiState(); ['board','projects','load','people'].forEach(x=>{ const el = document.getElementById(x); if(el) el.style.display=(x===t?'block':'none'); }); document.querySelectorAll('.tab').forEach(el=>el.classList.toggle('active',el.dataset.tab===t)); if(t==='board' && currentWeek()) renderBoard(); if(t==='projects') renderProjects(); if(t==='load') renderLoad(); if(t==='people') renderPeople(); } // ---------- Passwort-Schutz Implementierung ---------- // confirmPassword: prüft das eingegebene Passwort gegen den gespeicherten Hash function confirmPassword(){ const pwd = document.getElementById('passwordInput').value; if(!pwd) { alert("Bitte Passwort eingeben!"); return; } const pwdHash = CryptoJS.SHA256(pwd).toString(); if(pwdHash === PROTECTED_PASSWORD_HASH){ const action = requestedAction; requestedAction = null; document.getElementById('passwordModal').style.display='none'; setTimeout(() => { if(action?.type==='tab') showTab(action.tab, true); else if(action?.type==='deleteWeek') deleteWeek(true); else if(action?.type==='unlockWeek') unlockCurrentWeek(true); }, 100); } else { alert("Falsches Passwort!"); document.getElementById('passwordInput').value=''; document.getElementById('passwordInput').focus(); } } function closePasswordModal(){ document.getElementById('passwordModal').style.display='none'; requestedAction = null; } // ---------- Export PDF (pdfmake) ---------- // Erzeugt ein Vektor-PDF: Deckblatt, Projektübersicht nach Gruppen, // Anwesenheit und Auslastung. Format durchgehend A4 quer. // Ersetzt die frühere jsPDF/html2canvas-Rasterlösung. const PDF_ROLE_BG = { 'Planung': '#D4F5E0', 'Projektleitung': '#FFDBDB', 'Bauüberwachung': '#CBEEFF' }; const PDF_NEUTRAL_BG = '#F0F0F2'; const PDF_ALT_ROW_BG = '#FAFAFC'; const PDF_BORDER = '#a6a6ad'; const PDF_ASSIGN_MAIN = '#248A3D'; const PDF_ASSIGN_OPT = '#D18B00'; // --- Seitengeometrie (A3 quer, in pt) --- const PDF_PAGE_W = 1190.55; // A3 quer, vorher A4: 841.89 const PDF_MARGIN_X = 20; const PDF_PAD_X = 2; const PDF_LINE_W = 0.8; const PDF_USABLE_W = PDF_PAGE_W - PDF_MARGIN_X * 2; // 1150,55 const PDF_STYLES = { h1: { fontSize: 20, bold: true, color: '#1D1D1F' }, h2: { fontSize: 10, bold: true, color: '#1D1D1F' }, th: { fontSize: 7, bold: true, color: '#1D1D1F', fillColor: PDF_NEUTRAL_BG }, thSub: { fontSize: 6.5, bold: true, color: '#1D1D1F' }, td: { fontSize: 7, color: '#1D1D1F' }, tdCenter: { fontSize: 7, color: '#1D1D1F', alignment: 'center' } }; function pdfRoleBg(role, fallback){ return PDF_ROLE_BG[role] || fallback || '#FFFFFF'; } function pdfTableLayout(){ return { hLineWidth: () => PDF_LINE_W, vLineWidth: () => PDF_LINE_W, hLineColor: () => PDF_BORDER, vLineColor: () => PDF_BORDER, paddingLeft: () => PDF_PAD_X, paddingRight: () => PDF_PAD_X, paddingTop: () => 2, paddingBottom: () => 2 }; } // pdfStatusText: wandelt das Status-HTML in eine pdfmake-Textstruktur um. // Erhält Zeilenumbrüche (
, Block-Ende von

/

) sowie die vom // Sanitizing erlaubten Auszeichnungen (bold, italic, underline, color). // Rückgabe: Objekt mit text-Array, direkt als Zellinhalt verwendbar. function pdfStatusText(htmlStatus){ if(!htmlStatus) return { text: '', style: 'td' }; const root = document.createElement('div'); root.innerHTML = htmlStatus; const parts = []; // pushNewline: fügt einen Umbruch ein, vermeidet aber führende // und doppelte Leerzeilen. function pushNewline(){ if(parts.length === 0) return; const last = parts[parts.length - 1]; if(typeof last.text === 'string' && last.text.endsWith('\n')) return; parts.push({ text: '\n' }); } const BLOCK = new Set(['P', 'DIV']); function walk(node, inh){ for(const child of node.childNodes){ // Textknoten if(child.nodeType === 3){ // Nur Zeichen normalisieren, die im HTML keine Bedeutung haben. // Echte Umbrüche kommen ausschliesslich aus BR/Blockenden. const t = child.nodeValue .replace(/\u00A0/g, ' ') .replace(/[\r\n\t]+/g, ' '); if(t === '') continue; // Reine Whitespace-Knoten zwischen Blöcken überspringen if(/^\s+$/.test(t) && (parts.length === 0 || (typeof parts[parts.length-1].text === 'string' && parts[parts.length-1].text.endsWith('\n')))) continue; parts.push(Object.assign({ text: t }, inh)); continue; } if(child.nodeType !== 1) continue; const tag = child.tagName.toUpperCase(); if(tag === 'BR'){ parts.push({ text: '\n' }); continue; } // Auszeichnungen vererben const own = Object.assign({}, inh); if(tag === 'B' || tag === 'STRONG') own.bold = true; if(tag === 'I' || tag === 'EM') own.italics = true; if(tag === 'U') own.decoration = 'underline'; // Farbe / Fettung aus style bzw. font color const st = child.getAttribute && child.getAttribute('style'); if(st){ const mCol = st.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i); if(mCol){ const c = pdfNormalizeColor(mCol[1].trim()); if(c) own.color = c; } const mFw = st.match(/(?:^|;)\s*font-weight\s*:\s*([^;]+)/i); if(mFw){ const v = mFw[1].trim().toLowerCase(); if(v === 'bold' || v === 'bolder' || parseInt(v, 10) >= 600) own.bold = true; if(v === 'normal' || parseInt(v, 10) < 600) own.bold = false; } } if(tag === 'FONT' && child.getAttribute('color')){ const c = pdfNormalizeColor(child.getAttribute('color')); if(c) own.color = c; } if(BLOCK.has(tag)){ pushNewline(); walk(child, own); pushNewline(); } else { walk(child, own); } } } walk(root, {}); // Führende und schliessende Umbrüche entfernen while(parts.length && parts[0].text === '\n') parts.shift(); while(parts.length && parts[parts.length-1].text === '\n') parts.pop(); if(parts.length === 0) return { text: '', style: 'td' }; return { text: parts, style: 'td' }; } // pdfNormalizeColor: pdfmake akzeptiert Hex und benannte Farben, // aber kein rgb(). Wandelt rgb()/rgba() in Hex um. function pdfNormalizeColor(v){ if(!v) return null; v = String(v).trim(); const m = v.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i); if(m){ const hex = n => Math.max(0, Math.min(255, parseInt(n,10))) .toString(16).padStart(2,'0'); return '#' + hex(m[1]) + hex(m[2]) + hex(m[3]); } if(/^#[0-9a-f]{3}$/i.test(v)){ return '#' + v[1]+v[1] + v[2]+v[2] + v[3]+v[3]; } if(/^#[0-9a-f]{6}$/i.test(v)) return v; // Benannte Farben durchlassen (pdfmake kennt die CSS-Namen) if(/^[a-z]+$/i.test(v)) return v.toLowerCase(); return null; } // loadLogoPng: lädt dein_bild.ico und konvertiert es via Canvas nach PNG. // pdfmake unterstützt nur JPEG und PNG, kein ICO. // Gibt null zurück, wenn Laden oder Dekodieren fehlschlägt. async function loadLogoPng(size){ size = size || 256; try { const res = await fetch(baseUrl() + '/dein_bild.ico'); if(!res.ok) return null; const blob = await res.blob(); const url = URL.createObjectURL(blob); try { const img = await new Promise((resolve, reject) => { const i = new Image(); i.onload = () => resolve(i); i.onerror = () => reject(new Error('Icon nicht dekodierbar')); i.src = url; }); const cv = document.createElement('canvas'); cv.width = cv.height = size; const ctx = cv.getContext('2d'); const scale = Math.min(size / img.width, size / img.height); const dw = img.width * scale, dh = img.height * scale; ctx.drawImage(img, (size - dw) / 2, (size - dh) / 2, dw, dh); return cv.toDataURL('image/png'); } finally { URL.revokeObjectURL(url); } } catch(e){ console.warn('Logo nicht ladbar:', e); return null; } } // pdfHeaderRows: die zwei Kopfzeilen der Projekttabelle als pdfmake-Zellenarrays. // Achtung: Nach colSpan:n folgen exakt n-1 leere {}; unter rowSpan-Zellen // steht in der Folgezeile ebenfalls {}. Verstöße brechen das Layout still. function pdfHeaderRows(grouped){ const h1 = [ { text: 'Pr.-Nr.', rowSpan: 2, style: 'th' }, { text: 'Bezeichnung', rowSpan: 2, style: 'th' }, { text: 'Status', rowSpan: 2, style: 'th' } ]; const h2 = [ {}, {}, {} ]; ROLES.forEach(r => { const list = grouped[r] || []; const span = Math.max(1, list.length); h1.push({ text: r, colSpan: span, style: 'th', fillColor: pdfRoleBg(r, PDF_NEUTRAL_BG), alignment: 'center' }); for(let i = 1; i < span; i++) h1.push({}); if(list.length === 0){ h2.push({ text: '–', style: 'thSub', alignment: 'center', fillColor: pdfRoleBg(r, PDF_ALT_ROW_BG) }); } else { list.forEach(e => h2.push({ text: e.person.name, style: 'thSub', alignment: 'center', fillColor: pdfRoleBg(r, PDF_ALT_ROW_BG) })); } }); h1.push({ text: 'Leistungsphasen (%)', colSpan: LPHS.length, style: 'th', alignment: 'center' }); for(let i = 1; i < LPHS.length; i++) h1.push({}); LPHS.forEach(l => h2.push({ text: String(l), style: 'thSub', alignment: 'center', fillColor: PDF_ALT_ROW_BG })); return [h1, h2]; } // pdfProjectRow: eine Projektzeile als pdfmake-Zellenarray. function pdfProjectRow(p, allPeople, alt){ const base = alt ? PDF_ALT_ROW_BG : null; const row = [ { text: p.nr || '', style: 'td', fillColor: base, noWrap: true }, { text: p.name || '', style: 'td', fillColor: base }, Object.assign(pdfStatusText(p.status), { fillColor: base }) ]; allPeople.forEach(entry => { const s = (p.assign || {})[assignKey(entry.person.id, entry.role)] || 0; const txt = s === 1 ? 'X' : s === 2 ? '(X)' : ''; const bg = s === 1 ? PDF_ASSIGN_MAIN : s === 2 ? PDF_ASSIGN_OPT : pdfRoleBg(entry.role, base || '#FFFFFF'); row.push({ text: txt, style: 'tdCenter', fillColor: bg, color: (s === 1 || s === 2) ? '#FFFFFF' : '#1D1D1F', bold: s === 1 }); }); LPHS.forEach(l => { const raw = (p.lph && p.lph[l] !== undefined && p.lph[l] !== '') ? String(p.lph[l]).replace(/%/g, '') : ''; row.push({ text: raw ? raw + '%' : '', style: 'tdCenter', fillColor: base }); }); return row; } // pdfGroupTitleRow: Gruppenüberschrift über die volle Tabellenbreite. function pdfGroupTitleRow(groupName, colCount){ const row = [{ text: groupName || '', colSpan: colCount, bold: true, fillColor: PDF_NEUTRAL_BG, fontSize: 8, color: '#1D1D1F', margin: [3, 3, 3, 3] }]; for(let i = 1; i < colCount; i++) row.push({}); return row; } // pdfColumnWidths: verteilt die tatsächlich verfügbare Breite. // Berücksichtigt Padding und Linienstärke, die pdfmake ZUSÄTZLICH // zur angegebenen Spaltenbreite rechnet. Gibt ausschliesslich // numerische Breiten zurück - kein '*', damit nichts überläuft. function pdfColumnWidths(allPeople){ const nPeople = allPeople.length; const nLph = LPHS.length; const nCols = 3 + nPeople + nLph; // Overhead, den pdfmake on top der Spaltenbreiten aufschlägt const overhead = nCols * PDF_PAD_X * 2 + (nCols + 1) * PDF_LINE_W; let budget = PDF_USABLE_W - overhead; // Schmale Spalten: schrumpfen bei Bedarf, aber nicht unter Minimum let wNr = 40; let wPerson = 15; let wLph = 19; const fixed = () => wNr + nPeople * wPerson + nLph * wLph; // Zielraum für Bezeichnung + Status const TEXT_MIN = 150; if(budget - fixed() < TEXT_MIN){ // 1. Personen- und LPH-Spalten stufenweise verschmälern while(budget - fixed() < TEXT_MIN && (wPerson > 11 || wLph > 15)){ if(wLph > 15) wLph--; if(wPerson > 11) wPerson -= 0.5; } if(budget - fixed() < TEXT_MIN && wNr > 30) wNr = 30; } let textSpace = budget - fixed(); if(textSpace < 60){ console.warn('PDF: Zu viele Spalten für A4 quer. ' + 'Verbleibend für Bezeichnung/Status: ' + Math.round(textSpace) + ' pt.'); textSpace = 60; } // Bezeichnung erhält 60 %, Status 40 % const wName = Math.floor(textSpace * 0.40); const wStatus = Math.floor(textSpace * 0.60); return [wNr, wName, wStatus] .concat(new Array(nPeople).fill(wPerson)) .concat(new Array(nLph).fill(wLph)); } // pdfCheckWidth: prüft, ob die Tabelle in den Satzspiegel passt. function pdfCheckWidth(allPeople){ const widths = pdfColumnWidths(allPeople); const nCols = widths.length; const overhead = nCols * PDF_PAD_X * 2 + (nCols + 1) * PDF_LINE_W; const total = widths.reduce((a, b) => a + b, 0) + overhead; if(total > PDF_USABLE_W + 0.5){ console.warn('PDF: Tabellenbreite ' + Math.round(total) + ' pt > verfügbar ' + Math.round(PDF_USABLE_W) + ' pt.'); return false; } return true; } // pdfAttendanceBlock: Anwesenheitsübersicht. function pdfAttendanceBlock(w){ const att = w.attendance || {}; const fmt = p => p.name + ' (' + (p.fullName || p.name) + ')'; const present = (w.people || []).filter(p => att[p.id]).map(fmt).join(', '); const absent = (w.people || []).filter(p => !att[p.id]).map(fmt).join(', '); return [ { text: 'Anwesenheit', style: 'h2', margin: [0, 0, 0, 6] }, { text: [{ text: 'Anwesend: ', bold: true }, present || '—'], fontSize: 9, margin: [0, 0, 0, 3] }, { text: [{ text: 'Abwesend: ', bold: true }, absent || '—'], fontSize: 9 } ]; } // pdfLoadSection: Auslastungsübersicht inkl. Balken (als Canvas-Rechtecke). function pdfLoadSection(w, grouped){ const stats = {}; ROLES.forEach(r => (grouped[r] || []).forEach(e => { stats[assignKey(e.person.id, r)] = { load:0, anbahnung:0, projects:{} }; })); (w.projects || []).forEach(pr => { const aktivePhase = lpActiveList(pr)[0]; Object.keys(pr.assign || {}).forEach(aKey => { const s = stats[aKey]; if(!s) return; const st = pr.assign[aKey]; const rolle = aKey.slice(aKey.indexOf('|') + 1); const al = berechneAuslastung(null, pr, aktivePhase, rolle, st); if(al.anbahnung){ s.anbahnung++; return; } if(al.load > 0){ s.load += al.load; s.projects[pr.name] = (s.projects[pr.name] || 0) + 1; } }); }); const BAR_W = 140; const barMax = LOAD_BAR_MAX; const body = [[ { text: 'Rolle', style: 'th' }, { text: 'Kürzel', style: 'th' }, { text: 'Auslastung', style: 'th' }, { text: 'Auslastung (Faktor)',style: 'th' }, { text: 'Projektdetails', style: 'th' } ]]; ROLES.forEach(r => (grouped[r] || []).forEach(entry => { const s = stats[assignKey(entry.person.id, r)]; const load = s.load; // aktuelle Sitzung = 1 Woche // Balken relativ zur einstellbaren Skala (Standard 8); die Ampel-Farbe // und der Zahlenwert bleiben ungedeckelt (Überlastung > 1,0). const col = pdfLoadAmpelColor(load); const det = Object.keys(s.projects) .map(k => k + ' (' + s.projects[k] + '\u00D7)').join(', ') || '—'; body.push([ { text: r, style: 'td' }, { text: entry.person.name, style: 'td', bold: true }, { text: faktorFormat(load), style: 'tdCenter' }, { stack: [ { canvas: [ { type:'rect', x:0, y:0, w:BAR_W, h:7, color:'#F0F0F0' }, { type:'rect', x:0, y:0, w:Math.max(0, BAR_W * Math.min(1, load / barMax)), h:7, color:col } ]}, { text: faktorFormat(load), fontSize: 6, color: '#6E6E73', alignment: 'right', width: BAR_W, margin: [0, 1, 0, 0] } ], margin: [3, 3, 3, 3] }, { text: s.anbahnung ? [ det, { text: '\nIn Anbahnung: ' + s.anbahnung, color: '#0071E3' } ] : det, style: 'td', fontSize: 6.5 } ]); })); return [ { text: 'Auslastung der Projektbeteiligten (aktuelle Sitzung)', style: 'h2', margin: [0, 0, 0, 6] }, { table: { headerRows: 1, widths: [80, 40, 45, BAR_W + 6, '*'], body }, layout: pdfTableLayout() }, { text: 'Rollenbezogener Gewichtungsfaktor je Leistungsphase. Aktueller Wert: ' + faktorFormat(LOAD_BAR_MAX), fontSize: 6.5, color: '#6E6E73', margin: [0, 4, 0, 0] } ]; } // exportPDF: Hauptfunktion. Erzeugt und lädt das PDF. async function exportPDF(){ if(!window.pdfMake){ alert('PDF-Export nicht verfügbar: pdfmake wurde nicht geladen. ' + 'Bitte Internetverbindung bzw. Skript-Einbindung prüfen.'); return; } const w = currentWeek(); if(!w) return; try { const grouped = peopleByRole(w); const allPeople = []; ROLES.forEach(r => (grouped[r] || []).forEach(e => { // role explizit setzen, falls peopleByRole sie nicht mitliefert allPeople.push({ person: e.person, role: e.role || r }); })); pdfCheckWidth(allPeople); const logo = await loadLogoPng(); const colCount = 3 + allPeople.length + LPHS.length; const widths = pdfColumnWidths(allPeople); // --- Deckblatt --- const cover = []; if(logo){ cover.push({ image: logo, width: 130, alignment: 'center', margin: [0, 0, 0, 24] }); } cover.push( { text: 'Projektübersicht ' + DEPARTMENTS[currentDepartment].label, style: 'h1', alignment: 'center', margin: [0, 0, 0, 10] }, { text: w.label || '', fontSize: 13, color: '#6E6E73', alignment: 'center', margin: [0, 0, 0, 30] } ); if(w.date){ cover.push({ text: 'Datum: ' + formatWeekDate(w.date), fontSize: 9, color: '#6E6E73', alignment: 'center', margin: [0, 0, 0, 8] }); } cover.push({ text: 'Erstellt am ' + new Date().toLocaleDateString('de-DE'), fontSize: 8, color: '#6E6E73', alignment: 'center', margin: [0, 30, 0, 0] }); const content = [{ stack: cover, margin: [0, 170, 0, 0], pageBreak: 'after' }]; // --- Projektgruppen --- const groups = {}; (w.projects || []).forEach(p => { (groups[p.group] || (groups[p.group] = [])).push(p); }); Object.keys(groups).forEach(g => groups[g].sort((a, b) => (a.order || 0) - (b.order || 0))); sortGroups(Object.keys(groups)).forEach(groupName => { const projects = groups[groupName]; if(!projects || !projects.length) return; // Kopfzeilen MÜSSEN pro Tabelle neu erzeugt werden: // pdfmake mutiert Zellobjekte (rowSpan/colSpan, _span, _inlines). // Wiederverwendung derselben Referenzen führt zu leeren Kopfzeilen // in allen Tabellen ab der zweiten. const header = pdfHeaderRows(grouped); const body = [ pdfGroupTitleRow(groupName, colCount), header[0], header[1] ]; projects.forEach((p, i) => body.push(pdfProjectRow(p, allPeople, i % 2 === 0))); content.push({ table: { headerRows: 3, widths: widths, body: body, dontBreakRows: true }, layout: pdfTableLayout(), margin: [0, 0, 0, 14], pageBreak: 'after' }); }); // --- Anwesenheit / Auslastung --- //content.push({ text: '', pageBreak: 'before' }); pdfAttendanceBlock(w).forEach(b => content.push(b)); content.push({ text: '', margin: [0, 14, 0, 0] }); pdfLoadSection(w, grouped).forEach(b => content.push(b)); const doc = { pageSize: 'A3', pageOrientation: 'landscape', pageMargins: [PDF_MARGIN_X, 25, PDF_MARGIN_X, 30], defaultStyle: { font: 'Roboto', fontSize: 7, color: '#1D1D1F' }, styles: PDF_STYLES, footer: (page, count) => page === 1 ? '' : { text: (w.label || '') + ' · Seite ' + page + ' von ' + count, alignment: 'right', fontSize: 6.5, color: '#6E6E73', margin: [0, 8, PDF_MARGIN_X, 0] }, info: { title: 'Projektübersicht ' + (w.label || ''), creator: 'Projektboard' }, content: content }; const filename = 'Projektuebersicht_' + String(w.label || 'Sitzung').replace(/[^\w]/g, '_') + '.pdf'; pdfMake.createPdf(doc).download(filename); } catch(err){ console.error('PDF-Export fehlgeschlagen:', err); alert('PDF-Export fehlgeschlagen: ' + (err.message || err)); } } // sendCsvToServer: POSTet CSV an /save-csv function blobToDataUrl(blob){ return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(blob); }); } function sendCsvToServer(filename, csvContent){ if(!window.fetch) return Promise.reject(new Error('fetch nicht verfügbar')); return fetch(baseUrl() + '/save-csv',{ method:'POST', headers:{'Content-Type':'application/json; charset=utf-8'}, body:JSON.stringify({filename,csv:csvContent,department:currentDepartment}) }).then(r=>{ if(!r.ok) throw new Error('Server HTTP '+r.status); return r.json(); }); } // ---------- Show Mode ---------- let showModeActive = false; let showModeTimer = null; let showModeResumeTimer = null; let showModePaused = false; let showModeOriginal = { department: DEFAULT_DEPARTMENT, tab: DEFAULT_TAB }; const SHOW_MODE_SLIDE_DURATION = 10000; // 10 Sekunden pro Slide const SHOW_MODE_PAUSE_DURATION = 60000; // 1 Minute Pause const SHOW_MODE_SEQUENCE = [ { dept: 'ELT', view: 'board' }, { dept: 'ELT', view: 'load' }, { dept: 'HLS', view: 'board' }, { dept: 'HLS', view: 'load' }, { dept: 'ARCH', view: 'board' }, { dept: 'ARCH', view: 'load' } ]; let showModeIndex = 0; function startShowMode(){ if(showModeActive) return; showModeActive = true; showModePaused = false; showModeOriginal = { department: currentDepartment, tab: currentTab }; showModeIndex = 0; const overlay = document.getElementById('showModeOverlay'); if(overlay) overlay.style.display = 'flex'; // Menü schließen, falls offen setHeaderCollapsed(true, false); showModeNextSlide(); } function stopShowMode(){ showModeActive = false; showModePaused = false; clearTimeout(showModeTimer); clearTimeout(showModeResumeTimer); const overlay = document.getElementById('showModeOverlay'); if(overlay) overlay.style.display = 'none'; // Ursprünglichen Zustand wiederherstellen currentTab = showModeOriginal.tab; if(currentDepartment !== showModeOriginal.department){ switchDepartment(showModeOriginal.department); } else { showTab(currentTab, true); renderAll(); } } function pauseShowMode(){ if(!showModeActive) return; if(showModePaused){ // bereits pausiert -> sofort fortsetzen resumeShowMode(); return; } showModePaused = true; clearTimeout(showModeTimer); clearTimeout(showModeResumeTimer); const pausedEl = document.getElementById('showModePaused'); if(pausedEl) pausedEl.style.display = 'block'; showModeResumeTimer = setTimeout(() => { if(showModeActive && showModePaused) resumeShowMode(); }, SHOW_MODE_PAUSE_DURATION); } function resumeShowMode(){ if(!showModeActive) return; showModePaused = false; clearTimeout(showModeResumeTimer); const pausedEl = document.getElementById('showModePaused'); if(pausedEl) pausedEl.style.display = 'none'; showModeNextSlide(); } function showModeNextSlide(){ if(!showModeActive || showModePaused) return; const slide = SHOW_MODE_SEQUENCE[showModeIndex % SHOW_MODE_SEQUENCE.length]; showModeIndex++; const titleEl = document.getElementById('showModeTitle'); if(titleEl){ const deptLabel = DEPARTMENTS[slide.dept] ? DEPARTMENTS[slide.dept].label : slide.dept; titleEl.textContent = `${deptLabel} – ${slide.view === 'load' ? 'Auslastung' : 'Projektboard'}`; } // Ladehinweis anzeigen, damit der alte Inhalt nicht kurz sichtbar flackert const content = document.getElementById('showModeContent'); if(content){ content.innerHTML = '
Lädt…
'; } // Wechsle Department und View in der Hauptansicht, dann kopiere ins Overlay. // Da switchDepartment asynchron lädt, müssen wir auf den Abschluss warten. if(currentDepartment !== slide.dept){ // Temporär currentTab setzen, damit nach dem Laden der richtige Tab aktiv ist. currentTab = slide.view; switchDepartment(slide.dept); // Warte bis Daten geladen sind, dann rendern und kopieren waitForLoadThenRender(slide.view); } else { showTab(slide.view, true); renderAll(); requestAnimationFrame(() => { requestAnimationFrame(() => { copyCurrentViewToShowMode(); scheduleNextShowModeSlide(); }); }); } } function waitForLoadThenRender(view){ let attempts = 0; const check = setInterval(() => { attempts++; // Wenn Wochen geladen sind oder nach Seed gefallen ist if(db.weeks.length > 0 || attempts > 30){ clearInterval(check); showTab(view, true); renderAll(); // Kurze Verzögerung, damit das DOM aktualisiert ist, bevor kopiert wird requestAnimationFrame(() => { requestAnimationFrame(() => { copyCurrentViewToShowMode(); scheduleNextShowModeSlide(); }); }); } }, 200); } function scheduleNextShowModeSlide(){ if(!showModeActive || showModePaused) return; clearTimeout(showModeTimer); showModeTimer = setTimeout(() => { showModeNextSlide(); }, SHOW_MODE_SLIDE_DURATION); } function copyCurrentViewToShowMode(){ const content = document.getElementById('showModeContent'); const sourceId = currentTab === 'load' ? 'load' : 'board'; const source = document.getElementById(sourceId); if(content && source){ content.innerHTML = source.innerHTML; // Im Show Mode sollen in der Auslastung Überschrift und Steuerelemente ausgeblendet werden if(currentTab === 'load'){ content.querySelectorAll('.section-title, .toolbar').forEach(el => el.remove()); } } } // ESC beendet Show Mode document.addEventListener('keydown', function(e){ if(showModeActive && e.key === 'Escape'){ stopShowMode(); } }); // ---------- Init / Start ---------- // renderAll: Convenience um UI-Elemente zu initialisieren function renderAll(){ refreshWeekSelect(); updateWeekDate(); renderBoard(); const activeTab = document.querySelector('.tab.active')?.dataset.tab || currentTab; if(activeTab === 'load') renderLoad(); else if(activeTab === 'projects') renderProjects(); else if(activeTab === 'people') renderPeople(); } function initUiState(){ loadUiState(); loadTheme(); loadHeaderState(); document.querySelectorAll('.dept-tab').forEach(btn => { btn.classList.toggle('active', btn.dataset.dept === currentDepartment); }); const info = DEPARTMENTS[currentDepartment]; document.title = 'Projektübersicht ' + info.label; const titleEl = document.getElementById('pageTitle'); if(titleEl) titleEl.textContent = info.icon + ' Projektübersicht ' + info.label; const tab = ['board','load','projects','people'].includes(currentTab) ? currentTab : DEFAULT_TAB; // Beim initialen Start niemals einen passwortgeschützten Tab direkt anzeigen. const safeTab = (tab === 'projects' || tab === 'people') ? DEFAULT_TAB : tab; showTab(safeTab, true); } function syncRangeDisplay(displayId, startId, endId){ const disp = document.getElementById(displayId); if(!disp) return; const sEl = document.getElementById(startId); const eEl = document.getElementById(endId); const s = sEl ? parseDateISO(sEl.value) : null; const e = eEl ? parseDateISO(eEl.value) : null; const from = s ? formatDateDE(s) : ''; const to = e ? formatDateDE(e) : from; disp.value = from ? (from + ' – ' + to) : ''; } // Automatische Initialisierung nur auf der Hauptseite (Projektboard). // Andere Seiten (z.B. Projektplan) laden main.v2.js nur für die Hilfsfunktionen. if(document.getElementById('board')){ initUiState(); switchDepartment(currentDepartment); }