PDF Export von html2canvas auf makepdf geändert

This commit is contained in:
Erik committed 2026-08-12 16:25:32 +02:00
1 parent c055091dc7
commit cddce52d3d
3 files changed
+526 -375

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+2 -2
View File
@@ -6,8 +6,8 @@
<title>Projektübersicht Elektro</title>
<link rel="stylesheet" href="styles.css?v=3">
<script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/pdfmake.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.2.10/vfs_fonts.js"></script>
</head>
<body>
<div class="dept-tabs">
+524 -373
View File
@@ -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 (<br>, Block-Ende von <p>/<div>) 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);
try {
const grouped = peopleByRole(w);
const allPeople = [];
ROLES.forEach(r => grouped[r].forEach(entry => allPeople.push(entry)));
ROLES.forEach(r => (grouped[r] || []).forEach(e => {
// role explizit setzen, falls peopleByRole sie nicht mitliefert
allPeople.push({ person: e.person, role: e.role || r });
}));
// --- 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(', ');
pdfCheckWidth(allPeople);
// 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 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
? `<div style="margin-bottom:28px;"><img src="${logoDataUrl}" alt="Logo" style="width:200px; height:200px; object-fit:contain;"></div>`
: `<div style="font-size:80px; margin-bottom:28px;">⚡</div>`;
cover.innerHTML = `
${logoHtml}
<h1 style="font-size:32px; margin:0 0 16px 0; color:#1d1d1f;">Projektübersicht ${DEPARTMENTS[currentDepartment].label}</h1>
<div style="font-size:18px; color:#6e6e73; margin-bottom:48px;">${esc(w.label)}</div>
${w.date ? `<div style="font-size:14px; color:#6e6e73; margin-bottom:12px;">Datum: ${esc(formatWeekDate(w.date))}</div>` : ''}
<div style="font-size:12px; color:#6e6e73; margin-top:60px;">Erstellt am ${new Date().toLocaleDateString('de-DE')}</div>
`;
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('<tr>', `<tr${rowBg}>`);
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 = `<tr><td rowspan="2" style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Pr.-Nr.</td><td rowspan="2" style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Bezeichnung</td><td rowspan="2" style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Status</td>`;
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 += `<td class="role-header role-${r.toLowerCase()}" colspan="${count}" style="border:1px solid #e8e8ed;background:${roleBg};color:#1d1d1f;padding:6px;font-weight:bold;">${r}</td>`;
});
h1 += `<td colspan="${LPHS.length}" style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Leistungsphasen (%)</td></tr>`;
let h2 = `<tr>`;
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 += `<td style="border:1px solid #e8e8ed;background:${roleBg};padding:4px;font-weight:bold;">–</td>`;
} else {
grouped[r].forEach(entry => {
h2 += `<td style="border:1px solid #e8e8ed;background:${roleBg};padding:4px;font-weight:bold;">${esc(entry.person.name)}</td>`;
});
}
});
LPHS.forEach(l => h2 += `<td style="border:1px solid #e8e8ed;background:#fafafc;padding:4px;font-weight:bold;">${l}</td>`);
h2 += `</tr>`;
return h1 + h2;
}
// buildPdfProjectRow: baut eine einzelne Projektzeile als HTML-String.
function buildPdfProjectRow(p, allPeople){
let body = `<tr>`;
body += `<td style="border:1px solid #e8e8ed;padding:4px;white-space:nowrap;">${esc(p.nr)}</td>`;
body += `<td style="border:1px solid #e8e8ed;padding:4px;">${esc(p.name)}</td>`;
const statusText = pdfStatusText(p.status);
body += `<td style="border:1px solid #e8e8ed;padding:4px;">${statusText}</td>`;
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 += `<td style="border:1px solid #e8e8ed;padding:4px;text-align:center;background:${cellBg};color:${textColor};">${txt}</td>`;
});
LPHS.forEach(l => {
const v = (p.lph[l] !== undefined && p.lph[l] !== '') ? String(p.lph[l]).replace('%','') : '';
body += `<td style="border:1px solid #e8e8ed;padding:4px;text-align:center;">${esc(v ? v + '%' : '')}</td>`;
});
body += `</tr>`;
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 += `<tr style="background:${rowBg}">`;
html += `<td style="border:1px solid #e8e8ed;padding:4px;white-space:nowrap;">${esc(pr.nr)}</td>`;
html += `<td style="border:1px solid #e8e8ed;padding:4px;">${esc(pr.name)}</td>`;
const statusText = pdfStatusText(pr.status);
html += `<td style="border:1px solid #e8e8ed;padding:4px;">${statusText}</td>`;
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 += `<td style="border:1px solid #e8e8ed;padding:4px;text-align:center;background:${bg};color:${assign===1||assign===2?'#fff':'#1d1d1f'}";">${txt}</td>`;
});
LPHS.forEach(l => {
const v = (pr.lph && pr.lph[l] !== undefined && pr.lph[l] !== '') ? String(pr.lph[l]).replace('%','') : '';
html += `<td style="border:1px solid #e8e8ed;padding:4px;text-align:center;">${esc(v ? v + '%' : '')}</td>`;
});
html += `</tr>`;
});
return html;
}
// buildPdfGroupTitleRow: baut die Zeile mit dem Gruppentitel.
function buildPdfGroupTitleRow(groupName, allPeople){
const colCount = 3 + allPeople.length + LPHS.length;
return `<tr><td colspan="${colCount}" style="border:1px solid #e8e8ed;background:#f0f0f2;font-weight:bold;padding:6px;color:#1d1d1f;">${esc(groupName)}</td></tr>`;
}
// 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 = `<div class="pdf-block" style="margin-bottom:16px;"><table style="border-collapse:collapse;width:100%;"><tbody>${titleRow}${headerRows}${bodyRows}</tbody></table></div>`;
container.appendChild(wrapper);
return wrapper;
}
// buildPdfAttendanceBlock: baut den Anwesenheits-Block als HTML-String.
function buildPdfAttendanceBlock(presentList, absentList){
return `<div class="pdf-block" style="margin-bottom:16px;">
<div style="margin-bottom:8px; font-weight:bold; color:#1d1d1f; font-size:12px;">Anwesenheit</div>
<div style="margin-bottom:4px;"><b>Anwesend:</b> ${presentList || '—'}</div>
<div><b>Abwesend:</b> ${absentList || '—'}</div>
</div>`;
}
// 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 = `<div class="pdf-block"><div style="margin-top:24px; margin-bottom:8px; font-weight:bold; color:#1d1d1f; font-size:12px;">Auslastung der Projektbeteiligten (aktuelle Sitzung)</div>`;
html += '<table style="border-collapse:collapse;width:100%;"><tbody><tr><td style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Rolle</td><td style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Kürzel</td><td style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Punkte</td><td style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Balken</td><td style="border:1px solid #e8e8ed;background:#f0f0f2;color:#1d1d1f;padding:6px;font-weight:bold;">Projektdetails</td></tr>';
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 += `<tr>
<td style="border:1px solid #e8e8ed;padding:4px;">${r}</td>
<td style="border:1px solid #e8e8ed;padding:4px;font-weight:bold;">${esc(entry.person.name)}</td>
<td style="border:1px solid #e8e8ed;padding:4px;text-align:center;">${load.toString().replace('.',',')}</td>
<td style="border:1px solid #e8e8ed;padding:4px;">
<div style="background:#f0f0f0;height:10px;border-radius:3px;overflow:hidden;">
<div style="width:${pct}%;background:${color};height:100%;"></div>
</div>
<div style="font-size:9px;color:#6e6e73;text-align:right;">${pct}%</div>
</td>
<td style="border:1px solid #e8e8ed;padding:4px;font-size:9px;">${detail}${s.anbahnung ? `<br><span style="color:#0071e3;">In Anbahnung: ${s.anbahnung}</span>` : ''}</td>
</tr>`;
});
});
html += '</tbody></table>';
html += '<div style="margin-top:4px;font-size:9px;color:#6e6e73;">Haupt=1 · Optional=0,5 · Kleine Projekte=0,3 · In Anbahnung wird nicht in der Punktzahl gezählt.</div>';
html += '</div>';
return html;
}
// sendCsvToServer: POSTet CSV an <baseUrl>/save-csv
function blobToDataUrl(blob){
return new Promise((resolve, reject) => {