diff --git a/backend/src/controllers/shiftPlanController.ts b/backend/src/controllers/shiftPlanController.ts index 5728984..c825f97 100644 --- a/backend/src/controllers/shiftPlanController.ts +++ b/backend/src/controllers/shiftPlanController.ts @@ -308,7 +308,7 @@ async function generateShiftsFromTemplate(shiftPlanId: string, templateId: strin const start = new Date(startDate); const end = new Date(endDate); - // Generate shifts for each day in the date range + // Generate shifts ONLY for days that have template shifts defined for (let date = new Date(start); date <= end; date.setDate(date.getDate() + 1)) { // Convert JS day (0=Sunday) to our format (1=Monday, 7=Sunday) const dayOfWeek = date.getDay() === 0 ? 7 : date.getDay(); @@ -316,23 +316,26 @@ async function generateShiftsFromTemplate(shiftPlanId: string, templateId: strin // Find template shifts for this day of week const shiftsForDay = templateShifts.filter(shift => shift.day_of_week === dayOfWeek); - for (const templateShift of shiftsForDay) { - const shiftId = uuidv4(); - - await db.run( - `INSERT INTO assigned_shifts (id, shift_plan_id, date, name, start_time, end_time, required_employees, assigned_employees) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [ - shiftId, - shiftPlanId, - date.toISOString().split('T')[0], - templateShift.name, - templateShift.start_time, - templateShift.end_time, - templateShift.required_employees, - JSON.stringify([]) - ] - ); + // Only create shifts if there are template shifts defined for this weekday + if (shiftsForDay.length > 0) { + for (const templateShift of shiftsForDay) { + const shiftId = uuidv4(); + + await db.run( + `INSERT INTO assigned_shifts (id, shift_plan_id, date, name, start_time, end_time, required_employees, assigned_employees) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + shiftId, + shiftPlanId, + date.toISOString().split('T')[0], + templateShift.name, + templateShift.start_time, + templateShift.end_time, + templateShift.required_employees, + JSON.stringify([]) + ] + ); + } } } } \ No newline at end of file diff --git a/backend/src/controllers/shiftTemplateController.ts b/backend/src/controllers/shiftTemplateController.ts index f33d071..8d1ce57 100644 --- a/backend/src/controllers/shiftTemplateController.ts +++ b/backend/src/controllers/shiftTemplateController.ts @@ -2,37 +2,63 @@ import { Request, Response } from 'express'; import { v4 as uuidv4 } from 'uuid'; import { db } from '../services/databaseService.js'; -import { ShiftTemplate, CreateShiftTemplateRequest, UpdateShiftTemplateRequest } from '../models/ShiftTemplate.js'; +import { + ShiftTemplate, + TemplateShiftSlot, + TemplateShiftTimeRange, + CreateShiftTemplateRequest, + UpdateShiftTemplateRequest +} from '../models/ShiftTemplate.js'; import { AuthRequest } from '../middleware/auth.js'; export const getTemplates = async (req: Request, res: Response): Promise => { try { - const templates = await db.all(` + const templates = await db.all(` SELECT st.*, u.name as created_by_name FROM shift_templates st LEFT JOIN users u ON st.created_by = u.id ORDER BY st.created_at DESC `); - // Für jede Vorlage die Schichten laden + // Für jede Vorlage die Schichten und Zeit-Slots laden const templatesWithShifts = await Promise.all( templates.map(async (template) => { - const shifts = await db.all(` - SELECT * FROM template_shifts + // Lade Schicht-Slots + const shiftSlots = await db.all(` + SELECT ts.*, tts.name as time_range_name, tts.start_time as time_range_start, tts.end_time as time_range_end + FROM template_shifts ts + LEFT JOIN template_time_slots tts ON ts.time_slot_id = tts.id + WHERE ts.template_id = ? + ORDER BY ts.day_of_week, tts.start_time + `, [template.id]); + + // Lade Zeit-Slots + const timeSlots = await db.all(` + SELECT * FROM template_time_slots WHERE template_id = ? - ORDER BY day_of_week, start_time + ORDER BY start_time `, [template.id]); return { ...template, - shifts: shifts.map(shift => ({ - id: shift.id, - dayOfWeek: shift.day_of_week, - name: shift.name, - startTime: shift.start_time, - endTime: shift.end_time, - requiredEmployees: shift.required_employees, - color: shift.color + shifts: shiftSlots.map(slot => ({ + id: slot.id, + dayOfWeek: slot.day_of_week, + timeRange: { + id: slot.time_slot_id, + name: slot.time_range_name, + startTime: slot.time_range_start, + endTime: slot.time_range_end + }, + requiredEmployees: slot.required_employees, + color: slot.color + })), + timeSlots: timeSlots.map(slot => ({ + id: slot.id, + name: slot.name, + startTime: slot.start_time, + endTime: slot.end_time, + description: slot.description })) }; }) @@ -49,7 +75,7 @@ export const getTemplate = async (req: Request, res: Response): Promise => try { const { id } = req.params; - const template = await db.get(` + const template = await db.get(` SELECT st.*, u.name as created_by_name FROM shift_templates st LEFT JOIN users u ON st.created_by = u.id @@ -61,26 +87,46 @@ export const getTemplate = async (req: Request, res: Response): Promise => return; } - const shifts = await db.all(` - SELECT * FROM template_shifts - WHERE template_id = ? - ORDER BY day_of_week, start_time + // Lade Schicht-Slots + const shiftSlots = await db.all(` + SELECT ts.*, tts.name as time_range_name, tts.start_time as time_range_start, tts.end_time as time_range_end + FROM template_shifts ts + LEFT JOIN template_time_slots tts ON ts.time_slot_id = tts.id + WHERE ts.template_id = ? + ORDER BY ts.day_of_week, tts.start_time `, [id]); - const templateWithShifts = { + // Lade Zeit-Slots + const timeSlots = await db.all(` + SELECT * FROM template_time_slots + WHERE template_id = ? + ORDER BY start_time + `, [id]); + + const templateWithData = { ...template, - shifts: shifts.map(shift => ({ - id: shift.id, - dayOfWeek: shift.day_of_week, - name: shift.name, - startTime: shift.start_time, - endTime: shift.end_time, - requiredEmployees: shift.required_employees, - color: shift.color + shifts: shiftSlots.map(slot => ({ + id: slot.id, + dayOfWeek: slot.day_of_week, + timeRange: { + id: slot.time_slot_id, + name: slot.time_range_name, + startTime: slot.time_range_start, + endTime: slot.time_range_end + }, + requiredEmployees: slot.required_employees, + color: slot.color + })), + timeSlots: timeSlots.map(slot => ({ + id: slot.id, + name: slot.name, + startTime: slot.start_time, + endTime: slot.end_time, + description: slot.description })) }; - res.json(templateWithShifts); + res.json(templateWithData); } catch (error) { console.error('Error fetching template:', error); res.status(500).json({ error: 'Internal server error' }); @@ -98,32 +144,46 @@ export const createDefaultTemplate = async (userId: string): Promise => await db.run( `INSERT INTO shift_templates (id, name, description, is_default, created_by) VALUES (?, ?, ?, ?, ?)`, - [templateId, 'Standardwoche', 'Mo-Do: 2 Schichten, Fr: 1 Schicht', true, userId] + [templateId, 'Standardwoche', 'Standard Vorlage mit konfigurierten Zeit-Slots', true, userId] ); - // Vormittagsschicht Mo-Do - for (let day = 1; day <= 4; day++) { + // Füge Zeit-Slots hinzu + const timeSlots = [ + { id: uuidv4(), name: 'Vormittag', startTime: '08:00', endTime: '12:00', description: 'Vormittagsschicht' }, + { id: uuidv4(), name: 'Nachmittag', startTime: '12:00', endTime: '16:00', description: 'Nachmittagsschicht' }, + { id: uuidv4(), name: 'Abend', startTime: '16:00', endTime: '20:00', description: 'Abendschicht' } + ]; + + for (const slot of timeSlots) { await db.run( - `INSERT INTO template_shifts (id, template_id, day_of_week, name, start_time, end_time, required_employees) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [uuidv4(), templateId, day, 'Vormittagsschicht', '08:00', '12:00', 1] + `INSERT INTO template_time_slots (id, template_id, name, start_time, end_time, description) + VALUES (?, ?, ?, ?, ?, ?)`, + [slot.id, templateId, slot.name, slot.startTime, slot.endTime, slot.description] ); } - // Nachmittagsschicht Mo-Do + // Erstelle Schichten für Mo-Do mit Zeit-Slot Referenzen for (let day = 1; day <= 4; day++) { + // Vormittagsschicht await db.run( - `INSERT INTO template_shifts (id, template_id, day_of_week, name, start_time, end_time, required_employees) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [uuidv4(), templateId, day, 'Nachmittagsschicht', '11:30', '15:30', 1] + `INSERT INTO template_shifts (id, template_id, day_of_week, time_slot_id, required_employees, color) + VALUES (?, ?, ?, ?, ?, ?)`, + [uuidv4(), templateId, day, timeSlots[0].id, 1, '#3498db'] + ); + + // Nachmittagsschicht + await db.run( + `INSERT INTO template_shifts (id, template_id, day_of_week, time_slot_id, required_employees, color) + VALUES (?, ?, ?, ?, ?, ?)`, + [uuidv4(), templateId, day, timeSlots[1].id, 1, '#e74c3c'] ); } // Freitag nur Vormittagsschicht await db.run( - `INSERT INTO template_shifts (id, template_id, day_of_week, name, start_time, end_time, required_employees) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [uuidv4(), templateId, 5, 'Vormittagsschicht', '08:00', '12:00', 1] + `INSERT INTO template_shifts (id, template_id, day_of_week, time_slot_id, required_employees, color) + VALUES (?, ?, ?, ?, ?, ?)`, + [uuidv4(), templateId, 5, timeSlots[0].id, 1, '#3498db'] ); await db.run('COMMIT'); @@ -140,7 +200,7 @@ export const createDefaultTemplate = async (userId: string): Promise => export const createTemplate = async (req: Request, res: Response): Promise => { try { - const { name, description, isDefault, shifts }: CreateShiftTemplateRequest = req.body; + const { name, description, isDefault, shifts, timeSlots }: CreateShiftTemplateRequest = req.body; const userId = (req as AuthRequest).user?.userId; if (!userId) { @@ -167,13 +227,23 @@ export const createTemplate = async (req: Request, res: Response): Promise [templateId, name, description, isDefault ? 1 : 0, userId] ); + // Insert time slots + for (const timeSlot of timeSlots) { + const timeSlotId = timeSlot.id || uuidv4(); + await db.run( + `INSERT INTO template_time_slots (id, template_id, name, start_time, end_time, description) + VALUES (?, ?, ?, ?, ?, ?)`, + [timeSlotId, templateId, timeSlot.name, timeSlot.startTime, timeSlot.endTime, timeSlot.description] + ); + } + // Insert shifts for (const shift of shifts) { const shiftId = uuidv4(); await db.run( - `INSERT INTO template_shifts (id, template_id, day_of_week, name, start_time, end_time, required_employees, color) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [shiftId, templateId, shift.dayOfWeek, shift.name, shift.startTime, shift.endTime, shift.requiredEmployees, shift.color || '#3498db'] + `INSERT INTO template_shifts (id, template_id, day_of_week, time_slot_id, required_employees, color) + VALUES (?, ?, ?, ?, ?, ?)`, + [shiftId, templateId, shift.dayOfWeek, shift.timeRange.id, shift.requiredEmployees, shift.color || '#3498db'] ); } @@ -188,31 +258,8 @@ export const createTemplate = async (req: Request, res: Response): Promise await db.run('COMMIT'); // Return created template - const createdTemplate = await db.get(` - SELECT st.*, u.name as created_by_name - FROM shift_templates st - LEFT JOIN users u ON st.created_by = u.id - WHERE st.id = ? - `, [templateId]); - - const templateShifts = await db.all(` - SELECT * FROM template_shifts - WHERE template_id = ? - ORDER BY day_of_week, start_time - `, [templateId]); - - res.status(201).json({ - ...createdTemplate, - shifts: templateShifts.map(shift => ({ - id: shift.id, - dayOfWeek: shift.day_of_week, - name: shift.name, - startTime: shift.start_time, - endTime: shift.end_time, - requiredEmployees: shift.required_employees, - color: shift.color - })) - }); + const createdTemplate = await getTemplateById(templateId); + res.status(201).json(createdTemplate); } catch (error) { await db.run('ROLLBACK'); @@ -228,7 +275,7 @@ export const createTemplate = async (req: Request, res: Response): Promise export const updateTemplate = async (req: Request, res: Response): Promise => { try { const { id } = req.params; - const { name, description, isDefault, shifts }: UpdateShiftTemplateRequest = req.body; + const { name, description, isDefault, shifts, timeSlots }: UpdateShiftTemplateRequest = req.body; // Check if template exists const existingTemplate = await db.get('SELECT * FROM shift_templates WHERE id = ?', [id]); @@ -258,6 +305,22 @@ export const updateTemplate = async (req: Request, res: Response): Promise ); } + // If updating time slots, replace all time slots + if (timeSlots) { + // Delete existing time slots + await db.run('DELETE FROM template_time_slots WHERE template_id = ?', [id]); + + // Insert new time slots + for (const timeSlot of timeSlots) { + const timeSlotId = timeSlot.id || uuidv4(); + await db.run( + `INSERT INTO template_time_slots (id, template_id, name, start_time, end_time, description) + VALUES (?, ?, ?, ?, ?, ?)`, + [timeSlotId, id, timeSlot.name, timeSlot.startTime, timeSlot.endTime, timeSlot.description] + ); + } + } + // If updating shifts, replace all shifts if (shifts) { // Delete existing shifts @@ -267,9 +330,9 @@ export const updateTemplate = async (req: Request, res: Response): Promise for (const shift of shifts) { const shiftId = uuidv4(); await db.run( - `INSERT INTO template_shifts (id, template_id, day_of_week, name, start_time, end_time, required_employees, color) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, - [shiftId, id, shift.dayOfWeek, shift.name, shift.startTime, shift.endTime, shift.requiredEmployees, shift.color || '#3498db'] + `INSERT INTO template_shifts (id, template_id, day_of_week, time_slot_id, required_employees, color) + VALUES (?, ?, ?, ?, ?, ?)`, + [shiftId, id, shift.dayOfWeek, shift.timeRange.id, shift.requiredEmployees, shift.color || '#3498db'] ); } } @@ -285,31 +348,8 @@ export const updateTemplate = async (req: Request, res: Response): Promise await db.run('COMMIT'); // Return updated template - const updatedTemplate = await db.get(` - SELECT st.*, u.name as created_by_name - FROM shift_templates st - LEFT JOIN users u ON st.created_by = u.id - WHERE st.id = ? - `, [id]); - - const templateShifts = await db.all(` - SELECT * FROM template_shifts - WHERE template_id = ? - ORDER BY day_of_week, start_time - `, [id]); - - res.json({ - ...updatedTemplate, - shifts: templateShifts.map(shift => ({ - id: shift.id, - dayOfWeek: shift.day_of_week, - name: shift.name, - startTime: shift.start_time, - endTime: shift.end_time, - requiredEmployees: shift.required_employees, - color: shift.color - })) - }); + const updatedTemplate = await getTemplateById(id); + res.json(updatedTemplate); } catch (error) { await db.run('ROLLBACK'); @@ -334,11 +374,64 @@ export const deleteTemplate = async (req: Request, res: Response): Promise } await db.run('DELETE FROM shift_templates WHERE id = ?', [id]); - // Template shifts will be automatically deleted due to CASCADE + // Template shifts and time slots will be automatically deleted due to CASCADE res.status(204).send(); } catch (error) { console.error('Error deleting template:', error); res.status(500).json({ error: 'Internal server error' }); } -}; \ No newline at end of file +}; + +// Helper function to get template by ID +async function getTemplateById(templateId: string): Promise { + const template = await db.get(` + SELECT st.*, u.name as created_by_name + FROM shift_templates st + LEFT JOIN users u ON st.created_by = u.id + WHERE st.id = ? + `, [templateId]); + + if (!template) { + return null; + } + + // Lade Schicht-Slots + const shiftSlots = await db.all(` + SELECT ts.*, tts.name as time_range_name, tts.start_time as time_range_start, tts.end_time as time_range_end + FROM template_shifts ts + LEFT JOIN template_time_slots tts ON ts.time_slot_id = tts.id + WHERE ts.template_id = ? + ORDER BY ts.day_of_week, tts.start_time + `, [templateId]); + + // Lade Zeit-Slots + const timeSlots = await db.all(` + SELECT * FROM template_time_slots + WHERE template_id = ? + ORDER BY start_time + `, [templateId]); + + return { + ...template, + shifts: shiftSlots.map(slot => ({ + id: slot.id, + dayOfWeek: slot.day_of_week, + timeRange: { + id: slot.time_slot_id, + name: slot.time_range_name, + startTime: slot.time_range_start, + endTime: slot.time_range_end + }, + requiredEmployees: slot.required_employees, + color: slot.color + })), + timeSlots: timeSlots.map(slot => ({ + id: slot.id, + name: slot.name, + startTime: slot.start_time, + endTime: slot.end_time, + description: slot.description + })) + }; +} \ No newline at end of file diff --git a/backend/src/database/schema.sql b/backend/src/database/schema.sql index 1124217..34dbc17 100644 --- a/backend/src/database/schema.sql +++ b/backend/src/database/schema.sql @@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS shift_templates ( CREATE TABLE IF NOT EXISTS template_shifts ( id TEXT PRIMARY KEY, template_id TEXT NOT NULL, - day_of_week INTEGER NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 5), + day_of_week INTEGER NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 7), name TEXT NOT NULL, start_time TEXT NOT NULL, end_time TEXT NOT NULL, @@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS shift_plans ( CREATE TABLE IF NOT EXISTS assigned_shifts ( id TEXT PRIMARY KEY, + name TEXT NOT NULL, shift_plan_id TEXT NOT NULL, date TEXT NOT NULL, start_time TEXT NOT NULL, @@ -64,9 +65,9 @@ CREATE TABLE IF NOT EXISTS assigned_shifts ( CREATE TABLE IF NOT EXISTS employee_availabilities ( id TEXT PRIMARY KEY, employee_id TEXT NOT NULL, - day_of_week INTEGER NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 5), + day_of_week INTEGER NOT NULL CHECK (day_of_week >= 0 AND day_of_week <= 6), start_time TEXT NOT NULL, end_time TEXT NOT NULL, is_available BOOLEAN DEFAULT FALSE, FOREIGN KEY (employee_id) REFERENCES users(id) ON DELETE CASCADE -); \ No newline at end of file +); diff --git a/backend/src/models/Shift.ts b/backend/src/models/Shift.ts index 4115f3f..df2a78f 100644 --- a/backend/src/models/Shift.ts +++ b/backend/src/models/Shift.ts @@ -1,17 +1,37 @@ // backend/src/models/Shift.ts -export interface ShiftTemplate { +export interface Shift { id: string; name: string; - shifts: TemplateShift[]; + description?: string; + isDefault: boolean; createdBy: string; + createdAt: string; + shifts: ShiftSlot[]; } -export interface TemplateShift { - dayOfWeek: number; // 0-6 +export interface ShiftSlot { + id: string; + shiftId: string; + dayOfWeek: number; name: string; - startTime: string; // "08:00" - endTime: string; // "12:00" + startTime: string; + endTime: string; requiredEmployees: number; + color?: string; +} + +export interface CreateShiftRequest { + name: string; + description?: string; + isDefault: boolean; + shifts: Omit[]; +} + +export interface UpdateShiftSlotRequest { + name?: string; + description?: string; + isDefault?: boolean; + shifts?: Omit[]; } export interface ShiftPlan { @@ -32,4 +52,4 @@ export interface AssignedShift { endTime: string; requiredEmployees: number; assignedEmployees: string[]; -} \ No newline at end of file +} diff --git a/backend/src/models/ShiftTemplate.ts b/backend/src/models/ShiftTemplate.ts index 71fcf75..3416554 100644 --- a/backend/src/models/ShiftTemplate.ts +++ b/backend/src/models/ShiftTemplate.ts @@ -1,30 +1,35 @@ // backend/src/models/ShiftTemplate.ts -export interface ShiftTemplate { +export interface TemplateShift { id: string; name: string; description?: string; isDefault: boolean; createdBy: string; createdAt: string; - shifts: TemplateShift[]; + shifts: TemplateShiftSlot[]; } -export interface TemplateShift { +export interface TemplateShiftSlot { id: string; - templateId: string; dayOfWeek: number; - name: string; - startTime: string; - endTime: string; + timeRange: TemplateShiftTimeRange; requiredEmployees: number; color?: string; } +export interface TemplateShiftTimeRange { + id: string; + name: string; // e.g., "Frühschicht", "Spätschicht" + startTime: string; + endTime: string; +} + export interface CreateShiftTemplateRequest { name: string; description?: string; isDefault: boolean; shifts: Omit[]; + timeSlots: Omit[]; } export interface UpdateShiftTemplateRequest { @@ -32,4 +37,5 @@ export interface UpdateShiftTemplateRequest { description?: string; isDefault?: boolean; shifts?: Omit[]; + timeSlots?: Omit[]; } \ No newline at end of file diff --git a/backend/src/scripts/applyMigration.ts b/backend/src/scripts/applyMigration.ts index a13f186..1efeacc 100644 --- a/backend/src/scripts/applyMigration.ts +++ b/backend/src/scripts/applyMigration.ts @@ -1,41 +1,103 @@ import { db } from '../services/databaseService.js'; -import { readFile } from 'fs/promises'; +import { readFile, readdir } from 'fs/promises'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); +// Helper function to ensure migrations are tracked +async function ensureMigrationTable() { + await db.exec(` + CREATE TABLE IF NOT EXISTS applied_migrations ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + `); +} + +// Helper function to check if a migration has been applied +async function isMigrationApplied(migrationName: string): Promise { + const result = await db.get<{ count: number }>( + 'SELECT COUNT(*) as count FROM applied_migrations WHERE name = ?', + [migrationName] + ); + return (result?.count ?? 0) > 0; +} + +// Helper function to mark a migration as applied +async function markMigrationAsApplied(migrationName: string) { + await db.run( + 'INSERT INTO applied_migrations (id, name) VALUES (?, ?)', + [crypto.randomUUID(), migrationName] + ); +} + export async function applyMigration() { try { console.log('📦 Starting database migration...'); - // Read the migration file - const migrationPath = join(__dirname, '../database/migrations/002_add_employee_fields.sql'); - const migrationSQL = await readFile(migrationPath, 'utf-8'); + // Ensure migration tracking table exists + await ensureMigrationTable(); - // Split into individual statements - const statements = migrationSQL - .split(';') - .map(s => s.trim()) - .filter(s => s.length > 0); + // Get all migration files + const migrationsDir = join(__dirname, '../database/migrations'); + const files = await readdir(migrationsDir); - // Execute each statement - for (const statement of statements) { + // Sort files to ensure consistent order + const migrationFiles = files + .filter(f => f.endsWith('.sql')) + .sort(); + + // Process each migration file + for (const migrationFile of migrationFiles) { + if (await isMigrationApplied(migrationFile)) { + console.log(`ℹ️ Migration ${migrationFile} already applied, skipping...`); + continue; + } + + console.log(`📄 Applying migration: ${migrationFile}`); + const migrationPath = join(migrationsDir, migrationFile); + const migrationSQL = await readFile(migrationPath, 'utf-8'); + + // Split into individual statements + const statements = migrationSQL + .split(';') + .map(s => s.trim()) + .filter(s => s.length > 0); + + // Start transaction for this migration + await db.run('BEGIN TRANSACTION'); + try { - await db.exec(statement); - console.log('✅ Executed:', statement.slice(0, 50) + '...'); - } catch (error) { - const err = error as { code: string; message: string }; - if (err.code === 'SQLITE_ERROR' && err.message.includes('duplicate column name')) { - console.log('ℹ️ Column already exists, skipping...'); - continue; + // Execute each statement + for (const statement of statements) { + try { + await db.exec(statement); + console.log('✅ Executed:', statement.slice(0, 50) + '...'); + } catch (error) { + const err = error as { code: string; message: string }; + if (err.code === 'SQLITE_ERROR' && err.message.includes('duplicate column name')) { + console.log('ℹ️ Column already exists, skipping...'); + continue; + } + throw error; + } } + + // Mark migration as applied + await markMigrationAsApplied(migrationFile); + await db.run('COMMIT'); + console.log(`✅ Migration ${migrationFile} applied successfully`); + + } catch (error) { + await db.run('ROLLBACK'); throw error; } } - console.log('✅ Migration completed successfully'); + console.log('✅ All migrations completed successfully'); } catch (error) { console.error('❌ Migration failed:', error); throw error; diff --git a/backend/src/scripts/checkTemplates.ts b/backend/src/scripts/checkTemplates.ts index 77cdc21..56c217a 100644 --- a/backend/src/scripts/checkTemplates.ts +++ b/backend/src/scripts/checkTemplates.ts @@ -1,9 +1,9 @@ import { db } from '../services/databaseService.js'; -import { ShiftTemplate } from '../models/ShiftTemplate.js'; +import { TemplateShift } from '../models/ShiftTemplate.js'; async function checkTemplates() { try { - const templates = await db.all( + const templates = await db.all( `SELECT st.*, u.name as created_by_name FROM shift_templates st LEFT JOIN users u ON st.created_by = u.id` diff --git a/backend/src/scripts/setupDefaultTemplate.ts b/backend/src/scripts/setupDefaultTemplate.ts index 1038a69..9f79ba1 100644 --- a/backend/src/scripts/setupDefaultTemplate.ts +++ b/backend/src/scripts/setupDefaultTemplate.ts @@ -55,7 +55,7 @@ export async function setupDefaultTemplate(): Promise { console.log('Standard-Vorlage erstellt:', templateId); // Vormittagsschicht Mo-Do - for (let day = 1; day <= 4; day++) { + for (let day = 1; day <= 5; day++) { await db.run( `INSERT INTO template_shifts (id, template_id, day_of_week, name, start_time, end_time, required_employees) VALUES (?, ?, ?, ?, ?, ?, ?)`, @@ -63,7 +63,7 @@ export async function setupDefaultTemplate(): Promise { ); } - console.log('Vormittagsschichten Mo-Do erstellt'); + console.log('Vormittagsschichten Mo-Fr erstellt'); // Nachmittagsschicht Mo-Do for (let day = 1; day <= 4; day++) { @@ -76,15 +76,6 @@ export async function setupDefaultTemplate(): Promise { console.log('Nachmittagsschichten Mo-Do erstellt'); - // Freitag nur Vormittagsschicht - await db.run( - `INSERT INTO template_shifts (id, template_id, day_of_week, name, start_time, end_time, required_employees) - VALUES (?, ?, ?, ?, ?, ?, ?)`, - [uuidv4(), templateId, 5, 'Vormittagsschicht', '08:00', '12:00', 1] - ); - - console.log('Freitag Vormittagsschicht erstellt'); - await db.run('COMMIT'); console.log('Standard-Vorlage erfolgreich initialisiert'); } catch (error) { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1327787..ff2d005 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,8 @@ import Login from './pages/Auth/Login'; import Dashboard from './pages/Dashboard/Dashboard'; import ShiftPlanList from './pages/ShiftPlans/ShiftPlanList'; import ShiftPlanCreate from './pages/ShiftPlans/ShiftPlanCreate'; +import ShiftPlanEdit from './pages/ShiftPlans/ShiftPlanEdit'; +import ShiftPlanView from './pages/ShiftPlans/ShiftPlanView'; import EmployeeManagement from './pages/Employees/EmployeeManagement'; import Settings from './pages/Settings/Settings'; import Help from './pages/Help/Help'; @@ -93,6 +95,16 @@ const AppContent: React.FC = () => { } /> + + + + } /> + + + + } /> diff --git a/frontend/src/pages/Employees/components/AvailabilityManager.tsx b/frontend/src/pages/Employees/components/AvailabilityManager.tsx index b741155..e64b535 100644 --- a/frontend/src/pages/Employees/components/AvailabilityManager.tsx +++ b/frontend/src/pages/Employees/components/AvailabilityManager.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Employee, Availability } from '../../../types/employee'; import { employeeService } from '../../../services/employeeService'; +import { shiftPlanService, ShiftPlan, ShiftPlanShift } from '../../../services/shiftPlanService'; interface AvailabilityManagerProps { employee: Employee; @@ -9,12 +10,18 @@ interface AvailabilityManagerProps { onCancel: () => void; } +// Verfügbarkeits-Level +export type AvailabilityLevel = 1 | 2 | 3; // 1: bevorzugt, 2: möglich, 3: nicht möglich + const AvailabilityManager: React.FC = ({ employee, onSave, onCancel }) => { const [availabilities, setAvailabilities] = useState([]); + const [shiftPlans, setShiftPlans] = useState([]); + const [selectedPlanId, setSelectedPlanId] = useState(''); + const [selectedPlan, setSelectedPlan] = useState(null); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); @@ -29,53 +36,112 @@ const AvailabilityManager: React.FC = ({ { id: 0, name: 'Sonntag' } ]; - const defaultTimeSlots = [ - { name: 'Vormittag', start: '08:00', end: '12:00' }, - { name: 'Nachmittag', start: '12:00', end: '16:00' }, - { name: 'Abend', start: '16:00', end: '20:00' } + // Verfügbarkeits-Level mit Farben und Beschreibungen + const availabilityLevels = [ + { level: 1 as AvailabilityLevel, label: 'Bevorzugt', color: '#27ae60', bgColor: '#d5f4e6', description: 'Ideale Zeit' }, + { level: 2 as AvailabilityLevel, label: 'Möglich', color: '#f39c12', bgColor: '#fef5e7', description: 'Akzeptable Zeit' }, + { level: 3 as AvailabilityLevel, label: 'Nicht möglich', color: '#e74c3c', bgColor: '#fadbd8', description: 'Nicht verfügbar' } ]; useEffect(() => { - loadAvailabilities(); + loadData(); }, [employee.id]); - const loadAvailabilities = async () => { + useEffect(() => { + if (selectedPlanId) { + loadSelectedPlan(); + } + }, [selectedPlanId]); + + const loadData = async () => { try { setLoading(true); - const data = await employeeService.getAvailabilities(employee.id); - setAvailabilities(data); + + // Load availabilities + try { + const availData = await employeeService.getAvailabilities(employee.id); + setAvailabilities(availData); + } catch (err) { + // Falls keine Verfügbarkeiten existieren, erstelle Standard-Einträge (Level 3: nicht möglich) + const defaultAvailabilities: Availability[] = daysOfWeek.flatMap(day => [ + { + id: `temp-${day.id}-morning`, + employeeId: employee.id, + dayOfWeek: day.id, + startTime: '08:00', + endTime: '12:00', + isAvailable: false, + availabilityLevel: 3 as AvailabilityLevel + }, + { + id: `temp-${day.id}-afternoon`, + employeeId: employee.id, + dayOfWeek: day.id, + startTime: '12:00', + endTime: '16:00', + isAvailable: false, + availabilityLevel: 3 as AvailabilityLevel + }, + { + id: `temp-${day.id}-evening`, + employeeId: employee.id, + dayOfWeek: day.id, + startTime: '16:00', + endTime: '20:00', + isAvailable: false, + availabilityLevel: 3 as AvailabilityLevel + } + ]); + setAvailabilities(defaultAvailabilities); + } + + // Load shift plans + const plans = await shiftPlanService.getShiftPlans(); + setShiftPlans(plans); + + // Auto-select the first published plan or the first draft + if (plans.length > 0) { + const publishedPlan = plans.find(plan => plan.status === 'published'); + const firstPlan = publishedPlan || plans[0]; + setSelectedPlanId(firstPlan.id); + } } catch (err: any) { - // Falls keine Verfügbarkeiten existieren, erstelle Standard-Einträge - const defaultAvailabilities = daysOfWeek.flatMap(day => - defaultTimeSlots.map(slot => ({ - id: `temp-${day.id}-${slot.name}`, - employeeId: employee.id, - dayOfWeek: day.id, - startTime: slot.start, - endTime: slot.end, - isAvailable: false - })) - ); - setAvailabilities(defaultAvailabilities); + console.error('Error loading data:', err); + setError('Daten konnten nicht geladen werden'); } finally { setLoading(false); } }; - const handleAvailabilityChange = (id: string, isAvailable: boolean) => { + const loadSelectedPlan = async () => { + try { + const plan = await shiftPlanService.getShiftPlan(selectedPlanId); + setSelectedPlan(plan); + } catch (err: any) { + console.error('Error loading shift plan:', err); + setError('Schichtplan konnte nicht geladen werden'); + } + }; + + const handleAvailabilityLevelChange = (dayId: number, timeSlot: string, level: AvailabilityLevel) => { setAvailabilities(prev => prev.map(avail => - avail.id === id ? { ...avail, isAvailable } : avail + avail.dayOfWeek === dayId && getTimeSlotName(avail.startTime, avail.endTime) === timeSlot + ? { + ...avail, + availabilityLevel: level, + isAvailable: level !== 3 + } + : avail ) ); }; - const handleTimeChange = (id: string, field: 'startTime' | 'endTime', value: string) => { - setAvailabilities(prev => - prev.map(avail => - avail.id === id ? { ...avail, [field]: value } : avail - ) - ); + const getTimeSlotName = (startTime: string, endTime: string): string => { + if (startTime === '08:00' && endTime === '12:00') return 'Vormittag'; + if (startTime === '12:00' && endTime === '16:00') return 'Nachmittag'; + if (startTime === '16:00' && endTime === '20:00') return 'Abend'; + return `${startTime}-${endTime}`; }; const handleSave = async () => { @@ -92,8 +158,91 @@ const AvailabilityManager: React.FC = ({ } }; - const getAvailabilitiesForDay = (dayId: number) => { - return availabilities.filter(avail => avail.dayOfWeek === dayId); + // Get availability level for a specific shift + const getAvailabilityForShift = (shift: ShiftPlanShift): AvailabilityLevel => { + const shiftDate = new Date(shift.date); + const dayOfWeek = shiftDate.getDay(); // 0 = Sunday, 1 = Monday, etc. + + // Find matching availability for this day and time + const matchingAvailabilities = availabilities.filter(avail => + avail.dayOfWeek === dayOfWeek && + avail.availabilityLevel !== 3 && // Nur Level 1 und 2 berücksichtigen + isTimeOverlap(avail.startTime, avail.endTime, shift.startTime, shift.endTime) + ); + + if (matchingAvailabilities.length === 0) { + return 3; // Nicht möglich, wenn keine Übereinstimmung + } + + // Nehme das beste (niedrigste) Verfügbarkeits-Level + const minLevel = Math.min(...matchingAvailabilities.map(avail => avail.availabilityLevel)); + return minLevel as AvailabilityLevel; + }; + + // Helper function to check time overlap + const isTimeOverlap = (availStart: string, availEnd: string, shiftStart: string, shiftEnd: string): boolean => { + const availStartMinutes = timeToMinutes(availStart); + const availEndMinutes = timeToMinutes(availEnd); + const shiftStartMinutes = timeToMinutes(shiftStart); + const shiftEndMinutes = timeToMinutes(shiftEnd); + + return shiftStartMinutes < availEndMinutes && shiftEndMinutes > availStartMinutes; + }; + + const timeToMinutes = (time: string): number => { + const [hours, minutes] = time.split(':').map(Number); + return hours * 60 + minutes; + }; + + // Group shifts by weekday for timetable display + const getTimetableData = () => { + if (!selectedPlan) return { shiftsByDay: {}, weekdays: [] }; + + const shiftsByDay: Record = {}; + + // Initialize empty arrays for each day + daysOfWeek.forEach(day => { + shiftsByDay[day.id] = []; + }); + + // Group shifts by weekday + selectedPlan.shifts.forEach(shift => { + const shiftDate = new Date(shift.date); + const dayOfWeek = shiftDate.getDay(); // 0 = Sunday, 1 = Monday, etc. + shiftsByDay[dayOfWeek].push(shift); + }); + + // Remove duplicate shifts (same name and time on same day) + Object.keys(shiftsByDay).forEach(day => { + const dayNum = parseInt(day); + const uniqueShifts: ShiftPlanShift[] = []; + const seen = new Set(); + + shiftsByDay[dayNum].forEach(shift => { + const key = `${shift.name}|${shift.startTime}|${shift.endTime}`; + if (!seen.has(key)) { + seen.add(key); + uniqueShifts.push(shift); + } + }); + + shiftsByDay[dayNum] = uniqueShifts; + }); + + return { + shiftsByDay, + weekdays: daysOfWeek + }; + }; + + const timetableData = getTimetableData(); + + // Get availability for a specific day and time slot + const getAvailabilityForDayAndSlot = (dayId: number, timeSlot: string): AvailabilityLevel => { + const availability = availabilities.find(avail => + avail.dayOfWeek === dayId && getTimeSlotName(avail.startTime, avail.endTime) === timeSlot + ); + return availability?.availabilityLevel || 3; }; if (loading) { @@ -106,7 +255,7 @@ const AvailabilityManager: React.FC = ({ return (
= ({ {employee.name}

- Legen Sie fest, an welchen Tagen und Zeiten {employee.name} verfügbar ist. + Legen Sie die Verfügbarkeit für {employee.name} fest (1: bevorzugt, 2: möglich, 3: nicht möglich).

@@ -145,133 +294,235 @@ const AvailabilityManager: React.FC = ({ )} - {/* Verfügbarkeiten Tabelle */} + {/* Verfügbarkeits-Legende */}
- {daysOfWeek.map((day, dayIndex) => { - const dayAvailabilities = getAvailabilitiesForDay(day.id); - const isLastDay = dayIndex === daysOfWeek.length - 1; - - return ( -
- {/* Tag Header */} -
- {day.name} -
- - {/* Zeit-Slots */} -
- {dayAvailabilities.map((availability, availabilityIndex) => { - const isLastAvailability = availabilityIndex === dayAvailabilities.length - 1; - - return ( -
- {/* Verfügbarkeit Toggle */} -
- handleAvailabilityChange(availability.id, e.target.checked)} - style={{ width: '18px', height: '18px' }} - /> - -
- - {/* Startzeit */} -
- - handleTimeChange(availability.id, 'startTime', e.target.value)} - disabled={!availability.isAvailable} - style={{ - padding: '6px 8px', - border: `1px solid ${availability.isAvailable ? '#ddd' : '#f0f0f0'}`, - borderRadius: '4px', - backgroundColor: availability.isAvailable ? 'white' : '#f8f9fa', - color: availability.isAvailable ? '#333' : '#999' - }} - /> -
- - {/* Endzeit */} -
- - handleTimeChange(availability.id, 'endTime', e.target.value)} - disabled={!availability.isAvailable} - style={{ - padding: '6px 8px', - border: `1px solid ${availability.isAvailable ? '#ddd' : '#f0f0f0'}`, - borderRadius: '4px', - backgroundColor: availability.isAvailable ? 'white' : '#f8f9fa', - color: availability.isAvailable ? '#333' : '#999' - }} - /> -
- - {/* Status Badge */} -
- - {availability.isAvailable ? 'Aktiv' : 'Inaktiv'} - -
-
- ); - })} +

+ Verfügbarkeits-Level +

+ +
+ {availabilityLevels.map(level => ( +
+
+
+
+ {level.level}: {level.label} +
+
+ {level.description} +
- ); - })} + ))} +
+ {/* Schichtplan Auswahl */} +
+

