diff --git a/backend/package.json b/backend/package.json index 61978c8..7caee5e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,8 +27,7 @@ "helmet": "8.1.0", "express-validator": "7.3.0", "exceljs": "4.4.0", - "pdfkit": "0.12.3", - "@types/pdfkit": "^0.12.3" + "playwright": "^1.37.0" }, "devDependencies": { "@types/bcryptjs": "^2.4.2", diff --git a/backend/src/controllers/shiftPlanController.ts b/backend/src/controllers/shiftPlanController.ts index 3da44bc..7b61ce5 100644 --- a/backend/src/controllers/shiftPlanController.ts +++ b/backend/src/controllers/shiftPlanController.ts @@ -9,7 +9,7 @@ import { import { AuthRequest } from '../middleware/auth.js'; import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults.js'; import ExcelJS from 'exceljs'; -import PDFDocument from 'pdfkit'; +import { chromium } from 'playwright'; async function getPlanWithDetails(planId: string) { const plan = await db.get(` @@ -1298,12 +1298,11 @@ export const exportShiftPlanToExcel = async (req: Request, res: Response): Promi }; export const exportShiftPlanToPDF = async (req: Request, res: Response): Promise => { + let browser; try { const { id } = req.params; - console.log('📄 Starting PDF export for plan:', id); - // Check if plan exists const plan = await getShiftPlanById(id); if (!plan) { res.status(404).json({ error: 'Shift plan not found' }); @@ -1315,235 +1314,419 @@ export const exportShiftPlanToPDF = async (req: Request, res: Response): Promise return; } - // Create PDF document - const doc = new PDFDocument({ margin: 50 }); - - // Set response headers - res.setHeader('Content-Type', 'application/pdf'); - res.setHeader('Content-Disposition', `attachment; filename="Schichtplan_${plan.name}_${new Date().toISOString().split('T')[0]}.pdf"`); - - // Pipe PDF to response - doc.pipe(res); - - // Add title - doc.fontSize(20).font('Helvetica-Bold').text(`Schichtplan: ${plan.name}`, 50, 50); - doc.fontSize(12).font('Helvetica').text(`Erstellt am: ${new Date().toLocaleDateString('de-DE')}`, 50, 80); - - // Plan summary - let yPosition = 120; - doc.fontSize(14).font('Helvetica-Bold').text('Plan Informationen', 50, yPosition); - yPosition += 30; - - doc.fontSize(10).font('Helvetica'); - doc.text(`Plan Name: ${plan.name}`, 50, yPosition); - yPosition += 20; - - if (plan.description) { - doc.text(`Beschreibung: ${plan.description}`, 50, yPosition); - yPosition += 20; - } - - doc.text(`Zeitraum: ${plan.startDate} bis ${plan.endDate}`, 50, yPosition); - yPosition += 20; - doc.text(`Status: ${plan.status}`, 50, yPosition); - yPosition += 20; - doc.text(`Erstellt von: ${plan.created_by_name || 'Unbekannt'}`, 50, yPosition); - yPosition += 20; - doc.text(`Erstellt am: ${plan.createdAt}`, 50, yPosition); - yPosition += 20; - doc.text(`Anzahl Schichten: ${plan.scheduledShifts?.length || 0}`, 50, yPosition); - yPosition += 20; - doc.text(`Anzahl Mitarbeiter: ${plan.employees?.length || 0}`, 50, yPosition); - yPosition += 40; - - // Get timetable data for PDF + // Get timetable data (same as Excel) const timetableData = getTimetableDataForExport(plan); const { days, allTimeSlots } = timetableData; - // Add timetable section - doc.addPage(); - doc.fontSize(16).font('Helvetica-Bold').text('Schichtplan Timetable', 50, 50); - - let currentY = 80; - - // Define column widths - const timeSlotColWidth = 100; - const dayColWidth = (500 - timeSlotColWidth) / days.length; - - // Table headers - doc.fontSize(10).font('Helvetica-Bold'); - - // Time slot header - doc.rect(50, currentY, timeSlotColWidth, 20).fillAndStroke('#2c3e50', '#2c3e50'); - doc.fillColor('white').text('Schicht (Zeit)', 55, currentY + 5, { width: timeSlotColWidth - 10, align: 'left' }); - - // Day headers - days.forEach((day, index) => { - const xPos = 50 + timeSlotColWidth + (index * dayColWidth); - doc.rect(xPos, currentY, dayColWidth, 20).fillAndStroke('#2c3e50', '#2c3e50'); - doc.fillColor('white').text(day.name, xPos + 5, currentY + 5, { width: dayColWidth - 10, align: 'center' }); - }); - - doc.fillColor('black'); - currentY += 20; - - // Time slot rows - allTimeSlots.forEach((timeSlot, rowIndex) => { - // Check if we need a new page - if (currentY > 650) { - doc.addPage(); - currentY = 50; - - // Redraw headers on new page - doc.fontSize(10).font('Helvetica-Bold'); - doc.rect(50, currentY, timeSlotColWidth, 20).fillAndStroke('#2c3e50', '#2c3e50'); - doc.fillColor('white').text('Schicht (Zeit)', 55, currentY + 5, { width: timeSlotColWidth - 10, align: 'left' }); - - days.forEach((day, index) => { - const xPos = 50 + timeSlotColWidth + (index * dayColWidth); - doc.rect(xPos, currentY, dayColWidth, 20).fillAndStroke('#2c3e50', '#2c3e50'); - doc.fillColor('white').text(day.name, xPos + 5, currentY + 5, { width: dayColWidth - 10, align: 'center' }); - }); - - doc.fillColor('black'); - currentY += 20; - } - - // Alternate row background - const rowBgColor = rowIndex % 2 === 0 ? '#f8f9fa' : 'white'; - - // Time slot cell - doc.rect(50, currentY, timeSlotColWidth, 40).fillAndStroke(rowBgColor, '#dee2e6'); - doc.fontSize(9).font('Helvetica-Bold').text(timeSlot.name, 55, currentY + 5, { width: timeSlotColWidth - 10 }); - doc.fontSize(8).font('Helvetica').text(`${timeSlot.startTime} - ${timeSlot.endTime}`, 55, currentY + 18, { width: timeSlotColWidth - 10 }); - - // Day cells - days.forEach((day, colIndex) => { - const xPos = 50 + timeSlotColWidth + (colIndex * dayColWidth); - const shift = timeSlot.shiftsByDay[day.id]; - - doc.rect(xPos, currentY, dayColWidth, 40).fillAndStroke(rowBgColor, '#dee2e6'); - - if (!shift) { - doc.fontSize(8).font('Helvetica-Oblique').fillColor('#ccc').text('Keine Schicht', xPos + 5, currentY + 15, { - width: dayColWidth - 10, - align: 'center' - }); - } else { - // Get assignments for this time slot and day - const scheduledShift = plan.scheduledShifts?.find((scheduled: any) => { - const scheduledDayOfWeek = getDayOfWeek(scheduled.date); - return scheduledDayOfWeek === day.id && - scheduled.timeSlotId === timeSlot.id; - }); - - doc.fillColor('black').fontSize(8).font('Helvetica'); - - if (scheduledShift && scheduledShift.assignedEmployees.length > 0) { - let textY = currentY + 5; - scheduledShift.assignedEmployees.forEach((empId: string, empIndex: number) => { - if (textY < currentY + 35) { // Don't overflow cell - const employee = plan.employees?.find((emp: any) => emp.id === empId); - if (employee) { - let roleIndicator = ''; - if (employee.isTrainee) { - roleIndicator = ' (T)'; - doc.fillColor('#cda8f0'); // Trainee color - } else if (employee.employee_type === 'manager') { - roleIndicator = ' (M)'; - doc.fillColor('#CC0000'); // Manager color - } else { - doc.fillColor('#642ab5'); // Regular personnel color - } - - const name = `${employee.firstname} ${employee.lastname}${roleIndicator}`; - doc.text(name, xPos + 5, textY, { width: dayColWidth - 10, align: 'left' }); - textY += 10; - } - } - }); - doc.fillColor('black'); - } else { - // Show required count like in React component - const shiftsForSlot = plan.shifts?.filter((s: any) => - s.dayOfWeek === day.id && - s.timeSlotId === timeSlot.id - ) || []; - const totalRequired = shiftsForSlot.reduce((sum: number, s: any) => sum + s.requiredEmployees, 0); - const displayText = totalRequired === 0 ? '-' : `0/${totalRequired}`; - - doc.fillColor('#666').fontSize(9).font('Helvetica-Oblique') - .text(displayText, xPos + 5, currentY + 15, { width: dayColWidth - 10, align: 'center' }); - doc.fillColor('black'); - } - } - }); - - currentY += 40; - }); - - // Add employee overview page - doc.addPage(); - doc.fontSize(16).font('Helvetica-Bold').text('Mitarbeiterübersicht', 50, 50); - - currentY = 80; - - // Table headers - doc.fontSize(10).font('Helvetica-Bold'); - doc.text('Name', 50, currentY); - doc.text('E-Mail', 200, currentY); - doc.text('Rolle', 350, currentY); - doc.text('Typ', 450, currentY); - currentY += 15; - - // Horizontal line - doc.moveTo(50, currentY).lineTo(550, currentY).stroke(); - currentY += 10; - - doc.fontSize(9).font('Helvetica'); - - plan.employees?.forEach((employee: any) => { - if (currentY > 700) { - doc.addPage(); - currentY = 50; - // Re-add headers - doc.fontSize(10).font('Helvetica-Bold'); - doc.text('Name', 50, currentY); - doc.text('E-Mail', 200, currentY); - doc.text('Rolle', 350, currentY); - doc.text('Typ', 450, currentY); - currentY += 25; - } - - doc.text(`${employee.firstname} ${employee.lastname}`, 50, currentY); - doc.text(employee.email, 200, currentY, { width: 140 }); - doc.text(employee.roles?.join(', ') || 'Benutzer', 350, currentY, { width: 90 }); - doc.text(employee.employeeType, 450, currentY); - - currentY += 20; - }); - - // Add footer to each page - const pages = doc.bufferedPageRange(); - for (let i = 0; i < pages.count; i++) { - doc.switchToPage(i); - - doc.fontSize(8).font('Helvetica'); - doc.text( - `Seite ${i + 1} von ${pages.count} • Erstellt am: ${new Date().toLocaleString('de-DE')} • Schichtplaner System`, - 50, - 800, - { align: 'center', width: 500 } - ); + // Generate HTML content + const html = ` + + + + + + Schichtplan - ${plan.name} + + + +
+

Schichtplan: ${plan.name}

+
Erstellt am: ${new Date().toLocaleDateString('de-DE', { + year: 'numeric', + month: 'long', + day: 'numeric' + })}
+
- // Finalize PDF - doc.end(); +
+

Plan Informationen

+
+
+ Plan Name: + ${plan.name} +
+
+ Status: + ${plan.status} +
+
+ Beschreibung: + ${plan.description || 'Keine'} +
+
+ Erstellt von: + ${plan.created_by_name || 'Unbekannt'} +
+
+ Zeitraum: + ${plan.startDate} bis ${plan.endDate} +
+
+ Erstellt am: + ${new Date(plan.createdAt).toLocaleString('de-DE')} +
+
+ Anzahl Schichten: + ${plan.scheduledShifts?.length || 0} +
+
+ Anzahl Mitarbeiter: + ${plan.employees?.length || 0} +
+
+
+ +
+

Schichtplan Timetable

+ + + + + ${days.map(day => ``).join('')} + + + + ${allTimeSlots.map(timeSlot => ` + + + ${days.map(day => { + const shift = timeSlot.shiftsByDay[day.id]; + + if (!shift) { + return ''; + } + + const scheduledShift = plan.scheduledShifts?.find((s: any) => + getDayOfWeek(s.date) === day.id && s.timeSlotId === timeSlot.id + ); + + if (scheduledShift && scheduledShift.assignedEmployees?.length > 0) { + const employeeItems = scheduledShift.assignedEmployees.map((empId: string) => { + const emp = plan.employees?.find((e: any) => e.id === empId); + if (!emp) return '
  • Unbekannt
  • '; + + let cssClass = 'employee-regular'; + let suffix = ''; + + if (emp.isTrainee) { + cssClass = 'employee-trainee'; + suffix = ' (T)'; + } else if (emp.employee_type === 'manager') { + cssClass = 'employee-manager'; + suffix = ' (M)'; + } + + return `
  • ${emp.firstname} ${emp.lastname}${suffix}
  • `; + }).join(''); + + return ``; + } else { + const shiftsForSlot = plan.shifts?.filter((s: any) => + s.dayOfWeek === day.id && s.timeSlotId === timeSlot.id + ) || []; + const totalRequired = shiftsForSlot.reduce((sum: number, s: any) => + sum + s.requiredEmployees, 0 + ); + const displayText = totalRequired === 0 ? '-' : `0/${totalRequired}`; + return ``; + } + }).join('')} + + `).join('')} + +
    Schicht (Zeit)${day.name}
    +
    ${timeSlot.name}
    +
    ${timeSlot.startTime} - ${timeSlot.endTime}
    +
    Keine Schicht
      ${employeeItems}
    ${displayText}
    + +
    +
    +
    + Manager +
    +
    +
    + Trainee +
    +
    +
    + Mitarbeiter +
    +
    +
    + Keine Schicht +
    +
    +
    + +
    +

    Mitarbeiterübersicht

    + + + + + + + + + + + + + ${plan.employees?.map((emp: any) => ` + + + + + + + + + `).join('') || ''} + +
    NameE-MailRolleMitarbeiter TypVertragstypTrainee
    ${emp.firstname} ${emp.lastname}${emp.email}${emp.roles?.join(', ') || 'Benutzer'}${emp.employeeType || 'Unbekannt'}${emp.contractType || 'Nicht angegeben'}${emp.isTrainee ? 'Ja' : 'Nein'}
    Keine Mitarbeiter
    +
    + + + + + `; + + // Launch browser and generate PDF + browser = await chromium.launch({ headless: true }); + const context = await browser.newContext(); + const page = await context.newPage(); + + await page.setContent(html, { waitUntil: 'networkidle' }); + + const pdfBuffer = await page.pdf({ + format: 'A4', + printBackground: true, + margin: { + top: '20mm', + right: '15mm', + bottom: '20mm', + left: '15mm' + }, + displayHeaderFooter: true, + headerTemplate: '
    ', + footerTemplate: ` +
    + / +
    + ` + }); + + await browser.close(); + + // Set response headers and send PDF + const fileName = `Schichtplan_${plan.name}_${new Date().toISOString().split('T')[0]}.pdf`; + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); + res.send(pdfBuffer); console.log('✅ PDF export completed for plan:', id); } catch (error) { console.error('❌ Error exporting to PDF:', error); + if (browser) { + await browser.close(); + } res.status(500).json({ error: 'Internal server error during PDF export' }); } };