715 lines
30 KiB
JavaScript
715 lines
30 KiB
JavaScript
// PDF-Generierung Service
|
||
|
||
const PDFDocument = require('pdfkit');
|
||
const QRCode = require('qrcode');
|
||
const { db } = require('../database');
|
||
const { formatDate, formatDateTime, formatHoursMin } = require('../helpers/utils');
|
||
const { getHolidaysWithNamesForDateRange } = 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, u.arbeitstage
|
||
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 (mit Namen für PDF-Ausgabe)
|
||
const arbeitstage = timesheet.arbeitstage || 5;
|
||
const fullDayHours = timesheet.wochenstunden > 0 && arbeitstage > 0 ? timesheet.wochenstunden / arbeitstage : 8;
|
||
getHolidaysWithNamesForDateRange(timesheet.week_start, timesheet.week_end)
|
||
.then(({ holidaySet, holidayNames }) => {
|
||
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 += fullDayHours;
|
||
}
|
||
}
|
||
return { holidaySet, holidayNames, holidayHours };
|
||
})
|
||
.catch(() => ({ holidaySet: new Set(), holidayNames: new Map(), holidayHours: 0 }))
|
||
.then(({ holidaySet, holidayNames, 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);
|
||
|
||
// Zeilen: Einträge + Feiertage ohne Eintrag, nach Datum sortiert
|
||
const allRows = [];
|
||
entries.forEach((e) => allRows.push({ type: 'entry', entry: e }));
|
||
holidaySet.forEach((dateStr) => {
|
||
if (!entriesByDate[dateStr]) {
|
||
allRows.push({ type: 'holiday', date: dateStr, holidayName: holidayNames.get(dateStr) || 'Feiertag' });
|
||
}
|
||
});
|
||
allRows.sort((a, b) => {
|
||
const dateA = a.type === 'entry' ? a.entry.date : a.date;
|
||
const dateB = b.type === 'entry' ? b.entry.date : b.date;
|
||
return dateA.localeCompare(dateB);
|
||
});
|
||
|
||
// Tabellen-Daten
|
||
doc.font('Helvetica');
|
||
let totalHours = 0;
|
||
let vacationHours = 0; // Urlaubsstunden für Überstunden-Berechnung
|
||
|
||
allRows.forEach((row) => {
|
||
if (row.type === 'holiday') {
|
||
y = doc.y;
|
||
x = 50;
|
||
// Feiertag am Wochenende: keine Tagesarbeitsstunden anzeigen
|
||
const holidayDay = new Date(row.date + 'T12:00:00').getDay();
|
||
const isWeekendHoliday = (holidayDay === 0 || holidayDay === 6);
|
||
const holidayHoursStr = isWeekendHoliday ? '0 h 0 min (Feiertag)' : formatHoursMin(fullDayHours) + ' (Feiertag)';
|
||
const rowData = [formatDate(row.date), '-', '-', '-', holidayHoursStr];
|
||
rowData.forEach((data, i) => {
|
||
doc.text(data, x, y, { width: colWidths[i], align: 'left' });
|
||
x += colWidths[i];
|
||
});
|
||
doc.moveDown(0.2);
|
||
doc.fontSize(9).font('Helvetica-Oblique');
|
||
doc.text('Feiertag: ' + row.holidayName, 60, doc.y, { width: 360 });
|
||
doc.fontSize(10);
|
||
doc.moveDown(0.5);
|
||
y = doc.y;
|
||
doc.moveTo(50, y).lineTo(430, y).stroke();
|
||
doc.moveDown(0.3);
|
||
return;
|
||
}
|
||
|
||
const entry = row.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 ? formatHoursMin(entry.total_hours) : '-'
|
||
];
|
||
|
||
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 += ` - ${formatHoursMin(activity.hours)}`;
|
||
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 (holidaySet.has(entry.date)) {
|
||
overtimeInfo.push('Feiertag: ' + (holidayNames.get(entry.date) || 'Feiertag'));
|
||
}
|
||
if (entry.overtime_taken_hours && parseFloat(entry.overtime_taken_hours) > 0) {
|
||
overtimeInfo.push(`Überstunden genommen: ${formatHoursMin(parseFloat(entry.overtime_taken_hours))}`);
|
||
}
|
||
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 += fullDayHours;
|
||
} else if (entry.vacation_type === 'half') {
|
||
vacationHours += fullDayHours / 2;
|
||
if (entry.total_hours) {
|
||
totalHours += entry.total_hours;
|
||
}
|
||
} else {
|
||
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 (8h pro Feiertag)
|
||
const totalHoursWithVacation = totalHours + vacationHours + holidayHours;
|
||
doc.text(`Gesamtstunden: ${formatHoursMin(totalHoursWithVacation)}`, 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: +${formatHoursMin(overtimeHours)}`, 50, doc.y);
|
||
} else if (overtimeHours < 0) {
|
||
doc.text(`Überstunden: ${formatHoursMin(overtimeHours)}`, 50, doc.y);
|
||
} else {
|
||
doc.text(`Überstunden: ${formatHoursMin(0)}`, 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, u.arbeitstage
|
||
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 arbeitstageBuf = timesheet.arbeitstage || 5;
|
||
const fullDayHoursBuf = timesheet.wochenstunden > 0 && arbeitstageBuf > 0 ? timesheet.wochenstunden / arbeitstageBuf : 8;
|
||
getHolidaysWithNamesForDateRange(timesheet.week_start, timesheet.week_end)
|
||
.then(({ holidaySet, holidayNames }) => {
|
||
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 += fullDayHoursBuf;
|
||
}
|
||
}
|
||
return { holidaySet, holidayNames, holidayHours };
|
||
})
|
||
.catch(() => ({ holidaySet: new Set(), holidayNames: new Map(), holidayHours: 0 }))
|
||
.then(({ holidaySet, holidayNames, 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);
|
||
|
||
// Zeilen: Einträge + Feiertage ohne Eintrag, nach Datum sortiert
|
||
const allRowsBuf = [];
|
||
entries.forEach((e) => allRowsBuf.push({ type: 'entry', entry: e }));
|
||
holidaySet.forEach((dateStr) => {
|
||
if (!entriesByDate[dateStr]) {
|
||
allRowsBuf.push({ type: 'holiday', date: dateStr, holidayName: holidayNames.get(dateStr) || 'Feiertag' });
|
||
}
|
||
});
|
||
allRowsBuf.sort((a, b) => {
|
||
const dateA = a.type === 'entry' ? a.entry.date : a.date;
|
||
const dateB = b.type === 'entry' ? b.entry.date : b.date;
|
||
return dateA.localeCompare(dateB);
|
||
});
|
||
|
||
// Tabellen-Daten
|
||
doc.font('Helvetica');
|
||
let totalHours = 0;
|
||
let vacationHours = 0;
|
||
|
||
allRowsBuf.forEach((row) => {
|
||
if (row.type === 'holiday') {
|
||
y = doc.y;
|
||
x = 50;
|
||
// Feiertag am Wochenende: keine Tagesarbeitsstunden anzeigen
|
||
const holidayDay = new Date(row.date + 'T12:00:00').getDay();
|
||
const isWeekendHoliday = (holidayDay === 0 || holidayDay === 6);
|
||
const holidayHoursStr = isWeekendHoliday ? '0 h 0 min (Feiertag)' : formatHoursMin(fullDayHoursBuf) + ' (Feiertag)';
|
||
const rowDataBuf = [formatDate(row.date), '-', '-', '-', holidayHoursStr];
|
||
rowDataBuf.forEach((data, i) => {
|
||
doc.text(data, x, y, { width: colWidths[i], align: 'left' });
|
||
x += colWidths[i];
|
||
});
|
||
doc.moveDown(0.2);
|
||
doc.fontSize(9).font('Helvetica-Oblique');
|
||
doc.text('Feiertag: ' + row.holidayName, 60, doc.y, { width: 360 });
|
||
doc.fontSize(10);
|
||
doc.moveDown(0.5);
|
||
y = doc.y;
|
||
doc.moveTo(50, y).lineTo(430, y).stroke();
|
||
doc.moveDown(0.3);
|
||
return;
|
||
}
|
||
|
||
const entry = row.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 ? formatHoursMin(entry.total_hours) : '-'
|
||
];
|
||
|
||
rowData.forEach((data, i) => {
|
||
doc.text(data, x, y, { width: colWidths[i], align: 'left' });
|
||
x += colWidths[i];
|
||
});
|
||
|
||
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
|
||
});
|
||
}
|
||
}
|
||
|
||
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 += ` - ${formatHoursMin(activity.hours)}`;
|
||
doc.fontSize(9).font('Helvetica');
|
||
doc.text(activityText, 70, doc.y, { width: 360 });
|
||
doc.moveDown(0.2);
|
||
});
|
||
doc.fontSize(10);
|
||
}
|
||
|
||
const overtimeInfo = [];
|
||
if (holidaySet.has(entry.date)) {
|
||
overtimeInfo.push('Feiertag: ' + (holidayNames.get(entry.date) || 'Feiertag'));
|
||
}
|
||
if (entry.overtime_taken_hours && parseFloat(entry.overtime_taken_hours) > 0) {
|
||
overtimeInfo.push(`Überstunden genommen: ${formatHoursMin(parseFloat(entry.overtime_taken_hours))}`);
|
||
}
|
||
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) => {
|
||
doc.text(info, 70, doc.y, { width: 360 });
|
||
doc.moveDown(0.15);
|
||
});
|
||
doc.fontSize(10);
|
||
}
|
||
|
||
if (entry.vacation_type === 'full') {
|
||
vacationHours += fullDayHoursBuf;
|
||
} else if (entry.vacation_type === 'half') {
|
||
vacationHours += fullDayHoursBuf / 2;
|
||
if (entry.total_hours) {
|
||
totalHours += entry.total_hours;
|
||
}
|
||
} else {
|
||
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');
|
||
const totalHoursWithVacation = totalHours + vacationHours + holidayHours;
|
||
doc.text(`Gesamtstunden: ${formatHoursMin(totalHoursWithVacation)}`, 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: +${formatHoursMin(overtimeHours)}`, 50, doc.y);
|
||
} else if (overtimeHours < 0) {
|
||
doc.text(`Überstunden: ${formatHoursMin(overtimeHours)}`, 50, doc.y);
|
||
} else {
|
||
doc.text(`Überstunden: ${formatHoursMin(0)}`, 50, doc.y);
|
||
}
|
||
|
||
doc.end();
|
||
});
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// Check-in/Check-out URL-Basis (wie im Dashboard-Frontend)
|
||
function getCheckinBaseUrl(req, callback) {
|
||
// Versuche Root URL aus Datenbank zu laden
|
||
db.get('SELECT checkin_root_url FROM system_options WHERE id = 1', (err, options) => {
|
||
if (err) {
|
||
console.warn('Fehler beim Laden der Root URL, verwende Fallback:', err);
|
||
}
|
||
|
||
let checkinBaseUrl = null;
|
||
if (options && options.checkin_root_url && options.checkin_root_url.trim() !== '') {
|
||
checkinBaseUrl = options.checkin_root_url.trim();
|
||
// Stelle sicher, dass kein trailing slash vorhanden ist
|
||
checkinBaseUrl = checkinBaseUrl.replace(/\/+$/, '');
|
||
}
|
||
|
||
// Fallback: Konstruiere URL aus Request (Port 3334 für Check-in)
|
||
if (!checkinBaseUrl) {
|
||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
||
checkinBaseUrl = baseUrl.replace(/:\d+$/, ':3334');
|
||
}
|
||
|
||
callback(checkinBaseUrl);
|
||
});
|
||
}
|
||
|
||
// PDF mit Check-in- und Check-out-QR-Codes (A4)
|
||
// urlType: 'internal' oder 'external'
|
||
async function generateCheckinCheckoutQRPDF(req, res, urlType = 'internal') {
|
||
const userId = req.session.userId;
|
||
if (!userId) {
|
||
return res.status(401).send('Nicht angemeldet');
|
||
}
|
||
|
||
let checkinBaseUrl;
|
||
|
||
if (urlType === 'external') {
|
||
// Externe URL: Lade aus Datenbank
|
||
checkinBaseUrl = await new Promise((resolve) => {
|
||
getCheckinBaseUrl(req, resolve);
|
||
});
|
||
} else {
|
||
// Interne URL: Konstruiere aus Request (Port 3334)
|
||
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
||
if (baseUrl.match(/:\d+$/)) {
|
||
checkinBaseUrl = baseUrl.replace(/:\d+$/, ':3334');
|
||
} else {
|
||
const url = new URL(baseUrl);
|
||
checkinBaseUrl = `${url.protocol}//${url.hostname}:3334`;
|
||
}
|
||
}
|
||
|
||
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 urlTypeLabel = urlType === 'external' ? 'Extern' : 'Intern';
|
||
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_${urlTypeLabel}_${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);
|
||
|
||
const title = urlType === 'external'
|
||
? 'Check-in / Check-out – Zeiterfassung (Externe URLs)'
|
||
: 'Check-in / Check-out – Zeiterfassung (Interne URLs)';
|
||
doc.fontSize(18).text(title, { align: 'center' });
|
||
doc.moveDown(1.5);
|
||
|
||
const displayFirst = (req.session.firstname || '').trim();
|
||
const displayLast = (req.session.lastname || '').trim();
|
||
const displayName = [displayFirst, displayLast].filter(Boolean).join(' ') || 'Mitarbeiter';
|
||
doc.fontSize(12).font('Helvetica').text('Mitarbeiter: ' + displayName, { align: 'center' });
|
||
doc.moveDown(0.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 };
|