Files
projektuebersicht/public/js/pdf.js
T

1011 lines
45 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), 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 (<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;
}
}
// -----------------------------
// 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) || '';
// Erstellungsdatum steht ausgegraut unter der Nummer statt in
// einer eigenen Spalte — auf Eriks Wunsch, damit die Tabelle
// keine zusätzliche Spalte bekommt.
const erstelltText = formatDateDE(parseDateISO(p.erstelltAm));
const nummerParts = [nummer];
if(p.neu) nummerParts.push({ text: '\nNEU', fontSize: 5.5, bold: true, color: '#8A6D00' });
if(erstelltText) nummerParts.push({ text: '\n' + erstelltText, fontSize: 5.5, bold: false, color: '#86868B' });
const nummerText = { text: nummerParts };
const statusLabel = { offen: 'offen', erledigt: 'erledigt', info: 'Info' }[p.status] || 'offen';
const statusCell = zelle({ text: statusLabel, 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: [34, '*', 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));
}
}