From 3edc0fe60cf3130ce857728bc4c7bef1e721f8f4 Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Thu, 12 Feb 2026 11:24:45 +0100 Subject: [PATCH] Overtime Corrections mit Historie und Grund --- database.js | 25 +++ doc/Stunderfassung todo.txt | 3 +- routes/user-routes.js | 361 +++++++++++++++++++---------------- routes/verwaltung-routes.js | 81 +++++++- views/overtime-breakdown.ejs | 73 +++++++ views/verwaltung.ejs | 330 +++++++++++++++++++++++++++----- 6 files changed, 648 insertions(+), 225 deletions(-) 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): - +
Lade Daten...
@@ -213,6 +222,35 @@ return date.toLocaleDateString('de-DE'); } + // SQLite-Datetime (YYYY-MM-DD HH:MM:SS) robust parsen + function parseSqliteDatetime(value) { + if (!value) return null; + const s = String(value); + if (s.includes('T')) return new Date(s); + // SQLite datetime('now') liefert UTC ohne "T" / "Z" + return new Date(s.replace(' ', 'T') + 'Z'); + } + + function formatHours(value) { + const n = Number(value); + if (!Number.isFinite(n)) return ''; + const sign = n > 0 ? '+' : ''; + let s = sign + n.toFixed(2); + s = s.replace(/\.00$/, ''); + s = s.replace(/(\.\d)0$/, '$1'); + return s; + } + + let correctionsExpanded = false; + function toggleCorrectionsSection() { + const content = document.getElementById('correctionsContent'); + const icon = document.getElementById('correctionsToggleIcon'); + if (!content || !icon) return; + correctionsExpanded = !correctionsExpanded; + content.style.display = correctionsExpanded ? 'block' : 'none'; + icon.style.transform = correctionsExpanded ? 'rotate(180deg)' : 'rotate(0deg)'; + } + // Überstunden-Daten laden async function loadOvertimeBreakdown() { const loadingEl = document.getElementById('loading'); @@ -273,6 +311,41 @@ } else { offsetItem.style.display = 'none'; } + + // Korrekturen durch die Verwaltung anzeigen (Collapsible, nur wenn vorhanden) + const correctionsSectionEl = document.getElementById('correctionsSection'); + const correctionsListEl = document.getElementById('correctionsList'); + const correctionsHeaderEl = document.getElementById('correctionsHeader'); + const correctionsContentEl = document.getElementById('correctionsContent'); + const correctionsIconEl = document.getElementById('correctionsToggleIcon'); + const corrections = Array.isArray(data.overtime_corrections) ? data.overtime_corrections : []; + + if (correctionsSectionEl && correctionsListEl && correctionsHeaderEl && correctionsContentEl && correctionsIconEl && corrections.length > 0) { + correctionsSectionEl.style.display = 'block'; + correctionsListEl.innerHTML = ''; + + corrections.forEach(c => { + const dt = parseSqliteDatetime(c.corrected_at); + const dateText = dt ? dt.toLocaleDateString('de-DE') : ''; + const hoursText = formatHours(c.correction_hours); + const reason = (c && c.reason != null) ? String(c.reason).trim() : ''; + const li = document.createElement('li'); + li.textContent = reason + ? `Korrektur am ${dateText} ${hoursText} h – ${reason}` + : `Korrektur am ${dateText} ${hoursText} h`; + correctionsListEl.appendChild(li); + }); + + // Standard: zugeklappt + correctionsExpanded = false; + correctionsContentEl.style.display = 'none'; + correctionsIconEl.style.transform = 'rotate(0deg)'; + + // Click-Handler setzen (idempotent) + correctionsHeaderEl.onclick = toggleCorrectionsSection; + } else if (correctionsSectionEl) { + correctionsSectionEl.style.display = 'none'; + } summaryBoxEl.style.display = 'block'; diff --git a/views/verwaltung.ejs b/views/verwaltung.ejs index 1ce579c..4465fce 100644 --- a/views/verwaltung.ejs +++ b/views/verwaltung.ejs @@ -94,23 +94,36 @@ Verbleibender Urlaub: - Tage
- Überstunden-Offset: + Überstunden-Korrektur: + title="Korrektur eingeben (z. B. +10 oder -20). Nach dem Speichern wird das Feld auf 0 gesetzt." /> + +
+
Urlaubstage-Offset: @@ -489,6 +502,192 @@ loadCurrentOvertime(); loadRemainingVacation(); + // Überstunden-Korrektur-Historie laden/anzeigen + function parseSqliteDatetime(value) { + if (!value) return null; + const s = String(value); + if (s.includes('T')) return new Date(s); + return new Date(s.replace(' ', 'T') + 'Z'); + } + + function formatHours(value) { + const n = Number(value); + if (!Number.isFinite(n)) return ''; + const sign = n > 0 ? '+' : ''; + let s = sign + n.toFixed(2); + s = s.replace(/\.00$/, ''); + s = s.replace(/(\.\d)0$/, '$1'); + return s; + } + + function showOvertimeCorrectionReasonModal(opts) { + const title = opts && opts.title ? String(opts.title) : 'Grund für die Korrektur'; + const promptText = opts && opts.prompt ? String(opts.prompt) : 'Bitte geben Sie einen Grund an, warum die Korrektur vorgenommen wird.'; + const onSubmit = opts && typeof opts.onSubmit === 'function' ? opts.onSubmit : null; + const onCancel = opts && typeof opts.onCancel === 'function' ? opts.onCancel : null; + + const modal = document.createElement('div'); + modal.style.cssText = ` + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; + `; + + const box = document.createElement('div'); + box.style.cssText = ` + background: #fff; + border-radius: 8px; + width: 90%; + max-width: 520px; + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + padding: 18px 18px 14px 18px; + `; + + box.innerHTML = ` +
+

