Compare commits
8 Commits
1264a8fbc6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
563d4fc373 | ||
|
|
587bdd2722 | ||
|
|
daf4f9b77c | ||
|
|
a0acd188a8 | ||
|
|
33c62a7a2a | ||
|
|
0f10f35149 | ||
|
|
70c106ec1a | ||
|
|
f7b1322ae6 |
20
.dockerignore
Normal file
20
.dockerignore
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
SCHNELLSTART.md
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
dev
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Package-Dateien kopieren
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Dependencies installieren
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Anwendungsdateien kopieren
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Ports freigeben
|
||||||
|
EXPOSE 3333 3334
|
||||||
|
|
||||||
|
# Anwendung starten
|
||||||
|
CMD ["node", "server.js"]
|
||||||
127
checkin-server.js
Normal file
127
checkin-server.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
// Check-in Server (separater Express-App auf Port 3334)
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { db } = require('./database');
|
||||||
|
const { getCurrentDate, getCurrentTime, updateTotalHours } = require('./helpers/utils');
|
||||||
|
|
||||||
|
const checkinApp = express();
|
||||||
|
const CHECKIN_PORT = 3334;
|
||||||
|
|
||||||
|
// Middleware für Check-in-Server
|
||||||
|
checkinApp.use(express.json());
|
||||||
|
|
||||||
|
// API: Check-in (Kommen)
|
||||||
|
checkinApp.get('/api/checkin/:userId', (req, res) => {
|
||||||
|
const userId = parseInt(req.params.userId);
|
||||||
|
const currentDate = getCurrentDate();
|
||||||
|
const currentTime = getCurrentTime();
|
||||||
|
|
||||||
|
// Prüfe ob User existiert
|
||||||
|
db.get('SELECT id FROM users WHERE id = ?', [userId], (err, user) => {
|
||||||
|
if (err || !user) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob bereits ein Eintrag für heute existiert
|
||||||
|
db.get('SELECT * FROM timesheet_entries WHERE user_id = ? AND date = ? ORDER BY updated_at DESC, id DESC LIMIT 1',
|
||||||
|
[userId, currentDate], (err, entry) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ success: false, error: 'Fehler beim Abrufen des Eintrags' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
// Kein Eintrag existiert → Erstelle neuen mit start_time
|
||||||
|
db.run(`INSERT INTO timesheet_entries (user_id, date, start_time, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||||
|
[userId, currentDate, currentTime], (err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ success: false, error: 'Fehler beim Erstellen des Eintrags' });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Start-Zeit erfasst: ${currentTime}`,
|
||||||
|
start_time: currentTime,
|
||||||
|
date: currentDate
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else if (!entry.start_time) {
|
||||||
|
// Eintrag existiert, aber keine Start-Zeit → Setze start_time
|
||||||
|
db.run('UPDATE timesheet_entries SET start_time = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[currentTime, entry.id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ success: false, error: 'Fehler beim Aktualisieren' });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Start-Zeit erfasst: ${currentTime}`,
|
||||||
|
start_time: currentTime,
|
||||||
|
date: currentDate
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Start-Zeit bereits vorhanden → Ignoriere weiteren Check-in
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Bereits eingecheckt um ${entry.start_time}. Check-in ignoriert.`,
|
||||||
|
start_time: entry.start_time,
|
||||||
|
date: currentDate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Check-out (Gehen)
|
||||||
|
checkinApp.get('/api/checkout/:userId', (req, res) => {
|
||||||
|
const userId = parseInt(req.params.userId);
|
||||||
|
const currentDate = getCurrentDate();
|
||||||
|
const currentTime = getCurrentTime();
|
||||||
|
|
||||||
|
// Prüfe ob User existiert
|
||||||
|
db.get('SELECT id FROM users WHERE id = ?', [userId], (err, user) => {
|
||||||
|
if (err || !user) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Benutzer nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob bereits ein Eintrag für heute existiert
|
||||||
|
db.get('SELECT * FROM timesheet_entries WHERE user_id = ? AND date = ? ORDER BY updated_at DESC, id DESC LIMIT 1',
|
||||||
|
[userId, currentDate], (err, entry) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ success: false, error: 'Fehler beim Abrufen des Eintrags' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry || !entry.start_time) {
|
||||||
|
// Kein Eintrag oder keine Start-Zeit → Fehler
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Bitte zuerst einchecken (Kommen).'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Berechne total_hours basierend auf start_time, end_time und break_minutes
|
||||||
|
const breakMinutes = entry.break_minutes || 0;
|
||||||
|
const totalHours = updateTotalHours(entry.start_time, currentTime, breakMinutes);
|
||||||
|
|
||||||
|
// Setze end_time (überschreibt vorherige End-Zeit falls vorhanden)
|
||||||
|
db.run('UPDATE timesheet_entries SET end_time = ?, total_hours = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[currentTime, totalHours, entry.id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ success: false, error: 'Fehler beim Aktualisieren' });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `End-Zeit erfasst: ${currentTime}. Gesamtstunden: ${totalHours.toFixed(2)} h`,
|
||||||
|
end_time: currentTime,
|
||||||
|
total_hours: totalHours,
|
||||||
|
date: currentDate
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check-in-Server starten (auf Port 3334)
|
||||||
|
checkinApp.listen(CHECKIN_PORT, () => {
|
||||||
|
console.log(`Check-in Server läuft auf http://localhost:${CHECKIN_PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = checkinApp;
|
||||||
40
database.js
40
database.js
@@ -2,7 +2,8 @@ const sqlite3 = require('sqlite3').verbose();
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const dbPath = path.join(__dirname, 'stundenerfassung.db');
|
// Datenbank-Pfad: Umgebungsvariable oder Standard-Pfad
|
||||||
|
const dbPath = process.env.DB_PATH || path.join(__dirname, 'stundenerfassung.db');
|
||||||
const db = new sqlite3.Database(dbPath);
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
|
||||||
// Datenbank initialisieren
|
// Datenbank initialisieren
|
||||||
@@ -148,6 +149,14 @@ function initDatabase() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Migration: Krank-Status hinzufügen
|
||||||
|
db.run(`ALTER TABLE timesheet_entries ADD COLUMN sick_status INTEGER DEFAULT 0`, (err) => {
|
||||||
|
// Fehler ignorieren wenn Spalte bereits existiert
|
||||||
|
if (err && !err.message.includes('duplicate column')) {
|
||||||
|
console.warn('Warnung beim Hinzufügen der Spalte sick_status:', err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Migration: Pausen-Zeiten für API-Zeiterfassung hinzufügen
|
// Migration: Pausen-Zeiten für API-Zeiterfassung hinzufügen
|
||||||
db.run(`ALTER TABLE timesheet_entries ADD COLUMN pause_start_time TEXT`, (err) => {
|
db.run(`ALTER TABLE timesheet_entries ADD COLUMN pause_start_time TEXT`, (err) => {
|
||||||
// Fehler ignorieren wenn Spalte bereits existiert
|
// Fehler ignorieren wenn Spalte bereits existiert
|
||||||
@@ -175,6 +184,35 @@ function initDatabase() {
|
|||||||
db.run(`ALTER TABLE users ADD COLUMN urlaubstage REAL`, (err) => {
|
db.run(`ALTER TABLE users ADD COLUMN urlaubstage REAL`, (err) => {
|
||||||
// Fehler ignorieren wenn Spalte bereits existiert
|
// Fehler ignorieren wenn Spalte bereits existiert
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Migration: Überstunden-Offset (manuelle Korrektur durch Verwaltung)
|
||||||
|
db.run(`ALTER TABLE users ADD COLUMN overtime_offset_hours REAL DEFAULT 0`, (err) => {
|
||||||
|
// Fehler ignorieren wenn Spalte bereits existiert
|
||||||
|
if (err && !err.message.includes('duplicate column')) {
|
||||||
|
console.warn('Warnung beim Hinzufügen der Spalte overtime_offset_hours:', err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Migration: ping_ip Spalte hinzufügen
|
||||||
|
db.run(`ALTER TABLE users ADD COLUMN ping_ip TEXT`, (err) => {
|
||||||
|
// Fehler ignorieren wenn Spalte bereits existiert
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ping-Status-Tabelle für IP-basierte Zeiterfassung
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS ping_status (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
last_successful_ping DATETIME,
|
||||||
|
failed_ping_count INTEGER DEFAULT 0,
|
||||||
|
start_time_set INTEGER DEFAULT 0,
|
||||||
|
first_failed_ping_time DATETIME,
|
||||||
|
PRIMARY KEY (user_id, date),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
)`, (err) => {
|
||||||
|
if (err && !err.message.includes('duplicate column')) {
|
||||||
|
console.warn('Warnung beim Erstellen der ping_status Tabelle:', err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// LDAP-Konfiguration-Tabelle
|
// LDAP-Konfiguration-Tabelle
|
||||||
db.run(`CREATE TABLE IF NOT EXISTS ldap_config (
|
db.run(`CREATE TABLE IF NOT EXISTS ldap_config (
|
||||||
|
|||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
stundenerfassung:
|
||||||
|
build: .
|
||||||
|
container_name: stundenerfassung
|
||||||
|
ports:
|
||||||
|
- "3333:3333" # Hauptserver
|
||||||
|
- "3334:3334" # Check-in Server
|
||||||
|
volumes:
|
||||||
|
# Datenbank: Host /var/stundenerfassung/ -> Container Projekt-Verzeichnis (/app)
|
||||||
|
- /var/stundenerfassung/stundenerfassung.db:/app/stundenerfassung.db
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
restart: unless-stopped
|
||||||
131
helpers/utils.js
Normal file
131
helpers/utils.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// Helper-Funktionen für das Stundenerfassungs-System
|
||||||
|
|
||||||
|
// Helper: Prüft ob User eine bestimmte Rolle hat
|
||||||
|
function hasRole(req, role) {
|
||||||
|
if (!req.session.roles || !Array.isArray(req.session.roles)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return req.session.roles.includes(role);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Bestimmt die Standard-Rolle (höchste Priorität: admin > verwaltung > mitarbeiter)
|
||||||
|
function getDefaultRole(roles) {
|
||||||
|
if (!Array.isArray(roles) || roles.length === 0) {
|
||||||
|
return 'mitarbeiter';
|
||||||
|
}
|
||||||
|
if (roles.includes('admin')) return 'admin';
|
||||||
|
if (roles.includes('verwaltung')) return 'verwaltung';
|
||||||
|
return roles[0]; // Fallback auf erste Rolle
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Gibt aktuelles Datum als YYYY-MM-DD zurück
|
||||||
|
function getCurrentDate() {
|
||||||
|
const now = new Date();
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(now.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Gibt aktuelle Zeit als HH:MM zurück
|
||||||
|
function getCurrentTime() {
|
||||||
|
const now = new Date();
|
||||||
|
const hours = String(now.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Berechnet Pausenzeit in Minuten zwischen zwei Zeiten
|
||||||
|
function calculateBreakMinutes(pauseStart, pauseEnd) {
|
||||||
|
if (!pauseStart || !pauseEnd) return 0;
|
||||||
|
|
||||||
|
const [startHours, startMinutes] = pauseStart.split(':').map(Number);
|
||||||
|
const [endHours, endMinutes] = pauseEnd.split(':').map(Number);
|
||||||
|
|
||||||
|
const startTotalMinutes = startHours * 60 + startMinutes;
|
||||||
|
const endTotalMinutes = endHours * 60 + endMinutes;
|
||||||
|
|
||||||
|
return endTotalMinutes - startTotalMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Berechnet total_hours basierend auf start_time, end_time und break_minutes
|
||||||
|
function updateTotalHours(startTime, endTime, breakMinutes) {
|
||||||
|
if (!startTime || !endTime) return 0;
|
||||||
|
|
||||||
|
const [startHours, startMinutes] = startTime.split(':').map(Number);
|
||||||
|
const [endHours, endMinutes] = endTime.split(':').map(Number);
|
||||||
|
|
||||||
|
const startTotalMinutes = startHours * 60 + startMinutes;
|
||||||
|
const endTotalMinutes = endHours * 60 + endMinutes;
|
||||||
|
|
||||||
|
const totalMinutes = endTotalMinutes - startTotalMinutes - (breakMinutes || 0);
|
||||||
|
return totalMinutes / 60; // Konvertiere zu Stunden
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Formatiert Datum für Anzeige (DD.MM.YYYY)
|
||||||
|
function formatDate(dateStr) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Formatiert Datum und Zeit für Anzeige
|
||||||
|
function formatDateTime(dateStr) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Berechnet Kalenderwoche aus einem Datum (ISO 8601)
|
||||||
|
function getCalendarWeek(dateStr) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||||
|
const dayNum = d.getUTCDay() || 7;
|
||||||
|
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||||
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||||
|
const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
||||||
|
return weekNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: Berechnet week_start (Montag) und week_end (Sonntag) aus Jahr und Kalenderwoche (ISO 8601)
|
||||||
|
function getWeekDatesFromCalendarWeek(year, weekNumber) {
|
||||||
|
// ISO 8601: Woche beginnt am Montag, erste Woche enthält den 4. Januar
|
||||||
|
const jan4 = new Date(Date.UTC(year, 0, 4));
|
||||||
|
const jan4Day = jan4.getUTCDay() || 7; // 1 = Montag, 7 = Sonntag
|
||||||
|
const daysToMonday = jan4Day === 1 ? 0 : 1 - jan4Day;
|
||||||
|
|
||||||
|
// Montag der ersten Woche
|
||||||
|
const firstMonday = new Date(Date.UTC(year, 0, 4 + daysToMonday));
|
||||||
|
|
||||||
|
// Montag der gewünschten Woche (Woche 1 = erste Woche)
|
||||||
|
const weekMonday = new Date(firstMonday);
|
||||||
|
weekMonday.setUTCDate(firstMonday.getUTCDate() + (weekNumber - 1) * 7);
|
||||||
|
|
||||||
|
// Sonntag der Woche (6 Tage nach Montag)
|
||||||
|
const weekSunday = new Date(weekMonday);
|
||||||
|
weekSunday.setUTCDate(weekMonday.getUTCDate() + 6);
|
||||||
|
|
||||||
|
// Format: YYYY-MM-DD
|
||||||
|
const formatDate = (date) => {
|
||||||
|
const y = date.getUTCFullYear();
|
||||||
|
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getUTCDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
week_start: formatDate(weekMonday),
|
||||||
|
week_end: formatDate(weekSunday)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
hasRole,
|
||||||
|
getDefaultRole,
|
||||||
|
getCurrentDate,
|
||||||
|
getCurrentTime,
|
||||||
|
calculateBreakMinutes,
|
||||||
|
updateTotalHours,
|
||||||
|
formatDate,
|
||||||
|
formatDateTime,
|
||||||
|
getCalendarWeek,
|
||||||
|
getWeekDatesFromCalendarWeek
|
||||||
|
};
|
||||||
@@ -113,6 +113,19 @@ class LDAPService {
|
|||||||
return Array.isArray(attr.values) ? attr.values[0] : attr.values;
|
return Array.isArray(attr.values) ? attr.values[0] : attr.values;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escaped einen Wert für LDAP-Filter (verhindert LDAP-Injection)
|
||||||
|
*/
|
||||||
|
static escapeLDAPFilter(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
return value
|
||||||
|
.replace(/\\/g, '\\5c')
|
||||||
|
.replace(/\*/g, '\\2a')
|
||||||
|
.replace(/\(/g, '\\28')
|
||||||
|
.replace(/\)/g, '\\29')
|
||||||
|
.replace(/\0/g, '\\00');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Benutzer in SQLite synchronisieren
|
* Benutzer in SQLite synchronisieren
|
||||||
*/
|
*/
|
||||||
@@ -217,6 +230,83 @@ class LDAPService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Benutzer gegen LDAP authentifizieren
|
||||||
|
*/
|
||||||
|
static authenticate(username, password, callback) {
|
||||||
|
// Konfiguration abrufen
|
||||||
|
this.getConfig((err, config) => {
|
||||||
|
if (err || !config || !config.enabled) {
|
||||||
|
return callback(new Error('LDAP ist nicht aktiviert'), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAP-Verbindung herstellen (mit Service-Account)
|
||||||
|
this.connect(config, (err, client) => {
|
||||||
|
if (err) {
|
||||||
|
return callback(err, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suche nach dem Benutzer in LDAP
|
||||||
|
const baseDN = config.base_dn || '';
|
||||||
|
const usernameAttr = config.username_attribute || 'cn';
|
||||||
|
const escapedUsername = this.escapeLDAPFilter(username);
|
||||||
|
const searchFilter = `(${usernameAttr}=${escapedUsername})`;
|
||||||
|
const searchOptions = {
|
||||||
|
filter: searchFilter,
|
||||||
|
scope: 'sub',
|
||||||
|
attributes: ['dn', usernameAttr]
|
||||||
|
};
|
||||||
|
|
||||||
|
let userDN = null;
|
||||||
|
|
||||||
|
client.search(baseDN, searchOptions, (err, res) => {
|
||||||
|
if (err) {
|
||||||
|
client.unbind();
|
||||||
|
return callback(err, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.on('searchEntry', (entry) => {
|
||||||
|
userDN = entry.dn.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('error', (err) => {
|
||||||
|
client.unbind();
|
||||||
|
callback(err, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.on('end', (result) => {
|
||||||
|
// Service-Account-Verbindung schließen
|
||||||
|
client.unbind();
|
||||||
|
|
||||||
|
if (!userDN) {
|
||||||
|
return callback(new Error('Benutzer nicht gefunden'), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Versuche, sich mit den Benutzer-Credentials zu binden
|
||||||
|
const authClient = ldap.createClient({
|
||||||
|
url: config.url,
|
||||||
|
timeout: 10000,
|
||||||
|
connectTimeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
authClient.on('error', (err) => {
|
||||||
|
authClient.unbind();
|
||||||
|
callback(err, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
authClient.bind(userDN, password, (err) => {
|
||||||
|
authClient.unbind();
|
||||||
|
if (err) {
|
||||||
|
return callback(new Error('Ungültiges Passwort'), false);
|
||||||
|
}
|
||||||
|
callback(null, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vollständige Synchronisation durchführen
|
* Vollständige Synchronisation durchführen
|
||||||
*/
|
*/
|
||||||
|
|||||||
48
middleware/auth.js
Normal file
48
middleware/auth.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// Authentifizierungs-Middleware
|
||||||
|
|
||||||
|
const { hasRole } = require('../helpers/utils');
|
||||||
|
|
||||||
|
// Middleware: Authentifizierung prüfen
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
if (req.session.userId) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
res.redirect('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware: Prüft ob User eine bestimmte Rolle hat
|
||||||
|
function requireRole(role) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (req.session.userId && hasRole(req, role)) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
res.status(403).send('Zugriff verweigert');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware: Admin-Rolle prüfen
|
||||||
|
function requireAdmin(req, res, next) {
|
||||||
|
if (req.session.userId && hasRole(req, 'admin')) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
res.status(403).send('Zugriff verweigert');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware: Verwaltung-Rolle prüfen (Verwaltung oder Admin)
|
||||||
|
function requireVerwaltung(req, res, next) {
|
||||||
|
if (req.session.userId && (hasRole(req, 'verwaltung') || hasRole(req, 'admin'))) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
res.status(403).send('Zugriff verweigert');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
requireAuth,
|
||||||
|
requireRole,
|
||||||
|
requireAdmin,
|
||||||
|
requireVerwaltung
|
||||||
|
};
|
||||||
1464
package-lock.json
generated
1464
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,9 @@
|
|||||||
"ejs": "^3.1.9",
|
"ejs": "^3.1.9",
|
||||||
"pdfkit": "^0.13.0",
|
"pdfkit": "^0.13.0",
|
||||||
"ldapjs": "^3.0.7",
|
"ldapjs": "^3.0.7",
|
||||||
"node-cron": "^3.0.3"
|
"node-cron": "^3.0.3",
|
||||||
|
"ping": "^0.4.4",
|
||||||
|
"archiver": "^7.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1"
|
||||||
|
|||||||
@@ -388,9 +388,12 @@ table input[type="text"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.summary-item {
|
.summary-item {
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-item strong {
|
||||||
|
margin-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Actions */
|
/* Actions */
|
||||||
@@ -410,6 +413,13 @@ table input[type="text"] {
|
|||||||
padding: 30px;
|
padding: 30px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin Container - volle Breite */
|
||||||
|
.admin-container .container {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-user-form {
|
.add-user-form {
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
console.warn('Konnte letzte Woche nicht vom Server laden:', error);
|
console.warn('Konnte letzte Woche nicht vom Server laden:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ping-IP laden
|
||||||
|
loadPingIP();
|
||||||
|
|
||||||
// Statistiken laden
|
// Statistiken laden
|
||||||
loadUserStats();
|
loadUserStats();
|
||||||
|
|
||||||
@@ -248,8 +251,8 @@ function renderWeek() {
|
|||||||
let totalHours = 0;
|
let totalHours = 0;
|
||||||
let allWeekdaysFilled = true;
|
let allWeekdaysFilled = true;
|
||||||
|
|
||||||
// 7 Tage (Montag bis Sonntag)
|
// Hilfsfunktion zum Rendern eines einzelnen Tages
|
||||||
for (let i = 0; i < 7; i++) {
|
function renderDay(i) {
|
||||||
const date = new Date(startDate);
|
const date = new Date(startDate);
|
||||||
date.setDate(date.getDate() + i);
|
date.setDate(date.getDate() + i);
|
||||||
const dateStr = formatDate(date);
|
const dateStr = formatDate(date);
|
||||||
@@ -261,6 +264,7 @@ function renderWeek() {
|
|||||||
const hours = entry.total_hours || 0;
|
const hours = entry.total_hours || 0;
|
||||||
const overtimeTaken = entry.overtime_taken_hours || '';
|
const overtimeTaken = entry.overtime_taken_hours || '';
|
||||||
const vacationType = entry.vacation_type || '';
|
const vacationType = entry.vacation_type || '';
|
||||||
|
const sickStatus = entry.sick_status || false;
|
||||||
|
|
||||||
// Tätigkeiten laden
|
// Tätigkeiten laden
|
||||||
const activities = [
|
const activities = [
|
||||||
@@ -272,26 +276,27 @@ function renderWeek() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Prüfen ob Werktag (Montag-Freitag, i < 5) ausgefüllt ist
|
// Prüfen ob Werktag (Montag-Freitag, i < 5) ausgefüllt ist
|
||||||
// Bei ganztägigem Urlaub gilt der Tag als ausgefüllt
|
// Bei ganztägigem Urlaub oder Krank gilt der Tag als ausgefüllt
|
||||||
if (i < 5 && vacationType !== 'full' && (!startTime || !endTime || startTime.trim() === '' || endTime.trim() === '')) {
|
if (i < 5 && vacationType !== 'full' && !sickStatus && (!startTime || !endTime || startTime.trim() === '' || endTime.trim() === '')) {
|
||||||
allWeekdaysFilled = false;
|
allWeekdaysFilled = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stunden zur Summe hinzufügen
|
// Stunden zur Summe hinzufügen
|
||||||
// Bei ganztägigem Urlaub sollten es bereits 8 Stunden sein (vom Backend gesetzt)
|
// Bei ganztägigem Urlaub oder Krank sollten es bereits 8 Stunden sein (vom Backend gesetzt)
|
||||||
// Bei halbem Tag Urlaub werden die Urlaubsstunden später in der Überstunden-Berechnung hinzugezählt
|
// Bei halbem Tag Urlaub werden die Urlaubsstunden später in der Überstunden-Berechnung hinzugezählt
|
||||||
totalHours += hours;
|
totalHours += hours;
|
||||||
|
|
||||||
// Bearbeitung ist immer möglich, auch nach Abschicken
|
// Bearbeitung ist immer möglich, auch nach Abschicken
|
||||||
// Bei ganztägigem Urlaub werden Zeitfelder deaktiviert
|
// Bei ganztägigem Urlaub oder Krank werden Zeitfelder deaktiviert
|
||||||
const isFullDayVacation = vacationType === 'full';
|
const isFullDayVacation = vacationType === 'full';
|
||||||
const timeFieldsDisabled = isFullDayVacation ? 'disabled' : '';
|
const isSick = sickStatus === true || sickStatus === 1;
|
||||||
|
const timeFieldsDisabled = (isFullDayVacation || isSick) ? 'disabled' : '';
|
||||||
const disabled = '';
|
const disabled = '';
|
||||||
|
|
||||||
html += `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>${getWeekday(dateStr)}</strong></td>
|
<td><strong>${getWeekday(dateStr)}</strong></td>
|
||||||
<td>${formatDateDE(dateStr)}${isFullDayVacation ? ' <span style="color: #28a745;">(Urlaub - ganzer Tag)</span>' : ''}</td>
|
<td>${formatDateDE(dateStr)}${isFullDayVacation ? ' <span style="color: #28a745;">(Urlaub - ganzer Tag)</span>' : ''}${isSick ? ' <span style="color: #e74c3c;">(Krank)</span>' : ''}</td>
|
||||||
<td>
|
<td>
|
||||||
<input type="time" value="${startTime}"
|
<input type="time" value="${startTime}"
|
||||||
data-date="${dateStr}" data-field="start_time"
|
data-date="${dateStr}" data-field="start_time"
|
||||||
@@ -309,7 +314,7 @@ function renderWeek() {
|
|||||||
data-date="${dateStr}" data-field="break_minutes"
|
data-date="${dateStr}" data-field="break_minutes"
|
||||||
${timeFieldsDisabled} ${disabled} oninput="saveEntry(this)" onchange="saveEntry(this)">
|
${timeFieldsDisabled} ${disabled} oninput="saveEntry(this)" onchange="saveEntry(this)">
|
||||||
</td>
|
</td>
|
||||||
<td><strong id="hours_${dateStr}">${isFullDayVacation ? '8.00 h (Urlaub)' : hours.toFixed(2) + ' h'}</strong></td>
|
<td><strong id="hours_${dateStr}">${isFullDayVacation ? '8.00 h (Urlaub)' : isSick ? '8.00 h (Krank)' : hours.toFixed(2) + ' h'}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="activities-row">
|
<tr class="activities-row">
|
||||||
<td colspan="6" class="activities-cell">
|
<td colspan="6" class="activities-cell">
|
||||||
@@ -369,9 +374,9 @@ function renderWeek() {
|
|||||||
step="0.25"
|
step="0.25"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
${disabled}
|
${disabled}
|
||||||
onblur="saveEntry(this)"
|
onblur="handleOvertimeChange('${dateStr}', this.value); saveEntry(this);"
|
||||||
oninput="updateOvertimeDisplay();"
|
oninput="updateOvertimeDisplay();"
|
||||||
onchange="updateOvertimeDisplay();"
|
onchange="handleOvertimeChange('${dateStr}', this.value); updateOvertimeDisplay();"
|
||||||
style="width: 80px; margin-left: 5px;"
|
style="width: 80px; margin-left: 5px;"
|
||||||
class="overtime-input">
|
class="overtime-input">
|
||||||
<span>h</span>
|
<span>h</span>
|
||||||
@@ -395,12 +400,60 @@ function renderWeek() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sick-control">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" onclick="toggleSickStatus('${dateStr}')" style="margin-right: 5px;">
|
||||||
|
Krank
|
||||||
|
</button>
|
||||||
|
<div id="sick-checkbox-${dateStr}" style="display: ${sickStatus ? 'inline-block' : 'none'};">
|
||||||
|
<input type="checkbox"
|
||||||
|
data-date="${dateStr}"
|
||||||
|
data-field="sick_status"
|
||||||
|
${sickStatus ? 'checked' : ''}
|
||||||
|
${disabled}
|
||||||
|
onchange="saveEntry(this); updateOvertimeDisplay();"
|
||||||
|
style="margin-left: 5px;"
|
||||||
|
class="sick-checkbox">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Werktage (Montag bis Freitag) rendern
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
html += renderDay(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wochenende (Samstag und Sonntag) in zusammenklappbarer Sektion
|
||||||
|
html += `
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" style="padding: 0; border: none;">
|
||||||
|
<div class="weekend-section" style="margin-top: 10px;">
|
||||||
|
<div class="collapsible-header" onclick="toggleWeekendSection()" style="cursor: pointer; padding: 12px; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h4 style="margin: 0; font-size: 14px; font-weight: 600;">Wochenende</h4>
|
||||||
|
<span id="weekendToggleIcon" style="font-size: 16px; transition: transform 0.3s;">▼</span>
|
||||||
|
</div>
|
||||||
|
<div id="weekendContent" style="display: none; border: 1px solid #ddd; border-top: none; border-radius: 0 0 4px 4px; background-color: #fff;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Samstag und Sonntag rendern
|
||||||
|
for (let i = 5; i < 7; i++) {
|
||||||
|
html += renderDay(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
|
||||||
html += `
|
html += `
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -442,16 +495,6 @@ function renderWeek() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Überstunden-Anzeige erstellen (falls noch nicht vorhanden)
|
|
||||||
let overtimeDisplay = document.getElementById('overtimeDisplay');
|
|
||||||
if (!overtimeDisplay) {
|
|
||||||
const summaryDiv = document.querySelector('.summary');
|
|
||||||
overtimeDisplay = document.createElement('div');
|
|
||||||
overtimeDisplay.id = 'overtimeDisplay';
|
|
||||||
overtimeDisplay.className = 'summary-item';
|
|
||||||
summaryDiv.appendChild(overtimeDisplay);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Überstunden-Berechnung aufrufen
|
// Überstunden-Berechnung aufrufen
|
||||||
updateOvertimeDisplay();
|
updateOvertimeDisplay();
|
||||||
|
|
||||||
@@ -480,18 +523,45 @@ function updateOvertimeDisplay() {
|
|||||||
|
|
||||||
// Gesamtstunden berechnen - direkt aus DOM-Elementen lesen für Echtzeit-Aktualisierung
|
// Gesamtstunden berechnen - direkt aus DOM-Elementen lesen für Echtzeit-Aktualisierung
|
||||||
let totalHours = 0;
|
let totalHours = 0;
|
||||||
|
let vacationHours = 0;
|
||||||
const startDateObj = new Date(startDate);
|
const startDateObj = new Date(startDate);
|
||||||
for (let i = 0; i < 7; i++) {
|
for (let i = 0; i < 7; i++) {
|
||||||
const date = new Date(startDateObj);
|
const date = new Date(startDateObj);
|
||||||
date.setDate(date.getDate() + i);
|
date.setDate(date.getDate() + i);
|
||||||
const dateStr = formatDate(date);
|
const dateStr = formatDate(date);
|
||||||
|
|
||||||
// Prüfe Urlaub-Status
|
// Prüfe Urlaub-Status und Krank-Status
|
||||||
const vacationSelect = document.querySelector(`select[data-date="${dateStr}"][data-field="vacation_type"]`);
|
const vacationSelect = document.querySelector(`select[data-date="${dateStr}"][data-field="vacation_type"]`);
|
||||||
const vacationType = vacationSelect ? vacationSelect.value : (currentEntries[dateStr]?.vacation_type || '');
|
const vacationType = vacationSelect ? vacationSelect.value : (currentEntries[dateStr]?.vacation_type || '');
|
||||||
|
const sickCheckbox = document.querySelector(`input[data-date="${dateStr}"][data-field="sick_status"]`);
|
||||||
|
const sickStatus = sickCheckbox ? sickCheckbox.checked : (currentEntries[dateStr]?.sick_status || false);
|
||||||
|
|
||||||
|
// Wenn Urlaub oder Krank, zähle nur diese Stunden (nicht zusätzlich Arbeitsstunden)
|
||||||
if (vacationType === 'full') {
|
if (vacationType === 'full') {
|
||||||
totalHours += 8; // Ganzer Tag Urlaub = 8 Stunden
|
vacationHours += 8; // Ganzer Tag Urlaub = 8 Stunden
|
||||||
|
} else if (vacationType === 'half') {
|
||||||
|
vacationHours += 4; // Halber Tag Urlaub = 4 Stunden
|
||||||
|
// Bei halbem Tag Urlaub können noch Arbeitsstunden vorhanden sein
|
||||||
|
const startInput = document.querySelector(`input[data-date="${dateStr}"][data-field="start_time"]`);
|
||||||
|
const endInput = document.querySelector(`input[data-date="${dateStr}"][data-field="end_time"]`);
|
||||||
|
const breakInput = document.querySelector(`input[data-date="${dateStr}"][data-field="break_minutes"]`);
|
||||||
|
|
||||||
|
const startTime = startInput ? startInput.value : '';
|
||||||
|
const endTime = endInput ? endInput.value : '';
|
||||||
|
const breakMinutes = parseInt(breakInput ? breakInput.value : 0) || 0;
|
||||||
|
|
||||||
|
if (startTime && endTime) {
|
||||||
|
const start = new Date(`2000-01-01T${startTime}`);
|
||||||
|
const end = new Date(`2000-01-01T${endTime}`);
|
||||||
|
const diffMs = end - start;
|
||||||
|
const hours = (diffMs / (1000 * 60 * 60)) - (breakMinutes / 60);
|
||||||
|
totalHours += hours;
|
||||||
|
} else if (currentEntries[dateStr]?.total_hours) {
|
||||||
|
// Fallback auf gespeicherte Werte
|
||||||
|
totalHours += parseFloat(currentEntries[dateStr].total_hours) || 0;
|
||||||
|
}
|
||||||
|
} else if (sickStatus) {
|
||||||
|
totalHours += 8; // Krank = 8 Stunden
|
||||||
} else {
|
} else {
|
||||||
// Berechne Stunden direkt aus Start-/Endzeit und Pause
|
// Berechne Stunden direkt aus Start-/Endzeit und Pause
|
||||||
const startInput = document.querySelector(`input[data-date="${dateStr}"][data-field="start_time"]`);
|
const startInput = document.querySelector(`input[data-date="${dateStr}"][data-field="start_time"]`);
|
||||||
@@ -515,24 +585,6 @@ function updateOvertimeDisplay() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Urlaubsstunden berechnen (Urlaub zählt als normale Arbeitszeit)
|
|
||||||
let vacationHours = 0;
|
|
||||||
const startDateObj2 = new Date(startDate);
|
|
||||||
for (let i = 0; i < 7; i++) {
|
|
||||||
const date = new Date(startDateObj2);
|
|
||||||
date.setDate(date.getDate() + i);
|
|
||||||
const dateStr = formatDate(date);
|
|
||||||
|
|
||||||
const vacationSelect = document.querySelector(`select[data-date="${dateStr}"][data-field="vacation_type"]`);
|
|
||||||
const vacationType = vacationSelect ? vacationSelect.value : (currentEntries[dateStr]?.vacation_type || '');
|
|
||||||
|
|
||||||
if (vacationType === 'full') {
|
|
||||||
vacationHours += 8; // Ganzer Tag = 8 Stunden
|
|
||||||
} else if (vacationType === 'half') {
|
|
||||||
vacationHours += 4; // Halber Tag = 4 Stunden
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Genommene Überstunden berechnen - direkt aus DOM lesen
|
// Genommene Überstunden berechnen - direkt aus DOM lesen
|
||||||
let overtimeTaken = 0;
|
let overtimeTaken = 0;
|
||||||
const startDateObj3 = new Date(startDate);
|
const startDateObj3 = new Date(startDate);
|
||||||
@@ -552,17 +604,94 @@ function updateOvertimeDisplay() {
|
|||||||
// Überstunden = (Tatsächliche Stunden + Urlaubsstunden) - Sollstunden
|
// Überstunden = (Tatsächliche Stunden + Urlaubsstunden) - Sollstunden
|
||||||
const totalHoursWithVacation = totalHours + vacationHours;
|
const totalHoursWithVacation = totalHours + vacationHours;
|
||||||
const overtimeHours = totalHoursWithVacation - sollStunden;
|
const overtimeHours = totalHoursWithVacation - sollStunden;
|
||||||
const remainingOvertime = overtimeHours - overtimeTaken;
|
|
||||||
|
|
||||||
// Überstunden-Anzeige aktualisieren
|
// Überstunden-Anzeige aktualisieren
|
||||||
const overtimeDisplay = document.getElementById('overtimeDisplay');
|
const overtimeSummaryItem = document.getElementById('overtimeSummaryItem');
|
||||||
if (overtimeDisplay) {
|
const overtimeHoursSpan = document.getElementById('overtimeHours');
|
||||||
overtimeDisplay.innerHTML = `
|
if (overtimeSummaryItem && overtimeHoursSpan) {
|
||||||
<strong>Überstunden diese Woche:</strong>
|
overtimeSummaryItem.style.display = 'block';
|
||||||
<span id="overtimeHours">${overtimeHours.toFixed(2)} h</span>
|
const sign = overtimeHours >= 0 ? '+' : '';
|
||||||
${overtimeTaken > 0 ? `<br><strong>Davon genommen:</strong> <span>${overtimeTaken.toFixed(2)} h</span>` : ''}
|
overtimeHoursSpan.textContent = `${sign}${overtimeHours.toFixed(2)} h`;
|
||||||
${remainingOvertime !== overtimeHours ? `<br><strong>Verbleibend:</strong> <span>${remainingOvertime.toFixed(2)} h</span>` : ''}
|
overtimeHoursSpan.style.color = overtimeHours >= 0 ? '#27ae60' : '#e74c3c';
|
||||||
`;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Überstunden-Änderung verarbeiten
|
||||||
|
function handleOvertimeChange(dateStr, overtimeHours) {
|
||||||
|
if (!userWochenstunden || userWochenstunden <= 0) {
|
||||||
|
console.warn('Wochenstunden nicht verfügbar, kann Überstunden-Logik nicht anwenden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullDayHours = userWochenstunden / 5;
|
||||||
|
const overtimeValue = parseFloat(overtimeHours) || 0;
|
||||||
|
|
||||||
|
// Prüfe ob ganzer Tag Überstunden
|
||||||
|
if (overtimeValue > 0 && Math.abs(overtimeValue - fullDayHours) < 0.01) {
|
||||||
|
// Ganzer Tag Überstunden
|
||||||
|
// Setze Activity1 auf "Überstunden" mit 0 Stunden
|
||||||
|
const activity1DescInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity1_desc"]`);
|
||||||
|
const activity1HoursInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity1_hours"]`);
|
||||||
|
|
||||||
|
if (activity1DescInput) {
|
||||||
|
activity1DescInput.value = 'Überstunden';
|
||||||
|
// Trigger saveEntry für dieses Feld
|
||||||
|
saveEntry(activity1DescInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity1HoursInput) {
|
||||||
|
activity1HoursInput.value = '0';
|
||||||
|
// Trigger saveEntry für dieses Feld
|
||||||
|
saveEntry(activity1HoursInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leere Start- und End-Zeit
|
||||||
|
const startInput = document.querySelector(`input[data-date="${dateStr}"][data-field="start_time"]`);
|
||||||
|
const endInput = document.querySelector(`input[data-date="${dateStr}"][data-field="end_time"]`);
|
||||||
|
|
||||||
|
if (startInput) {
|
||||||
|
startInput.value = '';
|
||||||
|
saveEntry(startInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endInput) {
|
||||||
|
endInput.value = '';
|
||||||
|
saveEntry(endInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (overtimeValue > 0 && overtimeValue < fullDayHours) {
|
||||||
|
// Weniger als ganzer Tag - füge "Überstunden" als Tätigkeit hinzu
|
||||||
|
// Finde erste freie Activity-Spalte oder prüfe ob bereits vorhanden
|
||||||
|
let foundOvertime = false;
|
||||||
|
let firstEmptySlot = null;
|
||||||
|
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const descInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_desc"]`);
|
||||||
|
const hoursInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${i}_hours"]`);
|
||||||
|
|
||||||
|
if (descInput && descInput.value && descInput.value.trim().toLowerCase() === 'überstunden') {
|
||||||
|
foundOvertime = true;
|
||||||
|
break; // Bereits vorhanden
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!firstEmptySlot && descInput && (!descInput.value || descInput.value.trim() === '')) {
|
||||||
|
firstEmptySlot = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn nicht gefunden und freier Slot vorhanden, füge hinzu
|
||||||
|
if (!foundOvertime && firstEmptySlot) {
|
||||||
|
const descInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${firstEmptySlot}_desc"]`);
|
||||||
|
const hoursInput = document.querySelector(`input[data-date="${dateStr}"][data-field="activity${firstEmptySlot}_hours"]`);
|
||||||
|
|
||||||
|
if (descInput) {
|
||||||
|
descInput.value = 'Überstunden';
|
||||||
|
saveEntry(descInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stunden bleiben unverändert (werden vom User eingegeben oder bleiben leer)
|
||||||
|
// total_hours bleibt auch unverändert
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,6 +723,7 @@ async function saveEntry(input) {
|
|||||||
const notesInput = document.querySelector(`textarea[data-date="${date}"][data-field="notes"]`);
|
const notesInput = document.querySelector(`textarea[data-date="${date}"][data-field="notes"]`);
|
||||||
const vacationSelect = document.querySelector(`select[data-date="${date}"][data-field="vacation_type"]`);
|
const vacationSelect = document.querySelector(`select[data-date="${date}"][data-field="vacation_type"]`);
|
||||||
const overtimeInput = document.querySelector(`input[data-date="${date}"][data-field="overtime_taken_hours"]`);
|
const overtimeInput = document.querySelector(`input[data-date="${date}"][data-field="overtime_taken_hours"]`);
|
||||||
|
const sickCheckbox = document.querySelector(`input[data-date="${date}"][data-field="sick_status"]`);
|
||||||
|
|
||||||
// Wenn das aktuelle Input-Element das gesuchte Feld ist, verwende dessen Wert direkt
|
// Wenn das aktuelle Input-Element das gesuchte Feld ist, verwende dessen Wert direkt
|
||||||
// Das stellt sicher, dass der Wert auch bei oninput/onchange sofort verfügbar ist
|
// Das stellt sicher, dass der Wert auch bei oninput/onchange sofort verfügbar ist
|
||||||
@@ -612,6 +742,7 @@ async function saveEntry(input) {
|
|||||||
const notes = notesInput ? (notesInput.value || '') : (currentEntries[date].notes || '');
|
const notes = notesInput ? (notesInput.value || '') : (currentEntries[date].notes || '');
|
||||||
const vacation_type = vacationSelect && vacationSelect.value ? vacationSelect.value : (currentEntries[date].vacation_type || null);
|
const vacation_type = vacationSelect && vacationSelect.value ? vacationSelect.value : (currentEntries[date].vacation_type || null);
|
||||||
const overtime_taken_hours = overtimeInput && overtimeInput.value ? overtimeInput.value : (currentEntries[date].overtime_taken_hours || null);
|
const overtime_taken_hours = overtimeInput && overtimeInput.value ? overtimeInput.value : (currentEntries[date].overtime_taken_hours || null);
|
||||||
|
const sick_status = sickCheckbox ? (sickCheckbox.checked ? true : false) : (currentEntries[date].sick_status || false);
|
||||||
|
|
||||||
// Activity-Felder aus DOM lesen
|
// Activity-Felder aus DOM lesen
|
||||||
const activities = [];
|
const activities = [];
|
||||||
@@ -634,6 +765,7 @@ async function saveEntry(input) {
|
|||||||
currentEntries[date].notes = notes;
|
currentEntries[date].notes = notes;
|
||||||
currentEntries[date].vacation_type = vacation_type;
|
currentEntries[date].vacation_type = vacation_type;
|
||||||
currentEntries[date].overtime_taken_hours = overtime_taken_hours;
|
currentEntries[date].overtime_taken_hours = overtime_taken_hours;
|
||||||
|
currentEntries[date].sick_status = sick_status;
|
||||||
for (let i = 1; i <= 5; i++) {
|
for (let i = 1; i <= 5; i++) {
|
||||||
currentEntries[date][`activity${i}_desc`] = activities[i-1].desc;
|
currentEntries[date][`activity${i}_desc`] = activities[i-1].desc;
|
||||||
currentEntries[date][`activity${i}_hours`] = activities[i-1].hours;
|
currentEntries[date][`activity${i}_hours`] = activities[i-1].hours;
|
||||||
@@ -671,7 +803,8 @@ async function saveEntry(input) {
|
|||||||
activity5_hours: activities[4].hours,
|
activity5_hours: activities[4].hours,
|
||||||
activity5_project_number: activities[4].projectNumber,
|
activity5_project_number: activities[4].projectNumber,
|
||||||
overtime_taken_hours: overtime_taken_hours,
|
overtime_taken_hours: overtime_taken_hours,
|
||||||
vacation_type: vacation_type
|
vacation_type: vacation_type,
|
||||||
|
sick_status: sick_status
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -727,15 +860,17 @@ function checkWeekComplete() {
|
|||||||
date.setDate(date.getDate() + i);
|
date.setDate(date.getDate() + i);
|
||||||
const dateStr = formatDate(date);
|
const dateStr = formatDate(date);
|
||||||
|
|
||||||
// Prüfe Urlaub-Status
|
// Prüfe Urlaub-Status und Krank-Status
|
||||||
const entry = currentEntries[dateStr] || {};
|
const entry = currentEntries[dateStr] || {};
|
||||||
const vacationType = entry.vacation_type;
|
const vacationType = entry.vacation_type;
|
||||||
const vacationSelect = document.querySelector(`select[data-date="${dateStr}"][data-field="vacation_type"]`);
|
const vacationSelect = document.querySelector(`select[data-date="${dateStr}"][data-field="vacation_type"]`);
|
||||||
const vacationValue = vacationSelect ? vacationSelect.value : (vacationType || '');
|
const vacationValue = vacationSelect ? vacationSelect.value : (vacationType || '');
|
||||||
|
const sickCheckbox = document.querySelector(`input[data-date="${dateStr}"][data-field="sick_status"]`);
|
||||||
|
const sickStatus = sickCheckbox ? sickCheckbox.checked : (entry.sick_status || false);
|
||||||
|
|
||||||
// Wenn ganzer Tag Urlaub, dann ist der Tag als ausgefüllt zu betrachten
|
// Wenn ganzer Tag Urlaub oder Krank, dann ist der Tag als ausgefüllt zu betrachten
|
||||||
if (vacationValue === 'full') {
|
if (vacationValue === 'full' || sickStatus) {
|
||||||
continue; // Tag ist ausgefüllt (ganzer Tag Urlaub)
|
continue; // Tag ist ausgefüllt (ganzer Tag Urlaub oder Krank)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfe IMMER direkt die Input-Felder im DOM (das ist die zuverlässigste Quelle)
|
// Prüfe IMMER direkt die Input-Felder im DOM (das ist die zuverlässigste Quelle)
|
||||||
@@ -832,14 +967,16 @@ async function submitWeek() {
|
|||||||
const weekday = getWeekday(dateStr);
|
const weekday = getWeekday(dateStr);
|
||||||
const dateDisplay = formatDateDE(dateStr);
|
const dateDisplay = formatDateDE(dateStr);
|
||||||
|
|
||||||
// Prüfe Urlaub-Status
|
// Prüfe Urlaub-Status und Krank-Status
|
||||||
const entry = currentEntries[dateStr] || {};
|
const entry = currentEntries[dateStr] || {};
|
||||||
const vacationSelect = document.querySelector(`select[data-date="${dateStr}"][data-field="vacation_type"]`);
|
const vacationSelect = document.querySelector(`select[data-date="${dateStr}"][data-field="vacation_type"]`);
|
||||||
const vacationValue = vacationSelect ? vacationSelect.value : (entry.vacation_type || '');
|
const vacationValue = vacationSelect ? vacationSelect.value : (entry.vacation_type || '');
|
||||||
|
const sickCheckbox = document.querySelector(`input[data-date="${dateStr}"][data-field="sick_status"]`);
|
||||||
|
const sickStatus = sickCheckbox ? sickCheckbox.checked : (entry.sick_status || false);
|
||||||
|
|
||||||
// Wenn ganzer Tag Urlaub, dann ist der Tag als ausgefüllt zu betrachten
|
// Wenn ganzer Tag Urlaub oder Krank, dann ist der Tag als ausgefüllt zu betrachten
|
||||||
if (vacationValue === 'full') {
|
if (vacationValue === 'full' || sickStatus) {
|
||||||
continue; // Tag ist ausgefüllt (ganzer Tag Urlaub)
|
continue; // Tag ist ausgefüllt (ganzer Tag Urlaub oder Krank)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfe IMMER direkt die Input-Felder im DOM - auch bei manueller Eingabe
|
// Prüfe IMMER direkt die Input-Felder im DOM - auch bei manueller Eingabe
|
||||||
@@ -1043,7 +1180,8 @@ async function submitWeekWithReason(versionReason) {
|
|||||||
const versionText = result.version ? ` (Version ${result.version})` : '';
|
const versionText = result.version ? ` (Version ${result.version})` : '';
|
||||||
alert(`Stundenzettel wurde erfolgreich eingereicht${versionText}!`);
|
alert(`Stundenzettel wurde erfolgreich eingereicht${versionText}!`);
|
||||||
loadWeek(); // Neu laden um Status zu aktualisieren
|
loadWeek(); // Neu laden um Status zu aktualisieren
|
||||||
loadUserStats(); // Statistiken aktualisieren
|
// Statistiken aktualisieren
|
||||||
|
loadUserStats();
|
||||||
} else {
|
} else {
|
||||||
console.error('Fehler-Details:', result);
|
console.error('Fehler-Details:', result);
|
||||||
alert(result.error || 'Fehler beim Einreichen des Stundenzettels');
|
alert(result.error || 'Fehler beim Einreichen des Stundenzettels');
|
||||||
@@ -1105,3 +1243,100 @@ function toggleVacationSelect(dateStr) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Krank-Status ein-/ausblenden
|
||||||
|
function toggleSickStatus(dateStr) {
|
||||||
|
const checkboxDiv = document.getElementById(`sick-checkbox-${dateStr}`);
|
||||||
|
if (checkboxDiv) {
|
||||||
|
if (checkboxDiv.style.display === 'none' || !checkboxDiv.style.display) {
|
||||||
|
checkboxDiv.style.display = 'inline-block';
|
||||||
|
const checkbox = checkboxDiv.querySelector('input[type="checkbox"]');
|
||||||
|
if (checkbox) {
|
||||||
|
// Prüfe aktuellen Status aus currentEntries
|
||||||
|
const currentSickStatus = currentEntries[dateStr]?.sick_status || false;
|
||||||
|
checkbox.checked = currentSickStatus || true; // Wenn nicht gesetzt, auf true setzen
|
||||||
|
checkbox.focus();
|
||||||
|
// Sofort speichern wenn aktiviert
|
||||||
|
if (!currentSickStatus) {
|
||||||
|
saveEntry(checkbox);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Wert löschen wenn ausgeblendet
|
||||||
|
const checkbox = checkboxDiv.querySelector('input[type="checkbox"]');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
// Speichern
|
||||||
|
if (currentEntries[dateStr]) {
|
||||||
|
currentEntries[dateStr].sick_status = false;
|
||||||
|
saveEntry(checkbox);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkboxDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping-IP laden
|
||||||
|
async function loadPingIP() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/ping-ip');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Fehler beim Laden der IP-Adresse');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
const pingIpInput = document.getElementById('pingIpInput');
|
||||||
|
if (pingIpInput) {
|
||||||
|
pingIpInput.value = data.ping_ip || '';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Ping-IP:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping-IP speichern (global für onclick)
|
||||||
|
window.savePingIP = async function() {
|
||||||
|
const pingIpInput = document.getElementById('pingIpInput');
|
||||||
|
if (!pingIpInput) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pingIp = pingIpInput.value.trim();
|
||||||
|
|
||||||
|
// Finde den Button (nächstes Geschwisterelement oder über Parent)
|
||||||
|
const button = pingIpInput.parentElement?.querySelector('button') ||
|
||||||
|
document.querySelector('button[onclick*="savePingIP"]');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/user/ping-ip', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ping_ip: pingIp })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
alert(result.error || 'Fehler beim Speichern der IP-Adresse');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erfolgs-Feedback
|
||||||
|
if (button) {
|
||||||
|
const originalText = button.textContent;
|
||||||
|
button.textContent = 'Gespeichert!';
|
||||||
|
button.style.backgroundColor = '#27ae60';
|
||||||
|
setTimeout(() => {
|
||||||
|
button.textContent = originalText;
|
||||||
|
button.style.backgroundColor = '';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Ping-IP gespeichert:', result.ping_ip);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Speichern der Ping-IP:', error);
|
||||||
|
alert('Fehler beim Speichern der IP-Adresse');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
38
reset-db.js
38
reset-db.js
@@ -38,6 +38,8 @@ try {
|
|||||||
personalnummer TEXT,
|
personalnummer TEXT,
|
||||||
wochenstunden REAL,
|
wochenstunden REAL,
|
||||||
urlaubstage REAL,
|
urlaubstage REAL,
|
||||||
|
overtime_offset_hours REAL DEFAULT 0,
|
||||||
|
ping_ip TEXT,
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)`, (err) => {
|
)`, (err) => {
|
||||||
if (err) console.error('Fehler bei users:', err);
|
if (err) console.error('Fehler bei users:', err);
|
||||||
@@ -71,6 +73,9 @@ try {
|
|||||||
activity5_project_number TEXT,
|
activity5_project_number TEXT,
|
||||||
overtime_taken_hours REAL,
|
overtime_taken_hours REAL,
|
||||||
vacation_type TEXT,
|
vacation_type TEXT,
|
||||||
|
sick_status INTEGER DEFAULT 0,
|
||||||
|
pause_start_time TEXT,
|
||||||
|
pause_end_time TEXT,
|
||||||
status TEXT DEFAULT 'offen',
|
status TEXT DEFAULT 'offen',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -138,6 +143,21 @@ try {
|
|||||||
else console.log('✅ Tabelle ldap_sync_log erstellt');
|
else console.log('✅ Tabelle ldap_sync_log erstellt');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ping-Status-Tabelle für IP-basierte Zeiterfassung
|
||||||
|
db.run(`CREATE TABLE ping_status (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
last_successful_ping DATETIME,
|
||||||
|
failed_ping_count INTEGER DEFAULT 0,
|
||||||
|
start_time_set INTEGER DEFAULT 0,
|
||||||
|
first_failed_ping_time DATETIME,
|
||||||
|
PRIMARY KEY (user_id, date),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
)`, (err) => {
|
||||||
|
if (err) console.error('Fehler bei ping_status:', err);
|
||||||
|
else console.log('✅ Tabelle ping_status erstellt');
|
||||||
|
});
|
||||||
|
|
||||||
// Warte bis alle Tabellen erstellt sind
|
// Warte bis alle Tabellen erstellt sind
|
||||||
db.run('SELECT 1', (err) => {
|
db.run('SELECT 1', (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -147,29 +167,29 @@ try {
|
|||||||
|
|
||||||
console.log('\n👤 Erstelle Standard-Benutzer...\n');
|
console.log('\n👤 Erstelle Standard-Benutzer...\n');
|
||||||
|
|
||||||
// Standard Admin-Benutzer
|
// Standard Admin-Benutzer (Rolle als JSON-Array)
|
||||||
const adminPassword = bcrypt.hashSync('admin123', 10);
|
const adminPassword = bcrypt.hashSync('admin123', 10);
|
||||||
db.run(`INSERT INTO users (id, username, password, firstname, lastname, role)
|
db.run(`INSERT INTO users (id, username, password, firstname, lastname, role)
|
||||||
VALUES (1, 'admin', ?, 'System', 'Administrator', 'admin')`,
|
VALUES (1, 'admin', ?, 'System', 'Administrator', ?)`,
|
||||||
[adminPassword], (err) => {
|
[adminPassword, JSON.stringify(['admin'])], (err) => {
|
||||||
if (err) console.error('Fehler beim Erstellen des Admin-Users:', err);
|
if (err) console.error('Fehler beim Erstellen des Admin-Users:', err);
|
||||||
else console.log('✅ Admin-User erstellt (admin / admin123)');
|
else console.log('✅ Admin-User erstellt (admin / admin123)');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Standard Verwaltungs-Benutzer
|
// Standard Verwaltungs-Benutzer (Rolle als JSON-Array)
|
||||||
const verwaltungPassword = bcrypt.hashSync('verwaltung123', 10);
|
const verwaltungPassword = bcrypt.hashSync('verwaltung123', 10);
|
||||||
db.run(`INSERT INTO users (id, username, password, firstname, lastname, role)
|
db.run(`INSERT INTO users (id, username, password, firstname, lastname, role)
|
||||||
VALUES (2, 'verwaltung', ?, 'Verwaltung', 'User', 'verwaltung')`,
|
VALUES (2, 'verwaltung', ?, 'Verwaltung', 'User', ?)`,
|
||||||
[verwaltungPassword], (err) => {
|
[verwaltungPassword, JSON.stringify(['verwaltung'])], (err) => {
|
||||||
if (err) console.error('Fehler beim Erstellen des Verwaltungs-Users:', err);
|
if (err) console.error('Fehler beim Erstellen des Verwaltungs-Users:', err);
|
||||||
else console.log('✅ Verwaltungs-User erstellt (verwaltung / verwaltung123)');
|
else console.log('✅ Verwaltungs-User erstellt (verwaltung / verwaltung123)');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test-Mitarbeiter (optional)
|
// Test-Mitarbeiter (optional, Rolle als JSON-Array)
|
||||||
const mitarbeiterPassword = bcrypt.hashSync('test123', 10);
|
const mitarbeiterPassword = bcrypt.hashSync('test123', 10);
|
||||||
db.run(`INSERT INTO users (id, username, password, firstname, lastname, role, wochenstunden, urlaubstage)
|
db.run(`INSERT INTO users (id, username, password, firstname, lastname, role, wochenstunden, urlaubstage)
|
||||||
VALUES (3, 'test', ?, 'Test', 'Mitarbeiter', 'mitarbeiter', 40, 25)`,
|
VALUES (3, 'test', ?, 'Test', 'Mitarbeiter', ?, 40, 25)`,
|
||||||
[mitarbeiterPassword], (err) => {
|
[mitarbeiterPassword, JSON.stringify(['mitarbeiter'])], (err) => {
|
||||||
if (err && !err.message.includes('UNIQUE constraint')) {
|
if (err && !err.message.includes('UNIQUE constraint')) {
|
||||||
console.error('Fehler beim Erstellen des Test-Users:', err);
|
console.error('Fehler beim Erstellen des Test-Users:', err);
|
||||||
} else if (!err) {
|
} else if (!err) {
|
||||||
|
|||||||
167
routes/admin-ldap.js
Normal file
167
routes/admin-ldap.js
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
// LDAP Admin Routes
|
||||||
|
|
||||||
|
const { db } = require('../database');
|
||||||
|
const LDAPService = require('../ldap-service');
|
||||||
|
const { requireAdmin } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Routes registrieren
|
||||||
|
function registerAdminLDAPRoutes(app) {
|
||||||
|
// LDAP-Konfiguration abrufen
|
||||||
|
app.get('/admin/ldap/config', requireAdmin, (req, res) => {
|
||||||
|
db.get('SELECT * FROM ldap_config WHERE id = 1', (err, config) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Abrufen der Konfiguration' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passwort nicht zurückgeben
|
||||||
|
if (config) {
|
||||||
|
delete config.bind_password;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ config: config || null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// LDAP-Konfiguration speichern
|
||||||
|
app.post('/admin/ldap/config', requireAdmin, (req, res) => {
|
||||||
|
const {
|
||||||
|
enabled,
|
||||||
|
url,
|
||||||
|
bind_dn,
|
||||||
|
bind_password,
|
||||||
|
base_dn,
|
||||||
|
user_search_filter,
|
||||||
|
username_attribute,
|
||||||
|
firstname_attribute,
|
||||||
|
lastname_attribute,
|
||||||
|
sync_interval
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Validierung - nur wenn aktiviert
|
||||||
|
if (enabled && (!url || !base_dn)) {
|
||||||
|
return res.status(400).json({ error: 'URL und Base DN sind erforderlich wenn LDAP aktiviert ist' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob Konfiguration bereits existiert
|
||||||
|
db.get('SELECT id FROM ldap_config WHERE id = 1', (err, existing) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Prüfen der Konfiguration' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const configData = {
|
||||||
|
enabled: enabled ? 1 : 0,
|
||||||
|
url: url.trim(),
|
||||||
|
bind_dn: bind_dn ? bind_dn.trim() : null,
|
||||||
|
bind_password: bind_password ? bind_password.trim() : null,
|
||||||
|
base_dn: base_dn.trim(),
|
||||||
|
user_search_filter: user_search_filter ? user_search_filter.trim() : '(objectClass=person)',
|
||||||
|
username_attribute: username_attribute ? username_attribute.trim() : 'cn',
|
||||||
|
firstname_attribute: firstname_attribute ? firstname_attribute.trim() : 'givenName',
|
||||||
|
lastname_attribute: lastname_attribute ? lastname_attribute.trim() : 'sn',
|
||||||
|
sync_interval: parseInt(sync_interval) || 0,
|
||||||
|
updated_at: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update - Passwort nur aktualisieren wenn angegeben
|
||||||
|
if (configData.bind_password) {
|
||||||
|
db.run(
|
||||||
|
`UPDATE ldap_config SET
|
||||||
|
enabled = ?, url = ?, bind_dn = ?, bind_password = ?, base_dn = ?,
|
||||||
|
user_search_filter = ?, username_attribute = ?, firstname_attribute = ?,
|
||||||
|
lastname_attribute = ?, sync_interval = ?, updated_at = ?
|
||||||
|
WHERE id = 1`,
|
||||||
|
[
|
||||||
|
configData.enabled, configData.url, configData.bind_dn, configData.bind_password,
|
||||||
|
configData.base_dn, configData.user_search_filter, configData.username_attribute,
|
||||||
|
configData.firstname_attribute, configData.lastname_attribute, configData.sync_interval,
|
||||||
|
configData.updated_at
|
||||||
|
],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Speichern der Konfiguration' });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Passwort nicht ändern
|
||||||
|
db.run(
|
||||||
|
`UPDATE ldap_config SET
|
||||||
|
enabled = ?, url = ?, bind_dn = ?, base_dn = ?,
|
||||||
|
user_search_filter = ?, username_attribute = ?, firstname_attribute = ?,
|
||||||
|
lastname_attribute = ?, sync_interval = ?, updated_at = ?
|
||||||
|
WHERE id = 1`,
|
||||||
|
[
|
||||||
|
configData.enabled, configData.url, configData.bind_dn,
|
||||||
|
configData.base_dn, configData.user_search_filter, configData.username_attribute,
|
||||||
|
configData.firstname_attribute, configData.lastname_attribute, configData.sync_interval,
|
||||||
|
configData.updated_at
|
||||||
|
],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Speichern der Konfiguration' });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Insert
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO ldap_config (
|
||||||
|
enabled, url, bind_dn, bind_password, base_dn, user_search_filter,
|
||||||
|
username_attribute, firstname_attribute, lastname_attribute, sync_interval, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
configData.enabled, configData.url, configData.bind_dn, configData.bind_password,
|
||||||
|
configData.base_dn, configData.user_search_filter, configData.username_attribute,
|
||||||
|
configData.firstname_attribute, configData.lastname_attribute, configData.sync_interval,
|
||||||
|
configData.updated_at
|
||||||
|
],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Erstellen der Konfiguration' });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manuelle LDAP-Synchronisation starten
|
||||||
|
app.post('/admin/ldap/sync', requireAdmin, (req, res) => {
|
||||||
|
LDAPService.performSync('manual', (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({
|
||||||
|
error: err.message || 'Fehler bei der Synchronisation',
|
||||||
|
synced: result ? result.synced : 0,
|
||||||
|
errors: result ? result.errors : []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
synced: result.synced,
|
||||||
|
errors: result.errors || []
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync-Log abrufen
|
||||||
|
app.get('/admin/ldap/sync/log', requireAdmin, (req, res) => {
|
||||||
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
db.all(
|
||||||
|
'SELECT * FROM ldap_sync_log ORDER BY sync_started_at DESC LIMIT ?',
|
||||||
|
[limit],
|
||||||
|
(err, logs) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Abrufen des Logs' });
|
||||||
|
}
|
||||||
|
res.json({ logs: logs || [] });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = registerAdminLDAPRoutes;
|
||||||
154
routes/admin.js
Normal file
154
routes/admin.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// Admin Routes
|
||||||
|
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { db } = require('../database');
|
||||||
|
const { requireAdmin } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Routes registrieren
|
||||||
|
function registerAdminRoutes(app) {
|
||||||
|
// Admin-Bereich
|
||||||
|
app.get('/admin', requireAdmin, (req, res) => {
|
||||||
|
db.all('SELECT id, username, firstname, lastname, role, personalnummer, wochenstunden, urlaubstage, created_at FROM users ORDER BY created_at DESC',
|
||||||
|
(err, users) => {
|
||||||
|
// LDAP-Konfiguration und Sync-Log abrufen
|
||||||
|
db.get('SELECT * FROM ldap_config WHERE id = 1', (err, ldapConfig) => {
|
||||||
|
db.all('SELECT * FROM ldap_sync_log ORDER BY sync_started_at DESC LIMIT 10', (err, syncLogs) => {
|
||||||
|
// Parse Rollen für jeden User
|
||||||
|
const usersWithRoles = (users || []).map(u => {
|
||||||
|
let roles = [];
|
||||||
|
try {
|
||||||
|
roles = JSON.parse(u.role);
|
||||||
|
if (!Array.isArray(roles)) {
|
||||||
|
roles = [u.role];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
roles = [u.role || 'mitarbeiter'];
|
||||||
|
}
|
||||||
|
return { ...u, roles };
|
||||||
|
});
|
||||||
|
|
||||||
|
res.render('admin', {
|
||||||
|
users: usersWithRoles,
|
||||||
|
ldapConfig: ldapConfig || null,
|
||||||
|
syncLogs: syncLogs || [],
|
||||||
|
user: {
|
||||||
|
firstname: req.session.firstname,
|
||||||
|
lastname: req.session.lastname,
|
||||||
|
roles: req.session.roles || [],
|
||||||
|
currentRole: req.session.currentRole || 'admin'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Benutzer erstellen
|
||||||
|
app.post('/admin/users', requireAdmin, (req, res) => {
|
||||||
|
const { username, password, firstname, lastname, roles, personalnummer, wochenstunden, urlaubstage } = req.body;
|
||||||
|
const hashedPassword = bcrypt.hashSync(password, 10);
|
||||||
|
|
||||||
|
// Normalisiere die optionalen Felder
|
||||||
|
const normalizedPersonalnummer = personalnummer && personalnummer.trim() !== '' ? personalnummer.trim() : null;
|
||||||
|
const normalizedWochenstunden = wochenstunden && wochenstunden !== '' ? parseFloat(wochenstunden) : null;
|
||||||
|
const normalizedUrlaubstage = urlaubstage && urlaubstage !== '' ? parseFloat(urlaubstage) : null;
|
||||||
|
|
||||||
|
// Rollen verarbeiten: Erwarte Array, konvertiere zu JSON-String
|
||||||
|
let rolesArray = [];
|
||||||
|
if (Array.isArray(roles)) {
|
||||||
|
rolesArray = roles.filter(r => r && ['mitarbeiter', 'verwaltung', 'admin'].includes(r));
|
||||||
|
} else if (roles) {
|
||||||
|
// Fallback: Einzelne Rolle als Array
|
||||||
|
rolesArray = [roles];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mindestens eine Rolle erforderlich
|
||||||
|
if (rolesArray.length === 0) {
|
||||||
|
rolesArray = ['mitarbeiter']; // Standard-Rolle
|
||||||
|
}
|
||||||
|
|
||||||
|
const rolesJson = JSON.stringify(rolesArray);
|
||||||
|
|
||||||
|
db.run('INSERT INTO users (username, password, firstname, lastname, role, personalnummer, wochenstunden, urlaubstage) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
|
[username, hashedPassword, firstname, lastname, rolesJson, normalizedPersonalnummer, normalizedWochenstunden, normalizedUrlaubstage],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(400).json({ error: 'Benutzername existiert bereits' });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Benutzer löschen
|
||||||
|
app.delete('/admin/users/:id', requireAdmin, (req, res) => {
|
||||||
|
const userId = req.params.id;
|
||||||
|
|
||||||
|
// Admin darf sich nicht selbst löschen
|
||||||
|
if (userId == req.session.userId) {
|
||||||
|
return res.status(400).json({ error: 'Sie können sich nicht selbst löschen' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run('DELETE FROM users WHERE id = ?', [userId], (err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Löschen' });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Benutzer aktualisieren (Personalnummer, Wochenstunden, Urlaubstage, Rollen)
|
||||||
|
app.put('/admin/users/:id', requireAdmin, (req, res) => {
|
||||||
|
const userId = req.params.id;
|
||||||
|
const { personalnummer, wochenstunden, urlaubstage, roles } = req.body;
|
||||||
|
|
||||||
|
// Rollen verarbeiten falls vorhanden
|
||||||
|
let rolesJson = null;
|
||||||
|
if (roles !== undefined) {
|
||||||
|
let rolesArray = [];
|
||||||
|
if (Array.isArray(roles)) {
|
||||||
|
rolesArray = roles.filter(r => r && ['mitarbeiter', 'verwaltung', 'admin'].includes(r));
|
||||||
|
}
|
||||||
|
// Mindestens eine Rolle erforderlich
|
||||||
|
if (rolesArray.length === 0) {
|
||||||
|
return res.status(400).json({ error: 'Mindestens eine Rolle ist erforderlich' });
|
||||||
|
}
|
||||||
|
rolesJson = JSON.stringify(rolesArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQL-Query dynamisch zusammenstellen
|
||||||
|
if (rolesJson !== null) {
|
||||||
|
// Aktualisiere auch Rollen
|
||||||
|
db.run('UPDATE users SET personalnummer = ?, wochenstunden = ?, urlaubstage = ?, role = ? WHERE id = ?',
|
||||||
|
[
|
||||||
|
personalnummer || null,
|
||||||
|
wochenstunden ? parseFloat(wochenstunden) : null,
|
||||||
|
urlaubstage ? parseFloat(urlaubstage) : null,
|
||||||
|
rolesJson,
|
||||||
|
userId
|
||||||
|
],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Aktualisieren' });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Nur andere Felder aktualisieren
|
||||||
|
db.run('UPDATE users SET personalnummer = ?, wochenstunden = ?, urlaubstage = ? WHERE id = ?',
|
||||||
|
[
|
||||||
|
personalnummer || null,
|
||||||
|
wochenstunden ? parseFloat(wochenstunden) : null,
|
||||||
|
urlaubstage ? parseFloat(urlaubstage) : null,
|
||||||
|
userId
|
||||||
|
],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Aktualisieren' });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = registerAdminRoutes;
|
||||||
129
routes/auth.js
Normal file
129
routes/auth.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
// Authentifizierungs-Routes
|
||||||
|
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { db } = require('../database');
|
||||||
|
const LDAPService = require('../ldap-service');
|
||||||
|
const { getDefaultRole } = require('../helpers/utils');
|
||||||
|
|
||||||
|
// Helper-Funktion für erfolgreiche Anmeldung
|
||||||
|
function handleSuccessfulLogin(req, res, user, rememberMe = false) {
|
||||||
|
// Rollen als JSON-Array parsen
|
||||||
|
let roles = [];
|
||||||
|
try {
|
||||||
|
roles = JSON.parse(user.role);
|
||||||
|
if (!Array.isArray(roles)) {
|
||||||
|
// Fallback: Falls kein Array, erstelle Array mit vorhandener Rolle
|
||||||
|
roles = [user.role];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Fallback: Falls kein JSON, erstelle Array mit vorhandener Rolle
|
||||||
|
roles = [user.role || 'mitarbeiter'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard-Rolle bestimmen: Immer "mitarbeiter" wenn vorhanden, sonst höchste Priorität
|
||||||
|
let defaultRole;
|
||||||
|
if (roles.includes('mitarbeiter')) {
|
||||||
|
defaultRole = 'mitarbeiter';
|
||||||
|
} else {
|
||||||
|
defaultRole = getDefaultRole(roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.userId = user.id;
|
||||||
|
req.session.username = user.username;
|
||||||
|
req.session.roles = roles;
|
||||||
|
req.session.currentRole = defaultRole;
|
||||||
|
req.session.firstname = user.firstname;
|
||||||
|
req.session.lastname = user.lastname;
|
||||||
|
|
||||||
|
// Session-Gültigkeit setzen: 30 Tage wenn "Angemeldet bleiben" aktiviert, sonst 24 Stunden
|
||||||
|
if (rememberMe) {
|
||||||
|
req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 Tage
|
||||||
|
} else {
|
||||||
|
req.session.cookie.maxAge = 24 * 60 * 60 * 1000; // 24 Stunden
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect: Immer zu Dashboard wenn Mitarbeiter-Rolle vorhanden, sonst basierend auf Standard-Rolle
|
||||||
|
if (roles.includes('mitarbeiter')) {
|
||||||
|
res.redirect('/dashboard');
|
||||||
|
} else if (defaultRole === 'admin') {
|
||||||
|
res.redirect('/admin');
|
||||||
|
} else if (defaultRole === 'verwaltung') {
|
||||||
|
res.redirect('/verwaltung');
|
||||||
|
} else {
|
||||||
|
res.redirect('/dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes registrieren
|
||||||
|
function registerAuthRoutes(app) {
|
||||||
|
// Login-Seite
|
||||||
|
app.get('/login', (req, res) => {
|
||||||
|
res.render('login', { error: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login-Verarbeitung
|
||||||
|
app.post('/login', (req, res) => {
|
||||||
|
const { username, password, remember_me } = req.body;
|
||||||
|
const rememberMe = remember_me === 'on' || remember_me === true;
|
||||||
|
|
||||||
|
// Prüfe ob LDAP aktiviert ist
|
||||||
|
LDAPService.getConfig((err, ldapConfig) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Fehler beim Abrufen der LDAP-Konfiguration:', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLDAPEnabled = ldapConfig && ldapConfig.enabled === 1;
|
||||||
|
|
||||||
|
// Wenn LDAP aktiviert ist, authentifiziere gegen LDAP
|
||||||
|
if (isLDAPEnabled) {
|
||||||
|
LDAPService.authenticate(username, password, (authErr, authSuccess) => {
|
||||||
|
if (authErr || !authSuccess) {
|
||||||
|
// LDAP-Authentifizierung fehlgeschlagen - prüfe lokale Datenbank als Fallback
|
||||||
|
db.get('SELECT * FROM users WHERE username = ?', [username], (err, user) => {
|
||||||
|
if (err || !user) {
|
||||||
|
return res.render('login', { error: 'Ungültiger Benutzername oder Passwort' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Versuche lokale Authentifizierung
|
||||||
|
if (bcrypt.compareSync(password, user.password)) {
|
||||||
|
handleSuccessfulLogin(req, res, user, rememberMe);
|
||||||
|
} else {
|
||||||
|
res.render('login', { error: 'Ungültiger Benutzername oder Passwort' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// LDAP-Authentifizierung erfolgreich - hole Benutzer aus Datenbank
|
||||||
|
db.get('SELECT * FROM users WHERE username = ?', [username], (err, user) => {
|
||||||
|
if (err || !user) {
|
||||||
|
return res.render('login', { error: 'Benutzer nicht in der Datenbank gefunden. Bitte führen Sie eine LDAP-Synchronisation durch.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSuccessfulLogin(req, res, user, rememberMe);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// LDAP nicht aktiviert - verwende lokale Authentifizierung
|
||||||
|
db.get('SELECT * FROM users WHERE username = ?', [username], (err, user) => {
|
||||||
|
if (err || !user) {
|
||||||
|
return res.render('login', { error: 'Ungültiger Benutzername oder Passwort' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bcrypt.compareSync(password, user.password)) {
|
||||||
|
handleSuccessfulLogin(req, res, user, rememberMe);
|
||||||
|
} else {
|
||||||
|
res.render('login', { error: 'Ungültiger Benutzername oder Passwort' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout
|
||||||
|
app.get('/logout', (req, res) => {
|
||||||
|
req.session.destroy();
|
||||||
|
res.redirect('/login');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = registerAuthRoutes;
|
||||||
35
routes/dashboard.js
Normal file
35
routes/dashboard.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Dashboard-Route
|
||||||
|
|
||||||
|
const { hasRole } = require('../helpers/utils');
|
||||||
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Routes registrieren
|
||||||
|
function registerDashboardRoutes(app) {
|
||||||
|
// Dashboard für Mitarbeiter
|
||||||
|
app.get('/dashboard', requireAuth, (req, res) => {
|
||||||
|
// Prüfe ob User Mitarbeiter-Rolle hat
|
||||||
|
if (!hasRole(req, 'mitarbeiter')) {
|
||||||
|
// Wenn User keine Mitarbeiter-Rolle hat, aber andere Rollen, redirecte entsprechend
|
||||||
|
if (hasRole(req, 'admin')) {
|
||||||
|
return res.redirect('/admin');
|
||||||
|
}
|
||||||
|
if (hasRole(req, 'verwaltung')) {
|
||||||
|
return res.redirect('/verwaltung');
|
||||||
|
}
|
||||||
|
return res.status(403).send('Zugriff verweigert');
|
||||||
|
}
|
||||||
|
|
||||||
|
res.render('dashboard', {
|
||||||
|
user: {
|
||||||
|
id: req.session.userId,
|
||||||
|
firstname: req.session.firstname,
|
||||||
|
lastname: req.session.lastname,
|
||||||
|
username: req.session.username,
|
||||||
|
roles: req.session.roles || [],
|
||||||
|
currentRole: req.session.currentRole || 'mitarbeiter'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = registerDashboardRoutes;
|
||||||
338
routes/timesheet.js
Normal file
338
routes/timesheet.js
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
// Timesheet API Routes
|
||||||
|
|
||||||
|
const { db } = require('../database');
|
||||||
|
const { requireAuth, requireVerwaltung } = require('../middleware/auth');
|
||||||
|
const { generatePDF } = require('../services/pdf-service');
|
||||||
|
|
||||||
|
// Routes registrieren
|
||||||
|
function registerTimesheetRoutes(app) {
|
||||||
|
// API: Stundenerfassung speichern
|
||||||
|
app.post('/api/timesheet/save', requireAuth, (req, res) => {
|
||||||
|
const {
|
||||||
|
date, start_time, end_time, break_minutes, notes,
|
||||||
|
activity1_desc, activity1_hours, activity1_project_number,
|
||||||
|
activity2_desc, activity2_hours, activity2_project_number,
|
||||||
|
activity3_desc, activity3_hours, activity3_project_number,
|
||||||
|
activity4_desc, activity4_hours, activity4_project_number,
|
||||||
|
activity5_desc, activity5_hours, activity5_project_number,
|
||||||
|
overtime_taken_hours, vacation_type, sick_status
|
||||||
|
} = req.body;
|
||||||
|
const userId = req.session.userId;
|
||||||
|
|
||||||
|
// Normalisiere end_time: Leere Strings werden zu null
|
||||||
|
const normalizedEndTime = (end_time && typeof end_time === 'string' && end_time.trim() !== '') ? end_time.trim() : (end_time || null);
|
||||||
|
const normalizedStartTime = (start_time && typeof start_time === 'string' && start_time.trim() !== '') ? start_time.trim() : (start_time || null);
|
||||||
|
|
||||||
|
// Normalisiere sick_status: Boolean oder 1/0 zu Boolean
|
||||||
|
const isSick = sick_status === true || sick_status === 1 || sick_status === 'true' || sick_status === '1';
|
||||||
|
|
||||||
|
// User-Daten laden (für Überstunden-Berechnung)
|
||||||
|
db.get('SELECT wochenstunden FROM users WHERE id = ?', [userId], (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Fehler beim Laden der User-Daten:', err);
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Laden der User-Daten' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const wochenstunden = user?.wochenstunden || 0;
|
||||||
|
const overtimeValue = overtime_taken_hours ? parseFloat(overtime_taken_hours) : 0;
|
||||||
|
const fullDayHours = wochenstunden > 0 ? wochenstunden / 5 : 0;
|
||||||
|
|
||||||
|
// Überstunden-Logik: Prüfe ob ganzer Tag oder weniger
|
||||||
|
let isFullDayOvertime = false;
|
||||||
|
if (overtimeValue > 0 && fullDayHours > 0 && Math.abs(overtimeValue - fullDayHours) < 0.01) {
|
||||||
|
isFullDayOvertime = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gesamtstunden berechnen (aus Start- und Endzeit, nicht aus Tätigkeiten)
|
||||||
|
// Wenn ganzer Tag Urlaub oder Krank, dann zählt dieser als 8 Stunden normale Arbeitszeit
|
||||||
|
let total_hours = 0;
|
||||||
|
let finalActivity1Desc = activity1_desc;
|
||||||
|
let finalActivity1Hours = parseFloat(activity1_hours) || 0;
|
||||||
|
let finalActivity2Desc = activity2_desc;
|
||||||
|
let finalActivity3Desc = activity3_desc;
|
||||||
|
let finalActivity4Desc = activity4_desc;
|
||||||
|
let finalActivity5Desc = activity5_desc;
|
||||||
|
let finalStartTime = normalizedStartTime;
|
||||||
|
let finalEndTime = normalizedEndTime;
|
||||||
|
|
||||||
|
// Überstunden-Logik: Bei vollem Tag Überstunden
|
||||||
|
if (isFullDayOvertime) {
|
||||||
|
total_hours = 0;
|
||||||
|
finalStartTime = null;
|
||||||
|
finalEndTime = null;
|
||||||
|
// Keine Tätigkeit setzen - Überstunden werden über overtime_taken_hours in der PDF angezeigt
|
||||||
|
} else if (vacation_type === 'full') {
|
||||||
|
total_hours = 8; // Ganzer Tag Urlaub = 8 Stunden normale Arbeitszeit
|
||||||
|
} else if (isSick) {
|
||||||
|
total_hours = 8; // Krank = 8 Stunden normale Arbeitszeit
|
||||||
|
finalActivity1Desc = 'Krank';
|
||||||
|
finalActivity1Hours = 8;
|
||||||
|
} else if (normalizedStartTime && normalizedEndTime) {
|
||||||
|
const start = new Date(`2000-01-01T${normalizedStartTime}`);
|
||||||
|
const end = new Date(`2000-01-01T${normalizedEndTime}`);
|
||||||
|
const diffMs = end - start;
|
||||||
|
total_hours = (diffMs / (1000 * 60 * 60)) - (break_minutes / 60);
|
||||||
|
// Bei halbem Tag Urlaub: total_hours bleibt die tatsächlich gearbeiteten Stunden
|
||||||
|
// Die 4 Stunden Urlaub werden nur in der Überstunden-Berechnung hinzugezählt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Überstunden werden nicht mehr als Tätigkeit hinzugefügt
|
||||||
|
// Sie werden über overtime_taken_hours in der PDF angezeigt
|
||||||
|
|
||||||
|
// Prüfen ob Eintrag existiert - verwende den neuesten Eintrag falls mehrere existieren
|
||||||
|
db.get('SELECT id FROM timesheet_entries WHERE user_id = ? AND date = ? ORDER BY updated_at DESC, id DESC LIMIT 1',
|
||||||
|
[userId, date], (err, row) => {
|
||||||
|
if (row) {
|
||||||
|
// Update
|
||||||
|
db.run(`UPDATE timesheet_entries
|
||||||
|
SET start_time = ?, end_time = ?, break_minutes = ?, total_hours = ?, notes = ?,
|
||||||
|
activity1_desc = ?, activity1_hours = ?, activity1_project_number = ?,
|
||||||
|
activity2_desc = ?, activity2_hours = ?, activity2_project_number = ?,
|
||||||
|
activity3_desc = ?, activity3_hours = ?, activity3_project_number = ?,
|
||||||
|
activity4_desc = ?, activity4_hours = ?, activity4_project_number = ?,
|
||||||
|
activity5_desc = ?, activity5_hours = ?, activity5_project_number = ?,
|
||||||
|
overtime_taken_hours = ?, vacation_type = ?, sick_status = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?`,
|
||||||
|
[
|
||||||
|
finalStartTime, finalEndTime, break_minutes, total_hours, notes,
|
||||||
|
finalActivity1Desc || null, finalActivity1Hours, activity1_project_number || null,
|
||||||
|
finalActivity2Desc || null, parseFloat(activity2_hours) || 0, activity2_project_number || null,
|
||||||
|
finalActivity3Desc || null, parseFloat(activity3_hours) || 0, activity3_project_number || null,
|
||||||
|
finalActivity4Desc || null, parseFloat(activity4_hours) || 0, activity4_project_number || null,
|
||||||
|
finalActivity5Desc || null, parseFloat(activity5_hours) || 0, activity5_project_number || null,
|
||||||
|
overtime_taken_hours ? parseFloat(overtime_taken_hours) : null,
|
||||||
|
vacation_type || null,
|
||||||
|
isSick ? 1 : 0,
|
||||||
|
row.id
|
||||||
|
],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Fehler beim Update:', err);
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Speichern: ' + err.message });
|
||||||
|
}
|
||||||
|
res.json({ success: true, total_hours });
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Insert
|
||||||
|
db.run(`INSERT INTO timesheet_entries
|
||||||
|
(user_id, date, start_time, end_time, break_minutes, total_hours, notes,
|
||||||
|
activity1_desc, activity1_hours, activity1_project_number,
|
||||||
|
activity2_desc, activity2_hours, activity2_project_number,
|
||||||
|
activity3_desc, activity3_hours, activity3_project_number,
|
||||||
|
activity4_desc, activity4_hours, activity4_project_number,
|
||||||
|
activity5_desc, activity5_hours, activity5_project_number,
|
||||||
|
overtime_taken_hours, vacation_type, sick_status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
userId, date, finalStartTime, finalEndTime, break_minutes, total_hours, notes,
|
||||||
|
finalActivity1Desc || null, finalActivity1Hours, activity1_project_number || null,
|
||||||
|
finalActivity2Desc || null, parseFloat(activity2_hours) || 0, activity2_project_number || null,
|
||||||
|
finalActivity3Desc || null, parseFloat(activity3_hours) || 0, activity3_project_number || null,
|
||||||
|
finalActivity4Desc || null, parseFloat(activity4_hours) || 0, activity4_project_number || null,
|
||||||
|
finalActivity5Desc || null, parseFloat(activity5_hours) || 0, activity5_project_number || null,
|
||||||
|
overtime_taken_hours ? parseFloat(overtime_taken_hours) : null,
|
||||||
|
vacation_type || null,
|
||||||
|
isSick ? 1 : 0
|
||||||
|
],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Fehler beim Insert:', err);
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Speichern: ' + err.message });
|
||||||
|
}
|
||||||
|
res.json({ success: true, total_hours });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Stundenerfassung für Woche laden
|
||||||
|
app.get('/api/timesheet/week/:weekStart', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const weekStart = req.params.weekStart;
|
||||||
|
|
||||||
|
// Berechne Wochenende
|
||||||
|
const startDate = new Date(weekStart);
|
||||||
|
const endDate = new Date(startDate);
|
||||||
|
endDate.setDate(endDate.getDate() + 6);
|
||||||
|
const weekEnd = endDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Prüfe ob die Woche bereits eingereicht wurde (aber ermögliche Bearbeitung)
|
||||||
|
db.get(`SELECT id, version FROM weekly_timesheets
|
||||||
|
WHERE user_id = ? AND week_start = ? AND week_end = ?
|
||||||
|
ORDER BY version DESC LIMIT 1`,
|
||||||
|
[userId, weekStart, weekEnd],
|
||||||
|
(err, weeklySheet) => {
|
||||||
|
const hasSubmittedVersion = !!weeklySheet;
|
||||||
|
const latestVersion = weeklySheet ? weeklySheet.version : 0;
|
||||||
|
|
||||||
|
// Lade alle Einträge für die Woche
|
||||||
|
db.all(`SELECT * FROM timesheet_entries
|
||||||
|
WHERE user_id = ? AND date >= ? AND date <= ?
|
||||||
|
ORDER BY date`,
|
||||||
|
[userId, weekStart, weekEnd],
|
||||||
|
(err, entries) => {
|
||||||
|
// Füge Status-Info hinzu (Bearbeitung ist immer möglich)
|
||||||
|
const entriesWithStatus = (entries || []).map(entry => ({
|
||||||
|
...entry,
|
||||||
|
week_submitted: false, // Immer false, damit Bearbeitung möglich ist
|
||||||
|
latest_version: latestVersion,
|
||||||
|
has_existing_version: latestVersion > 0
|
||||||
|
}));
|
||||||
|
res.json(entriesWithStatus);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Woche abschicken
|
||||||
|
app.post('/api/timesheet/submit', requireAuth, (req, res) => {
|
||||||
|
const { week_start, week_end, version_reason } = req.body;
|
||||||
|
const userId = req.session.userId;
|
||||||
|
|
||||||
|
// Validierung: Prüfen ob alle 7 Tage der Woche ausgefüllt sind
|
||||||
|
db.all(`SELECT id, date, start_time, end_time, vacation_type, sick_status, updated_at FROM timesheet_entries
|
||||||
|
WHERE user_id = ? AND date >= ? AND date <= ?
|
||||||
|
ORDER BY date, updated_at DESC, id DESC`,
|
||||||
|
[userId, week_start, week_end],
|
||||||
|
(err, entries) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Prüfen der Daten' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erstelle Set mit vorhandenen Daten
|
||||||
|
// WICHTIG: Wenn mehrere Einträge für denselben Tag existieren, nimm den neuesten
|
||||||
|
const entriesByDate = {};
|
||||||
|
entries.forEach(entry => {
|
||||||
|
const existing = entriesByDate[entry.date];
|
||||||
|
// Wenn noch kein Eintrag existiert oder dieser neuer ist, verwende ihn
|
||||||
|
if (!existing) {
|
||||||
|
entriesByDate[entry.date] = entry;
|
||||||
|
} else {
|
||||||
|
// Vergleiche updated_at (falls vorhanden) oder id (höhere ID = neuer)
|
||||||
|
const existingTime = existing.updated_at ? new Date(existing.updated_at).getTime() : 0;
|
||||||
|
const currentTime = entry.updated_at ? new Date(entry.updated_at).getTime() : 0;
|
||||||
|
if (currentTime > existingTime || (currentTime === existingTime && entry.id > existing.id)) {
|
||||||
|
entriesByDate[entry.date] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prüfe nur Werktage (Montag-Freitag, erste 5 Tage)
|
||||||
|
// Samstag und Sonntag sind optional
|
||||||
|
// Bei ganztägigem Urlaub (vacation_type = 'full') ist der Tag als ausgefüllt zu betrachten
|
||||||
|
// week_start ist bereits im Format YYYY-MM-DD
|
||||||
|
const startDateParts = week_start.split('-');
|
||||||
|
const startYear = parseInt(startDateParts[0]);
|
||||||
|
const startMonth = parseInt(startDateParts[1]) - 1; // Monat ist 0-basiert
|
||||||
|
const startDay = parseInt(startDateParts[2]);
|
||||||
|
|
||||||
|
let missingDays = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
// Datum direkt berechnen ohne Zeitzonenprobleme
|
||||||
|
const date = new Date(startYear, startMonth, startDay + i);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const dateStr = `${year}-${month}-${day}`;
|
||||||
|
const entry = entriesByDate[dateStr];
|
||||||
|
|
||||||
|
// Wenn ganztägiger Urlaub oder Krank, dann ist der Tag als ausgefüllt zu betrachten
|
||||||
|
const isSick = entry && (entry.sick_status === 1 || entry.sick_status === true);
|
||||||
|
if (entry && (entry.vacation_type === 'full' || isSick)) {
|
||||||
|
continue; // Tag ist ausgefüllt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bei halbem Tag Urlaub oder keinem Urlaub müssen Start- und Endzeit vorhanden sein
|
||||||
|
// start_time und end_time könnten null, undefined oder leer strings sein
|
||||||
|
const hasStartTime = entry && entry.start_time && entry.start_time.toString().trim() !== '';
|
||||||
|
const hasEndTime = entry && entry.end_time && entry.end_time.toString().trim() !== '';
|
||||||
|
|
||||||
|
if (!entry || !hasStartTime || !hasEndTime) {
|
||||||
|
missingDays.push(dateStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingDays.length > 0) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Nicht alle Werktage (Montag bis Freitag) sind ausgefüllt. Fehlende Tage: ${missingDays.join(', ')}. Bitte füllen Sie alle Werktage mit Start- und Endzeit aus. Wochenende ist optional.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle Tage ausgefüllt - Woche abschicken (immer neue Version erstellen)
|
||||||
|
// Prüfe welche Version die letzte ist
|
||||||
|
db.get(`SELECT MAX(version) as max_version FROM weekly_timesheets
|
||||||
|
WHERE user_id = ? AND week_start = ? AND week_end = ?`,
|
||||||
|
[userId, week_start, week_end],
|
||||||
|
(err, result) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Fehler beim Prüfen der Version' });
|
||||||
|
|
||||||
|
const maxVersion = result && result.max_version ? result.max_version : 0;
|
||||||
|
const newVersion = maxVersion + 1;
|
||||||
|
|
||||||
|
// Wenn bereits eine Version existiert, ist version_reason erforderlich
|
||||||
|
if (maxVersion > 0 && (!version_reason || version_reason.trim() === '')) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Bitte geben Sie einen Grund für die neue Version an.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neue Version erstellen (nicht überschreiben)
|
||||||
|
db.run(`INSERT INTO weekly_timesheets (user_id, week_start, week_end, version, status, version_reason)
|
||||||
|
VALUES (?, ?, ?, ?, 'eingereicht', ?)`,
|
||||||
|
[userId, week_start, week_end, newVersion, version_reason ? version_reason.trim() : null],
|
||||||
|
(err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Fehler beim Abschicken' });
|
||||||
|
|
||||||
|
// Status der Einträge aktualisieren (optional - für Nachverfolgung)
|
||||||
|
db.run(`UPDATE timesheet_entries
|
||||||
|
SET status = 'eingereicht'
|
||||||
|
WHERE user_id = ? AND date >= ? AND date <= ?`,
|
||||||
|
[userId, week_start, week_end],
|
||||||
|
(err) => {
|
||||||
|
if (err) return res.status(500).json({ error: 'Fehler beim Aktualisieren des Status' });
|
||||||
|
res.json({ success: true, version: newVersion });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: PDF Download-Info abrufen
|
||||||
|
app.get('/api/timesheet/download-info/:id', requireVerwaltung, (req, res) => {
|
||||||
|
const timesheetId = req.params.id;
|
||||||
|
|
||||||
|
db.get(`SELECT wt.pdf_downloaded_at,
|
||||||
|
dl.firstname as downloaded_by_firstname,
|
||||||
|
dl.lastname as downloaded_by_lastname
|
||||||
|
FROM weekly_timesheets wt
|
||||||
|
LEFT JOIN users dl ON wt.pdf_downloaded_by = dl.id
|
||||||
|
WHERE wt.id = ?`, [timesheetId], (err, result) => {
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
console.error('Fehler beim Abrufen der Download-Info:', err);
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Abrufen der Informationen' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({ error: 'Stundenzettel nicht gefunden' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
downloaded: !!result.pdf_downloaded_at,
|
||||||
|
downloaded_at: result.pdf_downloaded_at,
|
||||||
|
downloaded_by_firstname: result.downloaded_by_firstname,
|
||||||
|
downloaded_by_lastname: result.downloaded_by_lastname
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: PDF generieren
|
||||||
|
app.get('/api/timesheet/pdf/:id', requireVerwaltung, (req, res) => {
|
||||||
|
const timesheetId = req.params.id;
|
||||||
|
generatePDF(timesheetId, req, res);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = registerTimesheetRoutes;
|
||||||
323
routes/user.js
Normal file
323
routes/user.js
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
// User API Routes
|
||||||
|
|
||||||
|
const { db } = require('../database');
|
||||||
|
const { hasRole, getCurrentDate } = require('../helpers/utils');
|
||||||
|
const { requireAuth } = require('../middleware/auth');
|
||||||
|
|
||||||
|
// Routes registrieren
|
||||||
|
function registerUserRoutes(app) {
|
||||||
|
// API: Letzte bearbeitete Woche abrufen
|
||||||
|
app.get('/api/user/last-week', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
|
||||||
|
db.get('SELECT last_week_start FROM users WHERE id = ?', [userId], (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Abrufen der letzten Woche' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ last_week_start: user?.last_week_start || null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Letzte bearbeitete Woche speichern
|
||||||
|
app.post('/api/user/last-week', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const { week_start } = req.body;
|
||||||
|
|
||||||
|
if (!week_start) {
|
||||||
|
return res.status(400).json({ error: 'week_start ist erforderlich' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run('UPDATE users SET last_week_start = ? WHERE id = ?',
|
||||||
|
[week_start, userId],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Speichern der letzten Woche' });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: User-Daten abrufen (Wochenstunden)
|
||||||
|
app.get('/api/user/data', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
|
||||||
|
db.get('SELECT wochenstunden FROM users WHERE id = ?', [userId], (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Abrufen der User-Daten' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ wochenstunden: user?.wochenstunden || 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Ping-IP abrufen
|
||||||
|
app.get('/api/user/ping-ip', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
|
||||||
|
db.get('SELECT ping_ip FROM users WHERE id = ?', [userId], (err, user) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Abrufen der IP-Adresse' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ ping_ip: user?.ping_ip || null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Ping-IP speichern
|
||||||
|
app.post('/api/user/ping-ip', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
const { ping_ip } = req.body;
|
||||||
|
|
||||||
|
// Validierung: IPv4 Format (einfache Prüfung)
|
||||||
|
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
|
||||||
|
if (ping_ip && ping_ip.trim() !== '' && !ipv4Regex.test(ping_ip.trim())) {
|
||||||
|
return res.status(400).json({ error: 'Ungültige IP-Adresse. Bitte geben Sie eine gültige IPv4-Adresse ein.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalisiere: Leere Strings werden zu null
|
||||||
|
const normalizedPingIp = (ping_ip && ping_ip.trim() !== '') ? ping_ip.trim() : null;
|
||||||
|
|
||||||
|
db.run('UPDATE users SET ping_ip = ? WHERE id = ?', [normalizedPingIp, userId], (err) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Speichern der IP-Adresse' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn IP entfernt wurde, lösche auch den Ping-Status für heute
|
||||||
|
if (!normalizedPingIp) {
|
||||||
|
const currentDate = getCurrentDate();
|
||||||
|
db.run('DELETE FROM ping_status WHERE user_id = ? AND date = ?', [userId, currentDate], (err) => {
|
||||||
|
// Fehler ignorieren
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, ping_ip: normalizedPingIp });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Rollenwechsel
|
||||||
|
app.post('/api/user/switch-role', requireAuth, (req, res) => {
|
||||||
|
const { role } = req.body;
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
return res.status(400).json({ error: 'Rolle ist erforderlich' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob User diese Rolle hat
|
||||||
|
if (!hasRole(req, role)) {
|
||||||
|
return res.status(403).json({ error: 'Sie haben keine Berechtigung für diese Rolle' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validiere dass die Rolle eine gültige Rolle ist
|
||||||
|
const validRoles = ['mitarbeiter', 'verwaltung', 'admin'];
|
||||||
|
if (!validRoles.includes(role)) {
|
||||||
|
return res.status(400).json({ error: 'Ungültige Rolle' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setze aktuelle Rolle
|
||||||
|
req.session.currentRole = role;
|
||||||
|
|
||||||
|
res.json({ success: true, currentRole: role });
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Gesamtstatistiken für Mitarbeiter (Überstunden und Urlaubstage)
|
||||||
|
app.get('/api/user/stats', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
|
||||||
|
// User-Daten abrufen
|
||||||
|
db.get('SELECT wochenstunden, urlaubstage, overtime_offset_hours FROM users WHERE id = ?', [userId], (err, user) => {
|
||||||
|
if (err || !user) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Abrufen der User-Daten' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const wochenstunden = user.wochenstunden || 0;
|
||||||
|
const urlaubstage = user.urlaubstage || 0;
|
||||||
|
const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0;
|
||||||
|
|
||||||
|
// Alle eingereichten Wochen abrufen
|
||||||
|
db.all(`SELECT DISTINCT week_start, week_end
|
||||||
|
FROM weekly_timesheets
|
||||||
|
WHERE user_id = ? AND status = 'eingereicht'
|
||||||
|
ORDER BY week_start`,
|
||||||
|
[userId],
|
||||||
|
(err, weeks) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Abrufen der Wochen' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn keine Wochen vorhanden
|
||||||
|
if (!weeks || weeks.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
currentOvertime: overtimeOffsetHours,
|
||||||
|
remainingVacation: urlaubstage,
|
||||||
|
totalOvertimeHours: 0,
|
||||||
|
totalOvertimeTaken: 0,
|
||||||
|
totalVacationDays: 0,
|
||||||
|
urlaubstage: urlaubstage,
|
||||||
|
overtimeOffsetHours: overtimeOffsetHours
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalOvertimeHours = 0;
|
||||||
|
let totalOvertimeTaken = 0;
|
||||||
|
let totalVacationDays = 0;
|
||||||
|
let processedWeeks = 0;
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
|
// Für jede Woche die Statistiken berechnen
|
||||||
|
weeks.forEach((week) => {
|
||||||
|
// Einträge für diese Woche abrufen (nur neueste pro Tag)
|
||||||
|
db.all(`SELECT id, date, total_hours, overtime_taken_hours, vacation_type, sick_status, start_time, end_time, updated_at
|
||||||
|
FROM timesheet_entries
|
||||||
|
WHERE user_id = ? AND date >= ? AND date <= ?
|
||||||
|
ORDER BY date, updated_at DESC, id DESC`,
|
||||||
|
[userId, week.week_start, week.week_end],
|
||||||
|
(err, allEntries) => {
|
||||||
|
if (hasError) return; // Wenn bereits ein Fehler aufgetreten ist, ignoriere weitere Ergebnisse
|
||||||
|
|
||||||
|
if (err) {
|
||||||
|
hasError = true;
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Abrufen der Einträge' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtere auf neuesten Eintrag pro Tag
|
||||||
|
const entriesByDate = {};
|
||||||
|
(allEntries || []).forEach(entry => {
|
||||||
|
const existing = entriesByDate[entry.date];
|
||||||
|
if (!existing) {
|
||||||
|
entriesByDate[entry.date] = entry;
|
||||||
|
} else {
|
||||||
|
// Vergleiche updated_at (falls vorhanden) oder id (höhere ID = neuer)
|
||||||
|
const existingTime = existing.updated_at ? new Date(existing.updated_at).getTime() : 0;
|
||||||
|
const currentTime = entry.updated_at ? new Date(entry.updated_at).getTime() : 0;
|
||||||
|
if (currentTime > existingTime || (currentTime === existingTime && entry.id > existing.id)) {
|
||||||
|
entriesByDate[entry.date] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Konvertiere zurück zu Array
|
||||||
|
const entries = Object.values(entriesByDate);
|
||||||
|
|
||||||
|
// Prüfe ob Woche vollständig ausgefüllt ist (alle 5 Werktage)
|
||||||
|
|
||||||
|
// Prüfe alle 5 Werktage (Montag-Freitag)
|
||||||
|
const startDate = new Date(week.week_start);
|
||||||
|
const endDate = new Date(week.week_end);
|
||||||
|
let workdays = 0;
|
||||||
|
let filledWorkdays = 0;
|
||||||
|
|
||||||
|
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||||
|
const day = d.getDay();
|
||||||
|
if (day >= 1 && day <= 5) { // Montag bis Freitag
|
||||||
|
workdays++;
|
||||||
|
const dateStr = d.toISOString().split('T')[0];
|
||||||
|
const entry = entriesByDate[dateStr];
|
||||||
|
|
||||||
|
// Tag gilt als ausgefüllt wenn:
|
||||||
|
// - Ganzer Tag Urlaub (vacation_type = 'full')
|
||||||
|
// - Krank (sick_status = 1)
|
||||||
|
// - ODER Start- und End-Zeit vorhanden sind
|
||||||
|
if (entry) {
|
||||||
|
const isFullDayVacation = entry.vacation_type === 'full';
|
||||||
|
const isSick = entry.sick_status === 1 || entry.sick_status === true;
|
||||||
|
const hasStartAndEnd = entry.start_time && entry.end_time &&
|
||||||
|
entry.start_time.toString().trim() !== '' &&
|
||||||
|
entry.end_time.toString().trim() !== '';
|
||||||
|
|
||||||
|
if (isFullDayVacation || isSick || hasStartAndEnd) {
|
||||||
|
filledWorkdays++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nur berechnen wenn alle Werktage ausgefüllt sind
|
||||||
|
if (filledWorkdays < workdays) {
|
||||||
|
// Woche nicht vollständig - überspringe diese Woche
|
||||||
|
processedWeeks++;
|
||||||
|
if (processedWeeks === weeks.length && !hasError) {
|
||||||
|
const currentOvertime = (totalOvertimeHours - totalOvertimeTaken) + overtimeOffsetHours;
|
||||||
|
const remainingVacation = urlaubstage - totalVacationDays;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
currentOvertime: currentOvertime,
|
||||||
|
remainingVacation: remainingVacation,
|
||||||
|
totalOvertimeHours: totalOvertimeHours,
|
||||||
|
totalOvertimeTaken: totalOvertimeTaken,
|
||||||
|
totalVacationDays: totalVacationDays,
|
||||||
|
urlaubstage: urlaubstage,
|
||||||
|
overtimeOffsetHours: overtimeOffsetHours
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return; // Überspringe diese Woche
|
||||||
|
}
|
||||||
|
|
||||||
|
// Berechnungen für diese Woche (nur wenn vollständig ausgefüllt)
|
||||||
|
let weekTotalHours = 0;
|
||||||
|
let weekOvertimeTaken = 0;
|
||||||
|
let weekVacationDays = 0;
|
||||||
|
let weekVacationHours = 0;
|
||||||
|
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.overtime_taken_hours) {
|
||||||
|
weekOvertimeTaken += entry.overtime_taken_hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Urlaub hat Priorität - wenn Urlaub, zähle nur Urlaubsstunden, nicht zusätzlich Arbeitsstunden
|
||||||
|
if (entry.vacation_type === 'full') {
|
||||||
|
weekVacationDays += 1;
|
||||||
|
weekVacationHours += 8; // Ganzer Tag = 8 Stunden
|
||||||
|
// Bei vollem Tag Urlaub werden keine Arbeitsstunden gezählt
|
||||||
|
} else if (entry.vacation_type === 'half') {
|
||||||
|
weekVacationDays += 0.5;
|
||||||
|
weekVacationHours += 4; // Halber Tag = 4 Stunden
|
||||||
|
// Bei halbem Tag Urlaub können noch Arbeitsstunden vorhanden sein
|
||||||
|
if (entry.total_hours) {
|
||||||
|
weekTotalHours += entry.total_hours;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Kein Urlaub - zähle nur Arbeitsstunden
|
||||||
|
if (entry.total_hours) {
|
||||||
|
weekTotalHours += entry.total_hours;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sollstunden berechnen
|
||||||
|
const sollStunden = (wochenstunden / 5) * workdays;
|
||||||
|
|
||||||
|
// Überstunden für diese Woche: Urlaub zählt als normale Arbeitszeit
|
||||||
|
const weekTotalHoursWithVacation = weekTotalHours + weekVacationHours;
|
||||||
|
const weekOvertimeHours = weekTotalHoursWithVacation - sollStunden;
|
||||||
|
|
||||||
|
// Kumulativ addieren
|
||||||
|
totalOvertimeHours += weekOvertimeHours;
|
||||||
|
totalOvertimeTaken += weekOvertimeTaken;
|
||||||
|
totalVacationDays += weekVacationDays;
|
||||||
|
|
||||||
|
processedWeeks++;
|
||||||
|
|
||||||
|
// Wenn alle Wochen verarbeitet wurden, Antwort senden
|
||||||
|
if (processedWeeks === weeks.length && !hasError) {
|
||||||
|
const currentOvertime = (totalOvertimeHours - totalOvertimeTaken) + overtimeOffsetHours;
|
||||||
|
const remainingVacation = urlaubstage - totalVacationDays;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
currentOvertime: currentOvertime,
|
||||||
|
remainingVacation: remainingVacation,
|
||||||
|
totalOvertimeHours: totalOvertimeHours,
|
||||||
|
totalOvertimeTaken: totalOvertimeTaken,
|
||||||
|
totalVacationDays: totalVacationDays,
|
||||||
|
urlaubstage: urlaubstage,
|
||||||
|
overtimeOffsetHours: overtimeOffsetHours
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = registerUserRoutes;
|
||||||
353
routes/verwaltung.js
Normal file
353
routes/verwaltung.js
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
// Verwaltung Routes
|
||||||
|
|
||||||
|
const archiver = require('archiver');
|
||||||
|
const { db } = require('../database');
|
||||||
|
const { requireVerwaltung } = require('../middleware/auth');
|
||||||
|
const { getWeekDatesFromCalendarWeek } = require('../helpers/utils');
|
||||||
|
const { generatePDFToBuffer } = require('../services/pdf-service');
|
||||||
|
|
||||||
|
// Routes registrieren
|
||||||
|
function registerVerwaltungRoutes(app) {
|
||||||
|
// Verwaltungs-Bereich
|
||||||
|
app.get('/verwaltung', requireVerwaltung, (req, res) => {
|
||||||
|
db.all(`
|
||||||
|
SELECT wt.*, u.firstname, u.lastname, u.username, u.personalnummer, u.wochenstunden, u.urlaubstage, u.overtime_offset_hours,
|
||||||
|
dl.firstname as downloaded_by_firstname,
|
||||||
|
dl.lastname as downloaded_by_lastname,
|
||||||
|
(SELECT COUNT(*) FROM weekly_timesheets wt2
|
||||||
|
WHERE wt2.user_id = wt.user_id
|
||||||
|
AND wt2.week_start = wt.week_start
|
||||||
|
AND wt2.week_end = wt.week_end) as total_versions
|
||||||
|
FROM weekly_timesheets wt
|
||||||
|
JOIN users u ON wt.user_id = u.id
|
||||||
|
LEFT JOIN users dl ON wt.pdf_downloaded_by = dl.id
|
||||||
|
WHERE wt.status = 'eingereicht'
|
||||||
|
ORDER BY wt.week_start DESC, wt.user_id, wt.version DESC
|
||||||
|
`, (err, timesheets) => {
|
||||||
|
// Gruppiere nach Mitarbeiter, dann nach Kalenderwoche
|
||||||
|
// Struktur: { [user_id]: { user: {...}, weeks: { [week_key]: {...} } } }
|
||||||
|
const groupedByEmployee = {};
|
||||||
|
|
||||||
|
(timesheets || []).forEach(ts => {
|
||||||
|
const userId = ts.user_id;
|
||||||
|
const weekKey = `${ts.week_start}_${ts.week_end}`;
|
||||||
|
|
||||||
|
// Level 1: Mitarbeiter
|
||||||
|
if (!groupedByEmployee[userId]) {
|
||||||
|
groupedByEmployee[userId] = {
|
||||||
|
user: {
|
||||||
|
id: ts.user_id,
|
||||||
|
firstname: ts.firstname,
|
||||||
|
lastname: ts.lastname,
|
||||||
|
username: ts.username,
|
||||||
|
personalnummer: ts.personalnummer,
|
||||||
|
wochenstunden: ts.wochenstunden,
|
||||||
|
urlaubstage: ts.urlaubstage,
|
||||||
|
overtime_offset_hours: ts.overtime_offset_hours
|
||||||
|
},
|
||||||
|
weeks: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level 2: Kalenderwoche
|
||||||
|
if (!groupedByEmployee[userId].weeks[weekKey]) {
|
||||||
|
groupedByEmployee[userId].weeks[weekKey] = {
|
||||||
|
week_start: ts.week_start,
|
||||||
|
week_end: ts.week_end,
|
||||||
|
total_versions: ts.total_versions,
|
||||||
|
versions: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Level 3: Versionen
|
||||||
|
groupedByEmployee[userId].weeks[weekKey].versions.push(ts);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sortierung: Mitarbeiter nach Name, Wochen nach Datum (neueste zuerst)
|
||||||
|
const sortedEmployees = Object.values(groupedByEmployee).map(employee => {
|
||||||
|
// Wochen innerhalb jedes Mitarbeiters sortieren
|
||||||
|
const sortedWeeks = Object.values(employee.weeks).sort((a, b) => {
|
||||||
|
return new Date(b.week_start) - new Date(a.week_start);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...employee,
|
||||||
|
weeks: sortedWeeks
|
||||||
|
};
|
||||||
|
}).sort((a, b) => {
|
||||||
|
// Mitarbeiter nach Nachname, dann Vorname sortieren
|
||||||
|
const nameA = `${a.user.lastname} ${a.user.firstname}`.toLowerCase();
|
||||||
|
const nameB = `${b.user.lastname} ${b.user.firstname}`.toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.render('verwaltung', {
|
||||||
|
groupedByEmployee: sortedEmployees,
|
||||||
|
user: {
|
||||||
|
firstname: req.session.firstname,
|
||||||
|
lastname: req.session.lastname,
|
||||||
|
roles: req.session.roles || [],
|
||||||
|
currentRole: req.session.currentRole || 'verwaltung'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Überstunden-Offset für einen User setzen (positiv/negativ)
|
||||||
|
app.put('/api/verwaltung/user/:id/overtime-offset', requireVerwaltung, (req, res) => {
|
||||||
|
const userId = req.params.id;
|
||||||
|
const raw = req.body ? req.body.overtime_offset_hours : undefined;
|
||||||
|
|
||||||
|
// Leere Eingabe => 0
|
||||||
|
const normalized = (raw === '' || raw === null || raw === undefined) ? 0 : parseFloat(raw);
|
||||||
|
if (!Number.isFinite(normalized)) {
|
||||||
|
return res.status(400).json({ error: 'Ungültiger Überstunden-Offset' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run('UPDATE users SET overtime_offset_hours = ? WHERE id = ?', [normalized, userId], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Fehler beim Speichern des Überstunden-Offsets:', err);
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Speichern des Überstunden-Offsets' });
|
||||||
|
}
|
||||||
|
res.json({ success: true, overtime_offset_hours: normalized });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Überstunden- und Urlaubsstatistiken für einen User abrufen
|
||||||
|
app.get('/api/verwaltung/user/:id/stats', requireVerwaltung, (req, res) => {
|
||||||
|
const userId = req.params.id;
|
||||||
|
const { week_start, week_end } = req.query;
|
||||||
|
|
||||||
|
// User-Daten abrufen
|
||||||
|
db.get('SELECT wochenstunden, urlaubstage, overtime_offset_hours FROM users WHERE id = ?', [userId], (err, user) => {
|
||||||
|
if (err || !user) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Abrufen der User-Daten' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const wochenstunden = user.wochenstunden || 0;
|
||||||
|
const urlaubstage = user.urlaubstage || 0;
|
||||||
|
const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0;
|
||||||
|
|
||||||
|
// Einträge für die Woche abrufen
|
||||||
|
db.all(`SELECT date, total_hours, overtime_taken_hours, vacation_type
|
||||||
|
FROM timesheet_entries
|
||||||
|
WHERE user_id = ? AND date >= ? AND date <= ?
|
||||||
|
ORDER BY date`,
|
||||||
|
[userId, week_start, week_end],
|
||||||
|
(err, entries) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Abrufen der Einträge' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Berechnungen
|
||||||
|
let totalHours = 0;
|
||||||
|
let overtimeTaken = 0;
|
||||||
|
let vacationDays = 0;
|
||||||
|
let vacationHours = 0;
|
||||||
|
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.overtime_taken_hours) {
|
||||||
|
overtimeTaken += entry.overtime_taken_hours;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Urlaub hat Priorität - wenn Urlaub, zähle nur Urlaubsstunden, nicht zusätzlich Arbeitsstunden
|
||||||
|
if (entry.vacation_type === 'full') {
|
||||||
|
vacationDays += 1;
|
||||||
|
vacationHours += 8; // Ganzer Tag = 8 Stunden
|
||||||
|
// Bei vollem Tag Urlaub werden keine Arbeitsstunden gezählt
|
||||||
|
} else if (entry.vacation_type === 'half') {
|
||||||
|
vacationDays += 0.5;
|
||||||
|
vacationHours += 4; // Halber Tag = 4 Stunden
|
||||||
|
// Bei halbem Tag Urlaub können noch Arbeitsstunden vorhanden sein
|
||||||
|
if (entry.total_hours) {
|
||||||
|
totalHours += entry.total_hours;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Kein Urlaub - zähle nur Arbeitsstunden
|
||||||
|
if (entry.total_hours) {
|
||||||
|
totalHours += entry.total_hours;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Anzahl Werktage berechnen (Montag-Freitag)
|
||||||
|
const startDate = new Date(week_start);
|
||||||
|
const endDate = new Date(week_end);
|
||||||
|
let workdays = 0;
|
||||||
|
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||||
|
const day = d.getDay();
|
||||||
|
if (day >= 1 && day <= 5) { // Montag bis Freitag
|
||||||
|
workdays++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sollstunden berechnen
|
||||||
|
const sollStunden = (wochenstunden / 5) * workdays;
|
||||||
|
|
||||||
|
// Überstunden berechnen: Urlaub zählt als normale Arbeitszeit
|
||||||
|
// Überstunden = (Tatsächliche Stunden + Urlaubsstunden) - Sollstunden
|
||||||
|
const totalHoursWithVacation = totalHours + vacationHours;
|
||||||
|
const overtimeHours = totalHoursWithVacation - sollStunden;
|
||||||
|
const remainingOvertime = overtimeHours - overtimeTaken;
|
||||||
|
const remainingOvertimeWithOffset = remainingOvertime + overtimeOffsetHours;
|
||||||
|
|
||||||
|
// Verbleibende Urlaubstage
|
||||||
|
const remainingVacation = urlaubstage - vacationDays;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
wochenstunden,
|
||||||
|
urlaubstage,
|
||||||
|
totalHours,
|
||||||
|
sollStunden,
|
||||||
|
overtimeHours,
|
||||||
|
overtimeTaken,
|
||||||
|
remainingOvertime,
|
||||||
|
overtimeOffsetHours,
|
||||||
|
remainingOvertimeWithOffset,
|
||||||
|
vacationDays,
|
||||||
|
remainingVacation,
|
||||||
|
workdays
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Admin-Kommentar speichern
|
||||||
|
app.put('/api/verwaltung/timesheet/:id/comment', requireVerwaltung, (req, res) => {
|
||||||
|
const timesheetId = req.params.id;
|
||||||
|
const { comment } = req.body;
|
||||||
|
|
||||||
|
db.run('UPDATE weekly_timesheets SET admin_comment = ? WHERE id = ?',
|
||||||
|
[comment ? comment.trim() : null, timesheetId],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Fehler beim Speichern des Kommentars:', err);
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Speichern des Kommentars' });
|
||||||
|
}
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API: Massendownload aller PDFs für eine Kalenderwoche
|
||||||
|
app.get('/api/verwaltung/bulk-download/:year/:week', requireVerwaltung, async (req, res) => {
|
||||||
|
const year = parseInt(req.params.year);
|
||||||
|
const week = parseInt(req.params.week);
|
||||||
|
const downloadedBy = req.session.userId;
|
||||||
|
|
||||||
|
// Validierung
|
||||||
|
if (!year || year < 2000 || year > 2100) {
|
||||||
|
return res.status(400).json({ error: 'Ungültiges Jahr' });
|
||||||
|
}
|
||||||
|
if (!week || week < 1 || week > 53) {
|
||||||
|
return res.status(400).json({ error: 'Ungültige Kalenderwoche (1-53)' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Berechne week_start und week_end aus Jahr und KW
|
||||||
|
const { week_start, week_end } = getWeekDatesFromCalendarWeek(year, week);
|
||||||
|
|
||||||
|
// Hole alle eingereichten Stundenzettel für diese KW
|
||||||
|
db.all(`SELECT wt.id, wt.user_id, wt.version, u.firstname, u.lastname
|
||||||
|
FROM weekly_timesheets wt
|
||||||
|
JOIN users u ON wt.user_id = u.id
|
||||||
|
WHERE wt.status = 'eingereicht'
|
||||||
|
AND wt.week_start = ?
|
||||||
|
AND wt.week_end = ?
|
||||||
|
ORDER BY wt.user_id, wt.version DESC`,
|
||||||
|
[week_start, week_end],
|
||||||
|
async (err, allTimesheets) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Fehler beim Abrufen der Stundenzettel:', err);
|
||||||
|
return res.status(500).json({ error: 'Fehler beim Abrufen der Stundenzettel' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allTimesheets || allTimesheets.length === 0) {
|
||||||
|
return res.status(404).json({ error: `Keine eingereichten Stundenzettel für KW ${week}/${year} gefunden` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gruppiere nach user_id und wähle neueste Version pro User
|
||||||
|
const latestByUser = {};
|
||||||
|
allTimesheets.forEach(ts => {
|
||||||
|
if (!latestByUser[ts.user_id] || ts.version > latestByUser[ts.user_id].version) {
|
||||||
|
latestByUser[ts.user_id] = ts;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const timesheetsToDownload = Object.values(latestByUser);
|
||||||
|
const timesheetIds = timesheetsToDownload.map(ts => ts.id);
|
||||||
|
|
||||||
|
// Erstelle ZIP
|
||||||
|
res.setHeader('Content-Type', 'application/zip');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="Stundenzettel_KW${String(week).padStart(2, '0')}_${year}.zip"`);
|
||||||
|
|
||||||
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||||
|
archive.on('error', (err) => {
|
||||||
|
console.error('Fehler beim Erstellen des ZIP:', err);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: 'Fehler beim Erstellen des ZIP-Archivs' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.pipe(res);
|
||||||
|
|
||||||
|
// Generiere PDFs sequenziell und füge sie zum ZIP hinzu
|
||||||
|
const errors = [];
|
||||||
|
for (const ts of timesheetsToDownload) {
|
||||||
|
try {
|
||||||
|
// Erstelle Mock-Request-Objekt für generatePDFToBuffer
|
||||||
|
const mockReq = {
|
||||||
|
session: { userId: downloadedBy },
|
||||||
|
query: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pdfBuffer = await generatePDFToBuffer(ts.id, mockReq);
|
||||||
|
|
||||||
|
// Dateiname: Stundenzettel_KW{week}_{Nachname}{Vorname}_Version{version}.pdf
|
||||||
|
const employeeName = `${ts.lastname}${ts.firstname}`.replace(/\s+/g, '');
|
||||||
|
const filename = `Stundenzettel_KW${String(week).padStart(2, '0')}_${employeeName}_Version${ts.version}.pdf`;
|
||||||
|
|
||||||
|
archive.append(pdfBuffer, { name: filename });
|
||||||
|
} catch (pdfError) {
|
||||||
|
console.error(`Fehler beim Generieren des PDFs für Timesheet ${ts.id}:`, pdfError);
|
||||||
|
errors.push(`Fehler bei ${ts.firstname} ${ts.lastname}: ${pdfError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warte auf ZIP-Finalisierung und markiere dann PDFs als heruntergeladen
|
||||||
|
archive.on('end', () => {
|
||||||
|
if (timesheetIds.length > 0 && downloadedBy) {
|
||||||
|
// Update alle betroffenen timesheets
|
||||||
|
const placeholders = timesheetIds.map(() => '?').join(',');
|
||||||
|
db.run(`UPDATE weekly_timesheets
|
||||||
|
SET pdf_downloaded_at = CURRENT_TIMESTAMP,
|
||||||
|
pdf_downloaded_by = ?
|
||||||
|
WHERE id IN (${placeholders})`,
|
||||||
|
[downloadedBy, ...timesheetIds],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Fehler beim Markieren der PDFs als heruntergeladen:', err);
|
||||||
|
} else {
|
||||||
|
console.log(`Massendownload: ${timesheetIds.length} PDFs als heruntergeladen markiert`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Finalisiere ZIP (startet den Stream)
|
||||||
|
archive.finalize();
|
||||||
|
|
||||||
|
// Wenn Fehler aufgetreten sind, aber ZIP trotzdem erstellt wurde, logge sie
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.warn('Einige PDFs konnten nicht generiert werden:', errors);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Massendownload:', error);
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.status(500).json({ error: 'Fehler beim Massendownload: ' + error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = registerVerwaltungRoutes;
|
||||||
34
services/ldap-scheduler.js
Normal file
34
services/ldap-scheduler.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// LDAP-Scheduler Service
|
||||||
|
|
||||||
|
const { db } = require('../database');
|
||||||
|
const LDAPService = require('../ldap-service');
|
||||||
|
|
||||||
|
// Automatische LDAP-Synchronisation einrichten
|
||||||
|
function setupLDAPScheduler() {
|
||||||
|
// Prüfe alle 5 Minuten, ob eine Synchronisation notwendig ist
|
||||||
|
setInterval(() => {
|
||||||
|
db.get('SELECT * FROM ldap_config WHERE id = 1 AND enabled = 1 AND sync_interval > 0', (err, config) => {
|
||||||
|
if (err || !config) {
|
||||||
|
return; // Keine aktive Konfiguration
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const lastSync = config.last_sync ? new Date(config.last_sync) : null;
|
||||||
|
const syncIntervalMs = config.sync_interval * 60 * 1000; // Minuten in Millisekunden
|
||||||
|
|
||||||
|
// Prüfe ob Synchronisation fällig ist
|
||||||
|
if (!lastSync || (now - lastSync) >= syncIntervalMs) {
|
||||||
|
console.log('Starte automatische LDAP-Synchronisation...');
|
||||||
|
LDAPService.performSync('scheduled', (err, result) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Fehler bei automatischer LDAP-Synchronisation:', err.message);
|
||||||
|
} else {
|
||||||
|
console.log(`Automatische LDAP-Synchronisation abgeschlossen: ${result.synced} Benutzer synchronisiert`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 5 * 60 * 1000); // Alle 5 Minuten prüfen
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { setupLDAPScheduler };
|
||||||
493
services/pdf-service.js
Normal file
493
services/pdf-service.js
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
// PDF-Generierung Service
|
||||||
|
|
||||||
|
const PDFDocument = require('pdfkit');
|
||||||
|
const { db } = require('../database');
|
||||||
|
const { formatDate, formatDateTime } = require('../helpers/utils');
|
||||||
|
|
||||||
|
// Kalenderwoche berechnen
|
||||||
|
function getCalendarWeek(dateStr) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||||
|
const dayNum = d.getUTCDay() || 7;
|
||||||
|
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||||
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||||
|
const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
||||||
|
return weekNo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF generieren
|
||||||
|
function generatePDF(timesheetId, req, res) {
|
||||||
|
db.get(`SELECT wt.*, u.firstname, u.lastname, u.username, u.wochenstunden
|
||||||
|
FROM weekly_timesheets wt
|
||||||
|
JOIN users u ON wt.user_id = u.id
|
||||||
|
WHERE wt.id = ?`, [timesheetId], (err, timesheet) => {
|
||||||
|
|
||||||
|
if (err || !timesheet) {
|
||||||
|
return res.status(404).send('Stundenzettel nicht gefunden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hole Einträge die zum Zeitpunkt der Einreichung existierten
|
||||||
|
// Filtere nach submitted_at der Version, damit jede Version ihre eigenen Daten zeigt
|
||||||
|
// Logik: Wenn updated_at existiert, verwende das, sonst created_at, sonst zeige Eintrag (für alte Daten ohne Timestamps)
|
||||||
|
db.all(`SELECT * FROM timesheet_entries
|
||||||
|
WHERE user_id = ? AND date >= ? AND date <= ?
|
||||||
|
AND (
|
||||||
|
(updated_at IS NOT NULL AND updated_at <= ?) OR
|
||||||
|
(updated_at IS NULL AND created_at IS NOT NULL AND created_at <= ?) OR
|
||||||
|
(updated_at IS NULL AND created_at IS NULL)
|
||||||
|
)
|
||||||
|
ORDER BY date, updated_at DESC, id DESC`,
|
||||||
|
[timesheet.user_id, timesheet.week_start, timesheet.week_end,
|
||||||
|
timesheet.submitted_at, timesheet.submitted_at],
|
||||||
|
(err, allEntries) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).send('Fehler beim Abrufen der Einträge');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtere auf neuesten Eintrag pro Tag (basierend auf updated_at oder id)
|
||||||
|
const entriesByDate = {};
|
||||||
|
(allEntries || []).forEach(entry => {
|
||||||
|
const existing = entriesByDate[entry.date];
|
||||||
|
if (!existing) {
|
||||||
|
entriesByDate[entry.date] = entry;
|
||||||
|
} else {
|
||||||
|
// Vergleiche updated_at (falls vorhanden) oder id (höhere ID = neuer)
|
||||||
|
const existingTime = existing.updated_at ? new Date(existing.updated_at).getTime() : 0;
|
||||||
|
const currentTime = entry.updated_at ? new Date(entry.updated_at).getTime() : 0;
|
||||||
|
if (currentTime > existingTime || (currentTime === existingTime && entry.id > existing.id)) {
|
||||||
|
entriesByDate[entry.date] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Konvertiere zu Array und sortiere nach Datum
|
||||||
|
const entries = Object.values(entriesByDate).sort((a, b) => {
|
||||||
|
return new Date(a.date) - new Date(b.date);
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc = new PDFDocument({ margin: 50 });
|
||||||
|
|
||||||
|
// Prüfe ob inline angezeigt werden soll (für Vorschau)
|
||||||
|
const inline = req.query.inline === 'true';
|
||||||
|
|
||||||
|
// Dateinamen generieren: Stundenzettel_KWxxx_NameMitarbeiter_heutigesDatum.pdf
|
||||||
|
const calendarWeek = getCalendarWeek(timesheet.week_start);
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = today.getFullYear() + '-' +
|
||||||
|
String(today.getMonth() + 1).padStart(2, '0') + '-' +
|
||||||
|
String(today.getDate()).padStart(2, '0');
|
||||||
|
const employeeName = `${timesheet.firstname}${timesheet.lastname}`.replace(/\s+/g, '');
|
||||||
|
const filename = `Stundenzettel_KW${String(calendarWeek).padStart(2, '0')}_${employeeName}_${todayStr}.pdf`;
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
|
|
||||||
|
if (inline) {
|
||||||
|
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||||
|
// Zusätzliche Header für iframe-Unterstützung
|
||||||
|
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||||
|
} else {
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
|
|
||||||
|
// Marker setzen, dass PDF heruntergeladen wurde (nur bei Download, nicht bei Vorschau)
|
||||||
|
const downloadedBy = req.session.userId; // User der die PDF herunterlädt
|
||||||
|
console.log('PDF Download - User ID:', downloadedBy, 'Timesheet ID:', timesheetId);
|
||||||
|
|
||||||
|
if (downloadedBy) {
|
||||||
|
db.run(`UPDATE weekly_timesheets
|
||||||
|
SET pdf_downloaded_at = CURRENT_TIMESTAMP,
|
||||||
|
pdf_downloaded_by = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[downloadedBy, timesheetId],
|
||||||
|
(err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Fehler beim Setzen des Download-Markers:', err);
|
||||||
|
} else {
|
||||||
|
console.log('Download-Marker erfolgreich gesetzt für User:', downloadedBy);
|
||||||
|
}
|
||||||
|
// Fehler wird ignoriert, damit PDF trotzdem generiert wird
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('PDF Download - Keine User ID in Session gefunden!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.pipe(res);
|
||||||
|
|
||||||
|
// Header (Kalenderwoche wurde bereits oben berechnet)
|
||||||
|
doc.fontSize(20).text(`Stundenzettel für KW ${calendarWeek}`, { align: 'center' });
|
||||||
|
doc.moveDown();
|
||||||
|
|
||||||
|
// Mitarbeiter-Info
|
||||||
|
doc.fontSize(12);
|
||||||
|
doc.text(`Mitarbeiter: ${timesheet.firstname} ${timesheet.lastname}`);
|
||||||
|
doc.text(`Zeitraum: ${formatDate(timesheet.week_start)} - ${formatDate(timesheet.week_end)}`);
|
||||||
|
doc.text(`Eingereicht am: ${formatDateTime(timesheet.submitted_at)}`);
|
||||||
|
doc.moveDown();
|
||||||
|
|
||||||
|
// Tabelle - Basis-Informationen
|
||||||
|
const tableTop = doc.y;
|
||||||
|
const colWidths = [80, 80, 80, 60, 80];
|
||||||
|
const headers = ['Datum', 'Start', 'Ende', 'Pause', 'Stunden'];
|
||||||
|
|
||||||
|
// Tabellen-Header
|
||||||
|
doc.fontSize(10).font('Helvetica-Bold');
|
||||||
|
let x = 50;
|
||||||
|
headers.forEach((header, i) => {
|
||||||
|
doc.text(header, x, tableTop, { width: colWidths[i], align: 'left' });
|
||||||
|
x += colWidths[i];
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.moveDown();
|
||||||
|
let y = doc.y;
|
||||||
|
doc.moveTo(50, y).lineTo(430, y).stroke();
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
|
||||||
|
// Tabellen-Daten
|
||||||
|
doc.font('Helvetica');
|
||||||
|
let totalHours = 0;
|
||||||
|
let vacationHours = 0; // Urlaubsstunden für Überstunden-Berechnung
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
y = doc.y;
|
||||||
|
x = 50;
|
||||||
|
|
||||||
|
// Basis-Zeile
|
||||||
|
const rowData = [
|
||||||
|
formatDate(entry.date),
|
||||||
|
entry.start_time || '-',
|
||||||
|
entry.end_time || '-',
|
||||||
|
entry.break_minutes ? `${entry.break_minutes} min` : '-',
|
||||||
|
entry.total_hours ? entry.total_hours.toFixed(2) + ' h' : '-'
|
||||||
|
];
|
||||||
|
|
||||||
|
rowData.forEach((data, i) => {
|
||||||
|
doc.text(data, x, y, { width: colWidths[i], align: 'left' });
|
||||||
|
x += colWidths[i];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tätigkeiten sammeln
|
||||||
|
const activities = [];
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const desc = entry[`activity${i}_desc`];
|
||||||
|
const hours = entry[`activity${i}_hours`];
|
||||||
|
const projectNumber = entry[`activity${i}_project_number`];
|
||||||
|
if (desc && desc.trim() && hours > 0) {
|
||||||
|
activities.push({
|
||||||
|
desc: desc.trim(),
|
||||||
|
hours: parseFloat(hours),
|
||||||
|
projectNumber: projectNumber ? projectNumber.trim() : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tätigkeiten anzeigen
|
||||||
|
if (activities.length > 0) {
|
||||||
|
doc.moveDown(0.3);
|
||||||
|
doc.fontSize(9).font('Helvetica-Oblique');
|
||||||
|
doc.text('Tätigkeiten:', 60, doc.y, { width: 380 });
|
||||||
|
doc.moveDown(0.2);
|
||||||
|
|
||||||
|
activities.forEach((activity, idx) => {
|
||||||
|
let activityText = `${idx + 1}. ${activity.desc}`;
|
||||||
|
if (activity.projectNumber) {
|
||||||
|
activityText += ` (Projekt: ${activity.projectNumber})`;
|
||||||
|
}
|
||||||
|
activityText += ` - ${activity.hours.toFixed(2)} h`;
|
||||||
|
doc.fontSize(9).font('Helvetica');
|
||||||
|
doc.text(activityText, 70, doc.y, { width: 360 });
|
||||||
|
doc.moveDown(0.2);
|
||||||
|
});
|
||||||
|
doc.fontSize(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Überstunden und Urlaub anzeigen
|
||||||
|
const overtimeInfo = [];
|
||||||
|
if (entry.overtime_taken_hours && parseFloat(entry.overtime_taken_hours) > 0) {
|
||||||
|
overtimeInfo.push(`Überstunden genommen: ${parseFloat(entry.overtime_taken_hours).toFixed(2)} h`);
|
||||||
|
}
|
||||||
|
if (entry.vacation_type) {
|
||||||
|
const vacationText = entry.vacation_type === 'full' ? 'Ganzer Tag' : 'Halber Tag';
|
||||||
|
overtimeInfo.push(`Urlaub: ${vacationText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overtimeInfo.length > 0) {
|
||||||
|
doc.moveDown(0.2);
|
||||||
|
doc.fontSize(9).font('Helvetica-Oblique');
|
||||||
|
overtimeInfo.forEach((info, idx) => {
|
||||||
|
doc.text(info, 70, doc.y, { width: 360 });
|
||||||
|
doc.moveDown(0.15);
|
||||||
|
});
|
||||||
|
doc.fontSize(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Urlaub hat Priorität - wenn Urlaub, zähle nur Urlaubsstunden, nicht zusätzlich Arbeitsstunden
|
||||||
|
if (entry.vacation_type === 'full') {
|
||||||
|
vacationHours += 8; // Ganzer Tag = 8 Stunden
|
||||||
|
// Bei vollem Tag Urlaub werden keine Arbeitsstunden gezählt
|
||||||
|
} else if (entry.vacation_type === 'half') {
|
||||||
|
vacationHours += 4; // Halber Tag = 4 Stunden
|
||||||
|
// Bei halbem Tag Urlaub können noch Arbeitsstunden vorhanden sein
|
||||||
|
if (entry.total_hours) {
|
||||||
|
totalHours += entry.total_hours;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Kein Urlaub - zähle nur Arbeitsstunden
|
||||||
|
if (entry.total_hours) {
|
||||||
|
totalHours += entry.total_hours;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
|
||||||
|
// Trennlinie zwischen Einträgen
|
||||||
|
y = doc.y;
|
||||||
|
doc.moveTo(50, y).lineTo(430, y).stroke();
|
||||||
|
doc.moveDown(0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Summe
|
||||||
|
y = doc.y;
|
||||||
|
doc.moveTo(50, y).lineTo(550, y).stroke();
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
doc.font('Helvetica-Bold');
|
||||||
|
doc.text(`Gesamtstunden: ${totalHours.toFixed(2)} h`, 50, doc.y);
|
||||||
|
|
||||||
|
// Überstunden berechnen und anzeigen
|
||||||
|
const wochenstunden = timesheet.wochenstunden || 0;
|
||||||
|
// Überstunden = (Tatsächliche Stunden + Urlaubsstunden) - Wochenstunden
|
||||||
|
const totalHoursWithVacation = totalHours + vacationHours;
|
||||||
|
const overtimeHours = totalHoursWithVacation - wochenstunden;
|
||||||
|
|
||||||
|
doc.moveDown(0.3);
|
||||||
|
doc.font('Helvetica-Bold');
|
||||||
|
if (overtimeHours > 0) {
|
||||||
|
doc.text(`Überstunden: +${overtimeHours.toFixed(2)} h`, 50, doc.y);
|
||||||
|
} else if (overtimeHours < 0) {
|
||||||
|
doc.text(`Überstunden: ${overtimeHours.toFixed(2)} h`, 50, doc.y);
|
||||||
|
} else {
|
||||||
|
doc.text(`Überstunden: 0.00 h`, 50, doc.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDF als Buffer generieren (für ZIP-Downloads)
|
||||||
|
function generatePDFToBuffer(timesheetId, req) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get(`SELECT wt.*, u.firstname, u.lastname, u.username, u.wochenstunden
|
||||||
|
FROM weekly_timesheets wt
|
||||||
|
JOIN users u ON wt.user_id = u.id
|
||||||
|
WHERE wt.id = ?`, [timesheetId], (err, timesheet) => {
|
||||||
|
|
||||||
|
if (err || !timesheet) {
|
||||||
|
return reject(new Error('Stundenzettel nicht gefunden'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hole Einträge die zum Zeitpunkt der Einreichung existierten
|
||||||
|
db.all(`SELECT * FROM timesheet_entries
|
||||||
|
WHERE user_id = ? AND date >= ? AND date <= ?
|
||||||
|
AND (
|
||||||
|
(updated_at IS NOT NULL AND updated_at <= ?) OR
|
||||||
|
(updated_at IS NULL AND created_at IS NOT NULL AND created_at <= ?) OR
|
||||||
|
(updated_at IS NULL AND created_at IS NULL)
|
||||||
|
)
|
||||||
|
ORDER BY date, updated_at DESC, id DESC`,
|
||||||
|
[timesheet.user_id, timesheet.week_start, timesheet.week_end,
|
||||||
|
timesheet.submitted_at, timesheet.submitted_at],
|
||||||
|
(err, allEntries) => {
|
||||||
|
if (err) {
|
||||||
|
return reject(new Error('Fehler beim Abrufen der Einträge'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtere auf neuesten Eintrag pro Tag
|
||||||
|
const entriesByDate = {};
|
||||||
|
(allEntries || []).forEach(entry => {
|
||||||
|
const existing = entriesByDate[entry.date];
|
||||||
|
if (!existing) {
|
||||||
|
entriesByDate[entry.date] = entry;
|
||||||
|
} else {
|
||||||
|
const existingTime = existing.updated_at ? new Date(existing.updated_at).getTime() : 0;
|
||||||
|
const currentTime = entry.updated_at ? new Date(entry.updated_at).getTime() : 0;
|
||||||
|
if (currentTime > existingTime || (currentTime === existingTime && entry.id > existing.id)) {
|
||||||
|
entriesByDate[entry.date] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries = Object.values(entriesByDate).sort((a, b) => {
|
||||||
|
return new Date(a.date) - new Date(b.date);
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc = new PDFDocument({ margin: 50 });
|
||||||
|
const buffers = [];
|
||||||
|
|
||||||
|
doc.on('data', buffers.push.bind(buffers));
|
||||||
|
doc.on('end', () => {
|
||||||
|
const pdfBuffer = Buffer.concat(buffers);
|
||||||
|
resolve(pdfBuffer);
|
||||||
|
});
|
||||||
|
doc.on('error', reject);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
const calendarWeek = getCalendarWeek(timesheet.week_start);
|
||||||
|
doc.fontSize(20).text(`Stundenzettel für KW ${calendarWeek}`, { align: 'center' });
|
||||||
|
doc.moveDown();
|
||||||
|
|
||||||
|
// Mitarbeiter-Info
|
||||||
|
doc.fontSize(12);
|
||||||
|
doc.text(`Mitarbeiter: ${timesheet.firstname} ${timesheet.lastname}`);
|
||||||
|
doc.text(`Zeitraum: ${formatDate(timesheet.week_start)} - ${formatDate(timesheet.week_end)}`);
|
||||||
|
doc.text(`Eingereicht am: ${formatDateTime(timesheet.submitted_at)}`);
|
||||||
|
doc.moveDown();
|
||||||
|
|
||||||
|
// Tabelle - Basis-Informationen
|
||||||
|
const tableTop = doc.y;
|
||||||
|
const colWidths = [80, 80, 80, 60, 80];
|
||||||
|
const headers = ['Datum', 'Start', 'Ende', 'Pause', 'Stunden'];
|
||||||
|
|
||||||
|
// Tabellen-Header
|
||||||
|
doc.fontSize(10).font('Helvetica-Bold');
|
||||||
|
let x = 50;
|
||||||
|
headers.forEach((header, i) => {
|
||||||
|
doc.text(header, x, tableTop, { width: colWidths[i], align: 'left' });
|
||||||
|
x += colWidths[i];
|
||||||
|
});
|
||||||
|
|
||||||
|
doc.moveDown();
|
||||||
|
let y = doc.y;
|
||||||
|
doc.moveTo(50, y).lineTo(430, y).stroke();
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
|
||||||
|
// Tabellen-Daten
|
||||||
|
doc.font('Helvetica');
|
||||||
|
let totalHours = 0;
|
||||||
|
let vacationHours = 0;
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
y = doc.y;
|
||||||
|
x = 50;
|
||||||
|
|
||||||
|
const rowData = [
|
||||||
|
formatDate(entry.date),
|
||||||
|
entry.start_time || '-',
|
||||||
|
entry.end_time || '-',
|
||||||
|
entry.break_minutes ? `${entry.break_minutes} min` : '-',
|
||||||
|
entry.total_hours ? entry.total_hours.toFixed(2) + ' h' : '-'
|
||||||
|
];
|
||||||
|
|
||||||
|
rowData.forEach((data, i) => {
|
||||||
|
doc.text(data, x, y, { width: colWidths[i], align: 'left' });
|
||||||
|
x += colWidths[i];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tätigkeiten sammeln
|
||||||
|
const activities = [];
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const desc = entry[`activity${i}_desc`];
|
||||||
|
const hours = entry[`activity${i}_hours`];
|
||||||
|
const projectNumber = entry[`activity${i}_project_number`];
|
||||||
|
if (desc && desc.trim() && hours > 0) {
|
||||||
|
activities.push({
|
||||||
|
desc: desc.trim(),
|
||||||
|
hours: parseFloat(hours),
|
||||||
|
projectNumber: projectNumber ? projectNumber.trim() : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tätigkeiten anzeigen
|
||||||
|
if (activities.length > 0) {
|
||||||
|
doc.moveDown(0.3);
|
||||||
|
doc.fontSize(9).font('Helvetica-Oblique');
|
||||||
|
doc.text('Tätigkeiten:', 60, doc.y, { width: 380 });
|
||||||
|
doc.moveDown(0.2);
|
||||||
|
|
||||||
|
activities.forEach((activity, idx) => {
|
||||||
|
let activityText = `${idx + 1}. ${activity.desc}`;
|
||||||
|
if (activity.projectNumber) {
|
||||||
|
activityText += ` (Projekt: ${activity.projectNumber})`;
|
||||||
|
}
|
||||||
|
activityText += ` - ${activity.hours.toFixed(2)} h`;
|
||||||
|
doc.fontSize(9).font('Helvetica');
|
||||||
|
doc.text(activityText, 70, doc.y, { width: 360 });
|
||||||
|
doc.moveDown(0.2);
|
||||||
|
});
|
||||||
|
doc.fontSize(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Überstunden und Urlaub anzeigen
|
||||||
|
const overtimeInfo = [];
|
||||||
|
if (entry.overtime_taken_hours && parseFloat(entry.overtime_taken_hours) > 0) {
|
||||||
|
overtimeInfo.push(`Überstunden genommen: ${parseFloat(entry.overtime_taken_hours).toFixed(2)} h`);
|
||||||
|
}
|
||||||
|
if (entry.vacation_type) {
|
||||||
|
const vacationText = entry.vacation_type === 'full' ? 'Ganzer Tag' : 'Halber Tag';
|
||||||
|
overtimeInfo.push(`Urlaub: ${vacationText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overtimeInfo.length > 0) {
|
||||||
|
doc.moveDown(0.2);
|
||||||
|
doc.fontSize(9).font('Helvetica-Oblique');
|
||||||
|
overtimeInfo.forEach((info, idx) => {
|
||||||
|
doc.text(info, 70, doc.y, { width: 360 });
|
||||||
|
doc.moveDown(0.15);
|
||||||
|
});
|
||||||
|
doc.fontSize(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Urlaub hat Priorität - wenn Urlaub, zähle nur Urlaubsstunden, nicht zusätzlich Arbeitsstunden
|
||||||
|
if (entry.vacation_type === 'full') {
|
||||||
|
vacationHours += 8; // Ganzer Tag = 8 Stunden
|
||||||
|
// Bei vollem Tag Urlaub werden keine Arbeitsstunden gezählt
|
||||||
|
} else if (entry.vacation_type === 'half') {
|
||||||
|
vacationHours += 4; // Halber Tag = 4 Stunden
|
||||||
|
// Bei halbem Tag Urlaub können noch Arbeitsstunden vorhanden sein
|
||||||
|
if (entry.total_hours) {
|
||||||
|
totalHours += entry.total_hours;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Kein Urlaub - zähle nur Arbeitsstunden
|
||||||
|
if (entry.total_hours) {
|
||||||
|
totalHours += entry.total_hours;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
|
||||||
|
y = doc.y;
|
||||||
|
doc.moveTo(50, y).lineTo(430, y).stroke();
|
||||||
|
doc.moveDown(0.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Summe
|
||||||
|
y = doc.y;
|
||||||
|
doc.moveTo(50, y).lineTo(550, y).stroke();
|
||||||
|
doc.moveDown(0.5);
|
||||||
|
doc.font('Helvetica-Bold');
|
||||||
|
doc.text(`Gesamtstunden: ${totalHours.toFixed(2)} h`, 50, doc.y);
|
||||||
|
|
||||||
|
const wochenstunden = timesheet.wochenstunden || 0;
|
||||||
|
// Überstunden = (Tatsächliche Stunden + Urlaubsstunden) - Wochenstunden
|
||||||
|
const totalHoursWithVacation = totalHours + vacationHours;
|
||||||
|
const overtimeHours = totalHoursWithVacation - wochenstunden;
|
||||||
|
|
||||||
|
doc.moveDown(0.3);
|
||||||
|
doc.font('Helvetica-Bold');
|
||||||
|
if (overtimeHours > 0) {
|
||||||
|
doc.text(`Überstunden: +${overtimeHours.toFixed(2)} h`, 50, doc.y);
|
||||||
|
} else if (overtimeHours < 0) {
|
||||||
|
doc.text(`Überstunden: ${overtimeHours.toFixed(2)} h`, 50, doc.y);
|
||||||
|
} else {
|
||||||
|
doc.text(`Überstunden: 0.00 h`, 50, doc.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generatePDF, generatePDFToBuffer };
|
||||||
182
services/ping-service.js
Normal file
182
services/ping-service.js
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
// Ping-Service für IP-basierte automatische Zeiterfassung
|
||||||
|
|
||||||
|
const ping = require('ping');
|
||||||
|
const { db } = require('../database');
|
||||||
|
const { getCurrentDate, getCurrentTime, updateTotalHours } = require('../helpers/utils');
|
||||||
|
|
||||||
|
// Ping-Funktion für einen User
|
||||||
|
async function pingUserIP(userId, ip, currentDate, currentTime) {
|
||||||
|
try {
|
||||||
|
const result = await ping.promise.probe(ip, {
|
||||||
|
timeout: 3,
|
||||||
|
min_reply: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
const isReachable = result.alive;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Hole oder erstelle Ping-Status für heute
|
||||||
|
db.get('SELECT * FROM ping_status WHERE user_id = ? AND date = ?',
|
||||||
|
[userId, currentDate], (err, pingStatus) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(`Fehler beim Abrufen des Ping-Status für User ${userId}:`, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hole aktuellen Eintrag für heute
|
||||||
|
db.get('SELECT * FROM timesheet_entries WHERE user_id = ? AND date = ? ORDER BY updated_at DESC, id DESC LIMIT 1',
|
||||||
|
[userId, currentDate], (err, entry) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(`Fehler beim Abrufen des Eintrags für User ${userId}:`, err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReachable) {
|
||||||
|
// IP ist erreichbar
|
||||||
|
if (!pingStatus) {
|
||||||
|
// Erstelle neuen Ping-Status
|
||||||
|
db.run(`INSERT INTO ping_status (user_id, date, last_successful_ping, failed_ping_count, start_time_set)
|
||||||
|
VALUES (?, ?, ?, 0, 0)`,
|
||||||
|
[userId, currentDate, now], (err) => {
|
||||||
|
if (err) console.error(`Fehler beim Erstellen des Ping-Status:`, err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Update Ping-Status: Reset failed_ping_count, update last_successful_ping
|
||||||
|
db.run(`UPDATE ping_status
|
||||||
|
SET last_successful_ping = ?, failed_ping_count = 0, first_failed_ping_time = NULL
|
||||||
|
WHERE user_id = ? AND date = ?`,
|
||||||
|
[now, userId, currentDate], (err) => {
|
||||||
|
if (err) console.error(`Fehler beim Aktualisieren des Ping-Status:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start-Zeit setzen wenn noch nicht vorhanden
|
||||||
|
if (entry && !entry.start_time) {
|
||||||
|
db.run('UPDATE timesheet_entries SET start_time = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[currentTime, entry.id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(`Fehler beim Setzen der Start-Zeit für User ${userId}:`, err);
|
||||||
|
} else {
|
||||||
|
console.log(`Start-Zeit gesetzt für User ${userId} um ${currentTime}`);
|
||||||
|
// Markiere dass Start-Zeit gesetzt wurde
|
||||||
|
db.run('UPDATE ping_status SET start_time_set = 1 WHERE user_id = ? AND date = ?',
|
||||||
|
[userId, currentDate], (err) => {
|
||||||
|
if (err) console.error(`Fehler beim Aktualisieren von start_time_set:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (!entry) {
|
||||||
|
// Kein Eintrag existiert → Erstelle neuen mit start_time
|
||||||
|
db.run(`INSERT INTO timesheet_entries (user_id, date, start_time, updated_at)
|
||||||
|
VALUES (?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||||
|
[userId, currentDate, currentTime], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(`Fehler beim Erstellen des Eintrags für User ${userId}:`, err);
|
||||||
|
} else {
|
||||||
|
console.log(`Eintrag erstellt und Start-Zeit gesetzt für User ${userId} um ${currentTime}`);
|
||||||
|
// Markiere dass Start-Zeit gesetzt wurde
|
||||||
|
db.run('UPDATE ping_status SET start_time_set = 1 WHERE user_id = ? AND date = ?',
|
||||||
|
[userId, currentDate], (err) => {
|
||||||
|
if (err) {
|
||||||
|
// Falls kein Ping-Status existiert, erstelle einen
|
||||||
|
db.run(`INSERT INTO ping_status (user_id, date, last_successful_ping, failed_ping_count, start_time_set)
|
||||||
|
VALUES (?, ?, ?, 0, 1)`,
|
||||||
|
[userId, currentDate, now], (err) => {
|
||||||
|
if (err) console.error(`Fehler beim Erstellen des Ping-Status:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// IP ist nicht erreichbar
|
||||||
|
if (!pingStatus) {
|
||||||
|
// Erstelle neuen Ping-Status mit failed_ping_count = 1
|
||||||
|
db.run(`INSERT INTO ping_status (user_id, date, failed_ping_count, first_failed_ping_time)
|
||||||
|
VALUES (?, ?, 1, ?)`,
|
||||||
|
[userId, currentDate, now], (err) => {
|
||||||
|
if (err) console.error(`Fehler beim Erstellen des Ping-Status:`, err);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Erhöhe failed_ping_count
|
||||||
|
const newFailedCount = (pingStatus.failed_ping_count || 0) + 1;
|
||||||
|
const firstFailedTime = pingStatus.first_failed_ping_time || now;
|
||||||
|
|
||||||
|
db.run(`UPDATE ping_status
|
||||||
|
SET failed_ping_count = ?, first_failed_ping_time = ?
|
||||||
|
WHERE user_id = ? AND date = ?`,
|
||||||
|
[newFailedCount, firstFailedTime, userId, currentDate], (err) => {
|
||||||
|
if (err) console.error(`Fehler beim Aktualisieren des Ping-Status:`, err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wenn 3 oder mehr fehlgeschlagene Pings UND Start-Zeit existiert UND keine End-Zeit
|
||||||
|
if (newFailedCount >= 3 && entry && entry.start_time && !entry.end_time) {
|
||||||
|
// Setze End-Zeit auf Zeit des ersten fehlgeschlagenen Pings
|
||||||
|
const firstFailedDate = new Date(firstFailedTime);
|
||||||
|
const endTime = `${String(firstFailedDate.getHours()).padStart(2, '0')}:${String(firstFailedDate.getMinutes()).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Berechne total_hours
|
||||||
|
const breakMinutes = entry.break_minutes || 0;
|
||||||
|
const totalHours = updateTotalHours(entry.start_time, endTime, breakMinutes);
|
||||||
|
|
||||||
|
db.run('UPDATE timesheet_entries SET end_time = ?, total_hours = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
[endTime, totalHours, entry.id], (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(`Fehler beim Setzen der End-Zeit für User ${userId}:`, err);
|
||||||
|
} else {
|
||||||
|
console.log(`End-Zeit gesetzt für User ${userId} um ${endTime} (nach ${newFailedCount} fehlgeschlagenen Pings)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Fehler beim Ping für User ${userId} (IP: ${ip}):`, error);
|
||||||
|
// Behandle als nicht erreichbar
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.get('SELECT * FROM ping_status WHERE user_id = ? AND date = ?',
|
||||||
|
[userId, currentDate], (err, pingStatus) => {
|
||||||
|
if (!err && pingStatus) {
|
||||||
|
const newFailedCount = (pingStatus.failed_ping_count || 0) + 1;
|
||||||
|
const firstFailedTime = pingStatus.first_failed_ping_time || now;
|
||||||
|
|
||||||
|
db.run(`UPDATE ping_status
|
||||||
|
SET failed_ping_count = ?, first_failed_ping_time = ?
|
||||||
|
WHERE user_id = ? AND date = ?`,
|
||||||
|
[newFailedCount, firstFailedTime, userId, currentDate], (err) => {
|
||||||
|
if (err) console.error(`Fehler beim Aktualisieren des Ping-Status:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping-Service Setup
|
||||||
|
function setupPingService() {
|
||||||
|
setInterval(async () => {
|
||||||
|
const currentDate = getCurrentDate();
|
||||||
|
const currentTime = getCurrentTime();
|
||||||
|
|
||||||
|
// Hole alle User mit IP-Adresse
|
||||||
|
db.all('SELECT id, ping_ip FROM users WHERE ping_ip IS NOT NULL AND ping_ip != ""', (err, users) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Fehler beim Abrufen der User mit IP-Adressen:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!users || users.length === 0) {
|
||||||
|
return; // Keine User mit IP-Adressen
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping alle User parallel
|
||||||
|
users.forEach(user => {
|
||||||
|
pingUserIP(user.id, user.ping_ip, currentDate, currentTime);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, 60000); // Jede Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { setupPingService };
|
||||||
@@ -25,12 +25,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container">
|
<div class="admin-container">
|
||||||
<div class="admin-panel">
|
<div class="container">
|
||||||
|
<div class="admin-panel">
|
||||||
<h2>Benutzerverwaltung</h2>
|
<h2>Benutzerverwaltung</h2>
|
||||||
|
|
||||||
<div class="add-user-form">
|
<!-- Benutzer anlegen - Zusammenklappbar -->
|
||||||
<h3>Neuen Benutzer anlegen</h3>
|
<div class="add-user-section" style="margin-top: 20px;">
|
||||||
|
<div class="collapsible-header" onclick="toggleAddUserSection()" style="cursor: pointer; padding: 15px; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h3 style="margin: 0;">Neuen Benutzer anlegen</h3>
|
||||||
|
<span id="addUserToggleIcon" style="font-size: 18px; transition: transform 0.3s;">▼</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="addUserContent" style="display: none; padding: 20px; border: 1px solid #ddd; border-top: none; border-radius: 0 0 4px 4px; background-color: #fff;">
|
||||||
|
<div class="add-user-form">
|
||||||
<form id="addUserForm">
|
<form id="addUserForm">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -94,11 +102,20 @@
|
|||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Benutzer anlegen</button>
|
<button type="submit" class="btn btn-primary">Benutzer anlegen</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="user-list">
|
<!-- Benutzer-Liste - Zusammenklappbar -->
|
||||||
<h3>Benutzer-Liste</h3>
|
<div class="user-list-section" style="margin-top: 20px;">
|
||||||
<table>
|
<div class="collapsible-header" onclick="toggleUserListSection()" style="cursor: pointer; padding: 15px; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h3 style="margin: 0;">Benutzer-Liste</h3>
|
||||||
|
<span id="userListToggleIcon" style="font-size: 18px; transition: transform 0.3s;">▼</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="userListContent" style="display: none; padding: 20px; border: 1px solid #ddd; border-top: none; border-radius: 0 0 4px 4px; background-color: #fff; overflow-x: auto; max-width: 100%;">
|
||||||
|
<div class="user-list" style="min-width: 100%;">
|
||||||
|
<table style="width: 100%; min-width: 900px;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
@@ -178,11 +195,17 @@
|
|||||||
<% }); %>
|
<% }); %>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ldap-sync-section" style="margin-top: 40px;">
|
<div class="ldap-sync-section" style="margin-top: 40px;">
|
||||||
<h2>LDAP-Synchronisation</h2>
|
<div class="collapsible-header" onclick="toggleLDAPSection()" style="cursor: pointer; padding: 15px; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<h2 style="margin: 0;">LDAP-Synchronisation</h2>
|
||||||
|
<span id="ldapToggleIcon" style="font-size: 18px; transition: transform 0.3s;">▼</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ldapContent" style="display: none; padding: 20px; border: 1px solid #ddd; border-top: none; border-radius: 0 0 4px 4px; background-color: #fff;">
|
||||||
<div class="ldap-config-form">
|
<div class="ldap-config-form">
|
||||||
<h3>LDAP-Konfiguration</h3>
|
<h3>LDAP-Konfiguration</h3>
|
||||||
<form id="ldapConfigForm">
|
<form id="ldapConfigForm">
|
||||||
@@ -298,12 +321,56 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/admin.js"></script>
|
<script src="/js/admin.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
// Benutzer anlegen Sektion ein-/ausklappen
|
||||||
|
function toggleAddUserSection() {
|
||||||
|
const content = document.getElementById('addUserContent');
|
||||||
|
const icon = document.getElementById('addUserToggleIcon');
|
||||||
|
|
||||||
|
if (content.style.display === 'none') {
|
||||||
|
content.style.display = 'block';
|
||||||
|
icon.style.transform = 'rotate(180deg)';
|
||||||
|
} else {
|
||||||
|
content.style.display = 'none';
|
||||||
|
icon.style.transform = 'rotate(0deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benutzer-Liste Sektion ein-/ausklappen
|
||||||
|
function toggleUserListSection() {
|
||||||
|
const content = document.getElementById('userListContent');
|
||||||
|
const icon = document.getElementById('userListToggleIcon');
|
||||||
|
|
||||||
|
if (content.style.display === 'none') {
|
||||||
|
content.style.display = 'block';
|
||||||
|
icon.style.transform = 'rotate(180deg)';
|
||||||
|
} else {
|
||||||
|
content.style.display = 'none';
|
||||||
|
icon.style.transform = 'rotate(0deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAP-Sektion ein-/ausklappen
|
||||||
|
function toggleLDAPSection() {
|
||||||
|
const content = document.getElementById('ldapContent');
|
||||||
|
const icon = document.getElementById('ldapToggleIcon');
|
||||||
|
|
||||||
|
if (content.style.display === 'none') {
|
||||||
|
content.style.display = 'block';
|
||||||
|
icon.style.transform = 'rotate(180deg)';
|
||||||
|
} else {
|
||||||
|
content.style.display = 'none';
|
||||||
|
icon.style.transform = 'rotate(0deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rollenwechsel-Handler
|
// Rollenwechsel-Handler
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const roleSwitcher = document.getElementById('roleSwitcher');
|
const roleSwitcher = document.getElementById('roleSwitcher');
|
||||||
|
|||||||
@@ -44,6 +44,10 @@
|
|||||||
<strong>Gesamtstunden diese Woche:</strong>
|
<strong>Gesamtstunden diese Woche:</strong>
|
||||||
<span id="totalHours">0.00 h</span>
|
<span id="totalHours">0.00 h</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="summary-item" id="overtimeSummaryItem" style="display: none;">
|
||||||
|
<strong>Überstunden diese Woche:</strong>
|
||||||
|
<span id="overtimeHours">0.00 h</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -51,11 +55,11 @@
|
|||||||
<p class="help-text">Stunden werden automatisch gespeichert. Am Ende der Woche können Sie die Stunden abschicken.</p>
|
<p class="help-text">Stunden werden automatisch gespeichert. Am Ende der Woche können Sie die Stunden abschicken.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auswertung: Überstunden und Urlaubstage -->
|
<!-- Rechte Seitenleiste mit Statistiken und Erfassungs-URLs -->
|
||||||
<div class="user-stats-panel" id="userStatsPanel">
|
<div class="user-stats-panel">
|
||||||
<h3>Ihre Auswertung</h3>
|
<!-- Statistik-Karten -->
|
||||||
<div class="stat-card stat-overtime">
|
<div class="stat-card">
|
||||||
<div class="stat-label">Aktuelle Überstunden</div>
|
<div class="stat-label">Aktuelle Überstunden</div>
|
||||||
<div class="stat-value" id="currentOvertime">-</div>
|
<div class="stat-value" id="currentOvertime">-</div>
|
||||||
<div class="stat-unit">Stunden</div>
|
<div class="stat-unit">Stunden</div>
|
||||||
@@ -66,28 +70,36 @@
|
|||||||
<div class="stat-unit">von <span id="totalVacation">-</span> Tagen</div>
|
<div class="stat-unit">von <span id="totalVacation">-</span> Tagen</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API-URLs für Zeiterfassung -->
|
<!-- URL-Erfassung -->
|
||||||
<div class="stat-card stat-api-urls" style="margin-top: 20px; padding: 15px; box-sizing: border-box; width: 100%;">
|
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #e0e0e0;">
|
||||||
<h4 style="margin-bottom: 15px; font-size: 14px; color: #555;">Schnelle Zeiterfassung</h4>
|
<h3 style="font-size: 14px; margin-bottom: 15px; color: #2c3e50;">Zeiterfassung per URL</h3>
|
||||||
<div style="margin-bottom: 12px;">
|
<div class="form-group" style="margin-bottom: 15px;">
|
||||||
<label style="display: block; font-size: 12px; color: #666; margin-bottom: 5px;">Kommen (Check-in):</label>
|
<label style="font-size: 12px; color: #666; margin-bottom: 5px;">Check-in URL</label>
|
||||||
<div style="display: flex; gap: 8px; align-items: center; width: 100%;">
|
<div style="display: flex; gap: 5px;">
|
||||||
<input type="text" id="checkinUrl" readonly value=""
|
<input type="text" id="checkinUrl" readonly style="flex: 1; padding: 8px; font-size: 11px; border: 1px solid #ddd; border-radius: 4px; background: #f8f9fa;">
|
||||||
style="flex: 1; min-width: 0; padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 12px; background-color: #f9f9f9;">
|
<button onclick="copyToClipboard('checkinUrl')" class="btn btn-sm btn-secondary" style="padding: 8px 12px;">Kopieren</button>
|
||||||
<button onclick="copyToClipboard('checkinUrl')" class="btn btn-secondary btn-sm" style="padding: 6px 12px; font-size: 12px; flex-shrink: 0;">Kopieren</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="form-group" style="margin-bottom: 15px;">
|
||||||
<label style="display: block; font-size: 12px; color: #666; margin-bottom: 5px;">Gehen (Check-out):</label>
|
<label style="font-size: 12px; color: #666; margin-bottom: 5px;">Check-out URL</label>
|
||||||
<div style="display: flex; gap: 8px; align-items: center; width: 100%;">
|
<div style="display: flex; gap: 5px;">
|
||||||
<input type="text" id="checkoutUrl" readonly value=""
|
<input type="text" id="checkoutUrl" readonly style="flex: 1; padding: 8px; font-size: 11px; border: 1px solid #ddd; border-radius: 4px; background: #f8f9fa;">
|
||||||
style="flex: 1; min-width: 0; padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 12px; background-color: #f9f9f9;">
|
<button onclick="copyToClipboard('checkoutUrl')" class="btn btn-sm btn-secondary" style="padding: 8px 12px;">Kopieren</button>
|
||||||
<button onclick="copyToClipboard('checkoutUrl')" class="btn btn-secondary btn-sm" style="padding: 6px 12px; font-size: 12px; flex-shrink: 0;">Kopieren</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p style="margin-top: 10px; font-size: 11px; color: #888; line-height: 1.4;">
|
</div>
|
||||||
Diese URLs können Sie in einer App eintragen oder direkt im Browser aufrufen, um Ihre Arbeitszeiten zu erfassen.
|
|
||||||
</p>
|
<!-- IP-Erfassung -->
|
||||||
|
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #e0e0e0;">
|
||||||
|
<h3 style="font-size: 14px; margin-bottom: 15px; color: #2c3e50;">IP-basierte Zeiterfassung</h3>
|
||||||
|
<div class="form-group" style="margin-bottom: 15px;">
|
||||||
|
<label style="font-size: 12px; color: #666; margin-bottom: 5px;">Ping-IP Adresse</label>
|
||||||
|
<div style="display: flex; gap: 5px;">
|
||||||
|
<input type="text" id="pingIpInput" placeholder="z.B. 192.168.1.100" style="flex: 1; padding: 8px; font-size: 12px; border: 1px solid #ddd; border-radius: 4px;">
|
||||||
|
<button onclick="window.savePingIP()" class="btn btn-sm btn-success" style="padding: 8px 12px;">Speichern</button>
|
||||||
|
</div>
|
||||||
|
<p style="font-size: 11px; color: #666; margin-top: 5px; font-style: italic;">Ihre IP-Adresse für automatische Zeiterfassung</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,6 +107,22 @@
|
|||||||
|
|
||||||
<script src="/js/dashboard.js"></script>
|
<script src="/js/dashboard.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
// Wochenende-Sektion ein-/ausklappen
|
||||||
|
function toggleWeekendSection() {
|
||||||
|
const content = document.getElementById('weekendContent');
|
||||||
|
const icon = document.getElementById('weekendToggleIcon');
|
||||||
|
|
||||||
|
if (content && icon) {
|
||||||
|
if (content.style.display === 'none') {
|
||||||
|
content.style.display = 'block';
|
||||||
|
icon.style.transform = 'rotate(180deg)';
|
||||||
|
} else {
|
||||||
|
content.style.display = 'none';
|
||||||
|
icon.style.transform = 'rotate(0deg)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// URL-Kopier-Funktion
|
// URL-Kopier-Funktion
|
||||||
function copyToClipboard(inputId) {
|
function copyToClipboard(inputId) {
|
||||||
const input = document.getElementById(inputId);
|
const input = document.getElementById(inputId);
|
||||||
@@ -115,14 +143,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// URLs mit aktueller Domain aktualisieren
|
// URLs mit aktueller Domain aktualisieren (Port 3334 für Check-in)
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const userId = '<%= user.id %>';
|
const userId = '<%= user.id %>';
|
||||||
const baseUrl = window.location.origin;
|
const baseUrl = window.location.origin;
|
||||||
|
// Check-in URLs verwenden Port 3334
|
||||||
|
// Ersetze Port in URL oder füge Port hinzu falls nicht vorhanden
|
||||||
|
let checkinBaseUrl;
|
||||||
|
if (baseUrl.match(/:\d+$/)) {
|
||||||
|
// Port vorhanden - ersetze ihn
|
||||||
|
checkinBaseUrl = baseUrl.replace(/:\d+$/, ':3334');
|
||||||
|
} else {
|
||||||
|
// Kein Port - füge Port hinzu
|
||||||
|
const url = new URL(baseUrl);
|
||||||
|
checkinBaseUrl = `${url.protocol}//${url.hostname}:3334`;
|
||||||
|
}
|
||||||
const checkinInput = document.getElementById('checkinUrl');
|
const checkinInput = document.getElementById('checkinUrl');
|
||||||
const checkoutInput = document.getElementById('checkoutUrl');
|
const checkoutInput = document.getElementById('checkoutUrl');
|
||||||
if (checkinInput) checkinInput.value = `${baseUrl}/api/checkin/${userId}`;
|
if (checkinInput) checkinInput.value = `${checkinBaseUrl}/api/checkin/${userId}`;
|
||||||
if (checkoutInput) checkoutInput.value = `${baseUrl}/api/checkout/${userId}`;
|
if (checkoutInput) checkoutInput.value = `${checkinBaseUrl}/api/checkout/${userId}`;
|
||||||
|
|
||||||
// Rollenwechsel-Handler
|
// Rollenwechsel-Handler
|
||||||
const roleSwitcher = document.getElementById('roleSwitcher');
|
const roleSwitcher = document.getElementById('roleSwitcher');
|
||||||
|
|||||||
@@ -27,6 +27,16 @@
|
|||||||
<input type="password" id="password" name="password" required>
|
<input type="password" id="password" name="password" required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: flex-start; cursor: pointer;">
|
||||||
|
<input type="checkbox" name="remember_me" id="remember_me" style="margin-right: 8px; margin-top: 2px;">
|
||||||
|
<span style="display: flex; flex-direction: column;">
|
||||||
|
<span>Angemeldet bleiben</span>
|
||||||
|
<span style="font-size: 0.9em; color: #666;">(30 Tage)</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">Anmelden</button>
|
<button type="submit" class="btn btn-primary">Anmelden</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,6 +29,41 @@
|
|||||||
<div class="verwaltung-panel">
|
<div class="verwaltung-panel">
|
||||||
<h2>Postfach - Eingereichte Stundenzettel</h2>
|
<h2>Postfach - Eingereichte Stundenzettel</h2>
|
||||||
|
|
||||||
|
<!-- Massendownload für Kalenderwoche -->
|
||||||
|
<div style="margin-bottom: 30px; padding: 20px; background-color: #f8f9fa; border-radius: 8px; border: 1px solid #dee2e6;">
|
||||||
|
<h3 style="margin-top: 0; margin-bottom: 15px; font-size: 16px; color: #333;">Massendownload für Kalenderwoche</h3>
|
||||||
|
<div style="display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap;">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 5px;">
|
||||||
|
<label for="bulkDownloadYear" style="font-size: 13px; color: #555; font-weight: 500;">Jahr:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="bulkDownloadYear"
|
||||||
|
min="2000"
|
||||||
|
max="2100"
|
||||||
|
value="<%= new Date().getFullYear() %>"
|
||||||
|
style="padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; width: 100px;"
|
||||||
|
placeholder="2024">
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 5px;">
|
||||||
|
<label for="bulkDownloadWeek" style="font-size: 13px; color: #555; font-weight: 500;">Kalenderwoche:</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
id="bulkDownloadWeek"
|
||||||
|
min="1"
|
||||||
|
max="53"
|
||||||
|
style="padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; width: 100px;"
|
||||||
|
placeholder="5">
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
id="bulkDownloadBtn"
|
||||||
|
class="btn btn-primary"
|
||||||
|
style="padding: 8px 20px; font-size: 14px; white-space: nowrap;">
|
||||||
|
Alle PDFs für KW herunterladen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="bulkDownloadStatus" style="margin-top: 12px; font-size: 13px; color: #666; display: none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<% if (!groupedByEmployee || groupedByEmployee.length === 0) { %>
|
<% if (!groupedByEmployee || groupedByEmployee.length === 0) { %>
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<p>Keine eingereichten Stundenzettel vorhanden.</p>
|
<p>Keine eingereichten Stundenzettel vorhanden.</p>
|
||||||
@@ -53,6 +88,25 @@
|
|||||||
<div style="display: inline-block; margin-right: 20px;">
|
<div style="display: inline-block; margin-right: 20px;">
|
||||||
<strong>Urlaubstage:</strong> <span><%= employee.user.urlaubstage || '-' %></span>
|
<strong>Urlaubstage:</strong> <span><%= employee.user.urlaubstage || '-' %></span>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display: inline-flex; gap: 8px; align-items: center; margin-right: 20px;">
|
||||||
|
<strong>Überstunden-Offset:</strong>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.25"
|
||||||
|
class="overtime-offset-input"
|
||||||
|
data-user-id="<%= employee.user.id %>"
|
||||||
|
value="<%= (employee.user.overtime_offset_hours !== undefined && employee.user.overtime_offset_hours !== null) ? employee.user.overtime_offset_hours : 0 %>"
|
||||||
|
style="width: 90px; padding: 4px 6px; border: 1px solid #ddd; border-radius: 4px;"
|
||||||
|
title="Manuelle Korrektur (positiv oder negativ) in Stunden" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-success btn-sm save-overtime-offset-btn"
|
||||||
|
data-user-id="<%= employee.user.id %>"
|
||||||
|
style="padding: 6px 10px; white-space: nowrap;"
|
||||||
|
title="Überstunden-Offset speichern">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div style="display: inline-block; margin-right: 20px;">
|
<div style="display: inline-block; margin-right: 20px;">
|
||||||
<strong>Kalenderwochen:</strong> <span><%= employee.weeks.length %></span>
|
<strong>Kalenderwochen:</strong> <span><%= employee.weeks.length %></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,7 +124,20 @@
|
|||||||
<div class="week-header">
|
<div class="week-header">
|
||||||
<div class="week-info">
|
<div class="week-info">
|
||||||
<div class="week-dates">
|
<div class="week-dates">
|
||||||
<strong>Kalenderwoche:</strong> <%= new Date(week.week_start).toLocaleDateString('de-DE') %> -
|
<%
|
||||||
|
// Kalenderwoche berechnen
|
||||||
|
function getCalendarWeek(dateStr) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||||
|
const dayNum = d.getUTCDay() || 7;
|
||||||
|
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||||
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||||
|
const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
||||||
|
return weekNo;
|
||||||
|
}
|
||||||
|
const calendarWeek = getCalendarWeek(week.week_start);
|
||||||
|
%>
|
||||||
|
<strong>Kalenderwoche <%= String(calendarWeek).padStart(2, '0') %>:</strong> <%= new Date(week.week_start).toLocaleDateString('de-DE') %> -
|
||||||
<%= new Date(week.week_end).toLocaleDateString('de-DE') %>
|
<%= new Date(week.week_end).toLocaleDateString('de-DE') %>
|
||||||
</div>
|
</div>
|
||||||
<div class="group-stats" data-user-id="<%= employee.user.id %>" data-week-start="<%= week.week_start %>" data-week-end="<%= week.week_end %>" style="margin-top: 10px;">
|
<div class="group-stats" data-user-id="<%= employee.user.id %>" data-week-start="<%= week.week_start %>" data-week-end="<%= week.week_end %>" style="margin-top: 10px;">
|
||||||
@@ -205,13 +272,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Statistiken für alle Wochen laden
|
async function loadStatsForDiv(statsDiv) {
|
||||||
document.querySelectorAll('.group-stats').forEach(statsDiv => {
|
|
||||||
const userId = statsDiv.dataset.userId;
|
const userId = statsDiv.dataset.userId;
|
||||||
const weekStart = statsDiv.dataset.weekStart;
|
const weekStart = statsDiv.dataset.weekStart;
|
||||||
const weekEnd = statsDiv.dataset.weekEnd;
|
const weekEnd = statsDiv.dataset.weekEnd;
|
||||||
|
|
||||||
fetch(`/api/verwaltung/user/${userId}/stats?week_start=${weekStart}&week_end=${weekEnd}`)
|
return fetch(`/api/verwaltung/user/${userId}/stats?week_start=${weekStart}&week_end=${weekEnd}`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const loadingDiv = statsDiv.querySelector('.stats-loading');
|
const loadingDiv = statsDiv.querySelector('.stats-loading');
|
||||||
@@ -219,17 +285,26 @@
|
|||||||
loadingDiv.style.display = 'none';
|
loadingDiv.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vorherige Stats entfernen (wenn reloaded)
|
||||||
|
statsDiv.querySelectorAll('.stats-inline').forEach(n => n.remove());
|
||||||
|
|
||||||
// Statistiken anzeigen
|
// Statistiken anzeigen
|
||||||
let statsHTML = '';
|
let statsHTML = '';
|
||||||
if (data.overtimeHours !== undefined) {
|
if (data.overtimeHours !== undefined) {
|
||||||
statsHTML += `<div style="display: inline-block; margin-right: 20px;">
|
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
|
||||||
<strong>Überstunden:</strong> <span>${data.overtimeHours.toFixed(2)} h</span>
|
<strong>Überstunden:</strong> <span>${data.overtimeHours.toFixed(2)} h</span>
|
||||||
${data.overtimeTaken > 0 ? `<span style="color: #666;">(davon genommen: ${data.overtimeTaken.toFixed(2)} h)</span>` : ''}
|
${data.overtimeTaken > 0 ? `<span style="color: #666;">(davon genommen: ${data.overtimeTaken.toFixed(2)} h)</span>` : ''}
|
||||||
${data.remainingOvertime !== data.overtimeHours ? `<span style="color: #28a745;">(verbleibend: ${data.remainingOvertime.toFixed(2)} h)</span>` : ''}
|
${data.remainingOvertime !== data.overtimeHours ? `<span style="color: #28a745;">(verbleibend: ${data.remainingOvertime.toFixed(2)} h)</span>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
if (data.overtimeOffsetHours !== undefined && data.overtimeOffsetHours !== 0) {
|
||||||
|
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
|
||||||
|
<strong>Offset:</strong> <span>${Number(data.overtimeOffsetHours).toFixed(2)} h</span>
|
||||||
|
${data.remainingOvertimeWithOffset !== undefined ? `<span style="color: #28a745;">(verbleibend inkl. Offset: ${Number(data.remainingOvertimeWithOffset).toFixed(2)} h)</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
if (data.vacationDays !== undefined) {
|
if (data.vacationDays !== undefined) {
|
||||||
statsHTML += `<div style="display: inline-block; margin-right: 20px;">
|
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
|
||||||
<strong>Urlaub genommen:</strong> <span>${data.vacationDays.toFixed(1)} Tag${data.vacationDays !== 1 ? 'e' : ''}</span>
|
<strong>Urlaub genommen:</strong> <span>${data.vacationDays.toFixed(1)} Tag${data.vacationDays !== 1 ? 'e' : ''}</span>
|
||||||
${data.remainingVacation !== undefined ? `<span style="color: #28a745;">(verbleibend: ${data.remainingVacation.toFixed(1)} Tage)</span>` : ''}
|
${data.remainingVacation !== undefined ? `<span style="color: #28a745;">(verbleibend: ${data.remainingVacation.toFixed(1)} Tage)</span>` : ''}
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -247,6 +322,71 @@
|
|||||||
loadingDiv.style.color = 'red';
|
loadingDiv.style.color = 'red';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Statistiken für alle Wochen initial laden
|
||||||
|
document.querySelectorAll('.group-stats').forEach(statsDiv => loadStatsForDiv(statsDiv));
|
||||||
|
|
||||||
|
// Überstunden-Offset speichern
|
||||||
|
document.querySelectorAll('.save-overtime-offset-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async function() {
|
||||||
|
const userId = this.dataset.userId;
|
||||||
|
const input = document.querySelector(`.overtime-offset-input[data-user-id="${userId}"]`);
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
const originalText = this.textContent;
|
||||||
|
this.disabled = true;
|
||||||
|
this.textContent = '...';
|
||||||
|
|
||||||
|
// leere Eingabe => 0 (Backend macht das auch, aber UI soll sauber sein)
|
||||||
|
const raw = (input.value || '').trim();
|
||||||
|
const value = raw === '' ? '' : Number(raw);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/verwaltung/user/${userId}/overtime-offset`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ overtime_offset_hours: value })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!resp.ok) {
|
||||||
|
alert(data.error || 'Fehler beim Speichern des Offsets');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalisiere Input auf Zahl (Backend gibt number zurück)
|
||||||
|
input.value = (data.overtime_offset_hours !== undefined && data.overtime_offset_hours !== null)
|
||||||
|
? Number(data.overtime_offset_hours)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Stats für diesen User neu laden
|
||||||
|
const statDivs = document.querySelectorAll(`.group-stats[data-user-id="${userId}"]`);
|
||||||
|
statDivs.forEach(div => {
|
||||||
|
// loading indicator optional wieder anzeigen
|
||||||
|
const loading = div.querySelector('.stats-loading');
|
||||||
|
if (loading) {
|
||||||
|
loading.style.display = 'inline-block';
|
||||||
|
loading.style.color = '#666';
|
||||||
|
loading.textContent = 'Lade Statistiken...';
|
||||||
|
}
|
||||||
|
loadStatsForDiv(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.textContent = '✓';
|
||||||
|
setTimeout(() => {
|
||||||
|
this.textContent = originalText;
|
||||||
|
this.disabled = false;
|
||||||
|
}, 900);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Speichern des Offsets:', e);
|
||||||
|
alert('Fehler beim Speichern des Offsets');
|
||||||
|
} finally {
|
||||||
|
if (this.textContent === '...') {
|
||||||
|
this.textContent = originalText;
|
||||||
|
this.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mitarbeiter-Gruppen auf-/zuklappen (zeigt/versteckt Wochen)
|
// Mitarbeiter-Gruppen auf-/zuklappen (zeigt/versteckt Wochen)
|
||||||
@@ -469,6 +609,88 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Massendownload für Kalenderwoche
|
||||||
|
const bulkDownloadBtn = document.getElementById('bulkDownloadBtn');
|
||||||
|
const bulkDownloadYear = document.getElementById('bulkDownloadYear');
|
||||||
|
const bulkDownloadWeek = document.getElementById('bulkDownloadWeek');
|
||||||
|
const bulkDownloadStatus = document.getElementById('bulkDownloadStatus');
|
||||||
|
|
||||||
|
if (bulkDownloadBtn) {
|
||||||
|
bulkDownloadBtn.addEventListener('click', async function() {
|
||||||
|
const year = parseInt(bulkDownloadYear.value);
|
||||||
|
const week = parseInt(bulkDownloadWeek.value);
|
||||||
|
|
||||||
|
// Validierung
|
||||||
|
if (!year || year < 2000 || year > 2100) {
|
||||||
|
bulkDownloadStatus.textContent = 'Bitte geben Sie ein gültiges Jahr ein (2000-2100)';
|
||||||
|
bulkDownloadStatus.style.display = 'block';
|
||||||
|
bulkDownloadStatus.style.color = '#dc3545';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!week || week < 1 || week > 53) {
|
||||||
|
bulkDownloadStatus.textContent = 'Bitte geben Sie eine gültige Kalenderwoche ein (1-53)';
|
||||||
|
bulkDownloadStatus.style.display = 'block';
|
||||||
|
bulkDownloadStatus.style.color = '#dc3545';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button deaktivieren und Status anzeigen
|
||||||
|
bulkDownloadBtn.disabled = true;
|
||||||
|
bulkDownloadBtn.textContent = 'Lädt...';
|
||||||
|
bulkDownloadStatus.textContent = 'PDFs werden generiert und ZIP erstellt...';
|
||||||
|
bulkDownloadStatus.style.display = 'block';
|
||||||
|
bulkDownloadStatus.style.color = '#666';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/verwaltung/bulk-download/${year}/${week}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: 'Unbekannter Fehler' }));
|
||||||
|
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZIP-Download starten
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `Stundenzettel_KW${String(week).padStart(2, '0')}_${year}.zip`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
// Erfolgsmeldung
|
||||||
|
bulkDownloadStatus.textContent = `✓ ZIP erfolgreich heruntergeladen (KW ${week}/${year})`;
|
||||||
|
bulkDownloadStatus.style.color = '#28a745';
|
||||||
|
|
||||||
|
// Seite nach kurzer Verzögerung neu laden, um Download-Marker zu aktualisieren
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Massendownload:', error);
|
||||||
|
bulkDownloadStatus.textContent = `Fehler: ${error.message || 'Unbekannter Fehler'}`;
|
||||||
|
bulkDownloadStatus.style.color = '#dc3545';
|
||||||
|
bulkDownloadBtn.disabled = false;
|
||||||
|
bulkDownloadBtn.textContent = 'Alle PDFs für KW herunterladen';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Enter-Taste in Eingabefeldern
|
||||||
|
[bulkDownloadYear, bulkDownloadWeek].forEach(input => {
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
bulkDownloadBtn.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Rollenwechsel-Handler
|
// Rollenwechsel-Handler
|
||||||
|
|||||||
Reference in New Issue
Block a user