Refactoring

This commit is contained in:
Carsten Graf
2026-01-23 14:13:18 +01:00
parent 33c62a7a2a
commit a0acd188a8
22 changed files with 2392 additions and 1611 deletions

20
.dockerignore Normal file
View 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
View 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
View 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;

View File

@@ -2,7 +2,8 @@ const sqlite3 = require('sqlite3').verbose();
const bcrypt = require('bcryptjs');
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);
// Datenbank initialisieren
@@ -183,6 +184,27 @@ function initDatabase() {
db.run(`ALTER TABLE users ADD COLUMN urlaubstage REAL`, (err) => {
// Fehler ignorieren wenn Spalte bereits existiert
});
// 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
db.run(`CREATE TABLE IF NOT EXISTS ldap_config (

15
docker-compose.yml Normal file
View 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

86
helpers/utils.js Normal file
View File

@@ -0,0 +1,86 @@
// 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');
}
module.exports = {
hasRole,
getDefaultRole,
getCurrentDate,
getCurrentTime,
calculateBreakMinutes,
updateTotalHours,
formatDate,
formatDateTime
};

48
middleware/auth.js Normal file
View 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
};

14
package-lock.json generated
View File

@@ -16,6 +16,7 @@
"ldapjs": "^3.0.7",
"node-cron": "^3.0.3",
"pdfkit": "^0.13.0",
"ping": "^0.4.4",
"sqlite3": "^5.1.6"
},
"devDependencies": {
@@ -2485,6 +2486,14 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/ping": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/ping/-/ping-0.4.4.tgz",
"integrity": "sha512-56ZMC0j7SCsMMLdOoUg12VZCfj/+ZO+yfOSjaNCRrmZZr6GLbN2X/Ui56T15dI8NhiHckaw5X2pvyfAomanwqQ==",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/png-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
@@ -5286,6 +5295,11 @@
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true
},
"ping": {
"version": "0.4.4",
"resolved": "https://registry.npmjs.org/ping/-/ping-0.4.4.tgz",
"integrity": "sha512-56ZMC0j7SCsMMLdOoUg12VZCfj/+ZO+yfOSjaNCRrmZZr6GLbN2X/Ui56T15dI8NhiHckaw5X2pvyfAomanwqQ=="
},
"png-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",

View File

@@ -17,7 +17,8 @@
"ejs": "^3.1.9",
"pdfkit": "^0.13.0",
"ldapjs": "^3.0.7",
"node-cron": "^3.0.3"
"node-cron": "^3.0.3",
"ping": "^0.4.4"
},
"devDependencies": {
"nodemon": "^3.0.1"

View File

@@ -64,6 +64,9 @@ document.addEventListener('DOMContentLoaded', async function() {
// Statistiken laden
loadUserStats();
// Ping-IP laden
loadPingIP();
loadWeek();
document.getElementById('prevWeek').addEventListener('click', function() {
@@ -371,9 +374,9 @@ function renderWeek() {
step="0.25"
placeholder="0.00"
${disabled}
onblur="saveEntry(this)"
onblur="handleOvertimeChange('${dateStr}', this.value); saveEntry(this);"
oninput="updateOvertimeDisplay();"
onchange="updateOvertimeDisplay();"
onchange="handleOvertimeChange('${dateStr}', this.value); updateOvertimeDisplay();"
style="width: 80px; margin-left: 5px;"
class="overtime-input">
<span>h</span>
@@ -620,6 +623,85 @@ function updateOvertimeDisplay() {
}
}
// Ü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
}
}
}
// Eintrag speichern
async function saveEntry(input) {
const date = input.dataset.date;
@@ -1200,3 +1282,67 @@ function toggleSickStatus(dateStr) {
}
}
}
// 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');
}
};

167
routes/admin-ldap.js Normal file
View 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
View 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;

121
routes/auth.js Normal file
View File

@@ -0,0 +1,121 @@
// 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) {
// 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;
// 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 } = req.body;
// 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);
} 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);
});
}
});
} 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);
} 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
View 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;

378
routes/timesheet.js Normal file
View File

@@ -0,0 +1,378 @@
// 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;
finalActivity1Desc = 'Überstunden';
finalActivity1Hours = 0;
} 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-Logik: Bei weniger als vollem Tag - füge "Überstunden" als Tätigkeit hinzu
if (overtimeValue > 0 && !isFullDayOvertime && fullDayHours > 0) {
// Prüfe ob "Überstunden" bereits in activities vorhanden
const activities = [
{ desc: finalActivity1Desc, hours: finalActivity1Hours },
{ desc: finalActivity2Desc, hours: activity2_hours },
{ desc: finalActivity3Desc, hours: activity3_hours },
{ desc: finalActivity4Desc, hours: activity4_hours },
{ desc: finalActivity5Desc, hours: activity5_hours }
];
let foundOvertime = false;
for (let i = 0; i < activities.length; i++) {
if (activities[i].desc && activities[i].desc.trim().toLowerCase() === 'überstunden') {
foundOvertime = true;
break;
}
}
// Wenn nicht gefunden, füge zur ersten freien Activity-Spalte hinzu
if (!foundOvertime) {
for (let i = 0; i < activities.length; i++) {
if (!activities[i].desc || activities[i].desc.trim() === '') {
// Setze diese Activity auf "Überstunden"
if (i === 0) {
finalActivity1Desc = 'Überstunden';
// Stunden bleiben unverändert (werden vom User eingegeben)
} else if (i === 1) {
finalActivity2Desc = 'Überstunden';
} else if (i === 2) {
finalActivity3Desc = 'Überstunden';
} else if (i === 3) {
finalActivity4Desc = 'Überstunden';
} else if (i === 4) {
finalActivity5Desc = 'Überstunden';
}
break;
}
}
}
}
// 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;

310
routes/user.js Normal file
View File

@@ -0,0 +1,310 @@
// 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 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;
// 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: 0,
remainingVacation: urlaubstage,
totalOvertimeHours: 0,
totalOvertimeTaken: 0,
totalVacationDays: 0,
urlaubstage: urlaubstage
});
}
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;
const remainingVacation = urlaubstage - totalVacationDays;
res.json({
currentOvertime: currentOvertime,
remainingVacation: remainingVacation,
totalOvertimeHours: totalOvertimeHours,
totalOvertimeTaken: totalOvertimeTaken,
totalVacationDays: totalVacationDays,
urlaubstage: urlaubstage
});
}
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.total_hours) {
weekTotalHours += entry.total_hours;
}
if (entry.overtime_taken_hours) {
weekOvertimeTaken += entry.overtime_taken_hours;
}
if (entry.vacation_type === 'full') {
weekVacationDays += 1;
weekVacationHours += 8;
} else if (entry.vacation_type === 'half') {
weekVacationDays += 0.5;
weekVacationHours += 4;
}
});
// 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;
const remainingVacation = urlaubstage - totalVacationDays;
res.json({
currentOvertime: currentOvertime,
remainingVacation: remainingVacation,
totalOvertimeHours: totalOvertimeHours,
totalOvertimeTaken: totalOvertimeTaken,
totalVacationDays: totalVacationDays,
urlaubstage: urlaubstage
});
}
});
});
});
});
});
}
module.exports = registerUserRoutes;

195
routes/verwaltung.js Normal file
View File

@@ -0,0 +1,195 @@
// Verwaltung Routes
const { db } = require('../database');
const { requireVerwaltung } = require('../middleware/auth');
// 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,
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
},
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- 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 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;
// 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.total_hours) {
totalHours += entry.total_hours;
}
if (entry.overtime_taken_hours) {
overtimeTaken += entry.overtime_taken_hours;
}
if (entry.vacation_type === 'full') {
vacationDays += 1;
vacationHours += 8; // Ganzer Tag = 8 Stunden
} else if (entry.vacation_type === 'half') {
vacationDays += 0.5;
vacationHours += 4; // Halber Tag = 4 Stunden
}
});
// 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;
// Verbleibende Urlaubstage
const remainingVacation = urlaubstage - vacationDays;
res.json({
wochenstunden,
urlaubstage,
totalHours,
sollStunden,
overtimeHours,
overtimeTaken,
remainingOvertime,
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 });
});
});
}
module.exports = registerVerwaltungRoutes;

1633
server.js

File diff suppressed because it is too large Load Diff

View 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 };

271
services/pdf-service.js Normal file
View File

@@ -0,0 +1,271 @@
// 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);
}
if (entry.total_hours) {
totalHours += entry.total_hours;
}
// Urlaubsstunden für Überstunden-Berechnung sammeln
if (entry.vacation_type === 'full') {
vacationHours += 8; // Ganzer Tag = 8 Stunden
} else if (entry.vacation_type === 'half') {
vacationHours += 4; // Halber Tag = 4 Stunden
}
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 = Gesamtstunden - Wochenstunden
// Urlaub zählt als normale Arbeitszeit, daher sind Urlaubsstunden bereits in totalHours enthalten
const overtimeHours = totalHours - 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 };

182
services/ping-service.js Normal file
View 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 };

View File

@@ -68,7 +68,7 @@
<!-- API-URLs für Zeiterfassung -->
<div class="stat-card stat-api-urls" style="margin-top: 20px; padding: 15px; box-sizing: border-box; width: 100%;">
<h4 style="margin-bottom: 15px; font-size: 14px; color: #555;">Schnelle Zeiterfassung</h4>
<h4 style="margin-bottom: 15px; font-size: 14px; color: #555;">Zeiterfassung vir URL</h4>
<div style="margin-bottom: 12px;">
<label style="display: block; font-size: 12px; color: #666; margin-bottom: 5px;">Kommen (Check-in):</label>
<div style="display: flex; gap: 8px; align-items: center; width: 100%;">
@@ -89,6 +89,22 @@
Diese URLs können Sie in einer App eintragen oder direkt im Browser aufrufen, um Ihre Arbeitszeiten zu erfassen.
</p>
</div>
<!-- IP-basierte automatische Zeiterfassung -->
<div class="stat-card stat-ping-ip" style="margin-top: 20px; padding: 15px; box-sizing: border-box; width: 100%;">
<h4 style="margin-bottom: 15px; font-size: 14px; color: #555;">Automatische Zeiterfassung per IP</h4>
<div style="margin-bottom: 12px;">
<label style="display: block; font-size: 12px; color: #666; margin-bottom: 5px;">IP-Adresse für automatische Zeiterfassung:</label>
<div style="display: flex; gap: 8px; align-items: center; width: 100%;">
<input type="text" id="pingIpInput" placeholder="z.B. 192.168.1.100"
style="flex: 1; min-width: 0; padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 12px;">
<button onclick="savePingIP()" class="btn btn-primary btn-sm" style="padding: 6px 12px; font-size: 12px; flex-shrink: 0;">Speichern</button>
</div>
</div>
<p style="margin-top: 10px; font-size: 11px; color: #888; line-height: 1.4;">
Geben Sie Ihre IP-Adresse ein. Das System pingt diese jede Minute automatisch. Bei erster Erreichbarkeit wird die Start-Zeit gesetzt, bei 3+ fehlgeschlagenen Pings die End-Zeit.
</p>
</div>
</div>
</div>
</div>