import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import PDFDocument from 'pdfkit'; import ExcelJS from 'exceljs'; /* * Gemeinsamer Unterbau fuer alle Exporte. Ein Export wird als neutrales * Dokument beschrieben (Titel, Infoblock, Kennzahlen, Tabellen) und von hier * aus als PDF, Excel oder CSV geschrieben - so sehen Stundenzettel und Reports * in allen drei Formaten gleich aufgebaut aus und tragen denselben Kopf und Fuss * aus den Einstellungen. * * doc = { * title, subtitle, filename, * info: [[Label, Wert], ...], * kpis: [[Label, Wert], ...], * tables: [{ title?, columns: [{ key, label, width?, align?, type? }], rows: [...], totals?: {...} }], * signature: boolean, * landscape: boolean, * } * type: 'hours' (Minuten -> 1,50), 'duration' (Minuten -> 1:30), 'money', 'date', 'text', 'percent' */ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DEFAULT_LOGO = path.join(__dirname, '..', '..', 'assets', 'logo.png'); export function hoursDe(minutes) { return (minutes / 60).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } export function durationDe(minutes) { const m = Math.round(minutes); return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`; } export function moneyDe(value) { return `${Number(value || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`; } export function dateDe(value) { const [y, m, d] = String(value).slice(0, 10).split('-'); return `${d}.${m}.${y}`; } function formatCell(value, type) { if (value == null || value === '') return ''; switch (type) { case 'hours': return hoursDe(value); case 'duration': return durationDe(value); case 'money': return moneyDe(value); case 'date': return dateDe(value); case 'percent': return `${value} %`; default: return String(value); } } function logoBuffer(exportSettings) { const logo = exportSettings.logo; if (typeof logo === 'string' && logo.startsWith('data:image/')) { const match = logo.match(/^data:image\/(png|jpe?g);base64,(.+)$/); if (match) return Buffer.from(match[2], 'base64'); } return fs.readFileSync(DEFAULT_LOGO); } /* ------------------------------------------------------------------ CSV -- */ export function renderCsv(doc) { const escape = (v) => { const s = String(v ?? ''); return /[;"\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s; }; const lines = []; doc.tables.forEach((table, index) => { if (index > 0) lines.push(''); if (doc.tables.length > 1 && table.title) lines.push(escape(table.title)); lines.push(table.columns.map((c) => escape(c.label)).join(';')); for (const row of table.rows) { lines.push(table.columns.map((c) => escape(formatCell(row[c.key], c.type))).join(';')); } if (table.totals) lines.push(table.columns.map((c) => escape(formatCell(table.totals[c.key], c.type))).join(';')); }); // BOM, damit Excel die Umlaute als UTF-8 liest. return Buffer.from('' + lines.join('\r\n'), 'utf8'); } /* ---------------------------------------------------------------- Excel -- */ export async function renderXlsx(doc, settings) { const ex = settings.export; const accent = (ex.accent || '#6BA04A').replace('#', '').toUpperCase(); const workbook = new ExcelJS.Workbook(); workbook.creator = settings.company_name || 'perinco'; workbook.created = new Date(); const sheet = workbook.addWorksheet(doc.sheetName || 'Stundennachweis', { pageSetup: { paperSize: 9, orientation: doc.landscape ? 'landscape' : 'portrait', fitToPage: true, fitToWidth: 1, fitToHeight: 0, margins: { left: 0.5, right: 0.5, top: 0.6, bottom: 0.8, header: 0.3, footer: 0.3 }, }, headerFooter: { oddFooter: `&L&8${xlEsc(ex.footer_left)}&C&8${xlEsc(ex.footer_center)}&R&8${xlEsc(ex.footer_right)} · Seite &P von &N`, }, views: [{ showGridLines: false }], }); const widest = doc.tables.reduce((m, t) => Math.max(m, t.columns.length), 2); // Spaltenbreiten aus der ersten (Haupt-)Tabelle. sheet.columns = doc.tables[0].columns.map((c) => ({ width: c.xlsWidth || Math.max(10, (c.width || 80) / 5.5) })); const logo = workbook.addImage({ buffer: logoBuffer(ex), extension: /^data:image\/jpe?g/.test(ex.logo || '') ? 'jpeg' : 'png' }); sheet.addImage(logo, { tl: { col: 0, row: 0 }, ext: { width: 170, height: 77 } }); sheet.getRow(1).height = 22; sheet.getRow(2).height = 22; sheet.getRow(3).height = 22; const headerRight = sheet.getCell(1, widest); headerRight.value = ex.header_right || ''; headerRight.alignment = { horizontal: 'right' }; headerRight.font = { size: 9, color: { argb: 'FF6E6E6E' } }; let row = 5; const title = sheet.getCell(row, 1); title.value = doc.title; title.font = { size: 18, bold: true, color: { argb: `FF${accent}` } }; row++; if (doc.subtitle) { sheet.getCell(row, 1).value = doc.subtitle; sheet.getCell(row, 1).font = { size: 11, color: { argb: 'FF6E6E6E' } }; row++; } row++; for (const [label, value] of doc.info || []) { sheet.getCell(row, 1).value = label; sheet.getCell(row, 1).font = { bold: true, size: 10, color: { argb: 'FF6E6E6E' } }; sheet.getCell(row, 2).value = value; sheet.getCell(row, 2).font = { size: 10 }; row++; } if (doc.kpis?.length) { row++; doc.kpis.forEach(([label, value], i) => { const col = 1 + i * 2; if (col > Math.max(widest, 8)) return; sheet.getCell(row, col).value = label; sheet.getCell(row, col).font = { size: 9, color: { argb: 'FF6E6E6E' } }; sheet.getCell(row + 1, col).value = value; sheet.getCell(row + 1, col).font = { size: 14, bold: true }; }); row += 2; } for (const table of doc.tables) { row++; if (table.title) { sheet.getCell(row, 1).value = table.title; sheet.getCell(row, 1).font = { bold: true, size: 12, color: { argb: `FF${accent}` } }; row++; } const headRow = sheet.getRow(row); table.columns.forEach((c, i) => { const cell = headRow.getCell(i + 1); cell.value = c.label; cell.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 10 }; cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: `FF${accent}` } }; cell.alignment = { horizontal: c.align === 'right' ? 'right' : 'left', vertical: 'middle' }; }); headRow.height = 20; row++; const first = row; table.rows.forEach((data, index) => { const r = sheet.getRow(row); table.columns.forEach((c, i) => writeXlsCell(r.getCell(i + 1), data[c.key], c, index % 2 === 1)); row++; }); if (table.totals) { const r = sheet.getRow(row); table.columns.forEach((c, i) => { const cell = r.getCell(i + 1); if ((c.type === 'hours' || c.type === 'money') && table.rows.length && table.totals[c.key] != null) { const letter = sheet.getColumn(i + 1).letter; const value = c.type === 'hours' ? table.totals[c.key] / 60 : table.totals[c.key]; cell.value = { formula: `SUM(${letter}${first}:${letter}${row - 1})`, result: value }; cell.numFmt = c.type === 'money' ? '#,##0.00 "€"' : '#,##0.00'; } else { cell.value = formatCell(table.totals[c.key], c.type); } cell.font = { bold: true }; cell.alignment = { horizontal: c.align === 'right' ? 'right' : 'left' }; cell.border = { top: { style: 'medium', color: { argb: `FF${accent}` } } }; }); row++; } } if (doc.signature && ex.show_signature) { row += 3; const half = Math.max(2, Math.floor(widest / 2)); for (const [col, label] of [ [1, ex.signature_left], [half + 1, ex.signature_right], ]) { const cell = sheet.getCell(row, col); cell.border = { top: { style: 'thin', color: { argb: 'FF9A9A9A' } } }; sheet.getCell(row, col + 1).border = { top: { style: 'thin', color: { argb: 'FF9A9A9A' } } }; cell.value = label; cell.font = { size: 9, color: { argb: 'FF6E6E6E' } }; } } return Buffer.from(await workbook.xlsx.writeBuffer()); } function xlEsc(value) { return String(value || '').replaceAll('&', '&&'); } function writeXlsCell(cell, value, column, zebra) { switch (column.type) { case 'hours': cell.value = value == null ? null : Math.round((value / 60) * 100) / 100; cell.numFmt = '#,##0.00'; break; case 'duration': cell.value = value == null ? null : durationDe(value); break; case 'money': cell.value = value == null ? null : Number(value); cell.numFmt = '#,##0.00 "€"'; break; case 'date': cell.value = value ? new Date(`${String(value).slice(0, 10)}T00:00:00Z`) : null; cell.numFmt = 'dd.mm.yyyy'; break; case 'percent': cell.value = value == null ? null : value / 100; cell.numFmt = '0%'; break; default: cell.value = value ?? ''; } cell.font = { size: 10 }; cell.alignment = { horizontal: column.align === 'right' ? 'right' : 'left', vertical: 'top', wrapText: column.type === 'text' || !column.type }; if (zebra) cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF4F7F1' } }; cell.border = { bottom: { style: 'hair', color: { argb: 'FFDCDCDC' } } }; } /* ------------------------------------------------------------------ PDF -- */ export function renderPdf(doc, settings) { const ex = settings.export; const accent = ex.accent || '#6BA04A'; const pdf = new PDFDocument({ size: 'A4', layout: doc.landscape ? 'landscape' : 'portrait', margins: { top: 40, bottom: 70, left: 42, right: 42 }, bufferPages: true, info: { Title: `${doc.title} ${doc.subtitle || ''}`.trim(), Author: settings.company_name || 'perinco' }, }); const chunks = []; pdf.on('data', (c) => chunks.push(c)); const done = new Promise((resolve) => pdf.on('end', () => resolve(Buffer.concat(chunks)))); const left = pdf.page.margins.left; const width = pdf.page.width - left - pdf.page.margins.right; const logo = logoBuffer(ex); const drawHeader = () => { pdf.image(logo, left, 30, { fit: [120, 46] }); pdf.fontSize(8).fillColor('#6E6E6E').font('Helvetica'); if (ex.subtitle) pdf.text(ex.subtitle, left, 34, { width, align: 'right' }); if (ex.header_right) pdf.text(ex.header_right, left, 46, { width, align: 'right' }); pdf.moveTo(left, 84).lineTo(left + width, 84).lineWidth(1.5).strokeColor(accent).stroke(); pdf.x = left; pdf.y = 100; }; drawHeader(); pdf.on('pageAdded', drawHeader); // Titel pdf.font('Helvetica-Bold').fontSize(20).fillColor('#1F1F1F').text(doc.title, left, pdf.y); if (doc.subtitle) pdf.font('Helvetica').fontSize(11).fillColor(accent).text(doc.subtitle); pdf.moveDown(0.8); // Infoblock zweispaltig if (doc.info?.length) { const colW = width / 2; const startY = pdf.y; let maxY = startY; doc.info.forEach(([label, value], i) => { const x = left + (i % 2) * colW; const y = startY + Math.floor(i / 2) * 30; pdf.font('Helvetica').fontSize(7.5).fillColor('#9A9A9A').text(label.toUpperCase(), x, y, { width: colW - 10, characterSpacing: 0.6 }); pdf.font('Helvetica-Bold').fontSize(10).fillColor('#1F1F1F').text(String(value ?? '–'), x, y + 10, { width: colW - 10, height: 14, ellipsis: true }); maxY = Math.max(maxY, y + 26); }); pdf.y = maxY + 8; } // Kennzahlen als Kacheln if (doc.kpis?.length) { const gap = 8; const boxW = (width - gap * (doc.kpis.length - 1)) / doc.kpis.length; const y = pdf.y; doc.kpis.forEach(([label, value], i) => { const x = left + i * (boxW + gap); pdf.roundedRect(x, y, boxW, 46, 6).fillColor('#F4F7F1').fill(); pdf.rect(x, y, 3, 46).fillColor(accent).fill(); pdf.font('Helvetica').fontSize(7.5).fillColor('#6E6E6E').text(label, x + 10, y + 8, { width: boxW - 16 }); pdf.font('Helvetica-Bold').fontSize(14).fillColor('#1F1F1F').text(String(value), x + 10, y + 21, { width: boxW - 16 }); }); pdf.y = y + 60; } for (const table of doc.tables) drawTable(pdf, table, { left, width, accent }); if (doc.signature && ex.show_signature) { if (pdf.y + 80 > pdf.page.height - pdf.page.margins.bottom) pdf.addPage(); const y = pdf.y + 45; const w = (width - 40) / 2; for (const [x, label] of [ [left, ex.signature_left], [left + w + 40, ex.signature_right], ]) { pdf.moveTo(x, y).lineTo(x + w, y).lineWidth(0.6).strokeColor('#9A9A9A').stroke(); pdf.font('Helvetica').fontSize(8).fillColor('#6E6E6E').text(label || '', x, y + 4, { width: w }); } } // Fuss auf jeder Seite, inkl. Seitenzahl. const range = pdf.bufferedPageRange(); for (let i = 0; i < range.count; i++) { pdf.switchToPage(range.start + i); const bottom = pdf.page.height - 48; const margin = pdf.page.margins.bottom; pdf.page.margins.bottom = 0; // sonst bricht text() unten auf eine neue Seite um pdf.moveTo(left, bottom - 8).lineTo(left + width, bottom - 8).lineWidth(0.5).strokeColor('#DCDCDC').stroke(); pdf.font('Helvetica').fontSize(7.5).fillColor('#6E6E6E'); const third = width / 3; pdf.text(ex.footer_left || '', left, bottom, { width: third, align: 'left', lineGap: 1 }); pdf.text(ex.footer_center || '', left + third, bottom, { width: third, align: 'center', lineGap: 1 }); pdf.text(`${ex.footer_right || ''}${ex.footer_right ? '\n' : ''}Seite ${i + 1} von ${range.count}`, left + 2 * third, bottom, { width: third, align: 'right', lineGap: 1, }); pdf.page.margins.bottom = margin; } pdf.end(); return done; } function drawTable(pdf, table, { left, width, accent }) { const fixed = table.columns.reduce((s, c) => s + (c.width || 0), 0); const flex = table.columns.filter((c) => !c.width).length; const widths = table.columns.map((c) => c.width || Math.max(40, (width - fixed) / Math.max(1, flex))); const scale = width / widths.reduce((a, b) => a + b, 0); const cols = widths.map((w) => w * scale); const bottomLimit = () => pdf.page.height - pdf.page.margins.bottom; const header = () => { const y = pdf.y; pdf.rect(left, y, width, 18).fillColor(accent).fill(); let x = left; table.columns.forEach((c, i) => { pdf.font('Helvetica-Bold').fontSize(8).fillColor('#FFFFFF').text(c.label, x + 5, y + 5, { width: cols[i] - 10, align: c.align || 'left', lineBreak: false, ellipsis: true, }); x += cols[i]; }); pdf.y = y + 18; }; if (pdf.y + 60 > bottomLimit()) pdf.addPage(); if (table.title) { pdf.moveDown(0.6); pdf.font('Helvetica-Bold').fontSize(11).fillColor('#1F1F1F').text(table.title, left, pdf.y); pdf.moveDown(0.3); } header(); const drawRow = (data, { zebra = false, bold = false } = {}) => { const texts = table.columns.map((c) => formatCell(data[c.key], c.type)); pdf.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8.5); const height = Math.max(16, ...texts.map((t, i) => pdf.heightOfString(t || ' ', { width: cols[i] - 10 }) + 8)); if (pdf.y + height > bottomLimit()) { pdf.addPage(); header(); } const y = pdf.y; if (zebra) pdf.rect(left, y, width, height).fillColor('#F4F7F1').fill(); if (bold) { pdf.moveTo(left, y).lineTo(left + width, y).lineWidth(1.2).strokeColor(accent).stroke(); } let x = left; texts.forEach((text, i) => { pdf.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8.5).fillColor('#1F1F1F').text(text, x + 5, y + 4, { width: cols[i] - 10, align: table.columns[i].align || 'left', }); x += cols[i]; }); if (!bold) pdf.moveTo(left, y + height).lineTo(left + width, y + height).lineWidth(0.3).strokeColor('#E4E4E4').stroke(); pdf.y = y + height; }; if (table.rows.length === 0) { pdf.font('Helvetica-Oblique').fontSize(9).fillColor('#9A9A9A').text('Keine Buchungen im Zeitraum.', left + 5, pdf.y + 6); pdf.y += 10; } table.rows.forEach((row, index) => drawRow(row, { zebra: index % 2 === 1 })); if (table.totals) drawRow(table.totals, { bold: true }); pdf.x = left; pdf.moveDown(0.8); } export async function sendDocument(res, doc, format, settings) { const safe = doc.filename.replace(/[^\w.\-äöüÄÖÜß ]+/g, '_'); let buffer; let type; if (format === 'pdf') { buffer = await renderPdf(doc, settings); type = 'application/pdf'; } else if (format === 'xlsx') { buffer = await renderXlsx(doc, settings); type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; } else { buffer = renderCsv(doc); type = 'text/csv; charset=utf-8'; } res.setHeader('Content-Type', type); res.setHeader( 'Content-Disposition', `attachment; filename="${safe.replace(/[^\x20-\x7e]/g, '_')}.${format}"; filename*=UTF-8''${encodeURIComponent(safe)}.${format}` ); res.send(buffer); }