// ----------------------------- // 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 (
, Block-Ende von

/

) sowie die vom // Sanitizing erlaubten Auszeichnungen (bold, italic, underline, color). // Rückgabe: Objekt mit text-Array, direkt als Zellinhalt verwendbar. function pdfStatusText(htmlStatus){ if(!htmlStatus) return { text: '', style: 'td' }; const root = document.createElement('div'); // 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 , 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 innerContent = { columns: [ { width: '*', stack: [kopf, aenderung, kommentar].concat(vorher ? [vorher] : []) }, { 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] }; } // 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)); } }