- 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>
1199 lines
57 KiB
JavaScript
1199 lines
57 KiB
JavaScript
// -----------------------------
|
||
// 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));
|
||
}
|
||
}
|