Termin aus Mail, Rollen & Rechte, Abwesenheiten, Glas-Darstellung und Pi-Betrieb

- Termin aus Mail: Mailtext oder .eml per Drag & Drop/Strg+V auf Terminplan
  oder Aufgaben, KI schlägt Aufgaben vor, Prüfung im Aufgaben-Panel; neues
  Notizfeld je Aufgabe (plan_todos.notiz)
- Terminplan: Meilensteine ohne Aufgabenbezug je in eigener Zeile,
  "+ Neu"-Menü klappt neben schmaler Seitenleiste nach rechts, Leistungsphasen
- Rollen & Rechte, Abwesenheiten-Seite, Benachrichtigungen, Rundgang
- Glas-Darstellung, Logo als Favicon
- Pi-Betrieb: PDF-Arbeit im Kindprozess, Wächter, Einrichtungs- und
  Prüfskript inkl. WLAN-Wächter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
erikandClaude Opus 5.5 committed 2026-10-29 14:16:52 +01:00
1 parent f4fb3c550b
commit b7e0bd0d92
56 files changed
+12265 -2974

No files matched your search

+316 -191
View File
@@ -4,7 +4,7 @@
// 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)
// * 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
@@ -19,10 +19,9 @@
// 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()
// 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().
@@ -35,14 +34,6 @@ 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 = {
@@ -393,6 +384,15 @@ function pdfWbGruppentitel(name, topMargin){
// 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));
@@ -407,11 +407,11 @@ function pdfWbUebersichtBlock(liste, prevWeek){
{ text: 'Projekt', style: 'th' },
{ text: 'Änderung', style: 'th' },
{ text: 'Amt. LPH', style: 'th' },
{ text: '✓', style: 'th', alignment: 'center' }
{ text: 'Bespr.', style: 'th', alignment: 'center' }
];
const body = [header];
projekte.forEach(p => {
const change = classifyProjectChange(p, prevWeek);
const change = classifyProjectChange(p, prevWeek, wochenberichtGewerk);
const info = pdfWbAktuelleLphInfo(p);
body.push([
pdfWbChangeDot(change.bucket),
@@ -419,10 +419,10 @@ function pdfWbUebersichtBlock(liste, prevWeek){
{ 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 }
kommentarVon(p, wochenberichtGewerk).besprochen ? pdfHaken(PDF_ASSIGN_MAIN, 38) : { text: '', style: 'tdCenter' }
]);
});
blocks.push({ table: { headerRows: 1, widths: [14, 40, '*', 118, 76, 18], body },
blocks.push({ table: { headerRows: 1, widths: [14, 40, '*', 118, 76, 38], body },
layout: pdfWbGridLayout(true), margin: [0, 0, 0, 4] });
});
return blocks;
@@ -436,11 +436,12 @@ function pdfWbUebersichtBlock(liste, prevWeek){
// 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 : [];
// Nur das Gewerk des Berichts — wie am Bildschirm (renderAttendance()).
const people = personenDesGewerks(w, wochenberichtGewerk);
if(!people.length){
return [
pdfWbGruppentitel('Anwesenheit der Sitzung', 18),
{ text: 'Für diese Sitzung sind keine Projektbeteiligten hinterlegt.',
pdfWbGruppentitel('Anwesenheit ' + (wochenberichtGewerk || ''), 18),
{ text: 'Für dieses Gewerk sind in dieser Sitzung keine Projektbeteiligten hinterlegt.',
fontSize: 8, italics: true, color: '#86868B' }
];
}
@@ -460,7 +461,7 @@ function pdfWbAnwesenheitBlock(w){
body.push(row);
}
return [
pdfWbGruppentitel('Anwesenheit der Sitzung', 18),
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) }
];
@@ -472,7 +473,7 @@ function pdfWbAnwesenheitBlock(w){
// 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 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(', ') + ']' : '';
@@ -483,7 +484,7 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
{ 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,
{ text: kommentarVon(p, wochenberichtGewerk).besprochen ? '✓ besprochen' : '', fontSize: 7, color: PDF_ASSIGN_MAIN,
alignment: 'right', width: 70 }
], margin: [0, 0, 0, 3]
};
@@ -492,11 +493,17 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
? { 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 || '');
// 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);
@@ -507,13 +514,13 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
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,
rightStack.push({ text: 'Heute abwesend: ' + abwesend.join(', '), fontSize: 7, italics: true,
color: '#D70015', margin: [0, 6, 0, 0] });
}
const innerContent = {
columns: [
{ width: '*', stack: [kopf, aenderung, kommentar] },
{ width: '*', stack: [kopf, aenderung, kommentar].concat(vorher ? [vorher] : []) },
{ width: 190, stack: rightStack }
],
columnGap: 12
@@ -541,6 +548,36 @@ function pdfWbProjektAkte(p, w, prevWeek, allePersonen){
};
}
// 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,
@@ -574,7 +611,7 @@ async function exportWochenberichtPDF(){
ROLES.forEach(r => grouped[r].forEach(e => allePersonen.push(e)));
const logo = await loadLogoPng();
const ab = liste.filter(p => p.besprochen).length;
const ab = liste.filter(p => kommentarVon(p, wochenberichtGewerk).besprochen).length;
const gewerkLabel = (typeof GEWERK_LABELS === 'object' && GEWERK_LABELS[wochenberichtGewerk]) || wochenberichtGewerk;
const kopf = [];
@@ -640,218 +677,306 @@ async function exportWochenberichtPDF(){
}
}
// -----------------------------
// Export PDF — Planung (Sandbox-Terminplan)
// Export PDF — Terminplan (Projekt und Planung)
// -----------------------------
// 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.
// 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).
//
// 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.
// 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_PLAN_LABEL_W = 190;
const PDF_PLAN_ROW_H = 18;
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'
};
// 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;
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);
}
// 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;
// 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;
}
// 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){
// 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 {
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]
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 }]
};
}
// 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 }
]
// 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;
}
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' });
// 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++;
}
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' });
// 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' }));
}
return pdfPlanZeile('◆ ' + (m.name || 'Meilenstein'), true, canvasInhalt);
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: Hauptfunktion. Erzeugt den Balkenplan-PDF-Export
// EINES Terminplans der Planung-Sandbox. Parameter: planId. Keine Rückgabe.
// 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;
}
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.');
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;
}
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;
// 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 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] });
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));
}
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 fussText = 'Terminplan' + (!sandbox && p.nr ? ' ' + p.nr : '') + ' · erstellt am ' + new Date().toLocaleDateString('de-DE');
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,
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) => ({
text: 'Planung · ' + p.name + ' · Seite ' + page + ' von ' + count,
alignment: 'right', fontSize: 6.5, color: '#6E6E73',
margin: [0, 8, PDF_MARGIN_X, 0]
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: content
info: { title: 'Terminplan ' + (p.name || ''), creator: 'Projektübersicht' },
content
};
const filename = 'Terminplan_Planung_' + String(p.name || 'Plan').replace(/[^\w]/g, '_') + '.pdf';
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('Planung-PDF-Export fehlgeschlagen:', err);
console.error('Terminplan-PDF-Export fehlgeschlagen:', err);
alert('PDF-Export fehlgeschlagen: ' + (err.message || err));
}
}