${title}

+ +
+
${promptText}
+ +
+
+ + +
+ `; + + modal.appendChild(box); + document.body.appendChild(modal); + + const textarea = box.querySelector('textarea[data-role="reason"]'); + const errorEl = box.querySelector('[data-role="error"]'); + + function close() { + document.body.removeChild(modal); + } + + function cancel() { + try { + if (onCancel) onCancel(); + } finally { + close(); + } + } + + function setError(msg) { + if (!errorEl) return; + errorEl.textContent = msg; + errorEl.style.display = msg ? 'block' : 'none'; + } + + async function submit() { + const reason = textarea ? textarea.value.trim() : ''; + if (!reason) { + setError('Bitte Grund angeben.'); + if (textarea) textarea.focus(); + return; + } + setError(''); + if (onSubmit) { + await onSubmit(reason); + } + close(); + } + + modal.addEventListener('click', (e) => { + if (e.target === modal) cancel(); + }); + box.querySelectorAll('button[data-action="close"], button[data-action="cancel"]').forEach(btn => { + btn.addEventListener('click', cancel); + }); + const submitBtn = box.querySelector('button[data-action="submit"]'); + if (submitBtn) submitBtn.addEventListener('click', submit); + + if (textarea) { + textarea.focus(); + textarea.addEventListener('keydown', (e) => { + if (e.key === 'Escape') cancel(); + if ((e.key === 'Enter' && (e.ctrlKey || e.metaKey))) { + e.preventDefault(); + submit(); + } + }); + } + } + + async function loadOvertimeCorrectionsForUser(userId) { + const container = document.querySelector(`.overtime-corrections-container[data-user-id="${userId}"]`); + if (!container) return; + + const loadingEl = container.querySelector('.overtime-corrections-loading'); + const emptyEl = container.querySelector('.overtime-corrections-empty'); + const listEl = container.querySelector('.overtime-corrections-list'); + + if (loadingEl) loadingEl.style.display = 'block'; + if (emptyEl) emptyEl.style.display = 'none'; + if (listEl) listEl.innerHTML = ''; + + try { + const resp = await fetch(`/api/verwaltung/user/${userId}/overtime-corrections`); + const data = await resp.json().catch(() => ({})); + const corrections = Array.isArray(data.corrections) ? data.corrections : []; + + if (!resp.ok) { + throw new Error(data.error || 'Fehler beim Laden der Korrekturen'); + } + + if (corrections.length === 0) { + if (emptyEl) emptyEl.style.display = 'block'; + return; + } + + corrections.forEach(c => { + const dt = parseSqliteDatetime(c.corrected_at); + const dateText = dt ? dt.toLocaleDateString('de-DE') : ''; + const hoursText = formatHours(c.correction_hours); + const reason = (c && c.reason != null) ? String(c.reason).trim() : ''; + const li = document.createElement('li'); + li.textContent = reason + ? `Korrektur am ${dateText} ${hoursText} h – ${reason}` + : `Korrektur am ${dateText} ${hoursText} h`; + if (listEl) listEl.appendChild(li); + }); + } catch (e) { + console.error('Fehler beim Laden der Überstunden-Korrekturen:', e); + if (emptyEl) { + emptyEl.textContent = 'Fehler beim Laden der Korrekturen.'; + emptyEl.style.display = 'block'; + } + } finally { + if (loadingEl) loadingEl.style.display = 'none'; + } + } + + document.querySelectorAll('.toggle-overtime-corrections-btn').forEach(btn => { + btn.addEventListener('click', async function() { + const userId = this.dataset.userId; + const container = document.querySelector(`.overtime-corrections-container[data-user-id="${userId}"]`); + if (!container) return; + + const isOpen = container.style.display !== 'none' && container.style.display !== ''; + if (isOpen) { + container.style.display = 'none'; + return; + } + + container.style.display = 'block'; + + // Beim Öffnen immer neu laden (damit neue Korrekturen sofort sichtbar sind) + await loadOvertimeCorrectionsForUser(userId); + container.dataset.loaded = 'true'; + }); + }); + // Überstunden-Offset speichern document.querySelectorAll('.save-overtime-offset-btn').forEach(btn => { btn.addEventListener('click', async function() { @@ -496,59 +695,90 @@ const input = document.querySelector(`.overtime-offset-input[data-user-id="${userId}"]`); if (!input) return; + if (this.dataset.modalOpen === 'true') return; + const originalText = this.textContent; - this.disabled = true; - this.textContent = '...'; // leere Eingabe => 0 (Backend macht das auch, aber UI soll sauber sein) const raw = (input.value || '').trim(); const value = raw === '' ? '' : Number(raw); - try { - const resp = await fetch(`/api/verwaltung/user/${userId}/overtime-offset`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ overtime_offset_hours: value }) - }); - const data = await resp.json(); - if (!resp.ok) { - alert(data.error || 'Fehler beim Speichern des Offsets'); - return; - } - - // Normalisiere Input auf Zahl (Backend gibt number zurück) - input.value = (data.overtime_offset_hours !== undefined && data.overtime_offset_hours !== null) - ? Number(data.overtime_offset_hours) - : 0; - - // Stats für diesen User neu laden - const statDivs = document.querySelectorAll(`.group-stats[data-user-id="${userId}"]`); - statDivs.forEach(div => { - // loading indicator optional wieder anzeigen - const loading = div.querySelector('.stats-loading'); - if (loading) { - loading.style.display = 'inline-block'; - loading.style.color = '#666'; - loading.textContent = 'Lade Statistiken...'; - } - loadStatsForDiv(div); - }); - loadCurrentOvertime(); - - this.textContent = '✓'; - setTimeout(() => { - this.textContent = originalText; - this.disabled = false; - }, 900); - } catch (e) { - console.error('Fehler beim Speichern des Offsets:', e); - alert('Fehler beim Speichern des Offsets'); - } finally { - if (this.textContent === '...') { - this.textContent = originalText; - this.disabled = false; - } + // Wenn keine Korrektur (0), nichts tun außer UI auf 0 zu normalisieren + if (value === 0) { + input.value = 0; + this.textContent = originalText; + this.disabled = false; + return; } + + this.dataset.modalOpen = 'true'; + this.disabled = true; + + // Modal: Grund ist Pflicht + showOvertimeCorrectionReasonModal({ + title: 'Grund für die Überstunden-Korrektur', + prompt: `Korrektur: ${value > 0 ? '+' : ''}${value} h`, + onCancel: () => { + delete this.dataset.modalOpen; + this.disabled = false; + }, + onSubmit: async (reason) => { + this.textContent = '...'; + try { + const resp = await fetch(`/api/verwaltung/user/${userId}/overtime-offset`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ overtime_offset_hours: value, reason }) + }); + const data = await resp.json(); + if (!resp.ok) { + alert(data.error || 'Fehler beim Speichern der Korrektur'); + this.textContent = originalText; + this.disabled = false; + delete this.dataset.modalOpen; + return; + } + + // Normalisiere Input auf Zahl (Backend gibt number zurück) + input.value = (data.overtime_offset_hours !== undefined && data.overtime_offset_hours !== null) + ? Number(data.overtime_offset_hours) + : 0; + + // Stats für diesen User neu laden + const statDivs = document.querySelectorAll(`.group-stats[data-user-id="${userId}"]`); + statDivs.forEach(div => { + // loading indicator optional wieder anzeigen + const loading = div.querySelector('.stats-loading'); + if (loading) { + loading.style.display = 'inline-block'; + loading.style.color = '#666'; + loading.textContent = 'Lade Statistiken...'; + } + loadStatsForDiv(div); + }); + loadCurrentOvertime(); + + // Historie (falls geöffnet) aktualisieren + const correctionsContainer = document.querySelector(`.overtime-corrections-container[data-user-id="${userId}"]`); + if (correctionsContainer && correctionsContainer.style.display !== 'none') { + await loadOvertimeCorrectionsForUser(userId); + } + + this.textContent = '✓'; + setTimeout(() => { + this.textContent = originalText; + this.disabled = false; + delete this.dataset.modalOpen; + }, 900); + } catch (e) { + console.error('Fehler beim Speichern der Korrektur:', e); + alert('Fehler beim Speichern der Korrektur'); + this.textContent = originalText; + this.disabled = false; + delete this.dataset.modalOpen; + } + } + }); }); });