386 lines
14 KiB
Plaintext
386 lines
14 KiB
Plaintext
<!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">
|
||
<%- include('header') %>
|
||
<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;
|
||
}
|
||
.summary-value.overtime-positive {
|
||
color: #27ae60 !important;
|
||
}
|
||
.summary-value.overtime-negative {
|
||
color: #e74c3c !important;
|
||
}
|
||
</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-logout">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 class="summary-item" id="offsetItem" style="display: none;">
|
||
<span class="summary-label">Manuelle Korrektur (Verwaltung):</span>
|
||
<span class="summary-value" id="overtimeOffset">-</span>
|
||
</div>
|
||
<div id="correctionsSection" style="margin-top: 15px; 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>
|
||
|
||
<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 src="/js/format-hours.js"></script>
|
||
<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');
|
||
}
|
||
|
||
// 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
|
||
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 overtimeOffset = data.overtime_offset_hours || 0;
|
||
// 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
|
||
const totalOvertimeEl = document.getElementById('totalOvertime');
|
||
totalOvertimeEl.textContent =
|
||
(displayTotalOvertime >= 0 ? '+' : '') + formatHoursMin(displayTotalOvertime);
|
||
totalOvertimeEl.className =
|
||
'summary-value ' + (displayTotalOvertime >= 0 ? 'overtime-positive' : 'overtime-negative');
|
||
|
||
const totalOvertimeTakenEl = document.getElementById('totalOvertimeTaken');
|
||
totalOvertimeTakenEl.textContent =
|
||
totalOvertimeTaken > 0 ? '-' + formatHoursMin(totalOvertimeTaken) : formatHoursMin(0);
|
||
totalOvertimeTakenEl.className = 'summary-value overtime-negative';
|
||
|
||
const remainingOvertimeEl = document.getElementById('remainingOvertime');
|
||
remainingOvertimeEl.textContent =
|
||
(remainingOvertime >= 0 ? '+' : '') + formatHoursMin(remainingOvertime);
|
||
remainingOvertimeEl.className =
|
||
'summary-value ' + (remainingOvertime >= 0 ? 'overtime-positive' : 'overtime-negative');
|
||
|
||
// Manuelle Korrektur anzeigen (nur wenn vorhanden)
|
||
const offsetItem = document.getElementById('offsetItem');
|
||
const offsetValue = document.getElementById('overtimeOffset');
|
||
if (overtimeOffset !== 0) {
|
||
offsetValue.textContent = (overtimeOffset >= 0 ? '+' : '') + formatHoursMin(overtimeOffset);
|
||
offsetValue.className = 'summary-value ' + (overtimeOffset >= 0 ? 'overtime-positive' : 'overtime-negative');
|
||
offsetItem.style.display = 'flex';
|
||
} else {
|
||
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 hoursText = formatHoursMin(c.correction_hours);
|
||
const reason = (c && c.reason != null) ? String(c.reason).trim() : '';
|
||
const li = document.createElement('li');
|
||
li.textContent = reason
|
||
? `Korrektur am ${dateText} ${hoursText} – ${reason}`
|
||
: `Korrektur am ${dateText} ${hoursText}`;
|
||
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';
|
||
|
||
// 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 ? '+' : '';
|
||
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 = `
|
||
<td><strong>${week.year} KW${calendarWeekStr}</strong></td>
|
||
<td>${dateRange}</td>
|
||
<td>${formatHoursMin(week.total_hours)}</td>
|
||
<td>${formatHoursMin(week.soll_stunden)}</td>
|
||
<td class="${overtimeClass}">${overtimeCell}</td>
|
||
<td class="overtime-negative">${takenCell}</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>
|
||
<%- include('footer') %>
|
||
</body>
|
||
</html>
|