Files
perinco-Pulse/server/src/export/render.js
T
schakal00783andClaude Opus 5 ef676a95f4 feat: perinco Pulse - Zeiterfassung fuer perinco
Erste Fassung, live unter https://pulse.perinco.de.

Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit
mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV.
Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM.

Fachlich:
- Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5
- Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder
  aufgebrauchtem Budget, Buchung nur auf den passenden Topf
- Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte
- Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend)
- SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 22:52:20 +02:00

451 lines
17 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}