Verwaltung Überstunden anzeige, Buttons am unteren ende der Seite
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
195
services/overtime-service.js
Normal file
195
services/overtime-service.js
Normal 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
|
||||||
|
};
|
||||||
@@ -61,9 +61,17 @@
|
|||||||
</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;">
|
||||||
<button id="submitWeek" class="btn btn-success" onclick="window.submitWeekHandler(event)" disabled>Woche abschicken</button>
|
<div style="flex: 1; display: flex; justify-content: flex-start; min-width: 0;">
|
||||||
<button id="viewPdfBtn" class="btn btn-info" disabled>PDF anzeigen</button>
|
<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="viewPdfBtn" class="btn btn-info" disabled>PDF anzeigen</button>
|
||||||
|
</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>
|
</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>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user