566 lines
20 KiB
JavaScript
566 lines
20 KiB
JavaScript
// -----------------------------
|
||
// 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.js, Helfer wie assignKey()/sortGroups()/
|
||
// formatWeekDate() aus js/utils.js sowie die Auslastungs-Berechnung
|
||
// (berechneAuslastung, lpActiveList, faktorFormat, pdfLoadAmpelColor,
|
||
// LOAD_BAR_MAX) aus js/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 (<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' });
|
||
}
|
||
|
||
// 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));
|
||
}
|
||
} |