diff --git a/backend/src/controllers/ProfileController.js b/backend/src/controllers/ProfileController.js new file mode 100644 index 0000000..c559537 --- /dev/null +++ b/backend/src/controllers/ProfileController.js @@ -0,0 +1,62 @@ +const ProfileService = require('../services/ProfileService'); + +/** + * Controller für Benutzer-Profil + * Verarbeitet HTTP-Requests und delegiert an ProfileService + */ +class ProfileController { + /** + * Holt das Benutzerprofil + */ + async getProfile(req, res) { + try { + const userId = req.user?.id || 1; + const profile = await ProfileService.getProfile(userId); + res.json(profile); + } catch (error) { + console.error('Fehler beim Abrufen des Profils:', error); + res.status(500).json({ + message: 'Fehler beim Abrufen des Profils', + error: error.message + }); + } + } + + /** + * Aktualisiert das Benutzerprofil + */ + async updateProfile(req, res) { + try { + const userId = req.user?.id || 1; + const updates = req.body; + + const profile = await ProfileService.updateProfile(userId, updates); + res.json(profile); + } catch (error) { + console.error('Fehler beim Aktualisieren des Profils:', error); + res.status(500).json({ + message: 'Fehler beim Aktualisieren des Profils', + error: error.message + }); + } + } + + /** + * Holt alle verfügbaren Bundesländer + */ + async getStates(req, res) { + try { + const states = await ProfileService.getAllStates(); + res.json(states); + } catch (error) { + console.error('Fehler beim Abrufen der Bundesländer:', error); + res.status(500).json({ + message: 'Fehler beim Abrufen der Bundesländer', + error: error.message + }); + } + } +} + +module.exports = new ProfileController(); + diff --git a/backend/src/index.js b/backend/src/index.js index 5844b64..61c75b2 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -90,6 +90,10 @@ app.use('/api/calendar', authenticateToken, calendarRouter); const holidaysRouter = require('./routes/holidays'); app.use('/api/holidays', authenticateToken, holidaysRouter); +// Profile routes (geschützt) - MIT ID-Hashing +const profileRouter = require('./routes/profile'); +app.use('/api/profile', authenticateToken, profileRouter); + // Error handling middleware app.use((err, req, res, next) => { console.error(err.stack); diff --git a/backend/src/repositories/WorklogRepository.js b/backend/src/repositories/WorklogRepository.js index f567bbc..ab31e56 100644 --- a/backend/src/repositories/WorklogRepository.js +++ b/backend/src/repositories/WorklogRepository.js @@ -539,21 +539,58 @@ class WorklogRepository { * @param {Date} endDate - End-Datum * @returns {Promise} Array von Holiday-Einträgen mit Datum und Stunden */ - async getHolidaysInDateRange(startDate, endDate) { - const { Holiday } = database.getModels(); + async getHolidaysInDateRange(startDate, endDate, userId = null) { + const { Holiday, State, User } = database.getModels(); try { + // Hole User-Bundesland (falls userId angegeben) + let userStateId = null; + if (userId) { + const user = await User.findByPk(userId, { + attributes: ['state_id'], + raw: true + }); + userStateId = user?.state_id; + } + const holidays = await Holiday.findAll({ where: { date: { [Op.between]: [startDate, endDate] } }, - raw: true, + include: [ + { + model: State, + as: 'states', + attributes: ['id'], + through: { attributes: [] }, + required: false + } + ], order: [['date', 'ASC']] }); - return holidays; + // Filtere nach User-Bundesland (falls userId angegeben) + if (userId && userStateId) { + return holidays.filter(h => { + const holidayStates = h.states || []; + const isFederal = holidayStates.length === 0; + const appliesToUser = isFederal || holidayStates.some(s => s.id === userStateId); + return appliesToUser; + }).map(h => ({ + date: h.date, + hours: h.hours, + description: h.description + })); + } + + // Ohne userId: Alle Feiertage zurückgeben + return holidays.map(h => ({ + date: h.date, + hours: h.hours, + description: h.description + })); } catch (error) { console.error('Fehler beim Abrufen der Feiertage:', error); return []; diff --git a/backend/src/routes/profile.js b/backend/src/routes/profile.js new file mode 100644 index 0000000..793855d --- /dev/null +++ b/backend/src/routes/profile.js @@ -0,0 +1,19 @@ +const express = require('express'); +const router = express.Router(); +const ProfileController = require('../controllers/ProfileController'); + +/** + * Routen für Benutzer-Profil + */ + +// GET /api/profile/states - Alle Bundesländer abrufen +router.get('/states', ProfileController.getStates.bind(ProfileController)); + +// GET /api/profile - Profil abrufen +router.get('/', ProfileController.getProfile.bind(ProfileController)); + +// PUT /api/profile - Profil aktualisieren +router.put('/', ProfileController.updateProfile.bind(ProfileController)); + +module.exports = router; + diff --git a/backend/src/services/CalendarService.js b/backend/src/services/CalendarService.js index 4fd7f7c..d019e67 100644 --- a/backend/src/services/CalendarService.js +++ b/backend/src/services/CalendarService.js @@ -18,9 +18,16 @@ class CalendarService { * @returns {Promise} Kalenderdaten */ async getCalendarMonth(userId, year, month) { - const { Holiday, Sick, Vacation } = database.getModels(); + const { Holiday, Sick, Vacation, User, State } = database.getModels(); const sequelize = database.sequelize; + // Hole User-Bundesland + const user = await User.findByPk(userId, { + attributes: ['state_id'], + raw: true + }); + const userStateId = user?.state_id; + // Berechne Start- und End-Datum des Monats const firstDay = new Date(year, month - 1, 1); const lastDay = new Date(year, month, 0); @@ -36,19 +43,35 @@ class CalendarService { const startDateStr = this._formatDate(startDate); const endDateStr = this._formatDate(endDate); - // Hole alle Feiertage + // Hole alle Feiertage für diesen Zeitraum UND das Bundesland des Users + // Feiertag gilt wenn: Kein State (Bundesfeiertag) ODER User's State ist dabei const holidays = await Holiday.findAll({ where: { date: { [Op.between]: [startDateStr, endDateStr] } }, - raw: true + include: [ + { + model: State, + as: 'states', + attributes: ['id', 'state_name'], + through: { attributes: [] }, + required: false // LEFT JOIN, damit Bundesfeiertage (ohne States) auch dabei sind + } + ] }); const holidayMap = new Map(); holidays.forEach(h => { - holidayMap.set(h.date, h.description); + // Feiertag gilt wenn: Keine States (Bundesfeiertag) ODER User's State ist dabei + const holidayStates = h.states || []; + const isFederal = holidayStates.length === 0; + const appliesToUser = isFederal || holidayStates.some(s => s.id === userStateId); + + if (appliesToUser) { + holidayMap.set(h.date, h.description); + } }); // Hole alle Krankheitstage diff --git a/backend/src/services/ProfileService.js b/backend/src/services/ProfileService.js new file mode 100644 index 0000000..83d3b49 --- /dev/null +++ b/backend/src/services/ProfileService.js @@ -0,0 +1,124 @@ +const database = require('../config/database'); + +/** + * Service-Klasse für Benutzer-Profil-Einstellungen + */ +class ProfileService { + /** + * Holt das Benutzerprofil + * @param {number} userId - Benutzer-ID + * @returns {Promise} Profildaten + */ + async getProfile(userId) { + const { User, State, AuthInfo } = database.getModels(); + + const user = await User.findByPk(userId, { + include: [ + { + model: State, + as: 'state', + attributes: ['id', 'state_name'] + }, + { + model: AuthInfo, + as: 'authInfo', + attributes: ['email'] + } + ] + }); + + if (!user) { + throw new Error('Benutzer nicht gefunden'); + } + + return { + id: user.id, + fullName: user.full_name, + email: user.authInfo?.email || null, + stateId: user.state_id, + stateName: user.state?.state_name || null, + weekWorkdays: user.week_workdays, + dailyHours: user.daily_hours, + preferredTitleType: user.preferred_title_type + }; + } + + /** + * Aktualisiert das Benutzerprofil + * @param {number} userId - Benutzer-ID + * @param {Object} updates - Zu aktualisierende Felder + * @returns {Promise} Aktualisierte Profildaten + */ + async updateProfile(userId, updates) { + const { User, State } = database.getModels(); + + const user = await User.findByPk(userId); + + if (!user) { + throw new Error('Benutzer nicht gefunden'); + } + + // Aktualisiere erlaubte Felder + if (updates.fullName !== undefined) { + user.full_name = updates.fullName; + } + + if (updates.stateId !== undefined) { + // Prüfe ob State existiert + if (updates.stateId) { + const state = await State.findByPk(updates.stateId); + if (!state) { + throw new Error('Ungültiges Bundesland'); + } + } + user.state_id = updates.stateId; + } + + if (updates.weekWorkdays !== undefined) { + if (updates.weekWorkdays < 1 || updates.weekWorkdays > 7) { + throw new Error('Arbeitstage pro Woche müssen zwischen 1 und 7 liegen'); + } + user.week_workdays = updates.weekWorkdays; + } + + if (updates.dailyHours !== undefined) { + if (updates.dailyHours < 1 || updates.dailyHours > 24) { + throw new Error('Tägliche Arbeitsstunden müssen zwischen 1 und 24 liegen'); + } + user.daily_hours = updates.dailyHours; + } + + if (updates.preferredTitleType !== undefined) { + if (updates.preferredTitleType < 0 || updates.preferredTitleType > 7) { + throw new Error('Ungültiger Seitentitel-Typ'); + } + user.preferred_title_type = updates.preferredTitleType; + } + + await user.save(); + + // Lade aktualisiertes Profil + return this.getProfile(userId); + } + + /** + * Holt alle verfügbaren Bundesländer + * @returns {Promise} Array von States + */ + async getAllStates() { + const { State } = database.getModels(); + + const states = await State.findAll({ + order: [['state_name', 'ASC']], + raw: true + }); + + return states.map(s => ({ + id: s.id, + name: s.state_name + })); + } +} + +module.exports = new ProfileService(); + diff --git a/backend/src/services/TimeEntryService.js b/backend/src/services/TimeEntryService.js index 4bd8919..0b12cab 100644 --- a/backend/src/services/TimeEntryService.js +++ b/backend/src/services/TimeEntryService.js @@ -212,6 +212,11 @@ class TimeEntryService { async getStatistics(userId = null) { const uid = userId || this.defaultUserId; + // Hole User-Daten (für daily_hours Fallback) + const { User } = database.getModels(); + const user = await User.findByPk(uid, { attributes: ['daily_hours'], raw: true }); + const userDailyHours = user?.daily_hours || 8; + let currentlyWorked = null; let allEntries = []; @@ -556,7 +561,7 @@ class TimeEntryService { return true; }); - let daySollHours = 8; // Standard: 8h + let daySollHours = userDailyHours; // Fallback: User's daily_hours if (applicableTimewish && applicableTimewish.wishtype === 2 && applicableTimewish.hours) { daySollHours = applicableTimewish.hours; } @@ -638,7 +643,7 @@ class TimeEntryService { return true; }); - let daySollHours = 8; + let daySollHours = userDailyHours; // Fallback: User's daily_hours if (applicableTimewish && applicableTimewish.wishtype === 2 && applicableTimewish.hours) { daySollHours = applicableTimewish.hours; } @@ -961,9 +966,10 @@ class TimeEntryService { console.log('DEBUG _calculateTotalOvertime: Starte optimierte DB-basierte Berechnung...'); - // Hole den Überstunden-Offset für diesen User - const user = await User.findByPk(userId, { attributes: ['overtime_offset_minutes'], raw: true }); + // Hole den Überstunden-Offset und daily_hours für diesen User + const user = await User.findByPk(userId, { attributes: ['overtime_offset_minutes', 'daily_hours'], raw: true }); const overtimeOffsetMinutes = user?.overtime_offset_minutes || 0; + const userDailyHours = user?.daily_hours || 8; if (overtimeOffsetMinutes !== 0) { const offsetHours = Math.floor(Math.abs(overtimeOffsetMinutes) / 60); @@ -1014,7 +1020,8 @@ class TimeEntryService { return parseFloat(applicableTimewish.hours); } - return 8.0; // Fallback + // Fallback: User's daily_hours + return parseFloat(userDailyHours); }; // Berechne Endedatum: gestern (wie alte MySQL-Funktion) @@ -1073,17 +1080,43 @@ class TimeEntryService { console.log(`DEBUG: ${workDays.length} Arbeitstage gefunden (mit timefix-Korrekturen)`); - // Hole Feiertage + // Hole User-Bundesland + const { State: StateModel } = database.getModels(); + const userForState = await User.findByPk(userId, { + attributes: ['state_id'], + raw: true + }); + const userStateId = userForState?.state_id; + + // Hole Feiertage mit States const holidays = await Holiday.findAll({ - attributes: ['date'], where: { date: { [require('sequelize').Op.lte]: endDateStr } }, - raw: true + include: [ + { + model: StateModel, + as: 'states', + attributes: ['id'], + through: { attributes: [] }, + required: false + } + ] + }); + + // Filtere nur Feiertage die für den User gelten + const holidaySet = new Set(); + holidays.forEach(h => { + const holidayStates = h.states || []; + const isFederal = holidayStates.length === 0; + const appliesToUser = isFederal || holidayStates.some(s => s.id === userStateId); + + if (appliesToUser) { + holidaySet.add(h.date); + } }); - const holidaySet = new Set(holidays.map(h => h.date)); // Hole Krankheitstage (expandiert) const sickDaysRaw = await Sick.findAll({ @@ -1307,8 +1340,8 @@ class TimeEntryService { sickMap.set(sickKey, sick); }); - // Hole Feiertage für diese Woche - const holidays = await worklogRepository.getHolidaysInDateRange(weekStart, weekEnd); + // Hole Feiertage für diese Woche (nur die für das User-Bundesland gelten) + const holidays = await worklogRepository.getHolidaysInDateRange(weekStart, weekEnd, uid); // Erstelle Map von Datum zu Holiday const holidayMap = new Map(); @@ -1800,34 +1833,31 @@ class TimeEntryService { Object.values(dayData).forEach(day => { const dayOfWeek = new Date(day.date).getDay(); - // Prüfe auf Krankheit (hat höchste Priorität, überschreibt alles außer Feiertage) + // Sammle alle Ereignisse für diesen Tag + const events = []; + + // Prüfe auf Krankheit const sick = sickMap.get(day.date); if (sick) { - // Krankheitstage überschreiben geloggte Zeiten day.sick = { hours: 8, type: sick.sick_type }; - // Lösche geloggte Arbeitszeit - day.workTime = null; - day.pauses = []; - day.totalWorkTime = null; - day.pauseTimes = []; - day.netWorkTime = null; - - // Setze Status basierend auf Krankheitstyp const sickLabels = { 'self': 'Krank', 'child': 'Kind krank', 'parents': 'Eltern krank', 'partner': 'Partner krank' }; + events.push(sickLabels[sick.sick_type] || 'Krank'); - day.status = 'sick'; - day.statusText = sickLabels[sick.sick_type] || 'Krank'; - - return; // Überspringe weitere Verarbeitung + // Bei Krankheit: Lösche geloggte Arbeitszeit + day.workTime = null; + day.pauses = []; + day.totalWorkTime = null; + day.pauseTimes = []; + day.netWorkTime = null; } // Prüfe auf Feiertag @@ -1837,48 +1867,52 @@ class TimeEntryService { hours: holiday.hours, description: holiday.description }; - - // Status setzen - if (!day.workTime) { - day.status = 'holiday'; - day.statusText = holiday.description || 'Feiertag'; - } else { - // Feiertag + Arbeit - day.status = 'holiday-work'; - day.statusText = `${holiday.description || 'Feiertag'} + Arbeit`; - } + events.push(holiday.description || 'Feiertag'); } - // Prüfe auf Urlaub (nur wenn kein Feiertag) - if (!holiday) { - const vacation = vacationMap.get(day.date); - if (vacation) { - const isHalfDay = vacation.half_day === 1; - const vacationHours = isHalfDay ? 4 : 8; - - day.vacation = { - hours: vacationHours, - halfDay: isHalfDay - }; - - // Status setzen (kann später überschrieben werden wenn auch gearbeitet wurde) - if (!day.workTime) { - day.status = isHalfDay ? 'vacation-half' : 'vacation-full'; - day.statusText = isHalfDay ? 'Urlaub (halber Tag)' : 'Urlaub'; - } else { - // Urlaub + Arbeit - day.status = 'vacation-work'; - day.statusText = isHalfDay ? 'Urlaub (halber Tag) + Arbeit' : 'Urlaub + Arbeit'; + // Prüfe auf Urlaub + const vacation = vacationMap.get(day.date); + if (vacation) { + const isHalfDay = vacation.half_day === 1; + const vacationHours = isHalfDay ? 4 : 8; + + day.vacation = { + hours: vacationHours, + halfDay: isHalfDay + }; + events.push(isHalfDay ? 'Urlaub (halber Tag)' : 'Urlaub'); + } + + // Prüfe auf Wochenende + const isWeekend = (dayOfWeek === 0 || dayOfWeek === 6); + if (isWeekend) { + events.push('Wochenende'); + } + + // Setze Status basierend auf gesammelten Ereignissen + if (events.length > 0) { + if (day.workTime) { + // Ereignis(se) + Arbeit + day.statusText = events.join(' + ') + ' + Arbeit'; + day.status = 'mixed-work'; + } else { + // Nur Ereignis(se), keine Arbeit + day.statusText = events.join(' + '); + // Status basierend auf primärem Ereignis + if (sick) { + day.status = 'sick'; + } else if (holiday) { + day.status = 'holiday'; + } else if (vacation) { + day.status = vacation.halfDay ? 'vacation-half' : 'vacation-full'; + } else if (isWeekend) { + day.status = 'weekend'; } } - } - - // Wochenenden (nur wenn kein Urlaub, Feiertag oder Arbeit) - if (dayOfWeek === 0 || dayOfWeek === 6) { - if (!day.status) { - day.status = 'weekend'; - day.statusText = 'Wochenende'; - } + } else if (isWeekend) { + // Nur Wochenende, keine anderen Events + day.status = 'weekend'; + day.statusText = 'Wochenende'; } }); diff --git a/backend/src/services/WorkdaysService.js b/backend/src/services/WorkdaysService.js index 34b8f55..40e46da 100644 --- a/backend/src/services/WorkdaysService.js +++ b/backend/src/services/WorkdaysService.js @@ -17,13 +17,20 @@ class WorkdaysService { * @returns {Promise} Statistiken */ async getWorkdaysStatistics(userId, year) { - const { Holiday, Sick, Vacation } = database.getModels(); + const { Holiday, Sick, Vacation, User, State } = database.getModels(); const sequelize = database.sequelize; + // Hole User-Bundesland + const user = await User.findByPk(userId, { + attributes: ['state_id'], + raw: true + }); + const userStateId = user?.state_id; + // Berechne Anzahl Werktage (Mo-Fr) im Jahr const workdays = this._countWorkdays(year); - // Hole alle Feiertage für das Jahr + // Hole alle Feiertage für das Jahr mit States const holidays = await Holiday.findAll({ where: { date: { @@ -31,14 +38,32 @@ class WorkdaysService { [Op.lte]: `${year}-12-31` } }, - raw: true + include: [ + { + model: State, + as: 'states', + attributes: ['id'], + through: { attributes: [] }, + required: false + } + ] }); - // Zähle nur Feiertage die auf Werktage fallen + // Zähle nur Feiertage die auf Werktage fallen UND für den User gelten const holidayCount = holidays.filter(h => { const holidayDate = new Date(h.date + 'T00:00:00'); const dayOfWeek = holidayDate.getDay(); - return dayOfWeek >= 1 && dayOfWeek <= 5; // Mo-Fr + + if (dayOfWeek < 1 || dayOfWeek > 5) { + return false; // Kein Werktag + } + + // Feiertag gilt wenn: Keine States (Bundesfeiertag) ODER User's State ist dabei + const holidayStates = h.states || []; + const isFederal = holidayStates.length === 0; + const appliesToUser = isFederal || holidayStates.some(s => s.id === userStateId); + + return appliesToUser; }).length; // Hole alle Krankheitstage für das Jahr diff --git a/frontend/src/App.vue b/frontend/src/App.vue index d0a0954..2fd521d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -73,6 +73,7 @@ const pageTitle = computed(() => { 'workdays': 'Arbeitstage', 'calendar': 'Kalender', 'admin-holidays': 'Feiertage', + 'settings-profile': 'Persönliches', 'entries': 'Einträge', 'stats': 'Statistiken' } diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 6c69962..1281664 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -16,6 +16,7 @@ import Sick from '../views/Sick.vue' import Workdays from '../views/Workdays.vue' import Calendar from '../views/Calendar.vue' import Holidays from '../views/Holidays.vue' +import Profile from '../views/Profile.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -98,6 +99,12 @@ const router = createRouter({ component: Holidays, meta: { requiresAuth: true, requiresAdmin: true } }, + { + path: '/settings/profile', + name: 'settings-profile', + component: Profile, + meta: { requiresAuth: true } + }, { path: '/entries', name: 'entries', diff --git a/frontend/src/views/Profile.vue b/frontend/src/views/Profile.vue new file mode 100644 index 0000000..fe623d2 --- /dev/null +++ b/frontend/src/views/Profile.vue @@ -0,0 +1,326 @@ + + + + + +