Added Charts im Overtime-breakdown
This commit is contained in:
@@ -100,8 +100,11 @@
|
||||
Verbleibende Urlaubstage
|
||||
<span class="help-icon" onclick="showHelpModal('remaining-vacation-help')" style="cursor: pointer; color: #3498db; font-size: 14px; font-weight: bold; width: 18px; height: 18px; border-radius: 50%; background: #e8f4f8; display: inline-flex; align-items: center; justify-content: center; line-height: 1;">?</span>
|
||||
</div>
|
||||
<div class="stat-value" id="remainingVacation">-</div>
|
||||
<div class="stat-value" id="remainingVacation">-</div> Tage
|
||||
<div class="stat-unit">von <span id="totalVacation">-</span> Tagen</div>
|
||||
<div style="margin-top: 6px; font-size: 11px; color: #555;">
|
||||
Korrektur Verwaltung: <span id="vacationOffsetDays">-</span> Tage
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-planned">
|
||||
<div class="stat-label" style="display: flex; align-items: center; gap: 5px;">
|
||||
|
||||
@@ -105,6 +105,46 @@
|
||||
.summary-value.overtime-negative {
|
||||
color: #e74c3c !important;
|
||||
}
|
||||
.overtime-chart-container {
|
||||
margin-top: 30px;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
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: #2c3e50;
|
||||
}
|
||||
.overtime-chart-subtitle {
|
||||
font-size: 13px;
|
||||
color: #777;
|
||||
text-align: right;
|
||||
}
|
||||
.overtime-chart-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 260px;
|
||||
}
|
||||
.weekly-overtime-chart-container {
|
||||
margin-top: 20px;
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
padding: 20px;
|
||||
}
|
||||
.weekly-overtime-chart-title {
|
||||
font-size: 18px;
|
||||
margin: 0 0 10px 0;
|
||||
color: #2c3e50;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -185,8 +225,29 @@
|
||||
<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
|
||||
@@ -234,6 +295,117 @@
|
||||
|
||||
// formatHoursMin aus format-hours.js (window.formatHoursMin)
|
||||
|
||||
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');
|
||||
@@ -251,6 +423,8 @@
|
||||
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');
|
||||
@@ -263,6 +437,8 @@
|
||||
|
||||
if (!data.weeks || data.weeks.length === 0) {
|
||||
noDataEl.style.display = 'block';
|
||||
if (chartSectionEl) chartSectionEl.style.display = 'none';
|
||||
if (weeklyChartSectionEl) weeklyChartSectionEl.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -349,6 +525,265 @@
|
||||
|
||||
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
|
||||
},
|
||||
tooltip: {
|
||||
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: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Kalenderwoche'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Überstunden (Stunden)'
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
const v = Number(value) || 0;
|
||||
const sign = v >= 0 ? '+' : '';
|
||||
return `${sign}${formatHoursMin(v)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} 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
|
||||
},
|
||||
tooltip: {
|
||||
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: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Kalenderwoche'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Überstunden je Woche (Stunden)'
|
||||
},
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
const v = Number(value) || 0;
|
||||
const sign = v >= 0 ? '+' : '-';
|
||||
return `${sign}${formatHoursMin(Math.abs(v))}`;
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
zeroLineColor: '#000'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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 => {
|
||||
|
||||
Reference in New Issue
Block a user