Compare commits

..

16 Commits

19 changed files with 734 additions and 5651 deletions

View File

@@ -1,27 +1,29 @@
# === SCHICHTPLANER DOCKER COMPOSE ENVIRONMENT VARIABLES === # .env.template
# Diese Datei wird von docker-compose automatisch geladen # ============================================
# DOCKER COMPOSE ENVIRONMENT TEMPLATE
# Copy this file to .env and adjust values
# ============================================
# Security # Application settings
JWT_SECRET=${JWT_SECRET:-your-secret-key-please-change} NODE_ENV=production
NODE_ENV=${NODE_ENV:-production} JWT_SECRET=your-secret-key-please-change
HOSTNAME=localhost
# Security & Network
TRUST_PROXY_ENABLED=false
TRUSTED_PROXY_IPS=127.0.0.1,::1
FORCE_HTTPS=false
# Database # Database
DB_PATH=${DB_PATH:-/app/data/database.db} DATABASE_PATH=/app/data/schichtplaner.db
# Server # Optional features
PORT=${PORT:-3002} ENABLE_PRO=false
DEBUG=false
# App Configuration # Port configuration
APP_TITLE="Shift Planning App" APP_PORT=3002
ENABLE_PRO=${ENABLE_PRO:-false}
# Trust Proxy Configuration # ============================================
TRUST_PROXY_ENABLED=false # END OF TEMPLATE
TRUSTED_PROXY_IPS= # Your load balancer/reverse proxy IPs # ============================================
TRUSTED_PROXY_HEADER=x-forwarded-for
# Rate Limiting Configuration
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
RATE_LIMIT_SKIP_PATHS=/api/health,/api/status
RATE_LIMIT_WHITELIST=127.0.0.1,::1

View File

@@ -83,9 +83,13 @@ jobs:
with: with:
node-version: '20' node-version: '20'
- name: Create package-lock.json
working-directory: .
run: npm i --package-lock-only
- name: Install backend dependencies - name: Install backend dependencies
working-directory: ./backend working-directory: ./backend
run: npm install run: npm ci
- name: Run TypeScript check - name: Run TypeScript check
working-directory: ./backend working-directory: ./backend

1
.gitignore vendored
View File

@@ -57,6 +57,7 @@ yarn-error.log*
# Build outputs # Build outputs
dist/ dist/
build/ build/
package-lock.json
# Environment variables # Environment variables
.env .env

View File

@@ -1,22 +1,17 @@
# Single stage build for workspaces # Single stage build for workspaces
FROM node:20-bullseye AS builder FROM node:20-bookworm AS builder
WORKDIR /app 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 root package files first
COPY package*.json ./ COPY package*.json ./
COPY tsconfig.base.json ./ COPY tsconfig.base.json ./
COPY ecosystem.config.cjs ./ COPY ecosystem.config.cjs ./
# Install root dependencies # Install root dependencies
RUN npm install --only=production #RUN npm install --only=production
RUN npm i --package-lock-only
RUN npm ci
# Copy workspace files # Copy workspace files
COPY backend/ ./backend/ COPY backend/ ./backend/
@@ -30,10 +25,7 @@ RUN npm install --workspace=frontend
RUN npm run build --only=production --workspace=backend RUN npm run build --only=production --workspace=backend
# Build frontend # Build frontend
RUN npm run build --workspace=frontend RUN npm run build --only=production --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 # Production stage
FROM node:20-bookworm FROM node:20-bookworm
@@ -57,7 +49,20 @@ COPY --from=builder /app/frontend/dist/ ./frontend-build/
COPY --from=builder /app/ecosystem.config.cjs ./ COPY --from=builder /app/ecosystem.config.cjs ./
COPY --from=builder /app/backend/src/database/ ./dist/database/ COPY --from=builder /app/backend/src/database/ ./dist/database/
COPY --from=builder /app/backend/src/database/ ./database/ # should be obsolete with the line above
#COPY --from=builder /app/backend/src/database/ ./database/
COPY --from=builder /app/backend/src/python-scripts/ ./python-scripts/
# Install Python + OR-Tools
RUN apt-get update && apt-get install -y python3 python3-pip build-essential \
&& pip install --no-cache-dir --break-system-packages ortools
# Create symlink so python3 is callable as python
RUN ln -sf /usr/bin/python3 /usr/bin/python
# Verify Python and OR-Tools installation
RUN python -c "from ortools.sat.python import cp_model; print('OR-Tools installed successfully')"
# Copy init script and env template # Copy init script and env template
COPY docker-init.sh /usr/local/bin/ COPY docker-init.sh /usr/local/bin/

View File

