diff --git a/backend/src/controllers/shiftPlanController.ts b/backend/src/controllers/shiftPlanController.ts index 76ae340..e95c287 100644 --- a/backend/src/controllers/shiftPlanController.ts +++ b/backend/src/controllers/shiftPlanController.ts @@ -969,6 +969,119 @@ export const clearAssignments = async (req: Request, res: Response): Promise(); + plan.timeSlots.forEach((ts: any) => { + timeSlotMap.set(ts.id, ts); + }); + + // Group shifts by day + const shiftsByDay: { [dayId: number]: any[] } = plan.shifts.reduce((acc: any, shift: any) => { + if (!acc[shift.dayOfWeek]) { + acc[shift.dayOfWeek] = []; + } + + const timeSlot = timeSlotMap.get(shift.timeSlotId); + const enhancedShift = { + ...shift, + timeSlotName: timeSlot?.name, + startTime: timeSlot?.startTime, + endTime: timeSlot?.endTime + }; + + acc[shift.dayOfWeek].push(enhancedShift); + return acc; + }, {}); + + // Sort shifts within each day by start time + Object.keys(shiftsByDay).forEach(day => { + const dayNum = parseInt(day); + shiftsByDay[dayNum].sort((a: any, b: any) => { + const timeA = a.startTime || ''; + const timeB = b.startTime || ''; + return timeA.localeCompare(timeB); + }); + }); + + // Get unique days that have shifts + const days: ExportDay[] = Array.from(new Set(plan.shifts.map((shift: any) => shift.dayOfWeek))) + .sort() + .map(dayId => { + return weekdays.find(day => day.id === dayId) || { id: dayId as number, name: `Tag ${dayId}` }; + }); + + // Get all unique time slots (rows) by collecting from all shifts + const allTimeSlotsMap = new Map(); + days.forEach(day => { + shiftsByDay[day.id]?.forEach((shift: any) => { + const timeSlot = timeSlotMap.get(shift.timeSlotId); + if (timeSlot && !allTimeSlotsMap.has(timeSlot.id)) { + const exportTimeSlot: ExportTimeSlot = { + id: timeSlot.id, + name: timeSlot.name, + startTime: timeSlot.startTime, + endTime: timeSlot.endTime, + shiftsByDay: {} + }; + allTimeSlotsMap.set(timeSlot.id, exportTimeSlot); + } + }); + }); + + // Populate shifts for each time slot by day + days.forEach(day => { + shiftsByDay[day.id]?.forEach((shift: any) => { + const timeSlot = allTimeSlotsMap.get(shift.timeSlotId); + if (timeSlot) { + timeSlot.shiftsByDay[day.id] = shift; + } + }); + }); + + // Convert to array and sort by start time + const allTimeSlots = Array.from(allTimeSlotsMap.values()).sort((a: ExportTimeSlot, b: ExportTimeSlot) => { + return (a.startTime || '').localeCompare(b.startTime || ''); + }); + + return { days, allTimeSlots }; +} + +// Update the Excel export function with proper typing export const exportShiftPlanToExcel = async (req: Request, res: Response): Promise => { try { const { id } = req.params; @@ -1021,75 +1134,106 @@ export const exportShiftPlanToExcel = async (req: Request, res: Response): Promi }; summarySheet.getRow(1).font = { color: { argb: 'FFFFFFFF' }, bold: true }; - // Add Assignments Sheet - const assignmentsSheet = workbook.addWorksheet('Schichtzuweisungen'); + // Add Timetable Sheet (matching React component visualization) + const timetableSheet = workbook.addWorksheet('Schichtplan'); - assignmentsSheet.columns = [ - { header: 'Datum', key: 'date', width: 12 }, - { header: 'Tag', key: 'day', width: 10 }, - { header: 'Schicht', key: 'shift', width: 15 }, - { header: 'Zeit', key: 'time', width: 15 }, - { header: 'Zugewiesene Mitarbeiter', key: 'employees', width: 30 }, - { header: 'Benötigte Mitarbeiter', key: 'required', width: 18 } - ]; + // Get timetable data structure similar to React component + const timetableData = getTimetableDataForExport(plan); + const { days, allTimeSlots } = timetableData; - // Group scheduled shifts by date - const shiftsByDate = new Map(); - plan.scheduledShifts?.forEach((scheduledShift: any) => { - const date = scheduledShift.date; - if (!shiftsByDate.has(date)) { - shiftsByDate.set(date, []); - } - shiftsByDate.get(date).push(scheduledShift); + // Create header row + const headerRow = ['Schicht (Zeit)', ...days.map(day => day.name)]; + timetableSheet.addRow(headerRow); + + // Add data rows for each time slot + allTimeSlots.forEach(timeSlot => { + const rowData: any[] = [ + `${timeSlot.name}\n${timeSlot.startTime} - ${timeSlot.endTime}` + ]; + + days.forEach(day => { + const shift = timeSlot.shiftsByDay[day.id]; + if (!shift) { + rowData.push('Keine Schicht'); + return; + } + + // 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; + }); + + if (scheduledShift && scheduledShift.assignedEmployees.length > 0) { + const employeeNames = scheduledShift.assignedEmployees.map((empId: string) => { + const employee = plan.employees?.find((emp: any) => emp.id === empId); + if (!employee) return 'Unbekannt'; + + // Add role indicator similar to React component + let roleIndicator = ''; + if (employee.isTrainee) { + roleIndicator = ' (Trainee)'; + } else if (employee.roles?.includes('manager')) { + roleIndicator = ' (Manager)'; + } + + return `${employee.firstname} ${employee.lastname}${roleIndicator}`; + }).join('\n'); + + rowData.push(employeeNames); + } else { + // Show required employees 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); + rowData.push(totalRequired === 0 ? '-' : `0/${totalRequired}`); + } + }); + + timetableSheet.addRow(rowData); }); - // Sort dates chronologically - const sortedDates = Array.from(shiftsByDate.keys()).sort(); + // Style timetable sheet + timetableSheet.getRow(1).font = { bold: true }; + timetableSheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF2C3E50' } + }; + timetableSheet.getRow(1).font = { color: { argb: 'FFFFFFFF' }, bold: true }; - // Add data to sheet - sortedDates.forEach(date => { - const dateShifts = shiftsByDate.get(date); - const dateObj = new Date(date); - const dayName = getGermanDayName(dateObj.getDay()); - - dateShifts.forEach((scheduledShift: any) => { - const timeSlot = plan.timeSlots?.find((ts: any) => ts.id === scheduledShift.timeSlotId); - const employeeNames = scheduledShift.assignedEmployees.map((empId: string) => { - const employee = plan.employees?.find((emp: any) => emp.id === empId); - return employee ? `${employee.firstname} ${employee.lastname}` : 'Unbekannt'; - }).join(', ') || 'Keine Zuweisungen'; - - assignmentsSheet.addRow({ - date: date, - day: dayName, - shift: timeSlot?.name || 'Unbekannt', - time: timeSlot ? `${timeSlot.startTime} - ${timeSlot.endTime}` : '', - employees: employeeNames, - required: scheduledShift.requiredEmployees || 2 - }); + // Set row heights and wrap text + timetableSheet.eachRow((row, rowNumber) => { + if (rowNumber > 1) { + row.height = 60; + row.alignment = { vertical: 'top', wrapText: true }; + } + + row.eachCell((cell, colNumber) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + + if (rowNumber === 1) { + cell.alignment = { horizontal: 'center', vertical: 'middle' }; + } else if (colNumber === 1) { + cell.alignment = { vertical: 'middle' }; + } else { + cell.alignment = { vertical: 'top', wrapText: true }; + } }); }); - // Style assignments sheet - assignmentsSheet.getRow(1).font = { bold: true }; - assignmentsSheet.getRow(1).fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FF34495E' } - }; - assignmentsSheet.getRow(1).font = { color: { argb: 'FFFFFFFF' }, bold: true }; - - // Add border to all cells with data - assignmentsSheet.eachRow((row, rowNumber) => { - if (rowNumber > 1) { - row.eachCell((cell) => { - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - }; - }); + // Auto-fit columns + timetableSheet.columns.forEach(column => { + if (column.width) { + column.width = Math.max(column.width, 12); } }); @@ -1101,7 +1245,8 @@ export const exportShiftPlanToExcel = async (req: Request, res: Response): Promi { header: 'E-Mail', key: 'email', width: 25 }, { header: 'Rolle', key: 'role', width: 15 }, { header: 'Mitarbeiter Typ', key: 'type', width: 15 }, - { header: 'Vertragstyp', key: 'contract', width: 15 } + { header: 'Vertragstyp', key: 'contract', width: 15 }, + { header: 'Trainee', key: 'trainee', width: 10 } ]; plan.employees?.forEach((employee: any) => { @@ -1110,7 +1255,8 @@ export const exportShiftPlanToExcel = async (req: Request, res: Response): Promi email: employee.email, role: employee.roles?.join(', ') || 'Benutzer', type: employee.employeeType, - contract: employee.contractType || 'Nicht angegeben' + contract: employee.contractType || 'Nicht angegeben', + trainee: employee.isTrainee ? 'Ja' : 'Nein' }); }); @@ -1197,91 +1343,130 @@ export const exportShiftPlanToPDF = async (req: Request, res: Response): Promise doc.text(`Anzahl Mitarbeiter: ${plan.employees?.length || 0}`, 50, yPosition); yPosition += 40; - // Group scheduled shifts by date - const shiftsByDate = new Map(); - plan.scheduledShifts?.forEach((scheduledShift: any) => { - const date = scheduledShift.date; - if (!shiftsByDate.has(date)) { - shiftsByDate.set(date, []); - } - shiftsByDate.get(date).push(scheduledShift); - }); + // Get timetable data for PDF + const timetableData = getTimetableDataForExport(plan); + const { days, allTimeSlots } = timetableData; - // Sort dates chronologically - const sortedDates = Array.from(shiftsByDate.keys()).sort(); - - // Add assignments section + // Add timetable section doc.addPage(); - doc.fontSize(16).font('Helvetica-Bold').text('Schichtzuweisungen', 50, 50); + doc.fontSize(16).font('Helvetica-Bold').text('Schichtplan Timetable', 50, 50); let currentY = 80; - sortedDates.forEach(date => { - const dateShifts = shiftsByDate.get(date); - const dateObj = new Date(date); - const dayName = getGermanDayName(dateObj.getDay()); + // 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; } - // Date header - doc.fontSize(12).font('Helvetica-Bold').text(`${date} (${dayName})`, 50, currentY); - 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 }); - // Table headers - doc.fontSize(10).font('Helvetica-Bold'); - doc.text('Schicht', 50, currentY); - doc.text('Zeit', 150, currentY); - doc.text('Mitarbeiter', 250, currentY); - doc.text('Benötigt', 450, currentY); - currentY += 15; + // 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; + }); - // Horizontal line - doc.moveTo(50, currentY).lineTo(550, currentY).stroke(); - currentY += 10; - - doc.fontSize(9).font('Helvetica'); - - dateShifts.forEach((scheduledShift: any) => { - // Check if we need a new page for this shift - if (currentY > 700) { - doc.addPage(); - currentY = 50; - // Re-add headers for new page - doc.fontSize(10).font('Helvetica-Bold'); - doc.text('Schicht', 50, currentY); - doc.text('Zeit', 150, currentY); - doc.text('Mitarbeiter', 250, currentY); - doc.text('Benötigt', 450, currentY); - currentY += 25; + 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.roles?.includes('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'); + } } - - const timeSlot = plan.timeSlots?.find((ts: any) => ts.id === scheduledShift.timeSlotId); - const employeeNames = scheduledShift.assignedEmployees.map((empId: string) => { - const employee = plan.employees?.find((emp: any) => emp.id === empId); - return employee ? `${employee.firstname} ${employee.lastname}` : 'Unbekannt'; - }).join(', ') || 'Keine Zuweisungen'; - - // Split employee names if too long - const employeesLines = doc.heightOfString(employeeNames, { width: 190 }); - - doc.text(timeSlot?.name || 'Unbekannt', 50, currentY); - doc.text(timeSlot ? `${timeSlot.startTime} - ${timeSlot.endTime}` : '', 150, currentY); - - // Handle multi-line employee names - const employeeText = doc.heightOfString(employeeNames, { width: 190 }) > 20 ? - employeeNames.split(', ').join(',\n') : employeeNames; - - doc.text(employeeText, 250, currentY, { width: 190, align: 'left' }); - doc.text(String(scheduledShift.requiredEmployees || 2), 450, currentY); - - currentY += Math.max(20, employeesLines) + 5; }); - currentY += 20; // Space between dates + currentY += 40; }); // Add employee overview page @@ -1350,106 +1535,10 @@ export const exportShiftPlanToPDF = async (req: Request, res: Response): Promise } }; -// Helper function to generate HTML data -function generateHTMLFromPlan(plan: any): string { - const shiftsByDate = new Map(); - - plan.scheduledShifts?.forEach((scheduledShift: any) => { - const date = scheduledShift.date; - if (!shiftsByDate.has(date)) { - shiftsByDate.set(date, []); - } - shiftsByDate.get(date).push(scheduledShift); - }); - - const sortedDates = Array.from(shiftsByDate.keys()).sort(); - - let html = ` - - - - - Schichtplan: ${plan.name} - - - -

