Added Charts im Overtime-breakdown

This commit is contained in:
2026-03-16 19:40:43 +01:00
parent 5a8dcf2cb5
commit 2aa4e6f037
4 changed files with 446 additions and 178 deletions

View File

@@ -1,177 +0,0 @@
---
name: MySQL-Integration mit Admin-Konfiguration
overview: Erweitere das System um MySQL-Unterstützung mit einer Admin-Oberfläche zur Konfiguration. Die Datenbank wird in einer Konfigurationsdatei gespeichert und kann im Admin-Bereich geändert werden (erfordert Server-Neustart).
todos: []
---
# MySQL/MariaDB-Integration mit Admin-Konfiguration
## Übersicht
Erweitere das System um MySQL/MariaDB-Unterstützung neben SQLite3. Die Datenbank-Konfiguration wird in einer Konfigurationsdatei gespeichert und kann im Admin-Bereich verwaltet werden. Ein Wechsel erfordert einen Server-Neustart.
**Hinweis:** MariaDB ist vollständig MySQL-kompatibel und funktioniert mit derselben Schnittstelle (`mysql2` Package).
## Architektur
### Datenbank-Abstraktionsschicht
Erstelle eine Abstraktionsschicht, die sowohl SQLite als auch MySQL unterstützt:
1. **Neue Datei:** `database/db-adapter.js`
- Abstrahiert `db.get()`, `db.run()`, `db.all()` für beide Datenbanken
- Promise-basierte API (für bessere Kompatibilität)
- Automatische Fehlerbehandlung
- SQL-Dialekt-Anpassung (z.B. `AUTOINCREMENT` vs `AUTO_INCREMENT`)
2. **Neue Datei:** `database/sqlite-adapter.js`
- SQLite-spezifische Implementierung
- Wrapper um sqlite3
3. **Neue Datei:** `database/mysql-adapter.js`
- MySQL/MariaDB-spezifische Implementierung
- Verwendet `mysql2` Package (funktioniert mit MySQL und MariaDB)
4. **Anpassung:** `database.js`
- Lädt Konfiguration aus `config/database.json`
- Initialisiert entsprechenden Adapter
- Behält `initDatabase()` Funktion bei
### Konfigurationsdatei
**Neue Datei:** `config/database.json`
```json
{
"type": "sqlite",
"sqlite": {
"path": "./stundenerfassung.db"
},
"mysql": {
"host": "localhost",
"port": 3306,
"user": "root",
"password": "",
"database": "stundenerfassung",
"charset": "utf8mb4"
}
}
```
### Admin-Interface
**Anpassung:** `routes/admin.js`
- Neue Route `/admin/database/config` (GET/PUT)
- Zeigt aktuelle Datenbank-Konfiguration
- Formular zum Ändern der Datenbank-Einstellungen
- Warnung bei Wechsel (Server-Neustart erforderlich)
**Anpassung:** `views/admin.ejs`
- Neuer Abschnitt "Datenbank-Konfiguration"
- Formular für SQLite/MySQL/MariaDB-Auswahl
- Eingabefelder für MySQL/MariaDB-Verbindungsdaten
- Test-Verbindung Button
- Warnung bei Änderungen
- Hinweis: MariaDB funktioniert mit MySQL-Konfiguration
### SQL-Anpassungen
**Unterschiede zwischen SQLite und MySQL:**
- `INTEGER PRIMARY KEY AUTOINCREMENT``INT AUTO_INCREMENT PRIMARY KEY`
- `TEXT``VARCHAR(255)` oder `TEXT`
- `REAL``DOUBLE` oder `DECIMAL`
- `TEXT` (unbegrenzt) → `TEXT` oder `LONGTEXT`
- `DATETIME DEFAULT CURRENT_TIMESTAMP``DATETIME DEFAULT CURRENT_TIMESTAMP` (gleich)
- `INSERT OR IGNORE``INSERT IGNORE`
- `COLLATE NOCASE``COLLATE utf8mb4_general_ci` (für case-insensitive)
- Foreign Keys müssen in MySQL explizit aktiviert werden
**Anpassung:** `database.js` - `initDatabase()`
- SQL-Generierung basierend auf Datenbanktyp
- Separate CREATE TABLE Statements für SQLite und MySQL
- Migrationen anpassen (ALTER TABLE Syntax)
### Package-Abhängigkeiten
**Anpassung:** `package.json`
- `mysql2` hinzufügen (bessere Promise-Unterstützung als `mysql`, funktioniert mit MySQL und MariaDB)
### Services und Routes
Alle Dateien, die `db` verwenden, müssen angepasst werden:
- `routes/*.js` (7 Dateien)
- `services/*.js` (5 Dateien)
- `checkin-server.js`
**Änderungen:**
- Promise-basierte Queries verwenden (statt Callbacks)
- Oder: Callback-Wrapper in Adapter
### Initialisierung
**Anpassung:** `server.js`
- Prüft ob `config/database.json` existiert
- Erstellt Standard-Konfiguration falls nicht vorhanden
- Initialisiert Datenbank entsprechend Konfiguration
## Implementierungsschritte
1. **Datenbank-Abstraktionsschicht erstellen**
- `database/db-adapter.js` - Interface
- `database/sqlite-adapter.js` - SQLite-Implementierung
- `database/mysql-adapter.js` - MySQL-Implementierung
2. **Konfigurationssystem**
- `config/database.json` Template
- Konfigurations-Loader in `database.js`
3. **SQL-Anpassungen**
- `initDatabase()` für beide Datenbanken anpassen
- SQL-Dialekt-Unterschiede behandeln
4. **Admin-Interface**
- Route für Datenbank-Konfiguration
- View mit Konfigurationsformular
- Test-Verbindung Funktion
5. **Alle Queries anpassen**
- Callbacks zu Promises oder Callback-Wrapper
- SQLite-spezifische Syntax entfernen (z.B. `COLLATE NOCASE`)
6. **Dokumentation**
- README.md aktualisieren
- Konfigurationsanleitung
## Technische Details
- **MySQL/MariaDB-Package:** `mysql2` (bessere Promise-Unterstützung, funktioniert mit beiden)
- **Kompatibilität:** MariaDB ist vollständig MySQL-kompatibel, verwendet dieselbe Konfiguration
- **Fallback:** Bei Fehler in MySQL/MariaDB-Verbindung auf SQLite zurückfallen
- **Validierung:** Test-Verbindung vor Speichern der Konfiguration
- **Sicherheit:** MySQL/MariaDB-Passwörter verschlüsselt in Config speichern (optional)
## Dateien die angepasst werden müssen
- `database.js``database/` Ordner mit mehreren Dateien
- `routes/admin.js` - Datenbank-Konfiguration
- `views/admin.ejs` - Konfigurations-UI
- `package.json` - mysql2 Dependency
- `server.js` - Konfigurationspr

View File

@@ -92,6 +92,13 @@ async function loadUserStats() {
if (totalVacationEl) {
totalVacationEl.textContent = (stats.urlaubstage || 0).toFixed(1);
}
// Urlaubstage-Übertrag der Verwaltung anzeigen (Offset)
const vacationOffsetEl = document.getElementById('vacationOffsetDays');
if (vacationOffsetEl) {
const offset = stats.vacationOffsetDays || 0;
vacationOffsetEl.textContent = (offset >= 0 ? '+' : '-') + offset.toFixed(1);
}
// Verplante Urlaubstage anzeigen
const plannedVacationEl = document.getElementById('plannedVacation');

View File

@@ -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;">

View File

@@ -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 => {