871 lines
33 KiB
Plaintext
871 lines
33 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: var(--text-strong);
|
||
margin: 0;
|
||
}
|
||
.overtime-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
background: var(--bg-surface);
|
||
box-shadow: var(--shadow-sm);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
.overtime-table thead {
|
||
background-color: var(--bg-soft);
|
||
color: var(--text-strong);
|
||
}
|
||
.overtime-table th {
|
||
padding: 15px;
|
||
text-align: left;
|
||
font-weight: 600;
|
||
}
|
||
.overtime-table td {
|
||
padding: 12px 15px;
|
||
border-bottom: 1px solid var(--border-soft);
|
||
}
|
||
.overtime-table tbody tr:hover {
|
||
background-color: var(--table-hover);
|
||
}
|
||
.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: var(--text-muted);
|
||
}
|
||
.no-data {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: var(--text-muted);
|
||
}
|
||
.summary-box {
|
||
background: var(--bg-surface);
|
||
padding: 20px;
|
||
border-radius: 8px;
|
||
box-shadow: var(--shadow-sm);
|
||
margin-bottom: 30px;
|
||
}
|
||
.summary-box h3 {
|
||
margin-top: 0;
|
||
margin-bottom: 15px;
|
||
color: var(--text-strong);
|
||
}
|
||
.summary-item {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
padding: 10px 0;
|
||
border-bottom: 1px solid var(--border-soft);
|
||
}
|
||
.summary-item:last-child {
|
||
border-bottom: none;
|
||
}
|
||
.summary-label {
|
||
font-weight: 500;
|
||
color: var(--text-muted);
|
||
}
|
||
.summary-value {
|
||
font-weight: 600;
|
||
color: var(--text-strong);
|
||
}
|
||
.summary-value.overtime-positive {
|
||
color: #27ae60 !important;
|
||
}
|
||
.summary-value.overtime-negative {
|
||
color: #e74c3c !important;
|
||
}
|
||
.overtime-chart-container {
|
||
margin-top: 30px;
|
||
background: var(--bg-surface);
|
||
border-radius: 8px;
|
||
box-shadow: var(--shadow-sm);
|
||
padding: 20px;
|
||
}
|
||
.overtime-chart-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: baseline;
|
||
margin-bottom: 10px;
|
||
}
|
||
.overtime-chart-title {
|
||
font-size: 20px;
|
||
margin: 0;
|
||
color: var(--text-strong);
|
||
}
|
||
.overtime-chart-subtitle {
|
||
font-size: 13px;
|
||
color: var(--text-soft);
|
||
text-align: right;
|
||
}
|
||
.overtime-chart-wrapper {
|
||
position: relative;
|
||
width: 100%;
|
||
min-height: 260px;
|
||
}
|
||
.weekly-overtime-chart-container {
|
||
margin-top: 20px;
|
||
background: var(--bg-surface);
|
||
border-radius: 8px;
|
||
box-shadow: var(--shadow-sm);
|
||
padding: 20px;
|
||
}
|
||
.weekly-overtime-chart-title {
|
||
font-size: 18px;
|
||
margin: 0 0 10px 0;
|
||
color: var(--text-strong);
|
||
}
|
||
</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" id="offsetItem" style="display: none;">
|
||
<span class="summary-label">Manuelle Korrektur (Verwaltung):</span>
|
||
<span class="summary-value" id="overtimeOffset">-</span>
|
||
</div>
|
||
<div id="correctionsSection" class="collapsible-section" style="display: none;">
|
||
<div id="correctionsHeader" class="collapsible-header">
|
||
<span class="collapsible-title">Korrekturen durch die Verwaltung</span>
|
||
<span id="correctionsToggleIcon" style="font-size: 16px; transition: transform 0.3s;">▼</span>
|
||
</div>
|
||
<div id="correctionsContent" class="collapsible-content" style="padding: 10px 12px;">
|
||
<ul id="correctionsList" style="margin: 0; padding-left: 18px;"></ul>
|
||
</div>
|
||
</div>
|
||
<div class="summary-item">
|
||
<span class="summary-label">Verbleibend:</span>
|
||
<span class="summary-value" id="remainingOvertime">-</span>
|
||
</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 id="overtimeChartSection" class="overtime-chart-container" style="display: none;">
|
||
<div class="overtime-chart-header">
|
||
<h3 class="overtime-chart-title">Überstundenverlauf</h3>
|
||
<div class="overtime-chart-subtitle">
|
||
Kumulierte Überstunden pro Kalenderwoche<br>
|
||
(inkl. manueller Korrekturen der Verwaltung)
|
||
</div>
|
||
</div>
|
||
<div class="overtime-chart-wrapper">
|
||
<canvas id="overtimeChart"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="weeklyOvertimeChartSection" class="weekly-overtime-chart-container" style="display: none;">
|
||
<h3 class="weekly-overtime-chart-title">Überstunden pro Kalenderwoche</h3>
|
||
<div class="overtime-chart-wrapper">
|
||
<canvas id="weeklyOvertimeChart"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||
<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)
|
||
function getThemeChartColors() {
|
||
const styles = getComputedStyle(document.documentElement);
|
||
return {
|
||
textStrong: styles.getPropertyValue('--text-strong').trim() || '#f3f4f6',
|
||
textMuted: styles.getPropertyValue('--text-muted').trim() || '#cbd5e1',
|
||
borderSoft: styles.getPropertyValue('--border-soft').trim() || '#334155',
|
||
surface: styles.getPropertyValue('--bg-surface').trim() || '#111827'
|
||
};
|
||
}
|
||
|
||
let overtimeChartInstance = null;
|
||
let weeklyChartInstance = null;
|
||
const correctionMarkerPlugin = {
|
||
id: 'correctionMarkerPlugin',
|
||
afterDatasetsDraw(chart) {
|
||
const ctx = chart.ctx;
|
||
const chartArea = chart.chartArea;
|
||
if (!chartArea) return;
|
||
|
||
const datasets = chart.data.datasets || [];
|
||
if (datasets.length < 2) return;
|
||
|
||
const mainDataset = datasets[0];
|
||
const markerDataset = datasets[1];
|
||
const xScale = chart.scales.x;
|
||
const yScale = chart.scales.y;
|
||
if (!xScale || !yScale) return;
|
||
|
||
const labels = chart.data.labels || [];
|
||
const markerData = markerDataset.data || [];
|
||
const markerColors = markerDataset.pointBackgroundColor || markerDataset.backgroundColor || [];
|
||
|
||
ctx.save();
|
||
|
||
labels.forEach((label, index) => {
|
||
const markerValue = markerData[index];
|
||
if (markerValue === null || markerValue === undefined || isNaN(markerValue)) {
|
||
return;
|
||
}
|
||
|
||
const color = Array.isArray(markerColors) ? (markerColors[index] || '#000') : markerColors;
|
||
|
||
const x = xScale.getPixelForValue(index);
|
||
const y = yScale.getPixelForValue(markerValue);
|
||
const topY = chartArea.top;
|
||
|
||
// Senkrechte Linie von oben zum Überstundenwert
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, topY);
|
||
ctx.lineTo(x, y);
|
||
ctx.stroke();
|
||
|
||
// Marker oben über der Linie
|
||
const markerRadius = 5;
|
||
ctx.fillStyle = color;
|
||
ctx.beginPath();
|
||
ctx.arc(x, topY - markerRadius - 2, markerRadius, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
});
|
||
|
||
ctx.restore();
|
||
}
|
||
};
|
||
const overtimeAreaPlugin = {
|
||
id: 'overtimeAreaPlugin',
|
||
beforeDatasetsDraw(chart) {
|
||
// Nur für Liniendiagramme ausführen, damit Balken-Charts keine Fläche bekommen
|
||
if (chart.config.type !== 'line') {
|
||
return;
|
||
}
|
||
|
||
const datasets = chart.data.datasets || [];
|
||
if (!datasets.length) return;
|
||
|
||
const mainDataset = datasets[0];
|
||
const meta = chart.getDatasetMeta(0);
|
||
const points = meta.data || [];
|
||
const yScale = chart.scales.y;
|
||
const chartArea = chart.chartArea;
|
||
if (!yScale || !chartArea || points.length < 2) return;
|
||
|
||
const ctx = chart.ctx;
|
||
const baselineY = yScale.getPixelForValue(0);
|
||
|
||
ctx.save();
|
||
|
||
for (let i = 1; i < points.length; i++) {
|
||
const prev = points[i - 1];
|
||
const curr = points[i];
|
||
if (!prev || !curr || prev.skip || curr.skip) continue;
|
||
|
||
const prevValue = mainDataset.data[i - 1];
|
||
const currValue = mainDataset.data[i];
|
||
if (prevValue == null || currValue == null || isNaN(prevValue) || isNaN(currValue)) continue;
|
||
|
||
const isUp = currValue >= prevValue;
|
||
ctx.fillStyle = isUp ? 'rgba(39, 174, 96, 0.12)' : 'rgba(231, 76, 60, 0.12)';
|
||
|
||
const x1 = prev.x;
|
||
const y1 = prev.y;
|
||
const x2 = curr.x;
|
||
const y2 = curr.y;
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(x1, baselineY);
|
||
ctx.lineTo(x1, y1);
|
||
ctx.lineTo(x2, y2);
|
||
ctx.lineTo(x2, baselineY);
|
||
ctx.closePath();
|
||
ctx.fill();
|
||
}
|
||
|
||
ctx.restore();
|
||
}
|
||
};
|
||
if (window.Chart && window.Chart.register) {
|
||
window.Chart.register(correctionMarkerPlugin);
|
||
window.Chart.register(overtimeAreaPlugin);
|
||
}
|
||
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');
|
||
const chartSectionEl = document.getElementById('overtimeChartSection');
|
||
const weeklyChartSectionEl = document.getElementById('weeklyOvertimeChartSection');
|
||
|
||
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';
|
||
if (chartSectionEl) chartSectionEl.style.display = 'none';
|
||
if (weeklyChartSectionEl) weeklyChartSectionEl.style.display = 'none';
|
||
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 rawHours = Number(c.correction_hours) || 0;
|
||
const absHours = Math.abs(rawHours);
|
||
const signPrefix = rawHours >= 0 ? '+' : '-';
|
||
const hoursClass = rawHours >= 0 ? 'overtime-positive' : 'overtime-negative';
|
||
const hoursDisplay = signPrefix + formatHoursMin(absHours);
|
||
const reason = (c && c.reason != null) ? String(c.reason).trim() : '';
|
||
const li = document.createElement('li');
|
||
li.innerHTML = reason
|
||
? `Korrektur am ${dateText} <span class="${hoursClass}">${hoursDisplay}</span> – ${reason}`
|
||
: `Korrektur am ${dateText} <span class="${hoursClass}">${hoursDisplay}</span>`;
|
||
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';
|
||
|
||
// Überstundenverlauf (kumuliert) vorbereiten – inkl. Verwaltungskorrekturen
|
||
if (chartSectionEl && Array.isArray(data.weeks)) {
|
||
// Wochen chronologisch (älteste zuerst) sortieren
|
||
const sortedWeeks = [...data.weeks].sort((a, b) => {
|
||
if (a.year !== b.year) return a.year - b.year;
|
||
if (a.calendar_week !== b.calendar_week) return a.calendar_week - b.calendar_week;
|
||
return new Date(a.week_start) - new Date(b.week_start);
|
||
});
|
||
|
||
// Korrekturen nach Datum aufsteigend sortieren
|
||
const rawCorrections = Array.isArray(data.overtime_corrections) ? data.overtime_corrections : [];
|
||
const sortedCorrections = rawCorrections
|
||
.map(c => ({
|
||
hours: Number(c.correction_hours) || 0,
|
||
at: parseSqliteDatetime(c.corrected_at)
|
||
}))
|
||
.filter(c => c.at instanceof Date && !isNaN(c.at.getTime()))
|
||
.sort((a, b) => a.at - b.at);
|
||
|
||
// Basis-Offset rekonstruieren:
|
||
// aktueller Offset (overtime_offset_hours) = Basis-Offset + Summe aller Korrekturen
|
||
const totalCorrectionSum = sortedCorrections.reduce((sum, c) => sum + c.hours, 0);
|
||
const baseOffset = (data.overtime_offset_hours || 0) - totalCorrectionSum;
|
||
|
||
let correctionIndex = 0;
|
||
let cumulativeCorrectionHours = 0;
|
||
let cumulativeOvertimeHours = 0;
|
||
|
||
const labels = [];
|
||
const dataPoints = [];
|
||
const markerData = [];
|
||
const markerColors = [];
|
||
const markerCorrectionValues = [];
|
||
|
||
sortedWeeks.forEach(week => {
|
||
cumulativeOvertimeHours += Number(week.overtime_hours) || 0;
|
||
|
||
const weekEndDate = new Date(week.week_end);
|
||
|
||
// Korrekturen, die in diese Woche fallen, separat sammeln
|
||
let weekNetCorrection = 0;
|
||
|
||
// Alle Korrekturen bis einschließlich Wochenende einrechnen
|
||
while (correctionIndex < sortedCorrections.length) {
|
||
const corr = sortedCorrections[correctionIndex];
|
||
if (corr.at <= weekEndDate) {
|
||
cumulativeCorrectionHours += corr.hours;
|
||
weekNetCorrection += corr.hours;
|
||
correctionIndex++;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Kontostand dieser Woche:
|
||
// = Summe Wochen-Überstunden bis hier + Basis-Offset + Korrekturen bis einschließlich dieser Woche
|
||
const effectiveOffsetForWeek = baseOffset + cumulativeCorrectionHours;
|
||
const balance = cumulativeOvertimeHours + effectiveOffsetForWeek;
|
||
const calendarWeekStr = String(week.calendar_week).padStart(2, '0');
|
||
labels.push(`${week.year} KW${calendarWeekStr}`);
|
||
dataPoints.push(Number(balance.toFixed(2)));
|
||
|
||
// Marker nur setzen, wenn es in dieser Woche Korrekturen gab
|
||
if (weekNetCorrection !== 0) {
|
||
markerData.push(Number(balance.toFixed(2)));
|
||
markerColors.push(weekNetCorrection > 0 ? '#27ae60' : '#e74c3c');
|
||
markerCorrectionValues.push(weekNetCorrection);
|
||
} else {
|
||
// Kein Marker für diese Woche
|
||
markerData.push(null);
|
||
markerColors.push('rgba(0,0,0,0)');
|
||
markerCorrectionValues.push(0);
|
||
}
|
||
});
|
||
|
||
if (labels.length > 0) {
|
||
// Kumulativer Chart
|
||
chartSectionEl.style.display = 'block';
|
||
|
||
const canvasEl = document.getElementById('overtimeChart');
|
||
if (canvasEl && canvasEl.getContext) {
|
||
const ctx = canvasEl.getContext('2d');
|
||
if (overtimeChartInstance) {
|
||
overtimeChartInstance.destroy();
|
||
}
|
||
|
||
overtimeChartInstance = new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels,
|
||
datasets: [
|
||
{
|
||
label: 'Kumulierte Überstunden',
|
||
data: dataPoints,
|
||
borderColor: '#3498db',
|
||
backgroundColor: 'rgba(52, 152, 219, 0.12)',
|
||
borderWidth: 2,
|
||
pointRadius: 3,
|
||
pointHoverRadius: 5,
|
||
tension: 0.15,
|
||
fill: false
|
||
},
|
||
{
|
||
label: 'Korrektur Verwaltung',
|
||
data: markerData,
|
||
borderColor: 'rgba(0,0,0,0)',
|
||
backgroundColor: markerColors,
|
||
pointBackgroundColor: markerColors,
|
||
pointBorderColor: markerColors,
|
||
pointRadius: 0,
|
||
pointHoverRadius: 0,
|
||
showLine: false
|
||
}
|
||
]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: {
|
||
mode: 'index',
|
||
intersect: false
|
||
},
|
||
plugins: {
|
||
legend: {
|
||
display: true,
|
||
labels: {
|
||
color: getThemeChartColors().textStrong
|
||
}
|
||
},
|
||
tooltip: {
|
||
backgroundColor: getThemeChartColors().surface,
|
||
titleColor: getThemeChartColors().textStrong,
|
||
bodyColor: getThemeChartColors().textStrong,
|
||
borderColor: getThemeChartColors().borderSoft,
|
||
borderWidth: 1,
|
||
callbacks: {
|
||
label: function(context) {
|
||
// Hauptlinie: kumulierte Überstunden
|
||
if (context.datasetIndex === 0) {
|
||
const value = context.parsed.y || 0;
|
||
const sign = value >= 0 ? '+' : '';
|
||
return `${context.dataset.label}: ${sign}${formatHoursMin(value)}`;
|
||
}
|
||
|
||
// Marker: tatsächliche Korrektur dieser Woche
|
||
if (context.datasetIndex === 1) {
|
||
const idx = context.dataIndex;
|
||
const corrValue = markerCorrectionValues[idx] || 0;
|
||
if (corrValue === 0) return '';
|
||
const sign = corrValue >= 0 ? '+' : '-';
|
||
return `${context.dataset.label}: ${sign}${formatHoursMin(Math.abs(corrValue))}`;
|
||
}
|
||
|
||
return '';
|
||
}
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
ticks: {
|
||
color: getThemeChartColors().textMuted
|
||
},
|
||
grid: {
|
||
color: getThemeChartColors().borderSoft
|
||
},
|
||
title: {
|
||
display: true,
|
||
text: 'Kalenderwoche',
|
||
color: getThemeChartColors().textMuted
|
||
}
|
||
},
|
||
y: {
|
||
ticks: {
|
||
color: getThemeChartColors().textMuted,
|
||
callback: function(value) {
|
||
const v = Number(value) || 0;
|
||
const sign = v >= 0 ? '+' : '';
|
||
return `${sign}${formatHoursMin(v)}`;
|
||
}
|
||
},
|
||
grid: {
|
||
color: getThemeChartColors().borderSoft
|
||
},
|
||
title: {
|
||
display: true,
|
||
text: 'Überstunden (Stunden)',
|
||
color: getThemeChartColors().textMuted
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
} else {
|
||
chartSectionEl.style.display = 'none';
|
||
}
|
||
|
||
// Wochenweise Überstunden (Delta je Woche)
|
||
if (weeklyChartSectionEl) {
|
||
const weeklyLabels = sortedWeeks.map(week => {
|
||
const calendarWeekStr = String(week.calendar_week).padStart(2, '0');
|
||
return `${week.year} KW${calendarWeekStr}`;
|
||
});
|
||
const weeklyValues = sortedWeeks.map(week => Number(week.overtime_hours) || 0);
|
||
|
||
const weeklyCanvasEl = document.getElementById('weeklyOvertimeChart');
|
||
if (weeklyCanvasEl && weeklyCanvasEl.getContext) {
|
||
const wctx = weeklyCanvasEl.getContext('2d');
|
||
if (weeklyChartInstance) {
|
||
weeklyChartInstance.destroy();
|
||
}
|
||
|
||
weeklyChartInstance = new Chart(wctx, {
|
||
type: 'bar',
|
||
data: {
|
||
labels: weeklyLabels,
|
||
datasets: [{
|
||
label: 'Überstunden je Woche',
|
||
data: weeklyValues,
|
||
backgroundColor: weeklyValues.map(v => v >= 0 ? 'rgba(39, 174, 96, 0.6)' : 'rgba(231, 76, 60, 0.6)'),
|
||
borderColor: weeklyValues.map(v => v >= 0 ? '#27ae60' : '#e74c3c'),
|
||
borderWidth: 1
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: true,
|
||
labels: {
|
||
color: getThemeChartColors().textStrong
|
||
}
|
||
},
|
||
tooltip: {
|
||
backgroundColor: getThemeChartColors().surface,
|
||
titleColor: getThemeChartColors().textStrong,
|
||
bodyColor: getThemeChartColors().textStrong,
|
||
borderColor: getThemeChartColors().borderSoft,
|
||
borderWidth: 1,
|
||
callbacks: {
|
||
label: function(context) {
|
||
const v = context.parsed.y || 0;
|
||
const sign = v >= 0 ? '+' : '-';
|
||
return `${context.dataset.label}: ${sign}${formatHoursMin(Math.abs(v))}`;
|
||
}
|
||
}
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
ticks: {
|
||
color: getThemeChartColors().textMuted
|
||
},
|
||
grid: {
|
||
color: getThemeChartColors().borderSoft
|
||
},
|
||
title: {
|
||
display: true,
|
||
text: 'Kalenderwoche',
|
||
color: getThemeChartColors().textMuted
|
||
}
|
||
},
|
||
y: {
|
||
ticks: {
|
||
color: getThemeChartColors().textMuted,
|
||
callback: function(value) {
|
||
const v = Number(value) || 0;
|
||
const sign = v >= 0 ? '+' : '-';
|
||
return `${sign}${formatHoursMin(Math.abs(v))}`;
|
||
}
|
||
},
|
||
title: {
|
||
display: true,
|
||
text: 'Überstunden je Woche (Stunden)',
|
||
color: getThemeChartColors().textMuted
|
||
},
|
||
grid: {
|
||
color: getThemeChartColors().borderSoft
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
weeklyChartSectionEl.style.display = 'block';
|
||
} else {
|
||
weeklyChartSectionEl.style.display = 'none';
|
||
}
|
||
}
|
||
} else {
|
||
chartSectionEl.style.display = 'none';
|
||
if (weeklyChartSectionEl) weeklyChartSectionEl.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 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>
|