From 0902472dfe33148d4e24a8e0f7ba20e79a87f55b Mon Sep 17 00:00:00 2001 From: donpat1to Date: Mon, 19 Jan 2026 21:07:38 +0100 Subject: [PATCH] added editing for shiftplans --- .../src/controllers/shiftPlanController.ts | 338 ++++++- backend/src/middleware/validation.ts | 105 +++ backend/src/routes/shiftPlans.ts | 46 +- frontend/src/components/Modal/Modal.tsx | 122 +++ .../Employees/components/EmployeeList.tsx | 43 +- .../src/pages/ShiftPlans/ShiftPlanEdit.tsx | 871 ++++++++++++------ .../src/pages/ShiftPlans/ShiftPlanList.tsx | 54 +- .../ShiftPlans/components/AddDayButton.tsx | 123 +++ .../pages/ShiftPlans/components/ShiftCell.tsx | 234 +++++ .../ShiftPlans/components/TimeSlotEditor.tsx | 199 ++++ frontend/src/services/shiftPlanService.ts | 119 ++- frontend/src/utils/buttonStyles.ts | 150 +++ 12 files changed, 2012 insertions(+), 392 deletions(-) create mode 100644 frontend/src/components/Modal/Modal.tsx create mode 100644 frontend/src/pages/ShiftPlans/components/AddDayButton.tsx create mode 100644 frontend/src/pages/ShiftPlans/components/ShiftCell.tsx create mode 100644 frontend/src/pages/ShiftPlans/components/TimeSlotEditor.tsx create mode 100644 frontend/src/utils/buttonStyles.ts diff --git a/backend/src/controllers/shiftPlanController.ts b/backend/src/controllers/shiftPlanController.ts index 4f8abcb..6d6e4a7 100644 --- a/backend/src/controllers/shiftPlanController.ts +++ b/backend/src/controllers/shiftPlanController.ts @@ -472,8 +472,8 @@ export const updateShiftPlan = async (req: Request, res: Response): Promise('SELECT id FROM time_slots WHERE plan_id = ?', [id]); + const existingIds = new Set(existingSlots.map(s => s.id)); + const incomingIds = new Set(timeSlots.filter((ts: any) => ts.id).map((ts: any) => ts.id)); - // Insert new time slots - always generate new IDs for (const timeSlot of timeSlots) { - const timeSlotId = uuidv4(); - await db.run( - `INSERT INTO time_slots (id, plan_id, name, start_time, end_time, description) - VALUES (?, ?, ?, ?, ?, ?)`, - [timeSlotId, id, timeSlot.name, timeSlot.startTime, timeSlot.endTime, timeSlot.description || ''] - ); + if ((timeSlot as any).id && existingIds.has((timeSlot as any).id)) { + // UPDATE existing time slot - preserve ID + await db.run( + `UPDATE time_slots SET name = ?, start_time = ?, end_time = ?, description = ? WHERE id = ?`, + [timeSlot.name, timeSlot.startTime, timeSlot.endTime, timeSlot.description || '', (timeSlot as any).id] + ); + } else { + // INSERT new time slot + const newId = (timeSlot as any).id || uuidv4(); + await db.run( + `INSERT INTO time_slots (id, plan_id, name, start_time, end_time, description) + VALUES (?, ?, ?, ?, ?, ?)`, + [newId, id, timeSlot.name, timeSlot.startTime, timeSlot.endTime, timeSlot.description || ''] + ); + } + } + + // DELETE removed time slots (only if no shifts reference them) + for (const existingId of existingIds) { + if (!incomingIds.has(existingId)) { + const hasShifts = await db.get('SELECT 1 FROM shifts WHERE time_slot_id = ?', [existingId]); + if (hasShifts) { + await db.run('ROLLBACK'); + res.status(400).json({ + error: 'Cannot delete time slot', + message: `Time slot ${existingId} is referenced by shifts. Remove the shifts first.` + }); + return; + } + await db.run('DELETE FROM time_slots WHERE id = ?', [existingId]); + } } } - // If updating shifts, replace all shifts + // If updating shifts, use ID-preserving logic if (shifts) { - // Delete existing shifts - await db.run('DELETE FROM shifts WHERE plan_id = ?', [id]); + const existingShifts = await db.all('SELECT id FROM shifts WHERE plan_id = ?', [id]); + const existingShiftIds = new Set(existingShifts.map(s => s.id)); + const incomingShiftIds = new Set(shifts.filter((s: any) => s.id).map((s: any) => s.id)); - // Insert new shifts - use new timeSlotId (they should reference the newly created time slots) for (const shift of shifts) { - const shiftId = uuidv4(); - await db.run( - `INSERT INTO shifts (id, plan_id, day_of_week, time_slot_id, required_employees, color) - VALUES (?, ?, ?, ?, ?, ?)`, - [shiftId, id, shift.dayOfWeek, shift.timeSlotId, shift.requiredEmployees, shift.color || '#3498db'] + // Validate time_slot_id exists + const timeSlotExists = await db.get( + 'SELECT id FROM time_slots WHERE id = ? AND plan_id = ?', + [shift.timeSlotId, id] ); + if (!timeSlotExists) { + await db.run('ROLLBACK'); + res.status(400).json({ + error: 'Invalid time slot', + message: `Time slot ${shift.timeSlotId} not found in this plan` + }); + return; + } + + if ((shift as any).id && existingShiftIds.has((shift as any).id)) { + // UPDATE existing shift - preserve ID + await db.run( + `UPDATE shifts SET day_of_week = ?, time_slot_id = ?, required_employees = ?, color = ? WHERE id = ?`, + [shift.dayOfWeek, shift.timeSlotId, shift.requiredEmployees, shift.color || '#3498db', (shift as any).id] + ); + } else { + // INSERT new shift + const newId = (shift as any).id || uuidv4(); + await db.run( + `INSERT INTO shifts (id, plan_id, day_of_week, time_slot_id, required_employees, color) + VALUES (?, ?, ?, ?, ?, ?)`, + [newId, id, shift.dayOfWeek, shift.timeSlotId, shift.requiredEmployees, shift.color || '#3498db'] + ); + } + } + + // DELETE removed shifts + for (const existingShiftId of existingShiftIds) { + if (!incomingShiftIds.has(existingShiftId)) { + await db.run('DELETE FROM shifts WHERE id = ?', [existingShiftId]); + } } } @@ -553,6 +608,247 @@ export const deleteShiftPlan = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const { name, startTime, endTime, description } = req.body; + + // Check if plan exists + const existingPlan = await db.get('SELECT * FROM shift_plans WHERE id = ?', [id]); + if (!existingPlan) { + res.status(404).json({ error: 'Shift plan not found' }); + return; + } + + const timeSlotId = uuidv4(); + await db.run( + `INSERT INTO time_slots (id, plan_id, name, start_time, end_time, description) + VALUES (?, ?, ?, ?, ?, ?)`, + [timeSlotId, id, name, startTime, endTime, description || ''] + ); + + res.status(201).json({ + id: timeSlotId, + planId: id, + name, + startTime, + endTime, + description: description || '' + }); + } catch (error) { + console.error('Error adding time slot:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +export const updateTimeSlot = async (req: Request, res: Response): Promise => { + try { + const { id, slotId } = req.params; + const { name, startTime, endTime, description } = req.body; + + // Check if time slot exists and belongs to this plan + const existingSlot = await db.get( + 'SELECT * FROM time_slots WHERE id = ? AND plan_id = ?', + [slotId, id] + ); + if (!existingSlot) { + res.status(404).json({ error: 'Time slot not found in this plan' }); + return; + } + + await db.run( + `UPDATE time_slots + SET name = COALESCE(?, name), + start_time = COALESCE(?, start_time), + end_time = COALESCE(?, end_time), + description = COALESCE(?, description) + WHERE id = ?`, + [name, startTime, endTime, description, slotId] + ); + + const updatedSlot = await db.get('SELECT * FROM time_slots WHERE id = ?', [slotId]); + res.json({ + id: updatedSlot.id, + planId: updatedSlot.plan_id, + name: updatedSlot.name, + startTime: updatedSlot.start_time, + endTime: updatedSlot.end_time, + description: updatedSlot.description + }); + } catch (error) { + console.error('Error updating time slot:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +export const deleteTimeSlot = async (req: Request, res: Response): Promise => { + try { + const { id, slotId } = req.params; + + // Check if time slot exists and belongs to this plan + const existingSlot = await db.get( + 'SELECT id FROM time_slots WHERE id = ? AND plan_id = ?', + [slotId, id] + ); + if (!existingSlot) { + res.status(404).json({ error: 'Time slot not found in this plan' }); + return; + } + + // Check if any shifts reference this time slot + const dependentShifts = await db.all( + 'SELECT id FROM shifts WHERE plan_id = ? AND time_slot_id = ?', + [id, slotId] + ); + + if (dependentShifts.length > 0) { + res.status(400).json({ + error: 'Cannot delete time slot', + message: `${dependentShifts.length} shift(s) reference this time slot. Remove them first.`, + dependentShiftIds: dependentShifts.map(s => s.id) + }); + return; + } + + await db.run('DELETE FROM time_slots WHERE id = ? AND plan_id = ?', [slotId, id]); + res.status(204).send(); + } catch (error) { + console.error('Error deleting time slot:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +// ===== GRANULAR SHIFT ENDPOINTS ===== + +export const addShift = async (req: Request, res: Response): Promise => { + try { + const { id } = req.params; + const { timeSlotId, dayOfWeek, requiredEmployees, color } = req.body; + + // Check if plan exists + const existingPlan = await db.get('SELECT * FROM shift_plans WHERE id = ?', [id]); + if (!existingPlan) { + res.status(404).json({ error: 'Shift plan not found' }); + return; + } + + // Validate time slot exists and belongs to this plan + const timeSlot = await db.get( + 'SELECT id FROM time_slots WHERE id = ? AND plan_id = ?', + [timeSlotId, id] + ); + if (!timeSlot) { + res.status(400).json({ error: 'Time slot not found in this plan' }); + return; + } + + // Check for duplicate (same plan + time slot + day) + const existing = await db.get( + 'SELECT id FROM shifts WHERE plan_id = ? AND time_slot_id = ? AND day_of_week = ?', + [id, timeSlotId, dayOfWeek] + ); + if (existing) { + res.status(409).json({ error: 'Shift already exists for this day and time slot' }); + return; + } + + const shiftId = uuidv4(); + await db.run( + `INSERT INTO shifts (id, plan_id, time_slot_id, day_of_week, required_employees, color) + VALUES (?, ?, ?, ?, ?, ?)`, + [shiftId, id, timeSlotId, dayOfWeek, requiredEmployees || 2, color || '#3498db'] + ); + + res.status(201).json({ + id: shiftId, + planId: id, + timeSlotId, + dayOfWeek, + requiredEmployees: requiredEmployees || 2, + color: color || '#3498db' + }); + } catch (error) { + console.error('Error adding shift:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +export const updateShift = async (req: Request, res: Response): Promise => { + try { + const { id, shiftId } = req.params; + const { requiredEmployees, color, timeSlotId, dayOfWeek } = req.body; + + // Check if shift exists and belongs to this plan + const existingShift = await db.get( + 'SELECT * FROM shifts WHERE id = ? AND plan_id = ?', + [shiftId, id] + ); + if (!existingShift) { + res.status(404).json({ error: 'Shift not found in this plan' }); + return; + } + + // If changing time slot, validate it exists + if (timeSlotId) { + const timeSlot = await db.get( + 'SELECT id FROM time_slots WHERE id = ? AND plan_id = ?', + [timeSlotId, id] + ); + if (!timeSlot) { + res.status(400).json({ error: 'Time slot not found in this plan' }); + return; + } + } + + await db.run( + `UPDATE shifts + SET required_employees = COALESCE(?, required_employees), + color = COALESCE(?, color), + time_slot_id = COALESCE(?, time_slot_id), + day_of_week = COALESCE(?, day_of_week) + WHERE id = ?`, + [requiredEmployees, color, timeSlotId, dayOfWeek, shiftId] + ); + + const updatedShift = await db.get('SELECT * FROM shifts WHERE id = ?', [shiftId]); + res.json({ + id: updatedShift.id, + planId: updatedShift.plan_id, + timeSlotId: updatedShift.time_slot_id, + dayOfWeek: updatedShift.day_of_week, + requiredEmployees: updatedShift.required_employees, + color: updatedShift.color + }); + } catch (error) { + console.error('Error updating shift:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +export const deleteShift = async (req: Request, res: Response): Promise => { + try { + const { id, shiftId } = req.params; + + // Check if shift exists and belongs to this plan + const existingShift = await db.get( + 'SELECT id FROM shifts WHERE id = ? AND plan_id = ?', + [shiftId, id] + ); + if (!existingShift) { + res.status(404).json({ error: 'Shift not found in this plan' }); + return; + } + + await db.run('DELETE FROM shifts WHERE id = ? AND plan_id = ?', [shiftId, id]); + res.status(204).send(); + } catch (error) { + console.error('Error deleting shift:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + // Helper function to get plan by ID async function getShiftPlanById(planId: string): Promise { const plan = await db.get(` diff --git a/backend/src/middleware/validation.ts b/backend/src/middleware/validation.ts index c7e4722..afa4388 100644 --- a/backend/src/middleware/validation.ts +++ b/backend/src/middleware/validation.ts @@ -388,6 +388,111 @@ export const validateCreateFromPreset = [ .withMessage('isTemplate must be a boolean') ]; +// ===== TIME SLOT VALIDATION ===== +export const validateTimeSlot = [ + body('name') + .isLength({ min: 1, max: 100 }) + .withMessage('Time slot name must be between 1-100 characters') + .trim() + .escape(), + + body('startTime') + .matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/) + .withMessage('Start time must be in HH:MM format'), + + body('endTime') + .matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/) + .withMessage('End time must be in HH:MM format'), + + body('description') + .optional() + .isLength({ max: 500 }) + .withMessage('Description cannot exceed 500 characters') + .trim() + .escape() +]; + +export const validateTimeSlotUpdate = [ + body('name') + .optional() + .isLength({ min: 1, max: 100 }) + .withMessage('Time slot name must be between 1-100 characters') + .trim() + .escape(), + + body('startTime') + .optional() + .matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/) + .withMessage('Start time must be in HH:MM format'), + + body('endTime') + .optional() + .matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/) + .withMessage('End time must be in HH:MM format'), + + body('description') + .optional() + .isLength({ max: 500 }) + .withMessage('Description cannot exceed 500 characters') + .trim() + .escape() +]; + +export const validateSlotId = [ + param('slotId') + .isUUID() + .withMessage('Slot ID must be a valid UUID') +]; + +// ===== SHIFT VALIDATION ===== +export const validateShiftCreate = [ + body('timeSlotId') + .isUUID() + .withMessage('Time slot ID must be a valid UUID'), + + body('dayOfWeek') + .isInt({ min: 1, max: 7 }) + .withMessage('Day of week must be between 1-7 (Monday-Sunday)'), + + body('requiredEmployees') + .optional() + .isInt({ min: 1, max: 10 }) + .withMessage('Required employees must be between 1-10'), + + body('color') + .optional() + .isHexColor() + .withMessage('Color must be a valid hex color') +]; + +export const validateShiftUpdate = [ + body('timeSlotId') + .optional() + .isUUID() + .withMessage('Time slot ID must be a valid UUID'), + + body('dayOfWeek') + .optional() + .isInt({ min: 1, max: 7 }) + .withMessage('Day of week must be between 1-7 (Monday-Sunday)'), + + body('requiredEmployees') + .optional() + .isInt({ min: 1, max: 10 }) + .withMessage('Required employees must be between 1-10'), + + body('color') + .optional() + .isHexColor() + .withMessage('Color must be a valid hex color') +]; + +export const validateShiftId = [ + param('shiftId') + .isUUID() + .withMessage('Shift ID must be a valid UUID') +]; + // ===== SCHEDULED SHIFTS VALIDATION ===== export const validateScheduledShiftUpdate = [ body('assignedEmployees') diff --git a/backend/src/routes/shiftPlans.ts b/backend/src/routes/shiftPlans.ts index 3cb4651..555ff26 100644 --- a/backend/src/routes/shiftPlans.ts +++ b/backend/src/routes/shiftPlans.ts @@ -1,22 +1,34 @@ import express from 'express'; import { authMiddleware, requireRole } from '../middleware/auth.js'; -import { - getShiftPlans, - getShiftPlan, - createShiftPlan, - updateShiftPlan, +import { + getShiftPlans, + getShiftPlan, + createShiftPlan, + updateShiftPlan, deleteShiftPlan, createFromPreset, clearAssignments, exportShiftPlanToExcel, - exportShiftPlanToPDF + exportShiftPlanToPDF, + addTimeSlot, + updateTimeSlot, + deleteTimeSlot, + addShift, + updateShift, + deleteShift } from '../controllers/shiftPlanController.js'; -import { - validateShiftPlan, - validateShiftPlanUpdate, - validateCreateFromPreset, - handleValidationErrors, - validateId +import { + validateShiftPlan, + validateShiftPlanUpdate, + validateCreateFromPreset, + handleValidationErrors, + validateId, + validateTimeSlot, + validateTimeSlotUpdate, + validateShiftCreate, + validateShiftUpdate, + validateSlotId, + validateShiftId } from '../middleware/validation.js'; const router = express.Router(); @@ -35,4 +47,14 @@ router.post('/:id/clear-assignments', validateId, handleValidationErrors, requir router.get('/:id/export/excel', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), exportShiftPlanToExcel); router.get('/:id/export/pdf', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), exportShiftPlanToPDF); +// Time slot management +router.post('/:id/time-slots', validateId, validateTimeSlot, handleValidationErrors, requireRole(['admin', 'maintenance']), addTimeSlot); +router.put('/:id/time-slots/:slotId', validateId, validateSlotId, validateTimeSlotUpdate, handleValidationErrors, requireRole(['admin', 'maintenance']), updateTimeSlot); +router.delete('/:id/time-slots/:slotId', validateId, validateSlotId, handleValidationErrors, requireRole(['admin', 'maintenance']), deleteTimeSlot); + +// Shift management +router.post('/:id/shifts', validateId, validateShiftCreate, handleValidationErrors, requireRole(['admin', 'maintenance']), addShift); +router.patch('/:id/shifts/:shiftId', validateId, validateShiftId, validateShiftUpdate, handleValidationErrors, requireRole(['admin', 'maintenance']), updateShift); +router.delete('/:id/shifts/:shiftId', validateId, validateShiftId, handleValidationErrors, requireRole(['admin', 'maintenance']), deleteShift); + export default router; \ No newline at end of file diff --git a/frontend/src/components/Modal/Modal.tsx b/frontend/src/components/Modal/Modal.tsx new file mode 100644 index 0000000..c5168a8 --- /dev/null +++ b/frontend/src/components/Modal/Modal.tsx @@ -0,0 +1,122 @@ +import React, { useEffect } from 'react'; +import { BUTTON_COLORS } from '../../utils/buttonStyles'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: React.ReactNode; + width?: string; +} + +const Modal: React.FC = ({ + isOpen, + onClose, + title, + children, + width = '400px' +}) => { + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener('keydown', handleEscape); + document.body.style.overflow = 'hidden'; + } + + return () => { + document.removeEventListener('keydown', handleEscape); + document.body.style.overflow = 'unset'; + }; + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const overlayStyle: React.CSSProperties = { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 9999, + }; + + const modalStyle: React.CSSProperties = { + backgroundColor: 'white', + borderRadius: '8px', + boxShadow: '0 4px 20px rgba(0, 0, 0, 0.2)', + width: width, + maxWidth: '90vw', + maxHeight: '90vh', + overflow: 'auto', + }; + + const headerStyle: React.CSSProperties = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '16px 20px', + borderBottom: '1px solid #dee2e6', + backgroundColor: BUTTON_COLORS.primary, + color: 'white', + borderRadius: '8px 8px 0 0', + }; + + const titleStyle: React.CSSProperties = { + margin: 0, + fontSize: '18px', + fontWeight: 'bold', + }; + + const closeButtonStyle: React.CSSProperties = { + background: 'none', + border: 'none', + color: 'white', + fontSize: '24px', + cursor: 'pointer', + padding: '0', + lineHeight: 1, + opacity: 0.8, + }; + + const contentStyle: React.CSSProperties = { + padding: '20px', + }; + + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+
e.stopPropagation()}> +
+

