From cddce52d3db63e123a0af9e444b55ab2949b6e2e Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 12 Aug 2026 16:25:32 +0200 Subject: [PATCH] =?UTF-8?q?PDF=20Export=20von=20html2canvas=20auf=20makepd?= =?UTF-8?q?f=20ge=C3=A4ndert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 6148 -> 10244 bytes public/Test.html | 4 +- public/main.v2.js | 905 +++++++++++++++++++++++++++------------------- 3 files changed, 530 insertions(+), 379 deletions(-) diff --git a/.DS_Store b/.DS_Store index b51b8ab1cebb60131cde2f40866483f80747ee3d..e46b6d8790e260f8e173224911f952520e4c5ed7 100644 GIT binary patch literal 10244 zcmeHMU2GIp6uxI#+B{}SR)f)8rM1|u(EVj`MoV!{iFQO}(_+wN>B@r9p1y22#g`X_J<%ojD}n~!l|=$VC5VEkQO4_ zDcF`-18LBuA(xJDiX&Knt0Lv9;2tr6t0O@oDju@6e}7Wpn*I zajGnf)lKBbef>Z39={vW6Nb+qNs~DGs*yx|$QpFx<-_=(u32_X%|#Fj3a1nm(_&ge zAB*&xLlHY>r>(|Vs)zO3OjD0%+p&0urVd5r8BL~P$23DtISQJhqR8G(%}~stMl)?F zmZKZt15~79R1OReuX=EGb+E3!{!n#rc-^X+>R|nv`a_3AIy`!^B>h97KrV-H&s!_Sr z!i8_c?yi)Y!3B3FOg)`6tf*X;i0O&69<$X3U1!3`E430SQ`dKymS$_Fv8T&c_uE-R zo}sR8%S_o#j^je9cs!#S{16{ix*E$n>}yg;^iyr z*KKTiq`7spR4yx@K0}ha5oN>HdNnoGKBOsj|DKqYPz^;h`Z_b3rNwo%&4?w{GPo>? zqvdj0X`mu7m&#SND;?LKOl$T5hbt`z1V#A_-K(H8EvCXODqMa+;9g357{4i{V$Ipo z(!hP99HVU(0>MpGODja#659qa6u4@wR7Yh+Y}aFPRp+cV(poAvi=7$1Le5+-ZJ=dP zEq38$WfIq8ads%sNagc%ADeqe%(7btRU_+FD6oanZmU0*Qd@^G7PA`LDRq8B2tZ4FvGl(2R_Rk&lq`1duOKDpqmq38@M#=gq2fy&CJma)P`^J|kzzH{>GunfyxrBG<`303bjCNH7&j zL52!g1XZvYmOvdm3>#o0Y=;Q6K?iigUQnSI`auH&hG0J&gkd-gN8uPe2hYRHZ~|V1 z*WnF#8{UTx;3N1LzJ?3%1B}9DxC+0)@9?)UMJN)Ag)(7|@PM#Vs1a&~Rl<59EVKyS zLJ#*|$Y!70&#q0-vE|uObSvZTD8eU`WY^Yh+nc49yFR;qn#S{K=B(L4mSk%;G>(NL z58iCN=H`JD`kqK|Lf^@^{Fn_?%$YlHzEs(cxuNc#ELG)VmIZW_NiSR^RmxK^dw6%r zQtTFC=J4+Q)s>a9fCcQ2%FAhBNIs(x@q(w zpQRkd4=?0z(>GGz(?SA zAW*<339|eD&6EHC|8}_I&*3BBBXEZhfWmMj%&u^87y;N|39Cf7pHvg9YGr&Lp`{(~Vc)NBtod5p=I+k+< delta 215 zcmZn(XfcprU|?W$DortDU=RQ@Ie-{Mvv5sJ6q~50$SAQfU^g?P#AF@;$vA%1`yeR> zJ%)6KOoo!&d>5Cboctt^HtPhQ>p@~C98u*{;PP@dE3@3uProjektübersicht Elektro - - + +
diff --git a/public/main.v2.js b/public/main.v2.js index e3a63d6..23918ad 100644 --- a/public/main.v2.js +++ b/public/main.v2.js @@ -2222,410 +2222,561 @@ function closePasswordModal(){ requestedAction = null; } -// ---------- Export PDF ---------- -// exportPDF: erzeugt ein PDF im Format A4 hochkant (Deckblatt) und im Querformat (Tabelle, -// Anwesenheit und Auslastung auf mehreren Seiten, falls nötig). +// ---------- 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 (A4 quer, in pt) --- +const PDF_PAGE_W = 841.89; +const PDF_MARGIN_X = 20; // war 25 +const PDF_PAD_X = 2; // war 3 -> 4pt statt 6pt je Spalte +const PDF_LINE_W = 0.8; +const PDF_USABLE_W = PDF_PAGE_W - PDF_MARGIN_X * 2; // 801,89 + + +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.60); + const wStatus = Math.floor(textSpace * 0.40); + + 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)] = + { main:0, opt:0, kleine:0, anbahnung:0, projects:{} }; + })); + + (w.projects || []).forEach(pr => { + const isAnbahnung = pr.group === 'In Anbahnung'; + const isKleine = pr.group === 'Kleine Projekte'; + Object.keys(pr.assign || {}).forEach(aKey => { + const s = stats[aKey]; + if(!s) return; + const st = pr.assign[aKey]; + if(isAnbahnung){ + if(st === 1 || st === 2) s.anbahnung++; + return; + } + if(isKleine){ + if(st === 1 || st === 2){ + s.kleine++; + s.projects[pr.name] = (s.projects[pr.name] || 0) + 1; + } + return; + } + if(st === 1) s.main++; + else if(st === 2) s.opt++; + if(st === 1 || st === 2){ + s.projects[pr.name] = (s.projects[pr.name] || 0) + 1; + } + }); + }); + + const loadOf = s => s.main + s.opt * 0.5 + s.kleine * 0.3; + let maxLoad = 1; + Object.keys(stats).forEach(k => { + const l = loadOf(stats[k]); + if(l > maxLoad) maxLoad = l; + }); + + const BAR_W = 90; + const body = [[ + { text: 'Rolle', style: 'th' }, + { text: 'Kürzel', style: 'th' }, + { text: 'Punkte', style: 'th' }, + { text: 'Auslastung', style: 'th' }, + { text: 'Projektdetails', style: 'th' } + ]]; + + ROLES.forEach(r => (grouped[r] || []).forEach(entry => { + const s = stats[assignKey(entry.person.id, r)]; + const load = loadOf(s); + const pct = Math.round(load / maxLoad * 100); + const col = pct > 75 ? '#D70015' : pct > 45 ? '#B25000' : '#248A3D'; + 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: String(load).replace('.', ','), 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 * pct / 100), + h:7, color:col } + ]}, + { text: pct + '%', 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, 35, BAR_W + 6, '*'], body }, + layout: pdfTableLayout() }, + { text: 'Haupt = 1 · Optional = 0,5 · Kleine Projekte = 0,3 · ' + + '„In Anbahnung" wird nicht in die Punktzahl eingerechnet.', + fontSize: 6.5, color: '#6E6E73', margin: [0, 4, 0, 0] } + ]; +} + +// exportPDF: Hauptfunktion. Erzeugt und lädt das PDF. async function exportPDF(){ - const { jsPDF } = window.jspdf; - if(!jsPDF || !window.html2canvas){ - alert('PDF-Export nicht verfügbar. Bitte prüfen Sie die Internetverbindung (erforderlich für html2canvas / jsPDF).'); + 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; - // Temporären Container in unsichtbarem Bereich anlegen - let container = document.getElementById('pdfExportContainer'); - if(container) container.remove(); - container = document.createElement('div'); - container.id = 'pdfExportContainer'; - container.style.position = 'fixed'; - container.style.top = '-10000px'; - container.style.left = '0'; - container.style.width = '1200px'; - container.style.background = '#fff'; - container.style.padding = '20px'; - container.style.fontFamily = '-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; - container.style.fontSize = '10px'; - container.style.color = 'var(--text)'; - document.body.appendChild(container); - - const grouped = peopleByRole(w); - const allPeople = []; - ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry))); - - // --- Teilnehmerlisten --- - const att = w.attendance || {}; - const presentList = (w.people || []).filter(p => att[p.id]).map(p => `${esc(p.name)} (${esc(p.fullName || p.name)})`).join(', '); - const absentList = (w.people || []).filter(p => !att[p.id]).map(p => `${esc(p.name)} (${esc(p.fullName || p.name)})`).join(', '); - - // Ico als Bild laden und in Base64 umwandeln, damit html2canvas es korrekt rendern kann - let logoDataUrl = null; try { - const icoResponse = await fetch(baseUrl() + '/dein_bild.ico'); - if(icoResponse.ok){ - const icoBlob = await icoResponse.blob(); - logoDataUrl = await blobToDataUrl(icoBlob); + 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] }); } - } catch(e){ - console.warn('Konnte Logo nicht laden:', e); - } + 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] }); - // Deckblatt: A4 hochkant, einfacher Titel und Übersicht - const cover = document.createElement('div'); - cover.style.width = '595px'; - cover.style.minHeight = '842px'; - cover.style.padding = '80px 60px'; - cover.style.boxSizing = 'border-box'; - cover.style.background = '#fff'; - cover.style.display = 'flex'; - cover.style.flexDirection = 'column'; - cover.style.justifyContent = 'center'; - cover.style.textAlign = 'center'; - cover.style.fontFamily = '-apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif'; - const logoHtml = logoDataUrl - ? `
Logo
` - : `
⚡
`; - cover.innerHTML = ` - ${logoHtml} -