Schichtplan: ${plan.name}

- -
-

Plan Informationen

-

Zeitraum: ${plan.startDate} bis ${plan.endDate}

-

Status: ${plan.status}

-

Erstellt von: ${plan.created_by_name || 'Unbekannt'}

-

Erstellt am: ${plan.createdAt}

-

Anzahl Schichten: ${plan.scheduledShifts?.length || 0}

-
- -

Schichtzuweisungen

- `; - - sortedDates.forEach(date => { - const dateShifts = shiftsByDate.get(date); - const dateObj = new Date(date); - const dayName = getGermanDayName(dateObj.getDay()); - - html += ` -
-

${date} (${dayName})

- - - - - - - - - - - `; - - dateShifts.forEach((scheduledShift: any) => { - const timeSlot = plan.timeSlots?.find((ts: any) => ts.id === scheduledShift.timeSlotId); - const employeeNames = scheduledShift.assignedEmployees.map((empId: string) => { - const employee = plan.employees?.find((emp: any) => emp.id === empId); - return employee ? `${employee.firstname} ${employee.lastname}` : 'Unbekannt'; - }).join(', ') || 'Keine Zuweisungen'; - - html += ` - - - - - - - `; - }); - - html += ` - -
SchichtZeitZugewiesene MitarbeiterBenötigte Mitarbeiter
${timeSlot?.name || 'Unbekannt'}${timeSlot ? `${timeSlot.startTime} - ${timeSlot.endTime}` : ''}${employeeNames}${scheduledShift.requiredEmployees || 2}
-
- `; - }); - - html += ` -
- Erstellt am: ${new Date().toLocaleString('de-DE')} -
- - - `; - - return html; +// Helper function to get day of week from date string +function getDayOfWeek(dateString: string): number { + const date = new Date(dateString); + return date.getDay() === 0 ? 7 : date.getDay(); } // Helper function to get German day names