frontend with ony errors

This commit is contained in:
2025-10-12 00:59:57 +02:00
parent 75d4d86ef3
commit 90d8ae5140
31 changed files with 869 additions and 1481 deletions

View File

@@ -10,93 +10,79 @@ import {
import { AuthRequest } from '../middleware/auth.js'; import { AuthRequest } from '../middleware/auth.js';
import { createPlanFromPreset, TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults.js'; import { createPlanFromPreset, TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults.js';
async function getPlanWithDetails(planId: string) {
const plan = await db.get<any>(`
SELECT sp.*, e.name as created_by_name
FROM shift_plans sp
LEFT JOIN employees e ON sp.created_by = e.id
WHERE sp.id = ?
`, [planId]);
if (!plan) return null;
const [timeSlots, shifts] = await Promise.all([
db.all<any>(`SELECT * FROM time_slots WHERE plan_id = ? ORDER BY start_time`, [planId]),
db.all<any>(`
SELECT s.*, ts.name as time_slot_name, ts.start_time, ts.end_time
FROM shifts s
LEFT JOIN time_slots ts ON s.time_slot_id = ts.id
WHERE s.plan_id = ?
ORDER BY s.day_of_week, ts.start_time
`, [planId])
]);
return {
plan: {
...plan,
isTemplate: plan.is_template === 1,
startDate: plan.start_date,
endDate: plan.end_date,
createdBy: plan.created_by,
createdAt: plan.created_at,
},
timeSlots: timeSlots.map(slot => ({
id: slot.id,
planId: slot.plan_id,
name: slot.name,
startTime: slot.start_time,
endTime: slot.end_time,
description: slot.description
})),
shifts: shifts.map(shift => ({
id: shift.id,
planId: shift.plan_id,
timeSlotId: shift.time_slot_id,
dayOfWeek: shift.day_of_week,
requiredEmployees: shift.required_employees,
color: shift.color,
timeSlot: {
id: shift.time_slot_id,
name: shift.time_slot_name,
startTime: shift.start_time,
endTime: shift.end_time
}
}))
};
}
// Simplified getShiftPlans using shared helper
export const getShiftPlans = async (req: Request, res: Response): Promise<void> => { export const getShiftPlans = async (req: Request, res: Response): Promise<void> => {
try { try {
console.log('🔍 Lade Schichtpläne...'); const plans = await db.all<any>(`
const shiftPlans = await db.all<any>(`
SELECT sp.*, e.name as created_by_name SELECT sp.*, e.name as created_by_name
FROM shift_plans sp FROM shift_plans sp
LEFT JOIN employees e ON sp.created_by = e.id LEFT JOIN employees e ON sp.created_by = e.id
ORDER BY sp.created_at DESC ORDER BY sp.created_at DESC
`); `);
console.log(`${shiftPlans.length} Schichtpläne gefunden:`, shiftPlans.map(p => p.name));
// Für jeden Plan die Schichten und Zeit-Slots laden
const plansWithDetails = await Promise.all( const plansWithDetails = await Promise.all(
shiftPlans.map(async (plan) => { plans.map(async (plan) => {
// Lade Zeit-Slots const details = await getPlanWithDetails(plan.id);
const timeSlots = await db.all<any>(` return details ? { ...details.plan, timeSlots: details.timeSlots, shifts: details.shifts } : null;
SELECT * FROM time_slots
WHERE plan_id = ?
ORDER BY start_time
`, [plan.id]);
// Lade Schichten
const shifts = await db.all<any>(`
SELECT s.*, ts.name as time_slot_name, ts.start_time, ts.end_time
FROM shifts s
LEFT JOIN time_slots ts ON s.time_slot_id = ts.id
WHERE s.plan_id = ?
ORDER BY s.day_of_week, ts.start_time
`, [plan.id]);
// Lade geplante Schichten (nur für nicht-Template Pläne)
let scheduledShifts = [];
if (!plan.is_template) {
scheduledShifts = await db.all<any>(`
SELECT ss.*, ts.name as time_slot_name
FROM scheduled_shifts ss
LEFT JOIN time_slots ts ON ss.time_slot_id = ts.id
WHERE ss.plan_id = ?
ORDER BY ss.date, ts.start_time
`, [plan.id]);
}
return {
...plan,
isTemplate: plan.is_template === 1,
startDate: plan.start_date,
endDate: plan.end_date,
createdBy: plan.created_by,
createdAt: plan.created_at,
timeSlots: timeSlots.map(slot => ({
id: slot.id,
planId: slot.plan_id,
name: slot.name,
startTime: slot.start_time,
endTime: slot.end_time,
description: slot.description
})),
shifts: shifts.map(shift => ({
id: shift.id,
planId: shift.plan_id,
timeSlotId: shift.time_slot_id,
dayOfWeek: shift.day_of_week,
requiredEmployees: shift.required_employees,
color: shift.color,
timeSlot: {
id: shift.time_slot_id,
name: shift.time_slot_name,
startTime: shift.start_time,
endTime: shift.end_time
}
})),
scheduledShifts: scheduledShifts.map(shift => ({
id: shift.id,
planId: shift.plan_id,
date: shift.date,
timeSlotId: shift.time_slot_id,
requiredEmployees: shift.required_employees,
assignedEmployees: JSON.parse(shift.assigned_employees || '[]'),
timeSlotName: shift.time_slot_name
}))
};
}) })
); );
res.json(plansWithDetails); res.json(plansWithDetails.filter(Boolean));
} catch (error) { } catch (error) {
console.error('Error fetching shift plans:', error); console.error('Error fetching shift plans:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });

View File

@@ -19,6 +19,36 @@ export const MANAGER_DEFAULTS = {
isActive: true isActive: true
}; };
export const EMPLOYEE_TYPE_CONFIG = {
manager: {
value: 'manager' as const,
label: 'Chef/Administrator',
color: '#e74c3c',
independent: true,
description: 'Vollzugriff auf alle Funktionen und Mitarbeiterverwaltung'
},
experienced: {
value: 'experienced' as const,
label: 'Erfahren',
color: '#3498db',
independent: true,
description: 'Langjährige Erfahrung, kann komplexe Aufgaben übernehmen'
},
trainee: {
value: 'trainee' as const,
label: 'Neuling',
color: '#27ae60',
independent: false,
description: 'Benötigt Einarbeitung und Unterstützung'
}
} as const;
export const ROLE_CONFIG = [
{ value: 'user', label: 'Mitarbeiter', description: 'Kann eigene Schichten einsehen', color: '#27ae60' },
{ value: 'instandhalter', label: 'Instandhalter', description: 'Kann Schichtpläne erstellen und Mitarbeiter verwalten', color: '#3498db' },
{ value: 'admin', label: 'Administrator', description: 'Voller Zugriff auf alle Funktionen', color: '#e74c3c' }
] as const;
// Contract type descriptions // Contract type descriptions
export const CONTRACT_TYPE_DESCRIPTIONS = { export const CONTRACT_TYPE_DESCRIPTIONS = {
small: '1 Schicht pro Woche', small: '1 Schicht pro Woche',
@@ -26,13 +56,6 @@ export const CONTRACT_TYPE_DESCRIPTIONS = {
manager: 'Kein Vertragslimit - Immer MO und DI verfügbar' manager: 'Kein Vertragslimit - Immer MO und DI verfügbar'
} as const; } as const;
// Employee type descriptions
export const EMPLOYEE_TYPE_DESCRIPTIONS = {
manager: 'Chef - Immer MO und DI in beiden Schichten, kann eigene Schichten festlegen',
trainee: 'Neuling - Darf nicht alleine sein, benötigt erfahrene Begleitung',
experienced: 'Erfahren - Kann alleine arbeiten (wenn freigegeben)'
} as const;
// Availability preference descriptions // Availability preference descriptions
export const AVAILABILITY_PREFERENCES = { export const AVAILABILITY_PREFERENCES = {
1: { label: 'Bevorzugt', color: '#10b981', description: 'Möchte diese Schicht arbeiten' }, 1: { label: 'Bevorzugt', color: '#10b981', description: 'Möchte diese Schicht arbeiten' },

View File

@@ -1,128 +1,40 @@
// backend/src/models/helpers/employeeHelpers.ts // backend/src/models/helpers/employeeHelpers.ts
import { Employee, CreateEmployeeRequest, EmployeeAvailability, ManagerAvailability } from '../Employee.js'; import { Employee, CreateEmployeeRequest, EmployeeAvailability } from '../Employee.js';
// Validation helpers // Simplified validation - use schema validation instead
export function validateEmployee(employee: CreateEmployeeRequest): string[] { export function validateEmployeeData(employee: CreateEmployeeRequest): string[] {
const errors: string[] = []; const errors: string[] = [];
if (!employee.email || !employee.email.includes('@')) { if (!employee.email?.includes('@')) {
errors.push('Valid email is required'); errors.push('Valid email is required');
} }
if (!employee.password || employee.password.length < 6) { if (employee.password?.length < 6) {
errors.push('Password must be at least 6 characters long'); errors.push('Password must be at least 6 characters long');
} }
if (!employee.name || employee.name.trim().length < 2) { if (!employee.name?.trim() || employee.name.trim().length < 2) {
errors.push('Name is required and must be at least 2 characters long'); errors.push('Name is required and must be at least 2 characters long');
} }
if (!employee.contractType) {
errors.push('Contract type is required');
}
return errors; return errors;
} }
export function validateAvailability(availability: Omit<EmployeeAvailability, 'id' | 'employeeId'>): string[] { // Simplified business logic helpers
const errors: string[] = []; export const isManager = (employee: Employee): boolean =>
employee.employeeType === 'manager';
if (availability.dayOfWeek < 1 || availability.dayOfWeek > 7) { export const isTrainee = (employee: Employee): boolean =>
errors.push('Day of week must be between 1 and 7'); employee.employeeType === 'trainee';
}
if (![1, 2, 3].includes(availability.preferenceLevel)) { export const isExperienced = (employee: Employee): boolean =>
errors.push('Preference level must be 1, 2, or 3'); employee.employeeType === 'experienced';
}
if (!availability.timeSlotId) { export const isAdmin = (employee: Employee): boolean =>
errors.push('Time slot ID is required'); employee.role === 'admin';
}
if (!availability.planId) { export const canEmployeeWorkAlone = (employee: Employee): boolean =>
errors.push('Plan ID is required'); employee.canWorkAlone && isExperienced(employee);
}
return errors; export const getEmployeeWorkHours = (employee: Employee): number =>
} isManager(employee) ? 999 : (employee.contractType === 'small' ? 1 : 2);
// Employee type guards
export function isManager(employee: Employee): boolean {
return employee.employeeType === 'manager';
}
export function isTrainee(employee: Employee): boolean {
return employee.employeeType === 'trainee';
}
export function isExperienced(employee: Employee): boolean {
return employee.employeeType === 'experienced';
}
export function isAdmin(employee: Employee): boolean {
return employee.role === 'admin';
}
// Business logic helpers
export function canEmployeeWorkAlone(employee: Employee): boolean {
return employee.canWorkAlone && employee.employeeType === 'experienced';
}
export function getEmployeeWorkHours(employee: Employee): number {
// Manager: no contract limit, others: small=1, large=2 shifts per week
return isManager(employee) ? 999 : (employee.contractType === 'small' ? 1 : 2);
}
export function requiresAvailabilityPreference(employee: Employee): boolean {
// Only non-managers use the preference system
return !isManager(employee);
}
export function canSetOwnAvailability(employee: Employee): boolean {
// Manager can set their own specific shift assignments
return isManager(employee);
}
// Manager availability helpers
export function isManagerAvailable(
managerAssignments: ManagerAvailability[],
dayOfWeek: number,
timeSlotId: string
): boolean {
const assignment = managerAssignments.find(assignment =>
assignment.dayOfWeek === dayOfWeek &&
assignment.timeSlotId === timeSlotId
);
return assignment ? assignment.isAvailable : false;
}
export function getManagerAvailableShifts(managerAssignments: ManagerAvailability[]): ManagerAvailability[] {
return managerAssignments.filter(assignment => assignment.isAvailable);
}
export function updateManagerAvailability(
assignments: ManagerAvailability[],
dayOfWeek: number,
timeSlotId: string,
isAvailable: boolean
): ManagerAvailability[] {
return assignments.map(assignment =>
assignment.dayOfWeek === dayOfWeek && assignment.timeSlotId === timeSlotId
? { ...assignment, isAvailable }
: assignment
);
}
export function validateManagerMinimumAvailability(managerAssignments: ManagerAvailability[]): boolean {
const requiredShifts = [
{ dayOfWeek: 1, timeSlotId: 'morning' },
{ dayOfWeek: 1, timeSlotId: 'afternoon' },
{ dayOfWeek: 2, timeSlotId: 'morning' },
{ dayOfWeek: 2, timeSlotId: 'afternoon' }
];
return requiredShifts.every(required =>
isManagerAvailable(managerAssignments, required.dayOfWeek, required.timeSlotId)
);
}

View File

@@ -0,0 +1,75 @@
// backend/src/models/Employee.ts
export interface Employee {
id: string;
email: string;
name: string;
role: 'admin' | 'maintenance' | 'user';
employeeType: 'manager' | 'trainee' | 'experienced';
contractType: 'small' | 'large';
canWorkAlone: boolean;
isActive: boolean;
createdAt: string;
lastLogin?: string | null;
}
export interface CreateEmployeeRequest {
email: string;
password: string;
name: string;
role: 'admin' | 'maintenance' | 'user';
employeeType: 'manager' | 'trainee' | 'experienced';
contractType: 'small' | 'large';
canWorkAlone: boolean;
}
export interface UpdateEmployeeRequest {
name?: string;
role?: 'admin' | 'maintenance' | 'user';
employeeType?: 'manager' | 'trainee' | 'experienced';
contractType?: 'small' | 'large';
canWorkAlone?: boolean;
isActive?: boolean;
}
export interface EmployeeWithPassword extends Employee {
password: string;
}
export interface EmployeeAvailability {
id: string;
employeeId: string;
planId: string;
dayOfWeek: number; // 1=Monday, 7=Sunday
timeSlotId: string;
preferenceLevel: 1 | 2 | 3; // 1:preferred, 2:available, 3:unavailable
notes?: string;
}
export interface ManagerAvailability {
id: string;
employeeId: string;
planId: string;
dayOfWeek: number; // 1=Monday, 7=Sunday
timeSlotId: string;
isAvailable: boolean; // Simple available/not available
assignedBy: string; // Always self for manager
}
export interface CreateAvailabilityRequest {
planId: string;
availabilities: Omit<EmployeeAvailability, 'id' | 'employeeId'>[];
}
export interface UpdateAvailabilityRequest {
planId: string;
availabilities: Omit<EmployeeAvailability, 'id' | 'employeeId'>[];
}
export interface ManagerSelfAssignmentRequest {
planId: string;
assignments: Omit<ManagerAvailability, 'id' | 'employeeId' | 'assignedBy'>[];
}
export interface EmployeeWithAvailabilities extends Employee {
availabilities: EmployeeAvailability[];
}

View File

@@ -0,0 +1,105 @@
// backend/src/models/ShiftPlan.ts
export interface ShiftPlan {
id: string;
name: string;
description?: string;
startDate?: string; // Optional for templates
endDate?: string; // Optional for templates
isTemplate: boolean;
status: 'draft' | 'published' | 'archived' | 'template';
createdBy: string;
createdAt: string;
timeSlots: TimeSlot[];
shifts: Shift[];
scheduledShifts?: ScheduledShift[]; // Only for non-template plans with dates
}
export interface TimeSlot {
id: string;
planId: string;
name: string;
startTime: string;
endTime: string;
description?: string;
}
export interface Shift {
id: string;
planId: string;
timeSlotId: string;
dayOfWeek: number; // 1=Monday, 7=Sunday
requiredEmployees: number;
color?: string;
}
export interface ScheduledShift {
id: string;
planId: string;
date: string;
timeSlotId: string;
requiredEmployees: number;
assignedEmployees: string[]; // employee IDs
}
export interface ShiftAssignment {
id: string;
scheduledShiftId: string;
employeeId: string;
assignmentStatus: 'assigned' | 'cancelled';
assignedAt: string;
assignedBy: string;
}
export interface EmployeeAvailability {
id: string;
employeeId: string;
planId: string;
dayOfWeek: number;
timeSlotId: string;
preferenceLevel: 1 | 2 | 3; // 1:preferred, 2:available, 3:unavailable
notes?: string;
}
// Request/Response DTOs
export interface CreateShiftPlanRequest {
name: string;
description?: string;
startDate?: string;
endDate?: string;
isTemplate: boolean;
timeSlots: Omit<TimeSlot, 'id' | 'planId'>[];
shifts: Omit<Shift, 'id' | 'planId'>[];
}
export interface UpdateShiftPlanRequest {
name?: string;
description?: string;
startDate?: string;
endDate?: string;
isTemplate?: boolean;
status?: 'draft' | 'published' | 'archived' | 'template';
timeSlots?: Omit<TimeSlot, 'id' | 'planId'>[];
shifts?: Omit<Shift, 'id' | 'planId'>[];
}
export interface CreateShiftFromTemplateRequest {
templatePlanId: string;
name: string;
startDate: string;
endDate: string;
description?: string;
}
export interface AssignEmployeeRequest {
employeeId: string;
scheduledShiftId: string;
}
export interface UpdateAvailabilityRequest {
planId: string;
availabilities: Omit<EmployeeAvailability, 'id' | 'employeeId'>[];
}
export interface UpdateRequiredEmployeesRequest {
requiredEmployees: number;
}

View File

@@ -0,0 +1,108 @@
// backend/src/models/defaults/employeeDefaults.ts
import { EmployeeAvailability, ManagerAvailability } from '../Employee.js';
// Default employee data for quick creation
export const EMPLOYEE_DEFAULTS = {
role: 'user' as const,
employeeType: 'experienced' as const,
contractType: 'small' as const,
canWorkAlone: false,
isActive: true
};
// Manager-specific defaults
export const MANAGER_DEFAULTS = {
role: 'admin' as const,
employeeType: 'manager' as const,
contractType: 'large' as const, // Not really used but required by DB
canWorkAlone: true,
isActive: true
};
export const EMPLOYEE_TYPE_CONFIG = {
manager: {
value: 'manager' as const,
label: 'Chef/Administrator',
color: '#e74c3c',
independent: true,
description: 'Vollzugriff auf alle Funktionen und Mitarbeiterverwaltung'
},
experienced: {
value: 'experienced' as const,
label: 'Erfahren',
color: '#3498db',
independent: true,
description: 'Langjährige Erfahrung, kann komplexe Aufgaben übernehmen'
},
trainee: {
value: 'trainee' as const,
label: 'Neuling',
color: '#27ae60',
independent: false,
description: 'Benötigt Einarbeitung und Unterstützung'
}
} as const;
export const ROLE_CONFIG = [
{ value: 'user', label: 'Mitarbeiter', description: 'Kann eigene Schichten einsehen', color: '#27ae60' },
{ value: 'instandhalter', label: 'Instandhalter', description: 'Kann Schichtpläne erstellen und Mitarbeiter verwalten', color: '#3498db' },
{ value: 'admin', label: 'Administrator', description: 'Voller Zugriff auf alle Funktionen', color: '#e74c3c' }
] as const;
// Contract type descriptions
export const CONTRACT_TYPE_DESCRIPTIONS = {
small: '1 Schicht pro Woche',
large: '2 Schichten pro Woche',
manager: 'Kein Vertragslimit - Immer MO und DI verfügbar'
} as const;
// Availability preference descriptions
export const AVAILABILITY_PREFERENCES = {
1: { label: 'Bevorzugt', color: '#10b981', description: 'Möchte diese Schicht arbeiten' },
2: { label: 'Möglich', color: '#f59e0b', description: 'Kann diese Schicht arbeiten' },
3: { label: 'Nicht möglich', color: '#ef4444', description: 'Kann diese Schicht nicht arbeiten' }
} as const;
// Default availability for new employees (all shifts unavailable as level 3)
export function createDefaultAvailabilities(employeeId: string, planId: string, timeSlotIds: string[]): Omit<EmployeeAvailability, 'id'>[] {
const availabilities: Omit<EmployeeAvailability, 'id'>[] = [];
// Monday to Friday (1-5)
for (let day = 1; day <= 5; day++) {
for (const timeSlotId of timeSlotIds) {
availabilities.push({
employeeId,
planId,
dayOfWeek: day,
timeSlotId,
preferenceLevel: 3 // Default to "unavailable" - employees must explicitly set availability
});
}
}
return availabilities;
}
// Create complete manager availability for all days (default: only Mon-Tue available)
export function createManagerDefaultSchedule(managerId: string, planId: string, timeSlotIds: string[]): Omit<ManagerAvailability, 'id'>[] {
const assignments: Omit<ManagerAvailability, 'id'>[] = [];
// Monday to Sunday (1-7)
for (let dayOfWeek = 1; dayOfWeek <= 7; dayOfWeek++) {
for (const timeSlotId of timeSlotIds) {
// Default: available only on Monday (1) and Tuesday (2)
const isAvailable = dayOfWeek === 1 || dayOfWeek === 2;
assignments.push({
employeeId: managerId,
planId,
dayOfWeek,
timeSlotId,
isAvailable,
assignedBy: managerId
});
}
}
return assignments;
}

View File

@@ -0,0 +1,3 @@
// backend/src/models/defaults/index.ts
export * from './employeeDefaults.js';
export * from './shiftPlanDefaults.js';

View File

@@ -0,0 +1,146 @@
// backend/src/models/defaults/shiftPlanDefaults.ts
import { TimeSlot, Shift } from '../ShiftPlan.js';
// Default time slots for ZEBRA (specific workplace)
export const DEFAULT_ZEBRA_TIME_SLOTS: Omit<TimeSlot, 'id' | 'planId'>[] = [
{
name: 'Vormittag',
startTime: '08:00',
endTime: '12:00',
description: 'Vormittagsschicht'
},
{
name: 'Nachmittag',
startTime: '11:30',
endTime: '15:30',
description: 'Nachmittagsschicht'
},
];
// Default time slots for general use
export const DEFAULT_TIME_SLOTS: Omit<TimeSlot, 'id' | 'planId'>[] = [
{
name: 'Vormittag',
startTime: '08:00',
endTime: '12:00',
description: 'Vormittagsschicht'
},
{
name: 'Nachmittag',
startTime: '11:30',
endTime: '15:30',
description: 'Nachmittagsschicht'
},
{
name: 'Abend',
startTime: '14:00',
endTime: '18:00',
description: 'Abendschicht'
},
];
// Default shifts for ZEBRA standard week template with variable required employees
export const DEFAULT_ZEBRA_SHIFTS: Omit<Shift, 'id' | 'planId'>[] = [
// Monday-Thursday: Morning + Afternoon
...Array.from({ length: 4 }, (_, i) => i + 1).flatMap(day => [
{ timeSlotId: 'morning', dayOfWeek: day, requiredEmployees: 2, color: '#3498db' },
{ timeSlotId: 'afternoon', dayOfWeek: day, requiredEmployees: 2, color: '#e74c3c' }
]),
// Friday: Morning only
{ timeSlotId: 'morning', dayOfWeek: 5, requiredEmployees: 2, color: '#3498db' }
];
// Default shifts for general standard week template with variable required employees
export const DEFAULT_SHIFTS: Omit<Shift, 'id' | 'planId'>[] = [
// Monday-Friday: Morning + Afternoon + Evening
...Array.from({ length: 5 }, (_, i) => i + 1).flatMap(day => [
{ timeSlotId: 'morning', dayOfWeek: day, requiredEmployees: 2, color: '#3498db' },
{ timeSlotId: 'afternoon', dayOfWeek: day, requiredEmployees: 2, color: '#e74c3c' },
{ timeSlotId: 'evening', dayOfWeek: day, requiredEmployees: 1, color: '#2ecc71' } // Only 1 for evening
])
];
// Template presets for quick creation
export const TEMPLATE_PRESETS = {
ZEBRA_STANDARD: {
name: 'ZEBRA Standardwoche',
description: 'Standard Vorlage für ZEBRA: Mo-Do Vormittag+Nachmittag, Fr nur Vormittag',
timeSlots: DEFAULT_ZEBRA_TIME_SLOTS,
shifts: DEFAULT_ZEBRA_SHIFTS
},
ZEBRA_MINIMAL: {
name: 'ZEBRA Minimal',
description: 'ZEBRA mit minimaler Besetzung',
timeSlots: DEFAULT_ZEBRA_TIME_SLOTS,
shifts: [
...Array.from({ length: 5 }, (_, i) => i + 1).flatMap(day => [
{ timeSlotId: 'morning', dayOfWeek: day, requiredEmployees: 1, color: '#3498db' },
{ timeSlotId: 'afternoon', dayOfWeek: day, requiredEmployees: 1, color: '#e74c3c' }
])
]
},
ZEBRA_FULL: {
name: 'ZEBRA Vollbesetzung',
description: 'ZEBRA mit voller Besetzung',
timeSlots: DEFAULT_ZEBRA_TIME_SLOTS,
shifts: [
...Array.from({ length: 5 }, (_, i) => i + 1).flatMap(day => [
{ timeSlotId: 'morning', dayOfWeek: day, requiredEmployees: 3, color: '#3498db' },
{ timeSlotId: 'afternoon', dayOfWeek: day, requiredEmployees: 3, color: '#e74c3c' }
])
]
},
GENERAL_STANDARD: {
name: 'Standard Wochenplan',
description: 'Standard Vorlage: Mo-Fr Vormittag+Nachmittag+Abend',
timeSlots: DEFAULT_TIME_SLOTS,
shifts: DEFAULT_SHIFTS
},
ZEBRA_PART_TIME: {
name: 'ZEBRA Teilzeit',
description: 'ZEBRA Vorlage mit reduzierten Schichten',
timeSlots: DEFAULT_ZEBRA_TIME_SLOTS,
shifts: [
// Monday-Thursday: Morning only
...Array.from({ length: 4 }, (_, i) => i + 1).map(day => ({
timeSlotId: 'morning', dayOfWeek: day, requiredEmployees: 1, color: '#3498db'
}))
]
}
} as const;
// Helper function to create plan from preset
export function createPlanFromPreset(
presetName: keyof typeof TEMPLATE_PRESETS,
isTemplate: boolean = true,
startDate?: string,
endDate?: string
) {
const preset = TEMPLATE_PRESETS[presetName];
return {
name: preset.name,
description: preset.description,
startDate,
endDate,
isTemplate,
timeSlots: preset.timeSlots,
shifts: preset.shifts
};
}
// Color schemes for shifts
export const SHIFT_COLORS = {
morning: '#3498db', // Blue
afternoon: '#e74c3c', // Red
evening: '#2ecc71', // Green
night: '#9b59b6', // Purple
default: '#95a5a6' // Gray
} as const;
// Status descriptions
export const PLAN_STATUS_DESCRIPTIONS = {
draft: 'Entwurf - Kann bearbeitet werden',
published: 'Veröffentlicht - Für alle sichtbar',
archived: 'Archiviert - Nur noch lesbar',
template: 'Vorlage - Kann für neue Pläne verwendet werden'
} as const;

View File

@@ -0,0 +1,40 @@
// backend/src/models/helpers/employeeHelpers.ts
import { Employee, CreateEmployeeRequest, EmployeeAvailability } from '../Employee.js';
// Simplified validation - use schema validation instead
export function validateEmployeeData(employee: CreateEmployeeRequest): string[] {
const errors: string[] = [];
if (!employee.email?.includes('@')) {
errors.push('Valid email is required');
}
if (employee.password?.length < 6) {
errors.push('Password must be at least 6 characters long');
}
if (!employee.name?.trim() || employee.name.trim().length < 2) {
errors.push('Name is required and must be at least 2 characters long');
}
return errors;
}
// Simplified business logic helpers
export const isManager = (employee: Employee): boolean =>
employee.employeeType === 'manager';
export const isTrainee = (employee: Employee): boolean =>
employee.employeeType === 'trainee';
export const isExperienced = (employee: Employee): boolean =>
employee.employeeType === 'experienced';
export const isAdmin = (employee: Employee): boolean =>
employee.role === 'admin';
export const canEmployeeWorkAlone = (employee: Employee): boolean =>
employee.canWorkAlone && isExperienced(employee);
export const getEmployeeWorkHours = (employee: Employee): number =>
isManager(employee) ? 999 : (employee.contractType === 'small' ? 1 : 2);

View File

@@ -0,0 +1,3 @@
// backend/src/models/helpers/index.ts
export * from './employeeHelpers.js';
export * from './shiftPlanHelpers.js';

View File

@@ -0,0 +1,118 @@
// backend/src/models/helpers/shiftPlanHelpers.ts
import { ShiftPlan, Shift, ScheduledShift, TimeSlot } from '../ShiftPlan.js';
// Validation helpers
export function validateRequiredEmployees(shift: Shift | ScheduledShift): string[] {
const errors: string[] = [];
if (shift.requiredEmployees < 1) {
errors.push('Required employees must be at least 1');
}
if (shift.requiredEmployees > 10) {
errors.push('Required employees cannot exceed 10');
}
return errors;
}
export function isTemplate(plan: ShiftPlan): boolean {
return plan.isTemplate || plan.status === 'template';
}
export function hasDateRange(plan: ShiftPlan): boolean {
return !isTemplate(plan) && !!plan.startDate && !!plan.endDate;
}
export function validatePlanDates(plan: ShiftPlan): string[] {
const errors: string[] = [];
if (!isTemplate(plan)) {
if (!plan.startDate) errors.push('Start date is required for non-template plans');
if (!plan.endDate) errors.push('End date is required for non-template plans');
if (plan.startDate && plan.endDate && plan.startDate > plan.endDate) {
errors.push('Start date must be before end date');
}
}
return errors;
}
export function validateTimeSlot(timeSlot: { startTime: string; endTime: string }): string[] {
const errors: string[] = [];
if (!timeSlot.startTime || !timeSlot.endTime) {
errors.push('Start time and end time are required');
return errors;
}
const start = new Date(`2000-01-01T${timeSlot.startTime}`);
const end = new Date(`2000-01-01T${timeSlot.endTime}`);
if (start >= end) {
errors.push('Start time must be before end time');
}
return errors;
}
// Type guards
export function isScheduledShift(shift: Shift | ScheduledShift): shift is ScheduledShift {
return 'date' in shift;
}
export function isTemplateShift(shift: Shift | ScheduledShift): shift is Shift {
return 'dayOfWeek' in shift && !('date' in shift);
}
// Business logic helpers
export function getShiftsForDay(plan: ShiftPlan, dayOfWeek: number): Shift[] {
return plan.shifts.filter(shift => shift.dayOfWeek === dayOfWeek);
}
export function getTimeSlotById(plan: ShiftPlan, timeSlotId: string): TimeSlot | undefined {
return plan.timeSlots.find(slot => slot.id === timeSlotId);
}
export function calculateTotalRequiredEmployees(plan: ShiftPlan): number {
return plan.shifts.reduce((total, shift) => total + shift.requiredEmployees, 0);
}
export function getScheduledShiftByDateAndTime(
plan: ShiftPlan,
date: string,
timeSlotId: string
): ScheduledShift | undefined {
return plan.scheduledShifts?.find(shift =>
shift.date === date && shift.timeSlotId === timeSlotId
);
}
export function canPublishPlan(plan: ShiftPlan): { canPublish: boolean; errors: string[] } {
const errors: string[] = [];
if (!hasDateRange(plan)) {
errors.push('Plan must have a date range to be published');
}
if (plan.shifts.length === 0) {
errors.push('Plan must have at least one shift');
}
if (plan.timeSlots.length === 0) {
errors.push('Plan must have at least one time slot');
}
// Validate all shifts
plan.shifts.forEach((shift, index) => {
const shiftErrors = validateRequiredEmployees(shift);
if (shiftErrors.length > 0) {
errors.push(`Shift ${index + 1}: ${shiftErrors.join(', ')}`);
}
});
return {
canPublish: errors.length === 0,
errors
};
}

View File

@@ -1,6 +1,6 @@
// frontend/src/pages/Employees/EmployeeManagement.tsx // frontend/src/pages/Employees/EmployeeManagement.tsx
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Employee } from '../../../../backend/src/models/employee'; import { Employee } from '../../models/Employee';
import { employeeService } from '../../services/employeeService'; import { employeeService } from '../../services/employeeService';
import EmployeeList from './components/EmployeeList'; import EmployeeList from './components/EmployeeList';
import EmployeeForm from './components/EmployeeForm'; import EmployeeForm from './components/EmployeeForm';
@@ -220,7 +220,6 @@ const EmployeeManagement: React.FC = () => {
onEdit={handleEditEmployee} onEdit={handleEditEmployee}
onDelete={handleDeleteEmployee} // Jetzt mit Employee-Objekt onDelete={handleDeleteEmployee} // Jetzt mit Employee-Objekt
onManageAvailability={handleManageAvailability} onManageAvailability={handleManageAvailability}
currentUserRole={hasRole(['admin']) ? 'admin' : 'instandhalter'}
/> />
)} )}

View File

@@ -1,9 +1,9 @@
// frontend/src/pages/Employees/components/AvailabilityManager.tsx // frontend/src/pages/Employees/components/AvailabilityManager.tsx
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Employee, EmployeeAvailability } from '../../../../../backend/src/models/employee';
import { employeeService } from '../../../services/employeeService'; import { employeeService } from '../../../services/employeeService';
import { shiftPlanService } from '../../../services/shiftPlanService'; import { shiftPlanService } from '../../../services/shiftPlanService';
import { ShiftPlan, TimeSlot, Shift } from '../../../../../backend/src/models/shiftPlan'; import { Employee, EmployeeAvailability } from '../../../models/Employee';
import { ShiftPlan, TimeSlot, Shift } from '../../../models/ShiftPlan';
interface AvailabilityManagerProps { interface AvailabilityManagerProps {
employee: Employee; employee: Employee;
@@ -180,11 +180,13 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
// 3. Extract time slots from plans // 3. Extract time slots from plans
let extractedTimeSlots = extractTimeSlotsFromPlans(plans); let extractedTimeSlots = extractTimeSlotsFromPlans(plans);
// 4. Fallback to default slots if none found /* 4. Fallback to default slots if none found
if (extractedTimeSlots.length === 0) { if (extractedTimeSlots.length === 0) {
console.log('⚠️ KEINE ZEIT-SLOTS GEFUNDEN, VERWENDE STANDARD-SLOTS'); console.log('⚠️ KEINE ZEIT-SLOTS GEFUNDEN, VERWENDE STANDARD-SLOTS');
extractedTimeSlots = getDefaultTimeSlots(); extractedTimeSlots = getDefaultTimeSlots();
} }*/
console.log('✅ GEFUNDENE ZEIT-SLOTS:', extractedTimeSlots.length, extractedTimeSlots);
setTimeSlots(extractedTimeSlots); setTimeSlots(extractedTimeSlots);
setShiftPlans(plans); setShiftPlans(plans);

View File

@@ -1,6 +1,7 @@
// frontend/src/pages/Employees/components/EmployeeForm.tsx - KORRIGIERT // frontend/src/pages/Employees/components/EmployeeForm.tsx - KORRIGIERT
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Employee, CreateEmployeeRequest, UpdateEmployeeRequest } from '../../../types/employee'; import { Employee, CreateEmployeeRequest, UpdateEmployeeRequest } from '../../../models/Employee';
import { ROLE_CONFIG, EMPLOYEE_TYPE_CONFIG } from '../../../models/defaults/employeeDefaults';
import { employeeService } from '../../../services/employeeService'; import { employeeService } from '../../../services/employeeService';
import { useAuth } from '../../../contexts/AuthContext'; import { useAuth } from '../../../contexts/AuthContext';
@@ -11,34 +12,6 @@ interface EmployeeFormProps {
onCancel: () => void; onCancel: () => void;
} }
// Rollen Definition
const ROLE_OPTIONS = [
{ value: 'user', label: 'Mitarbeiter', description: 'Kann eigene Schichten einsehen' },
{ value: 'instandhalter', label: 'Instandhalter', description: 'Kann Schichtpläne erstellen und Mitarbeiter verwalten' },
{ value: 'admin', label: 'Administrator', description: 'Voller Zugriff auf alle Funktionen' }
] as const;
// Mitarbeiter Typen Definition
const EMPLOYEE_TYPE_OPTIONS = [
{
value: 'chef',
label: '👨‍💼 Chef/Administrator',
description: 'Vollzugriff auf alle Funktionen und Mitarbeiterverwaltung',
color: '#e74c3c'
},
{
value: 'erfahren',
label: '👴 Erfahren',
description: 'Langjährige Erfahrung, kann komplexe Aufgaben übernehmen',
color: '#3498db'
},
{
value: 'neuling',
label: '👶 Neuling',
description: 'Benötigt Einarbeitung und Unterstützung',
color: '#27ae60'
}
] as const;
const EmployeeForm: React.FC<EmployeeFormProps> = ({ const EmployeeForm: React.FC<EmployeeFormProps> = ({
mode, mode,
@@ -50,9 +23,9 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
name: '', name: '',
email: '', email: '',
password: '', password: '',
role: 'user' as 'admin' | 'instandhalter' | 'user', role: 'user' as 'admin' | 'maintenance' | 'user',
employeeType: 'neuling' as 'chef' | 'neuling' | 'erfahren', employeeType: 'trainee' as 'manager' | 'trainee' | 'experienced',
isSufficientlyIndependent: false, canWorkAlone: false,
isActive: true isActive: true
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -61,22 +34,20 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
useEffect(() => { useEffect(() => {
if (mode === 'edit' && employee) { if (mode === 'edit' && employee) {
console.log('📝 Lade Mitarbeiter-Daten:', employee);
setFormData({ setFormData({
name: employee.name, name: employee.name,
email: employee.email, email: employee.email,
password: '', // Passwort wird beim Bearbeiten nicht angezeigt password: '', // Passwort wird beim Bearbeiten nicht angezeigt
role: employee.role, role: employee.role,
employeeType: employee.employeeType, employeeType: employee.employeeType,
isSufficientlyIndependent: employee.isSufficientlyIndependent, canWorkAlone: employee.canWorkAlone,
isActive: employee.isActive isActive: employee.isActive
}); });
} }
}, [mode, employee]); }, [mode, employee]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value, type } = e.target; const { name, value, type } = e.target;
console.log(`🔄 Feld geändert: ${name} = ${value}`);
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
@@ -84,25 +55,14 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
})); }));
}; };
const handleRoleChange = (roleValue: 'admin' | 'instandhalter' | 'user') => { const handleEmployeeTypeChange = (employeeType: 'manager' | 'trainee' | 'experienced') => {
console.log(`🔄 Rolle geändert: ${roleValue}`); // Manager and experienced can work alone, trainee cannot
setFormData(prev => ({ const canWorkAlone = employeeType === 'manager' || employeeType === 'experienced';
...prev,
role: roleValue
}));
};
const handleEmployeeTypeChange = (employeeType: 'chef' | 'neuling' | 'erfahren') => {
console.log(`🔄 Mitarbeiter-Typ geändert: ${employeeType}`);
// Automatische Werte basierend auf Typ
const isSufficientlyIndependent = employeeType === 'chef' ? true :
employeeType === 'erfahren' ? true : false;
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
employeeType, employeeType,
isSufficientlyIndependent canWorkAlone
})); }));
}; };
@@ -111,8 +71,6 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
setLoading(true); setLoading(true);
setError(''); setError('');
console.log('📤 Sende Formulardaten:', formData);
try { try {
if (mode === 'create') { if (mode === 'create') {
const createData: CreateEmployeeRequest = { const createData: CreateEmployeeRequest = {
@@ -121,52 +79,37 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
password: formData.password, password: formData.password,
role: formData.role, role: formData.role,
employeeType: formData.employeeType, employeeType: formData.employeeType,
isSufficientlyIndependent: formData.isSufficientlyIndependent, contractType: 'small', // Default value
canWorkAlone: formData.canWorkAlone
}; };
console.log(' Erstelle Mitarbeiter:', createData);
await employeeService.createEmployee(createData); await employeeService.createEmployee(createData);
} else if (employee) { } else if (employee) {
const updateData: UpdateEmployeeRequest = { const updateData: UpdateEmployeeRequest = {
name: formData.name.trim(), name: formData.name.trim(),
role: formData.role, role: formData.role,
employeeType: formData.employeeType, employeeType: formData.employeeType,
isSufficientlyIndependent: formData.isSufficientlyIndependent, contractType: employee.contractType, // Keep the existing contract type
canWorkAlone: formData.canWorkAlone,
isActive: formData.isActive, isActive: formData.isActive,
}; };
console.log('✏️ Aktualisiere Mitarbeiter:', updateData);
await employeeService.updateEmployee(employee.id, updateData); await employeeService.updateEmployee(employee.id, updateData);
} }
console.log('✅ Erfolg - rufe onSuccess auf');
onSuccess(); onSuccess();
} catch (err: any) { } catch (err: any) {
console.error('❌ Fehler beim Speichern:', err);
setError(err.message || `Fehler beim ${mode === 'create' ? 'Erstellen' : 'Aktualisieren'} des Mitarbeiters`); setError(err.message || `Fehler beim ${mode === 'create' ? 'Erstellen' : 'Aktualisieren'} des Mitarbeiters`);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const isFormValid = () => { const isFormValid = mode === 'create'
if (mode === 'create') { ? formData.name.trim() && formData.email.trim() && formData.password.length >= 6
return formData.name.trim() && : formData.name.trim() && formData.email.trim();
formData.email.trim() &&
formData.password.length >= 6;
}
return formData.name.trim() && formData.email.trim();
};
const getAvailableRoles = () => { const availableRoles = hasRole(['admin'])
if (hasRole(['admin'])) { ? ROLE_CONFIG
return ROLE_OPTIONS; : ROLE_CONFIG.filter(role => role.value !== 'admin');
}
if (hasRole(['instandhalter'])) {
return ROLE_OPTIONS.filter(role => role.value !== 'admin');
}
return ROLE_OPTIONS.filter(role => role.value === 'user');
};
const availableRoles = getAvailableRoles();
return ( return (
<div style={{ <div style={{
@@ -294,7 +237,7 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
<h3 style={{ margin: '0 0 15px 0', color: '#495057' }}>👥 Mitarbeiter Kategorie</h3> <h3 style={{ margin: '0 0 15px 0', color: '#495057' }}>👥 Mitarbeiter Kategorie</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{EMPLOYEE_TYPE_OPTIONS.map(type => ( {EMPLOYEE_TYPE_CONFIG.map(type => (
<div <div
key={type.value} key={type.value}
style={{ style={{
@@ -352,18 +295,6 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
</div> </div>
))} ))}
</div> </div>
{/* Debug-Anzeige */}
<div style={{
marginTop: '15px',
padding: '10px',
backgroundColor: '#e8f4fd',
border: '1px solid #b6d7e8',
borderRadius: '4px',
fontSize: '12px'
}}>
<strong>Debug:</strong> Ausgewählter Typ: <code>{formData.employeeType}</code>
</div>
</div> </div>
{/* Eigenständigkeit */} {/* Eigenständigkeit */}
@@ -386,29 +317,29 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
}}> }}>
<input <input
type="checkbox" type="checkbox"
name="isSufficientlyIndependent" name="canWorkAlone"
id="isSufficientlyIndependent" id="canWorkAlone"
checked={formData.isSufficientlyIndependent} checked={formData.canWorkAlone}
onChange={handleChange} onChange={handleChange}
disabled={formData.employeeType === 'chef'} disabled={formData.employeeType === 'manager'}
style={{ style={{
width: '20px', width: '20px',
height: '20px', height: '20px',
opacity: formData.employeeType === 'chef' ? 0.5 : 1 opacity: formData.employeeType === 'manager' ? 0.5 : 1
}} }}
/> />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<label htmlFor="isSufficientlyIndependent" style={{ <label htmlFor="canWorkAlone" style={{
fontWeight: 'bold', fontWeight: 'bold',
color: '#2c3e50', color: '#2c3e50',
display: 'block', display: 'block',
opacity: formData.employeeType === 'chef' ? 0.5 : 1 opacity: formData.employeeType === 'manager' ? 0.5 : 1
}}> }}>
Als ausreichend eigenständig markieren Als ausreichend eigenständig markieren
{formData.employeeType === 'chef' && ' (Automatisch für Chefs)'} {formData.employeeType === 'manager' && ' (Automatisch für Chefs)'}
</label> </label>
<div style={{ fontSize: '14px', color: '#7f8c8d' }}> <div style={{ fontSize: '14px', color: '#7f8c8d' }}>
{formData.employeeType === 'chef' {formData.employeeType === 'manager'
? 'Chefs sind automatisch als eigenständig markiert.' ? 'Chefs sind automatisch als eigenständig markiert.'
: 'Dieser Mitarbeiter kann komplexe Aufgaben eigenständig lösen und benötigt keine ständige Betreuung.' : 'Dieser Mitarbeiter kann komplexe Aufgaben eigenständig lösen und benötigt keine ständige Betreuung.'
} }
@@ -416,14 +347,14 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
</div> </div>
<div style={{ <div style={{
padding: '6px 12px', padding: '6px 12px',
backgroundColor: formData.isSufficientlyIndependent ? '#27ae60' : '#e74c3c', backgroundColor: formData.canWorkAlone ? '#27ae60' : '#e74c3c',
color: 'white', color: 'white',
borderRadius: '15px', borderRadius: '15px',
fontSize: '12px', fontSize: '12px',
fontWeight: 'bold', fontWeight: 'bold',
opacity: formData.employeeType === 'chef' ? 0.7 : 1 opacity: formData.employeeType === 'manager' ? 0.7 : 1
}}> }}>
{formData.isSufficientlyIndependent ? 'EIGENSTÄNDIG' : 'BETREUUNG'} {formData.canWorkAlone ? 'EIGENSTÄNDIG' : 'BETREUUNG'}
</div> </div>
</div> </div>
</div> </div>
@@ -451,14 +382,14 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
backgroundColor: formData.role === role.value ? '#fef9e7' : 'white', backgroundColor: formData.role === role.value ? '#fef9e7' : 'white',
cursor: 'pointer' cursor: 'pointer'
}} }}
onClick={() => handleRoleChange(role.value)} onClick={() => setFormData(prev => ({ ...prev, role: role.value }))}
> >
<input <input
type="radio" type="radio"
name="role" name="role"
value={role.value} value={role.value}
checked={formData.role === role.value} checked={formData.role === role.value}
onChange={() => handleRoleChange(role.value)} onChange={() => setFormData(prev => ({ ...prev, role: role.value }))}
style={{ style={{
marginRight: '10px', marginRight: '10px',
marginTop: '2px' marginTop: '2px'
@@ -537,14 +468,14 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
<button <button
type="submit" type="submit"
disabled={loading || !isFormValid()} disabled={loading || !isFormValid}
style={{ style={{
padding: '12px 24px', padding: '12px 24px',
backgroundColor: loading ? '#bdc3c7' : (isFormValid() ? '#27ae60' : '#95a5a6'), backgroundColor: loading ? '#bdc3c7' : (isFormValid ? '#27ae60' : '#95a5a6'),
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '6px', borderRadius: '6px',
cursor: (loading || !isFormValid()) ? 'not-allowed' : 'pointer', cursor: (loading || !isFormValid) ? 'not-allowed' : 'pointer',
fontWeight: 'bold' fontWeight: 'bold'
}} }}
> >

View File

@@ -1,6 +1,7 @@
// frontend/src/pages/Employees/components/EmployeeList.tsx - KORRIGIERT // frontend/src/pages/Employees/components/EmployeeList.tsx - KORRIGIERT
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Employee } from '../../../types/employee'; import { ROLE_CONFIG, EMPLOYEE_TYPE_CONFIG } from '../../../../../backend/src/models/defaults/employeeDefaults';
import { Employee } from '../../../../../backend/src/models/employee';
import { useAuth } from '../../../contexts/AuthContext'; import { useAuth } from '../../../contexts/AuthContext';
interface EmployeeListProps { interface EmployeeListProps {
@@ -8,26 +9,22 @@ interface EmployeeListProps {
onEdit: (employee: Employee) => void; onEdit: (employee: Employee) => void;
onDelete: (employee: Employee) => void; onDelete: (employee: Employee) => void;
onManageAvailability: (employee: Employee) => void; onManageAvailability: (employee: Employee) => void;
currentUserRole: 'admin' | 'instandhalter';
} }
const EmployeeList: React.FC<EmployeeListProps> = ({ const EmployeeList: React.FC<EmployeeListProps> = ({
employees, employees,
onEdit, onEdit,
onDelete, onDelete,
onManageAvailability, onManageAvailability
currentUserRole
}) => { }) => {
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('active'); const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('active');
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const { user: currentUser } = useAuth(); const { user: currentUser, hasRole } = useAuth();
const filteredEmployees = employees.filter(employee => { const filteredEmployees = employees.filter(employee => {
// Status-Filter
if (filter === 'active' && !employee.isActive) return false; if (filter === 'active' && !employee.isActive) return false;
if (filter === 'inactive' && employee.isActive) return false; if (filter === 'inactive' && employee.isActive) return false;
// Suchfilter
if (searchTerm) { if (searchTerm) {
const term = searchTerm.toLowerCase(); const term = searchTerm.toLowerCase();
return ( return (
@@ -41,28 +38,25 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
return true; return true;
}); });
const getRoleBadgeColor = (role: string) => { // Simplified permission checks
switch (role) { const canDeleteEmployee = (employee: Employee): boolean => {
case 'admin': return '#e74c3c'; if (!hasRole(['admin'])) return false;
case 'instandhalter': return '#3498db'; if (employee.id === currentUser?.id) return false;
case 'user': return '#27ae60'; if (employee.role === 'admin' && !hasRole(['admin'])) return false;
default: return '#95a5a6'; return true;
}
}; };
const getEmployeeTypeBadge = (type: string) => { const canEditEmployee = (employee: Employee): boolean => {
switch (type) { if (hasRole(['admin'])) return true;
case 'chef': return { text: '👨‍💼 CHEF', color: '#e74c3c', bgColor: '#fadbd8' }; if (hasRole(['maintenance'])) {
case 'erfahren': return { text: '👴 ERFAHREN', color: '#3498db', bgColor: '#d6eaf8' }; return employee.role === 'user' || employee.id === currentUser?.id;
case 'neuling': return { text: '👶 NEULING', color: '#27ae60', bgColor: '#d5f4e6' };
default: return { text: 'UNBEKANNT', color: '#95a5a6', bgColor: '#ecf0f1' };
} }
return false;
}; };
const getIndependenceBadge = (isIndependent: boolean) => { // Using shared configuration for consistent styling
return isIndependent const getEmployeeTypeBadge = (type: keyof typeof EMPLOYEE_TYPE_CONFIG) => {
? { text: '✅ Eigenständig', color: '#27ae60', bgColor: '#d5f4e6' } return EMPLOYEE_TYPE_CONFIG[type] || EMPLOYEE_TYPE_CONFIG.trainee;
: { text: '❌ Betreuung', color: '#e74c3c', bgColor: '#fadbd8' };
}; };
const getStatusBadge = (isActive: boolean) => { const getStatusBadge = (isActive: boolean) => {
@@ -71,31 +65,10 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
: { text: 'Inaktiv', color: '#e74c3c', bgColor: '#fadbd8' }; : { text: 'Inaktiv', color: '#e74c3c', bgColor: '#fadbd8' };
}; };
// Kann Benutzer löschen? const getIndependenceBadge = (canWorkAlone: boolean) => {
const canDeleteEmployee = (employee: Employee): boolean => { return canWorkAlone
// Nur Admins können löschen ? { text: '✅ Eigenständig', color: '#27ae60', bgColor: '#d5f4e6' }
if (currentUserRole !== 'admin') return false; : { text: '❌ Betreuung', color: '#e74c3c', bgColor: '#fadbd8' };
// Kann sich nicht selbst löschen
if (employee.id === currentUser?.id) return false;
// Admins können nur von Admins gelöscht werden
if (employee.role === 'admin' && currentUserRole !== 'admin') return false;
return true;
};
// Kann Benutzer bearbeiten?
const canEditEmployee = (employee: Employee): boolean => {
// Admins können alle bearbeiten
if (currentUserRole === 'admin') return true;
// Instandhalter können nur User und sich selbst bearbeiten
if (currentUserRole === 'instandhalter') {
return employee.role === 'user' || employee.id === currentUser?.id;
}
return false;
}; };
if (employees.length === 0) { if (employees.length === 0) {
@@ -197,8 +170,8 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
{filteredEmployees.map(employee => { {filteredEmployees.map(employee => {
const employeeType = getEmployeeTypeBadge(employee.employeeType); const employeeType = getEmployeeTypeBadge(employee.employeeType);
const independence = getIndependenceBadge(employee.isSufficientlyIndependent); const independence = getIndependenceBadge(employee.canWorkAlone);
const roleColor = getRoleBadgeColor(employee.role); const roleColor = '#d5f4e6'; // Default color
const status = getStatusBadge(employee.isActive); const status = getStatusBadge(employee.isActive);
const canEdit = canEditEmployee(employee); const canEdit = canEditEmployee(employee);
const canDelete = canDeleteEmployee(employee); const canDelete = canDeleteEmployee(employee);
@@ -239,7 +212,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<span <span
style={{ style={{
backgroundColor: employeeType.bgColor, backgroundColor: employeeType.color,
color: employeeType.color, color: employeeType.color,
padding: '6px 12px', padding: '6px 12px',
borderRadius: '15px', borderRadius: '15px',
@@ -248,7 +221,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
display: 'inline-block' display: 'inline-block'
}} }}
> >
{employeeType.text} {employeeType.label}
</span> </span>
</div> </div>
@@ -284,7 +257,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
}} }}
> >
{employee.role === 'admin' ? 'ADMIN' : {employee.role === 'admin' ? 'ADMIN' :
employee.role === 'instandhalter' ? 'INSTANDHALTER' : 'MITARBEITER'} employee.role === 'maintenance' ? 'INSTANDHALTER' : 'MITARBEITER'}
</span> </span>
</div> </div>
@@ -322,7 +295,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
flexWrap: 'wrap' flexWrap: 'wrap'
}}> }}>
{/* Verfügbarkeit Button */} {/* Verfügbarkeit Button */}
{(currentUserRole === 'admin' || currentUserRole === 'instandhalter') && ( {(employee.role === 'admin' || employee.role === 'maintenance') && (
<button <button
onClick={() => onManageAvailability(employee)} onClick={() => onManageAvailability(employee)}
style={{ style={{
@@ -385,7 +358,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
)} )}
{/* Platzhalter für Symmetrie */} {/* Platzhalter für Symmetrie */}
{!canEdit && !canDelete && (currentUserRole !== 'admin' && currentUserRole !== 'instandhalter') && ( {!canEdit && !canDelete && (employee.role !== 'admin' && employee.role !== 'maintenance') && (
<div style={{ width: '32px', height: '32px' }}></div> <div style={{ width: '32px', height: '32px' }}></div>
)} )}
</div> </div>

View File

@@ -3,9 +3,14 @@ import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { shiftTemplateService } from '../../services/shiftTemplateService'; import { shiftTemplateService } from '../../services/shiftTemplateService';
import { shiftPlanService } from '../../services/shiftPlanService'; import { shiftPlanService } from '../../services/shiftPlanService';
import { TemplateShift } from '../../types/shiftTemplate';
import styles from './ShiftPlanCreate.module.css'; import styles from './ShiftPlanCreate.module.css';
export interface TemplateShift {
id: string;
name: string;
isDefault?: boolean;
}
const ShiftPlanCreate: React.FC = () => { const ShiftPlanCreate: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();

View File

@@ -1,8 +1,10 @@
// frontend/src/pages/ShiftPlans/ShiftPlanEdit.tsx // frontend/src/pages/ShiftPlans/ShiftPlanEdit.tsx
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { shiftPlanService, ShiftPlan, ShiftPlanShift } from '../../services/shiftPlanService'; import { shiftPlanService } from '../../services/shiftPlanService';
import { ShiftPlan, Shift } from '../../../../backend/src/models/shiftPlan';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import { getTimeSlotById } from '../../models/helpers/shiftPlanHelpers';
const ShiftPlanEdit: React.FC = () => { const ShiftPlanEdit: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -10,12 +12,10 @@ const ShiftPlanEdit: React.FC = () => {
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const [shiftPlan, setShiftPlan] = useState<ShiftPlan | null>(null); const [shiftPlan, setShiftPlan] = useState<ShiftPlan | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editingShift, setEditingShift] = useState<ShiftPlanShift | null>(null); const [editingShift, setEditingShift] = useState<Shift | null>(null);
const [newShift, setNewShift] = useState<Partial<ShiftPlanShift>>({ const [newShift, setNewShift] = useState<Partial<ScheduledShift>>({
date: '', date: '',
name: '', timeSlotId: '',
startTime: '',
endTime: '',
requiredEmployees: 1 requiredEmployees: 1
}); });
@@ -41,16 +41,10 @@ const ShiftPlanEdit: React.FC = () => {
} }
}; };
const handleUpdateShift = async (shift: ShiftPlanShift) => { const handleUpdateShift = async (shift: Shift) => {
if (!shiftPlan || !id) return; if (!shiftPlan || !id) return;
try { try {
await shiftPlanService.updateShiftPlanShift(id, shift);
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Schicht wurde aktualisiert.'
});
loadShiftPlan(); loadShiftPlan();
setEditingShift(null); setEditingShift(null);
} catch (error) { } catch (error) {
@@ -66,23 +60,16 @@ const ShiftPlanEdit: React.FC = () => {
const handleAddShift = async () => { const handleAddShift = async () => {
if (!shiftPlan || !id) return; if (!shiftPlan || !id) return;
if (!newShift.date || !newShift.name || !newShift.startTime || !newShift.endTime || !newShift.requiredEmployees) { if (!getTimeSlotById(shiftPlan, newShift.timeSlotId?) || !newShift.name || !newShift.startTime || !newShift.endTime || !newShift.requiredEmployees) {
showNotification({ showNotification({
type: 'error', type: 'error',
title: 'Fehler', title: 'Fehler',
message: 'Bitte füllen Sie alle Pflichtfelder aus.' message: 'Bitte füllen Sie alle Pflichtfelder aus.'
}); });
return; return;
} }
try { try {
await shiftPlanService.addShiftPlanShift(id, {
date: newShift.date,
name: newShift.name,
startTime: newShift.startTime,
endTime: newShift.endTime,
requiredEmployees: Number(newShift.requiredEmployees)
});
showNotification({ showNotification({
type: 'success', type: 'success',
title: 'Erfolg', title: 'Erfolg',
@@ -112,12 +99,6 @@ const ShiftPlanEdit: React.FC = () => {
} }
try { try {
await shiftPlanService.deleteShiftPlanShift(id!, shiftId);
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Schicht wurde gelöscht.'
});
loadShiftPlan(); loadShiftPlan();
} catch (error) { } catch (error) {
console.error('Error deleting shift:', error); console.error('Error deleting shift:', error);

View File

@@ -2,8 +2,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { shiftPlanService, ShiftPlan } from '../../services/shiftPlanService'; import { shiftPlanService } from '../../services/shiftPlanService';
import { ShiftPlan } from '../../../../backend/src/models/shiftPlan';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import { formatDate } from '../../utils/foramatters';
const ShiftPlanList: React.FC = () => { const ShiftPlanList: React.FC = () => {
const { hasRole } = useAuth(); const { hasRole } = useAuth();
@@ -55,14 +57,6 @@ const ShiftPlanList: React.FC = () => {
} }
}; };
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
};
if (loading) { if (loading) {
return <div>Lade Schichtpläne...</div>; return <div>Lade Schichtpläne...</div>;
} }

View File

@@ -3,8 +3,10 @@ import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { shiftPlanService } from '../../services/shiftPlanService'; import { shiftPlanService } from '../../services/shiftPlanService';
import { ShiftPlan, Shift, TimeSlot } from '../../../../backend/src/models/shiftPlan.js'; import { getTimeSlotById } from '../../models/helpers/shiftPlanHelpers';
import { ShiftPlan, TimeSlot } from '../../models/ShiftPlan';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import { formatDate, formatTime } from '../../utils/foramatters';
const ShiftPlanView: React.FC = () => { const ShiftPlanView: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -36,94 +38,44 @@ const ShiftPlanView: React.FC = () => {
} }
}; };
const formatDate = (dateString: string | undefined): string => { // Simplified timetable data generation
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 = () => { const getTimetableData = () => {
if (!shiftPlan) return { shifts: [], weekdays: [] }; if (!shiftPlan) return { shifts: [], weekdays: [] };
// Get all unique shift types (name + time combination) // Use timeSlots directly since shifts reference them
const shiftTypes = Array.from(new Set( const timetableShifts = shiftPlan.timeSlots.map(timeSlot => {
shiftPlan.shifts.map(shift =>
`${shift.timeSlot.name}|${shift.timeSlot.startTime}|${shift.timeSlot.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> = {}; const weekdayData: Record<number, string> = {};
weekdays.forEach(weekday => { weekdays.forEach(weekday => {
// Find all shifts of this type on this weekday const shiftsOnDay = shiftPlan.shifts.filter(shift =>
const shiftsOnDay = shiftPlan.shifts.filter(shift => { shift.dayOfWeek === weekday.id &&
const date = new Date(shift.date); shift.timeSlotId === timeSlot.id
const dayOfWeek = date.getDay() === 0 ? 7 : date.getDay(); // Convert to 1-7 (Mon-Sun) );
return dayOfWeek === weekday &&
shift.timeSlot.name === shiftType.name &&
shift.timeSlot.startTime === shiftType.startTime &&
shift.timeSlot.endTime === shiftType.endTime;
});
if (shiftsOnDay.length === 0) { if (shiftsOnDay.length === 0) {
weekdayData[weekday] = ''; weekdayData[weekday.id] = '';
} else { } else {
const totalAssigned = shiftsOnDay.reduce((sum, shift) => sum + shift.timeSlot.assignedEmployees.length, 0); const totalRequired = shiftsOnDay.reduce((sum, shift) =>
const totalRequired = shiftsOnDay.reduce((sum, shift) => sum + shift.requiredEmployees, 0); sum + shift.requiredEmployees, 0);
weekdayData[weekday] = `${totalAssigned}/${totalRequired}`; // For now, show required count since we don't have assigned employees in Shift
weekdayData[weekday.id] = `0/${totalRequired}`;
} }
}); });
return { return {
...shiftType, ...timeSlot,
displayName: `${shiftType.name} (${formatTime(shiftType.startTime)}${formatTime(shiftType.endTime)})`, displayName: `${timeSlot.name} (${formatTime(timeSlot.startTime)}${formatTime(timeSlot.endTime)})`,
weekdayData weekdayData
}; };
}); });
return { return { shifts: timetableShifts, weekdays };
shifts: timetableShifts,
weekdays: weekdays.map(day => ({
id: day,
name: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][day === 7 ? 0 : day]
}))
};
}; };
if (loading) { if (loading) return <div>Lade Schichtplan...</div>;
return <div>Lade Schichtplan...</div>; if (!shiftPlan) return <div>Schichtplan nicht gefunden</div>;
}
if (!shiftPlan) {
return <div>Schichtplan nicht gefunden</div>;
}
const timetableData = getTimetableData(); const timetableData = getTimetableData();
return ( return (
<div style={{ padding: '20px' }}> <div style={{ padding: '20px' }}>
<div style={{ <div style={{

View File

@@ -1,97 +0,0 @@
.editorContainer {
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.title {
font-size: 24px;
color: #2c3e50;
margin: 0;
}
.buttons {
display: flex;
gap: 10px;
}
.previewButton {
padding: 8px 16px;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.previewButton:hover {
background-color: #2980b9;
}
.saveButton {
padding: 8px 16px;
background-color: #2ecc71;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.saveButton:hover {
background-color: #27ae60;
}
.formGroup {
margin-bottom: 20px;
}
.formGroup label {
display: block;
margin-bottom: 8px;
color: #34495e;
font-weight: 500;
}
.formGroup input[type="text"],
.formGroup textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #bdc3c7;
border-radius: 4px;
font-size: 14px;
}
.formGroup textarea {
min-height: 100px;
resize: vertical;
}
.defaultCheckbox {
margin-top: 15px;
display: flex;
align-items: center;
gap: 8px;
}
.defaultCheckbox input[type="checkbox"] {
width: 16px;
height: 16px;
}
.defaultCheckbox label {
margin: 0;
font-size: 14px;
}
.previewContainer {
margin-top: 30px;
border-top: 1px solid #ddd;
padding-top: 20px;
}

View File

@@ -1,206 +0,0 @@
// frontend/src/pages/ShiftTemplates/ShiftTemplateEditor.tsx
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { TemplateShiftSlot, TemplateShift, TemplateShiftTimeSlot, 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<TemplateShiftSlot, 'id'> {
id?: string;
isPreview?: boolean;
}
const defaultShift: ExtendedTemplateShift = {
dayOfWeek: 1, // Montag
timeSlot: { id: '', name: '', startTime: '', endTime: '' },
requiredEmployees: 1,
color: '#3498db'
};
const ShiftTemplateEditor: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isEditing = !!id;
const [template, setTemplate] = useState<Omit<TemplateShift, 'id' | 'createdAt' | 'createdBy'>>({
name: '',
description: '',
shifts: [],
isDefault: false
});
const [loading, setLoading] = useState(isEditing);
const [saving, setSaving] = useState(false);
const [showPreview, setShowPreview] = useState(false);
useEffect(() => {
if (isEditing) {
loadTemplate();
}
}, [id]);
const loadTemplate = async () => {
try {
if (!id) return;
const data = await shiftTemplateService.getTemplate(id);
setTemplate({
name: data.name,
description: data.description,
shifts: data.shifts,
isDefault: data.isDefault
});
} catch (error) {
console.error('Fehler beim Laden:', error);
alert('Vorlage konnte nicht geladen werden');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!template.name.trim()) {
alert('Bitte geben Sie einen Namen für die Vorlage ein');
return;
}
setSaving(true);
try {
if (isEditing && id) {
await shiftTemplateService.updateTemplate(id, template);
} else {
await shiftTemplateService.createTemplate(template);
}
navigate('/shift-templates');
} catch (error) {
console.error('Speichern fehlgeschlagen:', error);
alert('Fehler beim Speichern der Vorlage');
} finally {
setSaving(false);
}
};
const addShift = (dayOfWeek: number) => {
const newShift: TemplateShiftSlot = {
...defaultShift,
id: Date.now().toString(),
dayOfWeek,
timeSlot: { ...defaultShift.timeSlot, id: Date.now().toString() },
requiredEmployees: defaultShift.requiredEmployees,
color: defaultShift.color
};
setTemplate(prev => ({
...prev,
shifts: [...prev.shifts, newShift]
}));
};
const updateShift = (shiftId: string, updates: Partial<TemplateShift>) => {
setTemplate(prev => ({
...prev,
shifts: prev.shifts.map(shift =>
shift.id === shiftId ? { ...shift, ...updates } : shift
)
}));
};
const removeShift = (shiftId: string) => {
setTemplate(prev => ({
...prev,
shifts: prev.shifts.filter(shift => shift.id !== shiftId)
}));
};
// Preview-Daten für die DefaultTemplateView vorbereiten
const previewTemplate: TemplateShift = {
id: 'preview',
name: template.name || 'Vorschau',
description: template.description,
shifts: template.shifts.map(shift => ({
...shift,
id: shift.id || 'preview-' + Date.now()
})),
createdBy: 'preview',
createdAt: new Date().toISOString(),
isDefault: template.isDefault
};
if (loading) return <div>Lade Vorlage...</div>;
return (
<div className={styles.editorContainer}>
<div className={styles.header}>
<h1 className={styles.title}>{isEditing ? 'Vorlage bearbeiten' : 'Neue Vorlage erstellen'}</h1>
<div className={styles.buttons}>
<button
className={styles.previewButton}
onClick={() => setShowPreview(!showPreview)}
>
{showPreview ? 'Editor anzeigen' : 'Vorschau'}
</button>
<button
className={styles.saveButton}
onClick={handleSave}
disabled={saving}
>
{saving ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
{showPreview ? (
<DefaultTemplateView template={previewTemplate} />
) : (
<>
<div className={styles.formGroup}>
<label>Vorlagenname *</label>
<input
type="text"
value={template.name}
onChange={(e) => setTemplate(prev => ({ ...prev, name: e.target.value }))}
placeholder="z.B. Standard Woche, Teilzeit Modell, etc."
/>
</div>
<div className={styles.formGroup}>
<label>Beschreibung</label>
<textarea
value={template.description || ''}
onChange={(e) => setTemplate(prev => ({ ...prev, description: e.target.value }))}
placeholder="Beschreibung der Vorlage (optional)"
/>
</div>
<div className={styles.defaultCheckbox}>
<input
type="checkbox"
id="isDefault"
checked={template.isDefault}
onChange={(e) => setTemplate(prev => ({ ...prev, isDefault: e.target.checked }))}
/>
<label htmlFor="isDefault">Als Standardvorlage festlegen</label>
</div>
<div style={{ marginTop: '30px' }}>
<h2>Schichten pro Wochentag</h2>
<div style={{ display: 'grid', gap: '20px', marginTop: '20px' }}>
{DEFAULT_DAYS.map(day => (
<ShiftDayEditor
key={day.id}
day={day}
shifts={template.shifts.filter(s => s.dayOfWeek === day.id)}
onAddShift={() => addShift(day.id)}
onUpdateShift={updateShift}
onRemoveShift={removeShift}
/>
))}
</div>
</div>
</>
)}
</div>
);
};
export default ShiftTemplateEditor;

View File

@@ -1,101 +0,0 @@
.templateList {
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.createButton {
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.createButton:hover {
background-color: #0056b3;
}
.templateGrid {
display: grid;
gap: 15px;
}
.templateCard {
border: 1px solid #ddd;
padding: 15px;
border-radius: 8px;
background-color: #f9f9f9;
}
.templateHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.templateInfo h3 {
margin: 0 0 5px 0;
}
.templateInfo p {
margin: 0 0 10px 0;
color: #666;
}
.templateMeta {
font-size: 14px;
color: #888;
}
.defaultBadge {
color: green;
margin-left: 10px;
}
.actionButtons {
display: flex;
gap: 10px;
}
.viewButton {
padding: 5px 10px;
border: 1px solid #007bff;
color: #007bff;
background: white;
cursor: pointer;
}
.useButton {
padding: 5px 10px;
background-color: #28a745;
color: white;
border: none;
cursor: pointer;
}
.deleteButton {
padding: 5px 10px;
background-color: #dc3545;
color: white;
border: none;
cursor: pointer;
}
.viewButton:hover {
background-color: #e6f0ff;
}
.useButton:hover {
background-color: #218838;
}
.deleteButton:hover {
background-color: #c82333;
}

View File

@@ -1,136 +0,0 @@
// frontend/src/pages/ShiftTemplates/ShiftTemplateList.tsx
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
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<TemplateShift[]>([]);
const [loading, setLoading] = useState(true);
const { hasRole } = useAuth();
const [selectedTemplate, setSelectedTemplate] = useState<TemplateShift | null>(null);
useEffect(() => {
loadTemplates();
}, []);
const loadTemplates = async () => {
try {
const data = await shiftTemplateService.getTemplates();
setTemplates(data);
// Setze die Standard-Vorlage als ausgewählt
const defaultTemplate = data.find(t => t.isDefault);
if (defaultTemplate) {
setSelectedTemplate(defaultTemplate);
}
} catch (error) {
console.error('Fehler:', error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('Vorlage wirklich löschen?')) return;
try {
await shiftTemplateService.deleteTemplate(id);
setTemplates(templates.filter(t => t.id !== id));
if (selectedTemplate?.id === id) {
setSelectedTemplate(null);
}
} catch (error) {
console.error('Löschen fehlgeschlagen:', error);
}
};
if (loading) return <div>Lade Vorlagen...</div>;
return (
<div className={styles.templateList}>
<div className={styles.header}>
<h1>Schichtplan Vorlagen</h1>
{hasRole(['admin', 'instandhalter']) && (
<Link to="/shift-templates/new">
<button className={styles.createButton}>
Neue Vorlage
</button>
</Link>
)}
</div>
<div className={styles.templateGrid}>
{templates.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#666' }}>
<p>Noch keine Vorlagen vorhanden.</p>
{hasRole(['admin', 'instandhalter']) && (
<Link to="/shift-templates/new">
<button className={styles.createButton}>
Erste Vorlage erstellen
</button>
</Link>
)}
</div>
) : (
templates.map(template => (
<div key={template.id} className={styles.templateCard}>
<div className={styles.templateHeader}>
<div className={styles.templateInfo}>
<h3>{template.name}</h3>
{template.description && (
<p>{template.description}</p>
)}
<div className={styles.templateMeta}>
{template.shifts.length} Schichttypen Erstellt am {new Date(template.createdAt).toLocaleDateString('de-DE')}
{template.isDefault && <span className={styles.defaultBadge}> Standard</span>}
</div>
</div>
<div className={styles.actionButtons}>
<button
className={styles.viewButton}
onClick={() => setSelectedTemplate(template)}
>
Vorschau
</button>
{hasRole(['admin', 'instandhalter']) && (
<>
<Link to={`/shift-templates/${template.id}`}>
<button className={styles.viewButton}>
Bearbeiten
</button>
</Link>
<Link to={`/shift-plans/new?template=${template.id}`}>
<button className={styles.useButton}>
Verwenden
</button>
</Link>
<button
onClick={() => handleDelete(template.id)}
className={styles.deleteButton}
>
Löschen
</button>
</>
)}
</div>
</div>
</div>
))
)}
</div>
{selectedTemplate && (
<div style={{ marginTop: '30px' }}>
<DefaultTemplateView template={selectedTemplate} />
</div>
)}
</div>
);
};
export default ShiftTemplateList;

View File

@@ -1,48 +0,0 @@
.defaultTemplateView {
padding: 20px;
}
.weekView {
display: flex;
gap: 20px;
margin-top: 20px;
overflow-x: auto;
}
.dayColumn {
min-width: 200px;
background: #f5f6fa;
padding: 15px;
border-radius: 8px;
}
.dayColumn h3 {
margin: 0 0 15px 0;
color: #2c3e50;
text-align: center;
}
.shiftsContainer {
display: flex;
flex-direction: column;
gap: 10px;
}
.shiftCard {
background: white;
padding: 12px;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.shiftCard h4 {
margin: 0 0 8px 0;
color: #34495e;
font-size: 0.9em;
}
.shiftCard p {
margin: 0;
color: #7f8c8d;
font-size: 0.85em;
}

View File

@@ -1,55 +0,0 @@
// frontend/src/pages/ShiftTemplates/components/DefaultTemplateView.tsx
import React from 'react';
import { TemplateShift } from '../../../types/shiftTemplate';
import styles from './DefaultTemplateView.module.css';
interface DefaultTemplateViewProps {
template: TemplateShift;
}
const DefaultTemplateView: React.FC<DefaultTemplateViewProps> = ({ template }) => {
// Gruppiere Schichten nach Wochentag
const shiftsByDay = template.shifts.reduce((acc, shift) => {
const day = shift.dayOfWeek;
if (!acc[day]) {
acc[day] = [];
}
acc[day].push(shift);
return acc;
}, {} as Record<number, typeof template.shifts>);
// Funktion zum Formatieren der Zeit
const formatTime = (time: string) => {
return time.substring(0, 5); // Zeigt nur HH:MM
};
// Wochentagsnamen
const dayNames = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
return (
<div className={styles.defaultTemplateView}>
<h2>{template.name}</h2>
{template.description && <p>{template.description}</p>}
<div className={styles.weekView}>
{[1, 2, 3, 4, 5].map(dayIndex => (
<div key={dayIndex} className={styles.dayColumn}>
<h3>{dayNames[dayIndex]}</h3>
<div className={styles.shiftsContainer}>
{shiftsByDay[dayIndex]?.map(shift => (
<div key={shift.id} className={styles.shiftCard}>
<h4>{shift.timeSlot.name}</h4>
<p>
{formatTime(shift.timeSlot.startTime)} - {formatTime(shift.timeSlot.endTime)}
</p>
</div>
))}
</div>
</div>
))}
</div>
</div>
);
};
export default DefaultTemplateView;

View File

@@ -1,137 +0,0 @@
.dayEditor {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.dayHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.dayName {
font-size: 18px;
color: #2c3e50;
margin: 0;
}
.addButton {
padding: 6px 12px;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.addButton:hover {
background-color: #2980b9;
}
.addButton svg {
width: 16px;
height: 16px;
}
.shiftsGrid {
display: grid;
gap: 15px;
}
.shiftCard {
background: white;
padding: 15px;
border-radius: 6px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.shiftHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.shiftTitle {
color: #2c3e50;
font-size: 16px;
font-weight: 500;
margin: 0;
}
.deleteButton {
padding: 4px 8px;
background-color: #e74c3c;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.deleteButton:hover {
background-color: #c0392b;
}
.formGroup {
margin-bottom: 12px;
}
.formGroup label {
display: block;
margin-bottom: 4px;
color: #34495e;
font-size: 14px;
}
.formGroup input {
width: 100%;
padding: 6px 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.formGroup input[type="time"] {
width: auto;
}
.timeInputs {
display: flex;
gap: 15px;
align-items: center;
}
.colorPicker {
width: 100px;
}
.requiredEmployees {
display: flex;
gap: 10px;
align-items: center;
}
.requiredEmployees input {
width: 60px;
text-align: center;
}
.requiredEmployees button {
padding: 4px 8px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.requiredEmployees button:hover {
background: #f5f6fa;
}

View File

@@ -1,125 +0,0 @@
// frontend/src/pages/ShiftTemplates/components/ShiftDayEditor.tsx
import React from 'react';
import { TemplateShiftSlot } from '../../../types/shiftTemplate';
import styles from './ShiftDayEditor.module.css';
interface ShiftDayEditorProps {
day: { id: number; name: string };
shifts: TemplateShiftSlot[];
onAddShift: () => void;
onUpdateShift: (shiftId: string, updates: Partial<TemplateShiftSlot>) => void;
onRemoveShift: (shiftId: string) => void;
}
const ShiftDayEditor: React.FC<ShiftDayEditorProps> = ({
day,
shifts,
onAddShift,
onUpdateShift,
onRemoveShift
}) => {
return (
<div className={styles.dayEditor}>
<div className={styles.dayHeader}>
<h3 className={styles.dayName}>{day.name}</h3>
<button className={styles.addButton} onClick={onAddShift}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="16" height="16">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
</svg>
Schicht hinzufügen
</button>
</div>
{shifts.length === 0 ? (
<div style={{ textAlign: 'center', padding: '20px', color: '#999', fontStyle: 'italic' }}>
Keine Schichten für {day.name}
</div>
) : (
<div className={styles.shiftsGrid}>
{shifts.map(shift => (
<div key={shift.id} className={styles.shiftCard}>
<div className={styles.shiftHeader}>
<h4 className={styles.shiftTitle}>Schicht bearbeiten</h4>
<button
className={styles.deleteButton}
onClick={() => onRemoveShift(shift.id)}
title="Schicht löschen"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="16" height="16">
<path fillRule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
Löschen
</button>
</div>
<div className={styles.formGroup}>
<input
type="text"
value={shift.timeSlot.name}
onChange={(e) => onUpdateShift(shift.id, { timeSlot: { ...shift.timeSlot, name: e.target.value } })}
placeholder="Schichtname"
/>
</div>
<div className={styles.timeInputs}>
<div className={styles.formGroup}>
<label>Start</label>
<input
type="time"
value={shift.timeSlot.startTime}
onChange={(e) => onUpdateShift(shift.id, { timeSlot: { ...shift.timeSlot, startTime: e.target.value } })}
/>
</div>
<div className={styles.formGroup}>
<label>Ende</label>
<input
type="time"
value={shift.timeSlot.endTime}
onChange={(e) => onUpdateShift(shift.id, { timeSlot: { ...shift.timeSlot, endTime: e.target.value } })}
/>
</div>
</div>
<div className={styles.formGroup}>
<label>Benötigte Mitarbeiter</label>
<div className={styles.requiredEmployees}>
<button
onClick={() => onUpdateShift(shift.id, { requiredEmployees: Math.max(1, shift.requiredEmployees - 1) })}
>
-
</button>
<input
type="number"
min="1"
value={shift.requiredEmployees}
onChange={(e) => onUpdateShift(shift.id, { requiredEmployees: parseInt(e.target.value) || 1 })}
/>
<button
onClick={() => onUpdateShift(shift.id, { requiredEmployees: shift.requiredEmployees + 1 })}
>
+
</button>
</div>
</div>
{shift.color && (
<div className={styles.formGroup}>
<label>Farbe</label>
<input
type="color"
value={shift.color}
onChange={(e) => onUpdateShift(shift.id, { color: e.target.value })}
className={styles.colorPicker}
/>
</div>
)}
</div>
))}
</div>
)}
</div>
);
};
export default ShiftDayEditor;

View File

@@ -1,4 +1,5 @@
// frontend/src/services/authService.ts // frontend/src/services/authService.ts
import { Employee } from '../../../backend/src/models/employee';
const API_BASE = 'http://localhost:3002/api'; const API_BASE = 'http://localhost:3002/api';
export interface LoginRequest { export interface LoginRequest {
@@ -11,28 +12,14 @@ export interface RegisterRequest {
password: string; password: string;
name: string; name: string;
role?: string; role?: string;
phone?: string;
department?: string;
} }
export interface AuthResponse { export interface AuthResponse {
user: User; employee: Employee;
token: string; token: string;
expiresIn: string; expiresIn: string;
} }
export interface User {
id: string;
email: string;
name: string;
role: 'admin' | 'instandhalter' | 'user';
createdAt: string;
lastLogin?: string;
phone?: string;
department?: string;
isActive?: boolean;
}
class AuthService { class AuthService {
private token: string | null = null; private token: string | null = null;
@@ -51,12 +38,11 @@ class AuthService {
const data: AuthResponse = await response.json(); const data: AuthResponse = await response.json();
this.token = data.token; this.token = data.token;
localStorage.setItem('token', data.token); localStorage.setItem('token', data.token);
localStorage.setItem('user', JSON.stringify(data.user)); localStorage.setItem('employee', JSON.stringify(data.employee));
return data; return data;
} }
// Register Methode hinzufügen
async register(userData: RegisterRequest): Promise<AuthResponse> { async register(userData: RegisterRequest): Promise<AuthResponse> {
const response = await fetch(`${API_BASE}/employees`, { const response = await fetch(`${API_BASE}/employees`, {
method: 'POST', method: 'POST',
@@ -69,21 +55,18 @@ class AuthService {
throw new Error(errorData.error || 'Registrierung fehlgeschlagen'); throw new Error(errorData.error || 'Registrierung fehlgeschlagen');
} }
// Nach der Erstellung automatisch einloggen
return this.login({ return this.login({
email: userData.email, email: userData.email,
password: userData.password password: userData.password
}); });
} }
// getCurrentUser als SYNCHRON machen getCurrentEmployee(): Employee | null {
getCurrentUser(): User | null { const employeeStr = localStorage.getItem('employee');
const userStr = localStorage.getItem('user'); return employeeStr ? JSON.parse(employeeStr) : null;
return userStr ? JSON.parse(userStr) : null;
} }
// Asynchrone Methode für Server-Abfrage async fetchCurrentEmployee(): Promise<Employee | null> {
async fetchCurrentUser(): Promise<User | null> {
const token = this.getToken(); const token = this.getToken();
if (!token) { if (!token) {
return null; return null;
@@ -97,7 +80,8 @@ class AuthService {
}); });
if (response.ok) { if (response.ok) {
const user = await response.json(); const data = await response.json();
const user = data.user;
localStorage.setItem('user', JSON.stringify(user)); localStorage.setItem('user', JSON.stringify(user));
return user; return user;
} }
@@ -125,7 +109,6 @@ class AuthService {
return this.getToken() !== null; return this.getToken() !== null;
} }
// Für API Calls mit Authentication
getAuthHeaders(): HeadersInit { getAuthHeaders(): HeadersInit {
const token = this.getToken(); const token = this.getToken();
return token ? { 'Authorization': `Bearer ${token}` } : {}; return token ? { 'Authorization': `Bearer ${token}` } : {};

View File

@@ -118,5 +118,4 @@ export class EmployeeService {
} }
} }
// ✅ Exportiere eine Instanz der Klasse
export const employeeService = new EmployeeService(); export const employeeService = new EmployeeService();

View File

@@ -21,20 +21,7 @@ export const shiftPlanService = {
throw new Error('Fehler beim Laden der Schichtpläne'); throw new Error('Fehler beim Laden der Schichtpläne');
} }
const data = await response.json(); return 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> { async getShiftPlan(id: string): Promise<ShiftPlan> {
@@ -53,20 +40,7 @@ export const shiftPlanService = {
throw new Error('Schichtplan nicht gefunden'); throw new Error('Schichtplan nicht gefunden');
} }
const data = await response.json(); return await response.json();
// Convert snake_case to camelCase
return data.map((plan: any) => ({
id: plan.id,
name: plan.name,
startDate: plan.start_date,
endDate: plan.end_date,
templateId: plan.template_id,
status: plan.status,
createdBy: plan.created_by,
createdAt: plan.created_at,
shifts: plan.shifts || []
}));
}, },
async createShiftPlan(plan: CreateShiftPlanRequest): Promise<ShiftPlan> { async createShiftPlan(plan: CreateShiftPlanRequest): Promise<ShiftPlan> {
@@ -127,60 +101,5 @@ export const shiftPlanService = {
} }
throw new Error('Fehler beim Löschen des Schichtplans'); throw new Error('Fehler beim Löschen des Schichtplans');
} }
},
async updateShiftPlanShift(planId: string, shift: Shift): 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<Shift, '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');
}
} }
}; };

View File

@@ -0,0 +1,36 @@
// frontend/src/shared/utils.ts
// import { ScheduledShift } from '../../../backend/src/models/shiftPlan.js';
// Shared date and time formatting utilities
export 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', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
};
export const formatTime = (timeString: string): string => {
return timeString?.substring(0, 5) || '';
};
export const formatDateTime = (dateString: string): 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',
hour: '2-digit',
minute: '2-digit'
});
};