mirror of
https://github.com/donpat1to/Schichtenplaner.git
synced 2025-11-30 22:45:46 +01:00
set template shift struc
This commit is contained in:
@@ -308,7 +308,7 @@ async function generateShiftsFromTemplate(shiftPlanId: string, templateId: strin
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
// Generate shifts for each day in the date range
|
||||
// Generate shifts ONLY for days that have template shifts defined
|
||||
for (let date = new Date(start); date <= end; date.setDate(date.getDate() + 1)) {
|
||||
// Convert JS day (0=Sunday) to our format (1=Monday, 7=Sunday)
|
||||
const dayOfWeek = date.getDay() === 0 ? 7 : date.getDay();
|
||||
@@ -316,6 +316,8 @@ async function generateShiftsFromTemplate(shiftPlanId: string, templateId: strin
|
||||
// Find template shifts for this day of week
|
||||
const shiftsForDay = templateShifts.filter(shift => shift.day_of_week === dayOfWeek);
|
||||
|
||||
// Only create shifts if there are template shifts defined for this weekday
|
||||
if (shiftsForDay.length > 0) {
|
||||
for (const templateShift of shiftsForDay) {
|
||||
const shiftId = uuidv4();
|
||||
|
||||
@@ -335,4 +337,5 @@ async function generateShiftsFromTemplate(shiftPlanId: string, templateId: strin
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,37 +2,63 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { db } from '../services/databaseService.js';
|
||||
import { ShiftTemplate, CreateShiftTemplateRequest, UpdateShiftTemplateRequest } from '../models/ShiftTemplate.js';
|
||||
import {
|
||||
ShiftTemplate,
|
||||
TemplateShiftSlot,
|
||||
TemplateShiftTimeRange,
|
||||
CreateShiftTemplateRequest,
|
||||
UpdateShiftTemplateRequest
|
||||
} from '../models/ShiftTemplate.js';
|
||||
import { AuthRequest } from '../middleware/auth.js';
|
||||
|
||||
export const getTemplates = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const templates = await db.all<ShiftTemplate>(`
|
||||
const templates = await db.all<any>(`
|
||||
SELECT st.*, u.name as created_by_name
|
||||
FROM shift_templates st
|
||||
LEFT JOIN users u ON st.created_by = u.id
|
||||
ORDER BY st.created_at DESC
|
||||
`);
|
||||
|
||||
// Für jede Vorlage die Schichten laden
|
||||
// Für jede Vorlage die Schichten und Zeit-Slots laden
|
||||
const templatesWithShifts = await Promise.all(
|
||||
templates.map(async (template) => {
|
||||
const shifts = await db.all<any>(`
|
||||
SELECT * FROM template_shifts
|
||||
// Lade Schicht-Slots
|
||||
const shiftSlots = await db.all<any>(`
|
||||
SELECT ts.*, tts.name as time_range_name, tts.start_time as time_range_start, tts.end_time as time_range_end
|
||||
FROM template_shifts ts
|
||||
LEFT JOIN template_time_slots tts ON ts.time_slot_id = tts.id
|
||||
WHERE ts.template_id = ?
|
||||
ORDER BY ts.day_of_week, tts.start_time
|
||||
`, [template.id]);
|
||||
|
||||
// Lade Zeit-Slots
|
||||
const timeSlots = await db.all<any>(`
|
||||
SELECT * FROM template_time_slots
|
||||
WHERE template_id = ?
|
||||
ORDER BY day_of_week, start_time
|
||||
ORDER BY start_time
|
||||
`, [template.id]);
|
||||
|
||||
return {
|
||||
...template,
|
||||
shifts: shifts.map(shift => ({
|
||||
id: shift.id,
|
||||
dayOfWeek: shift.day_of_week,
|
||||
name: shift.name,
|
||||
startTime: shift.start_time,
|
||||
endTime: shift.end_time,
|
||||
requiredEmployees: shift.required_employees,
|
||||
color: shift.color
|
||||
shifts: shiftSlots.map(slot => ({
|
||||
id: slot.id,
|
||||
dayOfWeek: slot.day_of_week,
|
||||
timeRange: {
|
||||
id: slot.time_slot_id,
|
||||
name: slot.time_range_name,
|
||||
startTime: slot.time_range_start,
|
||||
endTime: slot.time_range_end
|
||||
},
|
||||
requiredEmployees: slot.required_employees,
|
||||
color: slot.color
|
||||
})),
|
||||
timeSlots: timeSlots.map(slot => ({
|
||||
id: slot.id,
|
||||
name: slot.name,
|
||||
startTime: slot.start_time,
|
||||
endTime: slot.end_time,
|
||||
description: slot.description
|
||||
}))
|
||||
};
|
||||
})
|
||||
@@ -49,7 +75,7 @@ export const getTemplate = async (req: Request, res: Response): Promise<void> =>
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const template = await db.get<ShiftTemplate>(`
|
||||
const template = await db.get<any>(`
|
||||
SELECT st.*, u.name as created_by_name
|
||||
FROM shift_templates st
|
||||
LEFT JOIN users u ON st.created_by = u.id
|
||||
@@ -61,26 +87,46 @@ export const getTemplate = async (req: Request, res: Response): Promise<void> =>
|
||||
return;
|
||||
}
|
||||
|
||||
const shifts = await db.all<any>(`
|
||||
SELECT * FROM template_shifts
|
||||
WHERE template_id = ?
|
||||
ORDER BY day_of_week, start_time
|
||||
// Lade Schicht-Slots
|
||||
const shiftSlots = await db.all<any>(`
|
||||
SELECT ts.*, tts.name as time_range_name, tts.start_time as time_range_start, tts.end_time as time_range_end
|
||||
FROM template_shifts ts
|
||||
LEFT JOIN template_time_slots tts ON ts.time_slot_id = tts.id
|
||||
WHERE ts.template_id = ?
|
||||
ORDER BY ts.day_of_week, tts.start_time
|
||||
`, [id]);
|
||||
|
||||
const templateWithShifts = {
|
||||
// Lade Zeit-Slots
|
||||
const timeSlots = await db.all<any>(`
|
||||
SELECT * FROM template_time_slots
|
||||
WHERE template_id = ?
|
||||
ORDER BY start_time
|
||||
`, [id]);
|
||||
|
||||
const templateWithData = {
|
||||
...template,
|
||||
shifts: shifts.map(shift => ({
|
||||
id: shift.id,
|
||||
dayOfWeek: shift.day_of_week,
|
||||
name: shift.name,
|
||||
startTime: shift.start_time,
|
||||
endTime: shift.end_time,
|
||||
requiredEmployees: shift.required_employees,
|
||||
color: shift.color
|
||||
shifts: shiftSlots.map(slot => ({
|
||||
id: slot.id,
|
||||
dayOfWeek: slot.day_of_week,
|
||||
timeRange: {
|
||||
id: slot.time_slot_id,
|
||||
name: slot.time_range_name,
|
||||
startTime: slot.time_range_start,
|
||||
endTime: slot.time_range_end
|
||||
},
|
||||
requiredEmployees: slot.required_employees,
|
||||
color: slot.color
|
||||
})),
|
||||
timeSlots: timeSlots.map(slot => ({
|
||||
id: slot.id,
|
||||
name: slot.name,
|
||||
startTime: slot.start_time,
|
||||
endTime: slot.end_time,
|
||||
description: slot.description
|
||||
}))
|
||||
};
|
||||
|
||||
res.json(templateWithShifts);
|
||||
res.json(templateWithData);
|
||||
} catch (error) {
|
||||
console.error('Error fetching template:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
@@ -98,32 +144,46 @@ export const createDefaultTemplate = async (userId: string): Promise<string> =>
|
||||
await db.run(
|
||||
`INSERT INTO shift_templates (id, name, description, is_default, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[templateId, 'Standardwoche', 'Mo-Do: 2 Schichten, Fr: 1 Schicht', true, userId]
|
||||
[templateId, 'Standardwoche', 'Standard Vorlage mit konfigurierten Zeit-Slots', true, userId]
|
||||
);
|
||||
|
||||
// Vormittagsschicht Mo-Do
|
||||
for (let day = 1; day <= 4; day++) {
|
||||
// Füge Zeit-Slots hinzu
|
||||
const timeSlots = [
|
||||
{ id: uuidv4(), name: 'Vormittag', startTime: '08:00', endTime: '12:00', description: 'Vormittagsschicht' },
|
||||
{ id: uuidv4(), name: 'Nachmittag', startTime: '12:00', endTime: '16:00', description: 'Nachmittagsschicht' },
|
||||
{ id: uuidv4(), name: 'Abend', startTime: '16:00', endTime: '20:00', description: 'Abendschicht' }
|
||||
];
|
||||
|
||||
for (const slot of timeSlots) {
|
||||
await db.run(
|
||||
`INSERT INTO template_shifts (id, template_id, day_of_week, name, start_time, end_time, required_employees)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[uuidv4(), templateId, day, 'Vormittagsschicht', '08:00', '12:00', 1]
|
||||
`INSERT INTO template_time_slots (id, template_id, name, start_time, end_time, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[slot.id, templateId, slot.name, slot.startTime, slot.endTime, slot.description]
|
||||
);
|
||||
}
|
||||
|
||||
// Nachmittagsschicht Mo-Do
|
||||
// Erstelle Schichten für Mo-Do mit Zeit-Slot Referenzen
|
||||
for (let day = 1; day <= 4; day++) {
|
||||
// Vormittagsschicht
|
||||
await db.run(
|
||||
`INSERT INTO template_shifts (id, template_id, day_of_week, name, start_time, end_time, required_employees)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[uuidv4(), templateId, day, 'Nachmittagsschicht', '11:30', '15:30', 1]
|
||||
`INSERT INTO template_shifts (id, template_id, day_of_week, time_slot_id, required_employees, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[uuidv4(), templateId, day, timeSlots[0].id, 1, '#3498db']
|
||||
);
|
||||
|
||||
// Nachmittagsschicht
|
||||
await db.run(
|
||||
`INSERT INTO template_shifts (id, template_id, day_of_week, time_slot_id, required_employees, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[uuidv4(), templateId, day, timeSlots[1].id, 1, '#e74c3c']
|
||||
);
|
||||
}
|
||||
|
||||
// Freitag nur Vormittagsschicht
|
||||
await db.run(
|
||||
`INSERT INTO template_shifts (id, template_id, day_of_week, name, start_time, end_time, required_employees)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[uuidv4(), templateId, 5, 'Vormittagsschicht', '08:00', '12:00', 1]
|
||||
`INSERT INTO template_shifts (id, template_id, day_of_week, time_slot_id, required_employees, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[uuidv4(), templateId, 5, timeSlots[0].id, 1, '#3498db']
|
||||
);
|
||||
|
||||
await db.run('COMMIT');
|
||||
@@ -140,7 +200,7 @@ export const createDefaultTemplate = async (userId: string): Promise<string> =>
|
||||
|
||||
export const createTemplate = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { name, description, isDefault, shifts }: CreateShiftTemplateRequest = req.body;
|
||||
const { name, description, isDefault, shifts, timeSlots }: CreateShiftTemplateRequest = req.body;
|
||||
const userId = (req as AuthRequest).user?.userId;
|
||||
|
||||
if (!userId) {
|
||||
@@ -167,13 +227,23 @@ export const createTemplate = async (req: Request, res: Response): Promise<void>
|
||||
[templateId, name, description, isDefault ? 1 : 0, userId]
|
||||
);
|
||||
|
||||
// Insert time slots
|
||||
for (const timeSlot of timeSlots) {
|
||||
const timeSlotId = timeSlot.id || uuidv4();
|
||||
await db.run(
|
||||
`INSERT INTO template_time_slots (id, template_id, name, start_time, end_time, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[timeSlotId, templateId, timeSlot.name, timeSlot.startTime, timeSlot.endTime, timeSlot.description]
|
||||
);
|
||||
}
|
||||
|
||||
// Insert shifts
|
||||
for (const shift of shifts) {
|
||||
const shiftId = uuidv4();
|
||||
await db.run(
|
||||
`INSERT INTO template_shifts (id, template_id, day_of_week, name, start_time, end_time, required_employees, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[shiftId, templateId, shift.dayOfWeek, shift.name, shift.startTime, shift.endTime, shift.requiredEmployees, shift.color || '#3498db']
|
||||
`INSERT INTO template_shifts (id, template_id, day_of_week, time_slot_id, required_employees, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[shiftId, templateId, shift.dayOfWeek, shift.timeRange.id, shift.requiredEmployees, shift.color || '#3498db']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -188,31 +258,8 @@ export const createTemplate = async (req: Request, res: Response): Promise<void>
|
||||
await db.run('COMMIT');
|
||||
|
||||
// Return created template
|
||||
const createdTemplate = await db.get<ShiftTemplate>(`
|
||||
SELECT st.*, u.name as created_by_name
|
||||
FROM shift_templates st
|
||||
LEFT JOIN users u ON st.created_by = u.id
|
||||
WHERE st.id = ?
|
||||
`, [templateId]);
|
||||
|
||||
const templateShifts = await db.all<any>(`
|
||||
SELECT * FROM template_shifts
|
||||
WHERE template_id = ?
|
||||
ORDER BY day_of_week, start_time
|
||||
`, [templateId]);
|
||||
|
||||
res.status(201).json({
|
||||
...createdTemplate,
|
||||
shifts: templateShifts.map(shift => ({
|
||||
id: shift.id,
|
||||
dayOfWeek: shift.day_of_week,
|
||||
name: shift.name,
|
||||
startTime: shift.start_time,
|
||||
endTime: shift.end_time,
|
||||
requiredEmployees: shift.required_employees,
|
||||
color: shift.color
|
||||
}))
|
||||
});
|
||||
const createdTemplate = await getTemplateById(templateId);
|
||||
res.status(201).json(createdTemplate);
|
||||
|
||||
} catch (error) {
|
||||
await db.run('ROLLBACK');
|
||||
@@ -228,7 +275,7 @@ export const createTemplate = async (req: Request, res: Response): Promise<void>
|
||||
export const updateTemplate = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, isDefault, shifts }: UpdateShiftTemplateRequest = req.body;
|
||||
const { name, description, isDefault, shifts, timeSlots }: UpdateShiftTemplateRequest = req.body;
|
||||
|
||||
// Check if template exists
|
||||
const existingTemplate = await db.get('SELECT * FROM shift_templates WHERE id = ?', [id]);
|
||||
@@ -258,6 +305,22 @@ export const updateTemplate = async (req: Request, res: Response): Promise<void>
|
||||
);
|
||||
}
|
||||
|
||||
// If updating time slots, replace all time slots
|
||||
if (timeSlots) {
|
||||
// Delete existing time slots
|
||||
await db.run('DELETE FROM template_time_slots WHERE template_id = ?', [id]);
|
||||
|
||||
// Insert new time slots
|
||||
for (const timeSlot of timeSlots) {
|
||||
const timeSlotId = timeSlot.id || uuidv4();
|
||||
await db.run(
|
||||
`INSERT INTO template_time_slots (id, template_id, name, start_time, end_time, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[timeSlotId, id, timeSlot.name, timeSlot.startTime, timeSlot.endTime, timeSlot.description]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// If updating shifts, replace all shifts
|
||||
if (shifts) {
|
||||
// Delete existing shifts
|
||||
@@ -267,9 +330,9 @@ export const updateTemplate = async (req: Request, res: Response): Promise<void>
|
||||
for (const shift of shifts) {
|
||||
const shiftId = uuidv4();
|
||||
await db.run(
|
||||
`INSERT INTO template_shifts (id, template_id, day_of_week, name, start_time, end_time, required_employees, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[shiftId, id, shift.dayOfWeek, shift.name, shift.startTime, shift.endTime, shift.requiredEmployees, shift.color || '#3498db']
|
||||
`INSERT INTO template_shifts (id, template_id, day_of_week, time_slot_id, required_employees, color)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[shiftId, id, shift.dayOfWeek, shift.timeRange.id, shift.requiredEmployees, shift.color || '#3498db']
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -285,31 +348,8 @@ export const updateTemplate = async (req: Request, res: Response): Promise<void>
|
||||
await db.run('COMMIT');
|
||||
|
||||
// Return updated template
|
||||
const updatedTemplate = await db.get<ShiftTemplate>(`
|
||||
SELECT st.*, u.name as created_by_name
|
||||
FROM shift_templates st
|
||||
LEFT JOIN users u ON st.created_by = u.id
|
||||
WHERE st.id = ?
|
||||
`, [id]);
|
||||
|
||||
const templateShifts = await db.all<any>(`
|
||||
SELECT * FROM template_shifts
|
||||
WHERE template_id = ?
|
||||
ORDER BY day_of_week, start_time
|
||||
`, [id]);
|
||||
|
||||
res.json({
|
||||
...updatedTemplate,
|
||||
shifts: templateShifts.map(shift => ({
|
||||
id: shift.id,
|
||||
dayOfWeek: shift.day_of_week,
|
||||
name: shift.name,
|
||||
startTime: shift.start_time,
|
||||
endTime: shift.end_time,
|
||||
requiredEmployees: shift.required_employees,
|
||||
color: shift.color
|
||||
}))
|
||||
});
|
||||
const updatedTemplate = await getTemplateById(id);
|
||||
res.json(updatedTemplate);
|
||||
|
||||
} catch (error) {
|
||||
await db.run('ROLLBACK');
|
||||
@@ -334,7 +374,7 @@ export const deleteTemplate = async (req: Request, res: Response): Promise<void>
|
||||
}
|
||||
|
||||
await db.run('DELETE FROM shift_templates WHERE id = ?', [id]);
|
||||
// Template shifts will be automatically deleted due to CASCADE
|
||||
// Template shifts and time slots will be automatically deleted due to CASCADE
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
@@ -342,3 +382,56 @@ export const deleteTemplate = async (req: Request, res: Response): Promise<void>
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to get template by ID
|
||||
async function getTemplateById(templateId: string): Promise<any> {
|
||||
const template = await db.get<any>(`
|
||||
SELECT st.*, u.name as created_by_name
|
||||
FROM shift_templates st
|
||||
LEFT JOIN users u ON st.created_by = u.id
|
||||
WHERE st.id = ?
|
||||
`, [templateId]);
|
||||
|
||||
if (!template) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Lade Schicht-Slots
|
||||
const shiftSlots = await db.all<any>(`
|
||||
SELECT ts.*, tts.name as time_range_name, tts.start_time as time_range_start, tts.end_time as time_range_end
|
||||
FROM template_shifts ts
|
||||
LEFT JOIN template_time_slots tts ON ts.time_slot_id = tts.id
|
||||
WHERE ts.template_id = ?
|
||||
ORDER BY ts.day_of_week, tts.start_time
|
||||
`, [templateId]);
|
||||
|
||||
// Lade Zeit-Slots
|
||||
const timeSlots = await db.all<any>(`
|
||||
SELECT * FROM template_time_slots
|
||||
WHERE template_id = ?
|
||||
ORDER BY start_time
|
||||
`, [templateId]);
|
||||
|
||||
return {
|
||||
...template,
|
||||
shifts: shiftSlots.map(slot => ({
|
||||
id: slot.id,
|
||||
dayOfWeek: slot.day_of_week,
|
||||
timeRange: {
|
||||
id: slot.time_slot_id,
|
||||
name: slot.time_range_name,
|
||||
startTime: slot.time_range_start,
|
||||
endTime: slot.time_range_end
|
||||
},
|
||||
requiredEmployees: slot.required_employees,
|
||||
color: slot.color
|
||||
})),
|
||||
timeSlots: timeSlots.map(slot => ({
|
||||
id: slot.id,
|
||||
name: slot.name,
|
||||
startTime: slot.start_time,
|
||||
endTime: slot.end_time,
|
||||
description: slot.description
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS shift_templates (
|
||||
CREATE TABLE IF NOT EXISTS template_shifts (
|
||||
id TEXT PRIMARY KEY,
|
||||
template_id TEXT NOT NULL,
|
||||
day_of_week INTEGER NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 5),
|
||||
day_of_week INTEGER NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 7),
|
||||
name TEXT NOT NULL,
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
@@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS shift_plans (
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assigned_shifts (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
shift_plan_id TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
start_time TEXT NOT NULL,
|
||||
@@ -64,7 +65,7 @@ CREATE TABLE IF NOT EXISTS assigned_shifts (
|
||||
CREATE TABLE IF NOT EXISTS employee_availabilities (
|
||||
id TEXT PRIMARY KEY,
|
||||
employee_id TEXT NOT NULL,
|
||||
day_of_week INTEGER NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 5),
|
||||
day_of_week INTEGER NOT NULL CHECK (day_of_week >= 0 AND day_of_week <= 6),
|
||||
start_time TEXT NOT NULL,
|
||||
end_time TEXT NOT NULL,
|
||||
is_available BOOLEAN DEFAULT FALSE,
|
||||
|
||||
@@ -1,17 +1,37 @@
|
||||
// backend/src/models/Shift.ts
|
||||
export interface ShiftTemplate {
|
||||
export interface Shift {
|
||||
id: string;
|
||||
name: string;
|
||||
shifts: TemplateShift[];
|
||||
description?: string;
|
||||
isDefault: boolean;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
shifts: ShiftSlot[];
|
||||
}
|
||||
|
||||
export interface TemplateShift {
|
||||
dayOfWeek: number; // 0-6
|
||||
export interface ShiftSlot {
|
||||
id: string;
|
||||
shiftId: string;
|
||||
dayOfWeek: number;
|
||||
name: string;
|
||||
startTime: string; // "08:00"
|
||||
endTime: string; // "12:00"
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
requiredEmployees: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface CreateShiftRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
isDefault: boolean;
|
||||
shifts: Omit<ShiftSlot, 'id' | 'shiftId'>[];
|
||||
}
|
||||
|
||||
export interface UpdateShiftSlotRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
shifts?: Omit<ShiftSlot, 'id' | 'shiftId'>[];
|
||||
}
|
||||
|
||||
export interface ShiftPlan {
|
||||
|
||||
@@ -1,30 +1,35 @@
|
||||
// backend/src/models/ShiftTemplate.ts
|
||||
export interface ShiftTemplate {
|
||||
export interface TemplateShift {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
isDefault: boolean;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
shifts: TemplateShift[];
|
||||
shifts: TemplateShiftSlot[];
|
||||
}
|
||||
|
||||
export interface TemplateShift {
|
||||
export interface TemplateShiftSlot {
|
||||
id: string;
|
||||
templateId: string;
|
||||
dayOfWeek: number;
|
||||
name: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
timeRange: TemplateShiftTimeRange;
|
||||
requiredEmployees: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface TemplateShiftTimeRange {
|
||||
id: string;
|
||||
name: string; // e.g., "Frühschicht", "Spätschicht"
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
export interface CreateShiftTemplateRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
isDefault: boolean;
|
||||
shifts: Omit<TemplateShift, 'id' | 'templateId'>[];
|
||||
timeSlots: Omit<TemplateShiftTimeRange, 'id'>[];
|
||||
}
|
||||
|
||||
export interface UpdateShiftTemplateRequest {
|
||||
@@ -32,4 +37,5 @@ export interface UpdateShiftTemplateRequest {
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
shifts?: Omit<TemplateShift, 'id' | 'templateId'>[];
|
||||
timeSlots?: Omit<TemplateShiftTimeRange, 'id'>[];
|
||||
}
|
||||
@@ -1,17 +1,64 @@
|
||||
import { db } from '../services/databaseService.js';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { readFile, readdir } from 'fs/promises';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Helper function to ensure migrations are tracked
|
||||
async function ensureMigrationTable() {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS applied_migrations (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
// Helper function to check if a migration has been applied
|
||||
async function isMigrationApplied(migrationName: string): Promise<boolean> {
|
||||
const result = await db.get<{ count: number }>(
|
||||
'SELECT COUNT(*) as count FROM applied_migrations WHERE name = ?',
|
||||
[migrationName]
|
||||
);
|
||||
return (result?.count ?? 0) > 0;
|
||||
}
|
||||
|
||||
// Helper function to mark a migration as applied
|
||||
async function markMigrationAsApplied(migrationName: string) {
|
||||
await db.run(
|
||||
'INSERT INTO applied_migrations (id, name) VALUES (?, ?)',
|
||||
[crypto.randomUUID(), migrationName]
|
||||
);
|
||||
}
|
||||
|
||||
export async function applyMigration() {
|
||||
try {
|
||||
console.log('📦 Starting database migration...');
|
||||
|
||||
// Read the migration file
|
||||
const migrationPath = join(__dirname, '../database/migrations/002_add_employee_fields.sql');
|
||||
// Ensure migration tracking table exists
|
||||
await ensureMigrationTable();
|
||||
|
||||
// Get all migration files
|
||||
const migrationsDir = join(__dirname, '../database/migrations');
|
||||
const files = await readdir(migrationsDir);
|
||||
|
||||
// Sort files to ensure consistent order
|
||||
const migrationFiles = files
|
||||
.filter(f => f.endsWith('.sql'))
|
||||
.sort();
|
||||
|
||||
// Process each migration file
|
||||
for (const migrationFile of migrationFiles) {
|
||||
if (await isMigrationApplied(migrationFile)) {
|
||||
console.log(`ℹ️ Migration ${migrationFile} already applied, skipping...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`📄 Applying migration: ${migrationFile}`);
|
||||
const migrationPath = join(migrationsDir, migrationFile);
|
||||
const migrationSQL = await readFile(migrationPath, 'utf-8');
|
||||
|
||||
// Split into individual statements
|
||||
@@ -20,6 +67,10 @@ export async function applyMigration() {
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
|
||||
// Start transaction for this migration
|
||||
await db.run('BEGIN TRANSACTION');
|
||||
|
||||
try {
|
||||
// Execute each statement
|
||||
for (const statement of statements) {
|
||||
try {
|
||||
@@ -35,7 +86,18 @@ export async function applyMigration() {
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Migration completed successfully');
|
||||
// Mark migration as applied
|
||||
await markMigrationAsApplied(migrationFile);
|
||||
await db.run('COMMIT');
|
||||
console.log(`✅ Migration ${migrationFile} applied successfully`);
|
||||
|
||||
} catch (error) {
|
||||
await db.run('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ All migrations completed successfully');
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error);
|
||||
throw error;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { db } from '../services/databaseService.js';
|
||||
import { ShiftTemplate } from '../models/ShiftTemplate.js';
|
||||
import { TemplateShift } from '../models/ShiftTemplate.js';
|
||||
|
||||
async function checkTemplates() {
|
||||
try {
|
||||
const templates = await db.all<ShiftTemplate>(
|
||||
const templates = await db.all<TemplateShift>(
|
||||
`SELECT st.*, u.name as created_by_name
|
||||
FROM shift_templates st
|
||||
LEFT JOIN users u ON st.created_by = u.id`
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function setupDefaultTemplate(): Promise<void> {
|
||||
console.log('Standard-Vorlage erstellt:', templateId);
|
||||
|
||||
// Vormittagsschicht Mo-Do
|
||||
for (let day = 1; day <= 4; day++) {
|
||||
for (let day = 1; day <= 5; day++) {
|
||||
await db.run(
|
||||
`INSERT INTO template_shifts (id, template_id, day_of_week, name, start_time, end_time, required_employees)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
@@ -63,7 +63,7 @@ export async function setupDefaultTemplate(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
console.log('Vormittagsschichten Mo-Do erstellt');
|
||||
console.log('Vormittagsschichten Mo-Fr erstellt');
|
||||
|
||||
// Nachmittagsschicht Mo-Do
|
||||
for (let day = 1; day <= 4; day++) {
|
||||
@@ -76,15 +76,6 @@ export async function setupDefaultTemplate(): Promise<void> {
|
||||
|
||||
console.log('Nachmittagsschichten Mo-Do erstellt');
|
||||
|
||||
// Freitag nur Vormittagsschicht
|
||||
await db.run(
|
||||
`INSERT INTO template_shifts (id, template_id, day_of_week, name, start_time, end_time, required_employees)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[uuidv4(), templateId, 5, 'Vormittagsschicht', '08:00', '12:00', 1]
|
||||
);
|
||||
|
||||
console.log('Freitag Vormittagsschicht erstellt');
|
||||
|
||||
await db.run('COMMIT');
|
||||
console.log('Standard-Vorlage erfolgreich initialisiert');
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import Login from './pages/Auth/Login';
|
||||
import Dashboard from './pages/Dashboard/Dashboard';
|
||||
import ShiftPlanList from './pages/ShiftPlans/ShiftPlanList';
|
||||
import ShiftPlanCreate from './pages/ShiftPlans/ShiftPlanCreate';
|
||||
import ShiftPlanEdit from './pages/ShiftPlans/ShiftPlanEdit';
|
||||
import ShiftPlanView from './pages/ShiftPlans/ShiftPlanView';
|
||||
import EmployeeManagement from './pages/Employees/EmployeeManagement';
|
||||
import Settings from './pages/Settings/Settings';
|
||||
import Help from './pages/Help/Help';
|
||||
@@ -93,6 +95,16 @@ const AppContent: React.FC = () => {
|
||||
<ShiftPlanCreate />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/shift-plans/:id/edit" element={
|
||||
<ProtectedRoute roles={['admin', 'instandhalter']}>
|
||||
<ShiftPlanEdit />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/shift-plans/:id" element={
|
||||
<ProtectedRoute>
|
||||
<ShiftPlanView />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/employees" element={
|
||||
<ProtectedRoute roles={['admin', 'instandhalter']}>
|
||||
<EmployeeManagement />
|
||||
|
||||
@@ -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);
|
||||
} 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}`,
|
||||
|
||||
// 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: slot.start,
|
||||
endTime: slot.end,
|
||||
isAvailable: false
|
||||
}))
|
||||
);
|
||||
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) {
|
||||
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,132 +294,234 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Verfügbarkeiten Tabelle */}
|
||||
{/* Verfügbarkeits-Legende */}
|
||||
<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>
|
||||
|
||||
{/* 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',
|
||||
marginBottom: '30px'
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{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',
|
||||
backgroundColor: '#2c3e50',
|
||||
color: 'white',
|
||||
padding: '15px 20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#2c3e50',
|
||||
borderBottom: '1px solid #e0e0e0'
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{day.name}
|
||||
Verfügbarkeit für: {selectedPlan.name}
|
||||
</div>
|
||||
|
||||
{/* Zeit-Slots */}
|
||||
<div style={{ padding: '15px 20px' }}>
|
||||
{dayAvailabilities.map((availability, availabilityIndex) => {
|
||||
const isLastAvailability = availabilityIndex === dayAvailabilities.length - 1;
|
||||
<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 (
|
||||
<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}`}
|
||||
<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',
|
||||
color: availability.isAvailable ? '#27ae60' : '#95a5a6'
|
||||
minWidth: '120px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center'
|
||||
}}
|
||||
>
|
||||
{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}
|
||||
{availabilityLevels.map(level => (
|
||||
<option
|
||||
key={level.level}
|
||||
value={level.level}
|
||||
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',
|
||||
backgroundColor: level.bgColor,
|
||||
color: level.color,
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{availability.isAvailable ? 'Aktiv' : 'Inaktiv'}
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Text */}
|
||||
<div style={{
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
430
frontend/src/pages/ShiftPlans/ShiftPlanEdit.tsx
Normal file
430
frontend/src/pages/ShiftPlans/ShiftPlanEdit.tsx
Normal 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;
|
||||
292
frontend/src/pages/ShiftPlans/ShiftPlanView.tsx
Normal file
292
frontend/src/pages/ShiftPlans/ShiftPlanView.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -50,7 +50,20 @@ export const shiftPlanService = {
|
||||
throw new Error('Fehler beim Laden der Schichtpläne');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
const data = await response.json();
|
||||
|
||||
// Convert snake_case to camelCase
|
||||
return data.map((plan: any) => ({
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
startDate: plan.start_date, // Convert here
|
||||
endDate: plan.end_date, // Convert here
|
||||
templateId: plan.template_id,
|
||||
status: plan.status,
|
||||
createdBy: plan.created_by,
|
||||
createdAt: plan.created_at,
|
||||
shifts: plan.shifts || []
|
||||
}));
|
||||
},
|
||||
|
||||
async getShiftPlan(id: string): Promise<ShiftPlan> {
|
||||
@@ -69,7 +82,20 @@ export const shiftPlanService = {
|
||||
throw new Error('Schichtplan nicht gefunden');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
const data = await response.json();
|
||||
|
||||
// Convert snake_case to camelCase
|
||||
return {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
startDate: data.start_date, // Convert here
|
||||
endDate: data.end_date, // Convert here
|
||||
templateId: data.template_id,
|
||||
status: data.status,
|
||||
createdBy: data.created_by,
|
||||
createdAt: data.created_at,
|
||||
shifts: data.shifts || []
|
||||
};
|
||||
},
|
||||
|
||||
async createShiftPlan(plan: CreateShiftPlanRequest): Promise<ShiftPlan> {
|
||||
@@ -130,5 +156,60 @@ export const shiftPlanService = {
|
||||
}
|
||||
throw new Error('Fehler beim Löschen des Schichtplans');
|
||||
}
|
||||
},
|
||||
|
||||
async updateShiftPlanShift(planId: string, shift: ShiftPlanShift): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/${planId}/shifts/${shift.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authService.getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify(shift)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
authService.logout();
|
||||
throw new Error('Nicht authorisiert - bitte erneut anmelden');
|
||||
}
|
||||
throw new Error('Fehler beim Aktualisieren der Schicht');
|
||||
}
|
||||
},
|
||||
|
||||
async addShiftPlanShift(planId: string, shift: Omit<ShiftPlanShift, 'id' | 'shiftPlanId' | 'assignedEmployees'>): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/${planId}/shifts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authService.getAuthHeaders()
|
||||
},
|
||||
body: JSON.stringify(shift)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
authService.logout();
|
||||
throw new Error('Nicht authorisiert - bitte erneut anmelden');
|
||||
}
|
||||
throw new Error('Fehler beim Hinzufügen der Schicht');
|
||||
}
|
||||
},
|
||||
|
||||
async deleteShiftPlanShift(planId: string, shiftId: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/${planId}/shifts/${shiftId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
...authService.getAuthHeaders()
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
authService.logout();
|
||||
throw new Error('Nicht authorisiert - bitte erneut anmelden');
|
||||
}
|
||||
throw new Error('Fehler beim Löschen der Schicht');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
// frontend/src/services/shiftTemplateService.ts
|
||||
import { ShiftTemplate, TemplateShift } from '../types/shiftTemplate';
|
||||
import { TemplateShift } from '../types/shiftTemplate';
|
||||
import { authService } from './authService';
|
||||
|
||||
const API_BASE = 'http://localhost:3002/api/shift-templates';
|
||||
const API_BASE = 'http://localhost:3001/api/shift-templates';
|
||||
|
||||
export const shiftTemplateService = {
|
||||
async getTemplates(): Promise<ShiftTemplate[]> {
|
||||
async getTemplates(): Promise<TemplateShift[]> {
|
||||
const response = await fetch(API_BASE, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -23,14 +23,14 @@ export const shiftTemplateService = {
|
||||
|
||||
const templates = await response.json();
|
||||
// Sortiere die Vorlagen so, dass die Standard-Vorlage immer zuerst kommt
|
||||
return templates.sort((a: ShiftTemplate, b: ShiftTemplate) => {
|
||||
return templates.sort((a: TemplateShift, b: TemplateShift) => {
|
||||
if (a.isDefault && !b.isDefault) return -1;
|
||||
if (!a.isDefault && b.isDefault) return 1;
|
||||
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
|
||||
});
|
||||
},
|
||||
|
||||
async getTemplate(id: string): Promise<ShiftTemplate> {
|
||||
async getTemplate(id: string): Promise<TemplateShift> {
|
||||
const response = await fetch(`${API_BASE}/${id}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -49,7 +49,7 @@ export const shiftTemplateService = {
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async createTemplate(template: Omit<ShiftTemplate, 'id' | 'createdAt' | 'createdBy'>): Promise<ShiftTemplate> {
|
||||
async createTemplate(template: Omit<TemplateShift, 'id' | 'createdAt' | 'createdBy'>): Promise<TemplateShift> {
|
||||
// Wenn diese Vorlage als Standard markiert ist,
|
||||
// fragen wir den Benutzer, ob er wirklich die Standard-Vorlage ändern möchte
|
||||
if (template.isDefault) {
|
||||
@@ -81,7 +81,7 @@ export const shiftTemplateService = {
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async updateTemplate(id: string, template: Partial<ShiftTemplate>): Promise<ShiftTemplate> {
|
||||
async updateTemplate(id: string, template: Partial<TemplateShift>): Promise<TemplateShift> {
|
||||
const response = await fetch(`${API_BASE}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
|
||||
@@ -35,4 +35,5 @@ export interface Availability {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isAvailable: boolean;
|
||||
availabilityLevel: 1 | 2 | 3; // 1: bevorzugt, 2: möglich, 3: nicht möglich
|
||||
}
|
||||
@@ -1,28 +1,41 @@
|
||||
// frontend/src/types/shiftTemplate.ts
|
||||
export interface ShiftTemplate {
|
||||
export interface TemplateShift {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
shifts: TemplateShift[];
|
||||
isDefault: boolean;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
isDefault: boolean;
|
||||
shifts: TemplateShiftSlot[];
|
||||
}
|
||||
|
||||
export interface TemplateShift {
|
||||
export interface TemplateShiftSlot {
|
||||
id: string;
|
||||
dayOfWeek: number; // 1-5 (Montag=1, Dienstag=2, ...)
|
||||
name: string;
|
||||
startTime: string; // "08:00"
|
||||
endTime: string; // "12:00"
|
||||
dayOfWeek: number;
|
||||
timeRange: TemplateShiftTimeRange;
|
||||
requiredEmployees: number;
|
||||
color?: string; // Für visuelle Darstellung
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface TemplateShiftTimeRange {
|
||||
id: string;
|
||||
name: string; // e.g., "Frühschicht", "Spätschicht"
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_TIME_SLOTS: TemplateShiftTimeRange[] = [
|
||||
{ id: 'morning', name: 'Vormittag', startTime: '08:00', endTime: '12:00' },
|
||||
{ id: 'afternoon', name: 'Nachmittag', startTime: '11:30', endTime: '15:30' },
|
||||
];
|
||||
|
||||
|
||||
export const DEFAULT_DAYS = [
|
||||
{ id: 1, name: 'Montag' },
|
||||
{ id: 2, name: 'Dienstag' },
|
||||
{ id: 3, name: 'Donnerstag' },
|
||||
{ id: 4, name: 'Mittwoch' },
|
||||
{ id: 5, name: 'Freitag' },
|
||||
{ id: 6, name: 'Samstag' },
|
||||
{ id: 7, name: 'Sonntag' }
|
||||
];
|
||||
Reference in New Issue
Block a user