Compare commits

..

56 Commits

Author SHA1 Message Date
ebe9d4aa19 added vite dependency to the backend 2025-11-01 15:58:14 +01:00
07ab9586cc removed unnecessary comments 2025-11-01 15:22:47 +01:00
72430462f6 fixed ipSecurityCheck 2025-11-01 15:17:09 +01:00
c7016b5d04 added simulatnously starting frontend backend dev 2025-11-01 14:29:55 +01:00
41ddad6fa9 added apiClient class handling api auth and validation response from backend 2025-11-01 13:47:29 +01:00
29c66f0228 cleaned up vite.config.ts 2025-11-01 12:06:55 +01:00
0614b2f3f8 updated network for proxy use 2025-11-01 11:28:16 +01:00
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
bc73fcebd3 added pragma statements in .sql 2025-10-28 17:39:45 +01:00
82533ae616 added .env.production to .gitignore 2025-10-28 17:33:26 +01:00
840b4384a5 using static frontend build path for static expresss 2025-10-28 17:29:15 +01:00
5a8b7e89d7 removed unused imports 2025-10-28 16:49:53 +01:00
289c80eea1 removed unused .css files 2025-10-28 16:45:57 +01:00
1884a16220 changed setup button color 2025-10-28 15:58:24 +01:00
478578308d changed noting 2025-10-26 17:15:08 +01:00
93a52aa196 changed production routing for frontend build 2025-10-26 16:56:50 +01:00
donpat1to
b11c55c1d9 Update docker.yml 2025-10-26 16:13:43 +01:00
16302f2105 changed tagging logic for latest versions 2025-10-26 16:09:02 +01:00
57aff5c858 changed tagging logic for latest versions 2025-10-26 16:07:52 +01:00
b4abe459c2 changed tagging logic for latest versions 2025-10-26 15:35:07 +01:00
06bc27a6ce Merge branch 'main' of https://github.com/donpat1to/Schichtenplaner 2025-10-26 12:53:52 +01:00
0aad8f0a56 fixed footer 2025-10-26 12:40:16 +01:00
b52e9d57c7 new package lock generated 2025-10-26 12:24:20 +01:00
15f3183bc0 added esbuild 2025-10-26 12:14:24 +01:00
ca3a5d1c0e changed install to only-production 2025-10-26 12:13:37 +01:00
6a1509d807 removed esbuild 2025-10-26 11:44:39 +01:00
donpat1to
308ae74e37 Update LICENSE-COMMERCIAL 2025-10-26 10:27:11 +01:00
e876f5eb02 fixed login ui 2025-10-26 10:24:07 +01:00
83 changed files with 9040 additions and 3939 deletions

27
.env.template Normal file
View File

@@ -0,0 +1,27 @@
# === 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}
# Trust Proxy Configuration
TRUST_PROXY_ENABLED=false
TRUSTED_PROXY_IPS= # Your load balancer/reverse proxy IPs
TRUSTED_PROXY_HEADER=x-forwarded-for
# Rate Limiting Configuration
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
RATE_LIMIT_SKIP_PATHS=/api/health,/api/status
RATE_LIMIT_WHITELIST=127.0.0.1,::1

View File

