Verwaltung Überstunden anzeige, Buttons am unteren ende der Seite

This commit is contained in:
2026-02-09 17:37:06 +01:00
parent c396fe7d0e
commit cf1ca107f5
4 changed files with 326 additions and 5 deletions

View File

@@ -6,6 +6,7 @@ const { requireVerwaltung } = require('../middleware/auth');
const { getWeekDatesFromCalendarWeek } = require('../helpers/utils'); const { getWeekDatesFromCalendarWeek } = require('../helpers/utils');
const { generatePDFToBuffer } = require('../services/pdf-service'); const { generatePDFToBuffer } = require('../services/pdf-service');
const { getHolidaysForDateRange } = require('../services/feiertage-service'); const { getHolidaysForDateRange } = require('../services/feiertage-service');
const { getCurrentOvertimeForUser } = require('../services/overtime-service');
// Routes registrieren // Routes registrieren
function registerVerwaltungRoutes(app) { function registerVerwaltungRoutes(app) {
@@ -190,6 +191,35 @@ function registerVerwaltungRoutes(app) {
); );
}); });
// API: Aktuelle Überstunden für mehrere Mitarbeiter (Batch für Verwaltungs-Übersicht)
app.get('/api/verwaltung/employees/current-overtime', requireVerwaltung, (req, res) => {
const userIdsParam = req.query.userIds;
if (!userIdsParam || typeof userIdsParam !== 'string') {
return res.status(400).json({ error: 'userIds (kommagetrennt) erforderlich' });
}
const userIds = userIdsParam.split(',').map((id) => parseInt(id.trim(), 10)).filter((id) => !isNaN(id));
if (userIds.length === 0) {
return res.json({});
}
const result = {};
let pending = userIds.length;
userIds.forEach((userId) => {
getCurrentOvertimeForUser(userId, db, (err, currentOvertime) => {
if (err) {
result[String(userId)] = null;
} else {
result[String(userId)] = currentOvertime != null ? currentOvertime : 0;
}
pending--;
if (pending === 0) {
res.json(result);
}
});
});
});
// API: Überstunden- und Urlaubsstatistiken für einen User abrufen // API: Überstunden- und Urlaubsstatistiken für einen User abrufen
app.get('/api/verwaltung/user/:id/stats', requireVerwaltung, (req, res) => { app.get('/api/verwaltung/user/:id/stats', requireVerwaltung, (req, res) => {
const userId = req.params.id; const userId = req.params.id;

View File

@@ -0,0 +1,195 @@
// Overtime Service: Berechnung der aktuellen Überstunden-Bilanz pro User
// Wird von /api/user/stats und /api/verwaltung/employees/current-overtime genutzt.
const { getHolidaysForDateRange } = require('./feiertage-service');
/**
* Berechnet die aktuelle Überstunden-Bilanz für einen User (nur eingereichte Wochen).
* Entspricht der Anzeige "Aktuelle Überstunden" im Mitarbeiter-Dashboard.
*
* @param {number} userId - User-ID
* @param {object} db - Database-Instanz (sqlite3)
* @param {function} callback - (err, currentOvertime) currentOvertime in Stunden (kann negativ sein)
*/
function getCurrentOvertimeForUser(userId, db, callback) {
db.get(
'SELECT wochenstunden, arbeitstage, overtime_offset_hours FROM users WHERE id = ?',
[userId],
(err, user) => {
if (err) {
return callback(err, null);
}
if (!user) {
return callback(null, null);
}
const wochenstunden = user.wochenstunden || 0;
const arbeitstage = user.arbeitstage || 5;
const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0;
db.all(
`SELECT DISTINCT week_start, week_end
FROM weekly_timesheets
WHERE user_id = ? AND status = 'eingereicht'
ORDER BY week_start`,
[userId],
(err, weeks) => {
if (err) {
return callback(err, null);
}
if (!weeks || weeks.length === 0) {
return callback(null, overtimeOffsetHours);
}
let totalOvertimeHours = 0;
let totalOvertimeTaken = 0;
let processedWeeks = 0;
let hasError = false;
let callbackDone = false;
function done(value) {
if (callbackDone) return;
callbackDone = true;
callback(null, value);
}
weeks.forEach((week) => {
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;
if (!callbackDone) {
callbackDone = true;
callback(err, null);
}
return;
}
const entriesByDate = {};
(allEntries || []).forEach((entry) => {
const existing = entriesByDate[entry.date];
if (!existing) {
entriesByDate[entry.date] = entry;
} else {
const existingTime = existing.updated_at ? new Date(existing.updated_at).getTime() : 0;
const currentTime = entry.updated_at ? new Date(entry.updated_at).getTime() : 0;
if (currentTime > existingTime || (currentTime === existingTime && entry.id > existing.id)) {
entriesByDate[entry.date] = entry;
}
}
});
const entries = Object.values(entriesByDate);
getHolidaysForDateRange(week.week_start, week.week_end)
.catch(() => new Set())
.then((holidaySet) => {
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) {
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++;
}
}
}
}
if (filledWorkdays < workdays) {
processedWeeks++;
if (processedWeeks === weeks.length && !hasError) {
done(totalOvertimeHours - totalOvertimeTaken + overtimeOffsetHours);
}
return;
}
const fullDayHours = wochenstunden > 0 && arbeitstage > 0 ? wochenstunden / arbeitstage : 8;
let weekTotalHours = 0;
let weekOvertimeTaken = 0;
let weekVacationHours = 0;
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') {
weekVacationHours += fullDayHours;
} else if (entry.vacation_type === 'half') {
weekVacationHours += fullDayHours / 2;
if (entry.total_hours && !isFullDayOvertime) {
weekTotalHours += parseFloat(entry.total_hours) || 0;
}
} else {
if (entry.total_hours && !isFullDayOvertime) {
weekTotalHours += parseFloat(entry.total_hours) || 0;
}
}
});
let holidayHours = 0;
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const day = d.getDay();
if (day >= 1 && day <= 5) {
const dateStr = d.toISOString().split('T')[0];
if (holidaySet.has(dateStr)) holidayHours += fullDayHours;
}
}
const sollStunden = (wochenstunden / arbeitstage) * workdays;
const weekTotalHoursWithVacation = weekTotalHours + weekVacationHours + holidayHours;
const adjustedSollStunden = sollStunden - fullDayOvertimeDays * fullDayHours;
const weekOvertimeHours = weekTotalHoursWithVacation - adjustedSollStunden;
totalOvertimeHours += weekOvertimeHours;
totalOvertimeTaken += weekOvertimeTaken;
processedWeeks++;
if (processedWeeks === weeks.length && !hasError) {
done(totalOvertimeHours - totalOvertimeTaken + overtimeOffsetHours);
}
});
}
);
});
}
);
}
);
}
module.exports = {
getCurrentOvertimeForUser
};

