Compare commits

...

2 Commits

13 changed files with 645 additions and 641 deletions

View File

@@ -18,17 +18,17 @@ function generateEmail(firstname: string, lastname: string): string {
const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, '');
const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
return `${cleanFirstname}.${cleanLastname}@sp.de`;
}
export const getEmployees = async (req: AuthRequest, res: Response): Promise<void> => {
try {
console.log('🔍 Fetching employees - User:', req.user);
const { includeInactive } = req.query;
const includeInactiveFlag = includeInactive === 'true';
let query = `
SELECT
e.id, e.email, e.firstname, e.lastname,
@@ -43,13 +43,13 @@ export const getEmployees = async (req: AuthRequest, res: Response): Promise<voi
FROM employees e
LEFT JOIN employee_roles er ON e.id = er.employee_id
`;
if (!includeInactiveFlag) {
query += ' WHERE e.is_active = 1';
}
query += ' ORDER BY e.firstname, e.lastname';
const employees = await db.all<any>(query);
// Format employees with proper field names and roles array
@@ -132,12 +132,12 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
password: '***hidden***'
});
const {
password,
firstname,
lastname,
const {
password,
firstname,
lastname,
roles = ['user'],
employeeType,
employeeType,
contractType,
canWorkAlone = false,
isTrainee = false
@@ -146,21 +146,21 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
// Validation
if (!password || !firstname || !lastname || !employeeType) {
console.log('❌ Validation failed: Missing required fields');
res.status(400).json({
error: 'Password, firstname, lastname und employeeType sind erforderlich'
res.status(400).json({
error: 'Password, firstname, lastname und employeeType sind erforderlich'
});
return;
}
// ✅ ENHANCED: Validate employee type exists and get category info
const employeeTypeInfo = await db.get<{type: string, category: string, has_contract_type: number}>(
const employeeTypeInfo = await db.get<{ type: string, category: string, has_contract_type: number }>(
'SELECT type, category, has_contract_type FROM employee_types WHERE type = ?',
[employeeType]
);
if (!employeeTypeInfo) {
res.status(400).json({
error: `Ungültiger employeeType: ${employeeType}. Gültige Typen: manager, personell, apprentice, guest`
res.status(400).json({
error: `Ungültiger employeeType: ${employeeType}. Gültige Typen: manager, personell, apprentice, guest`
});
return;
}
@@ -169,22 +169,22 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
if (employeeTypeInfo.has_contract_type === 1) {
// Internal types require contract type
if (!contractType) {
res.status(400).json({
error: `contractType ist erforderlich für employeeType: ${employeeType}`
res.status(400).json({
error: `contractType ist erforderlich für employeeType: ${employeeType}`
});
return;
}
if (!['small', 'large', 'flexible'].includes(contractType)) {
res.status(400).json({
error: `Ungültiger contractType: ${contractType}. Gültige Werte: small, large, flexible`
res.status(400).json({
error: `Ungültiger contractType: ${contractType}. Gültige Werte: small, large, flexible`
});
return;
}
} else {
// External types (guest) should not have contract type
if (contractType) {
res.status(400).json({
error: `contractType ist nicht erlaubt für employeeType: ${employeeType}`
res.status(400).json({
error: `contractType ist nicht erlaubt für employeeType: ${employeeType}`
});
return;
}
@@ -192,8 +192,8 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
// ✅ ENHANCED: isTrainee validation - only applicable for personell type
if (isTrainee && employeeType !== 'personell') {
res.status(400).json({
error: `isTrainee ist nur für employeeType 'personell' erlaubt`
res.status(400).json({
error: `isTrainee ist nur für employeeType 'personell' erlaubt`
});
return;
}
@@ -204,11 +204,11 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
// Check if generated email already exists
const existingUser = await db.get<any>('SELECT id FROM employees WHERE email = ? AND is_active = 1', [email]);
if (existingUser) {
console.log('❌ Generated email already exists:', email);
res.status(409).json({
error: `Employee with email ${email} already exists. Please use different firstname/lastname.`
res.status(409).json({
error: `Employee with email ${email} already exists. Please use different firstname/lastname.`
});
return;
}
@@ -228,12 +228,12 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
is_active, is_trainee
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
employeeId,
email,
hashedPassword,
firstname,
lastname,
employeeType,
employeeId,
email,
hashedPassword,
firstname,
lastname,
employeeType,
contractType, // Will be NULL for external types
canWorkAlone ? 1 : 0,
1,
@@ -302,9 +302,9 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
const { id } = req.params;
const { firstname, lastname, roles, isActive, employeeType, contractType, canWorkAlone, isTrainee } = req.body;
console.log('📝 Update Employee Request:', {
id, firstname, lastname, roles, isActive,
employeeType, contractType, canWorkAlone, isTrainee
console.log('📝 Update Employee Request:', {
id, firstname, lastname, roles, isActive,
employeeType, contractType, canWorkAlone, isTrainee
});
// Check if employee exists and get current data
@@ -321,10 +321,10 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
'SELECT role FROM employee_roles WHERE employee_id = ?',
[currentUser.userId]
);
const isCurrentlyAdmin = currentUserRoles.some(role => role.role === 'admin');
const willBeAdmin = roles.includes('admin');
if (isCurrentlyAdmin && !willBeAdmin) {
res.status(400).json({ error: 'You cannot remove your own admin role' });
return;
@@ -372,8 +372,8 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
);
if (!validEmployeeType) {
res.status(400).json({
error: `Ungültiger employeeType: ${employeeType}`
res.status(400).json({
error: `Ungültiger employeeType: ${employeeType}`
});
return;
}
@@ -385,16 +385,16 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
const newFirstname = firstname || existingEmployee.firstname;
const newLastname = lastname || existingEmployee.lastname;
email = generateEmail(newFirstname, newLastname);
// Check if new email already exists (for another employee)
const emailExists = await db.get<any>(
'SELECT id FROM employees WHERE email = ? AND id != ? AND is_active = 1',
'SELECT id FROM employees WHERE email = ? AND id != ? AND is_active = 1',
[email, id]
);
if (emailExists) {
res.status(409).json({
error: `Cannot update name - email ${email} already exists for another employee`
res.status(409).json({
error: `Cannot update name - email ${email} already exists for another employee`
});
return;
}
@@ -423,7 +423,7 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
if (roles) {
// Delete existing roles
await db.run('DELETE FROM employee_roles WHERE employee_id = ?', [id]);
// Insert new roles
for (const role of roles) {
await db.run(
@@ -541,18 +541,18 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
try {
// 1. Remove availabilities
await db.run('DELETE FROM employee_availability WHERE employee_id = ?', [id]);
// 2. Remove from assigned_shifts (JSON field cleanup)
interface AssignedShift {
id: string;
assigned_employees: string;
}
const assignedShifts = await db.all<AssignedShift>(
'SELECT id, assigned_employees FROM scheduled_shifts WHERE json_extract(assigned_employees, "$") LIKE ?',
'SELECT id, assigned_employees FROM scheduled_shifts WHERE json_extract(assigned_employees, "$") LIKE ?',
[`%${id}%`]
);
for (const shift of assignedShifts) {
try {
const employeesArray: string[] = JSON.parse(shift.assigned_employees || '[]');
@@ -581,7 +581,7 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
await db.run('COMMIT');
console.log('✅ Successfully deleted employee:', existingEmployee.email);
res.status(204).send();
} catch (error) {
@@ -655,23 +655,23 @@ export const updateAvailabilities = async (req: AuthRequest, res: Response): Pro
}
// Validate contract type requirements
const availableCount = availabilities.filter((avail: any) =>
const availableCount = availabilities.filter((avail: any) =>
avail.preferenceLevel === 1 || avail.preferenceLevel === 2
).length;
const contractType = existingEmployee.contract_type;
// Apply contract type minimum requirements
if (contractType === 'small' && availableCount < 2) {
res.status(400).json({
error: 'Employees with small contract must have at least 2 available shifts'
res.status(400).json({
error: 'Employees with small contract must have at least 2 available shifts'
});
return;
}
if (contractType === 'large' && availableCount < 3) {
res.status(400).json({
error: 'Employees with large contract must have at least 3 available shifts'
res.status(400).json({
error: 'Employees with large contract must have at least 3 available shifts'
});
return;
}
@@ -742,12 +742,12 @@ export const changePassword = async (req: AuthRequest, res: Response): Promise<v
// Get the current user from the auth middleware
const currentUser = (req as AuthRequest).user;
// Check if user is changing their own password or is an admin
if (currentUser?.userId !== id && currentUser?.role !== 'admin') {
res.status(403).json({ error: 'You can only change your own password' });
return;
}
}
// Check if employee exists and get password
const employee = await db.get<{ password: string }>('SELECT password FROM employees WHERE id = ?', [id]);
@@ -756,8 +756,8 @@ export const changePassword = async (req: AuthRequest, res: Response): Promise<v
return;
}
// For non-admin users, verify current password
if (currentUser?.role !== 'admin') {
// Verify current password
if (employee) {
const isValidPassword = await bcrypt.compare(currentPassword, employee.password);
if (!isValidPassword) {
res.status(400).json({ error: 'Current password is incorrect' });
@@ -766,8 +766,8 @@ export const changePassword = async (req: AuthRequest, res: Response): Promise<v
}
// Validate new password
if (!newPassword || newPassword.length < 6) {
res.status(400).json({ error: 'New password must be at least 6 characters long' });
if (!newPassword || newPassword.length < 8) {
res.status(400).json({ error: 'New password must be at least 8 characters long' });
return;
}
@@ -798,13 +798,13 @@ export const updateLastLogin = async (req: AuthRequest, res: Response): Promise<
// Update last_login with current timestamp
const currentTimestamp = new Date().toISOString();
await db.run(
'UPDATE employees SET last_login = ? WHERE id = ?',
'UPDATE employees SET last_login = ? WHERE id = ?',
[currentTimestamp, id]
);
console.log(`✅ Last login updated for employee ${id}: ${currentTimestamp}`);
res.json({
res.json({
message: 'Last login updated successfully',
lastLogin: currentTimestamp
});
@@ -825,7 +825,7 @@ const checkAdminCount = async (employeeId: string, newRoles: string[]): Promise<
);
const currentAdminCount = adminCountResult?.count || 0;
// Check ALL current roles for the employee
const currentEmployeeRoles = await db.all<{ role: string }>(
`SELECT role FROM employee_roles WHERE employee_id = ?`,

View File

@@ -16,7 +16,7 @@ function generateEmail(firstname: string, lastname: string): string {
const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, '');
const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
return `${cleanFirstname}.${cleanLastname}@sp.de`;
}
@@ -31,15 +31,15 @@ export const checkSetupStatus = async (req: Request, res: Response): Promise<voi
);
console.log('Admin exists check:', adminExists);
const needsSetup = !adminExists || adminExists['COUNT(*)'] === 0;
res.json({
needsSetup: needsSetup
});
} catch (error) {
console.error('Error checking setup status:', error);
res.status(500).json({
res.status(500).json({
error: 'Internal server error during setup check'
});
}
@@ -75,8 +75,8 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
}
// Password length validation
if (password.length < 6) {
res.status(400).json({ error: 'Das Passwort muss mindestens 6 Zeichen lang sein' });
if (password.length < 8) {
res.status(400).json({ error: 'Das Passwort muss mindestens 8 Zeichen lang sein' });
return;
}
@@ -125,15 +125,15 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
} catch (dbError) {
await db.run('ROLLBACK');
console.error('❌ Database error during admin creation:', dbError);
res.status(500).json({
res.status(500).json({
error: 'Fehler beim Erstellen des Admin-Accounts'
});
}
} catch (error) {
console.error('❌ Error in setup:', error);
if (!res.headersSent) {
res.status(500).json({
res.status(500).json({
error: 'Ein unerwarteter Fehler ist aufgetreten'
});
}

View File

@@ -23,7 +23,7 @@
### \[CREATE\] Employee
* `firstname` 1-100 characters and must not be empty
* `lastname` 1-100 characters and must not be empty
* `password` must be at least 6 characters (in create mode)
* `password` must be at least 8 characters (in create mode)
* `employeeType` must be `manager`, `personell`, `apprentice`, or `guest`
* `canWorkAlone` optional boolean
* `isTrainee` optional boolean

View File

@@ -73,7 +73,7 @@ export const validateEmployee = [
body('contractType')
.custom((value, { req }) => {
const employeeType = req.body.employeeType;
// Manager, apprentice => contractType must be flexible
if (['manager', 'apprentice'].includes(employeeType)) {
if (value !== 'flexible') {
@@ -92,7 +92,7 @@ export const validateEmployee = [
throw new Error(`contractType must be 'small' or 'large' for employeeType: ${employeeType}`);
}
}
return true;
}),
@@ -156,7 +156,7 @@ export const validateEmployeeUpdate = [
.custom((value, { req }) => {
const employeeType = req.body.employeeType;
if (!employeeType) return true; // Skip if employeeType not provided
// Same validation logic as create
if (['manager', 'apprentice'].includes(employeeType)) {
if (value !== 'flexible') {
@@ -173,7 +173,7 @@ export const validateEmployeeUpdate = [
throw new Error(`contractType must be 'small' or 'large' for employeeType: ${employeeType}`);
}
}
return true;
}),
@@ -209,7 +209,7 @@ export const validateChangePassword = [
.isLength({ min: 1 })
.withMessage('Current password is required for self-password change'),
body('password')
body('newPassword')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
@@ -217,7 +217,7 @@ export const validateChangePassword = [
body('confirmPassword')
.custom((value, { req }) => {
if (value !== req.body.password) {
if (value !== req.body.newPassword) {
throw new Error('Passwords do not match');
}
return true;
@@ -465,7 +465,7 @@ export const validateAvailabilities = [
.withMessage('Availabilities must be an array')
.custom((availabilities, { req }) => {
// Count available shifts (preference level 1 or 2)
const availableCount = availabilities.filter((avail: any) =>
const availableCount = availabilities.filter((avail: any) =>
avail.preferenceLevel === 1 || avail.preferenceLevel === 2
).length;
@@ -473,7 +473,7 @@ export const validateAvailabilities = [
if (availableCount === 0) {
throw new Error('At least one available shift is required');
}
return true;
}),

View File

@@ -14,7 +14,7 @@ function generateEmail(firstname: string, lastname: string): string {
const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, '');
const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
return `${cleanFirstname}.${cleanLastname}@sp.de`;
}
@@ -22,8 +22,8 @@ function generateEmail(firstname: string, lastname: string): string {
export function validateEmployeeData(employee: CreateEmployeeRequest): string[] {
const errors: string[] = [];
if (employee.password?.length < 6) {
errors.push('Password must be at least 6 characters long');
if (employee.password?.length < 8) {
errors.push('Password must be at least 8 characters long');
}
if (!employee.firstname?.trim() || employee.firstname.trim().length < 2) {
@@ -72,16 +72,16 @@ export function generateEmployeeEmail(firstname: string, lastname: string): stri
}
// UPDATED: Business logic helpers for new employee types
export const isManager = (employee: Employee): boolean =>
export const isManager = (employee: Employee): boolean =>
employee.employeeType === 'manager';
export const isPersonell = (employee: Employee): boolean =>
export const isPersonell = (employee: Employee): boolean =>
employee.employeeType === 'personell';
export const isApprentice = (employee: Employee): boolean =>
export const isApprentice = (employee: Employee): boolean =>
employee.employeeType === 'apprentice';
export const isGuest = (employee: Employee): boolean =>
export const isGuest = (employee: Employee): boolean =>
employee.employeeType === 'guest';
export const isInternal = (employee: Employee): boolean =>
@@ -91,24 +91,24 @@ export const isExternal = (employee: Employee): boolean =>
employee.employeeType === 'guest';
// UPDATED: Trainee logic - now based on isTrainee field for personell type
export const isTrainee = (employee: Employee): boolean =>
export const isTrainee = (employee: Employee): boolean =>
employee.employeeType === 'personell' && employee.isTrainee;
export const isExperienced = (employee: Employee): boolean =>
export const isExperienced = (employee: Employee): boolean =>
employee.employeeType === 'personell' && !employee.isTrainee;
// Role-based helpers
export const isAdmin = (employee: Employee): boolean =>
export const isAdmin = (employee: Employee): boolean =>
employee.roles?.includes('admin') || false;
export const isMaintenance = (employee: Employee): boolean =>
export const isMaintenance = (employee: Employee): boolean =>
employee.roles?.includes('maintenance') || false;
export const isUser = (employee: Employee): boolean =>
export const isUser = (employee: Employee): boolean =>
employee.roles?.includes('user') || false;
// UPDATED: Work alone permission - managers and experienced personell can work alone
export const canEmployeeWorkAlone = (employee: Employee): boolean =>
export const canEmployeeWorkAlone = (employee: Employee): boolean =>
employee.canWorkAlone && (isManager(employee) || isExperienced(employee));
// Helper for full name display

View File

@@ -11,15 +11,15 @@ import {
changePassword,
updateLastLogin
} from '../controllers/employeeController.js';
import {
handleValidationErrors,
validateEmployee,
validateEmployeeUpdate,
import {
handleValidationErrors,
validateEmployee,
validateEmployeeUpdate,
validateChangePassword,
validateId,
validateEmployeeId,
validateAvailabilities,
validatePagination
validatePagination
} from '../middleware/validation.js';
const router = express.Router();
@@ -28,18 +28,18 @@ const router = express.Router();
router.use(authMiddleware);
// Employee CRUD Routes
router.get('/', validatePagination, handleValidationErrors, getEmployees);
router.get('/', validatePagination, handleValidationErrors, authMiddleware, getEmployees);
router.get('/:id', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), getEmployee);
router.post('/', validateEmployee, handleValidationErrors, requireRole(['admin']), createEmployee);
router.put('/:id', validateId, validateEmployeeUpdate, handleValidationErrors, requireRole(['admin', 'maintenance']), updateEmployee);
router.delete('/:id', validateId, handleValidationErrors, requireRole(['admin']), deleteEmployee);
// Password & Login Routes
router.put('/:id/password', validateId, validateChangePassword, handleValidationErrors, changePassword);
router.put('/:id/last-login', validateId, handleValidationErrors, updateLastLogin);
router.put('/:id/password', validateId, validateChangePassword, handleValidationErrors, authMiddleware, changePassword);
router.put('/:id/last-login', validateId, handleValidationErrors, authMiddleware, updateLastLogin);
// Availability Routes
router.get('/:employeeId/availabilities', validateEmployeeId, handleValidationErrors, getAvailabilities);
router.put('/:employeeId/availabilities', validateEmployeeId, validateAvailabilities, handleValidationErrors, updateAvailabilities);
router.get('/:employeeId/availabilities', validateEmployeeId, handleValidationErrors, authMiddleware, getAvailabilities);
router.put('/:employeeId/availabilities', validateEmployeeId, validateAvailabilities, handleValidationErrors, authMiddleware, updateAvailabilities);
export default router;

View File

@@ -33,35 +33,19 @@ export const useBackendValidation = () => {
const result = await apiCall();
return result;
} catch (error: any) {
if (error.validationErrors) {
if (error.validationErrors && Array.isArray(error.validationErrors)) {
setValidationErrors(error.validationErrors);
// Show specific validation error messages
if (error.validationErrors.length > 0) {
// Show the first validation error as the main notification
const firstError = error.validationErrors[0];
showNotification({
type: 'error',
title: 'Validierungsfehler',
message: firstError.message
});
// If there are multiple errors, show additional notifications for each
if (error.validationErrors.length > 1) {
// Wait a bit before showing additional notifications to avoid overlap
setTimeout(() => {
error.validationErrors.slice(1).forEach((validationError: ValidationError, index: number) => {
setTimeout(() => {
showNotification({
type: 'error',
title: 'Weiterer Fehler',
message: validationError.message
});
}, index * 300); // Stagger the notifications
});
}, 500);
}
}
// Show specific validation error messages from backend
error.validationErrors.forEach((validationError: ValidationError, index: number) => {
setTimeout(() => {
showNotification({
type: 'error',
title: 'Validierungsfehler',
message: `${validationError.field ? `${validationError.field}: ` : ''}${validationError.message}`
});
}, index * 500); // Stagger the notifications
});
} else {
// Show notification for other errors
showNotification({

View File

@@ -14,7 +14,7 @@ function generateEmail(firstname: string, lastname: string): string {
const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, '');
const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
return `${cleanFirstname}.${cleanLastname}@sp.de`;
}
@@ -22,8 +22,8 @@ function generateEmail(firstname: string, lastname: string): string {
export function validateEmployeeData(employee: CreateEmployeeRequest): string[] {
const errors: string[] = [];
if (employee.password?.length < 6) {
errors.push('Password must be at least 6 characters long');
if (employee.password?.length < 8) {
errors.push('Password must be at least 8 characters long');
}
if (!employee.firstname?.trim() || employee.firstname.trim().length < 2) {
@@ -72,16 +72,16 @@ export function generateEmployeeEmail(firstname: string, lastname: string): stri
}
// UPDATED: Business logic helpers for new employee types
export const isManager = (employee: Employee): boolean =>
export const isManager = (employee: Employee): boolean =>
employee.employeeType === 'manager';
export const isPersonell = (employee: Employee): boolean =>
export const isPersonell = (employee: Employee): boolean =>
employee.employeeType === 'personell';
export const isApprentice = (employee: Employee): boolean =>
export const isApprentice = (employee: Employee): boolean =>
employee.employeeType === 'apprentice';
export const isGuest = (employee: Employee): boolean =>
export const isGuest = (employee: Employee): boolean =>
employee.employeeType === 'guest';
export const isInternal = (employee: Employee): boolean =>
@@ -91,24 +91,24 @@ export const isExternal = (employee: Employee): boolean =>
employee.employeeType === 'guest';
// UPDATED: Trainee logic - now based on isTrainee field for personell type
export const isTrainee = (employee: Employee): boolean =>
export const isTrainee = (employee: Employee): boolean =>
employee.employeeType === 'personell' && employee.isTrainee;
export const isExperienced = (employee: Employee): boolean =>
export const isExperienced = (employee: Employee): boolean =>
employee.employeeType === 'personell' && !employee.isTrainee;
// Role-based helpers
export const isAdmin = (employee: Employee): boolean =>
export const isAdmin = (employee: Employee): boolean =>
employee.roles?.includes('admin') || false;
export const isMaintenance = (employee: Employee): boolean =>
export const isMaintenance = (employee: Employee): boolean =>
employee.roles?.includes('maintenance') || false;
export const isUser = (employee: Employee): boolean =>
export const isUser = (employee: Employee): boolean =>
employee.roles?.includes('user') || false;
// UPDATED: Work alone permission - managers and experienced personell can work alone
export const canEmployeeWorkAlone = (employee: Employee): boolean =>
export const canEmployeeWorkAlone = (employee: Employee): boolean =>
employee.canWorkAlone && (isManager(employee) || isExperienced(employee));
// Helper for full name display

View File

@@ -1,8 +1,9 @@
// frontend/src/pages/Settings/Settings.tsx - UPDATED WITH NEW STYLES
// frontend/src/pages/Settings/Settings.tsx - UPDATED WITH VALIDATION STRATEGY
import React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { employeeService } from '../../services/employeeService';
import { useNotification } from '../../contexts/NotificationContext';
import { useBackendValidation } from '../../hooks/useBackendValidation';
import AvailabilityManager from '../Employees/components/AvailabilityManager';
import { Employee } from '../../models/Employee';
import { styles } from './type/SettingsType';
@@ -10,11 +11,12 @@ import { styles } from './type/SettingsType';
const Settings: React.FC = () => {
const { user: currentUser, updateUser } = useAuth();
const { showNotification } = useNotification();
const { executeWithValidation, clearErrors, isSubmitting } = useBackendValidation();
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'availability'>('profile');
const [loading, setLoading] = useState(false);
const [showAvailabilityManager, setShowAvailabilityManager] = useState(false);
// Profile form state - updated for firstname/lastname
// Profile form state
const [profileForm, setProfileForm] = useState({
firstname: currentUser?.firstname || '',
lastname: currentUser?.lastname || ''
@@ -73,7 +75,7 @@ const Settings: React.FC = () => {
}));
};
// Password visibility handlers for current password
// Password visibility handlers
const handleCurrentPasswordMouseDown = () => {
currentPasswordTimeoutRef.current = setTimeout(() => {
setShowCurrentPassword(true);
@@ -88,7 +90,6 @@ const Settings: React.FC = () => {
setShowCurrentPassword(false);
};
// Password visibility handlers for new password
const handleNewPasswordMouseDown = () => {
newPasswordTimeoutRef.current = setTimeout(() => {
setShowNewPassword(true);
@@ -103,7 +104,6 @@ const Settings: React.FC = () => {
setShowNewPassword(false);
};
// Password visibility handlers for confirm password
const handleConfirmPasswordMouseDown = () => {
confirmPasswordTimeoutRef.current = setTimeout(() => {
setShowConfirmPassword(true);
@@ -129,7 +129,6 @@ const Settings: React.FC = () => {
cleanup();
};
// Prevent context menu
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
};
@@ -138,40 +137,46 @@ const Settings: React.FC = () => {
e.preventDefault();
if (!currentUser) return;
// Validation
if (!profileForm.firstname.trim() || !profileForm.lastname.trim()) {
// BASIC FRONTEND VALIDATION: Only check required fields
if (!profileForm.firstname.trim()) {
showNotification({
type: 'error',
title: 'Fehler',
message: 'Vorname und Nachname sind erforderlich'
message: 'Vorname ist erforderlich'
});
return;
}
if (!profileForm.lastname.trim()) {
showNotification({
type: 'error',
title: 'Fehler',
message: 'Nachname ist erforderlich'
});
return;
}
try {
setLoading(true);
await employeeService.updateEmployee(currentUser.id, {
firstname: profileForm.firstname.trim(),
lastname: profileForm.lastname.trim()
});
// Use executeWithValidation to handle backend validation
await executeWithValidation(async () => {
const updatedEmployee = await employeeService.updateEmployee(currentUser.id, {
firstname: profileForm.firstname.trim(),
lastname: profileForm.lastname.trim()
});
// Update the auth context with new user data
const updatedUser = await employeeService.getEmployee(currentUser.id);
updateUser(updatedUser);
// Update the auth context with new user data
updateUser(updatedEmployee);
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Profil erfolgreich aktualisiert'
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Profil erfolgreich aktualisiert'
});
});
} catch (error: any) {
showNotification({
type: 'error',
title: 'Fehler',
message: error.message || 'Profil konnte nicht aktualisiert werden'
});
} finally {
setLoading(false);
} catch (error) {
// Backend validation errors are already handled by executeWithValidation
// We only need to handle unexpected errors here
console.error('Unexpected error:', error);
}
};
@@ -179,12 +184,30 @@ const Settings: React.FC = () => {
e.preventDefault();
if (!currentUser) return;
// Validation
if (passwordForm.newPassword.length < 6) {
// BASIC FRONTEND VALIDATION: Only check minimum requirements
if (!passwordForm.currentPassword) {
showNotification({
type: 'error',
title: 'Fehler',
message: 'Das neue Passwort muss mindestens 6 Zeichen lang sein'
message: 'Aktuelles Passwort ist erforderlich'
});
return;
}
if (!passwordForm.newPassword) {
showNotification({
type: 'error',
title: 'Fehler',
message: 'Neues Passwort ist erforderlich'
});
return;
}
if (passwordForm.newPassword.length < 8) {
showNotification({
type: 'error',
title: 'Fehler',
message: 'Das neue Passwort muss mindestens 8 Zeichen lang sein'
});
return;
}
@@ -199,34 +222,30 @@ const Settings: React.FC = () => {
}
try {
setLoading(true);
// Use the actual password change endpoint
await employeeService.changePassword(currentUser.id, {
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword
});
// Use executeWithValidation to handle backend validation
await executeWithValidation(async () => {
await employeeService.changePassword(currentUser.id, {
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword,
confirmPassword: passwordForm.confirmPassword
});
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Passwort erfolgreich geändert'
});
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Passwort erfolgreich geändert'
});
// Clear password form
setPasswordForm({
currentPassword: '',
newPassword: '',
confirmPassword: ''
// Clear password form
setPasswordForm({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
});
} catch (error: any) {
showNotification({
type: 'error',
title: 'Fehler',
message: error.message || 'Passwort konnte nicht geändert werden'
});
} finally {
setLoading(false);
} catch (error) {
// Backend validation errors are already handled by executeWithValidation
console.error('Unexpected error:', error);
}
};
@@ -243,12 +262,18 @@ const Settings: React.FC = () => {
setShowAvailabilityManager(false);
};
// Clear validation errors when switching tabs
const handleTabChange = (tab: 'profile' | 'password' | 'availability') => {
clearErrors();
setActiveTab(tab);
};
if (!currentUser) {
return <div style={{
textAlign: 'center',
padding: '3rem',
color: '#666',
fontSize: '1.1rem'
return <div style={{
textAlign: 'center',
padding: '3rem',
color: '#666',
fontSize: '1.1rem'
}}>Nicht eingeloggt</div>;
}
@@ -270,10 +295,10 @@ const Settings: React.FC = () => {
<h1 style={styles.title}>Einstellungen</h1>
<div style={styles.subtitle}>Verwalten Sie Ihre Kontoeinstellungen und Präferenzen</div>
</div>
<div style={styles.tabs}>
<button
onClick={() => setActiveTab('profile')}
onClick={() => handleTabChange('profile')}
style={{
...styles.tab,
...(activeTab === 'profile' ? styles.tabActive : {})
@@ -299,9 +324,9 @@ const Settings: React.FC = () => {
<span style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '2px' }}>Persönliche Informationen</span>
</div>
</button>
<button
onClick={() => setActiveTab('password')}
onClick={() => handleTabChange('password')}
style={{
...styles.tab,
...(activeTab === 'password' ? styles.tabActive : {})
@@ -327,9 +352,9 @@ const Settings: React.FC = () => {
<span style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '2px' }}>Sicherheitseinstellungen</span>
</div>
</button>
<button
onClick={() => setActiveTab('availability')}
onClick={() => handleTabChange('availability')}
style={{
...styles.tab,
...(activeTab === 'availability' ? styles.tabActive : {})
@@ -369,7 +394,7 @@ const Settings: React.FC = () => {
Verwalten Sie Ihre persönlichen Informationen und Kontaktdaten
</p>
</div>
<form onSubmit={handleProfileUpdate} style={{ marginTop: '2rem' }}>
<div style={styles.formGrid}>
{/* Read-only information */}
@@ -480,28 +505,28 @@ const Settings: React.FC = () => {
<div style={styles.actions}>
<button
type="submit"
disabled={loading || !profileForm.firstname.trim() || !profileForm.lastname.trim()}
disabled={isSubmitting || !profileForm.firstname.trim() || !profileForm.lastname.trim()}
style={{
...styles.button,
...styles.buttonPrimary,
...((loading || !profileForm.firstname.trim() || !profileForm.lastname.trim()) ? styles.buttonDisabled : {})
...((isSubmitting || !profileForm.firstname.trim() || !profileForm.lastname.trim()) ? styles.buttonDisabled : {})
}}
onMouseEnter={(e) => {
if (!loading && profileForm.firstname.trim() && profileForm.lastname.trim()) {
if (!isSubmitting && profileForm.firstname.trim() && profileForm.lastname.trim()) {
e.currentTarget.style.background = styles.buttonPrimaryHover.background;
e.currentTarget.style.transform = styles.buttonPrimaryHover.transform;
e.currentTarget.style.boxShadow = styles.buttonPrimaryHover.boxShadow;
}
}}
onMouseLeave={(e) => {
if (!loading && profileForm.firstname.trim() && profileForm.lastname.trim()) {
if (!isSubmitting && profileForm.firstname.trim() && profileForm.lastname.trim()) {
e.currentTarget.style.background = styles.buttonPrimary.background;
e.currentTarget.style.transform = 'none';
e.currentTarget.style.boxShadow = styles.buttonPrimary.boxShadow;
}
}}
>
{loading ? '⏳ Wird gespeichert...' : 'Profil aktualisieren'}
{isSubmitting ? '⏳ Wird gespeichert...' : 'Profil aktualisieren'}
</button>
</div>
</form>
@@ -517,7 +542,7 @@ const Settings: React.FC = () => {
Aktualisieren Sie Ihr Passwort für erhöhte Sicherheit
</p>
</div>
<form onSubmit={handlePasswordUpdate} style={{ marginTop: '2rem' }}>
<div style={styles.formGridCompact}>
{/* Current Password Field */}
@@ -575,9 +600,9 @@ const Settings: React.FC = () => {
value={passwordForm.newPassword}
onChange={handlePasswordChange}
required
minLength={6}
minLength={8}
style={styles.fieldInputWithIcon}
placeholder="Mindestens 6 Zeichen"
placeholder="Mindestens 8 Zeichen"
onFocus={(e) => {
e.target.style.borderColor = '#1a1325';
e.target.style.boxShadow = '0 0 0 3px rgba(26, 19, 37, 0.1)';
@@ -606,7 +631,7 @@ const Settings: React.FC = () => {
</button>
</div>
<div style={styles.fieldHint}>
Das Passwort muss mindestens 6 Zeichen lang sein.
Das Passwort muss mindestens 8 Zeichen lang sein.
</div>
</div>
@@ -657,28 +682,28 @@ const Settings: React.FC = () => {
<div style={styles.actions}>
<button
type="submit"
disabled={loading || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword}
disabled={isSubmitting || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword}
style={{
...styles.button,
...styles.buttonPrimary,
...((loading || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) ? styles.buttonDisabled : {})
...((isSubmitting || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) ? styles.buttonDisabled : {})
}}
onMouseEnter={(e) => {
if (!loading && passwordForm.currentPassword && passwordForm.newPassword && passwordForm.confirmPassword) {
if (!isSubmitting && passwordForm.currentPassword && passwordForm.newPassword && passwordForm.confirmPassword) {
e.currentTarget.style.background = styles.buttonPrimaryHover.background;
e.currentTarget.style.transform = styles.buttonPrimaryHover.transform;
e.currentTarget.style.boxShadow = styles.buttonPrimaryHover.boxShadow;
}
}}
onMouseLeave={(e) => {
if (!loading && passwordForm.currentPassword && passwordForm.newPassword && passwordForm.confirmPassword) {
if (!isSubmitting && passwordForm.currentPassword && passwordForm.newPassword && passwordForm.confirmPassword) {
e.currentTarget.style.background = styles.buttonPrimary.background;
e.currentTarget.style.transform = 'none';
e.currentTarget.style.boxShadow = styles.buttonPrimary.boxShadow;
}
}}
>
{loading ? '⏳ Wird geändert...' : 'Passwort ändern'}
{isSubmitting ? '⏳ Wird geändert...' : 'Passwort ändern'}
</button>
</div>
</form>
@@ -694,16 +719,16 @@ const Settings: React.FC = () => {
Legen Sie Ihre persönliche Verfügbarkeit für Schichtpläne fest
</p>
</div>
<div style={styles.availabilityCard}>
<div style={styles.availabilityIcon}>📅</div>
<h3 style={styles.availabilityTitle}>Verfügbarkeit verwalten</h3>
<p style={styles.availabilityDescription}>
Hier können Sie Ihre persönliche Verfügbarkeit für Schichtpläne festlegen.
Legen Sie für jeden Tag und jede Schicht fest, ob Sie bevorzugt, möglicherweise
Legen Sie für jeden Tag und jede Schicht fest, ob Sie bevorzugt, möglicherweise
oder nicht verfügbar sind.
</p>
<button
onClick={() => setShowAvailabilityManager(true)}
style={{

View File

@@ -1,275 +1,275 @@
// frontend/src/pages/Settings/type/SettingsType.tsx - CORRECTED
// frontend/src/pages/Settings/type/SettingsType.tsx
export const styles = {
container: {
display: 'flex',
minHeight: 'calc(100vh - 120px)',
background: '#FBFAF6',
padding: '2rem',
maxWidth: '1200px',
margin: '0 auto',
gap: '2rem',
},
sidebar: {
width: '280px',
background: '#FBFAF6',
borderRadius: '16px',
border: '1px solid rgba(255, 255, 255, 0.8)',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04)',
padding: '1.5rem',
height: 'fit-content',
position: 'sticky' as const,
top: '2rem',
},
header: {
marginBottom: '2rem',
paddingBottom: '1.5rem',
borderBottom: '1px solid rgba(26, 19, 37, 0.1)',
},
title: {
fontSize: '1.5rem',
fontWeight: 600,
color: '#161718',
margin: '0 0 0.5rem 0',
},
subtitle: {
fontSize: '0.95rem',
color: '#666',
fontWeight: 400,
lineHeight: 1.5,
},
tabs: {
display: 'flex',
flexDirection: 'column' as const,
gap: '0.5rem',
},
tab: {
display: 'flex',
alignItems: 'center',
gap: '1rem',
padding: '1rem 1.25rem',
background: 'transparent',
color: '#666',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontWeight: 500,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
textAlign: 'left' as const,
width: '100%',
},
tabActive: {
background: '#51258f',
color: '#FBFAF6',
boxShadow: '0 4px 12px rgba(26, 19, 37, 0.15)',
},
tabHover: {
background: 'rgba(81, 37, 143, 0.1)',
color: '#1a1325',
transform: 'translateX(4px)',
},
content: {
flex: 1,
background: '#FBFAF6',
padding: '2.5rem',
borderRadius: '16px',
border: '1px solid rgba(255, 255, 255, 0.8)',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04)',
backdropFilter: 'blur(10px)',
minHeight: '100px',
},
section: {
marginBottom: '2rem',
},
sectionTitle: {
fontSize: '1.75rem',
fontWeight: 600,
color: '#161718',
margin: '0 0 0.5rem 0',
},
sectionDescription: {
color: '#666',
fontSize: '1rem',
margin: 0,
lineHeight: 1.5,
},
formGrid: {
display: 'grid',
gap: '1.5rem',
},
formGridCompact: {
display: 'grid',
gap: '1.5rem',
maxWidth: '500px',
},
infoCard: {
padding: '1.5rem',
background: 'rgba(26, 19, 37, 0.02)',
borderRadius: '12px',
border: '1px solid rgba(26, 19, 37, 0.1)',
},
infoCardTitle: {
fontSize: '1rem',
fontWeight: 600,
color: '#1a1325',
margin: '0 0 1rem 0',
},
infoGrid: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '1rem',
},
field: {
display: 'flex',
flexDirection: 'column' as const,
gap: '0.5rem',
width: '100%',
},
fieldLabel: {
fontSize: '0.9rem',
fontWeight: 600,
color: '#161718',
width: '100%',
},
fieldInputContainer: {
position: 'relative' as const,
width: '100%',
},
fieldInput: {
padding: '0.875rem 1rem',
border: '1.5px solid #e8e8e8',
borderRadius: '8px',
fontSize: '0.95rem',
background: '#FBFAF6',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
color: '#161718',
width: '100%',
boxSizing: 'border-box' as const,
},
fieldInputWithIcon: {
padding: '0.875rem 1rem',
border: '1.5px solid #e8e8e8',
borderRadius: '8px',
fontSize: '0.95rem',
background: '#FBFAF6',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
color: '#161718',
width: '100%',
paddingRight: '40px',
boxSizing: 'border-box' as const,
},
fieldInputDisabled: {
padding: '0.875rem 1rem',
border: '1.5px solid rgba(26, 19, 37, 0.1)',
borderRadius: '8px',
fontSize: '0.95rem',
background: 'rgba(26, 19, 37, 0.05)',
color: '#666',
cursor: 'not-allowed',
width: '100%',
boxSizing: 'border-box' as const,
},
fieldHint: {
fontSize: '0.8rem',
color: '#888',
marginTop: '0.25rem',
width: '100%',
},
passwordToggleButton: {
position: 'absolute' as const,
right: '10px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '5px',
borderRadius: '4px',
transition: 'background-color 0.2s',
userSelect: 'none' as const,
WebkitUserSelect: 'none' as const,
touchAction: 'manipulation' as const,
},
actions: {
display: 'flex',
justifyContent: 'flex-end',
marginTop: '2.5rem',
paddingTop: '1.5rem',
borderTop: '1px solid rgba(26, 19, 37, 0.1)',
},
button: {
padding: '0.875rem 2rem',
border: 'none',
borderRadius: '8px',
fontSize: '0.95rem',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
position: 'relative' as const,
overflow: 'hidden' as const,
},
buttonPrimary: {
background: '#1a1325',
color: '#FBFAF6',
boxShadow: '0 2px 8px rgba(26, 19, 37, 0.2)',
},
buttonPrimaryHover: {
background: '#24163a',
transform: 'translateY(-1px)',
boxShadow: '0 4px 16px rgba(26, 19, 37, 0.3)',
},
buttonDisabled: {
background: '#ccc',
color: '#666',
cursor: 'not-allowed',
transform: 'none',
boxShadow: 'none',
},
availabilityCard: {
padding: '3rem 2rem',
textAlign: 'center' as const,
background: 'rgba(26, 19, 37, 0.03)',
borderRadius: '16px',
border: '2px dashed rgba(26, 19, 37, 0.1)',
backdropFilter: 'blur(10px)',
},
availabilityIcon: {
fontSize: '3rem',
marginBottom: '1.5rem',
opacity: 0.8,
},
availabilityTitle: {
fontSize: '1.5rem',
fontWeight: 600,
color: '#161718',
margin: '0 0 1rem 0',
},
availabilityDescription: {
color: '#666',
marginBottom: '2rem',
lineHeight: 1.6,
maxWidth: '500px',
marginLeft: 'auto',
marginRight: 'auto',
},
infoHint: {
padding: '1.25rem',
background: 'rgba(26, 19, 37, 0.05)',
border: '1px solid rgba(26, 19, 37, 0.1)',
borderRadius: '12px',
fontSize: '0.9rem',
color: '#161718',
textAlign: 'left' as const,
maxWidth: '400px',
margin: '0 auto',
},
infoList: {
margin: '0.75rem 0 0 1rem',
padding: 0,
listStyle: 'none',
},
infoListItem: {
marginBottom: '0.5rem',
position: 'relative' as const,
paddingLeft: '1rem',
},
};
container: {
display: 'flex',
minHeight: 'calc(100vh - 120px)',
background: '#FBFAF6',
padding: '2rem',
maxWidth: '1200px',
margin: '0 auto',
gap: '2rem',
},
sidebar: {
width: '280px',
background: '#FBFAF6',
borderRadius: '16px',
border: '1px solid rgba(255, 255, 255, 0.8)',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04)',
padding: '1.5rem',
height: 'fit-content',
position: 'sticky' as const,
top: '2rem',
},
header: {
marginBottom: '2rem',
paddingBottom: '1.5rem',
borderBottom: '1px solid rgba(26, 19, 37, 0.1)',
},
title: {
fontSize: '1.5rem',
fontWeight: 600,
color: '#161718',
margin: '0 0 0.5rem 0',
},
subtitle: {
fontSize: '0.95rem',
color: '#666',
fontWeight: 400,
lineHeight: 1.5,
},
tabs: {
display: 'flex',
flexDirection: 'column' as const,
gap: '0.5rem',
},
tab: {
display: 'flex',
alignItems: 'center',
gap: '1rem',
padding: '1rem 1.25rem',
background: 'transparent',
color: '#666',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontWeight: 500,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
textAlign: 'left' as const,
width: '100%',
},
tabActive: {
background: '#51258f',
color: '#FBFAF6',
boxShadow: '0 4px 12px rgba(26, 19, 37, 0.15)',
},
tabHover: {
background: 'rgba(81, 37, 143, 0.1)',
color: '#1a1325',
transform: 'translateX(4px)',
},
content: {
flex: 1,
background: '#FBFAF6',
padding: '2.5rem',
borderRadius: '16px',
border: '1px solid rgba(255, 255, 255, 0.8)',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04)',
backdropFilter: 'blur(10px)',
minHeight: '100px',
},
section: {
marginBottom: '2rem',
},
sectionTitle: {
fontSize: '1.75rem',
fontWeight: 600,
color: '#161718',
margin: '0 0 0.5rem 0',
},
sectionDescription: {
color: '#666',
fontSize: '1rem',
margin: 0,
lineHeight: 1.5,
},
formGrid: {
display: 'grid',
gap: '1.5rem',
},
formGridCompact: {
display: 'grid',
gap: '1.5rem',
maxWidth: '500px',
},
infoCard: {
padding: '1.5rem',
background: 'rgba(26, 19, 37, 0.02)',
borderRadius: '12px',
border: '1px solid rgba(26, 19, 37, 0.1)',
},
infoCardTitle: {
fontSize: '1rem',
fontWeight: 600,
color: '#1a1325',
margin: '0 0 1rem 0',
},
infoGrid: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '1rem',
},
field: {
display: 'flex',
flexDirection: 'column' as const,
gap: '0.5rem',
width: '100%',
},
fieldLabel: {
fontSize: '0.9rem',
fontWeight: 600,
color: '#161718',
width: '100%',
},
fieldInputContainer: {
position: 'relative' as const,
width: '100%',
},
fieldInput: {
padding: '0.875rem 1rem',
border: '1.5px solid #e8e8e8',
borderRadius: '8px',
fontSize: '0.95rem',
background: '#FBFAF6',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
color: '#161718',
width: '100%',
boxSizing: 'border-box' as const,
},
fieldInputWithIcon: {
padding: '0.875rem 1rem',
border: '1.5px solid #e8e8e8',
borderRadius: '8px',
fontSize: '0.95rem',
background: '#FBFAF6',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
color: '#161718',
width: '100%',
paddingRight: '40px',
boxSizing: 'border-box' as const,
},
fieldInputDisabled: {
padding: '0.875rem 1rem',
border: '1.5px solid rgba(26, 19, 37, 0.1)',
borderRadius: '8px',
fontSize: '0.95rem',
background: 'rgba(26, 19, 37, 0.05)',
color: '#666',
cursor: 'not-allowed',
width: '100%',
boxSizing: 'border-box' as const,
},
fieldHint: {
fontSize: '0.8rem',
color: '#888',
marginTop: '0.25rem',
width: '100%',
},
passwordToggleButton: {
position: 'absolute' as const,
right: '10px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '5px',
borderRadius: '4px',
transition: 'background-color 0.2s',
userSelect: 'none' as const,
WebkitUserSelect: 'none' as const,
touchAction: 'manipulation' as const,
},
actions: {
display: 'flex',
justifyContent: 'flex-end',
marginTop: '2.5rem',
paddingTop: '1.5rem',
borderTop: '1px solid rgba(26, 19, 37, 0.1)',
},
button: {
padding: '0.875rem 2rem',
border: 'none',
borderRadius: '8px',
fontSize: '0.95rem',
fontWeight: 600,
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
position: 'relative' as const,
overflow: 'hidden' as const,
},
buttonPrimary: {
background: '#1a1325',
color: '#FBFAF6',
boxShadow: '0 2px 8px rgba(26, 19, 37, 0.2)',
},
buttonPrimaryHover: {
background: '#24163a',
transform: 'translateY(-1px)',
boxShadow: '0 4px 16px rgba(26, 19, 37, 0.3)',
},
buttonDisabled: {
background: '#ccc',
color: '#666',
cursor: 'not-allowed',
transform: 'none',
boxShadow: 'none',
},
availabilityCard: {
padding: '3rem 2rem',
textAlign: 'center' as const,
background: 'rgba(26, 19, 37, 0.03)',
borderRadius: '16px',
border: '2px dashed rgba(26, 19, 37, 0.1)',
backdropFilter: 'blur(10px)',
},
availabilityIcon: {
fontSize: '3rem',
marginBottom: '1.5rem',
opacity: 0.8,
},
availabilityTitle: {
fontSize: '1.5rem',
fontWeight: 600,
color: '#161718',
margin: '0 0 1rem 0',
},
availabilityDescription: {
color: '#666',
marginBottom: '2rem',
lineHeight: 1.6,
maxWidth: '500px',
marginLeft: 'auto',
marginRight: 'auto',
},
infoHint: {
padding: '1.25rem',
background: 'rgba(26, 19, 37, 0.05)',
border: '1px solid rgba(26, 19, 37, 0.1)',
borderRadius: '12px',
fontSize: '0.9rem',
color: '#161718',
textAlign: 'left' as const,
maxWidth: '400px',
margin: '0 auto',
},
infoList: {
margin: '0.75rem 0 0 1rem',
padding: 0,
listStyle: 'none',
},
infoListItem: {
marginBottom: '0.5rem',
position: 'relative' as const,
paddingLeft: '1rem',
},
};

View File

@@ -32,7 +32,7 @@ const useSetup = () => {
const steps: SetupStep[] = [
{
id: 'profile-setup',
id: 'profile-setup',
title: 'Profilinformationen',
subtitle: 'Geben Sie Ihre persönlichen Daten ein'
},
@@ -62,8 +62,8 @@ const useSetup = () => {
};
const validateStep2 = (): boolean => {
if (formData.password.length < 6) {
setError('Das Passwort muss mindestens 6 Zeichen lang sein.');
if (formData.password.length < 8) {
setError('Das Passwort muss mindestens 8 Zeichen lang sein.');
return false;
}
if (formData.password !== formData.confirmPassword) {
@@ -87,7 +87,7 @@ const useSetup = () => {
// ===== NAVIGATIONS-FUNKTIONEN =====
const goToNextStep = async (): Promise<void> => {
setError('');
if (!validateCurrentStep(currentStep)) {
return;
}
@@ -111,7 +111,7 @@ const useSetup = () => {
const handleStepChange = (stepIndex: number): void => {
setError('');
// Nur erlauben, zu bereits validierten Schritten zu springen
// oder zum nächsten Schritt nach dem aktuellen
if (stepIndex <= currentStep + 1) {
@@ -163,7 +163,7 @@ const useSetup = () => {
// Setup Status neu prüfen
await checkSetupStatus();
} catch (err: any) {
console.error('❌ Setup error:', err);
setError(err.message || 'Ein unerwarteter Fehler ist aufgetreten');
@@ -177,7 +177,7 @@ const useSetup = () => {
if (!formData.firstname.trim() || !formData.lastname.trim()) {
return 'vorname.nachname@sp.de';
}
const cleanFirstname = formData.firstname.toLowerCase().replace(/[^a-z0-9]/g, '');
const cleanLastname = formData.lastname.toLowerCase().replace(/[^a-z0-9]/g, '');
return `${cleanFirstname}.${cleanLastname}@sp.de`;
@@ -186,8 +186,8 @@ const useSetup = () => {
const isStepCompleted = (stepIndex: number): boolean => {
switch (stepIndex) {
case 0:
return formData.password.length >= 6 &&
formData.password === formData.confirmPassword;
return formData.password.length >= 8 &&
formData.password === formData.confirmPassword;
case 1:
return !!formData.firstname.trim() && !!formData.lastname.trim();
default:
@@ -202,13 +202,13 @@ const useSetup = () => {
loading,
error,
steps,
// Actions
goToNextStep,
goToPrevStep,
handleStepChange,
handleInputChange,
// Helpers
getEmailPreview,
isStepCompleted
@@ -223,16 +223,16 @@ interface StepContentProps {
currentStep: number;
}
const Step1Content: React.FC<StepContentProps> = ({
formData,
const Step1Content: React.FC<StepContentProps> = ({
formData,
onInputChange,
getEmailPreview
getEmailPreview
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div>
<label style={{
display: 'block',
marginBottom: '0.5rem',
<label style={{
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#495057'
}}>
@@ -257,9 +257,9 @@ const Step1Content: React.FC<StepContentProps> = ({
</div>
<div>
<label style={{
display: 'block',
marginBottom: '0.5rem',
<label style={{
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#495057'
}}>
@@ -284,17 +284,17 @@ const Step1Content: React.FC<StepContentProps> = ({
</div>
<div>
<label style={{
display: 'block',
marginBottom: '0.5rem',
<label style={{
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#495057'
}}>
Automatisch generierte E-Mail
</label>
<div style={{
padding: '0.75rem',
backgroundColor: '#e9ecef',
<div style={{
padding: '0.75rem',
backgroundColor: '#e9ecef',
border: '1px solid #ced4da',
borderRadius: '6px',
color: '#495057',
@@ -303,8 +303,8 @@ const Step1Content: React.FC<StepContentProps> = ({
}}>
{getEmailPreview()}
</div>
<div style={{
fontSize: '0.875rem',
<div style={{
fontSize: '0.875rem',
color: '#6c757d',
marginTop: '0.25rem'
}}>
@@ -315,15 +315,15 @@ const Step1Content: React.FC<StepContentProps> = ({
);
const Step2Content: React.FC<StepContentProps> = ({
formData,
onInputChange
const Step2Content: React.FC<StepContentProps> = ({
formData,
onInputChange
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div>
<label style={{
display: 'block',
marginBottom: '0.5rem',
<label style={{
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#495057'
}}>
@@ -342,16 +342,16 @@ const Step2Content: React.FC<StepContentProps> = ({
fontSize: '1rem',
transition: 'border-color 0.3s ease'
}}
placeholder="Mindestens 6 Zeichen"
placeholder="Mindestens 8 Zeichen"
required
autoComplete="new-password"
/>
</div>
<div>
<label style={{
display: 'block',
marginBottom: '0.5rem',
<label style={{
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#495057'
}}>
@@ -378,26 +378,26 @@ const Step2Content: React.FC<StepContentProps> = ({
</div>
);
const Step3Content: React.FC<StepContentProps> = ({
const Step3Content: React.FC<StepContentProps> = ({
formData,
getEmailPreview
getEmailPreview
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div style={{
backgroundColor: '#f8f9fa',
padding: '1.5rem',
<div style={{
backgroundColor: '#f8f9fa',
padding: '1.5rem',
borderRadius: '8px',
border: '1px solid #e9ecef'
}}>
<h3 style={{
marginBottom: '1rem',
<h3 style={{
marginBottom: '1rem',
color: '#2c3e50',
fontSize: '1.1rem',
fontWeight: '600'
}}>
Zusammenfassung
</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#6c757d' }}>E-Mail:</span>
@@ -413,15 +413,15 @@ const Step3Content: React.FC<StepContentProps> = ({
</div>
</div>
</div>
<div style={{
<div style={{
padding: '1rem',
backgroundColor: '#e7f3ff',
borderRadius: '6px',
border: '1px solid #b6d7e8',
color: '#2c3e50'
}}>
<strong>💡 Wichtig:</strong> Nach dem Setup können Sie sich mit Ihrer
<strong>💡 Wichtig:</strong> Nach dem Setup können Sie sich mit Ihrer
automatisch generierten E-Mail anmelden.
</div>
</div>
@@ -464,7 +464,7 @@ const Setup: React.FC = () => {
const getNextButtonText = (): string => {
if (loading) return '⏳ Wird verarbeitet...';
switch (currentStep) {
case 0:
return 'Weiter →';
@@ -479,9 +479,9 @@ const Setup: React.FC = () => {
// Inline Step Indicator Komponente
const StepIndicator: React.FC = () => (
<div style={{
display: 'flex',
justifyContent: 'space-between',
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '2.5rem',
position: 'relative',
@@ -497,18 +497,18 @@ const Setup: React.FC = () => {
backgroundColor: '#e9ecef',
zIndex: 1
}} />
{steps.map((step, index) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
const isClickable = index <= currentStep + 1;
return (
<div
<div
key={step.id}
style={{
display: 'flex',
flexDirection: 'column',
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
zIndex: 2,
position: 'relative',
@@ -538,10 +538,10 @@ const Setup: React.FC = () => {
>
{index + 1}
</button>
<div style={{ textAlign: 'center' }}>
<div style={{
fontSize: '14px',
<div style={{
fontSize: '14px',
fontWeight: isCurrent ? '600' : '400',
color: isCurrent ? '#51258f' : '#6c757d'
}}>
@@ -555,8 +555,8 @@ const Setup: React.FC = () => {
);
return (
<div style={{
minHeight: '100vh',
<div style={{
minHeight: '100vh',
backgroundColor: '#f8f9fa',
display: 'flex',
alignItems: 'center',
@@ -573,15 +573,15 @@ const Setup: React.FC = () => {
border: '1px solid #e9ecef'
}}>
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
<h1 style={{
fontSize: '2rem',
fontWeight: 'bold',
<h1 style={{
fontSize: '2rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
color: '#2c3e50'
}}>
🚀 Erstkonfiguration
</h1>
<p style={{
<p style={{
color: '#6c757d',
fontSize: '1.1rem',
marginBottom: '2rem'
@@ -592,16 +592,16 @@ const Setup: React.FC = () => {
{/* Aktueller Schritt Titel und Beschreibung */}
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
<h2 style={{
fontSize: '1.5rem',
fontWeight: 'bold',
<h2 style={{
fontSize: '1.5rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
color: '#2c3e50'
}}>
{steps[currentStep].title}
</h2>
{steps[currentStep].subtitle && (
<p style={{
<p style={{
color: '#6c757d',
fontSize: '1rem'
}}>
@@ -633,9 +633,9 @@ const Setup: React.FC = () => {
</div>
{/* Navigations-Buttons */}
<div style={{
marginTop: '2rem',
display: 'flex',
<div style={{
marginTop: '2rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
@@ -655,7 +655,7 @@ const Setup: React.FC = () => {
>
Zurück
</button>
<button
onClick={goToNextStep}
disabled={loading}
@@ -677,10 +677,10 @@ const Setup: React.FC = () => {
{/* Zusätzliche Informationen */}
{currentStep === 2 && !loading && (
<div style={{
marginTop: '1.5rem',
textAlign: 'center',
color: '#6c757d',
<div style={{
marginTop: '1.5rem',
textAlign: 'center',
color: '#6c757d',
fontSize: '0.9rem',
padding: '1rem',
backgroundColor: '#f8f9fa',

View File

@@ -1,5 +1,7 @@
// frontend/src/services/authService.ts
// frontend/src/services/authService.ts - UPDATED
import { Employee } from '../models/Employee';
import { ErrorService } from './errorService';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
export interface LoginRequest {
@@ -23,6 +25,23 @@ export interface AuthResponse {
class AuthService {
private token: string | null = null;
private async handleApiResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const validationErrors = ErrorService.extractValidationErrors(errorData);
if (validationErrors.length > 0) {
const error = new Error('Validation failed');
(error as any).validationErrors = validationErrors;
throw error;
}
throw new Error(errorData.error || errorData.message || 'Authentication failed');
}
return response.json();
}
async login(credentials: LoginRequest): Promise<AuthResponse> {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
@@ -30,12 +49,7 @@ class AuthService {
body: JSON.stringify(credentials)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Login fehlgeschlagen');
}
const data: AuthResponse = await response.json();
const data = await this.handleApiResponse<AuthResponse>(response);
this.token = data.token;
localStorage.setItem('token', data.token);
localStorage.setItem('employee', JSON.stringify(data.employee));
@@ -49,11 +63,7 @@ class AuthService {
body: JSON.stringify(userData)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Registrierung fehlgeschlagen');
}
const data = await this.handleApiResponse<AuthResponse>(response);
return this.login({
email: userData.email,
password: userData.password
@@ -95,6 +105,7 @@ class AuthService {
this.token = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('employee');
}
getToken(): string | null {

View File

@@ -17,40 +17,40 @@ export class EmployeeService {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
const validationErrors = ErrorService.extractValidationErrors(errorData);
if (validationErrors.length > 0) {
const error = new Error('Validation failed');
(error as any).validationErrors = validationErrors;
throw error;
}
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
throw new Error(errorData.error || errorData.message || `HTTP error! status: ${response.status}`);
}
return response.json();
}
async getEmployees(includeInactive: boolean = false): Promise<Employee[]> {
console.log('🔄 Fetching employees from API...');
const token = localStorage.getItem('token');
console.log('🔑 Token exists:', !!token);
const response = await fetch(`${API_BASE_URL}/employees?includeInactive=${includeInactive}`, {
headers: getAuthHeaders(),
});
console.log('📡 Response status:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('❌ API Error:', errorText);
throw new Error('Failed to fetch employees');
}
const employees = await response.json();
console.log('✅ Employees received:', employees.length);
return employees;
}
@@ -58,12 +58,8 @@ export class EmployeeService {
const response = await fetch(`${API_BASE_URL}/employees/${id}`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error('Failed to fetch employee');
}
return response.json();
return this.handleApiResponse<Employee>(response);
}
async createEmployee(employee: CreateEmployeeRequest): Promise<Employee> {
@@ -72,7 +68,7 @@ export class EmployeeService {
headers: getAuthHeaders(),
body: JSON.stringify(employee),
});
return this.handleApiResponse<Employee>(response);
}
@@ -82,7 +78,7 @@ export class EmployeeService {
headers: getAuthHeaders(),
body: JSON.stringify(employee),
});
return this.handleApiResponse<Employee>(response);
}
@@ -91,7 +87,7 @@ export class EmployeeService {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete employee');
@@ -102,12 +98,8 @@ export class EmployeeService {
const response = await fetch(`${API_BASE_URL}/employees/${employeeId}/availabilities`, {
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error('Failed to fetch availabilities');
}
return response.json();
return this.handleApiResponse<EmployeeAvailability[]>(response);
}
async updateAvailabilities(employeeId: string, data: { planId: string, availabilities: Omit<EmployeeAvailability, 'id' | 'employeeId'>[] }): Promise<EmployeeAvailability[]> {
@@ -117,26 +109,18 @@ export class EmployeeService {
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to update availabilities');
}
return response.json();
return this.handleApiResponse<EmployeeAvailability[]>(response);
}
async changePassword(id: string, data: { currentPassword: string, newPassword: string }): Promise<void> {
async changePassword(id: string, data: { currentPassword: string, newPassword: string, confirmPassword: string }): Promise<void> {
const response = await fetch(`${API_BASE_URL}/employees/${id}/password`, {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to change password');
}
return this.handleApiResponse<void>(response);
}
async updateLastLogin(employeeId: string): Promise<void> {