Add calendar routes to backend and frontend; update routing and UI components for calendar feature

This commit is contained in:
Torsten Schulz (local)
2025-10-17 21:46:32 +02:00
parent a58504a93e
commit 6a0b23e694
7 changed files with 683 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
const CalendarService = require('../services/CalendarService');
/**
* Controller für Kalender
* Verarbeitet HTTP-Requests und delegiert an CalendarService
*/
class CalendarController {
/**
* Holt Kalenderdaten für einen Monat
*/
async getMonth(req, res) {
try {
const userId = req.user?.id || 1;
const year = parseInt(req.query.year) || new Date().getFullYear();
const month = parseInt(req.query.month) || (new Date().getMonth() + 1);
if (isNaN(year) || year < 1900 || year > 2100) {
return res.status(400).json({ message: 'Ungültiges Jahr' });
}
if (isNaN(month) || month < 1 || month > 12) {
return res.status(400).json({ message: 'Ungültiger Monat' });
}
const calendarData = await CalendarService.getCalendarMonth(userId, year, month);
res.json(calendarData);
} catch (error) {
console.error('Fehler beim Abrufen der Kalenderdaten:', error);
res.status(500).json({
message: 'Fehler beim Abrufen der Kalenderdaten',
error: error.message
});
}
}
}
module.exports = new CalendarController();

View File

@@ -82,6 +82,10 @@ app.use('/api/sick', authenticateToken, sickRouter);
const workdaysRouter = require('./routes/workdays');
app.use('/api/workdays', authenticateToken, workdaysRouter);
// Calendar routes (geschützt) - MIT ID-Hashing
const calendarRouter = require('./routes/calendar');
app.use('/api/calendar', authenticateToken, calendarRouter);
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);

View File

@@ -0,0 +1,13 @@
const express = require('express');
const router = express.Router();
const CalendarController = require('../controllers/CalendarController');
/**
* Routen für Kalender
*/
// GET /api/calendar?year=2025&month=10 - Kalenderdaten für einen Monat abrufen
router.get('/', CalendarController.getMonth.bind(CalendarController));
module.exports = router;

View File

