mirror of
https://github.com/donpat1to/Schichtenplaner.git
synced 2025-12-01 06:55:45 +01:00
Compare commits
84 Commits
92840c2424
...
v1.0.13
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a87c49703 | |||
| 52f559199d | |||
| ebe9d4aa19 | |||
| 07ab9586cc | |||
| 72430462f6 | |||
| c7016b5d04 | |||
| 41ddad6fa9 | |||
| 29c66f0228 | |||
| 0614b2f3f8 | |||
| 00b48c1f41 | |||
| cae2b83649 | |||
| a69e934075 | |||
| 3ad497dd76 | |||
| b302c447f8 | |||
| 6cc8c91317 | |||
| 0b35bb6dc6 | |||
| 4ef8e7b1f3 | |||
| 14dce28698 | |||
| 82a30f6bb8 | |||
| 0623957993 | |||
| 5809bb8b09 | |||
| fbd0f03eb2 | |||
| 86166048e8 | |||
| 0363505126 | |||
| 1231c8362f | |||
| 663eb61352 | |||
| 23f1dd7aa0 | |||
| 5319ed5d7a | |||
| 65ebf1748b | |||
| 4321763a2b | |||
| 24525043e9 | |||
| d870523685 | |||
| 50a1f1a9b9 | |||
| 1927937109 | |||
| b3b3250f23 | |||
| 5f8a6bef31 | |||
| a838ba44e8 | |||
| 1057fd9954 | |||
| bc73fcebd3 | |||
| 82533ae616 | |||
| 840b4384a5 | |||
| 5a8b7e89d7 | |||
| 289c80eea1 | |||
| 1884a16220 | |||
| 478578308d | |||
| 93a52aa196 | |||
|
|
b11c55c1d9 | ||
| 16302f2105 | |||
| 57aff5c858 | |||
| b4abe459c2 | |||
| 06bc27a6ce | |||
| 0aad8f0a56 | |||
| b52e9d57c7 | |||
| 15f3183bc0 | |||
| ca3a5d1c0e | |||
| 6a1509d807 | |||
|
|
308ae74e37 | ||
| e876f5eb02 | |||
| dabd2dff3b | |||
| 84d7be052d | |||
| 9460f10278 | |||
| 6e1927fe2f | |||
| e5a6fc73fe | |||
| c773740634 | |||
| 23acd88ced | |||
| aa1a2d4d72 | |||
| cf3866ee21 | |||
| 7ab3e0a5fb | |||
| 41aa77e45d | |||
| 8e782a5290 | |||
| 3856f93484 | |||
| dae255e2c1 | |||
| 8f96368f5a | |||
| 636b892ece | |||
| 8be6a7b474 | |||
| a2b2b76665 | |||
| 6d00ab695c | |||
| 2608acc2d9 | |||
| 4dacf94077 | |||
| 5e7c5aabfb | |||
| 05fa87c638 | |||
| 875db3aeb7 | |||
| 809a838e27 | |||
| 8d020a0dac |
29
.env.template
Normal file
29
.env.template
Normal file
@@ -0,0 +1,29 @@
|
||||
# .env.template
|
||||
# ============================================
|
||||
# DOCKER COMPOSE ENVIRONMENT TEMPLATE
|
||||
# Copy this file to .env and adjust values
|
||||
# ============================================
|
||||
|
||||
# Application settings
|
||||
NODE_ENV=production
|
||||
JWT_SECRET=your-secret-key-please-change
|
||||
HOSTNAME=localhost
|
||||
|
||||
# Security & Network
|
||||
TRUST_PROXY_ENABLED=false
|
||||
TRUSTED_PROXY_IPS=127.0.0.1,::1
|
||||
FORCE_HTTPS=false
|
||||
|
||||
# Database
|
||||
DATABASE_PATH=/app/data/schichtplaner.db
|
||||
|
||||
# Optional features
|
||||
ENABLE_PRO=false
|
||||
DEBUG=false
|
||||
|
||||
# Port configuration
|
||||
APP_PORT=3002
|
||||
|
||||
# ============================================
|
||||
# END OF TEMPLATE
|
||||
# ============================================
|
||||
38
.github/workflows/docker.yml
vendored
38
.github/workflows/docker.yml
vendored
@@ -16,11 +16,21 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_name: ${{ steps.set_tag.outputs.tag_name }}
|
||||
is_main_branch: ${{ steps.branch_check.outputs.is_main }}
|
||||
steps:
|
||||
- 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=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_main=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Determine next semantic version tag
|
||||
id: set_tag
|
||||
@@ -29,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}"
|
||||
@@ -65,14 +82,10 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: backend/package-lock.json
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
# Try npm ci first, if it fails use npm install
|
||||
npm ci || (echo "package-lock.json out of sync, using npm install..." && npm install)
|
||||
run: npm install
|
||||
|
||||
- name: Run TypeScript check
|
||||
working-directory: ./backend
|
||||
@@ -81,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
|
||||
@@ -134,11 +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 }}
|
||||
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
|
||||
@@ -168,3 +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 }}"
|
||||
|
||||
27
.gitignore
vendored
27
.gitignore
vendored
@@ -64,6 +64,7 @@ build/
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.production
|
||||
|
||||
# Database
|
||||
database/*.db
|
||||
@@ -110,3 +111,29 @@ Thumbs.db
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Ignore contents of premium folder in public repo
|
||||
premium/*
|
||||
!premium/README-PREMIUM.md
|
||||
!premium/.gitkeep
|
||||
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.env
|
||||
.nyc_output
|
||||
coverage
|
||||
.cache
|
||||
dist
|
||||
build
|
||||
logs
|
||||
*.tsbuildinfo
|
||||
|
||||
# Frontend specific
|
||||
frontend/dist
|
||||
frontend/.vite
|
||||
|
||||
# Backend specific
|
||||
backend/dist
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "premium"]
|
||||
path = premium
|
||||
url = https://github.com/donpat1to/Schichtenplaner-Pro.git
|
||||
94
Dockerfile
94
Dockerfile
@@ -1,7 +1,7 @@
|
||||
# Multi-stage build for combined frontend + backend
|
||||
FROM node:20-bullseye AS backend-builder
|
||||
# Single stage build for workspaces
|
||||
FROM node:20-bullseye AS builder
|
||||
|
||||
WORKDIR /app/backend
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python + OR-Tools
|
||||
RUN apt-get update && apt-get install -y python3 python3-pip build-essential \
|
||||
@@ -10,81 +10,77 @@ RUN apt-get update && apt-get install -y python3 python3-pip build-essential \
|
||||
# Create symlink so python3 is callable as python
|
||||
RUN ln -sf /usr/bin/python3 /usr/bin/python
|
||||
|
||||
# Copy backend files
|
||||
COPY backend/package*.json ./
|
||||
COPY backend/tsconfig.json ./
|
||||
# Copy root package files first
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.base.json ./
|
||||
COPY ecosystem.config.cjs ./
|
||||
|
||||
# Install backend dependencies
|
||||
RUN npm ci
|
||||
# Install root dependencies
|
||||
RUN npm install --only=production
|
||||
|
||||
# Copy backend source
|
||||
COPY backend/src/ ./src/
|
||||
# Copy workspace files
|
||||
COPY backend/ ./backend/
|
||||
COPY frontend/ ./frontend/
|
||||
|
||||
# Build backend
|
||||
RUN npm run build
|
||||
# Install workspace dependencies individually
|
||||
RUN npm install --workspace=backend
|
||||
RUN npm install --workspace=frontend
|
||||
|
||||
# Copy database files manually
|
||||
RUN cp -r src/database/ dist/database/
|
||||
# Build backend first
|
||||
RUN npm run build --only=production --workspace=backend
|
||||
|
||||
# Build frontend
|
||||
RUN npm run build --workspace=frontend
|
||||
|
||||
# Verify Python and OR-Tools installation
|
||||
RUN python -c "from ortools.sat.python import cp_model; print('OR-Tools installed successfully')"
|
||||
|
||||
# Frontend build stage
|
||||
FROM node:20-bullseye AS frontend-builder
|
||||
|
||||
WORKDIR /app/frontend
|
||||
|
||||
# Copy frontend files
|
||||
COPY frontend/package*.json ./
|
||||
COPY frontend/tsconfig.json ./
|
||||
|
||||
# Install frontend dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy frontend source
|
||||
COPY frontend/src/ ./src/
|
||||
COPY frontend/public/ ./public/
|
||||
|
||||
# Build frontend
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install PM2 for process management
|
||||
RUN npm install -g pm2
|
||||
# Install system dependencies including gettext-base for envsubst
|
||||
RUN apt-get update && apt-get install -y gettext-base && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Create data directory for SQLite database with proper permissions
|
||||
RUN npm install -g pm2
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Copy backend built files
|
||||
COPY --from=backend-builder /app/backend/package*.json ./
|
||||
COPY --from=backend-builder /app/backend/dist/ ./dist/
|
||||
COPY --from=backend-builder /app/backend/node_modules/ ./node_modules/
|
||||
# Copy application files
|
||||
COPY --from=builder /app/backend/dist/ ./dist/
|
||||
COPY --from=builder /app/backend/package*.json ./
|
||||
|
||||
# Copy frontend built files
|
||||
COPY --from=frontend-builder /app/frontend/build/ ./frontend-build/
|
||||
COPY --from=builder /app/node_modules/ ./node_modules/
|
||||
COPY --from=builder /app/frontend/dist/ ./frontend-build/
|
||||
|
||||
# Copy PM2 configuration
|
||||
COPY ecosystem.config.cjs ./
|
||||
COPY --from=builder /app/ecosystem.config.cjs ./
|
||||
|
||||
# Create a non-root user and group - DEBIAN STYLE
|
||||
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
|
||||
|
||||
# Set PM2 to use app directory instead of home directory
|
||||
ENV PM2_HOME=/app/.pm2
|
||||
|
||||
USER schichtplan
|
||||
# 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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "npm run build && npx tsx src/server.ts",
|
||||
"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",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
@@ -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(
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -700,3 +813,35 @@ export const updateLastLogin = async (req: AuthRequest, res: Response): Promise<
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>(`
|
||||
|
||||
@@ -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,
|
||||
|
||||
25
backend/src/middleware/Validation/Access.md
Normal file
25
backend/src/middleware/Validation/Access.md
Normal 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
|
||||
37
backend/src/middleware/Validation/Assignment.md
Normal file
37
backend/src/middleware/Validation/Assignment.md
Normal 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
|
||||
23
backend/src/middleware/Validation/Authentication.md
Normal file
23
backend/src/middleware/Validation/Authentication.md
Normal 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
|
||||
23
backend/src/middleware/Validation/DataIntegrity
Normal file
23
backend/src/middleware/Validation/DataIntegrity
Normal 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
|
||||
75
backend/src/middleware/Validation/Employee.md
Normal file
75
backend/src/middleware/Validation/Employee.md
Normal 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
|
||||
66
backend/src/middleware/Validation/Shiftplan.md
Normal file
66
backend/src/middleware/Validation/Shiftplan.md
Normal 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
|
||||
@@ -52,3 +52,35 @@ export const requireRole = (roles: string[]) => {
|
||||
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();
|
||||
}
|
||||
145
backend/src/middleware/rateLimit.ts
Normal file
145
backend/src/middleware/rateLimit.ts
Normal 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)
|
||||
});
|
||||
550
backend/src/middleware/validation.ts
Normal file
550
backend/src/middleware/validation.ts
Normal 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();
|
||||
};
|
||||
@@ -18,12 +18,12 @@ function generateEmail(firstname: string, lastname: string): string {
|
||||
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,7 +71,7 @@ export function generateEmployeeEmail(firstname: string, lastname: string): stri
|
||||
return generateEmail(firstname, lastname);
|
||||
}
|
||||
|
||||
// UPDATED: Business logic helpers for new employee types
|
||||
// Business logic helpers for new employee types
|
||||
export const isManager = (employee: Employee): boolean =>
|
||||
employee.employeeType === 'manager';
|
||||
|
||||
@@ -90,7 +90,7 @@ 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
|
||||
// Trainee logic - now based on isTrainee field for personell type
|
||||
export const isTrainee = (employee: Employee): boolean =>
|
||||
employee.employeeType === 'personell' && employee.isTrainee;
|
||||
|
||||
@@ -107,7 +107,7 @@ export const isMaintenance = (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
|
||||
// Work alone permission - managers and experienced personell can work alone
|
||||
export const canEmployeeWorkAlone = (employee: Employee): boolean =>
|
||||
employee.canWorkAlone && (isManager(employee) || isExperienced(employee));
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ export async function initializeDatabase(): Promise<void> {
|
||||
|
||||
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',
|
||||
@@ -95,16 +95,40 @@ export async function initializeDatabase(): Promise<void> {
|
||||
// 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);
|
||||
@@ -124,7 +148,7 @@ export async function initializeDatabase(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)`);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { spawn } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
export function runPythonScript(scriptPath, args = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -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,313 @@ 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';
|
||||
|
||||
// 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 for reverse proxy setup
|
||||
console.log('🔒 Trust proxy: Using reverse proxy defaults (trust all)');
|
||||
return true; // Trust all proxies when behind nginx
|
||||
};
|
||||
|
||||
app.set('trust proxy', configureTrustProxy());
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
|
||||
const isHttps = protocol === 'https';
|
||||
|
||||
// Add security warning for HTTP requests
|
||||
if (!isHttps && process.env.NODE_ENV === 'production') {
|
||||
res.setHeader('X-Security-Warning', 'This application is being accessed over HTTP. For secure communication, please use HTTPS.');
|
||||
|
||||
// Log HTTP access in production
|
||||
console.warn(`⚠️ HTTP access detected: ${req.method} ${req.path} from ${req.ip}`);
|
||||
}
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// 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'"],
|
||||
upgradeInsecureRequests: process.env.FORCE_HTTPS === 'true' ? [] : null
|
||||
},
|
||||
},
|
||||
hsts: {
|
||||
maxAge: 31536000,
|
||||
includeSubDomains: true,
|
||||
preload: true
|
||||
}, // Enable HSTS for HTTPS
|
||||
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) => {
|
||||
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 = path.join(__dirname, '../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);
|
||||
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;
|
||||
};
|
||||
|
||||
// Serviere statische Dateien
|
||||
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'
|
||||
);
|
||||
}
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
|
||||
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(`📍 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 +328,4 @@ const initializeApp = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Start the application
|
||||
initializeApp();
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
// backend/tsconfig.json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,26 +1,27 @@
|
||||
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}
|
||||
- TRUST_PROXY_ENABLED=true
|
||||
- TRUSTED_PROXY_IPS=nginx-proxy,172.0.0.0/8,10.0.0.0/8,192.168.0.0/16
|
||||
- FORCE_HTTPS=${FORCE_HTTPS:-false}
|
||||
networks:
|
||||
- app-network
|
||||
volumes:
|
||||
- app_data:/app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3002/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
expose:
|
||||
- "3002"
|
||||
|
||||
volumes:
|
||||
app_data:
|
||||
55
docker-init.sh
Normal file
55
docker-init.sh
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Container Initialisierung gestartet..."
|
||||
|
||||
generate_secret() {
|
||||
length=$1
|
||||
tr -dc 'A-Za-z0-9!@#$%^&*()_+-=' < /dev/urandom | head -c $length
|
||||
}
|
||||
|
||||
# Create .env if it doesn't exist
|
||||
if [ ! -f /app/.env ]; then
|
||||
echo "📝 Erstelle .env Datei..."
|
||||
|
||||
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
|
||||
|
||||
# Create .env with all proxy settings
|
||||
cat > /app/.env << EOF
|
||||
NODE_ENV=production
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
TRUST_PROXY_ENABLED=${TRUST_PROXY_ENABLED:-true}
|
||||
TRUSTED_PROXY_IPS=${TRUSTED_PROXY_IPS:-172.0.0.0/8,10.0.0.0/8,192.168.0.0/16}
|
||||
HOSTNAME=${HOSTNAME:-localhost}
|
||||
EOF
|
||||
|
||||
echo "✅ .env Datei erstellt"
|
||||
else
|
||||
echo "ℹ️ .env Datei existiert bereits"
|
||||
|
||||
# Update JWT_SECRET if provided
|
||||
if [ -n "$JWT_SECRET" ] && [ "$JWT_SECRET" != "your-secret-key-please-change" ]; then
|
||||
echo "🔑 Aktualisiere JWT Secret in .env Datei"
|
||||
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$JWT_SECRET/" /app/.env
|
||||
fi
|
||||
fi
|
||||
|
||||
# Validate JWT_SECRET
|
||||
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
|
||||
|
||||
chmod 600 /app/.env
|
||||
|
||||
echo "🔧 Proxy Configuration:"
|
||||
echo " - TRUST_PROXY_ENABLED: ${TRUST_PROXY_ENABLED:-true}"
|
||||
echo " - TRUSTED_PROXY_IPS: ${TRUSTED_PROXY_IPS:-172.0.0.0/8,10.0.0.0/8,192.168.0.0/16}"
|
||||
echo "🔧 Starte Anwendung..."
|
||||
exec "$@"
|
||||
@@ -1,18 +1,17 @@
|
||||
// ecosystem.config.cjs
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'schichtplaner',
|
||||
apps: [{
|
||||
name: 'schichtplan-app',
|
||||
script: './dist/server.js',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3002
|
||||
PORT: 3002,
|
||||
FRONTEND_BUILD_PATH: './frontend-build'
|
||||
},
|
||||
error_file: './logs/app-err.log',
|
||||
out_file: './logs/app-out.log',
|
||||
error_file: './logs/err.log',
|
||||
out_file: './logs/out.log',
|
||||
log_file: './logs/combined.log',
|
||||
time: true
|
||||
}
|
||||
]
|
||||
}]
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
# frontend/.env.development
|
||||
BROWSER=none
|
||||
FAST_REFRESH=true
|
||||
DANGEROUSLY_DISABLE_HOST_CHECK=true
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Shift Planning App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
19000
frontend/package-lock.json
generated
19000
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,49 +2,34 @@
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"proxy": "http://localhost:3002",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.9.3",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"date-fns": "4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"react-scripts": "^5.0.1"
|
||||
"@types/node": "20.19.23",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"@types/jest": "30.0.0",
|
||||
"@testing-library/react": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@storybook/react": "10.0.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7",
|
||||
"esbuild": "^0.21.0",
|
||||
"terser": "5.44.0",
|
||||
"babel-plugin-transform-remove-console": "6.9.4",
|
||||
"framer-motion": "12.23.24"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"short_name": "SP",
|
||||
"name": "schichtenplaner",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// frontend/src/App.tsx - KORRIGIERT MIT LAYOUT
|
||||
// src/App.tsx
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
@@ -15,6 +15,40 @@ import EmployeeManagement from './pages/Employees/EmployeeManagement';
|
||||
import Settings from './pages/Settings/Settings';
|
||||
import Help from './pages/Help/Help';
|
||||
import Setup from './pages/Setup/Setup';
|
||||
import ErrorBoundary from './components/ErrorBoundary/ErrorBoundary';
|
||||
import SecurityWarning from './components/SecurityWarning/SecurityWarning';
|
||||
|
||||
// Free Footer Link Pages (always available)
|
||||
import FAQ from './components/Layout/FooterLinks/FAQ/FAQ';
|
||||
import About from './components/Layout/FooterLinks/About/About';
|
||||
import Features from './components/Layout/FooterLinks/Features/Features';
|
||||
import { CommunityContact, CommunityLegalPage } from './components/Layout/FooterLinks/CommunityLinks/communityLinks';
|
||||
|
||||
// Vite environment variables (use import.meta.env instead of process.env)
|
||||
const ENABLE_PRO = import.meta.env.ENABLE_PRO === 'true';
|
||||
|
||||
// Conditional Premium Components
|
||||
let PremiumContact: React.FC = CommunityContact;
|
||||
let PremiumPrivacy: React.FC = () => <CommunityLegalPage title="Datenschutz" />;
|
||||
let PremiumImprint: React.FC = () => <CommunityLegalPage title="Impressum" />;
|
||||
let PremiumTerms: React.FC = () => <CommunityLegalPage title="AGB" />;
|
||||
|
||||
// Load premium components only when ENABLE_PRO is true
|
||||
if (ENABLE_PRO) {
|
||||
try {
|
||||
// Use require with type assertions to avoid dynamic import issues
|
||||
const premiumModule = require('@premium-frontend/components/FooterLinks');
|
||||
|
||||
if (premiumModule.Contact) PremiumContact = premiumModule.Contact;
|
||||
if (premiumModule.Privacy) PremiumPrivacy = premiumModule.Privacy;
|
||||
if (premiumModule.Imprint) PremiumImprint = premiumModule.Imprint;
|
||||
if (premiumModule.Terms) PremiumTerms = premiumModule.Terms;
|
||||
|
||||
console.log('✅ Premium components loaded successfully');
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Premium components not available, using community fallbacks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected Route Component
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode; roles?: string[] }> = ({
|
||||
@@ -49,11 +83,27 @@ const ProtectedRoute: React.FC<{ children: React.ReactNode; roles?: string[] }>
|
||||
return <Layout>{children}</Layout>;
|
||||
};
|
||||
|
||||
// Public Route Component (without Layout for footer pages)
|
||||
const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div>⏳ Lade Anwendung...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return user ? <Layout>{children}</Layout> : <>{children}</>;
|
||||
};
|
||||
|
||||
// Main App Content
|
||||
const AppContent: React.FC = () => {
|
||||
const { loading, needsSetup, user } = useAuth();
|
||||
|
||||
console.log('🏠 AppContent rendering - loading:', loading, 'needsSetup:', needsSetup, 'user:', user);
|
||||
console.log('🎯 Premium features enabled:', ENABLE_PRO);
|
||||
|
||||
// Während des Ladens
|
||||
if (loading) {
|
||||
@@ -80,66 +130,49 @@ const AppContent: React.FC = () => {
|
||||
console.log('✅ Showing protected routes for user:', user.email);
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/shift-plans" element={
|
||||
<ProtectedRoute>
|
||||
<ShiftPlanList />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/shift-plans/new" element={
|
||||
<ProtectedRoute roles={['admin', 'maintenance']}>
|
||||
<ShiftPlanCreate />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/shift-plans/:id/edit" element={
|
||||
<ProtectedRoute roles={['admin', 'maintenance']}>
|
||||
<ShiftPlanEdit />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/shift-plans/:id" element={
|
||||
<ProtectedRoute>
|
||||
<ShiftPlanView />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/employees" element={
|
||||
<ProtectedRoute roles={['admin', 'maintenance']}>
|
||||
<EmployeeManagement />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/settings" element={
|
||||
<ProtectedRoute>
|
||||
<Settings />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/help" element={
|
||||
<ProtectedRoute>
|
||||
<Help />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
{/* Protected Routes (require login) */}
|
||||
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/shift-plans" element={<ProtectedRoute><ShiftPlanList /></ProtectedRoute>} />
|
||||
<Route path="/shift-plans/new" element={<ProtectedRoute roles={['admin', 'maintenance']}><ShiftPlanCreate /></ProtectedRoute>} />
|
||||
<Route path="/shift-plans/:id/edit" element={<ProtectedRoute roles={['admin', 'maintenance']}><ShiftPlanEdit /></ProtectedRoute>} />
|
||||
<Route path="/shift-plans/:id" element={<ProtectedRoute><ShiftPlanView /></ProtectedRoute>} />
|
||||
<Route path="/employees" element={<ProtectedRoute roles={['admin', 'maintenance']}><EmployeeManagement /></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||
<Route path="/help" element={<ProtectedRoute><Help /></ProtectedRoute>} />
|
||||
|
||||
{/* Public Footer Link Pages (always available) */}
|
||||
<Route path="/faq" element={<PublicRoute><FAQ /></PublicRoute>} />
|
||||
<Route path="/about" element={<PublicRoute><About /></PublicRoute>} />
|
||||
<Route path="/features" element={<PublicRoute><Features /></PublicRoute>} />
|
||||
|
||||
{/* PREMIUM Footer Link Pages (conditionally available) */}
|
||||
<Route path="/contact" element={<PublicRoute><PremiumContact /></PublicRoute>} />
|
||||
<Route path="/privacy" element={<PublicRoute><PremiumPrivacy /></PublicRoute>} />
|
||||
<Route path="/imprint" element={<PublicRoute><PremiumImprint /></PublicRoute>} />
|
||||
<Route path="/terms" element={<PublicRoute><PremiumTerms /></PublicRoute>} />
|
||||
|
||||
{/* Auth Routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="*" element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Catch-all Route */}
|
||||
<Route path="*" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<NotificationProvider>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<SecurityWarning />
|
||||
<NotificationContainer />
|
||||
<AppContent />
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</NotificationProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
101
frontend/src/components/ErrorBoundary/ErrorBoundary.tsx
Normal file
101
frontend/src/components/ErrorBoundary/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
// src/components/ErrorBoundary/ErrorBoundary.tsx
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('🚨 Application Error:', error);
|
||||
console.error('📋 Error Details:', errorInfo);
|
||||
|
||||
// In production, send to your error reporting service
|
||||
// logErrorToService(error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// You can render any custom fallback UI
|
||||
return this.props.fallback || (
|
||||
<div style={{
|
||||
padding: '40px',
|
||||
textAlign: 'center',
|
||||
fontFamily: 'Arial, sans-serif'
|
||||
}}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '20px' }}>⚠️</div>
|
||||
<h2>Oops! Something went wrong</h2>
|
||||
<p style={{ margin: '20px 0', color: '#666' }}>
|
||||
We encountered an unexpected error. Please try refreshing the page.
|
||||
</p>
|
||||
<div style={{ marginTop: '30px' }}>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
marginRight: '10px'
|
||||
}}
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false })}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||
<details style={{
|
||||
marginTop: '20px',
|
||||
textAlign: 'left',
|
||||
background: '#f8f9fa',
|
||||
padding: '15px',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
<summary>Error Details (Development)</summary>
|
||||
<pre style={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: '12px',
|
||||
color: '#dc3545'
|
||||
}}>
|
||||
{this.state.error.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -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',
|
||||
@@ -182,20 +182,6 @@ const Footer: React.FC = () => {
|
||||
>
|
||||
Funktionen
|
||||
</a>
|
||||
<a
|
||||
href="/pricing"
|
||||
style={styles.footerLink}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#FBFAF6';
|
||||
e.currentTarget.style.transform = 'translateX(4px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'rgba(251, 250, 246, 0.7)';
|
||||
e.currentTarget.style.transform = 'translateX(0)';
|
||||
}}
|
||||
>
|
||||
Preise
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
76
frontend/src/components/Layout/FooterLinks/About/About.tsx
Normal file
76
frontend/src/components/Layout/FooterLinks/About/About.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
// frontend/src/components/Layout/FooterLinks/About/About.tsx
|
||||
import React from 'react';
|
||||
|
||||
const About: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: '40px 20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1>👨💻 Über uns</h1>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '30px',
|
||||
marginTop: '20px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e0e0e0',
|
||||
lineHeight: 1.6
|
||||
}}>
|
||||
<h2 style={{ color: '#2c3e50' }}>Unser Team</h2>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginTop: '20px', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
|
||||
<div style={{ marginRight: '20px' }}>
|
||||
<div style={{
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
backgroundColor: '#3498db',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
P
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ color: '#2c3e50', margin: '0 0 5px 0' }}>Patrick</h3>
|
||||
<p style={{ color: '#6c757d', margin: '0 0 10px 0' }}>
|
||||
Full-Stack Developer & Projektleiter
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||
GitHub: <a href="https://github.com/donpat1to" style={{ color: '#3498db' }}>donpat1to</a><br/>
|
||||
E-Mail: <a href="mailto:dev.patrick@inca-vikingo.de" style={{ color: '#3498db' }}>dev.patrick@inca-vikingo.de</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style={{ color: '#3498db', marginTop: '30px' }}>🚀 Unsere Mission</h3>
|
||||
<p>
|
||||
Wir entwickeln intelligente Lösungen für die Personalplanung,
|
||||
die Zeit sparen und faire Schichtverteilung gewährleisten.
|
||||
</p>
|
||||
|
||||
<h3 style={{ color: '#3498db', marginTop: '25px' }}>💻 Technologie</h3>
|
||||
<p>
|
||||
Unser Stack umfasst moderne Technologien:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Frontend: React, TypeScript</li>
|
||||
<li>Backend: Node.js, Express</li>
|
||||
<li>Optimierung: Google OR-Tools CP-SAT</li>
|
||||
<li>Datenbank: SQLite/PostgreSQL</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={{ color: '#3498db', marginTop: '25px' }}>📈 Entwicklung</h3>
|
||||
<p>
|
||||
Schichtenplaner wird kontinuierlich weiterentwickelt und
|
||||
basiert auf Feedback unserer Nutzer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
@@ -0,0 +1,38 @@
|
||||
// frontend/src/components/Layout/FooterLinks/CommunityLinks/communityLinks.tsx
|
||||
import React from 'react';
|
||||
|
||||
export const CommunityContact: React.FC = () => (
|
||||
<div style={{ padding: '40px 20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1>📞 Kontakt</h1>
|
||||
<div style={{ backgroundColor: 'white', borderRadius: '12px', padding: '30px', marginTop: '20px' }}>
|
||||
<h2 style={{ color: '#2c3e50' }}>Community Edition</h2>
|
||||
<p>Kontaktfunktionen sind in der Premium Edition verfügbar.</p>
|
||||
<p>
|
||||
<a href="/features" style={{ color: '#3498db' }}>
|
||||
➡️ Zu den Features
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CommunityLegalPage: React.FC<{ title: string }> = ({ title }) => (
|
||||
<div style={{ padding: '40px 20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1>📄 {title}</h1>
|
||||
<div style={{ backgroundColor: 'white', borderRadius: '12px', padding: '30px', marginTop: '20px' }}>
|
||||
<h2 style={{ color: '#2c3e50' }}>Community Edition</h2>
|
||||
<p>Rechtliche Dokumentation ist in der Premium Edition verfügbar.</p>
|
||||
<p>
|
||||
<a href="/features" style={{ color: '#3498db' }}>
|
||||
➡️ Erfahren Sie mehr über Premium
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Optional: Barrel export für einfachere Imports
|
||||
export default {
|
||||
CommunityContact,
|
||||
CommunityLegalPage
|
||||
};
|
||||
103
frontend/src/components/Layout/FooterLinks/FAQ/FAQ.tsx
Normal file
103
frontend/src/components/Layout/FooterLinks/FAQ/FAQ.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
// frontend/src/components/Layout/FooterLinks/FAQ/FAQ.tsx
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const FAQ: React.FC = () => {
|
||||
const [openItems, setOpenItems] = useState<number[]>([]);
|
||||
|
||||
const toggleItem = (index: number) => {
|
||||
setOpenItems(prev =>
|
||||
prev.includes(index)
|
||||
? prev.filter(i => i !== index)
|
||||
: [...prev, index]
|
||||
);
|
||||
};
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: "Wie funktioniert der Scheduling-Algorithmus?",
|
||||
answer: "Unser System verwendet Google's OR-Tools CP-SAT Solver, um optimale Schichtzuweisungen basierend auf Verfügbarkeiten, Vertragstypen und Geschäftsregeln zu berechnen."
|
||||
},
|
||||
{
|
||||
question: "Was bedeuten die Verfügbarkeits-Level 1, 2 und 3?",
|
||||
answer: "Level 1: Bevorzugt (Mitarbeiter möchte diese Schicht), Level 2: Verfügbar (kann arbeiten), Level 3: Nicht verfügbar (kann nicht arbeiten)."
|
||||
},
|
||||
{
|
||||
question: "Wie werden Vertragstypen berücksichtigt?",
|
||||
answer: "Kleine Verträge: 1 Schicht pro Woche, Große Verträge: 2 Schichten pro Woche. Das System weist genau diese Anzahl zu."
|
||||
},
|
||||
{
|
||||
question: "Kann ich manuelle Anpassungen vornehmen?",
|
||||
answer: "Ja, nach dem automatischen Scheduling können Sie Zuordnungen manuell anpassen und optimieren."
|
||||
},
|
||||
{
|
||||
question: "Was passiert bei unterbesetzten Schichten?",
|
||||
answer: "Das System zeigt eine Warnung an und versucht, alternative Lösungen zu finden. In kritischen Fällen müssen manuelle Anpassungen vorgenommen werden."
|
||||
},
|
||||
{
|
||||
question: "Wie lange dauert die Planungserstellung?",
|
||||
answer: "Typischerweise maximal 105 Sekunden, abhängig von der Anzahl der Mitarbeiter und Schichten."
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '40px 20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1>❓ Häufige Fragen (FAQ)</h1>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
marginTop: '20px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
{faqItems.map((item, index) => (
|
||||
<div key={index} style={{
|
||||
borderBottom: index < faqItems.length - 1 ? '1px solid #e0e0e0' : 'none',
|
||||
padding: '20px 30px'
|
||||
}}>
|
||||
<div
|
||||
onClick={() => toggleItem(index)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<h3 style={{
|
||||
color: '#2c3e50',
|
||||
margin: 0,
|
||||
fontSize: '1.1rem'
|
||||
}}>
|
||||
{item.question}
|
||||
</h3>
|
||||
<span style={{
|
||||
fontSize: '1.5rem',
|
||||
color: '#3498db',
|
||||
transform: openItems.includes(index) ? 'rotate(45deg)' : 'rotate(0)',
|
||||
transition: 'transform 0.2s ease'
|
||||
}}>
|
||||
+
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{openItems.includes(index) && (
|
||||
<div style={{
|
||||
marginTop: '15px',
|
||||
padding: '15px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
color: '#6c757d',
|
||||
lineHeight: 1.6
|
||||
}}>
|
||||
{item.answer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FAQ;
|
||||
111
frontend/src/components/Layout/FooterLinks/Features/Features.tsx
Normal file
111
frontend/src/components/Layout/FooterLinks/Features/Features.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
// frontend/src/components/Layou/FooterLinks/Features/Features.tsx
|
||||
import React from 'react';
|
||||
|
||||
const Features: React.FC = () => {
|
||||
const features = [
|
||||
{
|
||||
icon: "🤖",
|
||||
title: "Automatisches Scheduling",
|
||||
description: "Intelligenter Algorithmus erstellt optimale Schichtpläne basierend auf Verfügbarkeiten und Regeln"
|
||||
},
|
||||
{
|
||||
icon: "⚡",
|
||||
title: "Schnelle Berechnung",
|
||||
description: "Google OR-Tools CP-SAT Solver findet Lösungen in maximal 105 Sekunden"
|
||||
},
|
||||
{
|
||||
icon: "👥",
|
||||
title: "Flexible Regelkonfiguration",
|
||||
description: "Anpassbare Geschäftsregeln für Trainee-Betreuung, Alleinarbeit, Vertragstypen"
|
||||
},
|
||||
{
|
||||
icon: "📊",
|
||||
title: "Echtzeit-Validierung",
|
||||
description: "Automatische Erkennung von Regelverletzungen und Konflikten"
|
||||
},
|
||||
{
|
||||
icon: "🔒",
|
||||
title: "Lokale Datenspeicherung",
|
||||
description: "Alle Daten bleiben in Ihrer Infrastruktur - volle Kontrolle und Datenschutz"
|
||||
},
|
||||
{
|
||||
icon: "🎯",
|
||||
title: "Präferenz-basiert",
|
||||
description: "Berücksichtigt Mitarbeiterwünsche für höhere Zufriedenheit"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '40px 20px', maxWidth: '1000px', margin: '0 auto' }}>
|
||||
<h1>✨ Funktionen</h1>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '30px',
|
||||
marginTop: '20px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<h2 style={{ color: '#2c3e50', textAlign: 'center', marginBottom: '40px' }}>
|
||||
Alles, was Sie für die perfekte Schichtplanung benötigen
|
||||
</h2>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: '30px'
|
||||
}}>
|
||||
{features.map((feature, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '25px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid #e9ecef',
|
||||
textAlign: 'center',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '3rem',
|
||||
marginBottom: '15px'
|
||||
}}>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<h3 style={{
|
||||
color: '#2c3e50',
|
||||
margin: '0 0 15px 0'
|
||||
}}>
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p style={{
|
||||
color: '#6c757d',
|
||||
margin: 0,
|
||||
lineHeight: 1.5
|
||||
}}>
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '40px',
|
||||
padding: '25px',
|
||||
backgroundColor: '#e8f4fd',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid #b8d4f0',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<h3 style={{ color: '#2980b9', margin: '0 0 15px 0' }}>
|
||||
🚀 Starter Sie durch
|
||||
</h3>
|
||||
<p style={{ color: '#2c3e50', margin: 0 }}>
|
||||
Erstellen Sie Ihren ersten optimierten Schichtplan in wenigen Minuten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Features;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// frontend/src/components/PillNav/index.ts
|
||||
export { default } from './PillNav';
|
||||
export type { PillNavProps, PillNavItem } from './PillNav';
|
||||
59
frontend/src/components/SecurityWarning/SecurityWarning.tsx
Normal file
59
frontend/src/components/SecurityWarning/SecurityWarning.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
// src/components/SecurityWarning/SecurityWarning.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const SecurityWarning: React.FC = () => {
|
||||
const [isHttp, setIsHttp] = useState(false);
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if current protocol is HTTP
|
||||
const checkProtocol = () => {
|
||||
setIsHttp(window.location.protocol === 'http:');
|
||||
};
|
||||
|
||||
checkProtocol();
|
||||
window.addEventListener('load', checkProtocol);
|
||||
|
||||
return () => window.removeEventListener('load', checkProtocol);
|
||||
}, []);
|
||||
|
||||
if (!isHttp || isDismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: '#ff6b35',
|
||||
color: 'white',
|
||||
padding: '10px 20px',
|
||||
textAlign: 'center',
|
||||
zIndex: 10000,
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
|
||||
}}>
|
||||
⚠️ SECURITY WARNING: This site is being accessed over HTTP.
|
||||
For secure communication, please use HTTPS.
|
||||
<button
|
||||
onClick={() => setIsDismissed(true)}
|
||||
style={{
|
||||
marginLeft: '15px',
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
border: '1px solid white',
|
||||
color: 'white',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityWarning;
|
||||
79
frontend/src/components/StepSetup/StepSetup.stories.tsx
Normal file
79
frontend/src/components/StepSetup/StepSetup.stories.tsx
Normal 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',
|
||||
};
|
||||
127
frontend/src/components/StepSetup/StepSetup.test.tsx
Normal file
127
frontend/src/components/StepSetup/StepSetup.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
516
frontend/src/components/StepSetup/StepSetup.tsx
Normal file
516
frontend/src/components/StepSetup/StepSetup.tsx
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// frontend/src/design/DesignSystem.tsx
|
||||
// frontend/src/design/DesignSystem.txt
|
||||
export const designTokens = {
|
||||
colors: {
|
||||
// Primary Colors
|
||||
73
frontend/src/hooks/useBackendValidation.ts
Normal file
73
frontend/src/hooks/useBackendValidation.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,14 @@
|
||||
/* Reset and base styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -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'>[] = [];
|
||||
|
||||
|
||||
@@ -18,12 +18,12 @@ function generateEmail(firstname: string, lastname: string): string {
|
||||
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,7 +71,7 @@ export function generateEmployeeEmail(firstname: string, lastname: string): stri
|
||||
return generateEmail(firstname, lastname);
|
||||
}
|
||||
|
||||
// UPDATED: Business logic helpers for new employee types
|
||||
// Business logic helpers for new employee types
|
||||
export const isManager = (employee: Employee): boolean =>
|
||||
employee.employeeType === 'manager';
|
||||
|
||||
@@ -90,7 +90,7 @@ 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
|
||||
// Trainee logic - now based on isTrainee field for personell type
|
||||
export const isTrainee = (employee: Employee): boolean =>
|
||||
employee.employeeType === 'personell' && employee.isTrainee;
|
||||
|
||||
@@ -107,7 +107,7 @@ export const isMaintenance = (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
|
||||
// Work alone permission - managers and experienced personell can work alone
|
||||
export const canEmployeeWorkAlone = (employee: Employee): boolean =>
|
||||
employee.canWorkAlone && (isManager(employee) || isExperienced(employee));
|
||||
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,11 +153,7 @@ 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);
|
||||
@@ -162,7 +162,11 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
||||
|
||||
} 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,62 +477,34 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
setError('');
|
||||
|
||||
if (!selectedPlanId) {
|
||||
setError('Bitte wählen Sie einen Schichtplan aus');
|
||||
showNotification({
|
||||
type: 'error',
|
||||
title: 'Fehler',
|
||||
message: 'Bitte wählen Sie einen Schichtplan aus'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter availabilities to only include those with actual shifts AND valid shiftIds
|
||||
// Basic frontend validation: Check if we have any availabilities to save
|
||||
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
|
||||
return avail.shiftId && selectedPlan?.shifts?.some(shift => shift.id === avail.shiftId);
|
||||
});
|
||||
|
||||
if (validAvailabilities.length === 0) {
|
||||
setError('Keine gültigen Verfügbarkeiten zum Speichern gefunden');
|
||||
showNotification({
|
||||
type: 'error',
|
||||
title: 'Fehler',
|
||||
message: 'Keine gültigen Verfügbarkeiten zum Speichern gefunden'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Contract type validation
|
||||
const availableShifts = validAvailabilities.filter(avail =>
|
||||
avail.preferenceLevel === 1 || avail.preferenceLevel === 2
|
||||
).length;
|
||||
// Complex validation (contract type rules) is now handled by backend
|
||||
// We only do basic required field validation in frontend
|
||||
|
||||
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;
|
||||
}
|
||||
await executeWithValidation(async () => {
|
||||
setSaving(true);
|
||||
|
||||
// Convert to the format expected by the API - using shiftId directly
|
||||
const requestData = {
|
||||
@@ -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,12 +552,11 @@ 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={{
|
||||
maxWidth: '1900px',
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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, {
|
||||
// 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);
|
||||
updateUser(updatedEmployee);
|
||||
|
||||
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,12 +222,12 @@ const Settings: React.FC = () => {
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Use the actual password change endpoint
|
||||
// Use executeWithValidation to handle backend validation
|
||||
await executeWithValidation(async () => {
|
||||
await employeeService.changePassword(currentUser.id, {
|
||||
currentPassword: passwordForm.currentPassword,
|
||||
newPassword: passwordForm.newPassword
|
||||
newPassword: passwordForm.newPassword,
|
||||
confirmPassword: passwordForm.confirmPassword
|
||||
});
|
||||
|
||||
showNotification({
|
||||
@@ -219,14 +242,10 @@ const Settings: React.FC = () => {
|
||||
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,6 +262,12 @@ 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',
|
||||
@@ -273,7 +298,7 @@ const Settings: React.FC = () => {
|
||||
|
||||
<div style={styles.tabs}>
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
onClick={() => handleTabChange('profile')}
|
||||
style={{
|
||||
...styles.tab,
|
||||
...(activeTab === 'profile' ? styles.tabActive : {})
|
||||
@@ -301,7 +326,7 @@ const Settings: React.FC = () => {
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('password')}
|
||||
onClick={() => handleTabChange('password')}
|
||||
style={{
|
||||
...styles.tab,
|
||||
...(activeTab === 'password' ? styles.tabActive : {})
|
||||
@@ -329,7 +354,7 @@ const Settings: React.FC = () => {
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('availability')}
|
||||
onClick={() => handleTabChange('availability')}
|
||||
style={{
|
||||
...styles.tab,
|
||||
...(activeTab === 'availability' ? styles.tabActive : {})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// frontend/src/pages/Settings/type/SettingsType.tsx - CORRECTED
|
||||
// frontend/src/pages/Settings/type/SettingsType.tsx
|
||||
export const styles = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
|
||||
@@ -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('');
|
||||
@@ -102,8 +172,8 @@ 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';
|
||||
}
|
||||
@@ -113,113 +183,51 @@ const Setup: React.FC = () => {
|
||||
return `${cleanFirstname}.${cleanLastname}@sp.de`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#f8f9fa',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '3rem',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.1)',
|
||||
width: '100%',
|
||||
maxWidth: '500px',
|
||||
border: '1px solid #e9ecef'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
|
||||
<h1 style={{
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '0.5rem',
|
||||
color: '#2c3e50'
|
||||
}}>
|
||||
🚀 Erstkonfiguration
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#6c757d',
|
||||
fontSize: '1.1rem'
|
||||
}}>
|
||||
Richten Sie Ihren Administrator-Account ein
|
||||
</p>
|
||||
</div>
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
backgroundColor: '#f8d7da',
|
||||
border: '1px solid #f5c6cb',
|
||||
color: '#721c24',
|
||||
padding: '1rem',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '1.5rem'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
return {
|
||||
// State
|
||||
currentStep,
|
||||
formData,
|
||||
loading,
|
||||
error,
|
||||
steps,
|
||||
|
||||
{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>
|
||||
// Actions
|
||||
goToNextStep,
|
||||
goToPrevStep,
|
||||
handleStepChange,
|
||||
handleInputChange,
|
||||
|
||||
<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>
|
||||
)}
|
||||
// Helpers
|
||||
getEmailPreview,
|
||||
isStepCompleted
|
||||
};
|
||||
};
|
||||
|
||||
{step === 2 && (
|
||||
// ===== 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={{
|
||||
@@ -234,7 +242,7 @@ const Setup: React.FC = () => {
|
||||
type="text"
|
||||
name="firstname"
|
||||
value={formData.firstname}
|
||||
onChange={handleInputChange}
|
||||
onChange={onInputChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
@@ -244,6 +252,7 @@ const Setup: React.FC = () => {
|
||||
}}
|
||||
placeholder="Max"
|
||||
required
|
||||
autoComplete="given-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -260,7 +269,7 @@ const Setup: React.FC = () => {
|
||||
type="text"
|
||||
name="lastname"
|
||||
value={formData.lastname}
|
||||
onChange={handleInputChange}
|
||||
onChange={onInputChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.75rem',
|
||||
@@ -270,6 +279,7 @@ const Setup: React.FC = () => {
|
||||
}}
|
||||
placeholder="Mustermann"
|
||||
required
|
||||
autoComplete="family-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -302,66 +312,381 @@ const Setup: React.FC = () => {
|
||||
</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',
|
||||
backgroundColor: '#f8f9fa',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '3rem',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.1)',
|
||||
width: '100%',
|
||||
maxWidth: '600px',
|
||||
border: '1px solid #e9ecef'
|
||||
}}>
|
||||
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
|
||||
<h1 style={{
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '0.5rem',
|
||||
color: '#2c3e50'
|
||||
}}>
|
||||
🚀 Erstkonfiguration
|
||||
</h1>
|
||||
<p style={{
|
||||
color: '#6c757d',
|
||||
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',
|
||||
border: '1px solid #f5c6cb',
|
||||
color: '#721c24',
|
||||
padding: '1rem',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '1.5rem'
|
||||
}}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Schritt-Inhalt */}
|
||||
<div style={{ minHeight: '200px' }}>
|
||||
{renderStepContent()}
|
||||
</div>
|
||||
|
||||
{/* Navigations-Buttons */}
|
||||
<div style={{
|
||||
marginTop: '2rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
{step === 2 && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
onClick={goToPrevStep}
|
||||
disabled={loading || currentStep === 0}
|
||||
style={{
|
||||
padding: '0.75rem 1.5rem',
|
||||
color: '#6c757d',
|
||||
border: '1px solid #6c757d',
|
||||
color: loading || currentStep === 0 ? '#adb5bd' : '#6c757d',
|
||||
border: `1px solid ${loading || currentStep === 0 ? '#adb5bd' : '#6c757d'}`,
|
||||
background: 'none',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: '500'
|
||||
cursor: loading || currentStep === 0 ? 'not-allowed' : 'pointer',
|
||||
fontWeight: '500',
|
||||
opacity: loading || currentStep === 0 ? 0.6 : 1
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
← Zurück
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
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 && (
|
||||
{/* 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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
// Basic frontend validation only
|
||||
if (!planName.trim()) {
|
||||
setError('Bitte geben Sie einen Namen für den Schichtplan ein');
|
||||
showNotification({
|
||||
type: 'error',
|
||||
title: 'Fehlende Angaben',
|
||||
message: 'Bitte geben Sie einen Namen für den Schichtplan ein'
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!startDate) {
|
||||
setError('Bitte wählen Sie ein Startdatum');
|
||||
showNotification({
|
||||
type: 'error',
|
||||
title: 'Fehlende Angaben',
|
||||
message: 'Bitte wählen Sie ein Startdatum'
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!endDate) {
|
||||
setError('Bitte wählen Sie ein Enddatum');
|
||||
showNotification({
|
||||
type: 'error',
|
||||
title: 'Fehlende Angaben',
|
||||
message: 'Bitte wählen Sie ein Enddatum'
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (new Date(endDate) < new Date(startDate)) {
|
||||
setError('Das Enddatum muss nach dem Startdatum liegen');
|
||||
showNotification({
|
||||
type: 'error',
|
||||
title: 'Ungültige Daten',
|
||||
message: 'Das Enddatum muss nach dem Startdatum liegen'
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!selectedPreset) {
|
||||
setError('Bitte wählen Sie eine Vorlage aus');
|
||||
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,23 +146,15 @@ 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}>
|
||||
<label>Plan Name:</label>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
await executeWithValidation(async () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete logic here
|
||||
loadShiftPlan();
|
||||
} catch (error) {
|
||||
console.error('Error deleting shift:', error);
|
||||
showNotification({
|
||||
type: 'error',
|
||||
title: 'Fehler',
|
||||
message: 'Die Schicht konnte nicht gelöscht werden.'
|
||||
const confirmed = await confirmDialog({
|
||||
title: 'Schicht löschen',
|
||||
message: 'Möchten Sie diese Schicht wirklich löschen?',
|
||||
confirmText: 'Löschen',
|
||||
cancelText: 'Abbrechen',
|
||||
type: 'warning'
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
showNotification({
|
||||
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
|
||||
|
||||
@@ -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 () => {
|
||||
await executeWithValidation(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.'
|
||||
});
|
||||
// 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 style={{ display: 'flex', gap: '10px' }}>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
1
frontend/src/react-app-env.d.ts
vendored
1
frontend/src/react-app-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="react-scripts" />
|
||||
@@ -1,15 +0,0 @@
|
||||
import { ReportHandler } from 'web-vitals';
|
||||
|
||||
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
130
frontend/src/services/apiClient.ts
Normal file
130
frontend/src/services/apiClient.ts
Normal 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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.token = null;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('employee');
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
const employees = await response.json();
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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 apiClient.get<EmployeeAvailability[]>(`/employees/${employeeId}/availabilities`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async updateAvailabilities(employeeId: string, data: { planId: string, availabilities: Omit<EmployeeAvailability, 'id' | 'employeeId'>[] }): Promise<EmployeeAvailability[]> {
|
||||
async updateAvailabilities(
|
||||
employeeId: string,
|
||||
data: { planId: string, availabilities: Omit<EmployeeAvailability, 'id' | 'employeeId'>[] }
|
||||
): Promise<EmployeeAvailability[]> {
|
||||
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 apiClient.put<EmployeeAvailability[]>(`/employees/${employeeId}/availabilities`, data);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
38
frontend/src/services/errorService.ts
Normal file
38
frontend/src/services/errorService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
@@ -1,198 +1,114 @@
|
||||
// frontend/src/services/shiftPlanService.ts
|
||||
import { authService } from './authService';
|
||||
import { ShiftPlan, CreateShiftPlanRequest, ScheduledShift, CreateShiftFromTemplateRequest } from '../models/ShiftPlan';
|
||||
import { ShiftPlan, CreateShiftPlanRequest } 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 { 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();
|
||||
throw new Error('Nicht authorisiert - bitte erneut anmelden');
|
||||
}
|
||||
throw new Error('Fehler beim Laden der Schichtpläne');
|
||||
}
|
||||
|
||||
const plans = await response.json();
|
||||
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');
|
||||
}
|
||||
},
|
||||
|
||||
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> {
|
||||
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) {
|
||||
await apiClient.post(`/shift-plans/${planId}/regenerate-shifts`);
|
||||
console.log('✅ Scheduled shifts regenerated');
|
||||
} else {
|
||||
console.error('❌ Failed to regenerate shifts');
|
||||
}
|
||||
} catch (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
|
||||
async getTemplatePresets(): Promise<{name: string, label: string, description: string}[]> {
|
||||
return Object.entries(TEMPLATE_PRESETS).map(([key, preset]) => ({
|
||||
name: key,
|
||||
label: preset.name,
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
12
frontend/src/vite-env.d.ts
vendored
Normal file
12
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// Define types for environment variables
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
readonly ENABLE_PRO: string
|
||||
// more env variables...
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -1,27 +1,38 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
//"ignoreDeprecations": "6.0",
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"jsx": "react-jsx",
|
||||
"downlevelIteration": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping (modern approach) */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/components/*": ["./src/components/*"],
|
||||
"@/pages/*": ["./src/pages/*"],
|
||||
"@/contexts/*": ["./src/contexts/*"],
|
||||
"@/utils/*": ["./src/utils/*"],
|
||||
"@/services/*": ["./src/services/*"],
|
||||
"@/models/*": ["./src/models/*"],
|
||||
"@/design/*": ["./src/design/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
79
frontend/vite.config.ts
Normal file
79
frontend/vite.config.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { resolve } from 'path'
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
6082
backend/package-lock.json → package-lock.json
generated
6082
backend/package-lock.json → package-lock.json
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user