Überstunden Details

This commit is contained in:
2026-01-30 21:00:32 +01:00
parent f16593a345
commit 3d02fd56ea
4 changed files with 540 additions and 14 deletions

View File

@@ -39,6 +39,32 @@ function registerDashboardRoutes(app) {
}
});
});
// Überstunden-Auswertung für Mitarbeiter
app.get('/overtime-breakdown', requireAuth, (req, res) => {
// Prüfe ob User Mitarbeiter-Rolle hat
if (!hasRole(req, 'mitarbeiter')) {
// Wenn User keine Mitarbeiter-Rolle hat, aber andere Rollen, redirecte entsprechend
if (hasRole(req, 'admin')) {
return res.redirect('/admin');
}
if (hasRole(req, 'verwaltung')) {
return res.redirect('/verwaltung');
}
return res.status(403).send('Zugriff verweigert');
}
res.render('overtime-breakdown', {
user: {
id: req.session.userId,
firstname: req.session.firstname,
lastname: req.session.lastname,
username: req.session.username,
roles: req.session.roles || [],
currentRole: req.session.currentRole || 'mitarbeiter'
}
});
});
}
module.exports = registerDashboardRoutes;

View File

@@ -431,25 +431,15 @@ function registerUserRoutes(app) {
weekVacationDays += 0.5;
weekVacationHours += 4; // Halber Tag = 4 Stunden
// Bei halbem Tag Urlaub können noch Arbeitsstunden vorhanden sein
// WICHTIG: total_hours enthält bereits Wochenend-Prozentsätze (aus timesheet.js)
if (entry.total_hours && !isFullDayOvertime) {
let hours = entry.total_hours;
// Wochenend-Prozentsatz anwenden (nur auf tatsächlich gearbeitete Stunden)
const weekendPercentage = getWeekendPercentage(entry.date);
if (weekendPercentage >= 100 && hours > 0 && !entry.sick_status) {
hours = hours * (weekendPercentage / 100);
}
weekTotalHours += hours;
weekTotalHours += parseFloat(entry.total_hours) || 0;
}
} else {
// Kein Urlaub - zähle nur Arbeitsstunden (wenn nicht 8 Überstunden)
// WICHTIG: total_hours enthält bereits Wochenend-Prozentsätze (aus timesheet.js)
if (entry.total_hours && !isFullDayOvertime) {
let hours = entry.total_hours;
// Wochenend-Prozentsatz anwenden (nur auf tatsächlich gearbeitete Stunden, nicht auf Krankheit)
const weekendPercentage = getWeekendPercentage(entry.date);
if (weekendPercentage > 0 && hours > 0 && !entry.sick_status) {
hours = hours * (1 + weekendPercentage / 100);
}
weekTotalHours += hours;
weekTotalHours += parseFloat(entry.total_hours) || 0;
}
}
});
@@ -511,6 +501,232 @@ function registerUserRoutes(app) {
}); // db.get (user)
}); // db.get (options)
}); // app.get
// API: Wöchentliche Überstunden-Aufschlüsselung
app.get('/api/user/overtime-breakdown', requireAuth, (req, res) => {
const userId = req.session.userId;
// Wochenend-Prozentsätze laden
db.get('SELECT saturday_percentage, sunday_percentage FROM system_options WHERE id = 1', (err, options) => {
if (err) {
return res.status(500).json({ error: 'Fehler beim Laden der Optionen' });
}
const saturdayPercentage = options?.saturday_percentage || 100;
const sundayPercentage = options?.sunday_percentage || 100;
// Hilfsfunktion: Prüft ob ein Datum ein Wochenendtag ist und gibt den Prozentsatz zurück
function getWeekendPercentage(dateStr) {
const date = new Date(dateStr);
const day = date.getDay();
if (day === 6) { // Samstag
return saturdayPercentage;
} else if (day === 0) { // Sonntag
return sundayPercentage;
}
return 100; // Kein Wochenende = 100% (normal)
}
// User-Daten abrufen
db.get('SELECT wochenstunden, overtime_offset_hours FROM users WHERE id = ?', [userId], (err, user) => {
if (err || !user) {
return res.status(500).json({ error: 'Fehler beim Abrufen der User-Daten' });
}
const wochenstunden = user.wochenstunden || 0;
const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0;
// Alle eingereichten Wochen abrufen
db.all(`SELECT DISTINCT week_start, week_end
FROM weekly_timesheets
WHERE user_id = ? AND status = 'eingereicht'
ORDER BY week_start DESC`,
[userId],
(err, weeks) => {
if (err) {
return res.status(500).json({ error: 'Fehler beim Abrufen der Wochen' });
}
// Wenn keine Wochen vorhanden
if (!weeks || weeks.length === 0) {
return res.json({ weeks: [] });
}
const { getCalendarWeek } = require('../helpers/utils');
const weekData = [];
let processedWeeks = 0;
let hasError = false;
// Für jede Woche die Statistiken berechnen
weeks.forEach((week) => {
// Einträge für diese Woche abrufen (nur neueste pro Tag)
db.all(`SELECT id, date, total_hours, overtime_taken_hours, vacation_type, sick_status, start_time, end_time, updated_at
FROM timesheet_entries
WHERE user_id = ? AND date >= ? AND date <= ?
ORDER BY date, updated_at DESC, id DESC`,
[userId, week.week_start, week.week_end],
(err, allEntries) => {
if (hasError) return;
if (err) {
hasError = true;
return res.status(500).json({ error: 'Fehler beim Abrufen der Einträge' });
}
// Filtere auf neuesten Eintrag pro Tag
const entriesByDate = {};
(allEntries || []).forEach(entry => {
const existing = entriesByDate[entry.date];
if (!existing) {
entriesByDate[entry.date] = entry;
} else {
const existingTime = existing.updated_at ? new Date(existing.updated_at).getTime() : 0;
const currentTime = entry.updated_at ? new Date(entry.updated_at).getTime() : 0;
if (currentTime > existingTime || (currentTime === existingTime && entry.id > existing.id)) {
entriesByDate[entry.date] = entry;
}
}
});
// Konvertiere zurück zu Array
const entries = Object.values(entriesByDate);
// Feiertage für die Woche laden
getHolidaysForDateRange(week.week_start, week.week_end)
.catch(() => new Set())
.then((holidaySet) => {
// Prüfe alle 5 Werktage (Montag-Freitag)
const startDate = new Date(week.week_start);
const endDate = new Date(week.week_end);
let workdays = 0;
let filledWorkdays = 0;
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const day = d.getDay();
if (day >= 1 && day <= 5) { // Montag bis Freitag
workdays++;
const dateStr = d.toISOString().split('T')[0];
if (holidaySet.has(dateStr)) {
filledWorkdays++;
continue;
}
const entry = entriesByDate[dateStr];
if (entry) {
const isFullDayVacation = entry.vacation_type === 'full';
const isSick = entry.sick_status === 1 || entry.sick_status === true;
const hasStartAndEnd = entry.start_time && entry.end_time &&
entry.start_time.toString().trim() !== '' &&
entry.end_time.toString().trim() !== '';
if (isFullDayVacation || isSick || hasStartAndEnd) {
filledWorkdays++;
}
}
}
}
// Nur berechnen wenn alle Werktage ausgefüllt sind
if (filledWorkdays < workdays) {
processedWeeks++;
if (processedWeeks === weeks.length && !hasError) {
res.json({ weeks: weekData });
}
return;
}
// Berechnungen für diese Woche
let weekTotalHours = 0;
let weekOvertimeTaken = 0;
let weekVacationDays = 0;
let weekVacationHours = 0;
const fullDayHours = wochenstunden > 0 ? wochenstunden / 5 : 8;
let fullDayOvertimeDays = 0;
entries.forEach(entry => {
const overtimeValue = entry.overtime_taken_hours ? parseFloat(entry.overtime_taken_hours) : 0;
const isFullDayOvertime = overtimeValue > 0 && Math.abs(overtimeValue - fullDayHours) < 0.01;
if (entry.overtime_taken_hours) {
weekOvertimeTaken += parseFloat(entry.overtime_taken_hours) || 0;
}
if (isFullDayOvertime) {
fullDayOvertimeDays++;
}
if (entry.vacation_type === 'full') {
weekVacationDays += 1;
weekVacationHours += 8;
} else if (entry.vacation_type === 'half') {
weekVacationDays += 0.5;
weekVacationHours += 4;
// WICHTIG: total_hours enthält bereits Wochenend-Prozentsätze (aus timesheet.js)
if (entry.total_hours && !isFullDayOvertime) {
weekTotalHours += parseFloat(entry.total_hours) || 0;
}
} else {
// WICHTIG: total_hours enthält bereits Wochenend-Prozentsätze (aus timesheet.js)
if (entry.total_hours && !isFullDayOvertime) {
weekTotalHours += parseFloat(entry.total_hours) || 0;
}
}
});
// Feiertagsstunden
let holidayHours = 0;
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const day = d.getDay();
if (day >= 1 && day <= 5) {
const dateStr = d.toISOString().split('T')[0];
if (holidaySet.has(dateStr)) holidayHours += 8;
}
}
// Sollstunden berechnen
const sollStunden = (wochenstunden / 5) * workdays;
const weekTotalHoursWithVacation = weekTotalHours + weekVacationHours + holidayHours;
const adjustedSollStunden = sollStunden - (fullDayOvertimeDays * fullDayHours);
const weekOvertimeHours = weekTotalHoursWithVacation - adjustedSollStunden;
// Kalenderwoche berechnen
const calendarWeek = getCalendarWeek(week.week_start);
const year = new Date(week.week_start).getFullYear();
// Wochen-Daten hinzufügen
weekData.push({
week_start: week.week_start,
week_end: week.week_end,
calendar_week: calendarWeek,
year: year,
overtime_hours: parseFloat(weekOvertimeHours.toFixed(2)),
overtime_taken: parseFloat(weekOvertimeTaken.toFixed(2)),
total_hours: parseFloat(weekTotalHoursWithVacation.toFixed(2)),
soll_stunden: parseFloat(adjustedSollStunden.toFixed(2)),
vacation_days: parseFloat(weekVacationDays.toFixed(1)),
workdays: workdays
});
processedWeeks++;
// Wenn alle Wochen verarbeitet wurden, Antwort senden
if (processedWeeks === weeks.length && !hasError) {
// Sortiere nach Datum (neueste zuerst)
weekData.sort((a, b) => {
if (a.year !== b.year) return b.year - a.year;
if (a.calendar_week !== b.calendar_week) return b.calendar_week - a.calendar_week;
return new Date(b.week_start) - new Date(a.week_start);
});
res.json({ weeks: weekData, overtime_offset_hours: overtimeOffsetHours });
}
}); // getHolidaysForDateRange.then
}); // db.all (allEntries)
}); // weeks.forEach
}); // db.all (weeks)
}); // db.get (user)
}); // db.get (options)
}); // app.get
}
module.exports = registerUserRoutes;

