PDF Export von html2canvas auf makepdf geändert
This commit is contained in:
1 parent
c055091dc7
commit
cddce52d3d
3 files changed
+530
-379
No files matched your search
+528
-377
@@ -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);
|
||||
|
||||
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
|
||||
? `<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) => {
|
||||
|
||||
Reference in new issue
Block a user