mirror of
https://github.com/donpat1to/Schichtenplaner.git
synced 2025-12-01 15:05:45 +01:00
Compare commits
120 Commits
ac2eab14d7
...
v1.0.6
| Author | SHA1 | Date | |
|---|---|---|---|
| 1231c8362f | |||
| 663eb61352 | |||
| 23f1dd7aa0 | |||
| 5319ed5d7a | |||
| 65ebf1748b | |||
| 4321763a2b | |||
| 24525043e9 | |||
| d870523685 | |||
| 50a1f1a9b9 | |||
| 1927937109 | |||
| b3b3250f23 | |||
| 5f8a6bef31 | |||
| a838ba44e8 | |||
| 1057fd9954 | |||
| bc73fcebd3 | |||
| 82533ae616 | |||
| 840b4384a5 | |||
| 5a8b7e89d7 | |||
| 289c80eea1 | |||
| 1884a16220 | |||
| 478578308d | |||
| 93a52aa196 | |||
|
|
b11c55c1d9 | ||
| 16302f2105 | |||
| 57aff5c858 | |||
| b4abe459c2 | |||
| 06bc27a6ce | |||
| 0aad8f0a56 | |||
| b52e9d57c7 | |||
| 15f3183bc0 | |||
| ca3a5d1c0e | |||
| 6a1509d807 | |||
|
|
308ae74e37 | ||
| e876f5eb02 | |||
| dabd2dff3b | |||
| 84d7be052d | |||
| 9460f10278 | |||
| 6e1927fe2f | |||
| e5a6fc73fe | |||
| c773740634 | |||
| 23acd88ced | |||
| aa1a2d4d72 | |||
| cf3866ee21 | |||
| 7ab3e0a5fb | |||
| 41aa77e45d | |||
| 8e782a5290 | |||
| 3856f93484 | |||
| dae255e2c1 | |||
| 8f96368f5a | |||
| 636b892ece | |||
| 8be6a7b474 | |||
| a2b2b76665 | |||
| 6d00ab695c | |||
| 2608acc2d9 | |||
| 4dacf94077 | |||
| 5e7c5aabfb | |||
| 05fa87c638 | |||
| 875db3aeb7 | |||
| 809a838e27 | |||
| 8d020a0dac | |||
| 92840c2424 | |||
| ce1c6b08b1 | |||
| b9a88bce1c | |||
| b60e5ccdd2 | |||
| f5aa376e31 | |||
| e82e584f76 | |||
| e177c3d2a6 | |||
| de23ea00ee | |||
| d78ba474d8 | |||
| 5c7786bc19 | |||
| 15107cdc63 | |||
| 22266c765b | |||
| a66609a40c | |||
| 87dda38bc3 | |||
| 9de501c7eb | |||
| 5c6a50ddcf | |||
| 017f5fb2e0 | |||
| 527954befd | |||
| e7d30151b7 | |||
| 4a006a2e69 | |||
| 4b699b05d3 | |||
| dcac0307a2 | |||
| a0cc859935 | |||
| 49afd75ed3 | |||
| ebb9e3f4fe | |||
| 48f140f930 | |||
| b6b31659e3 | |||
| 42e343777a | |||
| aa7af272d8 | |||
| a0d96925c5 | |||
|
|
28283fa8bc | ||
|
|
fdff2853bd | ||
| 09e08ff615 | |||
| fb4a2f6f20 | |||
| faaab74f07 | |||
| 90e10b9cc2 | |||
| 0821ca7711 | |||
| 02c1253f4f | |||
| 26417aaf88 | |||
| eb8945ae97 | |||
| 980cfab637 | |||
| accdc70165 | |||
| 9ac0309917 | |||
| afe5e85add | |||
| 4689966783 | |||
| 45509b1dbd | |||
| 590cf2d10f | |||
| d43162aff6 | |||
| fd00c47b20 | |||
| 3ff51f64eb | |||
| f64fb08566 | |||
| 5e248c662f | |||
| b3a3a5680e | |||
| 364fb41f0e | |||
| fb0d0b6711 | |||
| eb33f47071 | |||
| 5809e6c44c | |||
| 2200ef6937 | |||
| d6e0b45f07 | |||
| a45647bd87 |
16
.env.template
Normal file
16
.env.template
Normal file
@@ -0,0 +1,16 @@
|
||||
# === SCHICHTPLANER DOCKER COMPOSE ENVIRONMENT VARIABLES ===
|
||||
# Diese Datei wird von docker-compose automatisch geladen
|
||||
|
||||
# Security
|
||||
JWT_SECRET=${JWT_SECRET:-your-secret-key-please-change}
|
||||
NODE_ENV=${NODE_ENV:-production}
|
||||
|
||||
# Database
|
||||
DB_PATH=${DB_PATH:-/app/data/database.db}
|
||||
|
||||
# Server
|
||||
PORT=${PORT:-3002}
|
||||
|
||||
# App Configuration
|
||||
APP_TITLE="Shift Planning App"
|
||||
ENABLE_PRO=${ENABLE_PRO:-false}
|
||||
180
.github/workflows/docker.yml
vendored
Normal file
180
.github/workflows/docker.yml
vendored
Normal file
@@ -0,0 +1,180 @@
|
||||
name: CI/CD Pipeline
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "development", "main", "staging" ]
|
||||
tags: [ "v*.*.*" ]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
set-tag:
|
||||
name: Set Tag Name
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_name: ${{ steps.set_tag.outputs.tag_name }}
|
||||
is_main_branch: ${{ steps.branch_check.outputs.is_main }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if main branch
|
||||
id: branch_check
|
||||
run: |
|
||||
if [[ "${{ github.ref }}" == "refs/heads/main" || "${{ github.ref }}" == "refs/heads/master" ]]; then
|
||||
echo "is_main=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_main=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Determine next semantic version tag
|
||||
id: set_tag
|
||||
run: |
|
||||
git fetch --tags
|
||||
|
||||
# Find latest tag matching vX.Y.Z
|
||||
latest_tag=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n 1)
|
||||
echo "Latest tag found: $latest_tag"
|
||||
|
||||
if [[ -z "$latest_tag" ]]; then
|
||||
major=0
|
||||
minor=0
|
||||
patch=0
|
||||
echo "No existing tags found, starting from v0.0.0"
|
||||
else
|
||||
version="${latest_tag#v}"
|
||||
IFS='.' read -r major minor patch <<< "$version"
|
||||
echo "Parsed version: major=$major, minor=$minor, patch=$patch"
|
||||
fi
|
||||
|
||||
if [[ "${{ github.ref }}" == "refs/heads/main" || "${{ github.ref }}" == "refs/heads/master" ]]; then
|
||||
major=$((major + 1))
|
||||
minor=0
|
||||
patch=0
|
||||
echo "Main branch - major version bump"
|
||||
elif [[ "${{ github.ref }}" == "refs/heads/development" ]]; then
|
||||
minor=$((minor + 1))
|
||||
patch=0
|
||||
echo "Development branch - minor version bump"
|
||||
else
|
||||
patch=$((patch + 1))
|
||||
echo "Other branch - patch version bump"
|
||||
fi
|
||||
|
||||
new_tag="v${major}.${minor}.${patch}"
|
||||
echo "tag_name=${new_tag}" >> $GITHUB_OUTPUT
|
||||
echo "Next version tag: ${new_tag}"
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: set-tag
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: ./backend
|
||||
run: npm install
|
||||
|
||||
- name: Run TypeScript check
|
||||
working-directory: ./backend
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run backend tests
|
||||
working-directory: ./backend
|
||||
run: |
|
||||
if [ -f "node_modules/.bin/jest" ]; then
|
||||
npm test
|
||||
else
|
||||
echo "⚠️ Jest not installed. Skipping tests."
|
||||
fi
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install ortools
|
||||
|
||||
- name: Test Python integration
|
||||
run: |
|
||||
python -c "from ortools.sat.python import cp_model; print('OR-Tools available')"
|
||||
|
||||
- name: Display next version
|
||||
run: |
|
||||
echo "Next version will be: ${{ needs.set-tag.outputs.tag_name }}"
|
||||
|
||||
build-and-push:
|
||||
needs: [set-tag, test]
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ needs.set-tag.outputs.tag_name }}
|
||||
type=raw,value=latest,enable=${{ fromJSON(needs.set-tag.outputs.is_main_branch) }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Create Git Tag
|
||||
if: success()
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git tag ${{ needs.set-tag.outputs.tag_name }}
|
||||
git push origin ${{ needs.set-tag.outputs.tag_name }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Display pushed images
|
||||
run: |
|
||||
echo "Docker images pushed successfully!"
|
||||
echo "- Image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
echo "- Tags: ${{ steps.meta.outputs.tags }}"
|
||||
echo "- New version: ${{ needs.set-tag.outputs.tag_name }}"
|
||||
echo "- Is main branch: ${{ needs.set-tag.outputs.is_main_branch }}"
|
||||
139
.gitignore
vendored
Normal file
139
.gitignore
vendored
Normal file
@@ -0,0 +1,139 @@
|
||||
# Dependencies
|
||||
backend/node_modules/
|
||||
frontend/node_modules/
|
||||
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
/coverage
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
|
||||
----
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.production
|
||||
|
||||
# Database
|
||||
database/*.db
|
||||
database/*.db-journal
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Privat
|
||||
backend/src/python-scripts/progress_data/*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Ignore contents of premium folder in public repo
|
||||
premium/*
|
||||
!premium/README-PREMIUM.md
|
||||
!premium/.gitkeep
|
||||
|
||||
.git
|
||||
.gitignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.env
|
||||
.nyc_output
|
||||
coverage
|
||||
.cache
|
||||
dist
|
||||
build
|
||||
logs
|
||||
*.tsbuildinfo
|
||||
|
||||
# Frontend specific
|
||||
frontend/dist
|
||||
frontend/.vite
|
||||
|
||||
# Backend specific
|
||||
backend/dist
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "premium"]
|
||||
path = premium
|
||||
url = https://github.com/donpat1to/Schichtenplaner-Pro.git
|
||||
86
Dockerfile
Normal file
86
Dockerfile
Normal file
@@ -0,0 +1,86 @@
|
||||
# Single stage build for workspaces
|
||||
FROM node:20-bullseye AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install Python + OR-Tools
|
||||
RUN apt-get update && apt-get install -y python3 python3-pip build-essential \
|
||||
&& pip install --no-cache-dir ortools
|
||||
|
||||
# Create symlink so python3 is callable as python
|
||||
RUN ln -sf /usr/bin/python3 /usr/bin/python
|
||||
|
||||
# Copy root package files first
|
||||
COPY package*.json ./
|
||||
COPY tsconfig.base.json ./
|
||||
COPY ecosystem.config.cjs ./
|
||||
|
||||
# Install root dependencies
|
||||
RUN npm install --only=production
|
||||
|
||||
# Copy workspace files
|
||||
COPY backend/ ./backend/
|
||||
COPY frontend/ ./frontend/
|
||||
|
||||
# Install workspace dependencies individually
|
||||
RUN npm install --workspace=backend
|
||||
RUN npm install --workspace=frontend
|
||||
|
||||
# Build backend first
|
||||
RUN npm run build --only=production --workspace=backend
|
||||
|
||||
# Build frontend
|
||||
RUN npm run build --workspace=frontend
|
||||
|
||||
# Verify Python and OR-Tools installation
|
||||
RUN python -c "from ortools.sat.python import cp_model; print('OR-Tools installed successfully')"
|
||||
|
||||
# Production stage
|
||||
FROM node:20-bookworm
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies including gettext-base for envsubst
|
||||
RUN apt-get update && apt-get install -y gettext-base && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN npm install -g pm2
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Copy application files
|
||||
COPY --from=builder /app/backend/dist/ ./dist/
|
||||
COPY --from=builder /app/backend/package*.json ./
|
||||
|
||||
COPY --from=builder /app/node_modules/ ./node_modules/
|
||||
COPY --from=builder /app/frontend/dist/ ./frontend-build/
|
||||
|
||||
COPY --from=builder /app/ecosystem.config.cjs ./
|
||||
|
||||
COPY --from=builder /app/backend/src/database/ ./dist/database/
|
||||
COPY --from=builder /app/backend/src/database/ ./database/
|
||||
|
||||
# Copy init script and env template
|
||||
COPY docker-init.sh /usr/local/bin/
|
||||
COPY .env.template ./
|
||||
|
||||
# Set execute permissions for init script
|
||||
RUN chmod +x /usr/local/bin/docker-init.sh
|
||||
|
||||
# Create user and set permissions
|
||||
RUN groupadd -g 1001 nodejs && \
|
||||
useradd -m -u 1001 -s /bin/bash -g nodejs schichtplan && \
|
||||
chown -R schichtplan:nodejs /app && \
|
||||
chmod 755 /app && \
|
||||
chmod 775 /app/data
|
||||
|
||||
ENV PM2_HOME=/app/.pm2
|
||||
|
||||
# Set entrypoint to init script and keep existing cmd
|
||||
ENTRYPOINT ["/usr/local/bin/docker-init.sh"]
|
||||
CMD ["pm2-runtime", "ecosystem.config.cjs"]
|
||||
|
||||
USER schichtplan
|
||||
EXPOSE 3002
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3002/api/health || exit 1
|
||||
21
LICENSE-COMMERCIAL
Normal file
21
LICENSE-COMMERCIAL
Normal file
@@ -0,0 +1,21 @@
|
||||
COMMERCIAL LICENSE AGREEMENT
|
||||
Copyright (c) 2025 Patrick Mahnke-Hartmann
|
||||
|
||||
This software, "Schichtenplaner", is offered under a dual licensing model.
|
||||
|
||||
1. Open-Source License
|
||||
You may use this software under the terms of the MIT License
|
||||
(see LICENSE file) for non-commercial, personal, or educational use.
|
||||
|
||||
2. Commercial License
|
||||
Commercial use of this software requires a separate paid license.
|
||||
This includes, but is not limited to:
|
||||
- Use in proprietary, for-profit, or internal business applications
|
||||
- Use within paid services or SaaS offerings
|
||||
- Integration into commercial software or distributions
|
||||
|
||||
To obtain a commercial license, please contact:
|
||||
📧 dev.patrick@mahnke-hartmann.de
|
||||
or open an inquiry via GitHub: https://github.com/donpat1to/Schichtenplaner
|
||||
|
||||
Without a valid commercial license, all commercial rights are reserved.
|
||||
12
README.md
12
README.md
@@ -2,4 +2,14 @@
|
||||
Aufteilung der Schichten unter Mitarbeitern
|
||||
|
||||
|
||||
du knlich
|
||||
## 🧾 License
|
||||
|
||||
This project uses a **dual license model**:
|
||||
|
||||
- **Community Edition:** Licensed under [MIT](./LICENSE) for personal and non-commercial use.
|
||||
- **Commercial Edition:** A [commercial license](./LICENSE-COMMERCIAL) is required for any for-profit or business use.
|
||||
|
||||
To obtain a commercial license, contact:
|
||||
📧 patrick@mahnke-hartmann.dev
|
||||
|
||||
[](#license)
|
||||
|
||||
59
backend/.gitignore
vendored
59
backend/.gitignore
vendored
@@ -1,59 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Database
|
||||
database/*.db
|
||||
database/*.db-journal
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
@@ -1,8 +0,0 @@
|
||||
# backend/Dockerfile
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY . .
|
||||
EXPOSE 3002
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -1,61 +0,0 @@
|
||||
# Multi-stage Dockerfile for Node.js + Python application
|
||||
FROM node:20-alpine AS node-base
|
||||
|
||||
# Install Python and build dependencies in Node stage
|
||||
RUN apk add --no-cache \
|
||||
python3 \
|
||||
py3-pip \
|
||||
build-base \
|
||||
python3-dev
|
||||
|
||||
# Install Python dependencies
|
||||
COPY python-scripts/requirements.txt /tmp/requirements.txt
|
||||
RUN pip3 install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install Node.js dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Build stage
|
||||
FROM node-base AS builder
|
||||
|
||||
# Install all dependencies (including dev dependencies)
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node-base AS production
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/package*.json ./
|
||||
|
||||
# Copy Python scripts
|
||||
COPY --from=builder /app/python-scripts ./python-scripts
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nextjs -u 1001
|
||||
|
||||
# Change to non-root user
|
||||
USER nextjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node dist/health-check.js
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
18
backend/jest.config.js
Normal file
18
backend/jest.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
roots: ['<rootDir>/src'],
|
||||
testMatch: [
|
||||
'**/__tests__/**/*.+(ts|tsx|js)',
|
||||
'**/?(*.)+(spec|test).+(ts|tsx|js)'
|
||||
],
|
||||
transform: {
|
||||
'^.+\.(ts|tsx)$': 'ts-jest',
|
||||
},
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{js,ts}',
|
||||
'!src/server.ts',
|
||||
'!src/**/*.d.ts'
|
||||
],
|
||||
};
|
||||
@@ -6,25 +6,30 @@
|
||||
"dev": "npm run build && npx tsx src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"prestart": "npm run build"
|
||||
"prestart": "npm run build",
|
||||
"test": "jest",
|
||||
"test:python": "python -c \"from ortools.sat.python import cp_model; print('OR-Tools OK')\"",
|
||||
"docker:build": "docker build -t schichtplan-backend .",
|
||||
"docker:run": "docker run -p 3001:3001 schichtplan-backend"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"sqlite3": "^5.1.6",
|
||||
"uuid": "^9.0.0",
|
||||
"worker_threads": "native"
|
||||
"express-rate-limit": "8.1.0",
|
||||
"helmet": "8.1.0",
|
||||
"express-validator": "7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/cors": "^2.8.13",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jsonwebtoken": "^9.0.2",
|
||||
"@types/uuid": "^9.0.2",
|
||||
"@types/jest": "^29.5.0",
|
||||
"ts-node": "^10.9.0",
|
||||
"typescript": "^5.0.0",
|
||||
"tsx": "^4.0.0"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// backend/src/controllers/employeeController.ts
|
||||
import { Request, Response } from 'express';
|
||||
import { Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from '../services/databaseService.js';
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// backend/src/controllers/setupController.ts
|
||||
import { Request, Response } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { db } from '../services/databaseService.js';
|
||||
|
||||
|
||||
@@ -5,10 +5,9 @@ import { db } from '../services/databaseService.js';
|
||||
import {
|
||||
CreateShiftPlanRequest,
|
||||
UpdateShiftPlanRequest,
|
||||
ShiftPlan
|
||||
} from '../models/ShiftPlan.js';
|
||||
import { AuthRequest } from '../middleware/auth.js';
|
||||
import { createPlanFromPreset, TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults.js';
|
||||
import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults.js';
|
||||
|
||||
async function getPlanWithDetails(planId: string) {
|
||||
const plan = await db.get<any>(`
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
PRAGMA secure_delete = ON;
|
||||
PRAGMA auto_vacuum = INCREMENTAL;
|
||||
|
||||
-- Employee Types
|
||||
CREATE TABLE IF NOT EXISTS employee_types (
|
||||
type TEXT PRIMARY KEY,
|
||||
|
||||
48
backend/src/middleware/rateLimit.ts
Normal file
48
backend/src/middleware/rateLimit.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { Request } from 'express';
|
||||
|
||||
// Helper to check if request should be limited
|
||||
const shouldSkipLimit = (req: Request): boolean => {
|
||||
const skipPaths = [
|
||||
'/api/health',
|
||||
'/api/setup/status',
|
||||
'/api/auth/validate'
|
||||
];
|
||||
|
||||
// Skip for successful GET requests (data fetching)
|
||||
if (req.method === 'GET' && req.path.startsWith('/api/')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return skipPaths.includes(req.path);
|
||||
};
|
||||
|
||||
// Main API limiter - nur für POST/PUT/DELETE
|
||||
export const apiLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 200, // 200 non-GET requests per 15 minutes
|
||||
message: {
|
||||
error: 'Zu viele Anfragen, bitte verlangsamen Sie Ihre Aktionen'
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => {
|
||||
// ✅ Skip für GET requests (Data Fetching)
|
||||
if (req.method === 'GET') return true;
|
||||
|
||||
// ✅ Skip für Health/Status Checks
|
||||
return shouldSkipLimit(req);
|
||||
}
|
||||
});
|
||||
|
||||
// Strict limiter for auth endpoints
|
||||
export const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 5,
|
||||
message: {
|
||||
error: 'Zu viele Login-Versuche, bitte versuchen Sie es später erneut'
|
||||
},
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: true,
|
||||
});
|
||||
457
backend/src/middleware/validation.ts
Normal file
457
backend/src/middleware/validation.ts
Normal file
@@ -0,0 +1,457 @@
|
||||
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')
|
||||
.isLength({ min: 6 })
|
||||
.withMessage('Password must be at least 6 characters')
|
||||
.trim()
|
||||
.escape()
|
||||
];
|
||||
|
||||
export const validateRegister = [
|
||||
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')
|
||||
.isLength({ min: 8 })
|
||||
.withMessage('Password must be at least 8 characters')
|
||||
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
|
||||
.withMessage('Password must contain uppercase, lowercase and number')
|
||||
];
|
||||
|
||||
// ===== EMPLOYEE VALIDATION =====
|
||||
export const validateEmployee = [
|
||||
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 and number'),
|
||||
|
||||
body('employeeType')
|
||||
.isIn(['manager', 'personell', 'apprentice', 'guest'])
|
||||
.withMessage('Employee type must be manager, personell, apprentice or guest'),
|
||||
|
||||
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')
|
||||
.trim()
|
||||
.escape(),
|
||||
|
||||
body('lastname')
|
||||
.optional()
|
||||
.isLength({ min: 1, max: 100 })
|
||||
.withMessage('Last name must be between 1-100 characters')
|
||||
.trim()
|
||||
.escape(),
|
||||
|
||||
body('employeeType')
|
||||
.optional()
|
||||
.isIn(['manager', 'personell', 'apprentice', 'guest'])
|
||||
.withMessage('Employee type must be manager, personell, apprentice or guest'),
|
||||
|
||||
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 validateChangePassword = [
|
||||
body('currentPassword')
|
||||
.optional()
|
||||
.isLength({ min: 6 })
|
||||
.withMessage('Current password must be at least 6 characters'),
|
||||
|
||||
body('newPassword')
|
||||
.isLength({ min: 8 })
|
||||
.withMessage('New password must be at least 8 characters')
|
||||
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
|
||||
.withMessage('New password must contain uppercase, lowercase and number')
|
||||
];
|
||||
|
||||
// ===== 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(['standardWeek', 'extendedWeek', 'weekendFocused', 'morningOnly', 'eveningOnly', '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')
|
||||
.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')
|
||||
];
|
||||
|
||||
// ===== 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')
|
||||
.isLength({ min: 8 })
|
||||
.withMessage('Password must be at least 8 characters')
|
||||
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
|
||||
.withMessage('Password must contain uppercase, lowercase and number')
|
||||
];
|
||||
|
||||
// ===== 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'),
|
||||
|
||||
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();
|
||||
};
|
||||
@@ -102,7 +102,6 @@ export const AVAILABILITY_PREFERENCES = {
|
||||
} as const;
|
||||
|
||||
// Default availability for new employees (all shifts unavailable as level 3)
|
||||
// UPDATED: Now uses shiftId instead of timeSlotId + dayOfWeek
|
||||
export function createDefaultAvailabilities(employeeId: string, planId: string, shiftIds: string[]): Omit<EmployeeAvailability, 'id'>[] {
|
||||
const availabilities: Omit<EmployeeAvailability, 'id'>[] = [];
|
||||
|
||||
@@ -120,7 +119,6 @@ export function createDefaultAvailabilities(employeeId: string, planId: string,
|
||||
}
|
||||
|
||||
// Create complete manager availability for all days (default: only Mon-Tue available)
|
||||
// NOTE: This function might need revision based on new schema requirements
|
||||
export function createManagerDefaultSchedule(managerId: string, planId: string, timeSlotIds: string[]): Omit<ManagerAvailability, 'id'>[] {
|
||||
const assignments: Omit<ManagerAvailability, 'id'>[] = [];
|
||||
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T11:16:11.693081",
|
||||
"success": true,
|
||||
"metadata": {
|
||||
"solveTime": 0.0218085,
|
||||
"constraintsAdded": 217,
|
||||
"variablesCreated": 90,
|
||||
"optimal": true
|
||||
},
|
||||
"progress": [
|
||||
{
|
||||
"timestamp": 0.00886988639831543,
|
||||
"objective": 1050.0,
|
||||
"bound": 2100.0,
|
||||
"solution_count": 1
|
||||
},
|
||||
{
|
||||
"timestamp": 0.009304285049438477,
|
||||
"objective": 1300.0,
|
||||
"bound": 1300.0,
|
||||
"solution_count": 2
|
||||
}
|
||||
],
|
||||
"solution_summary": {
|
||||
"assignments_count": 15,
|
||||
"violations_count": 0,
|
||||
"variables_count": 90,
|
||||
"constraints_count": 217,
|
||||
"solve_time": 0.0218085,
|
||||
"optimal": true
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T11:16:16.813066",
|
||||
"success": true,
|
||||
"metadata": {
|
||||
"solveTime": 0.0158702,
|
||||
"constraintsAdded": 217,
|
||||
"variablesCreated": 90,
|
||||
"optimal": true
|
||||
},
|
||||
"progress": [
|
||||
{
|
||||
"timestamp": 0.008541107177734375,
|
||||
"objective": 1050.0,
|
||||
"bound": 2000.0,
|
||||
"solution_count": 1
|
||||
},
|
||||
{
|
||||
"timestamp": 0.00941777229309082,
|
||||
"objective": 1250.0,
|
||||
"bound": 1300.0,
|
||||
"solution_count": 2
|
||||
},
|
||||
{
|
||||
"timestamp": 0.009499549865722656,
|
||||
"objective": 1300.0,
|
||||
"bound": 1300.0,
|
||||
"solution_count": 3
|
||||
}
|
||||
],
|
||||
"solution_summary": {
|
||||
"assignments_count": 15,
|
||||
"violations_count": 0,
|
||||
"variables_count": 90,
|
||||
"constraints_count": 217,
|
||||
"solve_time": 0.0158702,
|
||||
"optimal": true
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T14:34:06.393151",
|
||||
"success": true,
|
||||
"metadata": {
|
||||
"solveTime": 0.046477700000000004,
|
||||
"constraintsAdded": 217,
|
||||
"variablesCreated": 90,
|
||||
"optimal": true
|
||||
},
|
||||
"progress": [
|
||||
{
|
||||
"timestamp": 0.04335761070251465,
|
||||
"objective": 1050.0,
|
||||
"bound": 2100.0,
|
||||
"solution_count": 1
|
||||
},
|
||||
{
|
||||
"timestamp": 0.04459857940673828,
|
||||
"objective": 1250.0,
|
||||
"bound": 1700.0,
|
||||
"solution_count": 2
|
||||
},
|
||||
{
|
||||
"timestamp": 0.04603719711303711,
|
||||
"objective": 1300.0,
|
||||
"bound": 1300.0,
|
||||
"solution_count": 3
|
||||
}
|
||||
],
|
||||
"solution_summary": {
|
||||
"assignments_count": 15,
|
||||
"violations_count": 0,
|
||||
"variables_count": 90,
|
||||
"constraints_count": 217,
|
||||
"solve_time": 0.046477700000000004,
|
||||
"optimal": true
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T14:37:10.936471",
|
||||
"success": true,
|
||||
"metadata": {
|
||||
"solveTime": 0.022602300000000002,
|
||||
"constraintsAdded": 217,
|
||||
"variablesCreated": 90,
|
||||
"optimal": true
|
||||
},
|
||||
"progress": [
|
||||
{
|
||||
"timestamp": 0.008664369583129883,
|
||||
"objective": 1050.0,
|
||||
"bound": 1300.0,
|
||||
"solution_count": 1
|
||||
},
|
||||
{
|
||||
"timestamp": 0.008890628814697266,
|
||||
"objective": 1150.0,
|
||||
"bound": 1300.0,
|
||||
"solution_count": 2
|
||||
},
|
||||
{
|
||||
"timestamp": 0.009119987487792969,
|
||||
"objective": 1250.0,
|
||||
"bound": 1300.0,
|
||||
"solution_count": 3
|
||||
},
|
||||
{
|
||||
"timestamp": 0.009514808654785156,
|
||||
"objective": 1300.0,
|
||||
"bound": 1300.0,
|
||||
"solution_count": 4
|
||||
}
|
||||
],
|
||||
"solution_summary": {
|
||||
"assignments_count": 15,
|
||||
"violations_count": 0,
|
||||
"variables_count": 90,
|
||||
"constraints_count": 217,
|
||||
"solve_time": 0.022602300000000002,
|
||||
"optimal": true
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T14:38:04.517181",
|
||||
"success": true,
|
||||
"metadata": {
|
||||
"solveTime": 0.017061200000000002,
|
||||
"constraintsAdded": 217,
|
||||
"variablesCreated": 90,
|
||||
"optimal": true
|
||||
},
|
||||
"progress": [
|
||||
{
|
||||
"timestamp": 0.008075952529907227,
|
||||
"objective": 1000.0,
|
||||
"bound": 1300.0,
|
||||
"solution_count": 1
|
||||
},
|
||||
{
|
||||
"timestamp": 0.008380889892578125,
|
||||
"objective": 1200.0,
|
||||
"bound": 1300.0,
|
||||
"solution_count": 2
|
||||
},
|
||||
{
|
||||
"timestamp": 0.008938789367675781,
|
||||
"objective": 1300.0,
|
||||
"bound": 1300.0,
|
||||
"solution_count": 3
|
||||
}
|
||||
],
|
||||
"solution_summary": {
|
||||
"assignments_count": 15,
|
||||
"violations_count": 0,
|
||||
"variables_count": 90,
|
||||
"constraints_count": 217,
|
||||
"solve_time": 0.017061200000000002,
|
||||
"optimal": true
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T14:40:00.354343",
|
||||
"success": false,
|
||||
"metadata": {
|
||||
"solveTime": 0.0013928,
|
||||
"constraintsAdded": 248,
|
||||
"variablesCreated": 99,
|
||||
"optimal": false
|
||||
},
|
||||
"progress": [],
|
||||
"solution_summary": {
|
||||
"assignments_count": 0,
|
||||
"violations_count": 0,
|
||||
"variables_count": 99,
|
||||
"constraints_count": 248,
|
||||
"solve_time": 0.0013928,
|
||||
"optimal": false
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T14:43:01.616117",
|
||||
"success": false,
|
||||
"metadata": {
|
||||
"solveTime": 0.0009529,
|
||||
"constraintsAdded": 230,
|
||||
"variablesCreated": 99,
|
||||
"optimal": false
|
||||
},
|
||||
"progress": [],
|
||||
"solution_summary": {
|
||||
"assignments_count": 0,
|
||||
"violations_count": 0,
|
||||
"variables_count": 99,
|
||||
"constraints_count": 230,
|
||||
"solve_time": 0.0009529,
|
||||
"optimal": false
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T14:43:40.972769",
|
||||
"success": false,
|
||||
"metadata": {
|
||||
"solveTime": 0.0009025000000000001,
|
||||
"constraintsAdded": 230,
|
||||
"variablesCreated": 99,
|
||||
"optimal": false
|
||||
},
|
||||
"progress": [],
|
||||
"solution_summary": {
|
||||
"assignments_count": 0,
|
||||
"violations_count": 0,
|
||||
"variables_count": 99,
|
||||
"constraints_count": 230,
|
||||
"solve_time": 0.0009025000000000001,
|
||||
"optimal": false
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T15:15:30.967943",
|
||||
"success": false,
|
||||
"metadata": {
|
||||
"solveTime": 0.0009660000000000001,
|
||||
"constraintsAdded": 230,
|
||||
"variablesCreated": 99,
|
||||
"optimal": false
|
||||
},
|
||||
"progress": [],
|
||||
"solution_summary": {
|
||||
"assignments_count": 0,
|
||||
"violations_count": 0,
|
||||
"variables_count": 99,
|
||||
"constraints_count": 230,
|
||||
"solve_time": 0.0009660000000000001,
|
||||
"optimal": false
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T15:15:39.908329",
|
||||
"success": false,
|
||||
"metadata": {
|
||||
"solveTime": 0.0009183,
|
||||
"constraintsAdded": 230,
|
||||
"variablesCreated": 99,
|
||||
"optimal": false
|
||||
},
|
||||
"progress": [],
|
||||
"solution_summary": {
|
||||
"assignments_count": 0,
|
||||
"violations_count": 0,
|
||||
"variables_count": 99,
|
||||
"constraints_count": 230,
|
||||
"solve_time": 0.0009183,
|
||||
"optimal": false
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T15:17:28.688727",
|
||||
"success": true,
|
||||
"metadata": {
|
||||
"solveTime": 0.0175048,
|
||||
"constraintsAdded": 230,
|
||||
"variablesCreated": 99,
|
||||
"optimal": true
|
||||
},
|
||||
"progress": [
|
||||
{
|
||||
"timestamp": 0.0076940059661865234,
|
||||
"objective": 1150.0,
|
||||
"bound": 2650.0,
|
||||
"solution_count": 1
|
||||
},
|
||||
{
|
||||
"timestamp": 0.008573293685913086,
|
||||
"objective": 1200.0,
|
||||
"bound": 1400.0,
|
||||
"solution_count": 2
|
||||
},
|
||||
{
|
||||
"timestamp": 0.00871133804321289,
|
||||
"objective": 1250.0,
|
||||
"bound": 1400.0,
|
||||
"solution_count": 3
|
||||
},
|
||||
{
|
||||
"timestamp": 0.008805990219116211,
|
||||
"objective": 1300.0,
|
||||
"bound": 1400.0,
|
||||
"solution_count": 4
|
||||
},
|
||||
{
|
||||
"timestamp": 0.00890493392944336,
|
||||
"objective": 1350.0,
|
||||
"bound": 1400.0,
|
||||
"solution_count": 5
|
||||
},
|
||||
{
|
||||
"timestamp": 0.009298324584960938,
|
||||
"objective": 1400.0,
|
||||
"bound": 1400.0,
|
||||
"solution_count": 6
|
||||
}
|
||||
],
|
||||
"solution_summary": {
|
||||
"assignments_count": 16,
|
||||
"violations_count": 0,
|
||||
"variables_count": 99,
|
||||
"constraints_count": 230,
|
||||
"solve_time": 0.0175048,
|
||||
"optimal": true
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T15:19:19.785803",
|
||||
"success": true,
|
||||
"metadata": {
|
||||
"solveTime": 0.014216000000000001,
|
||||
"constraintsAdded": 228,
|
||||
"variablesCreated": 99,
|
||||
"optimal": true
|
||||
},
|
||||
"progress": [
|
||||
{
|
||||
"timestamp": 0.008774042129516602,
|
||||
"objective": 1200.0,
|
||||
"bound": 2850.0,
|
||||
"solution_count": 1
|
||||
},
|
||||
{
|
||||
"timestamp": 0.009451627731323242,
|
||||
"objective": 1250.0,
|
||||
"bound": 1450.0,
|
||||
"solution_count": 2
|
||||
},
|
||||
{
|
||||
"timestamp": 0.009568452835083008,
|
||||
"objective": 1300.0,
|
||||
"bound": 1450.0,
|
||||
"solution_count": 3
|
||||
},
|
||||
{
|
||||
"timestamp": 0.009636402130126953,
|
||||
"objective": 1350.0,
|
||||
"bound": 1450.0,
|
||||
"solution_count": 4
|
||||
},
|
||||
{
|
||||
"timestamp": 0.009870052337646484,
|
||||
"objective": 1400.0,
|
||||
"bound": 1450.0,
|
||||
"solution_count": 5
|
||||
},
|
||||
{
|
||||
"timestamp": 0.009946584701538086,
|
||||
"objective": 1450.0,
|
||||
"bound": 1450.0,
|
||||
"solution_count": 6
|
||||
}
|
||||
],
|
||||
"solution_summary": {
|
||||
"assignments_count": 17,
|
||||
"violations_count": 0,
|
||||
"variables_count": 99,
|
||||
"constraints_count": 228,
|
||||
"solve_time": 0.014216000000000001,
|
||||
"optimal": true
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T16:58:13.438358",
|
||||
"success": true,
|
||||
"metadata": {
|
||||
"solveTime": 0.012495000000000001,
|
||||
"constraintsAdded": 218,
|
||||
"variablesCreated": 90,
|
||||
"optimal": true
|
||||
},
|
||||
"progress": [
|
||||
{
|
||||
"timestamp": 0.008534908294677734,
|
||||
"objective": 1050.0,
|
||||
"bound": 2100.0,
|
||||
"solution_count": 1
|
||||
},
|
||||
{
|
||||
"timestamp": 0.009073495864868164,
|
||||
"objective": 1300.0,
|
||||
"bound": 1300.0,
|
||||
"solution_count": 2
|
||||
}
|
||||
],
|
||||
"solution_summary": {
|
||||
"assignments_count": 15,
|
||||
"violations_count": 0,
|
||||
"variables_count": 90,
|
||||
"constraints_count": 218,
|
||||
"solve_time": 0.012495000000000001,
|
||||
"optimal": true
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T16:58:37.184763",
|
||||
"success": true,
|
||||
"metadata": {
|
||||
"solveTime": 0.0245485,
|
||||
"constraintsAdded": 218,
|
||||
"variablesCreated": 90,
|
||||
"optimal": true
|
||||
},
|
||||
"progress": [
|
||||
{
|
||||
"timestamp": 0.009514808654785156,
|
||||
"objective": 1050.0,
|
||||
"bound": 2100.0,
|
||||
"solution_count": 1
|
||||
},
|
||||
{
|
||||
"timestamp": 0.010115385055541992,
|
||||
"objective": 1300.0,
|
||||
"bound": 1300.0,
|
||||
"solution_count": 2
|
||||
}
|
||||
],
|
||||
"solution_summary": {
|
||||
"assignments_count": 15,
|
||||
"violations_count": 0,
|
||||
"variables_count": 90,
|
||||
"constraints_count": 218,
|
||||
"solve_time": 0.0245485,
|
||||
"optimal": true
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T10:54:26.093544",
|
||||
"success": false,
|
||||
"metadata": {
|
||||
"solveTime": 0,
|
||||
"constraintsAdded": 0,
|
||||
"variablesCreated": 0,
|
||||
"optimal": false
|
||||
},
|
||||
"progress": [],
|
||||
"solution_summary": {
|
||||
"assignments_count": 0,
|
||||
"violations_count": 1,
|
||||
"variables_count": 0,
|
||||
"constraints_count": 0,
|
||||
"solve_time": 0,
|
||||
"optimal": false
|
||||
},
|
||||
"full_result": {
|
||||
"assignments": [],
|
||||
"violations": [
|
||||
"Error: 'SolutionCallback' object has no attribute 'HasObjective'"
|
||||
],
|
||||
"success": false,
|
||||
"metadata": {
|
||||
"solveTime": 0,
|
||||
"constraintsAdded": 0,
|
||||
"variablesCreated": 0,
|
||||
"optimal": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T10:59:36.646855",
|
||||
"success": false,
|
||||
"metadata": {
|
||||
"solveTime": 0,
|
||||
"constraintsAdded": 0,
|
||||
"variablesCreated": 0,
|
||||
"optimal": false
|
||||
},
|
||||
"progress": [],
|
||||
"solution_summary": {
|
||||
"assignments_count": 0,
|
||||
"violations_count": 1,
|
||||
"variables_count": 0,
|
||||
"constraints_count": 0,
|
||||
"solve_time": 0,
|
||||
"optimal": false
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"timestamp": "2025-10-21T11:03:36.697986",
|
||||
"success": true,
|
||||
"metadata": {
|
||||
"solveTime": 0.025875500000000003,
|
||||
"constraintsAdded": 217,
|
||||
"variablesCreated": 90,
|
||||
"optimal": true
|
||||
},
|
||||
"progress": [
|
||||
{
|
||||
"timestamp": 0.008769989013671875,
|
||||
"objective": 1050.0,
|
||||
"bound": 2000.0,
|
||||
"solution_count": 1
|
||||
},
|
||||
{
|
||||
"timestamp": 0.009685516357421875,
|
||||
"objective": 1250.0,
|
||||
"bound": 1700.0,
|
||||
"solution_count": 2
|
||||
},
|
||||
{
|
||||
"timestamp": 0.010709047317504883,
|
||||
"objective": 1300.0,
|
||||
"bound": 1300.0,
|
||||
"solution_count": 3
|
||||
}
|
||||
],
|
||||
"solution_summary": {
|
||||
"assignments_count": 15,
|
||||
"violations_count": 0,
|
||||
"variables_count": 90,
|
||||
"constraints_count": 217,
|
||||
"solve_time": 0.025875500000000003,
|
||||
"optimal": true
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
validateToken
|
||||
} from '../controllers/authController.js';
|
||||
import { authMiddleware } from '../middleware/auth.js';
|
||||
import { validateLogin, validateRegister, handleValidationErrors } from '../middleware/validation.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Public routes
|
||||
router.post('/login', login);
|
||||
router.post('/register', register);
|
||||
router.post('/login', validateLogin, handleValidationErrors, login);
|
||||
router.post('/register', validateRegister, handleValidationErrors, register);
|
||||
router.get('/validate', validateToken);
|
||||
|
||||
// Protected routes (require authentication)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// backend/src/routes/employees.ts
|
||||
import express from 'express';
|
||||
import { authMiddleware, requireRole } from '../middleware/auth.js';
|
||||
import {
|
||||
@@ -12,6 +11,16 @@ import {
|
||||
changePassword,
|
||||
updateLastLogin
|
||||
} from '../controllers/employeeController.js';
|
||||
import {
|
||||
handleValidationErrors,
|
||||
validateEmployee,
|
||||
validateEmployeeUpdate,
|
||||
validateChangePassword,
|
||||
validateId,
|
||||
validateEmployeeId,
|
||||
validateAvailabilities,
|
||||
validatePagination
|
||||
} from '../middleware/validation.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -19,16 +28,18 @@ const router = express.Router();
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Employee CRUD Routes
|
||||
router.get('/', authMiddleware, getEmployees);
|
||||
router.get('/:id', requireRole(['admin', 'instandhalter']), getEmployee);
|
||||
router.post('/', requireRole(['admin']), createEmployee);
|
||||
router.put('/:id', requireRole(['admin']), updateEmployee);
|
||||
router.delete('/:id', requireRole(['admin']), deleteEmployee);
|
||||
router.put('/:id/password', authMiddleware, changePassword);
|
||||
router.put('/:id/last-login', authMiddleware, updateLastLogin);
|
||||
router.get('/', validatePagination, handleValidationErrors, getEmployees);
|
||||
router.get('/:id', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), getEmployee);
|
||||
router.post('/', validateEmployee, handleValidationErrors, requireRole(['admin']), createEmployee);
|
||||
router.put('/:id', validateId, validateEmployeeUpdate, handleValidationErrors, requireRole(['admin', 'maintenance']), updateEmployee);
|
||||
router.delete('/:id', validateId, handleValidationErrors, requireRole(['admin']), deleteEmployee);
|
||||
|
||||
// Password & Login Routes
|
||||
router.put('/:id/password', validateId, validateChangePassword, handleValidationErrors, changePassword);
|
||||
router.put('/:id/last-login', validateId, handleValidationErrors, updateLastLogin);
|
||||
|
||||
// Availability Routes
|
||||
router.get('/:employeeId/availabilities', authMiddleware, getAvailabilities);
|
||||
router.put('/:employeeId/availabilities', authMiddleware, updateAvailabilities);
|
||||
router.get('/:employeeId/availabilities', validateEmployeeId, handleValidationErrors, getAvailabilities);
|
||||
router.put('/:employeeId/availabilities', validateEmployeeId, validateAvailabilities, handleValidationErrors, updateAvailabilities);
|
||||
|
||||
export default router;
|
||||
@@ -1,4 +1,3 @@
|
||||
// backend/src/routes/scheduledShifts.ts
|
||||
import express from 'express';
|
||||
import { authMiddleware, requireRole } from '../middleware/auth.js';
|
||||
import {
|
||||
@@ -8,23 +7,21 @@ import {
|
||||
getScheduledShiftsFromPlan,
|
||||
updateScheduledShift
|
||||
} from '../controllers/shiftPlanController.js';
|
||||
import {
|
||||
validateId,
|
||||
validatePlanId,
|
||||
validateScheduledShiftUpdate,
|
||||
handleValidationErrors
|
||||
} from '../middleware/validation.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
|
||||
router.post('/:id/generate-shifts', requireRole(['admin', 'instandhalter']), generateScheduledShiftsForPlan);
|
||||
|
||||
router.post('/:id/regenerate-shifts', requireRole(['admin', 'instandhalter']), regenerateScheduledShifts);
|
||||
|
||||
// GET all scheduled shifts for a plan
|
||||
router.get('/plan/:planId', authMiddleware, getScheduledShiftsFromPlan);
|
||||
|
||||
// GET specific scheduled shift
|
||||
router.get('/:id', authMiddleware, getScheduledShift);
|
||||
|
||||
// UPDATE scheduled shift
|
||||
router.put('/:id', authMiddleware, updateScheduledShift);
|
||||
router.post('/:id/generate-shifts', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), generateScheduledShiftsForPlan);
|
||||
router.post('/:id/regenerate-shifts', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), regenerateScheduledShifts);
|
||||
router.get('/plan/:planId', validatePlanId, handleValidationErrors, getScheduledShiftsFromPlan);
|
||||
router.get('/:id', validateId, handleValidationErrors, getScheduledShift);
|
||||
router.put('/:id', validateId, validateScheduledShiftUpdate, handleValidationErrors, updateScheduledShift);
|
||||
|
||||
export default router;
|
||||
@@ -1,9 +1,10 @@
|
||||
import express from 'express';
|
||||
import { SchedulingService } from '../services/SchedulingService.js';
|
||||
import { validateSchedulingRequest, handleValidationErrors } from '../middleware/validation.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/generate-schedule', async (req, res) => {
|
||||
router.post('/generate-schedule', validateSchedulingRequest, handleValidationErrors, async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const { shiftPlan, employees, availabilities, constraints } = req.body;
|
||||
|
||||
@@ -14,18 +15,6 @@ router.post('/generate-schedule', async (req, res) => {
|
||||
constraintCount: constraints?.length
|
||||
});
|
||||
|
||||
// Validate required data
|
||||
if (!shiftPlan || !employees || !availabilities) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing required data',
|
||||
details: {
|
||||
shiftPlan: !!shiftPlan,
|
||||
employees: !!employees,
|
||||
availabilities: !!availabilities
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const scheduler = new SchedulingService();
|
||||
const result = await scheduler.generateOptimalSchedule({
|
||||
shiftPlan,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// backend/src/routes/setup.ts
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { checkSetupStatus, setupAdmin } from '../controllers/setupController.js';
|
||||
import { validateSetupAdmin, handleValidationErrors } from '../middleware/validation.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/status', checkSetupStatus);
|
||||
router.post('/admin', setupAdmin);
|
||||
router.post('/admin', validateSetupAdmin, handleValidationErrors, setupAdmin);
|
||||
|
||||
export default router;
|
||||
@@ -1,4 +1,3 @@
|
||||
// backend/src/routes/shiftPlans.ts
|
||||
import express from 'express';
|
||||
import { authMiddleware, requireRole } from '../middleware/auth.js';
|
||||
import {
|
||||
@@ -10,32 +9,25 @@ import {
|
||||
createFromPreset,
|
||||
clearAssignments
|
||||
} from '../controllers/shiftPlanController.js';
|
||||
import {
|
||||
validateShiftPlan,
|
||||
validateShiftPlanUpdate,
|
||||
validateCreateFromPreset,
|
||||
handleValidationErrors,
|
||||
validateId
|
||||
} from '../middleware/validation.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
// Combined routes for both shift plans and templates
|
||||
|
||||
// GET all shift plans (including templates)
|
||||
router.get('/' , authMiddleware, getShiftPlans);
|
||||
|
||||
// GET specific shift plan or template
|
||||
router.get('/:id', authMiddleware, getShiftPlan);
|
||||
|
||||
// POST create new shift plan
|
||||
router.post('/', requireRole(['admin', 'instandhalter']), createShiftPlan);
|
||||
|
||||
// POST create new plan from preset
|
||||
router.post('/from-preset', requireRole(['admin', 'instandhalter']), createFromPreset);
|
||||
|
||||
// PUT update shift plan or template
|
||||
router.put('/:id', requireRole(['admin', 'instandhalter']), updateShiftPlan);
|
||||
|
||||
// DELETE shift plan or template
|
||||
router.delete('/:id', requireRole(['admin', 'instandhalter']), deleteShiftPlan);
|
||||
|
||||
// POST clear assignments and reset to draft
|
||||
router.post('/:id/clear-assignments', requireRole(['admin', 'instandhalter']), clearAssignments);
|
||||
router.get('/', getShiftPlans);
|
||||
router.get('/:id', validateId, handleValidationErrors, getShiftPlan);
|
||||
router.post('/', validateShiftPlan, handleValidationErrors, requireRole(['admin', 'maintenance']), createShiftPlan);
|
||||
router.post('/from-preset', validateCreateFromPreset, handleValidationErrors, requireRole(['admin', 'maintenance']), createFromPreset);
|
||||
router.put('/:id', validateId, validateShiftPlanUpdate, handleValidationErrors, requireRole(['admin', 'maintenance']), updateShiftPlan);
|
||||
router.delete('/:id', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), deleteShiftPlan);
|
||||
router.post('/:id/clear-assignments', validateId, handleValidationErrors, requireRole(['admin', 'maintenance']), clearAssignments);
|
||||
|
||||
export default router;
|
||||
@@ -8,7 +8,30 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export async function initializeDatabase(): Promise<void> {
|
||||
const schemaPath = path.join(__dirname, '../database/schema.sql');
|
||||
const possiblePaths = [
|
||||
path.join(__dirname, '../database/schema.sql'),
|
||||
path.join(__dirname, '../../database/schema.sql'),
|
||||
path.join(process.cwd(), 'database/schema.sql'),
|
||||
path.join(process.cwd(), 'src/database/schema.sql'),
|
||||
path.join(process.cwd(), 'dist/database/schema.sql')
|
||||
];
|
||||
|
||||
let schemaPath: string | null = null;
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
schemaPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!schemaPath) {
|
||||
throw new Error(
|
||||
`❌ schema.sql not found in any of the tested paths:\n${possiblePaths.join('\n')}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`✅ Using schema at: ${schemaPath}`);
|
||||
const schema = fs.readFileSync(schemaPath, 'utf8');
|
||||
|
||||
try {
|
||||
|
||||
26
backend/src/scripts/verify-python.js
Normal file
26
backend/src/scripts/verify-python.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
export function runPythonScript(scriptPath, args = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const pythonProcess = spawn('python', [scriptPath, ...args]);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
pythonProcess.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
pythonProcess.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
pythonProcess.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout);
|
||||
} else {
|
||||
reject(new Error(`Python script exited with code ${code}: ${stderr}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
// backend/src/server.ts
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { initializeDatabase } from './scripts/initializeDatabase.js';
|
||||
import fs from 'fs';
|
||||
import helmet from 'helmet';
|
||||
|
||||
// Route imports
|
||||
import authRoutes from './routes/auth.js';
|
||||
@@ -10,87 +13,197 @@ import shiftPlanRoutes from './routes/shiftPlans.js';
|
||||
import setupRoutes from './routes/setup.js';
|
||||
import scheduledShifts from './routes/scheduledShifts.js';
|
||||
import schedulingRoutes from './routes/scheduling.js';
|
||||
import { authLimiter, apiLimiter } from './middleware/rateLimit.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = 3002;
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
||||
// CORS und Middleware
|
||||
app.use(cors());
|
||||
app.set('trust proxy', true);
|
||||
|
||||
// Security configuration
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.info('Checking for JWT_SECRET');
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
if (!JWT_SECRET || JWT_SECRET === 'your-secret-key-please-change') {
|
||||
console.error('❌ Fatal: JWT_SECRET not set or using default value');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Security headers
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "https:"],
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
mediaSrc: ["'self'"],
|
||||
frameSrc: ["'none'"],
|
||||
},
|
||||
},
|
||||
hsts: false,
|
||||
crossOriginEmbedderPolicy: false
|
||||
}));
|
||||
|
||||
// Additional security headers
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||||
next();
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
|
||||
// Rate limiting - weniger restriktiv in Development
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use('/api/', apiLimiter);
|
||||
} else {
|
||||
console.log('🔧 Development: Rate limiting relaxed');
|
||||
}
|
||||
|
||||
// API Routes
|
||||
app.use('/api/setup', setupRoutes);
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/auth', authLimiter, authRoutes);
|
||||
app.use('/api/employees', employeeRoutes);
|
||||
app.use('/api/shift-plans', shiftPlanRoutes);
|
||||
app.use('/api/scheduled-shifts', scheduledShifts);
|
||||
app.use('/api/scheduling', schedulingRoutes);
|
||||
|
||||
// Error handling middleware should come after routes
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// 404 handler for API routes
|
||||
app.use('/api/*', (req, res) => {
|
||||
res.status(404).json({ error: 'API endpoint not found' });
|
||||
});
|
||||
|
||||
// Health route
|
||||
app.get('/api/health', (req: any, res: any) => {
|
||||
app.get('/api/health', (req: express.Request, res: express.Response) => {
|
||||
res.json({
|
||||
status: 'OK',
|
||||
message: 'Backend läuft!',
|
||||
timestamp: new Date().toISOString()
|
||||
timestamp: new Date().toISOString(),
|
||||
mode: process.env.NODE_ENV || 'development'
|
||||
});
|
||||
});
|
||||
|
||||
// Setup status route (additional endpoint for clarity)
|
||||
app.get('/api/initial-setup', async (req: any, res: any) => {
|
||||
// 🆕 IMPROVED STATIC FILE SERVING
|
||||
const findFrontendBuildPath = (): string | null => {
|
||||
const possiblePaths = [
|
||||
// Production path (Docker)
|
||||
'/app/frontend-build',
|
||||
// Development paths
|
||||
path.resolve(__dirname, '../../frontend/dist'),
|
||||
path.resolve(__dirname, '../../frontend-build'),
|
||||
path.resolve(process.cwd(), '../frontend/dist'),
|
||||
path.resolve(process.cwd(), 'frontend-build'),
|
||||
];
|
||||
|
||||
for (const testPath of possiblePaths) {
|
||||
try {
|
||||
const { db } = await import('./services/databaseService.js');
|
||||
|
||||
const adminExists = await db.get<{ 'COUNT(*)': number }>(
|
||||
'SELECT COUNT(*) FROM employees WHERE role = ?',
|
||||
['admin']
|
||||
);
|
||||
|
||||
res.json({
|
||||
needsInitialSetup: !adminExists || adminExists['COUNT(*)'] === 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking initial setup:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
if (fs.existsSync(testPath)) {
|
||||
const indexPath = path.join(testPath, 'index.html');
|
||||
if (fs.existsSync(indexPath)) {
|
||||
console.log('✅ Found frontend build at:', testPath);
|
||||
return testPath;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silent catch - just try next path
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const frontendBuildPath = findFrontendBuildPath();
|
||||
|
||||
if (frontendBuildPath) {
|
||||
app.use(express.static(frontendBuildPath));
|
||||
console.log('✅ Static file serving configured');
|
||||
} else {
|
||||
console.log(isDevelopment ?
|
||||
'🔧 Development: Frontend served by Vite dev server (localhost:3003)' :
|
||||
'❌ Production: No frontend build found'
|
||||
);
|
||||
}
|
||||
|
||||
// Root route
|
||||
app.get('/', (req, res) => {
|
||||
if (!frontendBuildPath) {
|
||||
if (isDevelopment) {
|
||||
return res.redirect('http://localhost:3003');
|
||||
}
|
||||
return res.status(500).send('Frontend build not found');
|
||||
}
|
||||
|
||||
const indexPath = path.join(frontendBuildPath, 'index.html');
|
||||
res.sendFile(indexPath);
|
||||
});
|
||||
|
||||
// Client-side routing fallback
|
||||
app.get('*', (req, res) => {
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(404).json({ error: 'API endpoint not found' });
|
||||
}
|
||||
|
||||
if (!frontendBuildPath) {
|
||||
if (isDevelopment) {
|
||||
return res.redirect(`http://localhost:3003${req.path}`);
|
||||
}
|
||||
return res.status(500).json({ error: 'Frontend application not available' });
|
||||
}
|
||||
|
||||
const indexPath = path.join(frontendBuildPath, 'index.html');
|
||||
res.sendFile(indexPath);
|
||||
});
|
||||
|
||||
// Error handling
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
console.error('Error:', err);
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: 'Something went wrong'
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
message: err.message,
|
||||
stack: err.stack
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 404 handling
|
||||
app.use('*', (req, res) => {
|
||||
res.status(404).json({ error: 'Endpoint not found' });
|
||||
});
|
||||
|
||||
// Initialize the application
|
||||
const initializeApp = async () => {
|
||||
try {
|
||||
// Initialize database with base schema
|
||||
await initializeDatabase();
|
||||
//console.log('✅ Database initialized successfully');
|
||||
|
||||
// Apply any pending migrations
|
||||
const { applyMigration } = await import('./scripts/applyMigration.js');
|
||||
await applyMigration();
|
||||
//console.log('✅ Database migrations applied');
|
||||
|
||||
// Start server only after successful initialization
|
||||
app.listen(PORT, () => {
|
||||
console.log('🎉 BACKEND STARTED SUCCESSFULLY!');
|
||||
console.log('🎉 APPLICATION STARTED SUCCESSFULLY!');
|
||||
console.log(`📍 Port: ${PORT}`);
|
||||
console.log(`📍 Health: http://localhost:${PORT}/api/health`);
|
||||
console.log('');
|
||||
console.log(`🔧 Setup ready at: http://localhost:${PORT}/api/setup/status`);
|
||||
console.log('📝 Create your admin account on first launch');
|
||||
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`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Error during initialization:', error);
|
||||
process.exit(1); // Exit if initialization fails
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Start the application
|
||||
initializeApp();
|
||||
@@ -2,8 +2,7 @@
|
||||
import { Worker } from 'worker_threads';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { Employee, EmployeeAvailability } from '../models/Employee.js';
|
||||
import { ShiftPlan, ScheduledShift } from '../models/ShiftPlan.js';
|
||||
import { ShiftPlan } from '../models/ShiftPlan.js';
|
||||
import { ScheduleRequest, ScheduleResult, Availability, Constraint } from '../models/scheduling.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
// backend/src/services/databaseService
|
||||
import sqlite3 from 'sqlite3';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import fs from 'fs';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const dbPath = path.join(__dirname, '../../database/schichtplan.db');
|
||||
|
||||
const dbPath = process.env.DB_PATH || '/app/data/schichtplan.db';
|
||||
|
||||
// Stelle sicher, dass das Verzeichnis existiert
|
||||
const dbDir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dbDir)) {
|
||||
fs.mkdirSync(dbDir, { recursive: true });
|
||||
}
|
||||
|
||||
class Database {
|
||||
private db: sqlite3.Database;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { parentPort, workerData } from 'worker_threads';
|
||||
import { CPModel, CPSolver } from './cp-sat-wrapper.js';
|
||||
import { ShiftPlan, Shift } from '../models/ShiftPlan.js';
|
||||
import { Employee, EmployeeAvailability } from '../models/Employee.js';
|
||||
import { Availability, Constraint, Violation, SolverOptions, Solution, Assignment } from '../models/scheduling.js';
|
||||
import { Employee } from '../models/Employee.js';
|
||||
import { Availability, Constraint } from '../models/scheduling.js';
|
||||
|
||||
interface WorkerData {
|
||||
shiftPlan: ShiftPlan;
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
// backend/tsconfig.json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"allowJs": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
frontend:
|
||||
build: ./frontend
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
ports:
|
||||
- "3001:3001"
|
||||
schichtplaner:
|
||||
container_name: schichtplaner
|
||||
image: ghcr.io/donpat1to/schichtenplaner:v1.0.0
|
||||
environment:
|
||||
- DATABASE_URL=file:./dev.db
|
||||
- JWT_SECRET=your-secret-key
|
||||
- NODE_ENV=production
|
||||
- JWT_SECRET=${JWT_SECRET:-your-secret-key-please-change}
|
||||
ports:
|
||||
- "3002:3002"
|
||||
volumes:
|
||||
- app_data:/app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3002/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Später: Database service hinzufügen
|
||||
volumes:
|
||||
app_data:
|
||||
50
docker-init.sh
Normal file
50
docker-init.sh
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "🚀 Container Initialisierung gestartet..."
|
||||
|
||||
# Funktion zum Generieren eines sicheren Secrets
|
||||
generate_secret() {
|
||||
length=$1
|
||||
tr -dc 'A-Za-z0-9!@#$%^&*()_+-=' < /dev/urandom | head -c $length
|
||||
}
|
||||
|
||||
# Prüfe ob .env existiert
|
||||
if [ ! -f /app/.env ]; then
|
||||
echo "📝 Erstelle .env Datei..."
|
||||
|
||||
# Verwende vorhandenes JWT_SECRET oder generiere ein neues
|
||||
if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "your-secret-key-please-change" ]; then
|
||||
export JWT_SECRET=$(generate_secret 64)
|
||||
echo "🔑 Automatisch sicheres JWT Secret generiert"
|
||||
else
|
||||
echo "🔑 Verwende vorhandenes JWT Secret aus Umgebungsvariable"
|
||||
fi
|
||||
|
||||
# Erstelle .env aus Template mit envsubst
|
||||
envsubst < /app/.env.template > /app/.env
|
||||
echo "✅ .env Datei erstellt"
|
||||
|
||||
else
|
||||
echo "ℹ️ .env Datei existiert bereits"
|
||||
|
||||
# Wenn .env existiert, aber JWT_SECRET Umgebungsvariable gesetzt ist, aktualisiere sie
|
||||
if [ -n "$JWT_SECRET" ] && [ "$JWT_SECRET" != "your-secret-key-please-change" ]; then
|
||||
echo "🔑 Aktualisiere JWT Secret in .env Datei"
|
||||
# Aktualisiere nur das JWT_SECRET in der .env Datei
|
||||
sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$JWT_SECRET/" /app/.env
|
||||
fi
|
||||
fi
|
||||
|
||||
# Validiere dass JWT_SECERT nicht der Standardwert ist
|
||||
if grep -q "JWT_SECRET=your-secret-key-please-change" /app/.env; then
|
||||
echo "❌ FEHLER: Standard JWT Secret in .env gefunden!"
|
||||
echo "❌ Bitte setzen Sie JWT_SECRET Umgebungsvariable"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Setze sichere Berechtigungen
|
||||
chmod 600 /app/.env
|
||||
|
||||
echo "🔧 Starte Anwendung..."
|
||||
exec "$@"
|
||||
17
ecosystem.config.cjs
Normal file
17
ecosystem.config.cjs
Normal file
@@ -0,0 +1,17 @@
|
||||
// ecosystem.config.cjs
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'schichtplan-app',
|
||||
script: './dist/server.js',
|
||||
instances: 1,
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3002,
|
||||
FRONTEND_BUILD_PATH: './frontend-build'
|
||||
},
|
||||
error_file: './logs/err.log',
|
||||
out_file: './logs/out.log',
|
||||
log_file: './logs/combined.log',
|
||||
time: true
|
||||
}]
|
||||
};
|
||||
45
frontend/.gitignore
vendored
45
frontend/.gitignore
vendored
@@ -1,45 +0,0 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
/coverage
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
@@ -1,8 +0,0 @@
|
||||
# backend/Dockerfile
|
||||
FROM node:18-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY . .
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/server.js"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Shift Planning App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
17666
frontend/package-lock.json
generated
17666
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -2,46 +2,28 @@
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"@types/jest": "^27.5.2",
|
||||
"@types/node": "^16.18.126",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/react-dom": "^19.2.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"date-fns": "4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "20.19.23",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.9.3",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^2.1.4",
|
||||
"worker_threads": "native"
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7",
|
||||
"esbuild": "^0.21.0",
|
||||
"terser": "5.44.0",
|
||||
"babel-plugin-transform-remove-console": "6.9.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"
|
||||
]
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"short_name": "SP",
|
||||
"name": "schichtenplaner",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
// frontend/src/App.tsx - KORRIGIERT MIT LAYOUT
|
||||
// src/App.tsx
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
@@ -16,10 +16,42 @@ import Settings from './pages/Settings/Settings';
|
||||
import Help from './pages/Help/Help';
|
||||
import Setup from './pages/Setup/Setup';
|
||||
|
||||
// Free Footer Link Pages (always available)
|
||||
import FAQ from './components/Layout/FooterLinks/FAQ/FAQ';
|
||||
import About from './components/Layout/FooterLinks/About/About';
|
||||
import Features from './components/Layout/FooterLinks/Features/Features';
|
||||
import { CommunityContact, CommunityLegalPage } from './components/Layout/FooterLinks/CommunityLinks/communityLinks';
|
||||
|
||||
// Vite environment variables (use import.meta.env instead of process.env)
|
||||
const ENABLE_PRO = import.meta.env.ENABLE_PRO === 'true';
|
||||
|
||||
// Conditional Premium Components
|
||||
let PremiumContact: React.FC = CommunityContact;
|
||||
let PremiumPrivacy: React.FC = () => <CommunityLegalPage title="Datenschutz" />;
|
||||
let PremiumImprint: React.FC = () => <CommunityLegalPage title="Impressum" />;
|
||||
let PremiumTerms: React.FC = () => <CommunityLegalPage title="AGB" />;
|
||||
|
||||
// Load premium components only when ENABLE_PRO is true
|
||||
if (ENABLE_PRO) {
|
||||
try {
|
||||
// Use require with type assertions to avoid dynamic import issues
|
||||
const premiumModule = require('@premium-frontend/components/FooterLinks');
|
||||
|
||||
if (premiumModule.Contact) PremiumContact = premiumModule.Contact;
|
||||
if (premiumModule.Privacy) PremiumPrivacy = premiumModule.Privacy;
|
||||
if (premiumModule.Imprint) PremiumImprint = premiumModule.Imprint;
|
||||
if (premiumModule.Terms) PremiumTerms = premiumModule.Terms;
|
||||
|
||||
console.log('✅ Premium components loaded successfully');
|
||||
} catch (error) {
|
||||
console.warn('⚠️ Premium components not available, using community fallbacks:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Protected Route Component
|
||||
const ProtectedRoute: React.FC<{ children: React.ReactNode; roles?: string[] }> = ({
|
||||
children,
|
||||
roles = ['admin', 'instandhalter', 'user']
|
||||
roles = ['admin', 'maintenance', 'user']
|
||||
}) => {
|
||||
const { user, loading, hasRole } = useAuth();
|
||||
|
||||
@@ -49,11 +81,27 @@ const ProtectedRoute: React.FC<{ children: React.ReactNode; roles?: string[] }>
|
||||
return <Layout>{children}</Layout>;
|
||||
};
|
||||
|
||||
// Public Route Component (without Layout for footer pages)
|
||||
const PublicRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
<div>⏳ Lade Anwendung...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return user ? <Layout>{children}</Layout> : <>{children}</>;
|
||||
};
|
||||
|
||||
// Main App Content
|
||||
const AppContent: React.FC = () => {
|
||||
const { loading, needsSetup, user } = useAuth();
|
||||
|
||||
console.log('🏠 AppContent rendering - loading:', loading, 'needsSetup:', needsSetup, 'user:', user);
|
||||
console.log('🎯 Premium features enabled:', ENABLE_PRO);
|
||||
|
||||
// Während des Ladens
|
||||
if (loading) {
|
||||
@@ -80,52 +128,32 @@ const AppContent: React.FC = () => {
|
||||
console.log('✅ Showing protected routes for user:', user.email);
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/shift-plans" element={
|
||||
<ProtectedRoute>
|
||||
<ShiftPlanList />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/shift-plans/new" element={
|
||||
<ProtectedRoute roles={['admin', 'instandhalter']}>
|
||||
<ShiftPlanCreate />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/shift-plans/:id/edit" element={
|
||||
<ProtectedRoute roles={['admin', 'instandhalter']}>
|
||||
<ShiftPlanEdit />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/shift-plans/:id" element={
|
||||
<ProtectedRoute>
|
||||
<ShiftPlanView />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/employees" element={
|
||||
<ProtectedRoute roles={['admin', 'instandhalter']}>
|
||||
<EmployeeManagement />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/settings" element={
|
||||
<ProtectedRoute>
|
||||
<Settings />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/help" element={
|
||||
<ProtectedRoute>
|
||||
<Help />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
{/* Protected Routes (require login) */}
|
||||
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/shift-plans" element={<ProtectedRoute><ShiftPlanList /></ProtectedRoute>} />
|
||||
<Route path="/shift-plans/new" element={<ProtectedRoute roles={['admin', 'maintenance']}><ShiftPlanCreate /></ProtectedRoute>} />
|
||||
<Route path="/shift-plans/:id/edit" element={<ProtectedRoute roles={['admin', 'maintenance']}><ShiftPlanEdit /></ProtectedRoute>} />
|
||||
<Route path="/shift-plans/:id" element={<ProtectedRoute><ShiftPlanView /></ProtectedRoute>} />
|
||||
<Route path="/employees" element={<ProtectedRoute roles={['admin', 'maintenance']}><EmployeeManagement /></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||
<Route path="/help" element={<ProtectedRoute><Help /></ProtectedRoute>} />
|
||||
|
||||
{/* Public Footer Link Pages (always available) */}
|
||||
<Route path="/faq" element={<PublicRoute><FAQ /></PublicRoute>} />
|
||||
<Route path="/about" element={<PublicRoute><About /></PublicRoute>} />
|
||||
<Route path="/features" element={<PublicRoute><Features /></PublicRoute>} />
|
||||
|
||||
{/* PREMIUM Footer Link Pages (conditionally available) */}
|
||||
<Route path="/contact" element={<PublicRoute><PremiumContact /></PublicRoute>} />
|
||||
<Route path="/privacy" element={<PublicRoute><PremiumPrivacy /></PublicRoute>} />
|
||||
<Route path="/imprint" element={<PublicRoute><PremiumImprint /></PublicRoute>} />
|
||||
<Route path="/terms" element={<PublicRoute><PremiumTerms /></PublicRoute>} />
|
||||
|
||||
{/* Auth Routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="*" element={
|
||||
<ProtectedRoute>
|
||||
<Dashboard />
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Catch-all Route */}
|
||||
<Route path="*" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// frontend/src/components/Layout/Footer.tsx - ELEGANT WHITE DESIGN
|
||||
// frontend/src/components/Layout/Footer.tsx
|
||||
import React from 'react';
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
@@ -10,12 +10,12 @@ const Footer: React.FC = () => {
|
||||
borderTop: '1px solid rgba(251, 250, 246, 0.1)',
|
||||
},
|
||||
footerContent: {
|
||||
maxWidth: '1200px',
|
||||
maxWidth: '1500px',
|
||||
margin: '0 auto',
|
||||
padding: '3rem 2rem 2rem',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
|
||||
gap: '3rem',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(100px, 1fr))',
|
||||
gap: '1rem',
|
||||
},
|
||||
footerSection: {
|
||||
display: 'flex',
|
||||
@@ -182,20 +182,6 @@ const Footer: React.FC = () => {
|
||||
>
|
||||
Funktionen
|
||||
</a>
|
||||
<a
|
||||
href="/pricing"
|
||||
style={styles.footerLink}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.color = '#FBFAF6';
|
||||
e.currentTarget.style.transform = 'translateX(4px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.color = 'rgba(251, 250, 246, 0.7)';
|
||||
e.currentTarget.style.transform = 'translateX(0)';
|
||||
}}
|
||||
>
|
||||
Preise
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
76
frontend/src/components/Layout/FooterLinks/About/About.tsx
Normal file
76
frontend/src/components/Layout/FooterLinks/About/About.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
// frontend/src/components/Layout/FooterLinks/About/About.tsx
|
||||
import React from 'react';
|
||||
|
||||
const About: React.FC = () => {
|
||||
return (
|
||||
<div style={{ padding: '40px 20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1>👨💻 Über uns</h1>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '30px',
|
||||
marginTop: '20px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e0e0e0',
|
||||
lineHeight: 1.6
|
||||
}}>
|
||||
<h2 style={{ color: '#2c3e50' }}>Unser Team</h2>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginTop: '20px', padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
|
||||
<div style={{ marginRight: '20px' }}>
|
||||
<div style={{
|
||||
width: '80px',
|
||||
height: '80px',
|
||||
backgroundColor: '#3498db',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'white',
|
||||
fontSize: '2rem',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
P
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ color: '#2c3e50', margin: '0 0 5px 0' }}>Patrick</h3>
|
||||
<p style={{ color: '#6c757d', margin: '0 0 10px 0' }}>
|
||||
Full-Stack Developer & Projektleiter
|
||||
</p>
|
||||
<p style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||
GitHub: <a href="https://github.com/donpat1to" style={{ color: '#3498db' }}>donpat1to</a><br/>
|
||||
E-Mail: <a href="mailto:dev.patrick@inca-vikingo.de" style={{ color: '#3498db' }}>dev.patrick@inca-vikingo.de</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style={{ color: '#3498db', marginTop: '30px' }}>🚀 Unsere Mission</h3>
|
||||
<p>
|
||||
Wir entwickeln intelligente Lösungen für die Personalplanung,
|
||||
die Zeit sparen und faire Schichtverteilung gewährleisten.
|
||||
</p>
|
||||
|
||||
<h3 style={{ color: '#3498db', marginTop: '25px' }}>💻 Technologie</h3>
|
||||
<p>
|
||||
Unser Stack umfasst moderne Technologien:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Frontend: React, TypeScript</li>
|
||||
<li>Backend: Node.js, Express</li>
|
||||
<li>Optimierung: Google OR-Tools CP-SAT</li>
|
||||
<li>Datenbank: SQLite/PostgreSQL</li>
|
||||
</ul>
|
||||
|
||||
<h3 style={{ color: '#3498db', marginTop: '25px' }}>📈 Entwicklung</h3>
|
||||
<p>
|
||||
Schichtenplaner wird kontinuierlich weiterentwickelt und
|
||||
basiert auf Feedback unserer Nutzer.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default About;
|
||||
@@ -0,0 +1,38 @@
|
||||
// frontend/src/components/Layout/FooterLinks/CommunityLinks/communityLinks.tsx
|
||||
import React from 'react';
|
||||
|
||||
export const CommunityContact: React.FC = () => (
|
||||
<div style={{ padding: '40px 20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1>📞 Kontakt</h1>
|
||||
<div style={{ backgroundColor: 'white', borderRadius: '12px', padding: '30px', marginTop: '20px' }}>
|
||||
<h2 style={{ color: '#2c3e50' }}>Community Edition</h2>
|
||||
<p>Kontaktfunktionen sind in der Premium Edition verfügbar.</p>
|
||||
<p>
|
||||
<a href="/features" style={{ color: '#3498db' }}>
|
||||
➡️ Zu den Features
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CommunityLegalPage: React.FC<{ title: string }> = ({ title }) => (
|
||||
<div style={{ padding: '40px 20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1>📄 {title}</h1>
|
||||
<div style={{ backgroundColor: 'white', borderRadius: '12px', padding: '30px', marginTop: '20px' }}>
|
||||
<h2 style={{ color: '#2c3e50' }}>Community Edition</h2>
|
||||
<p>Rechtliche Dokumentation ist in der Premium Edition verfügbar.</p>
|
||||
<p>
|
||||
<a href="/features" style={{ color: '#3498db' }}>
|
||||
➡️ Erfahren Sie mehr über Premium
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Optional: Barrel export für einfachere Imports
|
||||
export default {
|
||||
CommunityContact,
|
||||
CommunityLegalPage
|
||||
};
|
||||
103
frontend/src/components/Layout/FooterLinks/FAQ/FAQ.tsx
Normal file
103
frontend/src/components/Layout/FooterLinks/FAQ/FAQ.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
// frontend/src/components/Layout/FooterLinks/FAQ/FAQ.tsx
|
||||
import React, { useState } from 'react';
|
||||
|
||||
const FAQ: React.FC = () => {
|
||||
const [openItems, setOpenItems] = useState<number[]>([]);
|
||||
|
||||
const toggleItem = (index: number) => {
|
||||
setOpenItems(prev =>
|
||||
prev.includes(index)
|
||||
? prev.filter(i => i !== index)
|
||||
: [...prev, index]
|
||||
);
|
||||
};
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: "Wie funktioniert der Scheduling-Algorithmus?",
|
||||
answer: "Unser System verwendet Google's OR-Tools CP-SAT Solver, um optimale Schichtzuweisungen basierend auf Verfügbarkeiten, Vertragstypen und Geschäftsregeln zu berechnen."
|
||||
},
|
||||
{
|
||||
question: "Was bedeuten die Verfügbarkeits-Level 1, 2 und 3?",
|
||||
answer: "Level 1: Bevorzugt (Mitarbeiter möchte diese Schicht), Level 2: Verfügbar (kann arbeiten), Level 3: Nicht verfügbar (kann nicht arbeiten)."
|
||||
},
|
||||
{
|
||||
question: "Wie werden Vertragstypen berücksichtigt?",
|
||||
answer: "Kleine Verträge: 1 Schicht pro Woche, Große Verträge: 2 Schichten pro Woche. Das System weist genau diese Anzahl zu."
|
||||
},
|
||||
{
|
||||
question: "Kann ich manuelle Anpassungen vornehmen?",
|
||||
answer: "Ja, nach dem automatischen Scheduling können Sie Zuordnungen manuell anpassen und optimieren."
|
||||
},
|
||||
{
|
||||
question: "Was passiert bei unterbesetzten Schichten?",
|
||||
answer: "Das System zeigt eine Warnung an und versucht, alternative Lösungen zu finden. In kritischen Fällen müssen manuelle Anpassungen vorgenommen werden."
|
||||
},
|
||||
{
|
||||
question: "Wie lange dauert die Planungserstellung?",
|
||||
answer: "Typischerweise maximal 105 Sekunden, abhängig von der Anzahl der Mitarbeiter und Schichten."
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '40px 20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1>❓ Häufige Fragen (FAQ)</h1>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
marginTop: '20px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
{faqItems.map((item, index) => (
|
||||
<div key={index} style={{
|
||||
borderBottom: index < faqItems.length - 1 ? '1px solid #e0e0e0' : 'none',
|
||||
padding: '20px 30px'
|
||||
}}>
|
||||
<div
|
||||
onClick={() => toggleItem(index)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
>
|
||||
<h3 style={{
|
||||
color: '#2c3e50',
|
||||
margin: 0,
|
||||
fontSize: '1.1rem'
|
||||
}}>
|
||||
{item.question}
|
||||
</h3>
|
||||
<span style={{
|
||||
fontSize: '1.5rem',
|
||||
color: '#3498db',
|
||||
transform: openItems.includes(index) ? 'rotate(45deg)' : 'rotate(0)',
|
||||
transition: 'transform 0.2s ease'
|
||||
}}>
|
||||
+
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{openItems.includes(index) && (
|
||||
<div style={{
|
||||
marginTop: '15px',
|
||||
padding: '15px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
color: '#6c757d',
|
||||
lineHeight: 1.6
|
||||
}}>
|
||||
{item.answer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FAQ;
|
||||
111
frontend/src/components/Layout/FooterLinks/Features/Features.tsx
Normal file
111
frontend/src/components/Layout/FooterLinks/Features/Features.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
// frontend/src/components/Layou/FooterLinks/Features/Features.tsx
|
||||
import React from 'react';
|
||||
|
||||
const Features: React.FC = () => {
|
||||
const features = [
|
||||
{
|
||||
icon: "🤖",
|
||||
title: "Automatisches Scheduling",
|
||||
description: "Intelligenter Algorithmus erstellt optimale Schichtpläne basierend auf Verfügbarkeiten und Regeln"
|
||||
},
|
||||
{
|
||||
icon: "⚡",
|
||||
title: "Schnelle Berechnung",
|
||||
description: "Google OR-Tools CP-SAT Solver findet Lösungen in maximal 105 Sekunden"
|
||||
},
|
||||
{
|
||||
icon: "👥",
|
||||
title: "Flexible Regelkonfiguration",
|
||||
description: "Anpassbare Geschäftsregeln für Trainee-Betreuung, Alleinarbeit, Vertragstypen"
|
||||
},
|
||||
{
|
||||
icon: "📊",
|
||||
title: "Echtzeit-Validierung",
|
||||
description: "Automatische Erkennung von Regelverletzungen und Konflikten"
|
||||
},
|
||||
{
|
||||
icon: "🔒",
|
||||
title: "Lokale Datenspeicherung",
|
||||
description: "Alle Daten bleiben in Ihrer Infrastruktur - volle Kontrolle und Datenschutz"
|
||||
},
|
||||
{
|
||||
icon: "🎯",
|
||||
title: "Präferenz-basiert",
|
||||
description: "Berücksichtigt Mitarbeiterwünsche für höhere Zufriedenheit"
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '40px 20px', maxWidth: '1000px', margin: '0 auto' }}>
|
||||
<h1>✨ Funktionen</h1>
|
||||
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '30px',
|
||||
marginTop: '20px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<h2 style={{ color: '#2c3e50', textAlign: 'center', marginBottom: '40px' }}>
|
||||
Alles, was Sie für die perfekte Schichtplanung benötigen
|
||||
</h2>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
||||
gap: '30px'
|
||||
}}>
|
||||
{features.map((feature, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '25px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid #e9ecef',
|
||||
textAlign: 'center',
|
||||
transition: 'transform 0.2s ease, box-shadow 0.2s ease'
|
||||
}}>
|
||||
<div style={{
|
||||
fontSize: '3rem',
|
||||
marginBottom: '15px'
|
||||
}}>
|
||||
{feature.icon}
|
||||
</div>
|
||||
<h3 style={{
|
||||
color: '#2c3e50',
|
||||
margin: '0 0 15px 0'
|
||||
}}>
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p style={{
|
||||
color: '#6c757d',
|
||||
margin: 0,
|
||||
lineHeight: 1.5
|
||||
}}>
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
marginTop: '40px',
|
||||
padding: '25px',
|
||||
backgroundColor: '#e8f4fd',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid #b8d4f0',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<h3 style={{ color: '#2980b9', margin: '0 0 15px 0' }}>
|
||||
🚀 Starter Sie durch
|
||||
</h3>
|
||||
<p style={{ color: '#2c3e50', margin: 0 }}>
|
||||
Erstellen Sie Ihren ersten optimierten Schichtplan in wenigen Minuten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Features;
|
||||
@@ -1,220 +0,0 @@
|
||||
/* Layout.css - Professionelles Design */
|
||||
.layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Desktop Navigation */
|
||||
.desktop-nav {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* User Menu */
|
||||
.user-menu {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Mobile Menu Button */
|
||||
.mobile-menu-btn {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Mobile Navigation */
|
||||
.mobile-nav {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background: white;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.mobile-nav-link {
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-nav-link:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.mobile-user-info {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #eee;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.mobile-logout-btn {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
background-color: #f8f9fa;
|
||||
min-height: calc(100vh - 140px);
|
||||
}
|
||||
|
||||
.content-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 20px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.footer-section h3,
|
||||
.footer-section h4 {
|
||||
margin-bottom: 1rem;
|
||||
color: #ecf0f1;
|
||||
}
|
||||
|
||||
.footer-section a {
|
||||
color: #bdc3c7;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.footer-section a:hover {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
border-top: 1px solid #34495e;
|
||||
padding: 1rem 20px;
|
||||
text-align: center;
|
||||
color: #95a5a6;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.desktop-nav,
|
||||
.user-menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
padding: 1rem 15px;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.logo h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
padding: 1rem 10px;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// frontend/src/components/Layout/Layout.tsx - ELEGANT WHITE DESIGN
|
||||
// frontend/src/components/Layout/Layout.tsx
|
||||
import React from 'react';
|
||||
import Navigation from './Navigation';
|
||||
import Footer from './Footer';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// frontend/src/components/Layout/Navigation.tsx - ELEGANT WHITE DESIGN
|
||||
// frontend/src/components/Layout/Navigation.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import PillNav from '../PillNav/PillNav';
|
||||
@@ -30,11 +30,11 @@ const Navigation: React.FC = () => {
|
||||
};
|
||||
|
||||
const navigationItems = [
|
||||
{ path: '/', label: 'Dashboard', roles: ['admin', 'instandhalter', 'user'] },
|
||||
{ path: '/shift-plans', label: 'Schichtpläne', roles: ['admin', 'instandhalter', 'user'] },
|
||||
{ path: '/employees', label: 'Mitarbeiter', roles: ['admin', 'instandhalter'] },
|
||||
{ path: '/help', label: 'Hilfe', roles: ['admin', 'instandhalter', 'user'] },
|
||||
{ path: '/settings', label: 'Einstellungen', roles: ['admin', 'instandhalter', 'user'] },
|
||||
{ path: '/', label: 'Dashboard', roles: ['admin', 'maintenance', 'user'] },
|
||||
{ path: '/shift-plans', label: 'Schichtpläne', roles: ['admin', 'maintenance', 'user'] },
|
||||
{ path: '/employees', label: 'Mitarbeiter', roles: ['admin', 'maintenance'] },
|
||||
{ path: '/help', label: 'Hilfe', roles: ['admin', 'maintenance', 'user'] },
|
||||
{ path: '/settings', label: 'Einstellungen', roles: ['admin', 'maintenance', 'user'] },
|
||||
];
|
||||
|
||||
const filteredNavigation = navigationItems.filter(item =>
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
/* frontend/src/components/PillNav/PillNav.module.css */
|
||||
.pillNavContainer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding: 4px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.pillNavContainer::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pill {
|
||||
padding: 8px 16px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.pill:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Solid Variant */
|
||||
.pillSolid {
|
||||
background-color: transparent;
|
||||
color: #6b7280;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.pillSolidActive {
|
||||
background-color: #2563eb;
|
||||
color: white;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.pillSolid:hover:not(.pillSolidActive) {
|
||||
background-color: #f3f4f6;
|
||||
color: #374151;
|
||||
border-color: #9ca3af;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Outline Variant */
|
||||
.pillOutline {
|
||||
background-color: transparent;
|
||||
color: #6b7280;
|
||||
border-color: #d1d5db;
|
||||
}
|
||||
|
||||
.pillOutlineActive {
|
||||
color: #2563eb;
|
||||
border-color: #2563eb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.pillOutline:hover:not(.pillOutlineActive) {
|
||||
background-color: #f3f4f6;
|
||||
color: #374151;
|
||||
border-color: #9ca3af;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Ghost Variant */
|
||||
.pillGhost {
|
||||
background-color: transparent;
|
||||
color: #6b7280;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.pillGhostActive {
|
||||
background-color: #f3f4f6;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.pillGhost:hover:not(.pillGhostActive) {
|
||||
background-color: #f9fafb;
|
||||
color: #374151;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// frontend/src/components/PillNav/PillNav.tsx - ELEGANT WHITE DESIGN
|
||||
// frontend/src/components/PillNav/PillNav.tsx
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
export interface PillNavItem {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
// frontend/src/components/PillNav/index.ts
|
||||
export { default } from './PillNav';
|
||||
export type { PillNavProps, PillNavItem } from './PillNav';
|
||||
@@ -20,6 +20,7 @@ interface AuthContextType {
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || '/api';
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
@@ -48,7 +49,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
const checkSetupStatus = async (): Promise<void> => {
|
||||
try {
|
||||
console.log('🔍 Checking setup status...');
|
||||
const response = await fetch('http://localhost:3002/api/setup/status');
|
||||
const response = await fetch(`${API_BASE_URL}/setup/status`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Setup status check failed');
|
||||
}
|
||||
@@ -72,7 +73,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch('http://localhost:3002/api/auth/me', {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
@@ -104,7 +105,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
|
||||
try {
|
||||
console.log('🔐 Attempting login for:', credentials.email);
|
||||
|
||||
const response = await fetch('http://localhost:3002/api/auth/login', {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// frontend/src/design/DesignSystem.tsx
|
||||
// frontend/src/design/DesignSystem.txt
|
||||
export const designTokens = {
|
||||
colors: {
|
||||
// Primary Colors
|
||||
@@ -1,3 +1,14 @@
|
||||
/* Reset and base styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(
|
||||
document.getElementById('root') as HTMLElement
|
||||
);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -1,18 +1,21 @@
|
||||
// frontend/src/pages/Auth/Login.tsx - KORRIGIERT
|
||||
import React, { useState, useEffect } from 'react';
|
||||
// frontend/src/pages/Auth/Login.tsx - UPDATED PASSWORD SECTION
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { employeeService } from '../../services/employeeService';
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login, user } = useAuth();
|
||||
const { showNotification } = useNotification();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const holdTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const passwordInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
console.log('✅ User already logged in, redirecting to dashboard');
|
||||
@@ -20,6 +23,47 @@ const Login: React.FC = () => {
|
||||
}
|
||||
}, [user, navigate]);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (holdTimeoutRef.current) {
|
||||
clearTimeout(holdTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMouseDown = () => {
|
||||
// Start timeout to show password after a brief delay (300ms)
|
||||
holdTimeoutRef.current = setTimeout(() => {
|
||||
setShowPassword(true);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// Clear the timeout if user releases before delay completes
|
||||
if (holdTimeoutRef.current) {
|
||||
clearTimeout(holdTimeoutRef.current);
|
||||
holdTimeoutRef.current = null;
|
||||
}
|
||||
// Always hide password on release
|
||||
setShowPassword(false);
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
e.preventDefault(); // Prevent context menu on mobile
|
||||
handleMouseDown();
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
handleMouseUp();
|
||||
};
|
||||
|
||||
// Prevent context menu on long press
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@@ -35,7 +79,6 @@ const Login: React.FC = () => {
|
||||
message: `Willkommen zurück!`
|
||||
});
|
||||
|
||||
// Navigiere zur Startseite
|
||||
navigate('/');
|
||||
|
||||
} catch (error: any) {
|
||||
@@ -50,7 +93,6 @@ const Login: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Wenn bereits eingeloggt, zeige Ladeanzeige
|
||||
if (user) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>
|
||||
@@ -77,7 +119,7 @@ const Login: React.FC = () => {
|
||||
}}>
|
||||
<h2 style={{ textAlign: 'center', marginBottom: '30px' }}>Anmeldung</h2>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{ marginBottom: '20px', width: '100%' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
|
||||
E-Mail
|
||||
</label>
|
||||
@@ -97,24 +139,57 @@ const Login: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<div style={{ marginBottom: '30px', width: '100%' }}>
|
||||
<label style={{ display: 'block', marginBottom: '8px', fontWeight: 'bold' }}>
|
||||
Passwort
|
||||
</label>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<input
|
||||
type="password"
|
||||
ref={passwordInputRef}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
paddingRight: '10px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
placeholder="Ihr Passwort"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp} // Handle mouse leaving while pressed
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchEnd} // Handle touch cancellation
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '10px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '1px',
|
||||
borderRadius: '1px',
|
||||
backgroundColor: showPassword ? '#e0e0e0' : 'transparent',
|
||||
transition: 'background-color 0.2s',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
touchAction: 'manipulation'
|
||||
}}
|
||||
title="Gedrückt halten zum Anzeigen des Passworts"
|
||||
>
|
||||
{showPassword ? '👁' : '👁'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -123,7 +198,7 @@ const Login: React.FC = () => {
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
backgroundColor: loading ? '#ccc' : '#007bff',
|
||||
backgroundColor: loading ? '#ccc' : '#51258f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
|
||||
@@ -393,7 +393,7 @@ const Dashboard: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Quick Actions - Nur für Admins/Instandhalter */}
|
||||
{hasRole(['admin', 'instandhalter']) && (
|
||||
{hasRole(['admin', 'maintenance']) && (
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h2 style={{ marginBottom: '15px', color: '#2c3e50' }}>Schnellaktionen</h2>
|
||||
<div style={{
|
||||
@@ -535,7 +535,7 @@ const Dashboard: React.FC = () => {
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: '#666' }}>
|
||||
<div style={{ fontSize: '48px', marginBottom: '10px' }}>📅</div>
|
||||
<div>Kein aktiver Schichtplan</div>
|
||||
{hasRole(['admin', 'instandhalter']) && (
|
||||
{hasRole(['admin', 'maintenance']) && (
|
||||
<Link to="/shift-plans/new">
|
||||
<button style={{
|
||||
marginTop: '10px',
|
||||
@@ -643,7 +643,7 @@ const Dashboard: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Letzte Schichtpläne (für Admins/Instandhalter) */}
|
||||
{hasRole(['admin', 'instandhalter']) && (
|
||||
{hasRole(['admin', 'maintenance']) && (
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
padding: '20px',
|
||||
|
||||
@@ -588,6 +588,31 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Contract type validation
|
||||
const availableShifts = validAvailabilities.filter(avail =>
|
||||
avail.preferenceLevel === 1 || avail.preferenceLevel === 2
|
||||
).length;
|
||||
|
||||
let contractRequirement = 0;
|
||||
let contractTypeName = '';
|
||||
|
||||
if (employee.contractType === 'small') {
|
||||
contractRequirement = 2;
|
||||
contractTypeName = 'Kleiner Vertrag';
|
||||
} else if (employee.contractType === 'large') {
|
||||
contractRequirement = 3;
|
||||
contractTypeName = 'Großer Vertrag';
|
||||
}
|
||||
|
||||
if (contractRequirement > 0 && availableShifts < contractRequirement) {
|
||||
setError(
|
||||
`${contractTypeName} erfordert mindestens ${contractRequirement} verfügbare Shifts. ` +
|
||||
`Aktuell sind nur ${availableShifts} Shifts mit Verfügbarkeit "Bevorzugt" oder "Möglich" ausgewählt.`
|
||||
);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to the format expected by the API - using shiftId directly
|
||||
const requestData = {
|
||||
planId: selectedPlanId,
|
||||
@@ -633,6 +658,12 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
||||
// Get full name for display
|
||||
const employeeFullName = `${employee.firstname} ${employee.lastname}`;
|
||||
|
||||
// Mininmum amount of shifts per contract type
|
||||
const availableShiftsCount = availabilities.filter(avail =>
|
||||
avail.preferenceLevel === 1 || avail.preferenceLevel === 2
|
||||
).length;
|
||||
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
maxWidth: '1900px',
|
||||
@@ -660,6 +691,14 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
||||
<p style={{ margin: 0, color: '#7f8c8d' }}>
|
||||
<strong>Email:</strong> {employee.email}
|
||||
</p>
|
||||
{employee.contractType && (
|
||||
<p style={{ margin: '5px 0 0 0', color: employee.contractType === 'small' ? '#f39c12' : '#27ae60' }}>
|
||||
<strong>Vertrag:</strong>
|
||||
{employee.contractType === 'small' ? ' Kleiner Vertrag (min. 2 verfügbare Shifts)' :
|
||||
employee.contractType === 'large' ? ' Großer Vertrag (min. 3 verfügbare Shifts)' :
|
||||
' Flexibler Vertrag'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -816,7 +855,7 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{saving ? '⏳ Wird gespeichert...' : 'Verfügbarkeiten speichern'}
|
||||
{saving ? '⏳ Wird gespeichert...' : `Verfügbarkeiten speichern (${availableShiftsCount})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -99,21 +99,26 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
if (checked) {
|
||||
return {
|
||||
...prev,
|
||||
roles: [...prev.roles, role]
|
||||
roles: [role]
|
||||
};
|
||||
} else {
|
||||
const newRoles = prev.roles.filter(r => r !== role);
|
||||
return{
|
||||
...prev,
|
||||
roles: prev.roles.filter(r => r !== role)
|
||||
roles: newRoles.length > 0 ? newRoles : ['user']
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleEmployeeTypeChange = (employeeType: EmployeeType) => {
|
||||
// Determine if contract type should be shown and set default
|
||||
const requiresContract = employeeType !== 'guest';
|
||||
const defaultContractType = requiresContract ? 'small' as ContractType : undefined;
|
||||
// Determine contract type based on employee type
|
||||
let contractType: ContractType | undefined;
|
||||
if (employeeType === 'manager' || employeeType === 'apprentice') {
|
||||
contractType = 'flexible';
|
||||
} else if (employeeType !== 'guest') {
|
||||
contractType = 'small';
|
||||
}
|
||||
|
||||
// Determine if can work alone based on employee type
|
||||
const canWorkAlone = employeeType === 'manager' ||
|
||||
@@ -125,7 +130,7 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
employeeType,
|
||||
contractType: defaultContractType,
|
||||
contractType,
|
||||
canWorkAlone,
|
||||
isTrainee
|
||||
}));
|
||||
@@ -180,7 +185,7 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
// Password change logic remains the same
|
||||
if (showPasswordSection && passwordForm.newPassword && hasRole(['admin'])) {
|
||||
if (passwordForm.newPassword.length < 6) {
|
||||
throw new Error('Das neue Passwort muss mindestens 6 Zeichen lang sein');
|
||||
throw new Error('Das Passwort muss mindestens 6 Zeichen lang sein, Zahlen und Groß- / Kleinbuchstaben enthalten');
|
||||
}
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
throw new Error('Die Passwörter stimmen nicht überein');
|
||||
@@ -273,7 +278,7 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
onChange={handleChange}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
width: '94%',
|
||||
padding: '10px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
@@ -294,7 +299,7 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
onChange={handleChange}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
width: '94%',
|
||||
padding: '10px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
@@ -311,7 +316,7 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
E-Mail Adresse (automatisch generiert)
|
||||
</label>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
width: '97%',
|
||||
padding: '10px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
@@ -340,93 +345,21 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
required
|
||||
minLength={6}
|
||||
style={{
|
||||
width: '100%',
|
||||
width: '97%',
|
||||
padding: '10px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
placeholder="Mindestens 6 Zeichen, Zahlen, Groß- / Kleinzeichen"
|
||||
/>
|
||||
<div style={{ fontSize: '12px', color: '#7f8c8d', marginTop: '5px' }}>
|
||||
Das Passwort muss mindestens 6 Zeichen lang sein.
|
||||
Das Passwort muss mindestens 6 Zeichen lang sein, Zahlen und Groß- / Kleinbuchstaben enthalten.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vertragstyp (nur für Admins und interne Mitarbeiter) */}
|
||||
{hasRole(['admin']) && showContractType && (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#e8f4fd',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #b6d7e8'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 15px 0', color: '#0c5460' }}>📝 Vertragstyp</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{contractTypeOptions.map(contract => (
|
||||
<div
|
||||
key={contract.value}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
padding: '15px',
|
||||
border: `2px solid ${formData.contractType === contract.value ? '#3498db' : '#e0e0e0'}`,
|
||||
borderRadius: '8px',
|
||||
backgroundColor: formData.contractType === contract.value ? '#f0f8ff' : 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
onClick={() => handleContractTypeChange(contract.value)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="contractType"
|
||||
value={contract.value}
|
||||
checked={formData.contractType === contract.value}
|
||||
onChange={() => handleContractTypeChange(contract.value)}
|
||||
style={{
|
||||
marginRight: '12px',
|
||||
marginTop: '2px',
|
||||
width: '18px',
|
||||
height: '18px'
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontWeight: 'bold',
|
||||
color: '#2c3e50',
|
||||
marginBottom: '4px',
|
||||
fontSize: '16px'
|
||||
}}>
|
||||
{contract.label}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#7f8c8d',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{contract.description}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: formData.contractType === contract.value ? '#3498db' : '#95a5a6',
|
||||
color: 'white',
|
||||
borderRadius: '15px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{contract.value.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mitarbeiter Kategorie */}
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
@@ -528,6 +461,107 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vertragstyp (nur für Admins und interne Mitarbeiter) */}
|
||||
{hasRole(['admin']) && showContractType && (
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#e8f4fd',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #b6d7e8'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 15px 0', color: '#0c5460' }}>📝 Vertragstyp</h3>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{contractTypeOptions.map(contract => {
|
||||
const isFlexibleDisabled = contract.value === 'flexible' && formData.employeeType === 'personell';
|
||||
const isSmallLargeDisabled = (contract.value === 'small' || contract.value === 'large') &&
|
||||
(formData.employeeType === 'manager' || formData.employeeType === 'apprentice');
|
||||
const isDisabled = isFlexibleDisabled || isSmallLargeDisabled;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={contract.value}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
padding: '15px',
|
||||
border: `2px solid ${formData.contractType === contract.value ? '#3498db' : '#e0e0e0'}`,
|
||||
borderRadius: '8px',
|
||||
backgroundColor: formData.contractType === contract.value ? '#f0f8ff' : 'white',
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
opacity: isDisabled ? 0.6 : 1
|
||||
}}
|
||||
onClick={isDisabled ? undefined : () => handleContractTypeChange(contract.value)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="contractType"
|
||||
value={contract.value}
|
||||
checked={formData.contractType === contract.value}
|
||||
onChange={isDisabled ? undefined : () => handleContractTypeChange(contract.value)}
|
||||
disabled={isDisabled}
|
||||
style={{
|
||||
marginRight: '12px',
|
||||
marginTop: '2px',
|
||||
width: '18px',
|
||||
height: '18px'
|
||||
}}
|
||||
/>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{
|
||||
fontWeight: 'bold',
|
||||
color: '#2c3e50',
|
||||
marginBottom: '4px',
|
||||
fontSize: '16px'
|
||||
}}>
|
||||
{contract.label}
|
||||
{isFlexibleDisabled && (
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: '#e74c3c',
|
||||
marginLeft: '8px',
|
||||
fontWeight: 'normal'
|
||||
}}>
|
||||
(Nicht verfügbar für Personell)
|
||||
</span>
|
||||
)}
|
||||
{isSmallLargeDisabled && (
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
color: '#e74c3c',
|
||||
marginLeft: '8px',
|
||||
fontWeight: 'normal'
|
||||
}}>
|
||||
(Nicht verfügbar für {formData.employeeType === 'manager' ? 'Manager' : 'Auszubildende'})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '14px',
|
||||
color: '#7f8c8d',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{contract.description}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
padding: '6px 12px',
|
||||
backgroundColor: isDisabled ? '#95a5a6' : (formData.contractType === contract.value ? '#3498db' : '#95a5a6'),
|
||||
color: 'white',
|
||||
borderRadius: '15px',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{contract.value.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Eigenständigkeit */}
|
||||
<div style={{
|
||||
padding: '20px',
|
||||
@@ -638,7 +672,7 @@ const EmployeeForm: React.FC<EmployeeFormProps> = ({
|
||||
borderRadius: '4px',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
placeholder="Mindestens 6 Zeichen, Zahlen, Groß- / Kleinzeichen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -48,6 +48,13 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
||||
return true;
|
||||
});
|
||||
|
||||
// Helper to get highest role for sorting
|
||||
const getHighestRole = (roles: string[]): string => {
|
||||
if (roles.includes('admin')) return 'admin';
|
||||
if (roles.includes('maintenance')) return 'maintenance';
|
||||
return 'user';
|
||||
};
|
||||
|
||||
// Sort employees based on selected field and direction
|
||||
const sortedEmployees = [...filteredEmployees].sort((a, b) => {
|
||||
let aValue: any;
|
||||
@@ -67,7 +74,6 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
||||
bValue = b.canWorkAlone;
|
||||
break;
|
||||
case 'role':
|
||||
// Use the highest role for sorting
|
||||
aValue = getHighestRole(a.roles || []);
|
||||
bValue = getHighestRole(b.roles || []);
|
||||
break;
|
||||
@@ -87,13 +93,6 @@ const EmployeeList: React.FC<EmployeeListProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to get highest role for sorting
|
||||
const getHighestRole = (roles: string[]): string => {
|
||||
if (roles.includes('admin')) return 'admin';
|
||||
if (roles.includes('maintenance')) return 'maintenance';
|
||||
return 'user';
|
||||
};
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
// Toggle direction if same field
|
||||
|
||||
@@ -1,213 +1,50 @@
|
||||
// frontend/src/pages/Help/Help.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
const Help: React.FC = () => {
|
||||
const [currentStage, setCurrentStage] = useState(0);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
const algorithmStages = [
|
||||
{
|
||||
title: "📊 Phase A: Reguläre Mitarbeiterplanung",
|
||||
description: "Zuweisung aller Mitarbeiter außer Manager",
|
||||
steps: [
|
||||
"Grundabdeckung: Mindestens 1 Mitarbeiter pro Schicht",
|
||||
"Erfahrene Mitarbeiter werden bevorzugt",
|
||||
"Verhindere 'Neu allein' Situationen",
|
||||
"Fülle Schichten bis zur Zielbesetzung"
|
||||
],
|
||||
color: "#3498db"
|
||||
},
|
||||
{
|
||||
title: "👑 Phase B: Manager-Einfügung",
|
||||
description: "Manager wird seinen bevorzugten Schichten zugewiesen",
|
||||
steps: [
|
||||
"Manager wird festen Schichten zugewiesen",
|
||||
"Erfahrene Mitarbeiter werden zu Manager-Schichten hinzugefügt",
|
||||
"Bei Problemen: Austausch oder Bewegung von Mitarbeitern",
|
||||
"Fallback: Nicht-erfahrene als Backup"
|
||||
],
|
||||
color: "#e74c3c"
|
||||
},
|
||||
{
|
||||
title: "🔧 Phase C: Reparatur & Validierung",
|
||||
description: "Probleme erkennen und automatisch beheben",
|
||||
steps: [
|
||||
"Überbesetzte erfahrene Mitarbeiter identifizieren",
|
||||
"Mitarbeiter-Pool für Neuverteilung erstellen",
|
||||
"Priorisierte Zuweisung zu Problem-Schichten",
|
||||
"Finale Validierung aller Geschäftsregeln"
|
||||
],
|
||||
color: "#2ecc71"
|
||||
},
|
||||
{
|
||||
title: "✅ Finale Prüfung",
|
||||
description: "Zusammenfassung und Freigabe",
|
||||
steps: [
|
||||
"Reparatur-Bericht generieren",
|
||||
"Kritische vs. nicht-kritische Probleme klassifizieren",
|
||||
"Veröffentlichungsstatus bestimmen",
|
||||
"Benutzerfreundliche Zusammenfassung anzeigen"
|
||||
],
|
||||
color: "#f39c12"
|
||||
}
|
||||
];
|
||||
|
||||
const businessRules = [
|
||||
{ rule: "Manager darf nicht allein arbeiten", critical: true },
|
||||
{ rule: "Erfahrene mit canWorkAlone: false dürfen nicht allein arbeiten", critical: true },
|
||||
{ rule: "Keine leeren Schichten", critical: true },
|
||||
{ rule: "Keine 'Neu allein' Situationen", critical: true },
|
||||
{ rule: "Manager sollte mit erfahrenem Mitarbeiter arbeiten", critical: false },
|
||||
{ rule: "Vertragslimits einhalten", critical: true },
|
||||
{ rule: "Nicht zu viele erfahrene Mitarbeiter in einer Schicht", critical: false }
|
||||
{ rule: "Mitarbeiter werden nur Schichten zugewiesen, für die sie sich eingetragen haben", critical: true },
|
||||
{ rule: "Maximal 1 Schicht pro Tag pro Mitarbeiter", critical: true },
|
||||
{ rule: "Schichten haben Mindest- und Maximalkapazitäten", critical: true },
|
||||
{ rule: "Trainees benötigen erfahrene Begleitung in jeder Schicht", critical: true },
|
||||
{ rule: "Mitarbeiter, die nicht alleine arbeiten können, müssen Begleitung haben", critical: true },
|
||||
{ rule: "Vertragslimits: Klein=1 Schicht/Woche, Groß=2 Schichten/Woche", critical: true },
|
||||
{ rule: "Manager werden automatisch ihren bevorzugten Schichten zugewiesen", critical: false }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (isAnimating) {
|
||||
setCurrentStage((prev) => (prev + 1) % algorithmStages.length);
|
||||
const schedulingStages = [
|
||||
{
|
||||
title: "1. Verfügbarkeitsprüfung",
|
||||
description: "Nur Mitarbeiter, die sich für Schichten eingetragen haben (Verfügbarkeit 1 oder 2), werden berücksichtigt."
|
||||
},
|
||||
{
|
||||
title: "2. Modellaufbau",
|
||||
description: "Das System erstellt ein mathematisches Modell mit allen Variablen und Constraints."
|
||||
},
|
||||
{
|
||||
title: "3. CP-SAT Optimierung",
|
||||
description: "Google's Constraint Programming Solver findet die beste Zuordnung unter allen Regeln."
|
||||
},
|
||||
{
|
||||
title: "4. Manager-Zuweisung",
|
||||
description: "Manager werden automatisch ihren Wunschschichten (Verfügbarkeit 1) zugeordnet."
|
||||
},
|
||||
{
|
||||
title: "5. Validierung",
|
||||
description: "Die Lösung wird auf Regelverletzungen geprüft und ein Bericht generiert."
|
||||
}
|
||||
}, 3000);
|
||||
];
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isAnimating]);
|
||||
|
||||
const toggleAnimation = () => {
|
||||
setIsAnimating(!isAnimating);
|
||||
};
|
||||
const preferenceLevels = [
|
||||
{ level: 1, label: "Bevorzugt", description: "Mitarbeiter möchte diese Schicht unbedingt arbeiten", color: "#27ae60" },
|
||||
{ level: 2, label: "Verfügbar", description: "Mitarbeiter ist verfügbar für diese Schicht", color: "#f39c12" },
|
||||
{ level: 3, label: "Nicht verfügbar", description: "Mitarbeiter kann diese Schicht nicht arbeiten", color: "#e74c3c" }
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<h1>❓ Hilfe & Support - Scheduling Algorithmus</h1>
|
||||
|
||||
{/* Algorithm Visualization */}
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
padding: '30px',
|
||||
marginTop: '20px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
|
||||
<h2 style={{ margin: 0, color: '#2c3e50' }}>🧠 Algorithmus Visualisierung</h2>
|
||||
<button
|
||||
onClick={toggleAnimation}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: isAnimating ? '#e74c3c' : '#2ecc71',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '20px',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 'bold'
|
||||
}}
|
||||
>
|
||||
{isAnimating ? '⏸️ Animation pausieren' : '▶️ Animation starten'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stage Indicators */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '30px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
{algorithmStages.map((stage, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
flex: 1
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: currentStage === index ? stage.color : '#ecf0f1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: currentStage === index ? 'white' : '#7f8c8d',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '18px',
|
||||
transition: 'all 0.5s ease',
|
||||
boxShadow: currentStage === index ? `0 0 20px ${stage.color}80` : 'none',
|
||||
border: `3px solid ${stage.color}`
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '10px',
|
||||
fontWeight: currentStage === index ? 'bold' : 'normal',
|
||||
color: currentStage === index ? stage.color : '#7f8c8d'
|
||||
}}>
|
||||
{stage.title.split(':')[0]}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{index < algorithmStages.length - 1 && (
|
||||
<div style={{
|
||||
flex: 1,
|
||||
height: '3px',
|
||||
backgroundColor: currentStage > index ? stage.color : '#ecf0f1',
|
||||
alignSelf: 'center',
|
||||
margin: '0 10px',
|
||||
transition: 'all 0.5s ease'
|
||||
}} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Current Stage Details */}
|
||||
<div style={{
|
||||
backgroundColor: algorithmStages[currentStage].color + '15',
|
||||
border: `2px solid ${algorithmStages[currentStage].color}30`,
|
||||
borderRadius: '8px',
|
||||
padding: '20px',
|
||||
marginBottom: '20px',
|
||||
transition: 'all 0.5s ease'
|
||||
}}>
|
||||
<h3 style={{ color: algorithmStages[currentStage].color, marginTop: 0 }}>
|
||||
{algorithmStages[currentStage].title}
|
||||
</h3>
|
||||
<p style={{ color: '#2c3e50', fontSize: '16px', marginBottom: '15px' }}>
|
||||
{algorithmStages[currentStage].description}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gap: '8px' }}>
|
||||
{algorithmStages[currentStage].steps.map((step, stepIndex) => (
|
||||
<div
|
||||
key={stepIndex}
|
||||
style={{
|
||||
padding: '10px 15px',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '6px',
|
||||
borderLeft: `4px solid ${algorithmStages[currentStage].color}`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
animation: isAnimating ? 'pulse 2s infinite' : 'none'
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
marginRight: '10px',
|
||||
color: algorithmStages[currentStage].color,
|
||||
fontWeight: 'bold'
|
||||
}}>•</span>
|
||||
{step}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Business Rules */}
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
@@ -217,7 +54,7 @@ const Help: React.FC = () => {
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<h2 style={{ color: '#2c3e50', marginBottom: '20px' }}>📋 Validierungs Regeln</h2>
|
||||
<h2 style={{ color: '#2c3e50', marginBottom: '20px' }}>📋 Geschäftsregeln</h2>
|
||||
<div style={{ display: 'grid', gap: '10px' }}>
|
||||
{businessRules.map((rule, index) => (
|
||||
<div
|
||||
@@ -248,14 +85,14 @@ const Help: React.FC = () => {
|
||||
color: rule.critical ? '#e74c3c' : '#f39c12',
|
||||
fontWeight: 'bold'
|
||||
}}>
|
||||
{rule.critical ? 'KRITISCH' : 'WARNUNG'}
|
||||
{rule.critical ? 'HART' : 'WEICH'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Algorithm Explanation */}
|
||||
{/* Scheduling Process */}
|
||||
<div style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '12px',
|
||||
@@ -264,62 +101,125 @@ const Help: React.FC = () => {
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
border: '1px solid #e0e0e0'
|
||||
}}>
|
||||
<h2 style={{ color: '#2c3e50', marginBottom: '20px' }}>🎯 Wie der Algorithmus funktioniert</h2>
|
||||
<h2 style={{ color: '#2c3e50', marginBottom: '20px' }}>⚙️ Scheduling-Prozess</h2>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '20px' }}>
|
||||
<div style={{ display: 'grid', gap: '15px' }}>
|
||||
{schedulingStages.map((stage, index) => (
|
||||
<div key={index} style={{
|
||||
padding: '20px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '2px solid #e9ecef',
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: '#3498db',
|
||||
color: 'white',
|
||||
borderRadius: '50%',
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontWeight: 'bold',
|
||||
marginRight: '15px',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<h4 style={{ color: '#3498db' }}>🏗️ Phasen-basierter Ansatz</h4>
|
||||
<p>Der Algorithmus arbeitet in klar definierten Phasen, um komplexe Probleme schrittweise zu lösen und Stabilität zu gewährleisten.</p>
|
||||
<h4 style={{ color: '#2c3e50', margin: '0 0 8px 0' }}>{stage.title}</h4>
|
||||
<p style={{ color: '#6c757d', margin: 0 }}>{stage.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preference Levels */}
|
||||
<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', marginBottom: '20px' }}>🎯 Verfügbarkeits-Level</h2>
|
||||
|
||||
<div style={{ display: 'grid', gap: '12px' }}>
|
||||
{preferenceLevels.map((pref) => (
|
||||
<div key={pref.level} style={{
|
||||
padding: '15px',
|
||||
backgroundColor: `${pref.color}15`,
|
||||
border: `2px solid ${pref.color}`,
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<div style={{
|
||||
backgroundColor: pref.color,
|
||||
color: 'white',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 12px',
|
||||
fontWeight: 'bold',
|
||||
marginRight: '15px',
|
||||
minWidth: '120px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
Level {pref.level}: {pref.label}
|
||||
</div>
|
||||
<span style={{ color: '#2c3e50' }}>{pref.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div style={{
|
||||
marginTop: '25px',
|
||||
padding: '25px',
|
||||
backgroundColor: '#e8f4fd',
|
||||
borderRadius: '12px',
|
||||
border: '2px solid #b8d4f0'
|
||||
}}>
|
||||
<h3 style={{ color: '#2980b9', marginTop: 0 }}>💡 Best Practices für erfolgreiches Scheduling</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))', gap: '15px', marginTop: '15px' }}>
|
||||
<div>
|
||||
<h4 style={{ color: '#e74c3c' }}>⚖️ Wert-basierte Entscheidungen</h4>
|
||||
<p>Jede Zuweisung wird anhand eines Wertesystems bewertet, das Verfügbarkeit, Erfahrung und aktuelle Auslastung berücksichtigt.</p>
|
||||
<h4 style={{ color: '#2980b9' }}>Vor dem Scheduling</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: '#2c3e50' }}>
|
||||
<li>Stellen Sie sicher, dass alle Mitarbeiter ihre Verfügbarkeit eingetragen haben</li>
|
||||
<li>Überprüfen Sie die Mitarbeiterprofile (Trainee/Erfahren, Alleinarbeit möglich)</li>
|
||||
<li>Bestätigen Sie die Vertragstypen und Schichtanforderungen</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style={{ color: '#2ecc71' }}>🔧 Automatische Reparatur</h4>
|
||||
<p>Probleme werden automatisch erkannt und durch intelligente Tausch- und Bewegungsoperationen behoben.</p>
|
||||
<h4 style={{ color: '#2980b9' }}>Nach dem Scheduling</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px', color: '#2c3e50' }}>
|
||||
<li>Prüfen Sie den Lösungsbericht auf Verletzungen</li>
|
||||
<li>Kontrollieren Sie unterbesetzte Schichten</li>
|
||||
<li>Validieren Sie Trainee-Betreuung und Alleinarbeits-Regeln</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 style={{ color: '#f39c12' }}>📊 Transparente Berichterstattung</h4>
|
||||
<p>Detaillierte Berichte zeigen genau, welche Probleme behoben wurden und welche verbleiben.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Technical Info */}
|
||||
<div style={{
|
||||
marginTop: '25px',
|
||||
padding: '20px',
|
||||
backgroundColor: '#e8f4fd',
|
||||
backgroundColor: '#fff3cd',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #b8d4f0'
|
||||
border: '1px solid #ffeaa7'
|
||||
}}>
|
||||
<h4 style={{ color: '#2980b9', marginTop: 0 }}>💡 Tipps für beste Ergebnisse</h4>
|
||||
<ul style={{ margin: 0, paddingLeft: '20px' }}>
|
||||
<li>Stellen Sie sicher, dass alle Mitarbeiter ihre Verfügbarkeit eingetragen haben</li>
|
||||
<li>Überprüfen Sie die Vertragstypen (klein = 1 Schicht/Woche, groß = 2 Schichten/Woche)</li>
|
||||
<li>Markieren Sie erfahrene Mitarbeiter, die alleine arbeiten können</li>
|
||||
<li>Planen Sie Manager-Verfügbarkeit im Voraus</li>
|
||||
</ul>
|
||||
<h4 style={{ color: '#856404', marginTop: 0 }}>🔧 Technische Informationen</h4>
|
||||
<p style={{ color: '#856404', margin: 0 }}>
|
||||
<strong>Lösungsalgorithmus:</strong> Google OR-Tools CP-SAT Solver •
|
||||
<strong> Fallback:</strong> TypeScript-basierter Solver •
|
||||
<strong> Maximale Laufzeit:</strong> 105 Sekunden
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0% { transform: scale(1); }
|
||||
50% { transform: scale(1.02); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0% { box-shadow: 0 0 5px rgba(52, 152, 219, 0.5); }
|
||||
50% { box-shadow: 0 0 20px rgba(52, 152, 219, 0.8); }
|
||||
100% { box-shadow: 0 0 5px rgba(52, 152, 219, 0.5); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// frontend/src/pages/Settings/Settings.tsx
|
||||
import React, { useState, useEffect } from 'react';
|
||||
// frontend/src/pages/Settings/Settings.tsx - UPDATED WITH NEW STYLES
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { employeeService } from '../../services/employeeService';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
@@ -27,6 +27,16 @@ const Settings: React.FC = () => {
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
// Password visibility states
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
// Refs for timeout management
|
||||
const currentPasswordTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const newPasswordTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const confirmPasswordTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser) {
|
||||
setProfileForm({
|
||||
@@ -36,6 +46,17 @@ const Settings: React.FC = () => {
|
||||
}
|
||||
}, [currentUser]);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
[currentPasswordTimeoutRef, newPasswordTimeoutRef, confirmPasswordTimeoutRef].forEach(ref => {
|
||||
if (ref.current) {
|
||||
clearTimeout(ref.current);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleProfileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setProfileForm(prev => ({
|
||||
@@ -52,6 +73,67 @@ const Settings: React.FC = () => {
|
||||
}));
|
||||
};
|
||||
|
||||
// Password visibility handlers for current password
|
||||
const handleCurrentPasswordMouseDown = () => {
|
||||
currentPasswordTimeoutRef.current = setTimeout(() => {
|
||||
setShowCurrentPassword(true);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleCurrentPasswordMouseUp = () => {
|
||||
if (currentPasswordTimeoutRef.current) {
|
||||
clearTimeout(currentPasswordTimeoutRef.current);
|
||||
currentPasswordTimeoutRef.current = null;
|
||||
}
|
||||
setShowCurrentPassword(false);
|
||||
};
|
||||
|
||||
// Password visibility handlers for new password
|
||||
const handleNewPasswordMouseDown = () => {
|
||||
newPasswordTimeoutRef.current = setTimeout(() => {
|
||||
setShowNewPassword(true);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleNewPasswordMouseUp = () => {
|
||||
if (newPasswordTimeoutRef.current) {
|
||||
clearTimeout(newPasswordTimeoutRef.current);
|
||||
newPasswordTimeoutRef.current = null;
|
||||
}
|
||||
setShowNewPassword(false);
|
||||
};
|
||||
|
||||
// Password visibility handlers for confirm password
|
||||
const handleConfirmPasswordMouseDown = () => {
|
||||
confirmPasswordTimeoutRef.current = setTimeout(() => {
|
||||
setShowConfirmPassword(true);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleConfirmPasswordMouseUp = () => {
|
||||
if (confirmPasswordTimeoutRef.current) {
|
||||
clearTimeout(confirmPasswordTimeoutRef.current);
|
||||
confirmPasswordTimeoutRef.current = null;
|
||||
}
|
||||
setShowConfirmPassword(false);
|
||||
};
|
||||
|
||||
// Touch event handlers
|
||||
const handleTouchStart = (setter: () => void) => (e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
setter();
|
||||
};
|
||||
|
||||
const handleTouchEnd = (cleanup: () => void) => (e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
cleanup();
|
||||
};
|
||||
|
||||
// Prevent context menu
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleProfileUpdate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!currentUser) return;
|
||||
@@ -180,11 +262,6 @@ const Settings: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Get full name for display
|
||||
const getFullName = () => {
|
||||
return `${currentUser.firstname || ''} ${currentUser.lastname || ''}`.trim();
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
{/* Left Sidebar with Tabs */}
|
||||
@@ -443,17 +520,19 @@ const Settings: React.FC = () => {
|
||||
|
||||
<form onSubmit={handlePasswordUpdate} style={{ marginTop: '2rem' }}>
|
||||
<div style={styles.formGridCompact}>
|
||||
{/* Current Password Field */}
|
||||
<div style={styles.field}>
|
||||
<label style={styles.fieldLabel}>
|
||||
Aktuelles Passwort *
|
||||
</label>
|
||||
<div style={styles.fieldInputContainer}>
|
||||
<input
|
||||
type="password"
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
name="currentPassword"
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={handlePasswordChange}
|
||||
required
|
||||
style={styles.fieldInput}
|
||||
style={styles.fieldInputWithIcon}
|
||||
placeholder="Aktuelles Passwort"
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = '#1a1325';
|
||||
@@ -464,20 +543,40 @@ const Settings: React.FC = () => {
|
||||
e.target.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={handleCurrentPasswordMouseDown}
|
||||
onMouseUp={handleCurrentPasswordMouseUp}
|
||||
onMouseLeave={handleCurrentPasswordMouseUp}
|
||||
onTouchStart={handleTouchStart(handleCurrentPasswordMouseDown)}
|
||||
onTouchEnd={handleTouchEnd(handleCurrentPasswordMouseUp)}
|
||||
onTouchCancel={handleTouchEnd(handleCurrentPasswordMouseUp)}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{
|
||||
...styles.passwordToggleButton,
|
||||
backgroundColor: showCurrentPassword ? 'rgba(26, 19, 37, 0.1)' : 'transparent'
|
||||
}}
|
||||
title="Gedrückt halten zum Anzeigen des Passworts"
|
||||
>
|
||||
{showCurrentPassword ? '👁' : '👁'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Password Field */}
|
||||
<div style={styles.field}>
|
||||
<label style={styles.fieldLabel}>
|
||||
Neues Passwort *
|
||||
</label>
|
||||
<div style={styles.fieldInputContainer}>
|
||||
<input
|
||||
type="password"
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
name="newPassword"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={handlePasswordChange}
|
||||
required
|
||||
minLength={6}
|
||||
style={styles.fieldInput}
|
||||
style={styles.fieldInputWithIcon}
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = '#1a1325';
|
||||
@@ -488,22 +587,42 @@ const Settings: React.FC = () => {
|
||||
e.target.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={handleNewPasswordMouseDown}
|
||||
onMouseUp={handleNewPasswordMouseUp}
|
||||
onMouseLeave={handleNewPasswordMouseUp}
|
||||
onTouchStart={handleTouchStart(handleNewPasswordMouseDown)}
|
||||
onTouchEnd={handleTouchEnd(handleNewPasswordMouseUp)}
|
||||
onTouchCancel={handleTouchEnd(handleNewPasswordMouseUp)}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{
|
||||
...styles.passwordToggleButton,
|
||||
backgroundColor: showNewPassword ? 'rgba(26, 19, 37, 0.1)' : 'transparent'
|
||||
}}
|
||||
title="Gedrückt halten zum Anzeigen des Passworts"
|
||||
>
|
||||
{showNewPassword ? '👁' : '👁'}
|
||||
</button>
|
||||
</div>
|
||||
<div style={styles.fieldHint}>
|
||||
Das Passwort muss mindestens 6 Zeichen lang sein.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div style={styles.field}>
|
||||
<label style={styles.fieldLabel}>
|
||||
Neues Passwort bestätigen *
|
||||
</label>
|
||||
<div style={styles.fieldInputContainer}>
|
||||
<input
|
||||
type="password"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
name="confirmPassword"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={handlePasswordChange}
|
||||
required
|
||||
style={styles.fieldInput}
|
||||
style={styles.fieldInputWithIcon}
|
||||
placeholder="Passwort wiederholen"
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = '#1a1325';
|
||||
@@ -514,6 +633,24 @@ const Settings: React.FC = () => {
|
||||
e.target.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={handleConfirmPasswordMouseDown}
|
||||
onMouseUp={handleConfirmPasswordMouseUp}
|
||||
onMouseLeave={handleConfirmPasswordMouseUp}
|
||||
onTouchStart={handleTouchStart(handleConfirmPasswordMouseDown)}
|
||||
onTouchEnd={handleTouchEnd(handleConfirmPasswordMouseUp)}
|
||||
onTouchCancel={handleTouchEnd(handleConfirmPasswordMouseUp)}
|
||||
onContextMenu={handleContextMenu}
|
||||
style={{
|
||||
...styles.passwordToggleButton,
|
||||
backgroundColor: showConfirmPassword ? 'rgba(26, 19, 37, 0.1)' : 'transparent'
|
||||
}}
|
||||
title="Gedrückt halten zum Anzeigen des Passworts"
|
||||
>
|
||||
{showConfirmPassword ? '👁' : '👁'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// frontend/src/pages/Settings/type/SettingsType.tsx - CORRECTED
|
||||
export const styles = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
@@ -74,7 +75,7 @@
|
||||
border: '1px solid rgba(255, 255, 255, 0.8)',
|
||||
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.06), 0 1px 4px rgba(0, 0, 0, 0.04)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
minHeight: '200px',
|
||||
minHeight: '100px',
|
||||
},
|
||||
section: {
|
||||
marginBottom: '2rem',
|
||||
@@ -121,11 +122,17 @@
|
||||
display: 'flex',
|
||||
flexDirection: 'column' as const,
|
||||
gap: '0.5rem',
|
||||
width: '100%',
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 600,
|
||||
color: '#161718',
|
||||
width: '100%',
|
||||
},
|
||||
fieldInputContainer: {
|
||||
position: 'relative' as const,
|
||||
width: '100%',
|
||||
},
|
||||
fieldInput: {
|
||||
padding: '0.875rem 1rem',
|
||||
@@ -135,6 +142,20 @@
|
||||
background: '#FBFAF6',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
color: '#161718',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box' as const,
|
||||
},
|
||||
fieldInputWithIcon: {
|
||||
padding: '0.875rem 1rem',
|
||||
border: '1.5px solid #e8e8e8',
|
||||
borderRadius: '8px',
|
||||
fontSize: '0.95rem',
|
||||
background: '#FBFAF6',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
color: '#161718',
|
||||
width: '100%',
|
||||
paddingRight: '40px',
|
||||
boxSizing: 'border-box' as const,
|
||||
},
|
||||
fieldInputDisabled: {
|
||||
padding: '0.875rem 1rem',
|
||||
@@ -144,11 +165,29 @@
|
||||
background: 'rgba(26, 19, 37, 0.05)',
|
||||
color: '#666',
|
||||
cursor: 'not-allowed',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box' as const,
|
||||
},
|
||||
fieldHint: {
|
||||
fontSize: '0.8rem',
|
||||
color: '#888',
|
||||
marginTop: '0.25rem',
|
||||
width: '100%',
|
||||
},
|
||||
passwordToggleButton: {
|
||||
position: 'absolute' as const,
|
||||
right: '10px',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '5px',
|
||||
borderRadius: '4px',
|
||||
transition: 'background-color 0.2s',
|
||||
userSelect: 'none' as const,
|
||||
WebkitUserSelect: 'none' as const,
|
||||
touchAction: 'manipulation' as const,
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
const Setup: React.FC = () => {
|
||||
const [step, setStep] = useState(1);
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -73,7 +75,7 @@ const Setup: React.FC = () => {
|
||||
|
||||
console.log('🚀 Sending setup request...', payload);
|
||||
|
||||
const response = await fetch('http://localhost:3002/api/setup/admin', {
|
||||
const response = await fetch(`${API_BASE_URL}/setup/admin`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -331,7 +333,7 @@ const Setup: React.FC = () => {
|
||||
disabled={loading}
|
||||
style={{
|
||||
padding: '0.75rem 2rem',
|
||||
backgroundColor: loading ? '#6c757d' : '#007bff',
|
||||
backgroundColor: loading ? '#6c757d' : '#51258f',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
|
||||
.createButton {
|
||||
padding: 10px 20px;
|
||||
background-color: #2ecc71;
|
||||
background-color: #51258f;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
@@ -116,7 +116,7 @@
|
||||
}
|
||||
|
||||
.createButton:hover {
|
||||
background-color: #27ae60;
|
||||
background-color: #51258f;
|
||||
}
|
||||
|
||||
.createButton:disabled {
|
||||
|
||||
@@ -70,7 +70,7 @@ const ShiftPlanList: React.FC = () => {
|
||||
marginBottom: '30px'
|
||||
}}>
|
||||
<h1>📅 Schichtpläne</h1>
|
||||
{hasRole(['admin', 'instandhalter']) && (
|
||||
{hasRole(['admin', 'maintenance']) && (
|
||||
<Link to="/shift-plans/new">
|
||||
<button style={{
|
||||
padding: '10px 20px',
|
||||
@@ -143,7 +143,7 @@ const ShiftPlanList: React.FC = () => {
|
||||
>
|
||||
Anzeigen
|
||||
</button>
|
||||
{hasRole(['admin', 'instandhalter']) && (
|
||||
{hasRole(['admin', 'maintenance']) && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => navigate(`/shift-plans/${plan.id}/edit`)}
|
||||
|
||||
@@ -1005,7 +1005,7 @@ const ShiftPlanView: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
||||
{shiftPlan.status === 'published' && hasRole(['admin', 'instandhalter']) && (
|
||||
{shiftPlan.status === 'published' && hasRole(['admin', 'maintenance']) && (
|
||||
<button
|
||||
onClick={handleRecreateAssignments}
|
||||
disabled={recreating}
|
||||
@@ -1076,7 +1076,7 @@ const ShiftPlanView: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasRole(['admin', 'instandhalter']) && (
|
||||
{hasRole(['admin', 'maintenance']) && (
|
||||
<div>
|
||||
<button
|
||||
onClick={handlePreviewAssignment}
|
||||
|
||||
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;
|
||||
@@ -1,6 +1,6 @@
|
||||
// frontend/src/services/authService.ts
|
||||
import { Employee } from '../models/Employee';
|
||||
const API_BASE = 'http://localhost:3002/api';
|
||||
const API_BASE = process.env.REACT_APP_API_BASE_URL || '/api';
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// frontend/src/services/employeeService.ts
|
||||
import { Employee, CreateEmployeeRequest, UpdateEmployeeRequest, EmployeeAvailability } from '../models/Employee';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:3002/api';
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
const getAuthHeaders = () => {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
@@ -4,9 +4,7 @@ import { Employee, EmployeeAvailability } from '../models/Employee';
|
||||
import { authService } from './authService';
|
||||
import { AssignmentResult, ScheduleRequest } from '../models/scheduling';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:3002/api';
|
||||
|
||||
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
// Helper function to get auth headers
|
||||
const getAuthHeaders = () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// frontend/src/services/shiftPlanService.ts
|
||||
import { authService } from './authService';
|
||||
import { ShiftPlan, CreateShiftPlanRequest, ScheduledShift, CreateShiftFromTemplateRequest } from '../models/ShiftPlan';
|
||||
import { ShiftPlan, CreateShiftPlanRequest } from '../models/ShiftPlan';
|
||||
import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults';
|
||||
|
||||
const API_BASE = 'http://localhost:3002/api/shift-plans';
|
||||
const API_BASE_URL = '/api/shift-plans';
|
||||
|
||||
// Helper function to get auth headers
|
||||
const getAuthHeaders = () => {
|
||||
@@ -25,7 +25,7 @@ const handleResponse = async (response: Response) => {
|
||||
|
||||
export const shiftPlanService = {
|
||||
async getShiftPlans(): Promise<ShiftPlan[]> {
|
||||
const response = await fetch(API_BASE, {
|
||||
const response = await fetch(API_BASE_URL, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authService.getAuthHeaders()
|
||||
@@ -50,7 +50,7 @@ export const shiftPlanService = {
|
||||
},
|
||||
|
||||
async getShiftPlan(id: string): Promise<ShiftPlan> {
|
||||
const response = await fetch(`${API_BASE}/${id}`, {
|
||||
const response = await fetch(`${API_BASE_URL}/${id}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authService.getAuthHeaders()
|
||||
@@ -69,7 +69,7 @@ export const shiftPlanService = {
|
||||
},
|
||||
|
||||
async createShiftPlan(plan: CreateShiftPlanRequest): Promise<ShiftPlan> {
|
||||
const response = await fetch(API_BASE, {
|
||||
const response = await fetch(API_BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -90,7 +90,7 @@ export const shiftPlanService = {
|
||||
},
|
||||
|
||||
async updateShiftPlan(id: string, plan: Partial<ShiftPlan>): Promise<ShiftPlan> {
|
||||
const response = await fetch(`${API_BASE}/${id}`, {
|
||||
const response = await fetch(`${API_BASE_URL}/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -111,7 +111,7 @@ export const shiftPlanService = {
|
||||
},
|
||||
|
||||
async deleteShiftPlan(id: string): Promise<void> {
|
||||
const response = await fetch(`${API_BASE}/${id}`, {
|
||||
const response = await fetch(`${API_BASE_URL}/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -130,7 +130,7 @@ export const shiftPlanService = {
|
||||
|
||||
// Get specific template or plan
|
||||
getTemplate: async (id: string): Promise<ShiftPlan> => {
|
||||
const response = await fetch(`${API_BASE}/${id}`, {
|
||||
const response = await fetch(`${API_BASE_URL}/${id}`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
return handleResponse(response);
|
||||
@@ -142,7 +142,7 @@ export const shiftPlanService = {
|
||||
console.log('🔄 Attempting to regenerate scheduled shifts...');
|
||||
|
||||
// You'll need to add this API endpoint to your backend
|
||||
const response = await fetch(`${API_BASE}/${planId}/regenerate-shifts`, {
|
||||
const response = await fetch(`${API_BASE_URL}/${planId}/regenerate-shifts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -162,7 +162,7 @@ export const shiftPlanService = {
|
||||
|
||||
// Create new plan
|
||||
createPlan: async (data: CreateShiftPlanRequest): Promise<ShiftPlan> => {
|
||||
const response = await fetch(`${API_BASE}`, {
|
||||
const response = await fetch(`${API_BASE_URL}`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
@@ -177,7 +177,7 @@ export const shiftPlanService = {
|
||||
endDate: string;
|
||||
isTemplate?: boolean;
|
||||
}): Promise<ShiftPlan> => {
|
||||
const response = await fetch(`${API_BASE}/from-preset`, {
|
||||
const response = await fetch(`${API_BASE_URL}/from-preset`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
@@ -204,7 +204,7 @@ export const shiftPlanService = {
|
||||
try {
|
||||
console.log('🔄 Clearing assignments for plan:', planId);
|
||||
|
||||
const response = await fetch(`${API_BASE}/${planId}/clear-assignments`, {
|
||||
const response = await fetch(`${API_BASE_URL}/${planId}/clear-assignments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
||||
@@ -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,28 +1,38 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
//"ignoreDeprecations": "6.0",
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"ignoreDeprecations": "6.0",
|
||||
"jsx": "react-jsx",
|
||||
"downlevelIteration": true
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path mapping (modern approach) */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/components/*": ["./src/components/*"],
|
||||
"@/pages/*": ["./src/pages/*"],
|
||||
"@/contexts/*": ["./src/contexts/*"],
|
||||
"@/utils/*": ["./src/utils/*"],
|
||||
"@/services/*": ["./src/services/*"],
|
||||
"@/models/*": ["./src/models/*"],
|
||||
"@/design/*": ["./src/design/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
75
frontend/vite.config.ts
Normal file
75
frontend/vite.config.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// vite.config.ts
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isProduction = mode === 'production'
|
||||
const isDevelopment = mode === 'development'
|
||||
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
// 🆕 WICHTIG: Relative Pfade für Production
|
||||
const clientEnv = {
|
||||
NODE_ENV: mode,
|
||||
ENABLE_PRO: env.ENABLE_PRO || 'false',
|
||||
VITE_APP_TITLE: env.APP_TITLE || 'Shift Planning App',
|
||||
VITE_API_URL: isProduction ? '/api' : 'http://localhost:3002/api',
|
||||
}
|
||||
|
||||
return {
|
||||
plugins: [react()],
|
||||
|
||||
server: {
|
||||
port: 3003,
|
||||
host: true,
|
||||
open: isDevelopment,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3002',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: isDevelopment,
|
||||
base: isProduction ? '/' : '/',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
chunkFileNames: 'assets/[name]-[hash].js',
|
||||
entryFileNames: 'assets/[name]-[hash].js',
|
||||
assetFileNames: 'assets/[name]-[hash].[ext]',
|
||||
}
|
||||
},
|
||||
minify: isProduction ? 'terser' : false,
|
||||
terserOptions: isProduction ? {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true,
|
||||
pure_funcs: ['console.log', 'console.debug', 'console.info']
|
||||
}
|
||||
} : undefined,
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
'@/components': resolve(__dirname, './src/components'),
|
||||
'@/pages': resolve(__dirname, './src/pages'),
|
||||
'@/contexts': resolve(__dirname, './src/contexts'),
|
||||
'@/models': resolve(__dirname, './src/models'),
|
||||
'@/utils': resolve(__dirname, './src/utils'),
|
||||
'@/services': resolve(__dirname, './src/services'),
|
||||
'@/design': resolve(__dirname, './src/design')
|
||||
}
|
||||
},
|
||||
|
||||
define: Object.keys(clientEnv).reduce((acc, key) => {
|
||||
acc[`import.meta.env.${key}`] = JSON.stringify(clientEnv[key])
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
}
|
||||
})
|
||||
4250
backend/package-lock.json → package-lock.json
generated
4250
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