Files
SDSStundenerfassung/views/overtime-breakdown.ejs
2026-03-23 22:36:32 +01:00

871 lines
33 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: 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" style="margin-top: 15px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid #e0e0e0; 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 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>