// 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 };