Compare commits

...

29 Commits

Author SHA1 Message Date
00b48c1f41 changed dev routing away from faulty proxy 2025-10-31 14:24:56 +01:00
cae2b83649 added seperate transaction for pragma statement 2025-10-31 13:33:12 +01:00
a69e934075 fixed handleSubmit missing input 2025-10-31 12:51:22 +01:00
3ad497dd76 changed static password length statements 6 -> 8 2025-10-31 12:37:52 +01:00
b302c447f8 admin has to confirm current password as well on self password change 2025-10-31 12:30:54 +01:00
6cc8c91317 updated validation handling together with shiftplan 2025-10-31 00:27:50 +01:00
0b35bb6dc6 updated validation handling together with employeeform 2025-10-30 23:39:21 +01:00
4ef8e7b1f3 error handling in frontend together with validation in backend 2025-10-30 21:52:34 +01:00
14dce28698 added admin check for deletion and updates 2025-10-30 21:07:27 +01:00
82a30f6bb8 added Validation rules 2025-10-30 19:13:09 +01:00
0623957993 added Validation rules 2025-10-30 18:10:44 +01:00
5809bb8b09 added stepsetup for initial admin setup 2025-10-30 15:26:25 +01:00
fbd0f03eb2 password requirements allow more special chars 2025-10-29 11:12:37 +01:00
86166048e8 password requirements more strict 2025-10-29 11:05:05 +01:00
0363505126 added right env variable usage for frontend wiht meta.env 2025-10-29 09:44:41 +01:00
1231c8362f removed all cors statemnts 2025-10-29 00:34:12 +01:00
663eb61352 added basic route for production 2025-10-28 23:54:28 +01:00
23f1dd7aa0 updated docker entry point 2025-10-28 23:33:00 +01:00
5319ed5d7a added entrypoint for docker 2025-10-28 23:07:40 +01:00
65ebf1748b removed invalid terser option 2025-10-28 22:21:54 +01:00
4321763a2b added missing dependency 2025-10-28 22:16:19 +01:00
24525043e9 added missing dpendencie 2025-10-28 22:12:30 +01:00
d870523685 added security features from terser 2025-10-28 22:04:24 +01:00
50a1f1a9b9 npm run build iwhtout --only=production flag 2025-10-28 21:30:54 +01:00
1927937109 added corrected password needs 2025-10-28 20:13:09 +01:00
b3b3250f23 dropping console on production 2025-10-28 19:20:19 +01:00
5f8a6bef31 added express payload validation 2025-10-28 18:58:58 +01:00
a838ba44e8 moved pragma statements into schema.sql 2025-10-28 17:58:16 +01:00
1057fd9954 moved pragma statements in initializedatabase 2025-10-28 17:49:06 +01:00
50 changed files with 10232 additions and 2003 deletions

View File

@@ -1,4 +0,0 @@
# .env.production example
NODE_ENV=production
JWT_SECRET=your-super-secure-minimum-32-character-secret-key-here
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

@@ -30,19 +30,24 @@ RUN npm install --workspace=frontend
RUN npm run build --only=production --workspace=backend
# Build frontend
RUN npm run build --only=production --workspace=frontend
RUN npm run build --workspace=frontend
# Verify Python and OR-Tools installation
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
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 mkdir -p /app/data
# Copy application files
COPY --from=builder /app/backend/dist/ ./dist/
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/ ./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 && \
useradd -m -u 1001 -s /bin/bash -g nodejs schichtplan && \
chown -R schichtplan:nodejs /app && \
@@ -61,10 +74,13 @@ RUN groupadd -g 1001 nodejs && \
chmod 775 /app/data
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
EXPOSE 3002
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 ["pm2-runtime", "ecosystem.config.cjs"]
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/api/health || exit 1

View File

@@ -4,7 +4,8 @@
"type": "module",
"scripts": {
"dev": "npm run build && npx tsx src/server.ts",
"build": "tsc",
"dev:single": "cross-env NODE_ENV=development npx tsx src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"prestart": "npm run build",
"test": "jest",
@@ -19,7 +20,10 @@
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"sqlite3": "^5.1.6",
"uuid": "^9.0.0"
"uuid": "^9.0.0",
"express-rate-limit": "8.1.0",
"helmet": "8.1.0",
"express-validator": "7.3.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.2",
@@ -29,6 +33,7 @@
"@types/jest": "^29.5.0",
"ts-node": "^10.9.0",
"typescript": "^5.0.0",
"tsx": "^4.0.0"
"tsx": "^4.0.0",
"cross-env": "10.1.0"
}
}

View File