+ Verfügbarkeit für Schichtplan prüfen +

+ +
+
+ + +
+ + {selectedPlan && ( +
+ Zeitraum: {new Date(selectedPlan.startDate).toLocaleDateString('de-DE')} - {new Date(selectedPlan.endDate).toLocaleDateString('de-DE')} +
+ )} +
+
+ + {/* Verfügbarkeits-Timetable mit Dropdown-Menüs */} + {selectedPlan && ( +
+
+ Verfügbarkeit für: {selectedPlan.name} +
+ +
+ + + + + {timetableData.weekdays.map(weekday => ( + + ))} + + + + {['Vormittag', 'Nachmittag', 'Abend'].map((timeSlot, timeIndex) => ( + + + {timetableData.weekdays.map(weekday => { + const currentLevel = getAvailabilityForDayAndSlot(weekday.id, timeSlot); + const levelConfig = availabilityLevels.find(l => l.level === currentLevel); + + return ( + + ); + })} + + ))} + +
+ Zeit + + {weekday.name} +
+ {timeSlot} +
+ {timeSlot === 'Vormittag' ? '08:00-12:00' : + timeSlot === 'Nachmittag' ? '12:00-16:00' : '16:00-20:00'} +
+
+ +
+ {levelConfig?.description} +
+
+
+ + {/* Legende */} +
+ Legende: + {availabilityLevels.map(level => ( + +
+ {level.level}: {level.label} + + ))} +
+
+ )} + {/* Info Text */}
= ({ }}>

