diff --git a/database.js b/database.js index d6324a0..0d7ad7c 100644 --- a/database.js +++ b/database.js @@ -215,6 +215,31 @@ function initDatabase() { } }); + // Tabelle: Protokoll für Überstunden-Korrekturen durch Verwaltung + db.run(`CREATE TABLE IF NOT EXISTS overtime_corrections ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + correction_hours REAL NOT NULL, + reason TEXT, + corrected_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) + )`, (err) => { + if (err) { + console.warn('Warnung beim Erstellen der overtime_corrections Tabelle:', err.message); + } + }); + + // Migration: reason Spalte für overtime_corrections hinzufügen (falls Tabelle bereits existiert) + db.run(`ALTER TABLE overtime_corrections ADD COLUMN reason TEXT`, (err) => { + // Fehler ignorieren wenn Spalte bereits existiert + if (err && !err.message.includes('duplicate column')) { + // "duplicate column" ist SQLite CLI wording; sqlite3 node liefert typischerweise "duplicate column name" + if (!err.message.includes('duplicate column name')) { + console.warn('Warnung beim Hinzufügen der Spalte reason (overtime_corrections):', err.message); + } + } + }); + // Migration: Urlaubstage-Offset (manuelle Korrektur durch Verwaltung) db.run(`ALTER TABLE users ADD COLUMN vacation_offset_days REAL DEFAULT 0`, (err) => { // Fehler ignorieren wenn Spalte bereits existiert diff --git a/doc/Stunderfassung todo.txt b/doc/Stunderfassung todo.txt index bc6d3bd..7a95d91 100644 --- a/doc/Stunderfassung todo.txt +++ b/doc/Stunderfassung todo.txt @@ -14,4 +14,5 @@ - Oben wenn woche eingereicht anzeigen als hilfestellung -> DONE - Ausgefüllte Tage anhand der Tage pro woche gültig setzten -> DONE - Überstunden müssen anhand der Tagesstunden auch auf gültig setzten (Tag ausgefüllt wenn weniger als 8h) -> DONE sollte passen -- Verplante Urlaubstage müssen auf abgezogen werden, wenn die Woche die gepalnt war eingereicht wurde. -> DONE \ No newline at end of file +- Verplante Urlaubstage müssen auf abgezogen werden, wenn die Woche die gepalnt war eingereicht wurde. -> DONE +- Grund für Überstundenkorrektur -> DONE \ No newline at end of file diff --git a/routes/user-routes.js b/routes/user-routes.js index 2fa7fd7..398d7af 100644 --- a/routes/user-routes.js +++ b/routes/user-routes.js @@ -587,194 +587,219 @@ function registerUserRoutes(app) { const arbeitstage = user.arbeitstage || 5; const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0; - // Alle eingereichten Wochen abrufen - db.all(`SELECT DISTINCT week_start, week_end - FROM weekly_timesheets - WHERE user_id = ? AND status = 'eingereicht' - ORDER BY week_start DESC`, + // Korrekturen durch die Verwaltung (Historie) laden + db.all( + `SELECT correction_hours, corrected_at, reason + FROM overtime_corrections + WHERE user_id = ? + ORDER BY corrected_at DESC`, [userId], - (err, weeks) => { - if (err) { - return res.status(500).json({ error: 'Fehler beim Abrufen der Wochen' }); - } + (correctionsErr, corrections) => { + // Falls Tabelle noch nicht existiert (z. B. alte DB), nicht hart fehlschlagen + const overtimeCorrections = correctionsErr ? [] : (corrections || []); - // Wenn keine Wochen vorhanden - if (!weeks || weeks.length === 0) { - return res.json({ weeks: [] }); - } + // Alle eingereichten Wochen abrufen + db.all(`SELECT DISTINCT week_start, week_end + FROM weekly_timesheets + WHERE user_id = ? AND status = 'eingereicht' + ORDER BY week_start DESC`, + [userId], + (err, weeks) => { + if (err) { + return res.status(500).json({ error: 'Fehler beim Abrufen der Wochen' }); + } - const { getCalendarWeek } = require('../helpers/utils'); - const weekData = []; - let processedWeeks = 0; - let hasError = false; - - // Für jede Woche die Statistiken berechnen - weeks.forEach((week) => { - // Einträge für diese Woche abrufen (nur neueste pro Tag) - 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; - return res.status(500).json({ error: 'Fehler beim Abrufen der Einträge' }); - } - - // Filtere auf neuesten Eintrag pro Tag - 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; - } - } + // Wenn keine Wochen vorhanden + if (!weeks || weeks.length === 0) { + return res.json({ + weeks: [], + overtime_offset_hours: overtimeOffsetHours, + overtime_corrections: overtimeCorrections }); + } - // Konvertiere zurück zu Array - const entries = Object.values(entriesByDate); + const { getCalendarWeek } = require('../helpers/utils'); + const weekData = []; + let processedWeeks = 0; + let hasError = false; - // Feiertage für die Woche laden - getHolidaysForDateRange(week.week_start, week.week_end) - .catch(() => new Set()) - .then((holidaySet) => { - // Prüfe alle 5 Werktage (Montag-Freitag) - const startDate = new Date(week.week_start); - const endDate = new Date(week.week_end); - let workdays = 0; - let filledWorkdays = 0; + // Für jede Woche die Statistiken berechnen + weeks.forEach((week) => { + // Einträge für diese Woche abrufen (nur neueste pro Tag) + 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; - for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { - const day = d.getDay(); - if (day >= 1 && day <= 5) { // Montag bis Freitag - workdays++; - const dateStr = d.toISOString().split('T')[0]; - if (holidaySet.has(dateStr)) { - filledWorkdays++; - continue; + if (err) { + hasError = true; + return res.status(500).json({ error: 'Fehler beim Abrufen der Einträge' }); + } + + // Filtere auf neuesten Eintrag pro Tag + 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 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() !== ''; + // Konvertiere zurück zu Array + const entries = Object.values(entriesByDate); - if (isFullDayVacation || isSick || hasStartAndEnd) { - filledWorkdays++; + // Feiertage für die Woche laden + getHolidaysForDateRange(week.week_start, week.week_end) + .catch(() => new Set()) + .then((holidaySet) => { + // Prüfe alle 5 Werktage (Montag-Freitag) + 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) { // Montag bis Freitag + 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++; + } + } } } - } - } - // Nur berechnen wenn alle Werktage ausgefüllt sind - if (filledWorkdays < workdays) { - processedWeeks++; - if (processedWeeks === weeks.length && !hasError) { - res.json({ weeks: weekData }); - } - return; - } - - // Berechnungen für diese Woche - let weekTotalHours = 0; - let weekOvertimeTaken = 0; - let weekVacationDays = 0; - let weekVacationHours = 0; - - const fullDayHours = wochenstunden > 0 && arbeitstage > 0 ? wochenstunden / arbeitstage : 8; - 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') { - weekVacationDays += 1; - weekVacationHours += fullDayHours; - } else if (entry.vacation_type === 'half') { - weekVacationDays += 0.5; - weekVacationHours += fullDayHours / 2; - // WICHTIG: total_hours enthält bereits Wochenend-Prozentsätze (aus timesheet.js) - if (entry.total_hours && !isFullDayOvertime) { - weekTotalHours += parseFloat(entry.total_hours) || 0; + // Nur berechnen wenn alle Werktage ausgefüllt sind + if (filledWorkdays < workdays) { + processedWeeks++; + if (processedWeeks === weeks.length && !hasError) { + res.json({ + weeks: weekData, + overtime_offset_hours: overtimeOffsetHours, + overtime_corrections: overtimeCorrections + }); + } + return; } - } else { - // WICHTIG: total_hours enthält bereits Wochenend-Prozentsätze (aus timesheet.js) - if (entry.total_hours && !isFullDayOvertime) { - weekTotalHours += parseFloat(entry.total_hours) || 0; + + // Berechnungen für diese Woche + let weekTotalHours = 0; + let weekOvertimeTaken = 0; + let weekVacationDays = 0; + let weekVacationHours = 0; + + const fullDayHours = wochenstunden > 0 && arbeitstage > 0 ? wochenstunden / arbeitstage : 8; + 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') { + weekVacationDays += 1; + weekVacationHours += fullDayHours; + } else if (entry.vacation_type === 'half') { + weekVacationDays += 0.5; + weekVacationHours += fullDayHours / 2; + // WICHTIG: total_hours enthält bereits Wochenend-Prozentsätze (aus timesheet.js) + if (entry.total_hours && !isFullDayOvertime) { + weekTotalHours += parseFloat(entry.total_hours) || 0; + } + } else { + // WICHTIG: total_hours enthält bereits Wochenend-Prozentsätze (aus timesheet.js) + if (entry.total_hours && !isFullDayOvertime) { + weekTotalHours += parseFloat(entry.total_hours) || 0; + } + } + }); + + // Feiertagsstunden: (Wochenarbeitszeit / Arbeitstage) pro Werktag der ein Feiertag ist + 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; + } } - } - }); - // Feiertagsstunden: (Wochenarbeitszeit / Arbeitstage) pro Werktag der ein Feiertag ist - 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; - } - } + // Sollstunden berechnen + const sollStunden = (wochenstunden / arbeitstage) * workdays; + const weekTotalHoursWithVacation = weekTotalHours + weekVacationHours + holidayHours; + const adjustedSollStunden = sollStunden - (fullDayOvertimeDays * fullDayHours); + const weekOvertimeHours = weekTotalHoursWithVacation - adjustedSollStunden; - // Sollstunden berechnen - const sollStunden = (wochenstunden / arbeitstage) * workdays; - const weekTotalHoursWithVacation = weekTotalHours + weekVacationHours + holidayHours; - const adjustedSollStunden = sollStunden - (fullDayOvertimeDays * fullDayHours); - const weekOvertimeHours = weekTotalHoursWithVacation - adjustedSollStunden; + // Kalenderwoche berechnen + const calendarWeek = getCalendarWeek(week.week_start); + const year = new Date(week.week_start).getFullYear(); - // Kalenderwoche berechnen - const calendarWeek = getCalendarWeek(week.week_start); - const year = new Date(week.week_start).getFullYear(); + // Wochen-Daten hinzufügen + weekData.push({ + week_start: week.week_start, + week_end: week.week_end, + calendar_week: calendarWeek, + year: year, + overtime_hours: parseFloat(weekOvertimeHours.toFixed(2)), + overtime_taken: parseFloat(weekOvertimeTaken.toFixed(2)), + total_hours: parseFloat(weekTotalHoursWithVacation.toFixed(2)), + soll_stunden: parseFloat(adjustedSollStunden.toFixed(2)), + vacation_days: parseFloat(weekVacationDays.toFixed(1)), + workdays: workdays + }); - // Wochen-Daten hinzufügen - weekData.push({ - week_start: week.week_start, - week_end: week.week_end, - calendar_week: calendarWeek, - year: year, - overtime_hours: parseFloat(weekOvertimeHours.toFixed(2)), - overtime_taken: parseFloat(weekOvertimeTaken.toFixed(2)), - total_hours: parseFloat(weekTotalHoursWithVacation.toFixed(2)), - soll_stunden: parseFloat(adjustedSollStunden.toFixed(2)), - vacation_days: parseFloat(weekVacationDays.toFixed(1)), - workdays: workdays - }); + processedWeeks++; - processedWeeks++; - - // Wenn alle Wochen verarbeitet wurden, Antwort senden - if (processedWeeks === weeks.length && !hasError) { - // Sortiere nach Datum (neueste zuerst) - weekData.sort((a, b) => { - if (a.year !== b.year) return b.year - a.year; - if (a.calendar_week !== b.calendar_week) return b.calendar_week - a.calendar_week; - return new Date(b.week_start) - new Date(a.week_start); - }); - res.json({ weeks: weekData, overtime_offset_hours: overtimeOffsetHours }); - } - }); // getHolidaysForDateRange.then - }); // db.all (allEntries) - }); // weeks.forEach - }); // db.all (weeks) + // Wenn alle Wochen verarbeitet wurden, Antwort senden + if (processedWeeks === weeks.length && !hasError) { + // Sortiere nach Datum (neueste zuerst) + weekData.sort((a, b) => { + if (a.year !== b.year) return b.year - a.year; + if (a.calendar_week !== b.calendar_week) return b.calendar_week - a.calendar_week; + return new Date(b.week_start) - new Date(a.week_start); + }); + res.json({ + weeks: weekData, + overtime_offset_hours: overtimeOffsetHours, + overtime_corrections: overtimeCorrections + }); + } + }); // getHolidaysForDateRange.then + }); // db.all (allEntries) + }); // weeks.forEach + }); // db.all (weeks) + } + ); }); // db.get (user) }); // db.get (options) }); // app.get diff --git a/routes/verwaltung-routes.js b/routes/verwaltung-routes.js index 1f9278f..cc7111e 100644 --- a/routes/verwaltung-routes.js +++ b/routes/verwaltung-routes.js @@ -132,6 +132,8 @@ function registerVerwaltungRoutes(app) { app.put('/api/verwaltung/user/:id/overtime-offset', requireVerwaltung, (req, res) => { const userId = req.params.id; const raw = req.body ? req.body.overtime_offset_hours : undefined; + const reasonRaw = req.body ? req.body.reason : undefined; + const reason = (reasonRaw === null || reasonRaw === undefined) ? '' : String(reasonRaw).trim(); // Leere Eingabe => 0 const normalized = (raw === '' || raw === null || raw === undefined) ? 0 : parseFloat(raw); @@ -139,12 +141,59 @@ function registerVerwaltungRoutes(app) { return res.status(400).json({ error: 'Ungültiger Überstunden-Offset' }); } - db.run('UPDATE users SET overtime_offset_hours = ? WHERE id = ?', [normalized, userId], (err) => { - if (err) { - console.error('Fehler beim Speichern des Überstunden-Offsets:', err); - return res.status(500).json({ error: 'Fehler beim Speichern des Überstunden-Offsets' }); - } - res.json({ success: true, overtime_offset_hours: normalized }); + // Neue Logik: Korrektur protokollieren + kumulativ addieren + // Feld in der Verwaltung soll nach dem Speichern immer auf 0 zurückgesetzt werden. + if (normalized === 0) { + return res.json({ success: true, overtime_offset_hours: 0 }); + } + + if (!reason) { + return res.status(400).json({ error: 'Bitte geben Sie einen Grund für die Korrektur an.' }); + } + + db.serialize(() => { + db.run('BEGIN TRANSACTION'); + + db.run( + `INSERT INTO overtime_corrections (user_id, correction_hours, reason, corrected_at) + VALUES (?, ?, ?, datetime('now'))`, + [userId, normalized, reason], + (err) => { + if (err) { + console.error('Fehler beim Speichern der Überstunden-Korrektur:', err); + db.run('ROLLBACK', () => { + return res.status(500).json({ error: 'Fehler beim Speichern der Überstunden-Korrektur' }); + }); + return; + } + + db.run( + 'UPDATE users SET overtime_offset_hours = COALESCE(overtime_offset_hours, 0) + ? WHERE id = ?', + [normalized, userId], + (err) => { + if (err) { + console.error('Fehler beim Aktualisieren des Überstunden-Offsets:', err); + db.run('ROLLBACK', () => { + return res.status(500).json({ error: 'Fehler beim Speichern des Überstunden-Offsets' }); + }); + return; + } + + db.run('COMMIT', (err) => { + if (err) { + console.error('Fehler beim Commit der Überstunden-Korrektur:', err); + db.run('ROLLBACK', () => { + return res.status(500).json({ error: 'Fehler beim Speichern der Überstunden-Korrektur' }); + }); + return; + } + + res.json({ success: true, overtime_offset_hours: 0 }); + }); + } + ); + } + ); }); }); @@ -168,6 +217,26 @@ function registerVerwaltungRoutes(app) { }); }); + // API: Überstunden-Korrektur-Historie für einen User abrufen + app.get('/api/verwaltung/user/:id/overtime-corrections', requireVerwaltung, (req, res) => { + const userId = req.params.id; + + db.all( + `SELECT correction_hours, corrected_at, reason + FROM overtime_corrections + WHERE user_id = ? + ORDER BY corrected_at DESC`, + [userId], + (err, rows) => { + // Falls Tabelle noch nicht existiert (z. B. alte DB), nicht hart fehlschlagen + if (err) { + return res.json({ corrections: [] }); + } + res.json({ corrections: rows || [] }); + } + ); + }); + // API: Krankheitstage für einen User im aktuellen Jahr abrufen app.get('/api/verwaltung/user/:id/sick-days', requireVerwaltung, (req, res) => { const userId = req.params.id; diff --git a/views/overtime-breakdown.ejs b/views/overtime-breakdown.ejs index 3146fec..36aef5f 100644 --- a/views/overtime-breakdown.ejs +++ b/views/overtime-breakdown.ejs @@ -153,6 +153,15 @@ Manuelle Korrektur (Verwaltung): - +