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 { generatePDFToBuffer } = require('../services/pdf-service');
|
||||
const { getHolidaysForDateRange } = require('../services/feiertage-service');
|
||||
const { getCurrentOvertimeForUser } = require('../services/overtime-service');
|
||||
|
||||
// Routes registrieren
|
||||
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
|
||||
app.get('/api/verwaltung/user/:id/stats', requireVerwaltung, (req, res) => {
|
||||
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 class="actions">
|
||||
<div style="display: flex; gap: 10px; align-items: center; justify-content: center;">
|
||||
<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 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="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>
|
||||
<p class="help-text">Stunden werden automatisch gespeichert. Am Ende der Woche können Sie die Stunden abschicken.</p>
|
||||
</div>
|
||||
|
||||
@@ -88,10 +88,10 @@
|
||||
</div>
|
||||
<div class="employee-details" style="margin-top: 10px;">
|
||||
<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 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 style="display: inline-flex; gap: 8px; align-items: center; margin-right: 20px;">
|
||||
<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
|
||||
loadSickDays();
|
||||
loadCurrentOvertime();
|
||||
loadRemainingVacation();
|
||||
|
||||
// Überstunden-Offset speichern
|
||||
document.querySelectorAll('.save-overtime-offset-btn').forEach(btn => {
|
||||
@@ -446,6 +533,7 @@
|
||||
}
|
||||
loadStatsForDiv(div);
|
||||
});
|
||||
loadCurrentOvertime();
|
||||
|
||||
this.textContent = '✓';
|
||||
setTimeout(() => {
|
||||
|
||||
Reference in New Issue
Block a user