@@ -21,15 +21,15 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for tags
fetch-depth: 0
- name: Check if main branch
id: branch_check
run: |
if [[ "${GITHUB_REF}" == "refs/heads/main" || "${GITHUB_REF}" == "refs/heads/master" ]]; then
echo "is_main_branch=true" >> $GITHUB_OUTPUT
if [[ "${{ github.ref }}" == "refs/heads/main" || "${{ github.ref }}" == "refs/heads/master" ]]; then
echo "is_main=true" >> $GITHUB_OUTPUT
else
echo "is_main_branch=false" >> $GITHUB_OUTPUT
echo "is_main=false" >> $GITHUB_OUTPUT
fi
- name: Determine next semantic version tag
@@ -39,24 +39,31 @@ jobs:
# Find latest tag matching vX.Y.Z
latest_tag=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n 1)
echo "Latest tag found: $latest_tag"
if [[ -z "$latest_tag" ]]; then
major=0
minor=0
patch=0
echo "No existing tags found, starting from v0.0.0"
else
version="${latest_tag#v}"
IFS='.' read -r major minor patch <<< "$version"
echo "Parsed version: major=$major, minor=$minor, patch=$patch"
fi
if [[ "${GITHUB_REF}" == "refs/heads/main" || "${GITHUB_REF}" == "refs/heads/master" ]]; then
if [[ "${{ github.ref }}" == "refs/heads/main" || "${{ github.ref }}" == "refs/heads/master" ]]; then
major=$((major + 1))
minor=0
patch=0
elif [[ "${GITHUB_REF}" == "refs/heads/development" ]]; then
echo "Main branch - major version bump"
elif [[ "${{ github.ref }}" == "refs/heads/development" ]]; then
minor=$((minor + 1))
patch=0
echo "Development branch - minor version bump"
else
patch=$((patch + 1))
echo "Other branch - patch version bump"
fi
new_tag="v${major}.${minor}.${patch}"
@@ -87,7 +94,6 @@ jobs:
- name: Run backend tests
working-directory: ./backend
run: |
# Skip tests if jest is not installed
if [ -f "node_modules/.bin/jest" ]; then
npm test
else
@@ -140,13 +146,8 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha
# Add the dynamically generated semantic version
${{ needs.set-tag.outputs.tag_name }}
# Add latest tag for main branch
${{ needs.set-tag.outputs.is_main_branch == 'true' && 'latest' }}
type=raw,value=${{ needs.set-tag.outputs.tag_name }}
type=raw,value=latest,enable=${{ fromJSON(needs.set-tag.outputs.is_main_branch) }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
@@ -176,4 +177,4 @@ jobs:
echo "- Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
echo "- Tags: ${{ steps.meta.outputs.tags }}"
echo "- New version: ${{ needs.set-tag.outputs.tag_name }}"
echo "- Is main branch: ${{ needs.set-tag.outputs.is_main_branch }}"
echo "- Is main branch: ${{ needs.set-tag.outputs.is_main_branch }}"

1
.gitignore vendored
View File

@@ -64,6 +64,7 @@ build/
.env.development.local
.env.test.local
.env.production.local
.env.production
# Database
database/*.db

View File

@@ -16,7 +16,7 @@ COPY tsconfig.base.json ./
COPY ecosystem.config.cjs ./
# Install root dependencies
RUN npm install
RUN npm install --only=production
# Copy workspace files
COPY backend/ ./backend/
@@ -27,7 +27,7 @@ RUN npm install --workspace=backend
RUN npm install --workspace=frontend
# Build backend first
RUN npm run build --workspace=backend
RUN npm run build --only=production --workspace=backend
# Build frontend
RUN npm run build --workspace=frontend
@@ -35,26 +35,52 @@ 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 ./
COPY --from=builder /app/node_modules/ ./node_modules/
COPY --from=builder /app/frontend/dist/ ./frontend-build/
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 && \
chmod 755 /app && \
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

@@ -15,7 +15,7 @@ This software, "Schichtenplaner", is offered under a dual licensing model.
- Integration into commercial software or distributions
To obtain a commercial license, please contact:
📧 patrick@mahnke-hartmann.dev
📧 dev.patrick@mahnke-hartmann.de
or open an inquiry via GitHub: https://github.com/donpat1to/Schichtenplaner
Without a valid commercial license, all commercial rights are reserved.

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 TRUST_PROXY_ENABLED=false npx tsx src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"prestart": "npm run build",
"test": "jest",
@@ -14,23 +15,27 @@
},
"dependencies": {
"@types/bcrypt": "^6.0.0",
"@types/node": "24.9.2",
"vite":"7.1.12",
"bcrypt": "^6.0.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"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",
"@types/cors": "^2.8.13",
"@types/express": "^4.17.17",
"@types/jsonwebtoken": "^9.0.2",
"@types/uuid": "^9.0.2",
"@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

@@ -64,7 +64,7 @@ export const login = async (req: Request, res: Response) => {
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' });
}
// UPDATED: Get user from database with role from employee_roles table
// Get user from database with role from employee_roles table
const user = await db.get<any>(
`SELECT
e.id, e.email, e.password, e.firstname, e.lastname,
@@ -155,7 +155,7 @@ export const getCurrentUser = async (req: Request, res: Response) => {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
// UPDATED: Get user with role from employee_roles table
// Get user with role from employee_roles table
const user = await db.get<any>(
`SELECT
e.id, e.email, e.firstname, e.lastname,

View File

@@ -1,5 +1,5 @@
// backend/src/controllers/employeeController.ts
import { Request, Response } from 'express';
import { Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import bcrypt from 'bcryptjs';
import { db } from '../services/databaseService.js';
@@ -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

@@ -1,7 +1,6 @@
// backend/src/controllers/setupController.ts
import { Request, Response } from 'express';
import bcrypt from 'bcrypt';
import { v4 as uuidv4 } from 'uuid';
import { randomUUID } from 'crypto';
import { db } from '../services/databaseService.js';
@@ -17,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`;
}
@@ -32,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'
});
}
@@ -76,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;
}
@@ -126,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

@@ -5,10 +5,9 @@ import { db } from '../services/databaseService.js';
import {
CreateShiftPlanRequest,
UpdateShiftPlanRequest,
ShiftPlan
} from '../models/ShiftPlan.js';
import { AuthRequest } from '../middleware/auth.js';
import { createPlanFromPreset, TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults.js';
import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults.js';
async function getPlanWithDetails(planId: string) {
const plan = await db.get<any>(`

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,
@@ -148,4 +153,4 @@ CREATE INDEX IF NOT EXISTS idx_scheduled_shifts_date_time ON scheduled_shifts(da
CREATE INDEX IF NOT EXISTS idx_scheduled_shifts_required_employees ON scheduled_shifts(required_employees);
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);
CREATE INDEX IF NOT EXISTS idx_employee_availability_employee_plan ON employee_availability(employee_id, plan_id);

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

@@ -51,4 +51,36 @@ export const requireRole = (roles: string[]) => {
console.log(`✅ Role check passed for user: ${req.user.email}, role: ${req.user.role}`);
next();
};
};
};
export const getClientIP = (req: Request): string => {
const trustedHeader = process.env.TRUSTED_PROXY_HEADER || 'x-forwarded-for';
const forwarded = req.headers[trustedHeader];
const realIp = req.headers['x-real-ip'];
if (forwarded) {
if (Array.isArray(forwarded)) {
return forwarded[0].split(',')[0].trim();
} else if (typeof forwarded === 'string') {
return forwarded.split(',')[0].trim();
}
}
if (realIp) {
return realIp.toString();
}
return req.socket.remoteAddress || req.ip || 'unknown';
};
export const ipSecurityCheck = (req: AuthRequest, res: Response, next: NextFunction): void => {
const clientIP = getClientIP(req);
// Log suspicious activity
const suspiciousPaths = ['/api/auth/login', '/api/auth/register'];
if (suspiciousPaths.includes(req.path)) {
console.log(`🔐 Auth attempt from IP: ${clientIP}, Path: ${req.path}`);
}
next();
}

View File

@@ -0,0 +1,145 @@
import rateLimit from 'express-rate-limit';
import { Request } from 'express';
// Secure IP extraction that works with proxy settings
const getClientIP = (req: Request): string => {
// Read from environment which header to trust
const trustedHeader = process.env.TRUSTED_PROXY_HEADER || 'x-forwarded-for';
const forwarded = req.headers[trustedHeader];
const realIp = req.headers['x-real-ip'];
const cfConnectingIp = req.headers['cf-connecting-ip']; // Cloudflare
// If we have a forwarded header and trust proxy is configured
if (forwarded) {
if (Array.isArray(forwarded)) {
const firstIP = forwarded[0].split(',')[0].trim();
console.log(`🔍 Extracted IP from ${trustedHeader}: ${firstIP} (from: ${forwarded[0]})`);
return firstIP;
} else if (typeof forwarded === 'string') {
const firstIP = forwarded.split(',')[0].trim();
console.log(`🔍 Extracted IP from ${trustedHeader}: ${firstIP} (from: ${forwarded})`);
return firstIP;
}
}
// Cloudflare support
if (cfConnectingIp) {
console.log(`🔍 Using Cloudflare IP: ${cfConnectingIp}`);
return cfConnectingIp.toString();
}
// Fallback to x-real-ip
if (realIp) {
console.log(`🔍 Using x-real-ip: ${realIp}`);
return realIp.toString();
}
// Final fallback to connection remote address
const remoteAddress = req.socket.remoteAddress || req.ip || 'unknown';
console.log(`🔍 Using remote address: ${remoteAddress}`);
return remoteAddress;
};
// 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;
}
// Skip for whitelisted IPs from environment
const whitelist = process.env.RATE_LIMIT_WHITELIST?.split(',') || [];
const clientIP = getClientIP(req);
if (whitelist.includes(clientIP)) {
console.log(`✅ IP whitelisted: ${clientIP}`);
return true;
}
return skipPaths.includes(req.path);
};
// Environment-based configuration
const getRateLimitConfig = () => {
const isProduction = process.env.NODE_ENV === 'production';
return {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 minutes default
max: isProduction
? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100') // Stricter in production
: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '1000'), // More lenient in development
// Development-specific relaxations
skip: (req: Request) => {
// Skip all GET requests in development for easier testing
if (!isProduction && req.method === 'GET') {
return true;
}
return shouldSkipLimit(req);
}
};
};
// Main API limiter - nur für POST/PUT/DELETE
export const apiLimiter = rateLimit({
...getRateLimitConfig(),
message: {
error: 'Zu viele Anfragen, bitte verlangsamen Sie Ihre Aktionen'
},
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => getClientIP(req),
handler: (req, res) => {
const clientIP = getClientIP(req);
console.warn(`🚨 Rate limit exceeded for IP: ${clientIP}, Path: ${req.path}, Method: ${req.method}`);
res.status(429).json({
error: 'Zu viele Anfragen',
message: 'Bitte versuchen Sie es später erneut',
retryAfter: '15 Minuten',
clientIP: process.env.NODE_ENV === 'development' ? clientIP : undefined // Only expose IP in dev
});
}
});
// Strict limiter for auth endpoints
export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX_REQUESTS || '5'),
message: {
error: 'Zu viele Login-Versuche, bitte versuchen Sie es später erneut'
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true,
keyGenerator: (req) => getClientIP(req),
handler: (req, res) => {
const clientIP = getClientIP(req);
console.warn(`🚨 Auth rate limit exceeded for IP: ${clientIP}`);
res.status(429).json({
error: 'Zu viele Login-Versuche',
message: 'Aus Sicherheitsgründen wurde Ihr Konto temporär gesperrt',
retryAfter: '15 Minuten'
});
}
});
// Separate limiter for expensive endpoints
export const expensiveEndpointLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: parseInt(process.env.EXPENSIVE_ENDPOINT_LIMIT || '10'),
message: {
error: 'Zu viele Anfragen für diese Ressource'
},
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => getClientIP(req)
});

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,16 +14,16 @@ 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`;
}
// UPDATED: Validation for new employee model with employee types
// Validation for new employee model with employee types
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) {
@@ -71,17 +71,17 @@ export function generateEmployeeEmail(firstname: string, lastname: string): stri
return generateEmail(firstname, lastname);
}
// UPDATED: Business logic helpers for new employee types
export const isManager = (employee: Employee): boolean =>
// Business logic helpers for new employee types
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 =>
@@ -90,25 +90,25 @@ export const isInternal = (employee: Employee): boolean =>
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 =>
// Trainee logic - now based on isTrainee field for personell type
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 =>
// Work alone permission - managers and experienced personell can work alone
export const canEmployeeWorkAlone = (employee: Employee): boolean =>
employee.canWorkAlone && (isManager(employee) || isExperienced(employee));
// Helper for full name display
@@ -134,7 +134,7 @@ export function validateAvailabilityData(availability: Omit<EmployeeAvailability
return errors;
}
// UPDATED: Helper to get employee type category
// Helper to get employee type category
export const getEmployeeCategory = (employee: Employee): 'internal' | 'external' => {
return isInternal(employee) ? 'internal' : 'external';
};

View File

@@ -78,7 +78,7 @@ export function calculateTotalRequiredEmployees(plan: ShiftPlan): number {
return plan.shifts.reduce((total, shift) => total + shift.requiredEmployees, 0);
}
// UPDATED: Get scheduled shift by date and time slot
// Get scheduled shift by date and time slot
export function getScheduledShiftByDateAndTime(
plan: ShiftPlan,
date: string,

View File

@@ -2,7 +2,7 @@
import { Employee } from './Employee.js';
import { ShiftPlan } from './ShiftPlan.js';
// Updated Availability interface to match new schema
// Availability interface
export interface Availability {
id: string;
employeeId: string;

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,11 +1,10 @@
// backend/src/routes/setup.ts
import express from 'express';
import bcrypt from 'bcryptjs';
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

@@ -53,7 +53,7 @@ async function markMigrationAsApplied(migrationName: string) {
);
}
// UPDATED: Function to handle schema changes for the new employee type system
// Function to handle schema changes for the new employee type system
async function applySchemaUpdates() {
console.log('🔄 Applying schema updates for new employee type system...');
@@ -80,7 +80,7 @@ async function applySchemaUpdates() {
PRAGMA table_info(employees)
`);
// FIXED: Check for employee_type column (not roles column)
// Check for employee_type column (not roles column)
const hasEmployeeType = employeesTableInfo.some((col: TableColumnInfo) => col.name === 'employee_type');
const hasIsTrainee = employeesTableInfo.some((col: TableColumnInfo) => col.name === 'is_trainee');

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

@@ -1,5 +1,4 @@
import { spawn } from 'child_process';
import path from 'path';
export function runPythonScript(scriptPath, args = []) {
return new Promise((resolve, reject) => {

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,105 +14,305 @@ 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 {
apiLimiter,
authLimiter,
expensiveEndpointLimiter
} from './middleware/rateLimit.js';
import { ipSecurityCheck as authIpCheck } from './middleware/auth.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';
app.use(authIpCheck);
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 staticConfig = {
maxAge: '1y',
etag: false,
immutable: true,
index: false
};
// Serve frontend build
const frontendPath = '/app/frontend-build';
if (fs.existsSync(frontendPath)) {
console.log('✅ Serving frontend from:', frontendPath);
app.use(express.static(frontendPath, staticConfig));
}
// Serve premium assets if available
const premiumPath = '/app/premium-dist';
if (fs.existsSync(premiumPath)) {
console.log('✅ Serving premium assets from:', premiumPath);
app.use('/premium-assets', express.static(premiumPath, staticConfig));
}
};
// 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);
}
}
const configureTrustProxy = (): string | string[] | boolean | number => {
const trustedProxyIps = process.env.TRUSTED_PROXY_IPS;
const trustProxyEnabled = process.env.TRUST_PROXY_ENABLED !== 'false'; // Default true for production
// If explicitly disabled
if (!trustProxyEnabled) {
console.log('🔒 Trust proxy: Disabled');
return false;
}
// If specific IPs are provided via environment variable
if (trustedProxyIps) {
console.log('🔒 Trust proxy: Using configured IPs:', trustedProxyIps);
// Handle comma-separated list of IPs/CIDR ranges
if (trustedProxyIps.includes(',')) {
return trustedProxyIps.split(',').map(ip => ip.trim());
}
// Handle single IP/CIDR
return trustedProxyIps.trim();
}
// Default behavior based on environment
if (process.env.NODE_ENV === 'production') {
console.log('🔒 Trust proxy: Using production defaults (private networks)');
return [
'loopback',
'linklocal',
'uniquelocal',
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16'
];
} else {
console.log('🔒 Trust proxy: Development mode (disabled)');
return false;
}
};
app.set('trust proxy', configureTrustProxy());
// 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') {
console.log('🔒 Applying production rate limiting');
app.use('/api/', apiLimiter);
} else {
console.log('🔧 Development: Relaxed rate limiting applied');
// In development, you might want to be more permissive
app.use('/api/', apiLimiter);
}
// 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);
app.use('/api/scheduling', expensiveEndpointLimiter, 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 FÜR FRONTEND
const frontendBuildPath = process.env.FRONTEND_BUILD_PATH || '../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'),
];
// Überprüfe ob das Verzeichnis existiert
if (fs.existsSync(frontendBuildPath)) {
console.log('✅ Frontend build directory exists');
const files = fs.readdirSync(frontendBuildPath);
console.log('📄 Files in frontend-build:', files);
// Serviere statische Dateien
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();
configureStaticFiles();
if (frontendBuildPath) {
app.use(express.static(frontendBuildPath));
console.log('✅ Static file serving configured');
} else {
console.log('❌ Frontend build directory NOT FOUND:', frontendBuildPath);
console.log(isDevelopment ?
'🔧 Development: Frontend served by Vite dev server (localhost:3003)' :
'❌ Production: No frontend build found'
);
}
app.get('/', (req, res) => {
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');
// Root route
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 not available');
}
const indexPath = path.join(frontendBuildPath, 'index.html');
res.sendFile(indexPath);
});
app.get('*', (req, res) => {
// Ignoriere API Routes
// Client-side routing fallback
app.get('*', (req, res, next) => {
// Skip API routes
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'API endpoint not found' });
return next();
}
const indexPath = path.join(frontendBuildPath, 'index.html');
console.log('🔄 Client-side routing for:', req.path, '-> index.html');
// Skip file extensions (assets)
if (req.path.match(/\.[a-z0-9]+$/i)) {
return next();
}
// Serve React app for all other routes
const frontendPath = '/app/frontend-build';
const indexPath = path.join(frontendPath, 'index.html');
if (fs.existsSync(indexPath)) {
res.sendFile(indexPath);
} else {
console.error('❌ index.html not found for client-side routing');
res.status(404).json({ error: 'Frontend application not found' });
res.status(404).send('Frontend not available');
}
});
// Error handling middleware
// Error handling
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' });
console.error('Error:', err);
if (process.env.NODE_ENV === 'production') {
res.status(500).json({
error: 'Internal server error',
message: 'Something went wrong'
});
} else {
res.status(500).json({
error: 'Internal server error',
message: err.message,
stack: err.stack
});
}
});
// 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:3003`);
}
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);
@@ -118,5 +320,4 @@ const initializeApp = async () => {
}
};
// Start the application
initializeApp();

View File

@@ -2,8 +2,7 @@
import { Worker } from 'worker_threads';
import path from 'path';
import { fileURLToPath } from 'url';
import { Employee, EmployeeAvailability } from '../models/Employee.js';
import { ShiftPlan, ScheduledShift } from '../models/ShiftPlan.js';
import { ShiftPlan } from '../models/ShiftPlan.js';
import { ScheduleRequest, ScheduleResult, Availability, Constraint } from '../models/scheduling.js';
const __filename = fileURLToPath(import.meta.url);

View File

@@ -2,8 +2,8 @@
import { parentPort, workerData } from 'worker_threads';
import { CPModel, CPSolver } from './cp-sat-wrapper.js';
import { ShiftPlan, Shift } from '../models/ShiftPlan.js';
import { Employee, EmployeeAvailability } from '../models/Employee.js';
import { Availability, Constraint, Violation, SolverOptions, Solution, Assignment } from '../models/scheduling.js';
import { Employee } from '../models/Employee.js';
import { Availability, Constraint } from '../models/scheduling.js';
interface WorkerData {
shiftPlan: ShiftPlan;

View File

@@ -1,23 +1,19 @@
version: '3.8'
services:
schichtplan:
build:
context: .
dockerfile: backend/Dockerfile
ports:
- "3001:3001"
- "3000:3000"
schichtplaner:
container_name: schichtplaner
image: ghcr.io/donpat1to/schichtenplaner:v1.0.0
environment:
- NODE_ENV=production
- DATABASE_URL=file:./prod.db
- JWT_SECRET=your-production-secret-key-change-this
- PYTHON_PATH=/usr/bin/python3
- 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:3001/health"]
test: ["CMD", "curl", "-f", "http://localhost:3002/api/health"]
interval: 30s
timeout: 10s
retries: 3

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,11 +15,20 @@
"@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"
"vite": "^6.0.7",
"esbuild": "^0.21.0",
"terser": "5.44.0",
"babel-plugin-transform-remove-console": "6.9.4",
"framer-motion": "12.23.24"
},
"scripts": {
"dev": "vite",
"dev": "vite dev",
"build": "tsc && vite build",
"preview": "vite preview"
}

View File

@@ -1,4 +1,4 @@
// src/App.tsx - UPDATED FOR VITE
// src/App.tsx
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext';

View File

@@ -1,4 +1,4 @@
// frontend/src/components/Layout/Footer.tsx - ELEGANT WHITE DESIGN
// frontend/src/components/Layout/Footer.tsx
import React from 'react';
const Footer: React.FC = () => {
@@ -10,12 +10,12 @@ const Footer: React.FC = () => {
borderTop: '1px solid rgba(251, 250, 246, 0.1)',
},
footerContent: {
maxWidth: '1200px',
maxWidth: '1500px',
margin: '0 auto',
padding: '3rem 2rem 2rem',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '3rem',
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
gap: '1rem',
},
footerSection: {
display: 'flex',

View File

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

View File

@@ -1,3 +1,4 @@
// frontend/src/components/Layout/FooterLinks/CommunityLinks/communityLinks.tsx
import React from 'react';
export const CommunityContact: React.FC = () => (

View File

@@ -1,4 +1,4 @@
// frontend/src/pages/FAQ/FAQ.tsx
// frontend/src/components/Layout/FooterLinks/FAQ/FAQ.tsx
import React, { useState } from 'react';
const FAQ: React.FC = () => {
@@ -35,7 +35,7 @@ const FAQ: React.FC = () => {
},
{
question: "Wie lange dauert die Planungserstellung?",
answer: "Typischerweise 30-105 Sekunden, abhängig von der Anzahl der Mitarbeiter und Schichten."
answer: "Typischerweise maximal 105 Sekunden, abhängig von der Anzahl der Mitarbeiter und Schichten."
}
];

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 = () => {
@@ -11,7 +11,7 @@ const Features: React.FC = () => {
{
icon: "⚡",
title: "Schnelle Berechnung",
description: "Google OR-Tools CP-SAT Solver findet Lösungen in 30-105 Sekunden"
description: "Google OR-Tools CP-SAT Solver findet Lösungen in maximal 105 Sekunden"
},
{
icon: "👥",

View File

@@ -1,220 +0,0 @@
/* Layout.css - Professionelles Design */
.layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
position: sticky;
top: 0;
z-index: 1000;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
display: flex;
align-items: center;
justify-content: space-between;
height: 70px;
}
.logo h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
}
/* Desktop Navigation */
.desktop-nav {
display: flex;
gap: 2rem;
align-items: center;
}
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 6px;
transition: all 0.3s ease;
font-weight: 500;
}
.nav-link:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateY(-1px);
}
/* User Menu */
.user-menu {
display: flex;
align-items: center;
gap: 1rem;
}
.user-info {
font-weight: 500;
}
.logout-btn {
background: rgba(255, 255, 255, 0.1);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
}
.logout-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Mobile Menu Button */
.mobile-menu-btn {
display: none;
background: none;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
padding: 0.5rem;
}
/* Mobile Navigation */
.mobile-nav {
display: none;
flex-direction: column;
background: white;
padding: 1rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.mobile-nav-link {
color: #333;
text-decoration: none;
padding: 1rem;
border-bottom: 1px solid #eee;
transition: background-color 0.3s ease;
}
.mobile-nav-link:hover {
background-color: #f5f5f5;
}
.mobile-user-info {
padding: 1rem;
border-top: 1px solid #eee;
margin-top: 1rem;
}
.mobile-logout-btn {
background: #667eea;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
margin-top: 0.5rem;
width: 100%;
}
/* Main Content */
.main-content {
flex: 1;
background-color: #f8f9fa;
min-height: calc(100vh - 140px);
}
.content-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 20px;
}
/* Footer */
.footer {
background: #2c3e50;
color: white;
margin-top: auto;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 20px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 2rem;
}
.footer-section h3,
.footer-section h4 {
margin-bottom: 1rem;
color: #ecf0f1;
}
.footer-section a {
color: #bdc3c7;
text-decoration: none;
display: block;
margin-bottom: 0.5rem;
transition: color 0.3s ease;
}
.footer-section a:hover {
color: #3498db;
}
.footer-bottom {
border-top: 1px solid #34495e;
padding: 1rem 20px;
text-align: center;
color: #95a5a6;
}
/* Responsive Design */
@media (max-width: 768px) {
.desktop-nav,
.user-menu {
display: none;
}
.mobile-menu-btn {
display: block;
}
.mobile-nav {
display: flex;
}
.header-content {
padding: 0 15px;
}
.content-container {
padding: 1rem 15px;
}
.footer-content {
grid-template-columns: 1fr;
text-align: center;
}
}
@media (max-width: 480px) {
.logo h1 {
font-size: 1.2rem;
}
.content-container {
padding: 1rem 10px;
}
}

