Compare commits

..

16 Commits

41 changed files with 5581 additions and 2266 deletions

View File

@@ -1,4 +0,0 @@
# .env.production example
NODE_ENV=production
JWT_SECRET=your-secret-key
DATABASE_PATH=/app/data/production.db

16
.env.template Normal file
View File

@@ -0,0 +1,16 @@
# === SCHICHTPLANER DOCKER COMPOSE ENVIRONMENT VARIABLES ===
# Diese Datei wird von docker-compose automatisch geladen
# Security
JWT_SECRET=${JWT_SECRET:-your-secret-key-please-change}
NODE_ENV=${NODE_ENV:-production}
# Database
DB_PATH=${DB_PATH:-/app/data/database.db}
# Server
PORT=${PORT:-3002}
# App Configuration
APP_TITLE="Shift Planning App"
ENABLE_PRO=${ENABLE_PRO:-false}

View File

@@ -35,14 +35,19 @@ RUN npm run build --workspace=frontend
# Verify Python and OR-Tools installation # Verify Python and OR-Tools installation
RUN python -c "from ortools.sat.python import cp_model; print('OR-Tools installed successfully')" RUN python -c "from ortools.sat.python import cp_model; print('OR-Tools installed successfully')"
# Production stage (same as above) # Production stage
FROM node:20-bookworm FROM node:20-bookworm
WORKDIR /app WORKDIR /app
# Install system dependencies including gettext-base for envsubst
RUN apt-get update && apt-get install -y gettext-base && \
rm -rf /var/lib/apt/lists/*
RUN npm install -g pm2 RUN npm install -g pm2
RUN mkdir -p /app/data RUN mkdir -p /app/data
# Copy application files
COPY --from=builder /app/backend/dist/ ./dist/ COPY --from=builder /app/backend/dist/ ./dist/
COPY --from=builder /app/backend/package*.json ./ COPY --from=builder /app/backend/package*.json ./
@@ -54,6 +59,14 @@ COPY --from=builder /app/ecosystem.config.cjs ./
COPY --from=builder /app/backend/src/database/ ./dist/database/ COPY --from=builder /app/backend/src/database/ ./dist/database/
COPY --from=builder /app/backend/src/database/ ./database/ COPY --from=builder /app/backend/src/database/ ./database/
# Copy init script and env template
COPY docker-init.sh /usr/local/bin/
COPY .env.template ./
# Set execute permissions for init script
RUN chmod +x /usr/local/bin/docker-init.sh
# Create user and set permissions
RUN groupadd -g 1001 nodejs && \ RUN groupadd -g 1001 nodejs && \
useradd -m -u 1001 -s /bin/bash -g nodejs schichtplan && \ useradd -m -u 1001 -s /bin/bash -g nodejs schichtplan && \
chown -R schichtplan:nodejs /app && \ chown -R schichtplan:nodejs /app && \
@@ -61,10 +74,13 @@ RUN groupadd -g 1001 nodejs && \
chmod 775 /app/data chmod 775 /app/data
ENV PM2_HOME=/app/.pm2 ENV PM2_HOME=/app/.pm2
# Set entrypoint to init script and keep existing cmd
ENTRYPOINT ["/usr/local/bin/docker-init.sh"]
CMD ["pm2-runtime", "ecosystem.config.cjs"]
USER schichtplan USER schichtplan
EXPOSE 3002 EXPOSE 3002
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/api/health || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost:3002/api/health || exit 1
CMD ["pm2-runtime", "ecosystem.config.cjs"]

View File

@@ -18,17 +18,17 @@ function generateEmail(firstname: string, lastname: string): string {
const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, ''); const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, '');
const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, ''); const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
return `${cleanFirstname}.${cleanLastname}@sp.de`; return `${cleanFirstname}.${cleanLastname}@sp.de`;
} }
export const getEmployees = async (req: AuthRequest, res: Response): Promise<void> => { export const getEmployees = async (req: AuthRequest, res: Response): Promise<void> => {
try { try {
console.log('🔍 Fetching employees - User:', req.user); console.log('🔍 Fetching employees - User:', req.user);
const { includeInactive } = req.query; const { includeInactive } = req.query;
const includeInactiveFlag = includeInactive === 'true'; const includeInactiveFlag = includeInactive === 'true';
let query = ` let query = `
SELECT SELECT
e.id, e.email, e.firstname, e.lastname, 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 FROM employees e
LEFT JOIN employee_roles er ON e.id = er.employee_id LEFT JOIN employee_roles er ON e.id = er.employee_id
`; `;
if (!includeInactiveFlag) { if (!includeInactiveFlag) {
query += ' WHERE e.is_active = 1'; query += ' WHERE e.is_active = 1';
} }
query += ' ORDER BY e.firstname, e.lastname'; query += ' ORDER BY e.firstname, e.lastname';
const employees = await db.all<any>(query); const employees = await db.all<any>(query);
// Format employees with proper field names and roles array // 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***' password: '***hidden***'
}); });
const { const {
password, password,
firstname, firstname,
lastname, lastname,
roles = ['user'], roles = ['user'],
employeeType, employeeType,
contractType, contractType,
canWorkAlone = false, canWorkAlone = false,
isTrainee = false isTrainee = false
@@ -146,21 +146,21 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
// Validation // Validation
if (!password || !firstname || !lastname || !employeeType) { if (!password || !firstname || !lastname || !employeeType) {
console.log('❌ Validation failed: Missing required fields'); console.log('❌ Validation failed: Missing required fields');
res.status(400).json({ res.status(400).json({
error: 'Password, firstname, lastname und employeeType sind erforderlich' error: 'Password, firstname, lastname und employeeType sind erforderlich'
}); });
return; return;
} }
// ✅ ENHANCED: Validate employee type exists and get category info // ✅ 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 = ?', 'SELECT type, category, has_contract_type FROM employee_types WHERE type = ?',
[employeeType] [employeeType]
); );
if (!employeeTypeInfo) { if (!employeeTypeInfo) {
res.status(400).json({ res.status(400).json({
error: `Ungültiger employeeType: ${employeeType}. Gültige Typen: manager, personell, apprentice, guest` error: `Ungültiger employeeType: ${employeeType}. Gültige Typen: manager, personell, apprentice, guest`
}); });
return; return;
} }
@@ -169,22 +169,22 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
if (employeeTypeInfo.has_contract_type === 1) { if (employeeTypeInfo.has_contract_type === 1) {
// Internal types require contract type // Internal types require contract type
if (!contractType) { if (!contractType) {
res.status(400).json({ res.status(400).json({
error: `contractType ist erforderlich für employeeType: ${employeeType}` error: `contractType ist erforderlich für employeeType: ${employeeType}`
}); });
return; return;
} }
if (!['small', 'large', 'flexible'].includes(contractType)) { if (!['small', 'large', 'flexible'].includes(contractType)) {
res.status(400).json({ res.status(400).json({
error: `Ungültiger contractType: ${contractType}. Gültige Werte: small, large, flexible` error: `Ungültiger contractType: ${contractType}. Gültige Werte: small, large, flexible`
}); });
return; return;
} }
} else { } else {
// External types (guest) should not have contract type // External types (guest) should not have contract type
if (contractType) { if (contractType) {
res.status(400).json({ res.status(400).json({
error: `contractType ist nicht erlaubt für employeeType: ${employeeType}` error: `contractType ist nicht erlaubt für employeeType: ${employeeType}`
}); });
return; return;
} }
@@ -192,8 +192,8 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
// ✅ ENHANCED: isTrainee validation - only applicable for personell type // ✅ ENHANCED: isTrainee validation - only applicable for personell type
if (isTrainee && employeeType !== 'personell') { if (isTrainee && employeeType !== 'personell') {
res.status(400).json({ res.status(400).json({
error: `isTrainee ist nur für employeeType 'personell' erlaubt` error: `isTrainee ist nur für employeeType 'personell' erlaubt`
}); });
return; return;
} }
@@ -204,11 +204,11 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
// Check if generated email already exists // Check if generated email already exists
const existingUser = await db.get<any>('SELECT id FROM employees WHERE email = ? AND is_active = 1', [email]); const existingUser = await db.get<any>('SELECT id FROM employees WHERE email = ? AND is_active = 1', [email]);
if (existingUser) { if (existingUser) {
console.log('❌ Generated email already exists:', email); console.log('❌ Generated email already exists:', email);
res.status(409).json({ res.status(409).json({
error: `Employee with email ${email} already exists. Please use different firstname/lastname.` error: `Employee with email ${email} already exists. Please use different firstname/lastname.`
}); });
return; return;
} }
@@ -228,12 +228,12 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
is_active, is_trainee is_active, is_trainee
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
employeeId, employeeId,
email, email,
hashedPassword, hashedPassword,
firstname, firstname,
lastname, lastname,
employeeType, employeeType,
contractType, // Will be NULL for external types contractType, // Will be NULL for external types
canWorkAlone ? 1 : 0, canWorkAlone ? 1 : 0,
1, 1,
@@ -302,9 +302,9 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
const { id } = req.params; const { id } = req.params;
const { firstname, lastname, roles, isActive, employeeType, contractType, canWorkAlone, isTrainee } = req.body; const { firstname, lastname, roles, isActive, employeeType, contractType, canWorkAlone, isTrainee } = req.body;
console.log('📝 Update Employee Request:', { console.log('📝 Update Employee Request:', {
id, firstname, lastname, roles, isActive, id, firstname, lastname, roles, isActive,
employeeType, contractType, canWorkAlone, isTrainee employeeType, contractType, canWorkAlone, isTrainee
}); });
// Check if employee exists and get current data // Check if employee exists and get current data
@@ -314,6 +314,56 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
return; return;
} }
// Check if user is trying to remove their own admin role
const currentUser = req.user;
if (currentUser?.userId === id && roles) {
const currentUserRoles = await db.all<{ role: string }>(
'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;
}
}
// Check admin count if roles are being updated
if (roles) {
try {
await checkAdminCount(id, roles);
} catch (error: any) {
res.status(400).json({ error: error.message });
return;
}
}
// Check if trying to deactivate the last admin
if (isActive === false) {
const isEmployeeAdmin = await db.get<{ count: number }>(
`SELECT COUNT(*) as count FROM employee_roles WHERE employee_id = ? AND role = 'admin'`,
[id]
);
if (isEmployeeAdmin && isEmployeeAdmin.count > 0) {
const otherAdminCount = await db.get<{ count: number }>(
`SELECT COUNT(*) as count
FROM employee_roles er
JOIN employees e ON er.employee_id = e.id
WHERE er.role = 'admin' AND e.is_active = 1 AND er.employee_id != ?`,
[id]
);
if (!otherAdminCount || otherAdminCount.count === 0) {
res.status(400).json({ error: 'Cannot deactivate the last admin user' });
return;
}
}
}
// Validate employee type if provided // Validate employee type if provided
if (employeeType) { if (employeeType) {
const validEmployeeType = await db.get( const validEmployeeType = await db.get(
@@ -322,8 +372,8 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
); );
if (!validEmployeeType) { if (!validEmployeeType) {
res.status(400).json({ res.status(400).json({
error: `Ungültiger employeeType: ${employeeType}` error: `Ungültiger employeeType: ${employeeType}`
}); });
return; return;
} }
@@ -335,16 +385,16 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
const newFirstname = firstname || existingEmployee.firstname; const newFirstname = firstname || existingEmployee.firstname;
const newLastname = lastname || existingEmployee.lastname; const newLastname = lastname || existingEmployee.lastname;
email = generateEmail(newFirstname, newLastname); email = generateEmail(newFirstname, newLastname);
// Check if new email already exists (for another employee) // Check if new email already exists (for another employee)
const emailExists = await db.get<any>( 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] [email, id]
); );
if (emailExists) { if (emailExists) {
res.status(409).json({ res.status(409).json({
error: `Cannot update name - email ${email} already exists for another employee` error: `Cannot update name - email ${email} already exists for another employee`
}); });
return; return;
} }
@@ -373,7 +423,7 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
if (roles) { if (roles) {
// Delete existing roles // Delete existing roles
await db.run('DELETE FROM employee_roles WHERE employee_id = ?', [id]); await db.run('DELETE FROM employee_roles WHERE employee_id = ?', [id]);
// Insert new roles // Insert new roles
for (const role of roles) { for (const role of roles) {
await db.run( await db.run(
@@ -438,7 +488,15 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
const { id } = req.params; const { id } = req.params;
console.log('🗑️ Starting deletion process for employee ID:', id); console.log('🗑️ Starting deletion process for employee ID:', id);
// UPDATED: Check if employee exists with role from employee_roles const currentUser = req.user;
// Prevent self-deletion
if (currentUser?.userId === id) {
res.status(400).json({ error: 'You cannot delete yourself' });
return;
}
// Check if employee exists with role from employee_roles
const existingEmployee = await db.get<any>(` const existingEmployee = await db.get<any>(`
SELECT SELECT
e.id, e.email, e.firstname, e.lastname, e.is_active, e.id, e.email, e.firstname, e.lastname, e.is_active,
@@ -455,6 +513,26 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
return; return;
} }
const employeeRoles = await db.all<{ role: string }>(`
SELECT role FROM employee_roles WHERE employee_id = ?
`, [id]);
const isEmployeeAdmin = employeeRoles.some(role => role.role === 'admin');
// Check if this is the last admin
if (isEmployeeAdmin) {
const adminCount = await db.get<{ count: number }>(
`SELECT COUNT(*) as count
FROM employee_roles
WHERE role = 'admin'`
);
if (adminCount && adminCount.count <= 1) {
res.status(400).json({ error: 'Cannot delete the last admin user' });
return;
}
}
console.log('📝 Found employee to delete:', existingEmployee); console.log('📝 Found employee to delete:', existingEmployee);
// Start transaction // Start transaction
@@ -463,18 +541,18 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
try { try {
// 1. Remove availabilities // 1. Remove availabilities
await db.run('DELETE FROM employee_availability WHERE employee_id = ?', [id]); await db.run('DELETE FROM employee_availability WHERE employee_id = ?', [id]);
// 2. Remove from assigned_shifts (JSON field cleanup) // 2. Remove from assigned_shifts (JSON field cleanup)
interface AssignedShift { interface AssignedShift {
id: string; id: string;
assigned_employees: string; assigned_employees: string;
} }
const assignedShifts = await db.all<AssignedShift>( 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}%`] [`%${id}%`]
); );
for (const shift of assignedShifts) { for (const shift of assignedShifts) {
try { try {
const employeesArray: string[] = JSON.parse(shift.assigned_employees || '[]'); const employeesArray: string[] = JSON.parse(shift.assigned_employees || '[]');
@@ -503,7 +581,7 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
await db.run('COMMIT'); await db.run('COMMIT');
console.log('✅ Successfully deleted employee:', existingEmployee.email); console.log('✅ Successfully deleted employee:', existingEmployee.email);
res.status(204).send(); res.status(204).send();
} catch (error) { } catch (error) {
@@ -511,7 +589,6 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
console.error('Error during deletion transaction:', error); console.error('Error during deletion transaction:', error);
throw error; throw error;
} }
} catch (error) { } catch (error) {
console.error('Error deleting employee:', error); console.error('Error deleting employee:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
@@ -558,13 +635,49 @@ export const updateAvailabilities = async (req: AuthRequest, res: Response): Pro
const { employeeId } = req.params; const { employeeId } = req.params;
const { planId, availabilities } = req.body; const { planId, availabilities } = req.body;
// Check if employee exists // Check if employee exists and get contract type
const existingEmployee = await db.get('SELECT id FROM employees WHERE id = ?', [employeeId]); const existingEmployee = await db.get<any>(`
SELECT e.*, er.role
FROM employees e
LEFT JOIN employee_roles er ON e.id = er.employee_id
WHERE e.id = ?
`, [employeeId]);
if (!existingEmployee) { if (!existingEmployee) {
res.status(404).json({ error: 'Employee not found' }); res.status(404).json({ error: 'Employee not found' });
return; return;
} }
// Check if employee is active
if (!existingEmployee.is_active) {
res.status(400).json({ error: 'Cannot set availability for inactive employee' });
return;
}
// Validate contract type requirements
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'
});
return;
}
if (contractType === 'large' && availableCount < 3) {
res.status(400).json({
error: 'Employees with large contract must have at least 3 available shifts'
});
return;
}
// Flexible contract has no minimum requirement
await db.run('BEGIN TRANSACTION'); await db.run('BEGIN TRANSACTION');
try { try {
@@ -629,12 +742,12 @@ export const changePassword = async (req: AuthRequest, res: Response): Promise<v
// Get the current user from the auth middleware // Get the current user from the auth middleware
const currentUser = (req as AuthRequest).user; const currentUser = (req as AuthRequest).user;
// Check if user is changing their own password or is an admin // Check if user is changing their own password or is an admin
if (currentUser?.userId !== id && currentUser?.role !== 'admin') { if (currentUser?.userId !== id && currentUser?.role !== 'admin') {
res.status(403).json({ error: 'You can only change your own password' }); res.status(403).json({ error: 'You can only change your own password' });
return; return;
} }
// Check if employee exists and get password // Check if employee exists and get password
const employee = await db.get<{ password: string }>('SELECT password FROM employees WHERE id = ?', [id]); const employee = await db.get<{ password: string }>('SELECT password FROM employees WHERE id = ?', [id]);
@@ -643,8 +756,8 @@ export const changePassword = async (req: AuthRequest, res: Response): Promise<v
return; return;
} }
// For non-admin users, verify current password // Verify current password
if (currentUser?.role !== 'admin') { if (employee) {
const isValidPassword = await bcrypt.compare(currentPassword, employee.password); const isValidPassword = await bcrypt.compare(currentPassword, employee.password);
if (!isValidPassword) { if (!isValidPassword) {
res.status(400).json({ error: 'Current password is incorrect' }); res.status(400).json({ error: 'Current password is incorrect' });
@@ -653,8 +766,8 @@ export const changePassword = async (req: AuthRequest, res: Response): Promise<v
} }
// Validate new password // Validate new password
if (!newPassword || newPassword.length < 6) { if (!newPassword || newPassword.length < 8) {
res.status(400).json({ error: 'New password must be at least 6 characters long' }); res.status(400).json({ error: 'New password must be at least 8 characters long' });
return; return;
} }
@@ -685,13 +798,13 @@ export const updateLastLogin = async (req: AuthRequest, res: Response): Promise<
// Update last_login with current timestamp // Update last_login with current timestamp
const currentTimestamp = new Date().toISOString(); const currentTimestamp = new Date().toISOString();
await db.run( await db.run(
'UPDATE employees SET last_login = ? WHERE id = ?', 'UPDATE employees SET last_login = ? WHERE id = ?',
[currentTimestamp, id] [currentTimestamp, id]
); );
console.log(`✅ Last login updated for employee ${id}: ${currentTimestamp}`); console.log(`✅ Last login updated for employee ${id}: ${currentTimestamp}`);
res.json({ res.json({
message: 'Last login updated successfully', message: 'Last login updated successfully',
lastLogin: currentTimestamp lastLogin: currentTimestamp
}); });
@@ -699,4 +812,36 @@ export const updateLastLogin = async (req: AuthRequest, res: Response): Promise<
console.error('Error updating last login:', error); console.error('Error updating last login:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
} }
};
const checkAdminCount = async (employeeId: string, newRoles: string[]): Promise<void> => {
try {
// Count current admins excluding the employee being updated
const adminCountResult = await db.get<{ count: number }>(
`SELECT COUNT(DISTINCT employee_id) as count
FROM employee_roles
WHERE role = 'admin' AND employee_id != ?`,
[employeeId]
);
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 = ?`,
[employeeId]
);
const currentRoles = currentEmployeeRoles.map(role => role.role);
const isCurrentlyAdmin = currentRoles.includes('admin');
const willBeAdmin = newRoles.includes('admin');
// If removing admin role from the last admin, throw error
if (isCurrentlyAdmin && !willBeAdmin && currentAdminCount === 0) {
throw new Error('Cannot remove admin role from the last admin user');
}
} catch (error) {
throw error;
}
}; };

View File

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

View File

@@ -0,0 +1,25 @@
## User Settings
### \[UPDATE\] Personal availability
* Only the employee themselves can manage their availability
* Must select a valid shift plan with defined shifts
* All changes require explicit save action
### \[VIEW\] ShiftPlan assignments
* Published plans show actual assignments
* Draft plans show preview assignments (if calculated)
* Regular users can only view, not modify assignments
## System-wide
### \[ACCESS\] Role-based restrictions
* `admin`: Full access to all features
* `maintenance`: Access to shift plans and employee management (except admin users)
* `user`: Read-only access to shift plans, can manage own availability and profile
### \[DATA\] Validation rules
* Email addresses are automatically generated from firstname/lastname
* Employee status (`isActive`) controls login and planning eligibility
* Trainee status affects independence (`canWorkAlone`) automatically
* Date ranges must be valid (start before end)
* All required fields must be filled before form submission

View File

@@ -0,0 +1,37 @@
## Shift Assignment
### \[ACTION: update scheduled shift\]
* Requires valid scheduled shift ID
* Only updates assignedEmployees array
* Requires authentication with valid token
* Handles both JSON and non-JSON responses
### \[ACTION: assign shifts automatically\]
* Requires shift plan, employees, and availabilities
* Availability preferenceLevel must be 1, 2, or 3
* Constraints must be an array (converts non-array to empty array)
* All employees must have valid availability data
### \[ACTION: get scheduled shifts\]
* Requires valid plan ID
* Automatically fixes data structure inconsistencies:
- timeSlotId mapping (handles both naming conventions)
- requiredEmployees fallback to 2 if missing
- assignedEmployees fallback to empty array if missing
## Availability
### [UPDATE] availability
* planId: required valid UUID
* availabilities: required array with strict validation:
- shiftId: valid UUID
- preferenceLevel: 0 (unavailable), 1 (available), or 2 (preferred)
- notes: optional, max 500 characters
## Scheduling
### [ACTION: generate schedule]
* shiftPlan: required object with id (valid UUID)
* employees: required array with at least one employee, each with valid UUID
* availabilities: required array
* constraints: optional array

View File

@@ -0,0 +1,23 @@
## Authentication
### \[ACTION: login\]
* Requires valid email and password format:
- Minimum 8 characters
- Must contain uppercase, lowercase, number and special character
* Server validates credentials before issuing token
* Token and employee data stored in localStorage upon success
### \[ACTION: register\]
* `Password` optional but strict validation:
- Minimum 8 characters
- Must contain uppercase, lowercase, number and special character
* `firstname` 1-100 characters and must not be empty
* `lastname` 1-100 characters and must not be empty
* Requires valid email
* Role is optional during registration
* Automatically logs in user after successful registration
### \[ACTION: access protected resources\]
* Requires valid JWT token in Authorization header
* Token is automatically retrieved from localStorage
* Unauthorized requests (401) trigger automatic logout

View File

@@ -0,0 +1,23 @@
## Data Integrity
### \[GENERAL\] API communication
* All fetch requests include error handling
* Failed responses throw descriptive errors
* Token validation before protected operations
* Automatic localStorage cleanup on logout
### \[GENERAL\] data persistence
* Employee data cached in localStorage after login
* Token automatically retrieved from localStorage
* Data structure normalization for scheduled shifts
### \[GENERAL\] error handling
* Network errors are caught and logged
* HTTP errors include status codes and messages
* Failed authentication triggers cleanup and logout
## Role & Permission Notes
* The frontend services don't explicitly restrict actions by role
* Role-based restrictions are likely handled by the backend
* Frontend assumes user has permissions for requested operations
* 401 responses indicate insufficient permissions at backend level

View File

@@ -0,0 +1,75 @@
## Employee Management
### \[CREATE/UPDATE\] employee
* All employee operations require authentication
* Password changes require current password + new password
* Only authenticated users can create/update employees
### \[ACTION: delete employee\]
* Requires authentication
* Server validates permissions before deletion
### \[ACTION: update availability\]
* Requires employee ID and plan ID
* Availability updates must include valid preference levels
* Only authenticated users can update availabilities
### \[ACTION: update last login\]
* Requires employee ID
* Fails silently if update fails (logs error but doesn`t block user)
## Employee
### \[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 8 characters (in create mode)
* `employeeType` must be `manager`, `personell`, `apprentice`, or `guest`
* `canWorkAlone` optional boolean
* `isTrainee` optional boolean
* `isActive` optional boolean (default true)
* Contract type validation:
* `manager`, `apprentice` => `contractType` = flexible
* `guest` => `contractType` = undefined/NONE
* `personell` => `contractType` = small || large
### \[UPDATE\] Employee profile
* `firstname` 1-100 characters and must not be empty
* `lastname` 1-100 characters and must not be empty
* `employeeType` must be valid type if provided
* `contractType` must be valid type if provided
* `roles` must be valid array of roles if provided
* Only the employee themselves or admins can update
### \[UPDATE\] Employee password
* `newPassword` optional but strict validation:
- Minimum 8 characters
- Must contain uppercase, lowercase, number and special character
* `newPassword` must match `confirmPassword`
* For admin password reset: no `currentPassword` required
* For self-password change: `currentPassword` required
### \[UPDATE\] Employee roles
* Only users with role `admin` can modify roles
* At least one employee must maintain `admin` role
* Users cannot remove their own admin role
### \[UPDATE\] Employee availability
* Only active employees can set availability
* Contract type requirements:
* `small` contract: minimum 2 available shifts (preference level 1 or 2)
* `large` contract: minimum 3 available shifts (preference level 1 or 2)
* `flexible` contract: no minimum requirement
* Availability can only be set for valid shift patterns in selected plan
* `shiftId` must be valid and exist in the current plan
### \[ACTION: delete\] Employee
* Only users with role `admin` can delete employees
* Cannot delete yourself
* Cannot delete the last admin user
* User confirmation required before deletion
### \[ACTION: edit\] Employee
* Admins can edit all employees
* Maintenance users can edit non-admin employees or themselves
* Regular users can only edit themselves

View File

@@ -0,0 +1,66 @@
## Shift Plan Management
### \[CREATE\] shift plan
* All operations require authentication
* 401 responses trigger automatic logout
* Scheduled shifts array is guaranteed to exist (empty array if none)
### \[CREATE\] shift plan from preset
* presetName must match existing TEMPLATE_PRESETS
* Requires name, startDate, and endDate
* isTemplate is optional (defaults to false)
### \[UPDATE\] shift plan
* Requires valid shift plan ID
* Partial updates allowed
* Authentication required
### \[ACTION: delete shift plan\]
* Requires authentication
* 401 responses trigger automatic logout
### \[ACTION: regenerate scheduled shifts\]
* Requires valid plan ID
* Authentication required
* Fails silently if regeneration fails (logs error but continues)
### \[ACTION: clear assignments\]
* Requires valid plan ID
* Authentication required
* Clears all employee assignments from scheduled shifts
## ShiftPlan
### \[CREATE\] ShiftPlan from template
* `planName` must not be empty
* `startDate` must be set
* `endDate` must be set
* `endDate` must be after `startDate`
* `selectedPreset` must be chosen (template must be selected)
* Only available template presets can be used
### \[ACTION: publish\] ShiftPlan
* Plan must be in 'draft' status
* All active employees must have set their availabilities for the plan
* Only users with roles \['admin', 'maintenance'\] can publish
* Assignment algorithm must not have critical violations (ERROR or ❌ KRITISCH)
* employee && employee.contract_type === small => mind. 1 mal availability === 1 || availability === 2
* employee && employee.contract_type === large => mind. 3 mal availability === 1 || availability === 2
### \[ACTION: recreate assignments\]
* Plan must be in 'published' status
* Only users with roles \['admin', 'maintenance'\] can recreate
* User confirmation required before clearing all assignments
### \[ACTION: delete\] ShiftPlan
* Only users with roles \['admin', 'maintenance'\] can delete
* User confirmation required before deletion
### \[ACTION: edit\] ShiftPlan
* Only users with roles \['admin', 'maintenance'\] can edit
* Can only edit plans in 'draft' status
### \[UPDATE\] ShiftPlan shifts
* `timeSlotId` must be selected from available time slots
* `requiredEmployees` must be at least 1
* `dayOfWeek` must be between 1-7

View File

@@ -7,32 +7,38 @@ export const validateLogin = [
.isEmail() .isEmail()
.withMessage('Must be a valid email') .withMessage('Must be a valid email')
.normalizeEmail(), .normalizeEmail(),
body('password') body('password')
.isLength({ min: 6 }) .optional()
.withMessage('Password must be at least 6 characters') .isLength({ min: 8 })
.trim() .withMessage('Password must be at least 8 characters')
.escape() .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
.withMessage('Password must contain uppercase, lowercase, number and special character'),
]; ];
export const validateRegister = [ export const validateRegister = [
body('firstname') body('firstname')
.isLength({ min: 1, max: 100 }) .isLength({ min: 1, max: 100 })
.withMessage('First name must be between 1-100 characters') .withMessage('First name must be between 1-100 characters')
.notEmpty()
.withMessage('First name must not be empty')
.trim() .trim()
.escape(), .escape(),
body('lastname') body('lastname')
.isLength({ min: 1, max: 100 }) .isLength({ min: 1, max: 100 })
.withMessage('Last name must be between 1-100 characters') .withMessage('Last name must be between 1-100 characters')
.notEmpty()
.withMessage('Last name must not be empty')
.trim() .trim()
.escape(), .escape(),
body('password') body('password')
.optional()
.isLength({ min: 8 }) .isLength({ min: 8 })
.withMessage('Password must be at least 8 characters') .withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
.withMessage('Password must contain uppercase, lowercase and number') .withMessage('Password must contain uppercase, lowercase, number and special character'),
]; ];
// ===== EMPLOYEE VALIDATION ===== // ===== EMPLOYEE VALIDATION =====
@@ -40,51 +46,81 @@ export const validateEmployee = [
body('firstname') body('firstname')
.isLength({ min: 1, max: 100 }) .isLength({ min: 1, max: 100 })
.withMessage('First name must be between 1-100 characters') .withMessage('First name must be between 1-100 characters')
.notEmpty()
.withMessage('First name must not be empty')
.trim() .trim()
.escape(), .escape(),
body('lastname') body('lastname')
.isLength({ min: 1, max: 100 }) .isLength({ min: 1, max: 100 })
.withMessage('Last name must be between 1-100 characters') .withMessage('Last name must be between 1-100 characters')
.notEmpty()
.withMessage('Last name must not be empty')
.trim() .trim()
.escape(), .escape(),
body('password') body('password')
.optional() .optional()
.isLength({ min: 8 }) .isLength({ min: 8 })
.withMessage('Password must be at least 8 characters') .withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
.withMessage('Password must contain uppercase, lowercase and number'), .withMessage('Password must contain uppercase, lowercase, number and special character'),
body('employeeType') body('employeeType')
.isIn(['manager', 'personell', 'apprentice', 'guest']) .isIn(['manager', 'personell', 'apprentice', 'guest'])
.withMessage('Employee type must be manager, personell, apprentice or guest'), .withMessage('Employee type must be manager, personell, apprentice or guest'),
body('contractType')
.custom((value, { req }) => {
const employeeType = req.body.employeeType;
// Manager, apprentice => contractType must be flexible
if (['manager', 'apprentice'].includes(employeeType)) {
if (value !== 'flexible') {
throw new Error(`contractType must be 'flexible' for employeeType: ${employeeType}`);
}
}
// Guest => contractType must be undefined/NONE
else if (employeeType === 'guest') {
if (value !== undefined && value !== null) {
throw new Error(`contractType is not allowed for employeeType: ${employeeType}`);
}
}
// Personell => contractType must be small or large
else if (employeeType === 'personell') {
if (!['small', 'large'].includes(value)) {
throw new Error(`contractType must be 'small' or 'large' for employeeType: ${employeeType}`);
}
}
return true;
}),
body('contractType') body('contractType')
.optional() .optional()
.isIn(['small', 'large', 'flexible']) .isIn(['small', 'large', 'flexible'])
.withMessage('Contract type must be small, large or flexible'), .withMessage('Contract type must be small, large or flexible'),
body('roles') body('roles')
.optional() .optional()
.isArray() .isArray()
.withMessage('Roles must be an array'), .withMessage('Roles must be an array'),
body('roles.*') body('roles.*')
.optional() .optional()
.isIn(['admin', 'maintenance', 'user']) .isIn(['admin', 'maintenance', 'user'])
.withMessage('Invalid role. Allowed: admin, maintenance, user'), .withMessage('Invalid role. Allowed: admin, maintenance, user'),
body('canWorkAlone') body('canWorkAlone')
.optional() .optional()
.isBoolean() .isBoolean()
.withMessage('canWorkAlone must be a boolean'), .withMessage('canWorkAlone must be a boolean'),
body('isTrainee') body('isTrainee')
.optional() .optional()
.isBoolean() .isBoolean()
.withMessage('isTrainee must be a boolean'), .withMessage('isTrainee must be a boolean'),
body('isActive') body('isActive')
.optional() .optional()
.isBoolean() .isBoolean()
@@ -96,46 +132,71 @@ export const validateEmployeeUpdate = [
.optional() .optional()
.isLength({ min: 1, max: 100 }) .isLength({ min: 1, max: 100 })
.withMessage('First name must be between 1-100 characters') .withMessage('First name must be between 1-100 characters')
.notEmpty()
.withMessage('First name must not be empty')
.trim() .trim()
.escape(), .escape(),
body('lastname') body('lastname')
.optional() .optional()
.isLength({ min: 1, max: 100 }) .isLength({ min: 1, max: 100 })
.withMessage('Last name must be between 1-100 characters') .withMessage('Last name must be between 1-100 characters')
.notEmpty()
.withMessage('Last name must not be empty')
.trim() .trim()
.escape(), .escape(),
body('employeeType') body('employeeType')
.optional() .optional()
.isIn(['manager', 'personell', 'apprentice', 'guest']) .isIn(['manager', 'personell', 'apprentice', 'guest'])
.withMessage('Employee type must be manager, personell, apprentice or guest'), .withMessage('Employee type must be manager, personell, apprentice or guest'),
body('contractType') body('contractType')
.optional() .optional()
.isIn(['small', 'large', 'flexible']) .custom((value, { req }) => {
.withMessage('Contract type must be small, large or flexible'), 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') {
throw new Error(`contractType must be 'flexible' for employeeType: ${employeeType}`);
}
}
else if (employeeType === 'guest') {
if (value !== undefined && value !== null) {
throw new Error(`contractType is not allowed for employeeType: ${employeeType}`);
}
}
else if (employeeType === 'personell') {
if (!['small', 'large'].includes(value)) {
throw new Error(`contractType must be 'small' or 'large' for employeeType: ${employeeType}`);
}
}
return true;
}),
body('roles') body('roles')
.optional() .optional()
.isArray() .isArray()
.withMessage('Roles must be an array'), .withMessage('Roles must be an array'),
body('roles.*') body('roles.*')
.optional() .optional()
.isIn(['admin', 'maintenance', 'user']) .isIn(['admin', 'maintenance', 'user'])
.withMessage('Invalid role. Allowed: admin, maintenance, user'), .withMessage('Invalid role. Allowed: admin, maintenance, user'),
body('canWorkAlone') body('canWorkAlone')
.optional() .optional()
.isBoolean() .isBoolean()
.withMessage('canWorkAlone must be a boolean'), .withMessage('canWorkAlone must be a boolean'),
body('isTrainee') body('isTrainee')
.optional() .optional()
.isBoolean() .isBoolean()
.withMessage('isTrainee must be a boolean'), .withMessage('isTrainee must be a boolean'),
body('isActive') body('isActive')
.optional() .optional()
.isBoolean() .isBoolean()
@@ -145,14 +206,22 @@ export const validateEmployeeUpdate = [
export const validateChangePassword = [ export const validateChangePassword = [
body('currentPassword') body('currentPassword')
.optional() .optional()
.isLength({ min: 6 }) .isLength({ min: 1 })
.withMessage('Current password must be at least 6 characters'), .withMessage('Current password is required for self-password change'),
body('newPassword') body('newPassword')
.isLength({ min: 8 }) .isLength({ min: 8 })
.withMessage('New password must be at least 8 characters') .withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
.withMessage('New password must contain uppercase, lowercase and number') .withMessage('Password must contain uppercase, lowercase, number and special character'),
body('confirmPassword')
.custom((value, { req }) => {
if (value !== req.body.newPassword) {
throw new Error('Passwords do not match');
}
return true;
})
]; ];
// ===== SHIFT PLAN VALIDATION ===== // ===== SHIFT PLAN VALIDATION =====
@@ -162,77 +231,77 @@ export const validateShiftPlan = [
.withMessage('Name must be between 1-200 characters') .withMessage('Name must be between 1-200 characters')
.trim() .trim()
.escape(), .escape(),
body('description') body('description')
.optional() .optional()
.isLength({ max: 1000 }) .isLength({ max: 1000 })
.withMessage('Description cannot exceed 1000 characters') .withMessage('Description cannot exceed 1000 characters')
.trim() .trim()
.escape(), .escape(),
body('startDate') body('startDate')
.optional() .optional()
.isISO8601() .isISO8601()
.withMessage('Must be a valid date (ISO format)'), .withMessage('Must be a valid date (ISO format)'),
body('endDate') body('endDate')
.optional() .optional()
.isISO8601() .isISO8601()
.withMessage('Must be a valid date (ISO format)'), .withMessage('Must be a valid date (ISO format)'),
body('isTemplate') body('isTemplate')
.optional() .optional()
.isBoolean() .isBoolean()
.withMessage('isTemplate must be a boolean'), .withMessage('isTemplate must be a boolean'),
body('status') body('status')
.optional() .optional()
.isIn(['draft', 'published', 'archived', 'template']) .isIn(['draft', 'published', 'archived', 'template'])
.withMessage('Status must be draft, published, archived or template'), .withMessage('Status must be draft, published, archived or template'),
body('timeSlots') body('timeSlots')
.optional() .optional()
.isArray() .isArray()
.withMessage('Time slots must be an array'), .withMessage('Time slots must be an array'),
body('timeSlots.*.name') body('timeSlots.*.name')
.isLength({ min: 1, max: 100 }) .isLength({ min: 1, max: 100 })
.withMessage('Time slot name must be between 1-100 characters') .withMessage('Time slot name must be between 1-100 characters')
.trim() .trim()
.escape(), .escape(),
body('timeSlots.*.startTime') body('timeSlots.*.startTime')
.matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/) .matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/)
.withMessage('Start time must be in HH:MM format'), .withMessage('Start time must be in HH:MM format'),
body('timeSlots.*.endTime') body('timeSlots.*.endTime')
.matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/) .matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/)
.withMessage('End time must be in HH:MM format'), .withMessage('End time must be in HH:MM format'),
body('timeSlots.*.description') body('timeSlots.*.description')
.optional() .optional()
.isLength({ max: 500 }) .isLength({ max: 500 })
.withMessage('Time slot description cannot exceed 500 characters') .withMessage('Time slot description cannot exceed 500 characters')
.trim() .trim()
.escape(), .escape(),
body('shifts') body('shifts')
.optional() .optional()
.isArray() .isArray()
.withMessage('Shifts must be an array'), .withMessage('Shifts must be an array'),
body('shifts.*.dayOfWeek') body('shifts.*.dayOfWeek')
.isInt({ min: 1, max: 7 }) .isInt({ min: 1, max: 7 })
.withMessage('Day of week must be between 1-7 (Monday-Sunday)'), .withMessage('Day of week must be between 1-7 (Monday-Sunday)'),
body('shifts.*.timeSlotId') body('shifts.*.timeSlotId')
.isUUID() .isUUID()
.withMessage('Time slot ID must be a valid UUID'), .withMessage('Time slot ID must be a valid UUID'),
body('shifts.*.requiredEmployees') body('shifts.*.requiredEmployees')
.isInt({ min: 0 }) .isInt({ min: 0 })
.withMessage('Required employees must be a positive integer'), .withMessage('Required employees must be a positive integer'),
body('shifts.*.color') body('shifts.*.color')
.optional() .optional()
.isHexColor() .isHexColor()
@@ -246,34 +315,34 @@ export const validateShiftPlanUpdate = [
.withMessage('Name must be between 1-200 characters') .withMessage('Name must be between 1-200 characters')
.trim() .trim()
.escape(), .escape(),
body('description') body('description')
.optional() .optional()
.isLength({ max: 1000 }) .isLength({ max: 1000 })
.withMessage('Description cannot exceed 1000 characters') .withMessage('Description cannot exceed 1000 characters')
.trim() .trim()
.escape(), .escape(),
body('startDate') body('startDate')
.optional() .optional()
.isISO8601() .isISO8601()
.withMessage('Must be a valid date (ISO format)'), .withMessage('Must be a valid date (ISO format)'),
body('endDate') body('endDate')
.optional() .optional()
.isISO8601() .isISO8601()
.withMessage('Must be a valid date (ISO format)'), .withMessage('Must be a valid date (ISO format)'),
body('status') body('status')
.optional() .optional()
.isIn(['draft', 'published', 'archived', 'template']) .isIn(['draft', 'published', 'archived', 'template'])
.withMessage('Status must be draft, published, archived or template'), .withMessage('Status must be draft, published, archived or template'),
body('timeSlots') body('timeSlots')
.optional() .optional()
.isArray() .isArray()
.withMessage('Time slots must be an array'), .withMessage('Time slots must be an array'),
body('shifts') body('shifts')
.optional() .optional()
.isArray() .isArray()
@@ -284,25 +353,35 @@ export const validateCreateFromPreset = [
body('presetName') body('presetName')
.isLength({ min: 1 }) .isLength({ min: 1 })
.withMessage('Preset name is required') .withMessage('Preset name is required')
.isIn(['standardWeek', 'extendedWeek', 'weekendFocused', 'morningOnly', 'eveningOnly', 'ZEBRA_STANDARD']) .isIn(['GENERAL_STANDARD', 'ZEBRA_STANDARD'])
.withMessage('Invalid preset name'), .withMessage('Invalid preset name'),
body('name') body('name')
.isLength({ min: 1, max: 200 }) .isLength({ min: 1, max: 200 })
.withMessage('Name must be between 1-200 characters') .withMessage('Name must be between 1-200 characters')
.trim() .trim()
.escape(), .escape(),
body('startDate') body('startDate')
.optional()
.isISO8601() .isISO8601()
.withMessage('Must be a valid date (ISO format)'), .withMessage('Must be a valid date (ISO format)')
.custom((value, { req }) => {
if (req.body.endDate && new Date(value) > new Date(req.body.endDate)) {
throw new Error('Start date must be before end date');
}
return true;
}),
body('endDate') body('endDate')
.optional()
.isISO8601() .isISO8601()
.withMessage('Must be a valid date (ISO format)'), .withMessage('Must be a valid date (ISO format)')
.custom((value, { req }) => {
if (req.body.startDate && new Date(value) < new Date(req.body.startDate)) {
throw new Error('End date must be after start date');
}
return true;
}),
body('isTemplate') body('isTemplate')
.optional() .optional()
.isBoolean() .isBoolean()
@@ -314,11 +393,11 @@ export const validateScheduledShiftUpdate = [
body('assignedEmployees') body('assignedEmployees')
.isArray() .isArray()
.withMessage('assignedEmployees must be an array'), .withMessage('assignedEmployees must be an array'),
body('assignedEmployees.*') body('assignedEmployees.*')
.isUUID() .isUUID()
.withMessage('Each assigned employee must be a valid UUID'), .withMessage('Each assigned employee must be a valid UUID'),
body('requiredEmployees') body('requiredEmployees')
.optional() .optional()
.isInt({ min: 0 }) .isInt({ min: 0 })
@@ -332,18 +411,19 @@ export const validateSetupAdmin = [
.withMessage('First name must be between 1-100 characters') .withMessage('First name must be between 1-100 characters')
.trim() .trim()
.escape(), .escape(),
body('lastname') body('lastname')
.isLength({ min: 1, max: 100 }) .isLength({ min: 1, max: 100 })
.withMessage('Last name must be between 1-100 characters') .withMessage('Last name must be between 1-100 characters')
.trim() .trim()
.escape(), .escape(),
body('password') body('password')
.optional()
.isLength({ min: 8 }) .isLength({ min: 8 })
.withMessage('Password must be at least 8 characters') .withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/) .matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
.withMessage('Password must contain uppercase, lowercase and number') .withMessage('Password must contain uppercase, lowercase, number and special character'),
]; ];
// ===== SCHEDULING VALIDATION ===== // ===== SCHEDULING VALIDATION =====
@@ -351,23 +431,23 @@ export const validateSchedulingRequest = [
body('shiftPlan') body('shiftPlan')
.isObject() .isObject()
.withMessage('Shift plan is required'), .withMessage('Shift plan is required'),
body('shiftPlan.id') body('shiftPlan.id')
.isUUID() .isUUID()
.withMessage('Shift plan ID must be a valid UUID'), .withMessage('Shift plan ID must be a valid UUID'),
body('employees') body('employees')
.isArray({ min: 1 }) .isArray({ min: 1 })
.withMessage('At least one employee is required'), .withMessage('At least one employee is required'),
body('employees.*.id') body('employees.*.id')
.isUUID() .isUUID()
.withMessage('Each employee must have a valid UUID'), .withMessage('Each employee must have a valid UUID'),
body('availabilities') body('availabilities')
.isArray() .isArray()
.withMessage('Availabilities must be an array'), .withMessage('Availabilities must be an array'),
body('constraints') body('constraints')
.optional() .optional()
.isArray() .isArray()
@@ -379,19 +459,32 @@ export const validateAvailabilities = [
body('planId') body('planId')
.isUUID() .isUUID()
.withMessage('Plan ID must be a valid UUID'), .withMessage('Plan ID must be a valid UUID'),
body('availabilities') body('availabilities')
.isArray() .isArray()
.withMessage('Availabilities must be an array'), .withMessage('Availabilities must be an array')
.custom((availabilities, { req }) => {
// Count available shifts (preference level 1 or 2)
const availableCount = availabilities.filter((avail: any) =>
avail.preferenceLevel === 1 || avail.preferenceLevel === 2
).length;
// Basic validation - at least one available shift
if (availableCount === 0) {
throw new Error('At least one available shift is required');
}
return true;
}),
body('availabilities.*.shiftId') body('availabilities.*.shiftId')
.isUUID() .isUUID()
.withMessage('Each shift ID must be a valid UUID'), .withMessage('Each shift ID must be a valid UUID'),
body('availabilities.*.preferenceLevel') body('availabilities.*.preferenceLevel')
.isInt({ min: 0, max: 2 }) .isInt({ min: 0, max: 2 })
.withMessage('Preference level must be 0 (unavailable), 1 (available), or 2 (preferred)'), .withMessage('Preference level must be 0 (unavailable), 1 (available), or 2 (preferred)'),
body('availabilities.*.notes') body('availabilities.*.notes')
.optional() .optional()
.isLength({ max: 500 }) .isLength({ max: 500 })
@@ -424,12 +517,12 @@ export const validatePagination = [
.optional() .optional()
.isInt({ min: 1 }) .isInt({ min: 1 })
.withMessage('Page must be a positive integer'), .withMessage('Page must be a positive integer'),
query('limit') query('limit')
.optional() .optional()
.isInt({ min: 1, max: 100 }) .isInt({ min: 1, max: 100 })
.withMessage('Limit must be between 1-100'), .withMessage('Limit must be between 1-100'),
query('includeInactive') query('includeInactive')
.optional() .optional()
.isBoolean() .isBoolean()
@@ -439,19 +532,19 @@ export const validatePagination = [
// ===== MIDDLEWARE TO CHECK VALIDATION RESULTS ===== // ===== MIDDLEWARE TO CHECK VALIDATION RESULTS =====
export const handleValidationErrors = (req: Request, res: Response, next: NextFunction) => { export const handleValidationErrors = (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req); const errors = validationResult(req);
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
const errorMessages = errors.array().map(error => ({ const errorMessages = errors.array().map(error => ({
field: error.type === 'field' ? error.path : error.type, field: error.type === 'field' ? error.path : error.type,
message: error.msg, message: error.msg,
value: error.msg value: error.msg
})); }));
return res.status(400).json({ return res.status(400).json({
error: 'Validation failed', error: 'Validation failed',
details: errorMessages details: errorMessages
}); });
} }
next(); next();
}; };

View File

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

View File

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

View File

@@ -22,11 +22,13 @@ const app = express();
const PORT = 3002; const PORT = 3002;
const isDevelopment = process.env.NODE_ENV === 'development'; const isDevelopment = process.env.NODE_ENV === 'development';
app.set('trust proxy', true);
// Security configuration // Security configuration
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
console.info('Checking for JWT_SECRET'); console.info('Checking for JWT_SECRET');
const JWT_SECRET = process.env.JWT_SECRET; const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || JWT_SECRET === 'your-secret-key') { if (!JWT_SECRET || JWT_SECRET === 'your-secret-key-please-change') {
console.error('❌ Fatal: JWT_SECRET not set or using default value'); console.error('❌ Fatal: JWT_SECRET not set or using default value');
process.exit(1); process.exit(1);
} }
@@ -34,14 +36,20 @@ if (process.env.NODE_ENV === 'production') {
// Security headers // Security headers
app.use(helmet({ app.use(helmet({
contentSecurityPolicy: isDevelopment ? false : { contentSecurityPolicy: {
directives: { directives: {
defaultSrc: ["'self'"], defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"], styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"], imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
}, },
}, },
hsts: false,
crossOriginEmbedderPolicy: false crossOriginEmbedderPolicy: false
})); }));

View File

@@ -4,11 +4,19 @@ services:
schichtplaner: schichtplaner:
container_name: schichtplaner container_name: schichtplaner
image: ghcr.io/donpat1to/schichtenplaner:v1.0.0 image: ghcr.io/donpat1to/schichtenplaner:v1.0.0
environment:
- NODE_ENV=production
- JWT_SECRET=${JWT_SECRET:-your-secret-key-please-change}
ports: ports:
- "3002:3002" - "3002:3002"
volumes: volumes:
- app_data:/app/data - app_data:/app/data
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3002/api/health"]
interval: 30s
timeout: 10s
retries: 3
volumes: volumes:
app_data: app_data:

50
docker-init.sh Normal file
View File

@@ -0,0 +1,50 @@
#!/bin/bash
set -e
echo "🚀 Container Initialisierung gestartet..."
# Funktion zum Generieren eines sicheren Secrets
generate_secret() {
length=$1
tr -dc 'A-Za-z0-9!@#$%^&*()_+-=' < /dev/urandom | head -c $length
}
# Prüfe ob .env existiert
if [ ! -f /app/.env ]; then
echo "📝 Erstelle .env Datei..."
# Verwende vorhandenes JWT_SECRET oder generiere ein neues
if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "your-secret-key-please-change" ]; then
export JWT_SECRET=$(generate_secret 64)
echo "🔑 Automatisch sicheres JWT Secret generiert"
else
echo "🔑 Verwende vorhandenes JWT Secret aus Umgebungsvariable"
fi
# Erstelle .env aus Template mit envsubst
envsubst < /app/.env.template > /app/.env
echo "✅ .env Datei erstellt"
else
echo " .env Datei existiert bereits"
# Wenn .env existiert, aber JWT_SECRET Umgebungsvariable gesetzt ist, aktualisiere sie
if [ -n "$JWT_SECRET" ] && [ "$JWT_SECRET" != "your-secret-key-please-change" ]; then
echo "🔑 Aktualisiere JWT Secret in .env Datei"
# Aktualisiere nur das JWT_SECRET in der .env Datei
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$JWT_SECRET/" /app/.env
fi
fi
# Validiere dass JWT_SECERT nicht der Standardwert ist
if grep -q "JWT_SECRET=your-secret-key-please-change" /app/.env; then
echo "❌ FEHLER: Standard JWT Secret in .env gefunden!"
echo "❌ Bitte setzen Sie JWT_SECRET Umgebungsvariable"
exit 1
fi
# Setze sichere Berechtigungen
chmod 600 /app/.env
echo "🔧 Starte Anwendung..."
exec "$@"

View File

@@ -15,11 +15,17 @@
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^4.3.3", "@vitejs/plugin-react": "^4.3.3",
"@types/jest": "30.0.0",
"@testing-library/react": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/user-event": "14.6.1",
"@storybook/react": "10.0.1",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"vite": "^6.0.7", "vite": "^6.0.7",
"esbuild": "^0.21.0", "esbuild": "^0.21.0",
"terser": "5.44.0", "terser": "5.44.0",
"babel-plugin-transform-remove-console": "6.9.4" "babel-plugin-transform-remove-console": "6.9.4",
"framer-motion": "12.23.24"
}, },
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,4 +1,4 @@
// frontend/src/pages/Features/Features.tsx // frontend/src/components/Layou/FooterLinks/Features/Features.tsx
import React from 'react'; import React from 'react';
const Features: React.FC = () => { const Features: React.FC = () => {

View File

@@ -0,0 +1,79 @@
import React from 'react';
import type { Meta, StoryObj } from '@storybook/react';
import StepSetup from './StepSetup';
const meta: Meta<typeof StepSetup> = {
title: 'Components/StepSetup',
component: StepSetup,
parameters: {
layout: 'padded',
},
tags: ['autodocs'],
};
export default meta;
type Story = StoryObj<typeof StepSetup>;
const defaultSteps = [
{ id: 'step-1', title: 'Account Setup', subtitle: 'Create your account' },
{ id: 'step-2', title: 'Profile Information', subtitle: 'Add personal details' },
{ id: 'step-3', title: 'Preferences', subtitle: 'Customize your experience', optional: true },
{ id: 'step-4', title: 'Confirmation', subtitle: 'Review and confirm' },
];
export const Horizontal: Story = {
args: {
steps: defaultSteps,
defaultCurrent: 1,
orientation: 'horizontal',
},
};
export const Vertical: Story = {
args: {
steps: defaultSteps,
defaultCurrent: 1,
orientation: 'vertical',
},
parameters: {
layout: 'padded',
},
};
export const ClickableFalse: Story = {
args: {
steps: defaultSteps,
current: 2,
clickable: false,
},
name: 'Non-Clickable Steps',
};
export const AnimatedFalse: Story = {
args: {
steps: defaultSteps,
defaultCurrent: 1,
animated: false,
},
name: 'Without Animation',
};
export const DifferentSizes: Story = {
render: () => (
<div className="space-y-8">
<div>
<h3 className="text-sm font-medium mb-2">Small</h3>
<StepSetup steps={defaultSteps} size="sm" defaultCurrent={1} />
</div>
<div>
<h3 className="text-sm font-medium mb-2">Medium (default)</h3>
<StepSetup steps={defaultSteps} size="md" defaultCurrent={1} />
</div>
<div>
<h3 className="text-sm font-medium mb-2">Large</h3>
<StepSetup steps={defaultSteps} size="lg" defaultCurrent={1} />
</div>
</div>
),
name: 'Different Sizes',
};

View File

@@ -0,0 +1,127 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
import StepSetup from './StepSetup';
const mockSteps = [
{ id: 'step-1', title: 'First Step', subtitle: 'Description 1' },
{ id: 'step-2', title: 'Second Step' },
{ id: 'step-3', title: 'Third Step', subtitle: 'Description 3', optional: true },
];
describe('StepSetup', () => {
// a) Test verschiedener Step-Counts
test('renders correct number of steps', () => {
render(<StepSetup steps={mockSteps} />);
expect(screen.getByText('First Step')).toBeInTheDocument();
expect(screen.getByText('Second Step')).toBeInTheDocument();
expect(screen.getByText('Third Step')).toBeInTheDocument();
});
test('renders empty state correctly', () => {
render(<StepSetup steps={[]} />);
expect(screen.getByText('No steps available')).toBeInTheDocument();
});
// b) Keyboard-Navigation und Klicks
test('handles click navigation when clickable', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<StepSetup steps={mockSteps} clickable={true} onChange={onChange} />);
const secondStep = screen.getByRole('tab', { name: /second step/i });
await user.click(secondStep);
expect(onChange).toHaveBeenCalledWith(1);
});
test('handles keyboard navigation', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<StepSetup
steps={mockSteps}
defaultCurrent={0}
onChange={onChange}
/>
);
const firstStep = screen.getByRole('tab', { name: /first step/i });
firstStep.focus();
// Right arrow to next step
await user.keyboard('{ArrowRight}');
expect(onChange).toHaveBeenCalledWith(1);
// Home key to first step
await user.keyboard('{Home}');
expect(onChange).toHaveBeenCalledWith(0);
// End key to last step
await user.keyboard('{End}');
expect(onChange).toHaveBeenCalledWith(2);
});
// c) ARIA-Attribute Tests
test('has correct ARIA attributes', () => {
render(<StepSetup steps={mockSteps} current={1} />);
const tablist = screen.getByRole('tablist');
expect(tablist).toBeInTheDocument();
const tabs = screen.getAllByRole('tab');
expect(tabs).toHaveLength(3);
// Second step should be selected
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
expect(tabs[1]).toHaveAttribute('aria-current', 'step');
});
// d) Controlled vs Uncontrolled Tests
test('works in controlled mode', () => {
const onChange = jest.fn();
const { rerender } = render(
<StepSetup steps={mockSteps} current={0} onChange={onChange} />
);
// Click should call onChange but not change internal state in controlled mode
const secondStep = screen.getByRole('tab', { name: /second step/i });
fireEvent.click(secondStep);
expect(onChange).toHaveBeenCalledWith(1);
// Current step should still be first (controlled by prop)
expect(screen.getByRole('tab', { name: /first step/i }))
.toHaveAttribute('aria-selected', 'true');
// Update prop should change current step
rerender(<StepSetup steps={mockSteps} current={1} onChange={onChange} />);
expect(screen.getByRole('tab', { name: /second step/i }))
.toHaveAttribute('aria-selected', 'true');
});
test('works in uncontrolled mode', () => {
const onChange = jest.fn();
render(<StepSetup steps={mockSteps} defaultCurrent={0} onChange={onChange} />);
const secondStep = screen.getByRole('tab', { name: /second step/i });
fireEvent.click(secondStep);
expect(onChange).toHaveBeenCalledWith(1);
expect(secondStep).toHaveAttribute('aria-selected', 'true');
});
test('clamps out-of-range current values', () => {
render(<StepSetup steps={mockSteps} current={10} />);
// Should clamp to last step
const lastStep = screen.getByRole('tab', { name: /third step/i });
expect(lastStep).toHaveAttribute('aria-selected', 'true');
});
});

View File

@@ -0,0 +1,516 @@
import React, {
useState,
useEffect,
useId,
useCallback,
KeyboardEvent
} from 'react';
import { motion, MotionConfig, SpringOptions } from 'framer-motion';
// ===== TYP-DEFINITIONEN =====
export interface Step {
id: string;
title: string;
subtitle?: string;
optional?: boolean;
}
export interface StepSetupProps {
/** Array der Schritte mit ID, Titel und optionalen Eigenschaften */
steps: Step[];
/** Kontrollierter aktueller Schritt-Index */
current?: number;
/** Unkontrollierter Standard-Schritt-Index */
defaultCurrent?: number;
/** Callback bei Schrittänderung */
onChange?: (index: number) => void;
/** Ausrichtung des Steppers */
orientation?: 'horizontal' | 'vertical';
/** Ob Steps anklickbar sind */
clickable?: boolean;
/** Größe der Step-Komponente */
size?: 'sm' | 'md' | 'lg';
/** Animation aktivieren/deaktivieren */
animated?: boolean;
/** Zusätzliche CSS-Klassen */
className?: string;
}
export interface StepState {
currentStep: number;
isControlled: boolean;
}
// ===== HOOK FÜR ZUSTANDSVERWALTUNG =====
export const useStepSetup = (props: StepSetupProps) => {
const {
steps,
current,
defaultCurrent = 0,
onChange,
clickable = true
} = props;
const [internalStep, setInternalStep] = useState(defaultCurrent);
const isControlled = current !== undefined;
const currentStep = isControlled ? current : internalStep;
// Clamp den Schritt-Index auf gültigen Bereich
const clampedStep = Math.max(0, Math.min(currentStep, steps.length - 1));
const setStep = useCallback((newStep: number) => {
const clampedNewStep = Math.max(0, Math.min(newStep, steps.length - 1));
if (!isControlled) {
setInternalStep(clampedNewStep);
}
onChange?.(clampedNewStep);
}, [isControlled, onChange, steps.length]);
const goToNext = useCallback(() => {
if (clampedStep < steps.length - 1) {
setStep(clampedStep + 1);
}
}, [clampedStep, steps.length, setStep]);
const goToPrev = useCallback(() => {
if (clampedStep > 0) {
setStep(clampedStep - 1);
}
}, [clampedStep, setStep]);
const goToStep = useCallback((index: number) => {
if (clickable) {
setStep(index);
}
}, [clickable, setStep]);
// Warnung bei Duplicate IDs (nur in Development)
useEffect(() => {
if (process.env.NODE_ENV !== 'production') {
const stepIds = steps.map(step => step.id);
const duplicateIds = stepIds.filter((id, index) => stepIds.indexOf(id) !== index);
if (duplicateIds.length > 0) {
console.warn(
`StepSetup: Duplicate step IDs found: ${duplicateIds.join(', ')}. ` +
`Step IDs should be unique.`
);
}
}
}, [steps]);
return {
currentStep: clampedStep,
setStep,
goToNext,
goToPrev,
goToStep,
isControlled
};
};
// ===== ANIMATIONS-KONFIGURATION =====
const getAnimationConfig = (animated: boolean): { reduced: { duration: number }; normal: SpringOptions } => ({
reduced: { duration: 0 }, // Keine Animation
normal: {
stiffness: 500,
damping: 30,
mass: 0.5
}
});
// ===== HILFSFUNKTIONEN =====
/**
* Berechnet die Step-Größenklassen basierend auf der size-Prop
*/
const getSizeClasses = (size: StepSetupProps['size'] = 'md') => {
const sizes = {
sm: {
step: 'w-8 h-8 text-sm',
icon: 'w-4 h-4',
title: 'text-sm',
subtitle: 'text-xs'
},
md: {
step: 'w-10 h-10 text-base',
icon: 'w-5 h-5',
title: 'text-base',
subtitle: 'text-sm'
},
lg: {
step: 'w-12 h-12 text-lg',
icon: 'w-6 h-6',
title: 'text-lg',
subtitle: 'text-base'
}
};
return sizes[size];
};
/**
* Prüft ob prefers-reduced-motion aktiv ist
*/
const prefersReducedMotion = (): boolean => {
if (typeof window === 'undefined') return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
};
// ===== STEP ICON KOMPONENTE =====
interface StepIconProps {
stepIndex: number;
currentStep: number;
size: StepSetupProps['size'];
animated: boolean;
}
const StepNumber: React.FC<{ number: number; isCurrent?: boolean; isCompleted?: boolean }> = ({
number,
isCurrent = false,
isCompleted = false
}) => {
const baseClasses = `
w-6 h-6
flex items-center justify-center
rounded-full text-xs font-medium
transition-all duration-200
`;
if (isCompleted) {
return (
<div className={`${baseClasses} bg-blue-500 text-white`}>
</div>
);
}
if (isCurrent) {
return (
<div className={`${baseClasses} bg-blue-500 text-white ring-2 ring-blue-500 ring-opacity-20`}>
{number}
</div>
);
}
return (
<div className={`${baseClasses} bg-gray-200 text-gray-600`}>
{number}
</div>
);
};
const StepIcon: React.FC<StepIconProps> = ({
stepIndex,
currentStep,
size,
animated
}) => {
const isCompleted = stepIndex < currentStep;
const isCurrent = stepIndex === currentStep;
const sizeClasses = getSizeClasses(size);
const animationConfig = getAnimationConfig(animated);
const shouldAnimate = animated && !prefersReducedMotion();
const baseClasses = `
${sizeClasses.step}
flex items-center justify-center
rounded-full border-2 font-medium
transition-all duration-200
`;
if (isCompleted) {
const completedClasses = `
${baseClasses}
border-blue-500 bg-white
`;
const iconContent = shouldAnimate ? (
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={animationConfig.normal}
>
<StepNumber number={stepIndex + 1} isCompleted />
</motion.div>
) : (
<StepNumber number={stepIndex + 1} isCompleted />
);
return (
<div className={completedClasses}>
{iconContent}
</div>
);
}
if (isCurrent) {
const currentClasses = `
${baseClasses}
border-blue-500 bg-white
`;
const stepNumber = shouldAnimate ? (
<motion.div
key={stepIndex}
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={animationConfig.normal}
>
<StepNumber number={stepIndex + 1} isCurrent />
</motion.div>
) : (
<StepNumber number={stepIndex + 1} isCurrent />
);
return <div className={currentClasses}>{stepNumber}</div>;
}
const upcomingClasses = `
${baseClasses}
border-gray-300 bg-white
`;
return (
<div className={upcomingClasses}>
<StepNumber number={stepIndex + 1} />
</div>
);
};
// ===== HAUPTKOMPONENTE =====
/**
* Eine zugängliche, animierte Step/Progress-Komponente mit Unterstützung für
* kontrollierte und unkontrollierte Verwendung.
*
* @example
* ```tsx
* <StepSetup
* steps={[
* { id: 's1', title: 'First Step', subtitle: 'Optional description' },
* { id: 's2', title: 'Second Step' }
* ]}
* defaultCurrent={0}
* onChange={(index) => console.log('Step changed:', index)}
* />
* ```
*/
const StepSetup: React.FC<StepSetupProps> = (props) => {
const {
steps,
orientation = 'horizontal',
clickable = true,
size = 'md',
animated = true,
className = ''
} = props;
const {
currentStep,
goToStep
} = useStepSetup(props);
const listId = useId();
const shouldAnimate = animated && !prefersReducedMotion();
const sizeClasses = getSizeClasses(size);
// Fallback für leere Steps
if (!steps || steps.length === 0) {
return (
<div
className={`flex items-center justify-center p-4 text-gray-500 ${className}`}
role="status"
aria-live="polite"
>
No steps available
</div>
);
}
// Tastatur-Navigation Handler
const handleKeyDown = (
event: KeyboardEvent<HTMLButtonElement>,
stepIndex: number
) => {
if (!clickable) return;
const isHorizontal = orientation === 'horizontal';
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
goToStep(stepIndex);
break;
case 'ArrowRight':
case 'ArrowDown':
if ((isHorizontal && event.key === 'ArrowRight') ||
(!isHorizontal && event.key === 'ArrowDown')) {
event.preventDefault();
const nextStep = Math.min(stepIndex + 1, steps.length - 1);
goToStep(nextStep);
}
break;
case 'ArrowLeft':
case 'ArrowUp':
if ((isHorizontal && event.key === 'ArrowLeft') ||
(!isHorizontal && event.key === 'ArrowUp')) {
event.preventDefault();
const prevStep = Math.max(stepIndex - 1, 0);
goToStep(prevStep);
}
break;
case 'Home':
event.preventDefault();
goToStep(0);
break;
case 'End':
event.preventDefault();
goToStep(steps.length - 1);
break;
}
};
// Container-Klassen basierend auf Ausrichtung
const containerClasses = `
flex ${orientation === 'vertical' ? 'flex-col' : 'flex-row'}
${orientation === 'horizontal' ? 'items-center justify-center gap-8' : 'gap-6'}
${className}
`;
const StepContent = shouldAnimate ? motion.div : 'div';
return (
<MotionConfig reducedMotion={prefersReducedMotion() ? "always" : "user"}>
<nav
className={containerClasses.trim()}
aria-label="Progress steps"
role="tablist"
>
{steps.map((step, index) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
const isClickable = clickable && (isCompleted || isCurrent || step.optional);
// Für horizontale Ausrichtung: flex-col mit zentrierten Items
const stepClasses = `
flex flex-col items-center
gap-3
${isClickable ? 'cursor-pointer' : 'cursor-not-allowed'}
transition-colors duration-200
${orientation === 'horizontal' ? 'flex-1' : ''}
`;
const contentClasses = `
flex flex-col items-center text-center
${orientation === 'vertical' ? 'flex-1' : ''}
`;
// Verbindungslinie nur für horizontale Ausrichtung
const connectorClasses = `
flex-1 h-0.5 mt-5
${isCompleted ? 'bg-blue-500' : 'bg-gray-200'}
transition-colors duration-200
${orientation === 'horizontal' ? '' : 'hidden'}
`;
return (
<React.Fragment key={step.id}>
<StepContent
className={stepClasses}
layout={shouldAnimate ? "position" : false}
transition={shouldAnimate ? getAnimationConfig(animated).normal : undefined}
>
<div className="flex items-center w-full">
{/* Verbindungslinie vor dem Step (außer beim ersten) */}
{index > 0 && orientation === 'horizontal' && (
<div
className={connectorClasses}
aria-hidden="true"
/>
)}
<button
type="button"
onClick={() => isClickable && goToStep(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
disabled={!isClickable}
className={`
flex items-center justify-center
focus:outline-none focus:ring-2
focus:ring-blue-500 focus:ring-offset-2 rounded-full
${!isClickable ? 'opacity-50' : ''}
${orientation === 'horizontal' ? 'mx-2' : ''}
`}
role="tab"
aria-selected={isCurrent}
aria-controls={`${listId}-${step.id}`}
id={`${listId}-tab-${step.id}`}
tabIndex={isCurrent ? 0 : -1}
aria-current={isCurrent ? 'step' : undefined}
aria-disabled={!isClickable}
>
<StepIcon
stepIndex={index}
currentStep={currentStep}
size={size}
animated={animated}
/>
</button>
{/* Verbindungslinie nach dem Step (außer beim letzten) */}
{index < steps.length - 1 && orientation === 'horizontal' && (
<div
className={connectorClasses}
aria-hidden="true"
/>
)}
</div>
{/* Step Titel und Untertitel */}
<div className={contentClasses}>
<span
className={`
font-medium
${isCurrent ? 'text-blue-600' : isCompleted ? 'text-gray-900' : 'text-gray-500'}
${sizeClasses.title}
`}
id={`${listId}-title-${step.id}`}
>
{step.title}
{step.optional && (
<span className="text-gray-400 text-sm ml-1">(Optional)</span>
)}
</span>
{step.subtitle && (
<span
className={`
${isCurrent ? 'text-gray-700' : 'text-gray-500'}
${sizeClasses.subtitle}
`}
id={`${listId}-subtitle-${step.id}`}
>
{step.subtitle}
</span>
)}
</div>
</StepContent>
</React.Fragment>
);
})}
</nav>
</MotionConfig>
);
};
export default StepSetup;