View File

@@ -70,6 +70,9 @@
</div>
<div class="stat-value" id="currentOvertime">-</div>
<div class="stat-unit">Stunden</div>
<div style="margin-top: 10px;">
<a href="/overtime-breakdown" class="btn btn-primary" style="width: 100%; font-size: 13px; padding: 8px 12px;">Details anzeigen</a>
</div>
</div>
<div class="stat-card stat-vacation">
<div class="stat-label" style="display: flex; align-items: center; gap: 5px;">

View File

@@ -0,0 +1,281 @@
<!DOCTYPE html>
<html lang="de-DE">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Language" content="de-DE">
<title>Überstunden-Auswertung - Stundenerfassung</title>
<link rel="icon" type="image/png" href="/images/favicon.png">
<link rel="stylesheet" href="/css/style.css">
<style>
.overtime-breakdown-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.page-title {
font-size: 28px;
color: #2c3e50;
margin: 0;
}
.overtime-table {
width: 100%;
border-collapse: collapse;
background: white;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-radius: 8px;
overflow: hidden;
}
.overtime-table thead {
background-color: #2c3e50;
color: white;
}
.overtime-table th {
padding: 15px;
text-align: left;
font-weight: 600;
}
.overtime-table td {
padding: 12px 15px;
border-bottom: 1px solid #e0e0e0;
}
.overtime-table tbody tr:hover {
background-color: #f8f9fa;
}
.overtime-table tbody tr:last-child td {
border-bottom: none;
}
.overtime-positive {
color: #27ae60;
font-weight: 600;
}
.overtime-negative {
color: #e74c3c;
font-weight: 600;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.no-data {
text-align: center;
padding: 40px;
color: #666;
}
.summary-box {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin-bottom: 30px;
}
.summary-box h3 {
margin-top: 0;
margin-bottom: 15px;
color: #2c3e50;
}
.summary-item {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid #e0e0e0;
}
.summary-item:last-child {
border-bottom: none;
}
.summary-label {
font-weight: 500;
color: #555;
}
.summary-value {
font-weight: 600;
color: #2c3e50;
}
</style>
</head>
<body>
<div class="navbar">
<div class="container">
<div class="navbar-brand">
<img src="/images/header.png" alt="Logo" class="navbar-logo">
<h1>Stundenerfassung</h1>
</div>
<div class="nav-right">
<span>Willkommen, <%= user.firstname %> <%= user.lastname %></span>
<% if (user.roles && user.roles.length > 1) { %>
<select id="roleSwitcher" class="role-switcher" style="margin-right: 10px; padding: 5px 10px; border-radius: 4px; border: 1px solid #ddd;">
<% const roleLabels = { 'mitarbeiter': 'Mitarbeiter', 'verwaltung': 'Verwaltung', 'admin': 'Administrator' }; %>
<% user.roles.forEach(function(role) { %>
<option value="<%= role %>" <%= user.currentRole === role ? 'selected' : '' %>><%= roleLabels[role] || role %></option>
<% }); %>
</select>
<% } %>
<a href="/logout" class="btn btn-secondary">Abmelden</a>
</div>
</div>
</div>
<div class="overtime-breakdown-container">
<div class="page-header">
<h2 class="page-title">Überstunden-Auswertung</h2>
<a href="/dashboard" class="btn btn-secondary">Zurück zum Dashboard</a>
</div>
<div id="summaryBox" class="summary-box" style="display: none;">
<h3>Zusammenfassung</h3>
<div class="summary-item">
<span class="summary-label">Gesamt Überstunden:</span>
<span class="summary-value" id="totalOvertime">-</span>
</div>
<div class="summary-item">
<span class="summary-label">Davon genommen:</span>
<span class="summary-value" id="totalOvertimeTaken">-</span>
</div>
<div class="summary-item">
<span class="summary-label">Verbleibend:</span>
<span class="summary-value" id="remainingOvertime">-</span>
</div>
</div>
<div id="loading" class="loading">Lade Daten...</div>
<div id="noData" class="no-data" style="display: none;">
<p>Keine eingereichten Wochen gefunden.</p>
<p style="margin-top: 10px; font-size: 14px; color: #999;">Überstunden werden erst angezeigt, nachdem Wochen abgeschickt wurden.</p>
</div>
<table id="overtimeTable" class="overtime-table" style="display: none;">
<thead>
<tr>
<th>Kalenderwoche</th>
<th>Zeitraum</th>
<th>Gesamtstunden</th>
<th>Sollstunden</th>
<th>Überstunden</th>
<th>Genommen</th>
<th>Urlaubstage</th>
</tr>
</thead>
<tbody id="overtimeTableBody">
</tbody>
</table>
</div>
<script>
// Rollenwechsel
const roleSwitcher = document.getElementById('roleSwitcher');
if (roleSwitcher) {
roleSwitcher.addEventListener('change', async function() {
const role = this.value;
try {
const response = await fetch('/api/user/switch-role', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role })
});
const data = await response.json();
if (data.success) {
// Redirect basierend auf Rolle
if (role === 'admin') {
window.location.href = '/admin';
} else if (role === 'verwaltung') {
window.location.href = '/verwaltung';
} else {
window.location.href = '/dashboard';
}
}
} catch (error) {
console.error('Fehler beim Rollenwechsel:', error);
}
});
}
// Datum formatieren (DD.MM.YYYY)
function formatDate(dateStr) {
const date = new Date(dateStr);
return date.toLocaleDateString('de-DE');
}
// Überstunden-Daten laden
async function loadOvertimeBreakdown() {
const loadingEl = document.getElementById('loading');
const noDataEl = document.getElementById('noData');
const tableEl = document.getElementById('overtimeTable');
const tableBodyEl = document.getElementById('overtimeTableBody');
const summaryBoxEl = document.getElementById('summaryBox');
try {
const response = await fetch('/api/user/overtime-breakdown');
if (!response.ok) {
throw new Error('Fehler beim Laden der Daten');
}
const data = await response.json();
loadingEl.style.display = 'none';
if (!data.weeks || data.weeks.length === 0) {
noDataEl.style.display = 'block';
return;
}
// Zusammenfassung berechnen
let totalOvertime = 0;
let totalOvertimeTaken = 0;
data.weeks.forEach(week => {
totalOvertime += week.overtime_hours;
totalOvertimeTaken += week.overtime_taken;
});
const remainingOvertime = totalOvertime - totalOvertimeTaken + (data.overtime_offset_hours || 0);
// Zusammenfassung anzeigen
document.getElementById('totalOvertime').textContent =
(totalOvertime >= 0 ? '+' : '') + totalOvertime.toFixed(2) + ' h';
document.getElementById('totalOvertimeTaken').textContent =
totalOvertimeTaken.toFixed(2) + ' h';
document.getElementById('remainingOvertime').textContent =
(remainingOvertime >= 0 ? '+' : '') + remainingOvertime.toFixed(2) + ' h';
document.getElementById('remainingOvertime').className =
'summary-value ' + (remainingOvertime >= 0 ? 'overtime-positive' : 'overtime-negative');
summaryBoxEl.style.display = 'block';
// Tabelle füllen
tableBodyEl.innerHTML = '';
data.weeks.forEach(week => {
const row = document.createElement('tr');
const calendarWeekStr = String(week.calendar_week).padStart(2, '0');
const dateRange = formatDate(week.week_start) + ' - ' + formatDate(week.week_end);
const overtimeClass = week.overtime_hours >= 0 ? 'overtime-positive' : 'overtime-negative';
const overtimeSign = week.overtime_hours >= 0 ? '+' : '';
row.innerHTML = `
<td><strong>${week.year} KW${calendarWeekStr}</strong></td>
<td>${dateRange}</td>
<td>${week.total_hours.toFixed(2)} h</td>
<td>${week.soll_stunden.toFixed(2)} h</td>
<td class="${overtimeClass}">${overtimeSign}${week.overtime_hours.toFixed(2)} h</td>
<td>${week.overtime_taken.toFixed(2)} h</td>
<td>${week.vacation_days > 0 ? week.vacation_days.toFixed(1) : '-'}</td>
`;
tableBodyEl.appendChild(row);
});
tableEl.style.display = 'table';
} catch (error) {
console.error('Fehler beim Laden der Überstunden-Auswertung:', error);
loadingEl.textContent = 'Fehler beim Laden der Daten. Bitte versuchen Sie es später erneut.';
}
}
// Beim Laden der Seite
document.addEventListener('DOMContentLoaded', function() {
loadOvertimeBreakdown();
});
</script>
</body>
</html>