Compare commits

..

13 Commits

19 changed files with 1166 additions and 413 deletions

View File

@@ -3,7 +3,7 @@
const express = require('express'); const express = require('express');
const path = require('path'); const path = require('path');
const { db } = require('./database'); const { db } = require('./database');
const { getCurrentDate, getCurrentTime, updateTotalHours } = require('./helpers/utils'); const { getCurrentDate, getCurrentTime, updateTotalHours, formatHoursMin } = require('./helpers/utils');
const checkinApp = express(); const checkinApp = express();
const CHECKIN_PORT = 3334; const CHECKIN_PORT = 3334;
@@ -47,7 +47,7 @@ checkinApp.get('/api/checkin/:userId', (req, res) => {
const currentTime = getCurrentTime(); const currentTime = getCurrentTime();
// Prüfe ob User existiert // Prüfe ob User existiert
db.get('SELECT id FROM users WHERE id = ?', [userId], (err, user) => { db.get('SELECT id, default_break_minutes FROM users WHERE id = ?', [userId], (err, user) => {
if (err || !user) { if (err || !user) {
return sendResponse(req, res, false, { error: 'Benutzer nicht gefunden', status: 404 }); return sendResponse(req, res, false, { error: 'Benutzer nicht gefunden', status: 404 });
} }
@@ -61,10 +61,14 @@ checkinApp.get('/api/checkin/:userId', (req, res) => {
const successTitle = 'Hallo, du wurdest erfolgreich eingecheckt'; const successTitle = 'Hallo, du wurdest erfolgreich eingecheckt';
const userDefaultBreakMinutes = Number.isInteger(user?.default_break_minutes) && user.default_break_minutes >= 0
? user.default_break_minutes
: 30;
if (!entry) { if (!entry) {
// Kein Eintrag existiert → Erstelle neuen mit start_time // Kein Eintrag existiert → Erstelle neuen mit start_time
db.run(`INSERT INTO timesheet_entries (user_id, date, start_time, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)`, db.run(`INSERT INTO timesheet_entries (user_id, date, start_time, break_minutes, updated_at) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
[userId, currentDate, currentTime], (err) => { [userId, currentDate, currentTime, userDefaultBreakMinutes], (err) => {
if (err) { if (err) {
return sendResponse(req, res, false, { error: 'Fehler beim Erstellen des Eintrags', status: 500 }); return sendResponse(req, res, false, { error: 'Fehler beim Erstellen des Eintrags', status: 500 });
} }
@@ -139,7 +143,7 @@ checkinApp.get('/api/checkout/:userId', (req, res) => {
return sendResponse(req, res, false, { error: 'Fehler beim Aktualisieren', status: 500 }); return sendResponse(req, res, false, { error: 'Fehler beim Aktualisieren', status: 500 });
} }
const successTitle = 'Schönen Feierabend, du wurdest erfolgreich ausgecheckt'; const successTitle = 'Schönen Feierabend, du wurdest erfolgreich ausgecheckt';
const successMessage = `End-Zeit erfasst: ${currentTime}. Gesamtstunden: ${totalHours.toFixed(2)} h`; const successMessage = `End-Zeit erfasst: ${currentTime}. Gesamtstunden: ${formatHoursMin(totalHours)}`;
sendResponse(req, res, true, { sendResponse(req, res, true, {
title: successTitle, title: successTitle,
message: successMessage, message: successMessage,

View File

@@ -26,6 +26,13 @@ function initDatabase() {
// Fehler ignorieren wenn Spalte bereits existiert // Fehler ignorieren wenn Spalte bereits existiert
}); });
// Migration: default_break_minutes (Standard-Pausenzeit pro Mitarbeiter)
db.run(`ALTER TABLE users ADD COLUMN default_break_minutes INTEGER DEFAULT 30`, (err) => {
if (err && !err.message.includes('duplicate column')) {
console.warn('Warnung beim Hinzufügen der Spalte default_break_minutes:', err.message);
}
});
// Stundenerfassung-Tabelle // Stundenerfassung-Tabelle
db.run(`CREATE TABLE IF NOT EXISTS timesheet_entries ( db.run(`CREATE TABLE IF NOT EXISTS timesheet_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -208,6 +215,31 @@ function initDatabase() {
} }
}); });
// Tabelle: Protokoll für Überstunden-Korrekturen durch Verwaltung
db.run(`CREATE TABLE IF NOT EXISTS overtime_corrections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
correction_hours REAL NOT NULL,
reason TEXT,
corrected_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)`, (err) => {
if (err) {
console.warn('Warnung beim Erstellen der overtime_corrections Tabelle:', err.message);
}
});
// Migration: reason Spalte für overtime_corrections hinzufügen (falls Tabelle bereits existiert)
db.run(`ALTER TABLE overtime_corrections ADD COLUMN reason TEXT`, (err) => {
// Fehler ignorieren wenn Spalte bereits existiert
if (err && !err.message.includes('duplicate column')) {
// "duplicate column" ist SQLite CLI wording; sqlite3 node liefert typischerweise "duplicate column name"
if (!err.message.includes('duplicate column name')) {
console.warn('Warnung beim Hinzufügen der Spalte reason (overtime_corrections):', err.message);
}
}
});
// Migration: Urlaubstage-Offset (manuelle Korrektur durch Verwaltung) // Migration: Urlaubstage-Offset (manuelle Korrektur durch Verwaltung)
db.run(`ALTER TABLE users ADD COLUMN vacation_offset_days REAL DEFAULT 0`, (err) => { db.run(`ALTER TABLE users ADD COLUMN vacation_offset_days REAL DEFAULT 0`, (err) => {
// Fehler ignorieren wenn Spalte bereits existiert // Fehler ignorieren wenn Spalte bereits existiert

View File

@@ -15,3 +15,4 @@
- Ausgefüllte Tage anhand der Tage pro woche gültig setzten -> DONE - Ausgefüllte Tage anhand der Tage pro woche gültig setzten -> DONE
- Überstunden müssen anhand der Tagesstunden auch auf gültig setzten (Tag ausgefüllt wenn weniger als 8h) -> DONE sollte passen - Überstunden müssen anhand der Tagesstunden auch auf gültig setzten (Tag ausgefüllt wenn weniger als 8h) -> DONE sollte passen
- Verplante Urlaubstage müssen auf abgezogen werden, wenn die Woche die gepalnt war eingereicht wurde. -> DONE - Verplante Urlaubstage müssen auf abgezogen werden, wenn die Woche die gepalnt war eingereicht wurde. -> DONE
- Grund für Überstundenkorrektur -> DONE

View File

@@ -132,6 +132,22 @@ function getWeekStart(dateStr) {
return `${year}-${month}-${dayOfMonth}`; return `${year}-${month}-${dayOfMonth}`;
} }
// Helper: Dezimalstunden in Anzeige "X h Y min" (z. B. 8.25 -> "8 h 15 min", -1.5 -> "-1 h 30 min")
function formatHoursMin(decimalHours) {
if (decimalHours == null || !Number.isFinite(Number(decimalHours))) return '0h 0min';
const n = Number(decimalHours);
const sign = n < 0 ? -1 : 1;
const absVal = Math.abs(n);
let h = Math.floor(absVal);
let min = Math.round((absVal - h) * 60);
if (min >= 60) {
h += 1;
min = 0;
}
const prefix = sign < 0 ? '-' : '';
return prefix + h + 'h ' + min + 'min';
}
module.exports = { module.exports = {
hasRole, hasRole,
getDefaultRole, getDefaultRole,
@@ -143,5 +159,6 @@ module.exports = {
formatDateTime, formatDateTime,
getCalendarWeek, getCalendarWeek,
getWeekDatesFromCalendarWeek, getWeekDatesFromCalendarWeek,
getWeekStart getWeekStart,
formatHoursMin
}; };

View File

@@ -840,6 +840,17 @@ table input[type="text"] {
color: #7f8c8d; color: #7f8c8d;
} }
/* Überstunden-Farbklassen (global genutzt, z. B. Verwaltung & Auswertung) */
.overtime-positive {
color: #27ae60;
font-weight: 600;
}
.overtime-negative {
color: #e74c3c;
font-weight: 600;
}
/* Activities/Tätigkeiten */ /* Activities/Tätigkeiten */
.activities-row { .activities-row {
background-color: #f8f9fa; background-color: #f8f9fa;
@@ -864,7 +875,7 @@ table input[type="text"] {
.activity-row { .activity-row {
display: grid; display: grid;
grid-template-columns: 1fr 150px 120px; grid-template-columns: 1fr 150px 150px;
gap: 15px; gap: 15px;
align-items: center; align-items: center;
} }
@@ -893,7 +904,7 @@ table input[type="text"] {
} }
.activity-hours-input { .activity-hours-input {
width: 80px; width: 64px;
} }
.activity-hours-label { .activity-hours-label {
@@ -901,6 +912,11 @@ table input[type="text"] {
color: #555; color: #555;
} }
.activity-hours-hh-input,
.activity-hours-mm-input {
text-align: center;
}
.activity-project { .activity-project {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -976,6 +992,20 @@ table input[type="text"] {
margin-bottom: 10px; margin-bottom: 10px;
} }
.employee-new-version-warning {
display: inline-block;
margin-left: 10px;
padding: 2px 8px;
font-size: 12px;
font-weight: 700;
color: #e74c3c;
background-color: #fee;
border: 1px solid #e74c3c;
border-radius: 999px;
vertical-align: middle;
line-height: 1.6;
}
.employee-details { .employee-details {
font-size: 14px; font-size: 14px;
color: #666; color: #666;
@@ -1100,6 +1130,11 @@ table input[type="text"] {
} }
} }
/* Pausenfeld: rot nur wenn unter gesetzlicher Mindestpause (Tooltip im HTML) */
input.break-below-legal {
color: #dc3545;
}
/* App Footer */ /* App Footer */
.app-footer { .app-footer {
text-align: center; text-align: center;

View File

@@ -17,6 +17,10 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
const defaultBreakInput = document.getElementById('defaultBreakMinutes');
const defaultBreakVal = defaultBreakInput && defaultBreakInput.value !== '' ? parseInt(defaultBreakInput.value, 10) : 30;
const default_break_minutes = (!isNaN(defaultBreakVal) && defaultBreakVal >= 0) ? defaultBreakVal : 30;
const formData = { const formData = {
username: document.getElementById('username').value, username: document.getElementById('username').value,
password: document.getElementById('password').value, password: document.getElementById('password').value,
@@ -26,7 +30,8 @@ document.addEventListener('DOMContentLoaded', function() {
personalnummer: document.getElementById('personalnummer').value, personalnummer: document.getElementById('personalnummer').value,
wochenstunden: document.getElementById('wochenstunden').value, wochenstunden: document.getElementById('wochenstunden').value,
arbeitstage: document.getElementById('arbeitstage').value, arbeitstage: document.getElementById('arbeitstage').value,
urlaubstage: document.getElementById('urlaubstage').value urlaubstage: document.getElementById('urlaubstage').value,
default_break_minutes: default_break_minutes
}; };
try { try {
@@ -318,6 +323,9 @@ async function saveUser(userId) {
const wochenstunden = row.querySelector('input[data-field="wochenstunden"]').value; const wochenstunden = row.querySelector('input[data-field="wochenstunden"]').value;
const arbeitstage = row.querySelector('input[data-field="arbeitstage"]').value; const arbeitstage = row.querySelector('input[data-field="arbeitstage"]').value;
const urlaubstage = row.querySelector('input[data-field="urlaubstage"]').value; const urlaubstage = row.querySelector('input[data-field="urlaubstage"]').value;
const defaultBreakInput = row.querySelector('input[data-field="default_break_minutes"]');
const default_break_minutes = defaultBreakInput && defaultBreakInput.value !== '' ? parseInt(defaultBreakInput.value, 10) : 30;
const normalizedDefaultBreak = (!isNaN(default_break_minutes) && default_break_minutes >= 0) ? default_break_minutes : 30;
// Rollen aus Checkboxen sammeln // Rollen aus Checkboxen sammeln
const roleCheckboxes = row.querySelectorAll('.role-checkbox:checked'); const roleCheckboxes = row.querySelectorAll('.role-checkbox:checked');
@@ -340,6 +348,7 @@ async function saveUser(userId) {
wochenstunden: wochenstunden || null, wochenstunden: wochenstunden || null,
arbeitstage: arbeitstage || 5, arbeitstage: arbeitstage || 5,
urlaubstage: urlaubstage || null, urlaubstage: urlaubstage || null,
default_break_minutes: normalizedDefaultBreak,
roles: roles roles: roles
}) })
}); });
@@ -351,6 +360,8 @@ async function saveUser(userId) {
row.querySelector('span[data-field="personalnummer"]').textContent = personalnummer || '-'; row.querySelector('span[data-field="personalnummer"]').textContent = personalnummer || '-';
row.querySelector('span[data-field="wochenstunden"]').textContent = wochenstunden || '-'; row.querySelector('span[data-field="wochenstunden"]').textContent = wochenstunden || '-';
row.querySelector('span[data-field="urlaubstage"]').textContent = urlaubstage || '-'; row.querySelector('span[data-field="urlaubstage"]').textContent = urlaubstage || '-';
const defaultBreakDisplay = row.querySelector('span[data-field="default_break_minutes"]');
if (defaultBreakDisplay) defaultBreakDisplay.textContent = normalizedDefaultBreak;
// Rollen-Display aktualisieren // Rollen-Display aktualisieren
const rolesDisplay = row.querySelector('div[data-field="roles"]'); const rolesDisplay = row.querySelector('div[data-field="roles"]');

View File

@@ -6,6 +6,7 @@ let currentHolidayDates = new Set(); // Feiertage der aktuellen Woche (YYYY-MM-D
let userWochenstunden = 0; // Wochenstunden des Users let userWochenstunden = 0; // Wochenstunden des Users
let userArbeitstage = 5; // Arbeitstage pro Woche des Users (Standard: 5) let userArbeitstage = 5; // Arbeitstage pro Woche des Users (Standard: 5)
let weekendPercentages = { saturday: 100, sunday: 100 }; // Wochenend-Prozentsätze (100% = normal) let weekendPercentages = { saturday: 100, sunday: 100 }; // Wochenend-Prozentsätze (100% = normal)
let defaultBreakMinutes = 30; // Standard-Pausenzeit des Mitarbeiters (Vorbelegung)
let latestSubmittedTimesheetId = null; // ID der neuesten eingereichten Version let latestSubmittedTimesheetId = null; // ID der neuesten eingereichten Version
// Wochenend-Prozentsätze laden // Wochenend-Prozentsätze laden
@@ -50,7 +51,7 @@ async function loadUserStats() {
const currentOvertimeEl = document.getElementById('currentOvertime'); const currentOvertimeEl = document.getElementById('currentOvertime');
if (currentOvertimeEl) { if (currentOvertimeEl) {
const overtime = stats.currentOvertime || 0; const overtime = stats.currentOvertime || 0;
currentOvertimeEl.textContent = overtime >= 0 ? `+${overtime.toFixed(2)}` : overtime.toFixed(2); currentOvertimeEl.textContent = (overtime >= 0 ? '+' : '') + formatHoursMin(overtime);
currentOvertimeEl.style.color = overtime >= 0 ? '#27ae60' : '#e74c3c'; currentOvertimeEl.style.color = overtime >= 0 ? '#27ae60' : '#e74c3c';
// Auch die Border-Farbe des Cards anpassen // Auch die Border-Farbe des Cards anpassen
const overtimeCard = currentOvertimeEl.closest('.stat-card'); const overtimeCard = currentOvertimeEl.closest('.stat-card');
@@ -373,19 +374,67 @@ function getFullDayHours() {
return userWochenstunden && userArbeitstage ? (userWochenstunden / userArbeitstage) : 8; return userWochenstunden && userArbeitstage ? (userWochenstunden / userArbeitstage) : 8;
} }
function decimalHoursToParts(decimalHours) {
const parsed = Number(decimalHours);
if (!Number.isFinite(parsed) || parsed <= 0) {
return { hh: '', mm: '' };
}
const totalMinutes = Math.round(parsed * 60);
const hh = Math.floor(totalMinutes / 60);
const mm = totalMinutes % 60;
return {
hh: String(hh),
mm: String(mm).padStart(2, '0')
};
}
function parseActivityHoursFromInputs(hoursInput, minutesInput, fallbackValue) {
const hoursRaw = hoursInput ? hoursInput.value.trim() : '';
const minutesRaw = minutesInput ? minutesInput.value.trim() : '';
if (hoursRaw === '' && minutesRaw === '') {
return 0;
}
const parsedHours = parseInt(hoursRaw, 10);
const parsedMinutes = parseInt(minutesRaw, 10);
const safeHours = Number.isFinite(parsedHours) && parsedHours >= 0 ? parsedHours : 0;
const safeMinutes = Number.isFinite(parsedMinutes) && parsedMinutes >= 0 ? parsedMinutes : 0;
const totalMinutes = (safeHours * 60) + safeMinutes;
if (!Number.isFinite(totalMinutes) || totalMinutes < 0) {
return Number.isFinite(fallbackValue) ? fallbackValue : 0;
}
const normalizedHours = Math.floor(totalMinutes / 60);
const normalizedMinutes = totalMinutes % 60;
if (hoursInput) {
hoursInput.value = totalMinutes > 0 ? String(normalizedHours) : '';
}
if (minutesInput) {
minutesInput.value = totalMinutes > 0 ? String(normalizedMinutes).padStart(2, '0') : '';
}
return totalMinutes / 60;
}
// Woche laden // Woche laden
async function loadWeek() { async function loadWeek() {
try { try {
// User-Daten laden (Wochenstunden, Arbeitstage) // User-Daten laden (Wochenstunden, Arbeitstage, Standard-Pausenzeit)
try { try {
const userResponse = await fetch('/api/user/data'); const userResponse = await fetch('/api/user/data');
const userData = await userResponse.json(); const userData = await userResponse.json();
userWochenstunden = userData.wochenstunden || 0; userWochenstunden = userData.wochenstunden || 0;
userArbeitstage = userData.arbeitstage || 5; userArbeitstage = userData.arbeitstage || 5;
defaultBreakMinutes = userData.default_break_minutes ?? 30;
} catch (error) { } catch (error) {
console.warn('Konnte User-Daten nicht laden:', error); console.warn('Konnte User-Daten nicht laden:', error);
userWochenstunden = 0; userWochenstunden = 0;
userArbeitstage = 5; userArbeitstage = 5;
defaultBreakMinutes = 30;
} }
const parts = currentWeekStart.split('-'); const parts = currentWeekStart.split('-');
@@ -472,7 +521,7 @@ function renderWeek() {
const startTime = entry.start_time || ''; const startTime = entry.start_time || '';
const endTime = entry.end_time || ''; const endTime = entry.end_time || '';
const breakMinutes = entry.break_minutes || 0; const breakMinutes = (entry.break_minutes != null && entry.break_minutes !== '') ? entry.break_minutes : defaultBreakMinutes;
const hours = entry.total_hours || 0; const hours = entry.total_hours || 0;
const overtimeTaken = entry.overtime_taken_hours || ''; const overtimeTaken = entry.overtime_taken_hours || '';
const vacationType = entry.vacation_type || ''; const vacationType = entry.vacation_type || '';
@@ -545,47 +594,52 @@ function renderWeek() {
// Stunden-Anzeige für halben Tag Urlaub berechnen // Stunden-Anzeige für halben Tag Urlaub berechnen
let hoursDisplay = ''; let hoursDisplay = '';
if (isFullDayVacation) { if (isFullDayVacation) {
hoursDisplay = fullDayHours.toFixed(2) + ' h (Urlaub)'; hoursDisplay = formatHoursMin(fullDayHours) + ' (Urlaub)';
} else if (isHalfDayVacation) { } else if (isHalfDayVacation) {
const halfHours = fullDayHours / 2; const halfHours = fullDayHours / 2;
const workHours = hours || 0; // Das sind die gearbeiteten Stunden (ohne Urlaub) const workHours = hours || 0; // Das sind die gearbeiteten Stunden (ohne Urlaub)
const totalHours = halfHours + workHours; const totalHours = halfHours + workHours;
if (workHours > 0.01) { if (workHours > 0.01) {
hoursDisplay = totalHours.toFixed(2) + ' h (' + halfHours.toFixed(2) + ' h Urlaub + ' + workHours.toFixed(2) + ' h)'; hoursDisplay = formatHoursMin(totalHours) + ' (' + formatHoursMin(halfHours) + ' Urlaub + ' + formatHoursMin(workHours) + ')';
} else { } else {
hoursDisplay = halfHours.toFixed(2) + ' h (Urlaub)'; hoursDisplay = formatHoursMin(halfHours) + ' (Urlaub)';
} }
} else if (isSick) { } else if (isSick) {
hoursDisplay = fullDayHours.toFixed(2) + ' h (Krank)'; hoursDisplay = formatHoursMin(fullDayHours) + ' (Krank)';
} else if (isHoliday && isWeekend) { } else if (isHoliday && isWeekend) {
// Feiertag am Wochenende: keine Tagesarbeitsstunden // Feiertag am Wochenende: keine Tagesarbeitsstunden
hoursDisplay = (hours ? hours.toFixed(2) : '0') + ' h (Feiertag)'; hoursDisplay = formatHoursMin(hours || 0) + ' (Feiertag)';
} else if (isHoliday && !hours) { } else if (isHoliday && !hours) {
hoursDisplay = fullDayHours.toFixed(2) + ' h (Feiertag)'; hoursDisplay = formatHoursMin(fullDayHours) + ' (Feiertag)';
} else if (isHoliday && hours) { } else if (isHoliday && hours) {
hoursDisplay = fullDayHours.toFixed(2) + ' + ' + hours.toFixed(2) + ' h (Überst.)'; hoursDisplay = formatHoursMin(fullDayHours) + ' + ' + formatHoursMin(hours) + ' (Überst.)';
} else { } else {
hoursDisplay = hours.toFixed(2) + ' h'; hoursDisplay = formatHoursMin(hours);
} }
const requiredBreak = (startTime && endTime) ? calculateRequiredBreakMinutes(startTime, endTime) : null;
const isBreakBelowLegal = requiredBreak !== null && breakMinutes < requiredBreak;
const breakClass = isBreakBelowLegal ? 'break-below-legal' : '';
const breakTitle = isBreakBelowLegal ? ' title="Die Pausenzeit liegt unterhalb der gesetzlichen Vorgabe."' : '';
return ` return `
<tr> <tr>
<td><strong>${getWeekday(dateStr)}</strong></td> <td><strong>${getWeekday(dateStr)}</strong></td>
<td>${formatDateDE(dateStr)}${isFullDayVacation ? ' <span style="color: #28a745;">(Urlaub - ganzer Tag)</span>' : ''}${isSick ? ' <span style="color: #e74c3c;">(Krank)</span>' : ''}${holidayLabel}</td> <td>${formatDateDE(dateStr)}${isFullDayVacation ? ' <span style="color: #28a745;">(Urlaub - ganzer Tag)</span>' : ''}${isSick ? ' <span style="color: #e74c3c;">(Krank)</span>' : ''}${holidayLabel}</td>
<td> <td>
<input type="time" value="${startTime}" <input type="time" id="start_time_${dateStr}" name="start_time_${dateStr}" value="${startTime}"
data-date="${dateStr}" data-field="start_time" data-date="${dateStr}" data-field="start_time"
step="60" step="60"
${timeFieldsDisabled} ${disabled} oninput="saveEntry(this)" onchange="saveEntry(this)" onblur="saveEntry(this); checkWeekComplete();"> ${timeFieldsDisabled} ${disabled} oninput="saveEntry(this)" onchange="saveEntry(this)" onblur="saveEntry(this); checkWeekComplete();">
</td> </td>
<td> <td>
<input type="time" value="${endTime}" <input type="time" id="end_time_${dateStr}" name="end_time_${dateStr}" value="${endTime}"
data-date="${dateStr}" data-field="end_time" data-date="${dateStr}" data-field="end_time"
step="60" step="60"
${timeFieldsDisabled} ${disabled} oninput="saveEntry(this)" onchange="saveEntry(this)" onblur="saveEntry(this); checkWeekComplete();"> ${timeFieldsDisabled} ${disabled} oninput="saveEntry(this)" onchange="saveEntry(this)" onblur="saveEntry(this); checkWeekComplete();">
</td> </td>
<td> <td>
<input type="number" value="${breakMinutes}" min="0" step="15" <input type="number" id="break_minutes_${dateStr}" name="break_minutes_${dateStr}" value="${breakMinutes}" min="0" step="15" class="${breakClass}"${breakTitle}
data-date="${dateStr}" data-field="break_minutes" data-date="${dateStr}" data-field="break_minutes"
${timeFieldsDisabled} ${disabled} oninput="saveEntry(this)" onchange="saveEntry(this)"> ${timeFieldsDisabled} ${disabled} oninput="saveEntry(this)" onchange="saveEntry(this)">
</td> </td>
@@ -595,7 +649,9 @@ function renderWeek() {
<td colspan="6" class="activities-cell"> <td colspan="6" class="activities-cell">
<div class="activities-form"> <div class="activities-form">
<div class="activities-header"><strong>Tätigkeiten:</strong></div> <div class="activities-header"><strong>Tätigkeiten:</strong></div>
${activities.map((activity, idx) => ` ${activities.map((activity, idx) => {
const timeParts = decimalHoursToParts(activity.hours);
return `
<div class="activity-row"> <div class="activity-row">
<div class="activity-desc"> <div class="activity-desc">
<input type="text" <input type="text"
@@ -618,22 +674,36 @@ function renderWeek() {
class="activity-project-input"> class="activity-project-input">
</div> </div>
<div class="activity-hours"> <div class="activity-hours">
<input type="number" <input type="text"
data-date="${dateStr}" data-date="${dateStr}"
data-field="activity${idx + 1}_hours" data-field="activity${idx + 1}_hours_hh"
value="${activity.hours > 0 ? activity.hours.toFixed(2) : ''}" value="${timeParts.hh}"
min="0" inputmode="numeric"
step="0.25" pattern="[0-9]*"
placeholder="0.00" placeholder="hh"
${timeFieldsDisabled} ${disabled} ${timeFieldsDisabled} ${disabled}
onblur="saveEntry(this)" onblur="saveEntry(this)"
oninput="updateOvertimeDisplay();" oninput="updateOvertimeDisplay();"
onchange="updateOvertimeDisplay();" onchange="updateOvertimeDisplay();"
class="activity-hours-input"> class="activity-hours-input activity-hours-hh-input">
<span class="activity-hours-label">h</span> <span class="activity-hours-label">h</span>
<input type="text"
data-date="${dateStr}"
data-field="activity${idx + 1}_hours_mm"
value="${timeParts.mm}"
inputmode="numeric"
pattern="[0-9]*"
placeholder="mm"
${timeFieldsDisabled} ${disabled}
onblur="saveEntry(this)"
oninput="updateOvertimeDisplay();"
onchange="updateOvertimeDisplay();"
class="activity-hours-input activity-hours-mm-input">
<span class="activity-hours-label">min</span>
</div> </div>
</div> </div>
`).join('')} `;
}).join('')}
</div> </div>
<div class="overtime-vacation-controls" style="margin-top: 15px; display: flex; gap: 15px; align-items: center;"> <div class="overtime-vacation-controls" style="margin-top: 15px; display: flex; gap: 15px; align-items: center;">
<div class="overtime-control"> <div class="overtime-control">
@@ -733,7 +803,7 @@ function renderWeek() {
`; `;
document.getElementById('timesheetTable').innerHTML = html; document.getElementById('timesheetTable').innerHTML = html;
document.getElementById('totalHours').textContent = totalHours.toFixed(2) + ' h'; document.getElementById('totalHours').textContent = formatHoursMin(totalHours);
// Überstunden-Berechnung (startDate und endDate sind bereits oben deklariert) // Überstunden-Berechnung (startDate und endDate sind bereits oben deklariert)
@@ -995,13 +1065,9 @@ function updateOvertimeDisplay() {
} }
} }
// Überstunden berechnen (wie im Backend: mit adjustedSollStunden) // Variante B: Überstunden/Fehlstunden = Gesamt Soll (Soll immer vertraglich)
// totalHours enthält bereits Feiertagsstunden (8h oder gearbeitete Stunden) aus dem Feiertag-Zweig oben
const totalHoursWithVacation = totalHours + vacationHours; const totalHoursWithVacation = totalHours + vacationHours;
const adjustedSollStunden = sollStunden - (fullDayOvertimeDays * fullDayHours); const overtimeHours = totalHoursWithVacation - sollStunden;
// overtimeHours = Überstunden diese Woche (wie im Backend berechnet)
// Genommene Überstunden werden abgezogen, um die Netto-Überstunden zu erhalten
const overtimeHours = totalHoursWithVacation - adjustedSollStunden - overtimeTaken;
// Überstunden-Anzeige aktualisieren // Überstunden-Anzeige aktualisieren
const overtimeSummaryItem = document.getElementById('overtimeSummaryItem'); const overtimeSummaryItem = document.getElementById('overtimeSummaryItem');
@@ -1009,14 +1075,14 @@ function updateOvertimeDisplay() {
if (overtimeSummaryItem && overtimeHoursSpan) { if (overtimeSummaryItem && overtimeHoursSpan) {
overtimeSummaryItem.style.display = 'block'; overtimeSummaryItem.style.display = 'block';
const sign = overtimeHours >= 0 ? '+' : ''; const sign = overtimeHours >= 0 ? '+' : '';
overtimeHoursSpan.textContent = `${sign}${overtimeHours.toFixed(2)} h`; overtimeHoursSpan.textContent = (sign === '+' ? '+' : '') + formatHoursMin(overtimeHours);
overtimeHoursSpan.style.color = overtimeHours >= 0 ? '#27ae60' : '#e74c3c'; overtimeHoursSpan.style.color = overtimeHours >= 0 ? '#27ae60' : '#e74c3c';
} }
// Gesamtstunden-Anzeige aktualisieren // Gesamtstunden-Anzeige aktualisieren
const totalHoursElement = document.getElementById('totalHours'); const totalHoursElement = document.getElementById('totalHours');
if (totalHoursElement) { if (totalHoursElement) {
totalHoursElement.textContent = totalHoursWithVacation.toFixed(2) + ' h'; totalHoursElement.textContent = formatHoursMin(totalHoursWithVacation);
} }
} }
@@ -1034,7 +1100,8 @@ function handleOvertimeChange(dateStr, overtimeHours) {
// (Überstunden werden nur im PDF angezeigt, nicht als Tätigkeit) // (Überstunden werden nur im PDF angezeigt, nicht als Tätigkeit)
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
const descInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_desc"]`); const descInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_desc"]`);
const hoursInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_hours"]`); const hoursInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_hours_hh"]`);
const minutesInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_hours_mm"]`);
if (descInput && descInput.value && descInput.value.trim().toLowerCase() === 'überstunden') { if (descInput && descInput.value && descInput.value.trim().toLowerCase() === 'überstunden') {
descInput.value = ''; descInput.value = '';
@@ -1043,6 +1110,10 @@ function handleOvertimeChange(dateStr, overtimeHours) {
hoursInput.value = ''; hoursInput.value = '';
saveEntry(hoursInput); saveEntry(hoursInput);
} }
if (minutesInput) {
minutesInput.value = '';
saveEntry(minutesInput);
}
} }
} }
@@ -1087,6 +1158,26 @@ function calculateRequiredBreakMinutes(startTime, endTime) {
return 0; // Weniger als 6 Stunden: keine gesetzliche Pause erforderlich return 0; // Weniger als 6 Stunden: keine gesetzliche Pause erforderlich
} }
// Aktualisiert die visuelle Kennzeichnung (nur rot + Tooltip wenn unter gesetzlicher Mindestpause)
function updateBreakCompliance(dateStr) {
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"]`);
if (!breakInput) return;
breakInput.classList.remove('break-below-legal');
breakInput.removeAttribute('title');
const startTime = startInput && startInput.value ? startInput.value.trim() : '';
const endTime = endInput && endInput.value ? endInput.value.trim() : '';
if (!startTime || !endTime) return;
const required = calculateRequiredBreakMinutes(startTime, endTime);
if (required === null) return;
const breakVal = breakInput.value ? (parseInt(breakInput.value, 10) || 0) : 0;
if (breakVal < required) {
breakInput.classList.add('break-below-legal');
breakInput.setAttribute('title', 'Die Pausenzeit liegt unterhalb der gesetzlichen Vorgabe.');
}
}
// Eintrag speichern // Eintrag speichern
async function saveEntry(input) { async function saveEntry(input) {
const date = input.dataset.date; const date = input.dataset.date;
@@ -1131,21 +1222,19 @@ async function saveEntry(input) {
// Wichtig: Leere Strings werden zu null konvertiert, aber ein Wert sollte vorhanden sein // Wichtig: Leere Strings werden zu null konvertiert, aber ein Wert sollte vorhanden sein
const start_time = actualStartTime; const start_time = actualStartTime;
const end_time = actualEndTime; const end_time = actualEndTime;
let break_minutes = breakInput && breakInput.value ? (parseInt(breakInput.value) || 0) : (parseInt(currentEntries[date].break_minutes) || 0); let break_minutes = defaultBreakMinutes;
if (breakInput && breakInput.value !== '') {
const parsedBreak = parseInt(breakInput.value, 10);
break_minutes = Number.isFinite(parsedBreak) && parsedBreak >= 0 ? parsedBreak : defaultBreakMinutes;
} else if (
currentEntries[date].break_minutes !== null &&
currentEntries[date].break_minutes !== undefined &&
currentEntries[date].break_minutes !== ''
) {
const parsedStoredBreak = parseInt(currentEntries[date].break_minutes, 10);
break_minutes = Number.isFinite(parsedStoredBreak) && parsedStoredBreak >= 0 ? parsedStoredBreak : defaultBreakMinutes;
}
// Automatische Vorbelegung der Pausenzeiten basierend auf gesetzlichen Vorgaben
// Wird ausgelöst, wenn start_time oder end_time geändert werden
if ((input.dataset.field === 'start_time' || input.dataset.field === 'end_time') && start_time && end_time) {
const requiredBreakMinutes = calculateRequiredBreakMinutes(start_time, end_time);
if (requiredBreakMinutes !== null && requiredBreakMinutes > break_minutes) {
// Setze den höheren Wert (gesetzliche Mindestpause)
break_minutes = requiredBreakMinutes;
// Aktualisiere das Input-Feld im DOM
if (breakInput) {
breakInput.value = break_minutes;
}
}
}
const notes = notesInput ? (notesInput.value || '') : (currentEntries[date].notes || ''); const notes = notesInput ? (notesInput.value || '') : (currentEntries[date].notes || '');
const vacation_type = vacationSelect && vacationSelect.value ? vacationSelect.value : (currentEntries[date].vacation_type || null); const vacation_type = vacationSelect && vacationSelect.value ? vacationSelect.value : (currentEntries[date].vacation_type || null);
const overtime_taken_hours = overtimeInput && overtimeInput.value ? overtimeInput.value : (currentEntries[date].overtime_taken_hours || null); const overtime_taken_hours = overtimeInput && overtimeInput.value ? overtimeInput.value : (currentEntries[date].overtime_taken_hours || null);
@@ -1157,12 +1246,16 @@ async function saveEntry(input) {
const activities = []; const activities = [];
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
const descInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_desc"]`); const descInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_desc"]`);
const hoursInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_hours"]`); const hoursInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_hours_hh"]`);
const minutesInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_hours_mm"]`);
const projectInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_project_number"]`); const projectInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_project_number"]`);
const fallbackHours = parseFloat(currentEntries[date][`activity${i}_hours`]) || 0;
activities.push({ activities.push({
desc: descInput ? (descInput.value || null) : (currentEntries[date][`activity${i}_desc`] || null), desc: descInput ? (descInput.value || null) : (currentEntries[date][`activity${i}_desc`] || null),
hours: hoursInput ? (parseFloat(hoursInput.value) || 0) : (parseFloat(currentEntries[date][`activity${i}_hours`]) || 0), hours: (hoursInput || minutesInput)
? parseActivityHoursFromInputs(hoursInput, minutesInput, fallbackHours)
: fallbackHours,
projectNumber: projectInput ? (projectInput.value || null) : (currentEntries[date][`activity${i}_project_number`] || null) projectNumber: projectInput ? (projectInput.value || null) : (currentEntries[date][`activity${i}_project_number`] || null)
}); });
} }
@@ -1257,7 +1350,7 @@ async function saveEntry(input) {
if (hoursElement) { if (hoursElement) {
if (isFullDayVacation) { if (isFullDayVacation) {
// Ganzer Tag Urlaub: Zeige fullDayHours mit "(Urlaub)" Label // Ganzer Tag Urlaub: Zeige fullDayHours mit "(Urlaub)" Label
hoursElement.textContent = fullDayHours.toFixed(2) + ' h (Urlaub)'; hoursElement.textContent = formatHoursMin(fullDayHours) + ' (Urlaub)';
currentEntries[date].total_hours = fullDayHours; currentEntries[date].total_hours = fullDayHours;
} else if (isHalfDayVacation) { } else if (isHalfDayVacation) {
// Halber Tag Urlaub: Berechne Stunden aus Start/Ende falls vorhanden // Halber Tag Urlaub: Berechne Stunden aus Start/Ende falls vorhanden
@@ -1278,9 +1371,9 @@ async function saveEntry(input) {
const totalHours = halfHours + workHours; const totalHours = halfHours + workHours;
if (workHours > 0) { if (workHours > 0) {
hoursElement.textContent = totalHours.toFixed(2) + ' h (' + halfHours.toFixed(2) + ' h Urlaub + ' + workHours.toFixed(2) + ' h)'; hoursElement.textContent = formatHoursMin(totalHours) + ' (' + formatHoursMin(halfHours) + ' Urlaub + ' + formatHoursMin(workHours) + ')';
} else { } else {
hoursElement.textContent = halfHours.toFixed(2) + ' h (Urlaub)'; hoursElement.textContent = formatHoursMin(halfHours) + ' (Urlaub)';
} }
currentEntries[date].total_hours = totalHours; currentEntries[date].total_hours = totalHours;
} else { } else {
@@ -1288,15 +1381,15 @@ async function saveEntry(input) {
const d = new Date(date); const d = new Date(date);
const isWeekendHoliday = isHoliday && (d.getDay() === 6 || d.getDay() === 0); const isWeekendHoliday = isHoliday && (d.getDay() === 6 || d.getDay() === 0);
if (isSick) { if (isSick) {
hoursElement.textContent = fullDayHours.toFixed(2) + ' h (Krank)'; hoursElement.textContent = formatHoursMin(fullDayHours) + ' (Krank)';
} else if (isWeekendHoliday) { } else if (isWeekendHoliday) {
hoursElement.textContent = (hours ? hours.toFixed(2) : '0') + ' h (Feiertag)'; hoursElement.textContent = formatHoursMin(hours || 0) + ' (Feiertag)';
} else if (isHoliday && !hours) { } else if (isHoliday && !hours) {
hoursElement.textContent = fullDayHours.toFixed(2) + ' h (Feiertag)'; hoursElement.textContent = formatHoursMin(fullDayHours) + ' (Feiertag)';
} else if (isHoliday && hours) { } else if (isHoliday && hours) {
hoursElement.textContent = fullDayHours.toFixed(2) + ' + ' + hours.toFixed(2) + ' h (Überst.)'; hoursElement.textContent = formatHoursMin(fullDayHours) + ' + ' + formatHoursMin(hours) + ' (Überst.)';
} else { } else {
hoursElement.textContent = hours.toFixed(2) + ' h'; hoursElement.textContent = formatHoursMin(hours);
} }
} }
} }
@@ -1326,21 +1419,27 @@ async function saveEntry(input) {
// 5. Bei ganztägigem Urlaub: Setze "Urlaub" als erste Tätigkeit und leere andere // 5. Bei ganztägigem Urlaub: Setze "Urlaub" als erste Tätigkeit und leere andere
if (isFullDayVacation) { if (isFullDayVacation) {
const descInput = document.querySelector(`input[data-date="${date}"][data-field="activity1_desc"]`); const descInput = document.querySelector(`input[data-date="${date}"][data-field="activity1_desc"]`);
const hoursInput = document.querySelector(`input[data-date="${date}"][data-field="activity1_hours"]`); const hoursInput = document.querySelector(`input[data-date="${date}"][data-field="activity1_hours_hh"]`);
const minutesInput = document.querySelector(`input[data-date="${date}"][data-field="activity1_hours_mm"]`);
if (descInput) { if (descInput) {
descInput.value = 'Urlaub'; descInput.value = 'Urlaub';
currentEntries[date].activity1_desc = 'Urlaub'; currentEntries[date].activity1_desc = 'Urlaub';
} }
if (hoursInput) { if (hoursInput) {
hoursInput.value = fullDayHours.toFixed(2); const fullDayParts = decimalHoursToParts(fullDayHours);
hoursInput.value = fullDayParts.hh;
if (minutesInput) {
minutesInput.value = fullDayParts.mm;
}
currentEntries[date].activity1_hours = fullDayHours; currentEntries[date].activity1_hours = fullDayHours;
} }
// Leere andere Tätigkeiten // Leere andere Tätigkeiten
for (let i = 2; i <= 5; i++) { for (let i = 2; i <= 5; i++) {
const descInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_desc"]`); const descInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_desc"]`);
const hoursInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_hours"]`); const hoursInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_hours_hh"]`);
const minutesInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_hours_mm"]`);
const projectInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_project_number"]`); const projectInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_project_number"]`);
if (descInput) { if (descInput) {
@@ -1349,6 +1448,9 @@ async function saveEntry(input) {
} }
if (hoursInput) { if (hoursInput) {
hoursInput.value = ''; hoursInput.value = '';
if (minutesInput) {
minutesInput.value = '';
}
currentEntries[date][`activity${i}_hours`] = 0; currentEntries[date][`activity${i}_hours`] = 0;
} }
if (projectInput) { if (projectInput) {
@@ -1360,7 +1462,8 @@ async function saveEntry(input) {
// Bei Abwahl von Urlaub (nicht full): Alle Tätigkeitsfelder leeren // Bei Abwahl von Urlaub (nicht full): Alle Tätigkeitsfelder leeren
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
const descInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_desc"]`); const descInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_desc"]`);
const hoursInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_hours"]`); const hoursInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_hours_hh"]`);
const minutesInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_hours_mm"]`);
const projectInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_project_number"]`); const projectInput = document.querySelector(`input[data-date="${date}"][data-field="activity${i}_project_number"]`);
if (descInput) { if (descInput) {
@@ -1369,6 +1472,9 @@ async function saveEntry(input) {
} }
if (hoursInput) { if (hoursInput) {
hoursInput.value = ''; hoursInput.value = '';
if (minutesInput) {
minutesInput.value = '';
}
currentEntries[date][`activity${i}_hours`] = 0; currentEntries[date][`activity${i}_hours`] = 0;
} }
if (projectInput) { if (projectInput) {
@@ -1431,10 +1537,10 @@ async function saveEntry(input) {
const isHalfDayVacation = vacationType === 'half'; const isHalfDayVacation = vacationType === 'half';
const fullDayHours = getFullDayHours(); const fullDayHours = getFullDayHours();
let hoursText = result.total_hours.toFixed(2) + ' h'; let hoursText = formatHoursMin(result.total_hours);
if (isFullDayVacation) { if (isFullDayVacation) {
hoursText = fullDayHours.toFixed(2) + ' h (Urlaub)'; hoursText = formatHoursMin(fullDayHours) + ' (Urlaub)';
} else if (isHalfDayVacation) { } else if (isHalfDayVacation) {
// Bei halbem Tag Urlaub: result.total_hours enthält nur die gearbeiteten Stunden // Bei halbem Tag Urlaub: result.total_hours enthält nur die gearbeiteten Stunden
// Die Urlaubsstunden müssen addiert werden // Die Urlaubsstunden müssen addiert werden
@@ -1443,25 +1549,25 @@ async function saveEntry(input) {
const totalHours = halfHours + workHours; // Gesamt = Urlaub + gearbeitet const totalHours = halfHours + workHours; // Gesamt = Urlaub + gearbeitet
if (workHours > 0.01) { if (workHours > 0.01) {
hoursText = totalHours.toFixed(2) + ' h (' + halfHours.toFixed(2) + ' h Urlaub + ' + workHours.toFixed(2) + ' h)'; hoursText = formatHoursMin(totalHours) + ' (' + formatHoursMin(halfHours) + ' Urlaub + ' + formatHoursMin(workHours) + ')';
} else { } else {
hoursText = halfHours.toFixed(2) + ' h (Urlaub)'; hoursText = formatHoursMin(halfHours) + ' (Urlaub)';
} }
// Aktualisiere currentEntries mit den Gesamtstunden // Aktualisiere currentEntries mit den Gesamtstunden
currentEntries[date].total_hours = totalHours; currentEntries[date].total_hours = totalHours;
} else if (isSick) { } else if (isSick) {
hoursText = fullDayHours.toFixed(2) + ' h (Krank)'; hoursText = formatHoursMin(fullDayHours) + ' (Krank)';
} else if (isHoliday) { } else if (isHoliday) {
const d = new Date(date); const d = new Date(date);
const isWeekendHoliday = (d.getDay() === 6 || d.getDay() === 0); const isWeekendHoliday = (d.getDay() === 6 || d.getDay() === 0);
if (isWeekendHoliday) { if (isWeekendHoliday) {
hoursText = (result.total_hours || 0).toFixed(2) + ' h (Feiertag)'; hoursText = formatHoursMin(result.total_hours || 0) + ' (Feiertag)';
} else if (result.total_hours <= fullDayHours) { } else if (result.total_hours <= fullDayHours) {
hoursText = fullDayHours.toFixed(2) + ' h (Feiertag)'; hoursText = formatHoursMin(fullDayHours) + ' (Feiertag)';
} else { } else {
const overtime = result.total_hours - fullDayHours; const overtime = result.total_hours - fullDayHours;
hoursText = fullDayHours.toFixed(2) + ' + ' + overtime.toFixed(2) + ' h (Überst.)'; hoursText = formatHoursMin(fullDayHours) + ' + ' + formatHoursMin(overtime) + ' (Überst.)';
} }
} }
@@ -1477,7 +1583,7 @@ async function saveEntry(input) {
Object.values(currentEntries).forEach(e => { Object.values(currentEntries).forEach(e => {
totalHours += e.total_hours || 0; totalHours += e.total_hours || 0;
}); });
document.getElementById('totalHours').textContent = totalHours.toFixed(2) + ' h'; document.getElementById('totalHours').textContent = formatHoursMin(totalHours);
// Überstunden-Anzeige aktualisieren (bei jeder Änderung) // Überstunden-Anzeige aktualisieren (bei jeder Änderung)
updateOvertimeDisplay(); updateOvertimeDisplay();
@@ -1490,6 +1596,10 @@ async function saveEntry(input) {
// Submit-Button Status prüfen (nach jedem Speichern) // Submit-Button Status prüfen (nach jedem Speichern)
checkWeekComplete(); checkWeekComplete();
if (field === 'start_time' || field === 'end_time' || field === 'break_minutes') {
updateBreakCompliance(date);
}
// Visuelles Feedback // Visuelles Feedback
input.style.backgroundColor = '#d4edda'; input.style.backgroundColor = '#d4edda';
setTimeout(() => { setTimeout(() => {
@@ -1572,7 +1682,7 @@ function checkWeekComplete() {
if (overtimeValue > fullDayHours) { if (overtimeValue > fullDayHours) {
if (!startTime || !endTime || startTime === '' || endTime === '') { if (!startTime || !endTime || startTime === '' || endTime === '') {
allWeekdaysFilled = false; allWeekdaysFilled = false;
missingFields.push(formatDateDE(dateStr) + ' (bei Überstunden > ' + fullDayHours.toFixed(2) + 'h müssen Start/Ende vorhanden sein)'); missingFields.push(formatDateDE(dateStr) + ' (bei Überstunden > ' + formatHoursMin(fullDayHours) + ' müssen Start/Ende vorhanden sein)');
continue; // Weiter zum nächsten Tag continue; // Weiter zum nächsten Tag
} }
} }
@@ -2058,22 +2168,22 @@ function toggleSickStatus(dateStr) {
if (newStatus) { if (newStatus) {
// Krank: Zeige fullDayHours mit "(Krank)" Label // Krank: Zeige fullDayHours mit "(Krank)" Label
hoursElement.textContent = fullDayHours.toFixed(2) + ' h (Krank)'; hoursElement.textContent = formatHoursMin(fullDayHours) + ' (Krank)';
currentEntries[dateStr].total_hours = fullDayHours; currentEntries[dateStr].total_hours = fullDayHours;
} else { } else {
// Zurück zu normaler Anzeige basierend auf anderen Status // Zurück zu normaler Anzeige basierend auf anderen Status
const d = new Date(dateStr); const d = new Date(dateStr);
const isWeekendHoliday = isHoliday && (d.getDay() === 6 || d.getDay() === 0); const isWeekendHoliday = isHoliday && (d.getDay() === 6 || d.getDay() === 0);
if (isFullDayVacation) { if (isFullDayVacation) {
hoursElement.textContent = fullDayHours.toFixed(2) + ' h (Urlaub)'; hoursElement.textContent = formatHoursMin(fullDayHours) + ' (Urlaub)';
} else if (isWeekendHoliday) { } else if (isWeekendHoliday) {
hoursElement.textContent = (hours ? hours.toFixed(2) : '0') + ' h (Feiertag)'; hoursElement.textContent = formatHoursMin(hours || 0) + ' (Feiertag)';
} else if (isHoliday && !hours) { } else if (isHoliday && !hours) {
hoursElement.textContent = fullDayHours.toFixed(2) + ' h (Feiertag)'; hoursElement.textContent = formatHoursMin(fullDayHours) + ' (Feiertag)';
} else if (isHoliday && hours) { } else if (isHoliday && hours) {
hoursElement.textContent = fullDayHours.toFixed(2) + ' + ' + hours.toFixed(2) + ' h (Überst.)'; hoursElement.textContent = formatHoursMin(fullDayHours) + ' + ' + formatHoursMin(hours) + ' (Überst.)';
} else { } else {
hoursElement.textContent = hours.toFixed(2) + ' h'; hoursElement.textContent = formatHoursMin(hours);
} }
} }
} }
@@ -2103,7 +2213,8 @@ function toggleSickStatus(dateStr) {
// Leere alle Tätigkeitsfelder // Leere alle Tätigkeitsfelder
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
const descInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_desc"]`); const descInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_desc"]`);
const hoursInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_hours"]`); const hoursInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_hours_hh"]`);
const minutesInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_hours_mm"]`);
const projectInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_project_number"]`); const projectInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_project_number"]`);
if (descInput) { if (descInput) {
@@ -2112,6 +2223,9 @@ function toggleSickStatus(dateStr) {
} }
if (hoursInput) { if (hoursInput) {
hoursInput.value = ''; hoursInput.value = '';
if (minutesInput) {
minutesInput.value = '';
}
currentEntries[dateStr][`activity${i}_hours`] = 0; currentEntries[dateStr][`activity${i}_hours`] = 0;
} }
if (projectInput) { if (projectInput) {
@@ -2122,7 +2236,8 @@ function toggleSickStatus(dateStr) {
} else { } else {
// Bei Aktivierung: Setze "Krank" als erste Tätigkeit und leere andere // Bei Aktivierung: Setze "Krank" als erste Tätigkeit und leere andere
const descInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity1_desc"]`); const descInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity1_desc"]`);
const hoursInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity1_hours"]`); const hoursInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity1_hours_hh"]`);
const minutesInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity1_hours_mm"]`);
const fullDayHours = getFullDayHours(); const fullDayHours = getFullDayHours();
if (descInput) { if (descInput) {
@@ -2130,14 +2245,19 @@ function toggleSickStatus(dateStr) {
currentEntries[dateStr].activity1_desc = 'Krank'; currentEntries[dateStr].activity1_desc = 'Krank';
} }
if (hoursInput) { if (hoursInput) {
hoursInput.value = fullDayHours.toFixed(2); const fullDayParts = decimalHoursToParts(fullDayHours);
hoursInput.value = fullDayParts.hh;
if (minutesInput) {
minutesInput.value = fullDayParts.mm;
}
currentEntries[dateStr].activity1_hours = fullDayHours; currentEntries[dateStr].activity1_hours = fullDayHours;
} }
// Leere andere Tätigkeiten // Leere andere Tätigkeiten
for (let i = 2; i <= 5; i++) { for (let i = 2; i <= 5; i++) {
const descInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_desc"]`); const descInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_desc"]`);
const hoursInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_hours"]`); const hoursInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_hours_hh"]`);
const minutesInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_hours_mm"]`);
const projectInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_project_number"]`); const projectInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_project_number"]`);
if (descInput) { if (descInput) {
@@ -2146,6 +2266,9 @@ function toggleSickStatus(dateStr) {
} }
if (hoursInput) { if (hoursInput) {
hoursInput.value = ''; hoursInput.value = '';
if (minutesInput) {
minutesInput.value = '';
}
currentEntries[dateStr][`activity${i}_hours`] = 0; currentEntries[dateStr][`activity${i}_hours`] = 0;
} }
if (projectInput) { if (projectInput) {

79
public/js/format-hours.js Normal file
View File

@@ -0,0 +1,79 @@
// Gleiche Logik wie helpers/utils.js formatHoursMin für Browser (Dashboard, EJS-Seiten).
// Wird global als window.formatHoursMin bereitgestellt.
(function () {
function formatHoursMin(decimalHours) {
if (decimalHours == null || !Number.isFinite(Number(decimalHours))) return '0h 0min';
var n = Number(decimalHours);
var sign = n < 0 ? -1 : 1;
var absVal = Math.abs(n);
var h = Math.floor(absVal);
var min = Math.round((absVal - h) * 60);
if (min >= 60) {
h += 1;
min = 0;
}
var prefix = sign < 0 ? '-' : '';
return prefix + h + 'h ' + min + 'min';
}
/** Dezimalstunden in h:mm (z. B. 1.5 -> "1:30", -0.75 -> "-0:45") */
function decimalHoursToHhMm(decimalHours) {
if (decimalHours == null || !Number.isFinite(Number(decimalHours))) return '0:00';
var n = Number(decimalHours);
var sign = n < 0 ? -1 : 1;
var absVal = Math.abs(n);
var h = Math.floor(absVal);
var min = Math.round((absVal - h) * 60);
if (min >= 60) {
h += 1;
min = 0;
}
var prefix = sign < 0 ? '-' : '';
return prefix + h + ':' + String(min).padStart(2, '0');
}
/**
* Parst h:mm oder Xh Ymin zu Dezimalstunden.
* Beispiele: "1:30" -> 1.5, "-0:45" -> -0.75, "1h 30min" -> 1.5.
* @returns {number|null} Dezimalstunden oder null bei ungültiger Eingabe
*/
function parseHoursMin(str) {
if (str == null) return null;
var s = String(str).trim();
if (s === '') return 0;
var sign = 1;
if (s.charAt(0) === '-') {
sign = -1;
s = s.slice(1).trim();
} else if (s.charAt(0) === '+') {
s = s.slice(1).trim();
}
// h:mm oder h:mm:ss
var colonMatch = s.match(/^(\d+):(\d{1,2})(?::(\d{1,2}))?$/);
if (colonMatch) {
var hours = parseInt(colonMatch[1], 10);
var minutes = parseInt(colonMatch[2], 10);
if (minutes >= 60) return null;
var sec = colonMatch[3] != null ? parseInt(colonMatch[3], 10) : 0;
if (sec >= 60) return null;
var decimal = hours + minutes / 60 + sec / 3600;
return sign * decimal;
}
// Xh Ymin (optional Leerzeichen)
var hmMatch = s.match(/^(\d+)\s*h\s*(\d{1,2})?\s*min$/i);
if (hmMatch) {
var h = parseInt(hmMatch[1], 10);
var m = (hmMatch[2] != null) ? parseInt(hmMatch[2], 10) : 0;
if (m >= 60) return null;
return sign * (h + m / 60);
}
// Nur Zahl (Dezimalstunden) zulassen als Fallback
var num = parseFloat(s.replace(',', '.'));
if (!Number.isFinite(num)) return null;
return sign * num;
}
window.formatHoursMin = formatHoursMin;
window.decimalHoursToHhMm = decimalHoursToHhMm;
window.parseHoursMin = parseHoursMin;
})();

View File

@@ -8,7 +8,7 @@ const { requireAdmin } = require('../middleware/auth');
function registerAdminRoutes(app) { function registerAdminRoutes(app) {
// Admin-Bereich // Admin-Bereich
app.get('/admin', requireAdmin, (req, res) => { app.get('/admin', requireAdmin, (req, res) => {
db.all('SELECT id, username, firstname, lastname, role, personalnummer, wochenstunden, urlaubstage, arbeitstage, created_at FROM users ORDER BY created_at DESC', db.all('SELECT id, username, firstname, lastname, role, personalnummer, wochenstunden, urlaubstage, arbeitstage, default_break_minutes, created_at FROM users ORDER BY created_at DESC',
(err, users) => { (err, users) => {
// LDAP-Konfiguration, Sync-Log und Optionen abrufen // LDAP-Konfiguration, Sync-Log und Optionen abrufen
db.get('SELECT * FROM ldap_config WHERE id = 1', (err, ldapConfig) => { db.get('SELECT * FROM ldap_config WHERE id = 1', (err, ldapConfig) => {
@@ -48,7 +48,7 @@ function registerAdminRoutes(app) {
// Benutzer erstellen // Benutzer erstellen
app.post('/admin/users', requireAdmin, (req, res) => { app.post('/admin/users', requireAdmin, (req, res) => {
const { username, password, firstname, lastname, roles, personalnummer, wochenstunden, urlaubstage, arbeitstage } = req.body; const { username, password, firstname, lastname, roles, personalnummer, wochenstunden, urlaubstage, arbeitstage, default_break_minutes } = req.body;
const hashedPassword = bcrypt.hashSync(password, 10); const hashedPassword = bcrypt.hashSync(password, 10);
// Normalisiere die optionalen Felder // Normalisiere die optionalen Felder
@@ -56,6 +56,8 @@ function registerAdminRoutes(app) {
const normalizedWochenstunden = wochenstunden && wochenstunden !== '' ? parseFloat(wochenstunden) : null; const normalizedWochenstunden = wochenstunden && wochenstunden !== '' ? parseFloat(wochenstunden) : null;
const normalizedUrlaubstage = urlaubstage && urlaubstage !== '' ? parseFloat(urlaubstage) : null; const normalizedUrlaubstage = urlaubstage && urlaubstage !== '' ? parseFloat(urlaubstage) : null;
const normalizedArbeitstage = arbeitstage && arbeitstage !== '' ? parseInt(arbeitstage) : 5; const normalizedArbeitstage = arbeitstage && arbeitstage !== '' ? parseInt(arbeitstage) : 5;
const parsedBreak = default_break_minutes !== undefined && default_break_minutes !== '' ? parseInt(default_break_minutes, 10) : 30;
const normalizedDefaultBreak = (!isNaN(parsedBreak) && parsedBreak >= 0) ? parsedBreak : 30;
// Rollen verarbeiten: Erwarte Array, konvertiere zu JSON-String // Rollen verarbeiten: Erwarte Array, konvertiere zu JSON-String
let rolesArray = []; let rolesArray = [];
@@ -73,8 +75,8 @@ function registerAdminRoutes(app) {
const rolesJson = JSON.stringify(rolesArray); const rolesJson = JSON.stringify(rolesArray);
db.run('INSERT INTO users (username, password, firstname, lastname, role, personalnummer, wochenstunden, urlaubstage, arbeitstage) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', db.run('INSERT INTO users (username, password, firstname, lastname, role, personalnummer, wochenstunden, urlaubstage, arbeitstage, default_break_minutes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[username, hashedPassword, firstname, lastname, rolesJson, normalizedPersonalnummer, normalizedWochenstunden, normalizedUrlaubstage, normalizedArbeitstage], [username, hashedPassword, firstname, lastname, rolesJson, normalizedPersonalnummer, normalizedWochenstunden, normalizedUrlaubstage, normalizedArbeitstage, normalizedDefaultBreak],
(err) => { (err) => {
if (err) { if (err) {
return res.status(400).json({ error: 'Benutzername existiert bereits' }); return res.status(400).json({ error: 'Benutzername existiert bereits' });
@@ -100,10 +102,13 @@ function registerAdminRoutes(app) {
}); });
}); });
// Benutzer aktualisieren (Personalnummer, Wochenstunden, Urlaubstage, Rollen) // Benutzer aktualisieren (Personalnummer, Wochenstunden, Urlaubstage, Rollen, Standard-Pause)
app.put('/admin/users/:id', requireAdmin, (req, res) => { app.put('/admin/users/:id', requireAdmin, (req, res) => {
const userId = req.params.id; const userId = req.params.id;
const { personalnummer, wochenstunden, urlaubstage, arbeitstage, roles } = req.body; const { personalnummer, wochenstunden, urlaubstage, arbeitstage, roles, default_break_minutes } = req.body;
const parsedBreak = default_break_minutes !== undefined && default_break_minutes !== '' ? parseInt(default_break_minutes, 10) : 30;
const normalizedDefaultBreak = (!isNaN(parsedBreak) && parsedBreak >= 0) ? parsedBreak : 30;
// Rollen verarbeiten falls vorhanden // Rollen verarbeiten falls vorhanden
let rolesJson = null; let rolesJson = null;
@@ -122,12 +127,13 @@ function registerAdminRoutes(app) {
// SQL-Query dynamisch zusammenstellen // SQL-Query dynamisch zusammenstellen
if (rolesJson !== null) { if (rolesJson !== null) {
// Aktualisiere auch Rollen // Aktualisiere auch Rollen
db.run('UPDATE users SET personalnummer = ?, wochenstunden = ?, urlaubstage = ?, arbeitstage = ?, role = ? WHERE id = ?', db.run('UPDATE users SET personalnummer = ?, wochenstunden = ?, urlaubstage = ?, arbeitstage = ?, default_break_minutes = ?, role = ? WHERE id = ?',
[ [
personalnummer || null, personalnummer || null,
wochenstunden ? parseFloat(wochenstunden) : null, wochenstunden ? parseFloat(wochenstunden) : null,
urlaubstage ? parseFloat(urlaubstage) : null, urlaubstage ? parseFloat(urlaubstage) : null,
arbeitstage ? parseInt(arbeitstage) : 5, arbeitstage ? parseInt(arbeitstage) : 5,
normalizedDefaultBreak,
rolesJson, rolesJson,
userId userId
], ],
@@ -139,12 +145,13 @@ function registerAdminRoutes(app) {
}); });
} else { } else {
// Nur andere Felder aktualisieren // Nur andere Felder aktualisieren
db.run('UPDATE users SET personalnummer = ?, wochenstunden = ?, urlaubstage = ?, arbeitstage = ? WHERE id = ?', db.run('UPDATE users SET personalnummer = ?, wochenstunden = ?, urlaubstage = ?, arbeitstage = ?, default_break_minutes = ? WHERE id = ?',
[ [
personalnummer || null, personalnummer || null,
wochenstunden ? parseFloat(wochenstunden) : null, wochenstunden ? parseFloat(wochenstunden) : null,
urlaubstage ? parseFloat(urlaubstage) : null, urlaubstage ? parseFloat(urlaubstage) : null,
arbeitstage ? parseInt(arbeitstage) : 5, arbeitstage ? parseInt(arbeitstage) : 5,
normalizedDefaultBreak,
userId userId
], ],
(err) => { (err) => {

View File

@@ -20,6 +20,11 @@ function registerTimesheetRoutes(app) {
overtime_taken_hours, vacation_type, sick_status, weekend_travel overtime_taken_hours, vacation_type, sick_status, weekend_travel
} = req.body; } = req.body;
const userId = req.session.userId; const userId = req.session.userId;
const hasExplicitBreakMinutes = break_minutes !== undefined && break_minutes !== null && break_minutes !== '';
const parsedRequestedBreakMinutes = hasExplicitBreakMinutes ? parseInt(break_minutes, 10) : null;
const requestedBreakMinutes = Number.isFinite(parsedRequestedBreakMinutes) && parsedRequestedBreakMinutes >= 0
? parsedRequestedBreakMinutes
: null;
// Normalisiere end_time: Leere Strings werden zu null // Normalisiere end_time: Leere Strings werden zu null
const normalizedEndTime = (end_time && typeof end_time === 'string' && end_time.trim() !== '') ? end_time.trim() : (end_time || null); const normalizedEndTime = (end_time && typeof end_time === 'string' && end_time.trim() !== '') ? end_time.trim() : (end_time || null);
@@ -55,7 +60,7 @@ function registerTimesheetRoutes(app) {
} }
// User-Daten laden (für Überstunden-Berechnung) // User-Daten laden (für Überstunden-Berechnung)
db.get('SELECT wochenstunden, arbeitstage FROM users WHERE id = ?', [userId], (err, user) => { db.get('SELECT wochenstunden, arbeitstage, default_break_minutes FROM users WHERE id = ?', [userId], (err, user) => {
if (err) { if (err) {
console.error('Fehler beim Laden der User-Daten:', err); console.error('Fehler beim Laden der User-Daten:', err);
return res.status(500).json({ error: 'Fehler beim Laden der User-Daten' }); return res.status(500).json({ error: 'Fehler beim Laden der User-Daten' });
@@ -63,6 +68,10 @@ function registerTimesheetRoutes(app) {
const wochenstunden = user?.wochenstunden || 0; const wochenstunden = user?.wochenstunden || 0;
const arbeitstage = user?.arbeitstage || 5; const arbeitstage = user?.arbeitstage || 5;
const defaultBreakMinutes = Number.isInteger(user?.default_break_minutes) && user.default_break_minutes >= 0
? user.default_break_minutes
: 30;
let effectiveBreakMinutes = requestedBreakMinutes !== null ? requestedBreakMinutes : defaultBreakMinutes;
const overtimeValue = overtime_taken_hours ? parseFloat(overtime_taken_hours) : 0; const overtimeValue = overtime_taken_hours ? parseFloat(overtime_taken_hours) : 0;
const fullDayHours = wochenstunden > 0 && arbeitstage > 0 ? wochenstunden / arbeitstage : 0; const fullDayHours = wochenstunden > 0 && arbeitstage > 0 ? wochenstunden / arbeitstage : 0;
@@ -106,7 +115,7 @@ function registerTimesheetRoutes(app) {
const start = new Date(`2000-01-01T${normalizedStartTime}`); const start = new Date(`2000-01-01T${normalizedStartTime}`);
const end = new Date(`2000-01-01T${normalizedEndTime}`); const end = new Date(`2000-01-01T${normalizedEndTime}`);
const diffMs = end - start; const diffMs = end - start;
total_hours = (diffMs / (1000 * 60 * 60)) - (break_minutes / 60); total_hours = (diffMs / (1000 * 60 * 60)) - (effectiveBreakMinutes / 60);
// Wochenend-Prozentsatz anwenden (nur wenn weekend_travel aktiviert UND es ist ein Wochenendtag) // Wochenend-Prozentsatz anwenden (nur wenn weekend_travel aktiviert UND es ist ein Wochenendtag)
if (isWeekend && isWeekendTravel && total_hours > 0 && !isSick && vacation_type !== 'full') { if (isWeekend && isWeekendTravel && total_hours > 0 && !isSick && vacation_type !== 'full') {
@@ -124,9 +133,26 @@ function registerTimesheetRoutes(app) {
// Sie werden über overtime_taken_hours in der PDF angezeigt // Sie werden über overtime_taken_hours in der PDF angezeigt
// Prüfen ob Eintrag existiert - verwende den neuesten Eintrag falls mehrere existieren // Prüfen ob Eintrag existiert - verwende den neuesten Eintrag falls mehrere existieren
db.get('SELECT id, applied_weekend_percentage FROM timesheet_entries WHERE user_id = ? AND date = ? ORDER BY updated_at DESC, id DESC LIMIT 1', db.get('SELECT id, break_minutes, applied_weekend_percentage FROM timesheet_entries WHERE user_id = ? AND date = ? ORDER BY updated_at DESC, id DESC LIMIT 1',
[userId, date], (err, row) => { [userId, date], (err, row) => {
if (row) { if (row) {
if (requestedBreakMinutes === null && row.break_minutes !== null && row.break_minutes !== undefined) {
effectiveBreakMinutes = row.break_minutes;
}
if (normalizedStartTime && normalizedEndTime && !isSick && vacation_type !== 'full' && !isFullDayOvertime) {
const start = new Date(`2000-01-01T${normalizedStartTime}`);
const end = new Date(`2000-01-01T${normalizedEndTime}`);
const diffMs = end - start;
total_hours = (diffMs / (1000 * 60 * 60)) - (effectiveBreakMinutes / 60);
if (isWeekend && isWeekendTravel && total_hours > 0) {
const weekendPercentage = getWeekendPercentage(date);
if (weekendPercentage >= 100) {
total_hours = total_hours * (weekendPercentage / 100);
}
}
}
// Wenn bereits ein gespeicherter Prozentsatz existiert, diesen verwenden (historische Einträge bleiben unverändert) // Wenn bereits ein gespeicherter Prozentsatz existiert, diesen verwenden (historische Einträge bleiben unverändert)
let finalAppliedPercentage = appliedWeekendPercentage; let finalAppliedPercentage = appliedWeekendPercentage;
if (row.applied_weekend_percentage !== null && row.applied_weekend_percentage !== undefined) { if (row.applied_weekend_percentage !== null && row.applied_weekend_percentage !== undefined) {
@@ -138,7 +164,7 @@ function registerTimesheetRoutes(app) {
const start = new Date(`2000-01-01T${normalizedStartTime}`); const start = new Date(`2000-01-01T${normalizedStartTime}`);
const end = new Date(`2000-01-01T${normalizedEndTime}`); const end = new Date(`2000-01-01T${normalizedEndTime}`);
const diffMs = end - start; const diffMs = end - start;
const baseHours = (diffMs / (1000 * 60 * 60)) - (break_minutes / 60); const baseHours = (diffMs / (1000 * 60 * 60)) - (effectiveBreakMinutes / 60);
if (baseHours > 0 && finalAppliedPercentage >= 100) { if (baseHours > 0 && finalAppliedPercentage >= 100) {
total_hours = baseHours * (finalAppliedPercentage / 100); total_hours = baseHours * (finalAppliedPercentage / 100);
} }
@@ -165,7 +191,7 @@ function registerTimesheetRoutes(app) {
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ?`, WHERE id = ?`,
[ [
finalStartTime, finalEndTime, break_minutes, total_hours, notes, finalStartTime, finalEndTime, effectiveBreakMinutes, total_hours, notes,
finalActivity1Desc || null, finalActivity1Hours, activity1_project_number || null, finalActivity1Desc || null, finalActivity1Hours, activity1_project_number || null,
finalActivity2Desc || null, parseFloat(activity2_hours) || 0, activity2_project_number || null, finalActivity2Desc || null, parseFloat(activity2_hours) || 0, activity2_project_number || null,
finalActivity3Desc || null, parseFloat(activity3_hours) || 0, activity3_project_number || null, finalActivity3Desc || null, parseFloat(activity3_hours) || 0, activity3_project_number || null,
@@ -197,7 +223,7 @@ function registerTimesheetRoutes(app) {
overtime_taken_hours, vacation_type, sick_status, weekend_travel, applied_weekend_percentage) overtime_taken_hours, vacation_type, sick_status, weekend_travel, applied_weekend_percentage)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
userId, date, finalStartTime, finalEndTime, break_minutes, total_hours, notes, userId, date, finalStartTime, finalEndTime, effectiveBreakMinutes, total_hours, notes,
finalActivity1Desc || null, finalActivity1Hours, activity1_project_number || null, finalActivity1Desc || null, finalActivity1Hours, activity1_project_number || null,
finalActivity2Desc || null, parseFloat(activity2_hours) || 0, activity2_project_number || null, finalActivity2Desc || null, parseFloat(activity2_hours) || 0, activity2_project_number || null,
finalActivity3Desc || null, parseFloat(activity3_hours) || 0, activity3_project_number || null, finalActivity3Desc || null, parseFloat(activity3_hours) || 0, activity3_project_number || null,

View File

@@ -57,14 +57,15 @@ function registerUserRoutes(app) {
app.get('/api/user/data', requireAuth, (req, res) => { app.get('/api/user/data', requireAuth, (req, res) => {
const userId = req.session.userId; const userId = req.session.userId;
db.get('SELECT wochenstunden, arbeitstage FROM users WHERE id = ?', [userId], (err, user) => { db.get('SELECT wochenstunden, arbeitstage, default_break_minutes FROM users WHERE id = ?', [userId], (err, user) => {
if (err) { if (err) {
return res.status(500).json({ error: 'Fehler beim Abrufen der User-Daten' }); return res.status(500).json({ error: 'Fehler beim Abrufen der User-Daten' });
} }
res.json({ res.json({
wochenstunden: user?.wochenstunden || 0, wochenstunden: user?.wochenstunden || 0,
arbeitstage: user?.arbeitstage || 5 arbeitstage: user?.arbeitstage || 5,
default_break_minutes: user?.default_break_minutes ?? 30
}); });
}); });
}); });
@@ -392,6 +393,7 @@ function registerUserRoutes(app) {
const endDate = new Date(week.week_end); const endDate = new Date(week.week_end);
let workdays = 0; let workdays = 0;
let filledWorkdays = 0; let filledWorkdays = 0;
const fullDayHoursForCheck = wochenstunden > 0 && arbeitstage > 0 ? wochenstunden / arbeitstage : 8;
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const day = d.getDay(); const day = d.getDay();
@@ -407,15 +409,18 @@ function registerUserRoutes(app) {
// Tag gilt als ausgefüllt wenn: // Tag gilt als ausgefüllt wenn:
// - Ganzer Tag Urlaub (vacation_type = 'full') // - Ganzer Tag Urlaub (vacation_type = 'full')
// - Krank (sick_status = 1) // - Krank (sick_status = 1)
// - Ganzer Tag Überstunden (overtime_taken_hours = fullDayHours)
// - ODER Start- und End-Zeit vorhanden sind // - ODER Start- und End-Zeit vorhanden sind
if (entry) { if (entry) {
const isFullDayVacation = entry.vacation_type === 'full'; const isFullDayVacation = entry.vacation_type === 'full';
const isSick = entry.sick_status === 1 || entry.sick_status === true; const isSick = entry.sick_status === 1 || entry.sick_status === true;
const overtimeValue = entry.overtime_taken_hours ? parseFloat(entry.overtime_taken_hours) : 0;
const isFullDayOvertime = overtimeValue > 0 && Math.abs(overtimeValue - fullDayHoursForCheck) < 0.01;
const hasStartAndEnd = entry.start_time && entry.end_time && const hasStartAndEnd = entry.start_time && entry.end_time &&
entry.start_time.toString().trim() !== '' && entry.start_time.toString().trim() !== '' &&
entry.end_time.toString().trim() !== ''; entry.end_time.toString().trim() !== '';
if (isFullDayVacation || isSick || hasStartAndEnd) { if (isFullDayVacation || isSick || isFullDayOvertime || hasStartAndEnd) {
filledWorkdays++; filledWorkdays++;
} }
} }
@@ -461,7 +466,7 @@ function registerUserRoutes(app) {
const isFullDayOvertime = overtimeValue > 0 && Math.abs(overtimeValue - fullDayHours) < 0.01; const isFullDayOvertime = overtimeValue > 0 && Math.abs(overtimeValue - fullDayHours) < 0.01;
if (entry.overtime_taken_hours) { if (entry.overtime_taken_hours) {
weekOvertimeTaken += entry.overtime_taken_hours; weekOvertimeTaken += parseFloat(entry.overtime_taken_hours) || 0;
} }
// Wenn 8 Überstunden eingetragen sind, zählt der Tag als 0 Stunden // Wenn 8 Überstunden eingetragen sind, zählt der Tag als 0 Stunden
@@ -502,19 +507,14 @@ function registerUserRoutes(app) {
} }
} }
// Sollstunden berechnen // Sollstunden berechnen (Variante B: immer vertraglich, nicht reduziert durch „genommen“)
const sollStunden = (wochenstunden / arbeitstage) * workdays; const sollStunden = (wochenstunden / arbeitstage) * workdays;
// Überstunden für diese Woche: (totalHours + vacationHours + holidayHours) - adjustedSollStunden
const weekTotalHoursWithVacation = weekTotalHours + weekVacationHours + holidayHours; const weekTotalHoursWithVacation = weekTotalHours + weekVacationHours + holidayHours;
const adjustedSollStunden = sollStunden - (fullDayOvertimeDays * fullDayHours); // Überstunden/Fehlstunden = Gesamt Soll (kann negativ sein)
// weekOvertimeHours = Überstunden diese Woche (wie im Frontend berechnet) const weekOvertimeHours = weekTotalHoursWithVacation - sollStunden;
const weekOvertimeHours = weekTotalHoursWithVacation - adjustedSollStunden;
// Kumulativ addieren // 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; totalOvertimeHours += weekOvertimeHours;
totalOvertimeTaken += weekOvertimeTaken; totalOvertimeTaken += weekOvertimeTaken;
totalVacationDays += weekVacationDays; totalVacationDays += weekVacationDays;
@@ -523,10 +523,8 @@ function registerUserRoutes(app) {
// Wenn alle Wochen verarbeitet wurden, Antwort senden // Wenn alle Wochen verarbeitet wurden, Antwort senden
if (processedWeeks === weeks.length && !hasError) { if (processedWeeks === weeks.length && !hasError) {
// Aktuelle Überstunden = Summe aller Wochen-Überstunden - verbrauchte Überstunden + Offset // Variante B: Verbleibend = Summe Wochen-Überstunden + Offset („genommen“ nur Anzeige)
// weekOvertimeHours enthält bereits die korrekte Berechnung pro Woche (wie im Frontend) const currentOvertime = totalOvertimeHours + overtimeOffsetHours;
// weekOvertimeTaken enthält die verbrauchten Überstunden (8 Stunden pro Tag mit 8 Überstunden)
const currentOvertime = (totalOvertimeHours - totalOvertimeTaken) + overtimeOffsetHours;
const remainingVacation = urlaubstage - totalVacationDays + vacationOffsetDays; const remainingVacation = urlaubstage - totalVacationDays + vacationOffsetDays;
res.json({ res.json({
@@ -586,6 +584,17 @@ function registerUserRoutes(app) {
const arbeitstage = user.arbeitstage || 5; const arbeitstage = user.arbeitstage || 5;
const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0; const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0;
// Korrekturen durch die Verwaltung (Historie) laden
db.all(
`SELECT correction_hours, corrected_at, reason
FROM overtime_corrections
WHERE user_id = ?
ORDER BY corrected_at DESC`,
[userId],
(correctionsErr, corrections) => {
// Falls Tabelle noch nicht existiert (z. B. alte DB), nicht hart fehlschlagen
const overtimeCorrections = correctionsErr ? [] : (corrections || []);
// Alle eingereichten Wochen abrufen // Alle eingereichten Wochen abrufen
db.all(`SELECT DISTINCT week_start, week_end db.all(`SELECT DISTINCT week_start, week_end
FROM weekly_timesheets FROM weekly_timesheets
@@ -599,7 +608,11 @@ function registerUserRoutes(app) {
// Wenn keine Wochen vorhanden // Wenn keine Wochen vorhanden
if (!weeks || weeks.length === 0) { if (!weeks || weeks.length === 0) {
return res.json({ weeks: [] }); return res.json({
weeks: [],
overtime_offset_hours: overtimeOffsetHours,
overtime_corrections: overtimeCorrections
});
} }
const { getCalendarWeek } = require('../helpers/utils'); const { getCalendarWeek } = require('../helpers/utils');
@@ -650,6 +663,7 @@ function registerUserRoutes(app) {
const endDate = new Date(week.week_end); const endDate = new Date(week.week_end);
let workdays = 0; let workdays = 0;
let filledWorkdays = 0; let filledWorkdays = 0;
const fullDayHoursForCheck = wochenstunden > 0 && arbeitstage > 0 ? wochenstunden / arbeitstage : 8;
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const day = d.getDay(); const day = d.getDay();
@@ -665,11 +679,13 @@ function registerUserRoutes(app) {
if (entry) { if (entry) {
const isFullDayVacation = entry.vacation_type === 'full'; const isFullDayVacation = entry.vacation_type === 'full';
const isSick = entry.sick_status === 1 || entry.sick_status === true; const isSick = entry.sick_status === 1 || entry.sick_status === true;
const overtimeValue = entry.overtime_taken_hours ? parseFloat(entry.overtime_taken_hours) : 0;
const isFullDayOvertime = overtimeValue > 0 && Math.abs(overtimeValue - fullDayHoursForCheck) < 0.01;
const hasStartAndEnd = entry.start_time && entry.end_time && const hasStartAndEnd = entry.start_time && entry.end_time &&
entry.start_time.toString().trim() !== '' && entry.start_time.toString().trim() !== '' &&
entry.end_time.toString().trim() !== ''; entry.end_time.toString().trim() !== '';
if (isFullDayVacation || isSick || hasStartAndEnd) { if (isFullDayVacation || isSick || isFullDayOvertime || hasStartAndEnd) {
filledWorkdays++; filledWorkdays++;
} }
} }
@@ -680,7 +696,11 @@ function registerUserRoutes(app) {
if (filledWorkdays < workdays) { if (filledWorkdays < workdays) {
processedWeeks++; processedWeeks++;
if (processedWeeks === weeks.length && !hasError) { if (processedWeeks === weeks.length && !hasError) {
res.json({ weeks: weekData }); res.json({
weeks: weekData,
overtime_offset_hours: overtimeOffsetHours,
overtime_corrections: overtimeCorrections
});
} }
return; return;
} }
@@ -734,11 +754,10 @@ function registerUserRoutes(app) {
} }
} }
// Sollstunden berechnen // Sollstunden berechnen (Variante B: immer vertraglich)
const sollStunden = (wochenstunden / arbeitstage) * workdays; const sollStunden = (wochenstunden / arbeitstage) * workdays;
const weekTotalHoursWithVacation = weekTotalHours + weekVacationHours + holidayHours; const weekTotalHoursWithVacation = weekTotalHours + weekVacationHours + holidayHours;
const adjustedSollStunden = sollStunden - (fullDayOvertimeDays * fullDayHours); const weekOvertimeHours = weekTotalHoursWithVacation - sollStunden;
const weekOvertimeHours = weekTotalHoursWithVacation - adjustedSollStunden;
// Kalenderwoche berechnen // Kalenderwoche berechnen
const calendarWeek = getCalendarWeek(week.week_start); const calendarWeek = getCalendarWeek(week.week_start);
@@ -753,7 +772,7 @@ function registerUserRoutes(app) {
overtime_hours: parseFloat(weekOvertimeHours.toFixed(2)), overtime_hours: parseFloat(weekOvertimeHours.toFixed(2)),
overtime_taken: parseFloat(weekOvertimeTaken.toFixed(2)), overtime_taken: parseFloat(weekOvertimeTaken.toFixed(2)),
total_hours: parseFloat(weekTotalHoursWithVacation.toFixed(2)), total_hours: parseFloat(weekTotalHoursWithVacation.toFixed(2)),
soll_stunden: parseFloat(adjustedSollStunden.toFixed(2)), soll_stunden: parseFloat(sollStunden.toFixed(2)),
vacation_days: parseFloat(weekVacationDays.toFixed(1)), vacation_days: parseFloat(weekVacationDays.toFixed(1)),
workdays: workdays workdays: workdays
}); });
@@ -768,12 +787,18 @@ function registerUserRoutes(app) {
if (a.calendar_week !== b.calendar_week) return b.calendar_week - a.calendar_week; if (a.calendar_week !== b.calendar_week) return b.calendar_week - a.calendar_week;
return new Date(b.week_start) - new Date(a.week_start); return new Date(b.week_start) - new Date(a.week_start);
}); });
res.json({ weeks: weekData, overtime_offset_hours: overtimeOffsetHours }); res.json({
weeks: weekData,
overtime_offset_hours: overtimeOffsetHours,
overtime_corrections: overtimeCorrections
});
} }
}); // getHolidaysForDateRange.then }); // getHolidaysForDateRange.then
}); // db.all (allEntries) }); // db.all (allEntries)
}); // weeks.forEach }); // weeks.forEach
}); // db.all (weeks) }); // db.all (weeks)
}
);
}); // db.get (user) }); // db.get (user)
}); // db.get (options) }); // db.get (options)
}); // app.get }); // app.get

View File

@@ -105,8 +105,12 @@ function registerVerwaltungRoutes(app) {
return new Date(b.week_start) - new Date(a.week_start); return new Date(b.week_start) - new Date(a.week_start);
}); });
// Flag: Gibt es in irgendeiner Woche eine neue Version nach Download?
const hasNewVersionAfterDownload = sortedWeeks.some(w => w.has_new_version_after_download);
return { return {
...employee, ...employee,
has_new_version_after_download: hasNewVersionAfterDownload,
weeks: sortedWeeks weeks: sortedWeeks
}; };
}).sort((a, b) => { }).sort((a, b) => {
@@ -132,6 +136,8 @@ function registerVerwaltungRoutes(app) {
app.put('/api/verwaltung/user/:id/overtime-offset', requireVerwaltung, (req, res) => { app.put('/api/verwaltung/user/:id/overtime-offset', requireVerwaltung, (req, res) => {
const userId = req.params.id; const userId = req.params.id;
const raw = req.body ? req.body.overtime_offset_hours : undefined; const raw = req.body ? req.body.overtime_offset_hours : undefined;
const reasonRaw = req.body ? req.body.reason : undefined;
const reason = (reasonRaw === null || reasonRaw === undefined) ? '' : String(reasonRaw).trim();
// Leere Eingabe => 0 // Leere Eingabe => 0
const normalized = (raw === '' || raw === null || raw === undefined) ? 0 : parseFloat(raw); const normalized = (raw === '' || raw === null || raw === undefined) ? 0 : parseFloat(raw);
@@ -139,12 +145,59 @@ function registerVerwaltungRoutes(app) {
return res.status(400).json({ error: 'Ungültiger Überstunden-Offset' }); return res.status(400).json({ error: 'Ungültiger Überstunden-Offset' });
} }
db.run('UPDATE users SET overtime_offset_hours = ? WHERE id = ?', [normalized, userId], (err) => { // Neue Logik: Korrektur protokollieren + kumulativ addieren
if (err) { // Feld in der Verwaltung soll nach dem Speichern immer auf 0 zurückgesetzt werden.
console.error('Fehler beim Speichern des Überstunden-Offsets:', err); if (normalized === 0) {
return res.status(500).json({ error: 'Fehler beim Speichern des Überstunden-Offsets' }); return res.json({ success: true, overtime_offset_hours: 0 });
} }
res.json({ success: true, overtime_offset_hours: normalized });
if (!reason) {
return res.status(400).json({ error: 'Bitte geben Sie einen Grund für die Korrektur an.' });
}
db.serialize(() => {
db.run('BEGIN TRANSACTION');
db.run(
`INSERT INTO overtime_corrections (user_id, correction_hours, reason, corrected_at)
VALUES (?, ?, ?, datetime('now'))`,
[userId, normalized, reason],
(err) => {
if (err) {
console.error('Fehler beim Speichern der Überstunden-Korrektur:', err);
db.run('ROLLBACK', () => {
return res.status(500).json({ error: 'Fehler beim Speichern der Überstunden-Korrektur' });
});
return;
}
db.run(
'UPDATE users SET overtime_offset_hours = COALESCE(overtime_offset_hours, 0) + ? WHERE id = ?',
[normalized, userId],
(err) => {
if (err) {
console.error('Fehler beim Aktualisieren des Überstunden-Offsets:', err);
db.run('ROLLBACK', () => {
return res.status(500).json({ error: 'Fehler beim Speichern des Überstunden-Offsets' });
});
return;
}
db.run('COMMIT', (err) => {
if (err) {
console.error('Fehler beim Commit der Überstunden-Korrektur:', err);
db.run('ROLLBACK', () => {
return res.status(500).json({ error: 'Fehler beim Speichern der Überstunden-Korrektur' });
});
return;
}
res.json({ success: true, overtime_offset_hours: 0 });
});
}
);
}
);
}); });
}); });
@@ -168,6 +221,26 @@ function registerVerwaltungRoutes(app) {
}); });
}); });
// API: Überstunden-Korrektur-Historie für einen User abrufen
app.get('/api/verwaltung/user/:id/overtime-corrections', requireVerwaltung, (req, res) => {
const userId = req.params.id;
db.all(
`SELECT correction_hours, corrected_at, reason
FROM overtime_corrections
WHERE user_id = ?
ORDER BY corrected_at DESC`,
[userId],
(err, rows) => {
// Falls Tabelle noch nicht existiert (z. B. alte DB), nicht hart fehlschlagen
if (err) {
return res.json({ corrections: [] });
}
res.json({ corrections: rows || [] });
}
);
});
// API: Krankheitstage für einen User im aktuellen Jahr abrufen // API: Krankheitstage für einen User im aktuellen Jahr abrufen
app.get('/api/verwaltung/user/:id/sick-days', requireVerwaltung, (req, res) => { app.get('/api/verwaltung/user/:id/sick-days', requireVerwaltung, (req, res) => {
const userId = req.params.id; const userId = req.params.id;
@@ -524,12 +597,11 @@ function registerVerwaltungRoutes(app) {
const weekOvertimeHours = totalHoursWithVacation - sollStunden; const weekOvertimeHours = totalHoursWithVacation - sollStunden;
// Kumulative Überstunden: Summe aller Wochen bis zur aktuellen Woche // Kumulative Überstunden: Summe aller Wochen bis zur aktuellen Woche
// cumulativeOvertimeHours enthält bereits alle vorherigen Wochen
const totalCumulativeOvertimeHours = cumulativeOvertimeHours + weekOvertimeHours; const totalCumulativeOvertimeHours = cumulativeOvertimeHours + weekOvertimeHours;
const totalCumulativeOvertimeTaken = cumulativeOvertimeTaken + overtimeTaken; const totalCumulativeOvertimeTaken = cumulativeOvertimeTaken + overtimeTaken;
// Verbleibende Überstunden = kumulative Überstunden - kumulative genommene Überstunden // Variante B: Verbleibend = Summe Wochen-Überstunden („genommen“ nur Anzeige)
const remainingOvertime = totalCumulativeOvertimeHours - totalCumulativeOvertimeTaken; const remainingOvertime = totalCumulativeOvertimeHours;
const remainingOvertimeWithOffset = remainingOvertime + overtimeOffsetHours; const remainingOvertimeWithOffset = remainingOvertime + overtimeOffsetHours;
// Verbleibende Urlaubstage (berücksichtigt alle eingereichten Wochen, nicht nur die aktuelle) // Verbleibende Urlaubstage (berücksichtigt alle eingereichten Wochen, nicht nur die aktuelle)

View File

@@ -94,6 +94,7 @@ function getCurrentOvertimeForUser(userId, db, callback) {
const endDate = new Date(week.week_end); const endDate = new Date(week.week_end);
let workdays = 0; let workdays = 0;
let filledWorkdays = 0; let filledWorkdays = 0;
const fullDayHoursForCheck = wochenstunden > 0 && arbeitstage > 0 ? wochenstunden / arbeitstage : 8;
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) { for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const day = d.getDay(); const day = d.getDay();
@@ -108,26 +109,20 @@ function getCurrentOvertimeForUser(userId, db, callback) {
if (entry) { if (entry) {
const isFullDayVacation = entry.vacation_type === 'full'; const isFullDayVacation = entry.vacation_type === 'full';
const isSick = entry.sick_status === 1 || entry.sick_status === true; const isSick = entry.sick_status === 1 || entry.sick_status === true;
const overtimeValue = entry.overtime_taken_hours ? parseFloat(entry.overtime_taken_hours) : 0;
const isFullDayOvertime = overtimeValue > 0 && Math.abs(overtimeValue - fullDayHoursForCheck) < 0.01;
const hasStartAndEnd = const hasStartAndEnd =
entry.start_time && entry.start_time &&
entry.end_time && entry.end_time &&
entry.start_time.toString().trim() !== '' && entry.start_time.toString().trim() !== '' &&
entry.end_time.toString().trim() !== ''; entry.end_time.toString().trim() !== '';
if (isFullDayVacation || isSick || hasStartAndEnd) { if (isFullDayVacation || isSick || isFullDayOvertime || hasStartAndEnd) {
filledWorkdays++; filledWorkdays++;
} }
} }
} }
} }
if (filledWorkdays < workdays) {
processedWeeks++;
if (processedWeeks === weeks.length && !hasError) {
done(totalOvertimeHours - totalOvertimeTaken + overtimeOffsetHours);
}
return;
}
const fullDayHours = wochenstunden > 0 && arbeitstage > 0 ? wochenstunden / arbeitstage : 8; const fullDayHours = wochenstunden > 0 && arbeitstage > 0 ? wochenstunden / arbeitstage : 8;
let weekTotalHours = 0; let weekTotalHours = 0;
let weekOvertimeTaken = 0; let weekOvertimeTaken = 0;
@@ -168,17 +163,18 @@ function getCurrentOvertimeForUser(userId, db, callback) {
} }
} }
const sollStunden = (wochenstunden / arbeitstage) * workdays; // Variante B: Soll immer vertraglich, Verbleibend ohne Abzug „genommen“
//const sollStunden = (wochenstunden / arbeitstage) * workdays;
const sollStunden = wochenstunden;
const weekTotalHoursWithVacation = weekTotalHours + weekVacationHours + holidayHours; const weekTotalHoursWithVacation = weekTotalHours + weekVacationHours + holidayHours;
const adjustedSollStunden = sollStunden - fullDayOvertimeDays * fullDayHours; const weekOvertimeHours = weekTotalHoursWithVacation - sollStunden;
const weekOvertimeHours = weekTotalHoursWithVacation - adjustedSollStunden;
totalOvertimeHours += weekOvertimeHours; totalOvertimeHours += weekOvertimeHours;
totalOvertimeTaken += weekOvertimeTaken; totalOvertimeTaken += weekOvertimeTaken;
processedWeeks++; processedWeeks++;
if (processedWeeks === weeks.length && !hasError) { if (processedWeeks === weeks.length && !hasError) {
done(totalOvertimeHours - totalOvertimeTaken + overtimeOffsetHours); done(totalOvertimeHours + overtimeOffsetHours);
} }
}); });
} }

View File

@@ -3,7 +3,7 @@
const PDFDocument = require('pdfkit'); const PDFDocument = require('pdfkit');
const QRCode = require('qrcode'); const QRCode = require('qrcode');
const { db } = require('../database'); const { db } = require('../database');
const { formatDate, formatDateTime } = require('../helpers/utils'); const { formatDate, formatDateTime, formatHoursMin } = require('../helpers/utils');
const { getHolidaysWithNamesForDateRange } = require('./feiertage-service'); const { getHolidaysWithNamesForDateRange } = require('./feiertage-service');
// Kalenderwoche berechnen // Kalenderwoche berechnen
@@ -190,7 +190,7 @@ function generatePDF(timesheetId, req, res) {
// Feiertag am Wochenende: keine Tagesarbeitsstunden anzeigen // Feiertag am Wochenende: keine Tagesarbeitsstunden anzeigen
const holidayDay = new Date(row.date + 'T12:00:00').getDay(); const holidayDay = new Date(row.date + 'T12:00:00').getDay();
const isWeekendHoliday = (holidayDay === 0 || holidayDay === 6); const isWeekendHoliday = (holidayDay === 0 || holidayDay === 6);
const holidayHoursStr = isWeekendHoliday ? '0 h (Feiertag)' : fullDayHours.toFixed(2) + ' h (Feiertag)'; const holidayHoursStr = isWeekendHoliday ? '0h 0min (Feiertag)' : formatHoursMin(fullDayHours) + ' (Feiertag)';
const rowData = [formatDate(row.date), '-', '-', '-', holidayHoursStr]; const rowData = [formatDate(row.date), '-', '-', '-', holidayHoursStr];
rowData.forEach((data, i) => { rowData.forEach((data, i) => {
doc.text(data, x, y, { width: colWidths[i], align: 'left' }); doc.text(data, x, y, { width: colWidths[i], align: 'left' });
@@ -215,7 +215,7 @@ function generatePDF(timesheetId, req, res) {
entry.start_time || '-', entry.start_time || '-',
entry.end_time || '-', entry.end_time || '-',
entry.break_minutes ? `${entry.break_minutes} min` : '-', entry.break_minutes ? `${entry.break_minutes} min` : '-',
entry.total_hours ? entry.total_hours.toFixed(2) + ' h' : '-' entry.total_hours ? formatHoursMin(entry.total_hours) : '-'
]; ];
rowData.forEach((data, i) => { rowData.forEach((data, i) => {
@@ -229,7 +229,7 @@ function generatePDF(timesheetId, req, res) {
const desc = entry[`activity${i}_desc`]; const desc = entry[`activity${i}_desc`];
const hours = entry[`activity${i}_hours`]; const hours = entry[`activity${i}_hours`];
const projectNumber = entry[`activity${i}_project_number`]; const projectNumber = entry[`activity${i}_project_number`];
if (desc && desc.trim() && hours > 0) { if (desc && desc.trim()) {
activities.push({ activities.push({
desc: desc.trim(), desc: desc.trim(),
hours: parseFloat(hours), hours: parseFloat(hours),
@@ -250,7 +250,7 @@ function generatePDF(timesheetId, req, res) {
if (activity.projectNumber) { if (activity.projectNumber) {
activityText += ` (Projekt: ${activity.projectNumber})`; activityText += ` (Projekt: ${activity.projectNumber})`;
} }
activityText += ` - ${activity.hours.toFixed(2)} h`; activityText += ` - ${formatHoursMin(activity.hours)}`;
doc.fontSize(9).font('Helvetica'); doc.fontSize(9).font('Helvetica');
doc.text(activityText, 70, doc.y, { width: 360 }); doc.text(activityText, 70, doc.y, { width: 360 });
doc.moveDown(0.2); doc.moveDown(0.2);
@@ -264,7 +264,7 @@ function generatePDF(timesheetId, req, res) {
overtimeInfo.push('Feiertag: ' + (holidayNames.get(entry.date) || 'Feiertag')); overtimeInfo.push('Feiertag: ' + (holidayNames.get(entry.date) || 'Feiertag'));
} }
if (entry.overtime_taken_hours && parseFloat(entry.overtime_taken_hours) > 0) { if (entry.overtime_taken_hours && parseFloat(entry.overtime_taken_hours) > 0) {
overtimeInfo.push(`Überstunden genommen: ${parseFloat(entry.overtime_taken_hours).toFixed(2)} h`); overtimeInfo.push(`Überstunden genommen: ${formatHoursMin(parseFloat(entry.overtime_taken_hours))}`);
} }
if (entry.vacation_type) { if (entry.vacation_type) {
const vacationText = entry.vacation_type === 'full' ? 'Ganzer Tag' : 'Halber Tag'; const vacationText = entry.vacation_type === 'full' ? 'Ganzer Tag' : 'Halber Tag';
@@ -308,7 +308,7 @@ function generatePDF(timesheetId, req, res) {
doc.font('Helvetica-Bold'); doc.font('Helvetica-Bold');
// Gesamtstunden = Arbeitsstunden + Urlaubsstunden + Feiertagsstunden (8h pro Feiertag) // Gesamtstunden = Arbeitsstunden + Urlaubsstunden + Feiertagsstunden (8h pro Feiertag)
const totalHoursWithVacation = totalHours + vacationHours + holidayHours; const totalHoursWithVacation = totalHours + vacationHours + holidayHours;
doc.text(`Gesamtstunden: ${totalHoursWithVacation.toFixed(2)} h`, 50, doc.y); doc.text(`Gesamtstunden: ${formatHoursMin(totalHoursWithVacation)}`, 50, doc.y);
// Überstunden berechnen und anzeigen // Überstunden berechnen und anzeigen
const wochenstunden = timesheet.wochenstunden || 0; const wochenstunden = timesheet.wochenstunden || 0;
@@ -318,11 +318,11 @@ function generatePDF(timesheetId, req, res) {
doc.moveDown(0.3); doc.moveDown(0.3);
doc.font('Helvetica-Bold'); doc.font('Helvetica-Bold');
if (overtimeHours > 0) { if (overtimeHours > 0) {
doc.text(`Überstunden: +${overtimeHours.toFixed(2)} h`, 50, doc.y); doc.text(`Überstunden: +${formatHoursMin(overtimeHours)}`, 50, doc.y);
} else if (overtimeHours < 0) { } else if (overtimeHours < 0) {
doc.text(`Überstunden: ${overtimeHours.toFixed(2)} h`, 50, doc.y); doc.text(`Überstunden: ${formatHoursMin(overtimeHours)}`, 50, doc.y);
} else { } else {
doc.text(`Überstunden: 0.00 h`, 50, doc.y); doc.text(`Überstunden: ${formatHoursMin(0)}`, 50, doc.y);
} }
doc.end(); doc.end();
@@ -462,7 +462,7 @@ function generatePDFToBuffer(timesheetId, req) {
// Feiertag am Wochenende: keine Tagesarbeitsstunden anzeigen // Feiertag am Wochenende: keine Tagesarbeitsstunden anzeigen
const holidayDay = new Date(row.date + 'T12:00:00').getDay(); const holidayDay = new Date(row.date + 'T12:00:00').getDay();
const isWeekendHoliday = (holidayDay === 0 || holidayDay === 6); const isWeekendHoliday = (holidayDay === 0 || holidayDay === 6);
const holidayHoursStr = isWeekendHoliday ? '0 h (Feiertag)' : fullDayHoursBuf.toFixed(2) + ' h (Feiertag)'; const holidayHoursStr = isWeekendHoliday ? '0h 0min (Feiertag)' : formatHoursMin(fullDayHoursBuf) + ' (Feiertag)';
const rowDataBuf = [formatDate(row.date), '-', '-', '-', holidayHoursStr]; const rowDataBuf = [formatDate(row.date), '-', '-', '-', holidayHoursStr];
rowDataBuf.forEach((data, i) => { rowDataBuf.forEach((data, i) => {
doc.text(data, x, y, { width: colWidths[i], align: 'left' }); doc.text(data, x, y, { width: colWidths[i], align: 'left' });
@@ -487,7 +487,7 @@ function generatePDFToBuffer(timesheetId, req) {
entry.start_time || '-', entry.start_time || '-',
entry.end_time || '-', entry.end_time || '-',
entry.break_minutes ? `${entry.break_minutes} min` : '-', entry.break_minutes ? `${entry.break_minutes} min` : '-',
entry.total_hours ? entry.total_hours.toFixed(2) + ' h' : '-' entry.total_hours ? formatHoursMin(entry.total_hours) : '-'
]; ];
rowData.forEach((data, i) => { rowData.forEach((data, i) => {
@@ -500,7 +500,7 @@ function generatePDFToBuffer(timesheetId, req) {
const desc = entry[`activity${i}_desc`]; const desc = entry[`activity${i}_desc`];
const hours = entry[`activity${i}_hours`]; const hours = entry[`activity${i}_hours`];
const projectNumber = entry[`activity${i}_project_number`]; const projectNumber = entry[`activity${i}_project_number`];
if (desc && desc.trim() && hours > 0) { if (desc && desc.trim()) {
activities.push({ activities.push({
desc: desc.trim(), desc: desc.trim(),
hours: parseFloat(hours), hours: parseFloat(hours),
@@ -519,7 +519,7 @@ function generatePDFToBuffer(timesheetId, req) {
if (activity.projectNumber) { if (activity.projectNumber) {
activityText += ` (Projekt: ${activity.projectNumber})`; activityText += ` (Projekt: ${activity.projectNumber})`;
} }
activityText += ` - ${activity.hours.toFixed(2)} h`; activityText += ` - ${formatHoursMin(activity.hours)}`;
doc.fontSize(9).font('Helvetica'); doc.fontSize(9).font('Helvetica');
doc.text(activityText, 70, doc.y, { width: 360 }); doc.text(activityText, 70, doc.y, { width: 360 });
doc.moveDown(0.2); doc.moveDown(0.2);
@@ -532,7 +532,7 @@ function generatePDFToBuffer(timesheetId, req) {
overtimeInfo.push('Feiertag: ' + (holidayNames.get(entry.date) || 'Feiertag')); overtimeInfo.push('Feiertag: ' + (holidayNames.get(entry.date) || 'Feiertag'));
} }
if (entry.overtime_taken_hours && parseFloat(entry.overtime_taken_hours) > 0) { if (entry.overtime_taken_hours && parseFloat(entry.overtime_taken_hours) > 0) {
overtimeInfo.push(`Überstunden genommen: ${parseFloat(entry.overtime_taken_hours).toFixed(2)} h`); overtimeInfo.push(`Überstunden genommen: ${formatHoursMin(parseFloat(entry.overtime_taken_hours))}`);
} }
if (entry.vacation_type) { if (entry.vacation_type) {
const vacationText = entry.vacation_type === 'full' ? 'Ganzer Tag' : 'Halber Tag'; const vacationText = entry.vacation_type === 'full' ? 'Ganzer Tag' : 'Halber Tag';
@@ -573,7 +573,7 @@ function generatePDFToBuffer(timesheetId, req) {
doc.moveDown(0.5); doc.moveDown(0.5);
doc.font('Helvetica-Bold'); doc.font('Helvetica-Bold');
const totalHoursWithVacation = totalHours + vacationHours + holidayHours; const totalHoursWithVacation = totalHours + vacationHours + holidayHours;
doc.text(`Gesamtstunden: ${totalHoursWithVacation.toFixed(2)} h`, 50, doc.y); doc.text(`Gesamtstunden: ${formatHoursMin(totalHoursWithVacation)}`, 50, doc.y);
const wochenstunden = timesheet.wochenstunden || 0; const wochenstunden = timesheet.wochenstunden || 0;
const overtimeHours = totalHoursWithVacation - wochenstunden; const overtimeHours = totalHoursWithVacation - wochenstunden;
@@ -581,11 +581,11 @@ function generatePDFToBuffer(timesheetId, req) {
doc.moveDown(0.3); doc.moveDown(0.3);
doc.font('Helvetica-Bold'); doc.font('Helvetica-Bold');
if (overtimeHours > 0) { if (overtimeHours > 0) {
doc.text(`Überstunden: +${overtimeHours.toFixed(2)} h`, 50, doc.y); doc.text(`Überstunden: +${formatHoursMin(overtimeHours)}`, 50, doc.y);
} else if (overtimeHours < 0) { } else if (overtimeHours < 0) {
doc.text(`Überstunden: ${overtimeHours.toFixed(2)} h`, 50, doc.y); doc.text(`Überstunden: ${formatHoursMin(overtimeHours)}`, 50, doc.y);
} else { } else {
doc.text(`Überstunden: 0.00 h`, 50, doc.y); doc.text(`Überstunden: ${formatHoursMin(0)}`, 50, doc.y);
} }
doc.end(); doc.end();

View File

@@ -5,7 +5,7 @@ const { db } = require('../database');
const { getCurrentDate, getCurrentTime, updateTotalHours } = require('../helpers/utils'); const { getCurrentDate, getCurrentTime, updateTotalHours } = require('../helpers/utils');
// Ping-Funktion für einen User // Ping-Funktion für einen User
async function pingUserIP(userId, ip, currentDate, currentTime) { async function pingUserIP(userId, ip, defaultBreakMinutes, currentDate, currentTime) {
try { try {
const result = await ping.promise.probe(ip, { const result = await ping.promise.probe(ip, {
timeout: 3, timeout: 3,
@@ -31,6 +31,10 @@ async function pingUserIP(userId, ip, currentDate, currentTime) {
return; return;
} }
const userDefaultBreakMinutes = Number.isInteger(defaultBreakMinutes) && defaultBreakMinutes >= 0
? defaultBreakMinutes
: 30;
if (isReachable) { if (isReachable) {
// IP ist erreichbar // IP ist erreichbar
if (!pingStatus) { if (!pingStatus) {
@@ -67,9 +71,9 @@ async function pingUserIP(userId, ip, currentDate, currentTime) {
}); });
} else if (!entry) { } else if (!entry) {
// Kein Eintrag existiert → Erstelle neuen mit start_time // Kein Eintrag existiert → Erstelle neuen mit start_time
db.run(`INSERT INTO timesheet_entries (user_id, date, start_time, updated_at) db.run(`INSERT INTO timesheet_entries (user_id, date, start_time, break_minutes, updated_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)`, VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`,
[userId, currentDate, currentTime], (err) => { [userId, currentDate, currentTime, userDefaultBreakMinutes], (err) => {
if (err) { if (err) {
console.error(`Fehler beim Erstellen des Eintrags für User ${userId}:`, err); console.error(`Fehler beim Erstellen des Eintrags für User ${userId}:`, err);
} else { } else {
@@ -161,7 +165,7 @@ function setupPingService() {
const currentTime = getCurrentTime(); const currentTime = getCurrentTime();
// Hole alle User mit IP-Adresse // Hole alle User mit IP-Adresse
db.all('SELECT id, ping_ip FROM users WHERE ping_ip IS NOT NULL AND ping_ip != ""', (err, users) => { db.all('SELECT id, ping_ip, default_break_minutes FROM users WHERE ping_ip IS NOT NULL AND ping_ip != ""', (err, users) => {
if (err) { if (err) {
console.error('Fehler beim Abrufen der User mit IP-Adressen:', err); console.error('Fehler beim Abrufen der User mit IP-Adressen:', err);
return; return;
@@ -173,7 +177,7 @@ function setupPingService() {
// Ping alle User parallel // Ping alle User parallel
users.forEach(user => { users.forEach(user => {
pingUserIP(user.id, user.ping_ip, currentDate, currentTime); pingUserIP(user.id, user.ping_ip, user.default_break_minutes, currentDate, currentTime);
}); });
}); });
}, 60000); // Jede Minute }, 60000); // Jede Minute

View File

@@ -107,6 +107,11 @@
<label for="urlaubstage">Urlaubstage</label> <label for="urlaubstage">Urlaubstage</label>
<input type="number" id="urlaubstage" name="urlaubstage" step="0.5" min="0" placeholder="z.B. 25"> <input type="number" id="urlaubstage" name="urlaubstage" step="0.5" min="0" placeholder="z.B. 25">
</div> </div>
<div class="form-group">
<label for="defaultBreakMinutes">Standard-Pausenzeit (Min)</label>
<input type="number" id="defaultBreakMinutes" name="default_break_minutes" min="0" step="15" placeholder="30">
<small style="color: #666; display: block; margin-top: 5px;">Vorbelegung Pausenzeit pro Tag (Min., mind. 0).</small>
</div>
</div> </div>
<button type="submit" class="btn btn-primary">Benutzer anlegen</button> <button type="submit" class="btn btn-primary">Benutzer anlegen</button>
@@ -136,6 +141,7 @@
<th>Wochenstunden</th> <th>Wochenstunden</th>
<th>Arbeitstage pro Woche</th> <th>Arbeitstage pro Woche</th>
<th>Urlaubstage</th> <th>Urlaubstage</th>
<th>Standard-Pause (Min)</th>
<th>Erstellt am</th> <th>Erstellt am</th>
<th>Aktionen</th> <th>Aktionen</th>
</tr> </tr>
@@ -194,6 +200,10 @@
<span class="user-field-display" data-field="urlaubstage"><%= u.urlaubstage || '-' %></span> <span class="user-field-display" data-field="urlaubstage"><%= u.urlaubstage || '-' %></span>
<input type="number" step="0.5" class="user-field-edit" data-field="urlaubstage" data-user-id="<%= u.id %>" value="<%= u.urlaubstage || '' %>" style="display: none; width: 80px;"> <input type="number" step="0.5" class="user-field-edit" data-field="urlaubstage" data-user-id="<%= u.id %>" value="<%= u.urlaubstage || '' %>" style="display: none; width: 80px;">
</td> </td>
<td>
<span class="user-field-display" data-field="default_break_minutes"><%= (u.default_break_minutes != null && u.default_break_minutes !== '') ? u.default_break_minutes : '-' %></span>
<input type="number" min="0" step="15" class="user-field-edit" data-field="default_break_minutes" data-user-id="<%= u.id %>" value="<%= (u.default_break_minutes != null && u.default_break_minutes !== '') ? u.default_break_minutes : '' %>" style="display: none; width: 80px;">
</td>
<td><%= new Date(u.created_at).toLocaleDateString('de-DE') %></td> <td><%= new Date(u.created_at).toLocaleDateString('de-DE') %></td>
<td> <td>
<button onclick="editUser(<%= u.id %>)" class="btn btn-primary btn-sm edit-user-btn" data-user-id="<%= u.id %>">Bearbeiten</button> <button onclick="editUser(<%= u.id %>)" class="btn btn-primary btn-sm edit-user-btn" data-user-id="<%= u.id %>">Bearbeiten</button>

View File

@@ -52,11 +52,11 @@
<div class="summary"> <div class="summary">
<div class="summary-item"> <div class="summary-item">
<strong>Gesamtstunden diese Woche:</strong> <strong>Gesamtstunden diese Woche:</strong>
<span id="totalHours">0.00 h</span> <span id="totalHours">0h 0min</span>
</div> </div>
<div class="summary-item" id="overtimeSummaryItem" style="display: none;"> <div class="summary-item" id="overtimeSummaryItem" style="display: none;">
<strong>Überstunden diese Woche:</strong> <strong>Überstunden diese Woche:</strong>
<span id="overtimeHours">0.00 h</span> <span id="overtimeHours">0h 0min</span>
</div> </div>
</div> </div>
@@ -194,6 +194,7 @@
</div> </div>
</div> </div>
<script src="/js/format-hours.js"></script>
<script src="/js/dashboard.js"></script> <script src="/js/dashboard.js"></script>
<script> <script>
// Wochenende-Sektion ein-/ausklappen // Wochenende-Sektion ein-/ausklappen

View File

@@ -145,14 +145,23 @@
<span class="summary-label">Davon genommen:</span> <span class="summary-label">Davon genommen:</span>
<span class="summary-value" id="totalOvertimeTaken">-</span> <span class="summary-value" id="totalOvertimeTaken">-</span>
</div> </div>
<div class="summary-item">
<span class="summary-label">Verbleibend:</span>
<span class="summary-value" id="remainingOvertime">-</span>
</div>
<div class="summary-item" id="offsetItem" style="display: none;"> <div class="summary-item" id="offsetItem" style="display: none;">
<span class="summary-label">Manuelle Korrektur (Verwaltung):</span> <span class="summary-label">Manuelle Korrektur (Verwaltung):</span>
<span class="summary-value" id="overtimeOffset">-</span> <span class="summary-value" id="overtimeOffset">-</span>
</div> </div>
<div id="correctionsSection" style="margin-top: 15px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #e0e0e0; display: none;">
<div id="correctionsHeader" class="collapsible-header" style="cursor: pointer; padding: 12px; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 600; color: #2c3e50;">Korrekturen durch die Verwaltung</span>
<span id="correctionsToggleIcon" style="font-size: 16px; transition: transform 0.3s;">▼</span>
</div>
<div id="correctionsContent" style="display: none; border: 1px solid #ddd; border-top: none; border-radius: 0 0 4px 4px; background-color: #fff; padding: 10px 12px;">
<ul id="correctionsList" style="margin: 0; padding-left: 18px;"></ul>
</div>
</div>
<div class="summary-item">
<span class="summary-label">Verbleibend:</span>
<span class="summary-value" id="remainingOvertime">-</span>
</div>
</div> </div>
<div id="loading" class="loading">Lade Daten...</div> <div id="loading" class="loading">Lade Daten...</div>
@@ -178,6 +187,7 @@
</table> </table>
</div> </div>
<script src="/js/format-hours.js"></script>
<script> <script>
// Rollenwechsel // Rollenwechsel
const roleSwitcher = document.getElementById('roleSwitcher'); const roleSwitcher = document.getElementById('roleSwitcher');
@@ -213,6 +223,27 @@
return date.toLocaleDateString('de-DE'); return date.toLocaleDateString('de-DE');
} }
// SQLite-Datetime (YYYY-MM-DD HH:MM:SS) robust parsen
function parseSqliteDatetime(value) {
if (!value) return null;
const s = String(value);
if (s.includes('T')) return new Date(s);
// SQLite datetime('now') liefert UTC ohne "T" / "Z"
return new Date(s.replace(' ', 'T') + 'Z');
}
// formatHoursMin aus format-hours.js (window.formatHoursMin)
let correctionsExpanded = false;
function toggleCorrectionsSection() {
const content = document.getElementById('correctionsContent');
const icon = document.getElementById('correctionsToggleIcon');
if (!content || !icon) return;
correctionsExpanded = !correctionsExpanded;
content.style.display = correctionsExpanded ? 'block' : 'none';
icon.style.transform = correctionsExpanded ? 'rotate(180deg)' : 'rotate(0deg)';
}
// Überstunden-Daten laden // Überstunden-Daten laden
async function loadOvertimeBreakdown() { async function loadOvertimeBreakdown() {
const loadingEl = document.getElementById('loading'); const loadingEl = document.getElementById('loading');
@@ -243,23 +274,26 @@
totalOvertimeTaken += week.overtime_taken; totalOvertimeTaken += week.overtime_taken;
}); });
const overtimeOffset = data.overtime_offset_hours || 0; const overtimeOffset = data.overtime_offset_hours || 0;
const remainingOvertime = totalOvertime - totalOvertimeTaken + overtimeOffset; // Variante B: Verbleibend = Summe Wochen-Überstunden + Offset („genommen“ nur Anzeige)
const remainingOvertime = totalOvertime + overtimeOffset;
// Gesamt Überstunden = Verbleibend + Genommen (kumuliert inkl. bereits verbrauchter)
const displayTotalOvertime = remainingOvertime + totalOvertimeTaken;
// Zusammenfassung anzeigen // Zusammenfassung anzeigen
const totalOvertimeEl = document.getElementById('totalOvertime'); const totalOvertimeEl = document.getElementById('totalOvertime');
totalOvertimeEl.textContent = totalOvertimeEl.textContent =
(totalOvertime >= 0 ? '+' : '') + totalOvertime.toFixed(2) + ' h'; (displayTotalOvertime >= 0 ? '+' : '') + formatHoursMin(displayTotalOvertime);
totalOvertimeEl.className = totalOvertimeEl.className =
'summary-value ' + (totalOvertime >= 0 ? 'overtime-positive' : 'overtime-negative'); 'summary-value ' + (displayTotalOvertime >= 0 ? 'overtime-positive' : 'overtime-negative');
const totalOvertimeTakenEl = document.getElementById('totalOvertimeTaken'); const totalOvertimeTakenEl = document.getElementById('totalOvertimeTaken');
totalOvertimeTakenEl.textContent = totalOvertimeTakenEl.textContent =
totalOvertimeTaken.toFixed(2) + ' h'; totalOvertimeTaken > 0 ? '-' + formatHoursMin(totalOvertimeTaken) : formatHoursMin(0);
totalOvertimeTakenEl.className = 'summary-value overtime-positive'; totalOvertimeTakenEl.className = 'summary-value overtime-negative';
const remainingOvertimeEl = document.getElementById('remainingOvertime'); const remainingOvertimeEl = document.getElementById('remainingOvertime');
remainingOvertimeEl.textContent = remainingOvertimeEl.textContent =
(remainingOvertime >= 0 ? '+' : '') + remainingOvertime.toFixed(2) + ' h'; (remainingOvertime >= 0 ? '+' : '') + formatHoursMin(remainingOvertime);
remainingOvertimeEl.className = remainingOvertimeEl.className =
'summary-value ' + (remainingOvertime >= 0 ? 'overtime-positive' : 'overtime-negative'); 'summary-value ' + (remainingOvertime >= 0 ? 'overtime-positive' : 'overtime-negative');
@@ -267,13 +301,52 @@
const offsetItem = document.getElementById('offsetItem'); const offsetItem = document.getElementById('offsetItem');
const offsetValue = document.getElementById('overtimeOffset'); const offsetValue = document.getElementById('overtimeOffset');
if (overtimeOffset !== 0) { if (overtimeOffset !== 0) {
offsetValue.textContent = (overtimeOffset >= 0 ? '+' : '') + overtimeOffset.toFixed(2) + ' h'; offsetValue.textContent = (overtimeOffset >= 0 ? '+' : '') + formatHoursMin(overtimeOffset);
offsetValue.className = 'summary-value ' + (overtimeOffset >= 0 ? 'overtime-positive' : 'overtime-negative'); offsetValue.className = 'summary-value ' + (overtimeOffset >= 0 ? 'overtime-positive' : 'overtime-negative');
offsetItem.style.display = 'flex'; offsetItem.style.display = 'flex';
} else { } else {
offsetItem.style.display = 'none'; offsetItem.style.display = 'none';
} }
// Korrekturen durch die Verwaltung anzeigen (Collapsible, nur wenn vorhanden)
const correctionsSectionEl = document.getElementById('correctionsSection');
const correctionsListEl = document.getElementById('correctionsList');
const correctionsHeaderEl = document.getElementById('correctionsHeader');
const correctionsContentEl = document.getElementById('correctionsContent');
const correctionsIconEl = document.getElementById('correctionsToggleIcon');
const corrections = Array.isArray(data.overtime_corrections) ? data.overtime_corrections : [];
if (correctionsSectionEl && correctionsListEl && correctionsHeaderEl && correctionsContentEl && correctionsIconEl && corrections.length > 0) {
correctionsSectionEl.style.display = 'block';
correctionsListEl.innerHTML = '';
corrections.forEach(c => {
const dt = parseSqliteDatetime(c.corrected_at);
const dateText = dt ? dt.toLocaleDateString('de-DE') : '';
const rawHours = Number(c.correction_hours) || 0;
const absHours = Math.abs(rawHours);
const signPrefix = rawHours >= 0 ? '+' : '-';
const hoursClass = rawHours >= 0 ? 'overtime-positive' : 'overtime-negative';
const hoursDisplay = signPrefix + formatHoursMin(absHours);
const reason = (c && c.reason != null) ? String(c.reason).trim() : '';
const li = document.createElement('li');
li.innerHTML = reason
? `Korrektur am ${dateText} <span class="${hoursClass}">${hoursDisplay}</span> ${reason}`
: `Korrektur am ${dateText} <span class="${hoursClass}">${hoursDisplay}</span>`;
correctionsListEl.appendChild(li);
});
// Standard: zugeklappt
correctionsExpanded = false;
correctionsContentEl.style.display = 'none';
correctionsIconEl.style.transform = 'rotate(0deg)';
// Click-Handler setzen (idempotent)
correctionsHeaderEl.onclick = toggleCorrectionsSection;
} else if (correctionsSectionEl) {
correctionsSectionEl.style.display = 'none';
}
summaryBoxEl.style.display = 'block'; summaryBoxEl.style.display = 'block';
// Tabelle füllen // Tabelle füllen
@@ -284,14 +357,16 @@
const dateRange = formatDate(week.week_start) + ' - ' + formatDate(week.week_end); const dateRange = formatDate(week.week_start) + ' - ' + formatDate(week.week_end);
const overtimeClass = week.overtime_hours >= 0 ? 'overtime-positive' : 'overtime-negative'; const overtimeClass = week.overtime_hours >= 0 ? 'overtime-positive' : 'overtime-negative';
const overtimeSign = week.overtime_hours >= 0 ? '+' : ''; const overtimeSign = week.overtime_hours >= 0 ? '+' : '';
const overtimeCell = Math.abs(week.overtime_hours) < 0.01 ? '-' : overtimeSign + formatHoursMin(week.overtime_hours);
const takenCell = week.overtime_taken > 0 ? '-' + formatHoursMin(week.overtime_taken) : '-';
row.innerHTML = ` row.innerHTML = `
<td><strong>${week.year} KW${calendarWeekStr}</strong></td> <td><strong>${week.year} KW${calendarWeekStr}</strong></td>
<td>${dateRange}</td> <td>${dateRange}</td>
<td>${week.total_hours.toFixed(2)} h</td> <td>${formatHoursMin(week.total_hours)}</td>
<td>${week.soll_stunden.toFixed(2)} h</td> <td>${formatHoursMin(week.soll_stunden)}</td>
<td class="${overtimeClass}">${overtimeSign}${week.overtime_hours.toFixed(2)} h</td> <td class="${overtimeClass}">${overtimeCell}</td>
<td>${week.overtime_taken.toFixed(2)} h</td> <td class="overtime-negative">${takenCell}</td>
<td>${week.vacation_days > 0 ? week.vacation_days.toFixed(1) : '-'}</td> <td>${week.vacation_days > 0 ? week.vacation_days.toFixed(1) : '-'}</td>
`; `;
tableBodyEl.appendChild(row); tableBodyEl.appendChild(row);

View File

@@ -85,32 +85,48 @@
<% if (employee.user.personalnummer) { %> <% if (employee.user.personalnummer) { %>
<span style="margin-left: 10px; color: #666;">(Personalnummer: <%= employee.user.personalnummer %>)</span> <span style="margin-left: 10px; color: #666;">(Personalnummer: <%= employee.user.personalnummer %>)</span>
<% } %> <% } %>
<% if (employee.has_new_version_after_download) { %>
<span class="employee-new-version-warning">ACHTUNG: Neue version eingereicht</span>
<% } %>
</div> </div>
<div class="employee-details" style="margin-top: 10px;"> <div class="employee-details" style="margin-top: 10px;">
<div style="display: inline-block; margin-right: 20px;"> <div style="display: inline-block; margin-right: 20px;">
<strong>Aktuelle Überstunden:</strong> <span class="current-overtime-value" data-user-id="<%= employee.user.id %>">-</span> Stunden <strong>Aktuelle Überstunden:</strong> <span class="current-overtime-value" data-user-id="<%= employee.user.id %>">-</span>
</div> </div>
<div style="display: inline-block; margin-right: 20px;"> <div style="display: inline-block; margin-right: 20px;">
<strong>Verbleibender Urlaub:</strong> <span class="remaining-vacation-value" data-user-id="<%= employee.user.id %>">-</span> Tage <strong>Verbleibender Urlaub:</strong> <span class="remaining-vacation-value" data-user-id="<%= employee.user.id %>">-</span> Tage
</div> </div>
<div style="display: inline-flex; gap: 8px; align-items: center; margin-right: 20px;"> <div style="display: inline-flex; gap: 8px; align-items: center; margin-right: 20px;">
<strong>Überstunden-Offset:</strong> <strong>Überstunden-Korrektur:</strong>
<input <input
type="number" type="text"
step="0.25"
class="overtime-offset-input" class="overtime-offset-input"
data-user-id="<%= employee.user.id %>" data-user-id="<%= employee.user.id %>"
value="<%= (employee.user.overtime_offset_hours !== undefined && employee.user.overtime_offset_hours !== null) ? employee.user.overtime_offset_hours : 0 %>" value="0:00"
style="width: 90px; padding: 4px 6px; border: 1px solid #ddd; border-radius: 4px;" placeholder="z. B. 1:30, 10:30 oder -0:45"
title="Manuelle Korrektur (positiv oder negativ) in Stunden" /> style="width: 110px; padding: 4px 6px; border: 1px solid #ddd; border-radius: 4px;"
title="Korrektur in h:mm (z. B. 1:30, 10:30 oder -0:45). Nach dem Speichern wird das Feld auf 0:00 gesetzt." />
<button <button
type="button" type="button"
class="btn btn-success btn-sm save-overtime-offset-btn" class="btn btn-success btn-sm save-overtime-offset-btn"
data-user-id="<%= employee.user.id %>" data-user-id="<%= employee.user.id %>"
style="padding: 6px 10px; white-space: nowrap;" style="padding: 6px 10px; white-space: nowrap;"
title="Überstunden-Offset speichern"> title="Überstunden-Korrektur speichern (addiert/abzieht und protokolliert)">
Speichern Speichern
</button> </button>
<button
type="button"
class="btn btn-secondary btn-sm toggle-overtime-corrections-btn"
data-user-id="<%= employee.user.id %>"
style="padding: 6px 10px; white-space: nowrap;"
title="Korrektur-Historie anzeigen/ausblenden">
Historie
</button>
</div>
<div class="overtime-corrections-container" data-user-id="<%= employee.user.id %>" style="display: none; margin: 10px; padding: 10px 12px; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px;">
<div class="overtime-corrections-loading" style="color: #666; font-size: 13px;">Lade Korrekturen...</div>
<div class="overtime-corrections-empty" style="display: none; color: #999; font-size: 13px;">Keine Korrekturen vorhanden.</div>
<ul class="overtime-corrections-list" style="margin: 8px 0 0 0; padding-left: 18px; font-size: 13px;"></ul>
</div> </div>
<div style="display: inline-flex; gap: 8px; align-items: center; margin-right: 20px;"> <div style="display: inline-flex; gap: 8px; align-items: center; margin-right: 20px;">
<strong>Urlaubstage-Offset:</strong> <strong>Urlaubstage-Offset:</strong>
@@ -302,6 +318,7 @@
</div> </div>
</div> </div>
<script src="/js/format-hours.js"></script>
<script> <script>
async function loadStatsForDiv(statsDiv) { async function loadStatsForDiv(statsDiv) {
const userId = statsDiv.dataset.userId; const userId = statsDiv.dataset.userId;
@@ -325,13 +342,13 @@
const weekOvertimeColor = data.weekOvertimeHours < 0 ? '#dc3545' : (data.weekOvertimeHours > 0 ? '#28a745' : '#666'); const weekOvertimeColor = data.weekOvertimeHours < 0 ? '#dc3545' : (data.weekOvertimeHours > 0 ? '#28a745' : '#666');
const sign = data.weekOvertimeHours >= 0 ? '+' : ''; const sign = data.weekOvertimeHours >= 0 ? '+' : '';
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;"> statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
<strong>Überstunden:</strong> <span style="color: ${weekOvertimeColor};">${sign}${data.weekOvertimeHours.toFixed(2)} h</span> <strong>Überstunden:</strong> <span style="color: ${weekOvertimeColor};">${sign}${formatHoursMin(data.weekOvertimeHours)}</span>
</div>`; </div>`;
} }
if (data.overtimeOffsetHours !== undefined && data.overtimeOffsetHours !== 0) { if (data.overtimeOffsetHours !== undefined && data.overtimeOffsetHours !== 0) {
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;"> statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
<strong>Offset:</strong> <span>${Number(data.overtimeOffsetHours).toFixed(2)} h</span> <strong>Offset:</strong> <span>${formatHoursMin(Number(data.overtimeOffsetHours))}</span>
${data.remainingOvertimeWithOffset !== undefined ? `<span style="color: #28a745;">(verbleibend inkl. Offset: ${Number(data.remainingOvertimeWithOffset).toFixed(2)} h)</span>` : ''} ${data.remainingOvertimeWithOffset !== undefined ? `<span style="color: #28a745;">(verbleibend inkl. Offset: ${formatHoursMin(Number(data.remainingOvertimeWithOffset))})</span>` : ''}
</div>`; </div>`;
} }
if (data.totalVacationDays !== undefined || data.vacationDays !== undefined) { if (data.totalVacationDays !== undefined || data.vacationDays !== undefined) {
@@ -417,7 +434,7 @@
el.textContent = '-'; el.textContent = '-';
el.style.color = ''; el.style.color = '';
} else { } else {
el.textContent = value >= 0 ? '+' + value.toFixed(2) : value.toFixed(2); el.textContent = (value >= 0 ? '+' : '') + formatHoursMin(value);
el.style.color = value >= 0 ? '#27ae60' : '#e74c3c'; el.style.color = value >= 0 ? '#27ae60' : '#e74c3c';
} }
}); });
@@ -489,6 +506,188 @@
loadCurrentOvertime(); loadCurrentOvertime();
loadRemainingVacation(); loadRemainingVacation();
// Überstunden-Korrektur-Historie laden/anzeigen
function parseSqliteDatetime(value) {
if (!value) return null;
const s = String(value);
if (s.includes('T')) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
// formatHoursMin aus format-hours.js (window.formatHoursMin)
function showOvertimeCorrectionReasonModal(opts) {
const title = opts && opts.title ? String(opts.title) : 'Grund für die Korrektur';
const promptText = opts && opts.prompt ? String(opts.prompt) : 'Bitte geben Sie einen Grund an, warum die Korrektur vorgenommen wird.';
const onSubmit = opts && typeof opts.onSubmit === 'function' ? opts.onSubmit : null;
const onCancel = opts && typeof opts.onCancel === 'function' ? opts.onCancel : null;
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
`;
const box = document.createElement('div');
box.style.cssText = `
background: #fff;
border-radius: 8px;
width: 90%;
max-width: 520px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
padding: 18px 18px 14px 18px;
`;
box.innerHTML = `
<div style="display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom: 8px;">
<h3 style="margin: 0; font-size: 16px; color: #2c3e50;">${title}</h3>
<button type="button" data-action="close" class="btn btn-secondary btn-sm" style="padding: 6px 10px;">✕</button>
</div>
<div style="color:#666; font-size: 13px; margin-bottom: 10px;">${promptText}</div>
<textarea data-role="reason" rows="4" style="width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-family: inherit; font-size: 13px; resize: vertical;" placeholder="Grund..."></textarea>
<div data-role="error" style="display:none; margin-top: 8px; color:#dc3545; font-size: 13px;"></div>
<div style="display:flex; justify-content:flex-end; gap: 10px; margin-top: 12px;">
<button type="button" data-action="cancel" class="btn btn-secondary">Abbrechen</button>
<button type="button" data-action="submit" class="btn btn-success">Speichern</button>
</div>
`;
modal.appendChild(box);
document.body.appendChild(modal);
const textarea = box.querySelector('textarea[data-role="reason"]');
const errorEl = box.querySelector('[data-role="error"]');
function close() {
document.body.removeChild(modal);
}
function cancel() {
try {
if (onCancel) onCancel();
} finally {
close();
}
}
function setError(msg) {
if (!errorEl) return;
errorEl.textContent = msg;
errorEl.style.display = msg ? 'block' : 'none';
}
async function submit() {
const reason = textarea ? textarea.value.trim() : '';
if (!reason) {
setError('Bitte Grund angeben.');
if (textarea) textarea.focus();
return;
}
setError('');
if (onSubmit) {
await onSubmit(reason);
}
close();
}
modal.addEventListener('click', (e) => {
if (e.target === modal) cancel();
});
box.querySelectorAll('button[data-action="close"], button[data-action="cancel"]').forEach(btn => {
btn.addEventListener('click', cancel);
});
const submitBtn = box.querySelector('button[data-action="submit"]');
if (submitBtn) submitBtn.addEventListener('click', submit);
if (textarea) {
textarea.focus();
textarea.addEventListener('keydown', (e) => {
if (e.key === 'Escape') cancel();
if ((e.key === 'Enter' && (e.ctrlKey || e.metaKey))) {
e.preventDefault();
submit();
}
});
}
}
async function loadOvertimeCorrectionsForUser(userId) {
const container = document.querySelector(`.overtime-corrections-container[data-user-id="${userId}"]`);
if (!container) return;
const loadingEl = container.querySelector('.overtime-corrections-loading');
const emptyEl = container.querySelector('.overtime-corrections-empty');
const listEl = container.querySelector('.overtime-corrections-list');
if (loadingEl) loadingEl.style.display = 'block';
if (emptyEl) emptyEl.style.display = 'none';
if (listEl) listEl.innerHTML = '';
try {
const resp = await fetch(`/api/verwaltung/user/${userId}/overtime-corrections`);
const data = await resp.json().catch(() => ({}));
const corrections = Array.isArray(data.corrections) ? data.corrections : [];
if (!resp.ok) {
throw new Error(data.error || 'Fehler beim Laden der Korrekturen');
}
if (corrections.length === 0) {
if (emptyEl) emptyEl.style.display = 'block';
return;
}
corrections.forEach(c => {
const dt = parseSqliteDatetime(c.corrected_at);
const dateText = dt ? dt.toLocaleDateString('de-DE') : '';
const rawHours = Number(c.correction_hours) || 0;
const absHours = Math.abs(rawHours);
const signPrefix = rawHours >= 0 ? '+' : '-';
const hoursClass = rawHours >= 0 ? 'overtime-positive' : 'overtime-negative';
const hoursDisplay = signPrefix + formatHoursMin(absHours);
const reason = (c && c.reason != null) ? String(c.reason).trim() : '';
const li = document.createElement('li');
li.innerHTML = reason
? `Korrektur am ${dateText} <span class="${hoursClass}">${hoursDisplay}</span> ${reason}`
: `Korrektur am ${dateText} <span class="${hoursClass}">${hoursDisplay}</span>`;
if (listEl) listEl.appendChild(li);
});
} catch (e) {
console.error('Fehler beim Laden der Überstunden-Korrekturen:', e);
if (emptyEl) {
emptyEl.textContent = 'Fehler beim Laden der Korrekturen.';
emptyEl.style.display = 'block';
}
} finally {
if (loadingEl) loadingEl.style.display = 'none';
}
}
document.querySelectorAll('.toggle-overtime-corrections-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const userId = this.dataset.userId;
const container = document.querySelector(`.overtime-corrections-container[data-user-id="${userId}"]`);
if (!container) return;
const isOpen = container.style.display !== 'none' && container.style.display !== '';
if (isOpen) {
container.style.display = 'none';
return;
}
container.style.display = 'block';
// Beim Öffnen immer neu laden (damit neue Korrekturen sofort sichtbar sind)
await loadOvertimeCorrectionsForUser(userId);
container.dataset.loaded = 'true';
});
});
// Überstunden-Offset speichern // Überstunden-Offset speichern
document.querySelectorAll('.save-overtime-offset-btn').forEach(btn => { document.querySelectorAll('.save-overtime-offset-btn').forEach(btn => {
btn.addEventListener('click', async function() { btn.addEventListener('click', async function() {
@@ -496,30 +695,59 @@
const input = document.querySelector(`.overtime-offset-input[data-user-id="${userId}"]`); const input = document.querySelector(`.overtime-offset-input[data-user-id="${userId}"]`);
if (!input) return; if (!input) return;
if (this.dataset.modalOpen === 'true') return;
const originalText = this.textContent; const originalText = this.textContent;
this.disabled = true;
this.textContent = '...';
// leere Eingabe => 0 (Backend macht das auch, aber UI soll sauber sein) // leere Eingabe => 0 (Backend macht das auch, aber UI soll sauber sein)
const raw = (input.value || '').trim(); const raw = (input.value || '').trim();
const value = raw === '' ? '' : Number(raw); const value = (typeof parseHoursMin === 'function' ? parseHoursMin(raw) : null);
if (value === null && raw !== '') {
alert('Ungültiges Format. Bitte h:mm oder hh:mm eingeben (z. B. 1:30, 10:30 oder -0:45).');
return;
}
const decimalValue = (value === null ? 0 : value);
// Wenn keine Korrektur (0), nichts tun außer UI auf 0:00 zu normalisieren
if (decimalValue === 0) {
input.value = (typeof decimalHoursToHhMm === 'function' ? decimalHoursToHhMm(0) : '0:00');
this.textContent = originalText;
this.disabled = false;
return;
}
this.dataset.modalOpen = 'true';
this.disabled = true;
// Modal: Grund ist Pflicht
showOvertimeCorrectionReasonModal({
title: 'Grund für die Überstunden-Korrektur',
prompt: `Korrektur: ${decimalValue >= 0 ? '+' : ''}${formatHoursMin(decimalValue)}`,
onCancel: () => {
delete this.dataset.modalOpen;
this.disabled = false;
},
onSubmit: async (reason) => {
this.textContent = '...';
try { try {
const resp = await fetch(`/api/verwaltung/user/${userId}/overtime-offset`, { const resp = await fetch(`/api/verwaltung/user/${userId}/overtime-offset`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ overtime_offset_hours: value }) body: JSON.stringify({ overtime_offset_hours: decimalValue, reason })
}); });
const data = await resp.json(); const data = await resp.json();
if (!resp.ok) { if (!resp.ok) {
alert(data.error || 'Fehler beim Speichern des Offsets'); alert(data.error || 'Fehler beim Speichern der Korrektur');
this.textContent = originalText;
this.disabled = false;
delete this.dataset.modalOpen;
return; return;
} }
// Normalisiere Input auf Zahl (Backend gibt number zurück) // Normalisiere Input auf h:mm (Backend gibt 0 zurück nach Speichern)
input.value = (data.overtime_offset_hours !== undefined && data.overtime_offset_hours !== null) input.value = (typeof decimalHoursToHhMm === 'function')
? Number(data.overtime_offset_hours) ? decimalHoursToHhMm((data.overtime_offset_hours !== undefined && data.overtime_offset_hours !== null) ? Number(data.overtime_offset_hours) : 0)
: 0; : '0:00';
// Stats für diesen User neu laden // Stats für diesen User neu laden
const statDivs = document.querySelectorAll(`.group-stats[data-user-id="${userId}"]`); const statDivs = document.querySelectorAll(`.group-stats[data-user-id="${userId}"]`);
@@ -535,22 +763,29 @@
}); });
loadCurrentOvertime(); loadCurrentOvertime();
// Historie (falls geöffnet) aktualisieren
const correctionsContainer = document.querySelector(`.overtime-corrections-container[data-user-id="${userId}"]`);
if (correctionsContainer && correctionsContainer.style.display !== 'none') {
await loadOvertimeCorrectionsForUser(userId);
}
this.textContent = '✓'; this.textContent = '✓';
setTimeout(() => { setTimeout(() => {
this.textContent = originalText; this.textContent = originalText;
this.disabled = false; this.disabled = false;
delete this.dataset.modalOpen;
}, 900); }, 900);
} catch (e) { } catch (e) {
console.error('Fehler beim Speichern des Offsets:', e); console.error('Fehler beim Speichern der Korrektur:', e);
alert('Fehler beim Speichern des Offsets'); alert('Fehler beim Speichern der Korrektur');
} finally {
if (this.textContent === '...') {
this.textContent = originalText; this.textContent = originalText;
this.disabled = false; this.disabled = false;
delete this.dataset.modalOpen;
} }
} }
}); });
}); });
});
// Urlaubstage-Offset speichern // Urlaubstage-Offset speichern
document.querySelectorAll('.save-vacation-offset-btn').forEach(btn => { document.querySelectorAll('.save-vacation-offset-btn').forEach(btn => {