mirror of
https://github.com/donpat1to/Schichtenplaner.git
synced 2025-12-01 06:55:45 +01:00
Compare commits
84 Commits
8d020a0dac
...
v1.0.14
| Author | SHA1 | Date | |
|---|---|---|---|
| da2b3b0126 | |||
| 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 |
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
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
tag_name: ${{ steps.set_tag.outputs.tag_name }}
|
tag_name: ${{ steps.set_tag.outputs.tag_name }}
|
||||||
|
is_main_branch: ${{ steps.branch_check.outputs.is_main }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
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
|
- name: Determine next semantic version tag
|
||||||
id: set_tag
|
id: set_tag
|
||||||
@@ -29,24 +39,31 @@ jobs:
|
|||||||
|
|
||||||
# Find latest tag matching vX.Y.Z
|
# Find latest tag matching vX.Y.Z
|
||||||
latest_tag=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n 1)
|
latest_tag=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n 1)
|
||||||
|
echo "Latest tag found: $latest_tag"
|
||||||
|
|
||||||
if [[ -z "$latest_tag" ]]; then
|
if [[ -z "$latest_tag" ]]; then
|
||||||
major=0
|
major=0
|
||||||
minor=0
|
minor=0
|
||||||
patch=0
|
patch=0
|
||||||
|
echo "No existing tags found, starting from v0.0.0"
|
||||||
else
|
else
|
||||||
version="${latest_tag#v}"
|
version="${latest_tag#v}"
|
||||||
IFS='.' read -r major minor patch <<< "$version"
|
IFS='.' read -r major minor patch <<< "$version"
|
||||||
|
echo "Parsed version: major=$major, minor=$minor, patch=$patch"
|
||||||
fi
|
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))
|
major=$((major + 1))
|
||||||
minor=0
|
minor=0
|
||||||
patch=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))
|
minor=$((minor + 1))
|
||||||
patch=0
|
patch=0
|
||||||
|
echo "Development branch - minor version bump"
|
||||||
else
|
else
|
||||||
patch=$((patch + 1))
|
patch=$((patch + 1))
|
||||||
|
echo "Other branch - patch version bump"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
new_tag="v${major}.${minor}.${patch}"
|
new_tag="v${major}.${minor}.${patch}"
|
||||||
@@ -65,14 +82,10 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: 'npm'
|
|
||||||
cache-dependency-path: backend/package-lock.json
|
|
||||||
|
|
||||||
- name: Install backend dependencies
|
- name: Install backend dependencies
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: |
|
run: npm install
|
||||||
# Try npm ci first, if it fails use npm install
|
|
||||||
npm ci || (echo "package-lock.json out of sync, using npm install..." && npm install)
|
|
||||||
|
|
||||||
- name: Run TypeScript check
|
- name: Run TypeScript check
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
@@ -81,7 +94,6 @@ jobs:
|
|||||||
- name: Run backend tests
|
- name: Run backend tests
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: |
|
run: |
|
||||||
# Skip tests if jest is not installed
|
|
||||||
if [ -f "node_modules/.bin/jest" ]; then
|
if [ -f "node_modules/.bin/jest" ]; then
|
||||||
npm test
|
npm test
|
||||||
else
|
else
|
||||||
@@ -134,11 +146,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=ref,event=branch
|
type=raw,value=${{ needs.set-tag.outputs.tag_name }}
|
||||||
type=semver,pattern={{version}}
|
type=raw,value=latest,enable=${{ fromJSON(needs.set-tag.outputs.is_main_branch) }}
|
||||||
type=sha
|
|
||||||
# Add the dynamically generated semantic version
|
|
||||||
${{ needs.set-tag.outputs.tag_name }}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
@@ -168,3 +177,4 @@ jobs:
|
|||||||
echo "- Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
echo "- Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||||
echo "- Tags: ${{ steps.meta.outputs.tags }}"
|
echo "- Tags: ${{ steps.meta.outputs.tags }}"
|
||||||
echo "- New version: ${{ needs.set-tag.outputs.tag_name }}"
|
echo "- New version: ${{ needs.set-tag.outputs.tag_name }}"
|
||||||
|
echo "- Is main branch: ${{ needs.set-tag.outputs.is_main_branch }}"
|
||||||
|
|||||||
29
.gitignore
vendored
29
.gitignore
vendored
@@ -64,6 +64,7 @@ build/
|
|||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
database/*.db
|
database/*.db
|
||||||
@@ -109,4 +110,30 @@ Thumbs.db
|
|||||||
.npm
|
.npm
|
||||||
|
|
||||||
# Optional eslint cache
|
# Optional eslint cache
|
||||||
.eslintcache
|
.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
|
||||||
96
Dockerfile
96
Dockerfile
@@ -1,7 +1,7 @@
|
|||||||
# Multi-stage build for combined frontend + backend
|
# Single stage build for workspaces
|
||||||
FROM node:20-bullseye AS backend-builder
|
FROM node:20-bullseye AS builder
|
||||||
|
|
||||||
WORKDIR /app/backend
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Python + OR-Tools
|
# Install Python + OR-Tools
|
||||||
RUN apt-get update && apt-get install -y python3 python3-pip build-essential \
|
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
|
# Create symlink so python3 is callable as python
|
||||||
RUN ln -sf /usr/bin/python3 /usr/bin/python
|
RUN ln -sf /usr/bin/python3 /usr/bin/python
|
||||||
|
|
||||||
# Copy backend files
|
# Copy root package files first
|
||||||
COPY backend/package*.json ./
|
COPY package*.json ./
|
||||||
COPY backend/tsconfig.json ./
|
COPY tsconfig.base.json ./
|
||||||
|
COPY ecosystem.config.cjs ./
|
||||||
|
|
||||||
# Install backend dependencies
|
# Install root dependencies
|
||||||
RUN npm ci
|
RUN npm install --only=production
|
||||||
|
|
||||||
# Copy backend source
|
# Copy workspace files
|
||||||
COPY backend/src/ ./src/
|
COPY backend/ ./backend/
|
||||||
|
COPY frontend/ ./frontend/
|
||||||
|
|
||||||
# Build backend
|
# Install workspace dependencies individually
|
||||||
RUN npm run build
|
RUN npm install --workspace=backend
|
||||||
|
RUN npm install --workspace=frontend
|
||||||
|
|
||||||
# Copy database files manually
|
# Build backend first
|
||||||
RUN cp -r src/database/ dist/database/
|
RUN npm run build --only=production --workspace=backend
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
RUN npm run build --workspace=frontend
|
||||||
|
|
||||||
# Verify Python and OR-Tools installation
|
# Verify Python and OR-Tools installation
|
||||||
RUN python -c "from ortools.sat.python import cp_model; print('OR-Tools installed successfully')"
|
RUN python -c "from ortools.sat.python import cp_model; print('OR-Tools installed successfully')"
|
||||||
|
|
||||||
# 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
|
# Production stage
|
||||||
FROM node:20-bookworm
|
FROM node:20-bookworm
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install PM2 for process management
|
# Install system dependencies including gettext-base for envsubst
|
||||||
RUN npm install -g pm2
|
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
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
# Copy backend built files
|
# Copy application files
|
||||||
COPY --from=backend-builder /app/backend/package*.json ./
|
COPY --from=builder /app/backend/dist/ ./dist/
|
||||||
COPY --from=backend-builder /app/backend/dist/ ./dist/
|
COPY --from=builder /app/backend/package*.json ./
|
||||||
COPY --from=backend-builder /app/backend/node_modules/ ./node_modules/
|
|
||||||
|
|
||||||
# Copy frontend built files
|
COPY --from=builder /app/node_modules/ ./node_modules/
|
||||||
COPY --from=frontend-builder /app/frontend/build/ ./frontend-build/
|
COPY --from=builder /app/frontend/dist/ ./frontend-build/
|
||||||
|
|
||||||
# Copy PM2 configuration
|
COPY --from=builder /app/ecosystem.config.cjs ./
|
||||||
COPY 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 && \
|
RUN groupadd -g 1001 nodejs && \
|
||||||
useradd -m -u 1001 -s /bin/bash -g nodejs schichtplan && \
|
useradd -m -u 1001 -s /bin/bash -g nodejs schichtplan && \
|
||||||
chown -R schichtplan:nodejs /app && \
|
chown -R schichtplan:nodejs /app && \
|
||||||
chmod 755 /app && \
|
chmod 755 /app && \
|
||||||
chmod 775 /app/data
|
chmod 775 /app/data
|
||||||
|
|
||||||
# Set PM2 to use app directory instead of home directory
|
|
||||||
ENV PM2_HOME=/app/.pm2
|
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
|
EXPOSE 3002
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/api/health || exit 1
|
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/api/health || exit 1
|
||||||
|
|
||||||
CMD ["pm2-runtime", "ecosystem.config.cjs"]
|
|
||||||
@@ -15,7 +15,7 @@ This software, "Schichtenplaner", is offered under a dual licensing model.
|
|||||||
- Integration into commercial software or distributions
|
- Integration into commercial software or distributions
|
||||||
|
|
||||||
To obtain a commercial license, please contact:
|
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
|
or open an inquiry via GitHub: https://github.com/donpat1to/Schichtenplaner
|
||||||
|
|
||||||
Without a valid commercial license, all commercial rights are reserved.
|
Without a valid commercial license, all commercial rights are reserved.
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run build && npx tsx src/server.ts",
|
"dev": "npm run build && npx tsx src/server.ts",
|
||||||
"build": "tsc",
|
"dev:single": "cross-env NODE_ENV=development TRUST_PROXY_ENABLED=false npx tsx src/server.ts",
|
||||||
|
"build": "tsc",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"prestart": "npm run build",
|
"prestart": "npm run build",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
@@ -14,23 +15,27 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/bcrypt": "^6.0.0",
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/node": "24.9.2",
|
||||||
|
"vite":"7.1.12",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"sqlite3": "^5.1.6",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.2",
|
"@types/bcryptjs": "^2.4.2",
|
||||||
"@types/cors": "^2.8.13",
|
|
||||||
"@types/express": "^4.17.17",
|
"@types/express": "^4.17.17",
|
||||||
"@types/jsonwebtoken": "^9.0.2",
|
"@types/jsonwebtoken": "^9.0.2",
|
||||||
"@types/uuid": "^9.0.2",
|
"@types/uuid": "^9.0.2",
|
||||||
"@types/jest": "^29.5.0",
|
"@types/jest": "^29.5.0",
|
||||||
"ts-node": "^10.9.0",
|
"ts-node": "^10.9.0",
|
||||||
"typescript": "^5.0.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' });
|
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>(
|
const user = await db.get<any>(
|
||||||
`SELECT
|
`SELECT
|
||||||
e.id, e.email, e.password, e.firstname, e.lastname,
|
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' });
|
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>(
|
const user = await db.get<any>(
|
||||||
`SELECT
|
`SELECT
|
||||||
e.id, e.email, e.firstname, e.lastname,
|
e.id, e.email, e.firstname, e.lastname,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// backend/src/controllers/employeeController.ts
|
// backend/src/controllers/employeeController.ts
|
||||||
import { Request, Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { db } from '../services/databaseService.js';
|
import { db } from '../services/databaseService.js';
|
||||||
@@ -18,17 +18,17 @@ function generateEmail(firstname: string, lastname: string): string {
|
|||||||
|
|
||||||
const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, '');
|
const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, '');
|
||||||
const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
|
const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
|
||||||
|
|
||||||
return `${cleanFirstname}.${cleanLastname}@sp.de`;
|
return `${cleanFirstname}.${cleanLastname}@sp.de`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getEmployees = async (req: AuthRequest, res: Response): Promise<void> => {
|
export const getEmployees = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
console.log('🔍 Fetching employees - User:', req.user);
|
console.log('🔍 Fetching employees - User:', req.user);
|
||||||
|
|
||||||
const { includeInactive } = req.query;
|
const { includeInactive } = req.query;
|
||||||
const includeInactiveFlag = includeInactive === 'true';
|
const includeInactiveFlag = includeInactive === 'true';
|
||||||
|
|
||||||
let query = `
|
let query = `
|
||||||
SELECT
|
SELECT
|
||||||
e.id, e.email, e.firstname, e.lastname,
|
e.id, e.email, e.firstname, e.lastname,
|
||||||
@@ -43,13 +43,13 @@ export const getEmployees = async (req: AuthRequest, res: Response): Promise<voi
|
|||||||
FROM employees e
|
FROM employees e
|
||||||
LEFT JOIN employee_roles er ON e.id = er.employee_id
|
LEFT JOIN employee_roles er ON e.id = er.employee_id
|
||||||
`;
|
`;
|
||||||
|
|
||||||
if (!includeInactiveFlag) {
|
if (!includeInactiveFlag) {
|
||||||
query += ' WHERE e.is_active = 1';
|
query += ' WHERE e.is_active = 1';
|
||||||
}
|
}
|
||||||
|
|
||||||
query += ' ORDER BY e.firstname, e.lastname';
|
query += ' ORDER BY e.firstname, e.lastname';
|
||||||
|
|
||||||
const employees = await db.all<any>(query);
|
const employees = await db.all<any>(query);
|
||||||
|
|
||||||
// Format employees with proper field names and roles array
|
// Format employees with proper field names and roles array
|
||||||
@@ -132,12 +132,12 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
password: '***hidden***'
|
password: '***hidden***'
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
password,
|
password,
|
||||||
firstname,
|
firstname,
|
||||||
lastname,
|
lastname,
|
||||||
roles = ['user'],
|
roles = ['user'],
|
||||||
employeeType,
|
employeeType,
|
||||||
contractType,
|
contractType,
|
||||||
canWorkAlone = false,
|
canWorkAlone = false,
|
||||||
isTrainee = false
|
isTrainee = false
|
||||||
@@ -146,21 +146,21 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
// Validation
|
// Validation
|
||||||
if (!password || !firstname || !lastname || !employeeType) {
|
if (!password || !firstname || !lastname || !employeeType) {
|
||||||
console.log('❌ Validation failed: Missing required fields');
|
console.log('❌ Validation failed: Missing required fields');
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: 'Password, firstname, lastname und employeeType sind erforderlich'
|
error: 'Password, firstname, lastname und employeeType sind erforderlich'
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ ENHANCED: Validate employee type exists and get category info
|
// ✅ ENHANCED: Validate employee type exists and get category info
|
||||||
const employeeTypeInfo = await db.get<{type: string, category: string, has_contract_type: number}>(
|
const employeeTypeInfo = await db.get<{ type: string, category: string, has_contract_type: number }>(
|
||||||
'SELECT type, category, has_contract_type FROM employee_types WHERE type = ?',
|
'SELECT type, category, has_contract_type FROM employee_types WHERE type = ?',
|
||||||
[employeeType]
|
[employeeType]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!employeeTypeInfo) {
|
if (!employeeTypeInfo) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: `Ungültiger employeeType: ${employeeType}. Gültige Typen: manager, personell, apprentice, guest`
|
error: `Ungültiger employeeType: ${employeeType}. Gültige Typen: manager, personell, apprentice, guest`
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -169,22 +169,22 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
if (employeeTypeInfo.has_contract_type === 1) {
|
if (employeeTypeInfo.has_contract_type === 1) {
|
||||||
// Internal types require contract type
|
// Internal types require contract type
|
||||||
if (!contractType) {
|
if (!contractType) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: `contractType ist erforderlich für employeeType: ${employeeType}`
|
error: `contractType ist erforderlich für employeeType: ${employeeType}`
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!['small', 'large', 'flexible'].includes(contractType)) {
|
if (!['small', 'large', 'flexible'].includes(contractType)) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: `Ungültiger contractType: ${contractType}. Gültige Werte: small, large, flexible`
|
error: `Ungültiger contractType: ${contractType}. Gültige Werte: small, large, flexible`
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// External types (guest) should not have contract type
|
// External types (guest) should not have contract type
|
||||||
if (contractType) {
|
if (contractType) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: `contractType ist nicht erlaubt für employeeType: ${employeeType}`
|
error: `contractType ist nicht erlaubt für employeeType: ${employeeType}`
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -192,8 +192,8 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
|
|
||||||
// ✅ ENHANCED: isTrainee validation - only applicable for personell type
|
// ✅ ENHANCED: isTrainee validation - only applicable for personell type
|
||||||
if (isTrainee && employeeType !== 'personell') {
|
if (isTrainee && employeeType !== 'personell') {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: `isTrainee ist nur für employeeType 'personell' erlaubt`
|
error: `isTrainee ist nur für employeeType 'personell' erlaubt`
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -204,11 +204,11 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
|
|
||||||
// Check if generated email already exists
|
// Check if generated email already exists
|
||||||
const existingUser = await db.get<any>('SELECT id FROM employees WHERE email = ? AND is_active = 1', [email]);
|
const existingUser = await db.get<any>('SELECT id FROM employees WHERE email = ? AND is_active = 1', [email]);
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
console.log('❌ Generated email already exists:', email);
|
console.log('❌ Generated email already exists:', email);
|
||||||
res.status(409).json({
|
res.status(409).json({
|
||||||
error: `Employee with email ${email} already exists. Please use different firstname/lastname.`
|
error: `Employee with email ${email} already exists. Please use different firstname/lastname.`
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -228,12 +228,12 @@ export const createEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
is_active, is_trainee
|
is_active, is_trainee
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
employeeId,
|
employeeId,
|
||||||
email,
|
email,
|
||||||
hashedPassword,
|
hashedPassword,
|
||||||
firstname,
|
firstname,
|
||||||
lastname,
|
lastname,
|
||||||
employeeType,
|
employeeType,
|
||||||
contractType, // Will be NULL for external types
|
contractType, // Will be NULL for external types
|
||||||
canWorkAlone ? 1 : 0,
|
canWorkAlone ? 1 : 0,
|
||||||
1,
|
1,
|
||||||
@@ -302,9 +302,9 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { firstname, lastname, roles, isActive, employeeType, contractType, canWorkAlone, isTrainee } = req.body;
|
const { firstname, lastname, roles, isActive, employeeType, contractType, canWorkAlone, isTrainee } = req.body;
|
||||||
|
|
||||||
console.log('📝 Update Employee Request:', {
|
console.log('📝 Update Employee Request:', {
|
||||||
id, firstname, lastname, roles, isActive,
|
id, firstname, lastname, roles, isActive,
|
||||||
employeeType, contractType, canWorkAlone, isTrainee
|
employeeType, contractType, canWorkAlone, isTrainee
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if employee exists and get current data
|
// Check if employee exists and get current data
|
||||||
@@ -314,6 +314,56 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user is trying to remove their own admin role
|
||||||
|
const currentUser = req.user;
|
||||||
|
if (currentUser?.userId === id && roles) {
|
||||||
|
const currentUserRoles = await db.all<{ role: string }>(
|
||||||
|
'SELECT role FROM employee_roles WHERE employee_id = ?',
|
||||||
|
[currentUser.userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCurrentlyAdmin = currentUserRoles.some(role => role.role === 'admin');
|
||||||
|
const willBeAdmin = roles.includes('admin');
|
||||||
|
|
||||||
|
if (isCurrentlyAdmin && !willBeAdmin) {
|
||||||
|
res.status(400).json({ error: 'You cannot remove your own admin role' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check admin count if roles are being updated
|
||||||
|
if (roles) {
|
||||||
|
try {
|
||||||
|
await checkAdminCount(id, roles);
|
||||||
|
} catch (error: any) {
|
||||||
|
res.status(400).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if trying to deactivate the last admin
|
||||||
|
if (isActive === false) {
|
||||||
|
const isEmployeeAdmin = await db.get<{ count: number }>(
|
||||||
|
`SELECT COUNT(*) as count FROM employee_roles WHERE employee_id = ? AND role = 'admin'`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isEmployeeAdmin && isEmployeeAdmin.count > 0) {
|
||||||
|
const otherAdminCount = await db.get<{ count: number }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM employee_roles er
|
||||||
|
JOIN employees e ON er.employee_id = e.id
|
||||||
|
WHERE er.role = 'admin' AND e.is_active = 1 AND er.employee_id != ?`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!otherAdminCount || otherAdminCount.count === 0) {
|
||||||
|
res.status(400).json({ error: 'Cannot deactivate the last admin user' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate employee type if provided
|
// Validate employee type if provided
|
||||||
if (employeeType) {
|
if (employeeType) {
|
||||||
const validEmployeeType = await db.get(
|
const validEmployeeType = await db.get(
|
||||||
@@ -322,8 +372,8 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!validEmployeeType) {
|
if (!validEmployeeType) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
error: `Ungültiger employeeType: ${employeeType}`
|
error: `Ungültiger employeeType: ${employeeType}`
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -335,16 +385,16 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
const newFirstname = firstname || existingEmployee.firstname;
|
const newFirstname = firstname || existingEmployee.firstname;
|
||||||
const newLastname = lastname || existingEmployee.lastname;
|
const newLastname = lastname || existingEmployee.lastname;
|
||||||
email = generateEmail(newFirstname, newLastname);
|
email = generateEmail(newFirstname, newLastname);
|
||||||
|
|
||||||
// Check if new email already exists (for another employee)
|
// Check if new email already exists (for another employee)
|
||||||
const emailExists = await db.get<any>(
|
const emailExists = await db.get<any>(
|
||||||
'SELECT id FROM employees WHERE email = ? AND id != ? AND is_active = 1',
|
'SELECT id FROM employees WHERE email = ? AND id != ? AND is_active = 1',
|
||||||
[email, id]
|
[email, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (emailExists) {
|
if (emailExists) {
|
||||||
res.status(409).json({
|
res.status(409).json({
|
||||||
error: `Cannot update name - email ${email} already exists for another employee`
|
error: `Cannot update name - email ${email} already exists for another employee`
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -373,7 +423,7 @@ export const updateEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
if (roles) {
|
if (roles) {
|
||||||
// Delete existing roles
|
// Delete existing roles
|
||||||
await db.run('DELETE FROM employee_roles WHERE employee_id = ?', [id]);
|
await db.run('DELETE FROM employee_roles WHERE employee_id = ?', [id]);
|
||||||
|
|
||||||
// Insert new roles
|
// Insert new roles
|
||||||
for (const role of roles) {
|
for (const role of roles) {
|
||||||
await db.run(
|
await db.run(
|
||||||
@@ -438,7 +488,15 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
console.log('🗑️ Starting deletion process for employee ID:', id);
|
console.log('🗑️ Starting deletion process for employee ID:', id);
|
||||||
|
|
||||||
// UPDATED: Check if employee exists with role from employee_roles
|
const currentUser = req.user;
|
||||||
|
|
||||||
|
// Prevent self-deletion
|
||||||
|
if (currentUser?.userId === id) {
|
||||||
|
res.status(400).json({ error: 'You cannot delete yourself' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if employee exists with role from employee_roles
|
||||||
const existingEmployee = await db.get<any>(`
|
const existingEmployee = await db.get<any>(`
|
||||||
SELECT
|
SELECT
|
||||||
e.id, e.email, e.firstname, e.lastname, e.is_active,
|
e.id, e.email, e.firstname, e.lastname, e.is_active,
|
||||||
@@ -455,6 +513,26 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const employeeRoles = await db.all<{ role: string }>(`
|
||||||
|
SELECT role FROM employee_roles WHERE employee_id = ?
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
const isEmployeeAdmin = employeeRoles.some(role => role.role === 'admin');
|
||||||
|
|
||||||
|
// Check if this is the last admin
|
||||||
|
if (isEmployeeAdmin) {
|
||||||
|
const adminCount = await db.get<{ count: number }>(
|
||||||
|
`SELECT COUNT(*) as count
|
||||||
|
FROM employee_roles
|
||||||
|
WHERE role = 'admin'`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (adminCount && adminCount.count <= 1) {
|
||||||
|
res.status(400).json({ error: 'Cannot delete the last admin user' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
console.log('📝 Found employee to delete:', existingEmployee);
|
console.log('📝 Found employee to delete:', existingEmployee);
|
||||||
|
|
||||||
// Start transaction
|
// Start transaction
|
||||||
@@ -463,18 +541,18 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
try {
|
try {
|
||||||
// 1. Remove availabilities
|
// 1. Remove availabilities
|
||||||
await db.run('DELETE FROM employee_availability WHERE employee_id = ?', [id]);
|
await db.run('DELETE FROM employee_availability WHERE employee_id = ?', [id]);
|
||||||
|
|
||||||
// 2. Remove from assigned_shifts (JSON field cleanup)
|
// 2. Remove from assigned_shifts (JSON field cleanup)
|
||||||
interface AssignedShift {
|
interface AssignedShift {
|
||||||
id: string;
|
id: string;
|
||||||
assigned_employees: string;
|
assigned_employees: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assignedShifts = await db.all<AssignedShift>(
|
const assignedShifts = await db.all<AssignedShift>(
|
||||||
'SELECT id, assigned_employees FROM scheduled_shifts WHERE json_extract(assigned_employees, "$") LIKE ?',
|
'SELECT id, assigned_employees FROM scheduled_shifts WHERE json_extract(assigned_employees, "$") LIKE ?',
|
||||||
[`%${id}%`]
|
[`%${id}%`]
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const shift of assignedShifts) {
|
for (const shift of assignedShifts) {
|
||||||
try {
|
try {
|
||||||
const employeesArray: string[] = JSON.parse(shift.assigned_employees || '[]');
|
const employeesArray: string[] = JSON.parse(shift.assigned_employees || '[]');
|
||||||
@@ -503,7 +581,7 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
|
|
||||||
await db.run('COMMIT');
|
await db.run('COMMIT');
|
||||||
console.log('✅ Successfully deleted employee:', existingEmployee.email);
|
console.log('✅ Successfully deleted employee:', existingEmployee.email);
|
||||||
|
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -511,7 +589,6 @@ export const deleteEmployee = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
console.error('Error during deletion transaction:', error);
|
console.error('Error during deletion transaction:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting employee:', error);
|
console.error('Error deleting employee:', error);
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
@@ -558,13 +635,49 @@ export const updateAvailabilities = async (req: AuthRequest, res: Response): Pro
|
|||||||
const { employeeId } = req.params;
|
const { employeeId } = req.params;
|
||||||
const { planId, availabilities } = req.body;
|
const { planId, availabilities } = req.body;
|
||||||
|
|
||||||
// Check if employee exists
|
// Check if employee exists and get contract type
|
||||||
const existingEmployee = await db.get('SELECT id FROM employees WHERE id = ?', [employeeId]);
|
const existingEmployee = await db.get<any>(`
|
||||||
|
SELECT e.*, er.role
|
||||||
|
FROM employees e
|
||||||
|
LEFT JOIN employee_roles er ON e.id = er.employee_id
|
||||||
|
WHERE e.id = ?
|
||||||
|
`, [employeeId]);
|
||||||
|
|
||||||
if (!existingEmployee) {
|
if (!existingEmployee) {
|
||||||
res.status(404).json({ error: 'Employee not found' });
|
res.status(404).json({ error: 'Employee not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if employee is active
|
||||||
|
if (!existingEmployee.is_active) {
|
||||||
|
res.status(400).json({ error: 'Cannot set availability for inactive employee' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate contract type requirements
|
||||||
|
const availableCount = availabilities.filter((avail: any) =>
|
||||||
|
avail.preferenceLevel === 1 || avail.preferenceLevel === 2
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const contractType = existingEmployee.contract_type;
|
||||||
|
|
||||||
|
// Apply contract type minimum requirements
|
||||||
|
if (contractType === 'small' && availableCount < 2) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Employees with small contract must have at least 2 available shifts'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contractType === 'large' && availableCount < 3) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Employees with large contract must have at least 3 available shifts'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flexible contract has no minimum requirement
|
||||||
|
|
||||||
await db.run('BEGIN TRANSACTION');
|
await db.run('BEGIN TRANSACTION');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -629,12 +742,12 @@ export const changePassword = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
|
|
||||||
// Get the current user from the auth middleware
|
// Get the current user from the auth middleware
|
||||||
const currentUser = (req as AuthRequest).user;
|
const currentUser = (req as AuthRequest).user;
|
||||||
|
|
||||||
// Check if user is changing their own password or is an admin
|
// Check if user is changing their own password or is an admin
|
||||||
if (currentUser?.userId !== id && currentUser?.role !== 'admin') {
|
if (currentUser?.userId !== id && currentUser?.role !== 'admin') {
|
||||||
res.status(403).json({ error: 'You can only change your own password' });
|
res.status(403).json({ error: 'You can only change your own password' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if employee exists and get password
|
// Check if employee exists and get password
|
||||||
const employee = await db.get<{ password: string }>('SELECT password FROM employees WHERE id = ?', [id]);
|
const employee = await db.get<{ password: string }>('SELECT password FROM employees WHERE id = ?', [id]);
|
||||||
@@ -643,8 +756,8 @@ export const changePassword = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For non-admin users, verify current password
|
// Verify current password
|
||||||
if (currentUser?.role !== 'admin') {
|
if (employee) {
|
||||||
const isValidPassword = await bcrypt.compare(currentPassword, employee.password);
|
const isValidPassword = await bcrypt.compare(currentPassword, employee.password);
|
||||||
if (!isValidPassword) {
|
if (!isValidPassword) {
|
||||||
res.status(400).json({ error: 'Current password is incorrect' });
|
res.status(400).json({ error: 'Current password is incorrect' });
|
||||||
@@ -653,8 +766,8 @@ export const changePassword = async (req: AuthRequest, res: Response): Promise<v
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate new password
|
// Validate new password
|
||||||
if (!newPassword || newPassword.length < 6) {
|
if (!newPassword || newPassword.length < 8) {
|
||||||
res.status(400).json({ error: 'New password must be at least 6 characters long' });
|
res.status(400).json({ error: 'New password must be at least 8 characters long' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -685,13 +798,13 @@ export const updateLastLogin = async (req: AuthRequest, res: Response): Promise<
|
|||||||
// Update last_login with current timestamp
|
// Update last_login with current timestamp
|
||||||
const currentTimestamp = new Date().toISOString();
|
const currentTimestamp = new Date().toISOString();
|
||||||
await db.run(
|
await db.run(
|
||||||
'UPDATE employees SET last_login = ? WHERE id = ?',
|
'UPDATE employees SET last_login = ? WHERE id = ?',
|
||||||
[currentTimestamp, id]
|
[currentTimestamp, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(`✅ Last login updated for employee ${id}: ${currentTimestamp}`);
|
console.log(`✅ Last login updated for employee ${id}: ${currentTimestamp}`);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: 'Last login updated successfully',
|
message: 'Last login updated successfully',
|
||||||
lastLogin: currentTimestamp
|
lastLogin: currentTimestamp
|
||||||
});
|
});
|
||||||
@@ -699,4 +812,36 @@ export const updateLastLogin = async (req: AuthRequest, res: Response): Promise<
|
|||||||
console.error('Error updating last login:', error);
|
console.error('Error updating last login:', error);
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkAdminCount = async (employeeId: string, newRoles: string[]): Promise<void> => {
|
||||||
|
try {
|
||||||
|
// Count current admins excluding the employee being updated
|
||||||
|
const adminCountResult = await db.get<{ count: number }>(
|
||||||
|
`SELECT COUNT(DISTINCT employee_id) as count
|
||||||
|
FROM employee_roles
|
||||||
|
WHERE role = 'admin' AND employee_id != ?`,
|
||||||
|
[employeeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentAdminCount = adminCountResult?.count || 0;
|
||||||
|
|
||||||
|
// Check ALL current roles for the employee
|
||||||
|
const currentEmployeeRoles = await db.all<{ role: string }>(
|
||||||
|
`SELECT role FROM employee_roles WHERE employee_id = ?`,
|
||||||
|
[employeeId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentRoles = currentEmployeeRoles.map(role => role.role);
|
||||||
|
const isCurrentlyAdmin = currentRoles.includes('admin');
|
||||||
|
const willBeAdmin = newRoles.includes('admin');
|
||||||
|
|
||||||
|
// If removing admin role from the last admin, throw error
|
||||||
|
if (isCurrentlyAdmin && !willBeAdmin && currentAdminCount === 0) {
|
||||||
|
throw new Error('Cannot remove admin role from the last admin user');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
// backend/src/controllers/setupController.ts
|
// backend/src/controllers/setupController.ts
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { db } from '../services/databaseService.js';
|
import { db } from '../services/databaseService.js';
|
||||||
|
|
||||||
@@ -17,7 +16,7 @@ function generateEmail(firstname: string, lastname: string): string {
|
|||||||
|
|
||||||
const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, '');
|
const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, '');
|
||||||
const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
|
const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
|
||||||
|
|
||||||
return `${cleanFirstname}.${cleanLastname}@sp.de`;
|
return `${cleanFirstname}.${cleanLastname}@sp.de`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,15 +31,15 @@ export const checkSetupStatus = async (req: Request, res: Response): Promise<voi
|
|||||||
);
|
);
|
||||||
|
|
||||||
console.log('Admin exists check:', adminExists);
|
console.log('Admin exists check:', adminExists);
|
||||||
|
|
||||||
const needsSetup = !adminExists || adminExists['COUNT(*)'] === 0;
|
const needsSetup = !adminExists || adminExists['COUNT(*)'] === 0;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
needsSetup: needsSetup
|
needsSetup: needsSetup
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error checking setup status:', error);
|
console.error('Error checking setup status:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Internal server error during setup check'
|
error: 'Internal server error during setup check'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -76,8 +75,8 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Password length validation
|
// Password length validation
|
||||||
if (password.length < 6) {
|
if (password.length < 8) {
|
||||||
res.status(400).json({ error: 'Das Passwort muss mindestens 6 Zeichen lang sein' });
|
res.status(400).json({ error: 'Das Passwort muss mindestens 8 Zeichen lang sein' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,15 +125,15 @@ export const setupAdmin = async (req: Request, res: Response): Promise<void> =>
|
|||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
await db.run('ROLLBACK');
|
await db.run('ROLLBACK');
|
||||||
console.error('❌ Database error during admin creation:', dbError);
|
console.error('❌ Database error during admin creation:', dbError);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Fehler beim Erstellen des Admin-Accounts'
|
error: 'Fehler beim Erstellen des Admin-Accounts'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error in setup:', error);
|
console.error('❌ Error in setup:', error);
|
||||||
|
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Ein unerwarteter Fehler ist aufgetreten'
|
error: 'Ein unerwarteter Fehler ist aufgetreten'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import { db } from '../services/databaseService.js';
|
|||||||
import {
|
import {
|
||||||
CreateShiftPlanRequest,
|
CreateShiftPlanRequest,
|
||||||
UpdateShiftPlanRequest,
|
UpdateShiftPlanRequest,
|
||||||
ShiftPlan
|
|
||||||
} from '../models/ShiftPlan.js';
|
} from '../models/ShiftPlan.js';
|
||||||
import { AuthRequest } from '../middleware/auth.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) {
|
async function getPlanWithDetails(planId: string) {
|
||||||
const plan = await db.get<any>(`
|
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
|
-- Employee Types
|
||||||
CREATE TABLE IF NOT EXISTS employee_types (
|
CREATE TABLE IF NOT EXISTS employee_types (
|
||||||
type TEXT PRIMARY KEY,
|
type TEXT PRIMARY KEY,
|
||||||
@@ -148,4 +153,4 @@ CREATE INDEX IF NOT EXISTS idx_scheduled_shifts_date_time ON scheduled_shifts(da
|
|||||||
CREATE INDEX IF NOT EXISTS idx_scheduled_shifts_required_employees ON scheduled_shifts(required_employees);
|
CREATE INDEX IF NOT EXISTS idx_scheduled_shifts_required_employees ON scheduled_shifts(required_employees);
|
||||||
CREATE INDEX IF NOT EXISTS idx_shift_assignments_employee ON shift_assignments(employee_id);
|
CREATE INDEX IF NOT EXISTS idx_shift_assignments_employee ON shift_assignments(employee_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_shift_assignments_shift ON shift_assignments(scheduled_shift_id);
|
CREATE INDEX IF NOT EXISTS idx_shift_assignments_shift ON shift_assignments(scheduled_shift_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_employee_availability_employee_plan ON employee_availability(employee_id, plan_id);
|
CREATE INDEX IF NOT EXISTS idx_employee_availability_employee_plan ON employee_availability(employee_id, plan_id);
|
||||||
|
|||||||
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
|
||||||
@@ -51,4 +51,36 @@ export const requireRole = (roles: string[]) => {
|
|||||||
console.log(`✅ Role check passed for user: ${req.user.email}, role: ${req.user.role}`);
|
console.log(`✅ Role check passed for user: ${req.user.email}, role: ${req.user.role}`);
|
||||||
next();
|
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();
|
||||||
|
};
|
||||||
@@ -14,16 +14,16 @@ function generateEmail(firstname: string, lastname: string): string {
|
|||||||
|
|
||||||
const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, '');
|
const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, '');
|
||||||
const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
|
const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
|
||||||
|
|
||||||
return `${cleanFirstname}.${cleanLastname}@sp.de`;
|
return `${cleanFirstname}.${cleanLastname}@sp.de`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// UPDATED: Validation for new employee model with employee types
|
// Validation for new employee model with employee types
|
||||||
export function validateEmployeeData(employee: CreateEmployeeRequest): string[] {
|
export function validateEmployeeData(employee: CreateEmployeeRequest): string[] {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (employee.password?.length < 6) {
|
if (employee.password?.length < 8) {
|
||||||
errors.push('Password must be at least 6 characters long');
|
errors.push('Password must be at least 8 characters long');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!employee.firstname?.trim() || employee.firstname.trim().length < 2) {
|
if (!employee.firstname?.trim() || employee.firstname.trim().length < 2) {
|
||||||
@@ -71,17 +71,17 @@ export function generateEmployeeEmail(firstname: string, lastname: string): stri
|
|||||||
return generateEmail(firstname, lastname);
|
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 =>
|
export const isManager = (employee: Employee): boolean =>
|
||||||
employee.employeeType === 'manager';
|
employee.employeeType === 'manager';
|
||||||
|
|
||||||
export const isPersonell = (employee: Employee): boolean =>
|
export const isPersonell = (employee: Employee): boolean =>
|
||||||
employee.employeeType === 'personell';
|
employee.employeeType === 'personell';
|
||||||
|
|
||||||
export const isApprentice = (employee: Employee): boolean =>
|
export const isApprentice = (employee: Employee): boolean =>
|
||||||
employee.employeeType === 'apprentice';
|
employee.employeeType === 'apprentice';
|
||||||
|
|
||||||
export const isGuest = (employee: Employee): boolean =>
|
export const isGuest = (employee: Employee): boolean =>
|
||||||
employee.employeeType === 'guest';
|
employee.employeeType === 'guest';
|
||||||
|
|
||||||
export const isInternal = (employee: Employee): boolean =>
|
export const isInternal = (employee: Employee): boolean =>
|
||||||
@@ -90,25 +90,25 @@ export const isInternal = (employee: Employee): boolean =>
|
|||||||
export const isExternal = (employee: Employee): boolean =>
|
export const isExternal = (employee: Employee): boolean =>
|
||||||
employee.employeeType === 'guest';
|
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 =>
|
export const isTrainee = (employee: Employee): boolean =>
|
||||||
employee.employeeType === 'personell' && employee.isTrainee;
|
employee.employeeType === 'personell' && employee.isTrainee;
|
||||||
|
|
||||||
export const isExperienced = (employee: Employee): boolean =>
|
export const isExperienced = (employee: Employee): boolean =>
|
||||||
employee.employeeType === 'personell' && !employee.isTrainee;
|
employee.employeeType === 'personell' && !employee.isTrainee;
|
||||||
|
|
||||||
// Role-based helpers
|
// Role-based helpers
|
||||||
export const isAdmin = (employee: Employee): boolean =>
|
export const isAdmin = (employee: Employee): boolean =>
|
||||||
employee.roles?.includes('admin') || false;
|
employee.roles?.includes('admin') || false;
|
||||||
|
|
||||||
export const isMaintenance = (employee: Employee): boolean =>
|
export const isMaintenance = (employee: Employee): boolean =>
|
||||||
employee.roles?.includes('maintenance') || false;
|
employee.roles?.includes('maintenance') || false;
|
||||||
|
|
||||||
export const isUser = (employee: Employee): boolean =>
|
export const isUser = (employee: Employee): boolean =>
|
||||||
employee.roles?.includes('user') || false;
|
employee.roles?.includes('user') || false;
|
||||||
|
|
||||||
// UPDATED: Work alone permission - managers and experienced personell can work alone
|
// Work alone permission - managers and experienced personell can work alone
|
||||||
export const canEmployeeWorkAlone = (employee: Employee): boolean =>
|
export const canEmployeeWorkAlone = (employee: Employee): boolean =>
|
||||||
employee.canWorkAlone && (isManager(employee) || isExperienced(employee));
|
employee.canWorkAlone && (isManager(employee) || isExperienced(employee));
|
||||||
|
|
||||||
// Helper for full name display
|
// Helper for full name display
|
||||||
@@ -134,7 +134,7 @@ export function validateAvailabilityData(availability: Omit<EmployeeAvailability
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
// UPDATED: Helper to get employee type category
|
// Helper to get employee type category
|
||||||
export const getEmployeeCategory = (employee: Employee): 'internal' | 'external' => {
|
export const getEmployeeCategory = (employee: Employee): 'internal' | 'external' => {
|
||||||
return isInternal(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);
|
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(
|
export function getScheduledShiftByDateAndTime(
|
||||||
plan: ShiftPlan,
|
plan: ShiftPlan,
|
||||||
date: string,
|
date: string,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Employee } from './Employee.js';
|
import { Employee } from './Employee.js';
|
||||||
import { ShiftPlan } from './ShiftPlan.js';
|
import { ShiftPlan } from './ShiftPlan.js';
|
||||||
|
|
||||||
// Updated Availability interface to match new schema
|
// Availability interface
|
||||||
export interface Availability {
|
export interface Availability {
|
||||||
id: string;
|
id: string;
|
||||||
employeeId: string;
|
employeeId: string;
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import {
|
|||||||
validateToken
|
validateToken
|
||||||
} from '../controllers/authController.js';
|
} from '../controllers/authController.js';
|
||||||
import { authMiddleware } from '../middleware/auth.js';
|
import { authMiddleware } from '../middleware/auth.js';
|
||||||
|
import { validateLogin, validateRegister, handleValidationErrors } from '../middleware/validation.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Public routes
|
// Public routes
|
||||||
router.post('/login', login);
|
router.post('/login', validateLogin, handleValidationErrors, login);
|
||||||
router.post('/register', register);
|
router.post('/register', validateRegister, handleValidationErrors, register);
|
||||||
router.get('/validate', validateToken);
|
router.get('/validate', validateToken);
|
||||||
|
|
||||||
// Protected routes (require authentication)
|
// Protected routes (require authentication)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// backend/src/routes/employees.ts
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { authMiddleware, requireRole } from '../middleware/auth.js';
|
import { authMiddleware, requireRole } from '../middleware/auth.js';
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +11,16 @@ import {
|
|||||||
changePassword,
|
changePassword,
|
||||||
updateLastLogin
|
updateLastLogin
|
||||||
} from '../controllers/employeeController.js';
|
} from '../controllers/employeeController.js';
|
||||||
|
import {
|
||||||
|
handleValidationErrors,
|
||||||
|
validateEmployee,
|
||||||
|
validateEmployeeUpdate,
|
||||||
|
validateChangePassword,
|
||||||
|
validateId,
|
||||||
|
validateEmployeeId,
|
||||||
|
validateAvailabilities,
|
||||||
|
validatePagination
|
||||||
|
} from '../middleware/validation.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -19,16 +28,18 @@ const router = express.Router();
|
|||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
// Employee CRUD Routes
|
// Employee CRUD Routes
|
||||||
router.get('/', authMiddleware, getEmployees);
|
router.get('/', validatePagination, handleValidationErrors, authMiddleware, getEmployees);
|
||||||
router.get('/:id', requireRole(['admin', 'maintenance']), getEmployee);
|
router.get('/:id', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), getEmployee);
|
||||||
router.post('/', requireRole(['admin']), createEmployee);
|
router.post('/', validateEmployee, handleValidationErrors, requireRole(['admin']), createEmployee);
|
||||||
router.put('/:id', requireRole(['admin', 'maintenance']), updateEmployee);
|
router.put('/:id', validateId, validateEmployeeUpdate, handleValidationErrors, requireRole(['admin', 'maintenance']), updateEmployee);
|
||||||
router.delete('/:id', requireRole(['admin']), deleteEmployee);
|
router.delete('/:id', validateId, handleValidationErrors, requireRole(['admin']), deleteEmployee);
|
||||||
router.put('/:id/password', authMiddleware, changePassword);
|
|
||||||
router.put('/:id/last-login', authMiddleware, updateLastLogin);
|
// Password & Login Routes
|
||||||
|
router.put('/:id/password', validateId, validateChangePassword, handleValidationErrors, authMiddleware, changePassword);
|
||||||
|
router.put('/:id/last-login', validateId, handleValidationErrors, authMiddleware, updateLastLogin);
|
||||||
|
|
||||||
// Availability Routes
|
// Availability Routes
|
||||||
router.get('/:employeeId/availabilities', authMiddleware, getAvailabilities);
|
router.get('/:employeeId/availabilities', validateEmployeeId, handleValidationErrors, authMiddleware, getAvailabilities);
|
||||||
router.put('/:employeeId/availabilities', authMiddleware, updateAvailabilities);
|
router.put('/:employeeId/availabilities', validateEmployeeId, validateAvailabilities, handleValidationErrors, authMiddleware, updateAvailabilities);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
// backend/src/routes/scheduledShifts.ts
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { authMiddleware, requireRole } from '../middleware/auth.js';
|
import { authMiddleware, requireRole } from '../middleware/auth.js';
|
||||||
import {
|
import {
|
||||||
@@ -8,23 +7,21 @@ import {
|
|||||||
getScheduledShiftsFromPlan,
|
getScheduledShiftsFromPlan,
|
||||||
updateScheduledShift
|
updateScheduledShift
|
||||||
} from '../controllers/shiftPlanController.js';
|
} from '../controllers/shiftPlanController.js';
|
||||||
|
import {
|
||||||
|
validateId,
|
||||||
|
validatePlanId,
|
||||||
|
validateScheduledShiftUpdate,
|
||||||
|
handleValidationErrors
|
||||||
|
} from '../middleware/validation.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
|
router.post('/:id/generate-shifts', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), generateScheduledShiftsForPlan);
|
||||||
router.post('/:id/generate-shifts', requireRole(['admin', 'maintenance']), generateScheduledShiftsForPlan);
|
router.post('/:id/regenerate-shifts', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), regenerateScheduledShifts);
|
||||||
|
router.get('/plan/:planId', validatePlanId, handleValidationErrors, getScheduledShiftsFromPlan);
|
||||||
router.post('/:id/regenerate-shifts', requireRole(['admin', 'maintenance']), regenerateScheduledShifts);
|
router.get('/:id', validateId, handleValidationErrors, getScheduledShift);
|
||||||
|
router.put('/:id', validateId, validateScheduledShiftUpdate, handleValidationErrors, updateScheduledShift);
|
||||||
// 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);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { SchedulingService } from '../services/SchedulingService.js';
|
import { SchedulingService } from '../services/SchedulingService.js';
|
||||||
|
import { validateSchedulingRequest, handleValidationErrors } from '../middleware/validation.js';
|
||||||
|
|
||||||
const router = express.Router();
|
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 {
|
try {
|
||||||
const { shiftPlan, employees, availabilities, constraints } = req.body;
|
const { shiftPlan, employees, availabilities, constraints } = req.body;
|
||||||
|
|
||||||
@@ -14,18 +15,6 @@ router.post('/generate-schedule', async (req, res) => {
|
|||||||
constraintCount: constraints?.length
|
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 scheduler = new SchedulingService();
|
||||||
const result = await scheduler.generateOptimalSchedule({
|
const result = await scheduler.generateOptimalSchedule({
|
||||||
shiftPlan,
|
shiftPlan,
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
// backend/src/routes/setup.ts
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import bcrypt from 'bcryptjs';
|
|
||||||
import { checkSetupStatus, setupAdmin } from '../controllers/setupController.js';
|
import { checkSetupStatus, setupAdmin } from '../controllers/setupController.js';
|
||||||
|
import { validateSetupAdmin, handleValidationErrors } from '../middleware/validation.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/status', checkSetupStatus);
|
router.get('/status', checkSetupStatus);
|
||||||
router.post('/admin', setupAdmin);
|
router.post('/admin', validateSetupAdmin, handleValidationErrors, setupAdmin);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
// backend/src/routes/shiftPlans.ts
|
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { authMiddleware, requireRole } from '../middleware/auth.js';
|
import { authMiddleware, requireRole } from '../middleware/auth.js';
|
||||||
import {
|
import {
|
||||||
@@ -10,32 +9,25 @@ import {
|
|||||||
createFromPreset,
|
createFromPreset,
|
||||||
clearAssignments
|
clearAssignments
|
||||||
} from '../controllers/shiftPlanController.js';
|
} from '../controllers/shiftPlanController.js';
|
||||||
|
import {
|
||||||
|
validateShiftPlan,
|
||||||
|
validateShiftPlanUpdate,
|
||||||
|
validateCreateFromPreset,
|
||||||
|
handleValidationErrors,
|
||||||
|
validateId
|
||||||
|
} from '../middleware/validation.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
// Combined routes for both shift plans and templates
|
// Combined routes for both shift plans and templates
|
||||||
|
router.get('/', getShiftPlans);
|
||||||
// GET all shift plans (including templates)
|
router.get('/:id', validateId, handleValidationErrors, getShiftPlan);
|
||||||
router.get('/' , authMiddleware, getShiftPlans);
|
router.post('/', validateShiftPlan, handleValidationErrors, requireRole(['admin', 'maintenance']), createShiftPlan);
|
||||||
|
router.post('/from-preset', validateCreateFromPreset, handleValidationErrors, requireRole(['admin', 'maintenance']), createFromPreset);
|
||||||
// GET specific shift plan or template
|
router.put('/:id', validateId, validateShiftPlanUpdate, handleValidationErrors, requireRole(['admin', 'maintenance']), updateShiftPlan);
|
||||||
router.get('/:id', authMiddleware, getShiftPlan);
|
router.delete('/:id', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), deleteShiftPlan);
|
||||||
|
router.post('/:id/clear-assignments', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), clearAssignments);
|
||||||
// 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);
|
|
||||||
|
|
||||||
export default router;
|
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() {
|
async function applySchemaUpdates() {
|
||||||
console.log('🔄 Applying schema updates for new employee type system...');
|
console.log('🔄 Applying schema updates for new employee type system...');
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ async function applySchemaUpdates() {
|
|||||||
PRAGMA table_info(employees)
|
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 hasEmployeeType = employeesTableInfo.some((col: TableColumnInfo) => col.name === 'employee_type');
|
||||||
const hasIsTrainee = employeesTableInfo.some((col: TableColumnInfo) => col.name === 'is_trainee');
|
const hasIsTrainee = employeesTableInfo.some((col: TableColumnInfo) => col.name === 'is_trainee');
|
||||||
|
|
||||||
|
|||||||
@@ -33,10 +33,10 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
|
|
||||||
console.log(`✅ Using schema at: ${schemaPath}`);
|
console.log(`✅ Using schema at: ${schemaPath}`);
|
||||||
const schema = fs.readFileSync(schemaPath, 'utf8');
|
const schema = fs.readFileSync(schemaPath, 'utf8');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('Starting database initialization...');
|
console.log('Starting database initialization...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingAdmin = await db.get<{ count: number }>(
|
const existingAdmin = await db.get<{ count: number }>(
|
||||||
`SELECT COUNT(*) as count
|
`SELECT COUNT(*) as count
|
||||||
@@ -44,7 +44,7 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
JOIN employee_roles er ON e.id = er.employee_id
|
JOIN employee_roles er ON e.id = er.employee_id
|
||||||
WHERE er.role = 'admin' AND e.is_active = 1`
|
WHERE er.role = 'admin' AND e.is_active = 1`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingAdmin && existingAdmin.count > 0) {
|
if (existingAdmin && existingAdmin.count > 0) {
|
||||||
console.log('✅ Database already initialized with admin user');
|
console.log('✅ Database already initialized with admin user');
|
||||||
return;
|
return;
|
||||||
@@ -52,23 +52,23 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('ℹ️ Database tables might not exist yet, creating schema...');
|
console.log('ℹ️ Database tables might not exist yet, creating schema...');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get list of existing tables
|
// Get list of existing tables
|
||||||
interface TableInfo {
|
interface TableInfo {
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingTables = await db.all<TableInfo>(
|
const existingTables = await db.all<TableInfo>(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('Existing tables found:', existingTables.map(t => t.name).join(', ') || 'none');
|
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 = [
|
const tablesToDrop = [
|
||||||
'employee_availability',
|
'employee_availability',
|
||||||
'shift_assignments',
|
'shift_assignments',
|
||||||
'scheduled_shifts',
|
'scheduled_shifts',
|
||||||
'shifts',
|
'shifts',
|
||||||
'time_slots',
|
'time_slots',
|
||||||
@@ -79,7 +79,7 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
'shift_plans',
|
'shift_plans',
|
||||||
'applied_migrations'
|
'applied_migrations'
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const table of tablesToDrop) {
|
for (const table of tablesToDrop) {
|
||||||
if (existingTables.some(t => t.name === table)) {
|
if (existingTables.some(t => t.name === table)) {
|
||||||
console.log(`Dropping table: ${table}`);
|
console.log(`Dropping table: ${table}`);
|
||||||
@@ -94,17 +94,41 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
console.error('Error checking/dropping existing tables:', error);
|
console.error('Error checking/dropping existing tables:', error);
|
||||||
// Continue with schema creation even if table dropping fails
|
// Continue with schema creation even if table dropping fails
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute schema creation in a transaction
|
// NEU: PRAGMA-Anweisungen außerhalb der Transaktion ausführen
|
||||||
await db.run('BEGIN EXCLUSIVE TRANSACTION');
|
console.log('Executing PRAGMA statements outside transaction...');
|
||||||
|
const pragmaStatements = schema
|
||||||
// Execute each statement separately for better error reporting
|
|
||||||
const statements = schema
|
|
||||||
.split(';')
|
.split(';')
|
||||||
.map(stmt => stmt.trim())
|
.map(stmt => stmt.trim())
|
||||||
.filter(stmt => stmt.length > 0)
|
.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 => {
|
.map(stmt => {
|
||||||
// Remove any single-line comments
|
|
||||||
return stmt.split('\n')
|
return stmt.split('\n')
|
||||||
.filter(line => !line.trim().startsWith('--'))
|
.filter(line => !line.trim().startsWith('--'))
|
||||||
.join('\n')
|
.join('\n')
|
||||||
@@ -112,7 +136,7 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
})
|
})
|
||||||
.filter(stmt => stmt.length > 0);
|
.filter(stmt => stmt.length > 0);
|
||||||
|
|
||||||
for (const statement of statements) {
|
for (const statement of schemaStatements) {
|
||||||
try {
|
try {
|
||||||
console.log('Executing statement:', statement.substring(0, 50) + '...');
|
console.log('Executing statement:', statement.substring(0, 50) + '...');
|
||||||
await db.run(statement);
|
await db.run(statement);
|
||||||
@@ -123,8 +147,8 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UPDATED: Insert default data in correct order
|
// Insert default data in correct order
|
||||||
try {
|
try {
|
||||||
console.log('Inserting default employee types...');
|
console.log('Inserting default employee types...');
|
||||||
await db.run(`INSERT OR IGNORE INTO employee_types (type, category, has_contract_type) VALUES ('manager', 'internal', 1)`);
|
await db.run(`INSERT OR IGNORE INTO employee_types (type, category, has_contract_type) VALUES ('manager', 'internal', 1)`);
|
||||||
@@ -132,7 +156,7 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
await db.run(`INSERT OR IGNORE INTO employee_types (type, category, has_contract_type) VALUES ('apprentice', 'internal', 1)`);
|
await db.run(`INSERT OR IGNORE INTO employee_types (type, category, has_contract_type) VALUES ('apprentice', 'internal', 1)`);
|
||||||
await db.run(`INSERT OR IGNORE INTO employee_types (type, category, has_contract_type) VALUES ('guest', 'external', 0)`);
|
await db.run(`INSERT OR IGNORE INTO employee_types (type, category, has_contract_type) VALUES ('guest', 'external', 0)`);
|
||||||
console.log('✅ Default employee types inserted');
|
console.log('✅ Default employee types inserted');
|
||||||
|
|
||||||
console.log('Inserting default roles...');
|
console.log('Inserting default roles...');
|
||||||
await db.run(`INSERT OR IGNORE INTO roles (role, authority_level, description) VALUES ('admin', 100, 'Vollzugriff')`);
|
await db.run(`INSERT OR IGNORE INTO roles (role, authority_level, description) VALUES ('admin', 100, 'Vollzugriff')`);
|
||||||
await db.run(`INSERT OR IGNORE INTO roles (role, authority_level, description) VALUES ('maintenance', 50, 'Wartungszugriff')`);
|
await db.run(`INSERT OR IGNORE INTO roles (role, authority_level, description) VALUES ('maintenance', 50, 'Wartungszugriff')`);
|
||||||
@@ -143,13 +167,13 @@ export async function initializeDatabase(): Promise<void> {
|
|||||||
await db.run('ROLLBACK');
|
await db.run('ROLLBACK');
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.run('COMMIT');
|
await db.run('COMMIT');
|
||||||
console.log('✅ Database schema successfully initialized');
|
console.log('✅ Database schema successfully initialized');
|
||||||
|
|
||||||
// Give a small delay to ensure all transactions are properly closed
|
// Give a small delay to ensure all transactions are properly closed
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error during database initialization:', error);
|
console.error('Error during database initialization:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import path from 'path';
|
|
||||||
|
|
||||||
export function runPythonScript(scriptPath, args = []) {
|
export function runPythonScript(scriptPath, args = []) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import path from 'path';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { initializeDatabase } from './scripts/initializeDatabase.js';
|
import { initializeDatabase } from './scripts/initializeDatabase.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import type { ViteDevServer } from 'vite';
|
||||||
|
|
||||||
// Route imports
|
// Route imports
|
||||||
import authRoutes from './routes/auth.js';
|
import authRoutes from './routes/auth.js';
|
||||||
@@ -12,105 +14,313 @@ import shiftPlanRoutes from './routes/shiftPlans.js';
|
|||||||
import setupRoutes from './routes/setup.js';
|
import setupRoutes from './routes/setup.js';
|
||||||
import scheduledShifts from './routes/scheduledShifts.js';
|
import scheduledShifts from './routes/scheduledShifts.js';
|
||||||
import schedulingRoutes from './routes/scheduling.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 __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = 3002;
|
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
|
// Middleware
|
||||||
app.use(express.json());
|
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
|
// API Routes
|
||||||
app.use('/api/setup', setupRoutes);
|
app.use('/api/setup', setupRoutes);
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authLimiter, authRoutes);
|
||||||
app.use('/api/employees', employeeRoutes);
|
app.use('/api/employees', employeeRoutes);
|
||||||
app.use('/api/shift-plans', shiftPlanRoutes);
|
app.use('/api/shift-plans', shiftPlanRoutes);
|
||||||
app.use('/api/scheduled-shifts', scheduledShifts);
|
app.use('/api/scheduled-shifts', scheduledShifts);
|
||||||
app.use('/api/scheduling', schedulingRoutes);
|
app.use('/api/scheduling', expensiveEndpointLimiter, schedulingRoutes);
|
||||||
|
|
||||||
// Health route
|
// Health route
|
||||||
app.get('/api/health', (req: any, res: any) => {
|
app.get('/api/health', (req: express.Request, res: express.Response) => {
|
||||||
res.json({
|
res.json({
|
||||||
status: 'OK',
|
status: 'OK',
|
||||||
message: 'Backend läuft!',
|
message: 'Backend läuft!',
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString(),
|
||||||
|
mode: process.env.NODE_ENV || 'development'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🆕 STATIC FILE SERVING FÜR FRONTEND
|
// 🆕 IMPROVED STATIC FILE SERVING
|
||||||
const frontendBuildPath = path.join(__dirname, '../frontend-build');
|
const findFrontendBuildPath = (): string | null => {
|
||||||
console.log('📁 Frontend build path:', frontendBuildPath);
|
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
|
for (const testPath of possiblePaths) {
|
||||||
if (fs.existsSync(frontendBuildPath)) {
|
try {
|
||||||
console.log('✅ Frontend build directory exists');
|
if (fs.existsSync(testPath)) {
|
||||||
const files = fs.readdirSync(frontendBuildPath);
|
const indexPath = path.join(testPath, 'index.html');
|
||||||
console.log('📄 Files in frontend-build:', files);
|
if (fs.existsSync(indexPath)) {
|
||||||
|
console.log('✅ Found frontend build at:', testPath);
|
||||||
// Serviere statische Dateien
|
return testPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Silent catch - just try next path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const frontendBuildPath = findFrontendBuildPath();
|
||||||
|
configureStaticFiles();
|
||||||
|
|
||||||
|
if (frontendBuildPath) {
|
||||||
app.use(express.static(frontendBuildPath));
|
app.use(express.static(frontendBuildPath));
|
||||||
|
|
||||||
console.log('✅ Static file serving configured');
|
console.log('✅ Static file serving configured');
|
||||||
} else {
|
} else {
|
||||||
console.log('❌ Frontend build directory NOT FOUND:', frontendBuildPath);
|
console.log(isDevelopment ?
|
||||||
|
'🔧 Development: Frontend served by Vite dev server (localhost:3003)' :
|
||||||
|
'❌ Production: No frontend build found'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.get('/', (req, res) => {
|
// Root route
|
||||||
const indexPath = path.join(frontendBuildPath, 'index.html');
|
app.get('/', async (req, res) => {
|
||||||
console.log('📄 Serving index.html from:', indexPath);
|
// In development with Vite middleware
|
||||||
|
if (vite) {
|
||||||
if (fs.existsSync(indexPath)) {
|
try {
|
||||||
res.sendFile(indexPath);
|
const template = fs.readFileSync(
|
||||||
} else {
|
path.resolve(__dirname, '../../frontend/index.html'),
|
||||||
console.error('❌ index.html not found at:', indexPath);
|
'utf-8'
|
||||||
res.status(404).send('Frontend not found - index.html missing');
|
);
|
||||||
|
const html = await vite.transformIndexHtml(req.url, template);
|
||||||
|
res.send(html);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).send('Vite dev server error');
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback to static file serving
|
||||||
|
if (!frontendBuildPath) {
|
||||||
|
return res.status(500).send('Frontend not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexPath = path.join(frontendBuildPath, 'index.html');
|
||||||
|
res.sendFile(indexPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('*', (req, res) => {
|
// Client-side routing fallback
|
||||||
// Ignoriere API Routes
|
app.get('*', (req, res, next) => {
|
||||||
|
// Skip API routes
|
||||||
if (req.path.startsWith('/api/')) {
|
if (req.path.startsWith('/api/')) {
|
||||||
return res.status(404).json({ error: 'API endpoint not found' });
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const indexPath = path.join(frontendBuildPath, 'index.html');
|
// Skip file extensions (assets)
|
||||||
console.log('🔄 Client-side routing for:', req.path, '-> index.html');
|
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)) {
|
if (fs.existsSync(indexPath)) {
|
||||||
res.sendFile(indexPath);
|
res.sendFile(indexPath);
|
||||||
} else {
|
} else {
|
||||||
console.error('❌ index.html not found for client-side routing');
|
res.status(404).send('Frontend not available');
|
||||||
res.status(404).json({ error: 'Frontend application not found' });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Error handling middleware
|
// Error handling
|
||||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
console.error('Unhandled error:', err);
|
console.error('Error:', err);
|
||||||
res.status(500).json({ error: 'Internal server error' });
|
|
||||||
|
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
|
// Initialize the application
|
||||||
const initializeApp = async () => {
|
const initializeApp = async () => {
|
||||||
try {
|
try {
|
||||||
// Initialize database with base schema
|
|
||||||
await initializeDatabase();
|
await initializeDatabase();
|
||||||
|
|
||||||
// Apply any pending migrations
|
|
||||||
const { applyMigration } = await import('./scripts/applyMigration.js');
|
const { applyMigration } = await import('./scripts/applyMigration.js');
|
||||||
await applyMigration();
|
await applyMigration();
|
||||||
|
|
||||||
// Start server only after successful initialization
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log('🎉 APPLICATION STARTED SUCCESSFULLY!');
|
console.log('🎉 APPLICATION STARTED SUCCESSFULLY!');
|
||||||
console.log(`📍 Port: ${PORT}`);
|
console.log(`📍 Port: ${PORT}`);
|
||||||
console.log(`📍 Frontend: http://localhost:${PORT}`);
|
console.log(`📍 Mode: ${process.env.NODE_ENV || 'development'}`);
|
||||||
|
if (frontendBuildPath) {
|
||||||
|
console.log(`📍 Frontend: http://localhost:${PORT}`);
|
||||||
|
} else if (isDevelopment) {
|
||||||
|
console.log(`📍 Frontend (Vite): http://localhost:3003`);
|
||||||
|
}
|
||||||
console.log(`📍 API: http://localhost:${PORT}/api`);
|
console.log(`📍 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) {
|
} catch (error) {
|
||||||
console.error('❌ Error during initialization:', error);
|
console.error('❌ Error during initialization:', error);
|
||||||
@@ -118,5 +328,4 @@ const initializeApp = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start the application
|
|
||||||
initializeApp();
|
initializeApp();
|
||||||
@@ -2,8 +2,7 @@
|
|||||||
import { Worker } from 'worker_threads';
|
import { Worker } from 'worker_threads';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { Employee, EmployeeAvailability } from '../models/Employee.js';
|
import { ShiftPlan } from '../models/ShiftPlan.js';
|
||||||
import { ShiftPlan, ScheduledShift } from '../models/ShiftPlan.js';
|
|
||||||
import { ScheduleRequest, ScheduleResult, Availability, Constraint } from '../models/scheduling.js';
|
import { ScheduleRequest, ScheduleResult, Availability, Constraint } from '../models/scheduling.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
import { parentPort, workerData } from 'worker_threads';
|
import { parentPort, workerData } from 'worker_threads';
|
||||||
import { CPModel, CPSolver } from './cp-sat-wrapper.js';
|
import { CPModel, CPSolver } from './cp-sat-wrapper.js';
|
||||||
import { ShiftPlan, Shift } from '../models/ShiftPlan.js';
|
import { ShiftPlan, Shift } from '../models/ShiftPlan.js';
|
||||||
import { Employee, EmployeeAvailability } from '../models/Employee.js';
|
import { Employee } from '../models/Employee.js';
|
||||||
import { Availability, Constraint, Violation, SolverOptions, Solution, Assignment } from '../models/scheduling.js';
|
import { Availability, Constraint } from '../models/scheduling.js';
|
||||||
|
|
||||||
interface WorkerData {
|
interface WorkerData {
|
||||||
shiftPlan: ShiftPlan;
|
shiftPlan: ShiftPlan;
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
// backend/tsconfig.json
|
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"allowJs": true,
|
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src",
|
"rootDir": "./src",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": [
|
||||||
"exclude": ["node_modules", "dist"]
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules",
|
||||||
|
"dist"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@@ -1,26 +1,27 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
schichtplan:
|
schichtplaner:
|
||||||
build:
|
container_name: schichtplaner
|
||||||
context: .
|
image: ghcr.io/donpat1to/schichtenplaner:v1.0.0
|
||||||
dockerfile: backend/Dockerfile
|
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
||||||
- "3000:3000"
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- DATABASE_URL=file:./prod.db
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- JWT_SECRET=your-production-secret-key-change-this
|
- TRUST_PROXY_ENABLED=true
|
||||||
- PYTHON_PATH=/usr/bin/python3
|
- 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:
|
volumes:
|
||||||
- app_data:/app/data
|
- app_data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:3001/health"]
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3002/api/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
expose:
|
||||||
|
- "3002"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
app_data:
|
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
|
// ecosystem.config.cjs
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [{
|
||||||
{
|
name: 'schichtplan-app',
|
||||||
name: 'schichtplaner',
|
script: './dist/server.js',
|
||||||
script: './dist/server.js',
|
instances: 1,
|
||||||
instances: 1,
|
env: {
|
||||||
exec_mode: 'fork',
|
NODE_ENV: 'production',
|
||||||
env: {
|
PORT: 3002,
|
||||||
NODE_ENV: 'production',
|
FRONTEND_BUILD_PATH: './frontend-build'
|
||||||
PORT: 3002
|
},
|
||||||
},
|
error_file: './logs/err.log',
|
||||||
error_file: './logs/app-err.log',
|
out_file: './logs/out.log',
|
||||||
out_file: './logs/app-out.log',
|
log_file: './logs/combined.log',
|
||||||
time: true
|
time: true
|
||||||
}
|
}]
|
||||||
]
|
|
||||||
};
|
};
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# frontend/.env.development
|
|
||||||
BROWSER=none
|
|
||||||
FAST_REFRESH=true
|
|
||||||
DANGEROUSLY_DISABLE_HOST_CHECK=true
|
|
||||||
178
frontend/donpat1to.svg
Normal file
178
frontend/donpat1to.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 102 KiB |
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="/donpat1to.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",
|
"name": "frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"proxy": "http://localhost:3002",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@testing-library/dom": "^10.4.1",
|
"react": "^19.0.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"react-dom": "^19.0.0",
|
||||||
"@testing-library/react": "^16.3.0",
|
"react-router-dom": "^6.28.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"date-fns": "4.1.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"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"http-proxy-middleware": "^3.0.5",
|
"@types/node": "20.19.23",
|
||||||
"react-scripts": "^5.0.1"
|
"@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",
|
"short_name": "SP",
|
||||||
"name": "Create React App Sample",
|
"name": "schichtenplaner",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"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 React from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||||
@@ -15,6 +15,40 @@ import EmployeeManagement from './pages/Employees/EmployeeManagement';
|
|||||||
import Settings from './pages/Settings/Settings';
|
import Settings from './pages/Settings/Settings';
|
||||||
import Help from './pages/Help/Help';
|
import Help from './pages/Help/Help';
|
||||||
import Setup from './pages/Setup/Setup';
|
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
|
// Protected Route Component
|
||||||
const ProtectedRoute: React.FC<{ children: React.ReactNode; roles?: string[] }> = ({
|
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>;
|
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
|
// Main App Content
|
||||||
const AppContent: React.FC = () => {
|
const AppContent: React.FC = () => {
|
||||||
const { loading, needsSetup, user } = useAuth();
|
const { loading, needsSetup, user } = useAuth();
|
||||||
|
|
||||||
console.log('🏠 AppContent rendering - loading:', loading, 'needsSetup:', needsSetup, 'user:', user);
|
console.log('🏠 AppContent rendering - loading:', loading, 'needsSetup:', needsSetup, 'user:', user);
|
||||||
|
console.log('🎯 Premium features enabled:', ENABLE_PRO);
|
||||||
|
|
||||||
// Während des Ladens
|
// Während des Ladens
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -80,66 +130,49 @@ const AppContent: React.FC = () => {
|
|||||||
console.log('✅ Showing protected routes for user:', user.email);
|
console.log('✅ Showing protected routes for user:', user.email);
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={
|
{/* Protected Routes (require login) */}
|
||||||
<ProtectedRoute>
|
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||||
<Dashboard />
|
<Route path="/shift-plans" element={<ProtectedRoute><ShiftPlanList /></ProtectedRoute>} />
|
||||||
</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" element={
|
<Route path="/shift-plans/:id" element={<ProtectedRoute><ShiftPlanView /></ProtectedRoute>} />
|
||||||
<ProtectedRoute>
|
<Route path="/employees" element={<ProtectedRoute roles={['admin', 'maintenance']}><EmployeeManagement /></ProtectedRoute>} />
|
||||||
<ShiftPlanList />
|
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||||
</ProtectedRoute>
|
<Route path="/help" element={<ProtectedRoute><Help /></ProtectedRoute>} />
|
||||||
} />
|
|
||||||
<Route path="/shift-plans/new" element={
|
{/* Public Footer Link Pages (always available) */}
|
||||||
<ProtectedRoute roles={['admin', 'maintenance']}>
|
<Route path="/faq" element={<PublicRoute><FAQ /></PublicRoute>} />
|
||||||
<ShiftPlanCreate />
|
<Route path="/about" element={<PublicRoute><About /></PublicRoute>} />
|
||||||
</ProtectedRoute>
|
<Route path="/features" element={<PublicRoute><Features /></PublicRoute>} />
|
||||||
} />
|
|
||||||
<Route path="/shift-plans/:id/edit" element={
|
{/* PREMIUM Footer Link Pages (conditionally available) */}
|
||||||
<ProtectedRoute roles={['admin', 'maintenance']}>
|
<Route path="/contact" element={<PublicRoute><PremiumContact /></PublicRoute>} />
|
||||||
<ShiftPlanEdit />
|
<Route path="/privacy" element={<PublicRoute><PremiumPrivacy /></PublicRoute>} />
|
||||||
</ProtectedRoute>
|
<Route path="/imprint" element={<PublicRoute><PremiumImprint /></PublicRoute>} />
|
||||||
} />
|
<Route path="/terms" element={<PublicRoute><PremiumTerms /></PublicRoute>} />
|
||||||
<Route path="/shift-plans/:id" element={
|
|
||||||
<ProtectedRoute>
|
{/* Auth Routes */}
|
||||||
<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>
|
|
||||||
} />
|
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="*" element={
|
|
||||||
<ProtectedRoute>
|
{/* Catch-all Route */}
|
||||||
<Dashboard />
|
<Route path="*" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||||
</ProtectedRoute>
|
|
||||||
} />
|
|
||||||
</Routes>
|
</Routes>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<NotificationProvider>
|
<ErrorBoundary>
|
||||||
<AuthProvider>
|
<NotificationProvider>
|
||||||
<Router>
|
<AuthProvider>
|
||||||
<NotificationContainer />
|
<Router>
|
||||||
<AppContent />
|
<SecurityWarning />
|
||||||
</Router>
|
<NotificationContainer />
|
||||||
</AuthProvider>
|
<AppContent />
|
||||||
</NotificationProvider>
|
</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';
|
import React from 'react';
|
||||||
|
|
||||||
const Footer: React.FC = () => {
|
const Footer: React.FC = () => {
|
||||||
@@ -10,12 +10,12 @@ const Footer: React.FC = () => {
|
|||||||
borderTop: '1px solid rgba(251, 250, 246, 0.1)',
|
borderTop: '1px solid rgba(251, 250, 246, 0.1)',
|
||||||
},
|
},
|
||||||
footerContent: {
|
footerContent: {
|
||||||
maxWidth: '1200px',
|
maxWidth: '1500px',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
padding: '3rem 2rem 2rem',
|
padding: '3rem 2rem 2rem',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
|
||||||
gap: '3rem',
|
gap: '1rem',
|
||||||
},
|
},
|
||||||
footerSection: {
|
footerSection: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -182,20 +182,6 @@ const Footer: React.FC = () => {
|
|||||||
>
|
>
|
||||||
Funktionen
|
Funktionen
|
||||||
</a>
|
</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>
|
||||||
</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 React from 'react';
|
||||||
import Navigation from './Navigation';
|
import Navigation from './Navigation';
|
||||||
import Footer from './Footer';
|
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 React, { useState, useEffect } from 'react';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import PillNav from '../PillNav/PillNav';
|
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';
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
export interface PillNavItem {
|
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 AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || '/api';
|
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
interface AuthProviderProps {
|
interface AuthProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -49,12 +49,21 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
const checkSetupStatus = async (): Promise<void> => {
|
const checkSetupStatus = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
console.log('🔍 Checking setup status...');
|
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) {
|
if (!response.ok) {
|
||||||
|
console.error('❌ Setup status response not OK:', response.status, response.statusText);
|
||||||
throw new Error('Setup status check failed');
|
throw new Error('Setup status check failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('✅ Setup status response:', data);
|
console.log('✅ Setup status response data:', data);
|
||||||
setNeedsSetup(data.needsSetup === true);
|
setNeedsSetup(data.needsSetup === true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error checking setup status:', error);
|
console.error('❌ Error checking setup status:', error);
|
||||||
@@ -66,7 +75,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
try {
|
try {
|
||||||
const token = getStoredToken();
|
const token = getStoredToken();
|
||||||
console.log('🔄 Refreshing user, token exists:', !!token);
|
console.log('🔄 Refreshing user, token exists:', !!token);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
console.log('ℹ️ No token found, user not logged in');
|
console.log('ℹ️ No token found, user not logged in');
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@@ -95,7 +104,6 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add the updateUser function
|
|
||||||
const updateUser = (userData: Employee) => {
|
const updateUser = (userData: Employee) => {
|
||||||
console.log('🔄 Updating user in auth context:', userData);
|
console.log('🔄 Updating user in auth context:', userData);
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
@@ -104,7 +112,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
const login = async (credentials: LoginRequest): Promise<void> => {
|
const login = async (credentials: LoginRequest): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
console.log('🔐 Attempting login for:', credentials.email);
|
console.log('🔐 Attempting login for:', credentials.email);
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -120,7 +128,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('✅ Login successful, storing token');
|
console.log('✅ Login successful, storing token');
|
||||||
|
|
||||||
setStoredToken(data.token);
|
setStoredToken(data.token);
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -137,13 +145,13 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
|
|
||||||
const hasRole = (roles: string[]): boolean => {
|
const hasRole = (roles: string[]): boolean => {
|
||||||
if (!user || !user.roles || user.roles.length === 0) return false;
|
if (!user || !user.roles || user.roles.length === 0) return false;
|
||||||
|
|
||||||
// Check if user has at least one of the required roles
|
// Check if user has at least one of the required roles
|
||||||
return roles.some(requiredRole =>
|
return roles.some(requiredRole =>
|
||||||
user.roles!.includes(requiredRole)
|
user.roles!.includes(requiredRole)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initializeAuth = async () => {
|
const initializeAuth = async () => {
|
||||||
console.log('🚀 Initializing authentication...');
|
console.log('🚀 Initializing authentication...');
|
||||||
@@ -161,6 +169,8 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
initializeAuth();
|
initializeAuth();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const calculatedNeedsSetup = needsSetup === null ? true : needsSetup;
|
||||||
|
|
||||||
const value: AuthContextType = {
|
const value: AuthContextType = {
|
||||||
user,
|
user,
|
||||||
login,
|
login,
|
||||||
@@ -168,7 +178,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
|||||||
hasRole,
|
hasRole,
|
||||||
loading,
|
loading,
|
||||||
refreshUser,
|
refreshUser,
|
||||||
needsSetup: needsSetup === null ? true : needsSetup,
|
needsSetup: calculatedNeedsSetup,
|
||||||
checkSetupStatus,
|
checkSetupStatus,
|
||||||
updateUser,
|
updateUser,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// frontend/src/design/DesignSystem.tsx
|
// frontend/src/design/DesignSystem.txt
|
||||||
export const designTokens = {
|
export const designTokens = {
|
||||||
colors: {
|
colors: {
|
||||||
// Primary 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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
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;
|
} as const;
|
||||||
|
|
||||||
// Default availability for new employees (all shifts unavailable as level 3)
|
// 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'>[] {
|
export function createDefaultAvailabilities(employeeId: string, planId: string, shiftIds: string[]): Omit<EmployeeAvailability, 'id'>[] {
|
||||||
const availabilities: Omit<EmployeeAvailability, 'id'>[] = [];
|
const availabilities: Omit<EmployeeAvailability, 'id'>[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -14,16 +14,16 @@ function generateEmail(firstname: string, lastname: string): string {
|
|||||||
|
|
||||||
const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, '');
|
const cleanFirstname = convertUmlauts(firstname).replace(/[^a-z0-9]/g, '');
|
||||||
const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
|
const cleanLastname = convertUmlauts(lastname).replace(/[^a-z0-9]/g, '');
|
||||||
|
|
||||||
return `${cleanFirstname}.${cleanLastname}@sp.de`;
|
return `${cleanFirstname}.${cleanLastname}@sp.de`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// UPDATED: Validation for new employee model with employee types
|
// Validation for new employee model with employee types
|
||||||
export function validateEmployeeData(employee: CreateEmployeeRequest): string[] {
|
export function validateEmployeeData(employee: CreateEmployeeRequest): string[] {
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
if (employee.password?.length < 6) {
|
if (employee.password?.length < 8) {
|
||||||
errors.push('Password must be at least 6 characters long');
|
errors.push('Password must be at least 8 characters long');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!employee.firstname?.trim() || employee.firstname.trim().length < 2) {
|
if (!employee.firstname?.trim() || employee.firstname.trim().length < 2) {
|
||||||
@@ -71,17 +71,17 @@ export function generateEmployeeEmail(firstname: string, lastname: string): stri
|
|||||||
return generateEmail(firstname, lastname);
|
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 =>
|
export const isManager = (employee: Employee): boolean =>
|
||||||
employee.employeeType === 'manager';
|
employee.employeeType === 'manager';
|
||||||
|
|
||||||
export const isPersonell = (employee: Employee): boolean =>
|
export const isPersonell = (employee: Employee): boolean =>
|
||||||
employee.employeeType === 'personell';
|
employee.employeeType === 'personell';
|
||||||
|
|
||||||
export const isApprentice = (employee: Employee): boolean =>
|
export const isApprentice = (employee: Employee): boolean =>
|
||||||
employee.employeeType === 'apprentice';
|
employee.employeeType === 'apprentice';
|
||||||
|
|
||||||
export const isGuest = (employee: Employee): boolean =>
|
export const isGuest = (employee: Employee): boolean =>
|
||||||
employee.employeeType === 'guest';
|
employee.employeeType === 'guest';
|
||||||
|
|
||||||
export const isInternal = (employee: Employee): boolean =>
|
export const isInternal = (employee: Employee): boolean =>
|
||||||
@@ -90,25 +90,25 @@ export const isInternal = (employee: Employee): boolean =>
|
|||||||
export const isExternal = (employee: Employee): boolean =>
|
export const isExternal = (employee: Employee): boolean =>
|
||||||
employee.employeeType === 'guest';
|
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 =>
|
export const isTrainee = (employee: Employee): boolean =>
|
||||||
employee.employeeType === 'personell' && employee.isTrainee;
|
employee.employeeType === 'personell' && employee.isTrainee;
|
||||||
|
|
||||||
export const isExperienced = (employee: Employee): boolean =>
|
export const isExperienced = (employee: Employee): boolean =>
|
||||||
employee.employeeType === 'personell' && !employee.isTrainee;
|
employee.employeeType === 'personell' && !employee.isTrainee;
|
||||||
|
|
||||||
// Role-based helpers
|
// Role-based helpers
|
||||||
export const isAdmin = (employee: Employee): boolean =>
|
export const isAdmin = (employee: Employee): boolean =>
|
||||||
employee.roles?.includes('admin') || false;
|
employee.roles?.includes('admin') || false;
|
||||||
|
|
||||||
export const isMaintenance = (employee: Employee): boolean =>
|
export const isMaintenance = (employee: Employee): boolean =>
|
||||||
employee.roles?.includes('maintenance') || false;
|
employee.roles?.includes('maintenance') || false;
|
||||||
|
|
||||||
export const isUser = (employee: Employee): boolean =>
|
export const isUser = (employee: Employee): boolean =>
|
||||||
employee.roles?.includes('user') || false;
|
employee.roles?.includes('user') || false;
|
||||||
|
|
||||||
// UPDATED: Work alone permission - managers and experienced personell can work alone
|
// Work alone permission - managers and experienced personell can work alone
|
||||||
export const canEmployeeWorkAlone = (employee: Employee): boolean =>
|
export const canEmployeeWorkAlone = (employee: Employee): boolean =>
|
||||||
employee.canWorkAlone && (isManager(employee) || isExperienced(employee));
|
employee.canWorkAlone && (isManager(employee) || isExperienced(employee));
|
||||||
|
|
||||||
// Helper for full name display
|
// Helper for full name display
|
||||||
@@ -134,7 +134,7 @@ export function validateAvailabilityData(availability: Omit<EmployeeAvailability
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
// UPDATED: Helper to get employee type category
|
// Helper to get employee type category
|
||||||
export const getEmployeeCategory = (employee: Employee): 'internal' | 'external' => {
|
export const getEmployeeCategory = (employee: Employee): 'internal' | 'external' => {
|
||||||
return isInternal(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);
|
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(
|
export function getScheduledShiftByDateAndTime(
|
||||||
plan: ShiftPlan,
|
plan: ShiftPlan,
|
||||||
date: string,
|
date: string,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { Employee } from './Employee.js';
|
import { Employee } from './Employee.js';
|
||||||
import { ShiftPlan } from './ShiftPlan.js';
|
import { ShiftPlan } from './ShiftPlan.js';
|
||||||
|
|
||||||
// Updated Availability interface to match new schema
|
// Availability interface to match
|
||||||
export interface Availability {
|
export interface Availability {
|
||||||
id: string;
|
id: string;
|
||||||
employeeId: 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 React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
@@ -129,7 +129,7 @@ const Login: React.FC = () => {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
style={{
|
style={{
|
||||||
width: '94.5%',
|
width: '100%',
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
border: '1px solid #ddd',
|
border: '1px solid #ddd',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
@@ -151,7 +151,7 @@ const Login: React.FC = () => {
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
style={{
|
style={{
|
||||||
width: '94.5%',
|
width: '100%',
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
paddingRight: '10px',
|
paddingRight: '10px',
|
||||||
border: '1px solid #ddd',
|
border: '1px solid #ddd',
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { employeeService } from '../../../services/employeeService';
|
|||||||
import { shiftPlanService } from '../../../services/shiftPlanService';
|
import { shiftPlanService } from '../../../services/shiftPlanService';
|
||||||
import { Employee, EmployeeAvailability } from '../../../models/Employee';
|
import { Employee, EmployeeAvailability } from '../../../models/Employee';
|
||||||
import { ShiftPlan, TimeSlot, Shift } from '../../../models/ShiftPlan';
|
import { ShiftPlan, TimeSlot, Shift } from '../../../models/ShiftPlan';
|
||||||
|
import { useNotification } from '../../../contexts/NotificationContext';
|
||||||
|
import { useBackendValidation } from '../../../hooks/useBackendValidation';
|
||||||
|
|
||||||
interface AvailabilityManagerProps {
|
interface AvailabilityManagerProps {
|
||||||
employee: Employee;
|
employee: Employee;
|
||||||
@@ -36,7 +38,8 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
const [selectedPlan, setSelectedPlan] = useState<ShiftPlan | null>(null);
|
const [selectedPlan, setSelectedPlan] = useState<ShiftPlan | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const { showNotification } = useNotification();
|
||||||
|
const { executeWithValidation, isSubmitting } = useBackendValidation();
|
||||||
|
|
||||||
const daysOfWeek = [
|
const daysOfWeek = [
|
||||||
{ id: 1, name: 'Montag' },
|
{ id: 1, name: 'Montag' },
|
||||||
@@ -81,7 +84,11 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('❌ FEHLER BEIM LADEN DER INITIALDATEN:', err);
|
console.error('❌ FEHLER BEIM LADEN DER INITIALDATEN:', err);
|
||||||
setError('Daten konnten nicht geladen werden: ' + (err.message || 'Unbekannter Fehler'));
|
showNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Fehler beim Laden',
|
||||||
|
message: 'Daten konnten nicht geladen werden: ' + (err.message || 'Unbekannter Fehler')
|
||||||
|
});
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -134,9 +141,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
);
|
);
|
||||||
if (invalidAvailabilities.length > 0) {
|
if (invalidAvailabilities.length > 0) {
|
||||||
console.warn('⚠️ UNGÜLTIGE VERFÜGBARKEITEN (OHNE SHIFT-ID):', invalidAvailabilities.length);
|
console.warn('⚠️ UNGÜLTIGE VERFÜGBARKEITEN (OHNE SHIFT-ID):', invalidAvailabilities.length);
|
||||||
invalidAvailabilities.forEach(invalid => {
|
|
||||||
console.warn(' - Ungültiger Eintrag:', invalid);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transformiere die Daten
|
// Transformiere die Daten
|
||||||
@@ -149,20 +153,20 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
|
|
||||||
// Debug: Zeige vorhandene Präferenzen
|
// Debug: Zeige vorhandene Präferenzen
|
||||||
if (planAvailabilities.length > 0) {
|
if (planAvailabilities.length > 0) {
|
||||||
console.log('🎯 VORHANDENE PRÄFERENZEN:');
|
console.log('🎯 VORHANDENE PRÄFERENZEN:', planAvailabilities.length);
|
||||||
planAvailabilities.forEach(avail => {
|
|
||||||
const shift = plan.shifts?.find(s => s.id === avail.shiftId);
|
|
||||||
console.log(` - Shift: ${avail.shiftId} (Day: ${shift?.dayOfWeek}), Level: ${avail.preferenceLevel}`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (availError) {
|
} catch (availError) {
|
||||||
console.error('❌ FEHLER BEIM LADEN DER VERFÜGBARKEITEN:', availError);
|
console.error('❌ FEHLER BEIM LADEN DER VERFÜGBARKEITEN:', availError);
|
||||||
setAvailabilities([]);
|
setAvailabilities([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('❌ FEHLER BEIM LADEN DES SCHICHTPLANS:', err);
|
console.error('❌ FEHLER BEIM LADEN DES SCHICHTPLANS:', err);
|
||||||
setError('Schichtplan konnte nicht geladen werden: ' + (err.message || 'Unbekannter Fehler'));
|
showNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Fehler beim Laden',
|
||||||
|
message: 'Schichtplan konnte nicht geladen werden: ' + (err.message || 'Unbekannter Fehler')
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -316,26 +320,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
return (a.startTime || '').localeCompare(b.startTime || '');
|
return (a.startTime || '').localeCompare(b.startTime || '');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validation: Check if shifts are correctly placed
|
|
||||||
const validationErrors: string[] = [];
|
|
||||||
|
|
||||||
// Check for missing time slots
|
|
||||||
const usedTimeSlotIds = new Set(selectedPlan?.shifts?.map(s => s.timeSlotId) || []);
|
|
||||||
const availableTimeSlotIds = new Set(selectedPlan?.timeSlots?.map(ts => ts.id) || []);
|
|
||||||
|
|
||||||
usedTimeSlotIds.forEach(timeSlotId => {
|
|
||||||
if (!availableTimeSlotIds.has(timeSlotId)) {
|
|
||||||
validationErrors.push(`Zeitslot ${timeSlotId} wird verwendet, existiert aber nicht in timeSlots`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check for shifts with invalid day numbers
|
|
||||||
selectedPlan?.shifts?.forEach(shift => {
|
|
||||||
if (shift.dayOfWeek < 1 || shift.dayOfWeek > 7) {
|
|
||||||
validationErrors.push(`Shift ${shift.id} hat ungültigen Wochentag: ${shift.dayOfWeek}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
marginBottom: '30px',
|
marginBottom: '30px',
|
||||||
@@ -355,23 +339,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Validation Warnings */}
|
|
||||||
{validationErrors.length > 0 && (
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: '#fff3cd',
|
|
||||||
border: '1px solid #ffeaa7',
|
|
||||||
padding: '15px',
|
|
||||||
margin: '10px'
|
|
||||||
}}>
|
|
||||||
<h4 style={{ margin: '0 0 10px 0', color: '#856404' }}>⚠️ Validierungswarnungen:</h4>
|
|
||||||
<ul style={{ margin: 0, paddingLeft: '20px', fontSize: '12px' }}>
|
|
||||||
{validationErrors.map((error, index) => (
|
|
||||||
<li key={index}>{error}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ overflowX: 'auto' }}>
|
<div style={{ overflowX: 'auto' }}>
|
||||||
<table style={{
|
<table style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -421,9 +388,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||||
{formatTime(timeSlot.startTime)} - {formatTime(timeSlot.endTime)}
|
{formatTime(timeSlot.startTime)} - {formatTime(timeSlot.endTime)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: '11px', color: '#999', marginTop: '4px' }}>
|
|
||||||
ID: {timeSlot.id.substring(0, 8)}...
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
{days.map(weekday => {
|
{days.map(weekday => {
|
||||||
const shift = timeSlot.shiftsByDay[weekday.id];
|
const shift = timeSlot.shiftsByDay[weekday.id];
|
||||||
@@ -443,9 +407,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation: Check if shift has correct timeSlotId and dayOfWeek
|
|
||||||
const isValidShift = shift.timeSlotId === timeSlot.id && shift.dayOfWeek === weekday.id;
|
|
||||||
|
|
||||||
const currentLevel = getAvailabilityForShift(shift.id);
|
const currentLevel = getAvailabilityForShift(shift.id);
|
||||||
const levelConfig = availabilityLevels.find(l => l.level === currentLevel);
|
const levelConfig = availabilityLevels.find(l => l.level === currentLevel);
|
||||||
|
|
||||||
@@ -454,31 +415,8 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
padding: '12px 16px',
|
padding: '12px 16px',
|
||||||
border: '1px solid #dee2e6',
|
border: '1px solid #dee2e6',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
backgroundColor: !isValidShift ? '#fff3cd' : (levelConfig?.bgColor || 'white'),
|
backgroundColor: levelConfig?.bgColor || 'white'
|
||||||
position: 'relative'
|
|
||||||
}}>
|
}}>
|
||||||
{/* Validation indicator */}
|
|
||||||
{!isValidShift && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '2px',
|
|
||||||
right: '2px',
|
|
||||||
backgroundColor: '#f39c12',
|
|
||||||
color: 'white',
|
|
||||||
borderRadius: '50%',
|
|
||||||
width: '16px',
|
|
||||||
height: '16px',
|
|
||||||
fontSize: '10px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
title={`Shift Validierung: timeSlotId=${shift.timeSlotId}, dayOfWeek=${shift.dayOfWeek}`}
|
|
||||||
>
|
|
||||||
⚠️
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={currentLevel}
|
value={currentLevel}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -487,10 +425,10 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
border: `2px solid ${!isValidShift ? '#f39c12' : (levelConfig?.color || '#ddd')}`,
|
border: `2px solid ${levelConfig?.color || '#ddd'}`,
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
backgroundColor: !isValidShift ? '#fff3cd' : (levelConfig?.bgColor || 'white'),
|
backgroundColor: levelConfig?.bgColor || 'white',
|
||||||
color: !isValidShift ? '#856404' : (levelConfig?.color || '#333'),
|
color: levelConfig?.color || '#333',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
minWidth: '140px',
|
minWidth: '140px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -511,23 +449,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
{/* Shift debug info */}
|
|
||||||
<div style={{
|
|
||||||
fontSize: '10px',
|
|
||||||
color: '#666',
|
|
||||||
marginTop: '4px',
|
|
||||||
textAlign: 'left',
|
|
||||||
fontFamily: 'monospace'
|
|
||||||
}}>
|
|
||||||
<div>Shift: {shift.id.substring(0, 6)}...</div>
|
|
||||||
<div>Day: {shift.dayOfWeek}</div>
|
|
||||||
{!isValidShift && (
|
|
||||||
<div style={{ color: '#e74c3c', fontWeight: 'bold' }}>
|
|
||||||
VALIDATION ERROR
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -556,63 +477,35 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
if (!selectedPlanId) {
|
||||||
|
showNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Fehler',
|
||||||
|
message: 'Bitte wählen Sie einen Schichtplan aus'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic frontend validation: Check if we have any availabilities to save
|
||||||
|
const validAvailabilities = availabilities.filter(avail => {
|
||||||
|
return avail.shiftId && selectedPlan?.shifts?.some(shift => shift.id === avail.shiftId);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validAvailabilities.length === 0) {
|
||||||
|
showNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Fehler',
|
||||||
|
message: 'Keine gültigen Verfügbarkeiten zum Speichern gefunden'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complex validation (contract type rules) is now handled by backend
|
||||||
|
// We only do basic required field validation in frontend
|
||||||
|
|
||||||
|
await executeWithValidation(async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError('');
|
|
||||||
|
|
||||||
if (!selectedPlanId) {
|
|
||||||
setError('Bitte wählen Sie einen Schichtplan aus');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter availabilities to only include those with actual shifts AND valid shiftIds
|
|
||||||
const validAvailabilities = availabilities.filter(avail => {
|
|
||||||
// Check if this shiftId exists and is valid
|
|
||||||
if (!avail.shiftId) {
|
|
||||||
console.warn('⚠️ Überspringe ungültige Verfügbarkeit ohne Shift-ID:', avail);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this shiftId exists in the current plan
|
|
||||||
return selectedPlan?.shifts?.some(shift => shift.id === avail.shiftId);
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('💾 SPEICHERE VERFÜGBARKEITEN:', {
|
|
||||||
total: availabilities.length,
|
|
||||||
valid: validAvailabilities.length,
|
|
||||||
invalid: availabilities.length - validAvailabilities.length
|
|
||||||
});
|
|
||||||
|
|
||||||
if (validAvailabilities.length === 0) {
|
|
||||||
setError('Keine gültigen Verfügbarkeiten zum Speichern gefunden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contract type validation
|
|
||||||
const availableShifts = validAvailabilities.filter(avail =>
|
|
||||||
avail.preferenceLevel === 1 || avail.preferenceLevel === 2
|
|
||||||
).length;
|
|
||||||
|
|
||||||
let contractRequirement = 0;
|
|
||||||
let contractTypeName = '';
|
|
||||||
|
|
||||||
if (employee.contractType === 'small') {
|
|
||||||
contractRequirement = 2;
|
|
||||||
contractTypeName = 'Kleiner Vertrag';
|
|
||||||
} else if (employee.contractType === 'large') {
|
|
||||||
contractRequirement = 3;
|
|
||||||
contractTypeName = 'Großer Vertrag';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (contractRequirement > 0 && availableShifts < contractRequirement) {
|
|
||||||
setError(
|
|
||||||
`${contractTypeName} erfordert mindestens ${contractRequirement} verfügbare Shifts. ` +
|
|
||||||
`Aktuell sind nur ${availableShifts} Shifts mit Verfügbarkeit "Bevorzugt" oder "Möglich" ausgewählt.`
|
|
||||||
);
|
|
||||||
setSaving(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to the format expected by the API - using shiftId directly
|
// Convert to the format expected by the API - using shiftId directly
|
||||||
const requestData = {
|
const requestData = {
|
||||||
planId: selectedPlanId,
|
planId: selectedPlanId,
|
||||||
@@ -627,15 +520,16 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
await employeeService.updateAvailabilities(employee.id, requestData);
|
await employeeService.updateAvailabilities(employee.id, requestData);
|
||||||
console.log('✅ VERFÜGBARKEITEN ERFOLGREICH GESPEICHERT');
|
console.log('✅ VERFÜGBARKEITEN ERFOLGREICH GESPEICHERT');
|
||||||
|
|
||||||
|
showNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Erfolg',
|
||||||
|
message: 'Verfügbarkeiten wurden erfolgreich gespeichert'
|
||||||
|
});
|
||||||
|
|
||||||
window.dispatchEvent(new CustomEvent('availabilitiesChanged'));
|
window.dispatchEvent(new CustomEvent('availabilitiesChanged'));
|
||||||
|
|
||||||
onSave();
|
onSave();
|
||||||
} catch (err: any) {
|
});
|
||||||
console.error('❌ FEHLER BEIM SPEICHERN:', err);
|
|
||||||
setError(err.message || 'Fehler beim Speichern der Verfügbarkeiten');
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -658,11 +552,10 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
// Get full name for display
|
// Get full name for display
|
||||||
const employeeFullName = `${employee.firstname} ${employee.lastname}`;
|
const employeeFullName = `${employee.firstname} ${employee.lastname}`;
|
||||||
|
|
||||||
// Mininmum amount of shifts per contract type
|
// Available shifts count for display only (not for validation)
|
||||||
const availableShiftsCount = availabilities.filter(avail =>
|
const availableShiftsCount = availabilities.filter(avail =>
|
||||||
avail.preferenceLevel === 1 || avail.preferenceLevel === 2
|
avail.preferenceLevel === 1 || avail.preferenceLevel === 2
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -694,26 +587,14 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
{employee.contractType && (
|
{employee.contractType && (
|
||||||
<p style={{ margin: '5px 0 0 0', color: employee.contractType === 'small' ? '#f39c12' : '#27ae60' }}>
|
<p style={{ margin: '5px 0 0 0', color: employee.contractType === 'small' ? '#f39c12' : '#27ae60' }}>
|
||||||
<strong>Vertrag:</strong>
|
<strong>Vertrag:</strong>
|
||||||
{employee.contractType === 'small' ? ' Kleiner Vertrag (min. 2 verfügbare Shifts)' :
|
{employee.contractType === 'small' ? ' Kleiner Vertrag' :
|
||||||
employee.contractType === 'large' ? ' Großer Vertrag (min. 3 verfügbare Shifts)' :
|
employee.contractType === 'large' ? ' Großer Vertrag' :
|
||||||
' Flexibler Vertrag'}
|
' Flexibler Vertrag'}
|
||||||
|
{/* Note: Contract validation is now handled by backend */}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div style={{
|
|
||||||
backgroundColor: '#fee',
|
|
||||||
border: '1px solid #f5c6cb',
|
|
||||||
color: '#721c24',
|
|
||||||
padding: '12px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
marginBottom: '20px'
|
|
||||||
}}>
|
|
||||||
<strong>Fehler:</strong> {error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Availability Legend */}
|
{/* Availability Legend */}
|
||||||
<div style={{
|
<div style={{
|
||||||
marginBottom: '30px',
|
marginBottom: '30px',
|
||||||
@@ -774,7 +655,6 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
const newPlanId = e.target.value;
|
const newPlanId = e.target.value;
|
||||||
console.log('🔄 PLAN WECHSELN ZU:', newPlanId);
|
console.log('🔄 PLAN WECHSELN ZU:', newPlanId);
|
||||||
setSelectedPlanId(newPlanId);
|
setSelectedPlanId(newPlanId);
|
||||||
// Der useEffect wird automatisch ausgelöst
|
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
@@ -828,15 +708,15 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
}}>
|
}}>
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
disabled={saving}
|
disabled={isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
padding: '12px 24px',
|
padding: '12px 24px',
|
||||||
backgroundColor: '#95a5a6',
|
backgroundColor: '#95a5a6',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
cursor: saving ? 'not-allowed' : 'pointer',
|
cursor: isSubmitting ? 'not-allowed' : 'pointer',
|
||||||
opacity: saving ? 0.6 : 1
|
opacity: isSubmitting ? 0.6 : 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
@@ -844,18 +724,18 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving || shiftsCount === 0 || !selectedPlanId}
|
disabled={isSubmitting || shiftsCount === 0 || !selectedPlanId}
|
||||||
style={{
|
style={{
|
||||||
padding: '12px 24px',
|
padding: '12px 24px',
|
||||||
backgroundColor: saving ? '#bdc3c7' : (shiftsCount === 0 || !selectedPlanId ? '#95a5a6' : '#3498db'),
|
backgroundColor: isSubmitting ? '#bdc3c7' : (shiftsCount === 0 || !selectedPlanId ? '#95a5a6' : '#3498db'),
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
cursor: (saving || shiftsCount === 0 || !selectedPlanId) ? 'not-allowed' : 'pointer',
|
cursor: (isSubmitting || shiftsCount === 0 || !selectedPlanId) ? 'not-allowed' : 'pointer',
|
||||||
fontWeight: 'bold'
|
fontWeight: 'bold'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{saving ? '⏳ Wird gespeichert...' : `Verfügbarkeiten speichern (${availableShiftsCount})`}
|
{isSubmitting ? '⏳ Wird gespeichert...' : `Verfügbarkeiten speichern (${availableShiftsCount})`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import React, { useState } from 'react';
|
|||||||
import { ROLE_CONFIG, EMPLOYEE_TYPE_CONFIG } from '../../../models/defaults/employeeDefaults';
|
import { ROLE_CONFIG, EMPLOYEE_TYPE_CONFIG } from '../../../models/defaults/employeeDefaults';
|
||||||
import { Employee } from '../../../models/Employee';
|
import { Employee } from '../../../models/Employee';
|
||||||
import { useAuth } from '../../../contexts/AuthContext';
|
import { useAuth } from '../../../contexts/AuthContext';
|
||||||
|
import { useNotification } from '../../../contexts/NotificationContext';
|
||||||
|
|
||||||
interface EmployeeListProps {
|
interface EmployeeListProps {
|
||||||
employees: Employee[];
|
employees: Employee[];
|
||||||
@@ -14,7 +15,7 @@ interface EmployeeListProps {
|
|||||||
type SortField = 'name' | 'employeeType' | 'canWorkAlone' | 'role' | 'lastLogin';
|
type SortField = 'name' | 'employeeType' | 'canWorkAlone' | 'role' | 'lastLogin';
|
||||||
type SortDirection = 'asc' | 'desc';
|
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';
|
type EmployeeType = 'manager' | 'personell' | 'apprentice' | 'guest';
|
||||||
|
|
||||||
const EmployeeList: React.FC<EmployeeListProps> = ({
|
const EmployeeList: React.FC<EmployeeListProps> = ({
|
||||||
@@ -28,6 +29,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
|||||||
const [sortField, setSortField] = useState<SortField>('name');
|
const [sortField, setSortField] = useState<SortField>('name');
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||||
const { user: currentUser, hasRole } = useAuth();
|
const { user: currentUser, hasRole } = useAuth();
|
||||||
|
const { showNotification, confirmDialog } = useNotification();
|
||||||
|
|
||||||
// Filter employees based on active/inactive and search term
|
// Filter employees based on active/inactive and search term
|
||||||
const filteredEmployees = employees.filter(employee => {
|
const filteredEmployees = employees.filter(employee => {
|
||||||
@@ -128,7 +130,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
|||||||
const getEmployeeTypeBadge = (type: EmployeeType, isTrainee: boolean = false) => {
|
const getEmployeeTypeBadge = (type: EmployeeType, isTrainee: boolean = false) => {
|
||||||
const config = EMPLOYEE_TYPE_CONFIG[type];
|
const config = EMPLOYEE_TYPE_CONFIG[type];
|
||||||
|
|
||||||
// FIXED: Updated color mapping for actual employee types
|
// Color mapping for actual employee types
|
||||||
const bgColor =
|
const bgColor =
|
||||||
type === 'manager'
|
type === 'manager'
|
||||||
? '#fadbd8' // light red
|
? '#fadbd8' // light red
|
||||||
@@ -176,6 +178,31 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
|||||||
return 'MITARBEITER';
|
return 'MITARBEITER';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = async (employee: Employee) => {
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: 'Mitarbeiter löschen',
|
||||||
|
message: `Sind Sie sicher, dass Sie ${employee.firstname} ${employee.lastname} löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.`,
|
||||||
|
confirmText: 'Löschen',
|
||||||
|
cancelText: 'Abbrechen',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmed) {
|
||||||
|
try {
|
||||||
|
onDelete(employee);
|
||||||
|
showNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Erfolg',
|
||||||
|
message: `${employee.firstname} ${employee.lastname} wurde erfolgreich gelöscht.`
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
// Error will be handled by parent component through useBackendValidation
|
||||||
|
// We just need to re-throw it so the parent can catch it
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (employees.length === 0) {
|
if (employees.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -299,7 +326,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sortedEmployees.map(employee => {
|
{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 employeeType = getEmployeeTypeBadge(employee.employeeType as EmployeeType, employee.isTrainee);
|
||||||
const independence = getIndependenceBadge(employee.canWorkAlone);
|
const independence = getIndependenceBadge(employee.canWorkAlone);
|
||||||
const roleInfo = getRoleBadge(employee.roles);
|
const roleInfo = getRoleBadge(employee.roles);
|
||||||
@@ -468,7 +495,7 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
|||||||
{/* Löschen Button */}
|
{/* Löschen Button */}
|
||||||
{canDelete && (
|
{canDelete && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(employee)}
|
onClick={() => handleDeleteClick(employee)}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 8px',
|
padding: '6px 8px',
|
||||||
backgroundColor: '#e74c3c',
|
backgroundColor: '#e74c3c',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// frontend/src/pages/Settings/Settings.tsx - UPDATED WITH NEW STYLES
|
// frontend/src/pages/Settings/Settings.tsx - UPDATED WITH VALIDATION STRATEGY
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { employeeService } from '../../services/employeeService';
|
import { employeeService } from '../../services/employeeService';
|
||||||
import { useNotification } from '../../contexts/NotificationContext';
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
|
import { useBackendValidation } from '../../hooks/useBackendValidation';
|
||||||
import AvailabilityManager from '../Employees/components/AvailabilityManager';
|
import AvailabilityManager from '../Employees/components/AvailabilityManager';
|
||||||
import { Employee } from '../../models/Employee';
|
import { Employee } from '../../models/Employee';
|
||||||
import { styles } from './type/SettingsType';
|
import { styles } from './type/SettingsType';
|
||||||
@@ -10,11 +11,12 @@ import { styles } from './type/SettingsType';
|
|||||||
const Settings: React.FC = () => {
|
const Settings: React.FC = () => {
|
||||||
const { user: currentUser, updateUser } = useAuth();
|
const { user: currentUser, updateUser } = useAuth();
|
||||||
const { showNotification } = useNotification();
|
const { showNotification } = useNotification();
|
||||||
|
const { executeWithValidation, clearErrors, isSubmitting } = useBackendValidation();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'availability'>('profile');
|
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'availability'>('profile');
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [showAvailabilityManager, setShowAvailabilityManager] = useState(false);
|
const [showAvailabilityManager, setShowAvailabilityManager] = useState(false);
|
||||||
|
|
||||||
// Profile form state - updated for firstname/lastname
|
// Profile form state
|
||||||
const [profileForm, setProfileForm] = useState({
|
const [profileForm, setProfileForm] = useState({
|
||||||
firstname: currentUser?.firstname || '',
|
firstname: currentUser?.firstname || '',
|
||||||
lastname: currentUser?.lastname || ''
|
lastname: currentUser?.lastname || ''
|
||||||
@@ -73,7 +75,7 @@ const Settings: React.FC = () => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Password visibility handlers for current password
|
// Password visibility handlers
|
||||||
const handleCurrentPasswordMouseDown = () => {
|
const handleCurrentPasswordMouseDown = () => {
|
||||||
currentPasswordTimeoutRef.current = setTimeout(() => {
|
currentPasswordTimeoutRef.current = setTimeout(() => {
|
||||||
setShowCurrentPassword(true);
|
setShowCurrentPassword(true);
|
||||||
@@ -88,7 +90,6 @@ const Settings: React.FC = () => {
|
|||||||
setShowCurrentPassword(false);
|
setShowCurrentPassword(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Password visibility handlers for new password
|
|
||||||
const handleNewPasswordMouseDown = () => {
|
const handleNewPasswordMouseDown = () => {
|
||||||
newPasswordTimeoutRef.current = setTimeout(() => {
|
newPasswordTimeoutRef.current = setTimeout(() => {
|
||||||
setShowNewPassword(true);
|
setShowNewPassword(true);
|
||||||
@@ -103,7 +104,6 @@ const Settings: React.FC = () => {
|
|||||||
setShowNewPassword(false);
|
setShowNewPassword(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Password visibility handlers for confirm password
|
|
||||||
const handleConfirmPasswordMouseDown = () => {
|
const handleConfirmPasswordMouseDown = () => {
|
||||||
confirmPasswordTimeoutRef.current = setTimeout(() => {
|
confirmPasswordTimeoutRef.current = setTimeout(() => {
|
||||||
setShowConfirmPassword(true);
|
setShowConfirmPassword(true);
|
||||||
@@ -129,7 +129,6 @@ const Settings: React.FC = () => {
|
|||||||
cleanup();
|
cleanup();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Prevent context menu
|
|
||||||
const handleContextMenu = (e: React.MouseEvent) => {
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
};
|
};
|
||||||
@@ -138,40 +137,46 @@ const Settings: React.FC = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
// Validation
|
// BASIC FRONTEND VALIDATION: Only check required fields
|
||||||
if (!profileForm.firstname.trim() || !profileForm.lastname.trim()) {
|
if (!profileForm.firstname.trim()) {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
title: 'Fehler',
|
title: 'Fehler',
|
||||||
message: 'Vorname und Nachname sind erforderlich'
|
message: 'Vorname ist erforderlich'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profileForm.lastname.trim()) {
|
||||||
|
showNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Fehler',
|
||||||
|
message: 'Nachname ist erforderlich'
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
// Use executeWithValidation to handle backend validation
|
||||||
await employeeService.updateEmployee(currentUser.id, {
|
await executeWithValidation(async () => {
|
||||||
firstname: profileForm.firstname.trim(),
|
const updatedEmployee = await employeeService.updateEmployee(currentUser.id, {
|
||||||
lastname: profileForm.lastname.trim()
|
firstname: profileForm.firstname.trim(),
|
||||||
});
|
lastname: profileForm.lastname.trim()
|
||||||
|
});
|
||||||
|
|
||||||
// Update the auth context with new user data
|
// Update the auth context with new user data
|
||||||
const updatedUser = await employeeService.getEmployee(currentUser.id);
|
updateUser(updatedEmployee);
|
||||||
updateUser(updatedUser);
|
|
||||||
|
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: 'Erfolg',
|
title: 'Erfolg',
|
||||||
message: 'Profil erfolgreich aktualisiert'
|
message: 'Profil erfolgreich aktualisiert'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
showNotification({
|
// Backend validation errors are already handled by executeWithValidation
|
||||||
type: 'error',
|
// We only need to handle unexpected errors here
|
||||||
title: 'Fehler',
|
console.error('Unexpected error:', error);
|
||||||
message: error.message || 'Profil konnte nicht aktualisiert werden'
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,12 +184,30 @@ const Settings: React.FC = () => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
|
|
||||||
// Validation
|
// BASIC FRONTEND VALIDATION: Only check minimum requirements
|
||||||
if (passwordForm.newPassword.length < 6) {
|
if (!passwordForm.currentPassword) {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
title: 'Fehler',
|
title: 'Fehler',
|
||||||
message: 'Das neue Passwort muss mindestens 6 Zeichen lang sein'
|
message: 'Aktuelles Passwort ist erforderlich'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passwordForm.newPassword) {
|
||||||
|
showNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Fehler',
|
||||||
|
message: 'Neues Passwort ist erforderlich'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passwordForm.newPassword.length < 8) {
|
||||||
|
showNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Fehler',
|
||||||
|
message: 'Das neue Passwort muss mindestens 8 Zeichen lang sein'
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -199,34 +222,30 @@ const Settings: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
// Use executeWithValidation to handle backend validation
|
||||||
|
await executeWithValidation(async () => {
|
||||||
// Use the actual password change endpoint
|
await employeeService.changePassword(currentUser.id, {
|
||||||
await employeeService.changePassword(currentUser.id, {
|
currentPassword: passwordForm.currentPassword,
|
||||||
currentPassword: passwordForm.currentPassword,
|
newPassword: passwordForm.newPassword,
|
||||||
newPassword: passwordForm.newPassword
|
confirmPassword: passwordForm.confirmPassword
|
||||||
});
|
});
|
||||||
|
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: 'Erfolg',
|
title: 'Erfolg',
|
||||||
message: 'Passwort erfolgreich geändert'
|
message: 'Passwort erfolgreich geändert'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear password form
|
// Clear password form
|
||||||
setPasswordForm({
|
setPasswordForm({
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: ''
|
confirmPassword: ''
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error) {
|
||||||
showNotification({
|
// Backend validation errors are already handled by executeWithValidation
|
||||||
type: 'error',
|
console.error('Unexpected error:', error);
|
||||||
title: 'Fehler',
|
|
||||||
message: error.message || 'Passwort konnte nicht geändert werden'
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -243,12 +262,18 @@ const Settings: React.FC = () => {
|
|||||||
setShowAvailabilityManager(false);
|
setShowAvailabilityManager(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Clear validation errors when switching tabs
|
||||||
|
const handleTabChange = (tab: 'profile' | 'password' | 'availability') => {
|
||||||
|
clearErrors();
|
||||||
|
setActiveTab(tab);
|
||||||
|
};
|
||||||
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return <div style={{
|
return <div style={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
padding: '3rem',
|
padding: '3rem',
|
||||||
color: '#666',
|
color: '#666',
|
||||||
fontSize: '1.1rem'
|
fontSize: '1.1rem'
|
||||||
}}>Nicht eingeloggt</div>;
|
}}>Nicht eingeloggt</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -270,10 +295,10 @@ const Settings: React.FC = () => {
|
|||||||
<h1 style={styles.title}>Einstellungen</h1>
|
<h1 style={styles.title}>Einstellungen</h1>
|
||||||
<div style={styles.subtitle}>Verwalten Sie Ihre Kontoeinstellungen und Präferenzen</div>
|
<div style={styles.subtitle}>Verwalten Sie Ihre Kontoeinstellungen und Präferenzen</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.tabs}>
|
<div style={styles.tabs}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('profile')}
|
onClick={() => handleTabChange('profile')}
|
||||||
style={{
|
style={{
|
||||||
...styles.tab,
|
...styles.tab,
|
||||||
...(activeTab === 'profile' ? styles.tabActive : {})
|
...(activeTab === 'profile' ? styles.tabActive : {})
|
||||||
@@ -299,9 +324,9 @@ const Settings: React.FC = () => {
|
|||||||
<span style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '2px' }}>Persönliche Informationen</span>
|
<span style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '2px' }}>Persönliche Informationen</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('password')}
|
onClick={() => handleTabChange('password')}
|
||||||
style={{
|
style={{
|
||||||
...styles.tab,
|
...styles.tab,
|
||||||
...(activeTab === 'password' ? styles.tabActive : {})
|
...(activeTab === 'password' ? styles.tabActive : {})
|
||||||
@@ -327,9 +352,9 @@ const Settings: React.FC = () => {
|
|||||||
<span style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '2px' }}>Sicherheitseinstellungen</span>
|
<span style={{ fontSize: '0.8rem', opacity: 0.7, marginTop: '2px' }}>Sicherheitseinstellungen</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('availability')}
|
onClick={() => handleTabChange('availability')}
|
||||||
style={{
|
style={{
|
||||||
...styles.tab,
|
...styles.tab,
|
||||||
...(activeTab === 'availability' ? styles.tabActive : {})
|
...(activeTab === 'availability' ? styles.tabActive : {})
|
||||||
@@ -369,7 +394,7 @@ const Settings: React.FC = () => {
|
|||||||
Verwalten Sie Ihre persönlichen Informationen und Kontaktdaten
|
Verwalten Sie Ihre persönlichen Informationen und Kontaktdaten
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleProfileUpdate} style={{ marginTop: '2rem' }}>
|
<form onSubmit={handleProfileUpdate} style={{ marginTop: '2rem' }}>
|
||||||
<div style={styles.formGrid}>
|
<div style={styles.formGrid}>
|
||||||
{/* Read-only information */}
|
{/* Read-only information */}
|
||||||
@@ -480,28 +505,28 @@ const Settings: React.FC = () => {
|
|||||||
<div style={styles.actions}>
|
<div style={styles.actions}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !profileForm.firstname.trim() || !profileForm.lastname.trim()}
|
disabled={isSubmitting || !profileForm.firstname.trim() || !profileForm.lastname.trim()}
|
||||||
style={{
|
style={{
|
||||||
...styles.button,
|
...styles.button,
|
||||||
...styles.buttonPrimary,
|
...styles.buttonPrimary,
|
||||||
...((loading || !profileForm.firstname.trim() || !profileForm.lastname.trim()) ? styles.buttonDisabled : {})
|
...((isSubmitting || !profileForm.firstname.trim() || !profileForm.lastname.trim()) ? styles.buttonDisabled : {})
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!loading && profileForm.firstname.trim() && profileForm.lastname.trim()) {
|
if (!isSubmitting && profileForm.firstname.trim() && profileForm.lastname.trim()) {
|
||||||
e.currentTarget.style.background = styles.buttonPrimaryHover.background;
|
e.currentTarget.style.background = styles.buttonPrimaryHover.background;
|
||||||
e.currentTarget.style.transform = styles.buttonPrimaryHover.transform;
|
e.currentTarget.style.transform = styles.buttonPrimaryHover.transform;
|
||||||
e.currentTarget.style.boxShadow = styles.buttonPrimaryHover.boxShadow;
|
e.currentTarget.style.boxShadow = styles.buttonPrimaryHover.boxShadow;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
if (!loading && profileForm.firstname.trim() && profileForm.lastname.trim()) {
|
if (!isSubmitting && profileForm.firstname.trim() && profileForm.lastname.trim()) {
|
||||||
e.currentTarget.style.background = styles.buttonPrimary.background;
|
e.currentTarget.style.background = styles.buttonPrimary.background;
|
||||||
e.currentTarget.style.transform = 'none';
|
e.currentTarget.style.transform = 'none';
|
||||||
e.currentTarget.style.boxShadow = styles.buttonPrimary.boxShadow;
|
e.currentTarget.style.boxShadow = styles.buttonPrimary.boxShadow;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading ? '⏳ Wird gespeichert...' : 'Profil aktualisieren'}
|
{isSubmitting ? '⏳ Wird gespeichert...' : 'Profil aktualisieren'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -517,7 +542,7 @@ const Settings: React.FC = () => {
|
|||||||
Aktualisieren Sie Ihr Passwort für erhöhte Sicherheit
|
Aktualisieren Sie Ihr Passwort für erhöhte Sicherheit
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handlePasswordUpdate} style={{ marginTop: '2rem' }}>
|
<form onSubmit={handlePasswordUpdate} style={{ marginTop: '2rem' }}>
|
||||||
<div style={styles.formGridCompact}>
|
<div style={styles.formGridCompact}>
|
||||||
{/* Current Password Field */}
|
{/* Current Password Field */}
|
||||||
@@ -575,9 +600,9 @@ const Settings: React.FC = () => {
|
|||||||
value={passwordForm.newPassword}
|
value={passwordForm.newPassword}
|
||||||
onChange={handlePasswordChange}
|
onChange={handlePasswordChange}
|
||||||
required
|
required
|
||||||
minLength={6}
|
minLength={8}
|
||||||
style={styles.fieldInputWithIcon}
|
style={styles.fieldInputWithIcon}
|
||||||
placeholder="Mindestens 6 Zeichen"
|
placeholder="Mindestens 8 Zeichen"
|
||||||
onFocus={(e) => {
|
onFocus={(e) => {
|
||||||
e.target.style.borderColor = '#1a1325';
|
e.target.style.borderColor = '#1a1325';
|
||||||
e.target.style.boxShadow = '0 0 0 3px rgba(26, 19, 37, 0.1)';
|
e.target.style.boxShadow = '0 0 0 3px rgba(26, 19, 37, 0.1)';
|
||||||
@@ -606,7 +631,7 @@ const Settings: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={styles.fieldHint}>
|
<div style={styles.fieldHint}>
|
||||||
Das Passwort muss mindestens 6 Zeichen lang sein.
|
Das Passwort muss mindestens 8 Zeichen lang sein.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -657,28 +682,28 @@ const Settings: React.FC = () => {
|
|||||||
<div style={styles.actions}>
|
<div style={styles.actions}>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword}
|
disabled={isSubmitting || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword}
|
||||||
style={{
|
style={{
|
||||||
...styles.button,
|
...styles.button,
|
||||||
...styles.buttonPrimary,
|
...styles.buttonPrimary,
|
||||||
...((loading || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) ? styles.buttonDisabled : {})
|
...((isSubmitting || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword) ? styles.buttonDisabled : {})
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!loading && passwordForm.currentPassword && passwordForm.newPassword && passwordForm.confirmPassword) {
|
if (!isSubmitting && passwordForm.currentPassword && passwordForm.newPassword && passwordForm.confirmPassword) {
|
||||||
e.currentTarget.style.background = styles.buttonPrimaryHover.background;
|
e.currentTarget.style.background = styles.buttonPrimaryHover.background;
|
||||||
e.currentTarget.style.transform = styles.buttonPrimaryHover.transform;
|
e.currentTarget.style.transform = styles.buttonPrimaryHover.transform;
|
||||||
e.currentTarget.style.boxShadow = styles.buttonPrimaryHover.boxShadow;
|
e.currentTarget.style.boxShadow = styles.buttonPrimaryHover.boxShadow;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
if (!loading && passwordForm.currentPassword && passwordForm.newPassword && passwordForm.confirmPassword) {
|
if (!isSubmitting && passwordForm.currentPassword && passwordForm.newPassword && passwordForm.confirmPassword) {
|
||||||
e.currentTarget.style.background = styles.buttonPrimary.background;
|
e.currentTarget.style.background = styles.buttonPrimary.background;
|
||||||
e.currentTarget.style.transform = 'none';
|
e.currentTarget.style.transform = 'none';
|
||||||
e.currentTarget.style.boxShadow = styles.buttonPrimary.boxShadow;
|
e.currentTarget.style.boxShadow = styles.buttonPrimary.boxShadow;
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading ? '⏳ Wird geändert...' : 'Passwort ändern'}
|
{isSubmitting ? '⏳ Wird geändert...' : 'Passwort ändern'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -694,16 +719,16 @@ const Settings: React.FC = () => {
|
|||||||
Legen Sie Ihre persönliche Verfügbarkeit für Schichtpläne fest
|
Legen Sie Ihre persönliche Verfügbarkeit für Schichtpläne fest
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={styles.availabilityCard}>
|
<div style={styles.availabilityCard}>
|
||||||
<div style={styles.availabilityIcon}>📅</div>
|
<div style={styles.availabilityIcon}>📅</div>
|
||||||
<h3 style={styles.availabilityTitle}>Verfügbarkeit verwalten</h3>
|
<h3 style={styles.availabilityTitle}>Verfügbarkeit verwalten</h3>
|
||||||
<p style={styles.availabilityDescription}>
|
<p style={styles.availabilityDescription}>
|
||||||
Hier können Sie Ihre persönliche Verfügbarkeit für Schichtpläne festlegen.
|
Hier können Sie Ihre persönliche Verfügbarkeit für Schichtpläne festlegen.
|
||||||
Legen Sie für jeden Tag und jede Schicht fest, ob Sie bevorzugt, möglicherweise
|
Legen Sie für jeden Tag und jede Schicht fest, ob Sie bevorzugt, möglicherweise
|
||||||
oder nicht verfügbar sind.
|
oder nicht verfügbar sind.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAvailabilityManager(true)}
|
onClick={() => setShowAvailabilityManager(true)}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -1,275 +1,275 @@
|
|||||||
// frontend/src/pages/Settings/type/SettingsType.tsx - CORRECTED
|
// frontend/src/pages/Settings/type/SettingsType.tsx
|
||||||
export const styles = {
|
export const styles = {
|
||||||
container: {
|
container: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
minHeight: 'calc(100vh - 120px)',
|
minHeight: 'calc(100vh - 120px)',
|
||||||
background: '#FBFAF6',
|
background: '#FBFAF6',
|
||||||
padding: '2rem',
|
padding: '2rem',
|
||||||
maxWidth: '1200px',
|
maxWidth: '1200px',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
gap: '2rem',
|
gap: '2rem',
|
||||||
},
|
},
|
||||||
sidebar: {
|
sidebar: {
|
||||||
width: '280px',
|
width: '280px',
|
||||||
background: '#FBFAF6',
|
background: '#FBFAF6',
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
border: '1px solid rgba(255, 255, 255, 0.8)',
|
border: '1px solid rgba(255, 255, 255, 0.8)',
|
||||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04)',
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04)',
|
||||||
padding: '1.5rem',
|
padding: '1.5rem',
|
||||||
height: 'fit-content',
|
height: 'fit-content',
|
||||||
position: 'sticky' as const,
|
position: 'sticky' as const,
|
||||||
top: '2rem',
|
top: '2rem',
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
marginBottom: '2rem',
|
marginBottom: '2rem',
|
||||||
paddingBottom: '1.5rem',
|
paddingBottom: '1.5rem',
|
||||||
borderBottom: '1px solid rgba(26, 19, 37, 0.1)',
|
borderBottom: '1px solid rgba(26, 19, 37, 0.1)',
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: '1.5rem',
|
fontSize: '1.5rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#161718',
|
color: '#161718',
|
||||||
margin: '0 0 0.5rem 0',
|
margin: '0 0 0.5rem 0',
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
fontSize: '0.95rem',
|
fontSize: '0.95rem',
|
||||||
color: '#666',
|
color: '#666',
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
},
|
},
|
||||||
tabs: {
|
tabs: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column' as const,
|
flexDirection: 'column' as const,
|
||||||
gap: '0.5rem',
|
gap: '0.5rem',
|
||||||
},
|
},
|
||||||
tab: {
|
tab: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
padding: '1rem 1.25rem',
|
padding: '1rem 1.25rem',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
color: '#666',
|
color: '#666',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
textAlign: 'left' as const,
|
textAlign: 'left' as const,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
},
|
||||||
tabActive: {
|
tabActive: {
|
||||||
background: '#51258f',
|
background: '#51258f',
|
||||||
color: '#FBFAF6',
|
color: '#FBFAF6',
|
||||||
boxShadow: '0 4px 12px rgba(26, 19, 37, 0.15)',
|
boxShadow: '0 4px 12px rgba(26, 19, 37, 0.15)',
|
||||||
},
|
},
|
||||||
tabHover: {
|
tabHover: {
|
||||||
background: 'rgba(81, 37, 143, 0.1)',
|
background: 'rgba(81, 37, 143, 0.1)',
|
||||||
color: '#1a1325',
|
color: '#1a1325',
|
||||||
transform: 'translateX(4px)',
|
transform: 'translateX(4px)',
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
background: '#FBFAF6',
|
background: '#FBFAF6',
|
||||||
padding: '2.5rem',
|
padding: '2.5rem',
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
border: '1px solid rgba(255, 255, 255, 0.8)',
|
border: '1px solid rgba(255, 255, 255, 0.8)',
|
||||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04)',
|
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04)',
|
||||||
backdropFilter: 'blur(10px)',
|
backdropFilter: 'blur(10px)',
|
||||||
minHeight: '100px',
|
minHeight: '100px',
|
||||||
},
|
},
|
||||||
section: {
|
section: {
|
||||||
marginBottom: '2rem',
|
marginBottom: '2rem',
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontSize: '1.75rem',
|
fontSize: '1.75rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#161718',
|
color: '#161718',
|
||||||
margin: '0 0 0.5rem 0',
|
margin: '0 0 0.5rem 0',
|
||||||
},
|
},
|
||||||
sectionDescription: {
|
sectionDescription: {
|
||||||
color: '#666',
|
color: '#666',
|
||||||
fontSize: '1rem',
|
fontSize: '1rem',
|
||||||
margin: 0,
|
margin: 0,
|
||||||
lineHeight: 1.5,
|
lineHeight: 1.5,
|
||||||
},
|
},
|
||||||
formGrid: {
|
formGrid: {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '1.5rem',
|
gap: '1.5rem',
|
||||||
},
|
},
|
||||||
formGridCompact: {
|
formGridCompact: {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gap: '1.5rem',
|
gap: '1.5rem',
|
||||||
maxWidth: '500px',
|
maxWidth: '500px',
|
||||||
},
|
},
|
||||||
infoCard: {
|
infoCard: {
|
||||||
padding: '1.5rem',
|
padding: '1.5rem',
|
||||||
background: 'rgba(26, 19, 37, 0.02)',
|
background: 'rgba(26, 19, 37, 0.02)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
border: '1px solid rgba(26, 19, 37, 0.1)',
|
border: '1px solid rgba(26, 19, 37, 0.1)',
|
||||||
},
|
},
|
||||||
infoCardTitle: {
|
infoCardTitle: {
|
||||||
fontSize: '1rem',
|
fontSize: '1rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#1a1325',
|
color: '#1a1325',
|
||||||
margin: '0 0 1rem 0',
|
margin: '0 0 1rem 0',
|
||||||
},
|
},
|
||||||
infoGrid: {
|
infoGrid: {
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '1fr 1fr',
|
gridTemplateColumns: '1fr 1fr',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
},
|
},
|
||||||
field: {
|
field: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column' as const,
|
flexDirection: 'column' as const,
|
||||||
gap: '0.5rem',
|
gap: '0.5rem',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
},
|
||||||
fieldLabel: {
|
fieldLabel: {
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#161718',
|
color: '#161718',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
},
|
||||||
fieldInputContainer: {
|
fieldInputContainer: {
|
||||||
position: 'relative' as const,
|
position: 'relative' as const,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
},
|
||||||
fieldInput: {
|
fieldInput: {
|
||||||
padding: '0.875rem 1rem',
|
padding: '0.875rem 1rem',
|
||||||
border: '1.5px solid #e8e8e8',
|
border: '1.5px solid #e8e8e8',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
fontSize: '0.95rem',
|
fontSize: '0.95rem',
|
||||||
background: '#FBFAF6',
|
background: '#FBFAF6',
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
color: '#161718',
|
color: '#161718',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
boxSizing: 'border-box' as const,
|
boxSizing: 'border-box' as const,
|
||||||
},
|
},
|
||||||
fieldInputWithIcon: {
|
fieldInputWithIcon: {
|
||||||
padding: '0.875rem 1rem',
|
padding: '0.875rem 1rem',
|
||||||
border: '1.5px solid #e8e8e8',
|
border: '1.5px solid #e8e8e8',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
fontSize: '0.95rem',
|
fontSize: '0.95rem',
|
||||||
background: '#FBFAF6',
|
background: '#FBFAF6',
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
color: '#161718',
|
color: '#161718',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
paddingRight: '40px',
|
paddingRight: '40px',
|
||||||
boxSizing: 'border-box' as const,
|
boxSizing: 'border-box' as const,
|
||||||
},
|
},
|
||||||
fieldInputDisabled: {
|
fieldInputDisabled: {
|
||||||
padding: '0.875rem 1rem',
|
padding: '0.875rem 1rem',
|
||||||
border: '1.5px solid rgba(26, 19, 37, 0.1)',
|
border: '1.5px solid rgba(26, 19, 37, 0.1)',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
fontSize: '0.95rem',
|
fontSize: '0.95rem',
|
||||||
background: 'rgba(26, 19, 37, 0.05)',
|
background: 'rgba(26, 19, 37, 0.05)',
|
||||||
color: '#666',
|
color: '#666',
|
||||||
cursor: 'not-allowed',
|
cursor: 'not-allowed',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
boxSizing: 'border-box' as const,
|
boxSizing: 'border-box' as const,
|
||||||
},
|
},
|
||||||
fieldHint: {
|
fieldHint: {
|
||||||
fontSize: '0.8rem',
|
fontSize: '0.8rem',
|
||||||
color: '#888',
|
color: '#888',
|
||||||
marginTop: '0.25rem',
|
marginTop: '0.25rem',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
},
|
},
|
||||||
passwordToggleButton: {
|
passwordToggleButton: {
|
||||||
position: 'absolute' as const,
|
position: 'absolute' as const,
|
||||||
right: '10px',
|
right: '10px',
|
||||||
top: '50%',
|
top: '50%',
|
||||||
transform: 'translateY(-50%)',
|
transform: 'translateY(-50%)',
|
||||||
background: 'none',
|
background: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
padding: '5px',
|
padding: '5px',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
transition: 'background-color 0.2s',
|
transition: 'background-color 0.2s',
|
||||||
userSelect: 'none' as const,
|
userSelect: 'none' as const,
|
||||||
WebkitUserSelect: 'none' as const,
|
WebkitUserSelect: 'none' as const,
|
||||||
touchAction: 'manipulation' as const,
|
touchAction: 'manipulation' as const,
|
||||||
},
|
},
|
||||||
actions: {
|
actions: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
marginTop: '2.5rem',
|
marginTop: '2.5rem',
|
||||||
paddingTop: '1.5rem',
|
paddingTop: '1.5rem',
|
||||||
borderTop: '1px solid rgba(26, 19, 37, 0.1)',
|
borderTop: '1px solid rgba(26, 19, 37, 0.1)',
|
||||||
},
|
},
|
||||||
button: {
|
button: {
|
||||||
padding: '0.875rem 2rem',
|
padding: '0.875rem 2rem',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
fontSize: '0.95rem',
|
fontSize: '0.95rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
position: 'relative' as const,
|
position: 'relative' as const,
|
||||||
overflow: 'hidden' as const,
|
overflow: 'hidden' as const,
|
||||||
},
|
},
|
||||||
buttonPrimary: {
|
buttonPrimary: {
|
||||||
background: '#1a1325',
|
background: '#1a1325',
|
||||||
color: '#FBFAF6',
|
color: '#FBFAF6',
|
||||||
boxShadow: '0 2px 8px rgba(26, 19, 37, 0.2)',
|
boxShadow: '0 2px 8px rgba(26, 19, 37, 0.2)',
|
||||||
},
|
},
|
||||||
buttonPrimaryHover: {
|
buttonPrimaryHover: {
|
||||||
background: '#24163a',
|
background: '#24163a',
|
||||||
transform: 'translateY(-1px)',
|
transform: 'translateY(-1px)',
|
||||||
boxShadow: '0 4px 16px rgba(26, 19, 37, 0.3)',
|
boxShadow: '0 4px 16px rgba(26, 19, 37, 0.3)',
|
||||||
},
|
},
|
||||||
buttonDisabled: {
|
buttonDisabled: {
|
||||||
background: '#ccc',
|
background: '#ccc',
|
||||||
color: '#666',
|
color: '#666',
|
||||||
cursor: 'not-allowed',
|
cursor: 'not-allowed',
|
||||||
transform: 'none',
|
transform: 'none',
|
||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
},
|
},
|
||||||
availabilityCard: {
|
availabilityCard: {
|
||||||
padding: '3rem 2rem',
|
padding: '3rem 2rem',
|
||||||
textAlign: 'center' as const,
|
textAlign: 'center' as const,
|
||||||
background: 'rgba(26, 19, 37, 0.03)',
|
background: 'rgba(26, 19, 37, 0.03)',
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
border: '2px dashed rgba(26, 19, 37, 0.1)',
|
border: '2px dashed rgba(26, 19, 37, 0.1)',
|
||||||
backdropFilter: 'blur(10px)',
|
backdropFilter: 'blur(10px)',
|
||||||
},
|
},
|
||||||
availabilityIcon: {
|
availabilityIcon: {
|
||||||
fontSize: '3rem',
|
fontSize: '3rem',
|
||||||
marginBottom: '1.5rem',
|
marginBottom: '1.5rem',
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
},
|
},
|
||||||
availabilityTitle: {
|
availabilityTitle: {
|
||||||
fontSize: '1.5rem',
|
fontSize: '1.5rem',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: '#161718',
|
color: '#161718',
|
||||||
margin: '0 0 1rem 0',
|
margin: '0 0 1rem 0',
|
||||||
},
|
},
|
||||||
availabilityDescription: {
|
availabilityDescription: {
|
||||||
color: '#666',
|
color: '#666',
|
||||||
marginBottom: '2rem',
|
marginBottom: '2rem',
|
||||||
lineHeight: 1.6,
|
lineHeight: 1.6,
|
||||||
maxWidth: '500px',
|
maxWidth: '500px',
|
||||||
marginLeft: 'auto',
|
marginLeft: 'auto',
|
||||||
marginRight: 'auto',
|
marginRight: 'auto',
|
||||||
},
|
},
|
||||||
infoHint: {
|
infoHint: {
|
||||||
padding: '1.25rem',
|
padding: '1.25rem',
|
||||||
background: 'rgba(26, 19, 37, 0.05)',
|
background: 'rgba(26, 19, 37, 0.05)',
|
||||||
border: '1px solid rgba(26, 19, 37, 0.1)',
|
border: '1px solid rgba(26, 19, 37, 0.1)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
color: '#161718',
|
color: '#161718',
|
||||||
textAlign: 'left' as const,
|
textAlign: 'left' as const,
|
||||||
maxWidth: '400px',
|
maxWidth: '400px',
|
||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
},
|
},
|
||||||
infoList: {
|
infoList: {
|
||||||
margin: '0.75rem 0 0 1rem',
|
margin: '0.75rem 0 0 1rem',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
listStyle: 'none',
|
listStyle: 'none',
|
||||||
},
|
},
|
||||||
infoListItem: {
|
infoListItem: {
|
||||||
marginBottom: '0.5rem',
|
marginBottom: '0.5rem',
|
||||||
position: 'relative' as const,
|
position: 'relative' as const,
|
||||||
paddingLeft: '1rem',
|
paddingLeft: '1rem',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1,12 +1,26 @@
|
|||||||
// frontend/src/pages/Setup/Setup.tsx - UPDATED
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
|
||||||
const API_BASE_URL = '/api';
|
const API_BASE_URL = '/api';
|
||||||
|
|
||||||
const Setup: React.FC = () => {
|
// ===== TYP-DEFINITIONEN =====
|
||||||
const [step, setStep] = useState(1);
|
interface SetupFormData {
|
||||||
const [formData, setFormData] = useState({
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
firstname: string;
|
||||||
|
lastname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetupStep {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== HOOK FÜR SETUP-LOGIK =====
|
||||||
|
const useSetup = () => {
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [formData, setFormData] = useState<SetupFormData>({
|
||||||
password: '',
|
password: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
firstname: '',
|
firstname: '',
|
||||||
@@ -16,27 +30,26 @@ const Setup: React.FC = () => {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const { checkSetupStatus } = useAuth();
|
const { checkSetupStatus } = useAuth();
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const steps: SetupStep[] = [
|
||||||
const { name, value } = e.target;
|
{
|
||||||
setFormData(prev => ({
|
id: 'profile-setup',
|
||||||
...prev,
|
title: 'Profilinformationen',
|
||||||
[name]: value
|
subtitle: 'Geben Sie Ihre persönlichen Daten ein'
|
||||||
}));
|
},
|
||||||
};
|
{
|
||||||
|
id: 'password-setup',
|
||||||
const validateStep1 = () => {
|
title: 'Passwort erstellen',
|
||||||
if (formData.password.length < 6) {
|
subtitle: 'Legen Sie ein sicheres Passwort fest'
|
||||||
setError('Das Passwort muss mindestens 6 Zeichen lang sein.');
|
},
|
||||||
return false;
|
{
|
||||||
|
id: 'confirmation',
|
||||||
|
title: 'Bestätigung',
|
||||||
|
subtitle: 'Setup abschließen'
|
||||||
}
|
}
|
||||||
if (formData.password !== formData.confirmPassword) {
|
];
|
||||||
setError('Die Passwörter stimmen nicht überein.');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateStep2 = () => {
|
// ===== VALIDIERUNGS-FUNKTIONEN =====
|
||||||
|
const validateStep1 = (): boolean => {
|
||||||
if (!formData.firstname.trim()) {
|
if (!formData.firstname.trim()) {
|
||||||
setError('Bitte geben Sie einen Vornamen ein.');
|
setError('Bitte geben Sie einen Vornamen ein.');
|
||||||
return false;
|
return false;
|
||||||
@@ -48,21 +61,78 @@ const Setup: React.FC = () => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = () => {
|
const validateStep2 = (): boolean => {
|
||||||
setError('');
|
if (formData.password.length < 8) {
|
||||||
if (step === 1 && validateStep1()) {
|
setError('Das Passwort muss mindestens 8 Zeichen lang sein.');
|
||||||
setStep(2);
|
return false;
|
||||||
} else if (step === 2 && validateStep2()) {
|
}
|
||||||
handleSubmit();
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
setError('Die Passwörter stimmen nicht überein.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateCurrentStep = (stepIndex: number): boolean => {
|
||||||
|
switch (stepIndex) {
|
||||||
|
case 0:
|
||||||
|
return validateStep1();
|
||||||
|
case 1:
|
||||||
|
return validateStep2();
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
// ===== NAVIGATIONS-FUNKTIONEN =====
|
||||||
|
const goToNextStep = async (): Promise<void> => {
|
||||||
setError('');
|
setError('');
|
||||||
setStep(1);
|
|
||||||
|
if (!validateCurrentStep(currentStep)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn wir beim letzten Schritt sind, Submit ausführen
|
||||||
|
if (currentStep === steps.length - 1) {
|
||||||
|
await handleSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ansonsten zum nächsten Schritt gehen
|
||||||
|
setCurrentStep(prev => prev + 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const goToPrevStep = (): void => {
|
||||||
|
setError('');
|
||||||
|
if (currentStep > 0) {
|
||||||
|
setCurrentStep(prev => prev - 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStepChange = (stepIndex: number): void => {
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
// Nur erlauben, zu bereits validierten Schritten zu springen
|
||||||
|
// oder zum nächsten Schritt nach dem aktuellen
|
||||||
|
if (stepIndex <= currentStep + 1) {
|
||||||
|
// Vor dem Wechsel validieren
|
||||||
|
if (stepIndex > currentStep && !validateCurrentStep(currentStep)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentStep(stepIndex);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== FORM HANDLER =====
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
@@ -93,7 +163,7 @@ const Setup: React.FC = () => {
|
|||||||
|
|
||||||
// Setup Status neu prüfen
|
// Setup Status neu prüfen
|
||||||
await checkSetupStatus();
|
await checkSetupStatus();
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('❌ Setup error:', err);
|
console.error('❌ Setup error:', err);
|
||||||
setError(err.message || 'Ein unerwarteter Fehler ist aufgetreten');
|
setError(err.message || 'Ein unerwarteter Fehler ist aufgetreten');
|
||||||
@@ -102,20 +172,391 @@ const Setup: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to display generated email preview
|
// ===== HELPER FUNCTIONS =====
|
||||||
const getEmailPreview = () => {
|
const getEmailPreview = (): string => {
|
||||||
if (!formData.firstname.trim() || !formData.lastname.trim()) {
|
if (!formData.firstname.trim() || !formData.lastname.trim()) {
|
||||||
return 'vorname.nachname@sp.de';
|
return 'vorname.nachname@sp.de';
|
||||||
}
|
}
|
||||||
|
|
||||||
const cleanFirstname = formData.firstname.toLowerCase().replace(/[^a-z0-9]/g, '');
|
const cleanFirstname = formData.firstname.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
const cleanLastname = formData.lastname.toLowerCase().replace(/[^a-z0-9]/g, '');
|
const cleanLastname = formData.lastname.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
return `${cleanFirstname}.${cleanLastname}@sp.de`;
|
return `${cleanFirstname}.${cleanLastname}@sp.de`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isStepCompleted = (stepIndex: number): boolean => {
|
||||||
|
switch (stepIndex) {
|
||||||
|
case 0:
|
||||||
|
return formData.password.length >= 8 &&
|
||||||
|
formData.password === formData.confirmPassword;
|
||||||
|
case 1:
|
||||||
|
return !!formData.firstname.trim() && !!formData.lastname.trim();
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
currentStep,
|
||||||
|
formData,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
steps,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
goToNextStep,
|
||||||
|
goToPrevStep,
|
||||||
|
handleStepChange,
|
||||||
|
handleInputChange,
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
getEmailPreview,
|
||||||
|
isStepCompleted
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== STEP-INHALTS-KOMPONENTEN =====
|
||||||
|
interface StepContentProps {
|
||||||
|
formData: SetupFormData;
|
||||||
|
onInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
getEmailPreview: () => string;
|
||||||
|
currentStep: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Step1Content: React.FC<StepContentProps> = ({
|
||||||
|
formData,
|
||||||
|
onInputChange,
|
||||||
|
getEmailPreview
|
||||||
|
}) => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#495057'
|
||||||
|
}}>
|
||||||
|
Vorname
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="firstname"
|
||||||
|
value={formData.firstname}
|
||||||
|
onChange={onInputChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.75rem',
|
||||||
|
border: '1px solid #ced4da',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '1rem'
|
||||||
|
}}
|
||||||
|
placeholder="Max"
|
||||||
|
required
|
||||||
|
autoComplete="given-name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#495057'
|
||||||
|
}}>
|
||||||
|
Nachname
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="lastname"
|
||||||
|
value={formData.lastname}
|
||||||
|
onChange={onInputChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.75rem',
|
||||||
|
border: '1px solid #ced4da',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '1rem'
|
||||||
|
}}
|
||||||
|
placeholder="Mustermann"
|
||||||
|
required
|
||||||
|
autoComplete="family-name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#495057'
|
||||||
|
}}>
|
||||||
|
Automatisch generierte E-Mail
|
||||||
|
</label>
|
||||||
|
<div style={{
|
||||||
|
padding: '0.75rem',
|
||||||
|
backgroundColor: '#e9ecef',
|
||||||
|
border: '1px solid #ced4da',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: '#495057',
|
||||||
|
fontWeight: '500',
|
||||||
|
fontFamily: 'monospace'
|
||||||
|
}}>
|
||||||
|
{getEmailPreview()}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
color: '#6c757d',
|
||||||
|
marginTop: '0.25rem'
|
||||||
|
}}>
|
||||||
|
Die E-Mail wird automatisch aus Vor- und Nachname generiert
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
const Step2Content: React.FC<StepContentProps> = ({
|
||||||
|
formData,
|
||||||
|
onInputChange
|
||||||
|
}) => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#495057'
|
||||||
|
}}>
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={onInputChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.75rem',
|
||||||
|
border: '1px solid #ced4da',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '1rem',
|
||||||
|
transition: 'border-color 0.3s ease'
|
||||||
|
}}
|
||||||
|
placeholder="Mindestens 8 Zeichen"
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#495057'
|
||||||
|
}}>
|
||||||
|
Passwort bestätigen
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={onInputChange}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.75rem',
|
||||||
|
border: '1px solid #ced4da',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '1rem',
|
||||||
|
transition: 'border-color 0.3s ease'
|
||||||
|
}}
|
||||||
|
placeholder="Passwort wiederholen"
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Step3Content: React.FC<StepContentProps> = ({
|
||||||
|
formData,
|
||||||
|
getEmailPreview
|
||||||
|
}) => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#f8f9fa',
|
||||||
|
padding: '1.5rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #e9ecef'
|
||||||
|
}}>
|
||||||
|
<h3 style={{
|
||||||
|
marginBottom: '1rem',
|
||||||
|
color: '#2c3e50',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
fontWeight: '600'
|
||||||
|
}}>
|
||||||
|
Zusammenfassung
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: '#6c757d' }}>E-Mail:</span>
|
||||||
|
<span style={{ fontWeight: '500' }}>{getEmailPreview()}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: '#6c757d' }}>Vorname:</span>
|
||||||
|
<span style={{ fontWeight: '500' }}>{formData.firstname || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
|
<span style={{ color: '#6c757d' }}>Nachname:</span>
|
||||||
|
<span style={{ fontWeight: '500' }}>{formData.lastname || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '1rem',
|
||||||
|
backgroundColor: '#e7f3ff',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid #b6d7e8',
|
||||||
|
color: '#2c3e50'
|
||||||
|
}}>
|
||||||
|
<strong>💡 Wichtig:</strong> Nach dem Setup können Sie sich mit Ihrer
|
||||||
|
automatisch generierten E-Mail anmelden.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ===== HAUPTKOMPONENTE =====
|
||||||
|
const Setup: React.FC = () => {
|
||||||
|
const {
|
||||||
|
currentStep,
|
||||||
|
formData,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
steps,
|
||||||
|
goToNextStep,
|
||||||
|
goToPrevStep,
|
||||||
|
handleStepChange,
|
||||||
|
handleInputChange,
|
||||||
|
getEmailPreview
|
||||||
|
} = useSetup();
|
||||||
|
|
||||||
|
const renderStepContent = (): React.ReactNode => {
|
||||||
|
const stepProps = {
|
||||||
|
formData,
|
||||||
|
onInputChange: handleInputChange,
|
||||||
|
getEmailPreview,
|
||||||
|
currentStep
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (currentStep) {
|
||||||
|
case 0:
|
||||||
|
return <Step1Content {...stepProps} />;
|
||||||
|
case 1:
|
||||||
|
return <Step2Content {...stepProps} />;
|
||||||
|
case 2:
|
||||||
|
return <Step3Content {...stepProps} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNextButtonText = (): string => {
|
||||||
|
if (loading) return '⏳ Wird verarbeitet...';
|
||||||
|
|
||||||
|
switch (currentStep) {
|
||||||
|
case 0:
|
||||||
|
return 'Weiter →';
|
||||||
|
case 1:
|
||||||
|
return 'Weiter →';
|
||||||
|
case 2:
|
||||||
|
return 'Setup abschließen';
|
||||||
|
default:
|
||||||
|
return 'Weiter →';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inline Step Indicator Komponente
|
||||||
|
const StepIndicator: React.FC = () => (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '2.5rem',
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%'
|
||||||
|
}}>
|
||||||
|
{/* Verbindungslinien */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '12px',
|
||||||
|
left: '0',
|
||||||
|
right: '0',
|
||||||
|
height: '2px',
|
||||||
|
backgroundColor: '#e9ecef',
|
||||||
|
zIndex: 1
|
||||||
|
}} />
|
||||||
|
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isCompleted = index < currentStep;
|
||||||
|
const isCurrent = index === currentStep;
|
||||||
|
const isClickable = index <= currentStep + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 2,
|
||||||
|
position: 'relative',
|
||||||
|
flex: 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => isClickable && handleStepChange(index)}
|
||||||
|
disabled={!isClickable}
|
||||||
|
style={{
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: '2px solid',
|
||||||
|
borderColor: isCompleted || isCurrent ? '#51258f' : '#e9ecef',
|
||||||
|
backgroundColor: isCompleted ? '#51258f' : 'white',
|
||||||
|
color: isCompleted ? 'white' : (isCurrent ? '#51258f' : '#6c757d'),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
cursor: isClickable ? 'pointer' : 'not-allowed',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
marginBottom: '8px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: isCurrent ? '600' : '400',
|
||||||
|
color: isCurrent ? '#51258f' : '#6c757d'
|
||||||
|
}}>
|
||||||
|
{step.title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
backgroundColor: '#f8f9fa',
|
backgroundColor: '#f8f9fa',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -128,26 +569,51 @@ const Setup: React.FC = () => {
|
|||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
boxShadow: '0 10px 30px rgba(0,0,0,0.1)',
|
boxShadow: '0 10px 30px rgba(0,0,0,0.1)',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: '500px',
|
maxWidth: '600px',
|
||||||
border: '1px solid #e9ecef'
|
border: '1px solid #e9ecef'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
|
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
|
||||||
<h1 style={{
|
<h1 style={{
|
||||||
fontSize: '2rem',
|
fontSize: '2rem',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
marginBottom: '0.5rem',
|
marginBottom: '0.5rem',
|
||||||
color: '#2c3e50'
|
color: '#2c3e50'
|
||||||
}}>
|
}}>
|
||||||
🚀 Erstkonfiguration
|
🚀 Erstkonfiguration
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{
|
<p style={{
|
||||||
color: '#6c757d',
|
color: '#6c757d',
|
||||||
fontSize: '1.1rem'
|
fontSize: '1.1rem',
|
||||||
|
marginBottom: '2rem'
|
||||||
}}>
|
}}>
|
||||||
Richten Sie Ihren Administrator-Account ein
|
Richten Sie Ihren Administrator-Account ein
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Aktueller Schritt Titel und Beschreibung */}
|
||||||
|
<div style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
|
||||||
|
<h2 style={{
|
||||||
|
fontSize: '1.5rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
color: '#2c3e50'
|
||||||
|
}}>
|
||||||
|
{steps[currentStep].title}
|
||||||
|
</h2>
|
||||||
|
{steps[currentStep].subtitle && (
|
||||||
|
<p style={{
|
||||||
|
color: '#6c757d',
|
||||||
|
fontSize: '1rem'
|
||||||
|
}}>
|
||||||
|
{steps[currentStep].subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline Step Indicator */}
|
||||||
|
<StepIndicator />
|
||||||
|
|
||||||
|
{/* Fehleranzeige */}
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{
|
<div style={{
|
||||||
backgroundColor: '#f8d7da',
|
backgroundColor: '#f8d7da',
|
||||||
@@ -161,207 +627,66 @@ const Setup: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 1 && (
|
{/* Schritt-Inhalt */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
<div style={{ minHeight: '200px' }}>
|
||||||
<div>
|
{renderStepContent()}
|
||||||
<label style={{
|
</div>
|
||||||
display: 'block',
|
|
||||||
marginBottom: '0.5rem',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#495057'
|
|
||||||
}}>
|
|
||||||
Passwort
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="password"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.75rem',
|
|
||||||
border: '1px solid #ced4da',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '1rem',
|
|
||||||
transition: 'border-color 0.3s ease'
|
|
||||||
}}
|
|
||||||
placeholder="Mindestens 6 Zeichen"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={{
|
|
||||||
display: 'block',
|
|
||||||
marginBottom: '0.5rem',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#495057'
|
|
||||||
}}>
|
|
||||||
Passwort bestätigen
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
name="confirmPassword"
|
|
||||||
value={formData.confirmPassword}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.75rem',
|
|
||||||
border: '1px solid #ced4da',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '1rem',
|
|
||||||
transition: 'border-color 0.3s ease'
|
|
||||||
}}
|
|
||||||
placeholder="Passwort wiederholen"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 2 && (
|
{/* Navigations-Buttons */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
<div style={{
|
||||||
<div>
|
marginTop: '2rem',
|
||||||
<label style={{
|
display: 'flex',
|
||||||
display: 'block',
|
|
||||||
marginBottom: '0.5rem',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#495057'
|
|
||||||
}}>
|
|
||||||
Vorname
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="firstname"
|
|
||||||
value={formData.firstname}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.75rem',
|
|
||||||
border: '1px solid #ced4da',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '1rem'
|
|
||||||
}}
|
|
||||||
placeholder="Max"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={{
|
|
||||||
display: 'block',
|
|
||||||
marginBottom: '0.5rem',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#495057'
|
|
||||||
}}>
|
|
||||||
Nachname
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="lastname"
|
|
||||||
value={formData.lastname}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '0.75rem',
|
|
||||||
border: '1px solid #ced4da',
|
|
||||||
borderRadius: '6px',
|
|
||||||
fontSize: '1rem'
|
|
||||||
}}
|
|
||||||
placeholder="Mustermann"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={{
|
|
||||||
display: 'block',
|
|
||||||
marginBottom: '0.5rem',
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#495057'
|
|
||||||
}}>
|
|
||||||
Automatisch generierte E-Mail
|
|
||||||
</label>
|
|
||||||
<div style={{
|
|
||||||
padding: '0.75rem',
|
|
||||||
backgroundColor: '#e9ecef',
|
|
||||||
border: '1px solid #ced4da',
|
|
||||||
borderRadius: '6px',
|
|
||||||
color: '#495057',
|
|
||||||
fontWeight: '500',
|
|
||||||
fontFamily: 'monospace'
|
|
||||||
}}>
|
|
||||||
{getEmailPreview()}
|
|
||||||
</div>
|
|
||||||
<div style={{
|
|
||||||
fontSize: '0.875rem',
|
|
||||||
color: '#6c757d',
|
|
||||||
marginTop: '0.25rem'
|
|
||||||
}}>
|
|
||||||
Die E-Mail wird automatisch aus Vor- und Nachname generiert
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
marginTop: '2rem',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center'
|
alignItems: 'center'
|
||||||
}}>
|
}}>
|
||||||
{step === 2 && (
|
|
||||||
<button
|
|
||||||
onClick={handleBack}
|
|
||||||
style={{
|
|
||||||
padding: '0.75rem 1.5rem',
|
|
||||||
color: '#6c757d',
|
|
||||||
border: '1px solid #6c757d',
|
|
||||||
background: 'none',
|
|
||||||
borderRadius: '6px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontWeight: '500'
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
← Zurück
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
onClick={goToPrevStep}
|
||||||
|
disabled={loading || currentStep === 0}
|
||||||
|
style={{
|
||||||
|
padding: '0.75rem 1.5rem',
|
||||||
|
color: loading || currentStep === 0 ? '#adb5bd' : '#6c757d',
|
||||||
|
border: `1px solid ${loading || currentStep === 0 ? '#adb5bd' : '#6c757d'}`,
|
||||||
|
background: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: loading || currentStep === 0 ? 'not-allowed' : 'pointer',
|
||||||
|
fontWeight: '500',
|
||||||
|
opacity: loading || currentStep === 0 ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Zurück
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={goToNextStep}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
style={{
|
style={{
|
||||||
padding: '0.75rem 2rem',
|
padding: '0.75rem 2rem',
|
||||||
backgroundColor: loading ? '#6c757d' : '#007bff',
|
backgroundColor: loading ? '#6c757d' : '#51258f',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
cursor: loading ? 'not-allowed' : 'pointer',
|
cursor: loading ? 'not-allowed' : 'pointer',
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
fontSize: '1rem',
|
fontSize: '1rem',
|
||||||
marginLeft: step === 1 ? 'auto' : '0',
|
|
||||||
transition: 'background-color 0.3s ease'
|
transition: 'background-color 0.3s ease'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{loading ? '⏳ Wird verarbeitet...' :
|
{getNextButtonText()}
|
||||||
step === 1 ? 'Weiter →' : 'Setup abschließen'}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{step === 2 && (
|
{/* Zusätzliche Informationen */}
|
||||||
<div style={{
|
{currentStep === 2 && !loading && (
|
||||||
marginTop: '1.5rem',
|
<div style={{
|
||||||
textAlign: 'center',
|
marginTop: '1.5rem',
|
||||||
color: '#6c757d',
|
textAlign: 'center',
|
||||||
|
color: '#6c757d',
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
backgroundColor: '#e7f3ff',
|
backgroundColor: '#f8f9fa',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px'
|
||||||
border: '1px solid #b6d7e8'
|
|
||||||
}}>
|
}}>
|
||||||
💡 Nach dem erfolgreichen Setup werden Sie zur Anmeldeseite weitergeleitet,
|
Überprüfen Sie Ihre Daten, bevor Sie das Setup abschließen
|
||||||
wo Sie sich mit Ihrer automatisch generierten E-Mail anmelden können.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
|
|
||||||
.createButton {
|
.createButton {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
background-color: #2ecc71;
|
background-color: #51258f;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.createButton:hover {
|
.createButton:hover {
|
||||||
background-color: #27ae60;
|
background-color: #51258f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.createButton:disabled {
|
.createButton:disabled {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { shiftPlanService } from '../../services/shiftPlanService';
|
import { shiftPlanService } from '../../services/shiftPlanService';
|
||||||
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
|
import { useBackendValidation } from '../../hooks/useBackendValidation';
|
||||||
import styles from './ShiftPlanCreate.module.css';
|
import styles from './ShiftPlanCreate.module.css';
|
||||||
|
|
||||||
// Interface für Template Presets
|
// Interface für Template Presets
|
||||||
@@ -14,6 +16,8 @@ interface TemplatePreset {
|
|||||||
const ShiftPlanCreate: React.FC = () => {
|
const ShiftPlanCreate: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
|
const { showNotification } = useNotification();
|
||||||
|
const { executeWithValidation, isSubmitting } = useBackendValidation();
|
||||||
|
|
||||||
const [planName, setPlanName] = useState('');
|
const [planName, setPlanName] = useState('');
|
||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
@@ -21,8 +25,6 @@ const ShiftPlanCreate: React.FC = () => {
|
|||||||
const [selectedPreset, setSelectedPreset] = useState('');
|
const [selectedPreset, setSelectedPreset] = useState('');
|
||||||
const [presets, setPresets] = useState<TemplatePreset[]>([]);
|
const [presets, setPresets] = useState<TemplatePreset[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [success, setSuccess] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTemplatePresets();
|
loadTemplatePresets();
|
||||||
@@ -42,36 +44,60 @@ const ShiftPlanCreate: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Fehler beim Laden der Vorlagen-Presets:', error);
|
console.error('❌ Fehler beim Laden der Vorlagen-Presets:', error);
|
||||||
setError('Vorlagen-Presets konnten nicht geladen werden');
|
showNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Fehler beim Laden',
|
||||||
|
message: 'Vorlagen-Presets konnten nicht geladen werden'
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
try {
|
// Basic frontend validation only
|
||||||
// Validierung
|
if (!planName.trim()) {
|
||||||
if (!planName.trim()) {
|
showNotification({
|
||||||
setError('Bitte geben Sie einen Namen für den Schichtplan ein');
|
type: 'error',
|
||||||
return;
|
title: 'Fehlende Angaben',
|
||||||
}
|
message: 'Bitte geben Sie einen Namen für den Schichtplan ein'
|
||||||
if (!startDate) {
|
});
|
||||||
setError('Bitte wählen Sie ein Startdatum');
|
return;
|
||||||
return;
|
}
|
||||||
}
|
if (!startDate) {
|
||||||
if (!endDate) {
|
showNotification({
|
||||||
setError('Bitte wählen Sie ein Enddatum');
|
type: 'error',
|
||||||
return;
|
title: 'Fehlende Angaben',
|
||||||
}
|
message: 'Bitte wählen Sie ein Startdatum'
|
||||||
if (new Date(endDate) < new Date(startDate)) {
|
});
|
||||||
setError('Das Enddatum muss nach dem Startdatum liegen');
|
return;
|
||||||
return;
|
}
|
||||||
}
|
if (!endDate) {
|
||||||
if (!selectedPreset) {
|
showNotification({
|
||||||
setError('Bitte wählen Sie eine Vorlage aus');
|
type: 'error',
|
||||||
return;
|
title: 'Fehlende Angaben',
|
||||||
}
|
message: 'Bitte wählen Sie ein Enddatum'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (new Date(endDate) < new Date(startDate)) {
|
||||||
|
showNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Ungültige Daten',
|
||||||
|
message: 'Das Enddatum muss nach dem Startdatum liegen'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!selectedPreset) {
|
||||||
|
showNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Fehlende Angaben',
|
||||||
|
message: 'Bitte wählen Sie eine Vorlage aus'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeWithValidation(async () => {
|
||||||
console.log('🔄 Erstelle Schichtplan aus Preset...', {
|
console.log('🔄 Erstelle Schichtplan aus Preset...', {
|
||||||
presetName: selectedPreset,
|
presetName: selectedPreset,
|
||||||
name: planName,
|
name: planName,
|
||||||
@@ -91,16 +117,16 @@ const ShiftPlanCreate: React.FC = () => {
|
|||||||
console.log('✅ Plan erstellt:', createdPlan);
|
console.log('✅ Plan erstellt:', createdPlan);
|
||||||
|
|
||||||
// Erfolgsmeldung und Weiterleitung
|
// Erfolgsmeldung und Weiterleitung
|
||||||
setSuccess('Schichtplan erfolgreich erstellt!');
|
showNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Erfolg',
|
||||||
|
message: 'Schichtplan erfolgreich erstellt!'
|
||||||
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate(`/shift-plans/${createdPlan.id}`);
|
navigate(`/shift-plans/${createdPlan.id}`);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
|
});
|
||||||
} catch (error) {
|
|
||||||
const err = error as Error;
|
|
||||||
console.error('❌ Fehler beim Erstellen des Plans:', err);
|
|
||||||
setError(`Plan konnte nicht erstellt werden: ${err.message}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSelectedPresetDescription = () => {
|
const getSelectedPresetDescription = () => {
|
||||||
@@ -120,22 +146,14 @@ const ShiftPlanCreate: React.FC = () => {
|
|||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h1>Neuen Schichtplan erstellen</h1>
|
<h1>Neuen Schichtplan erstellen</h1>
|
||||||
<button onClick={() => navigate(-1)} className={styles.backButton}>
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className={styles.backButton}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
Zurück
|
Zurück
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className={styles.error}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{success && (
|
|
||||||
<div className={styles.success}>
|
|
||||||
{success}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={styles.form}>
|
<div className={styles.form}>
|
||||||
<div className={styles.formGroup}>
|
<div className={styles.formGroup}>
|
||||||
@@ -146,6 +164,7 @@ const ShiftPlanCreate: React.FC = () => {
|
|||||||
onChange={(e) => setPlanName(e.target.value)}
|
onChange={(e) => setPlanName(e.target.value)}
|
||||||
placeholder="z.B. KW 42 2025"
|
placeholder="z.B. KW 42 2025"
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -157,6 +176,7 @@ const ShiftPlanCreate: React.FC = () => {
|
|||||||
value={startDate}
|
value={startDate}
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
onChange={(e) => setStartDate(e.target.value)}
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -167,6 +187,7 @@ const ShiftPlanCreate: React.FC = () => {
|
|||||||
value={endDate}
|
value={endDate}
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,6 +198,7 @@ const ShiftPlanCreate: React.FC = () => {
|
|||||||
value={selectedPreset}
|
value={selectedPreset}
|
||||||
onChange={(e) => setSelectedPreset(e.target.value)}
|
onChange={(e) => setSelectedPreset(e.target.value)}
|
||||||
className={`${styles.select} ${presets.length === 0 ? styles.empty : ''}`}
|
className={`${styles.select} ${presets.length === 0 ? styles.empty : ''}`}
|
||||||
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
<option value="">Bitte wählen...</option>
|
<option value="">Bitte wählen...</option>
|
||||||
{presets.map(preset => (
|
{presets.map(preset => (
|
||||||
@@ -203,9 +225,9 @@ const ShiftPlanCreate: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
className={styles.createButton}
|
className={styles.createButton}
|
||||||
disabled={!selectedPreset || !planName.trim() || !startDate || !endDate}
|
disabled={isSubmitting || !selectedPreset || !planName.trim() || !startDate || !endDate}
|
||||||
>
|
>
|
||||||
Schichtplan erstellen
|
{isSubmitting ? 'Wird erstellt...' : 'Schichtplan erstellen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import { shiftPlanService } from '../../services/shiftPlanService';
|
import { shiftPlanService } from '../../services/shiftPlanService';
|
||||||
import { ShiftPlan, Shift, ScheduledShift } from '../../models/ShiftPlan';
|
import { ShiftPlan, Shift, ScheduledShift } from '../../models/ShiftPlan';
|
||||||
import { useNotification } from '../../contexts/NotificationContext';
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
|
import { useBackendValidation } from '../../hooks/useBackendValidation';
|
||||||
|
|
||||||
const ShiftPlanEdit: React.FC = () => {
|
const ShiftPlanEdit: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { showNotification } = useNotification();
|
const { showNotification, confirmDialog } = useNotification();
|
||||||
|
const { executeWithValidation, isSubmitting } = useBackendValidation();
|
||||||
|
|
||||||
const [shiftPlan, setShiftPlan] = useState<ShiftPlan | null>(null);
|
const [shiftPlan, setShiftPlan] = useState<ShiftPlan | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editingShift, setEditingShift] = useState<Shift | null>(null);
|
const [editingShift, setEditingShift] = useState<Shift | null>(null);
|
||||||
@@ -24,122 +27,150 @@ const ShiftPlanEdit: React.FC = () => {
|
|||||||
|
|
||||||
const loadShiftPlan = async () => {
|
const loadShiftPlan = async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
try {
|
|
||||||
const plan = await shiftPlanService.getShiftPlan(id);
|
await executeWithValidation(async () => {
|
||||||
setShiftPlan(plan);
|
try {
|
||||||
} catch (error) {
|
const plan = await shiftPlanService.getShiftPlan(id);
|
||||||
console.error('Error loading shift plan:', error);
|
setShiftPlan(plan);
|
||||||
showNotification({
|
} catch (error) {
|
||||||
type: 'error',
|
console.error('Error loading shift plan:', error);
|
||||||
title: 'Fehler',
|
navigate('/shift-plans');
|
||||||
message: 'Der Schichtplan konnte nicht geladen werden.'
|
} finally {
|
||||||
});
|
setLoading(false);
|
||||||
navigate('/shift-plans');
|
}
|
||||||
} finally {
|
});
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateShift = async (shift: Shift) => {
|
const handleUpdateShift = async (shift: Shift) => {
|
||||||
if (!shiftPlan || !id) return;
|
if (!shiftPlan || !id) return;
|
||||||
|
|
||||||
try {
|
await executeWithValidation(async () => {
|
||||||
// Update logic here
|
// Update logic here - will be implemented when backend API is available
|
||||||
|
// For now, just simulate success
|
||||||
|
console.log('Updating shift:', shift);
|
||||||
|
|
||||||
loadShiftPlan();
|
loadShiftPlan();
|
||||||
setEditingShift(null);
|
setEditingShift(null);
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating shift:', error);
|
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'success',
|
||||||
title: 'Fehler',
|
title: 'Erfolg',
|
||||||
message: 'Die Schicht konnte nicht aktualisiert werden.'
|
message: 'Schicht wurde erfolgreich aktualisiert.'
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddShift = async () => {
|
const handleAddShift = async () => {
|
||||||
if (!shiftPlan || !id) return;
|
if (!shiftPlan || !id) return;
|
||||||
|
|
||||||
if (!newShift.timeSlotId || !newShift.requiredEmployees) {
|
// Basic frontend validation only
|
||||||
|
if (!newShift.timeSlotId) {
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
title: 'Fehler',
|
title: 'Fehlende Angaben',
|
||||||
message: 'Bitte füllen Sie alle Pflichtfelder aus.'
|
message: 'Bitte wählen Sie einen Zeit-Slot aus.'
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (!newShift.requiredEmployees || newShift.requiredEmployees < 1) {
|
||||||
// Add shift logic here
|
showNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Fehlende Angaben',
|
||||||
|
message: 'Bitte geben Sie die Anzahl der benötigten Mitarbeiter an.'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeWithValidation(async () => {
|
||||||
|
// Add shift logic here - will be implemented when backend API is available
|
||||||
|
// For now, just simulate success
|
||||||
|
console.log('Adding shift:', newShift);
|
||||||
|
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: 'Erfolg',
|
title: 'Erfolg',
|
||||||
message: 'Neue Schicht wurde hinzugefügt.'
|
message: 'Neue Schicht wurde hinzugefügt.'
|
||||||
});
|
});
|
||||||
|
|
||||||
setNewShift({
|
setNewShift({
|
||||||
timeSlotId: '',
|
timeSlotId: '',
|
||||||
dayOfWeek: 1,
|
dayOfWeek: 1,
|
||||||
requiredEmployees: 1
|
requiredEmployees: 1
|
||||||
});
|
});
|
||||||
loadShiftPlan();
|
loadShiftPlan();
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Error adding shift:', error);
|
|
||||||
showNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Fehler',
|
|
||||||
message: 'Die Schicht konnte nicht hinzugefügt werden.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteShift = async (shiftId: string) => {
|
const handleDeleteShift = async (shiftId: string) => {
|
||||||
if (!window.confirm('Möchten Sie diese Schicht wirklich löschen?')) {
|
const confirmed = await confirmDialog({
|
||||||
return;
|
title: 'Schicht löschen',
|
||||||
}
|
message: 'Möchten Sie diese Schicht wirklich löschen?',
|
||||||
|
confirmText: 'Löschen',
|
||||||
|
cancelText: 'Abbrechen',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
if (!confirmed) return;
|
||||||
// Delete logic here
|
|
||||||
|
await executeWithValidation(async () => {
|
||||||
|
// Delete logic here - will be implemented when backend API is available
|
||||||
|
// For now, just simulate success
|
||||||
|
console.log('Deleting shift:', shiftId);
|
||||||
|
|
||||||
loadShiftPlan();
|
loadShiftPlan();
|
||||||
} catch (error) {
|
|
||||||
console.error('Error deleting shift:', error);
|
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'error',
|
type: 'success',
|
||||||
title: 'Fehler',
|
title: 'Erfolg',
|
||||||
message: 'Die Schicht konnte nicht gelöscht werden.'
|
message: 'Schicht wurde erfolgreich gelöscht.'
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePublish = async () => {
|
const handlePublish = async () => {
|
||||||
if (!shiftPlan || !id) return;
|
if (!shiftPlan || !id) return;
|
||||||
|
|
||||||
try {
|
await executeWithValidation(async () => {
|
||||||
await shiftPlanService.updateShiftPlan(id, {
|
await shiftPlanService.updateShiftPlan(id, {
|
||||||
...shiftPlan,
|
...shiftPlan,
|
||||||
status: 'published'
|
status: 'published'
|
||||||
});
|
});
|
||||||
|
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: 'Erfolg',
|
title: 'Erfolg',
|
||||||
message: 'Schichtplan wurde veröffentlicht.'
|
message: 'Schichtplan wurde veröffentlicht.'
|
||||||
});
|
});
|
||||||
|
|
||||||
loadShiftPlan();
|
loadShiftPlan();
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Error publishing shift plan:', error);
|
|
||||||
showNotification({
|
|
||||||
type: 'error',
|
|
||||||
title: 'Fehler',
|
|
||||||
message: 'Der Schichtplan konnte nicht veröffentlicht werden.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div>Lade Schichtplan...</div>;
|
return (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '40px',
|
||||||
|
fontSize: '18px',
|
||||||
|
color: '#666'
|
||||||
|
}}>
|
||||||
|
Lade Schichtplan...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shiftPlan) {
|
if (!shiftPlan) {
|
||||||
return <div>Schichtplan nicht gefunden</div>;
|
return (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '40px',
|
||||||
|
fontSize: '18px',
|
||||||
|
color: '#e74c3c'
|
||||||
|
}}>
|
||||||
|
Schichtplan nicht gefunden
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Group shifts by dayOfWeek
|
// Group shifts by dayOfWeek
|
||||||
@@ -174,28 +205,32 @@ const ShiftPlanEdit: React.FC = () => {
|
|||||||
{shiftPlan.status === 'draft' && (
|
{shiftPlan.status === 'draft' && (
|
||||||
<button
|
<button
|
||||||
onClick={handlePublish}
|
onClick={handlePublish}
|
||||||
|
disabled={isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
backgroundColor: '#2ecc71',
|
backgroundColor: '#2ecc71',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: isSubmitting ? 'not-allowed' : 'pointer',
|
||||||
marginRight: '10px'
|
marginRight: '10px',
|
||||||
|
opacity: isSubmitting ? 0.6 : 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Veröffentlichen
|
{isSubmitting ? 'Wird veröffentlicht...' : 'Veröffentlichen'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/shift-plans')}
|
onClick={() => navigate('/shift-plans')}
|
||||||
|
disabled={isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
backgroundColor: '#95a5a6',
|
backgroundColor: '#95a5a6',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer'
|
cursor: isSubmitting ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isSubmitting ? 0.6 : 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Zurück
|
Zurück
|
||||||
@@ -219,6 +254,7 @@ const ShiftPlanEdit: React.FC = () => {
|
|||||||
value={newShift.dayOfWeek}
|
value={newShift.dayOfWeek}
|
||||||
onChange={(e) => setNewShift({ ...newShift, dayOfWeek: parseInt(e.target.value) })}
|
onChange={(e) => setNewShift({ ...newShift, dayOfWeek: parseInt(e.target.value) })}
|
||||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||||
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{daysOfWeek.map(day => (
|
{daysOfWeek.map(day => (
|
||||||
<option key={day.id} value={day.id}>{day.name}</option>
|
<option key={day.id} value={day.id}>{day.name}</option>
|
||||||
@@ -231,6 +267,7 @@ const ShiftPlanEdit: React.FC = () => {
|
|||||||
value={newShift.timeSlotId}
|
value={newShift.timeSlotId}
|
||||||
onChange={(e) => setNewShift({ ...newShift, timeSlotId: e.target.value })}
|
onChange={(e) => setNewShift({ ...newShift, timeSlotId: e.target.value })}
|
||||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||||
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
<option value="">Bitte auswählen...</option>
|
<option value="">Bitte auswählen...</option>
|
||||||
{shiftPlan.timeSlots.map(slot => (
|
{shiftPlan.timeSlots.map(slot => (
|
||||||
@@ -246,25 +283,27 @@ const ShiftPlanEdit: React.FC = () => {
|
|||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
value={newShift.requiredEmployees}
|
value={newShift.requiredEmployees}
|
||||||
onChange={(e) => setNewShift({ ...newShift, requiredEmployees: parseInt(e.target.value) })}
|
onChange={(e) => setNewShift({ ...newShift, requiredEmployees: parseInt(e.target.value) || 1 })}
|
||||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||||
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddShift}
|
onClick={handleAddShift}
|
||||||
disabled={!newShift.timeSlotId || !newShift.requiredEmployees}
|
disabled={isSubmitting || !newShift.timeSlotId || !newShift.requiredEmployees}
|
||||||
style={{
|
style={{
|
||||||
marginTop: '15px',
|
marginTop: '15px',
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
backgroundColor: '#3498db',
|
backgroundColor: isSubmitting ? '#bdc3c7' : '#3498db',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer'
|
cursor: isSubmitting ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: (!newShift.timeSlotId || !newShift.requiredEmployees) ? 0.6 : 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Schicht hinzufügen
|
{isSubmitting ? 'Wird hinzugefügt...' : 'Schicht hinzufügen'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -300,6 +339,7 @@ const ShiftPlanEdit: React.FC = () => {
|
|||||||
value={editingShift.timeSlotId}
|
value={editingShift.timeSlotId}
|
||||||
onChange={(e) => setEditingShift({ ...editingShift, timeSlotId: e.target.value })}
|
onChange={(e) => setEditingShift({ ...editingShift, timeSlotId: e.target.value })}
|
||||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||||
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{shiftPlan.timeSlots.map(slot => (
|
{shiftPlan.timeSlots.map(slot => (
|
||||||
<option key={slot.id} value={slot.id}>
|
<option key={slot.id} value={slot.id}>
|
||||||
@@ -314,33 +354,37 @@ const ShiftPlanEdit: React.FC = () => {
|
|||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
value={editingShift.requiredEmployees}
|
value={editingShift.requiredEmployees}
|
||||||
onChange={(e) => setEditingShift({ ...editingShift, requiredEmployees: parseInt(e.target.value) })}
|
onChange={(e) => setEditingShift({ ...editingShift, requiredEmployees: parseInt(e.target.value) || 1 })}
|
||||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
|
||||||
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: '10px', alignItems: 'flex-end' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleUpdateShift(editingShift)}
|
onClick={() => handleUpdateShift(editingShift)}
|
||||||
|
disabled={isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
backgroundColor: '#2ecc71',
|
backgroundColor: isSubmitting ? '#bdc3c7' : '#2ecc71',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer'
|
cursor: isSubmitting ? 'not-allowed' : 'pointer'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Speichern
|
{isSubmitting ? 'Speichern...' : 'Speichern'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingShift(null)}
|
onClick={() => setEditingShift(null)}
|
||||||
|
disabled={isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
backgroundColor: '#95a5a6',
|
backgroundColor: '#95a5a6',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer'
|
cursor: isSubmitting ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isSubmitting ? 0.6 : 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
@@ -359,27 +403,31 @@ const ShiftPlanEdit: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingShift(shift)}
|
onClick={() => setEditingShift(shift)}
|
||||||
|
disabled={isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
backgroundColor: '#f1c40f',
|
backgroundColor: isSubmitting ? '#bdc3c7' : '#f1c40f',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer',
|
cursor: isSubmitting ? 'not-allowed' : 'pointer',
|
||||||
marginRight: '8px'
|
marginRight: '8px',
|
||||||
|
opacity: isSubmitting ? 0.6 : 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteShift(shift.id)}
|
onClick={() => handleDeleteShift(shift.id)}
|
||||||
|
disabled={isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
backgroundColor: '#e74c3c',
|
backgroundColor: isSubmitting ? '#bdc3c7' : '#e74c3c',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer'
|
cursor: isSubmitting ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isSubmitting ? 0.6 : 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Löschen
|
Löschen
|
||||||
|
|||||||
@@ -5,12 +5,15 @@ import { useAuth } from '../../contexts/AuthContext';
|
|||||||
import { shiftPlanService } from '../../services/shiftPlanService';
|
import { shiftPlanService } from '../../services/shiftPlanService';
|
||||||
import { ShiftPlan } from '../../models/ShiftPlan';
|
import { ShiftPlan } from '../../models/ShiftPlan';
|
||||||
import { useNotification } from '../../contexts/NotificationContext';
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
|
import { useBackendValidation } from '../../hooks/useBackendValidation';
|
||||||
import { formatDate } from '../../utils/foramatters';
|
import { formatDate } from '../../utils/foramatters';
|
||||||
|
|
||||||
const ShiftPlanList: React.FC = () => {
|
const ShiftPlanList: React.FC = () => {
|
||||||
const { hasRole } = useAuth();
|
const { hasRole } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { showNotification } = useNotification();
|
const { showNotification, confirmDialog } = useNotification();
|
||||||
|
const { executeWithValidation, isSubmitting } = useBackendValidation();
|
||||||
|
|
||||||
const [shiftPlans, setShiftPlans] = useState<ShiftPlan[]>([]);
|
const [shiftPlans, setShiftPlans] = useState<ShiftPlan[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -19,46 +22,80 @@ const ShiftPlanList: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadShiftPlans = async () => {
|
const loadShiftPlans = async () => {
|
||||||
try {
|
await executeWithValidation(async () => {
|
||||||
const plans = await shiftPlanService.getShiftPlans();
|
try {
|
||||||
setShiftPlans(plans);
|
const plans = await shiftPlanService.getShiftPlans();
|
||||||
} catch (error) {
|
setShiftPlans(plans);
|
||||||
console.error('Error loading shift plans:', error);
|
} catch (error) {
|
||||||
showNotification({
|
console.error('Error loading shift plans:', error);
|
||||||
type: 'error',
|
// Error is automatically handled by executeWithValidation
|
||||||
title: 'Fehler',
|
} finally {
|
||||||
message: 'Die Schichtpläne konnten nicht geladen werden.'
|
setLoading(false);
|
||||||
});
|
}
|
||||||
} finally {
|
});
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string, planName: string) => {
|
||||||
if (!window.confirm('Möchten Sie diesen Schichtplan wirklich löschen?')) {
|
const confirmed = await confirmDialog({
|
||||||
return;
|
title: 'Schichtplan löschen',
|
||||||
}
|
message: `Möchten Sie den Schichtplan "${planName}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`,
|
||||||
|
confirmText: 'Löschen',
|
||||||
|
cancelText: 'Abbrechen',
|
||||||
|
type: 'warning'
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
await executeWithValidation(async () => {
|
||||||
await shiftPlanService.deleteShiftPlan(id);
|
await shiftPlanService.deleteShiftPlan(id);
|
||||||
|
|
||||||
showNotification({
|
showNotification({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
title: 'Erfolg',
|
title: 'Erfolg',
|
||||||
message: 'Der Schichtplan wurde erfolgreich gelöscht.'
|
message: 'Der Schichtplan wurde erfolgreich gelöscht.'
|
||||||
});
|
});
|
||||||
|
|
||||||
loadShiftPlans();
|
loadShiftPlans();
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Error deleting shift plan:', error);
|
};
|
||||||
showNotification({
|
|
||||||
type: 'error',
|
const getStatusBadge = (status: string) => {
|
||||||
title: 'Fehler',
|
const config = {
|
||||||
message: 'Der Schichtplan konnte nicht gelöscht werden.'
|
draft: { text: 'Entwurf', color: '#f39c12', bgColor: '#fef5e7' },
|
||||||
});
|
published: { text: 'Veröffentlicht', color: '#27ae60', bgColor: '#d5f4e6' },
|
||||||
}
|
archived: { text: 'Archiviert', color: '#95a5a6', bgColor: '#f8f9fa' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusConfig = config[status as keyof typeof config] || config.draft;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
backgroundColor: statusConfig.bgColor,
|
||||||
|
color: statusConfig.color,
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
display: 'inline-block'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{statusConfig.text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div>Lade Schichtpläne...</div>;
|
return (
|
||||||
|
<div style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
padding: '40px',
|
||||||
|
fontSize: '18px',
|
||||||
|
color: '#666'
|
||||||
|
}}>
|
||||||
|
Lade Schichtpläne...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -97,6 +134,21 @@ const ShiftPlanList: React.FC = () => {
|
|||||||
<div style={{ fontSize: '48px', marginBottom: '20px' }}>📋</div>
|
<div style={{ fontSize: '48px', marginBottom: '20px' }}>📋</div>
|
||||||
<h3>Keine Schichtpläne vorhanden</h3>
|
<h3>Keine Schichtpläne vorhanden</h3>
|
||||||
<p>Erstellen Sie Ihren ersten Schichtplan!</p>
|
<p>Erstellen Sie Ihren ersten Schichtplan!</p>
|
||||||
|
{hasRole(['admin', 'maintenance']) && (
|
||||||
|
<Link to="/shift-plans/new">
|
||||||
|
<button style={{
|
||||||
|
marginTop: '15px',
|
||||||
|
padding: '10px 20px',
|
||||||
|
backgroundColor: '#51258f',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}>
|
||||||
|
Ersten Plan erstellen
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'grid', gap: '20px' }}>
|
<div style={{ display: 'grid', gap: '20px' }}>
|
||||||
@@ -110,35 +162,35 @@ const ShiftPlanList: React.FC = () => {
|
|||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center'
|
alignItems: 'center',
|
||||||
|
border: plan.status === 'published' ? '2px solid #d5f4e6' : '1px solid #e0e0e0'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div>
|
<div style={{ flex: 1 }}>
|
||||||
<h3 style={{ margin: '0 0 10px 0' }}>{plan.name}</h3>
|
<h3 style={{ margin: '0 0 10px 0', color: '#2c3e50' }}>{plan.name}</h3>
|
||||||
<div style={{ color: '#666', fontSize: '14px' }}>
|
<div style={{ color: '#666', fontSize: '14px', marginBottom: '10px' }}>
|
||||||
<p style={{ margin: '0' }}>
|
<p style={{ margin: '0' }}>
|
||||||
Zeitraum: {formatDate(plan.startDate)} - {formatDate(plan.endDate)}
|
<strong>Zeitraum:</strong> {formatDate(plan.startDate)} - {formatDate(plan.endDate)}
|
||||||
</p>
|
</p>
|
||||||
<p style={{ margin: '5px 0 0 0' }}>
|
<p style={{ margin: '5px 0 0 0' }}>
|
||||||
Status: <span style={{
|
<strong>Status:</strong> {getStatusBadge(plan.status)}
|
||||||
color: plan.status === 'published' ? '#2ecc71' : '#f1c40f',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}>
|
|
||||||
{plan.status === 'published' ? 'Veröffentlicht' : 'Entwurf'}
|
|
||||||
</span>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#95a5a6' }}>
|
||||||
|
Erstellt am: {formatDate(plan.createdAt || '')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '10px' }}>
|
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/shift-plans/${plan.id}`)}
|
onClick={() => navigate(`/shift-plans/${plan.id}`)}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
backgroundColor: '#2ecc71',
|
backgroundColor: '#3498db',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer'
|
cursor: 'pointer',
|
||||||
|
minWidth: '80px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Anzeigen
|
Anzeigen
|
||||||
@@ -149,27 +201,31 @@ const ShiftPlanList: React.FC = () => {
|
|||||||
onClick={() => navigate(`/shift-plans/${plan.id}/edit`)}
|
onClick={() => navigate(`/shift-plans/${plan.id}/edit`)}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
backgroundColor: '#f1c40f',
|
backgroundColor: '#f39c12',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer'
|
cursor: 'pointer',
|
||||||
|
minWidth: '80px'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Bearbeiten
|
Bearbeiten
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(plan.id)}
|
onClick={() => handleDelete(plan.id, plan.name)}
|
||||||
|
disabled={isSubmitting}
|
||||||
style={{
|
style={{
|
||||||
padding: '8px 16px',
|
padding: '8px 16px',
|
||||||
backgroundColor: '#e74c3c',
|
backgroundColor: isSubmitting ? '#bdc3c7' : '#e74c3c',
|
||||||
color: 'white',
|
color: 'white',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: 'pointer'
|
cursor: isSubmitting ? 'not-allowed' : 'pointer',
|
||||||
|
minWidth: '80px',
|
||||||
|
opacity: isSubmitting ? 0.6 : 1
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Löschen
|
{isSubmitting ? 'Löscht...' : 'Löschen'}
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -178,6 +234,22 @@ const ShiftPlanList: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Info for users without edit permissions */}
|
||||||
|
{!hasRole(['admin', 'maintenance']) && shiftPlans.length > 0 && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '20px',
|
||||||
|
padding: '15px',
|
||||||
|
backgroundColor: '#e8f4fd',
|
||||||
|
border: '1px solid #b6d7e8',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#2c3e50'
|
||||||
|
}}>
|
||||||
|
<strong>ℹ️ Informationen:</strong> Sie können Schichtpläne nur anzeigen.
|
||||||
|
Bearbeitungsrechte benötigen Admin- oder Instandhalter-Berechtigungen.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// frontend/src/pages/ShiftPlans/ShiftPlanView.tsx - UPDATED
|
// frontend/src/pages/ShiftPlans/ShiftPlanView.tsx
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
@@ -1118,7 +1118,7 @@ const ShiftPlanView: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Assignment Preview Modal - FIXED CONDITION */}
|
{/* Assignment Preview Modal */}
|
||||||
{(showAssignmentPreview || assignmentResult) && (
|
{(showAssignmentPreview || assignmentResult) && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'fixed',
|
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';
|
import { Employee } from '../models/Employee';
|
||||||
const API_BASE = process.env.REACT_APP_API_BASE_URL || '/api';
|
import { apiClient } from './apiClient';
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -24,37 +23,15 @@ class AuthService {
|
|||||||
private token: string | null = null;
|
private token: string | null = null;
|
||||||
|
|
||||||
async login(credentials: LoginRequest): Promise<AuthResponse> {
|
async login(credentials: LoginRequest): Promise<AuthResponse> {
|
||||||
const response = await fetch(`${API_BASE}/auth/login`, {
|
const data = await apiClient.post<AuthResponse>('/auth/login', credentials);
|
||||||
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();
|
|
||||||
this.token = data.token;
|
this.token = data.token;
|
||||||
localStorage.setItem('token', data.token);
|
localStorage.setItem('token', data.token);
|
||||||
localStorage.setItem('employee', JSON.stringify(data.employee));
|
localStorage.setItem('employee', JSON.stringify(data.employee));
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async register(userData: RegisterRequest): Promise<AuthResponse> {
|
async register(userData: RegisterRequest): Promise<AuthResponse> {
|
||||||
const response = await fetch(`${API_BASE}/employees`, {
|
await apiClient.post('/employees', userData);
|
||||||
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');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.login({
|
return this.login({
|
||||||
email: userData.email,
|
email: userData.email,
|
||||||
password: userData.password
|
password: userData.password
|
||||||
@@ -68,34 +45,23 @@ class AuthService {
|
|||||||
|
|
||||||
async fetchCurrentEmployee(): Promise<Employee | null> {
|
async fetchCurrentEmployee(): Promise<Employee | null> {
|
||||||
const token = this.getToken();
|
const token = this.getToken();
|
||||||
if (!token) {
|
if (!token) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/auth/me`, {
|
const data = await apiClient.get<{ user: Employee }>('/auth/me');
|
||||||
headers: {
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
'Authorization': `Bearer ${token}`
|
return data.user;
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
const user = data.user;
|
|
||||||
localStorage.setItem('user', JSON.stringify(user));
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching current user:', error);
|
console.error('Error fetching current user:', error);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logout(): void {
|
logout(): void {
|
||||||
this.token = null;
|
this.token = null;
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
|
localStorage.removeItem('employee');
|
||||||
}
|
}
|
||||||
|
|
||||||
getToken(): string | null {
|
getToken(): string | null {
|
||||||
|
|||||||
@@ -1,146 +1,58 @@
|
|||||||
// frontend/src/services/employeeService.ts
|
|
||||||
import { Employee, CreateEmployeeRequest, UpdateEmployeeRequest, EmployeeAvailability } from '../models/Employee';
|
import { Employee, CreateEmployeeRequest, UpdateEmployeeRequest, EmployeeAvailability } from '../models/Employee';
|
||||||
|
import { apiClient } from './apiClient';
|
||||||
const API_BASE_URL = '/api';
|
|
||||||
|
|
||||||
const getAuthHeaders = () => {
|
|
||||||
const token = localStorage.getItem('token');
|
|
||||||
return {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export class EmployeeService {
|
export class EmployeeService {
|
||||||
async getEmployees(includeInactive: boolean = false): Promise<Employee[]> {
|
async getEmployees(includeInactive: boolean = false): Promise<Employee[]> {
|
||||||
console.log('🔄 Fetching employees from API...');
|
console.log('🔄 Fetching employees from API...');
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
try {
|
||||||
console.log('🔑 Token exists:', !!token);
|
const employees = await apiClient.get<Employee[]>(`/employees?includeInactive=${includeInactive}`);
|
||||||
|
console.log('✅ Employees received:', employees.length);
|
||||||
const response = await fetch(`${API_BASE_URL}/employees?includeInactive=${includeInactive}`, {
|
return employees;
|
||||||
headers: getAuthHeaders(),
|
} catch (error) {
|
||||||
});
|
console.error('❌ Error fetching employees:', error);
|
||||||
|
throw error; // Let useBackendValidation handle this
|
||||||
console.log('📡 Response status:', response.status);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const errorText = await response.text();
|
|
||||||
console.error('❌ API Error:', errorText);
|
|
||||||
throw new Error('Failed to fetch employees');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const employees = await response.json();
|
|
||||||
console.log('✅ Employees received:', employees.length);
|
|
||||||
|
|
||||||
return employees;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEmployee(id: string): Promise<Employee> {
|
async getEmployee(id: string): Promise<Employee> {
|
||||||
const response = await fetch(`${API_BASE_URL}/employees/${id}`, {
|
return apiClient.get<Employee>(`/employees/${id}`);
|
||||||
headers: getAuthHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch employee');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createEmployee(employee: CreateEmployeeRequest): Promise<Employee> {
|
async createEmployee(employee: CreateEmployeeRequest): Promise<Employee> {
|
||||||
const response = await fetch(`${API_BASE_URL}/employees`, {
|
return apiClient.post<Employee>('/employees', employee);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateEmployee(id: string, employee: UpdateEmployeeRequest): Promise<Employee> {
|
async updateEmployee(id: string, employee: UpdateEmployeeRequest): Promise<Employee> {
|
||||||
const response = await fetch(`${API_BASE_URL}/employees/${id}`, {
|
return apiClient.put<Employee>(`/employees/${id}`, employee);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteEmployee(id: string): Promise<void> {
|
async deleteEmployee(id: string): Promise<void> {
|
||||||
const response = await fetch(`${API_BASE_URL}/employees/${id}`, {
|
await apiClient.delete(`/employees/${id}`);
|
||||||
method: 'DELETE',
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Failed to delete employee');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAvailabilities(employeeId: string): Promise<EmployeeAvailability[]> {
|
async getAvailabilities(employeeId: string): Promise<EmployeeAvailability[]> {
|
||||||
const response = await fetch(`${API_BASE_URL}/employees/${employeeId}/availabilities`, {
|
return apiClient.get<EmployeeAvailability[]>(`/employees/${employeeId}/availabilities`);
|
||||||
headers: getAuthHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch availabilities');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAvailabilities(employeeId: string, data: { planId: string, availabilities: Omit<EmployeeAvailability, 'id' | 'employeeId'>[] }): Promise<EmployeeAvailability[]> {
|
async updateAvailabilities(
|
||||||
|
employeeId: string,
|
||||||
|
data: { planId: string, availabilities: Omit<EmployeeAvailability, 'id' | 'employeeId'>[] }
|
||||||
|
): Promise<EmployeeAvailability[]> {
|
||||||
console.log('🔄 Updating availabilities for employee:', employeeId);
|
console.log('🔄 Updating availabilities for employee:', employeeId);
|
||||||
const response = await fetch(`${API_BASE_URL}/employees/${employeeId}/availabilities`, {
|
return apiClient.put<EmployeeAvailability[]>(`/employees/${employeeId}/availabilities`, data);
|
||||||
method: 'PUT',
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Failed to update availabilities');
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async changePassword(id: string, data: { currentPassword: string, newPassword: string }): Promise<void> {
|
async changePassword(
|
||||||
const response = await fetch(`${API_BASE_URL}/employees/${id}/password`, {
|
id: string,
|
||||||
method: 'PUT',
|
data: { currentPassword: string, newPassword: string, confirmPassword: string }
|
||||||
headers: getAuthHeaders(),
|
): Promise<void> {
|
||||||
body: JSON.stringify(data),
|
return apiClient.put<void>(`/employees/${id}/password`, data);
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const error = await response.json();
|
|
||||||
throw new Error(error.error || 'Failed to change password');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLastLogin(employeeId: string): Promise<void> {
|
async updateLastLogin(employeeId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/employees/${employeeId}/last-login`, {
|
await apiClient.patch(`/employees/${employeeId}/last-login`);
|
||||||
method: 'PATCH',
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to update last login');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating last login:', error);
|
console.error('Error updating last login:', error);
|
||||||
throw 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 { ShiftPlan, ScheduledShift } from '../models/ShiftPlan';
|
||||||
import { Employee, EmployeeAvailability } from '../models/Employee';
|
import { Employee, EmployeeAvailability } from '../models/Employee';
|
||||||
import { authService } from './authService';
|
|
||||||
import { AssignmentResult, ScheduleRequest } from '../models/scheduling';
|
import { AssignmentResult, ScheduleRequest } from '../models/scheduling';
|
||||||
|
import { apiClient } from './apiClient';
|
||||||
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}` })
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export class ShiftAssignmentService {
|
export class ShiftAssignmentService {
|
||||||
async updateScheduledShift(id: string, updates: { assignedEmployees: string[] }): Promise<void> {
|
async updateScheduledShift(id: string, updates: { assignedEmployees: string[] }): Promise<void> {
|
||||||
try {
|
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}`, {
|
await apiClient.put(`/scheduled-shifts/${id}`, updates);
|
||||||
method: 'PUT',
|
console.log('✅ Scheduled shift updated successfully');
|
||||||
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);
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error updating scheduled shift:', error);
|
console.error('❌ Error updating scheduled shift:', error);
|
||||||
@@ -69,48 +19,16 @@ export class ShiftAssignmentService {
|
|||||||
|
|
||||||
async getScheduledShift(id: string): Promise<any> {
|
async getScheduledShift(id: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/scheduled-shifts/${id}`, {
|
return await apiClient.get(`/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) : {};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching scheduled shift:', error);
|
console.error('Error fetching scheduled shift:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// New method to get all scheduled shifts for a plan
|
|
||||||
async getScheduledShiftsForPlan(planId: string): Promise<ScheduledShift[]> {
|
async getScheduledShiftsForPlan(planId: string): Promise<ScheduledShift[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/scheduled-shifts/plan/${planId}`, {
|
const shifts = await apiClient.get<ScheduledShift[]>(`/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();
|
|
||||||
|
|
||||||
// DEBUG: Check the structure of returned shifts
|
// DEBUG: Check the structure of returned shifts
|
||||||
console.log('🔍 SCHEDULED SHIFTS STRUCTURE:', shifts.slice(0, 3));
|
console.log('🔍 SCHEDULED SHIFTS STRUCTURE:', shifts.slice(0, 3));
|
||||||
@@ -132,21 +50,7 @@ export class ShiftAssignmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async callSchedulingAPI(request: ScheduleRequest): Promise<AssignmentResult> {
|
private async callSchedulingAPI(request: ScheduleRequest): Promise<AssignmentResult> {
|
||||||
const response = await fetch(`${API_BASE_URL}/scheduling/generate-schedule`, {
|
return await apiClient.post<AssignmentResult>('/scheduling/generate-schedule', request);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async assignShifts(
|
async assignShifts(
|
||||||
|
|||||||
@@ -1,199 +1,115 @@
|
|||||||
// frontend/src/services/shiftPlanService.ts
|
import { ShiftPlan, CreateShiftPlanRequest } from '../models/ShiftPlan';
|
||||||
import { authService } from './authService';
|
import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults';
|
||||||
import { ShiftPlan, CreateShiftPlanRequest, ScheduledShift, CreateShiftFromTemplateRequest } from '../models/ShiftPlan';
|
import { apiClient } from './apiClient';
|
||||||
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();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const shiftPlanService = {
|
export const shiftPlanService = {
|
||||||
async getShiftPlans(): Promise<ShiftPlan[]> {
|
async getShiftPlans(): Promise<ShiftPlan[]> {
|
||||||
const response = await fetch(API_BASE_URL, {
|
try {
|
||||||
headers: {
|
const plans = await apiClient.get<ShiftPlan[]>('/shift-plans');
|
||||||
'Content-Type': 'application/json',
|
|
||||||
...authService.getAuthHeaders()
|
// Ensure scheduledShifts is always an array
|
||||||
}
|
return plans.map((plan: any) => ({
|
||||||
});
|
...plan,
|
||||||
|
scheduledShifts: plan.scheduledShifts || []
|
||||||
if (!response.ok) {
|
}));
|
||||||
if (response.status === 401) {
|
} catch (error: any) {
|
||||||
authService.logout();
|
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('Nicht authorisiert - bitte erneut anmelden');
|
||||||
}
|
}
|
||||||
throw new Error('Fehler beim Laden der Schichtpläne');
|
throw new Error('Fehler beim Laden der Schichtpläne');
|
||||||
}
|
}
|
||||||
|
|
||||||
const plans = await response.json();
|
|
||||||
|
|
||||||
// Ensure scheduledShifts is always an array
|
|
||||||
return plans.map((plan: any) => ({
|
|
||||||
...plan,
|
|
||||||
scheduledShifts: plan.scheduledShifts || []
|
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getShiftPlan(id: string): Promise<ShiftPlan> {
|
async getShiftPlan(id: string): Promise<ShiftPlan> {
|
||||||
const response = await fetch(`${API_BASE_URL}/${id}`, {
|
try {
|
||||||
headers: {
|
return await apiClient.get<ShiftPlan>(`/shift-plans/${id}`);
|
||||||
'Content-Type': 'application/json',
|
} catch (error: any) {
|
||||||
...authService.getAuthHeaders()
|
if (error.statusCode === 401) {
|
||||||
}
|
localStorage.removeItem('token');
|
||||||
});
|
localStorage.removeItem('employee');
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
authService.logout();
|
|
||||||
throw new Error('Nicht authorisiert - bitte erneut anmelden');
|
throw new Error('Nicht authorisiert - bitte erneut anmelden');
|
||||||
}
|
}
|
||||||
throw new Error('Schichtplan nicht gefunden');
|
throw new Error('Schichtplan nicht gefunden');
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async createShiftPlan(plan: CreateShiftPlanRequest): Promise<ShiftPlan> {
|
async createShiftPlan(plan: CreateShiftPlanRequest): Promise<ShiftPlan> {
|
||||||
const response = await fetch(API_BASE_URL, {
|
try {
|
||||||
method: 'POST',
|
return await apiClient.post<ShiftPlan>('/shift-plans', plan);
|
||||||
headers: {
|
} catch (error: any) {
|
||||||
'Content-Type': 'application/json',
|
if (error.statusCode === 401) {
|
||||||
...authService.getAuthHeaders()
|
localStorage.removeItem('token');
|
||||||
},
|
localStorage.removeItem('employee');
|
||||||
body: JSON.stringify(plan)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
authService.logout();
|
|
||||||
throw new Error('Nicht authorisiert - bitte erneut anmelden');
|
throw new Error('Nicht authorisiert - bitte erneut anmelden');
|
||||||
}
|
}
|
||||||
throw new Error('Fehler beim Erstellen des Schichtplans');
|
throw new Error('Fehler beim Erstellen des Schichtplans');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateShiftPlan(id: string, plan: Partial<ShiftPlan>): Promise<ShiftPlan> {
|
async updateShiftPlan(id: string, plan: Partial<ShiftPlan>): Promise<ShiftPlan> {
|
||||||
const response = await fetch(`${API_BASE_URL}/${id}`, {
|
try {
|
||||||
method: 'PUT',
|
return await apiClient.put<ShiftPlan>(`/shift-plans/${id}`, plan);
|
||||||
headers: {
|
} catch (error: any) {
|
||||||
'Content-Type': 'application/json',
|
if (error.statusCode === 401) {
|
||||||
...authService.getAuthHeaders()
|
localStorage.removeItem('token');
|
||||||
},
|
localStorage.removeItem('employee');
|
||||||
body: JSON.stringify(plan)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
authService.logout();
|
|
||||||
throw new Error('Nicht authorisiert - bitte erneut anmelden');
|
throw new Error('Nicht authorisiert - bitte erneut anmelden');
|
||||||
}
|
}
|
||||||
throw new Error('Fehler beim Aktualisieren des Schichtplans');
|
throw new Error('Fehler beim Aktualisieren des Schichtplans');
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.json();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteShiftPlan(id: string): Promise<void> {
|
async deleteShiftPlan(id: string): Promise<void> {
|
||||||
const response = await fetch(`${API_BASE_URL}/${id}`, {
|
try {
|
||||||
method: 'DELETE',
|
await apiClient.delete(`/shift-plans/${id}`);
|
||||||
headers: {
|
} catch (error: any) {
|
||||||
'Content-Type': 'application/json',
|
if (error.statusCode === 401) {
|
||||||
...authService.getAuthHeaders()
|
localStorage.removeItem('token');
|
||||||
}
|
localStorage.removeItem('employee');
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
if (response.status === 401) {
|
|
||||||
authService.logout();
|
|
||||||
throw new Error('Nicht authorisiert - bitte erneut anmelden');
|
throw new Error('Nicht authorisiert - bitte erneut anmelden');
|
||||||
}
|
}
|
||||||
throw new Error('Fehler beim Löschen des Schichtplans');
|
throw new Error('Fehler beim Löschen des Schichtplans');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Get specific template or plan
|
async getTemplate(id: string): Promise<ShiftPlan> {
|
||||||
getTemplate: async (id: string): Promise<ShiftPlan> => {
|
return await apiClient.get<ShiftPlan>(`/shift-plans/${id}`);
|
||||||
const response = await fetch(`${API_BASE_URL}/${id}`, {
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
return handleResponse(response);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async regenerateScheduledShifts(planId: string): Promise<void> {
|
||||||
async regenerateScheduledShifts(planId: string):Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
console.log('🔄 Attempting to regenerate scheduled shifts...');
|
console.log('🔄 Attempting to regenerate scheduled shifts...');
|
||||||
|
await apiClient.post(`/shift-plans/${planId}/regenerate-shifts`);
|
||||||
// You'll need to add this API endpoint to your backend
|
console.log('✅ Scheduled shifts regenerated');
|
||||||
const response = await fetch(`${API_BASE_URL}/${planId}/regenerate-shifts`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${localStorage.getItem('token')}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
console.log('✅ Scheduled shifts regenerated');
|
|
||||||
} else {
|
|
||||||
console.error('❌ Failed to regenerate shifts');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error regenerating shifts:', error);
|
console.error('❌ Error regenerating shifts:', error);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Create new plan
|
async createPlan(data: CreateShiftPlanRequest): Promise<ShiftPlan> {
|
||||||
createPlan: async (data: CreateShiftPlanRequest): Promise<ShiftPlan> => {
|
return await apiClient.post<ShiftPlan>('/shift-plans', data);
|
||||||
const response = await fetch(`${API_BASE_URL}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: getAuthHeaders(),
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
return handleResponse(response);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
createFromPreset: async (data: {
|
async createFromPreset(data: {
|
||||||
presetName: string;
|
presetName: string;
|
||||||
name: string;
|
name: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
isTemplate?: boolean;
|
isTemplate?: boolean;
|
||||||
}): Promise<ShiftPlan> => {
|
}): Promise<ShiftPlan> {
|
||||||
const response = await fetch(`${API_BASE_URL}/from-preset`, {
|
try {
|
||||||
method: 'POST',
|
return await apiClient.post<ShiftPlan>('/shift-plans/from-preset', data);
|
||||||
headers: getAuthHeaders(),
|
} catch (error: any) {
|
||||||
body: JSON.stringify(data),
|
throw new Error(error.message || `HTTP error! status: ${error.statusCode}`);
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getTemplatePresets: async (): Promise<{name: string, label: string, description: string}[]> => {
|
async getTemplatePresets(): Promise<{name: string, label: string, description: string}[]> {
|
||||||
// name = label
|
return Object.entries(TEMPLATE_PRESETS).map(([key, preset]) => ({
|
||||||
return Object.entries(TEMPLATE_PRESETS).map(([key, preset]) => ({
|
|
||||||
name: key,
|
name: key,
|
||||||
label: preset.name,
|
label: preset.name,
|
||||||
description: preset.description
|
description: preset.description
|
||||||
@@ -203,22 +119,8 @@ export const shiftPlanService = {
|
|||||||
async clearAssignments(planId: string): Promise<void> {
|
async clearAssignments(planId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
console.log('🔄 Clearing assignments for plan:', planId);
|
console.log('🔄 Clearing assignments for plan:', planId);
|
||||||
|
await apiClient.post(`/shift-plans/${planId}/clear-assignments`);
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Assignments cleared successfully');
|
console.log('✅ Assignments cleared successfully');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error clearing assignments:', error);
|
console.error('❌ Error clearing assignments:', error);
|
||||||
throw 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": {
|
"compilerOptions": {
|
||||||
"target": "es5",
|
"target": "ES2020",
|
||||||
"lib": [
|
"useDefineForClassFields": true,
|
||||||
"dom",
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
"dom.iterable",
|
"module": "ESNext",
|
||||||
"esnext"
|
"skipLibCheck": true,
|
||||||
],
|
//"ignoreDeprecations": "6.0",
|
||||||
"allowJs": true,
|
|
||||||
"esModuleInterop": true,
|
/* Bundler mode */
|
||||||
"allowSyntheticDefaultImports": true,
|
"moduleResolution": "bundler",
|
||||||
"strict": true,
|
"allowImportingTsExtensions": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"ignoreDeprecations": "6.0",
|
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"downlevelIteration": true
|
|
||||||
|
/* 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": [
|
"include": ["src"],
|
||||||
"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