From ca48cdf78f8e59776a09fa00a2c65a46e5d782f2 Mon Sep 17 00:00:00 2001 From: Carsten Graf Date: Mon, 26 Jan 2026 17:58:12 +0100 Subject: [PATCH] BROKEN --- ldap-service.js | 71 ++++++++--- public/css/style.css | 30 +++++ public/js/dashboard.js | 262 +++++++++++++++++++++++++++++++++++------ reset-db.js | 256 +++++++++++++++++++++++++++++++++++++--- routes/auth.js | 9 +- routes/timesheet.js | 157 +++++++++++++----------- routes/user.js | 179 ++++++++++++++++++++++++---- server.js | 2 + views/dashboard.ejs | 183 +++++++++++++++++++++++++++- 9 files changed, 971 insertions(+), 178 deletions(-) diff --git a/ldap-service.js b/ldap-service.js index 5826fec..48a434a 100644 --- a/ldap-service.js +++ b/ldap-service.js @@ -104,26 +104,41 @@ class LDAPService { /** * Wert eines LDAP-Attributs extrahieren + * + * Die ldapjs-Bibliothek behandelt UTF-8-Zeichen automatisch korrekt. + * Diese Funktion stellt sicher, dass UTF-8-Zeichen wie ß, ä, ö, ü korrekt zurückgegeben werden. */ static getAttributeValue(entry, attributeName) { const attr = entry.attributes.find(a => a.type === attributeName); if (!attr) { return null; } - return Array.isArray(attr.values) ? attr.values[0] : attr.values; + const value = Array.isArray(attr.values) ? attr.values[0] : attr.values; + // Stelle sicher, dass der Wert als String zurückgegeben wird (UTF-8 wird automatisch korrekt behandelt) + return value != null ? String(value) : null; } /** * Escaped einen Wert für LDAP-Filter (verhindert LDAP-Injection) + * + * WICHTIG: UTF-8-Zeichen wie ß, ä, ö, ü müssen NICHT escaped werden. + * LDAP-Filter unterstützen UTF-8 direkt nach RFC 4515. + * Nur die speziellen LDAP-Filter-Zeichen werden escaped. */ static escapeLDAPFilter(value) { if (!value) return ''; - return value - .replace(/\\/g, '\\5c') - .replace(/\*/g, '\\2a') - .replace(/\(/g, '\\28') - .replace(/\)/g, '\\29') - .replace(/\0/g, '\\00'); + + // Stelle sicher, dass der Wert als String behandelt wird + const str = String(value); + + // Escape nur die speziellen LDAP-Filter-Zeichen + // UTF-8-Zeichen wie ß, ä, ö, ü werden direkt verwendet + return str + .replace(/\\/g, '\\5c') // Backslash + .replace(/\*/g, '\\2a') // Stern + .replace(/\(/g, '\\28') // Öffnende Klammer + .replace(/\)/g, '\\29') // Schließende Klammer + .replace(/\0/g, '\\00'); // Null-Byte } /** @@ -145,12 +160,14 @@ class LDAPService { } const ldapUser = ldapUsers[index]; - const username = ldapUser.username.trim(); - const firstname = ldapUser.firstname.trim(); - const lastname = ldapUser.lastname.trim(); + // .trim() behält UTF-8-Zeichen wie ß, ä, ö, ü korrekt bei + // Stelle sicher, dass Werte als String behandelt werden + const username = String(ldapUser.username || '').trim(); + const firstname = String(ldapUser.firstname || '').trim(); + const lastname = String(ldapUser.lastname || '').trim(); - // Prüfe ob Benutzer bereits existiert - db.get('SELECT id, role FROM users WHERE username = ?', [username], (err, existingUser) => { + // Prüfe ob Benutzer bereits existiert (case-insensitive) + db.get('SELECT id, role FROM users WHERE username = ? COLLATE NOCASE', [username], (err, existingUser) => { if (err) { errors.push(`Fehler beim Prüfen von ${username}: ${err.message}`); errorCount++; @@ -158,9 +175,9 @@ class LDAPService { } if (existingUser) { - // Benutzer existiert - aktualisiere nur Name, behalte Rolle + // Benutzer existiert - aktualisiere nur Name, behalte Rolle (case-insensitive) db.run( - 'UPDATE users SET firstname = ?, lastname = ? WHERE username = ?', + 'UPDATE users SET firstname = ?, lastname = ? WHERE username = ? COLLATE NOCASE', [firstname, lastname, username], (err) => { if (err) { @@ -232,8 +249,18 @@ class LDAPService { /** * Benutzer gegen LDAP authentifizieren + * + * Unterstützt UTF-8-Zeichen wie ß, ä, ö, ü in Usernamen. + * Die ldapjs-Bibliothek behandelt UTF-8 automatisch korrekt. */ static authenticate(username, password, callback) { + // Stelle sicher, dass Username als String behandelt wird (UTF-8 wird korrekt unterstützt) + const usernameStr = String(username || '').trim(); + + if (!usernameStr) { + return callback(new Error('Benutzername darf nicht leer sein'), false); + } + // Konfiguration abrufen this.getConfig((err, config) => { if (err || !config || !config.enabled) { @@ -249,7 +276,8 @@ class LDAPService { // Suche nach dem Benutzer in LDAP const baseDN = config.base_dn || ''; const usernameAttr = config.username_attribute || 'cn'; - const escapedUsername = this.escapeLDAPFilter(username); + // escapeLDAPFilter behandelt UTF-8-Zeichen korrekt (escaped sie nicht) + const escapedUsername = this.escapeLDAPFilter(usernameStr); const searchFilter = `(${usernameAttr}=${escapedUsername})`; const searchOptions = { filter: searchFilter, @@ -262,7 +290,9 @@ class LDAPService { client.search(baseDN, searchOptions, (err, res) => { if (err) { client.unbind(); - return callback(err, false); + // Verbesserte Fehlermeldung für mögliche Encoding-Probleme + const errorMsg = err.message || String(err); + return callback(new Error(`LDAP-Suche fehlgeschlagen: ${errorMsg}. Hinweis: Prüfen Sie, ob der Benutzername UTF-8-Zeichen (wie ß, ä, ö, ü) korrekt enthält.`), false); } res.on('searchEntry', (entry) => { @@ -271,7 +301,8 @@ class LDAPService { res.on('error', (err) => { client.unbind(); - callback(err, false); + const errorMsg = err.message || String(err); + callback(new Error(`LDAP-Suchfehler: ${errorMsg}`), false); }); res.on('end', (result) => { @@ -279,7 +310,8 @@ class LDAPService { client.unbind(); if (!userDN) { - return callback(new Error('Benutzer nicht gefunden'), false); + // Verbesserte Fehlermeldung: Hinweis auf mögliche Encoding-Probleme + return callback(new Error(`Benutzer "${usernameStr}" nicht gefunden. Hinweis: Prüfen Sie, ob der Benutzername korrekt ist und UTF-8-Zeichen (wie ß, ä, ö, ü) korrekt geschrieben sind.`), false); } // Versuche, sich mit den Benutzer-Credentials zu binden @@ -297,7 +329,8 @@ class LDAPService { authClient.bind(userDN, password, (err) => { authClient.unbind(); if (err) { - return callback(new Error('Ungültiges Passwort'), false); + const errorMsg = err.message || String(err); + return callback(new Error(`Ungültiges Passwort oder Authentifizierungsfehler: ${errorMsg}`), false); } callback(null, true); }); diff --git a/public/css/style.css b/public/css/style.css index 670fd94..9317247 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -73,6 +73,19 @@ body { transition: background-color 0.3s; } +.btn:disabled, +.btn[disabled] { + background-color: #95a5a6; + color: #ecf0f1; + cursor: not-allowed; + opacity: 0.6; +} + +.btn:disabled:hover, +.btn[disabled]:hover { + background-color: #95a5a6; +} + .btn-primary { background-color: #3498db; color: white; @@ -100,6 +113,19 @@ body { background-color: #229954; } +.btn-success:disabled, +.btn-success[disabled] { + background-color: #95a5a6; + color: #ecf0f1; + cursor: not-allowed; + opacity: 0.6; +} + +.btn-success:disabled:hover, +.btn-success[disabled]:hover { + background-color: #95a5a6; +} + .btn-danger { background-color: #e74c3c; color: white; @@ -322,6 +348,10 @@ body { border-left-color: #27ae60; } +.stat-card.stat-planned { + border-left-color: #f39c12; +} + .stat-label { font-size: 12px; color: #666; diff --git a/public/js/dashboard.js b/public/js/dashboard.js index 8e42d63..f686408 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -36,6 +36,28 @@ async function loadUserStats() { if (totalVacationEl) { totalVacationEl.textContent = (stats.urlaubstage || 0).toFixed(1); } + + // Verplante Urlaubstage anzeigen + const plannedVacationEl = document.getElementById('plannedVacation'); + if (plannedVacationEl) { + plannedVacationEl.textContent = (stats.plannedVacationDays || 0).toFixed(1); + } + + // Kalenderwochen anzeigen + const plannedWeeksEl = document.getElementById('plannedWeeks'); + if (plannedWeeksEl) { + if (stats.plannedWeeks && stats.plannedWeeks.length > 0) { + const weeksHTML = stats.plannedWeeks.map(w => { + const daysText = w.days === 1 ? '1 Tag' : `${w.days.toFixed(1)} Tage`; + return `${w.year} KW${String(w.week).padStart(2, '0')} (${daysText})`; + }).join('
'); + plannedWeeksEl.innerHTML = weeksHTML; + plannedWeeksEl.style.display = 'block'; + } else { + plannedWeeksEl.textContent = ''; + plannedWeeksEl.style.display = 'none'; + } + } } catch (error) { console.error('Fehler beim Laden der Statistiken:', error); // Fehlerbehandlung: Zeige "-" oder "Fehler" @@ -45,6 +67,13 @@ async function loadUserStats() { if (remainingVacationEl) remainingVacationEl.textContent = '-'; const totalVacationEl = document.getElementById('totalVacation'); if (totalVacationEl) totalVacationEl.textContent = '-'; + const plannedVacationEl = document.getElementById('plannedVacation'); + if (plannedVacationEl) plannedVacationEl.textContent = '-'; + const plannedWeeksEl = document.getElementById('plannedWeeks'); + if (plannedWeeksEl) { + plannedWeeksEl.textContent = ''; + plannedWeeksEl.style.display = 'none'; + } } } @@ -55,11 +84,27 @@ document.addEventListener('DOMContentLoaded', async function() { const response = await fetch('/api/user/last-week'); const data = await response.json(); if (data.last_week_start) { - currentWeekStart = data.last_week_start; + // Prüfe ob last_week_start wirklich ein Montag ist + if (isMonday(data.last_week_start)) { + currentWeekStart = data.last_week_start; + } else { + // Korrigiere zu Montag falls nicht + console.warn('last_week_start war kein Montag, korrigiere:', data.last_week_start); + currentWeekStart = getMonday(data.last_week_start); + // Speichere die korrigierte Woche + saveLastWeek(); + } } } catch (error) { console.warn('Konnte letzte Woche nicht vom Server laden:', error); } + + // Stelle sicher, dass currentWeekStart immer ein Montag ist + if (currentWeekStart && !isMonday(currentWeekStart)) { + console.warn('currentWeekStart war kein Montag, korrigiere:', currentWeekStart); + currentWeekStart = getMonday(currentWeekStart); + saveLastWeek(); + } // Ping-IP laden loadPingIP(); @@ -70,17 +115,23 @@ document.addEventListener('DOMContentLoaded', async function() { loadWeek(); document.getElementById('prevWeek').addEventListener('click', function() { - const date = new Date(currentWeekStart); + // Parse als lokales Datum + const parts = currentWeekStart.split('-'); + const date = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); date.setDate(date.getDate() - 7); - currentWeekStart = formatDate(date); + // Stelle sicher, dass es ein Montag ist + currentWeekStart = getMonday(date); saveLastWeek(); loadWeek(); }); document.getElementById('nextWeek').addEventListener('click', function() { - const date = new Date(currentWeekStart); + // Parse als lokales Datum + const parts = currentWeekStart.split('-'); + const date = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); date.setDate(date.getDate() + 7); - currentWeekStart = formatDate(date); + // Stelle sicher, dass es ein Montag ist + currentWeekStart = getMonday(date); saveLastWeek(); loadWeek(); }); @@ -140,13 +191,36 @@ async function saveLastWeek() { } } -// Montag der aktuellen Woche ermitteln +// Prüft ob ein Datum ein Montag ist (1 = Montag) +function isMonday(dateStr) { + const d = new Date(dateStr + 'T00:00:00'); // Lokale Zeit verwenden, nicht UTC + return d.getDay() === 1; +} + +// Montag der aktuellen Woche ermitteln (robust gegen Zeitzonenprobleme) function getMonday(date) { - const d = new Date(date); - const day = d.getDay(); - const diff = d.getDate() - day + (day === 0 ? -6 : 1); - d.setDate(diff); - return formatDate(d); + // Wenn date bereits ein String ist (YYYY-MM-DD), parsen wir es als lokales Datum + let d; + if (typeof date === 'string') { + // Parse als lokales Datum, nicht UTC + const parts = date.split('-'); + d = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2])); + } else { + d = new Date(date); + } + + // Stelle sicher, dass wir mit lokaler Zeit arbeiten + const day = d.getDay(); // 0 = Sonntag, 1 = Montag, ..., 6 = Samstag + // Berechne Differenz zum Montag: Montag = 1, also diff = 1 - day + // Aber: Sonntag = 0, also für Sonntag: diff = 1 - 0 = 1, aber wir wollen -6 Tage zurück + const diff = day === 0 ? -6 : 1 - day; + d.setDate(d.getDate() + diff); + + // Format als YYYY-MM-DD in lokaler Zeit + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const dayOfMonth = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${dayOfMonth}`; } // Kalenderwoche berechnen (ISO 8601 - Woche beginnt Montag) @@ -277,7 +351,12 @@ function renderWeek() { // Prüfen ob Werktag (Montag-Freitag, i < 5) ausgefüllt ist // Bei ganztägigem Urlaub oder Krank gilt der Tag als ausgefüllt - if (i < 5 && vacationType !== 'full' && !sickStatus && (!startTime || !endTime || startTime.trim() === '' || endTime.trim() === '')) { + // Bei 8 Überstunden (ganzer Tag) gilt der Tag auch als ausgefüllt + const overtimeValue = overtimeTaken ? parseFloat(overtimeTaken) : 0; + const fullDayHours = userWochenstunden ? (userWochenstunden / 5) : 8; + const isFullDayOvertime = overtimeValue > 0 && Math.abs(overtimeValue - fullDayHours) < 0.01; + + if (i < 5 && vacationType !== 'full' && !sickStatus && !isFullDayOvertime && (!startTime || !endTime || startTime.trim() === '' || endTime.trim() === '')) { allWeekdaysFilled = false; } @@ -524,6 +603,8 @@ function updateOvertimeDisplay() { // Gesamtstunden berechnen - direkt aus DOM-Elementen lesen für Echtzeit-Aktualisierung let totalHours = 0; let vacationHours = 0; + const fullDayHours = userWochenstunden ? (userWochenstunden / 5) : 8; + let fullDayOvertimeDays = 0; // Anzahl Tage mit 8 Überstunden (wie im Backend) const startDateObj = new Date(startDate); for (let i = 0; i < 7; i++) { const date = new Date(startDateObj); @@ -536,6 +617,16 @@ function updateOvertimeDisplay() { const sickCheckbox = document.querySelector(`input[data-date="${dateStr}"][data-field="sick_status"]`); const sickStatus = sickCheckbox ? sickCheckbox.checked : (currentEntries[dateStr]?.sick_status || false); + // Prüfe ob 8 Überstunden (ganzer Tag) eingetragen sind + const overtimeInput = document.querySelector(`input[data-date="${dateStr}"][data-field="overtime_taken_hours"]`); + const overtimeValue = overtimeInput ? parseFloat(overtimeInput.value) || 0 : (currentEntries[dateStr]?.overtime_taken_hours ? parseFloat(currentEntries[dateStr].overtime_taken_hours) : 0); + const isFullDayOvertime = overtimeValue > 0 && Math.abs(overtimeValue - fullDayHours) < 0.01; + + // Zähle volle Überstundentage (wie im Backend) + if (isFullDayOvertime) { + fullDayOvertimeDays++; + } + // Wenn Urlaub oder Krank, zähle nur diese Stunden (nicht zusätzlich Arbeitsstunden) if (vacationType === 'full') { vacationHours += 8; // Ganzer Tag Urlaub = 8 Stunden @@ -550,37 +641,43 @@ function updateOvertimeDisplay() { const endTime = endInput ? endInput.value : ''; const breakMinutes = parseInt(breakInput ? breakInput.value : 0) || 0; - if (startTime && endTime) { + if (startTime && endTime && !isFullDayOvertime) { const start = new Date(`2000-01-01T${startTime}`); const end = new Date(`2000-01-01T${endTime}`); const diffMs = end - start; const hours = (diffMs / (1000 * 60 * 60)) - (breakMinutes / 60); totalHours += hours; - } else if (currentEntries[dateStr]?.total_hours) { + } else if (currentEntries[dateStr]?.total_hours && !isFullDayOvertime) { // Fallback auf gespeicherte Werte totalHours += parseFloat(currentEntries[dateStr].total_hours) || 0; } } else if (sickStatus) { totalHours += 8; // Krank = 8 Stunden } else { - // Berechne Stunden direkt aus Start-/Endzeit und Pause - const startInput = document.querySelector(`input[data-date="${dateStr}"][data-field="start_time"]`); - const endInput = document.querySelector(`input[data-date="${dateStr}"][data-field="end_time"]`); - const breakInput = document.querySelector(`input[data-date="${dateStr}"][data-field="break_minutes"]`); - - const startTime = startInput ? startInput.value : ''; - const endTime = endInput ? endInput.value : ''; - const breakMinutes = parseInt(breakInput ? breakInput.value : 0) || 0; - - if (startTime && endTime) { - const start = new Date(`2000-01-01T${startTime}`); - const end = new Date(`2000-01-01T${endTime}`); - const diffMs = end - start; - const hours = (diffMs / (1000 * 60 * 60)) - (breakMinutes / 60); - totalHours += hours; - } else if (currentEntries[dateStr]?.total_hours) { - // Fallback auf gespeicherte Werte - totalHours += parseFloat(currentEntries[dateStr].total_hours) || 0; + // Wenn 8 Überstunden (ganzer Tag) eingetragen sind, zählt der Tag als 0 Stunden + if (isFullDayOvertime) { + // Tag zählt als 0 Stunden (Überstunden werden separat abgezogen) + // totalHours bleibt unverändert (0 Stunden für diesen Tag) + } else { + // Berechne Stunden direkt aus Start-/Endzeit und Pause + const startInput = document.querySelector(`input[data-date="${dateStr}"][data-field="start_time"]`); + const endInput = document.querySelector(`input[data-date="${dateStr}"][data-field="end_time"]`); + const breakInput = document.querySelector(`input[data-date="${dateStr}"][data-field="break_minutes"]`); + + const startTime = startInput ? startInput.value : ''; + const endTime = endInput ? endInput.value : ''; + const breakMinutes = parseInt(breakInput ? breakInput.value : 0) || 0; + + if (startTime && endTime) { + const start = new Date(`2000-01-01T${startTime}`); + const end = new Date(`2000-01-01T${endTime}`); + const diffMs = end - start; + const hours = (diffMs / (1000 * 60 * 60)) - (breakMinutes / 60); + totalHours += hours; + } else if (currentEntries[dateStr]?.total_hours) { + // Fallback auf gespeicherte Werte + totalHours += parseFloat(currentEntries[dateStr].total_hours) || 0; + } } } } @@ -601,9 +698,15 @@ function updateOvertimeDisplay() { } } - // Überstunden = (Tatsächliche Stunden + Urlaubsstunden) - Sollstunden + // Überstunden berechnen (wie im Backend: mit adjustedSollStunden) + // Wenn 8 Überstunden genommen wurden, zählen diese Tage als 0 Stunden + // Die negativen Stunden (wegen 0 statt Sollstunden) werden durch die verbrauchten Überstunden ausgeglichen + // Daher: adjustedSollStunden = sollStunden - (fullDayOvertimeDays * fullDayHours) + // So werden die Tage mit 8 Überstunden nicht zu negativen Überstunden führen const totalHoursWithVacation = totalHours + vacationHours; - const overtimeHours = totalHoursWithVacation - sollStunden; + const adjustedSollStunden = sollStunden - (fullDayOvertimeDays * fullDayHours); + // overtimeHours = Überstunden diese Woche (wie im Backend berechnet) + const overtimeHours = totalHoursWithVacation - adjustedSollStunden; // Überstunden-Anzeige aktualisieren const overtimeSummaryItem = document.getElementById('overtimeSummaryItem'); @@ -828,6 +931,11 @@ async function saveEntry(input) { // Überstunden-Anzeige aktualisieren (bei jeder Änderung) updateOvertimeDisplay(); + // Wenn vacation_type geändert wurde, Statistiken aktualisieren (für verplante Tage) + if (input.dataset.field === 'vacation_type') { + loadUserStats(); + } + // Submit-Button Status prüfen (nach jedem Speichern) checkWeekComplete(); @@ -873,6 +981,18 @@ function checkWeekComplete() { continue; // Tag ist ausgefüllt (ganzer Tag Urlaub oder Krank) } + // Prüfe ob 8 Überstunden eingetragen sind (dann ist der Tag auch ausgefüllt) + const overtimeInput = document.querySelector(`input[data-date="${dateStr}"][data-field="overtime_taken_hours"]`); + const overtimeValue = overtimeInput ? parseFloat(overtimeInput.value) || 0 : (entry.overtime_taken_hours ? parseFloat(entry.overtime_taken_hours) : 0); + + // Berechne fullDayHours (normalerweise 8 Stunden) + const fullDayHours = userWochenstunden ? (userWochenstunden / 5) : 8; + + // Wenn 8 Überstunden (ganzer Tag) eingetragen sind, ist der Tag ausgefüllt + if (overtimeValue > 0 && Math.abs(overtimeValue - fullDayHours) < 0.01) { + continue; // Tag ist ausgefüllt (8 Überstunden = ganzer Tag) + } + // Prüfe IMMER direkt die Input-Felder im DOM (das ist die zuverlässigste Quelle) // Auch bei manueller Eingabe werden die Werte hier erkannt const startInput = document.querySelector(`input[data-date="${dateStr}"][data-field="start_time"]`); @@ -883,15 +1003,18 @@ function checkWeekComplete() { const endTime = endInput ? (endInput.value || '').trim() : ''; // Debug-Ausgabe - zeigt auch den tatsächlichen Wert im Input-Feld - console.log(`Tag ${i + 1} (${dateStr}): Start="${startTime || 'LEER'}", Ende="${endTime || 'LEER'}", Urlaub="${vacationValue || 'KEIN'}"`, { + console.log(`Tag ${i + 1} (${dateStr}): Start="${startTime || 'LEER'}", Ende="${endTime || 'LEER'}", Urlaub="${vacationValue || 'KEIN'}", Überstunden="${overtimeValue}"`, { startInputExists: !!startInput, endInputExists: !!endInput, startInputValue: startInput ? startInput.value : 'N/A', endInputValue: endInput ? endInput.value : 'N/A', - vacationValue: vacationValue + vacationValue: vacationValue, + overtimeValue: overtimeValue, + fullDayHours: fullDayHours }); // Bei halbem Tag Urlaub oder keinem Urlaub müssen Start- und Endzeit vorhanden sein + // (außer wenn 8 Überstunden eingetragen sind, dann sind Start/Ende nicht nötig) if (!startTime || !endTime || startTime === '' || endTime === '') { allWeekdaysFilled = false; missingFields.push(formatDateDE(dateStr)); @@ -979,6 +1102,16 @@ async function submitWeek() { continue; // Tag ist ausgefüllt (ganzer Tag Urlaub oder Krank) } + // Prüfe ob 8 Überstunden eingetragen sind (dann ist der Tag auch ausgefüllt, Start/Ende nicht nötig) + const overtimeInput = document.querySelector(`input[data-date="${dateStr}"][data-field="overtime_taken_hours"]`); + const overtimeValue = overtimeInput ? parseFloat(overtimeInput.value) || 0 : (entry.overtime_taken_hours ? parseFloat(entry.overtime_taken_hours) : 0); + const fullDayHours = userWochenstunden ? (userWochenstunden / 5) : 8; + + // Wenn 8 Überstunden (ganzer Tag) eingetragen sind, ist der Tag ausgefüllt + if (overtimeValue > 0 && Math.abs(overtimeValue - fullDayHours) < 0.01) { + continue; // Tag ist ausgefüllt (8 Überstunden = ganzer Tag) + } + // Prüfe IMMER direkt die Input-Felder im DOM - auch bei manueller Eingabe const startInput = document.querySelector(`input[data-date="${dateStr}"][data-field="start_time"]`); const endInput = document.querySelector(`input[data-date="${dateStr}"][data-field="end_time"]`); @@ -988,13 +1121,15 @@ async function submitWeek() { const endTime = endInput ? (endInput.value || '').trim() : ''; // Debug-Ausgabe mit detaillierten Informationen - console.log(`Tag ${i + 1} (${dateStr}): Start="${startTime || 'LEER'}", Ende="${endTime || 'LEER'}", Urlaub="${vacationValue || 'KEIN'}"`, { + console.log(`Tag ${i + 1} (${dateStr}): Start="${startTime || 'LEER'}", Ende="${endTime || 'LEER'}", Urlaub="${vacationValue || 'KEIN'}", Überstunden="${overtimeValue}"`, { startInputExists: !!startInput, endInputExists: !!endInput, startInputValue: startInput ? `"${startInput.value}"` : 'N/A', endInputValue: endInput ? `"${endInput.value}"` : 'N/A', startInputType: startInput ? typeof startInput.value : 'N/A', - vacationValue: vacationValue + vacationValue: vacationValue, + overtimeValue: overtimeValue, + fullDayHours: fullDayHours }); const missing = []; @@ -1294,6 +1429,57 @@ async function loadPingIP() { } } +// Client-IP ermitteln und eintragen (global für onclick) +window.detectClientIP = async function() { + const pingIpInput = document.getElementById('pingIpInput'); + const detectButton = document.querySelector('button[onclick*="detectClientIP"]'); + + if (!pingIpInput) { + return; + } + + // Button-Status während des Ladens + if (detectButton) { + const originalText = detectButton.textContent; + detectButton.textContent = 'Ermittle...'; + detectButton.disabled = true; + + try { + const response = await fetch('/api/user/client-ip'); + if (!response.ok) { + throw new Error('Fehler beim Abrufen der IP-Adresse'); + } + + const data = await response.json(); + + if (data.client_ip && data.client_ip !== 'unknown') { + // IP in das Eingabefeld eintragen + pingIpInput.value = data.client_ip; + + // Erfolgs-Feedback + detectButton.textContent = 'IP ermittelt!'; + detectButton.style.backgroundColor = '#27ae60'; + setTimeout(() => { + detectButton.textContent = originalText; + detectButton.style.backgroundColor = '#3498db'; + detectButton.disabled = false; + }, 2000); + } else { + alert('IP-Adresse konnte nicht ermittelt werden.'); + detectButton.textContent = originalText; + detectButton.disabled = false; + } + } catch (error) { + console.error('Fehler beim Ermitteln der Client-IP:', error); + alert('Fehler beim Ermitteln der IP-Adresse'); + if (detectButton) { + detectButton.textContent = originalText; + detectButton.disabled = false; + } + } + } +}; + // Ping-IP speichern (global für onclick) window.savePingIP = async function() { const pingIpInput = document.getElementById('pingIpInput'); diff --git a/reset-db.js b/reset-db.js index f1f5e2a..61a6ccc 100644 --- a/reset-db.js +++ b/reset-db.js @@ -2,28 +2,218 @@ const sqlite3 = require('sqlite3').verbose(); const bcrypt = require('bcryptjs'); const path = require('path'); const fs = require('fs'); +const { exec } = require('child_process'); +const { promisify } = require('util'); +const execAsync = promisify(exec); const dbPath = path.join(__dirname, 'stundenerfassung.db'); console.log('🔄 Setze Datenbank zurück...\n'); // Datenbank schließen falls offen let db = null; +let savedLdapConfig = []; -try { - // Prüfe ob Datenbank existiert - if (fs.existsSync(dbPath)) { - console.log('📁 Datenbankdatei gefunden, lösche sie...'); - fs.unlinkSync(dbPath); - console.log('✅ Datenbankdatei gelöscht\n'); - } else { - console.log('ℹ️ Datenbankdatei existiert nicht, erstelle neue...\n'); +// Hilfsfunktion zum Warten +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Funktion zum Prüfen und Beenden von Prozessen auf bestimmten Ports +async function checkAndKillPorts(ports) { + const killedProcesses = []; + + for (const port of ports) { + try { + // Prüfe, ob der Port belegt ist (Windows) + const { stdout } = await execAsync(`netstat -ano | findstr :${port}`); + + if (stdout) { + // Extrahiere PID aus der Ausgabe + const lines = stdout.trim().split('\n'); + const pids = new Set(); + + for (const line of lines) { + const parts = line.trim().split(/\s+/); + if (parts.length > 0) { + const pid = parts[parts.length - 1]; + if (pid && !isNaN(pid)) { + pids.add(pid); + } + } + } + + // Beende alle Prozesse, die den Port verwenden + for (const pid of pids) { + try { + console.log(`🛑 Beende Prozess ${pid} auf Port ${port}...`); + await execAsync(`taskkill /F /PID ${pid}`); + killedProcesses.push({ port, pid }); + console.log(`✅ Prozess ${pid} beendet`); + } catch (err) { + // Prozess könnte bereits beendet sein oder keine Berechtigung + if (!err.message.includes('not found') && !err.message.includes('not running')) { + console.log(`⚠️ Konnte Prozess ${pid} nicht beenden: ${err.message}`); + } + } + } + } + } catch (err) { + // Port ist nicht belegt oder netstat hat nichts gefunden + if (!err.message.includes('findstr') && !err.message.includes('not found')) { + // Ignoriere Fehler, wenn der Port nicht belegt ist + } + } } + + if (killedProcesses.length > 0) { + console.log(`\n✅ ${killedProcesses.length} Prozess(e) beendet\n`); + // Warte kurz, damit die Ports freigegeben werden + await sleep(1000); + } else { + console.log('ℹ️ Keine Prozesse auf Ports 3333 oder 3334 gefunden\n'); + } + + return killedProcesses.length > 0; +} - // Neue Datenbank erstellen - db = new sqlite3.Database(dbPath); +// Funktion zum Löschen der Datenbankdatei mit Retry-Logik (async) +async function deleteDatabaseFile(retries = 10, initialDelay = 500) { + if (!fs.existsSync(dbPath)) { + return true; + } + + for (let i = 0; i < retries; i++) { + try { + fs.unlinkSync(dbPath); + console.log('✅ Datenbankdatei gelöscht\n'); + return true; + } catch (err) { + if (err.code === 'EBUSY' && i < retries - 1) { + // Exponentielle Backoff-Strategie + const waitTime = initialDelay * Math.pow(2, i); + console.log(`⏳ Datenbankdatei noch gesperrt (Versuch ${i + 1}/${retries}), warte ${waitTime}ms...`); + await sleep(waitTime); + continue; + } + if (i === retries - 1) { + console.error(`❌ Konnte Datenbankdatei nach ${retries} Versuchen nicht löschen.`); + console.error(' Bitte stellen Sie sicher, dass alle Prozesse geschlossen sind, die die Datenbank verwenden.'); + return false; + } + throw err; + } + } + return false; +} - db.serialize(() => { +// Promise-Wrapper für sqlite3 Database.all +function dbAll(db, query, params = []) { + return new Promise((resolve, reject) => { + db.all(query, params, (err, rows) => { + if (err) reject(err); + else resolve(rows); + }); + }); +} + +// Promise-Wrapper für sqlite3 Database.close +function dbClose(db) { + return new Promise((resolve, reject) => { + db.close((err) => { + if (err) reject(err); + else resolve(); + }); + }); +} + +// Promise-Wrapper für sqlite3 Database-Konstruktor +function openDatabase(path, mode = sqlite3.OPEN_READONLY) { + return new Promise((resolve, reject) => { + const db = new sqlite3.Database(path, mode, (err) => { + if (err) reject(err); + else resolve(db); + }); + }); +} + +// Hauptfunktion als async +async function resetDatabase() { + try { + // Prüfe und beende Prozesse auf Ports 3333 und 3334 + console.log('🔍 Prüfe auf laufende Server auf Ports 3333 und 3334...\n'); + await checkAndKillPorts([3333, 3334]); + + // Prüfe ob Datenbank existiert und sichere ldap_config Daten + if (fs.existsSync(dbPath)) { + console.log('📁 Datenbankdatei gefunden...'); + + try { + // Temporäre Datenbankverbindung zum Lesen der ldap_config + const tempDb = await openDatabase(dbPath, sqlite3.OPEN_READONLY); + + try { + // Lese ldap_config Daten + const rows = await dbAll(tempDb, 'SELECT * FROM ldap_config'); + if (rows && rows.length > 0) { + savedLdapConfig = rows; + console.log(`💾 ${rows.length} LDAP-Konfiguration(en) gesichert\n`); + } else { + console.log('ℹ️ Keine LDAP-Konfiguration vorhanden\n'); + } + } catch (err) { + console.log('ℹ️ ldap_config Tabelle existiert nicht oder konnte nicht gelesen werden\n'); + } + + // Schließe die temporäre Datenbank + await dbClose(tempDb); + + // Warte etwas länger, damit die Datenbank wirklich geschlossen ist + await sleep(500); + + // Datenbank löschen + const success = await deleteDatabaseFile(); + if (success) { + createNewDatabase(); + } else { + console.error('❌ Konnte Datenbankdatei nicht löschen'); + process.exit(1); + } + } catch (err) { + console.log('⚠️ Konnte Datenbank nicht öffnen zum Lesen, fahre fort...\n'); + // Datenbank löschen + const success = await deleteDatabaseFile(); + if (success) { + createNewDatabase(); + } else { + console.error('❌ Konnte Datenbankdatei nicht löschen'); + process.exit(1); + } + } + } else { + console.log('ℹ️ Datenbankdatei existiert nicht, erstelle neue...\n'); + createNewDatabase(); + } + } catch (error) { + console.error('❌ Fehler beim Zurücksetzen der Datenbank:', error); + if (db) { + db.close(); + } + process.exit(1); + } +} + +// Starte das Reset +resetDatabase().catch((error) => { + console.error('❌ Unerwarteter Fehler:', error); + process.exit(1); +}); + + function createNewDatabase() { + // Neue Datenbank erstellen + db = new sqlite3.Database(dbPath); + + db.serialize(() => { console.log('📊 Erstelle Tabellen...\n'); // Benutzer-Tabelle @@ -126,7 +316,42 @@ try { updated_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, (err) => { if (err) console.error('Fehler bei ldap_config:', err); - else console.log('✅ Tabelle ldap_config erstellt'); + else { + console.log('✅ Tabelle ldap_config erstellt'); + + // Stelle gesicherte LDAP-Konfiguration wieder her + if (savedLdapConfig.length > 0) { + console.log('🔄 Stelle LDAP-Konfiguration wieder her...'); + savedLdapConfig.forEach((config) => { + db.run(`INSERT INTO ldap_config ( + id, enabled, url, bind_dn, bind_password, base_dn, + user_search_filter, username_attribute, firstname_attribute, + lastname_attribute, sync_interval, last_sync, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ + config.id, + config.enabled, + config.url, + config.bind_dn, + config.bind_password, + config.base_dn, + config.user_search_filter, + config.username_attribute, + config.firstname_attribute, + config.lastname_attribute, + config.sync_interval, + config.last_sync, + config.created_at, + config.updated_at + ], (err) => { + if (err) { + console.error('Fehler beim Wiederherstellen der LDAP-Konfiguration:', err); + } else { + console.log(`✅ LDAP-Konfiguration (ID: ${config.id}) wiederhergestellt`); + } + }); + }); + } + } }); // LDAP-Sync-Log-Tabelle @@ -217,11 +442,4 @@ try { }, 500); }); }); - -} catch (error) { - console.error('❌ Fehler beim Zurücksetzen der Datenbank:', error); - if (db) { - db.close(); } - process.exit(1); -} diff --git a/routes/auth.js b/routes/auth.js index df279de..3c530ae 100644 --- a/routes/auth.js +++ b/routes/auth.js @@ -79,7 +79,8 @@ function registerAuthRoutes(app) { LDAPService.authenticate(username, password, (authErr, authSuccess) => { if (authErr || !authSuccess) { // LDAP-Authentifizierung fehlgeschlagen - prüfe lokale Datenbank als Fallback - db.get('SELECT * FROM users WHERE username = ?', [username], (err, user) => { + // Case-insensitive Suche: COLLATE NOCASE macht den Vergleich case-insensitive + db.get('SELECT * FROM users WHERE username = ? COLLATE NOCASE', [username], (err, user) => { if (err || !user) { return res.render('login', { error: 'Ungültiger Benutzername oder Passwort' }); } @@ -93,7 +94,8 @@ function registerAuthRoutes(app) { }); } else { // LDAP-Authentifizierung erfolgreich - hole Benutzer aus Datenbank - db.get('SELECT * FROM users WHERE username = ?', [username], (err, user) => { + // Case-insensitive Suche: COLLATE NOCASE macht den Vergleich case-insensitive + db.get('SELECT * FROM users WHERE username = ? COLLATE NOCASE', [username], (err, user) => { if (err || !user) { return res.render('login', { error: 'Benutzer nicht in der Datenbank gefunden. Bitte führen Sie eine LDAP-Synchronisation durch.' }); } @@ -104,7 +106,8 @@ function registerAuthRoutes(app) { }); } else { // LDAP nicht aktiviert - verwende lokale Authentifizierung - db.get('SELECT * FROM users WHERE username = ?', [username], (err, user) => { + // Case-insensitive Suche: COLLATE NOCASE macht den Vergleich case-insensitive + db.get('SELECT * FROM users WHERE username = ? COLLATE NOCASE', [username], (err, user) => { if (err || !user) { return res.render('login', { error: 'Ungültiger Benutzername oder Passwort' }); } diff --git a/routes/timesheet.js b/routes/timesheet.js index fe0be6e..4e87dd8 100644 --- a/routes/timesheet.js +++ b/routes/timesheet.js @@ -190,12 +190,12 @@ function registerTimesheetRoutes(app) { const { week_start, week_end, version_reason } = req.body; const userId = req.session.userId; - // Validierung: Prüfen ob alle 7 Tage der Woche ausgefüllt sind - db.all(`SELECT id, date, start_time, end_time, vacation_type, sick_status, updated_at FROM timesheet_entries - WHERE user_id = ? AND date >= ? AND date <= ? - ORDER BY date, updated_at DESC, id DESC`, - [userId, week_start, week_end], - (err, entries) => { + // Validierung: Prüfen ob alle 7 Tage der Woche ausgefüllt sind + db.all(`SELECT id, date, start_time, end_time, vacation_type, sick_status, overtime_taken_hours, updated_at FROM timesheet_entries + WHERE user_id = ? AND date >= ? AND date <= ? + ORDER BY date, updated_at DESC, id DESC`, + [userId, week_start, week_end], + (err, entries) => { if (err) { return res.status(500).json({ error: 'Fehler beim Prüfen der Daten' }); } @@ -221,81 +221,100 @@ function registerTimesheetRoutes(app) { // Prüfe nur Werktage (Montag-Freitag, erste 5 Tage) // Samstag und Sonntag sind optional // Bei ganztägigem Urlaub (vacation_type = 'full') ist der Tag als ausgefüllt zu betrachten + // Bei 8 Überstunden (ganzer Tag) ist der Tag auch als ausgefüllt zu betrachten // week_start ist bereits im Format YYYY-MM-DD const startDateParts = week_start.split('-'); const startYear = parseInt(startDateParts[0]); const startMonth = parseInt(startDateParts[1]) - 1; // Monat ist 0-basiert const startDay = parseInt(startDateParts[2]); - let missingDays = []; - - for (let i = 0; i < 5; i++) { - // Datum direkt berechnen ohne Zeitzonenprobleme - const date = new Date(startYear, startMonth, startDay + i); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const dateStr = `${year}-${month}-${day}`; - const entry = entriesByDate[dateStr]; - - // Wenn ganztägiger Urlaub oder Krank, dann ist der Tag als ausgefüllt zu betrachten - const isSick = entry && (entry.sick_status === 1 || entry.sick_status === true); - if (entry && (entry.vacation_type === 'full' || isSick)) { - continue; // Tag ist ausgefüllt + // User-Daten laden für Überstunden-Berechnung + db.get('SELECT wochenstunden FROM users WHERE id = ?', [userId], (err, user) => { + if (err) { + return res.status(500).json({ error: 'Fehler beim Laden der User-Daten' }); } - // Bei halbem Tag Urlaub oder keinem Urlaub müssen Start- und Endzeit vorhanden sein - // start_time und end_time könnten null, undefined oder leer strings sein - const hasStartTime = entry && entry.start_time && entry.start_time.toString().trim() !== ''; - const hasEndTime = entry && entry.end_time && entry.end_time.toString().trim() !== ''; + const wochenstunden = user?.wochenstunden || 0; + const fullDayHours = wochenstunden > 0 ? wochenstunden / 5 : 8; - if (!entry || !hasStartTime || !hasEndTime) { - missingDays.push(dateStr); - } - } - - if (missingDays.length > 0) { - return res.status(400).json({ - error: `Nicht alle Werktage (Montag bis Freitag) sind ausgefüllt. Fehlende Tage: ${missingDays.join(', ')}. Bitte füllen Sie alle Werktage mit Start- und Endzeit aus. Wochenende ist optional.` - }); - } - - // Alle Tage ausgefüllt - Woche abschicken (immer neue Version erstellen) - // Prüfe welche Version die letzte ist - db.get(`SELECT MAX(version) as max_version FROM weekly_timesheets - WHERE user_id = ? AND week_start = ? AND week_end = ?`, - [userId, week_start, week_end], - (err, result) => { - if (err) return res.status(500).json({ error: 'Fehler beim Prüfen der Version' }); + let missingDays = []; + + for (let i = 0; i < 5; i++) { + // Datum direkt berechnen ohne Zeitzonenprobleme + const date = new Date(startYear, startMonth, startDay + i); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const dateStr = `${year}-${month}-${day}`; + const entry = entriesByDate[dateStr]; - const maxVersion = result && result.max_version ? result.max_version : 0; - const newVersion = maxVersion + 1; - - // Wenn bereits eine Version existiert, ist version_reason erforderlich - if (maxVersion > 0 && (!version_reason || version_reason.trim() === '')) { - return res.status(400).json({ - error: 'Bitte geben Sie einen Grund für die neue Version an.' - }); + // Wenn ganztägiger Urlaub oder Krank, dann ist der Tag als ausgefüllt zu betrachten + const isSick = entry && (entry.sick_status === 1 || entry.sick_status === true); + if (entry && (entry.vacation_type === 'full' || isSick)) { + continue; // Tag ist ausgefüllt } - // Neue Version erstellen (nicht überschreiben) - db.run(`INSERT INTO weekly_timesheets (user_id, week_start, week_end, version, status, version_reason) - VALUES (?, ?, ?, ?, 'eingereicht', ?)`, - [userId, week_start, week_end, newVersion, version_reason ? version_reason.trim() : null], - (err) => { - if (err) return res.status(500).json({ error: 'Fehler beim Abschicken' }); - - // Status der Einträge aktualisieren (optional - für Nachverfolgung) - db.run(`UPDATE timesheet_entries - SET status = 'eingereicht' - WHERE user_id = ? AND date >= ? AND date <= ?`, - [userId, week_start, week_end], - (err) => { - if (err) return res.status(500).json({ error: 'Fehler beim Aktualisieren des Status' }); - res.json({ success: true, version: newVersion }); - }); - }); - }); + // Prüfe ob 8 Überstunden (ganzer Tag) eingetragen sind + const overtimeValue = entry && entry.overtime_taken_hours ? parseFloat(entry.overtime_taken_hours) : 0; + const isFullDayOvertime = overtimeValue > 0 && Math.abs(overtimeValue - fullDayHours) < 0.01; + + if (isFullDayOvertime) { + continue; // Tag ist ausgefüllt (8 Überstunden = ganzer Tag) + } + + // Bei halbem Tag Urlaub oder keinem Urlaub müssen Start- und Endzeit vorhanden sein + // start_time und end_time könnten null, undefined oder leer strings sein + const hasStartTime = entry && entry.start_time && entry.start_time.toString().trim() !== ''; + const hasEndTime = entry && entry.end_time && entry.end_time.toString().trim() !== ''; + + if (!entry || !hasStartTime || !hasEndTime) { + missingDays.push(dateStr); + } + } + + if (missingDays.length > 0) { + return res.status(400).json({ + error: `Nicht alle Werktage (Montag bis Freitag) sind ausgefüllt. Fehlende Tage: ${missingDays.join(', ')}. Bitte füllen Sie alle Werktage mit Start- und Endzeit aus. Wochenende ist optional.` + }); + } + + // Alle Tage ausgefüllt - Woche abschicken (immer neue Version erstellen) + // Prüfe welche Version die letzte ist + db.get(`SELECT MAX(version) as max_version FROM weekly_timesheets + WHERE user_id = ? AND week_start = ? AND week_end = ?`, + [userId, week_start, week_end], + (err, result) => { + if (err) return res.status(500).json({ error: 'Fehler beim Prüfen der Version' }); + + const maxVersion = result && result.max_version ? result.max_version : 0; + const newVersion = maxVersion + 1; + + // Wenn bereits eine Version existiert, ist version_reason erforderlich + if (maxVersion > 0 && (!version_reason || version_reason.trim() === '')) { + return res.status(400).json({ + error: 'Bitte geben Sie einen Grund für die neue Version an.' + }); + } + + // Neue Version erstellen (nicht überschreiben) + db.run(`INSERT INTO weekly_timesheets (user_id, week_start, week_end, version, status, version_reason) + VALUES (?, ?, ?, ?, 'eingereicht', ?)`, + [userId, week_start, week_end, newVersion, version_reason ? version_reason.trim() : null], + (err) => { + if (err) return res.status(500).json({ error: 'Fehler beim Abschicken' }); + + // Status der Einträge aktualisieren (optional - für Nachverfolgung) + db.run(`UPDATE timesheet_entries + SET status = 'eingereicht' + WHERE user_id = ? AND date >= ? AND date <= ?`, + [userId, week_start, week_end], + (err) => { + if (err) return res.status(500).json({ error: 'Fehler beim Aktualisieren des Status' }); + res.json({ success: true, version: newVersion }); + }); + }); + }); + }); }); }); diff --git a/routes/user.js b/routes/user.js index e549ba3..cd98481 100644 --- a/routes/user.js +++ b/routes/user.js @@ -51,6 +51,22 @@ function registerUserRoutes(app) { }); }); + // API: Client-IP abrufen + app.get('/api/user/client-ip', requireAuth, (req, res) => { + // Versuche verschiedene Methoden, um die Client-IP zu erhalten + const clientIp = req.ip || + req.connection.remoteAddress || + req.socket.remoteAddress || + (req.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0].trim() : null) || + req.headers['x-real-ip'] || + 'unknown'; + + // Entferne IPv6-Präfix falls vorhanden (::ffff:192.168.1.1 -> 192.168.1.1) + const cleanIp = clientIp.replace(/^::ffff:/, ''); + + res.json({ client_ip: cleanIp }); + }); + // API: Ping-IP abrufen app.get('/api/user/ping-ip', requireAuth, (req, res) => { const userId = req.session.userId; @@ -120,6 +136,52 @@ function registerUserRoutes(app) { res.json({ success: true, currentRole: role }); }); + // API: Verplante Urlaubstage (alle Wochen, auch nicht-eingereichte) + app.get('/api/user/planned-vacation', requireAuth, (req, res) => { + const userId = req.session.userId; + const { getCalendarWeek } = require('../helpers/utils'); + + db.all(`SELECT date, vacation_type FROM timesheet_entries + WHERE user_id = ? AND vacation_type IS NOT NULL AND vacation_type != ''`, + [userId], + (err, entries) => { + if (err) { + return res.status(500).json({ error: 'Fehler beim Abrufen der verplanten Tage' }); + } + + let plannedDays = 0; + const weeksMap = {}; // { KW: { year: YYYY, week: KW, days: X } } + + entries.forEach(entry => { + const dayValue = entry.vacation_type === 'full' ? 1 : 0.5; + plannedDays += dayValue; + + // Berechne Kalenderwoche + const date = new Date(entry.date); + const year = date.getFullYear(); + const week = getCalendarWeek(entry.date); + const weekKey = `${year}-KW${week}`; + + if (!weeksMap[weekKey]) { + weeksMap[weekKey] = { year, week, days: 0 }; + } + weeksMap[weekKey].days += dayValue; + }); + + // Konvertiere zu sortiertem Array + const weeks = Object.values(weeksMap).sort((a, b) => { + if (a.year !== b.year) return a.year - b.year; + return a.week - b.week; + }); + + res.json({ + plannedVacationDays: plannedDays, + weeks: weeks + }); + } + ); + }); + // API: Gesamtstatistiken für Mitarbeiter (Überstunden und Urlaubstage) app.get('/api/user/stats', requireAuth, (req, res) => { const userId = req.session.userId; @@ -134,29 +196,66 @@ function registerUserRoutes(app) { const urlaubstage = user.urlaubstage || 0; 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`, + // Verplante Urlaubstage berechnen (alle Wochen, auch nicht-eingereichte) + const { getCalendarWeek } = require('../helpers/utils'); + db.all(`SELECT date, vacation_type FROM timesheet_entries + WHERE user_id = ? AND vacation_type IS NOT NULL AND vacation_type != ''`, [userId], - (err, weeks) => { + (err, allVacationEntries) => { if (err) { - return res.status(500).json({ error: 'Fehler beim Abrufen der Wochen' }); + return res.status(500).json({ error: 'Fehler beim Abrufen der verplanten Tage' }); } - // Wenn keine Wochen vorhanden - if (!weeks || weeks.length === 0) { - return res.json({ - currentOvertime: overtimeOffsetHours, - remainingVacation: urlaubstage, - totalOvertimeHours: 0, - totalOvertimeTaken: 0, - totalVacationDays: 0, - urlaubstage: urlaubstage, - overtimeOffsetHours: overtimeOffsetHours - }); - } + let plannedVacationDays = 0; + const weeksMap = {}; // { KW: { year: YYYY, week: KW, days: X } } + + (allVacationEntries || []).forEach(entry => { + const dayValue = entry.vacation_type === 'full' ? 1 : 0.5; + plannedVacationDays += dayValue; + + // Berechne Kalenderwoche + const date = new Date(entry.date); + const year = date.getFullYear(); + const week = getCalendarWeek(entry.date); + const weekKey = `${year}-KW${week}`; + + if (!weeksMap[weekKey]) { + weeksMap[weekKey] = { year, week, days: 0 }; + } + weeksMap[weekKey].days += dayValue; + }); + + // Konvertiere zu sortiertem Array + const plannedWeeks = Object.values(weeksMap).sort((a, b) => { + if (a.year !== b.year) return a.year - b.year; + return a.week - b.week; + }); + + // 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`, + [userId], + (err, weeks) => { + if (err) { + return res.status(500).json({ error: 'Fehler beim Abrufen der Wochen' }); + } + + // Wenn keine Wochen vorhanden + if (!weeks || weeks.length === 0) { + return res.json({ + currentOvertime: overtimeOffsetHours, + remainingVacation: urlaubstage, + totalOvertimeHours: 0, + totalOvertimeTaken: 0, + totalVacationDays: 0, + plannedVacationDays: plannedVacationDays, + plannedWeeks: plannedWeeks, + urlaubstage: urlaubstage, + overtimeOffsetHours: overtimeOffsetHours + }); + } let totalOvertimeHours = 0; let totalOvertimeTaken = 0; @@ -246,6 +345,8 @@ function registerUserRoutes(app) { totalOvertimeHours: totalOvertimeHours, totalOvertimeTaken: totalOvertimeTaken, totalVacationDays: totalVacationDays, + plannedVacationDays: plannedVacationDays, + plannedWeeks: plannedWeeks, urlaubstage: urlaubstage, overtimeOffsetHours: overtimeOffsetHours }); @@ -259,11 +360,24 @@ function registerUserRoutes(app) { let weekVacationDays = 0; let weekVacationHours = 0; + const fullDayHours = wochenstunden > 0 ? wochenstunden / 5 : 8; + let fullDayOvertimeDays = 0; // Anzahl Tage mit 8 Überstunden + entries.forEach(entry => { + // Prüfe ob 8 Überstunden (ganzer Tag) eingetragen sind + 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 += entry.overtime_taken_hours; } + // Wenn 8 Überstunden eingetragen sind, zählt der Tag als 0 Stunden + // Diese Tage werden separat gezählt, um die Sollstunden anzupassen + if (isFullDayOvertime) { + fullDayOvertimeDays++; + } + // Urlaub hat Priorität - wenn Urlaub, zähle nur Urlaubsstunden, nicht zusätzlich Arbeitsstunden if (entry.vacation_type === 'full') { weekVacationDays += 1; @@ -273,12 +387,12 @@ function registerUserRoutes(app) { weekVacationDays += 0.5; weekVacationHours += 4; // Halber Tag = 4 Stunden // Bei halbem Tag Urlaub können noch Arbeitsstunden vorhanden sein - if (entry.total_hours) { + if (entry.total_hours && !isFullDayOvertime) { weekTotalHours += entry.total_hours; } } else { - // Kein Urlaub - zähle nur Arbeitsstunden - if (entry.total_hours) { + // Kein Urlaub - zähle nur Arbeitsstunden (wenn nicht 8 Überstunden) + if (entry.total_hours && !isFullDayOvertime) { weekTotalHours += entry.total_hours; } } @@ -287,11 +401,22 @@ function registerUserRoutes(app) { // Sollstunden berechnen const sollStunden = (wochenstunden / 5) * workdays; - // Überstunden für diese Woche: Urlaub zählt als normale Arbeitszeit + // Überstunden für diese Woche berechnen (wie im Frontend: totalHoursWithVacation - sollStunden) + // Wenn 8 Überstunden genommen wurden, zählen diese Tage als 0 Stunden + // Die Berechnung: (totalHours + vacationHours) - sollStunden + // Bei 8 Überstunden: totalHours = 0, daher: 0 - sollStunden = -sollStunden für diese Woche + // Die negativen Stunden (wegen 0 statt Sollstunden) werden durch die verbrauchten Überstunden ausgeglichen + // Daher: adjustedSollStunden = sollStunden - (fullDayOvertimeDays * fullDayHours) + // So werden die Tage mit 8 Überstunden nicht zu negativen Überstunden führen const weekTotalHoursWithVacation = weekTotalHours + weekVacationHours; - const weekOvertimeHours = weekTotalHoursWithVacation - sollStunden; + const adjustedSollStunden = sollStunden - (fullDayOvertimeDays * fullDayHours); + // weekOvertimeHours = Überstunden diese Woche (wie im Frontend berechnet) + const weekOvertimeHours = weekTotalHoursWithVacation - adjustedSollStunden; // Kumulativ addieren + // WICHTIG: weekOvertimeHours enthält bereits die Überstunden dieser Woche (kann negativ sein bei 8 Überstunden) + // weekOvertimeTaken enthält die verbrauchten Überstunden (8 Stunden pro Tag mit 8 Überstunden) + // Die aktuellen Überstunden = Summe aller Wochen-Überstunden - verbrauchte Überstunden totalOvertimeHours += weekOvertimeHours; totalOvertimeTaken += weekOvertimeTaken; totalVacationDays += weekVacationDays; @@ -300,6 +425,9 @@ function registerUserRoutes(app) { // Wenn alle Wochen verarbeitet wurden, Antwort senden if (processedWeeks === weeks.length && !hasError) { + // Aktuelle Überstunden = Summe aller Wochen-Überstunden - verbrauchte Überstunden + Offset + // weekOvertimeHours enthält bereits die korrekte Berechnung pro Woche (wie im Frontend) + // weekOvertimeTaken enthält die verbrauchten Überstunden (8 Stunden pro Tag mit 8 Überstunden) const currentOvertime = (totalOvertimeHours - totalOvertimeTaken) + overtimeOffsetHours; const remainingVacation = urlaubstage - totalVacationDays; @@ -309,6 +437,8 @@ function registerUserRoutes(app) { totalOvertimeHours: totalOvertimeHours, totalOvertimeTaken: totalOvertimeTaken, totalVacationDays: totalVacationDays, + plannedVacationDays: plannedVacationDays, + plannedWeeks: plannedWeeks, urlaubstage: urlaubstage, overtimeOffsetHours: overtimeOffsetHours }); @@ -316,6 +446,7 @@ function registerUserRoutes(app) { }); }); }); + }); }); }); } diff --git a/server.js b/server.js index 5860136..661f7c7 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,8 @@ const PORT = 3333; // Middleware app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.json()); +// Trust proxy für korrekte Client-IP-Erkennung (wichtig bei Proxies/Reverse Proxies) +app.set('trust proxy', true); app.use(express.static('public')); app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); diff --git a/views/dashboard.ejs b/views/dashboard.ejs index 8884679..04fa158 100644 --- a/views/dashboard.ejs +++ b/views/dashboard.ejs @@ -54,7 +54,7 @@
- +

Stunden werden automatisch gespeichert. Am Ende der Woche können Sie die Stunden abschicken.

@@ -63,15 +63,30 @@
-
Aktuelle Überstunden
+
+ Aktuelle Überstunden + ? +
-
Stunden
-
Verbleibende Urlaubstage
+
+ Verbleibende Urlaubstage + ? +
-
von - Tagen
+
+
+ Verplante Urlaubstage + ? +
+
-
+
Tage
+
+
@@ -82,7 +97,10 @@