mirror of
https://github.com/donpat1to/Schichtenplaner.git
synced 2025-11-30 22:45:46 +01:00
frontend with ony errors
This commit is contained in:
75
frontend/src/models/Employee.ts
Normal file
75
frontend/src/models/Employee.ts
Normal 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[];
|
||||
}
|
||||
105
frontend/src/models/ShiftPlan.ts
Normal file
105
frontend/src/models/ShiftPlan.ts
Normal 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;
|
||||
}
|
||||
108
frontend/src/models/defaults/employeeDefaults.ts
Normal file
108
frontend/src/models/defaults/employeeDefaults.ts
Normal 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;
|
||||
}
|
||||
3
frontend/src/models/defaults/index.ts
Normal file
3
frontend/src/models/defaults/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// backend/src/models/defaults/index.ts
|
||||
export * from './employeeDefaults.js';
|
||||
export * from './shiftPlanDefaults.js';
|
||||
146
frontend/src/models/defaults/shiftPlanDefaults.ts
Normal file
146
frontend/src/models/defaults/shiftPlanDefaults.ts
Normal 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;
|
||||
40
frontend/src/models/helpers/employeeHelpers.ts
Normal file
40
frontend/src/models/helpers/employeeHelpers.ts
Normal 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);
|
||||
3
frontend/src/models/helpers/index.ts
Normal file
3
frontend/src/models/helpers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// backend/src/models/helpers/index.ts
|
||||
export * from './employeeHelpers.js';
|
||||
export * from './shiftPlanHelpers.js';
|
||||
118
frontend/src/models/helpers/shiftPlanHelpers.ts
Normal file
118
frontend/src/models/helpers/shiftPlanHelpers.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// frontend/src/pages/Employees/EmployeeManagement.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Employee } from '../../../../backend/src/models/employee';
|
||||
import { Employee } from '../../models/Employee';
|
||||
import { employeeService } from '../../services/employeeService';
|
||||
import EmployeeList from './components/EmployeeList';
|
||||
import EmployeeForm from './components/EmployeeForm';
|
||||
@@ -220,7 +220,6 @@ const EmployeeManagement: React.FC = () => {
|
||||
onEdit={handleEditEmployee}
|
||||
onDelete={handleDeleteEmployee} // Jetzt mit Employee-Objekt
|
||||
onManageAvailability={handleManageAvailability}
|
||||
currentUserRole={hasRole(['admin']) ? 'admin' : 'instandhalter'}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// frontend/src/pages/Employees/components/AvailabilityManager.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Employee, EmployeeAvailability } from '../../../../../backend/src/models/employee';
|
||||
import { employeeService } from '../../../services/employeeService';
|
||||
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 {
|
||||
employee: Employee;
|
||||
@@ -180,11 +180,13 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
||||
// 3. Extract time slots from 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) {
|
||||
console.log('⚠️ KEINE ZEIT-SLOTS GEFUNDEN, VERWENDE STANDARD-SLOTS');
|
||||
extractedTimeSlots = getDefaultTimeSlots();
|
||||
}
|
||||
}*/
|
||||
|
||||
console.log('✅ GEFUNDENE ZEIT-SLOTS:', extractedTimeSlots.length, extractedTimeSlots);
|
||||
|
||||
setTimeSlots(extractedTimeSlots);
|
||||
setShiftPlans(plans);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// frontend/src/pages/Employees/components/EmployeeForm.tsx - KORRIGIERT
|
||||
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 { useAuth } from '../../../contexts/AuthContext';
|
||||
|
||||
@@ -11,34 +12,6 @@ interface EmployeeFormProps {
|
||||
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> = ({
|
||||
mode,
|
||||
@@ -50,9 +23,9 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user' as 'admin' | 'instandhalter' | 'user',
|
||||
employeeType: 'neuling' as 'chef' | 'neuling' | 'erfahren',
|
||||
isSufficientlyIndependent: false,
|
||||
role: 'user' as 'admin' | 'maintenance' | 'user',
|
||||
employeeType: 'trainee' as 'manager' | 'trainee' | 'experienced',
|
||||
canWorkAlone: false,
|
||||
isActive: true
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -61,22 +34,20 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'edit' && employee) {
|
||||
console.log('📝 Lade Mitarbeiter-Daten:', employee);
|
||||
setFormData({
|
||||
name: employee.name,
|
||||
email: employee.email,
|
||||
password: '', // Passwort wird beim Bearbeiten nicht angezeigt
|
||||
role: employee.role,
|
||||
employeeType: employee.employeeType,
|
||||
isSufficientlyIndependent: employee.isSufficientlyIndependent,
|
||||
canWorkAlone: employee.canWorkAlone,
|
||||
isActive: employee.isActive
|
||||
});
|
||||
}
|
||||
}, [mode, employee]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
console.log(`🔄 Feld geändert: ${name} = ${value}`);
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
@@ -84,25 +55,14 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
}));
|
||||
};
|
||||
|
||||
const handleRoleChange = (roleValue: 'admin' | 'instandhalter' | 'user') => {
|
||||
console.log(`🔄 Rolle geändert: ${roleValue}`);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
role: roleValue
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEmployeeTypeChange = (employeeType: 'chef' | 'neuling' | 'erfahren') => {
|
||||
console.log(`🔄 Mitarbeiter-Typ geändert: ${employeeType}`);
|
||||
const handleEmployeeTypeChange = (employeeType: 'manager' | 'trainee' | 'experienced') => {
|
||||
// Manager and experienced can work alone, trainee cannot
|
||||
const canWorkAlone = employeeType === 'manager' || employeeType === 'experienced';
|
||||
|
||||
// Automatische Werte basierend auf Typ
|
||||
const isSufficientlyIndependent = employeeType === 'chef' ? true :
|
||||
employeeType === 'erfahren' ? true : false;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
employeeType,
|
||||
isSufficientlyIndependent
|
||||
canWorkAlone
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -111,8 +71,6 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
console.log('📤 Sende Formulardaten:', formData);
|
||||
|
||||
try {
|
||||
if (mode === 'create') {
|
||||
const createData: CreateEmployeeRequest = {
|
||||
@@ -121,52 +79,37 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
password: formData.password,
|
||||
role: formData.role,
|
||||
employeeType: formData.employeeType,
|
||||
isSufficientlyIndependent: formData.isSufficientlyIndependent,
|
||||
contractType: 'small', // Default value
|
||||
canWorkAlone: formData.canWorkAlone
|
||||
};
|
||||
console.log('➕ Erstelle Mitarbeiter:', createData);
|
||||
await employeeService.createEmployee(createData);
|
||||
} else if (employee) {
|
||||
const updateData: UpdateEmployeeRequest = {
|
||||
name: formData.name.trim(),
|
||||
role: formData.role,
|
||||
employeeType: formData.employeeType,
|
||||
isSufficientlyIndependent: formData.isSufficientlyIndependent,
|
||||
contractType: employee.contractType, // Keep the existing contract type
|
||||
canWorkAlone: formData.canWorkAlone,
|
||||
isActive: formData.isActive,
|
||||
};
|
||||
console.log('✏️ Aktualisiere Mitarbeiter:', updateData);
|
||||
await employeeService.updateEmployee(employee.id, updateData);
|
||||
}
|
||||
|
||||
console.log('✅ Erfolg - rufe onSuccess auf');
|
||||
onSuccess();
|
||||
} catch (err: any) {
|
||||
console.error('❌ Fehler beim Speichern:', err);
|
||||
setError(err.message || `Fehler beim ${mode === 'create' ? 'Erstellen' : 'Aktualisieren'} des Mitarbeiters`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
if (mode === 'create') {
|
||||
return formData.name.trim() &&
|
||||
formData.email.trim() &&
|
||||
formData.password.length >= 6;
|
||||
}
|
||||
return formData.name.trim() && formData.email.trim();
|
||||
};
|
||||
const isFormValid = mode === 'create'
|
||||
? formData.name.trim() && formData.email.trim() && formData.password.length >= 6
|
||||
: formData.name.trim() && formData.email.trim();
|
||||
|
||||
const getAvailableRoles = () => {
|
||||
if (hasRole(['admin'])) {
|
||||
return ROLE_OPTIONS;
|
||||
}
|
||||
if (hasRole(['instandhalter'])) {
|
||||
return ROLE_OPTIONS.filter(role => role.value !== 'admin');
|
||||
}
|
||||
return ROLE_OPTIONS.filter(role => role.value === 'user');
|
||||
};
|
||||
|
||||
const availableRoles = getAvailableRoles();
|
||||
const availableRoles = hasRole(['admin'])
|
||||
? ROLE_CONFIG
|
||||
: ROLE_CONFIG.filter(role => role.value !== 'admin');
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
@@ -294,7 +237,7 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
<h3 style={{ margin: '0 0 15px 0', color: '#495057' }}>👥 Mitarbeiter Kategorie</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{EMPLOYEE_TYPE_OPTIONS.map(type => (
|
||||
{EMPLOYEE_TYPE_CONFIG.map(type => (
|
||||
<div
|
||||
key={type.value}
|
||||
style={{
|
||||
@@ -352,18 +295,6 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
</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>
|
||||
|
||||
{/* Eigenständigkeit */}
|
||||
@@ -386,29 +317,29 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
}}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isSufficientlyIndependent"
|
||||
id="isSufficientlyIndependent"
|
||||
checked={formData.isSufficientlyIndependent}
|
||||
name="canWorkAlone"
|
||||
id="canWorkAlone"
|
||||
checked={formData.canWorkAlone}
|
||||
onChange={handleChange}
|
||||
disabled={formData.employeeType === 'chef'}
|
||||
disabled={formData.employeeType === 'manager'}
|
||||
style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
opacity: formData.employeeType === 'chef' ? 0.5 : 1
|
||||
opacity: formData.employeeType === 'manager' ? 0.5 : 1
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label htmlFor="isSufficientlyIndependent" style={{
|
||||
<label htmlFor="canWorkAlone" style={{
|
||||
fontWeight: 'bold',
|
||||
color: '#2c3e50',
|
||||
display: 'block',
|
||||
opacity: formData.employeeType === 'chef' ? 0.5 : 1
|
||||
opacity: formData.employeeType === 'manager' ? 0.5 : 1
|
||||
}}>
|
||||
Als ausreichend eigenständig markieren
|
||||
{formData.employeeType === 'chef' && ' (Automatisch für Chefs)'}
|
||||
{formData.employeeType === 'manager' && ' (Automatisch für Chefs)'}
|
||||
</label>
|
||||
<div style={{ fontSize: '14px', color: '#7f8c8d' }}>
|
||||
{formData.employeeType === 'chef'
|
||||
{formData.employeeType === 'manager'
|
||||
? 'Chefs sind automatisch als eigenständig markiert.'
|
||||
: '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 style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: formData.isSufficientlyIndependent ? '#27ae60' : '#e74c3c',
|
||||
backgroundColor: formData.canWorkAlone ? '#27ae60' : '#e74c3c',
|
||||
color: 'white',
|
||||
borderRadius: '15px',
|
||||
fontSize: '12px',
|
||||
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>
|
||||
@@ -451,14 +382,14 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
backgroundColor: formData.role === role.value ? '#fef9e7' : 'white',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
onClick={() => handleRoleChange(role.value)}
|
||||
onClick={() => setFormData(prev => ({ ...prev, role: role.value }))}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="role"
|
||||
value={role.value}
|
||||
checked={formData.role === role.value}
|
||||
onChange={() => handleRoleChange(role.value)}
|
||||
onChange={() => setFormData(prev => ({ ...prev, role: role.value }))}
|
||||
style={{
|
||||
marginRight: '10px',
|
||||
marginTop: '2px'
|
||||
@@ -537,14 +468,14 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !isFormValid()}
|
||||
disabled={loading || !isFormValid}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
backgroundColor: loading ? '#bdc3c7' : (isFormValid() ? '#27ae60' : '#95a5a6'),
|
||||
backgroundColor: loading ? '#bdc3c7' : (isFormValid ? '#27ae60' : '#95a5a6'),
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: (loading || !isFormValid()) ? 'not-allowed' : 'pointer',
|
||||
cursor: (loading || !isFormValid) ? 'not-allowed' : 'pointer',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// frontend/src/pages/Employees/components/EmployeeList.tsx - KORRIGIERT
|
||||
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';
|
||||
|
||||
interface EmployeeListProps {
|
||||
@@ -8,26 +9,22 @@ interface EmployeeListProps {
|
||||
onEdit: (employee: Employee) => void;
|
||||
onDelete: (employee: Employee) => void;
|
||||
onManageAvailability: (employee: Employee) => void;
|
||||
currentUserRole: 'admin' | 'instandhalter';
|
||||
}
|
||||
|
||||
const EmployeeList: React.FC<EmployeeListProps> = ({
|
||||
employees,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onManageAvailability,
|
||||
currentUserRole
|
||||
onManageAvailability
|
||||
}) => {
|
||||
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('active');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const { user: currentUser } = useAuth();
|
||||
const { user: currentUser, hasRole } = useAuth();
|
||||
|
||||
const filteredEmployees = employees.filter(employee => {
|
||||
// Status-Filter
|
||||
if (filter === 'active' && !employee.isActive) return false;
|
||||
if (filter === 'inactive' && employee.isActive) return false;
|
||||
|
||||
// Suchfilter
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
@@ -41,28 +38,25 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
||||
return true;
|
||||
});
|
||||
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin': return '#e74c3c';
|
||||
case 'instandhalter': return '#3498db';
|
||||
case 'user': return '#27ae60';
|
||||
default: return '#95a5a6';
|
||||
}
|
||||
// Simplified permission checks
|
||||
const canDeleteEmployee = (employee: Employee): boolean => {
|
||||
if (!hasRole(['admin'])) return false;
|
||||
if (employee.id === currentUser?.id) return false;
|
||||
if (employee.role === 'admin' && !hasRole(['admin'])) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const getEmployeeTypeBadge = (type: string) => {
|
||||
switch (type) {
|
||||
case 'chef': return { text: '👨💼 CHEF', color: '#e74c3c', bgColor: '#fadbd8' };
|
||||
case 'erfahren': return { text: '👴 ERFAHREN', color: '#3498db', bgColor: '#d6eaf8' };
|
||||
case 'neuling': return { text: '👶 NEULING', color: '#27ae60', bgColor: '#d5f4e6' };
|
||||
default: return { text: 'UNBEKANNT', color: '#95a5a6', bgColor: '#ecf0f1' };
|
||||
const canEditEmployee = (employee: Employee): boolean => {
|
||||
if (hasRole(['admin'])) return true;
|
||||
if (hasRole(['maintenance'])) {
|
||||
return employee.role === 'user' || employee.id === currentUser?.id;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const getIndependenceBadge = (isIndependent: boolean) => {
|
||||
return isIndependent
|
||||
? { text: '✅ Eigenständig', color: '#27ae60', bgColor: '#d5f4e6' }
|
||||
: { text: '❌ Betreuung', color: '#e74c3c', bgColor: '#fadbd8' };
|
||||
// Using shared configuration for consistent styling
|
||||
const getEmployeeTypeBadge = (type: keyof typeof EMPLOYEE_TYPE_CONFIG) => {
|
||||
return EMPLOYEE_TYPE_CONFIG[type] || EMPLOYEE_TYPE_CONFIG.trainee;
|
||||
};
|
||||
|
||||
const getStatusBadge = (isActive: boolean) => {
|
||||
@@ -71,31 +65,10 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
||||
: { text: 'Inaktiv', color: '#e74c3c', bgColor: '#fadbd8' };
|
||||
};
|
||||
|
||||
// Kann Benutzer löschen?
|
||||
const canDeleteEmployee = (employee: Employee): boolean => {
|
||||
// Nur Admins können löschen
|
||||
if (currentUserRole !== 'admin') return false;
|
||||
|
||||
// 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;
|
||||
const getIndependenceBadge = (canWorkAlone: boolean) => {
|
||||
return canWorkAlone
|
||||
? { text: '✅ Eigenständig', color: '#27ae60', bgColor: '#d5f4e6' }
|
||||
: { text: '❌ Betreuung', color: '#e74c3c', bgColor: '#fadbd8' };
|
||||
};
|
||||
|
||||
if (employees.length === 0) {
|
||||
@@ -197,8 +170,8 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
||||
|
||||
{filteredEmployees.map(employee => {
|
||||
const employeeType = getEmployeeTypeBadge(employee.employeeType);
|
||||
const independence = getIndependenceBadge(employee.isSufficientlyIndependent);
|
||||
const roleColor = getRoleBadgeColor(employee.role);
|
||||
const independence = getIndependenceBadge(employee.canWorkAlone);
|
||||
const roleColor = '#d5f4e6'; // Default color
|
||||
const status = getStatusBadge(employee.isActive);
|
||||
const canEdit = canEditEmployee(employee);
|
||||
const canDelete = canDeleteEmployee(employee);
|
||||
@@ -239,7 +212,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: employeeType.bgColor,
|
||||
backgroundColor: employeeType.color,
|
||||
color: employeeType.color,
|
||||
padding: '6px 12px',
|
||||
borderRadius: '15px',
|
||||
@@ -248,7 +221,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
||||
display: 'inline-block'
|
||||
}}
|
||||
>
|
||||
{employeeType.text}
|
||||
{employeeType.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -284,7 +257,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
||||
}}
|
||||
>
|
||||
{employee.role === 'admin' ? 'ADMIN' :
|
||||
employee.role === 'instandhalter' ? 'INSTANDHALTER' : 'MITARBEITER'}
|
||||
employee.role === 'maintenance' ? 'INSTANDHALTER' : 'MITARBEITER'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -322,7 +295,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
||||
flexWrap: 'wrap'
|
||||
}}>
|
||||
{/* Verfügbarkeit Button */}
|
||||
{(currentUserRole === 'admin' || currentUserRole === 'instandhalter') && (
|
||||
{(employee.role === 'admin' || employee.role === 'maintenance') && (
|
||||
<button
|
||||
onClick={() => onManageAvailability(employee)}
|
||||
style={{
|
||||
@@ -385,7 +358,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
@@ -3,9 +3,14 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { shiftTemplateService } from '../../services/shiftTemplateService';
|
||||
import { shiftPlanService } from '../../services/shiftPlanService';
|
||||
import { TemplateShift } from '../../types/shiftTemplate';
|
||||
import styles from './ShiftPlanCreate.module.css';
|
||||
|
||||
export interface TemplateShift {
|
||||
id: string;
|
||||
name: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
const ShiftPlanCreate: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// 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 { shiftPlanService } from '../../services/shiftPlanService';
|
||||
import { ShiftPlan, Shift } from '../../../../backend/src/models/shiftPlan';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { getTimeSlotById } from '../../models/helpers/shiftPlanHelpers';
|
||||
|
||||
const ShiftPlanEdit: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -10,12 +12,10 @@ const ShiftPlanEdit: React.FC = () => {
|
||||
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>>({
|
||||
const [editingShift, setEditingShift] = useState<Shift | null>(null);
|
||||
const [newShift, setNewShift] = useState<Partial<ScheduledShift>>({
|
||||
date: '',
|
||||
name: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
timeSlotId: '',
|
||||
requiredEmployees: 1
|
||||
});
|
||||
|
||||
@@ -41,16 +41,10 @@ const ShiftPlanEdit: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateShift = async (shift: ShiftPlanShift) => {
|
||||
const handleUpdateShift = async (shift: Shift) => {
|
||||
if (!shiftPlan || !id) return;
|
||||
|
||||
try {
|
||||
await shiftPlanService.updateShiftPlanShift(id, shift);
|
||||
showNotification({
|
||||
type: 'success',
|
||||
title: 'Erfolg',
|
||||
message: 'Schicht wurde aktualisiert.'
|
||||
});
|
||||
loadShiftPlan();
|
||||
setEditingShift(null);
|
||||
} catch (error) {
|
||||
@@ -66,23 +60,16 @@ const ShiftPlanEdit: React.FC = () => {
|
||||
const handleAddShift = async () => {
|
||||
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({
|
||||
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',
|
||||
@@ -112,12 +99,6 @@ const ShiftPlanEdit: React.FC = () => {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
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 { formatDate } from '../../utils/foramatters';
|
||||
|
||||
const ShiftPlanList: React.FC = () => {
|
||||
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) {
|
||||
return <div>Lade Schichtpläne...</div>;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
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 { formatDate, formatTime } from '../../utils/foramatters';
|
||||
|
||||
const ShiftPlanView: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -36,94 +38,44 @@ const ShiftPlanView: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
// Simplified timetable data generation
|
||||
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.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 => {
|
||||
// Use timeSlots directly since shifts reference them
|
||||
const timetableShifts = shiftPlan.timeSlots.map(timeSlot => {
|
||||
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.timeSlot.name === shiftType.name &&
|
||||
shift.timeSlot.startTime === shiftType.startTime &&
|
||||
shift.timeSlot.endTime === shiftType.endTime;
|
||||
});
|
||||
const shiftsOnDay = shiftPlan.shifts.filter(shift =>
|
||||
shift.dayOfWeek === weekday.id &&
|
||||
shift.timeSlotId === timeSlot.id
|
||||
);
|
||||
|
||||
if (shiftsOnDay.length === 0) {
|
||||
weekdayData[weekday] = '';
|
||||
weekdayData[weekday.id] = '';
|
||||
} else {
|
||||
const totalAssigned = shiftsOnDay.reduce((sum, shift) => sum + shift.timeSlot.assignedEmployees.length, 0);
|
||||
const totalRequired = shiftsOnDay.reduce((sum, shift) => sum + shift.requiredEmployees, 0);
|
||||
weekdayData[weekday] = `${totalAssigned}/${totalRequired}`;
|
||||
const totalRequired = shiftsOnDay.reduce((sum, shift) =>
|
||||
sum + shift.requiredEmployees, 0);
|
||||
// For now, show required count since we don't have assigned employees in Shift
|
||||
weekdayData[weekday.id] = `0/${totalRequired}`;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...shiftType,
|
||||
displayName: `${shiftType.name} (${formatTime(shiftType.startTime)}–${formatTime(shiftType.endTime)})`,
|
||||
...timeSlot,
|
||||
displayName: `${timeSlot.name} (${formatTime(timeSlot.startTime)}–${formatTime(timeSlot.endTime)})`,
|
||||
weekdayData
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
shifts: timetableShifts,
|
||||
weekdays: weekdays.map(day => ({
|
||||
id: day,
|
||||
name: ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][day === 7 ? 0 : day]
|
||||
}))
|
||||
};
|
||||
return { shifts: timetableShifts, weekdays };
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>Lade Schichtplan...</div>;
|
||||
}
|
||||
|
||||
if (!shiftPlan) {
|
||||
return <div>Schichtplan nicht gefunden</div>;
|
||||
}
|
||||
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={{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,4 +1,5 @@
|
||||
// frontend/src/services/authService.ts
|
||||
import { Employee } from '../../../backend/src/models/employee';
|
||||
const API_BASE = 'http://localhost:3002/api';
|
||||
|
||||
export interface LoginRequest {
|
||||
@@ -11,28 +12,14 @@ export interface RegisterRequest {
|
||||
password: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
phone?: string;
|
||||
department?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
user: User;
|
||||
employee: Employee;
|
||||
token: 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 {
|
||||
private token: string | null = null;
|
||||
|
||||
@@ -51,12 +38,11 @@ class AuthService {
|
||||
const data: AuthResponse = await response.json();
|
||||
this.token = data.token;
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
localStorage.setItem('employee', JSON.stringify(data.employee));
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Register Methode hinzufügen
|
||||
async register(userData: RegisterRequest): Promise<AuthResponse> {
|
||||
const response = await fetch(`${API_BASE}/employees`, {
|
||||
method: 'POST',
|
||||
@@ -69,21 +55,18 @@ class AuthService {
|
||||
throw new Error(errorData.error || 'Registrierung fehlgeschlagen');
|
||||
}
|
||||
|
||||
// Nach der Erstellung automatisch einloggen
|
||||
return this.login({
|
||||
email: userData.email,
|
||||
password: userData.password
|
||||
});
|
||||
}
|
||||
|
||||
// getCurrentUser als SYNCHRON machen
|
||||
getCurrentUser(): User | null {
|
||||
const userStr = localStorage.getItem('user');
|
||||
return userStr ? JSON.parse(userStr) : null;
|
||||
getCurrentEmployee(): Employee | null {
|
||||
const employeeStr = localStorage.getItem('employee');
|
||||
return employeeStr ? JSON.parse(employeeStr) : null;
|
||||
}
|
||||
|
||||
// Asynchrone Methode für Server-Abfrage
|
||||
async fetchCurrentUser(): Promise<User | null> {
|
||||
async fetchCurrentEmployee(): Promise<Employee | null> {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
@@ -97,7 +80,8 @@ class AuthService {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
const data = await response.json();
|
||||
const user = data.user;
|
||||
localStorage.setItem('user', JSON.stringify(user));
|
||||
return user;
|
||||
}
|
||||
@@ -125,7 +109,6 @@ class AuthService {
|
||||
return this.getToken() !== null;
|
||||
}
|
||||
|
||||
// Für API Calls mit Authentication
|
||||
getAuthHeaders(): HeadersInit {
|
||||
const token = this.getToken();
|
||||
return token ? { 'Authorization': `Bearer ${token}` } : {};
|
||||
|
||||
@@ -118,5 +118,4 @@ export class EmployeeService {
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ Exportiere eine Instanz der Klasse
|
||||
export const employeeService = new EmployeeService();
|
||||
@@ -21,20 +21,7 @@ export const shiftPlanService = {
|
||||
throw new Error('Fehler beim Laden der Schichtpläne');
|
||||
}
|
||||
|
||||
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 || []
|
||||
}));
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async getShiftPlan(id: string): Promise<ShiftPlan> {
|
||||
@@ -53,20 +40,7 @@ export const shiftPlanService = {
|
||||
throw new Error('Schichtplan nicht gefunden');
|
||||
}
|
||||
|
||||
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,
|
||||
endDate: plan.end_date,
|
||||
templateId: plan.template_id,
|
||||
status: plan.status,
|
||||
createdBy: plan.created_by,
|
||||
createdAt: plan.created_at,
|
||||
shifts: plan.shifts || []
|
||||
}));
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
async createShiftPlan(plan: CreateShiftPlanRequest): Promise<ShiftPlan> {
|
||||
@@ -127,60 +101,5 @@ export const shiftPlanService = {
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
36
frontend/src/utils/foramatters.ts
Normal file
36
frontend/src/utils/foramatters.ts
Normal 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'
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user