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