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>
This commit is contained in:
schakal00783
2026-09-17 22:52:20 +02:00
co-authored by Claude Opus 5
commit ef676a95f4
104 changed files with 21030 additions and 0 deletions
+450
View File
@@ -0,0 +1,450 @@
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);
}