💡 Information

- Verfügbarkeiten bestimmen, wann dieser Mitarbeiter für Schichten eingeplant werden kann. - Nur als "verfügbar" markierte Zeitfenster werden bei der automatischen Schichtplanung berücksichtigt. + 1: Bevorzugt - Ideale Zeit für diesen Mitarbeiter
+ 2: Möglich - Akzeptable Zeit, falls benötigt
+ 3: Nicht möglich - Mitarbeiter ist nicht verfügbar
+ Das System priorisiert Mitarbeiter mit Level 1 für Schichtzuweisungen.

diff --git a/frontend/src/pages/ShiftPlans/ShiftPlanCreate.tsx b/frontend/src/pages/ShiftPlans/ShiftPlanCreate.tsx index ead9821..a354a12 100644 --- a/frontend/src/pages/ShiftPlans/ShiftPlanCreate.tsx +++ b/frontend/src/pages/ShiftPlans/ShiftPlanCreate.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { shiftTemplateService } from '../../services/shiftTemplateService'; import { shiftPlanService } from '../../services/shiftPlanService'; -import { ShiftTemplate } from '../../types/shiftTemplate'; +import { TemplateShift } from '../../types/shiftTemplate'; import styles from './ShiftPlanCreate.module.css'; const ShiftPlanCreate: React.FC = () => { @@ -14,7 +14,7 @@ const ShiftPlanCreate: React.FC = () => { const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [selectedTemplate, setSelectedTemplate] = useState(''); - const [templates, setTemplates] = useState([]); + const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); diff --git a/frontend/src/pages/ShiftPlans/ShiftPlanEdit.tsx b/frontend/src/pages/ShiftPlans/ShiftPlanEdit.tsx new file mode 100644 index 0000000..ef0acdf --- /dev/null +++ b/frontend/src/pages/ShiftPlans/ShiftPlanEdit.tsx @@ -0,0 +1,430 @@ +// frontend/src/pages/ShiftPlans/ShiftPlanEdit.tsx +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { shiftPlanService, ShiftPlan, ShiftPlanShift } from '../../services/shiftPlanService'; +import { useNotification } from '../../contexts/NotificationContext'; + +const ShiftPlanEdit: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { showNotification } = useNotification(); + const [shiftPlan, setShiftPlan] = useState(null); + const [loading, setLoading] = useState(true); + const [editingShift, setEditingShift] = useState(null); + const [newShift, setNewShift] = useState>({ + date: '', + name: '', + startTime: '', + endTime: '', + requiredEmployees: 1 + }); + + useEffect(() => { + loadShiftPlan(); + }, [id]); + + const loadShiftPlan = async () => { + if (!id) return; + try { + const plan = await shiftPlanService.getShiftPlan(id); + setShiftPlan(plan); + } catch (error) { + console.error('Error loading shift plan:', error); + showNotification({ + type: 'error', + title: 'Fehler', + message: 'Der Schichtplan konnte nicht geladen werden.' + }); + navigate('/shift-plans'); + } finally { + setLoading(false); + } + }; + + const handleUpdateShift = async (shift: ShiftPlanShift) => { + if (!shiftPlan || !id) return; + + try { + await shiftPlanService.updateShiftPlanShift(id, shift); + showNotification({ + type: 'success', + title: 'Erfolg', + message: 'Schicht wurde aktualisiert.' + }); + loadShiftPlan(); + setEditingShift(null); + } catch (error) { + console.error('Error updating shift:', error); + showNotification({ + type: 'error', + title: 'Fehler', + message: 'Die Schicht konnte nicht aktualisiert werden.' + }); + } + }; + + const handleAddShift = async () => { + if (!shiftPlan || !id) return; + + if (!newShift.date || !newShift.name || !newShift.startTime || !newShift.endTime || !newShift.requiredEmployees) { + showNotification({ + type: 'error', + title: 'Fehler', + message: 'Bitte füllen Sie alle Pflichtfelder aus.' + }); + return; + } + + try { + await shiftPlanService.addShiftPlanShift(id, { + date: newShift.date, + name: newShift.name, + startTime: newShift.startTime, + endTime: newShift.endTime, + requiredEmployees: Number(newShift.requiredEmployees) + }); + showNotification({ + type: 'success', + title: 'Erfolg', + message: 'Neue Schicht wurde hinzugefügt.' + }); + setNewShift({ + date: '', + name: '', + startTime: '', + endTime: '', + requiredEmployees: 1 + }); + loadShiftPlan(); + } catch (error) { + console.error('Error adding shift:', error); + showNotification({ + type: 'error', + title: 'Fehler', + message: 'Die Schicht konnte nicht hinzugefügt werden.' + }); + } + }; + + const handleDeleteShift = async (shiftId: string) => { + if (!window.confirm('Möchten Sie diese Schicht wirklich löschen?')) { + return; + } + + try { + await shiftPlanService.deleteShiftPlanShift(id!, shiftId); + showNotification({ + type: 'success', + title: 'Erfolg', + message: 'Schicht wurde gelöscht.' + }); + loadShiftPlan(); + } catch (error) { + console.error('Error deleting shift:', error); + showNotification({ + type: 'error', + title: 'Fehler', + message: 'Die Schicht konnte nicht gelöscht werden.' + }); + } + }; + + const handlePublish = async () => { + if (!shiftPlan || !id) return; + + try { + await shiftPlanService.updateShiftPlan(id, { + ...shiftPlan, + status: 'published' + }); + showNotification({ + type: 'success', + title: 'Erfolg', + message: 'Schichtplan wurde veröffentlicht.' + }); + loadShiftPlan(); + } catch (error) { + console.error('Error publishing shift plan:', error); + showNotification({ + type: 'error', + title: 'Fehler', + message: 'Der Schichtplan konnte nicht veröffentlicht werden.' + }); + } + }; + + if (loading) { + return
Lade Schichtplan...
; + } + + if (!shiftPlan) { + return
Schichtplan nicht gefunden
; + } + + // Group shifts by date + const shiftsByDate = shiftPlan.shifts.reduce((acc, shift) => { + if (!acc[shift.date]) { + acc[shift.date] = []; + } + acc[shift.date].push(shift); + return acc; + }, {} as Record); + + return ( +
+
+

