Compare commits

...

20 Commits

Author SHA1 Message Date
9eb9afce1e added timetable export to the export funciton 2025-11-04 23:25:26 +01:00
17d68c2426 Merge branch 'staging' of https://github.com/donpat1to/Schichtenplaner into staging 2025-11-04 22:31:23 +01:00
cff2374f41 fixed klammer usage 2025-11-04 22:28:39 +01:00
3a787875e6 implemented export with pdf and excel library 2025-11-04 15:33:51 +01:00
0b46919e46 fixed role handling in getshiftplanbyid 2025-11-03 23:16:53 +01:00
65cb3e72ba added backend for shiftplan export 2025-11-03 22:50:02 +01:00
dab5164704 added exporting files 2025-11-03 22:07:32 +01:00
7c63bee1b3 updated python installation to break system packages 2025-11-03 10:55:07 +01:00
4c275993e6 changing debian versions from bookworm to bullseye 2025-11-03 10:40:46 +01:00
5c925e3b54 copying python files seperate for scheduling mechanism 2025-11-03 10:33:07 +01:00
11b6ee7672 moved python installation from builder to image 2025-11-03 09:39:42 +01:00
19357d12c1 changed ci to create its own pakcage-lock 2025-11-02 21:14:34 +01:00
8ccd506b7d changed ci to create its own pakcage-lock 2025-11-02 21:13:32 +01:00
e09979aa77 put package-lock.json into the .gitignore 2025-11-02 21:09:25 +01:00
0eda1ac125 creating package lock package on every ci seperate 2025-11-02 20:59:35 +01:00
6aa9511fbe brought package.json back from the grave 2025-11-02 20:57:34 +01:00
ab24f5cf35 npm ci install for prod 2025-11-02 20:48:43 +01:00
2e81ed48c4 more lenient api rate limit 2025-11-02 20:40:59 +01:00
da2b3b0126 pushed new svg 2025-11-01 18:25:36 +01:00
7a87c49703 added configuration over https / http 2025-11-01 17:54:12 +01:00
20 changed files with 1251 additions and 5649 deletions

View File

@@ -1,27 +1,29 @@
# === SCHICHTPLANER DOCKER COMPOSE ENVIRONMENT VARIABLES === # .env.template
# Diese Datei wird von docker-compose automatisch geladen # ============================================
# DOCKER COMPOSE ENVIRONMENT TEMPLATE
# Copy this file to .env and adjust values
# ============================================
# Security # Application settings
JWT_SECRET=${JWT_SECRET:-your-secret-key-please-change} NODE_ENV=production
NODE_ENV=${NODE_ENV:-production} JWT_SECRET=your-secret-key-please-change
HOSTNAME=localhost
# Security & Network
TRUST_PROXY_ENABLED=false
TRUSTED_PROXY_IPS=127.0.0.1,::1
FORCE_HTTPS=false
# Database # Database
DB_PATH=${DB_PATH:-/app/data/database.db} DATABASE_PATH=/app/data/schichtplaner.db
# Server # Optional features
PORT=${PORT:-3002} ENABLE_PRO=false
DEBUG=false
# App Configuration # Port configuration
APP_TITLE="Shift Planning App" APP_PORT=3002
ENABLE_PRO=${ENABLE_PRO:-false}
# Trust Proxy Configuration # ============================================
TRUST_PROXY_ENABLED=false # END OF TEMPLATE
TRUSTED_PROXY_IPS= # Your load balancer/reverse proxy IPs # ============================================
TRUSTED_PROXY_HEADER=x-forwarded-for
# Rate Limiting Configuration
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
RATE_LIMIT_SKIP_PATHS=/api/health,/api/status
RATE_LIMIT_WHITELIST=127.0.0.1,::1

View File

@@ -83,9 +83,13 @@ jobs:
with: with:
node-version: '20' node-version: '20'
- name: Create package-lock.json
working-directory: .
run: npm i --package-lock-only
- name: Install backend dependencies - name: Install backend dependencies
working-directory: ./backend working-directory: ./backend
run: npm install run: npm ci
- name: Run TypeScript check - name: Run TypeScript check
working-directory: ./backend working-directory: ./backend

1
.gitignore vendored
View File

@@ -57,6 +57,7 @@ yarn-error.log*
# Build outputs # Build outputs
dist/ dist/
build/ build/
package-lock.json
# Environment variables # Environment variables
.env .env

View File

