// ----------------------------- // Projektübersicht — Export PDF (pdfmake) // Erzeugt ein Vektor-PDF: Deckblatt, Projektübersicht nach Gruppen, // Anwesenheit und Auslastung. Format durchgehend A3 quer. // Ersetzt die frühere jsPDF/html2canvas-Rasterlösung. // ----------------------------- // Modul für den PDF-Export der Hauptseite (nur von Test.html geladen, direkt // vor js/init.js); exportPDF() hängt am Export-Button von Test.html und nutzt // die per CDN eingebundene Bibliothek pdfmake (offline nicht verfügbar). Es // liest die aktuelle Woche über currentWeek() aus js/store.js, Personen über // peopleByRole() aus js/utils.js, Helfer wie assignKey()/sortGroups()/ // formatWeekDate() aus js/utils.js sowie die Auslastungs-Berechnung // (berechneAuslastung, lpActiveList, faktorFormat, pdfLoadAmpelColor, // LOAD_BAR_MAX) aus js/load/load.js. Das Modul stellt ausschließlich exportPDF() // bereit. // Farbkonstanten des PDF-Layouts: Rollen-Hintergründe, neutrale bzw. // alternierende Zeilenfarben, Rahmenfarbe und die Kennzeichnungsfarben für // Haupt-Zuordnung (X) und Optionale Zuordnung ((X)). 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 // PDF_STYLES: wiederverwendbare Textstile (Überschriften, Tabellen-/Zellformat) // für die pdfmake-Dokumentdefinition 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' } }; // pdfRoleBg: liefert die Hintergrundfarbe einer Rolle aus PDF_ROLE_BG bzw. den // übergebenen Fallback. Parameter: role = Rollenname, fallback = Ersatzfarbe; // Rückgabe: Hex-Farbstring. function pdfRoleBg(role, fallback){ return PDF_ROLE_BG[role] || fallback || '#FFFFFF'; } // pdfTableLayout: erzeugt das pdfmake-Tabellenlayout (feine Rahmen, kleines // Zell-Padding in den PDF-Farben). Rückgabe: Layout-Objekt für pdfmake. 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' }); } // BLOCK: Block-Tags, deren Ende einen Zeilenumbruch erzeugt const BLOCK = new Set(['P', 'DIV']); // walk: durchläuft den DOM-Baum rekursiv und sammelt Textfragmente samt // geerbter Auszeichnungen (bold/italics/underline/color) als pdfmake-Inlines. // node = Knoten, inh = von den Eltern geerbte Stil-Attribute. 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 => { // Platzhalter-Einträge für leere Rollen (person === null) → leere Zelle, // damit Spaltenzahl mit der Kopfzeile übereinstimmt. if(!entry.person){ row.push({ text: '', style: 'tdCenter', fillColor: base || '#FFFFFF' }); return; } 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); // displayPeople: Spalten der Tabelle inkl. Platzhalter für leere Rollen – // pdfHeaderRows erzwingt pro Rolle mindestens eine Spalte (colSpan/“–”), // deshalb müssen Zeilen, Breiten und colCount dieselbe Spaltenzahl nutzen, // sonst wirft pdfmake bei einer leeren Rolle einen Layout-Fehler. const displayPeople = []; ROLES.forEach(r => { const list = grouped[r] || []; if(list.length === 0){ displayPeople.push({ person: null, role: r }); } else { list.forEach(e => displayPeople.push({ person: e.person, role: e.role || r })); } }); pdfCheckWidth(displayPeople); const logo = await loadLogoPng(); const colCount = 3 + displayPeople.length + LPHS.length; const widths = pdfColumnWidths(displayPeople); // --- 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, displayPeople, 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)); } }