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.