@@ -0,0 +1,283 @@
const database = require('../config/database');
const { Op } = require('sequelize');
/**
* Service-Klasse für Kalender-Daten
* Lädt alle Informationen für einen Monat
*/
class CalendarService {
constructor() {
this.defaultUserId = 1;
}
/**
* Holt Kalenderdaten für einen Monat
* @param {number} userId - Benutzer-ID
* @param {number} year - Jahr
* @param {number} month - Monat (1-12)
* @returns {Promise<Object>} Kalenderdaten
*/
async getCalendarMonth(userId, year, month) {
const { Holiday, Sick, Vacation } = database.getModels();
const sequelize = database.sequelize;
// Berechne Start- und End-Datum des Monats
const firstDay = new Date(year, month - 1, 1);
const lastDay = new Date(year, month, 0);
// Erweitere den Bereich um die Tage aus dem vorherigen/nächsten Monat
const startDate = new Date(firstDay);
startDate.setDate(startDate.getDate() - ((startDate.getDay() + 6) % 7)); // Montag der ersten Woche
const endDate = new Date(lastDay);
const daysToAdd = (7 - ((endDate.getDay() + 6) % 7)) % 7;
endDate.setDate(endDate.getDate() + daysToAdd); // Sonntag der letzten Woche
const startDateStr = this._formatDate(startDate);
const endDateStr = this._formatDate(endDate);
// Hole alle Feiertage
const holidays = await Holiday.findAll({
where: {
date: {
[Op.between]: [startDateStr, endDateStr]
}
},
raw: true
});
const holidayMap = new Map();
holidays.forEach(h => {
holidayMap.set(h.date, h.description);
});
// Hole alle Krankheitstage
const sickEntries = await Sick.findAll({
where: {
user_id: userId,
[Op.or]: [
{
first_day: {
[Op.between]: [startDateStr, endDateStr]
}
},
{
last_day: {
[Op.between]: [startDateStr, endDateStr]
}
},
{
[Op.and]: [
{ first_day: { [Op.lte]: startDateStr } },
{ last_day: { [Op.gte]: endDateStr } }
]
}
]
},
raw: true
});
// Erstelle Set mit allen Krankheitstagen
const sickDays = new Set();
sickEntries.forEach(sick => {
const start = new Date(Math.max(new Date(sick.first_day + 'T00:00:00'), new Date(startDateStr + 'T00:00:00')));
const end = new Date(Math.min(new Date(sick.last_day + 'T00:00:00'), new Date(endDateStr + 'T00:00:00')));
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
sickDays.add(this._formatDate(d));
}
});
// Hole alle Urlaubstage
const vacationEntries = await Vacation.findAll({
where: {
user_id: userId,
[Op.or]: [
{
first_day: {
[Op.between]: [startDateStr, endDateStr]
}
},
{
last_day: {
[Op.between]: [startDateStr, endDateStr]
}
},
{
[Op.and]: [
{ first_day: { [Op.lte]: startDateStr } },
{ last_day: { [Op.gte]: endDateStr } }
]
}
]
},
raw: true
});
// Erstelle Map mit Urlaubstagen (und ob es halbe Tage sind)
const vacationDays = new Map();
vacationEntries.forEach(vac => {
const start = new Date(Math.max(new Date(vac.first_day + 'T00:00:00'), new Date(startDateStr + 'T00:00:00')));
const end = new Date(Math.min(new Date(vac.last_day + 'T00:00:00'), new Date(endDateStr + 'T00:00:00')));
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
vacationDays.set(this._formatDate(d), vac.half_day === 1);
}
});
// Hole Arbeitszeiten über den TimeEntryService (ähnlich wie WeekOverview)
// Wir verwenden eine vereinfachte Query die alle worklog-Einträge holt
const workQuery = `
SELECT DATE(tstamp) as work_date, tstamp, state, relatedTo_id, id
FROM worklog
WHERE user_id = :userId
AND DATE(tstamp) BETWEEN :startDate AND :endDate
ORDER BY tstamp ASC
`;
const workEntries = await sequelize.query(workQuery, {
replacements: {
userId,
startDate: startDateStr,
endDate: endDateStr
},
type: sequelize.QueryTypes.SELECT
});
// Gruppiere nach Datum und berechne Arbeitszeit
const workMap = new Map();
const entriesByDate = new Map();
workEntries.forEach(entry => {
if (!entriesByDate.has(entry.work_date)) {
entriesByDate.set(entry.work_date, []);
}
entriesByDate.get(entry.work_date).push(entry);
});
// Berechne für jeden Tag die Arbeitszeit (Nettoarbeitszeit mit Pausenabzug)
entriesByDate.forEach((entries, date) => {
let totalWorkMinutes = 0;
let totalPauseMinutes = 0;
// Parse state für alle Einträge
const parsedEntries = entries.map(entry => {
let state = entry.state;
if (typeof state === 'string' && state.startsWith('{')) {
try {
state = JSON.parse(state);
} catch (e) {
// ignore
}
}
const action = state?.action || state;
return { ...entry, action };
});
// Finde alle start work -> stop work Paare und berechne Bruttoarbeitszeit
parsedEntries.forEach(entry => {
if (entry.action === 'stop work') {
// Finde das zugehörige start work über relatedTo_id
const startEntry = parsedEntries.find(e => e.id === entry.relatedTo_id && e.action === 'start work');
if (startEntry) {
const start = new Date(startEntry.tstamp);
const end = new Date(entry.tstamp);
const minutes = (end - start) / (1000 * 60);
totalWorkMinutes += minutes;
// Berechne Pausen innerhalb dieses Arbeitsblocks
const pauseEntries = parsedEntries.filter(e =>
new Date(e.tstamp) > start &&
new Date(e.tstamp) < end &&
(e.action === 'start pause' || e.action === 'stop pause')
);
// Paare start pause -> stop pause
pauseEntries.forEach(pauseEntry => {
if (pauseEntry.action === 'stop pause') {
const pauseStart = pauseEntries.find(pe =>
pe.id === pauseEntry.relatedTo_id &&
pe.action === 'start pause'
);
if (pauseStart) {
const pStart = new Date(pauseStart.tstamp);
const pEnd = new Date(pauseEntry.tstamp);
const pauseMins = (pEnd - pStart) / (1000 * 60);
totalPauseMinutes += pauseMins;
}
}
});
}
}
});
const netMinutes = totalWorkMinutes - totalPauseMinutes;
if (netMinutes > 0) {
workMap.set(date, netMinutes / 60); // Convert to hours
}
});
// Erstelle Kalendertage
const days = [];
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
const dateStr = this._formatDate(currentDate);
const day = currentDate.getDate();
const isCurrentMonth = currentDate.getMonth() === (month - 1);
const isToday = this._isToday(currentDate);
const dayData = {
date: dateStr,
day,
isCurrentMonth,
isToday,
holiday: holidayMap.get(dateStr) || null,
sick: sickDays.has(dateStr),
vacation: vacationDays.has(dateStr) ? (vacationDays.get(dateStr) ? 'half' : 'full') : null,
workedHours: workMap.get(dateStr) || null
};
days.push(dayData);
currentDate.setDate(currentDate.getDate() + 1);
}
// Gruppiere in Wochen
const weeks = [];
for (let i = 0; i < days.length; i += 7) {
weeks.push(days.slice(i, i + 7));
}
return {
year,
month,
weeks
};
}
/**
* Formatiert ein Datum als YYYY-MM-DD
*/
_formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Prüft ob ein Datum heute ist
*/
_isToday(date) {
const today = new Date();
return date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear();
}
}
module.exports = new CalendarService();