Files
erikandClaude Opus 5.5 e12936444d Aufgaben im Wochenbericht, Meilensteine „Erreicht“, Panels als Planblatt
- Wochenbericht: Abschnitt „Aufgaben“ je Projekt (erledigt seit Vorwoche,
  überfällig, nächste zwei Wochen), rote Zahl in der Tagesordnung,
  Tabelle in der PDF-Akte; Klick öffnet das Aufgaben-Panel
- Meilensteine lassen sich als erreicht markieren (plan_milestones.erreicht);
  nur nicht erreichte vergangene gelten als überfällig
- Aufgabe- und Meilenstein-Panel neu aufgebaut: Stand oben, Lage im
  Terminplan, Angaben als Zeilen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:41:44 +01:00

1199 lines
57 KiB
JavaScript
Raw Permalink 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() — Terminplan (Balkenplan eines Projekts oder Planung-Plans)
// * 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, PDF_STYLES (der Terminplan bringt seine
// eigene Geometrie in PDF_TP mit, Wochenbericht/Protokoll sind A4 hochkant
// mit PDF_WB_PAGE_W/PDF_WB_MARGIN_X weiter unten), 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';
// 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');
// sanitizeStatusHtml() ist hier PFLICHT, nicht Kosmetik: Der Server
// speichert status/text unverändert (keine serverseitige Bereinigung), und
// innerHTML führt Handler auch dann aus, wenn das Element gar nicht im
// Dokument hängt — ein <img onerror=…>, das ein angemeldeter Nutzer über
// die API in einen Kommentar oder Protokollpunkt schreibt, liefe sonst in
// der Sitzung JEDER Person, die daraus ein PDF erzeugt. Die Anzeige am
// Bildschirm bereinigt an ihren eigenen Stellen bereits; nur dieser Pfad
// fehlte. Geprüft: ohne diese Zeile feuert der Handler tatsächlich.
root.innerHTML = sanitizeStatusHtml(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 logo.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() + '/logo.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.
// pdfHaken: Haken als Vektorgrafik. Das Zeichen "✓" fehlt in der
// PDF-Schrift (Roboto aus vfs_fonts.js) und erschien als leeres Kästchen —
// Eriks Meldung. Parameter: farbe, breite (Spaltenbreite für die Mitte).
function pdfHaken(farbe, breite){
return { canvas: [{ type: 'polyline', lineWidth: 1.6, lineColor: farbe, lineCap: 'round',
points: [{ x: 0, y: 4 }, { x: 3.2, y: 7.2 }, { x: 9, y: 0.5 }] }],
margin: [Math.max(0, (breite - 9) / 2 - 4), 0, 0, 0] };
}
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: 'Bespr.', style: 'th', alignment: 'center' }
];
const body = [header];
projekte.forEach(p => {
const change = classifyProjectChange(p, prevWeek, wochenberichtGewerk);
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' },
kommentarVon(p, wochenberichtGewerk).besprochen ? pdfHaken(PDF_ASSIGN_MAIN, 38) : { text: '', style: 'tdCenter' }
]);
});
blocks.push({ table: { headerRows: 1, widths: [14, 40, '*', 118, 76, 38], 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){
// Nur das Gewerk des Berichts — wie am Bildschirm (renderAttendance()).
const people = personenDesGewerks(w, wochenberichtGewerk);
if(!people.length){
return [
pdfWbGruppentitel('Anwesenheit ' + (wochenberichtGewerk || ''), 18),
{ text: 'Für dieses Gewerk sind in dieser Sitzung 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 ' + (wochenberichtGewerk || ''), 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, wochenberichtGewerk);
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: kommentarVon(p, wochenberichtGewerk).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] };
// Der Kommentar des Gewerks, für das der Bericht erstellt wird.
const kommentarInhalt = pdfStatusText(kommentarVon(p, wochenberichtGewerk).status || '');
const kommentar = (kommentarInhalt.text && kommentarInhalt.text.length)
? Object.assign({}, kommentarInhalt, { fontSize: 8 })
: { text: 'Kein Kommentar.', fontSize: 7.5, italics: true, color: '#86868B' };
// Der zuletzt geschriebene frühere Kommentar desselben Gewerks (wie die
// Zeile "Zuletzt KW …" auf dem Bildschirm, berichtVerlaufDaten() in
// wochenbericht.js) — mit Formatierung, abgesetzt unter dem aktuellen.
const vorher = pdfWbVorigerKommentar(p, w);
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: 'Heute abwesend: ' + abwesend.join(', '), fontSize: 7, italics: true,
color: '#D70015', margin: [0, 6, 0, 0] });
}
const aufgaben = pdfWbAufgaben(p, w, prevWeek);
const innerContent = { stack: [{
columns: [
{ width: '*', stack: [kopf, aenderung, kommentar].concat(vorher ? [vorher] : []) },
{ width: 190, stack: rightStack }
],
columnGap: 12
}].concat(aufgaben ? [aufgaben] : []) };
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]
};
}
// pdfWbAufgaben: die Aufgaben der Akte wie auf dem Bildschirm
// (berichtAufgabenDaten() in wochenbericht.js) — erledigt seit der Vorwoche,
// überfällig, nächste zwei Wochen, je höchstens fünf. Rückgabe: pdfmake-Block
// oder null, wenn in keiner Gruppe etwas steht.
function pdfWbAufgaben(p, w, prevWeek){
if(typeof berichtAufgabenDaten !== 'function') return null;
const d = berichtAufgabenDaten(p, w, prevWeek);
const gruppen = [
[prevWeek ? 'Erledigt seit ' + berichtKurzKw(prevWeek.label) : 'Erledigt', d.erledigt, null],
['Überfällig', d.ueberfaellig, '#D70015'],
['Nächste zwei Wochen', d.bald, null]
].filter(g => g[1].length);
if(!gruppen.length) return null;
const body = [];
gruppen.forEach(([titel, liste, farbe]) => {
liste.slice(0, 5).forEach((e, i) => body.push([
{ text: i === 0 ? titel + ' (' + liste.length + ')' : '', style: 'td', bold: true, color: farbe || undefined },
{ text: (e.art === 'meilenstein' ? 'Meilenstein: ' : '') + e.titel + (e.neu ? ' neu' : ''), style: 'td' },
{ text: e.kuerzel, style: 'td' },
{ text: e.datum ? formatDateDE(parseDateISO(e.datum)) : '', style: 'td', alignment: 'right', color: farbe || undefined }
]));
});
return { stack: [
{ text: 'AUFGABEN', fontSize: 6.5, bold: true, color: '#8E8E93', margin: [0, 10, 0, 3] },
{ table: { widths: [100, '*', 70, 50], body }, layout: pdfWbGridLayout(false) }
] };
}
// pdfWbVorigerKommentar: Block mit dem jüngsten früheren Kommentar des
// Gewerks. Steht er in der direkten Vorwoche, heißt er "Vorwoche KW …",
// sonst "Zuletzt KW …". Parameter: p (Projekt), w (Sitzung). Rückgabe:
// pdfmake-Block oder null (kein früherer Kommentar).
function pdfWbVorigerKommentar(p, w){
if(!p.projectUid || typeof berichtVerlaufDaten !== 'function') return null;
const e = berichtVerlaufDaten(p, w, wochenberichtGewerk).eintraege[0];
if(!e) return null;
const inhalt = pdfStatusText(e.html || '');
if(!inhalt.text || !inhalt.text.length) return null;
const vorwoche = findPreviousWeek(w);
const titel = (vorwoche && vorwoche.label === e.week.label ? 'VORWOCHE ' : 'ZULETZT ')
+ berichtKurzKw(e.week.label).toUpperCase()
+ (e.autor ? ' · ' + e.autor : '');
return {
table: { widths: ['*'], body: [[{
stack: [
{ text: titel, fontSize: 6.5, bold: true, color: '#8E8E93', margin: [0, 0, 0, 2] },
Object.assign({}, inhalt, { fontSize: 7.5, color: '#3A3A3C' })
],
fillColor: '#F5F5F7'
}]] },
layout: {
hLineWidth: () => 0, vLineWidth: () => 0,
paddingLeft: () => 6, paddingRight: () => 6, paddingTop: () => 4, paddingBottom: () => 5
},
margin: [0, 8, 0, 0]
};
}
// 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 => kommentarVon(p, wochenberichtGewerk).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 — Terminplan (Projekt und Planung)
// -----------------------------
// Balkenplan EINES Terminplans, A3 quer, Gestaltung „A2 — volles Raster“
// (Eriks Wahl aus dem Entwurfsvergleich): links eine Vorgangsliste (Vorgang,
// Start, Ende), rechts die Balken über Monaten (im Wechsel hinterlegt) und
// Kalenderwochen, Gitterlinien über das ganze Blatt. Gegliedert nach
// Leistungsphasen wie der Terminplan am Bildschirm: jeder Phasenbalken
// (balkenPhase()) wird eine dunkelblaue Kopfzeile mit Sammelbalken und
// Fortschritt aus dem Wochenbericht, darunter die Aufgaben, die in ihm
// beginnen (aufgabenPhase()), und die Meilensteine in seinem Zeitraum. Der
// Rest steht unter „Weitere Aufgaben“. Keine Zuständigen, keine Heute-Linie
// (Eriks Vorgabe).
//
// Technik: Jede Seite wird frei positioniert (absolutePosition) — ein
// Canvas für Flächen, Linien und Balken, Texte als eigene Knoten darüber.
// pdfmake kann Text nicht innerhalb eines Canvas setzen. Passen nicht alle
// Zeilen auf ein Blatt, geht es auf weiteren Seiten mit demselben Kopf weiter.
const PDF_TP = {
W: 1190.55, H: 841.89, M: 32,
top: 84, kopfH: 34, unten: 60,
spalten: [ ['Vorgang', 280], ['Start', 60], ['Ende', 60] ],
blau: '#0f5a85', ink: '#1a1f24', grau: '#5f6a73', gitter: '#c9d1d8',
woche: '#dde3e8', monatHell: '#f4f6f8', phase: '#8fb2c9',
aufgabeRand: '#4a6f88', inArbeit: '#4a86b3', erledigt: '#2e7a50'
};
const PDF_TP_LPH_NAMEN = {
'LPH 1': 'Grundlagenermittlung', 'LPH 2': 'Vorplanung', 'LPH 3': 'Entwurfsplanung',
'LPH 4': 'Genehmigungsplanung', 'LPH 5': 'Ausführungsplanung', 'LPH 6': 'Vorbereitung der Vergabe',
'LPH 7': 'Mitwirkung bei der Vergabe', 'LPH 8': 'Objektüberwachung', 'LPH 9': 'Objektbetreuung',
'W+M': 'Wartung und Monitoring'
};
// pdfTpDatum: 'TT.MM.JJ'. Parameter: Date. Rückgabe: String.
function pdfTpDatum(d){
return String(d.getDate()).padStart(2, '0') + '.' + String(d.getMonth() + 1).padStart(2, '0') + '.'
+ String(d.getFullYear()).slice(2);
}
// pdfTpKuerzen: kürzt Text auf eine Breite (pt) bei gegebener Schriftgröße —
// grob geschätzt, Roboto ist im Mittel gut eine halbe Schriftgröße breit.
function pdfTpKuerzen(text, breite, groesse){
const max = Math.floor(breite / (groesse * 0.52));
const t = String(text || '');
return t.length > max ? t.slice(0, Math.max(1, max - 1)) + '…' : t;
}
// pdfTpText: frei positionierter Text in einem Kasten der Breite w.
// opts: size, bold, italics, color, align.
function pdfTpText(x, y, w, text, opts){
const o = opts || {};
w = Math.max(1, Math.min(w, PDF_TP.W - PDF_TP.M - x)); // nie über den rechten Rand
return {
absolutePosition: { x, y },
columns: [{ width: w, text: String(text), fontSize: o.size || 9, bold: !!o.bold, italics: !!o.italics,
color: o.color || PDF_TP.ink, alignment: o.align || 'left', noWrap: true }]
};
}
// pdfTpZeilen: die Zeilen des Plans in Anzeigereihenfolge — je Phase eine
// Kopfzeile, ihre Aufgaben und Meilensteine, am Ende „Weitere Aufgaben“.
// Parameter: p (Projekt). Rückgabe: [{art:'phase'|'aufgabe'|'ms', …}].
function pdfTpZeilen(p){
const plan = p.plan || {};
const todos = (Array.isArray(plan.todos) ? plan.todos : [])
.filter(t => t.start && t.end && t.showInGantt !== false && parseDateISO(t.start) && parseDateISO(t.end));
const ms = (Array.isArray(plan.milestones) ? plan.milestones : []).filter(m => parseDateISO(m.date));
const nachStart = (a, b) => String(a.start).localeCompare(String(b.start));
const phasen = todos.filter(t => balkenPhase(t)).sort(nachStart);
const aufgaben = todos.filter(t => !balkenPhase(t));
const msPhase = m => {
const d = parseDateISO(m.date);
const k = phasen.find(b => d >= parseDateISO(b.start) && d <= parseDateISO(b.end));
return k ? balkenPhase(k) : '';
};
const zeilen = [];
const gruppe = (kopf, l) => {
const eigene = aufgaben.filter(t => aufgabenPhase(t, todos) === l).sort(nachStart);
const eigeneMs = ms.filter(m => msPhase(m) === l).sort((a, b) => String(a.date).localeCompare(String(b.date)));
if(!kopf && !eigene.length && !eigeneMs.length) return;
zeilen.push(kopf || { art: 'phase', titel: 'Weitere Aufgaben' });
eigene.forEach(t => zeilen.push({ art: 'aufgabe', titel: t.title || 'Aufgabe',
von: parseDateISO(t.start), bis: parseDateISO(t.end),
fort: t.done ? 100 : Math.max(0, Math.min(100, Number(t.progress) || 0)) }));
eigeneMs.forEach(m => zeilen.push({ art: 'ms', titel: m.name || 'Meilenstein', datum: parseDateISO(m.date) }));
};
phasen.forEach(b => {
const l = balkenPhase(b);
gruppe({ art: 'phase', titel: l + (PDF_TP_LPH_NAMEN[l] ? ' ' + PDF_TP_LPH_NAMEN[l] : ''),
von: parseDateISO(b.start), bis: parseDateISO(b.end),
fort: typeof fortschrittProzent === 'function' ? fortschrittProzent(p, l) : 0 }, l);
});
gruppe(null, '');
return zeilen;
}
// pdfTpLegende: die Legende als Spalten für die Fußzeile (Zeichen + Text).
function pdfTpLegende(){
const T = PDF_TP, sp = [];
[
[{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: T.phase }, 'Leistungsphase'],
[{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: T.inArbeit }, 'in Arbeit'],
[{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: T.erledigt }, 'erledigt'],
[{ type: 'rect', x: 0, y: 1, w: 15, h: 8, color: '#ffffff', lineColor: T.aufgabeRand, lineWidth: 0.8 }, 'offen'],
[{ type: 'polyline', closePath: true, color: T.ink, points: [{ x: 7, y: 0 }, { x: 12, y: 5 }, { x: 7, y: 10 }, { x: 2, y: 5 }] }, 'Meilenstein']
].forEach(([form, text]) => {
sp.push({ width: 20, canvas: [form] });
sp.push({ width: text.length * 4.4 + 16, text, fontSize: 8, color: T.grau });
});
return sp;
}
// pdfTpSeite: alle Knoten EINER Seite. Parameter: zeilen (die dieser Seite),
// rowH, x (Datum → x), range {start, ende}, kopf (Knoten des Seitenkopfs),
// ersteSeite. Rückgabe: Array von pdfmake-Knoten.
function pdfTpSeite(zeilen, rowH, x, range, kopf, ersteSeite){
const T = PDF_TP, M = T.M, cx0 = M + T.spalten.reduce((s, c) => s + c[1], 0), cx1 = T.W - M;
const bodyTop = T.top + T.kopfH, bodyBot = bodyTop + zeilen.length * rowH;
const plus1 = d => addDays(d, 1);
const flaechen = [], linien = [], balken = [], texte = [];
// Monate im Wechsel hinterlegt, mit Namen
let m = new Date(range.start.getFullYear(), range.start.getMonth(), 1), i = 0;
while(m <= range.ende && i < 120){
const naechster = new Date(m.getFullYear(), m.getMonth() + 1, 1);
const a = Math.max(cx0, x(m)), e = Math.min(cx1, x(naechster));
if(e > a){
if(i % 2 === 0) flaechen.push({ type: 'rect', x: a, y: T.top, w: e - a, h: bodyBot - T.top, color: T.monatHell });
const name = m.toLocaleDateString('de-DE', { month: 'short' }).replace('.', '') + ' ' + String(m.getFullYear()).slice(2);
if(e - a > 30) texte.push(pdfTpText(a, T.top + 4.5, e - a, name, { bold: true, align: 'center' }));
linien.push({ type: 'line', x1: a, y1: T.top, x2: a, y2: bodyBot, lineWidth: 0.8, lineColor: T.gitter });
}
m = naechster; i++;
}
// Kalenderwochen: Linie je Woche, Nummer nur so oft, wie Platz ist
const wocheB = x(addDays(range.start, 7)) - x(range.start);
const schritt = Math.max(1, Math.ceil(11 / Math.max(0.1, wocheB)));
for(let w = new Date(range.start), n = 0; w <= range.ende; w = addDays(w, 7), n++){
const a = x(w);
linien.push({ type: 'line', x1: a, y1: T.top + 17, x2: a, y2: bodyBot, lineWidth: 0.5, lineColor: T.woche });
if(n % schritt === 0) texte.push(pdfTpText(a, T.top + 23, wocheB * schritt, getWeekNumber(w), { size: 6.5, color: T.grau, align: schritt === 1 ? 'center' : 'left' }));
}
linien.push({ type: 'line', x1: cx0, y1: T.top + 17, x2: cx1, y2: T.top + 17, lineWidth: 0.6, lineColor: T.gitter });
// Tabellenkopf links
flaechen.push({ type: 'rect', x: M, y: T.top, w: cx0 - M, h: T.kopfH, color: '#e9edf1' });
let sx = M;
T.spalten.forEach(([name, w]) => {
texte.push(pdfTpText(sx + 6, T.top + 12, w - 8, name, { bold: true }));
sx += w;
if(sx < cx0) linien.push({ type: 'line', x1: sx, y1: T.top, x2: sx, y2: bodyBot, lineWidth: 0.6, lineColor: T.gitter });
});
const xStart = M + T.spalten[0][1], xEnde = xStart + T.spalten[1][1];
// Zeilen
zeilen.forEach((z, k) => {
const y = bodyTop + k * rowH, my = y + rowH / 2, ty = my - 4.6;
if(z.art === 'phase'){
flaechen.push({ type: 'rect', x: M, y, w: cx0 - M, h: rowH, color: T.blau });
texte.push(pdfTpText(M + 6, ty - 0.4, T.spalten[0][1] - 10, pdfTpKuerzen(z.titel, T.spalten[0][1] - 10, 9.5), { size: 9.5, bold: true, color: '#ffffff' }));
if(z.von){
texte.push(pdfTpText(xStart + 6, ty, 54, pdfTpDatum(z.von), { bold: true, color: '#ffffff' }));
texte.push(pdfTpText(xEnde + 6, ty, 54, pdfTpDatum(z.bis), { bold: true, color: '#ffffff' }));
const a = x(z.von), e = Math.max(a + 3, x(plus1(z.bis)));
balken.push({ type: 'rect', x: a, y: my - 5, w: e - a, h: 10, color: T.phase });
if(z.fort > 0) balken.push({ type: 'rect', x: a, y: my - 5, w: (e - a) * z.fort / 100, h: 10, color: T.blau });
// Prozent rechts neben dem Balken; am rechten Rand links davor —
// Text über den Seitenrand hinaus erzeugte eine leere Zusatzseite.
if(e + 45 <= T.W - M) texte.push(pdfTpText(e + 5, my - 4, 40, z.fort + ' %', { size: 8, bold: true, color: T.blau }));
else texte.push(pdfTpText(a - 45, my - 4, 40, z.fort + ' %', { size: 8, bold: true, color: T.blau, align: 'right' }));
}
} else if(z.art === 'aufgabe'){
texte.push(pdfTpText(M + 16, ty, T.spalten[0][1] - 20, pdfTpKuerzen(z.titel, T.spalten[0][1] - 20, 9)));
texte.push(pdfTpText(xStart + 6, ty, 54, pdfTpDatum(z.von)));
texte.push(pdfTpText(xEnde + 6, ty, 54, pdfTpDatum(z.bis)));
const a = x(z.von), e = Math.max(a + 3, x(plus1(z.bis)));
balken.push({ type: 'rect', x: a, y: my - 5, w: e - a, h: 10, color: '#ffffff', lineColor: T.aufgabeRand, lineWidth: 0.8 });
if(z.fort > 0) balken.push({ type: 'rect', x: a, y: my - 5, w: (e - a) * z.fort / 100, h: 10, color: z.fort >= 100 ? T.erledigt : T.inArbeit });
} else {
texte.push(pdfTpText(M + 16, ty, T.spalten[0][1] - 20, pdfTpKuerzen(z.titel, T.spalten[0][1] - 20, 9), { italics: true }));
texte.push(pdfTpText(xStart + 6, ty, 54, pdfTpDatum(z.datum)));
const mx = x(z.datum), r = 6;
balken.push({ type: 'polyline', closePath: true, color: T.ink,
points: [{ x: mx, y: my - r }, { x: mx + r, y: my }, { x: mx, y: my + r }, { x: mx - r, y: my }] });
texte.push(pdfTpText(mx - 9 - 40, my - 4, 40, pdfTpDatum(z.datum).slice(0, 6), { size: 8, align: 'right' }));
}
linien.push({ type: 'line', x1: M, y1: y + rowH, x2: cx1, y2: y + rowH, lineWidth: 0.5, lineColor: T.gitter });
});
// Rahmen
const rahmen = [
{ type: 'rect', x: M, y: T.top, w: cx1 - M, h: bodyBot - T.top, lineColor: T.ink, lineWidth: 1 },
{ type: 'line', x1: cx0, y1: T.top, x2: cx0, y2: bodyBot, lineWidth: 1, lineColor: T.ink },
{ type: 'line', x1: M, y1: bodyTop, x2: cx1, y2: bodyTop, lineWidth: 1, lineColor: T.ink }
];
const knoten = [{ canvas: [...flaechen, ...linien, ...balken, ...rahmen], absolutePosition: { x: 0, y: 0 } }];
if(!ersteSeite) knoten[0].pageBreak = 'before';
return [...knoten, ...kopf, ...texte];
}
// exportSandboxPlanPDF: Einstieg vom Knopf „Ansicht → Als PDF exportieren“.
// Findet einen Planung-Plan oder das im Projektbereich geöffnete Projekt.
// Parameter: planId. Keine Rückgabe.
async function exportSandboxPlanPDF(planId){
const sandbox = (db.sandboxWeek && Array.isArray(db.sandboxWeek.projects))
? db.sandboxWeek.projects.find(x => x.id === planId) : null;
const app = window.PlanApp;
const p = sandbox || (app && app.project && app.project.id === planId ? app.project : null);
return exportTerminplanPDF(p, !!sandbox, sandbox ? null : (app && app.week));
}
// exportTerminplanPDF: erzeugt das PDF. Parameter: p (Projekt), sandbox
// (Boolean, Planung-Plan), week (Sitzung des Projekts oder null).
async function exportTerminplanPDF(p, sandbox, week){
if(!window.pdfMake){
alert('PDF-Export nicht verfügbar: pdfmake wurde nicht geladen. '
+ 'Bitte Internetverbindung bzw. Skript-Einbindung prüfen.');
return;
}
if(!p){
alert('Dieser Terminplan wurde nicht gefunden.');
return;
}
try {
const T = PDF_TP, M = T.M;
const zeilen = pdfTpZeilen(p);
const daten = [];
zeilen.forEach(z => { if(z.von) daten.push(z.von, z.bis); if(z.datum) daten.push(z.datum); });
if(!daten.length){
alert('Dieser Terminplan enthält noch keine Aufgaben mit Termin oder Meilensteine — es gibt nichts abzubilden.');
return;
}
// Zeitraum: Montag der ersten bis Sonntag der letzten Woche
const min = new Date(Math.min(...daten)), max = new Date(Math.max(...daten));
const start = addDays(min, -((min.getDay() + 6) % 7));
const ende = addDays(max, 6 - ((max.getDay() + 6) % 7));
const range = { start, ende };
const cx0 = M + T.spalten.reduce((s, c) => s + c[1], 0), cx1 = T.W - M;
const spanne = daysBetween(start, ende) + 1;
const x = d => cx0 + (cx1 - cx0) * daysBetween(start, d) / spanne;
// Zeilenhöhe: so groß wie möglich (höchstens 30 pt), mindestens 17 pt;
// reicht das nicht, geht es auf weiteren Seiten weiter.
const platz = T.H - T.unten - T.top - T.kopfH;
const rowH = Math.max(17, Math.min(30, platz / zeilen.length));
const jeSeite = Math.max(1, Math.floor(platz / rowH));
// Seitenkopf
const logo = await loadLogoPng();
const titel = (!sandbox && p.nr ? p.nr + ' ' : '') + (p.name || 'Terminplan');
const teile = [sandbox ? 'Terminplan — Planung, unabhängig von einem laufenden Projekt' : 'Terminplan'];
if(!sandbox && week && week.label) teile.push('Stand ' + week.label);
if(Number(p.honorar) > 0) teile.push('Honorar ' + Number(p.honorar).toLocaleString('de-DE') + ' €');
if(Array.isArray(p.beauftragteLP) && p.beauftragteLP.length) teile.push('beauftragt ' + p.beauftragteLP.join(', '));
// Je Seite neu gebaut: pdfmake verändert die Knoten beim Setzen, ein
// wiederverwendeter Kopf fehlte ab Seite 2.
const kopf = () => {
const k = [
pdfTpText(M, 30, 900, pdfTpKuerzen(titel, 900, 18), { size: 18, bold: true }),
pdfTpText(M, 54, 900, teile.join(' · '), { size: 10, color: T.grau })
];
if(logo) k.push({ image: logo, fit: [40, 40], absolutePosition: { x: T.W - M - 40, y: 26 } });
return k;
};
const content = [];
for(let i = 0; i < zeilen.length; i += jeSeite){
content.push(...pdfTpSeite(zeilen.slice(i, i + jeSeite), rowH, x, range, kopf(), i === 0));
}
const fussText = 'Terminplan' + (!sandbox && p.nr ? ' ' + p.nr : '') + ' · erstellt am ' + new Date().toLocaleDateString('de-DE');
const doc = {
pageSize: 'A3',
pageOrientation: 'landscape',
pageMargins: [M, 0, M, 36], // oben 0: Die Seite ist frei positioniert; mit Rand passte der Canvas über die ganze Höhe nicht.
defaultStyle: { font: 'Roboto', fontSize: 9, color: T.ink },
// Legende links und Seitenangabe rechts in der Fußzeile — als frei
// positionierter Text am Seitenende rutschte die Legende auf eine
// eigene, sonst leere Seite.
footer: (page, count) => ({
margin: [M, 12, M, 0],
columns: [
...pdfTpLegende(),
{ width: '*', text: fussText + ' · Seite ' + page + ' von ' + count, alignment: 'right', fontSize: 8, color: T.grau }
]
}),
info: { title: 'Terminplan ' + (p.name || ''), creator: 'Projektübersicht' },
content
};
const filename = (sandbox ? 'Terminplan_Planung_' : 'Terminplan_' + (p.nr ? p.nr + '_' : ''))
+ String(p.name || 'Plan').replace(/[^\w]/g, '_') + '.pdf';
pdfMake.createPdf(doc).download(filename);
} catch(err){
console.error('Terminplan-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' });
// entfaelltNaechstes: Gnadenrunde eines erledigten Punkts (Eriks
// Wunsch) — derselbe Hinweis wie am Bildschirm
// (.protokoll-chip-entfaellt, plan-protokoll.js), hier unter dem
// Erstellungsdatum statt in einer eigenen Spalte, aus demselben
// Platzgrund wie erstelltText oben.
if(p.entfaelltNaechstes) nummerParts.push({ text: '\nEntfällt im nächsten Protokoll', fontSize: 5.5, bold: true, color: '#D70015' });
const nummerText = { text: nummerParts };
const statusLabel = { offen: 'offen', erledigt: 'erledigt', info: 'Info' }[p.status] || 'offen';
const statusCell = zelle({ text: statusLabel, alignment: 'center' });
// themaGeaendert/textGeaendert: wie am Bildschirm (.ist-geaendert,
// plan-styles.css) markiert nur die Thema/Vereinbarung-Zelle gelb,
// nicht die ganze Zeile wie bei einem echten neuen Punkt (p.neu,
// oben in zelle()) — pdfmake kennt keine Hintergrundfarbe je
// Textlauf innerhalb einer Zelle, nur je ganzer Zelle, deshalb
// bekommt hier ausnahmsweise die ganze Thema-Zelle den Ton, auch
// wenn nur eines der beiden Felder sich geändert hat. Bei p.neu
// erübrigt sich das (die ganze Zeile ist ohnehin schon gelb).
// p.text ist bereits sanitiziertes HTML (contenteditable-Feld mit
// Fett/Kursiv/Farbe, richtextToolbarHtml()/js/utils.js, Eriks
// Wunsch) — dieselbe pdfStatusText() wie der Wochenbericht-
// Kommentar wandelt es direkt in eine pdfmake-Textstruktur mit
// echten Auszeichnungen, ohne Zwischenschritt.
const textLaeufe = p.text ? pdfStatusText(p.text).text : '';
const themaZelle = zelle({ text: [
p.thema ? { text: p.thema, bold: true } : '',
p.thema && p.text ? '\n' : '',
...(Array.isArray(textLaeufe) ? textLaeufe : (textLaeufe ? [{ text: textLaeufe }] : []))
] });
if(!p.neu && (p.themaGeaendert || p.textGeaendert)) themaZelle.fillColor = '#FFF6DB';
body.push([
zelle(nummerText, { bold: true }),
themaZelle,
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));
}
}