View File

@@ -1,4 +1,4 @@
// frontend/src/components/Layout/Layout.tsx - ELEGANT WHITE DESIGN
// frontend/src/components/Layout/Layout.tsx
import React from 'react';
import Navigation from './Navigation';
import Footer from './Footer';

View File

@@ -1,4 +1,4 @@
// frontend/src/components/Layout/Navigation.tsx - ELEGANT WHITE DESIGN
// frontend/src/components/Layout/Navigation.tsx
import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import PillNav from '../PillNav/PillNav';

View File

@@ -1,88 +0,0 @@
/* frontend/src/components/PillNav/PillNav.module.css */
.pillNavContainer {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 4px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.pillNavContainer::-webkit-scrollbar {
display: none;
}
.pill {
padding: 8px 16px;
border-radius: 9999px;
border: 1px solid;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease-in-out;
white-space: nowrap;
outline: none;
}
.pill:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
/* Solid Variant */
.pillSolid {
background-color: transparent;
color: #6b7280;
border-color: #d1d5db;
}
.pillSolidActive {
background-color: #2563eb;
color: white;
border-color: #2563eb;
}
.pillSolid:hover:not(.pillSolidActive) {
background-color: #f3f4f6;
color: #374151;
border-color: #9ca3af;
transform: translateY(-1px);
}
/* Outline Variant */
.pillOutline {
background-color: transparent;
color: #6b7280;
border-color: #d1d5db;
}
.pillOutlineActive {
color: #2563eb;
border-color: #2563eb;
font-weight: 600;
}
.pillOutline:hover:not(.pillOutlineActive) {
background-color: #f3f4f6;
color: #374151;
border-color: #9ca3af;
transform: translateY(-1px);
}
/* Ghost Variant */
.pillGhost {
background-color: transparent;
color: #6b7280;
border-color: transparent;
}
.pillGhostActive {
background-color: #f3f4f6;
color: #111827;
}
.pillGhost:hover:not(.pillGhostActive) {
background-color: #f9fafb;
color: #374151;
transform: translateY(-1px);
}

View File

@@ -1,4 +1,4 @@
// frontend/src/components/PillNav/PillNav.tsx - ELEGANT WHITE DESIGN
// frontend/src/components/PillNav/PillNav.tsx
import React, { useEffect, useRef } from 'react';
export interface PillNavItem {

View File

@@ -1,3 +0,0 @@
// frontend/src/components/PillNav/index.ts
export { default } from './PillNav';
export type { PillNavProps, PillNavItem } from './PillNav';

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;
@@ -49,12 +49,21 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
const checkSetupStatus = async (): Promise<void> => {
try {
console.log('🔍 Checking setup status...');
const response = await fetch(`${API_BASE_URL}/setup/status`);
const startTime = Date.now();
const response = await fetch(`${API_BASE_URL}/setup/status`, {
signal: AbortSignal.timeout(5000)
});
console.log(`✅ Setup status response received in ${Date.now() - startTime}ms`);
if (!response.ok) {
console.error('❌ Setup status response not OK:', response.status, response.statusText);
throw new Error('Setup status check failed');
}
const data = await response.json();
console.log('✅ Setup status response:', data);
console.log('✅ Setup status response data:', data);
setNeedsSetup(data.needsSetup === true);
} catch (error) {
console.error('❌ Error checking setup status:', error);
@@ -66,7 +75,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);
@@ -95,7 +104,6 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
}
};
// Add the updateUser function
const updateUser = (userData: Employee) => {
console.log('🔄 Updating user in auth context:', userData);
setUser(userData);
@@ -104,7 +112,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 +128,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 +145,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...');
@@ -161,6 +169,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
initializeAuth();
}, []);
const calculatedNeedsSetup = needsSetup === null ? true : needsSetup;
const value: AuthContextType = {
user,
login,
@@ -168,7 +178,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
hasRole,
loading,
refreshUser,
needsSetup: needsSetup === null ? true : needsSetup,
needsSetup: calculatedNeedsSetup,
checkSetupStatus,
updateUser,
};

