// -----------------------------
// Projektübersicht — Export PDF (pdfmake)
// Erzeugt ein Vektor-PDF: Deckblatt, Projektübersicht nach Gruppen,
// Anwesenheit und Auslastung. Format durchgehend A3 quer.
// Ersetzt die frühere jsPDF/html2canvas-Rasterlösung.
// -----------------------------
// Modul für den PDF-Export der Hauptseite (nur von Test.html geladen, direkt
// vor js/init.js); exportPDF() hängt am Export-Button von Test.html und nutzt
// die per CDN eingebundene Bibliothek pdfmake (offline nicht verfügbar). Es
// liest die aktuelle Woche über currentWeek() aus js/store.js, Personen über
// peopleByRole() aus js/board/board.js, Helfer wie assignKey()/sortGroups()/
// formatWeekDate() aus js/utils.js sowie die Auslastungs-Berechnung
// (berechneAuslastung, lpActiveList, faktorFormat, pdfLoadAmpelColor,
// LOAD_BAR_MAX) aus js/load/load.js. Das Modul stellt ausschließlich exportPDF()
// bereit.
// Farbkonstanten des PDF-Layouts: Rollen-Hintergründe, neutrale bzw.
// alternierende Zeilenfarben, Rahmenfarbe und die Kennzeichnungsfarben für
// Haupt-Zuordnung (X) und Optionale Zuordnung ((X)).
const PDF_ROLE_BG = {
'Planung': '#D4F5E0',
'Projektleitung': '#FFDBDB',
'Bauüberwachung': '#CBEEFF'
};
const PDF_NEUTRAL_BG = '#F0F0F2';
const PDF_ALT_ROW_BG = '#FAFAFC';
const PDF_BORDER = '#a6a6ad';
const PDF_ASSIGN_MAIN = '#248A3D';
const PDF_ASSIGN_OPT = '#D18B00';
// --- Seitengeometrie (A3 quer, in pt) ---
const PDF_PAGE_W = 1190.55; // A3 quer, vorher A4: 841.89
const PDF_MARGIN_X = 20;
const PDF_PAD_X = 2;
const PDF_LINE_W = 0.8;
const PDF_USABLE_W = PDF_PAGE_W - PDF_MARGIN_X * 2; // 1150,55
// PDF_STYLES: wiederverwendbare Textstile (Überschriften, Tabellen-/Zellformat)
// für die pdfmake-Dokumentdefinition
const PDF_STYLES = {
h1: { fontSize: 20, bold: true, color: '#1D1D1F' },
h2: { fontSize: 10, bold: true, color: '#1D1D1F' },
th: { fontSize: 7, bold: true, color: '#1D1D1F', fillColor: PDF_NEUTRAL_BG },
thSub: { fontSize: 6.5, bold: true, color: '#1D1D1F' },
td: { fontSize: 7, color: '#1D1D1F' },
tdCenter: { fontSize: 7, color: '#1D1D1F', alignment: 'center' }
};
// pdfRoleBg: liefert die Hintergrundfarbe einer Rolle aus PDF_ROLE_BG bzw. den
// übergebenen Fallback. Parameter: role = Rollenname, fallback = Ersatzfarbe;
// Rückgabe: Hex-Farbstring.
function pdfRoleBg(role, fallback){
return PDF_ROLE_BG[role] || fallback || '#FFFFFF';
}
// pdfTableLayout: erzeugt das pdfmake-Tabellenlayout (feine Rahmen, kleines
// Zell-Padding in den PDF-Farben). Rückgabe: Layout-Objekt für pdfmake.
function pdfTableLayout(){
return {
hLineWidth: () => PDF_LINE_W,
vLineWidth: () => PDF_LINE_W,
hLineColor: () => PDF_BORDER,
vLineColor: () => PDF_BORDER,
paddingLeft: () => PDF_PAD_X,
paddingRight: () => PDF_PAD_X,
paddingTop: () => 2,
paddingBottom: () => 2
};
}
// pdfStatusText: wandelt das Status-HTML in eine pdfmake-Textstruktur um.
// Erhält Zeilenumbrüche (
, Block-Ende von
/
) sowie die vom
// Sanitizing erlaubten Auszeichnungen (bold, italic, underline, color).
// Rückgabe: Objekt mit text-Array, direkt als Zellinhalt verwendbar.
function pdfStatusText(htmlStatus){
if(!htmlStatus) return { text: '', style: 'td' };
const root = document.createElement('div');
root.innerHTML = htmlStatus;
const parts = [];
// pushNewline: fügt einen Umbruch ein, vermeidet aber führende
// und doppelte Leerzeilen.
function pushNewline(){
if(parts.length === 0) return;
const last = parts[parts.length - 1];
if(typeof last.text === 'string' && last.text.endsWith('\n')) return;
parts.push({ text: '\n' });
}
// BLOCK: Block-Tags, deren Ende einen Zeilenumbruch erzeugt
const BLOCK = new Set(['P', 'DIV']);
// walk: durchläuft den DOM-Baum rekursiv und sammelt Textfragmente samt
// geerbter Auszeichnungen (bold/italics/underline/color) als pdfmake-Inlines.
// node = Knoten, inh = von den Eltern geerbte Stil-Attribute.
function walk(node, inh){
for(const child of node.childNodes){
// Textknoten
if(child.nodeType === 3){
// Nur Zeichen normalisieren, die im HTML keine Bedeutung haben.
// Echte Umbrüche kommen ausschliesslich aus BR/Blockenden.
const t = child.nodeValue
.replace(/\u00A0/g, ' ')
.replace(/[\r\n\t]+/g, ' ');
if(t === '') continue;
// Reine Whitespace-Knoten zwischen Blöcken überspringen
if(/^\s+$/.test(t) && (parts.length === 0
|| (typeof parts[parts.length-1].text === 'string'
&& parts[parts.length-1].text.endsWith('\n')))) continue;
parts.push(Object.assign({ text: t }, inh));
continue;
}
if(child.nodeType !== 1) continue;
const tag = child.tagName.toUpperCase();
if(tag === 'BR'){
parts.push({ text: '\n' });
continue;
}
// Auszeichnungen vererben
const own = Object.assign({}, inh);
if(tag === 'B' || tag === 'STRONG') own.bold = true;
if(tag === 'I' || tag === 'EM') own.italics = true;
if(tag === 'U') own.decoration = 'underline';
// Farbe / Fettung aus style bzw. font color
const st = child.getAttribute && child.getAttribute('style');
if(st){
const mCol = st.match(/(?:^|;)\s*color\s*:\s*([^;]+)/i);
if(mCol){
const c = pdfNormalizeColor(mCol[1].trim());
if(c) own.color = c;
}
const mFw = st.match(/(?:^|;)\s*font-weight\s*:\s*([^;]+)/i);
if(mFw){
const v = mFw[1].trim().toLowerCase();
if(v === 'bold' || v === 'bolder' || parseInt(v, 10) >= 600) own.bold = true;
if(v === 'normal' || parseInt(v, 10) < 600) own.bold = false;
}
}
if(tag === 'FONT' && child.getAttribute('color')){
const c = pdfNormalizeColor(child.getAttribute('color'));
if(c) own.color = c;
}
if(BLOCK.has(tag)){
pushNewline();
walk(child, own);
pushNewline();
} else {
walk(child, own);
}
}
}
walk(root, {});
// Führende und schliessende Umbrüche entfernen
while(parts.length && parts[0].text === '\n') parts.shift();
while(parts.length && parts[parts.length-1].text === '\n') parts.pop();
if(parts.length === 0) return { text: '', style: 'td' };
return { text: parts, style: 'td' };
}
// pdfNormalizeColor: pdfmake akzeptiert Hex und benannte Farben,
// aber kein rgb(). Wandelt rgb()/rgba() in Hex um.
function pdfNormalizeColor(v){
if(!v) return null;
v = String(v).trim();
const m = v.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
if(m){
const hex = n => Math.max(0, Math.min(255, parseInt(n,10)))
.toString(16).padStart(2,'0');
return '#' + hex(m[1]) + hex(m[2]) + hex(m[3]);
}
if(/^#[0-9a-f]{3}$/i.test(v)){
return '#' + v[1]+v[1] + v[2]+v[2] + v[3]+v[3];
}
if(/^#[0-9a-f]{6}$/i.test(v)) return v;
// Benannte Farben durchlassen (pdfmake kennt die CSS-Namen)
if(/^[a-z]+$/i.test(v)) return v.toLowerCase();
return null;
}
// loadLogoPng: lädt dein_bild.ico und konvertiert es via Canvas nach PNG.
// pdfmake unterstützt nur JPEG und PNG, kein ICO.
// Gibt null zurück, wenn Laden oder Dekodieren fehlschlägt.
async function loadLogoPng(size){
size = size || 256;
try {
const res = await fetch(baseUrl() + '/dein_bild.ico');
if(!res.ok) return null;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
try {
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error('Icon nicht dekodierbar'));
i.src = url;
});
const cv = document.createElement('canvas');
cv.width = cv.height = size;
const ctx = cv.getContext('2d');
const scale = Math.min(size / img.width, size / img.height);
const dw = img.width * scale, dh = img.height * scale;
ctx.drawImage(img, (size - dw) / 2, (size - dh) / 2, dw, dh);
return cv.toDataURL('image/png');
} finally {
URL.revokeObjectURL(url);
}
} catch(e){
console.warn('Logo nicht ladbar:', e);
return null;
}
}
// pdfHeaderRows: die zwei Kopfzeilen der Projekttabelle als pdfmake-Zellenarrays.
// Achtung: Nach colSpan:n folgen exakt n-1 leere {}; unter rowSpan-Zellen
// steht in der Folgezeile ebenfalls {}. Verstöße brechen das Layout still.
function pdfHeaderRows(grouped){
const h1 = [
{ text: 'Pr.-Nr.', rowSpan: 2, style: 'th' },
{ text: 'Bezeichnung', rowSpan: 2, style: 'th' },
{ text: 'Status', rowSpan: 2, style: 'th' }
];
const h2 = [ {}, {}, {} ];
ROLES.forEach(r => {
const list = grouped[r] || [];
const span = Math.max(1, list.length);
h1.push({ text: r, colSpan: span, style: 'th',
fillColor: pdfRoleBg(r, PDF_NEUTRAL_BG), alignment: 'center' });
for(let i = 1; i < span; i++) h1.push({});
if(list.length === 0){
h2.push({ text: '–', style: 'thSub', alignment: 'center',
fillColor: pdfRoleBg(r, PDF_ALT_ROW_BG) });
} else {
list.forEach(e => h2.push({
text: e.person.name, style: 'thSub', alignment: 'center',
fillColor: pdfRoleBg(r, PDF_ALT_ROW_BG)
}));
}
});
h1.push({ text: 'Leistungsphasen (%)', colSpan: LPHS.length,
style: 'th', alignment: 'center' });
for(let i = 1; i < LPHS.length; i++) h1.push({});
LPHS.forEach(l => h2.push({ text: String(l), style: 'thSub',
alignment: 'center', fillColor: PDF_ALT_ROW_BG }));
return [h1, h2];
}
// pdfProjectRow: eine Projektzeile als pdfmake-Zellenarray.
function pdfProjectRow(p, allPeople, alt){
const base = alt ? PDF_ALT_ROW_BG : null;
const row = [
{ text: p.nr || '', style: 'td', fillColor: base, noWrap: true },
{ text: p.name || '', style: 'td', fillColor: base },
Object.assign(pdfStatusText(p.status), { fillColor: base })
];
allPeople.forEach(entry => {
const s = (p.assign || {})[assignKey(entry.person.id, entry.role)] || 0;
const txt = s === 1 ? 'X' : s === 2 ? '(X)' : '';
const bg = s === 1 ? PDF_ASSIGN_MAIN
: s === 2 ? PDF_ASSIGN_OPT
: pdfRoleBg(entry.role, base || '#FFFFFF');
row.push({
text: txt, style: 'tdCenter', fillColor: bg,
color: (s === 1 || s === 2) ? '#FFFFFF' : '#1D1D1F',
bold: s === 1
});
});
LPHS.forEach(l => {
const raw = (p.lph && p.lph[l] !== undefined && p.lph[l] !== '')
? String(p.lph[l]).replace(/%/g, '') : '';
row.push({ text: raw ? raw + '%' : '', style: 'tdCenter', fillColor: base });
});
return row;
}
// pdfGroupTitleRow: Gruppenüberschrift über die volle Tabellenbreite.
function pdfGroupTitleRow(groupName, colCount){
const row = [{
text: groupName || '', colSpan: colCount, bold: true,
fillColor: PDF_NEUTRAL_BG, fontSize: 8, color: '#1D1D1F',
margin: [3, 3, 3, 3]
}];
for(let i = 1; i < colCount; i++) row.push({});
return row;
}
// pdfColumnWidths: verteilt die tatsächlich verfügbare Breite.
// Berücksichtigt Padding und Linienstärke, die pdfmake ZUSÄTZLICH
// zur angegebenen Spaltenbreite rechnet. Gibt ausschliesslich
// numerische Breiten zurück - kein '*', damit nichts überläuft.
function pdfColumnWidths(allPeople){
const nPeople = allPeople.length;
const nLph = LPHS.length;
const nCols = 3 + nPeople + nLph;
// Overhead, den pdfmake on top der Spaltenbreiten aufschlägt
const overhead = nCols * PDF_PAD_X * 2 + (nCols + 1) * PDF_LINE_W;
let budget = PDF_USABLE_W - overhead;
// Schmale Spalten: schrumpfen bei Bedarf, aber nicht unter Minimum
let wNr = 40;
let wPerson = 15;
let wLph = 19;
const fixed = () => wNr + nPeople * wPerson + nLph * wLph;
// Zielraum für Bezeichnung + Status
const TEXT_MIN = 150;
if(budget - fixed() < TEXT_MIN){
// 1. Personen- und LPH-Spalten stufenweise verschmälern
while(budget - fixed() < TEXT_MIN && (wPerson > 11 || wLph > 15)){
if(wLph > 15) wLph--;
if(wPerson > 11) wPerson -= 0.5;
}
if(budget - fixed() < TEXT_MIN && wNr > 30) wNr = 30;
}
let textSpace = budget - fixed();
if(textSpace < 60){
console.warn('PDF: Zu viele Spalten für A4 quer. '
+ 'Verbleibend für Bezeichnung/Status: ' + Math.round(textSpace) + ' pt.');
textSpace = 60;
}
// Bezeichnung erhält 60 %, Status 40 %
const wName = Math.floor(textSpace * 0.40);
const wStatus = Math.floor(textSpace * 0.60);
return [wNr, wName, wStatus]
.concat(new Array(nPeople).fill(wPerson))
.concat(new Array(nLph).fill(wLph));
}
// pdfCheckWidth: prüft, ob die Tabelle in den Satzspiegel passt.
function pdfCheckWidth(allPeople){
const widths = pdfColumnWidths(allPeople);
const nCols = widths.length;
const overhead = nCols * PDF_PAD_X * 2 + (nCols + 1) * PDF_LINE_W;
const total = widths.reduce((a, b) => a + b, 0) + overhead;
if(total > PDF_USABLE_W + 0.5){
console.warn('PDF: Tabellenbreite ' + Math.round(total)
+ ' pt > verfügbar ' + Math.round(PDF_USABLE_W) + ' pt.');
return false;
}
return true;
}
// pdfAttendanceBlock: Anwesenheitsübersicht.
function pdfAttendanceBlock(w){
const att = w.attendance || {};
const fmt = p => p.name + ' (' + (p.fullName || p.name) + ')';
const present = (w.people || []).filter(p => att[p.id]).map(fmt).join(', ');
const absent = (w.people || []).filter(p => !att[p.id]).map(fmt).join(', ');
return [
{ text: 'Anwesenheit', style: 'h2', margin: [0, 0, 0, 6] },
{ text: [{ text: 'Anwesend: ', bold: true }, present || '—'],
fontSize: 9, margin: [0, 0, 0, 3] },
{ text: [{ text: 'Abwesend: ', bold: true }, absent || '—'], fontSize: 9 }
];
}
// pdfLoadSection: Auslastungsübersicht inkl. Balken (als Canvas-Rechtecke).
function pdfLoadSection(w, grouped){
const stats = {};
ROLES.forEach(r => (grouped[r] || []).forEach(e => {
stats[assignKey(e.person.id, r)] = { load:0, anbahnung:0, projects:{} };
}));
(w.projects || []).forEach(pr => {
const aktivePhase = lpActiveList(pr)[0];
Object.keys(pr.assign || {}).forEach(aKey => {
const s = stats[aKey];
if(!s) return;
const st = pr.assign[aKey];
const rolle = aKey.slice(aKey.indexOf('|') + 1);
const al = berechneAuslastung(null, pr, aktivePhase, rolle, st);
if(al.anbahnung){
s.anbahnung++;
return;
}
if(al.load > 0){
s.load += al.load;
s.projects[pr.name] = (s.projects[pr.name] || 0) + 1;
}
});
});
const BAR_W = 140;
const barMax = LOAD_BAR_MAX;
const body = [[
{ text: 'Rolle', style: 'th' },
{ text: 'Kürzel', style: 'th' },
{ text: 'Auslastung', style: 'th' },
{ text: 'Auslastung (Faktor)',style: 'th' },
{ text: 'Projektdetails', style: 'th' }
]];
ROLES.forEach(r => (grouped[r] || []).forEach(entry => {
const s = stats[assignKey(entry.person.id, r)];
const load = s.load; // aktuelle Sitzung = 1 Woche
// Balken relativ zur einstellbaren Skala (Standard 8); die Ampel-Farbe
// und der Zahlenwert bleiben ungedeckelt (Überlastung > 1,0).
const col = pdfLoadAmpelColor(load);
const det = Object.keys(s.projects)
.map(k => k + ' (' + s.projects[k] + '\u00D7)').join(', ') || '—';
body.push([
{ text: r, style: 'td' },
{ text: entry.person.name, style: 'td', bold: true },
{ text: faktorFormat(load), style: 'tdCenter' },
{ stack: [
{ canvas: [
{ type:'rect', x:0, y:0, w:BAR_W, h:7, color:'#F0F0F0' },
{ type:'rect', x:0, y:0, w:Math.max(0, BAR_W * Math.min(1, load / barMax)),
h:7, color:col }
]},
{ text: faktorFormat(load), fontSize: 6, color: '#6E6E73',
alignment: 'right', width: BAR_W, margin: [0, 1, 0, 0] }
], margin: [3, 3, 3, 3] },
{ text: s.anbahnung
? [ det, { text: '\nIn Anbahnung: ' + s.anbahnung, color: '#0071E3' } ]
: det,
style: 'td', fontSize: 6.5 }
]);
}));
return [
{ text: 'Auslastung der Projektbeteiligten (aktuelle Sitzung)',
style: 'h2', margin: [0, 0, 0, 6] },
{ table: { headerRows: 1, widths: [80, 40, 45, BAR_W + 6, '*'], body },
layout: pdfTableLayout() },
{ text: 'Rollenbezogener Gewichtungsfaktor je Leistungsphase. Aktueller Wert: '
+ faktorFormat(LOAD_BAR_MAX),
fontSize: 6.5, color: '#6E6E73', margin: [0, 4, 0, 0] }
];
}
// exportPDF: Hauptfunktion. Erzeugt und lädt das PDF.
async function exportPDF(){
if(!window.pdfMake){
alert('PDF-Export nicht verfügbar: pdfmake wurde nicht geladen. '
+ 'Bitte Internetverbindung bzw. Skript-Einbindung prüfen.');
return;
}
const w = currentWeek();
if(!w) return;
try {
const grouped = peopleByRole(w);
const allPeople = [];
ROLES.forEach(r => (grouped[r] || []).forEach(e => {
// role explizit setzen, falls peopleByRole sie nicht mitliefert
allPeople.push({ person: e.person, role: e.role || r });
}));
pdfCheckWidth(allPeople);
const logo = await loadLogoPng();
const colCount = 3 + allPeople.length + LPHS.length;
const widths = pdfColumnWidths(allPeople);
// --- Deckblatt ---
const cover = [];
if(logo){
cover.push({ image: logo, width: 130, alignment: 'center',
margin: [0, 0, 0, 24] });
}
cover.push(
{ text: 'Projektübersicht ' + DEPARTMENTS[currentDepartment].label,
style: 'h1', alignment: 'center', margin: [0, 0, 0, 10] },
{ text: w.label || '', fontSize: 13, color: '#6E6E73',
alignment: 'center', margin: [0, 0, 0, 30] }
);
if(w.date){
cover.push({ text: 'Datum: ' + formatWeekDate(w.date), fontSize: 9,
color: '#6E6E73', alignment: 'center', margin: [0, 0, 0, 8] });
}
cover.push({ text: 'Erstellt am ' + new Date().toLocaleDateString('de-DE'),
fontSize: 8, color: '#6E6E73', alignment: 'center',
margin: [0, 30, 0, 0] });
const content = [{ stack: cover, margin: [0, 170, 0, 0], pageBreak: 'after' }];
// --- Projektgruppen ---
const groups = {};
(w.projects || []).forEach(p => {
(groups[p.group] || (groups[p.group] = [])).push(p);
});
Object.keys(groups).forEach(g =>
groups[g].sort((a, b) => (a.order || 0) - (b.order || 0)));
sortGroups(Object.keys(groups)).forEach(groupName => {
const projects = groups[groupName];
if(!projects || !projects.length) return;
// Kopfzeilen MÜSSEN pro Tabelle neu erzeugt werden:
// pdfmake mutiert Zellobjekte (rowSpan/colSpan, _span, _inlines).
// Wiederverwendung derselben Referenzen führt zu leeren Kopfzeilen
// in allen Tabellen ab der zweiten.
const header = pdfHeaderRows(grouped);
const body = [ pdfGroupTitleRow(groupName, colCount), header[0], header[1] ];
projects.forEach((p, i) => body.push(pdfProjectRow(p, allPeople, i % 2 === 0)));
content.push({
table: { headerRows: 3, widths: widths, body: body, dontBreakRows: true },
layout: pdfTableLayout(),
margin: [0, 0, 0, 14],
pageBreak: 'after'
});
});
// --- Anwesenheit / Auslastung ---
//content.push({ text: '', pageBreak: 'before' });
pdfAttendanceBlock(w).forEach(b => content.push(b));
content.push({ text: '', margin: [0, 14, 0, 0] });
pdfLoadSection(w, grouped).forEach(b => content.push(b));
const doc = {
pageSize: 'A3',
pageOrientation: 'landscape',
pageMargins: [PDF_MARGIN_X, 25, PDF_MARGIN_X, 30],
defaultStyle: { font: 'Roboto', fontSize: 7, color: '#1D1D1F' },
styles: PDF_STYLES,
footer: (page, count) => page === 1 ? '' : {
text: (w.label || '') + ' · Seite ' + page + ' von ' + count,
alignment: 'right', fontSize: 6.5, color: '#6E6E73',
margin: [0, 8, PDF_MARGIN_X, 0]
},
info: {
title: 'Projektübersicht ' + (w.label || ''),
creator: 'Projektboard'
},
content: content
};
const filename = 'Projektuebersicht_'
+ String(w.label || 'Sitzung').replace(/[^\w]/g, '_') + '.pdf';
pdfMake.createPdf(doc).download(filename);
} catch(err){
console.error('PDF-Export fehlgeschlagen:', err);
alert('PDF-Export fehlgeschlagen: ' + (err.message || err));
}
}