Files
SDSStundenerfassung/views/overtime-breakdown.ejs

388 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
// 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');
}
function formatHours(value) {
const n = Number(value);
if (!Number.isFinite(n)) return '';
const sign = n > 0 ? '+' : '';
let s = sign + n.toFixed(2);
s = s.replace(/\.00$/, '');
s = s.replace(/(\.\d)0$/, '$1');
return s;
}
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;
const remainingOvertime = totalOvertime - totalOvertimeTaken + overtimeOffset;
// Zusammenfassung anzeigen
const totalOvertimeEl = document.getElementById('totalOvertime');
totalOvertimeEl.textContent =
(totalOvertime >= 0 ? '+' : '') + totalOvertime.toFixed(2) + ' h';
totalOvertimeEl.className =
'summary-value ' + (totalOvertime >= 0 ? 'overtime-positive' : 'overtime-negative');
const totalOvertimeTakenEl = document.getElementById('totalOvertimeTaken');
totalOvertimeTakenEl.textContent =
totalOvertimeTaken.toFixed(2) + ' h';
totalOvertimeTakenEl.className = 'summary-value overtime-positive';
const remainingOvertimeEl = document.getElementById('remainingOvertime');
remainingOvertimeEl.textContent =
(remainingOvertime >= 0 ? '+' : '') + remainingOvertime.toFixed(2) + ' h';
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 ? '+' : '') + overtimeOffset.toFixed(2) + ' h';
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 = formatHours(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} h ${reason}`
: `Korrektur am ${dateText} ${hoursText} h`;
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 ? '+' : '';
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>
<%- include('footer') %>
</body>
</html>