View File

@@ -61,10 +61,18 @@
</div> </div>
<div class="actions"> <div class="actions">
<div style="display: flex; gap: 10px; align-items: center; justify-content: center;"> <div style="display: flex; justify-content: space-between; align-items: center; width: 100%; flex-wrap: wrap; gap: 15px;">
<div style="flex: 1; display: flex; justify-content: flex-start; min-width: 0;">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('prevWeek').click()">◀ Vorherige Woche</button>
</div>
<div style="display: flex; gap: 10px; align-items: center; flex-shrink: 0;">
<button id="submitWeek" class="btn btn-success" onclick="window.submitWeekHandler(event)" disabled>Woche abschicken</button> <button id="submitWeek" class="btn btn-success" onclick="window.submitWeekHandler(event)" disabled>Woche abschicken</button>
<button id="viewPdfBtn" class="btn btn-info" disabled>PDF anzeigen</button> <button id="viewPdfBtn" class="btn btn-info" disabled>PDF anzeigen</button>
</div> </div>
<div style="flex: 1; display: flex; justify-content: flex-end; min-width: 0;">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('nextWeek').click()">Nächste Woche ▶</button>
</div>
</div>
<p class="help-text">Stunden werden automatisch gespeichert. Am Ende der Woche können Sie die Stunden abschicken.</p> <p class="help-text">Stunden werden automatisch gespeichert. Am Ende der Woche können Sie die Stunden abschicken.</p>
</div> </div>
</div> </div>

