// -----------------------------
// Projektübersicht — Export PDF (pdfmake), gemeinsame Bausteine
// -----------------------------
// Dieses Modul stellt die drei PDF-Exporte der Anwendung bereit — jeder in
// seinem eigenen, klar abgegrenzten Abschnitt dieser Datei:
// * exportWochenberichtPDF() — Wochenbericht (Übersicht + Akte je Gewerk)
// * exportSandboxPlanPDF() — Planung (Balkenplan eines Sandbox-Terminplans)
// * exportProtokollPDF() — Protokolle (Besprechungsprotokoll je Projekt)
// Ein früherer, inzwischen entfernter dritter Export, exportPDF() (eine
// gewerkeübergreifende A3-Tabellenübersicht der ganzen Sitzung, samt
// "PDF Export"-Knopf in Werkzeuge), ist NICHT derselbe wie
// exportProtokollPDF() oben — er ist ersatzlos entfallen (auf Eriks Wunsch
// sollte ein PDF-Export nur noch im Wochenbericht und in den Terminplanungen
// stehen, bevor Protokolle als drittes Ziel dazukamen). Mit ihm gingen auch
// die Bausteine, die ausschließlich er nutzte (pdfRoleBg(), pdfTableLayout(),
// pdfHeaderRows(), pdfProjectRow(), pdfGroupTitleRow(), pdfColumnWidths(),
// pdfCheckWidth(), pdfAttendanceBlock(), pdfLoadSection(), sowie die nur
// dort gebrauchten Farbkonstanten PDF_ROLE_BG/PDF_ALT_ROW_BG und
// PDF_PAD_X/PDF_LINE_W).
//
// Was unten bleibt, wird von MEHREREN der drei Exporte gebraucht (nicht
// zwingend allen): Farbkonstanten, die A3-quer-Seitengeometrie
// PDF_PAGE_W/PDF_MARGIN_X/PDF_USABLE_W (nur exportSandboxPlanPDF() — die
// beiden anderen sind A4 hochkant, eigene Konstanten PDF_WB_PAGE_W/
// PDF_WB_MARGIN_X weiter unten), PDF_STYLES, pdfStatusText()
// (Kommentar-HTML -> pdfmake-Rich-Text), pdfNormalizeColor() und
// loadLogoPng().
// Farbkonstanten des PDF-Layouts: Rahmenfarbe, Kennzeichnungsfarben für
// Haupt-Zuordnung (X) und Optionale Zuordnung ((X)), sowie die neutrale
// Füllfarbe, die PDF_STYLES.th unten für Tabellenkopfzeilen verwendet.
const PDF_NEUTRAL_BG = '#F0F0F2';
const PDF_BORDER = '#a6a6ad';
const PDF_ASSIGN_MAIN = '#248A3D';
const PDF_ASSIGN_OPT = '#D18B00';
// --- Seitengeometrie (A3 quer, in pt) — nur von exportSandboxPlanPDF() genutzt;
// exportWochenberichtPDF()/exportProtokollPDF() sind A4 hochkant, siehe
// PDF_WB_PAGE_W/PDF_WB_MARGIN_X weiter unten ---
const PDF_PAGE_W = 1190.55; // A3 quer, vorher A4: 841.89
const PDF_MARGIN_X = 20;
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' }
};
// 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;
}
}
// -----------------------------
// Export PDF — Wochenbericht
// -----------------------------
// exportWochenberichtPDF() ist der EINE der beiden verbleibenden PDF-Exporte
// (der andere ist exportSandboxPlanPDF() unten, für die Planung). Der
// Wochenbericht braucht ein eigenes Format und ist selbst zweiteilig:
// Seite 1 eine kompakte Übersicht aller Projekte samt Anwesenheit der
// Sitzung, ab Seite 2 der vollständige Bericht — eine Akte je Projekt mit
// vollständigem Kommentar, allen Leistungsphasen und Beteiligten, in der
// Reihenfolge der Tagesordnung (berichtProjekte(),
// js/wochenbericht/wochenbericht.js). So beantwortet die Datei "was hat sich
// geändert, was ist offen" in Sekunden UND bleibt als vollständiges Protokoll
// lesbar, ohne dass an anderer Stelle etwas gekürzt werden muss.
//
// Teilt sich mit exportSandboxPlanPDF() dieselben Bausteine: PDF_STYLES,
// pdfStatusText() (Kommentar-HTML -> pdfmake-Rich-Text), loadLogoPng(),
// pdfNormalizeColor().
// Nutzt außerdem berichtProjekte()/findPreviousWeek()/classifyProjectChange()/
// abwesendeBeteiligte() aus js/wochenbericht/wochenbericht.js,
// auftragsstatusOf()/auftragsstatusLabel() aus js/auftrag.js sowie
// lpActiveList() aus js/load/load.js (dieselbe amtierende-Phase-Ermittlung
// wie die Auslastung, damit Übersicht und Akte nie widersprüchliche Werte
// zeigen).
const PDF_WB_PAGE_W = 595.28; // A4 hochkant in pt (Bibliothekswert)
const PDF_WB_MARGIN_X = 40;
const PDF_WB_HAIRLINE = '#DCDCDC';
// PDF_WB_CHANGE_COLORS: Farbe je Änderungsart aus classifyProjectChange()
// (bucket 'new'|'status'|'progress'|'unchanged') — dieselben Farben, die
// PDF_ASSIGN_MAIN/PDF_ASSIGN_OPT oben schon für Haupt-/Unterstützungs-
// Zuordnung festlegen, statt eine zweite Farbskala zu erfinden. Kennzeichnet
// sowohl den Punkt in der Übersichtstabelle als auch den linken Rand der
// Akte auf Seite 2.
const PDF_WB_CHANGE_COLORS = {
new: PDF_ASSIGN_MAIN, // Neues Projekt
status: '#0071E3', // Neuer Kommentar / Kommentar bestätigt
progress: PDF_ASSIGN_OPT, // Fortschritt aktualisiert
unchanged: PDF_BORDER // keine Änderung — neutral
};
// pdfHslToHex: wandelt eine CSS-Farbe (auch hsl(), das pdfmake selbst nicht
// versteht) in Hex um, indem der Browser selbst rechnet: ein unsichtbares
// Element bekommt die Farbe gesetzt, getComputedStyle liefert sie als rgb()
// zurück, das pdfNormalizeColor() bereits verarbeitet. So zeigt der
// Fortschrittsbalken im PDF exakt denselben Farbton wie am Bildschirm
// (getProgressColor() in js/utils.js), statt eine zweite Farbskala zu
// erfinden. Parameter: cssColor. Rückgabe: Hex-String (Fallback '#0071E3').
function pdfHslToHex(cssColor){
const probe = document.createElement('span');
probe.style.color = cssColor;
probe.style.display = 'none';
document.body.appendChild(probe);
const rgb = getComputedStyle(probe).color;
probe.remove();
return pdfNormalizeColor(rgb) || '#0071E3';
}
// pdfWbGridLayout: gemeinsames liniertes Tabellenraster für Übersicht,
// Anwesenheit und die Mini-Tabellen (Leistungsphasen/Beteiligte) einer Akte —
// ein durchgezogenes, dünnes Gitter statt einzelner Trennlinien, damit sich
// die strukturierten Angaben wie eine echte Tabelle lesen. headerRule=true
// zieht zusätzlich eine kräftigere Linie über und unter der ersten Zeile
// (Kopfzeile); für Tabellen ohne eigene Kopfzeile bleibt das Gitter
// durchgehend gleich dünn.
function pdfWbGridLayout(headerRule){
return {
hLineWidth: (i, node) => (i === 0 || i === node.table.body.length || (headerRule && i === 1)) ? 0.9 : 0.4,
vLineWidth: () => 0.4,
hLineColor: (i) => (headerRule && i <= 1) ? '#1D1D1F' : PDF_WB_HAIRLINE,
vLineColor: () => PDF_WB_HAIRLINE,
paddingLeft: () => 5, paddingRight: () => 5,
paddingTop: () => 3.5, paddingBottom: () => 3.5
};
}
// pdfWbAktuelleLphInfo: die amtierende Leistungsphase eines Projekts samt
// Fortschritt — dieselbe Ermittlung wie die Auslastung (lpActiveList()), von
// der Übersichtsseite UND der Akte genutzt, damit beide nie unterschiedliche
// Werte für dasselbe Projekt zeigen. Rückgabe: {lph, pct} oder null.
function pdfWbAktuelleLphInfo(p){
const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0];
if(!aktLp) return null;
const pct = Math.min(100, Math.max(0, parseFloat(String((p.lph || {})[aktLp] ?? '').replace(',', '.')) || 0));
return { lph: aktLp, pct };
}
// pdfWbChangeDot: der farbige Punkt der Übersichtstabelle, Farbe nach
// Änderungsart (siehe PDF_WB_CHANGE_COLORS).
function pdfWbChangeDot(bucket){
return { canvas: [{ type: 'ellipse', x: 4, y: 4.5, r1: 3, r2: 3,
color: PDF_WB_CHANGE_COLORS[bucket] || PDF_WB_CHANGE_COLORS.unchanged }] };
}
// pdfWbLphZeile: eine Leistungsphasen-Zeile — Label, Balken (Canvas-
// Rechtecke, dieselbe Technik wie die Gantt-Balken in
// exportSandboxPlanPDF() unten), Prozentwert. Parameter:
// lph (Phasenname), pct (Zahl 0–100), aktiv (Boolean, amtierende Phase —
// bekommt eine eingefärbte Zellenfüllung, da Fettung im Canvas selbst nicht
// geht), barWidth (Balkenbreite in pt, je nach Spaltenkontext). Rückgabe:
// pdfmake-Tabellenzeile (dreispaltiges Array).
function pdfWbLphZeile(lph, pct, aktiv, barWidth){
const BAR_W = barWidth || 150;
const farbe = pct > 0 ? pdfHslToHex(getProgressColor(pct)) : '#D2D2D7';
return [
{ text: lph, style: 'td', bold: aktiv, fontSize: 7, fillColor: aktiv ? '#EAF1FB' : undefined },
{ canvas: [
{ type: 'rect', x: 0, y: 0, w: BAR_W, h: 6, color: '#F0F0F0' },
{ type: 'rect', x: 0, y: 0, w: Math.max(0, BAR_W * Math.min(100, pct) / 100), h: 6, color: farbe }
], margin: [0, 1.5, 0, 0], fillColor: aktiv ? '#EAF1FB' : undefined },
{ text: (pct || 0) + ' %', style: 'tdCenter', fontSize: 7, fillColor: aktiv ? '#EAF1FB' : undefined }
];
}
// pdfWbLphMiniTable: die Leistungsphasen-Tabelle innerhalb einer Akte — nur
// die beauftragten Phasen, wie im Wochenbericht selbst
// (BERICHT_OPTS.nurBeauftragteLph, js/board/board.js); ohne beauftragte
// Auswahl gelten alle LPHS, genau wie dort. Rückgabe: pdfmake-Tabelle oder
// null, wenn keine Phase sichtbar ist (kommt praktisch nicht vor, da LPHS
// nie leer ist).
function pdfWbLphMiniTable(p, aktLp){
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
const sichtbareLphs = beauftragt.length ? LPHS.filter(l => beauftragt.includes(l)) : LPHS;
if(!sichtbareLphs.length) return null;
const body = sichtbareLphs.map(l => {
const pct = Math.min(100, Math.max(0, parseFloat(String((p.lph || {})[l] ?? '').replace(',', '.')) || 0));
return pdfWbLphZeile(l, pct, l === aktLp, 90);
});
return { table: { widths: [34, '*', 28], body }, layout: pdfWbGridLayout(false) };
}
// pdfWbBeteiligteTable: "Rolle | Namen"-Tabelle für die tatsächlich
// zugeordneten Personen eines Projekts — nur die, die zugeordnet sind
// (state 1 oder 2), nicht die ganze Belegschaft, denn eine Akte soll zeigen,
// wer WIRKLICH dabei ist. Parameter: p (Projekt), allePersonen (Array
// {person, role} aus peopleByRole()). Rückgabe: pdfmake-Tabelle oder ein
// Hinweistext, wenn niemand zugeordnet ist.
function pdfWbBeteiligteTable(p, allePersonen){
const byRole = {};
ROLES.forEach(rolle => {
const haupt = [], unterstuetzend = [];
allePersonen.filter(e => e.role === rolle).forEach(e => {
const stand = (p.assign || {})[assignKey(e.person.id, rolle)] || 0;
if(stand === 1) haupt.push(e.person.fullName || e.person.name);
else if(stand === 2) unterstuetzend.push(e.person.fullName || e.person.name);
});
if(haupt.length || unterstuetzend.length) byRole[rolle] = { haupt, unterstuetzend };
});
const rollen = Object.keys(byRole);
if(!rollen.length){
return { text: 'Noch niemand zugeordnet.', fontSize: 7.5, italics: true, color: '#86868B', margin: [0, 2, 0, 0] };
}
const body = rollen.map(rolle => {
const r = byRole[rolle];
const teile = [];
if(r.haupt.length) teile.push(r.haupt.join(', '));
if(r.unterstuetzend.length) teile.push(r.unterstuetzend.join(', ') + ' (unterst.)');
return [{ text: rolle, style: 'td', bold: true }, { text: teile.join('; '), style: 'td' }];
});
return { table: { widths: [62, '*'], body }, layout: pdfWbGridLayout(false) };
}
// pdfWbGruppentitel: die Gruppenüberschrift ("Aktive Projekte" usw.), sowohl
// auf der Übersichtsseite als auch im Berichtsteil verwendet. Rückgabe:
// pdfmake-Content-Block.
function pdfWbGruppentitel(name, topMargin){
return { text: name, fontSize: 11, bold: true, color: '#1D1D1F',
margin: [0, topMargin == null ? 14 : topMargin, 0, 6] };
}
// pdfWbUebersichtBlock: Seite 1 — eine liniierte Tabelle je Gruppe mit allen
// Projekten der Tagesordnung: Änderungspunkt, Nummer, Name, Änderungsgrund,
// amtierende Leistungsphase samt Fortschritt, Besprochen-Haken. Das ist die
// Landkarte der Sitzung — was hat sich geändert, was ist offen — bevor ab
// Seite 2 der vollständige Text folgt. Parameter: liste (aus
// berichtProjekte()), prevWeek. Rückgabe: Array von pdfmake-Content-Blöcken.
function pdfWbUebersichtBlock(liste, prevWeek){
const groups = {};
liste.forEach(p => (groups[p.group] || (groups[p.group] = [])).push(p));
const blocks = [];
sortGroups(Object.keys(groups)).forEach(groupName => {
const projekte = groups[groupName];
if(!projekte || !projekte.length) return;
blocks.push(pdfWbGruppentitel(groupName, blocks.length ? 14 : 4));
const header = [
{ text: '', style: 'th' },
{ text: 'Nr.', style: 'th' },
{ text: 'Projekt', style: 'th' },
{ text: 'Änderung', style: 'th' },
{ text: 'Amt. LPH', style: 'th' },
{ text: '✓', style: 'th', alignment: 'center' }
];
const body = [header];
projekte.forEach(p => {
const change = classifyProjectChange(p, prevWeek);
const info = pdfWbAktuelleLphInfo(p);
body.push([
pdfWbChangeDot(change.bucket),
{ text: p.nr || '–', style: 'td' },
{ text: p.name, style: 'td', bold: true },
{ text: change.bucket !== 'unchanged' ? change.reason : '', style: 'td' },
{ text: info ? info.lph + ' · ' + info.pct + ' %' : '–', style: 'td' },
{ text: p.besprochen ? '✓' : '', style: 'tdCenter', color: PDF_ASSIGN_MAIN }
]);
});
blocks.push({ table: { headerRows: 1, widths: [14, 40, '*', 118, 76, 18], body },
layout: pdfWbGridLayout(true), margin: [0, 0, 0, 4] });
});
return blocks;
}
// pdfWbAnwesenheitBlock: die Anwesenheit der Sitzung, als eigener
// Tabellenblock am Fuß von Seite 1 — dieselbe Erfassung, die am Bildschirm
// unter dem Wochenbericht steht (renderAttendance(),
// js/wochenbericht/wochenbericht.js): w.attendance[personId] wahr = anwesend,
// ein fehlender Eintrag zählt genauso als nicht anwesend wie ein
// ausdrückliches false — es gibt keinen dritten Zustand. Parameter: w
// (Sitzung). Rückgabe: Array von pdfmake-Content-Blöcken.
function pdfWbAnwesenheitBlock(w){
const people = Array.isArray(w.people) ? w.people : [];
if(!people.length){
return [
pdfWbGruppentitel('Anwesenheit der Sitzung', 18),
{ text: 'Für diese Sitzung sind keine Projektbeteiligten hinterlegt.',
fontSize: 8, italics: true, color: '#86868B' }
];
}
const att = w.attendance || {};
const anwesend = people.filter(p => att[p.id]).length;
const JE_ZEILE = 3;
const body = [];
for(let i = 0; i < people.length; i += JE_ZEILE){
const gruppe = people.slice(i, i + JE_ZEILE);
const row = [];
gruppe.forEach(p => {
const present = !!att[p.id];
row.push({ text: present ? 'X' : '–', style: 'tdCenter', color: present ? PDF_ASSIGN_MAIN : '#AEAEB2' });
row.push({ text: p.fullName || p.name, style: 'td' });
});
while(row.length < JE_ZEILE * 2) row.push({ text: '' });
body.push(row);
}
return [
pdfWbGruppentitel('Anwesenheit der Sitzung', 18),
{ text: anwesend + ' von ' + people.length + ' anwesend', fontSize: 8, color: '#6E6E73', margin: [0, 0, 0, 6] },
{ table: { widths: [16, '*', 16, '*', 16, '*'], body }, layout: pdfWbGridLayout(false) }
];
}
// pdfWbProjektAkte: die vollständige Akte eines Projekts für Seite 2 ff. —
// ein umrandeter Block mit farbigem linken Rand nach Änderungsart, innen
// zweispaltig: links Kopf/Änderung/Kommentar, rechts Leistungsphasen- und
// Beteiligten-Tabelle. Parameter: p (Projekt), w (Sitzung), prevWeek
// (Vorwoche-Objekt oder null), allePersonen. Rückgabe: pdfmake-Content-Block.
function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
const change = classifyProjectChange(p, prevWeek);
const spineColor = PDF_WB_CHANGE_COLORS[change.bucket] || PDF_WB_CHANGE_COLORS.unchanged;
const statusChip = auftragsstatusOf(p) === 'beauftragt' ? '' : ' · ' + auftragsstatusLabel(auftragsstatusOf(p));
const tags = Array.isArray(p.tags) && p.tags.length ? ' [' + p.tags.join(', ') + ']' : '';
const kopf = {
columns: [
{ text: [
{ text: (p.nr ? p.nr + ' ' : '') + p.name, bold: true, fontSize: 10 },
{ text: tags + statusChip, fontSize: 7.5, color: '#6E6E73' }
], width: '*' },
{ text: p.besprochen ? '✓ besprochen' : '', fontSize: 7, color: PDF_ASSIGN_MAIN,
alignment: 'right', width: 70 }
], margin: [0, 0, 0, 3]
};
const aenderung = change.bucket !== 'unchanged'
? { text: change.reason, fontSize: 7.5, bold: true, color: spineColor, margin: [0, 0, 0, 5] }
: { text: '', margin: [0, 0, 0, 1] };
const kommentarInhalt = pdfStatusText(p.status || '');
const kommentar = (kommentarInhalt.text && kommentarInhalt.text.length)
? Object.assign({}, kommentarInhalt, { fontSize: 8 })
: { text: 'Kein Kommentar.', fontSize: 7.5, italics: true, color: '#86868B' };
const aktLp = (typeof lpActiveList === 'function' ? lpActiveList(p) : [])[0];
const lphTable = pdfWbLphMiniTable(p, aktLp);
const beteiligteTable = pdfWbBeteiligteTable(p, allePersonen);
const abwesend = (typeof abwesendeBeteiligte === 'function') ? abwesendeBeteiligte(p, w) : [];
const rightStack = [{ text: 'LEISTUNGSPHASEN', fontSize: 6.5, bold: true, color: '#8E8E93', margin: [0, 0, 0, 3] }];
if(lphTable) rightStack.push(lphTable);
rightStack.push({ text: 'BETEILIGTE', fontSize: 6.5, bold: true, color: '#8E8E93', margin: [0, 10, 0, 3] });
rightStack.push(beteiligteTable);
if(abwesend.length){
rightStack.push({ text: 'Fehlt heute: ' + abwesend.join(', '), fontSize: 7, italics: true,
color: '#D70015', margin: [0, 6, 0, 0] });
}
const innerContent = {
columns: [
{ width: '*', stack: [kopf, aenderung, kommentar] },
{ width: 190, stack: rightStack }
],
columnGap: 12
};
return {
table: {
widths: [4, '*'],
body: [[
{ text: '', fillColor: spineColor },
{ stack: [innerContent] }
]]
},
layout: {
hLineWidth: (i, node) => (i === 0 || i === node.table.body.length) ? 0.75 : 0,
vLineWidth: (i) => (i === 2) ? 0.75 : 0,
hLineColor: () => PDF_WB_HAIRLINE,
vLineColor: () => PDF_WB_HAIRLINE,
paddingLeft: (i) => i === 0 ? 0 : 10,
paddingRight: () => 10,
paddingTop: () => 10,
paddingBottom: () => 10
},
margin: [0, 0, 0, 12]
};
}
// exportWochenberichtPDF: Hauptfunktion. Erzeugt aus der Tagesordnung der
// aktuellen Sitzung ein ablagefähiges, zweiteiliges PDF — Seite 1 die
// Übersicht samt Anwesenheit, ab Seite 2 die vollständige Akte je Projekt,
// in der Reihenfolge und mit den Angaben, die die Teambesprechung tatsächlich
// sieht (berichtProjekte(), js/wochenbericht/wochenbericht.js), gruppiert wie
// das Board. Keine Parameter, keine Rückgabe.
async function exportWochenberichtPDF(){
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){
alert('Es ist keine Sitzung geladen, die exportiert werden könnte.');
return;
}
if(!wochenberichtGewerk){
alert('Bitte zuerst ein Gewerk wählen — der Bericht wird je Gewerk abgelegt.');
return;
}
try {
// berichtProjekte() filtert bereits auf wochenberichtGewerk
// (js/wochenbericht/wochenbericht.js) — das PDF zeigt also genau die
// Projekte, die auch am Bildschirm in dieser Gewerke-Besprechung stehen.
const liste = berichtProjekte(w);
const prevWeek = findPreviousWeek(w);
const grouped = peopleByRole(w);
const allePersonen = [];
ROLES.forEach(r => grouped[r].forEach(e => allePersonen.push(e)));
const logo = await loadLogoPng();
const ab = liste.filter(p => p.besprochen).length;
const gewerkLabel = (typeof GEWERK_LABELS === 'object' && GEWERK_LABELS[wochenberichtGewerk]) || wochenberichtGewerk;
const kopf = [];
if(logo) kopf.push({ image: logo, width: 60, margin: [0, 0, 0, 8] });
kopf.push(
{ text: 'Wochenbericht — Übersicht', fontSize: 18, bold: true, color: '#1D1D1F' },
{ text: (w.label || '') + (w.date ? ' · ' + formatWeekDate(w.date) : '')
+ ' · ' + wochenberichtGewerk + ' (' + gewerkLabel + ')',
fontSize: 10, color: '#6E6E73', margin: [0, 2, 0, 0] },
{ text: ab + ' von ' + liste.length + ' Projekten besprochen · erstellt am '
+ new Date().toLocaleDateString('de-DE'),
fontSize: 8, color: '#6E6E73', margin: [0, 2, 0, 14] }
);
// Seite 1: Übersicht aller Projekte + Anwesenheit der Sitzung.
const content = [
...kopf,
...pdfWbUebersichtBlock(liste, prevWeek),
...pdfWbAnwesenheitBlock(w)
];
// Seite 2 ff.: der vollständige Bericht, eine Akte je Projekt.
const groups = {};
liste.forEach(p => (groups[p.group] || (groups[p.group] = [])).push(p));
let ersteGruppe = true;
sortGroups(Object.keys(groups)).forEach(groupName => {
const projekte = groups[groupName];
if(!projekte || !projekte.length) return;
if(ersteGruppe){
content.push({ text: 'Vollständiger Bericht', fontSize: 8, color: '#8E8E93',
alignment: 'center', pageBreak: 'before', margin: [0, 0, 0, 10] });
ersteGruppe = false;
}
content.push(pdfWbGruppentitel(groupName, 0));
projekte.forEach(p => content.push(pdfWbProjektAkte(p, w, prevWeek, allePersonen)));
});
const doc = {
pageSize: 'A4',
pageOrientation: 'portrait',
pageMargins: [PDF_WB_MARGIN_X, 30, PDF_WB_MARGIN_X, 30],
defaultStyle: { font: 'Roboto', fontSize: 8, color: '#1D1D1F' },
styles: PDF_STYLES,
footer: (page, count) => ({
text: (w.label || '') + ' · Wochenbericht ' + wochenberichtGewerk + ' · Seite ' + page + ' von ' + count,
alignment: 'right', fontSize: 6.5, color: '#6E6E73',
margin: [0, 8, PDF_WB_MARGIN_X, 0]
}),
info: {
title: 'Wochenbericht ' + wochenberichtGewerk + ' ' + (w.label || ''),
creator: 'Projektübersicht'
},
content: content
};
const filename = 'Wochenbericht_' + wochenberichtGewerk + '_'
+ String(w.label || 'Sitzung').replace(/[^\w]/g, '_') + '.pdf';
pdfMake.createPdf(doc).download(filename);
} catch(err){
console.error('Wochenbericht-PDF-Export fehlgeschlagen:', err);
alert('PDF-Export fehlgeschlagen: ' + (err.message || err));
}
}
// -----------------------------
// Export PDF — Planung (Sandbox-Terminplan)
// -----------------------------
// exportSandboxPlanPDF() ist der ANDERE der beiden verbleibenden PDF-Exporte
// (der erste ist exportWochenberichtPDF() oben): ein klassischer Balkenplan
// (Entwurf A der Design-Vergleiche, siehe CLAUDE.md "Planung —
// projektunabhängige Terminplan-Sandbox") für EINEN Terminplan der
// Planung-Sandbox — durchgehende Zeitachse, Balken je Aufgabe, Rauten für
// Meilensteine, eine rote Linie für "heute", genau wie am Bildschirm im
// Terminplan des Projektbereichs. A3 quer — eine Zeitachse braucht die
// Breite —, aber kein Übersichts-Raster wie eine Sitzungstabelle, sondern
// EIN Terminplan, deshalb kein eigenes Deckblatt.
//
// Es gab vor diesem Export keinerlei Gantt-Balken-PDF in dieser Anwendung —
// nur flache Prozent-Balken (LPH-/Auslastungs-Balken oben). Die Technik ist
// dieselbe (canvas-Rechtecke mit berechnetem x/w), nur nach Datum statt nach
// Prozent positioniert. Lange Zeiträume werden NICHT auf mehrere Blätter
// aufgeteilt, sondern auf die feste Blattbreite zusammengestaucht (wie die
// Bildschirmansicht bei pxPerDay=null) — für sehr lange Pläne ist das gröber,
// aber deutlich einfacher als eine Kachelung über mehrere Seiten, und für
// eine Sandbox (kein Abgabedokument) ausreichend. Abhängigkeitslinien sind
// bewusst nicht Teil dieser ersten Fassung — sie sind im Bildschirm-Terminplan
// ohnehin sichtbar, das PDF ist in erster Linie eine mitnehmbare
// Datums-Momentaufnahme.
const PDF_PLAN_LABEL_W = 190;
const PDF_PLAN_ROW_H = 18;
// pdfPlanDayX: x-Position (pt, relativ zum Beginn der Zeitachse) eines
// Datums innerhalb der Chart-Spalte. Parameter: date (Date), rangeStart
// (Date), totalDays (Zahl), chartW (pt). Rückgabe: Zahl (pt), auf den
// sichtbaren Bereich begrenzt.
function pdfPlanDayX(date, rangeStart, totalDays, chartW){
const tage = Math.max(0, Math.min(totalDays, daysBetween(rangeStart, date)));
return chartW * tage / totalDays;
}
// pdfPlanMonatsAchse: teilt [rangeStart, rangeEnd] in Monatsabschnitte für
// die Kopfzeile der Zeitachse. Rückgabe: Array {label, x1, x2}.
function pdfPlanMonatsAchse(rangeStart, rangeEnd, totalDays, chartW){
const monate = [];
let cur = new Date(rangeStart.getFullYear(), rangeStart.getMonth(), 1);
let sicherheit = 0;
while(cur <= rangeEnd && sicherheit++ < 60){
const naechster = new Date(cur.getFullYear(), cur.getMonth() + 1, 1);
const segStart = cur < rangeStart ? rangeStart : cur;
const segEnd = naechster > rangeEnd ? addDays(rangeEnd, 1) : naechster;
monate.push({
label: segStart.toLocaleDateString('de-DE', { month: 'short', year: '2-digit' }),
x1: pdfPlanDayX(segStart, rangeStart, totalDays, chartW),
x2: pdfPlanDayX(segEnd, rangeStart, totalDays, chartW)
});
cur = naechster;
}
return monate;
}
// pdfPlanAchsenZeile: die Kopfzeile der Zeitachse (Monatsbeschriftungen),
// über pdfmake columns statt canvas — Textlabel lassen sich in pdfmake nicht
// frei innerhalb eines Canvas positionieren, wohl aber über Spaltenbreiten.
function pdfPlanAchsenZeile(monate){
return {
columns: [
{ width: PDF_PLAN_LABEL_W, text: 'Aufgabe / Meilenstein', fontSize: 7.5, bold: true, color: '#6E6E73' },
...monate.map(m => ({
width: Math.max(2, m.x2 - m.x1), text: m.label, fontSize: 7, color: '#6E6E73'
}))
],
margin: [0, 0, 0, 3]
};
}
// pdfPlanBarFarbe: Balkenfarbe nach Aufgabenstand — dieselben drei Zustände,
// die die Aufgabe tatsächlich kennt (t.done, t.progress); anders als der
// Entwurf im Design-Vergleich gibt es hier keine vierte, erfundene Kategorie
// "geplant", weil dafür kein echtes Datenfeld existiert.
function pdfPlanBarFarbe(t){
if(t.done) return PDF_ASSIGN_MAIN;
if((Number(t.progress) || 0) > 0) return '#0071E3';
return '#D2D2D7';
}
// pdfPlanZeile: eine Zeile des Balkenplans (Aufgabe ODER Meilenstein) als
// pdfmake columns-Objekt. Parameter: label (String), aktiv (Boolean, fett),
// canvasInhalt (Array von canvas-Primitiven für die Chart-Spalte).
function pdfPlanZeile(label, kursiv, canvasInhalt){
return {
columns: [
{ width: PDF_PLAN_LABEL_W, text: label, fontSize: 7.5, italics: !!kursiv,
color: kursiv ? '#6E6E73' : '#1D1D1F', margin: [0, (PDF_PLAN_ROW_H - 9) / 2, 4, 0] },
{ width: '*', canvas: canvasInhalt }
]
};
}
function pdfPlanTodoZeile(t, rangeStart, totalDays, chartW, todayX){
const s = parseDateISO(t.start) || rangeStart;
const e = parseDateISO(t.end) || s;
const x1 = pdfPlanDayX(s, rangeStart, totalDays, chartW);
const x2 = Math.max(x1 + 2, pdfPlanDayX(addDays(e, 1), rangeStart, totalDays, chartW));
const canvasInhalt = [
{ type: 'rect', x: x1, y: 4, w: x2 - x1, h: PDF_PLAN_ROW_H - 8, color: pdfPlanBarFarbe(t) },
{ type: 'line', x1: 0, y1: PDF_PLAN_ROW_H, x2: chartW, y2: PDF_PLAN_ROW_H, lineWidth: 0.4, lineColor: '#ECECEC' }
];
if(todayX !== null){
canvasInhalt.push({ type: 'line', x1: todayX, y1: 0, x2: todayX, y2: PDF_PLAN_ROW_H, lineWidth: 0.75, lineColor: '#D70015' });
}
return pdfPlanZeile(t.title || 'Aufgabe', false, canvasInhalt);
}
function pdfPlanMeilensteinZeile(m, rangeStart, totalDays, chartW, todayX){
const d = parseDateISO(m.date) || rangeStart;
const x = pdfPlanDayX(d, rangeStart, totalDays, chartW);
const cy = PDF_PLAN_ROW_H / 2, r = 4.5;
const canvasInhalt = [
{ type: 'polyline', points: [{ x: x, y: cy - r }, { x: x + r, y: cy }, { x: x, y: cy + r }, { x: x - r, y: cy }],
closePath: true, color: '#0071E3' },
{ type: 'line', x1: 0, y1: PDF_PLAN_ROW_H, x2: chartW, y2: PDF_PLAN_ROW_H, lineWidth: 0.4, lineColor: '#ECECEC' }
];
if(todayX !== null){
canvasInhalt.push({ type: 'line', x1: todayX, y1: 0, x2: todayX, y2: PDF_PLAN_ROW_H, lineWidth: 0.75, lineColor: '#D70015' });
}
return pdfPlanZeile('◆ ' + (m.name || 'Meilenstein'), true, canvasInhalt);
}
// exportSandboxPlanPDF: Hauptfunktion. Erzeugt den Balkenplan-PDF-Export
// EINES Terminplans der Planung-Sandbox. Parameter: planId. Keine Rückgabe.
async function exportSandboxPlanPDF(planId){
if(!window.pdfMake){
alert('PDF-Export nicht verfügbar: pdfmake wurde nicht geladen. '
+ 'Bitte Internetverbindung bzw. Skript-Einbindung prüfen.');
return;
}
const p = (db.sandboxWeek && Array.isArray(db.sandboxWeek.projects))
? db.sandboxWeek.projects.find(x => x.id === planId) : null;
if(!p){
alert('Dieser Terminplan wurde nicht gefunden.');
return;
}
try {
const plan = p.plan || {};
const todos = (Array.isArray(plan.todos) ? plan.todos.slice() : [])
.sort((a, b) => (parseDateISO(a.start) || 0) - (parseDateISO(b.start) || 0));
const milestones = (Array.isArray(plan.milestones) ? plan.milestones.slice() : [])
.sort((a, b) => (parseDateISO(a.date) || 0) - (parseDateISO(b.date) || 0));
if(!todos.length && !milestones.length){
alert('Dieser Terminplan enthält noch keine Aufgaben oder Meilensteine — es gibt nichts abzubilden.');
return;
}
const range = ganttRangeFromProjects([p], 7);
const totalDays = Math.max(1, daysBetween(range.start, range.end) + 1);
const chartW = PDF_USABLE_W - PDF_PLAN_LABEL_W - 10;
const heute = new Date();
heute.setHours(0, 0, 0, 0);
const todayX = (heute >= range.start && heute <= range.end)
? pdfPlanDayX(heute, range.start, totalDays, chartW) : null;
const logo = await loadLogoPng();
const kopf = [];
if(logo) kopf.push({ image: logo, width: 46, margin: [0, 0, 0, 6] });
kopf.push(
{ text: p.name, fontSize: 17, bold: true, color: '#1D1D1F' },
{ text: 'Terminplan — Planung, unabhängig von einem laufenden Projekt',
fontSize: 9, color: '#6E6E73', margin: [0, 2, 0, 0] }
);
const honorar = Number(p.honorar) || 0;
const beauftragt = Array.isArray(p.beauftragteLP) ? p.beauftragteLP : [];
if(honorar > 0){
kopf.push({ text: 'Honorar ' + honorar.toLocaleString('de-DE') + ' €'
+ (beauftragt.length ? ' · ' + beauftragt.join(', ') : ''),
fontSize: 8, color: '#6E6E73', margin: [0, 2, 0, 0] });
}
kopf.push({ text: 'erstellt am ' + new Date().toLocaleDateString('de-DE'),
fontSize: 7.5, color: '#6E6E73', margin: [0, 2, 0, 16] });
const content = [...kopf];
content.push(pdfPlanAchsenZeile(pdfPlanMonatsAchse(range.start, range.end, totalDays, chartW)));
todos.forEach(t => content.push(pdfPlanTodoZeile(t, range.start, totalDays, chartW, todayX)));
milestones.forEach(m => content.push(pdfPlanMeilensteinZeile(m, range.start, totalDays, chartW, todayX)));
content.push({
columns: [
{ width: 'auto', text: [{ text: '■ ', color: PDF_ASSIGN_MAIN }, { text: 'Erledigt' }], fontSize: 8 },
{ width: 'auto', text: [{ text: '■ ', color: '#0071E3' }, { text: 'In Arbeit' }], fontSize: 8 },
{ width: 'auto', text: [{ text: '■ ', color: '#D2D2D7' }, { text: 'Offen' }], fontSize: 8 },
{ width: 'auto', text: [{ text: '◆ ', color: '#0071E3' }, { text: 'Meilenstein' }], fontSize: 8 },
{ width: 'auto', text: [{ text: '┃ ', color: '#D70015' }, { text: 'heute' }], fontSize: 8 }
],
columnGap: 16, margin: [0, 12, 0, 0]
});
const doc = {
pageSize: 'A3',
pageOrientation: 'landscape',
pageMargins: [PDF_MARGIN_X, 30, PDF_MARGIN_X, 30],
defaultStyle: { font: 'Roboto', fontSize: 8, color: '#1D1D1F' },
styles: PDF_STYLES,
footer: (page, count) => ({
text: 'Planung · ' + p.name + ' · Seite ' + page + ' von ' + count,
alignment: 'right', fontSize: 6.5, color: '#6E6E73',
margin: [0, 8, PDF_MARGIN_X, 0]
}),
info: { title: 'Terminplan ' + p.name, creator: 'Projektübersicht' },
content: content
};
const filename = 'Terminplan_Planung_' + String(p.name || 'Plan').replace(/[^\w]/g, '_') + '.pdf';
pdfMake.createPdf(doc).download(filename);
} catch(err){
console.error('Planung-PDF-Export fehlgeschlagen:', err);
alert('PDF-Export fehlgeschlagen: ' + (err.message || err));
}
}
// -----------------------------
// Export PDF — Protokoll
// -----------------------------
// exportProtokollPDF(protokollId) ist der dritte PDF-Export (neben
// exportWochenberichtPDF()/exportSandboxPlanPDF() oben) — A4 hochkant wie
// der Wochenbericht (PDF_WB_PAGE_W/PDF_WB_MARGIN_X, pdfWbGridLayout()),
// wiederverwendet PDF_STYLES/loadLogoPng()/PDF_ASSIGN_MAIN. Lädt das
// Protokoll bewusst frisch vom Server (GET /project-protokolle/:id) statt
// aus dem privaten Zustand von js/plan/plan-protokoll.js zu lesen — so
// bildet die PDF immer exakt den zuletzt GESPEICHERTEN Stand ab, nicht
// unfertige, noch nicht gespeicherte Eingaben im offenen Formular.
//
// Die Punktetabelle ist wie am Bildschirm in Abschnitten je Gruppe
// (freier Text, "Ohne Gruppe" zuerst, siehe CLAUDE.md "### Protokolle");
// neue Punkte (neu=1) bekommen denselben hellgelben Ton wie die
// Bildschirm-Markierung (--protokoll-neu-bg, hier als Literalwert, da ein
// PDF nicht auf das Farbschema der Anwendung reagiert) plus einen kleinen
// "NEU"-Vermerk unter der Nummer. Die Nummer selbst ("1.1", "1.2" …) kommt
// aus derselben berechneProtokollNummern() (js/plan/plan-protokoll.js), die
// auch der Bildschirm nutzt — nie aus dem gespeicherten Feld
// protokoll_punkte.nummer, damit PDF und Bildschirm nie auseinanderlaufen.
async function exportProtokollPDF(protokollId){
if(!window.pdfMake){
alert('PDF-Export nicht verfügbar: pdfmake wurde nicht geladen. '
+ 'Bitte Internetverbindung bzw. Skript-Einbindung prüfen.');
return;
}
let protokoll;
try {
const r = await fetch(baseUrl() + '/project-protokolle/' + encodeURIComponent(protokollId));
const d = await r.json();
if(!r.ok || !d.ok || !d.protokoll) throw new Error((d && d.error) || 'Protokoll nicht gefunden.');
protokoll = d.protokoll;
} catch(err){
alert('Protokoll konnte nicht geladen werden: ' + (err.message || err));
return;
}
try {
const logo = await loadLogoPng();
const kopf = [];
if(logo) kopf.push({ image: logo, width: 46, margin: [0, 0, 0, 6] });
kopf.push(
{ text: 'Protokoll Nr. ' + protokoll.nummer, fontSize: 16, bold: true, color: '#1D1D1F' },
{ text: (protokoll.projectName || '') + (protokoll.betreff ? ' · ' + protokoll.betreff : ''),
fontSize: 9.5, color: '#6E6E73', margin: [0, 2, 0, 0] },
{ text: [formatDateDE(parseDateISO(protokoll.datum)) || '', protokoll.ort || '',
'Verfasser: ' + (protokoll.verfasserName || '—'),
'erstellt am ' + new Date().toLocaleDateString('de-DE'),
protokoll.quelleDateiname ? 'importiert aus ' + protokoll.quelleDateiname : ''].filter(Boolean).join(' · '),
fontSize: 7.5, color: '#6E6E73', margin: [0, 2, 0, 14] }
);
// ---- Teilnehmer ----
const teilnehmer = Array.isArray(protokoll.teilnehmer) ? protokoll.teilnehmer : [];
const anwesendCount = teilnehmer.filter(t => t.anwesend).length;
const teilnehmerBody = [[
{ text: 'Anwesenheit', style: 'th' }, { text: 'Name', style: 'th' }, { text: 'Firma / Rolle', style: 'th' }
]];
teilnehmer.forEach(t => {
teilnehmerBody.push([
{ text: t.anwesend ? 'X' : '–', style: 'tdCenter', color: t.anwesend ? PDF_ASSIGN_MAIN : '#AEAEB2' },
{ text: t.name || '', style: 'td' },
{ text: t.firma || '', style: 'td' }
]);
});
const teilnehmerBlock = teilnehmer.length === 0
? [pdfWbGruppentitel('Teilnehmer', 0), { text: 'Keine Teilnehmer erfasst.', fontSize: 8, italics: true, color: '#86868B', margin: [0, 0, 0, 10] }]
: [pdfWbGruppentitel('Teilnehmer · ' + anwesendCount + ' von ' + teilnehmer.length + ' anwesend', 0),
{ table: { widths: [55, '*', '*'], body: teilnehmerBody }, layout: pdfWbGridLayout(true), margin: [0, 0, 0, 10] }];
// ---- Punkte, in Abschnitten je Gruppe ----
const punkte = Array.isArray(protokoll.punkte) ? protokoll.punkte : [];
const gruppenNamen = [];
let hatOhneGruppe = false;
punkte.forEach(p => {
const g = String(p.gruppe || '').trim();
if(!g){ hatOhneGruppe = true; return; }
if(!gruppenNamen.includes(g)) gruppenNamen.push(g);
});
const gruppenReihenfolge = (hatOhneGruppe ? [''] : []).concat(gruppenNamen);
// Anzeige-Nummer wie auf dem Bildschirm rein aus Gruppe+Reihenfolge
// errechnet (berechneProtokollNummern(), js/plan/plan-protokoll.js) —
// nie aus dem gespeicherten protokoll_punkte.nummer gelesen, damit PDF
// und Bildschirm niemals auseinanderlaufen können.
const nummern = window.berechneProtokollNummern(punkte);
const nummerById = new Map();
punkte.forEach((p, i) => nummerById.set(p.id, nummern[i]));
const punkteBlock = [];
if(punkte.length === 0){
punkteBlock.push(pdfWbGruppentitel('Punkte', 10));
punkteBlock.push({ text: 'Keine Punkte erfasst.', fontSize: 8, italics: true, color: '#86868B' });
} else {
gruppenReihenfolge.forEach(gruppe => {
const punkteDerGruppe = punkte.filter(p => String(p.gruppe || '').trim() === gruppe);
if(!punkteDerGruppe.length) return;
punkteBlock.push(pdfWbGruppentitel(gruppe || 'Ohne Gruppe', 10));
const body = [[
{ text: 'Nr.', style: 'th' }, { text: 'Thema / Vereinbarung', style: 'th' },
{ text: 'Zuständig', style: 'th' }, { text: 'Termin', style: 'th' }, { text: 'Status', style: 'th' }
]];
punkteDerGruppe.forEach(p => {
const zelle = (inhalt, extra) => {
const cell = Object.assign({ style: 'td' }, inhalt, extra || {});
if(p.neu) cell.fillColor = '#FFF6DB';
return cell;
};
const nummer = nummerById.get(p.id) || '';
const nummerText = p.neu
? { text: [nummer, { text: '\nNEU', fontSize: 5.5, bold: true, color: '#8A6D00' }] }
: { text: nummer };
const statusCell = zelle({ text: p.erledigt ? 'erledigt' : 'offen', alignment: 'center' });
body.push([
zelle(nummerText, { bold: true }),
zelle({ text: [p.thema ? { text: p.thema, bold: true } : '', p.text ? { text: (p.thema ? '\n' : '') + p.text } : ''] }),
zelle({ text: p.zustaendig || '' }),
zelle({ text: formatDateDE(parseDateISO(p.termin)) || '—' }),
statusCell
]);
});
punkteBlock.push({ table: { widths: [28, '*', 72, 42, 30], body }, layout: pdfWbGridLayout(true), margin: [0, 0, 0, 8] });
});
}
const doc = {
pageSize: 'A4',
pageOrientation: 'portrait',
pageMargins: [PDF_WB_MARGIN_X, 30, PDF_WB_MARGIN_X, 30],
defaultStyle: { font: 'Roboto', fontSize: 8, color: '#1D1D1F' },
styles: PDF_STYLES,
footer: (page, count) => ({
text: (protokoll.projectName || '') + ' · Protokoll Nr. ' + protokoll.nummer + ' · Seite ' + page + ' von ' + count,
alignment: 'right', fontSize: 6.5, color: '#6E6E73',
margin: [0, 8, PDF_WB_MARGIN_X, 0]
}),
info: {
title: 'Protokoll ' + protokoll.nummer + ' ' + (protokoll.projectName || ''),
creator: 'Projektübersicht'
},
content: [...kopf, ...teilnehmerBlock, ...punkteBlock]
};
const filename = 'Protokoll_' + String(protokoll.nummer) + '_'
+ String(protokoll.projectName || 'Projekt').replace(/[^\w]/g, '_') + '.pdf';
pdfMake.createPdf(doc).download(filename);
} catch(err){
console.error('Protokoll-PDF-Export fehlgeschlagen:', err);
alert('PDF-Export fehlgeschlagen: ' + (err.message || err));
}
}