@@ -1,22 +1,17 @@
# Single stage build for workspaces # Single stage build for workspaces
FROM node:20-bullseye AS builder FROM node:20-bookworm AS builder
WORKDIR /app WORKDIR /app
# Install Python + OR-Tools
RUN apt-get update && apt-get install -y python3 python3-pip build-essential \
&& pip install --no-cache-dir ortools
# Create symlink so python3 is callable as python
RUN ln -sf /usr/bin/python3 /usr/bin/python
# Copy root package files first # Copy root package files first
COPY package*.json ./ COPY package*.json ./
COPY tsconfig.base.json ./ COPY tsconfig.base.json ./
COPY ecosystem.config.cjs ./ COPY ecosystem.config.cjs ./
# Install root dependencies # Install root dependencies
RUN npm install --only=production #RUN npm install --only=production
RUN npm i --package-lock-only
RUN npm ci
# Copy workspace files # Copy workspace files
COPY backend/ ./backend/ COPY backend/ ./backend/
@@ -30,10 +25,7 @@ RUN npm install --workspace=frontend
RUN npm run build --only=production --workspace=backend RUN npm run build --only=production --workspace=backend
# Build frontend # Build frontend
RUN npm run build --workspace=frontend RUN npm run build --only=production --workspace=frontend
# Verify Python and OR-Tools installation
RUN python -c "from ortools.sat.python import cp_model; print('OR-Tools installed successfully')"
# Production stage # Production stage
FROM node:20-bookworm FROM node:20-bookworm
@@ -57,7 +49,20 @@ COPY --from=builder /app/frontend/dist/ ./frontend-build/
COPY --from=builder /app/ecosystem.config.cjs ./ COPY --from=builder /app/ecosystem.config.cjs ./
COPY --from=builder /app/backend/src/database/ ./dist/database/ COPY --from=builder /app/backend/src/database/ ./dist/database/
COPY --from=builder /app/backend/src/database/ ./database/ # should be obsolete with the line above
#COPY --from=builder /app/backend/src/database/ ./database/
COPY --from=builder /app/backend/src/python-scripts/ ./python-scripts/
# Install Python + OR-Tools
RUN apt-get update && apt-get install -y python3 python3-pip build-essential \
&& pip install --no-cache-dir --break-system-packages ortools
# Create symlink so python3 is callable as python
RUN ln -sf /usr/bin/python3 /usr/bin/python
# Verify Python and OR-Tools installation
RUN python -c "from ortools.sat.python import cp_model; print('OR-Tools installed successfully')"
# Copy init script and env template # Copy init script and env template
COPY docker-init.sh /usr/local/bin/ COPY docker-init.sh /usr/local/bin/

View File

@@ -25,7 +25,10 @@
"uuid": "^9.0.0", "uuid": "^9.0.0",
"express-rate-limit": "8.1.0", "express-rate-limit": "8.1.0",
"helmet": "8.1.0", "helmet": "8.1.0",
"express-validator": "7.3.0" "express-validator": "7.3.0",
"exceljs": "4.4.0",
"pdfkit": "0.12.3",
"@types/pdfkit": "^0.12.3"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.2", "@types/bcryptjs": "^2.4.2",

View File