{title}

+ +
+
+ {children} +
+
+
+ ); +}; + +export default Modal; diff --git a/frontend/src/pages/Employees/components/EmployeeList.tsx b/frontend/src/pages/Employees/components/EmployeeList.tsx index 6e3b699..19e7b8b 100644 --- a/frontend/src/pages/Employees/components/EmployeeList.tsx +++ b/frontend/src/pages/Employees/components/EmployeeList.tsx @@ -4,6 +4,7 @@ import { ROLE_CONFIG, EMPLOYEE_TYPE_CONFIG } from '../../../models/defaults/empl import { Employee } from '../../../models/Employee'; import { useAuth } from '../../../contexts/AuthContext'; import { useNotification } from '../../../contexts/NotificationContext'; +import { ICONS, iconButtonStyle, BUTTON_COLORS } from '../../../utils/buttonStyles'; interface EmployeeListProps { employees: Employee[]; @@ -455,40 +456,20 @@ const EmployeeList: React.FC = ({ {/* Verfügbarkeit Button */} {/* Bearbeiten Button */} {canEdit && ( )} @@ -496,20 +477,10 @@ const EmployeeList: React.FC = ({ {canDelete && ( )} diff --git a/frontend/src/pages/ShiftPlans/ShiftPlanEdit.tsx b/frontend/src/pages/ShiftPlans/ShiftPlanEdit.tsx index 1263eaa..63d76a2 100644 --- a/frontend/src/pages/ShiftPlans/ShiftPlanEdit.tsx +++ b/frontend/src/pages/ShiftPlans/ShiftPlanEdit.tsx @@ -1,33 +1,67 @@ // frontend/src/pages/ShiftPlans/ShiftPlanEdit.tsx -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { shiftPlanService } from '../../services/shiftPlanService'; -import { ShiftPlan, Shift, ScheduledShift } from '../../models/ShiftPlan'; +import { ShiftPlan, Shift, TimeSlot } from '../../models/ShiftPlan'; import { useNotification } from '../../contexts/NotificationContext'; import { useBackendValidation } from '../../hooks/useBackendValidation'; +import { formatTime } from '../../utils/foramatters'; +import { + ICONS, + smallDeleteButton, + addTextButton, + cancelTextButton, + addOutlineButton, + BUTTON_COLORS, +} from '../../utils/buttonStyles'; +import ShiftCell from './components/ShiftCell'; +import TimeSlotEditor from './components/TimeSlotEditor'; +import AddDayButton from './components/AddDayButton'; + +const DAYS_OF_WEEK = [ + { id: 1, name: 'Montag', shortName: 'Mo' }, + { id: 2, name: 'Dienstag', shortName: 'Di' }, + { id: 3, name: 'Mittwoch', shortName: 'Mi' }, + { id: 4, name: 'Donnerstag', shortName: 'Do' }, + { id: 5, name: 'Freitag', shortName: 'Fr' }, + { id: 6, name: 'Samstag', shortName: 'Sa' }, + { id: 7, name: 'Sonntag', shortName: 'So' }, +]; const ShiftPlanEdit: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const { showNotification, confirmDialog } = useNotification(); const { executeWithValidation, isSubmitting } = useBackendValidation(); - + const [shiftPlan, setShiftPlan] = useState(null); const [loading, setLoading] = useState(true); - const [editingShift, setEditingShift] = useState(null); - const [newShift, setNewShift] = useState>({ - timeSlotId: '', - dayOfWeek: 1, - requiredEmployees: 1 + const [activeDays, setActiveDays] = useState([]); + + // New time slot form state + const [showAddTimeSlot, setShowAddTimeSlot] = useState(false); + const [newTimeSlot, setNewTimeSlot] = useState({ + name: '', + startTime: '08:00', + endTime: '12:00', + description: '', }); useEffect(() => { loadShiftPlan(); }, [id]); + useEffect(() => { + if (shiftPlan) { + // Determine active days from existing shifts + const daysWithShifts = new Set(shiftPlan.shifts.map(s => s.dayOfWeek)); + setActiveDays(Array.from(daysWithShifts).sort((a, b) => a - b)); + } + }, [shiftPlan]); + const loadShiftPlan = async () => { if (!id) return; - + await executeWithValidation(async () => { try { const plan = await shiftPlanService.getShiftPlan(id); @@ -41,68 +75,223 @@ const ShiftPlanEdit: React.FC = () => { }); }; - const handleUpdateShift = async (shift: Shift) => { - if (!shiftPlan || !id) return; - - await executeWithValidation(async () => { - // Update logic here - will be implemented when backend API is available - // For now, just simulate success - console.log('Updating shift:', shift); - - loadShiftPlan(); - setEditingShift(null); - - showNotification({ - type: 'success', - title: 'Erfolg', - message: 'Schicht wurde erfolgreich aktualisiert.' - }); - }); + // Get shift for a specific cell + const getShift = (timeSlotId: string, dayOfWeek: number): Shift | null => { + if (!shiftPlan) return null; + return shiftPlan.shifts.find( + s => s.timeSlotId === timeSlotId && s.dayOfWeek === dayOfWeek + ) || null; }; - const handleAddShift = async () => { - if (!shiftPlan || !id) return; - - // Basic frontend validation only - if (!newShift.timeSlotId) { - showNotification({ - type: 'error', - title: 'Fehlende Angaben', - message: 'Bitte wählen Sie einen Zeit-Slot aus.' - }); - return; + // Count shifts for a time slot + const getShiftsCountForSlot = (slotId: string): number => { + if (!shiftPlan) return 0; + return shiftPlan.shifts.filter(s => s.timeSlotId === slotId).length; + }; + + // Sort time slots by start time (early to late) + const sortedTimeSlots = useMemo(() => { + if (!shiftPlan) return []; + + const timeToMinutes = (timeStr: string): number => { + if (!timeStr) return 0; + const [hours, minutes] = timeStr.split(':').map(Number); + return hours * 60 + minutes; + }; + + return [...shiftPlan.timeSlots].sort((a, b) => { + const minutesA = timeToMinutes(a.startTime); + const minutesB = timeToMinutes(b.startTime); + return minutesA - minutesB; + }); + }, [shiftPlan]); + + // Add a new day column + const handleAddDay = (dayOfWeek: number) => { + if (!activeDays.includes(dayOfWeek)) { + setActiveDays([...activeDays, dayOfWeek].sort((a, b) => a - b)); } - - if (!newShift.requiredEmployees || newShift.requiredEmployees < 1) { + }; + + // Remove a day column (delete all shifts for that day) + const handleRemoveDay = async (dayOfWeek: number) => { + if (!shiftPlan || !id) return; + + const shiftsForDay = shiftPlan.shifts.filter(s => s.dayOfWeek === dayOfWeek); + + if (shiftsForDay.length > 0) { + const confirmed = await confirmDialog({ + title: 'Tag entfernen', + message: `Dieser Tag enthält ${shiftsForDay.length} Schicht(en). Alle Schichten für diesen Tag werden gelöscht. Fortfahren?`, + confirmText: 'Löschen', + cancelText: 'Abbrechen', + type: 'warning' + }); + + if (!confirmed) return; + + // Delete all shifts for this day + await executeWithValidation(async () => { + for (const shift of shiftsForDay) { + await shiftPlanService.deleteShift(id, shift.id); + } + await loadShiftPlan(); + showNotification({ + type: 'success', + title: 'Erfolg', + message: `Alle Schichten für diesen Tag wurden gelöscht.` + }); + }); + } + + setActiveDays(activeDays.filter(d => d !== dayOfWeek)); + }; + + // Add a new time slot + const handleAddTimeSlot = async () => { + if (!id || !newTimeSlot.name || !newTimeSlot.startTime || !newTimeSlot.endTime) { showNotification({ type: 'error', title: 'Fehlende Angaben', - message: 'Bitte geben Sie die Anzahl der benötigten Mitarbeiter an.' + message: 'Bitte füllen Sie alle Pflichtfelder aus.' }); return; } await executeWithValidation(async () => { - // Add shift logic here - will be implemented when backend API is available - // For now, just simulate success - console.log('Adding shift:', newShift); - + await shiftPlanService.addTimeSlot(id, { + name: newTimeSlot.name, + startTime: newTimeSlot.startTime, + endTime: newTimeSlot.endTime, + description: newTimeSlot.description || undefined, + }); + showNotification({ type: 'success', title: 'Erfolg', - message: 'Neue Schicht wurde hinzugefügt.' + message: 'Zeit-Slot wurde hinzugefügt.' }); - - setNewShift({ - timeSlotId: '', - dayOfWeek: 1, - requiredEmployees: 1 - }); - loadShiftPlan(); + + setNewTimeSlot({ name: '', startTime: '08:00', endTime: '12:00', description: '' }); + setShowAddTimeSlot(false); + await loadShiftPlan(); }); }; + // Update a time slot + const handleUpdateTimeSlot = async ( + slot: TimeSlot, + name: string, + startTime: string, + endTime: string, + description?: string + ) => { + if (!id) return; + + await executeWithValidation(async () => { + await shiftPlanService.updateTimeSlot(id, slot.id, { + name, + startTime, + endTime, + description, + }); + + showNotification({ + type: 'success', + title: 'Erfolg', + message: 'Zeit-Slot wurde aktualisiert.' + }); + + await loadShiftPlan(); + }); + }; + + // Delete a time slot + const handleDeleteTimeSlot = async (slotId: string) => { + if (!id || !shiftPlan) return; + + const shiftsCount = getShiftsCountForSlot(slotId); + + const confirmed = await confirmDialog({ + title: 'Zeit-Slot löschen', + message: shiftsCount > 0 + ? `Dieser Zeit-Slot enthält ${shiftsCount} Schicht(en). Alle zugehörigen Schichten werden ebenfalls gelöscht. Fortfahren?` + : 'Möchten Sie diesen Zeit-Slot wirklich löschen?', + confirmText: 'Löschen', + cancelText: 'Abbrechen', + type: 'warning' + }); + + if (!confirmed) return; + + await executeWithValidation(async () => { + await shiftPlanService.deleteTimeSlot(id, slotId); + + showNotification({ + type: 'success', + title: 'Erfolg', + message: 'Zeit-Slot wurde gelöscht.' + }); + + await loadShiftPlan(); + }); + }; + + // Add a new shift + const handleAddShift = async ( + dayOfWeek: number, + timeSlotId: string, + requiredEmployees: number, + color: string + ) => { + if (!id) return; + + await executeWithValidation(async () => { + await shiftPlanService.addShift(id, { + dayOfWeek, + timeSlotId, + requiredEmployees, + color, + }); + + showNotification({ + type: 'success', + title: 'Erfolg', + message: 'Schicht wurde hinzugefügt.' + }); + + await loadShiftPlan(); + }); + }; + + // Update a shift + const handleUpdateShift = async ( + shift: Shift, + requiredEmployees: number, + color: string + ) => { + if (!id) return; + + await executeWithValidation(async () => { + await shiftPlanService.updateShift(id, shift.id, { + requiredEmployees, + color, + }); + + showNotification({ + type: 'success', + title: 'Erfolg', + message: 'Schicht wurde aktualisiert.' + }); + + await loadShiftPlan(); + }); + }; + + // Delete a shift const handleDeleteShift = async (shiftId: string) => { + if (!id) return; + const confirmed = await confirmDialog({ title: 'Schicht löschen', message: 'Möchten Sie diese Schicht wirklich löschen?', @@ -114,20 +303,19 @@ const ShiftPlanEdit: React.FC = () => { if (!confirmed) return; await executeWithValidation(async () => { - // Delete logic here - will be implemented when backend API is available - // For now, just simulate success - console.log('Deleting shift:', shiftId); - - loadShiftPlan(); - + await shiftPlanService.deleteShift(id, shiftId); + showNotification({ type: 'success', title: 'Erfolg', - message: 'Schicht wurde erfolgreich gelöscht.' + message: 'Schicht wurde gelöscht.' }); + + await loadShiftPlan(); }); }; + // Publish the shift plan const handlePublish = async () => { if (!shiftPlan || !id) return; @@ -136,21 +324,21 @@ const ShiftPlanEdit: React.FC = () => { ...shiftPlan, status: 'published' }); - + showNotification({ type: 'success', title: 'Erfolg', message: 'Schichtplan wurde veröffentlicht.' }); - - loadShiftPlan(); + + await loadShiftPlan(); }); }; if (loading) { return ( -
{ if (!shiftPlan) { return ( -
{ ); } - // Group shifts by dayOfWeek - const shiftsByDay = shiftPlan.shifts.reduce((acc, shift) => { - if (!acc[shift.dayOfWeek]) { - acc[shift.dayOfWeek] = []; - } - acc[shift.dayOfWeek].push(shift); - return acc; - }, {} as Record); - - const daysOfWeek = [ - { 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' } - ]; + const hasTimeSlots = shiftPlan.timeSlots.length > 0; + const hasActiveDays = activeDays.length > 0; return (
-
-

{shiftPlan.name} bearbeiten

+

{shiftPlan.name} bearbeiten

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

Neue Schicht hinzufügen

-
-
- - + {/* Empty State */} + {!hasTimeSlots && !hasActiveDays && ( +
+
+ 📋
-
- - +

+ Keine Schichten vorhanden +

+

+ Fügen Sie zunächst einen Zeit-Slot hinzu und wählen Sie dann die Tage aus, + an denen Schichten stattfinden sollen. +

+ +
+ )} + + {/* Grid Editor */} + {(hasTimeSlots || hasActiveDays) && ( +
+ {/* Header bar matching ShiftPlanView */} +
+ Schichtplan bearbeiten +
+ {sortedTimeSlots.length} Zeitslots • {activeDays.length} Tage +
-
- - setNewShift({ ...newShift, requiredEmployees: parseInt(e.target.value) || 1 })} - style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} - disabled={isSubmitting} - /> + +
+ + + + + {activeDays.map(dayId => { + const day = DAYS_OF_WEEK.find(d => d.id === dayId); + return ( + + ); + })} + + + + + {sortedTimeSlots.map((slot, index) => ( + + + {activeDays.map(dayId => ( + + ))} + + + ))} + + {/* Add Time Slot Row */} + + + + +
+ Schicht (Zeit) + +
+ {day?.name} + +
+
+ +
+ + + +
+ {showAddTimeSlot ? ( +
+
+ + setNewTimeSlot({ ...newTimeSlot, name: e.target.value })} + placeholder="z.B. Vormittag" + style={{ + padding: '8px', + borderRadius: '4px', + border: '1px solid #ddd', + width: '150px', + }} + disabled={isSubmitting} + /> +
+
+ + setNewTimeSlot({ ...newTimeSlot, startTime: e.target.value })} + style={{ + padding: '8px', + borderRadius: '4px', + border: '1px solid #ddd', + }} + disabled={isSubmitting} + /> +
+
+ + setNewTimeSlot({ ...newTimeSlot, endTime: e.target.value })} + style={{ + padding: '8px', + borderRadius: '4px', + border: '1px solid #ddd', + }} + disabled={isSubmitting} + /> +
+
+ + setNewTimeSlot({ ...newTimeSlot, description: e.target.value })} + placeholder="Optional" + style={{ + padding: '8px', + borderRadius: '4px', + border: '1px solid #ddd', + width: '150px', + }} + disabled={isSubmitting} + /> +
+ + +
+ ) : ( + + )} +
- -
+ )} - {/* Existing shifts */} -
- {daysOfWeek.map(day => { - const shifts = shiftsByDay[day.id] || []; - if (shifts.length === 0) return null; - - return ( -
-

{day.name}

-
- {shifts.map(shift => { - const timeSlot = shiftPlan.timeSlots.find(ts => ts.id === shift.timeSlotId); - return ( -
- {editingShift?.id === shift.id ? ( -
-
- - -
-
- - setEditingShift({ ...editingShift, requiredEmployees: parseInt(e.target.value) || 1 })} - style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} - disabled={isSubmitting} - /> -
-
- - -
-
- ) : ( - <> -
- {timeSlot?.name} ({timeSlot?.startTime?.substring(0, 5)} - {timeSlot?.endTime?.substring(0, 5)}) -
-
-
- Benötigte Mitarbeiter: {shift.requiredEmployees} -
-
- - -
-
- - )} -
- ); - })} -
-
- ); - })} + {/* Legend */} +
+