View File

@@ -88,10 +88,10 @@
</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>Wochenstunden:</strong> <span><%= employee.user.wochenstunden || '-' %></span> <strong>Aktuelle Überstunden:</strong> <span class="current-overtime-value" data-user-id="<%= employee.user.id %>">-</span> Stunden
</div> </div>
<div style="display: inline-block; margin-right: 20px;"> <div style="display: inline-block; margin-right: 20px;">
<strong>Urlaubstage:</strong> <span><%= employee.user.urlaubstage || '-' %></span> <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-Offset:</strong>
@@ -399,8 +399,95 @@
} }
} }
// Aktuelle Überstunden für alle Mitarbeiter-Header laden (wie im Dashboard)
async function loadCurrentOvertime() {
const elements = document.querySelectorAll('.current-overtime-value');
const userIds = [...new Set(Array.from(elements).map(el => el.dataset.userId).filter(Boolean))];
if (userIds.length === 0) return;
try {
const response = await fetch('/api/verwaltung/employees/current-overtime?userIds=' + userIds.join(','));
if (!response.ok) throw new Error('Fehler beim Laden der Überstunden');
const data = await response.json();
elements.forEach(el => {
const userId = el.dataset.userId;
const value = data[userId] != null ? Number(data[userId]) : null;
if (value === null) {
el.textContent = '-';
el.style.color = '';
} else {
el.textContent = value >= 0 ? '+' + value.toFixed(2) : value.toFixed(2);
el.style.color = value >= 0 ? '#27ae60' : '#e74c3c';
}
});
} catch (error) {
console.error('Fehler beim Laden der aktuellen Überstunden:', error);
elements.forEach(el => {
el.textContent = '-';
el.style.color = '';
});
}
}
// Verbleibenden Urlaub (Tage) für alle Mitarbeiter-Header laden
async function loadRemainingVacation() {
const elements = document.querySelectorAll('.remaining-vacation-value');
const byUser = {};
elements.forEach(el => {
const userId = el.dataset.userId;
if (!userId) return;
if (!byUser[userId]) byUser[userId] = [];
byUser[userId].push(el);
});
const userIds = Object.keys(byUser);
if (userIds.length === 0) return;
for (const userId of userIds) {
try {
// Nimm die erste (neueste) Woche für diesen Mitarbeiter
const statsDiv = document.querySelector(`.group-stats[data-user-id="${userId}"]`);
if (!statsDiv) {
byUser[userId].forEach(el => { el.textContent = '-'; });
continue;
}
const weekStart = statsDiv.dataset.weekStart;
const weekEnd = statsDiv.dataset.weekEnd;
if (!weekStart || !weekEnd) {
byUser[userId].forEach(el => { el.textContent = '-'; });
continue;
}
const resp = await fetch(`/api/verwaltung/user/${userId}/stats?week_start=${weekStart}&week_end=${weekEnd}`);
if (!resp.ok) {
byUser[userId].forEach(el => { el.textContent = '-'; });
continue;
}
const data = await resp.json();
const value = data && typeof data.remainingVacation === 'number'
? data.remainingVacation
: null;
byUser[userId].forEach(el => {
if (value === null) {
el.textContent = '-';
} else {
el.textContent = value.toFixed(1);
}
});
} catch (err) {
console.error('Fehler beim Laden des verbleibenden Urlaubs für User', userId, err);
byUser[userId].forEach(el => { el.textContent = '-'; });
}
}
}
// Krankheitstage beim Laden der Seite abrufen // Krankheitstage beim Laden der Seite abrufen
loadSickDays(); loadSickDays();
loadCurrentOvertime();
loadRemainingVacation();
// Überstunden-Offset speichern // Überstunden-Offset speichern
document.querySelectorAll('.save-overtime-offset-btn').forEach(btn => { document.querySelectorAll('.save-overtime-offset-btn').forEach(btn => {
@@ -446,6 +533,7 @@
} }
loadStatsForDiv(div); loadStatsForDiv(div);
}); });
loadCurrentOvertime();
this.textContent = '✓'; this.textContent = '✓';
setTimeout(() => { setTimeout(() => {