mirror of
https://github.com/donpat1to/Schichtenplaner.git
synced 2025-12-01 15:05:45 +01:00
Compare commits
4 Commits
feature/de
...
e5d836d037
| Author | SHA1 | Date | |
|---|---|---|---|
| e5d836d037 | |||
| 99d5105768 | |||
| a8dc11b024 | |||
| 0473a3b5bf |
@@ -29,7 +29,7 @@
|
|||||||
"helmet": "8.1.0",
|
"helmet": "8.1.0",
|
||||||
"express-validator": "7.3.0",
|
"express-validator": "7.3.0",
|
||||||
"exceljs": "4.4.0",
|
"exceljs": "4.4.0",
|
||||||
"playwright": "^1.37.0"
|
"playwright-chromium": "^1.37.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^2.4.2",
|
"@types/bcryptjs": "^2.4.2",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { AuthRequest } from '../middleware/auth.js';
|
import { AuthRequest } from '../middleware/auth.js';
|
||||||
import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults.js';
|
import { TEMPLATE_PRESETS } from '../models/defaults/shiftPlanDefaults.js';
|
||||||
import ExcelJS from 'exceljs';
|
import ExcelJS from 'exceljs';
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright-chromium';
|
||||||
|
|
||||||
async function getPlanWithDetails(planId: string) {
|
async function getPlanWithDetails(planId: string) {
|
||||||
const plan = await db.get<any>(`
|
const plan = await db.get<any>(`
|
||||||
@@ -989,6 +989,20 @@ interface ExportTimetableData {
|
|||||||
allTimeSlots: ExportTimeSlot[];
|
allTimeSlots: ExportTimeSlot[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sortTimeSlotsByStartTime(timeSlots: any[]): any[] {
|
||||||
|
const timeToMinutes = (timeStr: string) => {
|
||||||
|
if (!timeStr) return 0;
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
return [...timeSlots].sort((a, b) => {
|
||||||
|
const minutesA = timeToMinutes(a.startTime);
|
||||||
|
const minutesB = timeToMinutes(b.startTime);
|
||||||
|
return minutesA - minutesB; // Ascending order (earliest first)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getTimetableDataForExport(plan: any): ExportTimetableData {
|
function getTimetableDataForExport(plan: any): ExportTimetableData {
|
||||||
const weekdays = [
|
const weekdays = [
|
||||||
{ id: 1, name: 'Montag' },
|
{ id: 1, name: 'Montag' },
|
||||||
@@ -1032,9 +1046,16 @@ function getTimetableDataForExport(plan: any): ExportTimetableData {
|
|||||||
Object.keys(shiftsByDay).forEach(day => {
|
Object.keys(shiftsByDay).forEach(day => {
|
||||||
const dayNum = parseInt(day);
|
const dayNum = parseInt(day);
|
||||||
shiftsByDay[dayNum].sort((a: any, b: any) => {
|
shiftsByDay[dayNum].sort((a: any, b: any) => {
|
||||||
const timeA = a.startTime || '';
|
// Use numeric comparison for proper time sorting
|
||||||
const timeB = b.startTime || '';
|
const timeToMinutes = (timeStr: string) => {
|
||||||
return timeA.localeCompare(timeB);
|
if (!timeStr) return 0;
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const minutesA = timeToMinutes(a.startTime);
|
||||||
|
const minutesB = timeToMinutes(b.startTime);
|
||||||
|
return minutesA - minutesB;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1073,15 +1094,12 @@ function getTimetableDataForExport(plan: any): ExportTimetableData {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Convert to array and sort by start time
|
// Convert to array and sort by start time using numeric comparison
|
||||||
const allTimeSlots = Array.from(allTimeSlotsMap.values()).sort((a: ExportTimeSlot, b: ExportTimeSlot) => {
|
const allTimeSlots = sortTimeSlotsByStartTime(Array.from(allTimeSlotsMap.values()));
|
||||||
return (a.startTime || '').localeCompare(b.startTime || '');
|
|
||||||
});
|
|
||||||
|
|
||||||
return { days, allTimeSlots };
|
return { days, allTimeSlots };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Export shift plan to Excel
|
|
||||||
// Export shift plan to Excel
|
// Export shift plan to Excel
|
||||||
export const exportShiftPlanToExcel = async (req: Request, res: Response): Promise<void> => {
|
export const exportShiftPlanToExcel = async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -41,12 +41,35 @@ const getClientIP = (req: Request): string => {
|
|||||||
return remoteAddress;
|
return remoteAddress;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to check if an IP is a loopback address (IPv4 or IPv6)
|
||||||
|
const isLoopbackAddress = (ip: string): boolean => {
|
||||||
|
// IPv4 loopback: 127.0.0.0/8
|
||||||
|
if (ip.startsWith('127.') || ip === 'localhost') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPv6 loopback: ::1
|
||||||
|
// Also handle IPv4-mapped IPv6 addresses like ::ffff:127.0.0.1
|
||||||
|
if (ip === '::1' || ip === '::ffff:127.0.0.1') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle full IPv6 loopback notation
|
||||||
|
if (ip.toLowerCase().startsWith('0000:0000:0000:0000:0000:0000:0000:0001') ||
|
||||||
|
ip.toLowerCase() === '0:0:0:0:0:0:0:1') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
// Helper to check if request should be limited
|
// Helper to check if request should be limited
|
||||||
const shouldSkipLimit = (req: Request): boolean => {
|
const shouldSkipLimit = (req: Request): boolean => {
|
||||||
const skipPaths = [
|
const skipPaths = [
|
||||||
'/api/health',
|
'/api/health',
|
||||||
'/api/setup/status',
|
'/api/setup/status',
|
||||||
'/api/auth/validate'
|
'/api/auth/validate',
|
||||||
|
'/api/auth/me',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Skip for successful GET requests (data fetching)
|
// Skip for successful GET requests (data fetching)
|
||||||
@@ -54,9 +77,16 @@ const shouldSkipLimit = (req: Request): boolean => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clientIP = getClientIP(req);
|
||||||
|
|
||||||
|
// Skip for loopback addresses (local development)
|
||||||
|
if (isLoopbackAddress(clientIP)) {
|
||||||
|
console.log(`✅ Loopback address skipped: ${clientIP}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip for whitelisted IPs from environment
|
// Skip for whitelisted IPs from environment
|
||||||
const whitelist = process.env.RATE_LIMIT_WHITELIST?.split(',') || [];
|
const whitelist = process.env.RATE_LIMIT_WHITELIST?.split(',') || [];
|
||||||
const clientIP = getClientIP(req);
|
|
||||||
if (whitelist.includes(clientIP)) {
|
if (whitelist.includes(clientIP)) {
|
||||||
console.log(`✅ IP whitelisted: ${clientIP}`);
|
console.log(`✅ IP whitelisted: ${clientIP}`);
|
||||||
return true;
|
return true;
|
||||||
@@ -72,8 +102,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 || '1000') // Stricter in production
|
? parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '50') // Stricter in production
|
||||||
: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '5000'), // More lenient in development
|
: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100'), // More lenient in development
|
||||||
|
|
||||||
// Development-specific relaxations
|
// Development-specific relaxations
|
||||||
skip: (req: Request) => {
|
skip: (req: Request) => {
|
||||||
@@ -112,7 +142,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 || '100'),
|
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX_REQUESTS || '50'),
|
||||||
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 +165,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 || '100'),
|
max: parseInt(process.env.EXPENSIVE_ENDPOINT_LIMIT || '20'),
|
||||||
message: {
|
message: {
|
||||||
error: 'Zu viele Anfragen für diese Ressource'
|
error: 'Zu viele Anfragen für diese Ressource'
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ export async function seedTestData(): Promise<void> {
|
|||||||
console.log('🌱 Starting test data seeding...');
|
console.log('🌱 Starting test data seeding...');
|
||||||
|
|
||||||
// Read test.json file - adjust path to be relative to project root
|
// Read test.json file - adjust path to be relative to project root
|
||||||
const testDataPath = path.resolve(process.cwd(), 'test.json');
|
//const testDataPath = path.resolve(process.cwd(), './test.json');
|
||||||
|
const testDataPath = path.resolve(__dirname, './test.json');
|
||||||
|
|
||||||
console.log('🔍 Looking for test.json at:', testDataPath);
|
console.log('🔍 Looking for test.json at:', testDataPath);
|
||||||
|
|
||||||
@@ -95,9 +96,10 @@ export async function seedTestData(): Promise<void> {
|
|||||||
|
|
||||||
// Try alternative paths
|
// Try alternative paths
|
||||||
const alternativePaths = [
|
const alternativePaths = [
|
||||||
path.resolve(__dirname, '../../../test.json'),
|
//path.resolve(__dirname, '../../../test.json'),
|
||||||
path.resolve(process.cwd(), '../test.json'),
|
//path.resolve(process.cwd(), '../test.json'),
|
||||||
path.resolve(__dirname, '../../test.json')
|
//path.resolve(__dirname, '../../test.json'),
|
||||||
|
path.resolve(__dirname, './test.json')
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const altPath of alternativePaths) {
|
for (const altPath of alternativePaths) {
|
||||||
@@ -136,7 +138,7 @@ export async function seedTestData(): Promise<void> {
|
|||||||
|
|
||||||
const [firstname, lastname = ''] = name.split(' ');
|
const [firstname, lastname = ''] = name.split(' ');
|
||||||
const email = generateEmail(firstname, lastname || 'Test');
|
const email = generateEmail(firstname, lastname || 'Test');
|
||||||
const passwordHash = await bcrypt.hash('test1234', 10);
|
const passwordHash = await bcrypt.hash('ZebraAux123!', 10);
|
||||||
|
|
||||||
const contractType = mapContractType(testData.employee_info.contract_sizes[name]);
|
const contractType = mapContractType(testData.employee_info.contract_sizes[name]);
|
||||||
const employeeType = testData.employee_info.employee_types[name];
|
const employeeType = testData.employee_info.employee_types[name];
|
||||||
|
|||||||
@@ -317,7 +317,17 @@ const AvailabilityManager: React.FC<AvailabilityManagerProps> = ({
|
|||||||
|
|
||||||
// Convert to array and sort by start time
|
// Convert to array and sort by start time
|
||||||
const sortedTimeSlots = Array.from(allTimeSlots.values()).sort((a, b) => {
|
const sortedTimeSlots = Array.from(allTimeSlots.values()).sort((a, b) => {
|
||||||
return (a.startTime || '').localeCompare(b.startTime || '');
|
// Convert time strings to minutes for proper numeric comparison
|
||||||
|
const timeToMinutes = (timeStr: string) => {
|
||||||
|
if (!timeStr) return 0;
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const minutesA = timeToMinutes(a.startTime);
|
||||||
|
const minutesB = timeToMinutes(b.startTime);
|
||||||
|
|
||||||
|
return minutesA - minutesB; // Ascending order (earliest first)
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ const ShiftPlanView: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dropdownRef.current) {
|
if (dropdownRef.current) {
|
||||||
setDropdownWidth(dropdownRef.current.offsetWidth);
|
setDropdownWidth(dropdownRef.current.offsetWidth / 40); // Adjust divisor for desired slide distance
|
||||||
}
|
}
|
||||||
}, [exportType]);
|
}, [exportType]);
|
||||||
|
|
||||||
@@ -200,7 +200,17 @@ const ShiftPlanView: React.FC = () => {
|
|||||||
|
|
||||||
// Convert to array and sort by start time - SAME LOGIC AS AVAILABILITYMANAGER
|
// Convert to array and sort by start time - SAME LOGIC AS AVAILABILITYMANAGER
|
||||||
const allTimeSlots = Array.from(allTimeSlotsMap.values()).sort((a, b) => {
|
const allTimeSlots = Array.from(allTimeSlotsMap.values()).sort((a, b) => {
|
||||||
return (a.startTime || '').localeCompare(b.startTime || '');
|
// Convert time strings to minutes for proper numeric comparison
|
||||||
|
const timeToMinutes = (timeStr: string) => {
|
||||||
|
if (!timeStr) return 0;
|
||||||
|
const [hours, minutes] = timeStr.split(':').map(Number);
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const minutesA = timeToMinutes(a.startTime);
|
||||||
|
const minutesB = timeToMinutes(b.startTime);
|
||||||
|
|
||||||
|
return minutesA - minutesB; // Ascending order (earliest first)
|
||||||
});
|
});
|
||||||
|
|
||||||
return { days, shiftsByDay, allTimeSlots };
|
return { days, shiftsByDay, allTimeSlots };
|
||||||
@@ -1436,17 +1446,17 @@ const ShiftPlanView: React.FC = () => {
|
|||||||
<div style={{
|
<div style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
position: 'relative',
|
justifyContent: 'flex-end',
|
||||||
marginLeft: '10px'
|
marginTop: '20px',
|
||||||
|
gap: '10px'
|
||||||
}}>
|
}}>
|
||||||
{/* Export Dropdown */}
|
{/* Export Dropdown Container */}
|
||||||
<div
|
<div
|
||||||
ref={dropdownRef}
|
ref={dropdownRef}
|
||||||
style={{
|
style={{
|
||||||
transform: exportType ? `translateX(-${dropdownWidth}px)` : 'translateX(0)',
|
transform: exportType ? `translateX(-${dropdownWidth}px)` : 'translateX(0)',
|
||||||
transition: 'transform 0.3s ease-in-out',
|
transition: 'transform 0.3s ease-in-out',
|
||||||
position: exportType ? 'absolute' : 'relative',
|
position: 'relative'
|
||||||
right: exportType ? `-${dropdownWidth}px` : '0'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
@@ -1467,7 +1477,7 @@ const ShiftPlanView: React.FC = () => {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Export Button */}
|
{/* Export Button - erscheint nur wenn eine Option ausgewählt ist */}
|
||||||
{exportType && (
|
{exportType && (
|
||||||
<button
|
<button
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
@@ -1480,7 +1490,6 @@ const ShiftPlanView: React.FC = () => {
|
|||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
cursor: exporting ? 'not-allowed' : 'pointer',
|
cursor: exporting ? 'not-allowed' : 'pointer',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
marginLeft: '10px',
|
|
||||||
opacity: exporting ? 0.7 : 1,
|
opacity: exporting ? 0.7 : 1,
|
||||||
transition: 'opacity 0.2s ease'
|
transition: 'opacity 0.2s ease'
|
||||||
}}
|
}}
|
||||||
|
|||||||
Reference in New Issue
Block a user