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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user