diff --git a/routes/verwaltung-routes.js b/routes/verwaltung-routes.js index 5fe579d..1f9278f 100644 --- a/routes/verwaltung-routes.js +++ b/routes/verwaltung-routes.js @@ -6,6 +6,7 @@ const { requireVerwaltung } = require('../middleware/auth'); const { getWeekDatesFromCalendarWeek } = require('../helpers/utils'); const { generatePDFToBuffer } = require('../services/pdf-service'); const { getHolidaysForDateRange } = require('../services/feiertage-service'); +const { getCurrentOvertimeForUser } = require('../services/overtime-service'); // Routes registrieren function registerVerwaltungRoutes(app) { @@ -190,6 +191,35 @@ function registerVerwaltungRoutes(app) { ); }); + // API: Aktuelle Überstunden für mehrere Mitarbeiter (Batch für Verwaltungs-Übersicht) + app.get('/api/verwaltung/employees/current-overtime', requireVerwaltung, (req, res) => { + const userIdsParam = req.query.userIds; + if (!userIdsParam || typeof userIdsParam !== 'string') { + return res.status(400).json({ error: 'userIds (kommagetrennt) erforderlich' }); + } + const userIds = userIdsParam.split(',').map((id) => parseInt(id.trim(), 10)).filter((id) => !isNaN(id)); + if (userIds.length === 0) { + return res.json({}); + } + + const result = {}; + let pending = userIds.length; + + userIds.forEach((userId) => { + getCurrentOvertimeForUser(userId, db, (err, currentOvertime) => { + if (err) { + result[String(userId)] = null; + } else { + result[String(userId)] = currentOvertime != null ? currentOvertime : 0; + } + pending--; + if (pending === 0) { + res.json(result); + } + }); + }); + }); + // API: Überstunden- und Urlaubsstatistiken für einen User abrufen app.get('/api/verwaltung/user/:id/stats', requireVerwaltung, (req, res) => { const userId = req.params.id; diff --git a/services/overtime-service.js b/services/overtime-service.js new file mode 100644 index 0000000..88814fa --- /dev/null +++ b/services/overtime-service.js @@ -0,0 +1,195 @@ +// Overtime Service: Berechnung der aktuellen Überstunden-Bilanz pro User +// Wird von /api/user/stats und /api/verwaltung/employees/current-overtime genutzt. + +const { getHolidaysForDateRange } = require('./feiertage-service'); + +/** + * Berechnet die aktuelle Überstunden-Bilanz für einen User (nur eingereichte Wochen). + * Entspricht der Anzeige "Aktuelle Überstunden" im Mitarbeiter-Dashboard. + * + * @param {number} userId - User-ID + * @param {object} db - Database-Instanz (sqlite3) + * @param {function} callback - (err, currentOvertime) currentOvertime in Stunden (kann negativ sein) + */ +function getCurrentOvertimeForUser(userId, db, callback) { + db.get( + 'SELECT wochenstunden, arbeitstage, overtime_offset_hours FROM users WHERE id = ?', + [userId], + (err, user) => { + if (err) { + return callback(err, null); + } + if (!user) { + return callback(null, null); + } + + const wochenstunden = user.wochenstunden || 0; + const arbeitstage = user.arbeitstage || 5; + const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0; + + db.all( + `SELECT DISTINCT week_start, week_end + FROM weekly_timesheets + WHERE user_id = ? AND status = 'eingereicht' + ORDER BY week_start`, + [userId], + (err, weeks) => { + if (err) { + return callback(err, null); + } + + if (!weeks || weeks.length === 0) { + return callback(null, overtimeOffsetHours); + } + + let totalOvertimeHours = 0; + let totalOvertimeTaken = 0; + let processedWeeks = 0; + let hasError = false; + let callbackDone = false; + + function done(value) { + if (callbackDone) return; + callbackDone = true; + callback(null, value); + } + + weeks.forEach((week) => { + db.all( + `SELECT id, date, total_hours, overtime_taken_hours, vacation_type, sick_status, start_time, end_time, updated_at + FROM timesheet_entries + WHERE user_id = ? AND date >= ? AND date <= ? + ORDER BY date, updated_at DESC, id DESC`, + [userId, week.week_start, week.week_end], + (err, allEntries) => { + if (hasError) return; + if (err) { + hasError = true; + if (!callbackDone) { + callbackDone = true; + callback(err, null); + } + return; + } + + const entriesByDate = {}; + (allEntries || []).forEach((entry) => { + const existing = entriesByDate[entry.date]; + if (!existing) { + entriesByDate[entry.date] = entry; + } else { + const existingTime = existing.updated_at ? new Date(existing.updated_at).getTime() : 0; + const currentTime = entry.updated_at ? new Date(entry.updated_at).getTime() : 0; + if (currentTime > existingTime || (currentTime === existingTime && entry.id > existing.id)) { + entriesByDate[entry.date] = entry; + } + } + }); + const entries = Object.values(entriesByDate); + + getHolidaysForDateRange(week.week_start, week.week_end) + .catch(() => new Set()) + .then((holidaySet) => { + const startDate = new Date(week.week_start); + const endDate = new Date(week.week_end); + let workdays = 0; + let filledWorkdays = 0; + + for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { + const day = d.getDay(); + if (day >= 1 && day <= 5) { + workdays++; + const dateStr = d.toISOString().split('T')[0]; + if (holidaySet.has(dateStr)) { + filledWorkdays++; + continue; + } + const entry = entriesByDate[dateStr]; + if (entry) { + const isFullDayVacation = entry.vacation_type === 'full'; + const isSick = entry.sick_status === 1 || entry.sick_status === true; + const hasStartAndEnd = + entry.start_time && + entry.end_time && + entry.start_time.toString().trim() !== '' && + entry.end_time.toString().trim() !== ''; + if (isFullDayVacation || isSick || hasStartAndEnd) { + filledWorkdays++; + } + } + } + } + + if (filledWorkdays < workdays) { + processedWeeks++; + if (processedWeeks === weeks.length && !hasError) { + done(totalOvertimeHours - totalOvertimeTaken + overtimeOffsetHours); + } + return; + } + + const fullDayHours = wochenstunden > 0 && arbeitstage > 0 ? wochenstunden / arbeitstage : 8; + let weekTotalHours = 0; + let weekOvertimeTaken = 0; + let weekVacationHours = 0; + let fullDayOvertimeDays = 0; + + entries.forEach((entry) => { + const overtimeValue = entry.overtime_taken_hours ? parseFloat(entry.overtime_taken_hours) : 0; + const isFullDayOvertime = overtimeValue > 0 && Math.abs(overtimeValue - fullDayHours) < 0.01; + + if (entry.overtime_taken_hours) { + weekOvertimeTaken += parseFloat(entry.overtime_taken_hours) || 0; + } + if (isFullDayOvertime) { + fullDayOvertimeDays++; + } + + if (entry.vacation_type === 'full') { + weekVacationHours += fullDayHours; + } else if (entry.vacation_type === 'half') { + weekVacationHours += fullDayHours / 2; + if (entry.total_hours && !isFullDayOvertime) { + weekTotalHours += parseFloat(entry.total_hours) || 0; + } + } else { + if (entry.total_hours && !isFullDayOvertime) { + weekTotalHours += parseFloat(entry.total_hours) || 0; + } + } + }); + + let holidayHours = 0; + for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { + const day = d.getDay(); + if (day >= 1 && day <= 5) { + const dateStr = d.toISOString().split('T')[0]; + if (holidaySet.has(dateStr)) holidayHours += fullDayHours; + } + } + + const sollStunden = (wochenstunden / arbeitstage) * workdays; + const weekTotalHoursWithVacation = weekTotalHours + weekVacationHours + holidayHours; + const adjustedSollStunden = sollStunden - fullDayOvertimeDays * fullDayHours; + const weekOvertimeHours = weekTotalHoursWithVacation - adjustedSollStunden; + + totalOvertimeHours += weekOvertimeHours; + totalOvertimeTaken += weekOvertimeTaken; + processedWeeks++; + + if (processedWeeks === weeks.length && !hasError) { + done(totalOvertimeHours - totalOvertimeTaken + overtimeOffsetHours); + } + }); + } + ); + }); + } + ); + } + ); +} + +module.exports = { + getCurrentOvertimeForUser +}; diff --git a/views/dashboard.ejs b/views/dashboard.ejs index e7acd08..3bc30ce 100644 --- a/views/dashboard.ejs +++ b/views/dashboard.ejs @@ -61,9 +61,17 @@
Stunden werden automatisch gespeichert. Am Ende der Woche können Sie die Stunden abschicken.