set template shift struc

This commit is contained in:
2025-10-10 23:42:11 +02:00
parent 168f2cfae3
commit 6247461754
21 changed files with 1627 additions and 369 deletions

View File

@@ -2,6 +2,7 @@
import React, { useState, useEffect } from 'react';
import { Employee, Availability } from '../../../types/employee';
import { employeeService } from '../../../services/employeeService';
import { shiftPlanService, ShiftPlan, ShiftPlanShift } from '../../../services/shiftPlanService';
interface AvailabilityManagerProps {
employee: Employee;
@@ -9,12 +10,18 @@ interface AvailabilityManagerProps {
onCancel: () => void;
}
// Verfügbarkeits-Level
export type AvailabilityLevel = 1 | 2 | 3; // 1: bevorzugt, 2: möglich, 3: nicht möglich
const AvailabilityManager: React.FC<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 [error, setError] = useState('');
@@ -29,53 +36,112 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
{ id: 0, name: 'Sonntag' }
];
const defaultTimeSlots = [
{ name: 'Vormittag', start: '08:00', end: '12:00' },
{ name: 'Nachmittag', start: '12:00', end: '16:00' },
{ name: 'Abend', start: '16:00', end: '20:00' }
// Verfügbarkeits-Level mit Farben und Beschreibungen
const availabilityLevels = [
{ level: 1 as AvailabilityLevel, label: 'Bevorzugt', color: '#27ae60', bgColor: '#d5f4e6', description: 'Ideale Zeit' },
{ level: 2 as AvailabilityLevel, label: 'Möglich', color: '#f39c12', bgColor: '#fef5e7', description: 'Akzeptable Zeit' },
{ level: 3 as AvailabilityLevel, label: 'Nicht möglich', color: '#e74c3c', bgColor: '#fadbd8', description: 'Nicht verfügbar' }
];
useEffect(() => {
loadAvailabilities();
loadData();
}, [employee.id]);
const loadAvailabilities = async () => {
useEffect(() => {
if (selectedPlanId) {
loadSelectedPlan();
}
}, [selectedPlanId]);
const loadData = async () => {
try {
setLoading(true);
const data = await employeeService.getAvailabilities(employee.id);
setAvailabilities(data);
// Load availabilities
try {
const availData = await employeeService.getAvailabilities(employee.id);
setAvailabilities(availData);
} catch (err) {
// Falls keine Verfügbarkeiten existieren, erstelle Standard-Einträge (Level 3: nicht möglich)
const defaultAvailabilities: Availability[] = daysOfWeek.flatMap(day => [
{
id: `temp-${day.id}-morning`,
employeeId: employee.id,
dayOfWeek: day.id,
startTime: '08:00',
endTime: '12:00',
isAvailable: false,
availabilityLevel: 3 as AvailabilityLevel
},
{
id: `temp-${day.id}-afternoon`,
employeeId: employee.id,
dayOfWeek: day.id,
startTime: '12:00',
endTime: '16:00',
isAvailable: false,
availabilityLevel: 3 as AvailabilityLevel
},
{
id: `temp-${day.id}-evening`,
employeeId: employee.id,
dayOfWeek: day.id,
startTime: '16:00',
endTime: '20:00',
isAvailable: false,
availabilityLevel: 3 as AvailabilityLevel
}
]);
setAvailabilities(defaultAvailabilities);
}
// Load shift plans
const plans = await shiftPlanService.getShiftPlans();
setShiftPlans(plans);
// Auto-select the first published plan or the first draft
if (plans.length > 0) {
const publishedPlan = plans.find(plan => plan.status === 'published');
const firstPlan = publishedPlan || plans[0];
setSelectedPlanId(firstPlan.id);
}
} catch (err: any) {
// Falls keine Verfügbarkeiten existieren, erstelle Standard-Einträge
const defaultAvailabilities = daysOfWeek.flatMap(day =>
defaultTimeSlots.map(slot => ({
id: `temp-${day.id}-${slot.name}`,
employeeId: employee.id,
dayOfWeek: day.id,
startTime: slot.start,
endTime: slot.end,
isAvailable: false
}))
);
setAvailabilities(defaultAvailabilities);
console.error('Error loading data:', err);
setError('Daten konnten nicht geladen werden');
} finally {
setLoading(false);
}
};
const handleAvailabilityChange = (id: string, isAvailable: boolean) => {
const loadSelectedPlan = async () => {
try {
const plan = await shiftPlanService.getShiftPlan(selectedPlanId);
setSelectedPlan(plan);
} catch (err: any) {
console.error('Error loading shift plan:', err);
setError('Schichtplan konnte nicht geladen werden');
}
};
const handleAvailabilityLevelChange = (dayId: number, timeSlot: string, level: AvailabilityLevel) => {
setAvailabilities(prev =>
prev.map(avail =>
avail.id === id ? { ...avail, isAvailable } : avail
avail.dayOfWeek === dayId && getTimeSlotName(avail.startTime, avail.endTime) === timeSlot
? {
...avail,
availabilityLevel: level,
isAvailable: level !== 3
}
: avail
)
);
};
const handleTimeChange = (id: string, field: 'startTime' | 'endTime', value: string) => {
setAvailabilities(prev =>
prev.map(avail =>
avail.id === id ? { ...avail, [field]: value } : avail
)
);
const getTimeSlotName = (startTime: string, endTime: string): string => {
if (startTime === '08:00' && endTime === '12:00') return 'Vormittag';
if (startTime === '12:00' && endTime === '16:00') return 'Nachmittag';
if (startTime === '16:00' && endTime === '20:00') return 'Abend';
return `${startTime}-${endTime}`;
};
const handleSave = async () => {
@@ -92,8 +158,91 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
}
};
const getAvailabilitiesForDay = (dayId: number) => {
return availabilities.filter(avail => avail.dayOfWeek === dayId);
// Get availability level for a specific shift
const getAvailabilityForShift = (shift: ShiftPlanShift): AvailabilityLevel => {
const shiftDate = new Date(shift.date);
const dayOfWeek = shiftDate.getDay(); // 0 = Sunday, 1 = Monday, etc.
// Find matching availability for this day and time
const matchingAvailabilities = availabilities.filter(avail =>
avail.dayOfWeek === dayOfWeek &&
avail.availabilityLevel !== 3 && // Nur Level 1 und 2 berücksichtigen
isTimeOverlap(avail.startTime, avail.endTime, shift.startTime, shift.endTime)
);
if (matchingAvailabilities.length === 0) {
return 3; // Nicht möglich, wenn keine Übereinstimmung
}
// Nehme das beste (niedrigste) Verfügbarkeits-Level
const minLevel = Math.min(...matchingAvailabilities.map(avail => avail.availabilityLevel));
return minLevel as AvailabilityLevel;
};
// Helper function to check time overlap
const isTimeOverlap = (availStart: string, availEnd: string, shiftStart: string, shiftEnd: string): boolean => {
const availStartMinutes = timeToMinutes(availStart);
const availEndMinutes = timeToMinutes(availEnd);
const shiftStartMinutes = timeToMinutes(shiftStart);
const shiftEndMinutes = timeToMinutes(shiftEnd);
return shiftStartMinutes < availEndMinutes && shiftEndMinutes > availStartMinutes;
};
const timeToMinutes = (time: string): number => {
const [hours, minutes] = time.split(':').map(Number);
return hours * 60 + minutes;
};
// Group shifts by weekday for timetable display
const getTimetableData = () => {
if (!selectedPlan) return { shiftsByDay: {}, weekdays: [] };
const shiftsByDay: Record<number, ShiftPlanShift[]> = {};
// Initialize empty arrays for each day
daysOfWeek.forEach(day => {
shiftsByDay[day.id] = [];
});
// Group shifts by weekday
selectedPlan.shifts.forEach(shift => {
const shiftDate = new Date(shift.date);
const dayOfWeek = shiftDate.getDay(); // 0 = Sunday, 1 = Monday, etc.
shiftsByDay[dayOfWeek].push(shift);
});
// Remove duplicate shifts (same name and time on same day)
Object.keys(shiftsByDay).forEach(day => {
const dayNum = parseInt(day);
const uniqueShifts: ShiftPlanShift[] = [];
const seen = new Set();
shiftsByDay[dayNum].forEach(shift => {
const key = `${shift.name}|${shift.startTime}|${shift.endTime}`;
if (!seen.has(key)) {
seen.add(key);
uniqueShifts.push(shift);
}
});
shiftsByDay[dayNum] = uniqueShifts;
});
return {
shiftsByDay,
weekdays: daysOfWeek
};
};
const timetableData = getTimetableData();
// Get availability for a specific day and time slot
const getAvailabilityForDayAndSlot = (dayId: number, timeSlot: string): AvailabilityLevel => {
const availability = availabilities.find(avail =>
avail.dayOfWeek === dayId && getTimeSlotName(avail.startTime, avail.endTime) === timeSlot
);
return availability?.availabilityLevel || 3;
};
if (loading) {
@@ -106,7 +255,7 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
return (
<div style={{
maxWidth: '800px',
maxWidth: '1200px',
margin: '0 auto',
backgroundColor: 'white',
padding: '30px',
@@ -128,7 +277,7 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
{employee.name}
</h3>
<p style={{ margin: 0, color: '#7f8c8d' }}>
Legen Sie fest, an welchen Tagen und Zeiten {employee.name} verfügbar ist.
Legen Sie die Verfügbarkeit für {employee.name} fest (1: bevorzugt, 2: möglich, 3: nicht möglich).
</p>
</div>
@@ -145,133 +294,235 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
</div>
)}
{/* Verfügbarkeiten Tabelle */}
{/* Verfügbarkeits-Legende */}
<div style={{
border: '1px solid #e0e0e0',
marginBottom: '30px',
padding: '20px',
backgroundColor: '#f8f9fa',
borderRadius: '8px',
overflow: 'hidden',
marginBottom: '30px'
border: '1px solid #e9ecef'
}}>
{daysOfWeek.map((day, dayIndex) => {
const dayAvailabilities = getAvailabilitiesForDay(day.id);
const isLastDay = dayIndex === daysOfWeek.length - 1;
return (
<div key={day.id} style={{
borderBottom: isLastDay ? 'none' : '1px solid #f0f0f0'
}}>
{/* Tag Header */}
<div style={{
backgroundColor: '#f8f9fa',
padding: '15px 20px',
fontWeight: 'bold',
color: '#2c3e50',
borderBottom: '1px solid #e0e0e0'
}}>
{day.name}
</div>
{/* Zeit-Slots */}
<div style={{ padding: '15px 20px' }}>
{dayAvailabilities.map((availability, availabilityIndex) => {
const isLastAvailability = availabilityIndex === dayAvailabilities.length - 1;
return (
<div
key={availability.id}
style={{
display: 'grid',
gridTemplateColumns: '1fr auto auto auto',
gap: '15px',
alignItems: 'center',
padding: '10px 0',
borderBottom: isLastAvailability ? 'none' : '1px solid #f8f9fa'
}}
>
{/* Verfügbarkeit Toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<input
type="checkbox"
id={`avail-${availability.id}`}
checked={availability.isAvailable}
onChange={(e) => handleAvailabilityChange(availability.id, e.target.checked)}
style={{ width: '18px', height: '18px' }}
/>
<label
htmlFor={`avail-${availability.id}`}
style={{
fontWeight: 'bold',
color: availability.isAvailable ? '#27ae60' : '#95a5a6'
}}
>
{availability.isAvailable ? 'Verfügbar' : 'Nicht verfügbar'}
</label>
</div>
{/* Startzeit */}
<div>
<label style={{ fontSize: '12px', color: '#7f8c8d', display: 'block', marginBottom: '4px' }}>
Von
</label>
<input
type="time"
value={availability.startTime}
onChange={(e) => handleTimeChange(availability.id, 'startTime', e.target.value)}
disabled={!availability.isAvailable}
style={{
padding: '6px 8px',
border: `1px solid ${availability.isAvailable ? '#ddd' : '#f0f0f0'}`,
borderRadius: '4px',
backgroundColor: availability.isAvailable ? 'white' : '#f8f9fa',
color: availability.isAvailable ? '#333' : '#999'
}}
/>
</div>
{/* Endzeit */}
<div>
<label style={{ fontSize: '12px', color: '#7f8c8d', display: 'block', marginBottom: '4px' }}>
Bis
</label>
<input
type="time"
value={availability.endTime}
onChange={(e) => handleTimeChange(availability.id, 'endTime', e.target.value)}
disabled={!availability.isAvailable}
style={{
padding: '6px 8px',
border: `1px solid ${availability.isAvailable ? '#ddd' : '#f0f0f0'}`,
borderRadius: '4px',
backgroundColor: availability.isAvailable ? 'white' : '#f8f9fa',
color: availability.isAvailable ? '#333' : '#999'
}}
/>
</div>
{/* Status Badge */}
<div>
<span
style={{
backgroundColor: availability.isAvailable ? '#d5f4e6' : '#fadbd8',
color: availability.isAvailable ? '#27ae60' : '#e74c3c',
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: 'bold'
}}
>
{availability.isAvailable ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
</div>
);
})}
<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>
{/* Schichtplan Auswahl */}
<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 prüfen
</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) => setSelectedPlanId(e.target.value)}
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.status === 'published' ? 'Veröffentlicht' : 'Entwurf'})
</option>
))}
</select>
</div>
{selectedPlan && (
<div style={{ fontSize: '14px', color: '#666' }}>
Zeitraum: {new Date(selectedPlan.startDate).toLocaleDateString('de-DE')} - {new Date(selectedPlan.endDate).toLocaleDateString('de-DE')}
</div>
)}
</div>
</div>
{/* Verfügbarkeits-Timetable mit Dropdown-Menüs */}
{selectedPlan && (
<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 für: {selectedPlan.name}
</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: '150px'
}}>
Zeit
</th>
{timetableData.weekdays.map(weekday => (
<th key={weekday.id} style={{
padding: '12px 16px',
textAlign: 'center',
border: '1px solid #dee2e6',
fontWeight: 'bold',
minWidth: '150px'
}}>
{weekday.name}
</th>
))}
</tr>
</thead>
<tbody>
{['Vormittag', 'Nachmittag', 'Abend'].map((timeSlot, timeIndex) => (
<tr key={timeSlot} style={{
backgroundColor: timeIndex % 2 === 0 ? 'white' : '#f8f9fa'
}}>
<td style={{
padding: '12px 16px',
border: '1px solid #dee2e6',
fontWeight: '500',
backgroundColor: '#f8f9fa'
}}>
{timeSlot}
<div style={{ fontSize: '12px', color: '#666', marginTop: '4px' }}>
{timeSlot === 'Vormittag' ? '08:00-12:00' :
timeSlot === 'Nachmittag' ? '12:00-16:00' : '16:00-20:00'}
</div>
</td>
{timetableData.weekdays.map(weekday => {
const currentLevel = getAvailabilityForDayAndSlot(weekday.id, timeSlot);
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
}}>
<select
value={currentLevel}
onChange={(e) => handleAvailabilityLevelChange(weekday.id, timeSlot, parseInt(e.target.value) as AvailabilityLevel)}
style={{
padding: '8px 12px',
border: `2px solid ${levelConfig?.color || '#ddd'}`,
borderRadius: '6px',
backgroundColor: levelConfig?.bgColor || 'white',
color: levelConfig?.color || '#333',
fontWeight: 'bold',
minWidth: '120px',
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>
<div style={{
fontSize: '11px',
color: levelConfig?.color,
marginTop: '4px',
fontWeight: 'bold'
}}>
{levelConfig?.description}
</div>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{/* Legende */}
<div style={{
padding: '12px 16px',
backgroundColor: '#e8f4fd',
borderTop: '1px solid #b8d4f0',
fontSize: '14px',
color: '#2c3e50'
}}>
<strong>Legende:</strong>
{availabilityLevels.map(level => (
<span key={level.level} style={{ marginLeft: '15px', display: 'inline-flex', alignItems: 'center', gap: '5px' }}>
<div
style={{
width: '12px',
height: '12px',
backgroundColor: level.bgColor,
border: `1px solid ${level.color}`,
borderRadius: '2px'
}}
/>
<strong style={{ color: level.color }}>{level.level}</strong>: {level.label}
</span>
))}
</div>
</div>
)}
{/* Info Text */}
<div style={{
backgroundColor: '#e8f4fd',
@@ -282,8 +533,10 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
}}>
<h4 style={{ margin: '0 0 8px 0', color: '#2c3e50' }}>💡 Information</h4>
<p style={{ margin: 0, color: '#546e7a', fontSize: '14px' }}>
Verfügbarkeiten bestimmen, wann dieser Mitarbeiter für Schichten eingeplant werden kann.
Nur als "verfügbar" markierte Zeitfenster werden bei der automatischen Schichtplanung berücksichtigt.
<strong>1: Bevorzugt</strong> - Ideale Zeit für diesen Mitarbeiter<br/>
<strong>2: Möglich</strong> - Akzeptable Zeit, falls benötigt<br/>
<strong>3: Nicht möglich</strong> - Mitarbeiter ist nicht verfügbar<br/>
Das System priorisiert Mitarbeiter mit Level 1 für Schichtzuweisungen.
</p>
</div>

View File

@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { shiftTemplateService } from '../../services/shiftTemplateService';
import { shiftPlanService } from '../../services/shiftPlanService';
import { ShiftTemplate } from '../../types/shiftTemplate';
import { TemplateShift } from '../../types/shiftTemplate';
import styles from './ShiftPlanCreate.module.css';
const ShiftPlanCreate: React.FC = () => {
@@ -14,7 +14,7 @@ const ShiftPlanCreate: React.FC = () => {
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [selectedTemplate, setSelectedTemplate] = useState('');
const [templates, setTemplates] = useState<ShiftTemplate[]>([]);
const [templates, setTemplates] = useState<TemplateShift[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

View File

@@ -0,0 +1,430 @@
// frontend/src/pages/ShiftPlans/ShiftPlanEdit.tsx
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { shiftPlanService, ShiftPlan, ShiftPlanShift } from '../../services/shiftPlanService';
import { useNotification } from '../../contexts/NotificationContext';
const ShiftPlanEdit: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { showNotification } = useNotification();
const [shiftPlan, setShiftPlan] = useState<ShiftPlan | null>(null);
const [loading, setLoading] = useState(true);
const [editingShift, setEditingShift] = useState<ShiftPlanShift | null>(null);
const [newShift, setNewShift] = useState<Partial<ShiftPlanShift>>({
date: '',
name: '',
startTime: '',
endTime: '',
requiredEmployees: 1
});
useEffect(() => {
loadShiftPlan();
}, [id]);
const loadShiftPlan = async () => {
if (!id) return;
try {
const plan = await shiftPlanService.getShiftPlan(id);
setShiftPlan(plan);
} catch (error) {
console.error('Error loading shift plan:', error);
showNotification({
type: 'error',
title: 'Fehler',
message: 'Der Schichtplan konnte nicht geladen werden.'
});
navigate('/shift-plans');
} finally {
setLoading(false);
}
};
const handleUpdateShift = async (shift: ShiftPlanShift) => {
if (!shiftPlan || !id) return;
try {
await shiftPlanService.updateShiftPlanShift(id, shift);
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Schicht wurde aktualisiert.'
});
loadShiftPlan();
setEditingShift(null);
} catch (error) {
console.error('Error updating shift:', error);
showNotification({
type: 'error',
title: 'Fehler',
message: 'Die Schicht konnte nicht aktualisiert werden.'
});
}
};
const handleAddShift = async () => {
if (!shiftPlan || !id) return;
if (!newShift.date || !newShift.name || !newShift.startTime || !newShift.endTime || !newShift.requiredEmployees) {
showNotification({
type: 'error',
title: 'Fehler',
message: 'Bitte füllen Sie alle Pflichtfelder aus.'
});
return;
}
try {
await shiftPlanService.addShiftPlanShift(id, {
date: newShift.date,
name: newShift.name,
startTime: newShift.startTime,
endTime: newShift.endTime,
requiredEmployees: Number(newShift.requiredEmployees)
});
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Neue Schicht wurde hinzugefügt.'
});
setNewShift({
date: '',
name: '',
startTime: '',
endTime: '',
requiredEmployees: 1
});
loadShiftPlan();
} catch (error) {
console.error('Error adding shift:', error);
showNotification({
type: 'error',
title: 'Fehler',
message: 'Die Schicht konnte nicht hinzugefügt werden.'
});
}
};
const handleDeleteShift = async (shiftId: string) => {
if (!window.confirm('Möchten Sie diese Schicht wirklich löschen?')) {
return;
}
try {
await shiftPlanService.deleteShiftPlanShift(id!, shiftId);
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Schicht wurde gelöscht.'
});
loadShiftPlan();
} catch (error) {
console.error('Error deleting shift:', error);
showNotification({
type: 'error',
title: 'Fehler',
message: 'Die Schicht konnte nicht gelöscht werden.'
});
}
};
const handlePublish = async () => {
if (!shiftPlan || !id) return;
try {
await shiftPlanService.updateShiftPlan(id, {
...shiftPlan,
status: 'published'
});
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Schichtplan wurde veröffentlicht.'
});
loadShiftPlan();
} catch (error) {
console.error('Error publishing shift plan:', error);
showNotification({
type: 'error',
title: 'Fehler',
message: 'Der Schichtplan konnte nicht veröffentlicht werden.'
});
}
};
if (loading) {
return <div>Lade Schichtplan...</div>;
}
if (!shiftPlan) {
return <div>Schichtplan nicht gefunden</div>;
}
// Group shifts by date
const shiftsByDate = shiftPlan.shifts.reduce((acc, shift) => {
if (!acc[shift.date]) {
acc[shift.date] = [];
}
acc[shift.date].push(shift);
return acc;
}, {} as Record<string, typeof shiftPlan.shifts>);
return (
<div style={{ padding: '20px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '30px'
}}>
<h1>{shiftPlan.name} bearbeiten</h1>
<div>
{shiftPlan.status === 'draft' && (
<button
onClick={handlePublish}
style={{
padding: '8px 16px',
backgroundColor: '#2ecc71',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginRight: '10px'
}}
>
Veröffentlichen
</button>
)}
<button
onClick={() => navigate('/shift-plans')}
style={{
padding: '8px 16px',
backgroundColor: '#95a5a6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Zurück
</button>
</div>
</div>
{/* Add new shift form */}
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
padding: '20px',
marginBottom: '20px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}>
<h3>Neue Schicht hinzufügen</h3>
<div style={{ display: 'grid', gap: '15px', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' }}>
<div>
<label>Datum</label>
<input
type="date"
value={newShift.date}
onChange={(e) => setNewShift({ ...newShift, date: e.target.value })}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
</div>
<div>
<label>Name</label>
<input
type="text"
value={newShift.name}
onChange={(e) => setNewShift({ ...newShift, name: e.target.value })}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
</div>
<div>
<label>Startzeit</label>
<input
type="time"
value={newShift.startTime}
onChange={(e) => setNewShift({ ...newShift, startTime: e.target.value })}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
</div>
<div>
<label>Endzeit</label>
<input
type="time"
value={newShift.endTime}
onChange={(e) => setNewShift({ ...newShift, endTime: e.target.value })}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
</div>
<div>
<label>Benötigte Mitarbeiter</label>
<input
type="number"
min="1"
value={newShift.requiredEmployees}
onChange={(e) => setNewShift({ ...newShift, requiredEmployees: parseInt(e.target.value) })}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
</div>
</div>
<button
onClick={handleAddShift}
disabled={!newShift.date || !newShift.name || !newShift.startTime || !newShift.endTime}
style={{
marginTop: '15px',
padding: '8px 16px',
backgroundColor: '#3498db',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Schicht hinzufügen
</button>
</div>
{/* Existing shifts */}
<div style={{ display: 'grid', gap: '20px' }}>
{Object.entries(shiftsByDate).map(([date, shifts]) => (
<div key={date} style={{
backgroundColor: 'white',
borderRadius: '8px',
padding: '20px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}>
<h3 style={{ marginTop: 0 }}>{new Date(date).toLocaleDateString('de-DE', { weekday: 'long', day: '2-digit', month: '2-digit', year: 'numeric' })}</h3>
<div style={{ display: 'grid', gap: '15px' }}>
{shifts.map(shift => (
<div key={shift.id} style={{
backgroundColor: '#f8f9fa',
padding: '15px',
borderRadius: '6px',
boxShadow: '0 1px 3px rgba(0,0,0,0.05)'
}}>
{editingShift?.id === shift.id ? (
<div style={{ display: 'grid', gap: '10px', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))' }}>
<div>
<label>Name</label>
<input
type="text"
value={editingShift.name}
onChange={(e) => setEditingShift({ ...editingShift, name: e.target.value })}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
</div>
<div>
<label>Startzeit</label>
<input
type="time"
value={editingShift.startTime}
onChange={(e) => setEditingShift({ ...editingShift, startTime: e.target.value })}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
</div>
<div>
<label>Endzeit</label>
<input
type="time"
value={editingShift.endTime}
onChange={(e) => setEditingShift({ ...editingShift, endTime: e.target.value })}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
</div>
<div>
<label>Benötigte Mitarbeiter</label>
<input
type="number"
min="1"
value={editingShift.requiredEmployees}
onChange={(e) => setEditingShift({ ...editingShift, requiredEmployees: parseInt(e.target.value) })}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
</div>
<div style={{ display: 'flex', gap: '10px', alignItems: 'flex-end' }}>
<button
onClick={() => handleUpdateShift(editingShift)}
style={{
padding: '8px 16px',
backgroundColor: '#2ecc71',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Speichern
</button>
<button
onClick={() => setEditingShift(null)}
style={{
padding: '8px 16px',
backgroundColor: '#95a5a6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Abbrechen
</button>
</div>
</div>
) : (
<>
<div style={{ fontWeight: 'bold', marginBottom: '5px' }}>
{shift.name}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: '14px', color: '#666' }}>
<span>Zeit: {shift.startTime.substring(0, 5)} - {shift.endTime.substring(0, 5)}</span>
<span style={{ margin: '0 15px' }}>|</span>
<span>Benötigte Mitarbeiter: {shift.requiredEmployees}</span>
<span style={{ margin: '0 15px' }}>|</span>
<span>Zugewiesen: {shift.assignedEmployees.length}/{shift.requiredEmployees}</span>
</div>
<div>
<button
onClick={() => setEditingShift(shift)}
style={{
padding: '6px 12px',
backgroundColor: '#f1c40f',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginRight: '8px'
}}
>
Bearbeiten
</button>
<button
onClick={() => handleDeleteShift(shift.id)}
style={{
padding: '6px 12px',
backgroundColor: '#e74c3c',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Löschen
</button>
</div>
</div>
</>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
);
};
export default ShiftPlanEdit;

View File

@@ -0,0 +1,292 @@
// frontend/src/pages/ShiftPlans/ShiftPlanView.tsx
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { shiftPlanService, ShiftPlan } from '../../services/shiftPlanService';
import { useNotification } from '../../contexts/NotificationContext';
const ShiftPlanView: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { hasRole } = useAuth();
const { showNotification } = useNotification();
const [shiftPlan, setShiftPlan] = useState<ShiftPlan | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadShiftPlan();
}, [id]);
const loadShiftPlan = async () => {
if (!id) return;
try {
const plan = await shiftPlanService.getShiftPlan(id);
setShiftPlan(plan);
} catch (error) {
console.error('Error loading shift plan:', error);
showNotification({
type: 'error',
title: 'Fehler',
message: 'Der Schichtplan konnte nicht geladen werden.'
});
navigate('/shift-plans');
} finally {
setLoading(false);
}
};
const formatDate = (dateString: string | undefined): string => {
if (!dateString) return 'Kein Datum';
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return 'Ungültiges Datum';
}
return date.toLocaleDateString('de-DE', {
weekday: 'long',
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
};
const formatTime = (timeString: string) => {
return timeString.substring(0, 5);
};
// Get unique shift types and their staffing per weekday
const getTimetableData = () => {
if (!shiftPlan) return { shifts: [], weekdays: [] };
// Get all unique shift types (name + time combination)
const shiftTypes = Array.from(new Set(
shiftPlan.shifts.map(shift =>
`${shift.name}|${shift.startTime}|${shift.endTime}`
)
)).map(shiftKey => {
const [name, startTime, endTime] = shiftKey.split('|');
return { name, startTime, endTime };
});
// Weekdays (1=Monday, 7=Sunday)
const weekdays = [1, 2, 3, 4, 5, 6, 7];
// For each shift type and weekday, calculate staffing
const timetableShifts = shiftTypes.map(shiftType => {
const weekdayData: Record<number, string> = {};
weekdays.forEach(weekday => {
// Find all shifts of this type on this weekday
const shiftsOnDay = shiftPlan.shifts.filter(shift => {
const date = new Date(shift.date);
const dayOfWeek = date.getDay() === 0 ? 7 : date.getDay(); // Convert to 1-7 (Mon-Sun)
return dayOfWeek === weekday &&
shift.name === shiftType.name &&
shift.startTime === shiftType.startTime &&
shift.endTime === shiftType.endTime;
});
if (shiftsOnDay.length === 0) {
weekdayData[weekday] = '';
} else {
const totalAssigned = shiftsOnDay.reduce((sum, shift) => sum + shift.assignedEmployees.length, 0);
const totalRequired = shiftsOnDay.reduce((sum, shift) => sum + shift.requiredEmployees, 0);
weekdayData[weekday] = `${totalAssigned}/${totalRequired}`;
}
});
return {
...shiftType,
displayName: `${shiftType.name} (${formatTime(shiftType.startTime)}${formatTime(shiftType.endTime)})`,
weekdayData
};
});
return {
shifts: timetableShifts,
weekdays: weekdays.map(day => ({
id: day,
name: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][day === 7 ? 0 : day]
}))
};
};
if (loading) {
return <div>Lade Schichtplan...</div>;
}
if (!shiftPlan) {
return <div>Schichtplan nicht gefunden</div>;
}
const timetableData = getTimetableData();
return (
<div style={{ padding: '20px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '30px'
}}>
<div>
<h1>{shiftPlan.name}</h1>
<p style={{ color: '#666', marginTop: '5px' }}>
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>
{hasRole(['admin', 'instandhalter']) && (
<button
onClick={() => navigate(`/shift-plans/${id}/edit`)}
style={{
padding: '8px 16px',
backgroundColor: '#f1c40f',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginRight: '10px'
}}
>
Bearbeiten
</button>
)}
<button
onClick={() => navigate('/shift-plans')}
style={{
padding: '8px 16px',
backgroundColor: '#95a5a6',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Zurück
</button>
</div>
</div>
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
padding: '20px',
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 */}
<div style={{ marginTop: '30px' }}>
<h3>Schichtplan</h3>
{timetableData.shifts.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '40px',
color: '#666',
fontStyle: 'italic'
}}>
Keine Schichten für diesen Zeitraum konfiguriert
</div>
) : (
<div style={{
overflowX: 'auto',
marginTop: '20px'
}}>
<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: '200px'
}}>
Schicht (Zeit)
</th>
{timetableData.weekdays.map(weekday => (
<th key={weekday.id} style={{
padding: '12px 16px',
textAlign: 'center',
border: '1px solid #dee2e6',
fontWeight: 'bold',
minWidth: '80px'
}}>
{weekday.name}
</th>
))}
</tr>
</thead>
<tbody>
{timetableData.shifts.map((shift, index) => (
<tr key={index} style={{
backgroundColor: index % 2 === 0 ? 'white' : '#f8f9fa'
}}>
<td style={{
padding: '12px 16px',
border: '1px solid #dee2e6',
fontWeight: '500'
}}>
{shift.displayName}
</td>
{timetableData.weekdays.map(weekday => (
<td key={weekday.id} style={{
padding: '12px 16px',
border: '1px solid #dee2e6',
textAlign: 'center',
color: shift.weekdayData[weekday.id] ? '#2c3e50' : '#bdc3c7'
}}>
{shift.weekdayData[weekday.id] || ''}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Summary */}
{timetableData.shifts.length > 0 && (
<div style={{
marginTop: '20px',
padding: '12px 16px',
backgroundColor: '#e8f4fd',
borderRadius: '4px',
border: '1px solid #b8d4f0',
fontSize: '14px'
}}>
<strong>Legende:</strong> Angezeigt wird "zugewiesene/benötigte Mitarbeiter" pro Schicht und Wochentag
</div>
)}
</div>
</div>
);
};
export default ShiftPlanView;

View File

@@ -1,22 +1,20 @@
// frontend/src/pages/ShiftTemplates/ShiftTemplateEditor.tsx
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ShiftTemplate, TemplateShift, DEFAULT_DAYS } from '../../types/shiftTemplate';
import { TemplateShiftSlot, TemplateShift, TemplateShiftTimeRange, DEFAULT_DAYS } from '../../types/shiftTemplate';
import { shiftTemplateService } from '../../services/shiftTemplateService';
import ShiftDayEditor from './components/ShiftDayEditor';
import DefaultTemplateView from './components/DefaultTemplateView';
import styles from './ShiftTemplateEditor.module.css';
interface ExtendedTemplateShift extends Omit<TemplateShift, 'id'> {
interface ExtendedTemplateShift extends Omit<TemplateShiftSlot, 'id'> {
id?: string;
isPreview?: boolean;
}
const defaultShift: ExtendedTemplateShift = {
dayOfWeek: 1, // Montag
name: '',
startTime: '08:00',
endTime: '12:00',
timeRange: { id: '', name: '', startTime: '', endTime: '' },
requiredEmployees: 1,
color: '#3498db'
};
@@ -26,7 +24,7 @@ const ShiftTemplateEditor: React.FC = () => {
const navigate = useNavigate();
const isEditing = !!id;
const [template, setTemplate] = useState<Omit<ShiftTemplate, 'id' | 'createdAt' | 'createdBy'>>({
const [template, setTemplate] = useState<Omit<TemplateShift, 'id' | 'createdAt' | 'createdBy'>>({
name: '',
description: '',
shifts: [],
@@ -83,11 +81,13 @@ const ShiftTemplateEditor: React.FC = () => {
};
const addShift = (dayOfWeek: number) => {
const newShift: TemplateShift = {
const newShift: TemplateShiftSlot = {
...defaultShift,
id: Date.now().toString(),
dayOfWeek,
name: `Schicht ${template.shifts.filter(s => s.dayOfWeek === dayOfWeek).length + 1}`
timeRange: { ...defaultShift.timeRange, id: Date.now().toString() },
requiredEmployees: defaultShift.requiredEmployees,
color: defaultShift.color
};
setTemplate(prev => ({
@@ -113,7 +113,7 @@ const ShiftTemplateEditor: React.FC = () => {
};
// Preview-Daten für die DefaultTemplateView vorbereiten
const previewTemplate: ShiftTemplate = {
const previewTemplate: TemplateShift = {
id: 'preview',
name: template.name || 'Vorschau',
description: template.description,

View File

@@ -1,17 +1,17 @@
// frontend/src/pages/ShiftTemplates/ShiftTemplateList.tsx
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { ShiftTemplate } from '../../types/shiftTemplate';
import { TemplateShift } from '../../types/shiftTemplate';
import { shiftTemplateService } from '../../services/shiftTemplateService';
import { useAuth } from '../../contexts/AuthContext';
import DefaultTemplateView from './components/DefaultTemplateView';
import styles from './ShiftTemplateList.module.css';
const ShiftTemplateList: React.FC = () => {
const [templates, setTemplates] = useState<ShiftTemplate[]>([]);
const [templates, setTemplates] = useState<TemplateShift[]>([]);
const [loading, setLoading] = useState(true);
const { hasRole } = useAuth();
const [selectedTemplate, setSelectedTemplate] = useState<ShiftTemplate | null>(null);
const [selectedTemplate, setSelectedTemplate] = useState<TemplateShift | null>(null);
useEffect(() => {
loadTemplates();

View File

@@ -1,10 +1,10 @@
// frontend/src/pages/ShiftTemplates/components/DefaultTemplateView.tsx
import React from 'react';
import { ShiftTemplate } from '../../../types/shiftTemplate';
import { TemplateShift } from '../../../types/shiftTemplate';
import styles from './DefaultTemplateView.module.css';
interface DefaultTemplateViewProps {
template: ShiftTemplate;
template: TemplateShift;
}
const DefaultTemplateView: React.FC<DefaultTemplateViewProps> = ({ template }) => {
@@ -38,9 +38,9 @@ const DefaultTemplateView: React.FC<DefaultTemplateViewProps> = ({ template }) =
<div className={styles.shiftsContainer}>
{shiftsByDay[dayIndex]?.map(shift => (
<div key={shift.id} className={styles.shiftCard}>
<h4>{shift.name}</h4>
<h4>{shift.timeRange.name}</h4>
<p>
{formatTime(shift.startTime)} - {formatTime(shift.endTime)}
{formatTime(shift.timeRange.startTime)} - {formatTime(shift.timeRange.endTime)}
</p>
</div>
))}

View File

@@ -1,13 +1,13 @@
// frontend/src/pages/ShiftTemplates/components/ShiftDayEditor.tsx
import React from 'react';
import { TemplateShift } from '../../../types/shiftTemplate';
import { TemplateShiftSlot } from '../../../types/shiftTemplate';
import styles from './ShiftDayEditor.module.css';
interface ShiftDayEditorProps {
day: { id: number; name: string };
shifts: TemplateShift[];
shifts: TemplateShiftSlot[];
onAddShift: () => void;
onUpdateShift: (shiftId: string, updates: Partial<TemplateShift>) => void;
onUpdateShift: (shiftId: string, updates: Partial<TemplateShiftSlot>) => void;
onRemoveShift: (shiftId: string) => void;
}
@@ -55,8 +55,8 @@ const ShiftDayEditor: React.FC<ShiftDayEditorProps> = ({
<div className={styles.formGroup}>
<input
type="text"
value={shift.name}
onChange={(e) => onUpdateShift(shift.id, { name: e.target.value })}
value={shift.timeRange.name}
onChange={(e) => onUpdateShift(shift.id, { timeRange: { ...shift.timeRange, name: e.target.value } })}
placeholder="Schichtname"
/>
</div>
@@ -66,8 +66,8 @@ const ShiftDayEditor: React.FC<ShiftDayEditorProps> = ({
<label>Start</label>
<input
type="time"
value={shift.startTime}
onChange={(e) => onUpdateShift(shift.id, { startTime: e.target.value })}
value={shift.timeRange.startTime}
onChange={(e) => onUpdateShift(shift.id, { timeRange: { ...shift.timeRange, startTime: e.target.value } })}
/>
</div>
@@ -75,8 +75,8 @@ const ShiftDayEditor: React.FC<ShiftDayEditorProps> = ({
<label>Ende</label>
<input
type="time"
value={shift.endTime}
onChange={(e) => onUpdateShift(shift.id, { endTime: e.target.value })}
value={shift.timeRange.endTime}
onChange={(e) => onUpdateShift(shift.id, { timeRange: { ...shift.timeRange, endTime: e.target.value } })}
/>
</div>
</div>