Projektübersicht ${DEPARTMENTS[currentDepartment].label}

-
${esc(w.label)}
- ${w.date ? `
Datum: ${esc(formatWeekDate(w.date))}
` : ''} -
Erstellt am ${new Date().toLocaleDateString('de-DE')}
- `; - container.appendChild(cover); + const content = [{ stack: cover, margin: [0, 110, 0, 0], pageBreak: 'after' }]; - // Inhaltsseiten: A4 quer (Container bleibt leer, Blöcke werden einzeln gerendert) - const content = document.createElement('div'); - content.style.width = '1123px'; // A4 Quer bei 96 DPI - content.style.background = '#fff'; - content.style.padding = '20px'; - content.style.boxSizing = 'border-box'; - content.innerHTML = buildPdfAttendanceBlock(presentList, absentList) - + buildPdfLoadSection(allPeople); - container.appendChild(content); - - const pdf = new jsPDF({ unit: 'px', format: 'a4' }); - const pageWidthPx = pdf.internal.pageSize.getWidth(); - const pageHeightPx = pdf.internal.pageSize.getHeight(); - const marginPx = 32; - const landscapePageW = pageHeightPx - marginPx * 2; - const landscapePageH = pageWidthPx - marginPx * 2; - - // JPEG quality (0.1 - 1.0) - lower = smaller file - const JPEG_QUALITY = 1; - - async function addImageToLandscapePage(canvas){ - const imgW = landscapePageW; - const imgH = (canvas.height * imgW) / canvas.width; - pdf.addPage([pageHeightPx, pageWidthPx], 'landscape'); - pdf.addImage(canvas.toDataURL('image/jpeg', JPEG_QUALITY), 'JPEG', marginPx, marginPx, imgW, imgH); - } - - async function drawBlockOnLandscapePage(blockHTML){ - const wrapper = document.createElement('div'); - wrapper.style.width = '1123px'; - wrapper.style.padding = '20px'; - wrapper.style.boxSizing = 'border-box'; - wrapper.style.background = '#fff'; - wrapper.innerHTML = blockHTML; - container.appendChild(wrapper); - const canvas = await html2canvas(wrapper, { scale: 1.5, useCORS: true, backgroundColor: '#ffffff', width: 1123 }); - const imgH = (canvas.height * landscapePageW) / canvas.width; - wrapper.remove(); - return { canvas, imgH }; - } - - try { - // Deckblatt (Hochkant) zeichnen - const coverCanvas = await html2canvas(cover, { scale: 1.5, useCORS: true, backgroundColor: '#ffffff', width: 595, height: 842 }); - if(pdf.internal.getNumberOfPages() > 0) pdf.addPage(); - const coverW = pageWidthPx - marginPx * 2; - const coverH = (coverCanvas.height * coverW) / coverCanvas.width; - const coverX = marginPx + (coverW - (coverCanvas.width * coverW / coverCanvas.width)) / 2; - pdf.addImage(coverCanvas.toDataURL('image/jpeg', JPEG_QUALITY), 'JPEG', marginPx, marginPx + ((pageHeightPx - marginPx * 2 - coverH) / 2), coverW, coverH); - - const headerRows = buildPdfTableHeaderRows(allPeople); - - // Projektgruppen vorbereiten + // --- 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))); + (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))); - // Anwesenheit und Auslastung als HTML-Strings vorbereiten - const extraBlocks = [buildPdfAttendanceBlock(presentList, absentList), buildPdfLoadSection(allPeople)]; - - // Hilfsfunktion: misst Höhe einer Tabelle mit gegebenen Zeilen - async function measureBlockWithRows(titleRow, bodyRows){ - const wrapper = renderSinglePdfBlock(container, headerRows, titleRow, bodyRows); - const canvas = await html2canvas(wrapper, { scale: 1.5, useCORS: true, backgroundColor: '#ffffff', width: 1123 }); - const imgH = (canvas.height * landscapePageW) / canvas.width; - wrapper.remove(); - return { canvas, imgH }; - } - - const groupBlocks = []; - - for(const groupName of sortGroups(Object.keys(groups))){ + sortGroups(Object.keys(groups)).forEach(groupName => { const projects = groups[groupName]; - if(!projects || projects.length === 0) continue; + if(!projects || !projects.length) return; - const titleRow = buildPdfGroupTitleRow(groupName, allPeople); + // 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); - let currentRows = ''; - let currentStart = 0; + const body = [ pdfGroupTitleRow(groupName, colCount), header[0], header[1] ]; + projects.forEach((p, i) => body.push(pdfProjectRow(p, allPeople, i % 2 === 0))); - for(let i = 0; i < projects.length; i++){ - const rowHTML = buildPdfProjectRow(projects[i], allPeople); - // Alternating row background to match project board design - const rowBg = i % 2 === 0 ? ' style="background:#fafafc"' : ''; - const rowHTMLWithBg = rowHTML.replace('', ``); - const testRows = currentRows + rowHTMLWithBg; - const { imgH: testImgH } = await measureBlockWithRows(titleRow, testRows); + content.push({ + table: { headerRows: 3, widths: widths, body: body, dontBreakRows: true }, + layout: pdfTableLayout(), + margin: [0, 0, 0, 14] + }); + }); - // Wenn die aktuelle Seite überschritten würde und wir bereits Zeilen haben, - // schließen wir den aktuellen Block ab und beginnen auf der nächsten Seite neu. - if(testImgH > landscapePageH && currentRows !== ''){ - groupBlocks.push({ titleRow, bodyRows: currentRows, isContinuation: currentStart > 0 }); - currentRows = rowHTML; - currentStart = i; - } else { - currentRows = testRows; - } - } - if(currentRows !== ''){ - groupBlocks.push({ titleRow, bodyRows: currentRows, isContinuation: currentStart > 0 }); - } - } + // --- 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)); - // Projektboard-Blöcke zeichnen - for(const block of groupBlocks){ - const wrapper = renderSinglePdfBlock(container, headerRows, block.titleRow, block.bodyRows); - const canvas = await html2canvas(wrapper, { scale: 1.5, useCORS: true, backgroundColor: '#ffffff', width: 1123 }); - await addImageToLandscapePage(canvas); - wrapper.remove(); - } + const doc = { + pageSize: 'A4', + 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 + }; - // Anwesenheit und Auslastung zeichnen - for(const blockHTML of extraBlocks){ - const { canvas } = await drawBlockOnLandscapePage(blockHTML); - await addImageToLandscapePage(canvas); - } + const filename = 'Projektuebersicht_' + + String(w.label || 'Sitzung').replace(/[^\w]/g, '_') + '.pdf'; + pdfMake.createPdf(doc).download(filename); - // Erste (automatisch angelegte) leere Startseite entfernen, da Deckblatt manuell hinzugefügt wurde - if(pdf.internal.getNumberOfPages() > 1){ - try { pdf.deletePage(1); } catch(e){} - } - - const filename = 'Projektuebersicht_' + (w.label.replace(/[^\w]/g,'_')) + '.pdf'; - pdf.save(filename); } catch(err){ console.error('PDF-Export fehlgeschlagen:', err); alert('PDF-Export fehlgeschlagen: ' + (err.message || err)); - } finally { - container.remove(); } } -// buildPdfTableHeaderRows: baut die Kopfzeilen der Projektübersichts-Tabelle als HTML-String (zwei Zeilen). -function buildPdfTableHeaderRows(allPeople){ - let h1 = `Pr.-Nr.BezeichnungStatus`; - const grouped = peopleByRole(currentWeek()); - ROLES.forEach(r => { - const count = Math.max(1, grouped[r].length); - // Rollen-spezifische Hintergrundfarben aus dem Projektboard - let roleBg; - if(r === 'Planung') roleBg = 'rgb(212, 245, 224)'; - else if(r === 'Projektleitung') roleBg = 'rgb(255, 219, 219)'; - else if(r === 'Bauüberwachung') roleBg = 'rgb(203, 238, 255)'; - else roleBg = '#f0f0f2'; - h1 += `${r}`; - }); - h1 += `Leistungsphasen (%)`; - - let h2 = ``; - ROLES.forEach(r => { - let roleBg; - if(r === 'Planung') roleBg = 'rgb(212, 245, 224)'; - else if(r === 'Projektleitung') roleBg = 'rgb(255, 219, 219)'; - else if(r === 'Bauüberwachung') roleBg = 'rgb(203, 238, 255)'; - else roleBg = '#fafafc'; - if(grouped[r].length === 0){ - h2 += `–`; - } else { - grouped[r].forEach(entry => { - h2 += `${esc(entry.person.name)}`; - }); - } - }); - LPHS.forEach(l => h2 += `${l}`); - h2 += ``; - return h1 + h2; -} - -// buildPdfProjectRow: baut eine einzelne Projektzeile als HTML-String. -function buildPdfProjectRow(p, allPeople){ - let body = ``; - body += `${esc(p.nr)}`; - body += `${esc(p.name)}`; - const statusText = pdfStatusText(p.status); - body += `${statusText}`; - allPeople.forEach(entry => { - const s = p.assign[assignKey(entry.person.id, entry.role)] || 0; - const txt = s === 1 ? 'X' : s === 2 ? '(X)' : ''; - let roleBg = '#fff'; - if(entry.role === 'Planung') roleBg = 'rgb(212, 245, 224)'; - else if(entry.role === 'Projektleitung') roleBg = 'rgb(255, 219, 219)'; - else if(entry.role === 'Bauüberwachung') roleBg = 'rgb(203, 238, 255)'; - const textColor = (s === 1 || s === 2) ? '#fff' : '#1d1d1f'; - const cellBg = s === 1 ? '#248a3d' : s === 2 ? '#d18b00' : roleBg; - body += `${txt}`; - }); - LPHS.forEach(l => { - const v = (p.lph[l] !== undefined && p.lph[l] !== '') ? String(p.lph[l]).replace('%','') : ''; - body += `${esc(v ? v + '%' : '')}`; - }); - body += ``; - return body; -} - -// Build PDF group body rows with alternating background colors -function buildPdfGroupBodyRows(projectRows, allPeople) { - let html = ''; - projectRows.forEach((pr, i) => { - const s = pr.assign ? Object.values(pr.assign)[0] || 0 : 0; - const isEven = i % 2 === 0; - const rowBg = isEven ? '#fafafc' : '#fff'; - html += ``; - html += `${esc(pr.nr)}`; - html += `${esc(pr.name)}`; - const statusText = pdfStatusText(pr.status); - html += `${statusText}`; - allPeople.forEach(entry => { - const assignKey = `${entry.person.id}-${entry.role}`; - const assign = pr.assign ? pr.assign[assignKey] : 0; - const txt = assign === 1 ? 'X' : assign === 2 ? '(X)' : ''; - const bg = assign === 1 ? '#248a3d' : assign === 2 ? '#d18b00' : '#fff'; - html += `${txt}`; - }); - LPHS.forEach(l => { - const v = (pr.lph && pr.lph[l] !== undefined && pr.lph[l] !== '') ? String(pr.lph[l]).replace('%','') : ''; - html += `${esc(v ? v + '%' : '')}`; - }); - html += ``; - }); - return html; -} - -// buildPdfGroupTitleRow: baut die Zeile mit dem Gruppentitel. -function buildPdfGroupTitleRow(groupName, allPeople){ - const colCount = 3 + allPeople.length + LPHS.length; - return `${esc(groupName)}`; -} - -// renderSinglePdfBlock: erzeugt einen DOM-Wrapper mit einer einzelnen Tabelle -// für den PDF-Export (hilft bei der Höhenmessung). -function renderSinglePdfBlock(container, headerRows, titleRow, bodyRows){ - const wrapper = document.createElement('div'); - wrapper.style.width = '1123px'; - wrapper.style.padding = '20px'; - wrapper.style.boxSizing = 'border-box'; - wrapper.style.background = '#fff'; - wrapper.innerHTML = `
${titleRow}${headerRows}${bodyRows}
`; - container.appendChild(wrapper); - return wrapper; -} - -// buildPdfAttendanceBlock: baut den Anwesenheits-Block als HTML-String. -function buildPdfAttendanceBlock(presentList, absentList){ - return `
-
Anwesenheit
-
Anwesend: ${presentList || '—'}
-
Abwesend: ${absentList || '—'}
-
`; -} - -// pdfStatusText: konvertiert HTML-Status in reinen Text mit Zeilenumbrüchen. -function pdfStatusText(htmlStatus){ - if(!htmlStatus) return ''; - const div = document.createElement('div'); - div.innerHTML = htmlStatus; - const text = (div.textContent || div.innerText || '').replace(/\u00A0/g, ' ').trim(); - return esc(text); -} - -// buildPdfLoadSection: baut die Auslastungsübersicht für das PDF. -function buildPdfLoadSection(allPeople){ - const w = currentWeek(); - const grouped = peopleByRole(w); - const stats = {}; - ROLES.forEach(r => { - grouped[r].forEach(entry => { - stats[assignKey(entry.person.id, r)] = {main:0, opt:0, kleine:0, anbahnung:0, projects:{}}; - }); - }); - - w.projects.forEach(pr => { - const isAnbahnung = pr.group === 'In Anbahnung'; - const isKleine = pr.group === 'Kleine Projekte'; - Object.keys(pr.assign).forEach(aKey => { - if(!stats[aKey]) return; - if(isAnbahnung){ - if(pr.assign[aKey] === 1 || pr.assign[aKey] === 2) stats[aKey].anbahnung++; - return; - } - const st = pr.assign[aKey]; - if(isKleine){ - if(st === 1 || st === 2){ - stats[aKey].kleine++; - stats[aKey].projects[pr.name] = (stats[aKey].projects[pr.name] || 0) + 1; - } - return; - } - if(st === 1) stats[aKey].main++; - else if(st === 2) stats[aKey].opt++; - stats[aKey].projects[pr.name] = (stats[aKey].projects[pr.name] || 0) + 1; - }); - }); - - let maxLoad = 1; - Object.keys(stats).forEach(k => { - const l = stats[k].main + stats[k].opt * 0.5 + stats[k].kleine * 0.3; - if(l > maxLoad) maxLoad = l; - }); - - let html = `
Auslastung der Projektbeteiligten (aktuelle Sitzung)
`; - html += ''; - - ROLES.forEach(r => { - grouped[r].forEach(entry => { - const s = stats[assignKey(entry.person.id, r)]; - const load = s.main + s.opt * 0.5 + s.kleine * 0.3; - const pct = Math.round(load / maxLoad * 100); - const color = pct > 75 ? '#d70015' : pct > 45 ? '#b25000' : '#248a3d'; - const detail = Object.keys(s.projects).map(k => `${esc(k)} (${s.projects[k]}×)`).join(', ') || '—'; - html += ` - - - - - - `; - }); - }); - - html += '
RolleKürzelPunkteBalkenProjektdetails
${r}${esc(entry.person.name)}${load.toString().replace('.',',')} -
-
-
-
${pct}%
-
${detail}${s.anbahnung ? `
In Anbahnung: ${s.anbahnung}` : ''}
'; - html += '
Haupt=1 · Optional=0,5 · Kleine Projekte=0,3 · In Anbahnung wird nicht in der Punktzahl gezählt.
'; - html += '
'; - return html; -} - // sendCsvToServer: POSTet CSV an /save-csv function blobToDataUrl(blob){ return new Promise((resolve, reject) => {