Legende

+
+
+
+ Aktive Schicht (klicken zum Bearbeiten) +
+
+
+ Leere Zelle (klicken zum Hinzufügen) +
+
+ {ICONS.edit} + Zeit-Slot bearbeiten +
+
+ {ICONS.delete} + Löschen +
+
); }; -export default ShiftPlanEdit; \ No newline at end of file +export default ShiftPlanEdit; diff --git a/frontend/src/pages/ShiftPlans/ShiftPlanList.tsx b/frontend/src/pages/ShiftPlans/ShiftPlanList.tsx index afc3759..1f5d53a 100644 --- a/frontend/src/pages/ShiftPlans/ShiftPlanList.tsx +++ b/frontend/src/pages/ShiftPlans/ShiftPlanList.tsx @@ -13,7 +13,7 @@ const ShiftPlanList: React.FC = () => { const navigate = useNavigate(); const { showNotification, confirmDialog } = useNotification(); const { executeWithValidation, isSubmitting } = useBackendValidation(); - + const [shiftPlans, setShiftPlans] = useState([]); const [loading, setLoading] = useState(true); @@ -48,13 +48,13 @@ const ShiftPlanList: React.FC = () => { await executeWithValidation(async () => { await shiftPlanService.deleteShiftPlan(id); - + showNotification({ type: 'success', title: 'Erfolg', message: 'Der Schichtplan wurde erfolgreich gelöscht.' }); - + loadShiftPlans(); }); }; @@ -87,8 +87,8 @@ const ShiftPlanList: React.FC = () => { if (loading) { return ( -
{ return (
-

📅 Schichtpläne

{hasRole(['admin', 'maintenance']) && ( - )}
{shiftPlans.length === 0 ? ( -
@@ -136,12 +136,12 @@ const ShiftPlanList: React.FC = () => {

Erstellen Sie Ihren ersten Schichtplan!

{hasRole(['admin', 'maintenance']) && ( - + + { + setShowModal(false); + setSelectedDay(null); + }} + title="Tag hinzufügen" + width="350px" + > +
+ + +
+ +
+ + +
+
+ + ); +}; + +export default AddDayButton; diff --git a/frontend/src/pages/ShiftPlans/components/ShiftCell.tsx b/frontend/src/pages/ShiftPlans/components/ShiftCell.tsx new file mode 100644 index 0000000..4db0838 --- /dev/null +++ b/frontend/src/pages/ShiftPlans/components/ShiftCell.tsx @@ -0,0 +1,234 @@ +import React, { useState, useEffect } from 'react'; +import { Shift } from '../../../models/ShiftPlan'; +import { + ICONS, + addTextButton, + deleteTextButton, + cancelTextButton, + BUTTON_COLORS, +} from '../../../utils/buttonStyles'; +import Modal from '../../../components/Modal/Modal'; + +interface ShiftCellProps { + shift: Shift | null; + dayOfWeek: number; + timeSlotId: string; + onAdd: (dayOfWeek: number, timeSlotId: string, requiredEmployees: number, color: string) => void; + onEdit: (shift: Shift, requiredEmployees: number, color: string) => void; + onDelete: (shiftId: string) => void; + disabled?: boolean; +} + +const COLORS = [ + '#3498db', // Blue (default) + '#27ae60', // Green + '#e74c3c', // Red + '#f39c12', // Orange + '#9b59b6', // Purple + '#1abc9c', // Teal + '#e91e63', // Pink + '#795548', // Brown +]; + +const ShiftCell: React.FC = ({ + shift, + dayOfWeek, + timeSlotId, + onAdd, + onEdit, + onDelete, + disabled = false +}) => { + const [showModal, setShowModal] = useState(false); + const [requiredEmployees, setRequiredEmployees] = useState(shift?.requiredEmployees || 2); + const [selectedColor, setSelectedColor] = useState(shift?.color || '#3498db'); + const [isHovered, setIsHovered] = useState(false); + + useEffect(() => { + if (shift) { + setRequiredEmployees(shift.requiredEmployees); + setSelectedColor(shift.color || '#3498db'); + } else { + setRequiredEmployees(2); + setSelectedColor('#3498db'); + } + }, [shift]); + + const handleCellClick = () => { + if (disabled) return; + setShowModal(true); + }; + + const handleSave = () => { + if (shift) { + onEdit(shift, requiredEmployees, selectedColor); + } else { + onAdd(dayOfWeek, timeSlotId, requiredEmployees, selectedColor); + } + setShowModal(false); + }; + + const handleDelete = () => { + if (shift) { + onDelete(shift.id); + } + setShowModal(false); + }; + + const handleClose = () => { + if (shift) { + setRequiredEmployees(shift.requiredEmployees); + setSelectedColor(shift.color || '#3498db'); + } else { + setRequiredEmployees(2); + setSelectedColor('#3498db'); + } + setShowModal(false); + }; + + const cellStyle: React.CSSProperties = { + padding: '8px', + textAlign: 'center', + cursor: disabled ? 'not-allowed' : 'pointer', + minWidth: '80px', + height: '60px', + verticalAlign: 'middle', + transition: 'all 0.2s ease', + border: '1px solid #dee2e6', + ...(shift ? { + backgroundColor: shift.color ? `${shift.color}20` : '#d5f4e6', + borderColor: shift.color || '#27ae60', + borderWidth: '2px', + } : { + backgroundColor: isHovered ? '#f0fff4' : '#f8f9fa', + borderStyle: 'dashed', + borderColor: isHovered ? BUTTON_COLORS.add : '#dee2e6', + }), + opacity: disabled ? 0.6 : 1, + }; + + const inputStyle: React.CSSProperties = { + width: '100%', + padding: '10px', + borderRadius: '4px', + border: '1px solid #ddd', + fontSize: '14px', + boxSizing: 'border-box', + }; + + const labelStyle: React.CSSProperties = { + display: 'block', + marginBottom: '6px', + fontWeight: 'bold', + fontSize: '14px', + color: '#2c3e50', + }; + + return ( + <> + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + {shift ? ( +
+
+ + {shift.requiredEmployees} + +
+ + Mitarbeiter + +
+ ) : ( +
+ {ICONS.add} +
+ )} + + + +
+ + setRequiredEmployees(parseInt(e.target.value) || 1)} + style={inputStyle} + /> +
+ +
+ +
+ {COLORS.map((color) => ( +
+
+ +
+ + {shift && ( + + )} + +
+
+ + ); +}; + +export default ShiftCell; diff --git a/frontend/src/pages/ShiftPlans/components/TimeSlotEditor.tsx b/frontend/src/pages/ShiftPlans/components/TimeSlotEditor.tsx new file mode 100644 index 0000000..1b22d34 --- /dev/null +++ b/frontend/src/pages/ShiftPlans/components/TimeSlotEditor.tsx @@ -0,0 +1,199 @@ +import React, { useState, useEffect } from 'react'; +import { TimeSlot } from '../../../models/ShiftPlan'; +import { + ICONS, + addTextButton, + deleteTextButton, + cancelTextButton, + borderlessEditButton, +} from '../../../utils/buttonStyles'; +import Modal from '../../../components/Modal/Modal'; + +interface TimeSlotEditorProps { + slot: TimeSlot; + onUpdate: (slot: TimeSlot, name: string, startTime: string, endTime: string, description?: string) => void; + onDelete: (slotId: string) => void; + shiftsCount: number; + disabled?: boolean; +} + +const TimeSlotEditor: React.FC = ({ + slot, + onUpdate, + onDelete, + shiftsCount, + disabled = false +}) => { + const [showModal, setShowModal] = useState(false); + const [name, setName] = useState(slot.name); + const [startTime, setStartTime] = useState(slot.startTime); + const [endTime, setEndTime] = useState(slot.endTime); + const [description, setDescription] = useState(slot.description || ''); + + useEffect(() => { + setName(slot.name); + setStartTime(slot.startTime); + setEndTime(slot.endTime); + setDescription(slot.description || ''); + }, [slot]); + + const handleEditClick = () => { + if (disabled) return; + setShowModal(true); + }; + + const handleSave = () => { + onUpdate(slot, name, startTime, endTime, description || undefined); + setShowModal(false); + }; + + const handleDelete = () => { + onDelete(slot.id); + setShowModal(false); + }; + + const handleClose = () => { + setName(slot.name); + setStartTime(slot.startTime); + setEndTime(slot.endTime); + setDescription(slot.description || ''); + setShowModal(false); + }; + + const formatTime = (time: string) => { + return time.substring(0, 5); + }; + + const inputStyle: React.CSSProperties = { + width: '100%', + padding: '10px', + borderRadius: '4px', + border: '1px solid #ddd', + fontSize: '14px', + boxSizing: 'border-box', + }; + + const labelStyle: React.CSSProperties = { + display: 'block', + marginBottom: '6px', + fontWeight: 'bold', + fontSize: '14px', + color: '#2c3e50', + }; + + return ( + <> +
+ +
+
+ {slot.name} +
+
+ {formatTime(slot.startTime)} - {formatTime(slot.endTime)} +
+
+
+ + +
+ + setName(e.target.value)} + style={inputStyle} + placeholder="z.B. Vormittag" + /> +
+ +
+
+ + setStartTime(e.target.value)} + style={inputStyle} + /> +
+
+ + setEndTime(e.target.value)} + style={inputStyle} + /> +
+
+ +
+ + setDescription(e.target.value)} + style={inputStyle} + placeholder="Optionale Beschreibung" + /> +
+ + {shiftsCount > 0 && ( +
+ Dieser Zeit-Slot enthält {shiftsCount} Schicht(en). Beim Löschen werden alle zugehörigen Schichten entfernt. +
+ )} + +
+ + + +
+
+ + ); +}; + +export default TimeSlotEditor; diff --git a/frontend/src/services/shiftPlanService.ts b/frontend/src/services/shiftPlanService.ts index fbba720..5f2708e 100644 --- a/frontend/src/services/shiftPlanService.ts +++ b/frontend/src/services/shiftPlanService.ts @@ -1,7 +1,34 @@ -import { ShiftPlan, CreateShiftPlanRequest } from '../models/ShiftPlan'; +import { ShiftPlan, CreateShiftPlanRequest, TimeSlot, Shift } from '../models/ShiftPlan'; import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults'; import { apiClient } from './apiClient'; +// Request types for time slot and shift operations +export interface CreateTimeSlotRequest { + name: string; + startTime: string; + endTime: string; + description?: string; +} + +export interface UpdateTimeSlotRequest { + name?: string; + startTime?: string; + endTime?: string; + description?: string; +} + +export interface CreateShiftRequest { + timeSlotId: string; + dayOfWeek: number; + requiredEmployees: number; + color?: string; +} + +export interface UpdateShiftRequest { + requiredEmployees?: number; + color?: string; +} + export const shiftPlanService = { async getShiftPlans(): Promise { try { @@ -158,28 +185,108 @@ export const shiftPlanService = { async exportShiftPlanToPDF(planId: string): Promise { try { console.log('📄 Exporting shift plan to PDF:', planId); - + // Use the apiClient with blob response handling const blob = await apiClient.request(`/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'); } }, + + // Time Slot operations + async addTimeSlot(planId: string, timeSlot: CreateTimeSlotRequest): Promise { + try { + return await apiClient.post(`/shift-plans/${planId}/time-slots`, timeSlot); + } catch (error: any) { + if (error.statusCode === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('employee'); + throw new Error('Nicht authorisiert - bitte erneut anmelden'); + } + throw new Error('Fehler beim Hinzufügen des Zeit-Slots'); + } + }, + + async updateTimeSlot(planId: string, slotId: string, data: UpdateTimeSlotRequest): Promise { + try { + return await apiClient.put(`/shift-plans/${planId}/time-slots/${slotId}`, data); + } catch (error: any) { + if (error.statusCode === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('employee'); + throw new Error('Nicht authorisiert - bitte erneut anmelden'); + } + throw new Error('Fehler beim Aktualisieren des Zeit-Slots'); + } + }, + + async deleteTimeSlot(planId: string, slotId: string): Promise { + try { + await apiClient.delete(`/shift-plans/${planId}/time-slots/${slotId}`); + } catch (error: any) { + if (error.statusCode === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('employee'); + throw new Error('Nicht authorisiert - bitte erneut anmelden'); + } + throw new Error('Fehler beim Löschen des Zeit-Slots'); + } + }, + + // Shift operations + async addShift(planId: string, shift: CreateShiftRequest): Promise { + try { + return await apiClient.post(`/shift-plans/${planId}/shifts`, shift); + } catch (error: any) { + if (error.statusCode === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('employee'); + throw new Error('Nicht authorisiert - bitte erneut anmelden'); + } + throw new Error('Fehler beim Hinzufügen der Schicht'); + } + }, + + async updateShift(planId: string, shiftId: string, data: UpdateShiftRequest): Promise { + try { + return await apiClient.patch(`/shift-plans/${planId}/shifts/${shiftId}`, data); + } catch (error: any) { + if (error.statusCode === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('employee'); + throw new Error('Nicht authorisiert - bitte erneut anmelden'); + } + throw new Error('Fehler beim Aktualisieren der Schicht'); + } + }, + + async deleteShift(planId: string, shiftId: string): Promise { + try { + await apiClient.delete(`/shift-plans/${planId}/shifts/${shiftId}`); + } catch (error: any) { + if (error.statusCode === 401) { + localStorage.removeItem('token'); + localStorage.removeItem('employee'); + 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/utils/buttonStyles.ts b/frontend/src/utils/buttonStyles.ts new file mode 100644 index 0000000..4e17f7f --- /dev/null +++ b/frontend/src/utils/buttonStyles.ts @@ -0,0 +1,150 @@ +// Unified button styles for consistent UI across the application + +import React from 'react'; + +// Color palette +export const BUTTON_COLORS = { + delete: '#e74c3c', + add: '#27ae60', + edit: '#f39c12', + cancel: '#95a5a6', + info: '#3498db', + primary: '#2c3e50', +}; + +// Icon constants for uniform usage +export const ICONS = { + delete: '-', + add: '+', + edit: '✎', + close: '✕', + calendar: '📅', +}; + +// Base button style +const baseButtonStyle: React.CSSProperties = { + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontWeight: 'bold', + transition: 'opacity 0.2s ease', +}; + +// Icon button (small, square) - for inline actions +export const iconButtonStyle = ( + color: string, + disabled = false +): React.CSSProperties => ({ + ...baseButtonStyle, + padding: '6px 8px', + backgroundColor: color, + color: 'white', + fontSize: '14px', + minWidth: '32px', + height: '32px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + cursor: disabled ? 'not-allowed' : 'pointer', + opacity: disabled ? 0.5 : 1, +}); + +// Small inline icon button (for table cells, headers) +export const smallIconButtonStyle = ( + color: string, + disabled = false +): React.CSSProperties => ({ + ...baseButtonStyle, + padding: '4px 8px', + backgroundColor: color, + color: 'white', + fontSize: '12px', + minWidth: '24px', + height: '24px', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + cursor: disabled ? 'not-allowed' : 'pointer', + opacity: disabled ? 0.5 : 1, +}); + +// Text button with icon prefix +export const textButtonStyle = ( + color: string, + disabled = false +): React.CSSProperties => ({ + ...baseButtonStyle, + padding: '8px 16px', + backgroundColor: color, + color: 'white', + fontSize: '14px', + cursor: disabled ? 'not-allowed' : 'pointer', + opacity: disabled ? 0.6 : 1, +}); + +// Outline/dashed button for "add new" actions +export const outlineButtonStyle = ( + color: string, + disabled = false +): React.CSSProperties => ({ + ...baseButtonStyle, + padding: '10px 20px', + backgroundColor: 'transparent', + color: color, + border: `2px dashed ${color}`, + fontSize: '14px', + width: '100%', + cursor: disabled ? 'not-allowed' : 'pointer', + opacity: disabled ? 0.6 : 1, +}); + +// Borderless icon button (for minimal UI elements) +export const borderlessIconButtonStyle = ( + color: string, + disabled = false +): React.CSSProperties => ({ + background: 'none', + border: 'none', + color: color, + cursor: disabled ? 'not-allowed' : 'pointer', + fontSize: '16px', + padding: '4px 8px', + opacity: disabled ? 0.5 : 1, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', +}); + +// Preset button styles for common actions +export const deleteIconButton = (disabled = false) => + iconButtonStyle(BUTTON_COLORS.delete, disabled); + +export const addIconButton = (disabled = false) => + iconButtonStyle(BUTTON_COLORS.add, disabled); + +export const editIconButton = (disabled = false) => + iconButtonStyle(BUTTON_COLORS.edit, disabled); + +export const deleteTextButton = (disabled = false) => + textButtonStyle(BUTTON_COLORS.delete, disabled); + +export const addTextButton = (disabled = false) => + textButtonStyle(BUTTON_COLORS.add, disabled); + +export const cancelTextButton = (disabled = false) => + textButtonStyle(BUTTON_COLORS.cancel, disabled); + +export const addOutlineButton = (disabled = false) => + outlineButtonStyle(BUTTON_COLORS.add, disabled); + +export const smallDeleteButton = (disabled = false) => + smallIconButtonStyle(BUTTON_COLORS.delete, disabled); + +export const smallAddButton = (disabled = false) => + smallIconButtonStyle(BUTTON_COLORS.add, disabled); + +export const borderlessDeleteButton = (disabled = false) => + borderlessIconButtonStyle(BUTTON_COLORS.delete, disabled); + +export const borderlessEditButton = (disabled = false) => + borderlessIconButtonStyle(BUTTON_COLORS.edit, disabled);