@@ -18,17 +18,17 @@ function generateEmail(firstname: string, lastname: string): string {
const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, '');
const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
return `${cleanFirstname}.${cleanLastname}@sp.de`;
}
export const getEmployees = async (req: AuthRequest, res: Response): Promise<void> => {
try {
console.log('🔍 Fetching employees - User:', req.user);
const { includeInactive } = req.query;
const includeInactiveFlag = includeInactive === 'true';
let query = `
SELECT
e.id, e.email, e.firstname, e.lastname,
@@ -43,13 +43,13 @@ export const getEmployees = async (req: AuthRequest, res: Response): Promise<voi
FROM employees e
LEFT JOIN employee_roles er ON e.id = er.employee_id
`;
if (!includeInactiveFlag) {
query += ' WHERE e.is_active = 1';
}
query += ' ORDER BY e.firstname, e.lastname';
const employees = await db.all<any>(query);
// Format employees with proper field names and roles array
@@ -132,12 +132,12 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
password: '***hidden***'
});
const {
password,
firstname,
lastname,
const {
password,
firstname,
lastname,
roles = ['user'],
employeeType,
employeeType,
contractType,
canWorkAlone = false,
isTrainee = false
@@ -146,21 +146,21 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
// Validation
if (!password || !firstname || !lastname || !employeeType) {
console.log('❌ Validation failed: Missing required fields');
res.status(400).json({
error: 'Password, firstname, lastname und employeeType sind erforderlich'
res.status(400).json({
error: 'Password, firstname, lastname und employeeType sind erforderlich'
});
return;
}
// ✅ ENHANCED: Validate employee type exists and get category info
const employeeTypeInfo = await db.get<{type: string, category: string, has_contract_type: number}>(
const employeeTypeInfo = await db.get<{ type: string, category: string, has_contract_type: number }>(
'SELECT type, category, has_contract_type FROM employee_types WHERE type = ?',
[employeeType]
);
if (!employeeTypeInfo) {
res.status(400).json({
error: `Ungültiger employeeType: ${employeeType}. Gültige Typen: manager, personell, apprentice, guest`
res.status(400).json({
error: `Ungültiger employeeType: ${employeeType}. Gültige Typen: manager, personell, apprentice, guest`
});
return;
}
@@ -169,22 +169,22 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
if (employeeTypeInfo.has_contract_type === 1) {
// Internal types require contract type
if (!contractType) {
res.status(400).json({
error: `contractType ist erforderlich für employeeType: ${employeeType}`
res.status(400).json({
error: `contractType ist erforderlich für employeeType: ${employeeType}`
});
return;
}
if (!['small', 'large', 'flexible'].includes(contractType)) {
res.status(400).json({
error: `Ungültiger contractType: ${contractType}. Gültige Werte: small, large, flexible`
res.status(400).json({
error: `Ungültiger contractType: ${contractType}. Gültige Werte: small, large, flexible`
});
return;
}
} else {
// External types (guest) should not have contract type
if (contractType) {
res.status(400).json({
error: `contractType ist nicht erlaubt für employeeType: ${employeeType}`
res.status(400).json({
error: `contractType ist nicht erlaubt für employeeType: ${employeeType}`
});
return;
}
@@ -192,8 +192,8 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
// ✅ ENHANCED: isTrainee validation - only applicable for personell type
if (isTrainee && employeeType !== 'personell') {
res.status(400).json({
error: `isTrainee ist nur für employeeType 'personell' erlaubt`
res.status(400).json({
error: `isTrainee ist nur für employeeType 'personell' erlaubt`
});
return;
}
@@ -204,11 +204,11 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
// Check if generated email already exists
const existingUser = await db.get<any>('SELECT id FROM employees WHERE email = ? AND is_active = 1', [email]);
if (existingUser) {
console.log('❌ Generated email already exists:', email);
res.status(409).json({
error: `Employee with email ${email} already exists. Please use different firstname/lastname.`
res.status(409).json({
error: `Employee with email ${email} already exists. Please use different firstname/lastname.`
});
return;
}
@@ -228,12 +228,12 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
is_active, is_trainee
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
employeeId,
email,
hashedPassword,
firstname,
lastname,
employeeType,
employeeId,
email,
hashedPassword,
firstname,
lastname,
employeeType,
contractType, // Will be NULL for external types
canWorkAlone ? 1 : 0,
1,
@@ -302,9 +302,9 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
const { id } = req.params;
const { firstname, lastname, roles, isActive, employeeType, contractType, canWorkAlone, isTrainee } = req.body;
console.log('📝 Update Employee Request:', {
id, firstname, lastname, roles, isActive,
employeeType, contractType, canWorkAlone, isTrainee
console.log('📝 Update Employee Request:', {
id, firstname, lastname, roles, isActive,
employeeType, contractType, canWorkAlone, isTrainee
});
// Check if employee exists and get current data
@@ -314,6 +314,56 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
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
if (employeeType) {
const validEmployeeType = await db.get(
@@ -322,8 +372,8 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
);
if (!validEmployeeType) {
res.status(400).json({
error: `Ungültiger employeeType: ${employeeType}`
res.status(400).json({
error: `Ungültiger employeeType: ${employeeType}`
});
return;
}
@@ -335,16 +385,16 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
const newFirstname = firstname || existingEmployee.firstname;
const newLastname = lastname || existingEmployee.lastname;
email = generateEmail(newFirstname, newLastname);
// Check if new email already exists (for another employee)
const emailExists = await db.get<any>(
'SELECT id FROM employees WHERE email = ? AND id != ? AND is_active = 1',
'SELECT id FROM employees WHERE email = ? AND id != ? AND is_active = 1',
[email, id]
);
if (emailExists) {
res.status(409).json({
error: `Cannot update name - email ${email} already exists for another employee`
res.status(409).json({
error: `Cannot update name - email ${email} already exists for another employee`
});
return;
}
@@ -373,7 +423,7 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
if (roles) {
// Delete existing roles
await db.run('DELETE FROM employee_roles WHERE employee_id = ?', [id]);
// Insert new roles
for (const role of roles) {
await db.run(
@@ -438,7 +488,15 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
const { id } = req.params;
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>(`
SELECT
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;
}
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);
// Start transaction
@@ -463,18 +541,18 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
try {
// 1. Remove availabilities
await db.run('DELETE FROM employee_availability WHERE employee_id = ?', [id]);
// 2. Remove from assigned_shifts (JSON field cleanup)
interface AssignedShift {
id: string;
assigned_employees: string;
}
const assignedShifts = await db.all<AssignedShift>(
'SELECT id, assigned_employees FROM scheduled_shifts WHERE json_extract(assigned_employees, "$") LIKE ?',
'SELECT id, assigned_employees FROM scheduled_shifts WHERE json_extract(assigned_employees, "$") LIKE ?',
[`%${id}%`]
);
for (const shift of assignedShifts) {
try {
const employeesArray: string[] = JSON.parse(shift.assigned_employees || '[]');
@@ -503,7 +581,7 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
await db.run('COMMIT');
console.log('✅ Successfully deleted employee:', existingEmployee.email);
res.status(204).send();
} catch (error) {
@@ -511,7 +589,6 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
console.error('Error during deletion transaction:', error);
throw error;
}
} catch (error) {
console.error('Error deleting employee:', 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 { planId, availabilities } = req.body;
// Check if employee exists
const existingEmployee = await db.get('SELECT id FROM employees WHERE id = ?', [employeeId]);
// Check if employee exists and get contract type
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) {
res.status(404).json({ error: 'Employee not found' });
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');
try {
@@ -629,12 +742,12 @@ export const changePassword = async (req: AuthRequest, res: Response): Promise<v
// Get the current user from the auth middleware
const currentUser = (req as AuthRequest).user;
// Check if user is changing their own password or is an admin
if (currentUser?.userId !== id && currentUser?.role !== 'admin') {
res.status(403).json({ error: 'You can only change your own password' });
return;
}
}
// Check if employee exists and get password
const employee = await db.get<{ password: string }>('SELECT password FROM employees WHERE id = ?', [id]);
@@ -643,8 +756,8 @@ export const changePassword = async (req: AuthRequest, res: Response): Promise<v
return;
}
// For non-admin users, verify current password
if (currentUser?.role !== 'admin') {
// Verify current password
if (employee) {
const isValidPassword = await bcrypt.compare(currentPassword, employee.password);
if (!isValidPassword) {
res.status(400).json({ error: 'Current password is incorrect' });
@@ -653,8 +766,8 @@ export const changePassword = async (req: AuthRequest, res: Response): Promise<v
}
// Validate new password
if (!newPassword || newPassword.length < 6) {
res.status(400).json({ error: 'New password must be at least 6 characters long' });
if (!newPassword || newPassword.length < 8) {
res.status(400).json({ error: 'New password must be at least 8 characters long' });
return;
}
@@ -685,13 +798,13 @@ export const updateLastLogin = async (req: AuthRequest, res: Response): Promise<
// Update last_login with current timestamp
const currentTimestamp = new Date().toISOString();
await db.run(
'UPDATE employees SET last_login = ? WHERE id = ?',
'UPDATE employees SET last_login = ? WHERE id = ?',
[currentTimestamp, id]
);
console.log(`✅ Last login updated for employee ${id}: ${currentTimestamp}`);
res.json({
res.json({
message: 'Last login updated successfully',
lastLogin: currentTimestamp
});
@@ -699,4 +812,36 @@ export const updateLastLogin = async (req: AuthRequest, res: Response): Promise<
console.error('Error updating last login:', 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 cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
return `${cleanFirstname}.${cleanLastname}@sp.de`;
}
@@ -31,15 +31,15 @@ export const checkSetupStatus = async (req: Request, res: Response): Promise<voi
);
console.log('Admin exists check:', adminExists);
const needsSetup = !adminExists || adminExists['COUNT(*)'] === 0;
res.json({
needsSetup: needsSetup
});
} catch (error) {
console.error('Error checking setup status:', error);
res.status(500).json({
res.status(500).json({
error: 'Internal server error during setup check'
});
}
@@ -75,8 +75,8 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
}
// Password length validation
if (password.length < 6) {
res.status(400).json({ error: 'Das Passwort muss mindestens 6 Zeichen lang sein' });
if (password.length < 8) {
res.status(400).json({ error: 'Das Passwort muss mindestens 8 Zeichen lang sein' });
return;
}
@@ -125,15 +125,15 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
} catch (dbError) {
await db.run('ROLLBACK');
console.error('❌ Database error during admin creation:', dbError);
res.status(500).json({
res.status(500).json({
error: 'Fehler beim Erstellen des Admin-Accounts'
});
}
} catch (error) {
console.error('❌ Error in setup:', error);
if (!res.headersSent) {
res.status(500).json({
res.status(500).json({
error: 'Ein unerwarteter Fehler ist aufgetreten'
});
}

View File

@@ -1,3 +1,8 @@
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
PRAGMA secure_delete = ON;
PRAGMA auto_vacuum = INCREMENTAL;
-- Employee Types
CREATE TABLE IF NOT EXISTS employee_types (
type TEXT PRIMARY KEY,
@@ -149,9 +154,3 @@ CREATE INDEX IF NOT EXISTS idx_scheduled_shifts_required_employees ON scheduled_
CREATE INDEX IF NOT EXISTS idx_shift_assignments_employee ON shift_assignments(employee_id);
CREATE INDEX IF NOT EXISTS idx_shift_assignments_shift ON shift_assignments(scheduled_shift_id);
CREATE INDEX IF NOT EXISTS idx_employee_availability_employee_plan ON employee_availability(employee_id, plan_id);
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;
PRAGMA secure_delete = ON;
PRAGMA auto_vacuum = INCREMENTAL;

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

@@ -0,0 +1,48 @@
import rateLimit from 'express-rate-limit';
import { Request } from 'express';
// Helper to check if request should be limited
const shouldSkipLimit = (req: Request): boolean => {
const skipPaths = [
'/api/health',
'/api/setup/status',
'/api/auth/validate'
];
// Skip for successful GET requests (data fetching)
if (req.method === 'GET' && req.path.startsWith('/api/')) {
return true;
}
return skipPaths.includes(req.path);
};
// Main API limiter - nur für POST/PUT/DELETE
export const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200, // 200 non-GET requests per 15 minutes
message: {
error: 'Zu viele Anfragen, bitte verlangsamen Sie Ihre Aktionen'
},
standardHeaders: true,
legacyHeaders: false,
skip: (req) => {
// ✅ Skip für GET requests (Data Fetching)
if (req.method === 'GET') return true;
// ✅ Skip für Health/Status Checks
return shouldSkipLimit(req);
}
});
// Strict limiter for auth endpoints
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
message: {
error: 'Zu viele Login-Versuche, bitte versuchen Sie es später erneut'
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true,
});

View File

@@ -0,0 +1,550 @@
import { body, validationResult, param, query } from 'express-validator';
import { Request, Response, NextFunction } from 'express';
// ===== AUTH VALIDATION =====
export const validateLogin = [
body('email')
.isEmail()
.withMessage('Must be a valid email')
.normalizeEmail(),
body('password')
.optional()
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
.withMessage('Password must contain uppercase, lowercase, number and special character'),
];
export const validateRegister = [
body('firstname')
.isLength({ min: 1, max: 100 })
.withMessage('First name must be between 1-100 characters')
.notEmpty()
.withMessage('First name must not be empty')
.trim()
.escape(),
body('lastname')
.isLength({ min: 1, max: 100 })
.withMessage('Last name must be between 1-100 characters')
.notEmpty()
.withMessage('Last name must not be empty')
.trim()
.escape(),
body('password')
.optional()
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
.withMessage('Password must contain uppercase, lowercase, number and special character'),
];
// ===== EMPLOYEE VALIDATION =====
export const validateEmployee = [
body('firstname')
.isLength({ min: 1, max: 100 })
.withMessage('First name must be between 1-100 characters')
.notEmpty()
.withMessage('First name must not be empty')
.trim()
.escape(),
body('lastname')
.isLength({ min: 1, max: 100 })
.withMessage('Last name must be between 1-100 characters')
.notEmpty()
.withMessage('Last name must not be empty')
.trim()
.escape(),
body('password')
.optional()
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
.withMessage('Password must contain uppercase, lowercase, number and special character'),
body('employeeType')
.isIn(['manager', 'personell', 'apprentice', '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')
.optional()
.isIn(['small', 'large', 'flexible'])
.withMessage('Contract type must be small, large or flexible'),
body('roles')
.optional()
.isArray()
.withMessage('Roles must be an array'),
body('roles.*')
.optional()
.isIn(['admin', 'maintenance', 'user'])
.withMessage('Invalid role. Allowed: admin, maintenance, user'),
body('canWorkAlone')
.optional()
.isBoolean()
.withMessage('canWorkAlone must be a boolean'),
body('isTrainee')
.optional()
.isBoolean()
.withMessage('isTrainee must be a boolean'),
body('isActive')
.optional()
.isBoolean()
.withMessage('isActive must be a boolean')
];
export const validateEmployeeUpdate = [
body('firstname')
.optional()
.isLength({ min: 1, max: 100 })
.withMessage('First name must be between 1-100 characters')
.notEmpty()
.withMessage('First name must not be empty')
.trim()
.escape(),
body('lastname')
.optional()
.isLength({ min: 1, max: 100 })
.withMessage('Last name must be between 1-100 characters')
.notEmpty()
.withMessage('Last name must not be empty')
.trim()
.escape(),
body('employeeType')
.optional()
.isIn(['manager', 'personell', 'apprentice', 'guest'])
.withMessage('Employee type must be manager, personell, apprentice or guest'),
body('contractType')
.optional()
.custom((value, { req }) => {
const employeeType = req.body.employeeType;
if (!employeeType) return true; // Skip if employeeType not provided
// Same validation logic as create
if (['manager', 'apprentice'].includes(employeeType)) {
if (value !== 'flexible') {
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')
.optional()
.isArray()
.withMessage('Roles must be an array'),
body('roles.*')
.optional()
.isIn(['admin', 'maintenance', 'user'])
.withMessage('Invalid role. Allowed: admin, maintenance, user'),
body('canWorkAlone')
.optional()
.isBoolean()
.withMessage('canWorkAlone must be a boolean'),
body('isTrainee')
.optional()
.isBoolean()
.withMessage('isTrainee must be a boolean'),
body('isActive')
.optional()
.isBoolean()
.withMessage('isActive must be a boolean')
];
export const validateChangePassword = [
body('currentPassword')
.optional()
.isLength({ min: 1 })
.withMessage('Current password is required for self-password change'),
body('newPassword')
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
.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 =====
export const validateShiftPlan = [
body('name')
.isLength({ min: 1, max: 200 })
.withMessage('Name must be between 1-200 characters')
.trim()
.escape(),
body('description')
.optional()
.isLength({ max: 1000 })
.withMessage('Description cannot exceed 1000 characters')
.trim()
.escape(),
body('startDate')
.optional()
.isISO8601()
.withMessage('Must be a valid date (ISO format)'),
body('endDate')
.optional()
.isISO8601()
.withMessage('Must be a valid date (ISO format)'),
body('isTemplate')
.optional()
.isBoolean()
.withMessage('isTemplate must be a boolean'),
body('status')
.optional()
.isIn(['draft', 'published', 'archived', 'template'])
.withMessage('Status must be draft, published, archived or template'),
body('timeSlots')
.optional()
.isArray()
.withMessage('Time slots must be an array'),
body('timeSlots.*.name')
.isLength({ min: 1, max: 100 })
.withMessage('Time slot name must be between 1-100 characters')
.trim()
.escape(),
body('timeSlots.*.startTime')
.matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/)
.withMessage('Start time must be in HH:MM format'),
body('timeSlots.*.endTime')
.matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/)
.withMessage('End time must be in HH:MM format'),
body('timeSlots.*.description')
.optional()
.isLength({ max: 500 })
.withMessage('Time slot description cannot exceed 500 characters')
.trim()
.escape(),
body('shifts')
.optional()
.isArray()
.withMessage('Shifts must be an array'),
body('shifts.*.dayOfWeek')
.isInt({ min: 1, max: 7 })
.withMessage('Day of week must be between 1-7 (Monday-Sunday)'),
body('shifts.*.timeSlotId')
.isUUID()
.withMessage('Time slot ID must be a valid UUID'),
body('shifts.*.requiredEmployees')
.isInt({ min: 0 })
.withMessage('Required employees must be a positive integer'),
body('shifts.*.color')
.optional()
.isHexColor()
.withMessage('Color must be a valid hex color')
];
export const validateShiftPlanUpdate = [
body('name')
.optional()
.isLength({ min: 1, max: 200 })
.withMessage('Name must be between 1-200 characters')
.trim()
.escape(),
body('description')
.optional()
.isLength({ max: 1000 })
.withMessage('Description cannot exceed 1000 characters')
.trim()
.escape(),
body('startDate')
.optional()
.isISO8601()
.withMessage('Must be a valid date (ISO format)'),
body('endDate')
.optional()
.isISO8601()
.withMessage('Must be a valid date (ISO format)'),
body('status')
.optional()
.isIn(['draft', 'published', 'archived', 'template'])
.withMessage('Status must be draft, published, archived or template'),
body('timeSlots')
.optional()
.isArray()
.withMessage('Time slots must be an array'),
body('shifts')
.optional()
.isArray()
.withMessage('Shifts must be an array')
];
export const validateCreateFromPreset = [
body('presetName')
.isLength({ min: 1 })
.withMessage('Preset name is required')
.isIn(['GENERAL_STANDARD', 'ZEBRA_STANDARD'])
.withMessage('Invalid preset name'),
body('name')
.isLength({ min: 1, max: 200 })
.withMessage('Name must be between 1-200 characters')
.trim()
.escape(),
body('startDate')
.isISO8601()
.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')
.isISO8601()
.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')
.optional()
.isBoolean()
.withMessage('isTemplate must be a boolean')
];
// ===== SCHEDULED SHIFTS VALIDATION =====
export const validateScheduledShiftUpdate = [
body('assignedEmployees')
.isArray()
.withMessage('assignedEmployees must be an array'),
body('assignedEmployees.*')
.isUUID()
.withMessage('Each assigned employee must be a valid UUID'),
body('requiredEmployees')
.optional()
.isInt({ min: 0 })
.withMessage('Required employees must be a positive integer')
];
// ===== SETUP VALIDATION =====
export const validateSetupAdmin = [
body('firstname')
.isLength({ min: 1, max: 100 })
.withMessage('First name must be between 1-100 characters')
.trim()
.escape(),
body('lastname')
.isLength({ min: 1, max: 100 })
.withMessage('Last name must be between 1-100 characters')
.trim()
.escape(),
body('password')
.optional()
.isLength({ min: 8 })
.withMessage('Password must be at least 8 characters')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/)
.withMessage('Password must contain uppercase, lowercase, number and special character'),
];
// ===== SCHEDULING VALIDATION =====
export const validateSchedulingRequest = [
body('shiftPlan')
.isObject()
.withMessage('Shift plan is required'),
body('shiftPlan.id')
.isUUID()
.withMessage('Shift plan ID must be a valid UUID'),
body('employees')
.isArray({ min: 1 })
.withMessage('At least one employee is required'),
body('employees.*.id')
.isUUID()
.withMessage('Each employee must have a valid UUID'),
body('availabilities')
.isArray()
.withMessage('Availabilities must be an array'),
body('constraints')
.optional()
.isArray()
.withMessage('Constraints must be an array')
];
// ===== AVAILABILITY VALIDATION =====
export const validateAvailabilities = [
body('planId')
.isUUID()
.withMessage('Plan ID must be a valid UUID'),
body('availabilities')
.isArray()
.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')
.isUUID()
.withMessage('Each shift ID must be a valid UUID'),
body('availabilities.*.preferenceLevel')
.isInt({ min: 0, max: 2 })
.withMessage('Preference level must be 0 (unavailable), 1 (available), or 2 (preferred)'),
body('availabilities.*.notes')
.optional()
.isLength({ max: 500 })
.withMessage('Notes cannot exceed 500 characters')
.trim()
.escape()
];
// ===== COMMON VALIDATORS =====
export const validateId = [
param('id')
.isUUID()
.withMessage('Must be a valid UUID')
];
export const validateEmployeeId = [
param('employeeId')
.isUUID()
.withMessage('Must be a valid UUID')
];
export const validatePlanId = [
param('planId')
.isUUID()
.withMessage('Must be a valid UUID')
];
export const validatePagination = [
query('page')
.optional()
.isInt({ min: 1 })
.withMessage('Page must be a positive integer'),
query('limit')
.optional()
.isInt({ min: 1, max: 100 })
.withMessage('Limit must be between 1-100'),
query('includeInactive')
.optional()
.isBoolean()
.withMessage('includeInactive must be a boolean')
];
// ===== MIDDLEWARE TO CHECK VALIDATION RESULTS =====
export const handleValidationErrors = (req: Request, res: Response, next: NextFunction) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
const errorMessages = errors.array().map(error => ({
field: error.type === 'field' ? error.path : error.type,
message: error.msg,
value: error.msg
}));
return res.status(400).json({
error: 'Validation failed',
details: errorMessages
});
}
next();
};

View File

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

View File

@@ -8,12 +8,13 @@ import {
validateToken
} from '../controllers/authController.js';
import { authMiddleware } from '../middleware/auth.js';
import { validateLogin, validateRegister, handleValidationErrors } from '../middleware/validation.js';
const router = express.Router();
// Public routes
router.post('/login', login);
router.post('/register', register);
router.post('/login', validateLogin, handleValidationErrors, login);
router.post('/register', validateRegister, handleValidationErrors, register);
router.get('/validate', validateToken);
// Protected routes (require authentication)

View File

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

View File

@@ -1,4 +1,3 @@
// backend/src/routes/scheduledShifts.ts
import express from 'express';
import { authMiddleware, requireRole } from '../middleware/auth.js';
import {
@@ -8,23 +7,21 @@ import {
getScheduledShiftsFromPlan,
updateScheduledShift
} from '../controllers/shiftPlanController.js';
import {
validateId,
validatePlanId,
validateScheduledShiftUpdate,
handleValidationErrors
} from '../middleware/validation.js';
const router = express.Router();
router.use(authMiddleware);
router.post('/:id/generate-shifts', requireRole(['admin', 'maintenance']), generateScheduledShiftsForPlan);
router.post('/:id/regenerate-shifts', requireRole(['admin', 'maintenance']), regenerateScheduledShifts);
// GET all scheduled shifts for a plan
router.get('/plan/:planId', authMiddleware, getScheduledShiftsFromPlan);
// GET specific scheduled shift
router.get('/:id', authMiddleware, getScheduledShift);
// UPDATE scheduled shift
router.put('/:id', authMiddleware, updateScheduledShift);
router.post('/:id/generate-shifts', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), generateScheduledShiftsForPlan);
router.post('/:id/regenerate-shifts', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), regenerateScheduledShifts);
router.get('/plan/:planId', validatePlanId, handleValidationErrors, getScheduledShiftsFromPlan);
router.get('/:id', validateId, handleValidationErrors, getScheduledShift);
router.put('/:id', validateId, validateScheduledShiftUpdate, handleValidationErrors, updateScheduledShift);
export default router;

View File

@@ -1,9 +1,10 @@
import express from 'express';
import { SchedulingService } from '../services/SchedulingService.js';
import { validateSchedulingRequest, handleValidationErrors } from '../middleware/validation.js';
const router = express.Router();
router.post('/generate-schedule', async (req, res) => {
router.post('/generate-schedule', validateSchedulingRequest, handleValidationErrors, async (req: express.Request, res: express.Response) => {
try {
const { shiftPlan, employees, availabilities, constraints } = req.body;
@@ -14,18 +15,6 @@ router.post('/generate-schedule', async (req, res) => {
constraintCount: constraints?.length
});
// Validate required data
if (!shiftPlan || !employees || !availabilities) {
return res.status(400).json({
error: 'Missing required data',
details: {
shiftPlan: !!shiftPlan,
employees: !!employees,
availabilities: !!availabilities
}
});
}
const scheduler = new SchedulingService();
const result = await scheduler.generateOptimalSchedule({
shiftPlan,

View File

@@ -1,10 +1,10 @@
// backend/src/routes/setup.ts
import express from 'express';
import { checkSetupStatus, setupAdmin } from '../controllers/setupController.js';
import { validateSetupAdmin, handleValidationErrors } from '../middleware/validation.js';
const router = express.Router();
router.get('/status', checkSetupStatus);
router.post('/admin', setupAdmin);
router.post('/admin', validateSetupAdmin, handleValidationErrors, setupAdmin);
export default router;

View File

@@ -1,4 +1,3 @@
// backend/src/routes/shiftPlans.ts
import express from 'express';
import { authMiddleware, requireRole } from '../middleware/auth.js';
import {
@@ -10,32 +9,25 @@ import {
createFromPreset,
clearAssignments
} from '../controllers/shiftPlanController.js';
import {
validateShiftPlan,
validateShiftPlanUpdate,
validateCreateFromPreset,
handleValidationErrors,
validateId
} from '../middleware/validation.js';
const router = express.Router();
router.use(authMiddleware);
// Combined routes for both shift plans and templates
// GET all shift plans (including templates)
router.get('/' , authMiddleware, getShiftPlans);
// GET specific shift plan or template
router.get('/:id', authMiddleware, getShiftPlan);
// POST create new shift plan
router.post('/', requireRole(['admin', 'maintenance']), createShiftPlan);
// POST create new plan from preset
router.post('/from-preset', requireRole(['admin', 'maintenance']), createFromPreset);
// PUT update shift plan or template
router.put('/:id', requireRole(['admin', 'maintenance']), updateShiftPlan);
// DELETE shift plan or template
router.delete('/:id', requireRole(['admin', 'maintenance']), deleteShiftPlan);
// POST clear assignments and reset to draft
router.post('/:id/clear-assignments', requireRole(['admin', 'maintenance']), clearAssignments);
router.get('/', getShiftPlans);
router.get('/:id', validateId, handleValidationErrors, getShiftPlan);
router.post('/', validateShiftPlan, handleValidationErrors, requireRole(['admin', 'maintenance']), createShiftPlan);
router.post('/from-preset', validateCreateFromPreset, handleValidationErrors, requireRole(['admin', 'maintenance']), createFromPreset);
router.put('/:id', validateId, validateShiftPlanUpdate, handleValidationErrors, requireRole(['admin', 'maintenance']), updateShiftPlan);
router.delete('/:id', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), deleteShiftPlan);
router.post('/:id/clear-assignments', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), clearAssignments);
export default router;

View File

@@ -33,10 +33,10 @@ export async function initializeDatabase(): Promise<void> {
console.log(`✅ Using schema at: ${schemaPath}`);
const schema = fs.readFileSync(schemaPath, 'utf8');
try {
console.log('Starting database initialization...');
try {
const existingAdmin = await db.get<{ count: number }>(
`SELECT COUNT(*) as count
@@ -44,7 +44,7 @@ export async function initializeDatabase(): Promise<void> {
JOIN employee_roles er ON e.id = er.employee_id
WHERE er.role = 'admin' AND e.is_active = 1`
);
if (existingAdmin && existingAdmin.count > 0) {
console.log('✅ Database already initialized with admin user');
return;
@@ -52,23 +52,23 @@ export async function initializeDatabase(): Promise<void> {
} catch (error) {
console.log(' Database tables might not exist yet, creating schema...');
}
// Get list of existing tables
interface TableInfo {
name: string;
}
try {
const existingTables = await db.all<TableInfo>(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
);
console.log('Existing tables found:', existingTables.map(t => t.name).join(', ') || 'none');
// UPDATED: Drop tables in correct dependency order for new schema
// Drop tables in correct dependency order for new schema
const tablesToDrop = [
'employee_availability',
'shift_assignments',
'shift_assignments',
'scheduled_shifts',
'shifts',
'time_slots',
@@ -79,7 +79,7 @@ export async function initializeDatabase(): Promise<void> {
'shift_plans',
'applied_migrations'
];
for (const table of tablesToDrop) {
if (existingTables.some(t => t.name === table)) {
console.log(`Dropping table: ${table}`);
@@ -94,17 +94,41 @@ export async function initializeDatabase(): Promise<void> {
console.error('Error checking/dropping existing tables:', error);
// Continue with schema creation even if table dropping fails
}
// Execute schema creation in a transaction
await db.run('BEGIN EXCLUSIVE TRANSACTION');
// Execute each statement separately for better error reporting
const statements = schema
// NEU: PRAGMA-Anweisungen außerhalb der Transaktion ausführen
console.log('Executing PRAGMA statements outside transaction...');
const pragmaStatements = schema
.split(';')
.map(stmt => stmt.trim())
.filter(stmt => stmt.length > 0)
.filter(stmt => stmt.toUpperCase().startsWith('PRAGMA'))
.map(stmt => {
return stmt.split('\n')
.filter(line => !line.trim().startsWith('--'))
.join('\n')
.trim();
});
for (const statement of pragmaStatements) {
try {
console.log('Executing PRAGMA:', statement);
await db.run(statement);
} catch (error) {
console.warn('PRAGMA statement might have failed:', statement, error);
// Continue even if PRAGMA fails
}
}
// Schema-Erstellung in Transaktion
await db.run('BEGIN EXCLUSIVE TRANSACTION');
// Nur die CREATE TABLE und andere Anweisungen (ohne PRAGMA)
const schemaStatements = schema
.split(';')
.map(stmt => stmt.trim())
.filter(stmt => stmt.length > 0)
.filter(stmt => !stmt.toUpperCase().startsWith('PRAGMA'))
.map(stmt => {
// Remove any single-line comments
return stmt.split('\n')
.filter(line => !line.trim().startsWith('--'))
.join('\n')
@@ -112,7 +136,7 @@ export async function initializeDatabase(): Promise<void> {
})
.filter(stmt => stmt.length > 0);
for (const statement of statements) {
for (const statement of schemaStatements) {
try {
console.log('Executing statement:', statement.substring(0, 50) + '...');
await db.run(statement);
@@ -123,8 +147,8 @@ export async function initializeDatabase(): Promise<void> {
throw error;
}
}
// UPDATED: Insert default data in correct order
// Insert default data in correct order
try {
console.log('Inserting default employee types...');
await db.run(`INSERT OR IGNORE INTO employee_types (type, category, has_contract_type) VALUES ('manager', 'internal', 1)`);
@@ -132,7 +156,7 @@ export async function initializeDatabase(): Promise<void> {
await db.run(`INSERT OR IGNORE INTO employee_types (type, category, has_contract_type) VALUES ('apprentice', 'internal', 1)`);
await db.run(`INSERT OR IGNORE INTO employee_types (type, category, has_contract_type) VALUES ('guest', 'external', 0)`);
console.log('✅ Default employee types inserted');
console.log('Inserting default roles...');
await db.run(`INSERT OR IGNORE INTO roles (role, authority_level, description) VALUES ('admin', 100, 'Vollzugriff')`);
await db.run(`INSERT OR IGNORE INTO roles (role, authority_level, description) VALUES ('maintenance', 50, 'Wartungszugriff')`);
@@ -143,13 +167,13 @@ export async function initializeDatabase(): Promise<void> {
await db.run('ROLLBACK');
throw error;
}
await db.run('COMMIT');
console.log('✅ Database schema successfully initialized');
// Give a small delay to ensure all transactions are properly closed
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
console.error('Error during database initialization:', error);
throw error;

View File

@@ -4,6 +4,8 @@ import path from 'path';
import { fileURLToPath } from 'url';
import { initializeDatabase } from './scripts/initializeDatabase.js';
import fs from 'fs';
import helmet from 'helmet';
import type { ViteDevServer } from 'vite';
// Route imports
import authRoutes from './routes/auth.js';
@@ -12,122 +14,274 @@ import shiftPlanRoutes from './routes/shiftPlans.js';
import setupRoutes from './routes/setup.js';
import scheduledShifts from './routes/scheduledShifts.js';
import schedulingRoutes from './routes/scheduling.js';
import { authLimiter, apiLimiter } from './middleware/rateLimit.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = 3002;
const isDevelopment = process.env.NODE_ENV === 'development';
let vite: ViteDevServer | undefined;
if (isDevelopment) {
// Dynamically import and setup Vite middleware
const setupViteDevServer = async () => {
try {
const { createServer } = await import('vite');
vite = await createServer({
server: { middlewareMode: true },
appType: 'spa'
});
app.use(vite.middlewares);
console.log('🔧 Vite dev server integrated with Express');
} catch (error) {
console.warn('⚠️ Vite integration failed, using static files:', error);
}
};
setupViteDevServer();
}
const configureStaticFiles = () => {
const possiblePaths = [
// Production path (Docker)
'/app/frontend-build',
// Development paths
path.resolve(__dirname, '../../frontend/dist'),
path.resolve(__dirname, '../frontend/dist'), // Added for monorepo
path.resolve(process.cwd(), 'frontend/dist'), // Current directory
path.resolve(process.cwd(), '../frontend/dist'), // Parent directory
// Vite dev server fallback
...(isDevelopment ? [path.resolve(__dirname, '../../frontend')] : [])
];
for (const testPath of possiblePaths) {
try {
if (fs.existsSync(testPath)) {
// In development, check for dist or direct source
if (fs.existsSync(path.join(testPath, 'index.html'))) {
console.log('✅ Found frontend at:', testPath);
app.use(express.static(testPath));
return testPath;
}
// For Vite dev server in development
else if (isDevelopment && fs.existsSync(path.join(testPath, 'index.html'))) {
console.log('🔧 Development: Serving frontend source from:', testPath);
app.use(express.static(testPath));
return testPath;
}
}
} catch (error) {
// Silent catch
}
}
return null;
};
app.set('trust proxy', true);
// Security configuration
if (process.env.NODE_ENV === 'production') {
console.info('Checking for JWT_SECRET');
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || JWT_SECRET === 'your-secret-key-please-change') {
console.error('❌ Fatal: JWT_SECRET not set or using default value');
process.exit(1);
}
}
// Security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
},
},
hsts: false,
crossOriginEmbedderPolicy: false
}));
// Additional security headers
app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
next();
});
// Middleware
app.use(express.json());
// Rate limiting - weniger restriktiv in Development
if (process.env.NODE_ENV === 'production') {
app.use('/api/', apiLimiter);
} else {
console.log('🔧 Development: Rate limiting relaxed');
}
// API Routes
app.use('/api/setup', setupRoutes);
app.use('/api/auth', authRoutes);
app.use('/api/auth', authLimiter, authRoutes);
app.use('/api/employees', employeeRoutes);
app.use('/api/shift-plans', shiftPlanRoutes);
app.use('/api/scheduled-shifts', scheduledShifts);
app.use('/api/scheduling', schedulingRoutes);
// Health route
app.get('/api/health', (req: any, res: any) => {
res.json({
status: 'OK',
app.get('/api/health', (req: express.Request, res: express.Response) => {
res.json({
status: 'OK',
message: 'Backend läuft!',
timestamp: new Date().toISOString()
timestamp: new Date().toISOString(),
mode: process.env.NODE_ENV || 'development'
});
});
// 🆕 STATIC FILE SERVING
// Use absolute path that matches Docker container structure
const frontendBuildPath = path.resolve('/app/frontend-build');
console.log('📁 Frontend build path:', frontendBuildPath);
// 🆕 IMPROVED STATIC FILE SERVING
const findFrontendBuildPath = (): string | null => {
const possiblePaths = [
// Production path (Docker)
'/app/frontend-build',
// Development paths
path.resolve(__dirname, '../../frontend/dist'),
path.resolve(__dirname, '../../frontend-build'),
path.resolve(process.cwd(), '../frontend/dist'),
path.resolve(process.cwd(), 'frontend-build'),
];
for (const testPath of possiblePaths) {
try {
if (fs.existsSync(testPath)) {
const indexPath = path.join(testPath, 'index.html');
if (fs.existsSync(indexPath)) {
console.log('✅ Found frontend build at:', testPath);
return testPath;
}
}
} catch (error) {
// Silent catch - just try next path
}
}
return null;
};
const frontendBuildPath = findFrontendBuildPath();
const staticPath = configureStaticFiles();
if (frontendBuildPath) {
// Serviere statische Dateien
app.use(express.static(frontendBuildPath));
// List files for debugging
try {
const files = fs.readdirSync(frontendBuildPath);
console.log('📄 Files in frontend-build:', files);
} catch (err) {
console.log('❌ Could not read frontend-build directory:', err);
}
console.log('✅ Static file serving configured');
} else {
console.log('❌ Frontend build directory NOT FOUND in any location');
console.log(isDevelopment ?
'🔧 Development: Frontend served by Vite dev server (localhost:3003)' :
'❌ Production: No frontend build found'
);
}
// Root route
app.get('/', (req, res) => {
app.get('/', async (req, res) => {
// In development with Vite middleware
if (vite) {
try {
const template = fs.readFileSync(
path.resolve(__dirname, '../../frontend/index.html'),
'utf-8'
);
const html = await vite.transformIndexHtml(req.url, template);
res.send(html);
} catch (error) {
res.status(500).send('Vite dev server error');
}
return;
}
// Fallback to static file serving
if (!frontendBuildPath) {
return res.status(500).send('Frontend build not found');
return res.status(500).send('Frontend not available');
}
const indexPath = path.join(frontendBuildPath, 'index.html');
console.log('📄 Serving index.html from:', indexPath);
if (fs.existsSync(indexPath)) {
res.sendFile(indexPath);
} else {
console.error('❌ index.html not found at:', indexPath);
res.status(404).send('Frontend not found - index.html missing');
}
res.sendFile(indexPath);
});
// Client-side routing fallback
app.get('*', (req, res) => {
// Ignoriere API Routes
app.get('*', async (req, res, next) => {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'API endpoint not found' });
return next(); // API routes handled normally
}
if (!frontendBuildPath) {
return res.status(500).json({ error: 'Frontend application not available' });
// Vite dev server handling
if (vite) {
try {
const template = fs.readFileSync(
path.resolve(__dirname, '../../frontend/index.html'),
'utf-8'
);
const html = await vite.transformIndexHtml(req.url, template);
return res.send(html);
} catch (error) {
console.error('Vite transformation error:', error);
}
}
const indexPath = path.join(frontendBuildPath, 'index.html');
console.log('🔄 Client-side routing for:', req.path, '->', indexPath);
if (fs.existsSync(indexPath)) {
// Use absolute path with res.sendFile
res.sendFile(indexPath, (err) => {
if (err) {
console.error('Error sending index.html:', err);
res.status(500).send('Error loading application');
}
// Static file fallback
if (staticPath) {
const indexPath = path.join(staticPath, 'index.html');
return res.sendFile(indexPath);
}
res.status(500).send('Frontend not available');
});
// Error handling
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Error:', err);
if (process.env.NODE_ENV === 'production') {
res.status(500).json({
error: 'Internal server error',
message: 'Something went wrong'
});
} else {
console.error('❌ index.html not found for client-side routing at:', indexPath);
res.status(404).json({ error: 'Frontend application not found' });
res.status(500).json({
error: 'Internal server error',
message: err.message,
stack: err.stack
});
}
});
// Error handling middleware
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Unhandled error:', err);
res.status(500).json({ error: 'Internal server error' });
// 404 handling
app.use('*', (req, res) => {
res.status(404).json({ error: 'Endpoint not found' });
});
// Initialize the application
const initializeApp = async () => {
try {
// Initialize database with base schema
await initializeDatabase();
// Apply any pending migrations
const { applyMigration } = await import('./scripts/applyMigration.js');
await applyMigration();
// Start server only after successful initialization
app.listen(PORT, () => {
console.log('🎉 APPLICATION STARTED SUCCESSFULLY!');
console.log(`📍 Port: ${PORT}`);
console.log(`📍 Frontend: http://localhost:${PORT}`);
console.log(`📍 Mode: ${process.env.NODE_ENV || 'development'}`);
if (frontendBuildPath) {
console.log(`📍 Frontend: http://localhost:${PORT}`);
} else if (isDevelopment) {
console.log(`📍 Frontend (Vite): http://localhost:3002`);
}
console.log(`📍 API: http://localhost:${PORT}/api`);
console.log('');
console.log(`🔧 Setup: http://localhost:${PORT}/api/setup/status`);
console.log('📝 Create your admin account on first launch');
});
} catch (error) {
console.error('❌ Error during initialization:', error);
@@ -135,5 +289,4 @@ const initializeApp = async () => {
}
};
// Start the application
initializeApp();

View File

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

@@ -6,7 +6,8 @@
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^6.28.0"
"react-router-dom": "^6.28.0",
"date-fns": "4.1.0"
},
"devDependencies": {
"@types/node": "20.19.23",
@@ -14,9 +15,17 @@
"@types/react-dom": "^19.0.0",
"@types/react-router-dom": "^5.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",
"vite": "^6.0.7",
"esbuild": "^0.21.0"
"esbuild": "^0.21.0",
"terser": "5.44.0",
"babel-plugin-transform-remove-console": "6.9.4",
"framer-motion": "12.23.24"
},
"scripts": {
"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';
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 API_BASE_URL = process.env.REACT_APP_API_BASE_URL || '/api';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
interface AuthProviderProps {
children: ReactNode;
@@ -66,7 +66,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
try {
const token = getStoredToken();
console.log('🔄 Refreshing user, token exists:', !!token);
if (!token) {
console.log(' No token found, user not logged in');
setUser(null);
@@ -104,7 +104,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const login = async (credentials: LoginRequest): Promise<void> => {
try {
console.log('🔐 Attempting login for:', credentials.email);
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: {
@@ -120,7 +120,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const data = await response.json();
console.log('✅ Login successful, storing token');
setStoredToken(data.token);
setUser(data.user);
} catch (error) {
@@ -137,13 +137,13 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const hasRole = (roles: string[]): boolean => {
if (!user || !user.roles || user.roles.length === 0) return false;
// Check if user has at least one of the required roles
return roles.some(requiredRole =>
return roles.some(requiredRole =>
user.roles!.includes(requiredRole)
);
};
useEffect(() => {
const initializeAuth = async () => {
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 cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
return `${cleanFirstname}.${cleanLastname}@sp.de`;
}
@@ -22,8 +22,8 @@ function generateEmail(firstname: string, lastname: string): string {
export function validateEmployeeData(employee: CreateEmployeeRequest): string[] {
const errors: string[] = [];
if (employee.password?.length < 6) {
errors.push('Password must be at least 6 characters long');
if (employee.password?.length < 8) {
errors.push('Password must be at least 8 characters long');
}
if (!employee.firstname?.trim() || employee.firstname.trim().length < 2) {
@@ -72,16 +72,16 @@ export function generateEmployeeEmail(firstname: string, lastname: string): stri
}
// UPDATED: Business logic helpers for new employee types
export const isManager = (employee: Employee): boolean =>
export const isManager = (employee: Employee): boolean =>
employee.employeeType === 'manager';
export const isPersonell = (employee: Employee): boolean =>
export const isPersonell = (employee: Employee): boolean =>
employee.employeeType === 'personell';
export const isApprentice = (employee: Employee): boolean =>
export const isApprentice = (employee: Employee): boolean =>
employee.employeeType === 'apprentice';
export const isGuest = (employee: Employee): boolean =>
export const isGuest = (employee: Employee): boolean =>
employee.employeeType === 'guest';
export const isInternal = (employee: Employee): boolean =>
@@ -91,24 +91,24 @@ export const isExternal = (employee: Employee): boolean =>
employee.employeeType === 'guest';
// UPDATED: Trainee logic - now based on isTrainee field for personell type
export const isTrainee = (employee: Employee): boolean =>
export const isTrainee = (employee: Employee): boolean =>
employee.employeeType === 'personell' && employee.isTrainee;
export const isExperienced = (employee: Employee): boolean =>
export const isExperienced = (employee: Employee): boolean =>
employee.employeeType === 'personell' && !employee.isTrainee;
// Role-based helpers
export const isAdmin = (employee: Employee): boolean =>
export const isAdmin = (employee: Employee): boolean =>
employee.roles?.includes('admin') || false;
export const isMaintenance = (employee: Employee): boolean =>
export const isMaintenance = (employee: Employee): boolean =>
employee.roles?.includes('maintenance') || false;
export const isUser = (employee: Employee): boolean =>
export const isUser = (employee: Employee): boolean =>
employee.roles?.includes('user') || false;
// UPDATED: Work alone permission - managers and experienced personell can work alone
export const canEmployeeWorkAlone = (employee: Employee): boolean =>
export const canEmployeeWorkAlone = (employee: Employee): boolean =>
employee.canWorkAlone && (isManager(employee) || isExperienced(employee));
// Helper for full name display

View File

@@ -3,6 +3,8 @@ import { employeeService } from '../../../services/employeeService';
import { shiftPlanService } from '../../../services/shiftPlanService';
import { Employee, EmployeeAvailability } from '../../../models/Employee';
import { ShiftPlan, TimeSlot, Shift } from '../../../models/ShiftPlan';
import { useNotification } from '../../../contexts/NotificationContext';
import { useBackendValidation } from '../../../hooks/useBackendValidation';
interface AvailabilityManagerProps {
employee: Employee;
@@ -36,7 +38,8 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
const [selectedPlan, setSelectedPlan] = useState<ShiftPlan | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const { showNotification } = useNotification();
const { executeWithValidation, isSubmitting } = useBackendValidation();
const daysOfWeek = [
{ id: 1, name: 'Montag' },
@@ -81,7 +84,11 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
} catch (err: any) {
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);
}
};
@@ -134,9 +141,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
);
if (invalidAvailabilities.length > 0) {
console.warn('⚠️ UNGÜLTIGE VERFÜGBARKEITEN (OHNE SHIFT-ID):', invalidAvailabilities.length);
invalidAvailabilities.forEach(invalid => {
console.warn(' - Ungültiger Eintrag:', invalid);
});
}
// Transformiere die Daten
@@ -149,20 +153,20 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
// Debug: Zeige vorhandene Präferenzen
if (planAvailabilities.length > 0) {
console.log('🎯 VORHANDENE PRÄFERENZEN:');
planAvailabilities.forEach(avail => {
const shift = plan.shifts?.find(s => s.id === avail.shiftId);
console.log(` - Shift: ${avail.shiftId} (Day: ${shift?.dayOfWeek}), Level: ${avail.preferenceLevel}`);
});
console.log('🎯 VORHANDENE PRÄFERENZEN:', planAvailabilities.length);
}
} catch (availError) {
console.error('❌ FEHLER BEIM LADEN DER VERFÜGBARKEITEN:', availError);
setAvailabilities([]);
}
} catch (availError) {
console.error('❌ FEHLER BEIM LADEN DER VERFÜGBARKEITEN:', availError);
setAvailabilities([]);
}
} catch (err: any) {
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 {
setLoading(false);
}
@@ -316,26 +320,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
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 (
<div style={{
marginBottom: '30px',
@@ -355,23 +339,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
</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' }}>
<table style={{
width: '100%',
@@ -421,9 +388,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
<div style={{ fontSize: '14px', color: '#666' }}>
{formatTime(timeSlot.startTime)} - {formatTime(timeSlot.endTime)}
</div>
<div style={{ fontSize: '11px', color: '#999', marginTop: '4px' }}>
ID: {timeSlot.id.substring(0, 8)}...
</div>
</td>
{days.map(weekday => {
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 levelConfig = availabilityLevels.find(l => l.level === currentLevel);
@@ -454,31 +415,8 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
padding: '12px 16px',
border: '1px solid #dee2e6',
textAlign: 'center',
backgroundColor: !isValidShift ? '#fff3cd' : (levelConfig?.bgColor || 'white'),
position: 'relative'
backgroundColor: levelConfig?.bgColor || 'white'
}}>
{/* 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
value={currentLevel}
onChange={(e) => {
@@ -487,10 +425,10 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
}}
style={{
padding: '8px 12px',
border: `2px solid ${!isValidShift ? '#f39c12' : (levelConfig?.color || '#ddd')}`,
border: `2px solid ${levelConfig?.color || '#ddd'}`,
borderRadius: '6px',
backgroundColor: !isValidShift ? '#fff3cd' : (levelConfig?.bgColor || 'white'),
color: !isValidShift ? '#856404' : (levelConfig?.color || '#333'),
backgroundColor: levelConfig?.bgColor || 'white',
color: levelConfig?.color || '#333',
fontWeight: 'bold',
minWidth: '140px',
cursor: 'pointer',
@@ -511,23 +449,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
</option>
))}
</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>
);
})}
@@ -556,63 +477,35 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
};
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);
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
const requestData = {
planId: selectedPlanId,
@@ -627,15 +520,16 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
await employeeService.updateAvailabilities(employee.id, requestData);
console.log('✅ VERFÜGBARKEITEN ERFOLGREICH GESPEICHERT');
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Verfügbarkeiten wurden erfolgreich gespeichert'
});
window.dispatchEvent(new CustomEvent('availabilitiesChanged'));
onSave();
} catch (err: any) {
console.error('❌ FEHLER BEIM SPEICHERN:', err);
setError(err.message || 'Fehler beim Speichern der Verfügbarkeiten');
} finally {
setSaving(false);
}
});
};
if (loading) {
@@ -658,11 +552,10 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
// Get full name for display
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 =>
avail.preferenceLevel === 1 || avail.preferenceLevel === 2
).length;
return (
<div style={{
@@ -694,26 +587,14 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
{employee.contractType && (
<p style={{ margin: '5px 0 0 0', color: employee.contractType === 'small' ? '#f39c12' : '#27ae60' }}>
<strong>Vertrag:</strong>
{employee.contractType === 'small' ? ' Kleiner Vertrag (min. 2 verfügbare Shifts)' :
employee.contractType === 'large' ? ' Großer Vertrag (min. 3 verfügbare Shifts)' :
{employee.contractType === 'small' ? ' Kleiner Vertrag' :
employee.contractType === 'large' ? ' Großer Vertrag' :
' Flexibler Vertrag'}
{/* Note: Contract validation is now handled by backend */}
</p>
)}
</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 */}
<div style={{
marginBottom: '30px',
@@ -774,7 +655,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
const newPlanId = e.target.value;
console.log('🔄 PLAN WECHSELN ZU:', newPlanId);
setSelectedPlanId(newPlanId);
// Der useEffect wird automatisch ausgelöst
}}
style={{
padding: '8px 12px',
@@ -828,15 +708,15 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
}}>
<button
onClick={onCancel}
disabled={saving}
disabled={isSubmitting}
style={{
padding: '12px 24px',
backgroundColor: '#95a5a6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: saving ? 'not-allowed' : 'pointer',
opacity: saving ? 0.6 : 1
cursor: isSubmitting ? 'not-allowed' : 'pointer',
opacity: isSubmitting ? 0.6 : 1
}}
>
Abbrechen
@@ -844,18 +724,18 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
<button
onClick={handleSave}
disabled={saving || shiftsCount === 0 || !selectedPlanId}
disabled={isSubmitting || shiftsCount === 0 || !selectedPlanId}
style={{
padding: '12px 24px',
backgroundColor: saving ? '#bdc3c7' : (shiftsCount === 0 || !selectedPlanId ? '#95a5a6' : '#3498db'),
backgroundColor: isSubmitting ? '#bdc3c7' : (shiftsCount === 0 || !selectedPlanId ? '#95a5a6' : '#3498db'),
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: (saving || shiftsCount === 0 || !selectedPlanId) ? 'not-allowed' : 'pointer',
cursor: (isSubmitting || shiftsCount === 0 || !selectedPlanId) ? 'not-allowed' : 'pointer',
fontWeight: 'bold'
}}
>
{saving ? '⏳ Wird gespeichert...' : `Verfügbarkeiten speichern (${availableShiftsCount})`}
{isSubmitting ? '⏳ Wird gespeichert...' : `Verfügbarkeiten speichern (${availableShiftsCount})`}
</button>
</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 { Employee } from '../../../models/Employee';
import { useAuth } from '../../../contexts/AuthContext';
import { useNotification } from '../../../contexts/NotificationContext';
interface EmployeeListProps {
employees: Employee[];
@@ -28,6 +29,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
const [sortField, setSortField] = useState<SortField>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const { user: currentUser, hasRole } = useAuth();
const { showNotification, confirmDialog } = useNotification();
// Filter employees based on active/inactive and search term
const filteredEmployees = employees.filter(employee => {
@@ -176,6 +178,31 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
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) {
return (
<div style={{
@@ -468,7 +495,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
{/* Löschen Button */}
{canDelete && (
<button
onClick={() => onDelete(employee)}
onClick={() => handleDeleteClick(employee)}
style={{
padding: '6px 8px',
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 { useAuth } from '../../contexts/AuthContext';
import { employeeService } from '../../services/employeeService';
import { useNotification } from '../../contexts/NotificationContext';
import { useBackendValidation } from '../../hooks/useBackendValidation';
import AvailabilityManager from '../Employees/components/AvailabilityManager';
import { Employee } from '../../models/Employee';
import { styles } from './type/SettingsType';
@@ -10,11 +11,12 @@ import { styles } from './type/SettingsType';
const Settings: React.FC = () => {
const { user: currentUser, updateUser } = useAuth();
const { showNotification } = useNotification();
const { executeWithValidation, clearErrors, isSubmitting } = useBackendValidation();
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'availability'>('profile');
const [loading, setLoading] = useState(false);
const [showAvailabilityManager, setShowAvailabilityManager] = useState(false);
// Profile form state - updated for firstname/lastname
// Profile form state
const [profileForm, setProfileForm] = useState({
firstname: currentUser?.firstname || '',
lastname: currentUser?.lastname || ''
@@ -73,7 +75,7 @@ const Settings: React.FC = () => {
}));
};
// Password visibility handlers for current password
// Password visibility handlers
const handleCurrentPasswordMouseDown = () => {
currentPasswordTimeoutRef.current = setTimeout(() => {
setShowCurrentPassword(true);
@@ -88,7 +90,6 @@ const Settings: React.FC = () => {
setShowCurrentPassword(false);
};
// Password visibility handlers for new password
const handleNewPasswordMouseDown = () => {
newPasswordTimeoutRef.current = setTimeout(() => {
setShowNewPassword(true);
@@ -103,7 +104,6 @@ const Settings: React.FC = () => {
setShowNewPassword(false);
};
// Password visibility handlers for confirm password
const handleConfirmPasswordMouseDown = () => {
confirmPasswordTimeoutRef.current = setTimeout(() => {
setShowConfirmPassword(true);
@@ -129,7 +129,6 @@ const Settings: React.FC = () => {
cleanup();
};
// Prevent context menu
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
};
@@ -138,40 +137,46 @@ const Settings: React.FC = () => {
e.preventDefault();
if (!currentUser) return;
// Validation
if (!profileForm.firstname.trim() || !profileForm.lastname.trim()) {
// BASIC FRONTEND VALIDATION: Only check required fields
if (!profileForm.firstname.trim()) {
showNotification({
type: 'error',
title: 'Fehler',
message: 'Vorname und Nachname sind erforderlich'
message: 'Vorname ist erforderlich'
});
return;
}
if (!profileForm.lastname.trim()) {
showNotification({
type: 'error',
title: 'Fehler',
message: 'Nachname ist erforderlich'
});
return;
}
try {
setLoading(true);
await employeeService.updateEmployee(currentUser.id, {
firstname: profileForm.firstname.trim(),
lastname: profileForm.lastname.trim()
});
// Use executeWithValidation to handle backend validation
await executeWithValidation(async () => {
const updatedEmployee = await employeeService.updateEmployee(currentUser.id, {
firstname: profileForm.firstname.trim(),
lastname: profileForm.lastname.trim()
});
// Update the auth context with new user data
const updatedUser = await employeeService.getEmployee(currentUser.id);
updateUser(updatedUser);
// Update the auth context with new user data
updateUser(updatedEmployee);
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Profil erfolgreich aktualisiert'
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Profil erfolgreich aktualisiert'
});
});
} catch (error: any) {
showNotification({
type: 'error',
title: 'Fehler',
message: error.message || 'Profil konnte nicht aktualisiert werden'
});
} finally {
setLoading(false);
} catch (error) {
// Backend validation errors are already handled by executeWithValidation
// We only need to handle unexpected errors here
console.error('Unexpected error:', error);
}
};
@@ -179,12 +184,30 @@ const Settings: React.FC = () => {
e.preventDefault();
if (!currentUser) return;
// Validation
if (passwordForm.newPassword.length < 6) {
// BASIC FRONTEND VALIDATION: Only check minimum requirements
if (!passwordForm.currentPassword) {
showNotification({
type: 'error',
title: 'Fehler',
message: 'Das neue Passwort muss mindestens 6 Zeichen lang sein'
message: 'Aktuelles Passwort ist erforderlich'
});
return;
}
if (!passwordForm.newPassword) {
showNotification({
type: 'error',
title: 'Fehler',
message: 'Neues Passwort ist erforderlich'
});
return;
}
if (passwordForm.newPassword.length < 8) {
showNotification({
type: 'error',
title: 'Fehler',
message: 'Das neue Passwort muss mindestens 8 Zeichen lang sein'
});
return;
}
@@ -199,34 +222,30 @@ const Settings: React.FC = () => {
}
try {
setLoading(true);
// Use the actual password change endpoint
await employeeService.changePassword(currentUser.id, {
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword
});
// Use executeWithValidation to handle backend validation
await executeWithValidation(async () => {
await employeeService.changePassword(currentUser.id, {
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword,
confirmPassword: passwordForm.confirmPassword
});
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Passwort erfolgreich geändert'
});
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Passwort erfolgreich geändert'
});
// Clear password form
setPasswordForm({
currentPassword: '',
newPassword: '',
confirmPassword: ''
// Clear password form
setPasswordForm({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
});
} catch (error: any) {
showNotification({
type: 'error',
title: 'Fehler',
message: error.message || 'Passwort konnte nicht geändert werden'
});
} finally {
setLoading(false);
} catch (error) {
// Backend validation errors are already handled by executeWithValidation
console.error('Unexpected error:', error);
}
};
@@ -243,12 +262,18 @@ const Settings: React.FC = () => {
setShowAvailabilityManager(false);
};
// Clear validation errors when switching tabs
const handleTabChange = (tab: 'profile' | 'password' | 'availability') => {
clearErrors();
setActiveTab(tab);
};
if (!currentUser) {
return <div style={{
textAlign: 'center',
padding: '3rem',
color: '#666',
fontSize: '1.1rem'
return <div style={{
textAlign: 'center',
padding: '3rem',
color: '#666',
fontSize: '1.1rem'
}}>Nicht eingeloggt</div>;
}
@@ -270,10 +295,10 @@ const Settings: React.FC = () => {
<h1 style={styles.title}>Einstellungen</h1>
<div style={styles.subtitle}>Verwalten Sie Ihre Kontoeinstellungen und Präferenzen</div>
</div>
<div style={styles.tabs}>
<button
onClick={() => setActiveTab('profile')}
onClick={() => handleTabChange('profile')}
style={{
...styles.tab,
...(activeTab === 'profile' ? styles.tabActive : {})
@@ -299,9 +324,9 @@ const Settings: React.FC = () => {
<span style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '2px' }}>Persönliche Informationen</span>
</div>
</button>
<button
onClick={() => setActiveTab('password')}
onClick={() => handleTabChange('password')}
style={{
...styles.tab,
...(activeTab === 'password' ? styles.tabActive : {})
@@ -327,9 +352,9 @@ const Settings: React.FC = () => {
<span style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '2px' }}>Sicherheitseinstellungen</span>
</div>
</button>
<button
onClick={() => setActiveTab('availability')}
onClick={() => handleTabChange('availability')}
style={{
...styles.tab,
...(activeTab === 'availability' ? styles.tabActive : {})
@@ -369,7 +394,7 @@ const Settings: React.FC = () => {
Verwalten Sie Ihre persönlichen Informationen und Kontaktdaten
</p>
</div>
<form onSubmit={handleProfileUpdate} style={{ marginTop: '2rem' }}>
<div style={styles.formGrid}>
{/* Read-only information */}
@@ -480,28 +505,28 @@ const Settings: React.FC = () => {
<div style={styles.actions}>
<button
type="submit"
disabled={loading || !profileForm.firstname.trim() || !profileForm.lastname.trim()}
disabled={isSubmitting || !profileForm.firstname.trim() || !profileForm.lastname.trim()}
style={{
...styles.button,
...styles.buttonPrimary,
...((loading || !profileForm.firstname.trim() || !profileForm.lastname.trim()) ? styles.buttonDisabled : {})
...((isSubmitting || !profileForm.firstname.trim() || !profileForm.lastname.trim()) ? styles.buttonDisabled : {})
}}
onMouseEnter={(e) => {
if (!loading && profileForm.firstname.trim() && profileForm.lastname.trim()) {
if (!isSubmitting && profileForm.firstname.trim() && profileForm.lastname.trim()) {
e.currentTarget.style.background = styles.buttonPrimaryHover.background;
e.currentTarget.style.transform = styles.buttonPrimaryHover.transform;
e.currentTarget.style.boxShadow = styles.buttonPrimaryHover.boxShadow;
}
}}
onMouseLeave={(e) => {
if (!loading && profileForm.firstname.trim() && profileForm.lastname.trim()) {
if (!isSubmitting && profileForm.firstname.trim() && profileForm.lastname.trim()) {
e.currentTarget.style.background = styles.buttonPrimary.background;
e.currentTarget.style.transform = 'none';
e.currentTarget.style.boxShadow = styles.buttonPrimary.boxShadow;
}
}}
>
{loading ? '⏳ Wird gespeichert...' : 'Profil aktualisieren'}
{isSubmitting ? '⏳ Wird gespeichert...' : 'Profil aktualisieren'}
</button>
</div>
</form>
@@ -517,7 +542,7 @@ const Settings: React.FC = () => {
Aktualisieren Sie Ihr Passwort für erhöhte Sicherheit
</p>
</div>
<form onSubmit={handlePasswordUpdate} style={{ marginTop: '2rem' }}>
<div style={styles.formGridCompact}>
{/* Current Password Field */}
@@ -575,9 +600,9 @@ const Settings: React.FC = () => {
value={passwordForm.newPassword}
onChange={handlePasswordChange}
required
minLength={6}
minLength={8}
style={styles.fieldInputWithIcon}
placeholder="Mindestens 6 Zeichen"
placeholder="Mindestens 8 Zeichen"
onFocus={(e) => {
e.target.style.borderColor = '#1a1325';
e.target.style.boxShadow = '0 0 0 3px rgba(26, 19, 37, 0.1)';
@@ -606,7 +631,7 @@ const Settings: React.FC = () => {
</button>
</div>
<div style={styles.fieldHint}>
Das Passwort muss mindestens 6 Zeichen lang sein.
Das Passwort muss mindestens 8 Zeichen lang sein.
</div>
</div>
@@ -657,28 +682,28 @@ const Settings: React.FC = () => {
<div style={styles.actions}>
<button
type="submit"
disabled={loading || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword}
disabled={isSubmitting || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword}
style={{
...styles.button,
...styles.buttonPrimary,
...((loading || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) ? styles.buttonDisabled : {})
...((isSubmitting || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) ? styles.buttonDisabled : {})
}}
onMouseEnter={(e) => {
if (!loading && passwordForm.currentPassword && passwordForm.newPassword && passwordForm.confirmPassword) {
if (!isSubmitting && passwordForm.currentPassword && passwordForm.newPassword && passwordForm.confirmPassword) {
e.currentTarget.style.background = styles.buttonPrimaryHover.background;
e.currentTarget.style.transform = styles.buttonPrimaryHover.transform;
e.currentTarget.style.boxShadow = styles.buttonPrimaryHover.boxShadow;
}
}}
onMouseLeave={(e) => {
if (!loading && passwordForm.currentPassword && passwordForm.newPassword && passwordForm.confirmPassword) {
if (!isSubmitting && passwordForm.currentPassword && passwordForm.newPassword && passwordForm.confirmPassword) {
e.currentTarget.style.background = styles.buttonPrimary.background;
e.currentTarget.style.transform = 'none';
e.currentTarget.style.boxShadow = styles.buttonPrimary.boxShadow;
}
}}
>
{loading ? '⏳ Wird geändert...' : 'Passwort ändern'}
{isSubmitting ? '⏳ Wird geändert...' : 'Passwort ändern'}
</button>
</div>
</form>
@@ -694,16 +719,16 @@ const Settings: React.FC = () => {
Legen Sie Ihre persönliche Verfügbarkeit für Schichtpläne fest
</p>
</div>
<div style={styles.availabilityCard}>
<div style={styles.availabilityIcon}>📅</div>
<h3 style={styles.availabilityTitle}>Verfügbarkeit verwalten</h3>
<p style={styles.availabilityDescription}>
Hier können Sie Ihre persönliche Verfügbarkeit für Schichtpläne festlegen.
Legen Sie für jeden Tag und jede Schicht fest, ob Sie bevorzugt, möglicherweise
Legen Sie für jeden Tag und jede Schicht fest, ob Sie bevorzugt, möglicherweise
oder nicht verfügbar sind.
</p>
<button
onClick={() => setShowAvailabilityManager(true)}
style={{

View File

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

View File

@@ -1,12 +1,26 @@
// frontend/src/pages/Setup/Setup.tsx - UPDATED
import React, { useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
const API_BASE_URL = '/api';
const Setup: React.FC = () => {
const [step, setStep] = useState(1);
const [formData, setFormData] = useState({
// ===== TYP-DEFINITIONEN =====
interface SetupFormData {
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: '',
confirmPassword: '',
firstname: '',
@@ -16,27 +30,26 @@ const Setup: React.FC = () => {
const [error, setError] = useState('');
const { checkSetupStatus } = useAuth();
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const validateStep1 = () => {
if (formData.password.length < 6) {
setError('Das Passwort muss mindestens 6 Zeichen lang sein.');
return false;
const steps: SetupStep[] = [
{
id: 'profile-setup',
title: 'Profilinformationen',
subtitle: 'Geben Sie Ihre persönlichen Daten ein'
},
{
id: 'password-setup',
title: 'Passwort erstellen',
subtitle: 'Legen Sie ein sicheres Passwort fest'
},
{
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()) {
setError('Bitte geben Sie einen Vornamen ein.');
return false;
@@ -48,21 +61,78 @@ const Setup: React.FC = () => {
return true;
};
const handleNext = () => {
setError('');
if (step === 1 && validateStep1()) {
setStep(2);
} else if (step === 2 && validateStep2()) {
handleSubmit();
const validateStep2 = (): boolean => {
if (formData.password.length < 8) {
setError('Das Passwort muss mindestens 8 Zeichen lang sein.');
return false;
}
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('');
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 {
setLoading(true);
setError('');
@@ -93,7 +163,7 @@ const Setup: React.FC = () => {
// Setup Status neu prüfen
await checkSetupStatus();
} catch (err: any) {
console.error('❌ Setup error:', err);
setError(err.message || 'Ein unerwarteter Fehler ist aufgetreten');
@@ -102,20 +172,391 @@ const Setup: React.FC = () => {
}
};
// Helper to display generated email preview
const getEmailPreview = () => {
// ===== HELPER FUNCTIONS =====
const getEmailPreview = (): string => {
if (!formData.firstname.trim() || !formData.lastname.trim()) {
return 'vorname.nachname@sp.de';
}
const cleanFirstname = formData.firstname.toLowerCase().replace(/[^a-z0-9]/g, '');
const cleanLastname = formData.lastname.toLowerCase().replace(/[^a-z0-9]/g, '');
return `${cleanFirstname}.${cleanLastname}@sp.de`;
};
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 (
<div style={{
minHeight: '100vh',
<div style={{
minHeight: '100vh',
backgroundColor: '#f8f9fa',
display: 'flex',
alignItems: 'center',
@@ -128,26 +569,51 @@ const Setup: React.FC = () => {
borderRadius: '12px',
boxShadow: '0 10px 30px rgba(0,0,0,0.1)',
width: '100%',
maxWidth: '500px',
maxWidth: '600px',
border: '1px solid #e9ecef'
}}>
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
<h1 style={{
fontSize: '2rem',
fontWeight: 'bold',
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
<h1 style={{
fontSize: '2rem',
fontWeight: 'bold',
marginBottom: '0.5rem',
color: '#2c3e50'
}}>
🚀 Erstkonfiguration
</h1>
<p style={{
<p style={{
color: '#6c757d',
fontSize: '1.1rem'
fontSize: '1.1rem',
marginBottom: '2rem'
}}>
Richten Sie Ihren Administrator-Account ein
</p>
</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 && (
<div style={{
backgroundColor: '#f8d7da',
@@ -161,175 +627,37 @@ const Setup: React.FC = () => {
</div>
)}
{step === 1 && (
<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={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>
)}
{/* Schritt-Inhalt */}
<div style={{ minHeight: '200px' }}>
{renderStepContent()}
</div>
{step === 2 && (
<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={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',
{/* Navigations-Buttons */}
<div style={{
marginTop: '2rem',
display: 'flex',
justifyContent: 'space-between',
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
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}
style={{
padding: '0.75rem 2rem',
@@ -340,28 +668,25 @@ const Setup: React.FC = () => {
cursor: loading ? 'not-allowed' : 'pointer',
fontWeight: '600',
fontSize: '1rem',
marginLeft: step === 1 ? 'auto' : '0',
transition: 'background-color 0.3s ease'
}}
>
{loading ? '⏳ Wird verarbeitet...' :
step === 1 ? 'Weiter →' : 'Setup abschließen'}
{getNextButtonText()}
</button>
</div>
{step === 2 && (
<div style={{
marginTop: '1.5rem',
textAlign: 'center',
color: '#6c757d',
{/* Zusätzliche Informationen */}
{currentStep === 2 && !loading && (
<div style={{
marginTop: '1.5rem',
textAlign: 'center',
color: '#6c757d',
fontSize: '0.9rem',
padding: '1rem',
backgroundColor: '#e7f3ff',
borderRadius: '6px',
border: '1px solid #b6d7e8'
backgroundColor: '#f8f9fa',
borderRadius: '6px'
}}>
💡 Nach dem erfolgreichen Setup werden Sie zur Anmeldeseite weitergeleitet,
wo Sie sich mit Ihrer automatisch generierten E-Mail anmelden können.
Überprüfen Sie Ihre Daten, bevor Sie das Setup abschließen
</div>
)}
</div>

View File

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

View File

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

View File

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

View File

@@ -5,12 +5,15 @@ import { useAuth } from '../../contexts/AuthContext';
import { shiftPlanService } from '../../services/shiftPlanService';
import { ShiftPlan } from '../../models/ShiftPlan';
import { useNotification } from '../../contexts/NotificationContext';
import { useBackendValidation } from '../../hooks/useBackendValidation';
import { formatDate } from '../../utils/foramatters';
const ShiftPlanList: React.FC = () => {
const { hasRole } = useAuth();
const navigate = useNavigate();
const { showNotification } = useNotification();
const { showNotification, confirmDialog } = useNotification();
const { executeWithValidation, isSubmitting } = useBackendValidation();
const [shiftPlans, setShiftPlans] = useState<ShiftPlan[]>([]);
const [loading, setLoading] = useState(true);
@@ -19,46 +22,80 @@ const ShiftPlanList: React.FC = () => {
}, []);
const loadShiftPlans = async () => {
try {
const plans = await shiftPlanService.getShiftPlans();
setShiftPlans(plans);
} catch (error) {
console.error('Error loading shift plans:', error);
showNotification({
type: 'error',
title: 'Fehler',
message: 'Die Schichtpläne konnten nicht geladen werden.'
});
} finally {
setLoading(false);
}
await executeWithValidation(async () => {
try {
const plans = await shiftPlanService.getShiftPlans();
setShiftPlans(plans);
} catch (error) {
console.error('Error loading shift plans:', error);
// Error is automatically handled by executeWithValidation
} finally {
setLoading(false);
}
});
};
const handleDelete = async (id: string) => {
if (!window.confirm('Möchten Sie diesen Schichtplan wirklich löschen?')) {
return;
}
const handleDelete = async (id: string, planName: string) => {
const confirmed = await confirmDialog({
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);
showNotification({
type: 'success',
title: 'Erfolg',
message: 'Der Schichtplan wurde erfolgreich gelöscht.'
});
loadShiftPlans();
} catch (error) {
console.error('Error deleting shift plan:', error);
showNotification({
type: 'error',
title: 'Fehler',
message: 'Der Schichtplan konnte nicht gelöscht werden.'
});
}
});
};
const getStatusBadge = (status: string) => {
const config = {
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) {
return <div>Lade Schichtpläne...</div>;
return (
<div style={{
textAlign: 'center',
padding: '40px',
fontSize: '18px',
color: '#666'
}}>
Lade Schichtpläne...
</div>
);
}
return (
@@ -97,6 +134,21 @@ const ShiftPlanList: React.FC = () => {
<div style={{ fontSize: '48px', marginBottom: '20px' }}>📋</div>
<h3>Keine Schichtpläne vorhanden</h3>
<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 style={{ display: 'grid', gap: '20px' }}>
@@ -110,35 +162,35 @@ const ShiftPlanList: React.FC = () => {
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
alignItems: 'center',
border: plan.status === 'published' ? '2px solid #d5f4e6' : '1px solid #e0e0e0'
}}
>
<div>
<h3 style={{ margin: '0 0 10px 0' }}>{plan.name}</h3>
<div style={{ color: '#666', fontSize: '14px' }}>
<div style={{ flex: 1 }}>
<h3 style={{ margin: '0 0 10px 0', color: '#2c3e50' }}>{plan.name}</h3>
<div style={{ color: '#666', fontSize: '14px', marginBottom: '10px' }}>
<p style={{ margin: '0' }}>
Zeitraum: {formatDate(plan.startDate)} - {formatDate(plan.endDate)}
<strong>Zeitraum:</strong> {formatDate(plan.startDate)} - {formatDate(plan.endDate)}
</p>
<p style={{ margin: '5px 0 0 0' }}>
Status: <span style={{
color: plan.status === 'published' ? '#2ecc71' : '#f1c40f',
fontWeight: 'bold'
}}>
{plan.status === 'published' ? 'Veröffentlicht' : 'Entwurf'}
</span>
<strong>Status:</strong> {getStatusBadge(plan.status)}
</p>
</div>
<div style={{ fontSize: '12px', color: '#95a5a6' }}>
Erstellt am: {formatDate(plan.createdAt || '')}
</div>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
<button
onClick={() => navigate(`/shift-plans/${plan.id}`)}
style={{
padding: '8px 16px',
backgroundColor: '#2ecc71',
backgroundColor: '#3498db',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
cursor: 'pointer',
minWidth: '80px'
}}
>
Anzeigen
@@ -149,27 +201,31 @@ const ShiftPlanList: React.FC = () => {
onClick={() => navigate(`/shift-plans/${plan.id}/edit`)}
style={{
padding: '8px 16px',
backgroundColor: '#f1c40f',
backgroundColor: '#f39c12',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
cursor: 'pointer',
minWidth: '80px'
}}
>
Bearbeiten
</button>
<button
onClick={() => handleDelete(plan.id)}
onClick={() => handleDelete(plan.id, plan.name)}
disabled={isSubmitting}
style={{
padding: '8px 16px',
backgroundColor: '#e74c3c',
backgroundColor: isSubmitting ? '#bdc3c7' : '#e74c3c',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
cursor: isSubmitting ? 'not-allowed' : 'pointer',
minWidth: '80px',
opacity: isSubmitting ? 0.6 : 1
}}
>
Löschen
{isSubmitting ? 'Löscht...' : 'Löschen'}
</button>
</>
)}
@@ -178,6 +234,22 @@ const ShiftPlanList: React.FC = () => {
))}
</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>
);
};

View File

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

View File

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

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,45 +1,75 @@
import { defineConfig } from 'vite'
// vite.config.ts
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3003,
host: true,
open: true,
proxy: {
'/api': {
target: 'http://localhost:3002',
changeOrigin: true,
secure: false,
export default defineConfig(({ mode }) => {
const isProduction = mode === 'production'
const isDevelopment = mode === 'development'
const env = loadEnv(mode, process.cwd(), '')
// 🆕 WICHTIG: Relative Pfade für Production
const clientEnv = {
NODE_ENV: mode,
ENABLE_PRO: env.ENABLE_PRO || 'false',
VITE_APP_TITLE: env.APP_TITLE || 'Shift Planning App',
VITE_API_URL: isProduction ? '/api' : 'http://localhost:3002/api',
}
return {
plugins: [react()],
server: isDevelopment ? undefined : {
port: 3002,
host: true,
open: true,
proxy: {
'/api': {
target: 'http://localhost:3002',
changeOrigin: true,
secure: false,
}
}
}
},
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html')
},
build: {
outDir: 'dist',
sourcemap: isDevelopment,
base: isProduction ? '/' : '/',
rollupOptions: {
output: {
chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]',
}
},
minify: isProduction ? 'terser' : false,
terserOptions: isProduction ? {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.debug', 'console.info']
}
} : undefined,
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
'@/components': resolve(__dirname, './src/components'),
'@/pages': resolve(__dirname, './src/pages'),
'@/contexts': resolve(__dirname, './src/contexts'),
'@/models': resolve(__dirname, './src/models'),
'@/utils': resolve(__dirname, './src/utils'),
'@/services': resolve(__dirname, './src/services'),
'@/design': resolve(__dirname, './src/design')
}
}
},
resolve: {
alias: {
'@': resolve(__dirname, './src'),
'@/components': resolve(__dirname, './src/components'),
'@/pages': resolve(__dirname, './src/pages'),
'@/contexts': resolve(__dirname, './src/contexts'),
'@/models': resolve(__dirname, './src/models'),
'@/utils': resolve(__dirname, './src/utils'),
'@/services': resolve(__dirname, './src/services'),
'@/design': resolve(__dirname, './src/design')
}
},
// Define environment variables
define: {
'process.env': process.env
},
define: Object.keys(clientEnv).reduce((acc, key) => {
acc[`import.meta.env.${key}`] = JSON.stringify(clientEnv[key])
return acc
}, {} as Record<string, string>)
}
})

5224
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff