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 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
@@ -184,6 +185,27 @@ function initDatabase() {
// Fehler ignorieren wenn Spalte bereits existiert // 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 // LDAP-Konfiguration-Tabelle
db.run(`CREATE TABLE IF NOT EXISTS ldap_config ( db.run(`CREATE TABLE IF NOT EXISTS ldap_config (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,

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

View File

@@ -17,7 +17,8 @@
"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"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1" "nodemon": "^3.0.1"

View File

@@ -64,6 +64,9 @@ document.addEventListener('DOMContentLoaded', async function() {
// Statistiken laden // Statistiken laden
loadUserStats(); loadUserStats();
// Ping-IP laden
loadPingIP();
loadWeek(); loadWeek();
document.getElementById('prevWeek').addEventListener('click', function() { document.getElementById('prevWeek').addEventListener('click', function() {
@@ -371,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>
@@ -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 // Eintrag speichern
async function saveEntry(input) { async function saveEntry(input) {
const date = input.dataset.date; 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;

1637
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 --> <!-- API-URLs für Zeiterfassung -->
<div class="stat-card stat-api-urls" style="margin-top: 20px; padding: 15px; box-sizing: border-box; width: 100%;"> <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;"> <div style="margin-bottom: 12px;">
<label style="display: block; font-size: 12px; color: #666; margin-bottom: 5px;">Kommen (Check-in):</label> <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%;"> <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. Diese URLs können Sie in einer App eintragen oder direkt im Browser aufrufen, um Ihre Arbeitszeiten zu erfassen.
</p> </p>
</div> </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> </div>
</div> </div>