@@ -72,8 +72,8 @@ const getRateLimitConfig = () => {
return { return {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 minutes default windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 minutes default
max: isProduction max: isProduction
? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100') // Stricter in production ? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '1000') // Stricter in production
: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '1000'), // More lenient in development : parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '5000'), // More lenient in development
// Development-specific relaxations // Development-specific relaxations
skip: (req: Request) => { skip: (req: Request) => {
@@ -112,7 +112,7 @@ export const apiLimiter = rateLimit({
// Strict limiter for auth endpoints // Strict limiter for auth endpoints
export const authLimiter = rateLimit({ export const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX_REQUESTS || '5'), max: parseInt(process.env.AUTH_RATE_LIMIT_MAX_REQUESTS || '100'),
message: { message: {
error: 'Zu viele Login-Versuche, bitte versuchen Sie es später erneut' error: 'Zu viele Login-Versuche, bitte versuchen Sie es später erneut'
}, },
@@ -135,7 +135,7 @@ export const authLimiter = rateLimit({
// Separate limiter for expensive endpoints // Separate limiter for expensive endpoints
export const expensiveEndpointLimiter = rateLimit({ export const expensiveEndpointLimiter = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: parseInt(process.env.EXPENSIVE_ENDPOINT_LIMIT || '10'), max: parseInt(process.env.EXPENSIVE_ENDPOINT_LIMIT || '100'),
message: { message: {
error: 'Zu viele Anfragen für diese Ressource' error: 'Zu viele Anfragen für diese Ressource'
}, },

View File

@@ -85,7 +85,7 @@ if (process.env.NODE_ENV === 'production') {
const configureTrustProxy = (): string | string[] | boolean | number => { const configureTrustProxy = (): string | string[] | boolean | number => {
const trustedProxyIps = process.env.TRUSTED_PROXY_IPS; const trustedProxyIps = process.env.TRUSTED_PROXY_IPS;
const trustProxyEnabled = process.env.TRUST_PROXY_ENABLED !== 'false'; // Default true for production const trustProxyEnabled = process.env.TRUST_PROXY_ENABLED !== 'false';
// If explicitly disabled // If explicitly disabled
if (!trustProxyEnabled) { if (!trustProxyEnabled) {
@@ -106,25 +106,28 @@ const configureTrustProxy = (): string | string[] | boolean | number => {
return trustedProxyIps.trim(); return trustedProxyIps.trim();
} }
// Default behavior based on environment // Default behavior for reverse proxy setup
if (process.env.NODE_ENV === 'production') { console.log('🔒 Trust proxy: Using reverse proxy defaults (trust all)');
console.log('🔒 Trust proxy: Using production defaults (private networks)'); return true; // Trust all proxies when behind nginx
return [
'loopback',
'linklocal',
'uniquelocal',
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16'
];
} else {
console.log('🔒 Trust proxy: Development mode (disabled)');
return false;
}
}; };
app.set('trust proxy', configureTrustProxy()); app.set('trust proxy', configureTrustProxy());
app.use((req, res, next) => {
const protocol = req.headers['x-forwarded-proto'] || req.protocol;
const isHttps = protocol === 'https';
// Add security warning for HTTP requests
if (!isHttps && process.env.NODE_ENV === 'production') {
res.setHeader('X-Security-Warning', 'This application is being accessed over HTTP. For secure communication, please use HTTPS.');
// Log HTTP access in production
console.warn(`⚠️ HTTP access detected: ${req.method} ${req.path} from ${req.ip}`);
}
next();
});
// Security headers // Security headers
app.use(helmet({ app.use(helmet({
contentSecurityPolicy: { contentSecurityPolicy: {
@@ -138,9 +141,14 @@ app.use(helmet({
objectSrc: ["'none'"], objectSrc: ["'none'"],
mediaSrc: ["'self'"], mediaSrc: ["'self'"],
frameSrc: ["'none'"], frameSrc: ["'none'"],
upgradeInsecureRequests: process.env.FORCE_HTTPS === 'true' ? [] : null
}, },
}, },
hsts: false, hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}, // Enable HSTS for HTTPS
crossOriginEmbedderPolicy: false crossOriginEmbedderPolicy: false
})); }));

View File

@@ -6,17 +6,22 @@ services:
image: ghcr.io/donpat1to/schichtenplaner:v1.0.0 image: ghcr.io/donpat1to/schichtenplaner:v1.0.0
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- JWT_SECRET=${JWT_SECRET:-your-secret-key-please-change} - JWT_SECRET=${JWT_SECRET}
ports: - TRUST_PROXY_ENABLED=true
- "3002:3002" - TRUSTED_PROXY_IPS=nginx-proxy,172.0.0.0/8,10.0.0.0/8,192.168.0.0/16
- FORCE_HTTPS=${FORCE_HTTPS:-false}
networks:
- app-network
volumes: volumes:
- app_data:/app/data - app_data:/app/data
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3002/api/health"] test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3002/api/health"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
expose:
- "3002"
volumes: volumes:
app_data: app_data:

View File

@@ -3,17 +3,15 @@ set -e
echo "🚀 Container Initialisierung gestartet..." echo "🚀 Container Initialisierung gestartet..."
# Funktion zum Generieren eines sicheren Secrets
generate_secret() { generate_secret() {
length=$1 length=$1
tr -dc 'A-Za-z0-9!@#$%^&*()_+-=' < /dev/urandom | head -c $length tr -dc 'A-Za-z0-9!@#$%^&*()_+-=' < /dev/urandom | head -c $length
} }
# Prüfe ob .env existiert # Create .env if it doesn't exist
if [ ! -f /app/.env ]; then if [ ! -f /app/.env ]; then
echo "📝 Erstelle .env Datei..." echo "📝 Erstelle .env Datei..."
# Verwende vorhandenes JWT_SECRET oder generiere ein neues
if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "your-secret-key-please-change" ]; then if [ -z "$JWT_SECRET" ] || [ "$JWT_SECRET" = "your-secret-key-please-change" ]; then
export JWT_SECRET=$(generate_secret 64) export JWT_SECRET=$(generate_secret 64)
echo "🔑 Automatisch sicheres JWT Secret generiert" echo "🔑 Automatisch sicheres JWT Secret generiert"
@@ -21,30 +19,37 @@ if [ ! -f /app/.env ]; then
echo "🔑 Verwende vorhandenes JWT Secret aus Umgebungsvariable" echo "🔑 Verwende vorhandenes JWT Secret aus Umgebungsvariable"
fi fi
# Erstelle .env aus Template mit envsubst # Create .env with all proxy settings
envsubst < /app/.env.template > /app/.env cat > /app/.env << EOF
echo "✅ .env Datei erstellt" NODE_ENV=production
JWT_SECRET=${JWT_SECRET}
TRUST_PROXY_ENABLED=${TRUST_PROXY_ENABLED:-true}
TRUSTED_PROXY_IPS=${TRUSTED_PROXY_IPS:-172.0.0.0/8,10.0.0.0/8,192.168.0.0/16}
HOSTNAME=${HOSTNAME:-localhost}
EOF
echo "✅ .env Datei erstellt"
else else
echo " .env Datei existiert bereits" echo " .env Datei existiert bereits"
# Wenn .env existiert, aber JWT_SECRET Umgebungsvariable gesetzt ist, aktualisiere sie # Update JWT_SECRET if provided
if [ -n "$JWT_SECRET" ] && [ "$JWT_SECRET" != "your-secret-key-please-change" ]; then if [ -n "$JWT_SECRET" ] && [ "$JWT_SECRET" != "your-secret-key-please-change" ]; then
echo "🔑 Aktualisiere JWT Secret in .env Datei" 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 sed -i "s/^JWT_SECRET=.*/JWT_SECRET=$JWT_SECRET/" /app/.env
fi fi
fi fi
# Validiere dass JWT_SECERT nicht der Standardwert ist # Validate JWT_SECRET
if grep -q "JWT_SECRET=your-secret-key-please-change" /app/.env; then if grep -q "JWT_SECRET=your-secret-key-please-change" /app/.env; then
echo "❌ FEHLER: Standard JWT Secret in .env gefunden!" echo "❌ FEHLER: Standard JWT Secret in .env gefunden!"
echo "❌ Bitte setzen Sie JWT_SECRET Umgebungsvariable" echo "❌ Bitte setzen Sie JWT_SECRET Umgebungsvariable"
exit 1 exit 1
fi fi
# Setze sichere Berechtigungen
chmod 600 /app/.env chmod 600 /app/.env
echo "🔧 Proxy Configuration:"
echo " - TRUST_PROXY_ENABLED: ${TRUST_PROXY_ENABLED:-true}"
echo " - TRUSTED_PROXY_IPS: ${TRUSTED_PROXY_IPS:-172.0.0.0/8,10.0.0.0/8,192.168.0.0/16}"
echo "🔧 Starte Anwendung..." echo "🔧 Starte Anwendung..."
exec "$@" exec "$@"

178
frontend/donpat1to.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 102 KiB

View File

@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/donpat1to.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Shift Planning App</title> <title>Shift Planning App</title>
</head> </head>

View File

@@ -7,7 +7,9 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-router-dom": "^6.28.0", "react-router-dom": "^6.28.0",
"date-fns": "4.1.0" "date-fns": "4.1.0",
"@vitejs/plugin-react": "^4.3.3",
"vite": "^6.0.7"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "20.19.23", "@types/node": "20.19.23",
@@ -25,7 +27,10 @@
"esbuild": "^0.21.0", "esbuild": "^0.21.0",
"terser": "5.44.0", "terser": "5.44.0",
"babel-plugin-transform-remove-console": "6.9.4", "babel-plugin-transform-remove-console": "6.9.4",
"framer-motion": "12.23.24" "framer-motion": "12.23.24",
"file-saver": "2.0.5",
"@types/file-saver": "2.0.5"
}, },
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",

View File

@@ -15,6 +15,8 @@ import EmployeeManagement from './pages/Employees/EmployeeManagement';
import Settings from './pages/Settings/Settings'; import Settings from './pages/Settings/Settings';
import Help from './pages/Help/Help'; import Help from './pages/Help/Help';
import Setup from './pages/Setup/Setup'; import Setup from './pages/Setup/Setup';
import ErrorBoundary from './components/ErrorBoundary/ErrorBoundary';
import SecurityWarning from './components/SecurityWarning/SecurityWarning';
// Free Footer Link Pages (always available) // Free Footer Link Pages (always available)
import FAQ from './components/Layout/FooterLinks/FAQ/FAQ'; import FAQ from './components/Layout/FooterLinks/FAQ/FAQ';
@@ -160,14 +162,17 @@ const AppContent: React.FC = () => {
function App() { function App() {
return ( return (
<NotificationProvider> <ErrorBoundary>
<AuthProvider> <NotificationProvider>
<Router> <AuthProvider>
<NotificationContainer /> <Router>
<AppContent /> <SecurityWarning />
</Router> <NotificationContainer />
</AuthProvider> <AppContent />
</NotificationProvider> </Router>
</AuthProvider>
</NotificationProvider>
</ErrorBoundary>
); );
} }

View File

@@ -0,0 +1,101 @@
// src/components/ErrorBoundary/ErrorBoundary.tsx
import React from 'react';
interface Props {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('🚨 Application Error:', error);
console.error('📋 Error Details:', errorInfo);
// In production, send to your error reporting service
// logErrorToService(error, errorInfo);
}
render() {
if (this.state.hasError) {
// You can render any custom fallback UI
return this.props.fallback || (
<div style={{
padding: '40px',
textAlign: 'center',
fontFamily: 'Arial, sans-serif'
}}>
<div style={{ fontSize: '48px', marginBottom: '20px' }}></div>
<h2>Oops! Something went wrong</h2>
<p style={{ margin: '20px 0', color: '#666' }}>
We encountered an unexpected error. Please try refreshing the page.
</p>
<div style={{ marginTop: '30px' }}>
<button
onClick={() => window.location.reload()}
style={{
padding: '10px 20px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
marginRight: '10px'
}}
>
Refresh Page
</button>
<button
onClick={() => this.setState({ hasError: false })}
style={{
padding: '10px 20px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Try Again
</button>
</div>
{process.env.NODE_ENV === 'development' && this.state.error && (
<details style={{
marginTop: '20px',
textAlign: 'left',
background: '#f8f9fa',
padding: '15px',
borderRadius: '4px'
}}>
<summary>Error Details (Development)</summary>
<pre style={{
whiteSpace: 'pre-wrap',
fontSize: '12px',
color: '#dc3545'
}}>
{this.state.error.stack}
</pre>
</details>
)}
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,59 @@
// src/components/SecurityWarning/SecurityWarning.tsx
import React, { useState, useEffect } from 'react';
const SecurityWarning: React.FC = () => {
const [isHttp, setIsHttp] = useState(false);
const [isDismissed, setIsDismissed] = useState(false);
useEffect(() => {
// Check if current protocol is HTTP
const checkProtocol = () => {
setIsHttp(window.location.protocol === 'http:');
};
checkProtocol();
window.addEventListener('load', checkProtocol);
return () => window.removeEventListener('load', checkProtocol);
}, []);
if (!isHttp || isDismissed) {
return null;
}
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
backgroundColor: '#ff6b35',
color: 'white',
padding: '10px 20px',
textAlign: 'center',
zIndex: 10000,
fontSize: '14px',
fontWeight: 'bold',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
}}>
SECURITY WARNING: This site is being accessed over HTTP.
For secure communication, please use HTTPS.
<button
onClick={() => setIsDismissed(true)}
style={{
marginLeft: '15px',
background: 'rgba(255,255,255,0.2)',
border: '1px solid white',
color: 'white',
padding: '2px 8px',
borderRadius: '3px',
cursor: 'pointer'
}}
>
Dismiss
</button>
</div>
);
};
export default SecurityWarning;

View File

@@ -20,6 +20,8 @@ export const designTokens = {
10: '#ebd7fa', 10: '#ebd7fa',
}, },
manager: '#CC0000',
// Semantic Colors // Semantic Colors
primary: '#51258f', primary: '#51258f',
secondary: '#642ab5', secondary: '#642ab5',

View File

@@ -10,6 +10,7 @@ import { ShiftPlan, TimeSlot, ScheduledShift } from '../../models/ShiftPlan';
import { Employee, EmployeeAvailability } from '../../models/Employee'; import { Employee, EmployeeAvailability } from '../../models/Employee';
import { useNotification } from '../../contexts/NotificationContext'; import { useNotification } from '../../contexts/NotificationContext';
import { formatDate, formatTime } from '../../utils/foramatters'; import { formatDate, formatTime } from '../../utils/foramatters';
import { saveAs } from 'file-saver';
// Local interface extensions (same as AvailabilityManager) // Local interface extensions (same as AvailabilityManager)
interface ExtendedTimeSlot extends TimeSlot { interface ExtendedTimeSlot extends TimeSlot {
@@ -54,6 +55,7 @@ const ShiftPlanView: React.FC = () => {
const [scheduledShifts, setScheduledShifts] = useState<ScheduledShift[]>([]); const [scheduledShifts, setScheduledShifts] = useState<ScheduledShift[]>([]);
const [showAssignmentPreview, setShowAssignmentPreview] = useState(false); const [showAssignmentPreview, setShowAssignmentPreview] = useState(false);
const [recreating, setRecreating] = useState(false); const [recreating, setRecreating] = useState(false);
const [exporting, setExporting] = useState(false);
useEffect(() => { useEffect(() => {
loadShiftPlanData(); loadShiftPlanData();
@@ -240,6 +242,66 @@ const ShiftPlanView: React.FC = () => {
}; };
}; };
const handleExportExcel = async () => {
if (!shiftPlan) return;
try {
setExporting(true);
// Call the export service
const blob = await shiftPlanService.exportShiftPlanToExcel(shiftPlan.id);
// Use file-saver to download the file
saveAs(blob, `Schichtplan_${shiftPlan.name}_${new Date().toISOString().split('T')[0]}.xlsx`);
showNotification({
type: 'success',
title: 'Export erfolgreich',
message: 'Der Schichtplan wurde als Excel-Datei exportiert.'
});
} catch (error) {
console.error('Error exporting to Excel:', error);
showNotification({
type: 'error',
title: 'Export fehlgeschlagen',
message: 'Der Excel-Export konnte nicht durchgeführt werden.'
});
} finally {
setExporting(false);
}
};
const handleExportPDF = async () => {
if (!shiftPlan) return;
try {
setExporting(true);
// Call the PDF export service
const blob = await shiftPlanService.exportShiftPlanToPDF(shiftPlan.id);
// Use file-saver to download the file
saveAs(blob, `Schichtplan_${shiftPlan.name}_${new Date().toISOString().split('T')[0]}.pdf`);
showNotification({
type: 'success',
title: 'Export erfolgreich',
message: 'Der Schichtplan wurde als PDF exportiert.'
});
} catch (error) {
console.error('Error exporting to PDF:', error);
showNotification({
type: 'error',
title: 'Export fehlgeschlagen',
message: 'Der PDF-Export konnte nicht durchgeführt werden.'
});
} finally {
setExporting(false);
}
};
const loadShiftPlanData = async () => { const loadShiftPlanData = async () => {
if (!id) return; if (!id) return;
@@ -399,12 +461,12 @@ const ShiftPlanView: React.FC = () => {
console.log('- Scheduled Shifts:', scheduledShifts.length); console.log('- Scheduled Shifts:', scheduledShifts.length);
// DEBUG: Show shift pattern IDs // DEBUG: Show shift pattern IDs
if (shiftPlan.shifts) { /*if (shiftPlan.shifts) {
console.log('📋 SHIFT PATTERN IDs:'); console.log('📋 SHIFT PATTERN IDs:');
shiftPlan.shifts.forEach((shift, index) => { shiftPlan.shifts.forEach((shift, index) => {
console.log(` ${index + 1}. ${shift.id} (Day ${shift.dayOfWeek}, TimeSlot ${shift.timeSlotId})`); console.log(` ${index + 1}. ${shift.id} (Day ${shift.dayOfWeek}, TimeSlot ${shift.timeSlotId})`);
}); });
} }*/
const constraints = { const constraints = {
enforceNoTraineeAlone: true, enforceNoTraineeAlone: true,
@@ -650,6 +712,20 @@ const ShiftPlanView: React.FC = () => {
return employeesWithoutAvailabilities.length === 0; return employeesWithoutAvailabilities.length === 0;
}; };
const canPublishAssignment = (): boolean => {
if (!assignmentResult) return false;
// Check if assignment was successful
if (assignmentResult.success === false) return false;
// Check if there are any critical violations
const hasCriticalViolations = assignmentResult.violations.some(v =>
v.includes('ERROR:') || v.includes('KRITISCH:')
);
return !hasCriticalViolations;
};
const getAvailabilityStatus = () => { const getAvailabilityStatus = () => {
const totalEmployees = employees.length; const totalEmployees = employees.length;
const employeesWithAvailabilities = new Set( const employeesWithAvailabilities = new Set(
@@ -820,9 +896,6 @@ const ShiftPlanView: React.FC = () => {
<div style={{ fontSize: '14px', color: '#666' }}> <div style={{ fontSize: '14px', color: '#666' }}>
{formatTime(timeSlot.startTime)} - {formatTime(timeSlot.endTime)} {formatTime(timeSlot.startTime)} - {formatTime(timeSlot.endTime)}
</div> </div>
<div style={{ fontSize: '11px', color: '#999', marginTop: '4px' }}>
ID: {timeSlot.id.substring(0, 8)}...
</div>
</td> </td>
{days.map(weekday => { {days.map(weekday => {
const shift = timeSlot.shiftsByDay[weekday.id]; const shift = timeSlot.shiftsByDay[weekday.id];
@@ -846,7 +919,55 @@ const ShiftPlanView: React.FC = () => {
const isValidShift = shift.timeSlotId === timeSlot.id && shift.dayOfWeek === weekday.id; const isValidShift = shift.timeSlotId === timeSlot.id && shift.dayOfWeek === weekday.id;
let assignedEmployees: string[] = []; let assignedEmployees: string[] = [];
let displayText = ''; let displayContent: React.ReactNode = null;
// Helper function to create employee boxes
const createEmployeeBoxes = (employeeIds: string[]) => {
return employeeIds.map(empId => {
const employee = employees.find(emp => emp.id === empId);
if (!employee) return null;
// Determine background color based on employee role
let backgroundColor = '#642ab5'; // Default: non-trainee personnel (purple)
if (employee.isTrainee) {
backgroundColor = '#cda8f0'; // Trainee
} else if (employee.roles?.includes('manager')) {
backgroundColor = '#CC0000'; // Manager
}
return (
<div
key={empId}
style={{
backgroundColor,
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
marginBottom: '2px',
fontSize: '12px',
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}
title={`${employee.firstname} ${employee.lastname}${employee.isTrainee ? ' (Trainee)' : ''}`}
>
{employee.firstname} {employee.lastname}
</div>
);
}).filter(Boolean);
};
// Helper function to get fallback content
const getFallbackContent = () => {
const shiftsForSlot = shiftPlan?.shifts?.filter(s =>
s.dayOfWeek === weekday.id &&
s.timeSlotId === timeSlot.id
) || [];
const totalRequired = shiftsForSlot.reduce((sum, s) => sum + s.requiredEmployees, 0);
return totalRequired === 0 ? '-' : `0/${totalRequired}`;
};
if (shiftPlan?.status === 'published') { if (shiftPlan?.status === 'published') {
// For published plans, use actual assignments from scheduled shifts // For published plans, use actual assignments from scheduled shifts
@@ -859,15 +980,21 @@ const ShiftPlanView: React.FC = () => {
if (scheduledShift) { if (scheduledShift) {
assignedEmployees = scheduledShift.assignedEmployees || []; assignedEmployees = scheduledShift.assignedEmployees || [];
// DEBUG: Log if we're still seeing old data // Log if we're still seeing old data
if (assignedEmployees.length > 0) { if (assignedEmployees.length > 0) {
console.warn(`⚠️ Found non-empty assignments for ${weekday.name} ${timeSlot.name}:`, assignedEmployees); console.warn(`⚠️ Found non-empty assignments for ${weekday.name} ${timeSlot.name}:`, assignedEmployees);
} }
displayText = assignedEmployees.map(empId => { const employeeBoxes = createEmployeeBoxes(assignedEmployees);
const employee = employees.find(emp => emp.id === empId); displayContent = employeeBoxes.length > 0 ? (
return employee ? `${employee.firstname} ${employee.lastname}` : 'Unbekannt'; <div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
}).join(', '); {employeeBoxes}
</div>
) : (
<div style={{ color: '#666', fontStyle: 'italic' }}>
{getFallbackContent()}
</div>
);
} }
} else if (assignmentResult) { } else if (assignmentResult) {
// For draft with preview, use assignment result // For draft with preview, use assignment result
@@ -879,30 +1006,26 @@ const ShiftPlanView: React.FC = () => {
if (scheduledShift) { if (scheduledShift) {
assignedEmployees = getAssignmentsForScheduledShift(scheduledShift); assignedEmployees = getAssignmentsForScheduledShift(scheduledShift);
displayText = assignedEmployees.map(empId => { const employeeBoxes = createEmployeeBoxes(assignedEmployees);
const employee = employees.find(emp => emp.id === empId); displayContent = employeeBoxes.length > 0 ? (
return employee ? `${employee.firstname} ${employee.lastname}` : 'Unbekannt'; <div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
}).join(', '); {employeeBoxes}
</div>
) : (
<div style={{ color: '#666', fontStyle: 'italic' }}>
{getFallbackContent()}
</div>
);
} }
} }
// If no assignments yet, show empty or required count // If no display content set yet, use fallback
if (!displayText) { if (!displayContent) {
const shiftsForSlot = shiftPlan?.shifts?.filter(s => displayContent = (
s.dayOfWeek === weekday.id && <div style={{ color: '#666', fontStyle: 'italic' }}>
s.timeSlotId === timeSlot.id {getFallbackContent()}
) || []; </div>
);
const totalRequired = shiftsForSlot.reduce((sum, s) =>
sum + s.requiredEmployees, 0);
// Show "0/2" instead of just "0" to indicate it's empty
displayText = `0/${totalRequired}`;
// Optional: Show empty state more clearly
if (totalRequired === 0) {
displayText = '-';
}
} }
return ( return (
@@ -937,7 +1060,7 @@ const ShiftPlanView: React.FC = () => {
</div> </div>
)} )}
{displayText} {displayContent}
{/* Shift debug info - SAME AS AVAILABILITYMANAGER */} {/* Shift debug info - SAME AS AVAILABILITYMANAGER */}
<div style={{ <div style={{
@@ -947,8 +1070,6 @@ const ShiftPlanView: React.FC = () => {
textAlign: 'left', textAlign: 'left',
fontFamily: 'monospace' fontFamily: 'monospace'
}}> }}>
<div>Shift: {shift.id.substring(0, 6)}...</div>
<div>Day: {shift.dayOfWeek}</div>
{!isValidShift && ( {!isValidShift && (
<div style={{ color: '#e74c3c', fontWeight: 'bold' }}> <div style={{ color: '#e74c3c', fontWeight: 'bold' }}>
VALIDATION ERROR VALIDATION ERROR
@@ -963,7 +1084,6 @@ const ShiftPlanView: React.FC = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
); );
}; };
@@ -1005,7 +1125,50 @@ const ShiftPlanView: React.FC = () => {
</div> </div>
</div> </div>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
{shiftPlan.status === 'published' && hasRole(['admin', 'maintenance']) && ( {shiftPlan.status === 'published' && hasRole(['admin', 'maintenance']) && (
<>
<button
onClick={handleExportExcel}
disabled={exporting}
style={{
padding: '10px 20px',
backgroundColor: '#27ae60',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: exporting ? 'not-allowed' : 'pointer',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
{exporting ? '🔄' : '📊'} {exporting ? 'Exportiert...' : 'Excel Export'}
</button>
<button
onClick={handleExportPDF}
disabled={exporting}
style={{
padding: '10px 20px',
backgroundColor: '#e74c3c',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: exporting ? 'not-allowed' : 'pointer',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
gap: '8px'
}}
>
{exporting ? '🔄' : '📄'} {exporting ? 'Exportiert...' : 'PDF Export'}
</button>
</>
)}
{/* "Zuweisungen neu berechnen" button */}
{shiftPlan.status === 'published' && hasRole(['admin', 'maintenance']) && (
<button <button
onClick={handleRecreateAssignments} onClick={handleRecreateAssignments}
disabled={recreating} disabled={recreating}
@@ -1197,15 +1360,13 @@ const ShiftPlanView: React.FC = () => {
</div> </div>
)} )}
{/* KORRIGIERTE ZUSAMMENFASSUNG */} {/* ZUSAMMENFASSUNG */}
{assignmentResult && ( {assignmentResult && (
<div style={{ marginBottom: '20px' }}> <div style={{ marginBottom: '20px' }}>
<h4>Zusammenfassung:</h4> <h4>Zusammenfassung:</h4>
{/* Entscheidung basierend auf tatsächlichen kritischen Problemen */} {/* Entscheidung basierend auf tatsächlichen kritischen Problemen */}
{assignmentResult.violations.filter(v => {(assignmentResult.violations.length === 0) || assignmentResult.success == true ? (
v.includes('ERROR:') || v.includes('❌ KRITISCH:')
).length === 0 ? (
<div style={{ <div style={{
padding: '15px', padding: '15px',
backgroundColor: '#d4edda', backgroundColor: '#d4edda',
@@ -1288,32 +1449,24 @@ const ShiftPlanView: React.FC = () => {
Abbrechen Abbrechen
</button> </button>
{/* KORRIGIERTER BUTTON MIT TYPESCRIPT-FIX */} {/* BUTTON zum publishen */}
<button <button
onClick={handlePublish} onClick={handlePublish}
disabled={publishing || (assignmentResult ? assignmentResult.violations.filter(v => disabled={publishing || !canPublishAssignment()}
v.includes('ERROR:') || v.includes('❌ KRITISCH:')
).length > 0 : true)}
style={{ style={{
padding: '10px 20px', padding: '10px 20px',
backgroundColor: assignmentResult ? (assignmentResult.violations.filter(v => backgroundColor: canPublishAssignment() ? '#2ecc71' : '#95a5a6',
v.includes('ERROR:') || v.includes('❌ KRITISCH:')
).length === 0 ? '#2ecc71' : '#95a5a6') : '#95a5a6',
color: 'white', color: 'white',
border: 'none', border: 'none',
borderRadius: '4px', borderRadius: '4px',
cursor: assignmentResult ? (assignmentResult.violations.filter(v => cursor: canPublishAssignment() ? 'pointer' : 'not-allowed',
v.includes('ERROR:') || v.includes('❌ KRITISCH:')
).length === 0 ? 'pointer' : 'not-allowed') : 'not-allowed',
fontWeight: 'bold', fontWeight: 'bold',
fontSize: '16px' fontSize: '16px'
}} }}
> >
{publishing ? 'Veröffentliche...' : ( {publishing ? 'Veröffentliche...' : (
assignmentResult ? ( assignmentResult ? (
assignmentResult.violations.filter(v => canPublishAssignment()
v.includes('ERROR:') || v.includes('❌ KRITISCH:')
).length === 0
? 'Schichtplan veröffentlichen' ? 'Schichtplan veröffentlichen'
: 'Kritische Probleme müssen behoben werden' : 'Kritische Probleme müssen behoben werden'
) : 'Lade Zuordnungen...' ) : 'Lade Zuordnungen...'

View File

@@ -26,7 +26,7 @@ export class ApiClient {
return token ? { 'Authorization': `Bearer ${token}` } : {}; return token ? { 'Authorization': `Bearer ${token}` } : {};
} }
private async handleApiResponse<T>(response: Response): Promise<T> { private async handleApiResponse<T>(response: Response, responseType: 'json' | 'blob' = 'json'): Promise<T> {
if (!response.ok) { if (!response.ok) {
let errorData; let errorData;
@@ -61,7 +61,12 @@ export class ApiClient {
); );
} }
// For successful responses, try to parse as JSON // Handle blob responses (for file downloads)
if (responseType === 'blob') {
return response.blob() as Promise<T>;
}
// For successful JSON responses, try to parse as JSON
try { try {
const responseText = await response.text(); const responseText = await response.text();
return responseText ? JSON.parse(responseText) : {} as T; return responseText ? JSON.parse(responseText) : {} as T;
@@ -71,7 +76,7 @@ export class ApiClient {
} }
} }
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> { async request<T>(endpoint: string, options: RequestInit = {}, responseType: 'json' | 'blob' = 'json'): Promise<T> {
const url = `${this.baseURL}${endpoint}`; const url = `${this.baseURL}${endpoint}`;
const config: RequestInit = { const config: RequestInit = {
@@ -85,7 +90,7 @@ export class ApiClient {
try { try {
const response = await fetch(url, config); const response = await fetch(url, config);
return await this.handleApiResponse<T>(response); return await this.handleApiResponse<T>(response, responseType);
} catch (error) { } catch (error) {
// Re-throw the error to be caught by useBackendValidation // Re-throw the error to be caught by useBackendValidation
if (error instanceof ApiError) { if (error instanceof ApiError) {

View File

@@ -126,4 +126,60 @@ export const shiftPlanService = {
throw error; throw error;
} }
}, },
async exportShiftPlanToExcel(planId: string): Promise<Blob> {
try {
console.log('📊 Exporting shift plan to Excel:', planId);
// Use the apiClient with blob response handling
const blob = await apiClient.request<Blob>(`/shift-plans/${planId}/export/excel`, {
method: 'GET',
}, 'blob');
console.log('✅ Excel export successful');
return blob;
} catch (error: any) {
console.error('❌ Error exporting to Excel:', error);
if (error.statusCode === 401) {
localStorage.removeItem('token');
localStorage.removeItem('employee');
throw new Error('Nicht authorisiert - bitte erneut anmelden');
}
if (error.statusCode === 404) {
throw new Error('Schichtplan nicht gefunden');
}
throw new Error('Fehler beim Excel-Export des Schichtplans');
}
},
async exportShiftPlanToPDF(planId: string): Promise<Blob> {
try {
console.log('📄 Exporting shift plan to PDF:', planId);
// Use the apiClient with blob response handling
const blob = await apiClient.request<Blob>(`/shift-plans/${planId}/export/pdf`, {
method: 'GET',
}, 'blob');
console.log('✅ PDF export successful');
return blob;
} catch (error: any) {
console.error('❌ Error exporting to PDF:', error);
if (error.statusCode === 401) {
localStorage.removeItem('token');
localStorage.removeItem('employee');
throw new Error('Nicht authorisiert - bitte erneut anmelden');
}
if (error.statusCode === 404) {
throw new Error('Schichtplan nicht gefunden');
}
throw new Error('Fehler beim PDF-Export des Schichtplans');
}
},
}; };

5511
package-lock.json generated

File diff suppressed because it is too large Load Diff