Files
SDSStundenerfassung/services/pdf-service.js
Carsten Graf 563d4fc373 fixes
2026-01-23 18:33:33 +01:00

494 lines
20 KiB
JavaScript

// PDF-Generierung Service
const PDFDocument = require('pdfkit');
const { db } = require('../database');
const { formatDate, formatDateTime } = require('../helpers/utils');
// 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);
});
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 nur Arbeitsstunden
if (entry.total_hours) {
totalHours += entry.total_hours;
}
}
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');
doc.text(`Gesamtstunden: ${totalHours.toFixed(2)} h`, 50, doc.y);
// Überstunden berechnen und anzeigen
const wochenstunden = timesheet.wochenstunden || 0;
// Überstunden = (Tatsächliche Stunden + Urlaubsstunden) - Wochenstunden
const totalHoursWithVacation = totalHours + vacationHours;
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);
});
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');
doc.text(`Gesamtstunden: ${totalHours.toFixed(2)} h`, 50, doc.y);
const wochenstunden = timesheet.wochenstunden || 0;
// Überstunden = (Tatsächliche Stunden + Urlaubsstunden) - Wochenstunden
const totalHoursWithVacation = totalHours + vacationHours;
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();
});
});
});
}
module.exports = { generatePDF, generatePDFToBuffer };