View File

@@ -1,4 +1,4 @@
// frontend/src/design/DesignSystem.tsx
// frontend/src/design/DesignSystem.txt
export const designTokens = {
colors: {
// Primary Colors

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

@@ -102,7 +102,7 @@ export const AVAILABILITY_PREFERENCES = {
} as const;
// Default availability for new employees (all shifts unavailable as level 3)
// UPDATED: Now uses shiftId instead of timeSlotId + dayOfWeek
// Now uses shiftId instead of timeSlotId + dayOfWeek
export function createDefaultAvailabilities(employeeId: string, planId: string, shiftIds: string[]): Omit<EmployeeAvailability, 'id'>[] {
const availabilities: Omit<EmployeeAvailability, 'id'>[] = [];

View File

@@ -14,16 +14,16 @@ 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`;
}
// UPDATED: Validation for new employee model with employee types
// Validation for new employee model with employee types
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) {
@@ -71,17 +71,17 @@ export function generateEmployeeEmail(firstname: string, lastname: string): stri
return generateEmail(firstname, lastname);
}
// UPDATED: Business logic helpers for new employee types
export const isManager = (employee: Employee): boolean =>
// Business logic helpers for new employee types
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 =>
@@ -90,25 +90,25 @@ export const isInternal = (employee: Employee): boolean =>
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 =>
// Trainee logic - now based on isTrainee field for personell type
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 =>
// Work alone permission - managers and experienced personell can work alone
export const canEmployeeWorkAlone = (employee: Employee): boolean =>
employee.canWorkAlone && (isManager(employee) || isExperienced(employee));
// Helper for full name display
@@ -134,7 +134,7 @@ export function validateAvailabilityData(availability: Omit<EmployeeAvailability
return errors;
}
// UPDATED: Helper to get employee type category
// Helper to get employee type category
export const getEmployeeCategory = (employee: Employee): 'internal' | 'external' => {
return isInternal(employee) ? 'internal' : 'external';
};

View File

@@ -78,7 +78,7 @@ export function calculateTotalRequiredEmployees(plan: ShiftPlan): number {
return plan.shifts.reduce((total, shift) => total + shift.requiredEmployees, 0);
}
// UPDATED: Get scheduled shift by date and time slot
// Get scheduled shift by date and time slot
export function getScheduledShiftByDateAndTime(
plan: ShiftPlan,
date: string,

View File

@@ -2,7 +2,7 @@
import { Employee } from './Employee.js';
import { ShiftPlan } from './ShiftPlan.js';
// Updated Availability interface to match new schema
// Availability interface to match
export interface Availability {
id: string;
employeeId: string;

View File

@@ -1,4 +1,4 @@
// frontend/src/pages/Auth/Login.tsx - UPDATED PASSWORD SECTION
// frontend/src/pages/Auth/Login.tsx
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
@@ -129,7 +129,7 @@ const Login: React.FC = () => {
onChange={(e) => setEmail(e.target.value)}
required
style={{
width: '94.5%',
width: '100%',
padding: '10px',
border: '1px solid #ddd',
borderRadius: '4px',
@@ -151,7 +151,7 @@ const Login: React.FC = () => {
onChange={(e) => setPassword(e.target.value)}
required
style={{
width: '94.5%',
width: '100%',
padding: '10px',
paddingRight: '10px',
border: '1px solid #ddd',

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[];
@@ -14,7 +15,7 @@ interface EmployeeListProps {
type SortField = 'name' | 'employeeType' | 'canWorkAlone' | 'role' | 'lastLogin';
type SortDirection = 'asc' | 'desc';
// FIXED: Use the actual employee types from the Employee interface
// Use the actual employee types from the Employee interface
type EmployeeType = 'manager' | 'personell' | 'apprentice' | 'guest';
const EmployeeList: React.FC<EmployeeListProps> = ({
@@ -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 => {
@@ -128,7 +130,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
const getEmployeeTypeBadge = (type: EmployeeType, isTrainee: boolean = false) => {
const config = EMPLOYEE_TYPE_CONFIG[type];
// FIXED: Updated color mapping for actual employee types
// Color mapping for actual employee types
const bgColor =
type === 'manager'
? '#fadbd8' // light red
@@ -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={{
@@ -299,7 +326,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
</div>
{sortedEmployees.map(employee => {
// FIXED: Type assertion to ensure type safety
// Type assertion to ensure type safety
const employeeType = getEmployeeTypeBadge(employee.employeeType as EmployeeType, employee.isTrainee);
const independence = getIndependenceBadge(employee.canWorkAlone);
const roleInfo = getRoleBadge(employee.roles);
@@ -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,207 +627,66 @@ 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',
backgroundColor: loading ? '#6c757d' : '#007bff',
backgroundColor: loading ? '#6c757d' : '#51258f',
color: 'white',
border: 'none',
borderRadius: '6px',
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,4 +1,4 @@
// frontend/src/pages/ShiftPlans/ShiftPlanView.tsx - UPDATED
// frontend/src/pages/ShiftPlans/ShiftPlanView.tsx
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
@@ -1118,7 +1118,7 @@ const ShiftPlanView: React.FC = () => {
</div>
)}
{/* Assignment Preview Modal - FIXED CONDITION */}
{/* Assignment Preview Modal */}
{(showAssignmentPreview || assignmentResult) && (
<div style={{
position: 'fixed',

View File

@@ -0,0 +1,130 @@
import { ValidationError, ErrorService } from './errorService';
export class ApiError extends Error {
public validationErrors: ValidationError[];
public statusCode: number;
public originalError?: any;
constructor(message: string, validationErrors: ValidationError[] = [], statusCode: number = 0, originalError?: any) {
super(message);
this.name = 'ApiError';
this.validationErrors = validationErrors;
this.statusCode = statusCode;
this.originalError = originalError;
}
}
export class ApiClient {
private baseURL: string;
constructor() {
this.baseURL = import.meta.env.VITE_API_URL || '/api';
}
private getAuthHeaders(): HeadersInit {
const token = localStorage.getItem('token');
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
private async handleApiResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
let errorData;
try {
// Try to parse error response as JSON
const responseText = await response.text();
errorData = responseText ? JSON.parse(responseText) : {};
} catch {
// If not JSON, create a generic error object
errorData = { error: `HTTP ${response.status}: ${response.statusText}` };
}
// Extract validation errors using your existing ErrorService
const validationErrors = ErrorService.extractValidationErrors(errorData);
if (validationErrors.length > 0) {
// Throw error with validationErrors property for useBackendValidation hook
throw new ApiError(
errorData.error || 'Validation failed',
validationErrors,
response.status,
errorData
);
}
// Throw regular error for non-validation errors
throw new ApiError(
errorData.error || errorData.message || `HTTP error! status: ${response.status}`,
[],
response.status,
errorData
);
}
// For successful responses, try to parse as JSON
try {
const responseText = await response.text();
return responseText ? JSON.parse(responseText) : {} as T;
} catch (error) {
// If response is not JSON but request succeeded (e.g., 204 No Content)
return {} as T;
}
}
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
const config: RequestInit = {
headers: {
'Content-Type': 'application/json',
...this.getAuthHeaders(),
...options.headers,
},
...options,
};
try {
const response = await fetch(url, config);
return await this.handleApiResponse<T>(response);
} catch (error) {
// Re-throw the error to be caught by useBackendValidation
if (error instanceof ApiError) {
throw error;
}
// Wrap non-ApiError errors
throw new ApiError(
error instanceof Error ? error.message : 'Unknown error occurred',
[],
0,
error
);
}
}
// Standardized HTTP methods
get = <T>(endpoint: string) => this.request<T>(endpoint);
post = <T>(endpoint: string, data?: any) =>
this.request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined
});
put = <T>(endpoint: string, data?: any) =>
this.request<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined
});
patch = <T>(endpoint: string, data?: any) =>
this.request<T>(endpoint, {
method: 'PATCH',
body: data ? JSON.stringify(data) : undefined
});
delete = <T>(endpoint: string) =>
this.request<T>(endpoint, { method: 'DELETE' });
}
export const apiClient = new ApiClient();

View File

@@ -1,6 +1,5 @@
// frontend/src/services/authService.ts
import { Employee } from '../models/Employee';
const API_BASE = process.env.REACT_APP_API_BASE_URL || '/api';
import { apiClient } from './apiClient';
export interface LoginRequest {
email: string;
@@ -24,37 +23,15 @@ class AuthService {
private token: string | null = null;
async login(credentials: LoginRequest): Promise<AuthResponse> {
const response = await fetch(`${API_BASE}/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 apiClient.post<AuthResponse>('/auth/login', credentials);
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`, {
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');
}
await apiClient.post('/employees', userData);
return this.login({
email: userData.email,
password: userData.password
@@ -68,34 +45,23 @@ class AuthService {
async fetchCurrentEmployee(): Promise<Employee | null> {
const token = this.getToken();
if (!token) {
return null;
}
if (!token) return null;
try {
const response = await fetch(`${API_BASE}/auth/me`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
const user = data.user;
localStorage.setItem('user', JSON.stringify(user));
return user;
}
const data = await apiClient.get<{ user: Employee }>('/auth/me');
localStorage.setItem('user', JSON.stringify(data.user));
return data.user;
} catch (error) {
console.error('Error fetching current user:', error);
return null;
}
return null;
}
logout(): void {
this.token = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('employee');
}
getToken(): string | null {

View File

@@ -1,146 +1,58 @@
// frontend/src/services/employeeService.ts
import { Employee, CreateEmployeeRequest, UpdateEmployeeRequest, EmployeeAvailability } from '../models/Employee';
const API_BASE_URL = '/api';
const getAuthHeaders = () => {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
};
};
import { apiClient } from './apiClient';
export class EmployeeService {
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');
try {
const employees = await apiClient.get<Employee[]>(`/employees?includeInactive=${includeInactive}`);
console.log('✅ Employees received:', employees.length);
return employees;
} catch (error) {
console.error('❌ Error fetching employees:', error);
throw error; // Let useBackendValidation handle this
}
const employees = await response.json();
console.log('✅ Employees received:', employees.length);
return employees;
}
async getEmployee(id: string): Promise<Employee> {
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 apiClient.get<Employee>(`/employees/${id}`);
}
async createEmployee(employee: CreateEmployeeRequest): Promise<Employee> {
const response = await fetch(`${API_BASE_URL}/employees`, {
method: 'POST',
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 apiClient.post<Employee>('/employees', employee);
}
async updateEmployee(id: string, employee: UpdateEmployeeRequest): Promise<Employee> {
const response = await fetch(`${API_BASE_URL}/employees/${id}`, {
method: 'PUT',
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 apiClient.put<Employee>(`/employees/${id}`, employee);
}
async deleteEmployee(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/employees/${id}`, {
method: 'DELETE',
headers: getAuthHeaders(),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete employee');
}
await apiClient.delete(`/employees/${id}`);
}
async getAvailabilities(employeeId: string): Promise<EmployeeAvailability[]> {
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 apiClient.get<EmployeeAvailability[]>(`/employees/${employeeId}/availabilities`);
}
async updateAvailabilities(employeeId: string, data: { planId: string, availabilities: Omit<EmployeeAvailability, 'id' | 'employeeId'>[] }): Promise<EmployeeAvailability[]> {
async updateAvailabilities(
employeeId: string,
data: { planId: string, availabilities: Omit<EmployeeAvailability, 'id' | 'employeeId'>[] }
): Promise<EmployeeAvailability[]> {
console.log('🔄 Updating availabilities for employee:', employeeId);
const response = await fetch(`${API_BASE_URL}/employees/${employeeId}/availabilities`, {
method: 'PUT',
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 apiClient.put<EmployeeAvailability[]>(`/employees/${employeeId}/availabilities`, data);
}
async changePassword(id: string, data: { currentPassword: string, newPassword: 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');
}
async changePassword(
id: string,
data: { currentPassword: string, newPassword: string, confirmPassword: string }
): Promise<void> {
return apiClient.put<void>(`/employees/${id}/password`, data);
}
async updateLastLogin(employeeId: string): Promise<void> {
try {
const response = await fetch(`${API_BASE_URL}/employees/${employeeId}/last-login`, {
method: 'PATCH',
headers: getAuthHeaders(),
});
if (!response.ok) {
throw new Error('Failed to update last login');
}
await apiClient.patch(`/employees/${employeeId}/last-login`);
} catch (error) {
console.error('Error updating last login:', error);
throw error;

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,65 +1,15 @@
// frontend/src/services/shiftAssignmentService.ts - WEEKLY PATTERN VERSION
import { ShiftPlan, ScheduledShift } from '../models/ShiftPlan';
import { Employee, EmployeeAvailability } from '../models/Employee';
import { authService } from './authService';
import { AssignmentResult, ScheduleRequest } from '../models/scheduling';
const API_BASE_URL = '/api';
// Helper function to get auth headers
const getAuthHeaders = () => {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` })
};
};
import { apiClient } from './apiClient';
export class ShiftAssignmentService {
async updateScheduledShift(id: string, updates: { assignedEmployees: string[] }): Promise<void> {
try {
//console.log('🔄 Updating scheduled shift via API:', { id, updates });
console.log('🔄 Updating scheduled shift via API:', { id, updates });
const response = await fetch(`${API_BASE_URL}/scheduled-shifts/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...authService.getAuthHeaders()
},
body: JSON.stringify(updates)
});
// First, check if we got any response
if (!response.ok) {
// Try to get error message from response
const responseText = await response.text();
console.error('❌ Server response:', responseText);
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
// Try to parse as JSON if possible
try {
const errorData = JSON.parse(responseText);
errorMessage = errorData.error || errorMessage;
} catch (e) {
// If not JSON, use the text as is
errorMessage = responseText || errorMessage;
}
throw new Error(errorMessage);
}
// Try to parse successful response
const responseText = await response.text();
let result;
try {
result = responseText ? JSON.parse(responseText) : {};
} catch (e) {
console.warn('⚠️ Response was not JSON, but request succeeded');
result = { message: 'Update successful' };
}
console.log('✅ Scheduled shift updated successfully:', result);
await apiClient.put(`/scheduled-shifts/${id}`, updates);
console.log('✅ Scheduled shift updated successfully');
} catch (error) {
console.error('❌ Error updating scheduled shift:', error);
@@ -69,48 +19,16 @@ export class ShiftAssignmentService {
async getScheduledShift(id: string): Promise<any> {
try {
const response = await fetch(`${API_BASE_URL}/scheduled-shifts/${id}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (!response.ok) {
const responseText = await response.text();
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
try {
const errorData = JSON.parse(responseText);
errorMessage = errorData.error || errorMessage;
} catch (e) {
errorMessage = responseText || errorMessage;
}
throw new Error(errorMessage);
}
const responseText = await response.text();
return responseText ? JSON.parse(responseText) : {};
return await apiClient.get(`/scheduled-shifts/${id}`);
} catch (error) {
console.error('Error fetching scheduled shift:', error);
throw error;
}
}
// New method to get all scheduled shifts for a plan
async getScheduledShiftsForPlan(planId: string): Promise<ScheduledShift[]> {
try {
const response = await fetch(`${API_BASE_URL}/scheduled-shifts/plan/${planId}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (!response.ok) {
throw new Error(`Failed to fetch scheduled shifts: ${response.status}`);
}
const shifts = await response.json();
const shifts = await apiClient.get<ScheduledShift[]>(`/scheduled-shifts/plan/${planId}`);
// DEBUG: Check the structure of returned shifts
console.log('🔍 SCHEDULED SHIFTS STRUCTURE:', shifts.slice(0, 3));
@@ -132,21 +50,7 @@ export class ShiftAssignmentService {
}
private async callSchedulingAPI(request: ScheduleRequest): Promise<AssignmentResult> {
const response = await fetch(`${API_BASE_URL}/scheduling/generate-schedule`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authService.getAuthHeaders()
},
body: JSON.stringify(request)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Scheduling failed');
}
return response.json();
return await apiClient.post<AssignmentResult>('/scheduling/generate-schedule', request);
}
async assignShifts(

View File

@@ -1,199 +1,115 @@
// frontend/src/services/shiftPlanService.ts
import { authService } from './authService';
import { ShiftPlan, CreateShiftPlanRequest, ScheduledShift, CreateShiftFromTemplateRequest } from '../models/ShiftPlan';
import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults';
const API_BASE_URL = '/api/shift-plans';
// Helper function to get auth headers
const getAuthHeaders = () => {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` })
};
};
// Helper function to handle responses
const handleResponse = async (response: Response) => {
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
return response.json();
};
import { ShiftPlan, CreateShiftPlanRequest } from '../models/ShiftPlan';
import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults';
import { apiClient } from './apiClient';
export const shiftPlanService = {
async getShiftPlans(): Promise<ShiftPlan[]> {
const response = await fetch(API_BASE_URL, {
headers: {
'Content-Type': 'application/json',
...authService.getAuthHeaders()
}
});
if (!response.ok) {
if (response.status === 401) {
authService.logout();
try {
const plans = await apiClient.get<ShiftPlan[]>('/shift-plans');
// Ensure scheduledShifts is always an array
return plans.map((plan: any) => ({
...plan,
scheduledShifts: plan.scheduledShifts || []
}));
} catch (error: any) {
if (error.statusCode === 401) {
// You might want to import and use authService here if needed
localStorage.removeItem('token');
localStorage.removeItem('employee');
throw new Error('Nicht authorisiert - bitte erneut anmelden');
}
throw new Error('Fehler beim Laden der Schichtpläne');
}
const plans = await response.json();
// Ensure scheduledShifts is always an array
return plans.map((plan: any) => ({
...plan,
scheduledShifts: plan.scheduledShifts || []
}));
},
async getShiftPlan(id: string): Promise<ShiftPlan> {
const response = await fetch(`${API_BASE_URL}/${id}`, {
headers: {
'Content-Type': 'application/json',
...authService.getAuthHeaders()
}
});
if (!response.ok) {
if (response.status === 401) {
authService.logout();
try {
return await apiClient.get<ShiftPlan>(`/shift-plans/${id}`);
} catch (error: any) {
if (error.statusCode === 401) {
localStorage.removeItem('token');
localStorage.removeItem('employee');
throw new Error('Nicht authorisiert - bitte erneut anmelden');
}
throw new Error('Schichtplan nicht gefunden');
}
return await response.json();
},
async createShiftPlan(plan: CreateShiftPlanRequest): Promise<ShiftPlan> {
const response = await fetch(API_BASE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authService.getAuthHeaders()
},
body: JSON.stringify(plan)
});
if (!response.ok) {
if (response.status === 401) {
authService.logout();
try {
return await apiClient.post<ShiftPlan>('/shift-plans', plan);
} catch (error: any) {
if (error.statusCode === 401) {
localStorage.removeItem('token');
localStorage.removeItem('employee');
throw new Error('Nicht authorisiert - bitte erneut anmelden');
}
throw new Error('Fehler beim Erstellen des Schichtplans');
}
return response.json();
},
async updateShiftPlan(id: string, plan: Partial<ShiftPlan>): Promise<ShiftPlan> {
const response = await fetch(`${API_BASE_URL}/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...authService.getAuthHeaders()
},
body: JSON.stringify(plan)
});
if (!response.ok) {
if (response.status === 401) {
authService.logout();
try {
return await apiClient.put<ShiftPlan>(`/shift-plans/${id}`, plan);
} catch (error: any) {
if (error.statusCode === 401) {
localStorage.removeItem('token');
localStorage.removeItem('employee');
throw new Error('Nicht authorisiert - bitte erneut anmelden');
}
throw new Error('Fehler beim Aktualisieren des Schichtplans');
}
return response.json();
},
async deleteShiftPlan(id: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
...authService.getAuthHeaders()
}
});
if (!response.ok) {
if (response.status === 401) {
authService.logout();
try {
await apiClient.delete(`/shift-plans/${id}`);
} catch (error: any) {
if (error.statusCode === 401) {
localStorage.removeItem('token');
localStorage.removeItem('employee');
throw new Error('Nicht authorisiert - bitte erneut anmelden');
}
throw new Error('Fehler beim Löschen des Schichtplans');
}
},
// Get specific template or plan
getTemplate: async (id: string): Promise<ShiftPlan> => {
const response = await fetch(`${API_BASE_URL}/${id}`, {
headers: getAuthHeaders()
});
return handleResponse(response);
async getTemplate(id: string): Promise<ShiftPlan> {
return await apiClient.get<ShiftPlan>(`/shift-plans/${id}`);
},
async regenerateScheduledShifts(planId: string):Promise<void> {
async regenerateScheduledShifts(planId: string): Promise<void> {
try {
console.log('🔄 Attempting to regenerate scheduled shifts...');
// You'll need to add this API endpoint to your backend
const response = await fetch(`${API_BASE_URL}/${planId}/regenerate-shifts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
}
});
if (response.ok) {
console.log('✅ Scheduled shifts regenerated');
} else {
console.error('❌ Failed to regenerate shifts');
}
console.log('🔄 Attempting to regenerate scheduled shifts...');
await apiClient.post(`/shift-plans/${planId}/regenerate-shifts`);
console.log('✅ Scheduled shifts regenerated');
} catch (error) {
console.error('❌ Error regenerating shifts:', error);
console.error('❌ Error regenerating shifts:', error);
throw error;
}
},
// Create new plan
createPlan: async (data: CreateShiftPlanRequest): Promise<ShiftPlan> => {
const response = await fetch(`${API_BASE_URL}`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
return handleResponse(response);
async createPlan(data: CreateShiftPlanRequest): Promise<ShiftPlan> {
return await apiClient.post<ShiftPlan>('/shift-plans', data);
},
createFromPreset: async (data: {
async createFromPreset(data: {
presetName: string;
name: string;
startDate: string;
endDate: string;
isTemplate?: boolean;
}): Promise<ShiftPlan> => {
const response = await fetch(`${API_BASE_URL}/from-preset`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}): Promise<ShiftPlan> {
try {
return await apiClient.post<ShiftPlan>('/shift-plans/from-preset', data);
} catch (error: any) {
throw new Error(error.message || `HTTP error! status: ${error.statusCode}`);
}
return response.json();
},
getTemplatePresets: async (): Promise<{name: string, label: string, description: string}[]> => {
// name = label
return Object.entries(TEMPLATE_PRESETS).map(([key, preset]) => ({
async getTemplatePresets(): Promise<{name: string, label: string, description: string}[]> {
return Object.entries(TEMPLATE_PRESETS).map(([key, preset]) => ({
name: key,
label: preset.name,
description: preset.description
@@ -203,22 +119,8 @@ export const shiftPlanService = {
async clearAssignments(planId: string): Promise<void> {
try {
console.log('🔄 Clearing assignments for plan:', planId);
const response = await fetch(`${API_BASE_URL}/${planId}/clear-assignments`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authService.getAuthHeaders()
}
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(errorData.error || `Failed to clear assignments: ${response.status}`);
}
await apiClient.post(`/shift-plans/${planId}/clear-assignments`);
console.log('✅ Assignments cleared successfully');
} catch (error) {
console.error('❌ Error clearing assignments:', error);
throw error;

View File

@@ -1,45 +1,79 @@
import { defineConfig } from 'vite'
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 env = loadEnv(mode, process.cwd(), '')
return {
plugins: [react()],
// Development proxy
server: isProduction ? undefined : {
port: 3003,
host: true,
proxy: {
'/api': {
target: 'http://localhost:3002',
changeOrigin: true,
secure: false,
}
}
}
},
build: {
outDir: 'dist',
sourcemap: true,
rollupOptions: {
input: {
main: resolve(__dirname, 'index.html')
},
// Production build optimized for Express serving
build: {
outDir: 'dist',
sourcemap: false, // Disable in production
minify: 'terser',
// Bundle optimization
rollupOptions: {
output: {
// Efficient chunking
manualChunks: {
vendor: ['react', 'react-dom', 'react-router-dom'],
utils: ['date-fns']
},
// Cache-friendly naming
chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash].[ext]',
}
},
// Performance optimizations
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.debug']
}
},
// Reduce chunking overhead
chunkSizeWarningLimit: 800
},
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')
}
},
// Environment variables
define: {
'import.meta.env.VITE_API_URL': JSON.stringify(isProduction ? '/api' : '/api'),
'import.meta.env.ENABLE_PRO': JSON.stringify(env.ENABLE_PRO || 'false'),
'import.meta.env.NODE_ENV': JSON.stringify(mode)
}
},
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
}
})

4800
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,13 @@
"scripts": {
"docker:build": "docker build -t schichtplan-app .",
"docker:run": "docker run -p 3002:3002 schichtplan-app",
"build:all": "npm run build --workspace=backend && npm run build --workspace=frontend"
"build:all": "npm run build --workspace=backend && npm run build --workspace=frontend",
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
"dev:frontend": "cd frontend && npm run dev",
"dev:backend": "cd backend && npm run dev:single"
},
"devDependencies": {
"typescript": "^5.3.3"
"typescript": "^5.3.3",
"concurrently": "9.2.1"
}
}

Submodule premium updated: ce4b3fd6e0...c65016aaab