Files
Schichtenplaner/frontend/src/pages/Employees/components/AvailabilityManager.tsx

745 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { employeeService } from '../../../services/employeeService';
import { shiftPlanService } from '../../../services/shiftPlanService';
import { Employee, EmployeeAvailability } from '../../../models/Employee';
import { ShiftPlan, TimeSlot, Shift } from '../../../models/ShiftPlan';
import { useNotification } from '../../../contexts/NotificationContext';
import { useBackendValidation } from '../../../hooks/useBackendValidation';
interface AvailabilityManagerProps {
employee: Employee;
onSave: () => void;
onCancel: () => void;
}
// Local interface extensions
interface ExtendedShift extends Shift {
timeSlotName?: string;
startTime?: string;
endTime?: string;
displayName?: string;
}
interface Availability extends EmployeeAvailability {
isAvailable?: boolean;
}
// Verfügbarkeits-Level
export type AvailabilityLevel = 1 | 2 | 3;
const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
employee,
onSave,
onCancel
}) => {
const [availabilities, setAvailabilities] = useState<Availability[]>([]);
const [shiftPlans, setShiftPlans] = useState<ShiftPlan[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<string>('');
const [selectedPlan, setSelectedPlan] = useState<ShiftPlan | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const { showNotification } = useNotification();
const { executeWithValidation, isSubmitting } = useBackendValidation();
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 availabilityLevels = [
{ level: 1 as AvailabilityLevel, label: 'Bevorzugt', color: '#27ae60', bgColor: '#d5f4e6', description: 'Ideale Zeit' },
{ level: 2 as AvailabilityLevel, label: 'Möglich', color: '#f39c12', bgColor: '#fef5e7', description: 'Akzeptable Zeit' },
{ level: 3 as AvailabilityLevel, label: 'Nicht möglich', color: '#e74c3c', bgColor: '#fadbd8', description: 'Nicht verfügbar' }
];
// Lade initial die Schichtpläne
useEffect(() => {
const loadInitialData = async () => {
try {
setLoading(true);
console.log('🔄 LADE INITIALDATEN FÜR MITARBEITER:', employee.id);
// 1. Lade alle Schichtpläne
const plans = await shiftPlanService.getShiftPlans();
console.log('✅ SCHICHTPLÄNE GELADEN:', plans.length);
setShiftPlans(plans);
// 2. Wähle ersten verfügbaren Plan aus
if (plans.length > 0) {
const planWithShifts = plans.find(plan =>
plan.shifts && plan.shifts.length > 0 &&
plan.timeSlots && plan.timeSlots.length > 0
) || plans[0];
console.log('✅ ERSTER PLAN AUSGEWÄHLT:', planWithShifts.name);
setSelectedPlanId(planWithShifts.id);
} else {
setLoading(false);
}
} catch (err: any) {
console.error('❌ FEHLER BEIM LADEN DER INITIALDATEN:', err);
showNotification({
type: 'error',
title: 'Fehler beim Laden',
message: 'Daten konnten nicht geladen werden: ' + (err.message || 'Unbekannter Fehler')
});
setLoading(false);
}
};
loadInitialData();
}, [employee.id]);
// Lade Plan-Details und Verfügbarkeiten wenn selectedPlanId sich ändert
useEffect(() => {
const loadPlanData = async () => {
if (!selectedPlanId) {
setLoading(false);
return;
}
try {
setLoading(true);
console.log('🔄 LADE PLAN-DATEN FÜR:', selectedPlanId);
// 1. Lade Schichtplan Details
const plan = await shiftPlanService.getShiftPlan(selectedPlanId);
setSelectedPlan(plan);
console.log('✅ SCHICHTPLAN DETAILS GELADEN:', {
name: plan.name,
timeSlotsCount: plan.timeSlots?.length || 0,
shiftsCount: plan.shifts?.length || 0,
usedDays: Array.from(new Set(plan.shifts?.map(s => s.dayOfWeek) || [])).sort()
});
// 2. Lade Verfügbarkeiten für DIESEN Mitarbeiter und DIESEN Plan
console.log('🔄 LADE VERFÜGBARKEITEN FÜR:', {
employeeId: employee.id,
planId: selectedPlanId
});
try {
const allAvailabilities = await employeeService.getAvailabilities(employee.id);
console.log('📋 ALLE VERFÜGBARKEITEN DES MITARBEITERS:', allAvailabilities.length);
// Filtere nach dem aktuellen Plan UND stelle sicher, dass shiftId vorhanden ist
const planAvailabilities = allAvailabilities.filter(
avail => avail.planId === selectedPlanId && avail.shiftId
);
console.log('✅ VERFÜGBARKEITEN FÜR DIESEN PLAN (MIT SHIFT-ID):', planAvailabilities.length);
// Debug: Zeige auch ungültige Einträge
const invalidAvailabilities = allAvailabilities.filter(
avail => avail.planId === selectedPlanId && !avail.shiftId
);
if (invalidAvailabilities.length > 0) {
console.warn('⚠️ UNGÜLTIGE VERFÜGBARKEITEN (OHNE SHIFT-ID):', invalidAvailabilities.length);
}
// Transformiere die Daten
const transformedAvailabilities: Availability[] = planAvailabilities.map(avail => ({
...avail,
isAvailable: avail.preferenceLevel !== 3
}));
setAvailabilities(transformedAvailabilities);
// Debug: Zeige vorhandene Präferenzen
if (planAvailabilities.length > 0) {
console.log('🎯 VORHANDENE PRÄFERENZEN:', planAvailabilities.length);
}
} catch (availError) {
console.error('❌ FEHLER BEIM LADEN DER VERFÜGBARKEITEN:', availError);
setAvailabilities([]);
}
} catch (err: any) {
console.error('❌ FEHLER BEIM LADEN DES SCHICHTPLANS:', err);
showNotification({
type: 'error',
title: 'Fehler beim Laden',
message: 'Schichtplan konnte nicht geladen werden: ' + (err.message || 'Unbekannter Fehler')
});
} finally {
setLoading(false);
}
};
loadPlanData();
}, [selectedPlanId, employee.id]);
const formatTime = (time: string): string => {
if (!time) return '--:--';
return time.substring(0, 5);
};
// Create a data structure that maps days to their shifts with time slot info
const getTimetableData = () => {
if (!selectedPlan || !selectedPlan.shifts || !selectedPlan.timeSlots) {
return { days: [], shiftsByDay: {} };
}
// Create a map for quick time slot lookups
const timeSlotMap = new Map(selectedPlan.timeSlots.map(ts => [ts.id, ts]));
// Group shifts by day and enhance with time slot info
const shiftsByDay = selectedPlan.shifts.reduce((acc, shift) => {
if (!acc[shift.dayOfWeek]) {
acc[shift.dayOfWeek] = [];
}
const timeSlot = timeSlotMap.get(shift.timeSlotId);
const enhancedShift: ExtendedShift = {
...shift,
timeSlotName: timeSlot?.name,
startTime: timeSlot?.startTime,
endTime: timeSlot?.endTime,
displayName: timeSlot ? `${timeSlot.name} (${formatTime(timeSlot.startTime)}-${formatTime(timeSlot.endTime)})` : shift.id
};
acc[shift.dayOfWeek].push(enhancedShift);
return acc;
}, {} as Record<number, ExtendedShift[]>);
// Sort shifts within each day by start time
Object.keys(shiftsByDay).forEach(day => {
shiftsByDay[parseInt(day)].sort((a, b) => {
const timeA = a.startTime || '';
const timeB = b.startTime || '';
return timeA.localeCompare(timeB);
});
});
// Get unique days that have shifts
const days = Array.from(new Set(selectedPlan.shifts.map(shift => shift.dayOfWeek)))
.sort()
.map(dayId => {
return daysOfWeek.find(day => day.id === dayId) || { id: dayId, name: `Tag ${dayId}` };
});
return { days, shiftsByDay };
};
const handleAvailabilityLevelChange = (shiftId: string, level: AvailabilityLevel) => {
if (!shiftId) {
console.error('❌ Versuch, Verfügbarkeit ohne Shift-ID zu ändern');
return;
}
console.log(`🔄 ÄNDERE VERFÜGBARKEIT: Shift ${shiftId}, Level ${level}`);
setAvailabilities(prev => {
const existingIndex = prev.findIndex(avail => avail.shiftId === shiftId);
if (existingIndex >= 0) {
// Update existing availability
const updated = [...prev];
updated[existingIndex] = {
...updated[existingIndex],
preferenceLevel: level,
isAvailable: level !== 3
};
return updated;
} else {
// Create new availability using shiftId directly
const newAvailability: Availability = {
id: `temp-${shiftId}-${Date.now()}`,
employeeId: employee.id,
planId: selectedPlanId,
shiftId: shiftId,
preferenceLevel: level,
isAvailable: level !== 3
};
return [...prev, newAvailability];
}
});
};
const getAvailabilityForShift = (shiftId: string): AvailabilityLevel => {
const availability = availabilities.find(avail => avail.shiftId === shiftId);
return availability?.preferenceLevel || 3;
};
// Update the timetable rendering to use shifts directly
const renderTimetable = () => {
const { days, shiftsByDay } = getTimetableData();
if (days.length === 0 || Object.keys(shiftsByDay).length === 0) {
return (
<div style={{
padding: '40px',
textAlign: 'center',
backgroundColor: '#f8f9fa',
color: '#6c757d',
borderRadius: '8px',
border: '1px solid #e9ecef'
}}>
<div style={{ fontSize: '48px', marginBottom: '20px' }}>📅</div>
<h4>Keine Shifts im ausgewählten Plan</h4>
<p>Der ausgewählte Schichtplan hat keine Shifts definiert.</p>
</div>
);
}
// Create a map for quick time slot lookups
const timeSlotMap = new Map(selectedPlan?.timeSlots?.map(ts => [ts.id, ts]) || []);
// Get all unique time slots (rows) by collecting from all shifts
const allTimeSlots = new Map();
days.forEach(day => {
shiftsByDay[day.id]?.forEach(shift => {
const timeSlot = timeSlotMap.get(shift.timeSlotId);
if (timeSlot && !allTimeSlots.has(timeSlot.id)) {
allTimeSlots.set(timeSlot.id, {
...timeSlot,
shiftsByDay: {} // Initialize empty object to store shifts by day
});
}
});
});
// Populate shifts for each time slot by day
days.forEach(day => {
shiftsByDay[day.id]?.forEach(shift => {
const timeSlot = allTimeSlots.get(shift.timeSlotId);
if (timeSlot) {
timeSlot.shiftsByDay[day.id] = shift;
}
});
});
// Convert to array and sort by start time
const sortedTimeSlots = Array.from(allTimeSlots.values()).sort((a, b) => {
return (a.startTime || '').localeCompare(b.startTime || '');
});
return (
<div style={{
marginBottom: '30px',
border: '1px solid #e0e0e0',
borderRadius: '8px',
overflow: 'hidden'
}}>
<div style={{
backgroundColor: '#2c3e50',
color: 'white',
padding: '15px 20px',
fontWeight: 'bold'
}}>
Verfügbarkeit definieren
<div style={{ fontSize: '14px', fontWeight: 'normal', marginTop: '5px' }}>
{sortedTimeSlots.length} Zeitslots {days.length} Tage Zeitbasierte Darstellung
</div>
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{
width: '100%',
borderCollapse: 'collapse',
backgroundColor: 'white'
}}>
<thead>
<tr style={{ backgroundColor: '#f8f9fa' }}>
<th style={{
padding: '12px 16px',
textAlign: 'left',
border: '1px solid #dee2e6',
fontWeight: 'bold',
minWidth: '120px'
}}>
Zeitslot
</th>
{days.map(weekday => (
<th key={weekday.id} style={{
padding: '12px 16px',
textAlign: 'center',
border: '1px solid #dee2e6',
fontWeight: 'bold',
minWidth: '120px'
}}>
{weekday.name}
</th>
))}
</tr>
</thead>
<tbody>
{sortedTimeSlots.map((timeSlot, timeSlotIndex) => (
<tr key={timeSlot.id} style={{
backgroundColor: timeSlotIndex % 2 === 0 ? 'white' : '#f8f9fa'
}}>
<td style={{
padding: '12px 16px',
border: '1px solid #dee2e6',
fontWeight: '500',
backgroundColor: '#f8f9fa',
position: 'sticky',
left: 0
}}>
<div style={{ fontWeight: 'bold' }}>
{timeSlot.name}
</div>
<div style={{ fontSize: '14px', color: '#666' }}>
{formatTime(timeSlot.startTime)} - {formatTime(timeSlot.endTime)}
</div>
</td>
{days.map(weekday => {
const shift = timeSlot.shiftsByDay[weekday.id];
if (!shift) {
return (
<td key={weekday.id} style={{
padding: '12px 16px',
border: '1px solid #dee2e6',
textAlign: 'center',
backgroundColor: '#f8f9fa',
color: '#ccc',
fontStyle: 'italic'
}}>
Kein Shift
</td>
);
}
const currentLevel = getAvailabilityForShift(shift.id);
const levelConfig = availabilityLevels.find(l => l.level === currentLevel);
return (
<td key={weekday.id} style={{
padding: '12px 16px',
border: '1px solid #dee2e6',
textAlign: 'center',
backgroundColor: levelConfig?.bgColor || 'white'
}}>
<select
value={currentLevel}
onChange={(e) => {
const newLevel = parseInt(e.target.value) as AvailabilityLevel;
handleAvailabilityLevelChange(shift.id, newLevel);
}}
style={{
padding: '8px 12px',
border: `2px solid ${levelConfig?.color || '#ddd'}`,
borderRadius: '6px',
backgroundColor: levelConfig?.bgColor || 'white',
color: levelConfig?.color || '#333',
fontWeight: 'bold',
minWidth: '140px',
cursor: 'pointer',
textAlign: 'center'
}}
>
{availabilityLevels.map(level => (
<option
key={level.level}
value={level.level}
style={{
backgroundColor: level.bgColor,
color: level.color,
fontWeight: 'bold'
}}
>
{level.level}: {level.label}
</option>
))}
</select>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{/* Summary Statistics */}
<div style={{
backgroundColor: '#f8f9fa',
padding: '15px',
borderTop: '1px solid #dee2e6',
fontSize: '12px',
color: '#666'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<strong>Aktive Verfügbarkeiten:</strong> {availabilities.filter(a => a.preferenceLevel !== 3).length}
</div>
</div>
</div>
</div>
);
};
const handleSave = async () => {
if (!selectedPlanId) {
showNotification({
type: 'error',
title: 'Fehler',
message: 'Bitte wählen Sie einen Schichtplan aus'
});
return;
}
// Basic frontend validation: Check if we have any availabilities to save
const validAvailabilities = availabilities.filter(avail => {
return avail.shiftId && selectedPlan?.shifts?.some(shift => shift.id === avail.shiftId);
});
if (validAvailabilities.length === 0) {
showNotification({
type: 'error',
title: 'Fehler',
message: 'Keine gültigen Verfügbarkeiten zum Speichern gefunden'
});
return;
}
// Complex validation (contract type rules) is now handled by backend
// We only do basic required field validation in frontend
await executeWithValidation(async () => {
setSaving(true);
// Convert to the format expected by the API - using shiftId directly
const requestData = {
planId: selectedPlanId,
availabilities: validAvailabilities.map(avail => ({
planId: selectedPlanId,
shiftId: avail.shiftId,
preferenceLevel: avail.preferenceLevel,
notes: avail.notes
}))
};
await employeeService.updateAvailabilities(employee.id, requestData);
console.log('✅ VERFÜGBARKEITEN ERFOLGREICH GESPEICHERT');
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Verfügbarkeiten wurden erfolgreich gespeichert'
});
window.dispatchEvent(new CustomEvent('availabilitiesChanged'));
onSave();
});
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '40px' }}>
<div> Lade Verfügbarkeiten...</div>
</div>
);
}
const { days, shiftsByDay } = getTimetableData();
const allShiftIds = new Set<string>();
days.forEach(day => {
shiftsByDay[day.id]?.forEach(shift => {
allShiftIds.add(shift.id);
});
});
const shiftsCount = allShiftIds.size;
// Get full name for display
const employeeFullName = `${employee.firstname} ${employee.lastname}`;
// Available shifts count for display only (not for validation)
const availableShiftsCount = availabilities.filter(avail =>
avail.preferenceLevel === 1 || avail.preferenceLevel === 2
).length;
return (
<div style={{
maxWidth: '1900px',
margin: '0 auto',
backgroundColor: 'white',
padding: '30px',
borderRadius: '8px',
border: '1px solid #e0e0e0',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)'
}}>
<h2 style={{
margin: '0 0 25px 0',
color: '#2c3e50',
borderBottom: '2px solid #f0f0f0',
paddingBottom: '15px'
}}>
📅 Verfügbarkeit verwalten
</h2>
{/* Employee Info */}
<div style={{ marginBottom: '20px' }}>
<h3 style={{ margin: '0 0 10px 0', color: '#34495e' }}>
{employeeFullName}
</h3>
<p style={{ margin: 0, color: '#7f8c8d' }}>
<strong>Email:</strong> {employee.email}
</p>
{employee.contractType && (
<p style={{ margin: '5px 0 0 0', color: employee.contractType === 'small' ? '#f39c12' : '#27ae60' }}>
<strong>Vertrag:</strong>
{employee.contractType === 'small' ? ' Kleiner Vertrag' :
employee.contractType === 'large' ? ' Großer Vertrag' :
' Flexibler Vertrag'}
{/* Note: Contract validation is now handled by backend */}
</p>
)}
</div>
{/* Availability Legend */}
<div style={{
marginBottom: '30px',
padding: '20px',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
border: '1px solid #e9ecef'
}}>
<h4 style={{ margin: '0 0 15px 0', color: '#495057' }}>
Verfügbarkeits-Level
</h4>
<div style={{ display: 'flex', gap: '20px', flexWrap: 'wrap' }}>
{availabilityLevels.map(level => (
<div key={level.level} style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<div
style={{
width: '20px',
height: '20px',
backgroundColor: level.bgColor,
border: `2px solid ${level.color}`,
borderRadius: '4px'
}}
/>
<div>
<div style={{ fontWeight: 'bold', color: level.color }}>
{level.level}: {level.label}
</div>
<div style={{ fontSize: '12px', color: '#666' }}>
{level.description}
</div>
</div>
</div>
))}
</div>
</div>
{/* Shift Plan Selection */}
<div style={{
marginBottom: '30px',
padding: '20px',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
border: '1px solid #e9ecef'
}}>
<h4 style={{ margin: '0 0 15px 0', color: '#495057' }}>
Verfügbarkeit für Schichtplan
</h4>
<div style={{ display: 'flex', gap: '15px', alignItems: 'center', flexWrap: 'wrap' }}>
<div>
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold', color: '#2c3e50' }}>
Schichtplan auswählen:
</label>
<select
value={selectedPlanId}
onChange={(e) => {
const newPlanId = e.target.value;
console.log('🔄 PLAN WECHSELN ZU:', newPlanId);
setSelectedPlanId(newPlanId);
}}
style={{
padding: '8px 12px',
border: '1px solid #ddd',
borderRadius: '4px',
minWidth: '250px'
}}
>
<option value="">Bitte auswählen...</option>
{shiftPlans.map(plan => (
<option key={plan.id} value={plan.id}>
{plan.name} {plan.shifts && `(${plan.shifts.length} Shifts)`}
</option>
))}
</select>
</div>
{selectedPlan && (
<div style={{ fontSize: '14px', color: '#666' }}>
<div><strong>Plan:</strong> {selectedPlan.name}</div>
<div><strong>Shifts:</strong> {selectedPlan.shifts?.length || 0}</div>
<div><strong>Zeitslots:</strong> {selectedPlan.timeSlots?.length || 0}</div>
<div><strong>Status:</strong> {selectedPlan.status}</div>
</div>
)}
</div>
{/* Debug Info für Plan Loading */}
{!selectedPlanId && shiftPlans.length > 0 && (
<div style={{
marginTop: '10px',
padding: '10px',
backgroundColor: '#fff3cd',
border: '1px solid #ffeaa7',
borderRadius: '4px',
fontSize: '12px'
}}>
Bitte wählen Sie einen Schichtplan aus
</div>
)}
</div>
{/* Availability Timetable */}
{renderTimetable()}
{/* Buttons */}
<div style={{
display: 'flex',
gap: '15px',
justifyContent: 'flex-end'
}}>
<button
onClick={onCancel}
disabled={isSubmitting}
style={{
padding: '12px 24px',
backgroundColor: '#95a5a6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: isSubmitting ? 'not-allowed' : 'pointer',
opacity: isSubmitting ? 0.6 : 1
}}
>
Abbrechen
</button>
<button
onClick={handleSave}
disabled={isSubmitting || shiftsCount === 0 || !selectedPlanId}
style={{
padding: '12px 24px',
backgroundColor: isSubmitting ? '#bdc3c7' : (shiftsCount === 0 || !selectedPlanId ? '#95a5a6' : '#3498db'),
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: (isSubmitting || shiftsCount === 0 || !selectedPlanId) ? 'not-allowed' : 'pointer',
fontWeight: 'bold'
}}
>
{isSubmitting ? '⏳ Wird gespeichert...' : `Verfügbarkeiten speichern (${availableShiftsCount})`}
</button>
</div>
</div>
);
};
export default AvailabilityManager;