Files
SDSStundenerfassung/services/pdf-service.js
2026-01-30 18:59:00 +01:00

601 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
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.
// PDF-Generierung Service
const PDFDocument = require('pdfkit');
const QRCode = require('qrcode');
const { db } = require('../database');
const { formatDate, formatDateTime } = require('../helpers/utils');
const { getHolidaysForDateRange } = require('./feiertage-service');
// Kalenderwoche berechnen
function getCalendarWeek(dateStr) {
const date = new Date(dateStr);
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
return weekNo;
}
// PDF generieren
function generatePDF(timesheetId, req, res) {
db.get(`SELECT wt.*, u.firstname, u.lastname, u.username, u.wochenstunden
FROM weekly_timesheets wt
JOIN users u ON wt.user_id = u.id
WHERE wt.id = ?`, [timesheetId], (err, timesheet) => {
if (err || !timesheet) {
return res.status(404).send('Stundenzettel nicht gefunden');
}
// Hole Einträge die zum Zeitpunkt der Einreichung existierten
// Filtere nach submitted_at der Version, damit jede Version ihre eigenen Daten zeigt
// Logik: Wenn updated_at existiert, verwende das, sonst created_at, sonst zeige Eintrag (für alte Daten ohne Timestamps)
db.all(`SELECT * FROM timesheet_entries
WHERE user_id = ? AND date >= ? AND date <= ?
AND (
(updated_at IS NOT NULL AND updated_at <= ?) OR
(updated_at IS NULL AND created_at IS NOT NULL AND created_at <= ?) OR
(updated_at IS NULL AND created_at IS NULL)
)
ORDER BY date, updated_at DESC, id DESC`,
[timesheet.user_id, timesheet.week_start, timesheet.week_end,
timesheet.submitted_at, timesheet.submitted_at],
(err, allEntries) => {
if (err) {
return res.status(500).send('Fehler beim Abrufen der Einträge');
}
// Filtere auf neuesten Eintrag pro Tag (basierend auf updated_at oder id)
const entriesByDate = {};
(allEntries || []).forEach(entry => {
const existing = entriesByDate[entry.date];
if (!existing) {
entriesByDate[entry.date] = entry;
} else {
// Vergleiche updated_at (falls vorhanden) oder id (höhere ID = neuer)
const existingTime = existing.updated_at ? new Date(existing.updated_at).getTime() : 0;
const currentTime = entry.updated_at ? new Date(entry.updated_at).getTime() : 0;
if (currentTime > existingTime || (currentTime === existingTime && entry.id > existing.id)) {
entriesByDate[entry.date] = entry;
}
}
});
// Konvertiere zu Array und sortiere nach Datum
const entries = Object.values(entriesByDate).sort((a, b) => {
return new Date(a.date) - new Date(b.date);
});
// Feiertage für die Woche laden (8h pro Feiertag; Arbeit an Feiertag = Überstunden)
getHolidaysForDateRange(timesheet.week_start, timesheet.week_end)
.then((holidaySet) => {
let holidayHours = 0;
const start = new Date(timesheet.week_start);
const end = new Date(timesheet.week_end);
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const day = d.getDay();
if (day >= 1 && day <= 5) {
const dateStr = d.toISOString().split('T')[0];
if (holidaySet.has(dateStr)) holidayHours += 8;
}
}
return { holidaySet, holidayHours };
})
.catch(() => ({ holidaySet: new Set(), holidayHours: 0 }))
.then(({ holidaySet, holidayHours }) => {
const doc = new PDFDocument({ margin: 50 });
// Prüfe ob inline angezeigt werden soll (für Vorschau)
const inline = req.query.inline === 'true';
// Dateinamen generieren: Stundenzettel_KWxxx_NameMitarbeiter_heutigesDatum.pdf
const calendarWeek = getCalendarWeek(timesheet.week_start);
const today = new Date();
const todayStr = today.getFullYear() + '-' +
String(today.getMonth() + 1).padStart(2, '0') + '-' +
String(today.getDate()).padStart(2, '0');
const employeeName = `${timesheet.firstname}${timesheet.lastname}`.replace(/\s+/g, '');
const filename = `Stundenzettel_KW${String(calendarWeek).padStart(2, '0')}_${employeeName}_${todayStr}.pdf`;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('X-Content-Type-Options', 'nosniff');
if (inline) {
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
// Zusätzliche Header für iframe-Unterstützung
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
} else {
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
// Marker setzen, dass PDF heruntergeladen wurde (nur bei Download, nicht bei Vorschau)
const downloadedBy = req.session.userId; // User der die PDF herunterlädt
console.log('PDF Download - User ID:', downloadedBy, 'Timesheet ID:', timesheetId);
if (downloadedBy) {
db.run(`UPDATE weekly_timesheets
SET pdf_downloaded_at = CURRENT_TIMESTAMP,
pdf_downloaded_by = ?
WHERE id = ?`,
[downloadedBy, timesheetId],
(err) => {
if (err) {
console.error('Fehler beim Setzen des Download-Markers:', err);
} else {
console.log('Download-Marker erfolgreich gesetzt für User:', downloadedBy);
}
// Fehler wird ignoriert, damit PDF trotzdem generiert wird
});
} else {
console.warn('PDF Download - Keine User ID in Session gefunden!');
}
}
doc.pipe(res);
// Header (Kalenderwoche wurde bereits oben berechnet)
doc.fontSize(20).text(`Stundenzettel für KW ${calendarWeek}`, { align: 'center' });
doc.moveDown();
// Mitarbeiter-Info
doc.fontSize(12);
doc.text(`Mitarbeiter: ${timesheet.firstname} ${timesheet.lastname}`);
doc.text(`Zeitraum: ${formatDate(timesheet.week_start)} - ${formatDate(timesheet.week_end)}`);
doc.text(`Eingereicht am: ${formatDateTime(timesheet.submitted_at)}`);
doc.moveDown();
// Tabelle - Basis-Informationen
const tableTop = doc.y;
const colWidths = [80, 80, 80, 60, 80];
const headers = ['Datum', 'Start', 'Ende', 'Pause', 'Stunden'];
// Tabellen-Header
doc.fontSize(10).font('Helvetica-Bold');
let x = 50;
headers.forEach((header, i) => {
doc.text(header, x, tableTop, { width: colWidths[i], align: 'left' });
x += colWidths[i];
});
doc.moveDown();
let y = doc.y;
doc.moveTo(50, y).lineTo(430, y).stroke();
doc.moveDown(0.5);
// Tabellen-Daten
doc.font('Helvetica');
let totalHours = 0;
let vacationHours = 0; // Urlaubsstunden für Überstunden-Berechnung
entries.forEach((entry) => {
y = doc.y;
x = 50;
// Basis-Zeile
const rowData = [
formatDate(entry.date),
entry.start_time || '-',
entry.end_time || '-',
entry.break_minutes ? `${entry.break_minutes} min` : '-',
entry.total_hours ? entry.total_hours.toFixed(2) + ' h' : '-'
];
rowData.forEach((data, i) => {
doc.text(data, x, y, { width: colWidths[i], align: 'left' });
x += colWidths[i];
});
// Tätigkeiten sammeln
const activities = [];
for (let i = 1; i <= 5; i++) {
const desc = entry[`activity${i}_desc`];
const hours = entry[`activity${i}_hours`];
const projectNumber = entry[`activity${i}_project_number`];
if (desc && desc.trim() && hours > 0) {
activities.push({
desc: desc.trim(),
hours: parseFloat(hours),
projectNumber: projectNumber ? projectNumber.trim() : null
});
}
}
// Tätigkeiten anzeigen
if (activities.length > 0) {
doc.moveDown(0.3);
doc.fontSize(9).font('Helvetica-Oblique');
doc.text('Tätigkeiten:', 60, doc.y, { width: 380 });
doc.moveDown(0.2);
activities.forEach((activity, idx) => {
let activityText = `${idx + 1}. ${activity.desc}`;
if (activity.projectNumber) {
activityText += ` (Projekt: ${activity.projectNumber})`;
}
activityText += ` - ${activity.hours.toFixed(2)} h`;
doc.fontSize(9).font('Helvetica');
doc.text(activityText, 70, doc.y, { width: 360 });
doc.moveDown(0.2);
});
doc.fontSize(10);
}
// Überstunden und Urlaub anzeigen
const overtimeInfo = [];
if (entry.overtime_taken_hours && parseFloat(entry.overtime_taken_hours) > 0) {
overtimeInfo.push(`Überstunden genommen: ${parseFloat(entry.overtime_taken_hours).toFixed(2)} h`);
}
if (entry.vacation_type) {
const vacationText = entry.vacation_type === 'full' ? 'Ganzer Tag' : 'Halber Tag';
overtimeInfo.push(`Urlaub: ${vacationText}`);
}
if (overtimeInfo.length > 0) {
doc.moveDown(0.2);
doc.fontSize(9).font('Helvetica-Oblique');
overtimeInfo.forEach((info, idx) => {
doc.text(info, 70, doc.y, { width: 360 });
doc.moveDown(0.15);
});
doc.fontSize(10);
}
// Urlaub hat Priorität - wenn Urlaub, zähle nur Urlaubsstunden, nicht zusätzlich Arbeitsstunden
if (entry.vacation_type === 'full') {
vacationHours += 8; // Ganzer Tag = 8 Stunden
// Bei vollem Tag Urlaub werden keine Arbeitsstunden gezählt
} else if (entry.vacation_type === 'half') {
vacationHours += 4; // Halber Tag = 4 Stunden
// Bei halbem Tag Urlaub können noch Arbeitsstunden vorhanden sein
if (entry.total_hours) {
totalHours += entry.total_hours;
}
} else {
// Kein Urlaub - zähle Arbeitsstunden; an Feiertagen zählt jede Stunde als Überstunde (8h Feiertag + Arbeit)
if (entry.total_hours) {
totalHours += entry.total_hours;
}
}
// Feiertag: 8h sind über holidayHours erfasst; gearbeitete Stunden oben bereits zu totalHours addiert
doc.moveDown(0.5);
// Trennlinie zwischen Einträgen
y = doc.y;
doc.moveTo(50, y).lineTo(430, y).stroke();
doc.moveDown(0.3);
});
// Summe
y = doc.y;
doc.moveTo(50, y).lineTo(550, y).stroke();
doc.moveDown(0.5);
doc.font('Helvetica-Bold');
// Gesamtstunden = Arbeitsstunden + Urlaubsstunden + Feiertagsstunden (8h pro Feiertag)
const totalHoursWithVacation = totalHours + vacationHours + holidayHours;
doc.text(`Gesamtstunden: ${totalHoursWithVacation.toFixed(2)} h`, 50, doc.y);
// Überstunden berechnen und anzeigen
const wochenstunden = timesheet.wochenstunden || 0;
// Überstunden = (Tatsächliche Stunden + Urlaubsstunden) - Wochenstunden
const overtimeHours = totalHoursWithVacation - wochenstunden;
doc.moveDown(0.3);
doc.font('Helvetica-Bold');
if (overtimeHours > 0) {
doc.text(`Überstunden: +${overtimeHours.toFixed(2)} h`, 50, doc.y);
} else if (overtimeHours < 0) {
doc.text(`Überstunden: ${overtimeHours.toFixed(2)} h`, 50, doc.y);
} else {
doc.text(`Überstunden: 0.00 h`, 50, doc.y);
}
doc.end();
});
});
});
}
// PDF als Buffer generieren (für ZIP-Downloads)
function generatePDFToBuffer(timesheetId, req) {
return new Promise((resolve, reject) => {
db.get(`SELECT wt.*, u.firstname, u.lastname, u.username, u.wochenstunden
FROM weekly_timesheets wt
JOIN users u ON wt.user_id = u.id
WHERE wt.id = ?`, [timesheetId], (err, timesheet) => {
if (err || !timesheet) {
return reject(new Error('Stundenzettel nicht gefunden'));
}
// Hole Einträge die zum Zeitpunkt der Einreichung existierten
db.all(`SELECT * FROM timesheet_entries
WHERE user_id = ? AND date >= ? AND date <= ?
AND (
(updated_at IS NOT NULL AND updated_at <= ?) OR
(updated_at IS NULL AND created_at IS NOT NULL AND created_at <= ?) OR
(updated_at IS NULL AND created_at IS NULL)
)
ORDER BY date, updated_at DESC, id DESC`,
[timesheet.user_id, timesheet.week_start, timesheet.week_end,
timesheet.submitted_at, timesheet.submitted_at],
(err, allEntries) => {
if (err) {
return reject(new Error('Fehler beim Abrufen der Einträge'));
}
// Filtere auf neuesten Eintrag pro Tag
const entriesByDate = {};
(allEntries || []).forEach(entry => {
const existing = entriesByDate[entry.date];
if (!existing) {
entriesByDate[entry.date] = entry;
} else {
const existingTime = existing.updated_at ? new Date(existing.updated_at).getTime() : 0;
const currentTime = entry.updated_at ? new Date(entry.updated_at).getTime() : 0;
if (currentTime > existingTime || (currentTime === existingTime && entry.id > existing.id)) {
entriesByDate[entry.date] = entry;
}
}
});
const entries = Object.values(entriesByDate).sort((a, b) => {
return new Date(a.date) - new Date(b.date);
});
getHolidaysForDateRange(timesheet.week_start, timesheet.week_end)
.then((holidaySet) => {
let holidayHours = 0;
const start = new Date(timesheet.week_start);
const end = new Date(timesheet.week_end);
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const day = d.getDay();
if (day >= 1 && day <= 5) {
const dateStr = d.toISOString().split('T')[0];
if (holidaySet.has(dateStr)) holidayHours += 8;
}
}
return { holidaySet, holidayHours };
})
.catch(() => ({ holidaySet: new Set(), holidayHours: 0 }))
.then(({ holidayHours }) => {
const doc = new PDFDocument({ margin: 50 });
const buffers = [];
doc.on('data', buffers.push.bind(buffers));
doc.on('end', () => {
const pdfBuffer = Buffer.concat(buffers);
resolve(pdfBuffer);
});
doc.on('error', reject);
// Header
const calendarWeek = getCalendarWeek(timesheet.week_start);
doc.fontSize(20).text(`Stundenzettel für KW ${calendarWeek}`, { align: 'center' });
doc.moveDown();
// Mitarbeiter-Info
doc.fontSize(12);
doc.text(`Mitarbeiter: ${timesheet.firstname} ${timesheet.lastname}`);
doc.text(`Zeitraum: ${formatDate(timesheet.week_start)} - ${formatDate(timesheet.week_end)}`);
doc.text(`Eingereicht am: ${formatDateTime(timesheet.submitted_at)}`);
doc.moveDown();
// Tabelle - Basis-Informationen
const tableTop = doc.y;
const colWidths = [80, 80, 80, 60, 80];
const headers = ['Datum', 'Start', 'Ende', 'Pause', 'Stunden'];
// Tabellen-Header
doc.fontSize(10).font('Helvetica-Bold');
let x = 50;
headers.forEach((header, i) => {
doc.text(header, x, tableTop, { width: colWidths[i], align: 'left' });
x += colWidths[i];
});
doc.moveDown();
let y = doc.y;
doc.moveTo(50, y).lineTo(430, y).stroke();
doc.moveDown(0.5);
// Tabellen-Daten
doc.font('Helvetica');
let totalHours = 0;
let vacationHours = 0;
entries.forEach((entry) => {
y = doc.y;
x = 50;
const rowData = [
formatDate(entry.date),
entry.start_time || '-',
entry.end_time || '-',
entry.break_minutes ? `${entry.break_minutes} min` : '-',
entry.total_hours ? entry.total_hours.toFixed(2) + ' h' : '-'
];
rowData.forEach((data, i) => {
doc.text(data, x, y, { width: colWidths[i], align: 'left' });
x += colWidths[i];
});
// Tätigkeiten sammeln
const activities = [];
for (let i = 1; i <= 5; i++) {
const desc = entry[`activity${i}_desc`];
const hours = entry[`activity${i}_hours`];
const projectNumber = entry[`activity${i}_project_number`];
if (desc && desc.trim() && hours > 0) {
activities.push({
desc: desc.trim(),
hours: parseFloat(hours),
projectNumber: projectNumber ? projectNumber.trim() : null
});
}
}
// Tätigkeiten anzeigen
if (activities.length > 0) {
doc.moveDown(0.3);
doc.fontSize(9).font('Helvetica-Oblique');
doc.text('Tätigkeiten:', 60, doc.y, { width: 380 });
doc.moveDown(0.2);
activities.forEach((activity, idx) => {
let activityText = `${idx + 1}. ${activity.desc}`;
if (activity.projectNumber) {
activityText += ` (Projekt: ${activity.projectNumber})`;
}
activityText += ` - ${activity.hours.toFixed(2)} h`;
doc.fontSize(9).font('Helvetica');
doc.text(activityText, 70, doc.y, { width: 360 });
doc.moveDown(0.2);
});
doc.fontSize(10);
}
// Überstunden und Urlaub anzeigen
const overtimeInfo = [];
if (entry.overtime_taken_hours && parseFloat(entry.overtime_taken_hours) > 0) {
overtimeInfo.push(`Überstunden genommen: ${parseFloat(entry.overtime_taken_hours).toFixed(2)} h`);
}
if (entry.vacation_type) {
const vacationText = entry.vacation_type === 'full' ? 'Ganzer Tag' : 'Halber Tag';
overtimeInfo.push(`Urlaub: ${vacationText}`);
}
if (overtimeInfo.length > 0) {
doc.moveDown(0.2);
doc.fontSize(9).font('Helvetica-Oblique');
overtimeInfo.forEach((info, idx) => {
doc.text(info, 70, doc.y, { width: 360 });
doc.moveDown(0.15);
});
doc.fontSize(10);
}
// Urlaub hat Priorität - wenn Urlaub, zähle nur Urlaubsstunden, nicht zusätzlich Arbeitsstunden
if (entry.vacation_type === 'full') {
vacationHours += 8; // Ganzer Tag = 8 Stunden
// Bei vollem Tag Urlaub werden keine Arbeitsstunden gezählt
} else if (entry.vacation_type === 'half') {
vacationHours += 4; // Halber Tag = 4 Stunden
// Bei halbem Tag Urlaub können noch Arbeitsstunden vorhanden sein
if (entry.total_hours) {
totalHours += entry.total_hours;
}
} else {
// Kein Urlaub - zähle nur Arbeitsstunden
if (entry.total_hours) {
totalHours += entry.total_hours;
}
}
doc.moveDown(0.5);
y = doc.y;
doc.moveTo(50, y).lineTo(430, y).stroke();
doc.moveDown(0.3);
});
// Summe
y = doc.y;
doc.moveTo(50, y).lineTo(550, y).stroke();
doc.moveDown(0.5);
doc.font('Helvetica-Bold');
// Gesamtstunden = Arbeitsstunden + Urlaubsstunden + Feiertagsstunden
const totalHoursWithVacation = totalHours + vacationHours + holidayHours;
doc.text(`Gesamtstunden: ${totalHoursWithVacation.toFixed(2)} h`, 50, doc.y);
const wochenstunden = timesheet.wochenstunden || 0;
const overtimeHours = totalHoursWithVacation - wochenstunden;
doc.moveDown(0.3);
doc.font('Helvetica-Bold');
if (overtimeHours > 0) {
doc.text(`Überstunden: +${overtimeHours.toFixed(2)} h`, 50, doc.y);
} else if (overtimeHours < 0) {
doc.text(`Überstunden: ${overtimeHours.toFixed(2)} h`, 50, doc.y);
} else {
doc.text(`Überstunden: 0.00 h`, 50, doc.y);
}
doc.end();
});
});
});
});
}
// Check-in/Check-out URL-Basis (wie im Dashboard-Frontend)
function getCheckinBaseUrl(req) {
const baseUrl = `${req.protocol}://${req.get('host')}`;
return baseUrl.replace(/:\d+$/, ':3334');
}
// PDF mit Check-in- und Check-out-QR-Codes (A4)
async function generateCheckinCheckoutQRPDF(req, res) {
const userId = req.session.userId;
if (!userId) {
return res.status(401).send('Nicht angemeldet');
}
const checkinBaseUrl = getCheckinBaseUrl(req);
const checkinUrl = `${checkinBaseUrl}/api/checkin/${userId}`;
const checkoutUrl = `${checkinBaseUrl}/api/checkout/${userId}`;
try {
const [checkinQRBuffer, checkoutQRBuffer] = await Promise.all([
QRCode.toBuffer(checkinUrl, { type: 'png', width: 400, margin: 1 }),
QRCode.toBuffer(checkoutUrl, { type: 'png', width: 400, margin: 1 })
]);
const firstname = (req.session.firstname || '').replace(/\s+/g, '');
const lastname = (req.session.lastname || '').replace(/\s+/g, '');
const namePart = [firstname, lastname].filter(Boolean).join('_') || 'User';
const today = new Date();
const dateStr = today.getFullYear() + '-' +
String(today.getMonth() + 1).padStart(2, '0') + '-' +
String(today.getDate()).padStart(2, '0');
const filename = `Check-in_Check-out_QR_${namePart}_${dateStr}.pdf`;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
const doc = new PDFDocument({ size: 'A4', margin: 50 });
doc.pipe(res);
const pageWidth = 595.28 - 100;
const qrSize = 160;
const gap = 40;
const left1 = 50 + (pageWidth / 2 - qrSize - gap / 2);
const left2 = 50 + (pageWidth / 2 + gap / 2);
doc.fontSize(18).text('Check-in / Check-out Zeiterfassung', { align: 'center' });
doc.moveDown(1.5);
const topY = doc.y;
doc.image(checkinQRBuffer, left1, topY, { width: qrSize, height: qrSize });
doc.image(checkoutQRBuffer, left2, topY, { width: qrSize, height: qrSize });
doc.fontSize(12).font('Helvetica-Bold');
doc.text('Check-in', left1, topY + qrSize + 8, { width: qrSize, align: 'center' });
doc.text('Check-out', left2, topY + qrSize + 8, { width: qrSize, align: 'center' });
doc.moveDown(2);
doc.font('Helvetica').fontSize(10);
doc.text('Scannen Sie den jeweiligen QR-Code zum Erfassen von Arbeitsbeginn (Check-in) bzw. Arbeitsende (Check-out).', 50, doc.y, { width: pageWidth, align: 'center' });
doc.end();
} catch (err) {
console.error('Fehler beim Generieren des QR-PDFs:', err);
if (!res.headersSent) {
res.status(500).send('Fehler beim Erstellen des PDFs');
}
}
}
module.exports = { generatePDF, generatePDFToBuffer, generateCheckinCheckoutQRPDF };