Files
projektuebersicht/public/js/pdf.js
T
2026-08-14 11:47:19 +02:00

580 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// -----------------------------
// 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/utils.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 (<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 => {
// Platzhalter-Einträge für leere Rollen (person === null) → leere Zelle,
// damit Spaltenzahl mit der Kopfzeile übereinstimmt.
if(!entry.person){
row.push({ text: '', style: 'tdCenter', fillColor: base || '#FFFFFF' });
return;
}
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);
// displayPeople: Spalten der Tabelle inkl. Platzhalter für leere Rollen –
// pdfHeaderRows erzwingt pro Rolle mindestens eine Spalte (colSpan/“–”),
// deshalb müssen Zeilen, Breiten und colCount dieselbe Spaltenzahl nutzen,
// sonst wirft pdfmake bei einer leeren Rolle einen Layout-Fehler.
const displayPeople = [];
ROLES.forEach(r => {
const list = grouped[r] || [];
if(list.length === 0){
displayPeople.push({ person: null, role: r });
} else {
list.forEach(e => displayPeople.push({ person: e.person, role: e.role || r }));
}
});
pdfCheckWidth(displayPeople);
const logo = await loadLogoPng();
const colCount = 3 + displayPeople.length + LPHS.length;
const widths = pdfColumnWidths(displayPeople);
// --- 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, displayPeople, 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));
}
}