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 (
) 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
- ? `

`
- : `
⚡
`;
- 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. | Bezeichnung | Status | `;
- 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 += ``;
- });
- 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 += '
| Rolle | Kürzel | Punkte | Balken | Projektdetails |
';
-
- 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 += `
- | ${r} |
- ${esc(entry.person.name)} |
- ${load.toString().replace('.',',')} |
-
-
- ${pct}%
- |
- ${detail}${s.anbahnung ? ` In Anbahnung: ${s.anbahnung}` : ''} |
-
`;
- });
- });
-
- html += '
';
- 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) => {