diff --git a/backend/src/controllers/CalendarController.js b/backend/src/controllers/CalendarController.js new file mode 100644 index 0000000..0738659 --- /dev/null +++ b/backend/src/controllers/CalendarController.js @@ -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(); + diff --git a/backend/src/index.js b/backend/src/index.js index f1a48cc..d0725cf 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -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); diff --git a/backend/src/routes/calendar.js b/backend/src/routes/calendar.js new file mode 100644 index 0000000..78414c0 --- /dev/null +++ b/backend/src/routes/calendar.js @@ -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; + diff --git a/backend/src/services/CalendarService.js b/backend/src/services/CalendarService.js new file mode 100644 index 0000000..4fd7f7c --- /dev/null +++ b/backend/src/services/CalendarService.js @@ -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} 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(); + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 215b581..6893fa8 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -71,6 +71,7 @@ const pageTitle = computed(() => { 'vacation': 'Urlaub', 'sick': 'Krankheit', 'workdays': 'Arbeitstage', + 'calendar': 'Kalender', 'entries': 'Einträge', 'stats': 'Statistiken' } diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index d17717c..789e729 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -14,6 +14,7 @@ import Timefix from '../views/Timefix.vue' import Vacation from '../views/Vacation.vue' import Sick from '../views/Sick.vue' import Workdays from '../views/Workdays.vue' +import Calendar from '../views/Calendar.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -84,6 +85,12 @@ const router = createRouter({ component: Workdays, meta: { requiresAuth: true } }, + { + path: '/calendar', + name: 'calendar', + component: Calendar, + meta: { requiresAuth: true } + }, { path: '/entries', name: 'entries', diff --git a/frontend/src/views/Calendar.vue b/frontend/src/views/Calendar.vue new file mode 100644 index 0000000..8ce65d1 --- /dev/null +++ b/frontend/src/views/Calendar.vue @@ -0,0 +1,337 @@ + + + + + +