View File

@@ -20,7 +20,7 @@ interface AuthContextType {
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || '/api'; const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
interface AuthProviderProps { interface AuthProviderProps {
children: ReactNode; children: ReactNode;
@@ -66,7 +66,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
try { try {
const token = getStoredToken(); const token = getStoredToken();
console.log('🔄 Refreshing user, token exists:', !!token); console.log('🔄 Refreshing user, token exists:', !!token);
if (!token) { if (!token) {
console.log(' No token found, user not logged in'); console.log(' No token found, user not logged in');
setUser(null); setUser(null);
@@ -104,7 +104,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const login = async (credentials: LoginRequest): Promise<void> => { const login = async (credentials: LoginRequest): Promise<void> => {
try { try {
console.log('🔐 Attempting login for:', credentials.email); console.log('🔐 Attempting login for:', credentials.email);
const response = await fetch(`${API_BASE_URL}/auth/login`, { const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -120,7 +120,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const data = await response.json(); const data = await response.json();
console.log('✅ Login successful, storing token'); console.log('✅ Login successful, storing token');
setStoredToken(data.token); setStoredToken(data.token);
setUser(data.user); setUser(data.user);
} catch (error) { } catch (error) {
@@ -137,13 +137,13 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const hasRole = (roles: string[]): boolean => { const hasRole = (roles: string[]): boolean => {
if (!user || !user.roles || user.roles.length === 0) return false; if (!user || !user.roles || user.roles.length === 0) return false;
// Check if user has at least one of the required roles // Check if user has at least one of the required roles
return roles.some(requiredRole => return roles.some(requiredRole =>
user.roles!.includes(requiredRole) user.roles!.includes(requiredRole)
); );
}; };
useEffect(() => { useEffect(() => {
const initializeAuth = async () => { const initializeAuth = async () => {
console.log('🚀 Initializing authentication...'); console.log('🚀 Initializing authentication...');

View File

@@ -0,0 +1,73 @@
// frontend/src/hooks/useBackendValidation.ts
import { useState, useCallback } from 'react';
import { ValidationError } from '../services/errorService';
import { useNotification } from '../contexts/NotificationContext';
export const useBackendValidation = () => {
const [validationErrors, setValidationErrors] = useState<ValidationError[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const { showNotification } = useNotification();
const clearErrors = useCallback(() => {
setValidationErrors([]);
}, []);
const getFieldError = useCallback((fieldName: string): string | null => {
const error = validationErrors.find(error => error.field === fieldName);
return error ? error.message : null;
}, [validationErrors]);
const hasErrors = useCallback((fieldName?: string): boolean => {
if (fieldName) {
return validationErrors.some(error => error.field === fieldName);
}
return validationErrors.length > 0;
}, [validationErrors]);
const executeWithValidation = useCallback(
async <T>(apiCall: () => Promise<T>): Promise<T> => {
setIsSubmitting(true);
clearErrors();
try {
const result = await apiCall();
return result;
} catch (error: any) {
if (error.validationErrors && Array.isArray(error.validationErrors)) {
setValidationErrors(error.validationErrors);
// 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({
type: 'error',
title: 'Fehler',
message: error.message || 'Ein unerwarteter Fehler ist aufgetreten'
});
}
throw error;
} finally {
setIsSubmitting(false);
}
},
[clearErrors, showNotification]
);
return {
validationErrors,
isSubmitting,
clearErrors,
getFieldError,
hasErrors,
executeWithValidation,
};
};

View File

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

View File

@@ -3,6 +3,8 @@ import { employeeService } from '../../../services/employeeService';
import { shiftPlanService } from '../../../services/shiftPlanService'; import { shiftPlanService } from '../../../services/shiftPlanService';
import { Employee, EmployeeAvailability } from '../../../models/Employee'; import { Employee, EmployeeAvailability } from '../../../models/Employee';
import { ShiftPlan, TimeSlot, Shift } from '../../../models/ShiftPlan'; import { ShiftPlan, TimeSlot, Shift } from '../../../models/ShiftPlan';
import { useNotification } from '../../../contexts/NotificationContext';
import { useBackendValidation } from '../../../hooks/useBackendValidation';
interface AvailabilityManagerProps { interface AvailabilityManagerProps {
employee: Employee; employee: Employee;
@@ -36,7 +38,8 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
const [selectedPlan, setSelectedPlan] = useState<ShiftPlan | null>(null); const [selectedPlan, setSelectedPlan] = useState<ShiftPlan | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const { showNotification } = useNotification();
const { executeWithValidation, isSubmitting } = useBackendValidation();
const daysOfWeek = [ const daysOfWeek = [
{ id: 1, name: 'Montag' }, { id: 1, name: 'Montag' },
@@ -81,7 +84,11 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
} catch (err: any) { } catch (err: any) {
console.error('❌ FEHLER BEIM LADEN DER INITIALDATEN:', err); console.error('❌ FEHLER BEIM LADEN DER INITIALDATEN:', err);
setError('Daten konnten nicht geladen werden: ' + (err.message || 'Unbekannter Fehler')); showNotification({
type: 'error',
title: 'Fehler beim Laden',
message: 'Daten konnten nicht geladen werden: ' + (err.message || 'Unbekannter Fehler')
});
setLoading(false); setLoading(false);
} }
}; };
@@ -134,9 +141,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
); );
if (invalidAvailabilities.length > 0) { if (invalidAvailabilities.length > 0) {
console.warn('⚠️ UNGÜLTIGE VERFÜGBARKEITEN (OHNE SHIFT-ID):', invalidAvailabilities.length); console.warn('⚠️ UNGÜLTIGE VERFÜGBARKEITEN (OHNE SHIFT-ID):', invalidAvailabilities.length);
invalidAvailabilities.forEach(invalid => {
console.warn(' - Ungültiger Eintrag:', invalid);
});
} }
// Transformiere die Daten // Transformiere die Daten
@@ -149,20 +153,20 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
// Debug: Zeige vorhandene Präferenzen // Debug: Zeige vorhandene Präferenzen
if (planAvailabilities.length > 0) { if (planAvailabilities.length > 0) {
console.log('🎯 VORHANDENE PRÄFERENZEN:'); console.log('🎯 VORHANDENE PRÄFERENZEN:', planAvailabilities.length);
planAvailabilities.forEach(avail => {
const shift = plan.shifts?.find(s => s.id === avail.shiftId);
console.log(` - Shift: ${avail.shiftId} (Day: ${shift?.dayOfWeek}), Level: ${avail.preferenceLevel}`);
});
} }
} catch (availError) { } catch (availError) {
console.error('❌ FEHLER BEIM LADEN DER VERFÜGBARKEITEN:', availError); console.error('❌ FEHLER BEIM LADEN DER VERFÜGBARKEITEN:', availError);
setAvailabilities([]); setAvailabilities([]);
} }
} catch (err: any) { } catch (err: any) {
console.error('❌ FEHLER BEIM LADEN DES SCHICHTPLANS:', err); console.error('❌ FEHLER BEIM LADEN DES SCHICHTPLANS:', err);
setError('Schichtplan konnte nicht geladen werden: ' + (err.message || 'Unbekannter Fehler')); showNotification({
type: 'error',
title: 'Fehler beim Laden',
message: 'Schichtplan konnte nicht geladen werden: ' + (err.message || 'Unbekannter Fehler')
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -316,26 +320,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
return (a.startTime || '').localeCompare(b.startTime || ''); return (a.startTime || '').localeCompare(b.startTime || '');
}); });
// Validation: Check if shifts are correctly placed
const validationErrors: string[] = [];
// Check for missing time slots
const usedTimeSlotIds = new Set(selectedPlan?.shifts?.map(s => s.timeSlotId) || []);
const availableTimeSlotIds = new Set(selectedPlan?.timeSlots?.map(ts => ts.id) || []);
usedTimeSlotIds.forEach(timeSlotId => {
if (!availableTimeSlotIds.has(timeSlotId)) {
validationErrors.push(`Zeitslot ${timeSlotId} wird verwendet, existiert aber nicht in timeSlots`);
}
});
// Check for shifts with invalid day numbers
selectedPlan?.shifts?.forEach(shift => {
if (shift.dayOfWeek < 1 || shift.dayOfWeek > 7) {
validationErrors.push(`Shift ${shift.id} hat ungültigen Wochentag: ${shift.dayOfWeek}`);
}
});
return ( return (
<div style={{ <div style={{
marginBottom: '30px', marginBottom: '30px',
@@ -355,23 +339,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
</div> </div>
</div> </div>
{/* Validation Warnings */}
{validationErrors.length > 0 && (
<div style={{
backgroundColor: '#fff3cd',
border: '1px solid #ffeaa7',
padding: '15px',
margin: '10px'
}}>
<h4 style={{ margin: '0 0 10px 0', color: '#856404' }}> Validierungswarnungen:</h4>
<ul style={{ margin: 0, paddingLeft: '20px', fontSize: '12px' }}>
{validationErrors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
<div style={{ overflowX: 'auto' }}> <div style={{ overflowX: 'auto' }}>
<table style={{ <table style={{
width: '100%', width: '100%',
@@ -421,9 +388,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
<div style={{ fontSize: '14px', color: '#666' }}> <div style={{ fontSize: '14px', color: '#666' }}>
{formatTime(timeSlot.startTime)} - {formatTime(timeSlot.endTime)} {formatTime(timeSlot.startTime)} - {formatTime(timeSlot.endTime)}
</div> </div>
<div style={{ fontSize: '11px', color: '#999', marginTop: '4px' }}>
ID: {timeSlot.id.substring(0, 8)}...
</div>
</td> </td>
{days.map(weekday => { {days.map(weekday => {
const shift = timeSlot.shiftsByDay[weekday.id]; const shift = timeSlot.shiftsByDay[weekday.id];
@@ -443,9 +407,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
); );
} }
// Validation: Check if shift has correct timeSlotId and dayOfWeek
const isValidShift = shift.timeSlotId === timeSlot.id && shift.dayOfWeek === weekday.id;
const currentLevel = getAvailabilityForShift(shift.id); const currentLevel = getAvailabilityForShift(shift.id);
const levelConfig = availabilityLevels.find(l => l.level === currentLevel); const levelConfig = availabilityLevels.find(l => l.level === currentLevel);
@@ -454,31 +415,8 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
padding: '12px 16px', padding: '12px 16px',
border: '1px solid #dee2e6', border: '1px solid #dee2e6',
textAlign: 'center', textAlign: 'center',
backgroundColor: !isValidShift ? '#fff3cd' : (levelConfig?.bgColor || 'white'), backgroundColor: levelConfig?.bgColor || 'white'
position: 'relative'
}}> }}>
{/* Validation indicator */}
{!isValidShift && (
<div style={{
position: 'absolute',
top: '2px',
right: '2px',
backgroundColor: '#f39c12',
color: 'white',
borderRadius: '50%',
width: '16px',
height: '16px',
fontSize: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
title={`Shift Validierung: timeSlotId=${shift.timeSlotId}, dayOfWeek=${shift.dayOfWeek}`}
>
</div>
)}
<select <select
value={currentLevel} value={currentLevel}
onChange={(e) => { onChange={(e) => {
@@ -487,10 +425,10 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
}} }}
style={{ style={{
padding: '8px 12px', padding: '8px 12px',
border: `2px solid ${!isValidShift ? '#f39c12' : (levelConfig?.color || '#ddd')}`, border: `2px solid ${levelConfig?.color || '#ddd'}`,
borderRadius: '6px', borderRadius: '6px',
backgroundColor: !isValidShift ? '#fff3cd' : (levelConfig?.bgColor || 'white'), backgroundColor: levelConfig?.bgColor || 'white',
color: !isValidShift ? '#856404' : (levelConfig?.color || '#333'), color: levelConfig?.color || '#333',
fontWeight: 'bold', fontWeight: 'bold',
minWidth: '140px', minWidth: '140px',
cursor: 'pointer', cursor: 'pointer',
@@ -511,23 +449,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
</option> </option>
))} ))}
</select> </select>
{/* Shift debug info */}
<div style={{
fontSize: '10px',
color: '#666',
marginTop: '4px',
textAlign: 'left',
fontFamily: 'monospace'
}}>
<div>Shift: {shift.id.substring(0, 6)}...</div>
<div>Day: {shift.dayOfWeek}</div>
{!isValidShift && (
<div style={{ color: '#e74c3c', fontWeight: 'bold' }}>
VALIDATION ERROR
</div>
)}
</div>
</td> </td>
); );
})} })}
@@ -556,63 +477,35 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
}; };
const handleSave = async () => { const handleSave = async () => {
try { if (!selectedPlanId) {
showNotification({
type: 'error',
title: 'Fehler',
message: 'Bitte wählen Sie einen Schichtplan aus'
});
return;
}
// Basic frontend validation: Check if we have any availabilities to save
const validAvailabilities = availabilities.filter(avail => {
return avail.shiftId && selectedPlan?.shifts?.some(shift => shift.id === avail.shiftId);
});
if (validAvailabilities.length === 0) {
showNotification({
type: 'error',
title: 'Fehler',
message: 'Keine gültigen Verfügbarkeiten zum Speichern gefunden'
});
return;
}
// Complex validation (contract type rules) is now handled by backend
// We only do basic required field validation in frontend
await executeWithValidation(async () => {
setSaving(true); setSaving(true);
setError('');
if (!selectedPlanId) {
setError('Bitte wählen Sie einen Schichtplan aus');
return;
}
// Filter availabilities to only include those with actual shifts AND valid shiftIds
const validAvailabilities = availabilities.filter(avail => {
// Check if this shiftId exists and is valid
if (!avail.shiftId) {
console.warn('⚠️ Überspringe ungültige Verfügbarkeit ohne Shift-ID:', avail);
return false;
}
// Check if this shiftId exists in the current plan
return selectedPlan?.shifts?.some(shift => shift.id === avail.shiftId);
});
console.log('💾 SPEICHERE VERFÜGBARKEITEN:', {
total: availabilities.length,
valid: validAvailabilities.length,
invalid: availabilities.length - validAvailabilities.length
});
if (validAvailabilities.length === 0) {
setError('Keine gültigen Verfügbarkeiten zum Speichern gefunden');
return;
}
// Contract type validation
const availableShifts = validAvailabilities.filter(avail =>
avail.preferenceLevel === 1 || avail.preferenceLevel === 2
).length;
let contractRequirement = 0;
let contractTypeName = '';
if (employee.contractType === 'small') {
contractRequirement = 2;
contractTypeName = 'Kleiner Vertrag';
} else if (employee.contractType === 'large') {
contractRequirement = 3;
contractTypeName = 'Großer Vertrag';
}
if (contractRequirement > 0 && availableShifts < contractRequirement) {
setError(
`${contractTypeName} erfordert mindestens ${contractRequirement} verfügbare Shifts. ` +
`Aktuell sind nur ${availableShifts} Shifts mit Verfügbarkeit "Bevorzugt" oder "Möglich" ausgewählt.`
);
setSaving(false);
return;
}
// Convert to the format expected by the API - using shiftId directly // Convert to the format expected by the API - using shiftId directly
const requestData = { const requestData = {
planId: selectedPlanId, planId: selectedPlanId,
@@ -627,15 +520,16 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
await employeeService.updateAvailabilities(employee.id, requestData); await employeeService.updateAvailabilities(employee.id, requestData);
console.log('✅ VERFÜGBARKEITEN ERFOLGREICH GESPEICHERT'); console.log('✅ VERFÜGBARKEITEN ERFOLGREICH GESPEICHERT');
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Verfügbarkeiten wurden erfolgreich gespeichert'
});
window.dispatchEvent(new CustomEvent('availabilitiesChanged')); window.dispatchEvent(new CustomEvent('availabilitiesChanged'));
onSave(); onSave();
} catch (err: any) { });
console.error('❌ FEHLER BEIM SPEICHERN:', err);
setError(err.message || 'Fehler beim Speichern der Verfügbarkeiten');
} finally {
setSaving(false);
}
}; };
if (loading) { if (loading) {
@@ -658,11 +552,10 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
// Get full name for display // Get full name for display
const employeeFullName = `${employee.firstname} ${employee.lastname}`; const employeeFullName = `${employee.firstname} ${employee.lastname}`;
// Mininmum amount of shifts per contract type // Available shifts count for display only (not for validation)
const availableShiftsCount = availabilities.filter(avail => const availableShiftsCount = availabilities.filter(avail =>
avail.preferenceLevel === 1 || avail.preferenceLevel === 2 avail.preferenceLevel === 1 || avail.preferenceLevel === 2
).length; ).length;
return ( return (
<div style={{ <div style={{
@@ -694,26 +587,14 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
{employee.contractType && ( {employee.contractType && (
<p style={{ margin: '5px 0 0 0', color: employee.contractType === 'small' ? '#f39c12' : '#27ae60' }}> <p style={{ margin: '5px 0 0 0', color: employee.contractType === 'small' ? '#f39c12' : '#27ae60' }}>
<strong>Vertrag:</strong> <strong>Vertrag:</strong>
{employee.contractType === 'small' ? ' Kleiner Vertrag (min. 2 verfügbare Shifts)' : {employee.contractType === 'small' ? ' Kleiner Vertrag' :
employee.contractType === 'large' ? ' Großer Vertrag (min. 3 verfügbare Shifts)' : employee.contractType === 'large' ? ' Großer Vertrag' :
' Flexibler Vertrag'} ' Flexibler Vertrag'}
{/* Note: Contract validation is now handled by backend */}
</p> </p>
)} )}
</div> </div>
{error && (
<div style={{
backgroundColor: '#fee',
border: '1px solid #f5c6cb',
color: '#721c24',
padding: '12px',
borderRadius: '6px',
marginBottom: '20px'
}}>
<strong>Fehler:</strong> {error}
</div>
)}
{/* Availability Legend */} {/* Availability Legend */}
<div style={{ <div style={{
marginBottom: '30px', marginBottom: '30px',
@@ -774,7 +655,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
const newPlanId = e.target.value; const newPlanId = e.target.value;
console.log('🔄 PLAN WECHSELN ZU:', newPlanId); console.log('🔄 PLAN WECHSELN ZU:', newPlanId);
setSelectedPlanId(newPlanId); setSelectedPlanId(newPlanId);
// Der useEffect wird automatisch ausgelöst
}} }}
style={{ style={{
padding: '8px 12px', padding: '8px 12px',
@@ -828,15 +708,15 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
}}> }}>
<button <button
onClick={onCancel} onClick={onCancel}
disabled={saving} disabled={isSubmitting}
style={{ style={{
padding: '12px 24px', padding: '12px 24px',
backgroundColor: '#95a5a6', backgroundColor: '#95a5a6',
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '6px', borderRadius: '6px',
cursor: saving ? 'not-allowed' : 'pointer', cursor: isSubmitting ? 'not-allowed' : 'pointer',
opacity: saving ? 0.6 : 1 opacity: isSubmitting ? 0.6 : 1
}} }}
> >
Abbrechen Abbrechen
@@ -844,18 +724,18 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving || shiftsCount === 0 || !selectedPlanId} disabled={isSubmitting || shiftsCount === 0 || !selectedPlanId}
style={{ style={{
padding: '12px 24px', padding: '12px 24px',
backgroundColor: saving ? '#bdc3c7' : (shiftsCount === 0 || !selectedPlanId ? '#95a5a6' : '#3498db'), backgroundColor: isSubmitting ? '#bdc3c7' : (shiftsCount === 0 || !selectedPlanId ? '#95a5a6' : '#3498db'),
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '6px', borderRadius: '6px',
cursor: (saving || shiftsCount === 0 || !selectedPlanId) ? 'not-allowed' : 'pointer', cursor: (isSubmitting || shiftsCount === 0 || !selectedPlanId) ? 'not-allowed' : 'pointer',
fontWeight: 'bold' fontWeight: 'bold'
}} }}
> >
{saving ? '⏳ Wird gespeichert...' : `Verfügbarkeiten speichern (${availableShiftsCount})`} {isSubmitting ? '⏳ Wird gespeichert...' : `Verfügbarkeiten speichern (${availableShiftsCount})`}
</button> </button>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import React, { useState } from 'react';
import { ROLE_CONFIG, EMPLOYEE_TYPE_CONFIG } from '../../../models/defaults/employeeDefaults'; import { ROLE_CONFIG, EMPLOYEE_TYPE_CONFIG } from '../../../models/defaults/employeeDefaults';
import { Employee } from '../../../models/Employee'; import { Employee } from '../../../models/Employee';
import { useAuth } from '../../../contexts/AuthContext'; import { useAuth } from '../../../contexts/AuthContext';
import { useNotification } from '../../../contexts/NotificationContext';
interface EmployeeListProps { interface EmployeeListProps {
employees: Employee[]; employees: Employee[];
@@ -28,6 +29,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
const [sortField, setSortField] = useState<SortField>('name'); const [sortField, setSortField] = useState<SortField>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc'); const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const { user: currentUser, hasRole } = useAuth(); const { user: currentUser, hasRole } = useAuth();
const { showNotification, confirmDialog } = useNotification();
// Filter employees based on active/inactive and search term // Filter employees based on active/inactive and search term
const filteredEmployees = employees.filter(employee => { const filteredEmployees = employees.filter(employee => {
@@ -176,6 +178,31 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
return 'MITARBEITER'; return 'MITARBEITER';
}; };
const handleDeleteClick = async (employee: Employee) => {
const confirmed = await confirmDialog({
title: 'Mitarbeiter löschen',
message: `Sind Sie sicher, dass Sie ${employee.firstname} ${employee.lastname} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.`,
confirmText: 'Löschen',
cancelText: 'Abbrechen',
type: 'warning'
});
if (confirmed) {
try {
onDelete(employee);
showNotification({
type: 'success',
title: 'Erfolg',
message: `${employee.firstname} ${employee.lastname} wurde erfolgreich gelöscht.`
});
} catch (error: any) {
// Error will be handled by parent component through useBackendValidation
// We just need to re-throw it so the parent can catch it
throw error;
}
}
};
if (employees.length === 0) { if (employees.length === 0) {
return ( return (
<div style={{ <div style={{
@@ -468,7 +495,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
{/* Löschen Button */} {/* Löschen Button */}
{canDelete && ( {canDelete && (
<button <button
onClick={() => onDelete(employee)} onClick={() => handleDeleteClick(employee)}
style={{ style={{
padding: '6px 8px', padding: '6px 8px',
backgroundColor: '#e74c3c', backgroundColor: '#e74c3c',

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 React, { useState, useEffect, useRef } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { employeeService } from '../../services/employeeService'; import { employeeService } from '../../services/employeeService';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import { useBackendValidation } from '../../hooks/useBackendValidation';
import AvailabilityManager from '../Employees/components/AvailabilityManager'; import AvailabilityManager from '../Employees/components/AvailabilityManager';
import { Employee } from '../../models/Employee'; import { Employee } from '../../models/Employee';
import { styles } from './type/SettingsType'; import { styles } from './type/SettingsType';
@@ -10,11 +11,12 @@ import { styles } from './type/SettingsType';
const Settings: React.FC = () => { const Settings: React.FC = () => {
const { user: currentUser, updateUser } = useAuth(); const { user: currentUser, updateUser } = useAuth();
const { showNotification } = useNotification(); const { showNotification } = useNotification();
const { executeWithValidation, clearErrors, isSubmitting } = useBackendValidation();
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'availability'>('profile'); const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'availability'>('profile');
const [loading, setLoading] = useState(false);
const [showAvailabilityManager, setShowAvailabilityManager] = useState(false); const [showAvailabilityManager, setShowAvailabilityManager] = useState(false);
// Profile form state - updated for firstname/lastname // Profile form state
const [profileForm, setProfileForm] = useState({ const [profileForm, setProfileForm] = useState({
firstname: currentUser?.firstname || '', firstname: currentUser?.firstname || '',
lastname: currentUser?.lastname || '' lastname: currentUser?.lastname || ''
@@ -73,7 +75,7 @@ const Settings: React.FC = () => {
})); }));
}; };
// Password visibility handlers for current password // Password visibility handlers
const handleCurrentPasswordMouseDown = () => { const handleCurrentPasswordMouseDown = () => {
currentPasswordTimeoutRef.current = setTimeout(() => { currentPasswordTimeoutRef.current = setTimeout(() => {
setShowCurrentPassword(true); setShowCurrentPassword(true);
@@ -88,7 +90,6 @@ const Settings: React.FC = () => {
setShowCurrentPassword(false); setShowCurrentPassword(false);
}; };
// Password visibility handlers for new password
const handleNewPasswordMouseDown = () => { const handleNewPasswordMouseDown = () => {
newPasswordTimeoutRef.current = setTimeout(() => { newPasswordTimeoutRef.current = setTimeout(() => {
setShowNewPassword(true); setShowNewPassword(true);
@@ -103,7 +104,6 @@ const Settings: React.FC = () => {
setShowNewPassword(false); setShowNewPassword(false);
}; };
// Password visibility handlers for confirm password
const handleConfirmPasswordMouseDown = () => { const handleConfirmPasswordMouseDown = () => {
confirmPasswordTimeoutRef.current = setTimeout(() => { confirmPasswordTimeoutRef.current = setTimeout(() => {
setShowConfirmPassword(true); setShowConfirmPassword(true);
@@ -129,7 +129,6 @@ const Settings: React.FC = () => {
cleanup(); cleanup();
}; };
// Prevent context menu
const handleContextMenu = (e: React.MouseEvent) => { const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
}; };
@@ -138,40 +137,46 @@ const Settings: React.FC = () => {
e.preventDefault(); e.preventDefault();
if (!currentUser) return; if (!currentUser) return;
// Validation // BASIC FRONTEND VALIDATION: Only check required fields
if (!profileForm.firstname.trim() || !profileForm.lastname.trim()) { if (!profileForm.firstname.trim()) {
showNotification({ showNotification({
type: 'error', type: 'error',
title: 'Fehler', 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; return;
} }
try { try {
setLoading(true); // Use executeWithValidation to handle backend validation
await employeeService.updateEmployee(currentUser.id, { await executeWithValidation(async () => {
firstname: profileForm.firstname.trim(), const updatedEmployee = await employeeService.updateEmployee(currentUser.id, {
lastname: profileForm.lastname.trim() firstname: profileForm.firstname.trim(),
}); lastname: profileForm.lastname.trim()
});
// Update the auth context with new user data // Update the auth context with new user data
const updatedUser = await employeeService.getEmployee(currentUser.id); updateUser(updatedEmployee);
updateUser(updatedUser);
showNotification({ showNotification({
type: 'success', type: 'success',
title: 'Erfolg', title: 'Erfolg',
message: 'Profil erfolgreich aktualisiert' message: 'Profil erfolgreich aktualisiert'
});
}); });
} catch (error: any) { } catch (error) {
showNotification({ // Backend validation errors are already handled by executeWithValidation
type: 'error', // We only need to handle unexpected errors here
title: 'Fehler', console.error('Unexpected error:', error);
message: error.message || 'Profil konnte nicht aktualisiert werden'
});
} finally {
setLoading(false);
} }
}; };
@@ -179,12 +184,30 @@ const Settings: React.FC = () => {
e.preventDefault(); e.preventDefault();
if (!currentUser) return; if (!currentUser) return;
// Validation // BASIC FRONTEND VALIDATION: Only check minimum requirements
if (passwordForm.newPassword.length < 6) { if (!passwordForm.currentPassword) {
showNotification({ showNotification({
type: 'error', type: 'error',
title: 'Fehler', 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; return;
} }
@@ -199,34 +222,30 @@ const Settings: React.FC = () => {
} }
try { try {
setLoading(true); // Use executeWithValidation to handle backend validation
await executeWithValidation(async () => {
// Use the actual password change endpoint await employeeService.changePassword(currentUser.id, {
await employeeService.changePassword(currentUser.id, { currentPassword: passwordForm.currentPassword,
currentPassword: passwordForm.currentPassword, newPassword: passwordForm.newPassword,
newPassword: passwordForm.newPassword confirmPassword: passwordForm.confirmPassword
}); });
showNotification({ showNotification({
type: 'success', type: 'success',
title: 'Erfolg', title: 'Erfolg',
message: 'Passwort erfolgreich geändert' message: 'Passwort erfolgreich geändert'
}); });
// Clear password form // Clear password form
setPasswordForm({ setPasswordForm({
currentPassword: '', currentPassword: '',
newPassword: '', newPassword: '',
confirmPassword: '' confirmPassword: ''
});
}); });
} catch (error: any) { } catch (error) {
showNotification({ // Backend validation errors are already handled by executeWithValidation
type: 'error', console.error('Unexpected error:', error);
title: 'Fehler',
message: error.message || 'Passwort konnte nicht geändert werden'
});
} finally {
setLoading(false);
} }
}; };
@@ -243,12 +262,18 @@ const Settings: React.FC = () => {
setShowAvailabilityManager(false); setShowAvailabilityManager(false);
}; };
// Clear validation errors when switching tabs
const handleTabChange = (tab: 'profile' | 'password' | 'availability') => {
clearErrors();
setActiveTab(tab);
};
if (!currentUser) { if (!currentUser) {
return <div style={{ return <div style={{
textAlign: 'center', textAlign: 'center',
padding: '3rem', padding: '3rem',
color: '#666', color: '#666',
fontSize: '1.1rem' fontSize: '1.1rem'
}}>Nicht eingeloggt</div>; }}>Nicht eingeloggt</div>;
} }
@@ -270,10 +295,10 @@ const Settings: React.FC = () => {
<h1 style={styles.title}>Einstellungen</h1> <h1 style={styles.title}>Einstellungen</h1>
<div style={styles.subtitle}>Verwalten Sie Ihre Kontoeinstellungen und Präferenzen</div> <div style={styles.subtitle}>Verwalten Sie Ihre Kontoeinstellungen und Präferenzen</div>
</div> </div>
<div style={styles.tabs}> <div style={styles.tabs}>
<button <button
onClick={() => setActiveTab('profile')} onClick={() => handleTabChange('profile')}
style={{ style={{
...styles.tab, ...styles.tab,
...(activeTab === 'profile' ? styles.tabActive : {}) ...(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> <span style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '2px' }}>Persönliche Informationen</span>
</div> </div>
</button> </button>
<button <button
onClick={() => setActiveTab('password')} onClick={() => handleTabChange('password')}
style={{ style={{
...styles.tab, ...styles.tab,
...(activeTab === 'password' ? styles.tabActive : {}) ...(activeTab === 'password' ? styles.tabActive : {})
@@ -327,9 +352,9 @@ const Settings: React.FC = () => {
<span style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '2px' }}>Sicherheitseinstellungen</span> <span style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '2px' }}>Sicherheitseinstellungen</span>
</div> </div>
</button> </button>
<button <button
onClick={() => setActiveTab('availability')} onClick={() => handleTabChange('availability')}
style={{ style={{
...styles.tab, ...styles.tab,
...(activeTab === 'availability' ? styles.tabActive : {}) ...(activeTab === 'availability' ? styles.tabActive : {})
@@ -369,7 +394,7 @@ const Settings: React.FC = () => {
Verwalten Sie Ihre persönlichen Informationen und Kontaktdaten Verwalten Sie Ihre persönlichen Informationen und Kontaktdaten
</p> </p>
</div> </div>
<form onSubmit={handleProfileUpdate} style={{ marginTop: '2rem' }}> <form onSubmit={handleProfileUpdate} style={{ marginTop: '2rem' }}>
<div style={styles.formGrid}> <div style={styles.formGrid}>
{/* Read-only information */} {/* Read-only information */}
@@ -480,28 +505,28 @@ const Settings: React.FC = () => {
<div style={styles.actions}> <div style={styles.actions}>
<button <button
type="submit" type="submit"
disabled={loading || !profileForm.firstname.trim() || !profileForm.lastname.trim()} disabled={isSubmitting || !profileForm.firstname.trim() || !profileForm.lastname.trim()}
style={{ style={{
...styles.button, ...styles.button,
...styles.buttonPrimary, ...styles.buttonPrimary,
...((loading || !profileForm.firstname.trim() || !profileForm.lastname.trim()) ? styles.buttonDisabled : {}) ...((isSubmitting || !profileForm.firstname.trim() || !profileForm.lastname.trim()) ? styles.buttonDisabled : {})
}} }}
onMouseEnter={(e) => { 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.background = styles.buttonPrimaryHover.background;
e.currentTarget.style.transform = styles.buttonPrimaryHover.transform; e.currentTarget.style.transform = styles.buttonPrimaryHover.transform;
e.currentTarget.style.boxShadow = styles.buttonPrimaryHover.boxShadow; e.currentTarget.style.boxShadow = styles.buttonPrimaryHover.boxShadow;
} }
}} }}
onMouseLeave={(e) => { 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.background = styles.buttonPrimary.background;
e.currentTarget.style.transform = 'none'; e.currentTarget.style.transform = 'none';
e.currentTarget.style.boxShadow = styles.buttonPrimary.boxShadow; e.currentTarget.style.boxShadow = styles.buttonPrimary.boxShadow;
} }
}} }}
> >
{loading ? '⏳ Wird gespeichert...' : 'Profil aktualisieren'} {isSubmitting ? '⏳ Wird gespeichert...' : 'Profil aktualisieren'}
</button> </button>
</div> </div>
</form> </form>
@@ -517,7 +542,7 @@ const Settings: React.FC = () => {
Aktualisieren Sie Ihr Passwort für erhöhte Sicherheit Aktualisieren Sie Ihr Passwort für erhöhte Sicherheit
</p> </p>
</div> </div>
<form onSubmit={handlePasswordUpdate} style={{ marginTop: '2rem' }}> <form onSubmit={handlePasswordUpdate} style={{ marginTop: '2rem' }}>
<div style={styles.formGridCompact}> <div style={styles.formGridCompact}>
{/* Current Password Field */} {/* Current Password Field */}
@@ -575,9 +600,9 @@ const Settings: React.FC = () => {
value={passwordForm.newPassword} value={passwordForm.newPassword}
onChange={handlePasswordChange} onChange={handlePasswordChange}
required required
minLength={6} minLength={8}
style={styles.fieldInputWithIcon} style={styles.fieldInputWithIcon}
placeholder="Mindestens 6 Zeichen" placeholder="Mindestens 8 Zeichen"
onFocus={(e) => { onFocus={(e) => {
e.target.style.borderColor = '#1a1325'; e.target.style.borderColor = '#1a1325';
e.target.style.boxShadow = '0 0 0 3px rgba(26, 19, 37, 0.1)'; e.target.style.boxShadow = '0 0 0 3px rgba(26, 19, 37, 0.1)';
@@ -606,7 +631,7 @@ const Settings: React.FC = () => {
</button> </button>
</div> </div>
<div style={styles.fieldHint}> <div style={styles.fieldHint}>
Das Passwort muss mindestens 6 Zeichen lang sein. Das Passwort muss mindestens 8 Zeichen lang sein.
</div> </div>
</div> </div>
@@ -657,28 +682,28 @@ const Settings: React.FC = () => {
<div style={styles.actions}> <div style={styles.actions}>
<button <button
type="submit" type="submit"
disabled={loading || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword} disabled={isSubmitting || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword}
style={{ style={{
...styles.button, ...styles.button,
...styles.buttonPrimary, ...styles.buttonPrimary,
...((loading || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) ? styles.buttonDisabled : {}) ...((isSubmitting || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) ? styles.buttonDisabled : {})
}} }}
onMouseEnter={(e) => { 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.background = styles.buttonPrimaryHover.background;
e.currentTarget.style.transform = styles.buttonPrimaryHover.transform; e.currentTarget.style.transform = styles.buttonPrimaryHover.transform;
e.currentTarget.style.boxShadow = styles.buttonPrimaryHover.boxShadow; e.currentTarget.style.boxShadow = styles.buttonPrimaryHover.boxShadow;
} }
}} }}
onMouseLeave={(e) => { 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.background = styles.buttonPrimary.background;
e.currentTarget.style.transform = 'none'; e.currentTarget.style.transform = 'none';
e.currentTarget.style.boxShadow = styles.buttonPrimary.boxShadow; e.currentTarget.style.boxShadow = styles.buttonPrimary.boxShadow;
} }
}} }}
> >
{loading ? '⏳ Wird geändert...' : 'Passwort ändern'} {isSubmitting ? '⏳ Wird geändert...' : 'Passwort ändern'}
</button> </button>
</div> </div>
</form> </form>
@@ -694,16 +719,16 @@ const Settings: React.FC = () => {
Legen Sie Ihre persönliche Verfügbarkeit für Schichtpläne fest Legen Sie Ihre persönliche Verfügbarkeit für Schichtpläne fest
</p> </p>
</div> </div>
<div style={styles.availabilityCard}> <div style={styles.availabilityCard}>
<div style={styles.availabilityIcon}>📅</div> <div style={styles.availabilityIcon}>📅</div>
<h3 style={styles.availabilityTitle}>Verfügbarkeit verwalten</h3> <h3 style={styles.availabilityTitle}>Verfügbarkeit verwalten</h3>
<p style={styles.availabilityDescription}> <p style={styles.availabilityDescription}>
Hier können Sie Ihre persönliche Verfügbarkeit für Schichtpläne festlegen. 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. oder nicht verfügbar sind.
</p> </p>
<button <button
onClick={() => setShowAvailabilityManager(true)} onClick={() => setShowAvailabilityManager(true)}
style={{ 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 = { export const styles = {
container: { container: {
display: 'flex', display: 'flex',
minHeight: 'calc(100vh - 120px)', minHeight: 'calc(100vh - 120px)',
background: '#FBFAF6', background: '#FBFAF6',
padding: '2rem', padding: '2rem',
maxWidth: '1200px', maxWidth: '1200px',
margin: '0 auto', margin: '0 auto',
gap: '2rem', gap: '2rem',
}, },
sidebar: { sidebar: {
width: '280px', width: '280px',
background: '#FBFAF6', background: '#FBFAF6',
borderRadius: '16px', borderRadius: '16px',
border: '1px solid rgba(255, 255, 255, 0.8)', 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)', boxShadow: '0 4px 20px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04)',
padding: '1.5rem', padding: '1.5rem',
height: 'fit-content', height: 'fit-content',
position: 'sticky' as const, position: 'sticky' as const,
top: '2rem', top: '2rem',
}, },
header: { header: {
marginBottom: '2rem', marginBottom: '2rem',
paddingBottom: '1.5rem', paddingBottom: '1.5rem',
borderBottom: '1px solid rgba(26, 19, 37, 0.1)', borderBottom: '1px solid rgba(26, 19, 37, 0.1)',
}, },
title: { title: {
fontSize: '1.5rem', fontSize: '1.5rem',
fontWeight: 600, fontWeight: 600,
color: '#161718', color: '#161718',
margin: '0 0 0.5rem 0', margin: '0 0 0.5rem 0',
}, },
subtitle: { subtitle: {
fontSize: '0.95rem', fontSize: '0.95rem',
color: '#666', color: '#666',
fontWeight: 400, fontWeight: 400,
lineHeight: 1.5, lineHeight: 1.5,
}, },
tabs: { tabs: {
display: 'flex', display: 'flex',
flexDirection: 'column' as const, flexDirection: 'column' as const,
gap: '0.5rem', gap: '0.5rem',
}, },
tab: { tab: {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '1rem', gap: '1rem',
padding: '1rem 1.25rem', padding: '1rem 1.25rem',
background: 'transparent', background: 'transparent',
color: '#666', color: '#666',
border: 'none', border: 'none',
borderRadius: '8px', borderRadius: '8px',
cursor: 'pointer', cursor: 'pointer',
fontWeight: 500, fontWeight: 500,
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
textAlign: 'left' as const, textAlign: 'left' as const,
width: '100%', width: '100%',
}, },
tabActive: { tabActive: {
background: '#51258f', background: '#51258f',
color: '#FBFAF6', color: '#FBFAF6',
boxShadow: '0 4px 12px rgba(26, 19, 37, 0.15)', boxShadow: '0 4px 12px rgba(26, 19, 37, 0.15)',
}, },
tabHover: { tabHover: {
background: 'rgba(81, 37, 143, 0.1)', background: 'rgba(81, 37, 143, 0.1)',
color: '#1a1325', color: '#1a1325',
transform: 'translateX(4px)', transform: 'translateX(4px)',
}, },
content: { content: {
flex: 1, flex: 1,
background: '#FBFAF6', background: '#FBFAF6',
padding: '2.5rem', padding: '2.5rem',
borderRadius: '16px', borderRadius: '16px',
border: '1px solid rgba(255, 255, 255, 0.8)', 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)', boxShadow: '0 4px 20px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04)',
backdropFilter: 'blur(10px)', backdropFilter: 'blur(10px)',
minHeight: '100px', minHeight: '100px',
}, },
section: { section: {
marginBottom: '2rem', marginBottom: '2rem',
}, },
sectionTitle: { sectionTitle: {
fontSize: '1.75rem', fontSize: '1.75rem',
fontWeight: 600, fontWeight: 600,
color: '#161718', color: '#161718',
margin: '0 0 0.5rem 0', margin: '0 0 0.5rem 0',
}, },
sectionDescription: { sectionDescription: {
color: '#666', color: '#666',
fontSize: '1rem', fontSize: '1rem',
margin: 0, margin: 0,
lineHeight: 1.5, lineHeight: 1.5,
}, },
formGrid: { formGrid: {
display: 'grid', display: 'grid',
gap: '1.5rem', gap: '1.5rem',
}, },
formGridCompact: { formGridCompact: {
display: 'grid', display: 'grid',
gap: '1.5rem', gap: '1.5rem',
maxWidth: '500px', maxWidth: '500px',
}, },
infoCard: { infoCard: {
padding: '1.5rem', padding: '1.5rem',
background: 'rgba(26, 19, 37, 0.02)', background: 'rgba(26, 19, 37, 0.02)',
borderRadius: '12px', borderRadius: '12px',
border: '1px solid rgba(26, 19, 37, 0.1)', border: '1px solid rgba(26, 19, 37, 0.1)',
}, },
infoCardTitle: { infoCardTitle: {
fontSize: '1rem', fontSize: '1rem',
fontWeight: 600, fontWeight: 600,
color: '#1a1325', color: '#1a1325',
margin: '0 0 1rem 0', margin: '0 0 1rem 0',
}, },
infoGrid: { infoGrid: {
display: 'grid', display: 'grid',
gridTemplateColumns: '1fr 1fr', gridTemplateColumns: '1fr 1fr',
gap: '1rem', gap: '1rem',
}, },
field: { field: {
display: 'flex', display: 'flex',
flexDirection: 'column' as const, flexDirection: 'column' as const,
gap: '0.5rem', gap: '0.5rem',
width: '100%', width: '100%',
}, },
fieldLabel: { fieldLabel: {
fontSize: '0.9rem', fontSize: '0.9rem',
fontWeight: 600, fontWeight: 600,
color: '#161718', color: '#161718',
width: '100%', width: '100%',
}, },
fieldInputContainer: { fieldInputContainer: {
position: 'relative' as const, position: 'relative' as const,
width: '100%', width: '100%',
}, },
fieldInput: { fieldInput: {
padding: '0.875rem 1rem', padding: '0.875rem 1rem',
border: '1.5px solid #e8e8e8', border: '1.5px solid #e8e8e8',
borderRadius: '8px', borderRadius: '8px',
fontSize: '0.95rem', fontSize: '0.95rem',
background: '#FBFAF6', background: '#FBFAF6',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
color: '#161718', color: '#161718',
width: '100%', width: '100%',
boxSizing: 'border-box' as const, boxSizing: 'border-box' as const,
}, },
fieldInputWithIcon: { fieldInputWithIcon: {
padding: '0.875rem 1rem', padding: '0.875rem 1rem',
border: '1.5px solid #e8e8e8', border: '1.5px solid #e8e8e8',
borderRadius: '8px', borderRadius: '8px',
fontSize: '0.95rem', fontSize: '0.95rem',
background: '#FBFAF6', background: '#FBFAF6',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
color: '#161718', color: '#161718',
width: '100%', width: '100%',
paddingRight: '40px', paddingRight: '40px',
boxSizing: 'border-box' as const, boxSizing: 'border-box' as const,
}, },
fieldInputDisabled: { fieldInputDisabled: {
padding: '0.875rem 1rem', padding: '0.875rem 1rem',
border: '1.5px solid rgba(26, 19, 37, 0.1)', border: '1.5px solid rgba(26, 19, 37, 0.1)',
borderRadius: '8px', borderRadius: '8px',
fontSize: '0.95rem', fontSize: '0.95rem',
background: 'rgba(26, 19, 37, 0.05)', background: 'rgba(26, 19, 37, 0.05)',
color: '#666', color: '#666',
cursor: 'not-allowed', cursor: 'not-allowed',
width: '100%', width: '100%',
boxSizing: 'border-box' as const, boxSizing: 'border-box' as const,
}, },
fieldHint: { fieldHint: {
fontSize: '0.8rem', fontSize: '0.8rem',
color: '#888', color: '#888',
marginTop: '0.25rem', marginTop: '0.25rem',
width: '100%', width: '100%',
}, },
passwordToggleButton: { passwordToggleButton: {
position: 'absolute' as const, position: 'absolute' as const,
right: '10px', right: '10px',
top: '50%', top: '50%',
transform: 'translateY(-50%)', transform: 'translateY(-50%)',
background: 'none', background: 'none',
border: 'none', border: 'none',
cursor: 'pointer', cursor: 'pointer',
padding: '5px', padding: '5px',
borderRadius: '4px', borderRadius: '4px',
transition: 'background-color 0.2s', transition: 'background-color 0.2s',
userSelect: 'none' as const, userSelect: 'none' as const,
WebkitUserSelect: 'none' as const, WebkitUserSelect: 'none' as const,
touchAction: 'manipulation' as const, touchAction: 'manipulation' as const,
}, },
actions: { actions: {
display: 'flex', display: 'flex',
justifyContent: 'flex-end', justifyContent: 'flex-end',
marginTop: '2.5rem', marginTop: '2.5rem',
paddingTop: '1.5rem', paddingTop: '1.5rem',
borderTop: '1px solid rgba(26, 19, 37, 0.1)', borderTop: '1px solid rgba(26, 19, 37, 0.1)',
}, },
button: { button: {
padding: '0.875rem 2rem', padding: '0.875rem 2rem',
border: 'none', border: 'none',
borderRadius: '8px', borderRadius: '8px',
fontSize: '0.95rem', fontSize: '0.95rem',
fontWeight: 600, fontWeight: 600,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
position: 'relative' as const, position: 'relative' as const,
overflow: 'hidden' as const, overflow: 'hidden' as const,
}, },
buttonPrimary: { buttonPrimary: {
background: '#1a1325', background: '#1a1325',
color: '#FBFAF6', color: '#FBFAF6',
boxShadow: '0 2px 8px rgba(26, 19, 37, 0.2)', boxShadow: '0 2px 8px rgba(26, 19, 37, 0.2)',
}, },
buttonPrimaryHover: { buttonPrimaryHover: {
background: '#24163a', background: '#24163a',
transform: 'translateY(-1px)', transform: 'translateY(-1px)',
boxShadow: '0 4px 16px rgba(26, 19, 37, 0.3)', boxShadow: '0 4px 16px rgba(26, 19, 37, 0.3)',
}, },
buttonDisabled: { buttonDisabled: {
background: '#ccc', background: '#ccc',
color: '#666', color: '#666',
cursor: 'not-allowed', cursor: 'not-allowed',
transform: 'none', transform: 'none',
boxShadow: 'none', boxShadow: 'none',
}, },
availabilityCard: { availabilityCard: {
padding: '3rem 2rem', padding: '3rem 2rem',
textAlign: 'center' as const, textAlign: 'center' as const,
background: 'rgba(26, 19, 37, 0.03)', background: 'rgba(26, 19, 37, 0.03)',
borderRadius: '16px', borderRadius: '16px',
border: '2px dashed rgba(26, 19, 37, 0.1)', border: '2px dashed rgba(26, 19, 37, 0.1)',
backdropFilter: 'blur(10px)', backdropFilter: 'blur(10px)',
}, },
availabilityIcon: { availabilityIcon: {
fontSize: '3rem', fontSize: '3rem',
marginBottom: '1.5rem', marginBottom: '1.5rem',
opacity: 0.8, opacity: 0.8,
}, },
availabilityTitle: { availabilityTitle: {
fontSize: '1.5rem', fontSize: '1.5rem',
fontWeight: 600, fontWeight: 600,
color: '#161718', color: '#161718',
margin: '0 0 1rem 0', margin: '0 0 1rem 0',
}, },
availabilityDescription: { availabilityDescription: {
color: '#666', color: '#666',
marginBottom: '2rem', marginBottom: '2rem',
lineHeight: 1.6, lineHeight: 1.6,
maxWidth: '500px', maxWidth: '500px',
marginLeft: 'auto', marginLeft: 'auto',
marginRight: 'auto', marginRight: 'auto',
}, },
infoHint: { infoHint: {
padding: '1.25rem', padding: '1.25rem',
background: 'rgba(26, 19, 37, 0.05)', background: 'rgba(26, 19, 37, 0.05)',
border: '1px solid rgba(26, 19, 37, 0.1)', border: '1px solid rgba(26, 19, 37, 0.1)',
borderRadius: '12px', borderRadius: '12px',
fontSize: '0.9rem', fontSize: '0.9rem',
color: '#161718', color: '#161718',
textAlign: 'left' as const, textAlign: 'left' as const,
maxWidth: '400px', maxWidth: '400px',
margin: '0 auto', margin: '0 auto',
}, },
infoList: { infoList: {
margin: '0.75rem 0 0 1rem', margin: '0.75rem 0 0 1rem',
padding: 0, padding: 0,
listStyle: 'none', listStyle: 'none',
}, },
infoListItem: { infoListItem: {
marginBottom: '0.5rem', marginBottom: '0.5rem',
position: 'relative' as const, position: 'relative' as const,
paddingLeft: '1rem', paddingLeft: '1rem',
}, },
}; };

View File

@@ -1,12 +1,26 @@
// frontend/src/pages/Setup/Setup.tsx - UPDATED
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
const API_BASE_URL = '/api'; const API_BASE_URL = '/api';
const Setup: React.FC = () => { // ===== TYP-DEFINITIONEN =====
const [step, setStep] = useState(1); interface SetupFormData {
const [formData, setFormData] = useState({ password: string;
confirmPassword: string;
firstname: string;
lastname: string;
}
interface SetupStep {
id: string;
title: string;
subtitle?: string;
}
// ===== HOOK FÜR SETUP-LOGIK =====
const useSetup = () => {
const [currentStep, setCurrentStep] = useState(0);
const [formData, setFormData] = useState<SetupFormData>({
password: '', password: '',
confirmPassword: '', confirmPassword: '',
firstname: '', firstname: '',
@@ -16,27 +30,26 @@ const Setup: React.FC = () => {
const [error, setError] = useState(''); const [error, setError] = useState('');
const { checkSetupStatus } = useAuth(); const { checkSetupStatus } = useAuth();
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const steps: SetupStep[] = [
const { name, value } = e.target; {
setFormData(prev => ({ id: 'profile-setup',
...prev, title: 'Profilinformationen',
[name]: value subtitle: 'Geben Sie Ihre persönlichen Daten ein'
})); },
}; {
id: 'password-setup',
const validateStep1 = () => { title: 'Passwort erstellen',
if (formData.password.length < 6) { subtitle: 'Legen Sie ein sicheres Passwort fest'
setError('Das Passwort muss mindestens 6 Zeichen lang sein.'); },
return false; {
id: 'confirmation',
title: 'Bestätigung',
subtitle: 'Setup abschließen'
} }
if (formData.password !== formData.confirmPassword) { ];
setError('Die Passwörter stimmen nicht überein.');
return false;
}
return true;
};
const validateStep2 = () => { // ===== VALIDIERUNGS-FUNKTIONEN =====
const validateStep1 = (): boolean => {
if (!formData.firstname.trim()) { if (!formData.firstname.trim()) {
setError('Bitte geben Sie einen Vornamen ein.'); setError('Bitte geben Sie einen Vornamen ein.');
return false; return false;
@@ -48,21 +61,78 @@ const Setup: React.FC = () => {
return true; return true;
}; };
const handleNext = () => { const validateStep2 = (): boolean => {
setError(''); if (formData.password.length < 8) {
if (step === 1 && validateStep1()) { setError('Das Passwort muss mindestens 8 Zeichen lang sein.');
setStep(2); return false;
} else if (step === 2 && validateStep2()) { }
handleSubmit(); if (formData.password !== formData.confirmPassword) {
setError('Die Passwörter stimmen nicht überein.');
return false;
}
return true;
};
const validateCurrentStep = (stepIndex: number): boolean => {
switch (stepIndex) {
case 0:
return validateStep1();
case 1:
return validateStep2();
default:
return true;
} }
}; };
const handleBack = () => { // ===== NAVIGATIONS-FUNKTIONEN =====
const goToNextStep = async (): Promise<void> => {
setError(''); setError('');
setStep(1);
if (!validateCurrentStep(currentStep)) {
return;
}
// Wenn wir beim letzten Schritt sind, Submit ausführen
if (currentStep === steps.length - 1) {
await handleSubmit();
return;
}
// Ansonsten zum nächsten Schritt gehen
setCurrentStep(prev => prev + 1);
}; };
const handleSubmit = async () => { const goToPrevStep = (): void => {
setError('');
if (currentStep > 0) {
setCurrentStep(prev => prev - 1);
}
};
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) {
// Vor dem Wechsel validieren
if (stepIndex > currentStep && !validateCurrentStep(currentStep)) {
return;
}
setCurrentStep(stepIndex);
}
};
// ===== FORM HANDLER =====
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (): Promise<void> => {
try { try {
setLoading(true); setLoading(true);
setError(''); setError('');
@@ -93,7 +163,7 @@ const Setup: React.FC = () => {
// Setup Status neu prüfen // Setup Status neu prüfen
await checkSetupStatus(); await checkSetupStatus();
} catch (err: any) { } catch (err: any) {
console.error('❌ Setup error:', err); console.error('❌ Setup error:', err);
setError(err.message || 'Ein unerwarteter Fehler ist aufgetreten'); setError(err.message || 'Ein unerwarteter Fehler ist aufgetreten');
@@ -102,20 +172,391 @@ const Setup: React.FC = () => {
} }
}; };
// Helper to display generated email preview // ===== HELPER FUNCTIONS =====
const getEmailPreview = () => { const getEmailPreview = (): string => {
if (!formData.firstname.trim() || !formData.lastname.trim()) { if (!formData.firstname.trim() || !formData.lastname.trim()) {
return 'vorname.nachname@sp.de'; return 'vorname.nachname@sp.de';
} }
const cleanFirstname = formData.firstname.toLowerCase().replace(/[^a-z0-9]/g, ''); const cleanFirstname = formData.firstname.toLowerCase().replace(/[^a-z0-9]/g, '');
const cleanLastname = formData.lastname.toLowerCase().replace(/[^a-z0-9]/g, ''); const cleanLastname = formData.lastname.toLowerCase().replace(/[^a-z0-9]/g, '');
return `${cleanFirstname}.${cleanLastname}@sp.de`; return `${cleanFirstname}.${cleanLastname}@sp.de`;
}; };
const isStepCompleted = (stepIndex: number): boolean => {
switch (stepIndex) {
case 0:
return formData.password.length >= 8 &&
formData.password === formData.confirmPassword;
case 1:
return !!formData.firstname.trim() && !!formData.lastname.trim();
default:
return false;
}
};
return {
// State
currentStep,
formData,
loading,
error,
steps,
// Actions
goToNextStep,
goToPrevStep,
handleStepChange,
handleInputChange,
// Helpers
getEmailPreview,
isStepCompleted
};
};
// ===== STEP-INHALTS-KOMPONENTEN =====
interface StepContentProps {
formData: SetupFormData;
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
getEmailPreview: () => string;
currentStep: number;
}
const Step1Content: React.FC<StepContentProps> = ({
formData,
onInputChange,
getEmailPreview
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div>
<label style={{
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#495057'
}}>
Vorname
</label>
<input
type="text"
name="firstname"
value={formData.firstname}
onChange={onInputChange}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ced4da',
borderRadius: '6px',
fontSize: '1rem'
}}
placeholder="Max"
required
autoComplete="given-name"
/>
</div>
<div>
<label style={{
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#495057'
}}>
Nachname
</label>
<input
type="text"
name="lastname"
value={formData.lastname}
onChange={onInputChange}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ced4da',
borderRadius: '6px',
fontSize: '1rem'
}}
placeholder="Mustermann"
required
autoComplete="family-name"
/>
</div>
<div>
<label style={{
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#495057'
}}>
Automatisch generierte E-Mail
</label>
<div style={{
padding: '0.75rem',
backgroundColor: '#e9ecef',
border: '1px solid #ced4da',
borderRadius: '6px',
color: '#495057',
fontWeight: '500',
fontFamily: 'monospace'
}}>
{getEmailPreview()}
</div>
<div style={{
fontSize: '0.875rem',
color: '#6c757d',
marginTop: '0.25rem'
}}>
Die E-Mail wird automatisch aus Vor- und Nachname generiert
</div>
</div>
</div>
);
const Step2Content: React.FC<StepContentProps> = ({
formData,
onInputChange
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div>
<label style={{
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#495057'
}}>
Passwort
</label>
<input
type="password"
name="password"
value={formData.password}
onChange={onInputChange}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ced4da',
borderRadius: '6px',
fontSize: '1rem',
transition: 'border-color 0.3s ease'
}}
placeholder="Mindestens 8 Zeichen"
required
autoComplete="new-password"
/>
</div>
<div>
<label style={{
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#495057'
}}>
Passwort bestätigen
</label>
<input
type="password"
name="confirmPassword"
value={formData.confirmPassword}
onChange={onInputChange}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ced4da',
borderRadius: '6px',
fontSize: '1rem',
transition: 'border-color 0.3s ease'
}}
placeholder="Passwort wiederholen"
required
autoComplete="new-password"
/>
</div>
</div>
);
const Step3Content: React.FC<StepContentProps> = ({
formData,
getEmailPreview
}) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div style={{
backgroundColor: '#f8f9fa',
padding: '1.5rem',
borderRadius: '8px',
border: '1px solid #e9ecef'
}}>
<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>
<span style={{ fontWeight: '500' }}>{getEmailPreview()}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#6c757d' }}>Vorname:</span>
<span style={{ fontWeight: '500' }}>{formData.firstname || '-'}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#6c757d' }}>Nachname:</span>
<span style={{ fontWeight: '500' }}>{formData.lastname || '-'}</span>
</div>
</div>
</div>
<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
automatisch generierten E-Mail anmelden.
</div>
</div>
);
// ===== HAUPTKOMPONENTE =====
const Setup: React.FC = () => {
const {
currentStep,
formData,
loading,
error,
steps,
goToNextStep,
goToPrevStep,
handleStepChange,
handleInputChange,
getEmailPreview
} = useSetup();
const renderStepContent = (): React.ReactNode => {
const stepProps = {
formData,
onInputChange: handleInputChange,
getEmailPreview,
currentStep
};
switch (currentStep) {
case 0:
return <Step1Content {...stepProps} />;
case 1:
return <Step2Content {...stepProps} />;
case 2:
return <Step3Content {...stepProps} />;
default:
return null;
}
};
const getNextButtonText = (): string => {
if (loading) return '⏳ Wird verarbeitet...';
switch (currentStep) {
case 0:
return 'Weiter →';
case 1:
return 'Weiter →';
case 2:
return 'Setup abschließen';
default:
return 'Weiter →';
}
};
// Inline Step Indicator Komponente
const StepIndicator: React.FC = () => (
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '2.5rem',
position: 'relative',
width: '100%'
}}>
{/* Verbindungslinien */}
<div style={{
position: 'absolute',
top: '12px',
left: '0',
right: '0',
height: '2px',
backgroundColor: '#e9ecef',
zIndex: 1
}} />
{steps.map((step, index) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
const isClickable = index <= currentStep + 1;
return (
<div
key={step.id}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
zIndex: 2,
position: 'relative',
flex: 1
}}
>
<button
onClick={() => isClickable && handleStepChange(index)}
disabled={!isClickable}
style={{
width: '28px',
height: '28px',
borderRadius: '50%',
border: '2px solid',
borderColor: isCompleted || isCurrent ? '#51258f' : '#e9ecef',
backgroundColor: isCompleted ? '#51258f' : 'white',
color: isCompleted ? 'white' : (isCurrent ? '#51258f' : '#6c757d'),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontWeight: 'bold',
cursor: isClickable ? 'pointer' : 'not-allowed',
transition: 'all 0.3s ease',
marginBottom: '8px'
}}
>
{index + 1}
</button>
<div style={{ textAlign: 'center' }}>
<div style={{
fontSize: '14px',
fontWeight: isCurrent ? '600' : '400',
color: isCurrent ? '#51258f' : '#6c757d'
}}>
{step.title}
</div>
</div>
</div>
);
})}
</div>
);
return ( return (
<div style={{ <div style={{
minHeight: '100vh', minHeight: '100vh',
backgroundColor: '#f8f9fa', backgroundColor: '#f8f9fa',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -128,26 +569,51 @@ const Setup: React.FC = () => {
borderRadius: '12px', borderRadius: '12px',
boxShadow: '0 10px 30px rgba(0,0,0,0.1)', boxShadow: '0 10px 30px rgba(0,0,0,0.1)',
width: '100%', width: '100%',
maxWidth: '500px', maxWidth: '600px',
border: '1px solid #e9ecef' border: '1px solid #e9ecef'
}}> }}>
<div style={{ textAlign: 'center', marginBottom: '2rem' }}> <div style={{ textAlign: 'center', marginBottom: '1rem' }}>
<h1 style={{ <h1 style={{
fontSize: '2rem', fontSize: '2rem',
fontWeight: 'bold', fontWeight: 'bold',
marginBottom: '0.5rem', marginBottom: '0.5rem',
color: '#2c3e50' color: '#2c3e50'
}}> }}>
🚀 Erstkonfiguration 🚀 Erstkonfiguration
</h1> </h1>
<p style={{ <p style={{
color: '#6c757d', color: '#6c757d',
fontSize: '1.1rem' fontSize: '1.1rem',
marginBottom: '2rem'
}}> }}>
Richten Sie Ihren Administrator-Account ein Richten Sie Ihren Administrator-Account ein
</p> </p>
</div> </div>
{/* Aktueller Schritt Titel und Beschreibung */}
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
<h2 style={{
fontSize: '1.5rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
color: '#2c3e50'
}}>
{steps[currentStep].title}
</h2>
{steps[currentStep].subtitle && (
<p style={{
color: '#6c757d',
fontSize: '1rem'
}}>
{steps[currentStep].subtitle}
</p>
)}
</div>
{/* Inline Step Indicator */}
<StepIndicator />
{/* Fehleranzeige */}
{error && ( {error && (
<div style={{ <div style={{
backgroundColor: '#f8d7da', backgroundColor: '#f8d7da',
@@ -161,175 +627,37 @@ const Setup: React.FC = () => {
</div> </div>
)} )}
{step === 1 && ( {/* Schritt-Inhalt */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> <div style={{ minHeight: '200px' }}>
<div> {renderStepContent()}
<label style={{ </div>
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#495057'
}}>
Passwort
</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleInputChange}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ced4da',
borderRadius: '6px',
fontSize: '1rem',
transition: 'border-color 0.3s ease'
}}
placeholder="Mindestens 6 Zeichen"
required
/>
</div>
<div>
<label style={{
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#495057'
}}>
Passwort bestätigen
</label>
<input
type="password"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ced4da',
borderRadius: '6px',
fontSize: '1rem',
transition: 'border-color 0.3s ease'
}}
placeholder="Passwort wiederholen"
required
/>
</div>
</div>
)}
{step === 2 && ( {/* Navigations-Buttons */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}> <div style={{
<div> marginTop: '2rem',
<label style={{ display: 'flex',
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#495057'
}}>
Vorname
</label>
<input
type="text"
name="firstname"
value={formData.firstname}
onChange={handleInputChange}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ced4da',
borderRadius: '6px',
fontSize: '1rem'
}}
placeholder="Max"
required
/>
</div>
<div>
<label style={{
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#495057'
}}>
Nachname
</label>
<input
type="text"
name="lastname"
value={formData.lastname}
onChange={handleInputChange}
style={{
width: '100%',
padding: '0.75rem',
border: '1px solid #ced4da',
borderRadius: '6px',
fontSize: '1rem'
}}
placeholder="Mustermann"
required
/>
</div>
<div>
<label style={{
display: 'block',
marginBottom: '0.5rem',
fontWeight: '600',
color: '#495057'
}}>
Automatisch generierte E-Mail
</label>
<div style={{
padding: '0.75rem',
backgroundColor: '#e9ecef',
border: '1px solid #ced4da',
borderRadius: '6px',
color: '#495057',
fontWeight: '500',
fontFamily: 'monospace'
}}>
{getEmailPreview()}
</div>
<div style={{
fontSize: '0.875rem',
color: '#6c757d',
marginTop: '0.25rem'
}}>
Die E-Mail wird automatisch aus Vor- und Nachname generiert
</div>
</div>
</div>
)}
<div style={{
marginTop: '2rem',
display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center' alignItems: 'center'
}}> }}>
{step === 2 && (
<button
onClick={handleBack}
style={{
padding: '0.75rem 1.5rem',
color: '#6c757d',
border: '1px solid #6c757d',
background: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: '500'
}}
disabled={loading}
>
Zurück
</button>
)}
<button <button
onClick={handleNext} onClick={goToPrevStep}
disabled={loading || currentStep === 0}
style={{
padding: '0.75rem 1.5rem',
color: loading || currentStep === 0 ? '#adb5bd' : '#6c757d',
border: `1px solid ${loading || currentStep === 0 ? '#adb5bd' : '#6c757d'}`,
background: 'none',
borderRadius: '6px',
cursor: loading || currentStep === 0 ? 'not-allowed' : 'pointer',
fontWeight: '500',
opacity: loading || currentStep === 0 ? 0.6 : 1
}}
>
Zurück
</button>
<button
onClick={goToNextStep}
disabled={loading} disabled={loading}
style={{ style={{
padding: '0.75rem 2rem', padding: '0.75rem 2rem',
@@ -340,28 +668,25 @@ const Setup: React.FC = () => {
cursor: loading ? 'not-allowed' : 'pointer', cursor: loading ? 'not-allowed' : 'pointer',
fontWeight: '600', fontWeight: '600',
fontSize: '1rem', fontSize: '1rem',
marginLeft: step === 1 ? 'auto' : '0',
transition: 'background-color 0.3s ease' transition: 'background-color 0.3s ease'
}} }}
> >
{loading ? '⏳ Wird verarbeitet...' : {getNextButtonText()}
step === 1 ? 'Weiter →' : 'Setup abschließen'}
</button> </button>
</div> </div>
{step === 2 && ( {/* Zusätzliche Informationen */}
<div style={{ {currentStep === 2 && !loading && (
marginTop: '1.5rem', <div style={{
textAlign: 'center', marginTop: '1.5rem',
color: '#6c757d', textAlign: 'center',
color: '#6c757d',
fontSize: '0.9rem', fontSize: '0.9rem',
padding: '1rem', padding: '1rem',
backgroundColor: '#e7f3ff', backgroundColor: '#f8f9fa',
borderRadius: '6px', borderRadius: '6px'
border: '1px solid #b6d7e8'
}}> }}>
💡 Nach dem erfolgreichen Setup werden Sie zur Anmeldeseite weitergeleitet, Überprüfen Sie Ihre Daten, bevor Sie das Setup abschließen
wo Sie sich mit Ihrer automatisch generierten E-Mail anmelden können.
</div> </div>
)} )}
</div> </div>

View File

@@ -107,7 +107,7 @@
.createButton { .createButton {
padding: 10px 20px; padding: 10px 20px;
background-color: #2ecc71; background-color: #51258f;
color: white; color: white;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
@@ -116,7 +116,7 @@
} }
.createButton:hover { .createButton:hover {
background-color: #27ae60; background-color: #51258f;
} }
.createButton:disabled { .createButton:disabled {

View File

@@ -2,6 +2,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { shiftPlanService } from '../../services/shiftPlanService'; import { shiftPlanService } from '../../services/shiftPlanService';
import { useNotification } from '../../contexts/NotificationContext';
import { useBackendValidation } from '../../hooks/useBackendValidation';
import styles from './ShiftPlanCreate.module.css'; import styles from './ShiftPlanCreate.module.css';
// Interface für Template Presets // Interface für Template Presets
@@ -14,6 +16,8 @@ interface TemplatePreset {
const ShiftPlanCreate: React.FC = () => { const ShiftPlanCreate: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { showNotification } = useNotification();
const { executeWithValidation, isSubmitting } = useBackendValidation();
const [planName, setPlanName] = useState(''); const [planName, setPlanName] = useState('');
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState('');
@@ -21,8 +25,6 @@ const ShiftPlanCreate: React.FC = () => {
const [selectedPreset, setSelectedPreset] = useState(''); const [selectedPreset, setSelectedPreset] = useState('');
const [presets, setPresets] = useState<TemplatePreset[]>([]); const [presets, setPresets] = useState<TemplatePreset[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
loadTemplatePresets(); loadTemplatePresets();
@@ -42,36 +44,60 @@ const ShiftPlanCreate: React.FC = () => {
} }
} catch (error) { } catch (error) {
console.error('❌ Fehler beim Laden der Vorlagen-Presets:', error); console.error('❌ Fehler beim Laden der Vorlagen-Presets:', error);
setError('Vorlagen-Presets konnten nicht geladen werden'); showNotification({
type: 'error',
title: 'Fehler beim Laden',
message: 'Vorlagen-Presets konnten nicht geladen werden'
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleCreate = async () => { const handleCreate = async () => {
try { // Basic frontend validation only
// Validierung if (!planName.trim()) {
if (!planName.trim()) { showNotification({
setError('Bitte geben Sie einen Namen für den Schichtplan ein'); type: 'error',
return; title: 'Fehlende Angaben',
} message: 'Bitte geben Sie einen Namen für den Schichtplan ein'
if (!startDate) { });
setError('Bitte wählen Sie ein Startdatum'); return;
return; }
} if (!startDate) {
if (!endDate) { showNotification({
setError('Bitte wählen Sie ein Enddatum'); type: 'error',
return; title: 'Fehlende Angaben',
} message: 'Bitte wählen Sie ein Startdatum'
if (new Date(endDate) < new Date(startDate)) { });
setError('Das Enddatum muss nach dem Startdatum liegen'); return;
return; }
} if (!endDate) {
if (!selectedPreset) { showNotification({
setError('Bitte wählen Sie eine Vorlage aus'); type: 'error',
return; title: 'Fehlende Angaben',
} message: 'Bitte wählen Sie ein Enddatum'
});
return;
}
if (new Date(endDate) < new Date(startDate)) {
showNotification({
type: 'error',
title: 'Ungültige Daten',
message: 'Das Enddatum muss nach dem Startdatum liegen'
});
return;
}
if (!selectedPreset) {
showNotification({
type: 'error',
title: 'Fehlende Angaben',
message: 'Bitte wählen Sie eine Vorlage aus'
});
return;
}
await executeWithValidation(async () => {
console.log('🔄 Erstelle Schichtplan aus Preset...', { console.log('🔄 Erstelle Schichtplan aus Preset...', {
presetName: selectedPreset, presetName: selectedPreset,
name: planName, name: planName,
@@ -91,16 +117,16 @@ const ShiftPlanCreate: React.FC = () => {
console.log('✅ Plan erstellt:', createdPlan); console.log('✅ Plan erstellt:', createdPlan);
// Erfolgsmeldung und Weiterleitung // Erfolgsmeldung und Weiterleitung
setSuccess('Schichtplan erfolgreich erstellt!'); showNotification({
type: 'success',
title: 'Erfolg',
message: 'Schichtplan erfolgreich erstellt!'
});
setTimeout(() => { setTimeout(() => {
navigate(`/shift-plans/${createdPlan.id}`); navigate(`/shift-plans/${createdPlan.id}`);
}, 1500); }, 1500);
});
} catch (error) {
const err = error as Error;
console.error('❌ Fehler beim Erstellen des Plans:', err);
setError(`Plan konnte nicht erstellt werden: ${err.message}`);
}
}; };
const getSelectedPresetDescription = () => { const getSelectedPresetDescription = () => {
@@ -120,22 +146,14 @@ const ShiftPlanCreate: React.FC = () => {
<div className={styles.container}> <div className={styles.container}>
<div className={styles.header}> <div className={styles.header}>
<h1>Neuen Schichtplan erstellen</h1> <h1>Neuen Schichtplan erstellen</h1>
<button onClick={() => navigate(-1)} className={styles.backButton}> <button
onClick={() => navigate(-1)}
className={styles.backButton}
disabled={isSubmitting}
>
Zurück Zurück
</button> </button>
</div> </div>
{error && (
<div className={styles.error}>
{error}
</div>
)}
{success && (
<div className={styles.success}>
{success}
</div>
)}
<div className={styles.form}> <div className={styles.form}>
<div className={styles.formGroup}> <div className={styles.formGroup}>
@@ -146,6 +164,7 @@ const ShiftPlanCreate: React.FC = () => {
onChange={(e) => setPlanName(e.target.value)} onChange={(e) => setPlanName(e.target.value)}
placeholder="z.B. KW 42 2025" placeholder="z.B. KW 42 2025"
className={styles.input} className={styles.input}
disabled={isSubmitting}
/> />
</div> </div>
@@ -157,6 +176,7 @@ const ShiftPlanCreate: React.FC = () => {
value={startDate} value={startDate}
onChange={(e) => setStartDate(e.target.value)} onChange={(e) => setStartDate(e.target.value)}
className={styles.input} className={styles.input}
disabled={isSubmitting}
/> />
</div> </div>
@@ -167,6 +187,7 @@ const ShiftPlanCreate: React.FC = () => {
value={endDate} value={endDate}
onChange={(e) => setEndDate(e.target.value)} onChange={(e) => setEndDate(e.target.value)}
className={styles.input} className={styles.input}
disabled={isSubmitting}
/> />
</div> </div>
</div> </div>
@@ -177,6 +198,7 @@ const ShiftPlanCreate: React.FC = () => {
value={selectedPreset} value={selectedPreset}
onChange={(e) => setSelectedPreset(e.target.value)} onChange={(e) => setSelectedPreset(e.target.value)}
className={`${styles.select} ${presets.length === 0 ? styles.empty : ''}`} className={`${styles.select} ${presets.length === 0 ? styles.empty : ''}`}
disabled={isSubmitting}
> >
<option value="">Bitte wählen...</option> <option value="">Bitte wählen...</option>
{presets.map(preset => ( {presets.map(preset => (
@@ -203,9 +225,9 @@ const ShiftPlanCreate: React.FC = () => {
<button <button
onClick={handleCreate} onClick={handleCreate}
className={styles.createButton} className={styles.createButton}
disabled={!selectedPreset || !planName.trim() || !startDate || !endDate} disabled={isSubmitting || !selectedPreset || !planName.trim() || !startDate || !endDate}
> >
Schichtplan erstellen {isSubmitting ? 'Wird erstellt...' : 'Schichtplan erstellen'}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -4,11 +4,14 @@ import { useParams, useNavigate } from 'react-router-dom';
import { shiftPlanService } from '../../services/shiftPlanService'; import { shiftPlanService } from '../../services/shiftPlanService';
import { ShiftPlan, Shift, ScheduledShift } from '../../models/ShiftPlan'; import { ShiftPlan, Shift, ScheduledShift } from '../../models/ShiftPlan';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import { useBackendValidation } from '../../hooks/useBackendValidation';
const ShiftPlanEdit: React.FC = () => { const ShiftPlanEdit: React.FC = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { showNotification } = useNotification(); const { showNotification, confirmDialog } = useNotification();
const { executeWithValidation, isSubmitting } = useBackendValidation();
const [shiftPlan, setShiftPlan] = useState<ShiftPlan | null>(null); const [shiftPlan, setShiftPlan] = useState<ShiftPlan | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [editingShift, setEditingShift] = useState<Shift | null>(null); const [editingShift, setEditingShift] = useState<Shift | null>(null);
@@ -24,122 +27,150 @@ const ShiftPlanEdit: React.FC = () => {
const loadShiftPlan = async () => { const loadShiftPlan = async () => {
if (!id) return; if (!id) return;
try {
const plan = await shiftPlanService.getShiftPlan(id); await executeWithValidation(async () => {
setShiftPlan(plan); try {
} catch (error) { const plan = await shiftPlanService.getShiftPlan(id);
console.error('Error loading shift plan:', error); setShiftPlan(plan);
showNotification({ } catch (error) {
type: 'error', console.error('Error loading shift plan:', error);
title: 'Fehler', navigate('/shift-plans');
message: 'Der Schichtplan konnte nicht geladen werden.' } finally {
}); setLoading(false);
navigate('/shift-plans'); }
} finally { });
setLoading(false);
}
}; };
const handleUpdateShift = async (shift: Shift) => { const handleUpdateShift = async (shift: Shift) => {
if (!shiftPlan || !id) return; if (!shiftPlan || !id) return;
try { await executeWithValidation(async () => {
// Update logic here // Update logic here - will be implemented when backend API is available
// For now, just simulate success
console.log('Updating shift:', shift);
loadShiftPlan(); loadShiftPlan();
setEditingShift(null); setEditingShift(null);
} catch (error) {
console.error('Error updating shift:', error);
showNotification({ showNotification({
type: 'error', type: 'success',
title: 'Fehler', title: 'Erfolg',
message: 'Die Schicht konnte nicht aktualisiert werden.' message: 'Schicht wurde erfolgreich aktualisiert.'
}); });
} });
}; };
const handleAddShift = async () => { const handleAddShift = async () => {
if (!shiftPlan || !id) return; if (!shiftPlan || !id) return;
if (!newShift.timeSlotId || !newShift.requiredEmployees) { // Basic frontend validation only
if (!newShift.timeSlotId) {
showNotification({ showNotification({
type: 'error', type: 'error',
title: 'Fehler', title: 'Fehlende Angaben',
message: 'Bitte füllen Sie alle Pflichtfelder aus.' message: 'Bitte wählen Sie einen Zeit-Slot aus.'
}); });
return; return;
} }
try { if (!newShift.requiredEmployees || newShift.requiredEmployees < 1) {
// Add shift logic here showNotification({
type: 'error',
title: 'Fehlende Angaben',
message: 'Bitte geben Sie die Anzahl der benötigten Mitarbeiter an.'
});
return;
}
await executeWithValidation(async () => {
// Add shift logic here - will be implemented when backend API is available
// For now, just simulate success
console.log('Adding shift:', newShift);
showNotification({ showNotification({
type: 'success', type: 'success',
title: 'Erfolg', title: 'Erfolg',
message: 'Neue Schicht wurde hinzugefügt.' message: 'Neue Schicht wurde hinzugefügt.'
}); });
setNewShift({ setNewShift({
timeSlotId: '', timeSlotId: '',
dayOfWeek: 1, dayOfWeek: 1,
requiredEmployees: 1 requiredEmployees: 1
}); });
loadShiftPlan(); loadShiftPlan();
} catch (error) { });
console.error('Error adding shift:', error);
showNotification({
type: 'error',
title: 'Fehler',
message: 'Die Schicht konnte nicht hinzugefügt werden.'
});
}
}; };
const handleDeleteShift = async (shiftId: string) => { const handleDeleteShift = async (shiftId: string) => {
if (!window.confirm('Möchten Sie diese Schicht wirklich löschen?')) { const confirmed = await confirmDialog({
return; title: 'Schicht löschen',
} message: 'Möchten Sie diese Schicht wirklich löschen?',
confirmText: 'Löschen',
cancelText: 'Abbrechen',
type: 'warning'
});
try { if (!confirmed) return;
// Delete logic here
await executeWithValidation(async () => {
// Delete logic here - will be implemented when backend API is available
// For now, just simulate success
console.log('Deleting shift:', shiftId);
loadShiftPlan(); loadShiftPlan();
} catch (error) {
console.error('Error deleting shift:', error);
showNotification({ showNotification({
type: 'error', type: 'success',
title: 'Fehler', title: 'Erfolg',
message: 'Die Schicht konnte nicht gelöscht werden.' message: 'Schicht wurde erfolgreich gelöscht.'
}); });
} });
}; };
const handlePublish = async () => { const handlePublish = async () => {
if (!shiftPlan || !id) return; if (!shiftPlan || !id) return;
try { await executeWithValidation(async () => {
await shiftPlanService.updateShiftPlan(id, { await shiftPlanService.updateShiftPlan(id, {
...shiftPlan, ...shiftPlan,
status: 'published' status: 'published'
}); });
showNotification({ showNotification({
type: 'success', type: 'success',
title: 'Erfolg', title: 'Erfolg',
message: 'Schichtplan wurde veröffentlicht.' message: 'Schichtplan wurde veröffentlicht.'
}); });
loadShiftPlan(); loadShiftPlan();
} catch (error) { });
console.error('Error publishing shift plan:', error);
showNotification({
type: 'error',
title: 'Fehler',
message: 'Der Schichtplan konnte nicht veröffentlicht werden.'
});
}
}; };
if (loading) { if (loading) {
return <div>Lade Schichtplan...</div>; return (
<div style={{
textAlign: 'center',
padding: '40px',
fontSize: '18px',
color: '#666'
}}>
Lade Schichtplan...
</div>
);
} }
if (!shiftPlan) { if (!shiftPlan) {
return <div>Schichtplan nicht gefunden</div>; return (
<div style={{
textAlign: 'center',
padding: '40px',
fontSize: '18px',
color: '#e74c3c'
}}>
Schichtplan nicht gefunden
</div>
);
} }
// Group shifts by dayOfWeek // Group shifts by dayOfWeek
@@ -174,28 +205,32 @@ const ShiftPlanEdit: React.FC = () => {
{shiftPlan.status === 'draft' && ( {shiftPlan.status === 'draft' && (
<button <button
onClick={handlePublish} onClick={handlePublish}
disabled={isSubmitting}
style={{ style={{
padding: '8px 16px', padding: '8px 16px',
backgroundColor: '#2ecc71', backgroundColor: '#2ecc71',
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: isSubmitting ? 'not-allowed' : 'pointer',
marginRight: '10px' marginRight: '10px',
opacity: isSubmitting ? 0.6 : 1
}} }}
> >
Veröffentlichen {isSubmitting ? 'Wird veröffentlicht...' : 'Veröffentlichen'}
</button> </button>
)} )}
<button <button
onClick={() => navigate('/shift-plans')} onClick={() => navigate('/shift-plans')}
disabled={isSubmitting}
style={{ style={{
padding: '8px 16px', padding: '8px 16px',
backgroundColor: '#95a5a6', backgroundColor: '#95a5a6',
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer' cursor: isSubmitting ? 'not-allowed' : 'pointer',
opacity: isSubmitting ? 0.6 : 1
}} }}
> >
Zurück Zurück
@@ -219,6 +254,7 @@ const ShiftPlanEdit: React.FC = () => {
value={newShift.dayOfWeek} value={newShift.dayOfWeek}
onChange={(e) => setNewShift({ ...newShift, dayOfWeek: parseInt(e.target.value) })} onChange={(e) => setNewShift({ ...newShift, dayOfWeek: parseInt(e.target.value) })}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
disabled={isSubmitting}
> >
{daysOfWeek.map(day => ( {daysOfWeek.map(day => (
<option key={day.id} value={day.id}>{day.name}</option> <option key={day.id} value={day.id}>{day.name}</option>
@@ -231,6 +267,7 @@ const ShiftPlanEdit: React.FC = () => {
value={newShift.timeSlotId} value={newShift.timeSlotId}
onChange={(e) => setNewShift({ ...newShift, timeSlotId: e.target.value })} onChange={(e) => setNewShift({ ...newShift, timeSlotId: e.target.value })}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
disabled={isSubmitting}
> >
<option value="">Bitte auswählen...</option> <option value="">Bitte auswählen...</option>
{shiftPlan.timeSlots.map(slot => ( {shiftPlan.timeSlots.map(slot => (
@@ -246,25 +283,27 @@ const ShiftPlanEdit: React.FC = () => {
type="number" type="number"
min="1" min="1"
value={newShift.requiredEmployees} value={newShift.requiredEmployees}
onChange={(e) => setNewShift({ ...newShift, requiredEmployees: parseInt(e.target.value) })} onChange={(e) => setNewShift({ ...newShift, requiredEmployees: parseInt(e.target.value) || 1 })}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
disabled={isSubmitting}
/> />
</div> </div>
</div> </div>
<button <button
onClick={handleAddShift} onClick={handleAddShift}
disabled={!newShift.timeSlotId || !newShift.requiredEmployees} disabled={isSubmitting || !newShift.timeSlotId || !newShift.requiredEmployees}
style={{ style={{
marginTop: '15px', marginTop: '15px',
padding: '8px 16px', padding: '8px 16px',
backgroundColor: '#3498db', backgroundColor: isSubmitting ? '#bdc3c7' : '#3498db',
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer' cursor: isSubmitting ? 'not-allowed' : 'pointer',
opacity: (!newShift.timeSlotId || !newShift.requiredEmployees) ? 0.6 : 1
}} }}
> >
Schicht hinzufügen {isSubmitting ? 'Wird hinzugefügt...' : 'Schicht hinzufügen'}
</button> </button>
</div> </div>
@@ -300,6 +339,7 @@ const ShiftPlanEdit: React.FC = () => {
value={editingShift.timeSlotId} value={editingShift.timeSlotId}
onChange={(e) => setEditingShift({ ...editingShift, timeSlotId: e.target.value })} onChange={(e) => setEditingShift({ ...editingShift, timeSlotId: e.target.value })}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
disabled={isSubmitting}
> >
{shiftPlan.timeSlots.map(slot => ( {shiftPlan.timeSlots.map(slot => (
<option key={slot.id} value={slot.id}> <option key={slot.id} value={slot.id}>
@@ -314,33 +354,37 @@ const ShiftPlanEdit: React.FC = () => {
type="number" type="number"
min="1" min="1"
value={editingShift.requiredEmployees} value={editingShift.requiredEmployees}
onChange={(e) => setEditingShift({ ...editingShift, requiredEmployees: parseInt(e.target.value) })} onChange={(e) => setEditingShift({ ...editingShift, requiredEmployees: parseInt(e.target.value) || 1 })}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }} style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
disabled={isSubmitting}
/> />
</div> </div>
<div style={{ display: 'flex', gap: '10px', alignItems: 'flex-end' }}> <div style={{ display: 'flex', gap: '10px', alignItems: 'flex-end' }}>
<button <button
onClick={() => handleUpdateShift(editingShift)} onClick={() => handleUpdateShift(editingShift)}
disabled={isSubmitting}
style={{ style={{
padding: '8px 16px', padding: '8px 16px',
backgroundColor: '#2ecc71', backgroundColor: isSubmitting ? '#bdc3c7' : '#2ecc71',
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer' cursor: isSubmitting ? 'not-allowed' : 'pointer'
}} }}
> >
Speichern {isSubmitting ? 'Speichern...' : 'Speichern'}
</button> </button>
<button <button
onClick={() => setEditingShift(null)} onClick={() => setEditingShift(null)}
disabled={isSubmitting}
style={{ style={{
padding: '8px 16px', padding: '8px 16px',
backgroundColor: '#95a5a6', backgroundColor: '#95a5a6',
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer' cursor: isSubmitting ? 'not-allowed' : 'pointer',
opacity: isSubmitting ? 0.6 : 1
}} }}
> >
Abbrechen Abbrechen
@@ -359,27 +403,31 @@ const ShiftPlanEdit: React.FC = () => {
<div> <div>
<button <button
onClick={() => setEditingShift(shift)} onClick={() => setEditingShift(shift)}
disabled={isSubmitting}
style={{ style={{
padding: '6px 12px', padding: '6px 12px',
backgroundColor: '#f1c40f', backgroundColor: isSubmitting ? '#bdc3c7' : '#f1c40f',
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: isSubmitting ? 'not-allowed' : 'pointer',
marginRight: '8px' marginRight: '8px',
opacity: isSubmitting ? 0.6 : 1
}} }}
> >
Bearbeiten Bearbeiten
</button> </button>
<button <button
onClick={() => handleDeleteShift(shift.id)} onClick={() => handleDeleteShift(shift.id)}
disabled={isSubmitting}
style={{ style={{
padding: '6px 12px', padding: '6px 12px',
backgroundColor: '#e74c3c', backgroundColor: isSubmitting ? '#bdc3c7' : '#e74c3c',
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer' cursor: isSubmitting ? 'not-allowed' : 'pointer',
opacity: isSubmitting ? 0.6 : 1
}} }}
> >
Löschen Löschen

View File

@@ -5,12 +5,15 @@ import { useAuth } from '../../contexts/AuthContext';
import { shiftPlanService } from '../../services/shiftPlanService'; import { shiftPlanService } from '../../services/shiftPlanService';
import { ShiftPlan } from '../../models/ShiftPlan'; import { ShiftPlan } from '../../models/ShiftPlan';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import { useBackendValidation } from '../../hooks/useBackendValidation';
import { formatDate } from '../../utils/foramatters'; import { formatDate } from '../../utils/foramatters';
const ShiftPlanList: React.FC = () => { const ShiftPlanList: React.FC = () => {
const { hasRole } = useAuth(); const { hasRole } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { showNotification } = useNotification(); const { showNotification, confirmDialog } = useNotification();
const { executeWithValidation, isSubmitting } = useBackendValidation();
const [shiftPlans, setShiftPlans] = useState<ShiftPlan[]>([]); const [shiftPlans, setShiftPlans] = useState<ShiftPlan[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -19,46 +22,80 @@ const ShiftPlanList: React.FC = () => {
}, []); }, []);
const loadShiftPlans = async () => { const loadShiftPlans = async () => {
try { await executeWithValidation(async () => {
const plans = await shiftPlanService.getShiftPlans(); try {
setShiftPlans(plans); const plans = await shiftPlanService.getShiftPlans();
} catch (error) { setShiftPlans(plans);
console.error('Error loading shift plans:', error); } catch (error) {
showNotification({ console.error('Error loading shift plans:', error);
type: 'error', // Error is automatically handled by executeWithValidation
title: 'Fehler', } finally {
message: 'Die Schichtpläne konnten nicht geladen werden.' setLoading(false);
}); }
} finally { });
setLoading(false);
}
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string, planName: string) => {
if (!window.confirm('Möchten Sie diesen Schichtplan wirklich löschen?')) { const confirmed = await confirmDialog({
return; title: 'Schichtplan löschen',
} message: `Möchten Sie den Schichtplan "${planName}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`,
confirmText: 'Löschen',
cancelText: 'Abbrechen',
type: 'warning'
});
try { if (!confirmed) return;
await executeWithValidation(async () => {
await shiftPlanService.deleteShiftPlan(id); await shiftPlanService.deleteShiftPlan(id);
showNotification({ showNotification({
type: 'success', type: 'success',
title: 'Erfolg', title: 'Erfolg',
message: 'Der Schichtplan wurde erfolgreich gelöscht.' message: 'Der Schichtplan wurde erfolgreich gelöscht.'
}); });
loadShiftPlans(); loadShiftPlans();
} catch (error) { });
console.error('Error deleting shift plan:', error); };
showNotification({
type: 'error', const getStatusBadge = (status: string) => {
title: 'Fehler', const config = {
message: 'Der Schichtplan konnte nicht gelöscht werden.' draft: { text: 'Entwurf', color: '#f39c12', bgColor: '#fef5e7' },
}); published: { text: 'Veröffentlicht', color: '#27ae60', bgColor: '#d5f4e6' },
} archived: { text: 'Archiviert', color: '#95a5a6', bgColor: '#f8f9fa' }
};
const statusConfig = config[status as keyof typeof config] || config.draft;
return (
<span
style={{
backgroundColor: statusConfig.bgColor,
color: statusConfig.color,
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: 'bold',
display: 'inline-block'
}}
>
{statusConfig.text}
</span>
);
}; };
if (loading) { if (loading) {
return <div>Lade Schichtpläne...</div>; return (
<div style={{
textAlign: 'center',
padding: '40px',
fontSize: '18px',
color: '#666'
}}>
Lade Schichtpläne...
</div>
);
} }
return ( return (
@@ -97,6 +134,21 @@ const ShiftPlanList: React.FC = () => {
<div style={{ fontSize: '48px', marginBottom: '20px' }}>📋</div> <div style={{ fontSize: '48px', marginBottom: '20px' }}>📋</div>
<h3>Keine Schichtpläne vorhanden</h3> <h3>Keine Schichtpläne vorhanden</h3>
<p>Erstellen Sie Ihren ersten Schichtplan!</p> <p>Erstellen Sie Ihren ersten Schichtplan!</p>
{hasRole(['admin', 'maintenance']) && (
<Link to="/shift-plans/new">
<button style={{
marginTop: '15px',
padding: '10px 20px',
backgroundColor: '#51258f',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}>
Ersten Plan erstellen
</button>
</Link>
)}
</div> </div>
) : ( ) : (
<div style={{ display: 'grid', gap: '20px' }}> <div style={{ display: 'grid', gap: '20px' }}>
@@ -110,35 +162,35 @@ const ShiftPlanList: React.FC = () => {
boxShadow: '0 2px 4px rgba(0,0,0,0.1)', boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center' alignItems: 'center',
border: plan.status === 'published' ? '2px solid #d5f4e6' : '1px solid #e0e0e0'
}} }}
> >
<div> <div style={{ flex: 1 }}>
<h3 style={{ margin: '0 0 10px 0' }}>{plan.name}</h3> <h3 style={{ margin: '0 0 10px 0', color: '#2c3e50' }}>{plan.name}</h3>
<div style={{ color: '#666', fontSize: '14px' }}> <div style={{ color: '#666', fontSize: '14px', marginBottom: '10px' }}>
<p style={{ margin: '0' }}> <p style={{ margin: '0' }}>
Zeitraum: {formatDate(plan.startDate)} - {formatDate(plan.endDate)} <strong>Zeitraum:</strong> {formatDate(plan.startDate)} - {formatDate(plan.endDate)}
</p> </p>
<p style={{ margin: '5px 0 0 0' }}> <p style={{ margin: '5px 0 0 0' }}>
Status: <span style={{ <strong>Status:</strong> {getStatusBadge(plan.status)}
color: plan.status === 'published' ? '#2ecc71' : '#f1c40f',
fontWeight: 'bold'
}}>
{plan.status === 'published' ? 'Veröffentlicht' : 'Entwurf'}
</span>
</p> </p>
</div> </div>
<div style={{ fontSize: '12px', color: '#95a5a6' }}>
Erstellt am: {formatDate(plan.createdAt || '')}
</div>
</div> </div>
<div style={{ display: 'flex', gap: '10px' }}> <div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
<button <button
onClick={() => navigate(`/shift-plans/${plan.id}`)} onClick={() => navigate(`/shift-plans/${plan.id}`)}
style={{ style={{
padding: '8px 16px', padding: '8px 16px',
backgroundColor: '#2ecc71', backgroundColor: '#3498db',
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer' cursor: 'pointer',
minWidth: '80px'
}} }}
> >
Anzeigen Anzeigen
@@ -149,27 +201,31 @@ const ShiftPlanList: React.FC = () => {
onClick={() => navigate(`/shift-plans/${plan.id}/edit`)} onClick={() => navigate(`/shift-plans/${plan.id}/edit`)}
style={{ style={{
padding: '8px 16px', padding: '8px 16px',
backgroundColor: '#f1c40f', backgroundColor: '#f39c12',
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer' cursor: 'pointer',
minWidth: '80px'
}} }}
> >
Bearbeiten Bearbeiten
</button> </button>
<button <button
onClick={() => handleDelete(plan.id)} onClick={() => handleDelete(plan.id, plan.name)}
disabled={isSubmitting}
style={{ style={{
padding: '8px 16px', padding: '8px 16px',
backgroundColor: '#e74c3c', backgroundColor: isSubmitting ? '#bdc3c7' : '#e74c3c',
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer' cursor: isSubmitting ? 'not-allowed' : 'pointer',
minWidth: '80px',
opacity: isSubmitting ? 0.6 : 1
}} }}
> >
Löschen {isSubmitting ? 'Löscht...' : 'Löschen'}
</button> </button>
</> </>
)} )}
@@ -178,6 +234,22 @@ const ShiftPlanList: React.FC = () => {
))} ))}
</div> </div>
)} )}
{/* Info for users without edit permissions */}
{!hasRole(['admin', 'maintenance']) && shiftPlans.length > 0 && (
<div style={{
marginTop: '20px',
padding: '15px',
backgroundColor: '#e8f4fd',
border: '1px solid #b6d7e8',
borderRadius: '6px',
fontSize: '14px',
color: '#2c3e50'
}}>
<strong> Informationen:</strong> Sie können Schichtpläne nur anzeigen.
Bearbeitungsrechte benötigen Admin- oder Instandhalter-Berechtigungen.
</div>
)}
</div> </div>
); );
}; };

View File

@@ -1,6 +1,8 @@
// frontend/src/services/authService.ts // frontend/src/services/authService.ts - UPDATED
import { Employee } from '../models/Employee'; import { Employee } from '../models/Employee';
const API_BASE = process.env.REACT_APP_API_BASE_URL || '/api'; import { ErrorService } from './errorService';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
export interface LoginRequest { export interface LoginRequest {
email: string; email: string;
@@ -23,38 +25,45 @@ export interface AuthResponse {
class AuthService { class AuthService {
private token: string | null = null; 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> { async login(credentials: LoginRequest): Promise<AuthResponse> {
const response = await fetch(`${API_BASE}/auth/login`, { const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials) body: JSON.stringify(credentials)
}); });
if (!response.ok) { const data = await this.handleApiResponse<AuthResponse>(response);
const errorData = await response.json();
throw new Error(errorData.error || 'Login fehlgeschlagen');
}
const data: AuthResponse = await response.json();
this.token = data.token; this.token = data.token;
localStorage.setItem('token', data.token); localStorage.setItem('token', data.token);
localStorage.setItem('employee', JSON.stringify(data.employee)); localStorage.setItem('employee', JSON.stringify(data.employee));
return data; return data;
} }
async register(userData: RegisterRequest): Promise<AuthResponse> { async register(userData: RegisterRequest): Promise<AuthResponse> {
const response = await fetch(`${API_BASE}/employees`, { const response = await fetch(`${API_BASE_URL}/employees`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userData) body: JSON.stringify(userData)
}); });
if (!response.ok) { const data = await this.handleApiResponse<AuthResponse>(response);
const errorData = await response.json();
throw new Error(errorData.error || 'Registrierung fehlgeschlagen');
}
return this.login({ return this.login({
email: userData.email, email: userData.email,
password: userData.password password: userData.password
@@ -73,7 +82,7 @@ class AuthService {
} }
try { try {
const response = await fetch(`${API_BASE}/auth/me`, { const response = await fetch(`${API_BASE_URL}/auth/me`, {
headers: { headers: {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
} }
@@ -96,6 +105,7 @@ class AuthService {
this.token = null; this.token = null;
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('user'); localStorage.removeItem('user');
localStorage.removeItem('employee');
} }
getToken(): string | null { getToken(): string | null {

View File

@@ -1,5 +1,6 @@
// frontend/src/services/employeeService.ts // frontend/src/services/employeeService.ts
import { Employee, CreateEmployeeRequest, UpdateEmployeeRequest, EmployeeAvailability } from '../models/Employee'; import { Employee, CreateEmployeeRequest, UpdateEmployeeRequest, EmployeeAvailability } from '../models/Employee';
import { ErrorService, ValidationError } from './errorService';
const API_BASE_URL = '/api'; const API_BASE_URL = '/api';
@@ -12,27 +13,44 @@ const getAuthHeaders = () => {
}; };
export class EmployeeService { export class EmployeeService {
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 || `HTTP error! status: ${response.status}`);
}
return response.json();
}
async getEmployees(includeInactive: boolean = false): Promise<Employee[]> { async getEmployees(includeInactive: boolean = false): Promise<Employee[]> {
console.log('🔄 Fetching employees from API...'); console.log('🔄 Fetching employees from API...');
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
console.log('🔑 Token exists:', !!token); console.log('🔑 Token exists:', !!token);
const response = await fetch(`${API_BASE_URL}/employees?includeInactive=${includeInactive}`, { const response = await fetch(`${API_BASE_URL}/employees?includeInactive=${includeInactive}`, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); });
console.log('📡 Response status:', response.status); console.log('📡 Response status:', response.status);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
console.error('❌ API Error:', errorText); console.error('❌ API Error:', errorText);
throw new Error('Failed to fetch employees'); throw new Error('Failed to fetch employees');
} }
const employees = await response.json(); const employees = await response.json();
console.log('✅ Employees received:', employees.length); console.log('✅ Employees received:', employees.length);
return employees; return employees;
} }
@@ -40,12 +58,8 @@ export class EmployeeService {
const response = await fetch(`${API_BASE_URL}/employees/${id}`, { const response = await fetch(`${API_BASE_URL}/employees/${id}`, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); });
if (!response.ok) { return this.handleApiResponse<Employee>(response);
throw new Error('Failed to fetch employee');
}
return response.json();
} }
async createEmployee(employee: CreateEmployeeRequest): Promise<Employee> { async createEmployee(employee: CreateEmployeeRequest): Promise<Employee> {
@@ -54,13 +68,8 @@ export class EmployeeService {
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify(employee), body: JSON.stringify(employee),
}); });
if (!response.ok) { return this.handleApiResponse<Employee>(response);
const error = await response.json();
throw new Error(error.error || 'Failed to create employee');
}
return response.json();
} }
async updateEmployee(id: string, employee: UpdateEmployeeRequest): Promise<Employee> { async updateEmployee(id: string, employee: UpdateEmployeeRequest): Promise<Employee> {
@@ -69,13 +78,8 @@ export class EmployeeService {
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify(employee), body: JSON.stringify(employee),
}); });
if (!response.ok) { return this.handleApiResponse<Employee>(response);
const error = await response.json();
throw new Error(error.error || 'Failed to update employee');
}
return response.json();
} }
async deleteEmployee(id: string): Promise<void> { async deleteEmployee(id: string): Promise<void> {
@@ -83,7 +87,7 @@ export class EmployeeService {
method: 'DELETE', method: 'DELETE',
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
throw new Error(error.error || 'Failed to delete employee'); throw new Error(error.error || 'Failed to delete employee');
@@ -94,12 +98,8 @@ export class EmployeeService {
const response = await fetch(`${API_BASE_URL}/employees/${employeeId}/availabilities`, { const response = await fetch(`${API_BASE_URL}/employees/${employeeId}/availabilities`, {
headers: getAuthHeaders(), headers: getAuthHeaders(),
}); });
if (!response.ok) { return this.handleApiResponse<EmployeeAvailability[]>(response);
throw new Error('Failed to fetch availabilities');
}
return response.json();
} }
async updateAvailabilities(employeeId: string, data: { planId: string, availabilities: Omit<EmployeeAvailability, 'id' | 'employeeId'>[] }): Promise<EmployeeAvailability[]> { async updateAvailabilities(employeeId: string, data: { planId: string, availabilities: Omit<EmployeeAvailability, 'id' | 'employeeId'>[] }): Promise<EmployeeAvailability[]> {
@@ -109,26 +109,18 @@ export class EmployeeService {
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!response.ok) { return this.handleApiResponse<EmployeeAvailability[]>(response);
const error = await response.json();
throw new Error(error.error || 'Failed to update availabilities');
}
return response.json();
} }
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`, { const response = await fetch(`${API_BASE_URL}/employees/${id}/password`, {
method: 'PUT', method: 'PUT',
headers: getAuthHeaders(), headers: getAuthHeaders(),
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!response.ok) { return this.handleApiResponse<void>(response);
const error = await response.json();
throw new Error(error.error || 'Failed to change password');
}
} }
async updateLastLogin(employeeId: string): Promise<void> { async updateLastLogin(employeeId: string): Promise<void> {

View File

@@ -0,0 +1,38 @@
// frontend/src/services/errorService.ts
export interface ValidationError {
field: string;
message: string;
value?: any;
}
export interface ApiError {
error: string;
details?: ValidationError[];
message?: string;
}
export class ErrorService {
static extractValidationErrors(error: any): ValidationError[] {
if (error?.details && Array.isArray(error.details)) {
return error.details;
}
// Fallback for different error formats
if (error.message && typeof error.message === 'string') {
return [{ field: 'general', message: error.message }];
}
return [{ field: 'general', message: 'An unknown error occurred' }];
}
static getFieldErrors(errors: ValidationError[], fieldName: string): string[] {
return errors
.filter(error => error.field === fieldName)
.map(error => error.message);
}
static getFirstFieldError(errors: ValidationError[], fieldName: string): string | null {
const fieldErrors = this.getFieldErrors(errors, fieldName);
return fieldErrors.length > 0 ? fieldErrors[0] : null;
}
}

View File

@@ -1,71 +0,0 @@
import { useState, useCallback } from 'react';
import { ScheduleRequest, ScheduleResult } from '../../models/scheduling';
export const useScheduling = () => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<ScheduleResult | null>(null);
const generateSchedule = useCallback(async (request: ScheduleRequest) => {
setLoading(true);
setError(null);
try {
console.log('📤 Sending scheduling request:', {
shiftPlan: request.shiftPlan.name,
employees: request.employees.length,
availabilities: request.availabilities.length,
constraints: request.constraints.length
});
const response = await fetch('/api/scheduling/generate-schedule', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(request)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Scheduling request failed: ${response.status} ${errorText}`);
}
const data: ScheduleResult = await response.json();
console.log('📥 Received scheduling result:', {
success: data.success,
assignments: Object.keys(data.assignments).length,
violations: data.violations.length,
processingTime: data.processingTime
});
setResult(data);
return data;
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown scheduling error';
console.error('❌ Scheduling error:', errorMessage);
setError(errorMessage);
throw err;
} finally {
setLoading(false);
}
}, []);
const reset = useCallback(() => {
setLoading(false);
setError(null);
setResult(null);
}, []);
return {
generateSchedule,
loading,
error,
result,
reset
};
};
// Export for backward compatibility
export default useScheduling;

View File

@@ -1,166 +1,59 @@
// vite.config.ts
import { defineConfig, loadEnv } from 'vite' import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { resolve } from 'path' import { resolve } from 'path'
// Security-focused Vite configuration
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const isProduction = mode === 'production' const isProduction = mode === 'production'
const isDevelopment = mode === 'development' const isDevelopment = mode === 'development'
// Load environment variables securely
const env = loadEnv(mode, process.cwd(), '') const env = loadEnv(mode, process.cwd(), '')
// Strictly defined client-safe environment variables // 🆕 WICHTIG: Relative Pfade für Production
const clientEnv = { const clientEnv = {
NODE_ENV: mode, NODE_ENV: mode,
ENABLE_PRO: env.ENABLE_PRO || 'false', ENABLE_PRO: env.ENABLE_PRO || 'false',
VITE_APP_TITLE: env.VITE_APP_TITLE || 'Shift Planning App', VITE_APP_TITLE: env.APP_TITLE || 'Shift Planning App',
VITE_API_URL: isProduction ? '/api' : 'http://localhost:3002/api', VITE_API_URL: isProduction ? '/api' : '/api',
} }
return { return {
plugins: [ plugins: [react()],
react({
// React specific security settings
jsxRuntime: 'automatic',
babel: {
plugins: [
// Remove console in production
isProduction && ['babel-plugin-transform-remove-console', { exclude: ['error', 'warn'] }]
].filter(Boolean)
}
})
],
server: { server: {
port: 3003, port: 3003,
host: true, host: true,
open: isDevelopment, //open: isDevelopment,
// Security headers for dev server
headers: {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'camera=(), microphone=(), location=()'
},
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3002', target: 'http://localhost:3002',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
} }
}, }
// Security: disable HMR in non-dev environments
hmr: isDevelopment
}, },
build: { build: {
outDir: 'dist', outDir: 'dist',
// Security: No source maps in production sourcemap: isDevelopment,
sourcemap: isDevelopment ? 'inline' : false, base: isProduction ? '/' : '/',
// Generate deterministic hashes for better caching and security
assetsDir: 'assets',
rollupOptions: { rollupOptions: {
output: { output: {
// Security: Use content hashes for cache busting and integrity
chunkFileNames: 'assets/[name]-[hash].js', chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js', entryFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]', assetFileNames: 'assets/[name]-[hash].[ext]',
// Security: Manual chunks to separate vendor code
manualChunks: (id) => {
if (id.includes('node_modules')) {
if (id.includes('react') || id.includes('react-dom')) {
return 'vendor-react'
}
if (id.includes('react-router-dom')) {
return 'vendor-router'
}
return 'vendor'
}
}
} }
}, },
// Minification with security-focused settings
minify: isProduction ? 'terser' : false, minify: isProduction ? 'terser' : false,
terserOptions: isProduction ? { terserOptions: isProduction ? {
compress: { compress: {
drop_console: true, drop_console: true,
drop_debugger: true, drop_debugger: true,
// Security: Remove potentially sensitive code pure_funcs: ['console.log', 'console.debug', 'console.info']
pure_funcs: [
'console.log',
'console.info',
'console.debug',
'console.warn',
'console.trace',
'console.table',
'debugger'
],
dead_code: true,
if_return: true,
comparisons: true,
loops: true,
hoist_funs: true,
hoist_vars: true,
reduce_vars: true,
booleans: true,
conditionals: true,
evaluate: true,
sequences: true,
unused: true
},
mangle: {
// Security: Obfuscate code
toplevel: true,
keep_classnames: false,
keep_fnames: false,
reserved: [
'React',
'ReactDOM',
'useState',
'useEffect',
'useContext',
'createElement'
]
},
format: {
comments: false,
beautify: false,
// Security: ASCII only to prevent encoding attacks
ascii_only: true
} }
} : undefined, } : undefined,
// Security: Report bundle size issues
reportCompressedSize: true,
chunkSizeWarningLimit: 1000,
// Security: Don't expose source paths
assetsInlineLimit: 4096
}, },
preview: {
port: 3004,
headers: {
// Security headers for preview server
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block',
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Content-Security-Policy': `
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
`.replace(/\s+/g, ' ').trim()
}
},
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, './src'), '@': resolve(__dirname, './src'),
@@ -173,31 +66,10 @@ export default defineConfig(({ mode }) => {
'@/design': resolve(__dirname, './src/design') '@/design': resolve(__dirname, './src/design')
} }
}, },
// ✅ SICHER: Strict environment variable control
define: Object.keys(clientEnv).reduce((acc, key) => { define: Object.keys(clientEnv).reduce((acc, key) => {
acc[`import.meta.env.${key}`] = JSON.stringify(clientEnv[key]) acc[`import.meta.env.${key}`] = JSON.stringify(clientEnv[key])
return acc return acc
}, {} as Record<string, string>), }, {} as Record<string, string>)
// Security: Clear build directory
emptyOutDir: true,
// Security: Optimize dependencies
optimizeDeps: {
include: ['react', 'react-dom', 'react-router-dom'],
exclude: ['@vitejs/plugin-react']
},
// Security: CSS configuration
css: {
devSourcemap: isDevelopment,
modules: {
localsConvention: 'camelCase',
generateScopedName: isProduction
? '[hash:base64:8]'
: '[name]__[local]--[hash:base64:5]'
}
}
} }
}) })

1580
package-lock.json generated

File diff suppressed because it is too large Load Diff