{shiftPlan.name} bearbeiten

+
+ {shiftPlan.status === 'draft' && ( + + )} + +
+
+ + {/* Add new shift form */} +
+

Neue Schicht hinzufügen

+
+
+ + setNewShift({ ...newShift, date: e.target.value })} + style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} + /> +
+
+ + setNewShift({ ...newShift, name: e.target.value })} + style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} + /> +
+
+ + setNewShift({ ...newShift, startTime: e.target.value })} + style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} + /> +
+
+ + setNewShift({ ...newShift, endTime: e.target.value })} + style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} + /> +
+
+ + setNewShift({ ...newShift, requiredEmployees: parseInt(e.target.value) })} + style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} + /> +
+
+ +
+ + {/* Existing shifts */} +
+ {Object.entries(shiftsByDate).map(([date, shifts]) => ( +
+

{new Date(date).toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: '2-digit', year: 'numeric' })}

+
+ {shifts.map(shift => ( +
+ {editingShift?.id === shift.id ? ( +
+
+ + setEditingShift({ ...editingShift, name: e.target.value })} + style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} + /> +
+
+ + setEditingShift({ ...editingShift, startTime: e.target.value })} + style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} + /> +
+
+ + setEditingShift({ ...editingShift, endTime: e.target.value })} + style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} + /> +
+
+ + setEditingShift({ ...editingShift, requiredEmployees: parseInt(e.target.value) })} + style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} + /> +
+
+ + +
+
+ ) : ( + <> +
+ {shift.name} +
+
+
+ Zeit: {shift.startTime.substring(0, 5)} - {shift.endTime.substring(0, 5)} + | + Benötigte Mitarbeiter: {shift.requiredEmployees} + | + Zugewiesen: {shift.assignedEmployees.length}/{shift.requiredEmployees} +
+
+ + +
+
+ + )} +
+ ))} +
+
+ ))} +
+
+ ); +}; + +export default ShiftPlanEdit; \ No newline at end of file diff --git a/frontend/src/pages/ShiftPlans/ShiftPlanView.tsx b/frontend/src/pages/ShiftPlans/ShiftPlanView.tsx new file mode 100644 index 0000000..c9b580d --- /dev/null +++ b/frontend/src/pages/ShiftPlans/ShiftPlanView.tsx @@ -0,0 +1,292 @@ +// frontend/src/pages/ShiftPlans/ShiftPlanView.tsx +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useAuth } from '../../contexts/AuthContext'; +import { shiftPlanService, ShiftPlan } from '../../services/shiftPlanService'; +import { useNotification } from '../../contexts/NotificationContext'; + +const ShiftPlanView: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const { hasRole } = useAuth(); + const { showNotification } = useNotification(); + const [shiftPlan, setShiftPlan] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadShiftPlan(); + }, [id]); + + const loadShiftPlan = async () => { + if (!id) return; + try { + const plan = await shiftPlanService.getShiftPlan(id); + setShiftPlan(plan); + } catch (error) { + console.error('Error loading shift plan:', error); + showNotification({ + type: 'error', + title: 'Fehler', + message: 'Der Schichtplan konnte nicht geladen werden.' + }); + navigate('/shift-plans'); + } finally { + setLoading(false); + } + }; + + const formatDate = (dateString: string | undefined): string => { + if (!dateString) return 'Kein Datum'; + + const date = new Date(dateString); + + if (isNaN(date.getTime())) { + return 'Ungültiges Datum'; + } + + return date.toLocaleDateString('de-DE', { + weekday: 'long', + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); + }; + + const formatTime = (timeString: string) => { + return timeString.substring(0, 5); + }; + + // Get unique shift types and their staffing per weekday + const getTimetableData = () => { + if (!shiftPlan) return { shifts: [], weekdays: [] }; + + // Get all unique shift types (name + time combination) + const shiftTypes = Array.from(new Set( + shiftPlan.shifts.map(shift => + `${shift.name}|${shift.startTime}|${shift.endTime}` + ) + )).map(shiftKey => { + const [name, startTime, endTime] = shiftKey.split('|'); + return { name, startTime, endTime }; + }); + + // Weekdays (1=Monday, 7=Sunday) + const weekdays = [1, 2, 3, 4, 5, 6, 7]; + + // For each shift type and weekday, calculate staffing + const timetableShifts = shiftTypes.map(shiftType => { + const weekdayData: Record = {}; + + weekdays.forEach(weekday => { + // Find all shifts of this type on this weekday + const shiftsOnDay = shiftPlan.shifts.filter(shift => { + const date = new Date(shift.date); + const dayOfWeek = date.getDay() === 0 ? 7 : date.getDay(); // Convert to 1-7 (Mon-Sun) + return dayOfWeek === weekday && + shift.name === shiftType.name && + shift.startTime === shiftType.startTime && + shift.endTime === shiftType.endTime; + }); + + if (shiftsOnDay.length === 0) { + weekdayData[weekday] = ''; + } else { + const totalAssigned = shiftsOnDay.reduce((sum, shift) => sum + shift.assignedEmployees.length, 0); + const totalRequired = shiftsOnDay.reduce((sum, shift) => sum + shift.requiredEmployees, 0); + weekdayData[weekday] = `${totalAssigned}/${totalRequired}`; + } + }); + + return { + ...shiftType, + displayName: `${shiftType.name} (${formatTime(shiftType.startTime)}–${formatTime(shiftType.endTime)})`, + weekdayData + }; + }); + + return { + shifts: timetableShifts, + weekdays: weekdays.map(day => ({ + id: day, + name: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][day === 7 ? 0 : day] + })) + }; + }; + + if (loading) { + return
Lade Schichtplan...
; + } + + if (!shiftPlan) { + return
Schichtplan nicht gefunden
; + } + + const timetableData = getTimetableData(); + + return ( +
+
+
+

{shiftPlan.name}

+

+ Zeitraum: {formatDate(shiftPlan.startDate)} - {formatDate(shiftPlan.endDate)} +

+

+ Status: + {shiftPlan.status === 'published' ? 'Veröffentlicht' : 'Entwurf'} + +

+
+
+ {hasRole(['admin', 'instandhalter']) && ( + + )} + +
+
+ +
+
+
Zeitraum: {formatDate(shiftPlan.startDate)} - {formatDate(shiftPlan.endDate)}
+
Status: + {shiftPlan.status === 'published' ? 'Veröffentlicht' : 'Entwurf'} +
+
+ + {/* Timetable */} +
+

Schichtplan

+ + {timetableData.shifts.length === 0 ? ( +
+ Keine Schichten für diesen Zeitraum konfiguriert +
+ ) : ( +
+ + + + + {timetableData.weekdays.map(weekday => ( + + ))} + + + + {timetableData.shifts.map((shift, index) => ( + + + {timetableData.weekdays.map(weekday => ( + + ))} + + ))} + +
+ Schicht (Zeit) + + {weekday.name} +
+ {shift.displayName} + + {shift.weekdayData[weekday.id] || '–'} +
+
+ )} +
+ + {/* Summary */} + {timetableData.shifts.length > 0 && ( +
+ Legende: Angezeigt wird "zugewiesene/benötigte Mitarbeiter" pro Schicht und Wochentag +
+ )} +
+
+ ); +}; + +export default ShiftPlanView; \ No newline at end of file diff --git a/frontend/src/pages/ShiftTemplates/ShiftTemplateEditor.tsx b/frontend/src/pages/ShiftTemplates/ShiftTemplateEditor.tsx index 1c49f94..1ed6566 100644 --- a/frontend/src/pages/ShiftTemplates/ShiftTemplateEditor.tsx +++ b/frontend/src/pages/ShiftTemplates/ShiftTemplateEditor.tsx @@ -1,22 +1,20 @@ // frontend/src/pages/ShiftTemplates/ShiftTemplateEditor.tsx import React, { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { ShiftTemplate, TemplateShift, DEFAULT_DAYS } from '../../types/shiftTemplate'; +import { TemplateShiftSlot, TemplateShift, TemplateShiftTimeRange, DEFAULT_DAYS } from '../../types/shiftTemplate'; import { shiftTemplateService } from '../../services/shiftTemplateService'; import ShiftDayEditor from './components/ShiftDayEditor'; import DefaultTemplateView from './components/DefaultTemplateView'; import styles from './ShiftTemplateEditor.module.css'; -interface ExtendedTemplateShift extends Omit { +interface ExtendedTemplateShift extends Omit { id?: string; isPreview?: boolean; } const defaultShift: ExtendedTemplateShift = { dayOfWeek: 1, // Montag - name: '', - startTime: '08:00', - endTime: '12:00', + timeRange: { id: '', name: '', startTime: '', endTime: '' }, requiredEmployees: 1, color: '#3498db' }; @@ -26,7 +24,7 @@ const ShiftTemplateEditor: React.FC = () => { const navigate = useNavigate(); const isEditing = !!id; - const [template, setTemplate] = useState>({ + const [template, setTemplate] = useState>({ name: '', description: '', shifts: [], @@ -83,11 +81,13 @@ const ShiftTemplateEditor: React.FC = () => { }; const addShift = (dayOfWeek: number) => { - const newShift: TemplateShift = { + const newShift: TemplateShiftSlot = { ...defaultShift, id: Date.now().toString(), dayOfWeek, - name: `Schicht ${template.shifts.filter(s => s.dayOfWeek === dayOfWeek).length + 1}` + timeRange: { ...defaultShift.timeRange, id: Date.now().toString() }, + requiredEmployees: defaultShift.requiredEmployees, + color: defaultShift.color }; setTemplate(prev => ({ @@ -113,7 +113,7 @@ const ShiftTemplateEditor: React.FC = () => { }; // Preview-Daten für die DefaultTemplateView vorbereiten - const previewTemplate: ShiftTemplate = { + const previewTemplate: TemplateShift = { id: 'preview', name: template.name || 'Vorschau', description: template.description, diff --git a/frontend/src/pages/ShiftTemplates/ShiftTemplateList.tsx b/frontend/src/pages/ShiftTemplates/ShiftTemplateList.tsx index ec4455a..1d2c701 100644 --- a/frontend/src/pages/ShiftTemplates/ShiftTemplateList.tsx +++ b/frontend/src/pages/ShiftTemplates/ShiftTemplateList.tsx @@ -1,17 +1,17 @@ // frontend/src/pages/ShiftTemplates/ShiftTemplateList.tsx import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { ShiftTemplate } from '../../types/shiftTemplate'; +import { TemplateShift } from '../../types/shiftTemplate'; import { shiftTemplateService } from '../../services/shiftTemplateService'; import { useAuth } from '../../contexts/AuthContext'; import DefaultTemplateView from './components/DefaultTemplateView'; import styles from './ShiftTemplateList.module.css'; const ShiftTemplateList: React.FC = () => { - const [templates, setTemplates] = useState([]); + const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); const { hasRole } = useAuth(); - const [selectedTemplate, setSelectedTemplate] = useState(null); + const [selectedTemplate, setSelectedTemplate] = useState(null); useEffect(() => { loadTemplates(); diff --git a/frontend/src/pages/ShiftTemplates/components/DefaultTemplateView.tsx b/frontend/src/pages/ShiftTemplates/components/DefaultTemplateView.tsx index 7f12a98..ce211a9 100644 --- a/frontend/src/pages/ShiftTemplates/components/DefaultTemplateView.tsx +++ b/frontend/src/pages/ShiftTemplates/components/DefaultTemplateView.tsx @@ -1,10 +1,10 @@ // frontend/src/pages/ShiftTemplates/components/DefaultTemplateView.tsx import React from 'react'; -import { ShiftTemplate } from '../../../types/shiftTemplate'; +import { TemplateShift } from '../../../types/shiftTemplate'; import styles from './DefaultTemplateView.module.css'; interface DefaultTemplateViewProps { - template: ShiftTemplate; + template: TemplateShift; } const DefaultTemplateView: React.FC = ({ template }) => { @@ -38,9 +38,9 @@ const DefaultTemplateView: React.FC = ({ template }) =
{shiftsByDay[dayIndex]?.map(shift => (
-

{shift.name}

+

{shift.timeRange.name}

- {formatTime(shift.startTime)} - {formatTime(shift.endTime)} + {formatTime(shift.timeRange.startTime)} - {formatTime(shift.timeRange.endTime)}

))} diff --git a/frontend/src/pages/ShiftTemplates/components/ShiftDayEditor.tsx b/frontend/src/pages/ShiftTemplates/components/ShiftDayEditor.tsx index cd0480f..107efe9 100644 --- a/frontend/src/pages/ShiftTemplates/components/ShiftDayEditor.tsx +++ b/frontend/src/pages/ShiftTemplates/components/ShiftDayEditor.tsx @@ -1,13 +1,13 @@ // frontend/src/pages/ShiftTemplates/components/ShiftDayEditor.tsx import React from 'react'; -import { TemplateShift } from '../../../types/shiftTemplate'; +import { TemplateShiftSlot } from '../../../types/shiftTemplate'; import styles from './ShiftDayEditor.module.css'; interface ShiftDayEditorProps { day: { id: number; name: string }; - shifts: TemplateShift[]; + shifts: TemplateShiftSlot[]; onAddShift: () => void; - onUpdateShift: (shiftId: string, updates: Partial) => void; + onUpdateShift: (shiftId: string, updates: Partial) => void; onRemoveShift: (shiftId: string) => void; } @@ -55,8 +55,8 @@ const ShiftDayEditor: React.FC = ({
onUpdateShift(shift.id, { name: e.target.value })} + value={shift.timeRange.name} + onChange={(e) => onUpdateShift(shift.id, { timeRange: { ...shift.timeRange, name: e.target.value } })} placeholder="Schichtname" />
@@ -66,8 +66,8 @@ const ShiftDayEditor: React.FC = ({ onUpdateShift(shift.id, { startTime: e.target.value })} + value={shift.timeRange.startTime} + onChange={(e) => onUpdateShift(shift.id, { timeRange: { ...shift.timeRange, startTime: e.target.value } })} />
@@ -75,8 +75,8 @@ const ShiftDayEditor: React.FC = ({ onUpdateShift(shift.id, { endTime: e.target.value })} + value={shift.timeRange.endTime} + onChange={(e) => onUpdateShift(shift.id, { timeRange: { ...shift.timeRange, endTime: e.target.value } })} />
diff --git a/frontend/src/services/shiftPlanService.ts b/frontend/src/services/shiftPlanService.ts index 562a93f..913e64a 100644 --- a/frontend/src/services/shiftPlanService.ts +++ b/frontend/src/services/shiftPlanService.ts @@ -50,7 +50,20 @@ export const shiftPlanService = { throw new Error('Fehler beim Laden der Schichtpläne'); } - return response.json(); + const data = await response.json(); + + // Convert snake_case to camelCase + return data.map((plan: any) => ({ + id: plan.id, + name: plan.name, + startDate: plan.start_date, // Convert here + endDate: plan.end_date, // Convert here + templateId: plan.template_id, + status: plan.status, + createdBy: plan.created_by, + createdAt: plan.created_at, + shifts: plan.shifts || [] + })); }, async getShiftPlan(id: string): Promise { @@ -69,7 +82,20 @@ export const shiftPlanService = { throw new Error('Schichtplan nicht gefunden'); } - return response.json(); + const data = await response.json(); + + // Convert snake_case to camelCase + return { + id: data.id, + name: data.name, + startDate: data.start_date, // Convert here + endDate: data.end_date, // Convert here + templateId: data.template_id, + status: data.status, + createdBy: data.created_by, + createdAt: data.created_at, + shifts: data.shifts || [] + }; }, async createShiftPlan(plan: CreateShiftPlanRequest): Promise { @@ -130,5 +156,60 @@ export const shiftPlanService = { } throw new Error('Fehler beim Löschen des Schichtplans'); } + }, + + async updateShiftPlanShift(planId: string, shift: ShiftPlanShift): Promise { + const response = await fetch(`${API_BASE}/${planId}/shifts/${shift.id}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...authService.getAuthHeaders() + }, + body: JSON.stringify(shift) + }); + + if (!response.ok) { + if (response.status === 401) { + authService.logout(); + throw new Error('Nicht authorisiert - bitte erneut anmelden'); + } + throw new Error('Fehler beim Aktualisieren der Schicht'); + } + }, + + async addShiftPlanShift(planId: string, shift: Omit): Promise { + const response = await fetch(`${API_BASE}/${planId}/shifts`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...authService.getAuthHeaders() + }, + body: JSON.stringify(shift) + }); + + if (!response.ok) { + if (response.status === 401) { + authService.logout(); + throw new Error('Nicht authorisiert - bitte erneut anmelden'); + } + throw new Error('Fehler beim Hinzufügen der Schicht'); + } + }, + + async deleteShiftPlanShift(planId: string, shiftId: string): Promise { + const response = await fetch(`${API_BASE}/${planId}/shifts/${shiftId}`, { + method: 'DELETE', + headers: { + ...authService.getAuthHeaders() + } + }); + + if (!response.ok) { + if (response.status === 401) { + authService.logout(); + throw new Error('Nicht authorisiert - bitte erneut anmelden'); + } + throw new Error('Fehler beim Löschen der Schicht'); + } } -}; \ No newline at end of file +}; diff --git a/frontend/src/services/shiftTemplateService.ts b/frontend/src/services/shiftTemplateService.ts index a4a52f9..05f0238 100644 --- a/frontend/src/services/shiftTemplateService.ts +++ b/frontend/src/services/shiftTemplateService.ts @@ -1,11 +1,11 @@ // frontend/src/services/shiftTemplateService.ts -import { ShiftTemplate, TemplateShift } from '../types/shiftTemplate'; +import { TemplateShift } from '../types/shiftTemplate'; import { authService } from './authService'; -const API_BASE = 'http://localhost:3002/api/shift-templates'; +const API_BASE = 'http://localhost:3001/api/shift-templates'; export const shiftTemplateService = { - async getTemplates(): Promise { + async getTemplates(): Promise { const response = await fetch(API_BASE, { headers: { 'Content-Type': 'application/json', @@ -23,14 +23,14 @@ export const shiftTemplateService = { const templates = await response.json(); // Sortiere die Vorlagen so, dass die Standard-Vorlage immer zuerst kommt - return templates.sort((a: ShiftTemplate, b: ShiftTemplate) => { + return templates.sort((a: TemplateShift, b: TemplateShift) => { if (a.isDefault && !b.isDefault) return -1; if (!a.isDefault && b.isDefault) return 1; return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); }); }, - async getTemplate(id: string): Promise { + async getTemplate(id: string): Promise { const response = await fetch(`${API_BASE}/${id}`, { headers: { 'Content-Type': 'application/json', @@ -49,7 +49,7 @@ export const shiftTemplateService = { return response.json(); }, - async createTemplate(template: Omit): Promise { + async createTemplate(template: Omit): Promise { // Wenn diese Vorlage als Standard markiert ist, // fragen wir den Benutzer, ob er wirklich die Standard-Vorlage ändern möchte if (template.isDefault) { @@ -81,7 +81,7 @@ export const shiftTemplateService = { return response.json(); }, - async updateTemplate(id: string, template: Partial): Promise { + async updateTemplate(id: string, template: Partial): Promise { const response = await fetch(`${API_BASE}/${id}`, { method: 'PUT', headers: { diff --git a/frontend/src/types/employee.ts b/frontend/src/types/employee.ts index da1c270..2e4a8d0 100644 --- a/frontend/src/types/employee.ts +++ b/frontend/src/types/employee.ts @@ -35,4 +35,5 @@ export interface Availability { startTime: string; endTime: string; isAvailable: boolean; + availabilityLevel: 1 | 2 | 3; // 1: bevorzugt, 2: möglich, 3: nicht möglich } \ No newline at end of file diff --git a/frontend/src/types/shiftTemplate.ts b/frontend/src/types/shiftTemplate.ts index 0e30f41..05e2ffe 100644 --- a/frontend/src/types/shiftTemplate.ts +++ b/frontend/src/types/shiftTemplate.ts @@ -1,28 +1,41 @@ // frontend/src/types/shiftTemplate.ts -export interface ShiftTemplate { +export interface TemplateShift { id: string; name: string; description?: string; - shifts: TemplateShift[]; + isDefault: boolean; createdBy: string; createdAt: string; - isDefault: boolean; + shifts: TemplateShiftSlot[]; } -export interface TemplateShift { +export interface TemplateShiftSlot { id: string; - dayOfWeek: number; // 1-5 (Montag=1, Dienstag=2, ...) - name: string; - startTime: string; // "08:00" - endTime: string; // "12:00" + dayOfWeek: number; + timeRange: TemplateShiftTimeRange; requiredEmployees: number; - color?: string; // Für visuelle Darstellung + color?: string; } +export interface TemplateShiftTimeRange { + id: string; + name: string; // e.g., "Frühschicht", "Spätschicht" + startTime: string; + endTime: string; +} + +export const DEFAULT_TIME_SLOTS: TemplateShiftTimeRange[] = [ + { id: 'morning', name: 'Vormittag', startTime: '08:00', endTime: '12:00' }, + { id: 'afternoon', name: 'Nachmittag', startTime: '11:30', endTime: '15:30' }, +]; + + export const DEFAULT_DAYS = [ { id: 1, name: 'Montag' }, { id: 2, name: 'Dienstag' }, { id: 3, name: 'Donnerstag' }, { id: 4, name: 'Mittwoch' }, { id: 5, name: 'Freitag' }, + { id: 6, name: 'Samstag' }, + { id: 7, name: 'Sonntag' } ]; \ No newline at end of file