mirror of
https://github.com/donpat1to/Schichtenplaner.git
synced 2025-12-01 06:55:45 +01:00
added shiftplan generating logic
This commit is contained in:
@@ -10,7 +10,10 @@ export const getEmployees = async (req: AuthRequest, res: Response): Promise<voi
|
|||||||
try {
|
try {
|
||||||
console.log('🔍 Fetching employees - User:', req.user);
|
console.log('🔍 Fetching employees - User:', req.user);
|
||||||
|
|
||||||
const employees = await db.all<any>(`
|
const { includeInactive } = req.query;
|
||||||
|
const includeInactiveFlag = includeInactive === 'true';
|
||||||
|
|
||||||
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
id, email, name, role, is_active as isActive,
|
id, email, name, role, is_active as isActive,
|
||||||
employee_type as employeeType,
|
employee_type as employeeType,
|
||||||
@@ -19,9 +22,15 @@ export const getEmployees = async (req: AuthRequest, res: Response): Promise<voi
|
|||||||
created_at as createdAt,
|
created_at as createdAt,
|
||||||
last_login as lastLogin
|
last_login as lastLogin
|
||||||
FROM employees
|
FROM employees
|
||||||
WHERE is_active = 1
|
`;
|
||||||
ORDER BY name
|
|
||||||
`);
|
if (!includeInactiveFlag) {
|
||||||
|
query += ' WHERE is_active = 1';
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY name';
|
||||||
|
|
||||||
|
const employees = await db.all<any>(query);
|
||||||
|
|
||||||
console.log('✅ Employees found:', employees.length);
|
console.log('✅ Employees found:', employees.length);
|
||||||
res.json(employees);
|
res.json(employees);
|
||||||
|
|||||||
117
backend/src/routes/scheduledShifts.ts
Normal file
117
backend/src/routes/scheduledShifts.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// backend/src/routes/scheduledShifts.ts - COMPLETE REWRITE
|
||||||
|
import express from 'express';
|
||||||
|
import { authMiddleware, requireRole } from '../middleware/auth.js';
|
||||||
|
import { db } from '../services/databaseService.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
// GET all scheduled shifts for a plan (for debugging)
|
||||||
|
router.get('/plan/:planId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { planId } = req.params;
|
||||||
|
|
||||||
|
const shifts = await db.all(
|
||||||
|
`SELECT * FROM scheduled_shifts WHERE plan_id = ? ORDER BY date, time_slot_id`,
|
||||||
|
[planId]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse JSON arrays safely
|
||||||
|
const parsedShifts = shifts.map((shift: any) => {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
...shift,
|
||||||
|
assigned_employees: JSON.parse(shift.assigned_employees || '[]')
|
||||||
|
};
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Error parsing assigned_employees:', parseError);
|
||||||
|
return {
|
||||||
|
...shift,
|
||||||
|
assigned_employees: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(parsedShifts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching scheduled shifts:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET specific scheduled shift
|
||||||
|
router.get('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const shift = await db.get(
|
||||||
|
'SELECT * FROM scheduled_shifts WHERE id = ?',
|
||||||
|
[id]
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
if (!shift) {
|
||||||
|
return res.status(404).json({ error: 'Scheduled shift not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse JSON array
|
||||||
|
const parsedShift = {
|
||||||
|
...shift,
|
||||||
|
assigned_employees: JSON.parse(shift.assigned_employees || '[]')
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(parsedShift);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching scheduled shift:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error: ' + error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// UPDATE scheduled shift
|
||||||
|
router.put('/:id', requireRole(['admin', 'instandhalter']), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { assignedEmployees } = req.body;
|
||||||
|
|
||||||
|
console.log('🔄 Updating scheduled shift:', {
|
||||||
|
id,
|
||||||
|
assignedEmployees,
|
||||||
|
body: req.body
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!Array.isArray(assignedEmployees)) {
|
||||||
|
return res.status(400).json({ error: 'assignedEmployees must be an array' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if shift exists
|
||||||
|
const existingShift = await db.get(
|
||||||
|
'SELECT id FROM scheduled_shifts WHERE id = ?',
|
||||||
|
[id]
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
if (!existingShift) {
|
||||||
|
console.error('❌ Scheduled shift not found:', id);
|
||||||
|
return res.status(404).json({ error: `Scheduled shift ${id} not found` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the shift
|
||||||
|
const result = await db.run(
|
||||||
|
'UPDATE scheduled_shifts SET assigned_employees = ? WHERE id = ?',
|
||||||
|
[JSON.stringify(assignedEmployees), id]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('✅ Scheduled shift updated successfully');
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Scheduled shift updated successfully',
|
||||||
|
id: id,
|
||||||
|
assignedEmployees: assignedEmployees
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Error updating scheduled shift:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error: ' + error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -8,6 +8,7 @@ import authRoutes from './routes/auth.js';
|
|||||||
import employeeRoutes from './routes/employees.js';
|
import employeeRoutes from './routes/employees.js';
|
||||||
import shiftPlanRoutes from './routes/shiftPlans.js';
|
import shiftPlanRoutes from './routes/shiftPlans.js';
|
||||||
import setupRoutes from './routes/setup.js';
|
import setupRoutes from './routes/setup.js';
|
||||||
|
import scheduledShiftsRouter from './routes/scheduledShifts.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 3002;
|
const PORT = 3002;
|
||||||
@@ -21,6 +22,18 @@ app.use('/api/setup', setupRoutes);
|
|||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/employees', employeeRoutes);
|
app.use('/api/employees', employeeRoutes);
|
||||||
app.use('/api/shift-plans', shiftPlanRoutes);
|
app.use('/api/shift-plans', shiftPlanRoutes);
|
||||||
|
app.use('/api/scheduled-shifts', scheduledShiftsRouter);
|
||||||
|
|
||||||
|
// Error handling middleware should come after routes
|
||||||
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
|
console.error('Unhandled error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// 404 handler for API routes
|
||||||
|
app.use('/api/*', (req, res) => {
|
||||||
|
res.status(404).json({ error: 'API endpoint not found' });
|
||||||
|
});
|
||||||
|
|
||||||
// Health route
|
// Health route
|
||||||
app.get('/api/health', (req: any, res: any) => {
|
app.get('/api/health', (req: any, res: any) => {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const EmployeeManagement: React.FC = () => {
|
|||||||
console.log('🔄 Loading employees...');
|
console.log('🔄 Loading employees...');
|
||||||
|
|
||||||
// Add cache-busting parameter to prevent browser caching
|
// Add cache-busting parameter to prevent browser caching
|
||||||
const data = await employeeService.getEmployees();
|
const data = await employeeService.getEmployees(true);
|
||||||
console.log('✅ Employees loaded:', data);
|
console.log('✅ Employees loaded:', data);
|
||||||
|
|
||||||
setEmployees(data);
|
setEmployees(data);
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
|||||||
flexWrap: 'wrap'
|
flexWrap: 'wrap'
|
||||||
}}>
|
}}>
|
||||||
{/* Verfügbarkeit Button */}
|
{/* Verfügbarkeit Button */}
|
||||||
{(employee.role === 'admin' || employee.role === 'maintenance') && (
|
{(employee.role === 'admin' || employee.role === 'maintenance' || employee.role === 'user') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onManageAvailability(employee)}
|
onClick={() => onManageAvailability(employee)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
// frontend/src/pages/ShiftPlans/ShiftPlanView.tsx
|
// frontend/src/pages/ShiftPlans/ShiftPlanView.tsx (updated)
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { shiftPlanService } from '../../services/shiftPlanService';
|
import { shiftPlanService } from '../../services/shiftPlanService';
|
||||||
import { getTimeSlotById } from '../../models/helpers/shiftPlanHelpers';
|
import { employeeService } from '../../services/employeeService';
|
||||||
|
import { ShiftAssignmentService, AssignmentResult } from '../../services/shiftAssignmentService';
|
||||||
import { ShiftPlan, TimeSlot } from '../../models/ShiftPlan';
|
import { ShiftPlan, TimeSlot } from '../../models/ShiftPlan';
|
||||||
|
import { Employee, EmployeeAvailability } from '../../models/Employee';
|
||||||
import { useNotification } from '../../contexts/NotificationContext';
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
import { formatDate, formatTime } from '../../utils/foramatters';
|
import { formatDate, formatTime } from '../../utils/foramatters';
|
||||||
|
|
||||||
const ShiftPlanView: React.FC = () => {
|
const weekdays = [
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { hasRole } = useAuth();
|
|
||||||
const { showNotification } = useNotification();
|
|
||||||
const [shiftPlan, setShiftPlan] = useState<ShiftPlan | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadShiftPlan();
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
const weekdays = [
|
|
||||||
{ id: 1, name: 'Mo' },
|
{ id: 1, name: 'Mo' },
|
||||||
{ id: 2, name: 'Di' },
|
{ id: 2, name: 'Di' },
|
||||||
{ id: 3, name: 'Mi' },
|
{ id: 3, name: 'Mi' },
|
||||||
@@ -30,24 +20,201 @@ const ShiftPlanView: React.FC = () => {
|
|||||||
{ id: 7, name: 'So' }
|
{ id: 7, name: 'So' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const loadShiftPlan = async () => {
|
const ShiftPlanView: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { hasRole, user } = useAuth();
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
|
||||||
|
const [shiftPlan, setShiftPlan] = useState<ShiftPlan | null>(null);
|
||||||
|
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||||
|
const [availabilities, setAvailabilities] = useState<EmployeeAvailability[]>([]);
|
||||||
|
const [assignmentResult, setAssignmentResult] = useState<AssignmentResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [publishing, setPublishing] = useState(false);
|
||||||
|
const [showAssignmentPreview, setShowAssignmentPreview] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadShiftPlanData();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
const loadShiftPlanData = async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const plan = await shiftPlanService.getShiftPlan(id);
|
setLoading(true);
|
||||||
|
const [plan, employeesData] = await Promise.all([
|
||||||
|
shiftPlanService.getShiftPlan(id),
|
||||||
|
employeeService.getEmployees()
|
||||||
|
]);
|
||||||
|
|
||||||
setShiftPlan(plan);
|
setShiftPlan(plan);
|
||||||
|
setEmployees(employeesData.filter(emp => emp.isActive));
|
||||||
|
|
||||||
|
// Load availabilities for all employees
|
||||||
|
const availabilityPromises = employeesData
|
||||||
|
.filter(emp => emp.isActive)
|
||||||
|
.map(emp => employeeService.getAvailabilities(emp.id));
|
||||||
|
|
||||||
|
const allAvailabilities = await Promise.all(availabilityPromises);
|
||||||
|
const flattenedAvailabilities = allAvailabilities.flat();
|
||||||
|
|
||||||
|
// Filter availabilities to only include those for the current shift plan
|
||||||
|
const planAvailabilities = flattenedAvailabilities.filter(
|
||||||
|
availability => availability.planId === id
|
||||||
|
);
|
||||||
|
|
||||||
|
setAvailabilities(planAvailabilities);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading shift plan:', error);
|
console.error('Error loading shift plan data:', error);
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
title: 'Fehler',
|
title: 'Fehler',
|
||||||
message: 'Der Schichtplan konnte nicht geladen werden.'
|
message: 'Daten konnten nicht geladen werden.'
|
||||||
});
|
});
|
||||||
navigate('/shift-plans');
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePreviewAssignment = async () => {
|
||||||
|
if (!shiftPlan) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setPublishing(true);
|
||||||
|
const result = await ShiftAssignmentService.assignShifts(
|
||||||
|
shiftPlan,
|
||||||
|
employees,
|
||||||
|
availabilities,
|
||||||
|
{
|
||||||
|
enforceExperiencedWithChef: true,
|
||||||
|
enforceNoTraineeAlone: true,
|
||||||
|
maxRepairAttempts: 50
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setAssignmentResult(result);
|
||||||
|
setShowAssignmentPreview(true);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
showNotification({
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Warnung',
|
||||||
|
message: `Automatische Zuordnung hat ${result.violations.length} Probleme gefunden.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during assignment:', error);
|
||||||
|
showNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Fehler',
|
||||||
|
message: 'Automatische Zuordnung fehlgeschlagen.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPublishing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublish = async () => {
|
||||||
|
if (!shiftPlan || !assignmentResult) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setPublishing(true);
|
||||||
|
|
||||||
|
console.log('🔄 Starting to publish assignments...');
|
||||||
|
|
||||||
|
// Debug: Check if scheduled shifts exist
|
||||||
|
if (!shiftPlan.scheduledShifts || shiftPlan.scheduledShifts.length === 0) {
|
||||||
|
throw new Error('No scheduled shifts found in the plan');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update scheduled shifts with assignments
|
||||||
|
const updatePromises = shiftPlan.scheduledShifts.map(async (scheduledShift) => {
|
||||||
|
const assignedEmployees = assignmentResult.assignments[scheduledShift.id] || [];
|
||||||
|
|
||||||
|
console.log(`📝 Updating shift ${scheduledShift.id} with`, assignedEmployees.length, 'employees');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, verify the shift exists
|
||||||
|
await shiftPlanService.getScheduledShift(scheduledShift.id);
|
||||||
|
|
||||||
|
// Then update it
|
||||||
|
await shiftPlanService.updateScheduledShift(scheduledShift.id, {
|
||||||
|
assignedEmployees
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Successfully updated shift ${scheduledShift.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Failed to update shift ${scheduledShift.id}:`, error);
|
||||||
|
throw error; // Re-throw to stop the process
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(updatePromises);
|
||||||
|
|
||||||
|
// Update plan status to published
|
||||||
|
console.log('🔄 Updating plan status to published...');
|
||||||
|
await shiftPlanService.updateShiftPlan(shiftPlan.id, {
|
||||||
|
status: 'published'
|
||||||
|
});
|
||||||
|
|
||||||
|
showNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Erfolg',
|
||||||
|
message: 'Schichtplan wurde erfolgreich veröffentlicht!'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload the plan to reflect changes
|
||||||
|
loadShiftPlanData();
|
||||||
|
setShowAssignmentPreview(false);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error publishing shift plan:', error);
|
||||||
|
|
||||||
|
let message = 'Unbekannter Fehler';
|
||||||
|
if (error instanceof Error) {
|
||||||
|
message = error.message;
|
||||||
|
} else if (typeof error === 'string') {
|
||||||
|
message = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Fehler',
|
||||||
|
message: `Schichtplan konnte nicht veröffentlicht werden: ${message}`
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setPublishing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canPublish = () => {
|
||||||
|
if (!shiftPlan || shiftPlan.status === 'published') return false;
|
||||||
|
|
||||||
|
// Check if all active employees have set their availabilities
|
||||||
|
const employeesWithoutAvailabilities = employees.filter(emp => {
|
||||||
|
const empAvailabilities = availabilities.filter(avail => avail.employeeId === emp.id);
|
||||||
|
return empAvailabilities.length === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return employeesWithoutAvailabilities.length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvailabilityStatus = () => {
|
||||||
|
const totalEmployees = employees.length;
|
||||||
|
const employeesWithAvailabilities = new Set(
|
||||||
|
availabilities.map(avail => avail.employeeId)
|
||||||
|
).size;
|
||||||
|
|
||||||
|
return {
|
||||||
|
completed: employeesWithAvailabilities,
|
||||||
|
total: totalEmployees,
|
||||||
|
percentage: Math.round((employeesWithAvailabilities / totalEmployees) * 100)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Simplified timetable data generation
|
// Simplified timetable data generation
|
||||||
const getTimetableData = () => {
|
const getTimetableData = () => {
|
||||||
if (!shiftPlan) return { shifts: [], weekdays: [] };
|
if (!shiftPlan) return { shifts: [], weekdays: [] };
|
||||||
@@ -86,47 +253,187 @@ const ShiftPlanView: React.FC = () => {
|
|||||||
if (!shiftPlan) return <div>Schichtplan nicht gefunden</div>;
|
if (!shiftPlan) return <div>Schichtplan nicht gefunden</div>;
|
||||||
|
|
||||||
const timetableData = getTimetableData();
|
const timetableData = getTimetableData();
|
||||||
|
|
||||||
|
const debugApiEndpoints = async () => {
|
||||||
|
if (!shiftPlan) return;
|
||||||
|
|
||||||
|
console.log('🔍 Testing API endpoints for plan:', shiftPlan.id);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test the scheduled shifts endpoint
|
||||||
|
const shifts = await shiftPlanService.getScheduledShiftsForPlan(shiftPlan.id);
|
||||||
|
console.log('✅ GET /api/scheduled-shifts/plan/:planId works:', shifts.length, 'shifts found');
|
||||||
|
|
||||||
|
if (shifts.length > 0) {
|
||||||
|
const firstShift = shifts[0];
|
||||||
|
console.log('🔍 First shift:', firstShift);
|
||||||
|
|
||||||
|
// Test updating the first shift
|
||||||
|
try {
|
||||||
|
await shiftPlanService.updateScheduledShift(firstShift.id, {
|
||||||
|
assignedEmployees: ['test-employee']
|
||||||
|
});
|
||||||
|
console.log('✅ PUT /api/scheduled-shifts/:id works');
|
||||||
|
} catch (updateError) {
|
||||||
|
console.error('❌ PUT /api/scheduled-shifts/:id failed:', updateError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ GET /api/scheduled-shifts/plan/:planId failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '20px' }}>
|
<div style={{ padding: '20px' }}>
|
||||||
|
{/* Existing header code... */}
|
||||||
|
|
||||||
|
{/* Availability Status */}
|
||||||
|
{shiftPlan?.status === 'draft' && (
|
||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
backgroundColor: 'white',
|
||||||
justifyContent: 'space-between',
|
borderRadius: '8px',
|
||||||
alignItems: 'center',
|
padding: '20px',
|
||||||
marginBottom: '30px'
|
marginBottom: '20px',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||||
}}>
|
}}>
|
||||||
|
<h3>Veröffentlichungsvoraussetzungen</h3>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '20px', marginBottom: '15px' }}>
|
||||||
<div>
|
<div>
|
||||||
<h1>{shiftPlan.name}</h1>
|
<div style={{ fontSize: '14px', color: '#666', marginBottom: '5px' }}>
|
||||||
<p style={{ color: '#666', marginTop: '5px' }}>
|
Verfügbarkeitseinträge:
|
||||||
Zeitraum: {formatDate(shiftPlan.startDate)} - {formatDate(shiftPlan.endDate)}
|
|
||||||
</p>
|
|
||||||
<p style={{ color: '#666', marginTop: '5px' }}>
|
|
||||||
Status: <span style={{
|
|
||||||
color: shiftPlan.status === 'published' ? '#2ecc71' : '#f1c40f',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}>
|
|
||||||
{shiftPlan.status === 'published' ? 'Veröffentlicht' : 'Entwurf'}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div style={{ fontSize: '18px', fontWeight: 'bold' }}>
|
||||||
{hasRole(['admin', 'instandhalter']) && (
|
{getAvailabilityStatus().completed} / {getAvailabilityStatus().total} Mitarbeiter
|
||||||
<button
|
</div>
|
||||||
onClick={() => navigate(`/shift-plans/${id}/edit`)}
|
<div style={{
|
||||||
|
width: '200px',
|
||||||
|
height: '8px',
|
||||||
|
backgroundColor: '#e0e0e0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginTop: '5px',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
width: `${getAvailabilityStatus().percentage}%`,
|
||||||
backgroundColor: '#f1c40f',
|
height: '100%',
|
||||||
|
backgroundColor: getAvailabilityStatus().percentage === 100 ? '#2ecc71' : '#f1c40f',
|
||||||
|
transition: 'all 0.3s ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasRole(['admin', 'instandhalter']) && (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={handlePreviewAssignment}
|
||||||
|
disabled={!canPublish() || publishing}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: canPublish() ? '#2ecc71' : '#95a5a6',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: canPublish() ? 'pointer' : 'not-allowed',
|
||||||
marginRight: '10px'
|
fontWeight: 'bold'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Bearbeiten
|
{publishing ? 'Berechne...' : 'Automatisch zuweisen & Veröffentlichen'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{!canPublish() && (
|
||||||
|
<div style={{ fontSize: '12px', color: '#666', marginTop: '5px' }}>
|
||||||
|
{getAvailabilityStatus().percentage === 100
|
||||||
|
? 'Bereit zur Veröffentlichung'
|
||||||
|
: `${getAvailabilityStatus().total - getAvailabilityStatus().completed} Mitarbeiter müssen noch Verfügbarkeit eintragen`}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assignment Preview Modal */}
|
||||||
|
{showAssignmentPreview && assignmentResult && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 1000
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '30px',
|
||||||
|
maxWidth: '800px',
|
||||||
|
maxHeight: '80vh',
|
||||||
|
overflow: 'auto'
|
||||||
|
}}>
|
||||||
|
<h2>Wochenmuster-Zuordnung</h2>
|
||||||
|
|
||||||
|
{/* Show weekly pattern info */}
|
||||||
|
{assignmentResult.pattern && (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#e8f4fd',
|
||||||
|
border: '1px solid #b8d4f0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '15px',
|
||||||
|
marginBottom: '20px'
|
||||||
|
}}>
|
||||||
|
<h4 style={{ color: '#2c3e50', marginTop: 0 }}>Wochenmuster erstellt</h4>
|
||||||
|
<p style={{ margin: 0, color: '#2c3e50' }}>
|
||||||
|
Der Algorithmus hat ein Muster für <strong>{assignmentResult.pattern.weekShifts.length} Schichten</strong> in der ersten Woche erstellt
|
||||||
|
und dieses für alle {Math.ceil(Object.keys(assignmentResult.assignments).length / assignmentResult.pattern.weekShifts.length)} Wochen im Plan wiederholt.
|
||||||
|
</p>
|
||||||
|
<div style={{ marginTop: '10px', fontSize: '14px' }}>
|
||||||
|
<strong>Wochenmuster-Statistik:</strong>
|
||||||
|
<div>- Schichten pro Woche: {assignmentResult.pattern.weekShifts.length}</div>
|
||||||
|
<div>- Zuweisungen pro Woche: {Object.values(assignmentResult.pattern.assignments).flat().length}</div>
|
||||||
|
<div>- Gesamtzuweisungen: {Object.values(assignmentResult.assignments).flat().length}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{assignmentResult.violations.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#fff3cd',
|
||||||
|
border: '1px solid #ffeaa7',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '15px',
|
||||||
|
marginBottom: '20px'
|
||||||
|
}}>
|
||||||
|
<h4 style={{ color: '#856404', marginTop: 0 }}>Warnungen:</h4>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||||
|
{assignmentResult.violations.map((violation, index) => (
|
||||||
|
<li key={index} style={{ color: '#856404', marginBottom: '5px' }}>
|
||||||
|
{violation}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<h4>Zusammenfassung:</h4>
|
||||||
|
<p>
|
||||||
|
{assignmentResult.success
|
||||||
|
? '✅ Alle Schichten können zugeordnet werden!'
|
||||||
|
: '⚠️ Es gibt Probleme bei der Zuordnung die manuell behoben werden müssen.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/shift-plans')}
|
onClick={() => setShowAssignmentPreview(false)}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
backgroundColor: '#95a5a6',
|
backgroundColor: '#95a5a6',
|
||||||
@@ -136,10 +443,42 @@ const ShiftPlanView: React.FC = () => {
|
|||||||
cursor: 'pointer'
|
cursor: 'pointer'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Zurück
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handlePublish}
|
||||||
|
disabled={publishing || !assignmentResult.success}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: assignmentResult.success ? '#2ecc71' : '#95a5a6',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: assignmentResult.success ? 'pointer' : 'not-allowed'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{publishing ? 'Veröffentliche...' : 'Veröffentlichen'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={debugApiEndpoints}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#f39c12',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginRight: '10px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Test API Endpoints
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: 'white',
|
backgroundColor: 'white',
|
||||||
@@ -147,16 +486,6 @@ const ShiftPlanView: React.FC = () => {
|
|||||||
padding: '20px',
|
padding: '20px',
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ marginBottom: '20px' }}>
|
|
||||||
<div>Zeitraum: {formatDate(shiftPlan.startDate)} - {formatDate(shiftPlan.endDate)}</div>
|
|
||||||
<div>Status: <span style={{
|
|
||||||
color: shiftPlan.status === 'published' ? '#2ecc71' : '#f1c40f',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}>
|
|
||||||
{shiftPlan.status === 'published' ? 'Veröffentlicht' : 'Entwurf'}
|
|
||||||
</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timetable */}
|
{/* Timetable */}
|
||||||
<div style={{ marginTop: '30px' }}>
|
<div style={{ marginTop: '30px' }}>
|
||||||
<h3>Schichtplan</h3>
|
<h3>Schichtplan</h3>
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ const getAuthHeaders = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class EmployeeService {
|
export class EmployeeService {
|
||||||
async getEmployees(): Promise<Employee[]> {
|
async getEmployees(includeInactive: boolean = false): Promise<Employee[]> {
|
||||||
console.log('🔄 Fetching employees from API...');
|
console.log('🔄 Fetching employees from API...');
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
console.log('🔑 Token exists:', !!token);
|
console.log('🔑 Token exists:', !!token);
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/employees`, {
|
const response = await fetch(`${API_BASE_URL}/employees?includeInactive=${includeInactive}`, {
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
406
frontend/src/services/shiftAssignmentService.ts
Normal file
406
frontend/src/services/shiftAssignmentService.ts
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
// frontend/src/services/shiftAssignmentService.ts - WEEKLY PATTERN VERSION
|
||||||
|
import { ShiftPlan, ScheduledShift } from '../models/ShiftPlan';
|
||||||
|
import { Employee, EmployeeAvailability } from '../models/Employee';
|
||||||
|
|
||||||
|
export interface AssignmentResult {
|
||||||
|
assignments: { [shiftId: string]: string[] };
|
||||||
|
violations: string[];
|
||||||
|
success: boolean;
|
||||||
|
pattern: WeeklyPattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklyPattern {
|
||||||
|
weekShifts: ScheduledShift[];
|
||||||
|
assignments: { [shiftId: string]: string[] };
|
||||||
|
weekNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ShiftAssignmentService {
|
||||||
|
|
||||||
|
static async assignShifts(
|
||||||
|
shiftPlan: ShiftPlan,
|
||||||
|
employees: Employee[],
|
||||||
|
availabilities: EmployeeAvailability[],
|
||||||
|
constraints: any = {}
|
||||||
|
): Promise<AssignmentResult> {
|
||||||
|
|
||||||
|
console.log('🔄 Starting weekly pattern assignment...');
|
||||||
|
|
||||||
|
// Get defined shifts
|
||||||
|
const definedShifts = this.getDefinedShifts(shiftPlan);
|
||||||
|
const activeEmployees = employees.filter(emp => emp.isActive);
|
||||||
|
|
||||||
|
console.log('📊 Plan analysis:');
|
||||||
|
console.log('- Total shifts in plan:', definedShifts.length);
|
||||||
|
console.log('- Active employees:', activeEmployees.length);
|
||||||
|
|
||||||
|
// STRATEGY: Create weekly pattern and repeat
|
||||||
|
const weeklyPattern = await this.createWeeklyPattern(
|
||||||
|
definedShifts,
|
||||||
|
activeEmployees,
|
||||||
|
availabilities,
|
||||||
|
constraints.enforceNoTraineeAlone
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('🎯 Weekly pattern created for', weeklyPattern.weekShifts.length, 'shifts');
|
||||||
|
|
||||||
|
// Apply pattern to all weeks in the plan
|
||||||
|
const assignments = this.applyWeeklyPattern(definedShifts, weeklyPattern);
|
||||||
|
|
||||||
|
const violations = this.findViolations(assignments, activeEmployees, definedShifts, constraints.enforceNoTraineeAlone);
|
||||||
|
|
||||||
|
console.log('📊 Weekly pattern assignment completed:');
|
||||||
|
console.log('- Pattern shifts:', weeklyPattern.weekShifts.length);
|
||||||
|
console.log('- Total plan shifts:', definedShifts.length);
|
||||||
|
console.log('- Assignments made:', Object.values(assignments).flat().length);
|
||||||
|
console.log('- Violations:', violations.length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
assignments,
|
||||||
|
violations,
|
||||||
|
success: violations.length === 0,
|
||||||
|
pattern: weeklyPattern
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async createWeeklyPattern(
|
||||||
|
definedShifts: ScheduledShift[],
|
||||||
|
employees: Employee[],
|
||||||
|
availabilities: EmployeeAvailability[],
|
||||||
|
enforceNoTraineeAlone: boolean
|
||||||
|
): Promise<WeeklyPattern> {
|
||||||
|
|
||||||
|
// Get first week of shifts (7 days from the start)
|
||||||
|
const firstWeekShifts = this.getFirstWeekShifts(definedShifts);
|
||||||
|
|
||||||
|
console.log('📅 First week analysis:');
|
||||||
|
console.log('- Shifts in first week:', firstWeekShifts.length);
|
||||||
|
|
||||||
|
// Fix: Use Array.from instead of spread operator with Set
|
||||||
|
const uniqueDays = Array.from(new Set(firstWeekShifts.map(s => this.getDayOfWeek(s.date)))).sort();
|
||||||
|
console.log('- Days covered:', uniqueDays);
|
||||||
|
|
||||||
|
// Build availability map
|
||||||
|
const availabilityMap = this.buildAvailabilityMap(availabilities);
|
||||||
|
const employeeMap = new Map(employees.map(emp => [emp.id, emp]));
|
||||||
|
|
||||||
|
// Initialize assignment state for first week
|
||||||
|
const weeklyAssignments: { [shiftId: string]: string[] } = {};
|
||||||
|
const employeeAssignmentCount: { [employeeId: string]: number } = {};
|
||||||
|
|
||||||
|
employees.forEach(emp => {
|
||||||
|
employeeAssignmentCount[emp.id] = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
firstWeekShifts.forEach(shift => {
|
||||||
|
weeklyAssignments[shift.id] = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort employees by capacity and experience
|
||||||
|
const sortedEmployees = [...employees].sort((a, b) => {
|
||||||
|
const aCapacity = this.getMaxAssignments(a);
|
||||||
|
const bCapacity = this.getMaxAssignments(b);
|
||||||
|
const aIsManager = a.role === 'admin';
|
||||||
|
const bIsManager = b.role === 'admin';
|
||||||
|
|
||||||
|
if (aIsManager !== bIsManager) return aIsManager ? -1 : 1;
|
||||||
|
if (a.employeeType !== b.employeeType) {
|
||||||
|
if (a.employeeType === 'experienced') return -1;
|
||||||
|
if (b.employeeType === 'experienced') return 1;
|
||||||
|
}
|
||||||
|
return bCapacity - aCapacity;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort shifts by priority (those with fewer available employees first)
|
||||||
|
const sortedShifts = [...firstWeekShifts].sort((a, b) => {
|
||||||
|
const aAvailable = this.countAvailableEmployees(a, employees, availabilityMap);
|
||||||
|
const bAvailable = this.countAvailableEmployees(b, employees, availabilityMap);
|
||||||
|
return aAvailable - bAvailable;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign employees to first week shifts
|
||||||
|
for (const employee of sortedEmployees) {
|
||||||
|
const maxAssignments = this.getMaxAssignments(employee);
|
||||||
|
|
||||||
|
// Get available shifts for this employee in first week
|
||||||
|
const availableShifts = sortedShifts
|
||||||
|
.filter(shift => {
|
||||||
|
if (employeeAssignmentCount[employee.id] >= maxAssignments) return false;
|
||||||
|
if (weeklyAssignments[shift.id].length >= shift.requiredEmployees) return false;
|
||||||
|
|
||||||
|
const dayOfWeek = this.getDayOfWeek(shift.date);
|
||||||
|
const shiftKey = `${dayOfWeek}-${shift.timeSlotId}`;
|
||||||
|
|
||||||
|
return this.isEmployeeAvailable(employee, shiftKey, availabilityMap) &&
|
||||||
|
this.isAssignmentCompatible(employee, weeklyAssignments[shift.id], employeeMap, enforceNoTraineeAlone);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Prefer shifts with fewer current assignments
|
||||||
|
return weeklyAssignments[a.id].length - weeklyAssignments[b.id].length;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign to available shifts until capacity is reached
|
||||||
|
for (const shift of availableShifts) {
|
||||||
|
if (employeeAssignmentCount[employee.id] >= maxAssignments) break;
|
||||||
|
|
||||||
|
weeklyAssignments[shift.id].push(employee.id);
|
||||||
|
employeeAssignmentCount[employee.id]++;
|
||||||
|
console.log(`✅ Assigned ${employee.name} to weekly pattern shift`);
|
||||||
|
|
||||||
|
if (employeeAssignmentCount[employee.id] >= maxAssignments) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure all shifts in first week have at least one employee
|
||||||
|
for (const shift of firstWeekShifts) {
|
||||||
|
if (weeklyAssignments[shift.id].length === 0) {
|
||||||
|
const dayOfWeek = this.getDayOfWeek(shift.date);
|
||||||
|
const shiftKey = `${dayOfWeek}-${shift.timeSlotId}`;
|
||||||
|
|
||||||
|
const availableEmployees = employees
|
||||||
|
.filter(emp =>
|
||||||
|
this.isEmployeeAvailable(emp, shiftKey, availabilityMap) &&
|
||||||
|
this.canAssignByContract(emp, employeeAssignmentCount)
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aPref = availabilityMap.get(a.id)?.get(shiftKey) || 3;
|
||||||
|
const bPref = availabilityMap.get(b.id)?.get(shiftKey) || 3;
|
||||||
|
const aCount = employeeAssignmentCount[a.id] || 0;
|
||||||
|
const bCount = employeeAssignmentCount[b.id] || 0;
|
||||||
|
|
||||||
|
if (aPref !== bPref) return aPref - bPref;
|
||||||
|
return aCount - bCount;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (availableEmployees.length > 0) {
|
||||||
|
const bestCandidate = availableEmployees[0];
|
||||||
|
weeklyAssignments[shift.id].push(bestCandidate.id);
|
||||||
|
employeeAssignmentCount[bestCandidate.id]++;
|
||||||
|
console.log(`🆘 Emergency assigned ${bestCandidate.name} to weekly pattern`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
weekShifts: firstWeekShifts,
|
||||||
|
assignments: weeklyAssignments,
|
||||||
|
weekNumber: 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static applyWeeklyPattern(
|
||||||
|
allShifts: ScheduledShift[],
|
||||||
|
weeklyPattern: WeeklyPattern
|
||||||
|
): { [shiftId: string]: string[] } {
|
||||||
|
|
||||||
|
const assignments: { [shiftId: string]: string[] } = {};
|
||||||
|
|
||||||
|
// Group all shifts by week
|
||||||
|
const shiftsByWeek = this.groupShiftsByWeek(allShifts);
|
||||||
|
|
||||||
|
console.log('📅 Applying weekly pattern to', Object.keys(shiftsByWeek).length, 'weeks');
|
||||||
|
|
||||||
|
// For each week, apply the pattern from week 1
|
||||||
|
Object.entries(shiftsByWeek).forEach(([weekKey, weekShifts]) => {
|
||||||
|
const weekNumber = parseInt(weekKey);
|
||||||
|
|
||||||
|
weekShifts.forEach(shift => {
|
||||||
|
// Find the corresponding shift in the weekly pattern
|
||||||
|
const patternShift = this.findMatchingPatternShift(shift, weeklyPattern.weekShifts);
|
||||||
|
|
||||||
|
if (patternShift) {
|
||||||
|
// Use the same assignment as the pattern shift
|
||||||
|
assignments[shift.id] = [...weeklyPattern.assignments[patternShift.id]];
|
||||||
|
} else {
|
||||||
|
// No matching pattern shift, leave empty
|
||||||
|
assignments[shift.id] = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return assignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static groupShiftsByWeek(shifts: ScheduledShift[]): { [weekNumber: string]: ScheduledShift[] } {
|
||||||
|
const weeks: { [weekNumber: string]: ScheduledShift[] } = {};
|
||||||
|
|
||||||
|
shifts.forEach(shift => {
|
||||||
|
const weekNumber = this.getWeekNumber(shift.date);
|
||||||
|
if (!weeks[weekNumber]) {
|
||||||
|
weeks[weekNumber] = [];
|
||||||
|
}
|
||||||
|
weeks[weekNumber].push(shift);
|
||||||
|
});
|
||||||
|
|
||||||
|
return weeks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getFirstWeekShifts(shifts: ScheduledShift[]): ScheduledShift[] {
|
||||||
|
if (shifts.length === 0) return [];
|
||||||
|
|
||||||
|
// Sort by date and get the first 7 days
|
||||||
|
const sortedShifts = [...shifts].sort((a, b) => a.date.localeCompare(b.date));
|
||||||
|
const firstDate = new Date(sortedShifts[0].date);
|
||||||
|
const firstWeekEnd = new Date(firstDate);
|
||||||
|
firstWeekEnd.setDate(firstWeekEnd.getDate() + 6); // 7 days total
|
||||||
|
|
||||||
|
return sortedShifts.filter(shift => {
|
||||||
|
const shiftDate = new Date(shift.date);
|
||||||
|
return shiftDate >= firstDate && shiftDate <= firstWeekEnd;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static findMatchingPatternShift(
|
||||||
|
shift: ScheduledShift,
|
||||||
|
patternShifts: ScheduledShift[]
|
||||||
|
): ScheduledShift | null {
|
||||||
|
const shiftDayOfWeek = this.getDayOfWeek(shift.date);
|
||||||
|
const shiftTimeSlot = shift.timeSlotId;
|
||||||
|
|
||||||
|
return patternShifts.find(patternShift =>
|
||||||
|
this.getDayOfWeek(patternShift.date) === shiftDayOfWeek &&
|
||||||
|
patternShift.timeSlotId === shiftTimeSlot
|
||||||
|
) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getWeekNumber(dateString: string): number {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const firstDayOfYear = new Date(date.getFullYear(), 0, 1);
|
||||||
|
const pastDaysOfYear = (date.getTime() - firstDayOfYear.getTime()) / 86400000;
|
||||||
|
return Math.ceil((pastDaysOfYear + firstDayOfYear.getDay() + 1) / 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== EXISTING HELPER METHODS ==========
|
||||||
|
|
||||||
|
private static getDefinedShifts(shiftPlan: ShiftPlan): ScheduledShift[] {
|
||||||
|
if (!shiftPlan.scheduledShifts) return [];
|
||||||
|
|
||||||
|
const definedShiftPatterns = new Set(
|
||||||
|
shiftPlan.shifts.map(shift =>
|
||||||
|
`${shift.dayOfWeek}-${shift.timeSlotId}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const definedShifts = shiftPlan.scheduledShifts.filter(scheduledShift => {
|
||||||
|
const dayOfWeek = this.getDayOfWeek(scheduledShift.date);
|
||||||
|
const pattern = `${dayOfWeek}-${scheduledShift.timeSlotId}`;
|
||||||
|
return definedShiftPatterns.has(pattern);
|
||||||
|
});
|
||||||
|
|
||||||
|
return definedShifts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static countAvailableEmployees(
|
||||||
|
scheduledShift: ScheduledShift,
|
||||||
|
employees: Employee[],
|
||||||
|
availabilityMap: Map<string, Map<string, number>>
|
||||||
|
): number {
|
||||||
|
const dayOfWeek = this.getDayOfWeek(scheduledShift.date);
|
||||||
|
const shiftKey = `${dayOfWeek}-${scheduledShift.timeSlotId}`;
|
||||||
|
|
||||||
|
return employees.filter(emp =>
|
||||||
|
this.isEmployeeAvailable(emp, shiftKey, availabilityMap)
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isAssignmentCompatible(
|
||||||
|
candidate: Employee,
|
||||||
|
currentAssignments: string[],
|
||||||
|
employeeMap: Map<string, Employee>,
|
||||||
|
enforceNoTraineeAlone: boolean
|
||||||
|
): boolean {
|
||||||
|
if (!enforceNoTraineeAlone || currentAssignments.length === 0) return true;
|
||||||
|
|
||||||
|
const currentEmployees = currentAssignments.map(id => employeeMap.get(id)).filter(Boolean) as Employee[];
|
||||||
|
|
||||||
|
if (candidate.employeeType === 'trainee') {
|
||||||
|
const hasExperiencedOrChef = currentEmployees.some(emp =>
|
||||||
|
emp.employeeType === 'experienced' || emp.role === 'admin'
|
||||||
|
);
|
||||||
|
return hasExperiencedOrChef;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static buildAvailabilityMap(availabilities: EmployeeAvailability[]): Map<string, Map<string, number>> {
|
||||||
|
const map = new Map<string, Map<string, number>>();
|
||||||
|
|
||||||
|
availabilities.forEach(avail => {
|
||||||
|
if (!map.has(avail.employeeId)) {
|
||||||
|
map.set(avail.employeeId, new Map<string, number>());
|
||||||
|
}
|
||||||
|
|
||||||
|
const shiftKey = `${avail.dayOfWeek}-${avail.timeSlotId}`;
|
||||||
|
map.get(avail.employeeId)!.set(shiftKey, avail.preferenceLevel);
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static isEmployeeAvailable(
|
||||||
|
employee: Employee,
|
||||||
|
shiftKey: string,
|
||||||
|
availabilityMap: Map<string, Map<string, number>>
|
||||||
|
): boolean {
|
||||||
|
if (!employee.isActive) return false;
|
||||||
|
|
||||||
|
const employeeAvailability = availabilityMap.get(employee.id);
|
||||||
|
if (!employeeAvailability) return false;
|
||||||
|
|
||||||
|
const preference = employeeAvailability.get(shiftKey);
|
||||||
|
return preference !== undefined && preference !== 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static canAssignByContract(
|
||||||
|
employee: Employee,
|
||||||
|
assignmentCount: { [employeeId: string]: number }
|
||||||
|
): boolean {
|
||||||
|
const currentCount = assignmentCount[employee.id] || 0;
|
||||||
|
const maxAssignments = this.getMaxAssignments(employee);
|
||||||
|
return currentCount < maxAssignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getMaxAssignments(employee: Employee): number {
|
||||||
|
switch (employee.contractType) {
|
||||||
|
case 'small': return 1;
|
||||||
|
case 'large': return 2;
|
||||||
|
default: return 999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static findViolations(
|
||||||
|
assignments: { [shiftId: string]: string[] },
|
||||||
|
employees: Employee[],
|
||||||
|
definedShifts: ScheduledShift[],
|
||||||
|
enforceNoTraineeAlone: boolean
|
||||||
|
): string[] {
|
||||||
|
const violations: string[] = [];
|
||||||
|
const employeeMap = new Map(employees.map(emp => [emp.id, emp]));
|
||||||
|
|
||||||
|
definedShifts.forEach(scheduledShift => {
|
||||||
|
const assignedEmployeeIds = assignments[scheduledShift.id] || [];
|
||||||
|
|
||||||
|
if (assignedEmployeeIds.length === 0) {
|
||||||
|
violations.push(`Shift has no assigned employees`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedEmployees = assignedEmployeeIds.map(id => employeeMap.get(id)).filter(Boolean) as Employee[];
|
||||||
|
|
||||||
|
if (enforceNoTraineeAlone && assignedEmployees.length === 1) {
|
||||||
|
const soloEmployee = assignedEmployees[0];
|
||||||
|
if (soloEmployee.employeeType === 'trainee') {
|
||||||
|
violations.push(`Trainee ${soloEmployee.name} is working alone`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return violations;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getDayOfWeek(dateString: string): number {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.getDay() === 0 ? 7 : date.getDay();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// frontend/src/services/shiftPlanService.ts
|
// frontend/src/services/shiftPlanService.ts
|
||||||
import { authService } from './authService';
|
import { authService } from './authService';
|
||||||
import { ShiftPlan, CreateShiftPlanRequest, Shift, CreateShiftFromTemplateRequest } from '../models/ShiftPlan';
|
import { ShiftPlan, CreateShiftPlanRequest, ScheduledShift, CreateShiftFromTemplateRequest } from '../models/ShiftPlan';
|
||||||
import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults';
|
import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults';
|
||||||
|
|
||||||
const API_BASE = 'http://localhost:3002/api/shift-plans';
|
const API_BASE = 'http://localhost:3002/api/shift-plans';
|
||||||
@@ -186,4 +186,105 @@ export const shiftPlanService = {
|
|||||||
description: preset.description
|
description: preset.description
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateScheduledShift(id: string, updates: { assignedEmployees: string[] }): Promise<void> {
|
||||||
|
try {
|
||||||
|
console.log('🔄 Updating scheduled shift via API:', { id, updates });
|
||||||
|
|
||||||
|
const response = await fetch(`/api/scheduled-shifts/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updates)
|
||||||
|
});
|
||||||
|
|
||||||
|
// First, check if we got any response
|
||||||
|
if (!response.ok) {
|
||||||
|
// Try to get error message from response
|
||||||
|
const responseText = await response.text();
|
||||||
|
console.error('❌ Server response:', responseText);
|
||||||
|
|
||||||
|
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
|
||||||
|
// Try to parse as JSON if possible
|
||||||
|
try {
|
||||||
|
const errorData = JSON.parse(responseText);
|
||||||
|
errorMessage = errorData.error || errorMessage;
|
||||||
|
} catch (e) {
|
||||||
|
// If not JSON, use the text as is
|
||||||
|
errorMessage = responseText || errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse successful response
|
||||||
|
const responseText = await response.text();
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = responseText ? JSON.parse(responseText) : {};
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('⚠️ Response was not JSON, but request succeeded');
|
||||||
|
result = { message: 'Update successful' };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Scheduled shift updated successfully:', result);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error updating scheduled shift:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getScheduledShift(id: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/scheduled-shifts/${id}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const responseText = await response.text();
|
||||||
|
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorData = JSON.parse(responseText);
|
||||||
|
errorMessage = errorData.error || errorMessage;
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage = responseText || errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
return responseText ? JSON.parse(responseText) : {};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching scheduled shift:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// New method to get all scheduled shifts for a plan
|
||||||
|
async getScheduledShiftsForPlan(planId: string): Promise<ScheduledShift[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/scheduled-shifts/plan/${planId}`, {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch scheduled shifts: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching scheduled shifts for plan:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user