@@ -8,6 +8,8 @@ import {
} from '../models/ShiftPlan.js'; } from '../models/ShiftPlan.js';
import { AuthRequest } from '../middleware/auth.js'; import { AuthRequest } from '../middleware/auth.js';
import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults.js'; import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults.js';
import ExcelJS from 'exceljs';
import PDFDocument from 'pdfkit';
async function getPlanWithDetails(planId: string) { async function getPlanWithDetails(planId: string) {
const plan = await db.get<any>(` const plan = await db.get<any>(`
@@ -592,6 +594,26 @@ async function getShiftPlanById(planId: string): Promise<any> {
`, [planId]); `, [planId]);
} }
// Load employees without role column + join with employee_roles
const employees = await db.all<any>(`
SELECT
e.id,
e.firstname,
e.lastname,
e.email,
e.employee_type,
e.contract_type,
e.can_work_alone,
e.is_trainee,
e.is_active as isActive,
GROUP_CONCAT(er.role) as roles
FROM employees e
LEFT JOIN employee_roles er ON e.id = er.employee_id
WHERE e.is_active = 1
GROUP BY e.id
ORDER BY e.firstname, e.lastname
`, []);
return { return {
...plan, ...plan,
isTemplate: plan.is_template === 1, isTemplate: plan.is_template === 1,
@@ -629,6 +651,19 @@ async function getShiftPlanById(planId: string): Promise<any> {
requiredEmployees: shift.required_employees, requiredEmployees: shift.required_employees,
assignedEmployees: JSON.parse(shift.assigned_employees || '[]'), assignedEmployees: JSON.parse(shift.assigned_employees || '[]'),
timeSlotName: shift.time_slot_name timeSlotName: shift.time_slot_name
})),
// Include employees with proper role handling
employees: employees.map(emp => ({
id: emp.id,
firstname: emp.firstname,
lastname: emp.lastname,
email: emp.email,
employeeType: emp.employee_type,
contractType: emp.contract_type,
canWorkAlone: emp.can_work_alone === 1,
isTrainee: emp.is_trainee === 1,
isActive: emp.isActive === 1,
roles: emp.roles ? emp.roles.split(',') : [] // Convert comma-separated roles to array
})) }))
}; };
} }
@@ -933,3 +968,581 @@ export const clearAssignments = async (req: Request, res: Response): Promise<voi
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
}; };
// Helper interfaces for export
interface ExportDay {
id: number;
name: string;
}
interface ExportTimeSlot {
id: string;
name: string;
startTime: string;
endTime: string;
shiftsByDay: { [dayId: number]: any };
}
interface ExportTimetableData {
days: ExportDay[];
allTimeSlots: ExportTimeSlot[];
}
function getTimetableDataForExport(plan: any): ExportTimetableData {
const weekdays = [
{ id: 1, name: 'Montag' },
{ id: 2, name: 'Dienstag' },
{ id: 3, name: 'Mittwoch' },
{ id: 4, name: 'Donnerstag' },
{ id: 5, name: 'Freitag' },
{ id: 6, name: 'Samstag' },
{ id: 7, name: 'Sonntag' }
];
if (!plan.shifts || !plan.timeSlots) {
return { days: [], allTimeSlots: [] };
}
// Create a map for quick time slot lookups with proper typing
const timeSlotMap = new Map<string, any>();
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<string, ExportTimeSlot>();
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<void> => {
try {
const { id } = req.params;
console.log('📊 Starting Excel export for plan:', id);
// Check if plan exists
const plan = await getShiftPlanById(id);
if (!plan) {
res.status(404).json({ error: 'Shift plan not found' });
return;
}
if (plan.status !== 'published') {
res.status(400).json({ error: 'Can only export published shift plans' });
return;
}
// Create workbook
const workbook = new ExcelJS.Workbook();
workbook.creator = 'Schichtplaner System';
workbook.created = new Date();
// Add Summary Sheet
const summarySheet = workbook.addWorksheet('Planübersicht');
// Summary data
summarySheet.columns = [
{ header: 'Eigenschaft', key: 'property', width: 20 },
{ header: 'Wert', key: 'value', width: 30 }
];
summarySheet.addRows([
{ property: 'Plan Name', value: plan.name },
{ property: 'Beschreibung', value: plan.description || 'Keine' },
{ property: 'Zeitraum', value: `${plan.startDate} bis ${plan.endDate}` },
{ property: 'Status', value: plan.status },
{ property: 'Erstellt von', value: plan.created_by_name || 'Unbekannt' },
{ property: 'Erstellt am', value: plan.createdAt },
{ property: 'Anzahl Schichten', value: plan.scheduledShifts?.length || 0 },
{ property: 'Anzahl Mitarbeiter', value: plan.employees?.length || 0 }
]);
// Style summary sheet
summarySheet.getRow(1).font = { bold: true };
summarySheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF2C3E50' }
};
summarySheet.getRow(1).font = { color: { argb: 'FFFFFFFF' }, bold: true };
// Add Timetable Sheet (matching React component visualization)
const timetableSheet = workbook.addWorksheet('Schichtplan');
// Get timetable data structure similar to React component
const timetableData = getTimetableDataForExport(plan);
const { days, allTimeSlots } = timetableData;
// 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);
});
// 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 };
// 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 };
}
});
});
// Auto-fit columns
timetableSheet.columns.forEach(column => {
if (column.width) {
column.width = Math.max(column.width, 12);
}
});
// Add Employee Overview Sheet
const employeeSheet = workbook.addWorksheet('Mitarbeiterübersicht');
employeeSheet.columns = [
{ header: 'Name', key: 'name', width: 25 },
{ 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: 'Trainee', key: 'trainee', width: 10 }
];
plan.employees?.forEach((employee: any) => {
employeeSheet.addRow({
name: `${employee.firstname} ${employee.lastname}`,
email: employee.email,
role: employee.roles?.join(', ') || 'Benutzer',
type: employee.employeeType,
contract: employee.contractType || 'Nicht angegeben',
trainee: employee.isTrainee ? 'Ja' : 'Nein'
});
});
// Style employee sheet
employeeSheet.getRow(1).font = { bold: true };
employeeSheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF34495E' }
};
employeeSheet.getRow(1).font = { color: { argb: 'FFFFFFFF' }, bold: true };
// Set response headers
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="Schichtplan_${plan.name}_${new Date().toISOString().split('T')[0]}.xlsx"`);
// Write to response
await workbook.xlsx.write(res);
console.log('✅ Excel export completed for plan:', id);
} catch (error) {
console.error('❌ Error exporting to Excel:', error);
res.status(500).json({ error: 'Internal server error during Excel export' });
}
};
export const exportShiftPlanToPDF = async (req: Request, res: Response): Promise<void> => {
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' });
return;
}
if (plan.status !== 'published') {
res.status(400).json({ error: 'Can only export published shift plans' });
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
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.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');
}
}
});
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 }
);
}
// Finalize PDF
doc.end();
console.log('✅ PDF export completed for plan:', id);
} catch (error) {
console.error('❌ Error exporting to PDF:', error);
res.status(500).json({ error: 'Internal server error during PDF export' });
}
};
// 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
function getGermanDayName(dayIndex: number): string {
const days = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
return days[dayIndex];
}

View File

@@ -72,8 +72,8 @@ const getRateLimitConfig = () => {
return { return {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 minutes default windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 minutes default
max: isProduction max: isProduction
? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100') // Stricter in production ? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '1000') // Stricter in production
: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '1000'), // More lenient in development : parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '5000'), // More lenient in development
// Development-specific relaxations // Development-specific relaxations
skip: (req: Request) => { skip: (req: Request) => {
@@ -112,7 +112,7 @@ export const apiLimiter = rateLimit({
// Strict limiter for auth endpoints // Strict limiter for auth endpoints
export const authLimiter = rateLimit({ export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX_REQUESTS || '5'), max: parseInt(process.env.AUTH_RATE_LIMIT_MAX_REQUESTS || '100'),
message: { message: {
error: 'Zu viele Login-Versuche, bitte versuchen Sie es später erneut' error: 'Zu viele Login-Versuche, bitte versuchen Sie es später erneut'
}, },
@@ -135,7 +135,7 @@ export const authLimiter = rateLimit({
// Separate limiter for expensive endpoints // Separate limiter for expensive endpoints
export const expensiveEndpointLimiter = rateLimit({ export const expensiveEndpointLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: parseInt(process.env.EXPENSIVE_ENDPOINT_LIMIT || '10'), max: parseInt(process.env.EXPENSIVE_ENDPOINT_LIMIT || '100'),
message: { message: {
error: 'Zu viele Anfragen für diese Ressource' error: 'Zu viele Anfragen für diese Ressource'
}, },

View File

@@ -7,7 +7,9 @@ import {
updateShiftPlan, updateShiftPlan,
deleteShiftPlan, deleteShiftPlan,
createFromPreset, createFromPreset,
clearAssignments clearAssignments,
exportShiftPlanToExcel,
exportShiftPlanToPDF
} from '../controllers/shiftPlanController.js'; } from '../controllers/shiftPlanController.js';
import { import {
validateShiftPlan, validateShiftPlan,
@@ -30,4 +32,7 @@ router.put('/:id', validateId, validateShiftPlanUpdate, handleValidationErrors,
router.delete('/:id', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), deleteShiftPlan); router.delete('/:id', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), deleteShiftPlan);
router.post('/:id/clear-assignments', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), clearAssignments); router.post('/:id/clear-assignments', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), clearAssignments);
router.get('/:id/export/excel', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), exportShiftPlanToExcel);
router.get('/:id/export/pdf', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), exportShiftPlanToPDF);
export default router; export default router;

View File

@@ -113,6 +113,21 @@ const configureTrustProxy = (): string | string[] | boolean | number => {
app.set('trust proxy', configureTrustProxy()); app.set('trust proxy', configureTrustProxy());
app.use((req, res, next) => {
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
const isHttps = protocol === 'https';
// Add security warning for HTTP requests
if (!isHttps && process.env.NODE_ENV === 'production') {
res.setHeader('X-Security-Warning', 'This application is being accessed over HTTP. For secure communication, please use HTTPS.');
// Log HTTP access in production
console.warn(`⚠️ HTTP access detected: ${req.method} ${req.path} from ${req.ip}`);
}
next();
});
// Security headers // Security headers
app.use(helmet({ app.use(helmet({
contentSecurityPolicy: { contentSecurityPolicy: {
@@ -126,6 +141,7 @@ app.use(helmet({
objectSrc: ["'none'"], objectSrc: ["'none'"],
mediaSrc: ["'self'"], mediaSrc: ["'self'"],
frameSrc: ["'none'"], frameSrc: ["'none'"],
upgradeInsecureRequests: process.env.FORCE_HTTPS === 'true' ? [] : null
}, },
}, },
hsts: { hsts: {

View File

@@ -6,17 +6,22 @@ services:
image: ghcr.io/donpat1to/schichtenplaner:v1.0.0 image: ghcr.io/donpat1to/schichtenplaner:v1.0.0
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- JWT_SECRET=${JWT_SECRET:-your-secret-key-please-change} - JWT_SECRET=${JWT_SECRET}
ports: - TRUST_PROXY_ENABLED=true
- "3002:3002" - TRUSTED_PROXY_IPS=nginx-proxy,172.0.0.0/8,10.0.0.0/8,192.168.0.0/16
- FORCE_HTTPS=${FORCE_HTTPS:-false}
networks:
- app-network
volumes: volumes:
- app_data:/app/data - app_data:/app/data
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3002/api/health"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3002/api/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
expose:
- "3002"
volumes: volumes:
app_data: app_data:

178
frontend/donpat1to.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/donpat1to.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shift Planning App</title> <title>Shift Planning App</title>
</head> </head>

View File

@@ -7,7 +7,9 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^6.28.0", "react-router-dom": "^6.28.0",
"date-fns": "4.1.0" "date-fns": "4.1.0",
"@vitejs/plugin-react": "^4.3.3",
"vite": "^6.0.7"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "20.19.23", "@types/node": "20.19.23",
@@ -25,7 +27,9 @@
"esbuild": "^0.21.0", "esbuild": "^0.21.0",
"terser": "5.44.0", "terser": "5.44.0",
"babel-plugin-transform-remove-console": "6.9.4", "babel-plugin-transform-remove-console": "6.9.4",
"framer-motion": "12.23.24" "framer-motion": "12.23.24",
"file-saver": "2.0.5",
"@types/file-saver": "2.0.5"
}, },
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",

View File

@@ -16,6 +16,7 @@ import Settings from './pages/Settings/Settings';
import Help from './pages/Help/Help'; import Help from './pages/Help/Help';
import Setup from './pages/Setup/Setup'; import Setup from './pages/Setup/Setup';
import ErrorBoundary from './components/ErrorBoundary/ErrorBoundary'; import ErrorBoundary from './components/ErrorBoundary/ErrorBoundary';
import SecurityWarning from './components/SecurityWarning/SecurityWarning';
// Free Footer Link Pages (always available) // Free Footer Link Pages (always available)
import FAQ from './components/Layout/FooterLinks/FAQ/FAQ'; import FAQ from './components/Layout/FooterLinks/FAQ/FAQ';
@@ -165,6 +166,7 @@ function App() {
<NotificationProvider> <NotificationProvider>
<AuthProvider> <AuthProvider>
<Router> <Router>
<SecurityWarning />
<NotificationContainer /> <NotificationContainer />
<AppContent /> <AppContent />
</Router> </Router>

View File

@@ -0,0 +1,59 @@
// src/components/SecurityWarning/SecurityWarning.tsx
import React, { useState, useEffect } from 'react';
const SecurityWarning: React.FC = () => {
const [isHttp, setIsHttp] = useState(false);
const [isDismissed, setIsDismissed] = useState(false);
useEffect(() => {
// Check if current protocol is HTTP
const checkProtocol = () => {
setIsHttp(window.location.protocol === 'http:');
};
checkProtocol();
window.addEventListener('load', checkProtocol);
return () => window.removeEventListener('load', checkProtocol);
}, []);
if (!isHttp || isDismissed) {
return null;
}
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
backgroundColor: '#ff6b35',
color: 'white',
padding: '10px 20px',
textAlign: 'center',
zIndex: 10000,
fontSize: '14px',
fontWeight: 'bold',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
}}>
SECURITY WARNING: This site is being accessed over HTTP.
For secure communication, please use HTTPS.
<button
onClick={() => setIsDismissed(true)}
style={{
marginLeft: '15px',
background: 'rgba(255,255,255,0.2)',
border: '1px solid white',
color: 'white',
padding: '2px 8px',
borderRadius: '3px',
cursor: 'pointer'
}}
>
Dismiss
</button>
</div>
);
};
export default SecurityWarning;

View File

@@ -20,6 +20,8 @@ export const designTokens = {
10: '#ebd7fa', 10: '#ebd7fa',
}, },
manager: '#CC0000',
// Semantic Colors // Semantic Colors
primary: '#51258f', primary: '#51258f',
secondary: '#642ab5', secondary: '#642ab5',

View File

@@ -10,6 +10,7 @@ import { ShiftPlan, TimeSlot, ScheduledShift } from '../../models/ShiftPlan';
import { Employee, EmployeeAvailability } from '../../models/Employee'; import { Employee, EmployeeAvailability } from '../../models/Employee';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import { formatDate, formatTime } from '../../utils/foramatters'; import { formatDate, formatTime } from '../../utils/foramatters';
import { saveAs } from 'file-saver';
// Local interface extensions (same as AvailabilityManager) // Local interface extensions (same as AvailabilityManager)
interface ExtendedTimeSlot extends TimeSlot { interface ExtendedTimeSlot extends TimeSlot {
@@ -54,6 +55,7 @@ const ShiftPlanView: React.FC = () => {
const [scheduledShifts, setScheduledShifts] = useState<ScheduledShift[]>([]); const [scheduledShifts, setScheduledShifts] = useState<ScheduledShift[]>([]);
const [showAssignmentPreview, setShowAssignmentPreview] = useState(false); const [showAssignmentPreview, setShowAssignmentPreview] = useState(false);
const [recreating, setRecreating] = useState(false); const [recreating, setRecreating] = useState(false);
const [exporting, setExporting] = useState(false);
useEffect(() => { useEffect(() => {
loadShiftPlanData(); loadShiftPlanData();
@@ -240,6 +242,66 @@ const ShiftPlanView: React.FC = () => {
}; };
}; };
const handleExportExcel = async () => {
if (!shiftPlan) return;
try {
setExporting(true);
// Call the export service
const blob = await shiftPlanService.exportShiftPlanToExcel(shiftPlan.id);
// Use file-saver to download the file
saveAs(blob, `Schichtplan_${shiftPlan.name}_${new Date().toISOString().split('T')[0]}.xlsx`);
showNotification({
type: 'success',
title: 'Export erfolgreich',
message: 'Der Schichtplan wurde als Excel-Datei exportiert.'
});
} catch (error) {
console.error('Error exporting to Excel:', error);
showNotification({
type: 'error',
title: 'Export fehlgeschlagen',
message: 'Der Excel-Export konnte nicht durchgeführt werden.'
});
} finally {
setExporting(false);
}
};
const handleExportPDF = async () => {
if (!shiftPlan) return;
try {
setExporting(true);
// Call the PDF export service
const blob = await shiftPlanService.exportShiftPlanToPDF(shiftPlan.id);
// Use file-saver to download the file
saveAs(blob, `Schichtplan_${shiftPlan.name}_${new Date().toISOString().split('T')[0]}.pdf`);
showNotification({
type: 'success',
title: 'Export erfolgreich',
message: 'Der Schichtplan wurde als PDF exportiert.'
});
} catch (error) {
console.error('Error exporting to PDF:', error);
showNotification({
type: 'error',
title: 'Export fehlgeschlagen',
message: 'Der PDF-Export konnte nicht durchgeführt werden.'
});
} finally {
setExporting(false);
}
};
const loadShiftPlanData = async () => { const loadShiftPlanData = async () => {
if (!id) return; if (!id) return;
@@ -399,12 +461,12 @@ const ShiftPlanView: React.FC = () => {
console.log('- Scheduled Shifts:', scheduledShifts.length); console.log('- Scheduled Shifts:', scheduledShifts.length);
// DEBUG: Show shift pattern IDs // DEBUG: Show shift pattern IDs
if (shiftPlan.shifts) { /*if (shiftPlan.shifts) {
console.log('📋 SHIFT PATTERN IDs:'); console.log('📋 SHIFT PATTERN IDs:');
shiftPlan.shifts.forEach((shift, index) => { shiftPlan.shifts.forEach((shift, index) => {
console.log(` ${index + 1}. ${shift.id} (Day ${shift.dayOfWeek}, TimeSlot ${shift.timeSlotId})`); console.log(` ${index + 1}. ${shift.id} (Day ${shift.dayOfWeek}, TimeSlot ${shift.timeSlotId})`);
}); });
} }*/
const constraints = { const constraints = {
enforceNoTraineeAlone: true, enforceNoTraineeAlone: true,
@@ -650,6 +712,20 @@ const ShiftPlanView: React.FC = () => {
return employeesWithoutAvailabilities.length === 0; return employeesWithoutAvailabilities.length === 0;
}; };
const canPublishAssignment = (): boolean => {
if (!assignmentResult) return false;
// Check if assignment was successful
if (assignmentResult.success === false) return false;
// Check if there are any critical violations
const hasCriticalViolations = assignmentResult.violations.some(v =>
v.includes('ERROR:') || v.includes('KRITISCH:')
);
return !hasCriticalViolations;
};
const getAvailabilityStatus = () => { const getAvailabilityStatus = () => {
const totalEmployees = employees.length; const totalEmployees = employees.length;
const employeesWithAvailabilities = new Set( const employeesWithAvailabilities = new Set(
@@ -820,9 +896,6 @@ const ShiftPlanView: React.FC = () => {
<div style={{ fontSize: '14px', color: '#666' }}> <div style={{ fontSize: '14px', color: '#666' }}>
{formatTime(timeSlot.startTime)} - {formatTime(timeSlot.endTime)} {formatTime(timeSlot.startTime)} - {formatTime(timeSlot.endTime)}
</div> </div>
<div style={{ fontSize: '11px', color: '#999', marginTop: '4px' }}>
ID: {timeSlot.id.substring(0, 8)}...
</div>
</td> </td>
{days.map(weekday => { {days.map(weekday => {
const shift = timeSlot.shiftsByDay[weekday.id]; const shift = timeSlot.shiftsByDay[weekday.id];
@@ -846,7 +919,55 @@ const ShiftPlanView: React.FC = () => {
const isValidShift = shift.timeSlotId === timeSlot.id && shift.dayOfWeek === weekday.id; const isValidShift = shift.timeSlotId === timeSlot.id && shift.dayOfWeek === weekday.id;
let assignedEmployees: string[] = []; let assignedEmployees: string[] = [];
let displayText = ''; let displayContent: React.ReactNode = null;
// Helper function to create employee boxes
const createEmployeeBoxes = (employeeIds: string[]) => {
return employeeIds.map(empId => {
const employee = employees.find(emp => emp.id === empId);
if (!employee) return null;
// Determine background color based on employee role
let backgroundColor = '#642ab5'; // Default: non-trainee personnel (purple)
if (employee.isTrainee) {
backgroundColor = '#cda8f0'; // Trainee
} else if (employee.roles?.includes('manager')) {
backgroundColor = '#CC0000'; // Manager
}
return (
<div
key={empId}
style={{
backgroundColor,
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
marginBottom: '2px',
fontSize: '12px',
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
title={`${employee.firstname} ${employee.lastname}${employee.isTrainee ? ' (Trainee)' : ''}`}
>
{employee.firstname} {employee.lastname}
</div>
);
}).filter(Boolean);
};
// Helper function to get fallback content
const getFallbackContent = () => {
const shiftsForSlot = shiftPlan?.shifts?.filter(s =>
s.dayOfWeek === weekday.id &&
s.timeSlotId === timeSlot.id
) || [];
const totalRequired = shiftsForSlot.reduce((sum, s) => sum + s.requiredEmployees, 0);
return totalRequired === 0 ? '-' : `0/${totalRequired}`;
};
if (shiftPlan?.status === 'published') { if (shiftPlan?.status === 'published') {
// For published plans, use actual assignments from scheduled shifts // For published plans, use actual assignments from scheduled shifts
@@ -859,15 +980,21 @@ const ShiftPlanView: React.FC = () => {
if (scheduledShift) { if (scheduledShift) {
assignedEmployees = scheduledShift.assignedEmployees || []; assignedEmployees = scheduledShift.assignedEmployees || [];
// DEBUG: Log if we're still seeing old data // Log if we're still seeing old data
if (assignedEmployees.length > 0) { if (assignedEmployees.length > 0) {
console.warn(`⚠️ Found non-empty assignments for ${weekday.name} ${timeSlot.name}:`, assignedEmployees); console.warn(`⚠️ Found non-empty assignments for ${weekday.name} ${timeSlot.name}:`, assignedEmployees);
} }
displayText = assignedEmployees.map(empId => { const employeeBoxes = createEmployeeBoxes(assignedEmployees);
const employee = employees.find(emp => emp.id === empId); displayContent = employeeBoxes.length > 0 ? (
return employee ? `${employee.firstname} ${employee.lastname}` : 'Unbekannt'; <div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
}).join(', '); {employeeBoxes}
</div>
) : (
<div style={{ color: '#666', fontStyle: 'italic' }}>
{getFallbackContent()}
</div>
);
} }
} else if (assignmentResult) { } else if (assignmentResult) {
// For draft with preview, use assignment result // For draft with preview, use assignment result
@@ -879,30 +1006,26 @@ const ShiftPlanView: React.FC = () => {
if (scheduledShift) { if (scheduledShift) {
assignedEmployees = getAssignmentsForScheduledShift(scheduledShift); assignedEmployees = getAssignmentsForScheduledShift(scheduledShift);
displayText = assignedEmployees.map(empId => { const employeeBoxes = createEmployeeBoxes(assignedEmployees);
const employee = employees.find(emp => emp.id === empId); displayContent = employeeBoxes.length > 0 ? (
return employee ? `${employee.firstname} ${employee.lastname}` : 'Unbekannt'; <div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
}).join(', '); {employeeBoxes}
</div>
) : (
<div style={{ color: '#666', fontStyle: 'italic' }}>
{getFallbackContent()}
</div>
);
} }
} }
// If no assignments yet, show empty or required count // If no display content set yet, use fallback
if (!displayText) { if (!displayContent) {
const shiftsForSlot = shiftPlan?.shifts?.filter(s => displayContent = (
s.dayOfWeek === weekday.id && <div style={{ color: '#666', fontStyle: 'italic' }}>
s.timeSlotId === timeSlot.id {getFallbackContent()}
) || []; </div>
);
const totalRequired = shiftsForSlot.reduce((sum, s) =>
sum + s.requiredEmployees, 0);
// Show "0/2" instead of just "0" to indicate it's empty
displayText = `0/${totalRequired}`;
// Optional: Show empty state more clearly
if (totalRequired === 0) {
displayText = '-';
}
} }
return ( return (
@@ -937,7 +1060,7 @@ const ShiftPlanView: React.FC = () => {
</div> </div>
)} )}
{displayText} {displayContent}
{/* Shift debug info - SAME AS AVAILABILITYMANAGER */} {/* Shift debug info - SAME AS AVAILABILITYMANAGER */}
<div style={{ <div style={{
@@ -947,8 +1070,6 @@ const ShiftPlanView: React.FC = () => {
textAlign: 'left', textAlign: 'left',
fontFamily: 'monospace' fontFamily: 'monospace'
}}> }}>
<div>Shift: {shift.id.substring(0, 6)}...</div>
<div>Day: {shift.dayOfWeek}</div>
{!isValidShift && ( {!isValidShift && (
<div style={{ color: '#e74c3c', fontWeight: 'bold' }}> <div style={{ color: '#e74c3c', fontWeight: 'bold' }}>
VALIDATION ERROR VALIDATION ERROR
@@ -963,7 +1084,6 @@ const ShiftPlanView: React.FC = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
); );
}; };
@@ -1005,6 +1125,49 @@ const ShiftPlanView: React.FC = () => {
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
{shiftPlan.status === 'published' && hasRole(['admin', 'maintenance']) && (
<>
<button
onClick={handleExportExcel}
disabled={exporting}
style={{
padding: '10px 20px',
backgroundColor: '#27ae60',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: exporting ? 'not-allowed' : 'pointer',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
{exporting ? '🔄' : '📊'} {exporting ? 'Exportiert...' : 'Excel Export'}
</button>
<button
onClick={handleExportPDF}
disabled={exporting}
style={{
padding: '10px 20px',
backgroundColor: '#e74c3c',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: exporting ? 'not-allowed' : 'pointer',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
{exporting ? '🔄' : '📄'} {exporting ? 'Exportiert...' : 'PDF Export'}
</button>
</>
)}
{/* "Zuweisungen neu berechnen" button */}
{shiftPlan.status === 'published' && hasRole(['admin', 'maintenance']) && ( {shiftPlan.status === 'published' && hasRole(['admin', 'maintenance']) && (
<button <button
onClick={handleRecreateAssignments} onClick={handleRecreateAssignments}
@@ -1197,15 +1360,13 @@ const ShiftPlanView: React.FC = () => {
</div> </div>
)} )}
{/* KORRIGIERTE ZUSAMMENFASSUNG */} {/* ZUSAMMENFASSUNG */}
{assignmentResult && ( {assignmentResult && (
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<h4>Zusammenfassung:</h4> <h4>Zusammenfassung:</h4>
{/* Entscheidung basierend auf tatsächlichen kritischen Problemen */} {/* Entscheidung basierend auf tatsächlichen kritischen Problemen */}
{assignmentResult.violations.filter(v => {(assignmentResult.violations.length === 0) || assignmentResult.success == true ? (
v.includes('ERROR:') || v.includes('❌ KRITISCH:')
).length === 0 ? (
<div style={{ <div style={{
padding: '15px', padding: '15px',
backgroundColor: '#d4edda', backgroundColor: '#d4edda',
@@ -1288,32 +1449,24 @@ const ShiftPlanView: React.FC = () => {
Abbrechen Abbrechen
</button> </button>
{/* KORRIGIERTER BUTTON MIT TYPESCRIPT-FIX */} {/* BUTTON zum publishen */}
<button <button
onClick={handlePublish} onClick={handlePublish}
disabled={publishing || (assignmentResult ? assignmentResult.violations.filter(v => disabled={publishing || !canPublishAssignment()}
v.includes('ERROR:') || v.includes('❌ KRITISCH:')
).length > 0 : true)}
style={{ style={{
padding: '10px 20px', padding: '10px 20px',
backgroundColor: assignmentResult ? (assignmentResult.violations.filter(v => backgroundColor: canPublishAssignment() ? '#2ecc71' : '#95a5a6',
v.includes('ERROR:') || v.includes('❌ KRITISCH:')
).length === 0 ? '#2ecc71' : '#95a5a6') : '#95a5a6',
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: assignmentResult ? (assignmentResult.violations.filter(v => cursor: canPublishAssignment() ? 'pointer' : 'not-allowed',
v.includes('ERROR:') || v.includes('❌ KRITISCH:')
).length === 0 ? 'pointer' : 'not-allowed') : 'not-allowed',
fontWeight: 'bold', fontWeight: 'bold',
fontSize: '16px' fontSize: '16px'
}} }}
> >
{publishing ? 'Veröffentliche...' : ( {publishing ? 'Veröffentliche...' : (
assignmentResult ? ( assignmentResult ? (
assignmentResult.violations.filter(v => canPublishAssignment()
v.includes('ERROR:') || v.includes('❌ KRITISCH:')
).length === 0
? 'Schichtplan veröffentlichen' ? 'Schichtplan veröffentlichen'
: 'Kritische Probleme müssen behoben werden' : 'Kritische Probleme müssen behoben werden'
) : 'Lade Zuordnungen...' ) : 'Lade Zuordnungen...'

View File

@@ -26,7 +26,7 @@ export class ApiClient {
return token ? { 'Authorization': `Bearer ${token}` } : {}; return token ? { 'Authorization': `Bearer ${token}` } : {};
} }
private async handleApiResponse<T>(response: Response): Promise<T> { private async handleApiResponse<T>(response: Response, responseType: 'json' | 'blob' = 'json'): Promise<T> {
if (!response.ok) { if (!response.ok) {
let errorData; let errorData;
@@ -61,7 +61,12 @@ export class ApiClient {
); );
} }
// For successful responses, try to parse as JSON // Handle blob responses (for file downloads)
if (responseType === 'blob') {
return response.blob() as Promise<T>;
}
// For successful JSON responses, try to parse as JSON
try { try {
const responseText = await response.text(); const responseText = await response.text();
return responseText ? JSON.parse(responseText) : {} as T; return responseText ? JSON.parse(responseText) : {} as T;
@@ -71,7 +76,7 @@ export class ApiClient {
} }
} }
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> { async request<T>(endpoint: string, options: RequestInit = {}, responseType: 'json' | 'blob' = 'json'): Promise<T> {
const url = `${this.baseURL}${endpoint}`; const url = `${this.baseURL}${endpoint}`;
const config: RequestInit = { const config: RequestInit = {
@@ -85,7 +90,7 @@ export class ApiClient {
try { try {
const response = await fetch(url, config); const response = await fetch(url, config);
return await this.handleApiResponse<T>(response); return await this.handleApiResponse<T>(response, responseType);
} catch (error) { } catch (error) {
// Re-throw the error to be caught by useBackendValidation // Re-throw the error to be caught by useBackendValidation
if (error instanceof ApiError) { if (error instanceof ApiError) {

View File

@@ -126,4 +126,60 @@ export const shiftPlanService = {
throw error; throw error;
} }
}, },
async exportShiftPlanToExcel(planId: string): Promise<Blob> {
try {
console.log('📊 Exporting shift plan to Excel:', planId);
// Use the apiClient with blob response handling
const blob = await apiClient.request<Blob>(`/shift-plans/${planId}/export/excel`, {
method: 'GET',
}, 'blob');
console.log('✅ Excel export successful');
return blob;
} catch (error: any) {
console.error('❌ Error exporting to Excel:', error);
if (error.statusCode === 401) {
localStorage.removeItem('token');
localStorage.removeItem('employee');
throw new Error('Nicht authorisiert - bitte erneut anmelden');
}
if (error.statusCode === 404) {
throw new Error('Schichtplan nicht gefunden');
}
throw new Error('Fehler beim Excel-Export des Schichtplans');
}
},
async exportShiftPlanToPDF(planId: string): Promise<Blob> {
try {
console.log('📄 Exporting shift plan to PDF:', planId);
// Use the apiClient with blob response handling
const blob = await apiClient.request<Blob>(`/shift-plans/${planId}/export/pdf`, {
method: 'GET',
}, 'blob');
console.log('✅ PDF export successful');
return blob;
} catch (error: any) {
console.error('❌ Error exporting to PDF:', error);
if (error.statusCode === 401) {
localStorage.removeItem('token');
localStorage.removeItem('employee');
throw new Error('Nicht authorisiert - bitte erneut anmelden');
}
if (error.statusCode === 404) {
throw new Error('Schichtplan nicht gefunden');
}
throw new Error('Fehler beim PDF-Export des Schichtplans');
}
},
}; };

5511
package-lock.json generated

File diff suppressed because it is too large Load Diff