Init
This commit is contained in:
20
.dockerignore
Normal file
20
.dockerignore
Normal file
@@ -0,0 +1,20 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
SCHNELLSTART.md
|
||||
.env
|
||||
.env.local
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
logs
|
||||
*.log
|
||||
dev
|
||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
data/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Zeitzone einrichten
|
||||
RUN apk add --no-cache tzdata
|
||||
ENV TZ=Europe/Berlin
|
||||
|
||||
# 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"]
|
||||
152
README.md
Normal file
152
README.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Stundenerfassungs-System
|
||||
|
||||
Eine webbasierte Anwendung zur Erfassung von Arbeitszeiten mit Admin-Bereich und PDF-Export.
|
||||
|
||||
## Features
|
||||
|
||||
### Für Mitarbeiter
|
||||
- ✅ Login mit Benutzername und Passwort
|
||||
- ✅ Wöchentliche Stundenerfassung (Montag - Sonntag)
|
||||
- ✅ Automatisches Speichern der Einträge
|
||||
- ✅ Eingabe von Start-/Endzeit, Pausen und Notizen
|
||||
- ✅ Automatische Berechnung der Gesamtstunden
|
||||
- ✅ Wöchentliches Abschicken des Stundenzettels
|
||||
|
||||
### Für Administratoren
|
||||
- ✅ Benutzerverwaltung (Anlegen, Löschen)
|
||||
- ✅ Rollenvergabe (Mitarbeiter, Verwaltung, Admin)
|
||||
- ✅ Übersicht aller Benutzer
|
||||
|
||||
### Für Verwaltung
|
||||
- ✅ Postfach mit eingereichten Stundenzetteln
|
||||
- ✅ PDF-Generierung und Download
|
||||
- ✅ Übersichtliche Darstellung aller Einreichungen
|
||||
|
||||
## Installation
|
||||
|
||||
### Voraussetzungen
|
||||
- Node.js (Version 14 oder höher)
|
||||
- npm (wird mit Node.js installiert)
|
||||
|
||||
### Schritt 1: Dependencies installieren
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Schritt 2: Server starten
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
Der Server läuft nun auf `http://localhost:3333`
|
||||
|
||||
Für Entwicklung mit automatischem Neustart:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Standard-Zugangsdaten
|
||||
|
||||
Nach der Installation sind folgende Benutzer verfügbar:
|
||||
|
||||
### Administrator
|
||||
- **Benutzername:** admin
|
||||
- **Passwort:** admin123
|
||||
- **Funktion:** Kann Benutzer anlegen und verwalten
|
||||
|
||||
### Verwaltung
|
||||
- **Benutzername:** verwaltung
|
||||
- **Passwort:** verwaltung123
|
||||
- **Funktion:** Kann eingereichte Stundenzettel einsehen und PDFs erstellen
|
||||
|
||||
**WICHTIG:** Bitte ändern Sie diese Passwörter nach der ersten Anmeldung!
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Für Mitarbeiter
|
||||
|
||||
1. Melden Sie sich mit Ihren Zugangsdaten an
|
||||
2. Wählen Sie die gewünschte Woche aus (Pfeiltasten)
|
||||
3. Tragen Sie Ihre Arbeitszeiten ein:
|
||||
- **Start:** Arbeitsbeginn
|
||||
- **Ende:** Arbeitsende
|
||||
- **Pause:** Pausenzeit in Minuten
|
||||
- **Notizen:** Optional, z.B. Projekt oder Tätigkeit
|
||||
4. Die Einträge werden automatisch gespeichert
|
||||
5. Am Ende der Woche: Klicken Sie auf **"Woche abschicken"**
|
||||
6. Nach dem Abschicken können keine Änderungen mehr vorgenommen werden
|
||||
|
||||
### Für Administratoren
|
||||
|
||||
1. Melden Sie sich als Admin an
|
||||
2. Sie gelangen automatisch zur Benutzerverwaltung
|
||||
3. **Neuen Benutzer anlegen:**
|
||||
- Füllen Sie das Formular aus
|
||||
- Wählen Sie die passende Rolle
|
||||
- Klicken Sie auf "Benutzer anlegen"
|
||||
4. **Benutzer löschen:**
|
||||
- Klicken Sie auf "Löschen" neben dem gewünschten Benutzer
|
||||
- System-Benutzer (Admin, Verwaltung) können nicht gelöscht werden
|
||||
|
||||
### Für Verwaltung
|
||||
|
||||
1. Melden Sie sich als Verwaltungs-Benutzer an
|
||||
2. Sie sehen alle eingereichten Stundenzettel im Postfach
|
||||
3. **PDF erstellen:**
|
||||
- Klicken Sie auf "PDF herunterladen" neben dem gewünschten Stundenzettel
|
||||
- Die PDF wird automatisch generiert und heruntergeladen
|
||||
4. Die PDF enthält:
|
||||
- Mitarbeitername
|
||||
- Zeitraum
|
||||
- Alle Tageseinträge mit Start, Ende, Pause, Stunden und Notizen
|
||||
- Gesamtstundensumme
|
||||
|
||||
## Technologie-Stack
|
||||
|
||||
- **Backend:** Node.js + Express
|
||||
- **Datenbank:** SQLite3
|
||||
- **Template Engine:** EJS
|
||||
- **PDF-Generierung:** PDFKit
|
||||
- **Authentifizierung:** bcryptjs + express-session
|
||||
|
||||
## Datenbankstruktur
|
||||
|
||||
### Tabelle: users
|
||||
- Speichert Benutzerinformationen und Zugangsdaten
|
||||
- Passwörter werden verschlüsselt gespeichert
|
||||
|
||||
### Tabelle: timesheet_entries
|
||||
- Speichert einzelne Tageseinträge
|
||||
- Automatische Berechnung der Gesamtstunden
|
||||
|
||||
### Tabelle: weekly_timesheets
|
||||
- Speichert eingereichte Wochenstundenzettel
|
||||
- Verknüpfung mit Benutzer und Status
|
||||
|
||||
## Sicherheit
|
||||
|
||||
- ✅ Passwörter werden mit bcrypt verschlüsselt
|
||||
- ✅ Session-basierte Authentifizierung
|
||||
- ✅ Rollenbasierte Zugriffskontrolle
|
||||
- ✅ CSRF-Schutz durch Sessions
|
||||
|
||||
## Anpassungen
|
||||
|
||||
### Port ändern
|
||||
Bearbeiten Sie in `server.js` die Zeile:
|
||||
```javascript
|
||||
const PORT = 3333; // Ändern Sie hier den Port
|
||||
```
|
||||
|
||||
### Datenbank-Speicherort
|
||||
Die Datenbank wird standardmäßig als `stundenerfassung.db` im Projektverzeichnis gespeichert.
|
||||
|
||||
## Lizenz
|
||||
|
||||
Proprietär - Für interne Firmennutzung
|
||||
|
||||
## Support
|
||||
|
||||
Bei Fragen oder Problemen wenden Sie sich bitte an Ihre IT-Abteilung.
|
||||
110
SCHNELLSTART.md
Normal file
110
SCHNELLSTART.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Schnellstart-Anleitung
|
||||
|
||||
## Installation in 3 Schritten
|
||||
|
||||
### 1. Projekt entpacken
|
||||
Entpacken Sie das Projekt-Archiv in einen beliebigen Ordner auf Ihrem Server.
|
||||
|
||||
### 2. Dependencies installieren
|
||||
Öffnen Sie ein Terminal/Kommandozeile im Projekt-Ordner und führen Sie aus:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
Dies installiert alle benötigten Pakete:
|
||||
- express (Webserver)
|
||||
- sqlite3 (Datenbank)
|
||||
- bcryptjs (Passwort-Verschlüsselung)
|
||||
- express-session (Session-Verwaltung)
|
||||
- ejs (Template Engine)
|
||||
- pdfkit (PDF-Generierung)
|
||||
- body-parser (Request-Verarbeitung)
|
||||
|
||||
### 3. Server starten
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
Die Anwendung ist nun unter `http://localhost:3000` erreichbar.
|
||||
|
||||
## Erster Login
|
||||
|
||||
### Als Administrator
|
||||
- URL: `http://localhost:3000`
|
||||
- Benutzername: `admin`
|
||||
- Passwort: `admin123`
|
||||
|
||||
Nach dem Login können Sie:
|
||||
- Neue Mitarbeiter anlegen
|
||||
- Rollen vergeben (Mitarbeiter, Verwaltung, Admin)
|
||||
- Benutzer verwalten
|
||||
|
||||
### Als Verwaltung
|
||||
- URL: `http://localhost:3000`
|
||||
- Benutzername: `verwaltung`
|
||||
- Passwort: `verwaltung123`
|
||||
|
||||
Nach dem Login können Sie:
|
||||
- Eingereichte Stundenzettel einsehen
|
||||
- PDFs erstellen und herunterladen
|
||||
|
||||
## Wichtige Hinweise
|
||||
|
||||
⚠️ **Passwörter ändern!**
|
||||
Bitte ändern Sie die Standard-Passwörter nach der ersten Anmeldung!
|
||||
|
||||
⚠️ **Firewall-Einstellungen**
|
||||
Stellen Sie sicher, dass Port 3000 in Ihrer Firewall geöffnet ist, falls Sie von anderen Computern darauf zugreifen möchten.
|
||||
|
||||
⚠️ **Produktiv-Einsatz**
|
||||
Für den Produktiv-Einsatz empfehlen wir:
|
||||
- HTTPS verwenden (z.B. mit nginx als Reverse Proxy)
|
||||
- Starke Passwörter verwenden
|
||||
- Regelmäßige Backups der Datenbank erstellen
|
||||
|
||||
## Port ändern
|
||||
|
||||
Falls Port 3000 bereits belegt ist, können Sie den Port ändern:
|
||||
|
||||
1. Öffnen Sie `server.js`
|
||||
2. Ändern Sie die Zeile `const PORT = 3000;` auf den gewünschten Port
|
||||
3. Speichern und Server neu starten
|
||||
|
||||
## Datenbank-Speicherort
|
||||
|
||||
Die SQLite-Datenbank wird automatisch als `stundenerfassung.db` im Projekt-Verzeichnis erstellt.
|
||||
|
||||
**Backup erstellen:**
|
||||
Kopieren Sie einfach die Datei `stundenerfassung.db` an einen sicheren Ort.
|
||||
|
||||
## Problemlösung
|
||||
|
||||
### Server startet nicht
|
||||
- Prüfen Sie, ob Port 3000 bereits belegt ist
|
||||
- Prüfen Sie, ob Node.js installiert ist: `node --version`
|
||||
- Prüfen Sie, ob alle Dependencies installiert sind: `npm install`
|
||||
|
||||
### Login funktioniert nicht
|
||||
- Löschen Sie die Datei `stundenerfassung.db` und starten Sie den Server neu
|
||||
- Die Datenbank wird dann mit den Standard-Benutzern neu erstellt
|
||||
|
||||
### PDF-Download funktioniert nicht
|
||||
- Prüfen Sie die Browser-Konsole auf Fehler
|
||||
- Stellen Sie sicher, dass Popups für die Seite erlaubt sind
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Admin** legt neue Mitarbeiter an
|
||||
2. **Mitarbeiter** melden sich an und erfassen ihre Stunden
|
||||
3. Mitarbeiter sehen ihre Woche (Montag-Sonntag)
|
||||
4. Einträge werden automatisch beim Ausfüllen gespeichert
|
||||
5. Am Ende der Woche: "Woche abschicken" klicken
|
||||
6. **Verwaltung** sieht eingereichte Stundenzettel im Postfach
|
||||
7. Verwaltung kann PDFs erstellen und herunterladen
|
||||
|
||||
## Support
|
||||
|
||||
Bei Fragen oder Problemen:
|
||||
- Prüfen Sie die ausführliche README.md
|
||||
- Kontaktieren Sie Ihre IT-Abteilung
|
||||
127
checkin-server.js
Normal file
127
checkin-server.js
Normal file
@@ -0,0 +1,127 @@
|
||||
// Check-in Server (separater Express-App auf Port 3334)
|
||||
|
||||
const express = require('express');
|
||||
const { db } = require('./database');
|
||||
const { getCurrentDate, getCurrentTime, updateTotalHours } = require('./helpers/utils');
|
||||
|
||||
const checkinApp = express();
|
||||
const CHECKIN_PORT = 3336;
|
||||
|
||||
// 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;
|
||||
294
database.js
Normal file
294
database.js
Normal file
@@ -0,0 +1,294 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const path = require('path');
|
||||
|
||||
// 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
|
||||
function initDatabase() {
|
||||
db.serialize(() => {
|
||||
// Benutzer-Tabelle
|
||||
db.run(`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
firstname TEXT NOT NULL,
|
||||
lastname TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'mitarbeiter',
|
||||
last_week_start TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
|
||||
// Migration: last_week_start Spalte hinzufügen falls sie nicht existiert
|
||||
db.run(`ALTER TABLE users ADD COLUMN last_week_start TEXT`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
});
|
||||
|
||||
// Stundenerfassung-Tabelle
|
||||
db.run(`CREATE TABLE IF NOT EXISTS timesheet_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
break_minutes INTEGER DEFAULT 0,
|
||||
total_hours REAL,
|
||||
notes TEXT,
|
||||
activity1_desc TEXT,
|
||||
activity1_hours REAL,
|
||||
activity2_desc TEXT,
|
||||
activity2_hours REAL,
|
||||
activity3_desc TEXT,
|
||||
activity3_hours REAL,
|
||||
activity4_desc TEXT,
|
||||
activity4_hours REAL,
|
||||
activity5_desc TEXT,
|
||||
activity5_hours REAL,
|
||||
status TEXT DEFAULT 'offen',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)`);
|
||||
|
||||
// Migration: Tätigkeitsfelder hinzufügen falls sie nicht existieren
|
||||
const activityColumns = [
|
||||
'activity1_desc', 'activity1_hours',
|
||||
'activity2_desc', 'activity2_hours',
|
||||
'activity3_desc', 'activity3_hours',
|
||||
'activity4_desc', 'activity4_hours',
|
||||
'activity5_desc', 'activity5_hours'
|
||||
];
|
||||
|
||||
activityColumns.forEach(col => {
|
||||
const colType = col.includes('_hours') ? 'REAL' : 'TEXT';
|
||||
db.run(`ALTER TABLE timesheet_entries ADD COLUMN ${col} ${colType}`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
});
|
||||
});
|
||||
|
||||
// Wöchentliche Stundenzettel-Tabelle
|
||||
db.run(`CREATE TABLE IF NOT EXISTS weekly_timesheets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
week_start TEXT NOT NULL,
|
||||
week_end TEXT NOT NULL,
|
||||
version INTEGER DEFAULT 1,
|
||||
status TEXT DEFAULT 'eingereicht',
|
||||
submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
reviewed_by INTEGER,
|
||||
reviewed_at DATETIME,
|
||||
pdf_downloaded_at DATETIME,
|
||||
pdf_downloaded_by INTEGER,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (reviewed_by) REFERENCES users(id),
|
||||
FOREIGN KEY (pdf_downloaded_by) REFERENCES users(id)
|
||||
)`);
|
||||
|
||||
// Migration: version Spalte hinzufügen falls sie nicht existiert
|
||||
db.run(`ALTER TABLE weekly_timesheets ADD COLUMN version INTEGER DEFAULT 1`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
// Wenn Spalte neu erstellt wurde, bestehende Einträge haben automatisch version = 1
|
||||
});
|
||||
|
||||
// Migration: pdf_downloaded_at Spalte hinzufügen falls sie nicht existiert
|
||||
db.run(`ALTER TABLE weekly_timesheets ADD COLUMN pdf_downloaded_at DATETIME`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
});
|
||||
|
||||
// Migration: pdf_downloaded_by Spalte hinzufügen falls sie nicht existiert
|
||||
db.run(`ALTER TABLE weekly_timesheets ADD COLUMN pdf_downloaded_by INTEGER`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
});
|
||||
|
||||
// Migration: version_reason Spalte hinzufügen
|
||||
db.run(`ALTER TABLE weekly_timesheets ADD COLUMN version_reason TEXT`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
if (err && !err.message.includes('duplicate column')) {
|
||||
console.warn('Warnung beim Hinzufügen der Spalte version_reason:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Migration: admin_comment Spalte hinzufügen
|
||||
db.run(`ALTER TABLE weekly_timesheets ADD COLUMN admin_comment TEXT`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
if (err && !err.message.includes('duplicate column')) {
|
||||
console.warn('Warnung beim Hinzufügen der Spalte admin_comment:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Migration: Projektnummern für Tätigkeiten hinzufügen
|
||||
const projectNumberColumns = [
|
||||
'activity1_project_number', 'activity2_project_number',
|
||||
'activity3_project_number', 'activity4_project_number',
|
||||
'activity5_project_number'
|
||||
];
|
||||
|
||||
projectNumberColumns.forEach(col => {
|
||||
db.run(`ALTER TABLE timesheet_entries ADD COLUMN ${col} TEXT`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
if (err && !err.message.includes('duplicate column')) {
|
||||
console.warn(`Warnung beim Hinzufügen der Spalte ${col}:`, err.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Migration: Überstunden und Urlaub hinzufügen
|
||||
db.run(`ALTER TABLE timesheet_entries ADD COLUMN overtime_taken_hours REAL`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
if (err && !err.message.includes('duplicate column')) {
|
||||
console.warn('Warnung beim Hinzufügen der Spalte overtime_taken_hours:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
db.run(`ALTER TABLE timesheet_entries ADD COLUMN vacation_type TEXT`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
if (err && !err.message.includes('duplicate column')) {
|
||||
console.warn('Warnung beim Hinzufügen der Spalte vacation_type:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Migration: Krank-Status hinzufügen
|
||||
db.run(`ALTER TABLE timesheet_entries ADD COLUMN sick_status INTEGER DEFAULT 0`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
if (err && !err.message.includes('duplicate column')) {
|
||||
console.warn('Warnung beim Hinzufügen der Spalte sick_status:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Migration: Pausen-Zeiten für API-Zeiterfassung hinzufügen
|
||||
db.run(`ALTER TABLE timesheet_entries ADD COLUMN pause_start_time TEXT`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
if (err && !err.message.includes('duplicate column')) {
|
||||
console.warn('Warnung beim Hinzufügen der Spalte pause_start_time:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
db.run(`ALTER TABLE timesheet_entries ADD COLUMN pause_end_time TEXT`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
if (err && !err.message.includes('duplicate column')) {
|
||||
console.warn('Warnung beim Hinzufügen der Spalte pause_end_time:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Migration: User-Felder hinzufügen (Personalnummer, Wochenstunden, Urlaubstage)
|
||||
db.run(`ALTER TABLE users ADD COLUMN personalnummer TEXT`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
});
|
||||
|
||||
db.run(`ALTER TABLE users ADD COLUMN wochenstunden REAL`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
});
|
||||
|
||||
db.run(`ALTER TABLE users ADD COLUMN urlaubstage REAL`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
});
|
||||
|
||||
// Migration: Überstunden-Offset (manuelle Korrektur durch Verwaltung)
|
||||
db.run(`ALTER TABLE users ADD COLUMN overtime_offset_hours REAL DEFAULT 0`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
if (err && !err.message.includes('duplicate column')) {
|
||||
console.warn('Warnung beim Hinzufügen der Spalte overtime_offset_hours:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Migration: ping_ip Spalte hinzufügen
|
||||
db.run(`ALTER TABLE users ADD COLUMN ping_ip TEXT`, (err) => {
|
||||
// Fehler ignorieren wenn Spalte bereits existiert
|
||||
});
|
||||
|
||||
// Ping-Status-Tabelle für IP-basierte Zeiterfassung
|
||||
db.run(`CREATE TABLE IF NOT EXISTS ping_status (
|
||||
user_id INTEGER NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
last_successful_ping DATETIME,
|
||||
failed_ping_count INTEGER DEFAULT 0,
|
||||
start_time_set INTEGER DEFAULT 0,
|
||||
first_failed_ping_time DATETIME,
|
||||
PRIMARY KEY (user_id, date),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)`, (err) => {
|
||||
if (err && !err.message.includes('duplicate column')) {
|
||||
console.warn('Warnung beim Erstellen der ping_status Tabelle:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// LDAP-Konfiguration-Tabelle
|
||||
db.run(`CREATE TABLE IF NOT EXISTS ldap_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
enabled INTEGER DEFAULT 0,
|
||||
url TEXT,
|
||||
bind_dn TEXT,
|
||||
bind_password TEXT,
|
||||
base_dn TEXT,
|
||||
user_search_filter TEXT,
|
||||
username_attribute TEXT DEFAULT 'cn',
|
||||
firstname_attribute TEXT DEFAULT 'givenName',
|
||||
lastname_attribute TEXT DEFAULT 'sn',
|
||||
sync_interval INTEGER DEFAULT 0,
|
||||
last_sync DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
|
||||
// LDAP-Sync-Log-Tabelle
|
||||
db.run(`CREATE TABLE IF NOT EXISTS ldap_sync_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sync_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
users_synced INTEGER DEFAULT 0,
|
||||
error_message TEXT,
|
||||
sync_started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
sync_completed_at DATETIME
|
||||
)`);
|
||||
|
||||
// Feiertage (öffentliche Feiertage BW) – API wird nur 1x pro Jahr aufgerufen
|
||||
db.run(`CREATE TABLE IF NOT EXISTS public_holidays (
|
||||
date TEXT PRIMARY KEY,
|
||||
name TEXT
|
||||
)`);
|
||||
|
||||
// Migration: Bestehende Rollen zu JSON-Arrays konvertieren
|
||||
// Prüfe ob Rollen noch als einfache Strings gespeichert sind (nicht als JSON-Array)
|
||||
db.all('SELECT id, role FROM users', (err, users) => {
|
||||
if (!err && users) {
|
||||
users.forEach(user => {
|
||||
let roleValue = user.role;
|
||||
// Prüfe ob es bereits ein JSON-Array ist
|
||||
try {
|
||||
const parsed = JSON.parse(roleValue);
|
||||
// Wenn erfolgreich geparst und es ist ein Array, nichts tun
|
||||
if (Array.isArray(parsed)) {
|
||||
return; // Bereits JSON-Array
|
||||
}
|
||||
} catch (e) {
|
||||
// Nicht JSON, konvertiere zu JSON-Array
|
||||
}
|
||||
|
||||
// Konvertiere zu JSON-Array
|
||||
const roleArray = JSON.stringify([roleValue]);
|
||||
db.run('UPDATE users SET role = ? WHERE id = ?', [roleArray, user.id], (err) => {
|
||||
if (err) {
|
||||
console.warn(`Warnung beim Konvertieren der Rolle für User ${user.id}:`, err.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Standard Admin-Benutzer erstellen
|
||||
const adminPassword = bcrypt.hashSync('admin123', 10);
|
||||
db.run(`INSERT OR IGNORE INTO users (id, username, password, firstname, lastname, role)
|
||||
VALUES (1, 'admin', ?, 'System', 'Administrator', ?)`,
|
||||
[adminPassword, JSON.stringify(['admin'])]);
|
||||
|
||||
// Standard Verwaltungs-Benutzer erstellen
|
||||
const verwaltungPassword = bcrypt.hashSync('verwaltung123', 10);
|
||||
db.run(`INSERT OR IGNORE INTO users (id, username, password, firstname, lastname, role)
|
||||
VALUES (2, 'verwaltung', ?, 'Verwaltung', 'User', ?)`,
|
||||
[verwaltungPassword, JSON.stringify(['verwaltung'])]);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { db, initDatabase };
|
||||
44
dev/ldapserver/docker-compose.yml
Normal file
44
dev/ldapserver/docker-compose.yml
Normal file
@@ -0,0 +1,44 @@
|
||||
version: "3"
|
||||
|
||||
volumes:
|
||||
lldap_data:
|
||||
driver: local
|
||||
|
||||
services:
|
||||
lldap:
|
||||
image: lldap/lldap:stable
|
||||
ports:
|
||||
# For LDAP, not recommended to expose, see Usage section.
|
||||
- "3890:3890"
|
||||
# For LDAPS (LDAP Over SSL), enable port if LLDAP_LDAPS_OPTIONS__ENABLED set true, look env below
|
||||
#- "6360:6360"
|
||||
# For the web front-end
|
||||
- "17170:17170"
|
||||
volumes:
|
||||
- "lldap_data:/data"
|
||||
# Alternatively, you can mount a local folder
|
||||
# - "./lldap_data:/data"
|
||||
environment:
|
||||
- UID=1000
|
||||
- GID=1000
|
||||
- TZ=Europe/Berlin
|
||||
- LLDAP_JWT_SECRET=1omV4UDprT1PAJFYXGisVlei/V5b5Uaiqssl9qburwV+T1S0ox8iurI6FJkWPnX5xRUMbHswJwZLG7QzUnzdZw==
|
||||
- LLDAP_KEY_SEED=ffcomviFeT8RByJf7jT3AuzDcFbrgWb+oSuMDp72pql96J4Rdq5gno2Dk1xfWrYPLH5OoS/bpDuOp/oE9T5+sA==
|
||||
- LLDAP_LDAP_BASE_DN=dc=gmbh,dc=de
|
||||
- LLDAP_LDAP_USER_PASS=Delfine1!!! # If the password contains '$', escape it (e.g. Pas$$word sets Pas$word)
|
||||
# If using LDAPS, set enabled true and configure cert and key path
|
||||
# - LLDAP_LDAPS_OPTIONS__ENABLED=true
|
||||
# - LLDAP_LDAPS_OPTIONS__CERT_FILE=/path/to/certfile.crt
|
||||
# - LLDAP_LDAPS_OPTIONS__KEY_FILE=/path/to/keyfile.key
|
||||
# You can also set a different database:
|
||||
# - LLDAP_DATABASE_URL=mysql://mysql-user:password@mysql-server/my-database
|
||||
# - LLDAP_DATABASE_URL=postgres://postgres-user:password@postgres-server/my-database
|
||||
# If using SMTP, set the following variables
|
||||
# - LLDAP_SMTP_OPTIONS__ENABLE_PASSWORD_RESET=true
|
||||
# - LLDAP_SMTP_OPTIONS__SERVER=smtp.example.com
|
||||
# - LLDAP_SMTP_OPTIONS__PORT=465 # Check your smtp provider's documentation for this setting
|
||||
# - LLDAP_SMTP_OPTIONS__SMTP_ENCRYPTION=TLS # How the connection is encrypted, either "NONE" (no encryption, port 25), "TLS" (sometimes called SSL, port 465) or "STARTTLS" (sometimes called TLS, port 587).
|
||||
# - LLDAP_SMTP_OPTIONS__USER=no-reply@example.com # The SMTP user, usually your email address
|
||||
# - LLDAP_SMTP_OPTIONS__PASSWORD=PasswordGoesHere # The SMTP password
|
||||
# - LLDAP_SMTP_OPTIONS__FROM=no-reply <no-reply@example.com> # The header field, optional: how the sender appears in the email. The first is a free-form name, followed by an email between <>.
|
||||
# - LLDAP_SMTP_OPTIONS__TO=admin <admin@example.com> # Same for reply-to, optional.
|
||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
stundenerfassung:
|
||||
build: .
|
||||
container_name: stundenerfassung
|
||||
ports:
|
||||
- "3333:3333" # Hauptserver
|
||||
- "3334:3334" # Check-in Server
|
||||
volumes:
|
||||
# Datenbank: Verzeichnis mounten (nicht einzelne Datei – sonst erzeugt Docker ein Verzeichnis)
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DB_PATH=/app/data/stundenerfassung.db
|
||||
- TZ=Europe/Berlin
|
||||
restart: unless-stopped
|
||||
18
email-mitarbeiter-stundenerfassung.txt
Normal file
18
email-mitarbeiter-stundenerfassung.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
Test - Stundenerfassung
|
||||
|
||||
Hallo zusammen,
|
||||
|
||||
Mara ist auf mich mit einer Bitte herangetreten, ob ich die Stundenerfassung digitalisieren kann.
|
||||
Das habe ich die letzten 2 Wochen am abend und am WE gemacht.
|
||||
|
||||
Ich gleube, dass das System jetzt fest fertig ist und ihr es testen könnt
|
||||
Der test soll kleinere Fehler finden und mir noch die möglichkeit geben diese dann zu beheben.
|
||||
|
||||
Am Montag würde ich gerne eine kurze Einführung für die Leute im Büro geben.
|
||||
Um ca. 11:00 Uhr für so 10-15 Minuten.
|
||||
|
||||
Achtet bitte auf die Überstundenerechnung, da könnte noch der ein oder andere Fehler drin sein.
|
||||
|
||||
Viele Grüße
|
||||
Carsten Graf
|
||||
|
||||
131
helpers/utils.js
Normal file
131
helpers/utils.js
Normal file
@@ -0,0 +1,131 @@
|
||||
// Helper-Funktionen für das Stundenerfassungs-System
|
||||
|
||||
// Helper: Prüft ob User eine bestimmte Rolle hat
|
||||
function hasRole(req, role) {
|
||||
if (!req.session.roles || !Array.isArray(req.session.roles)) {
|
||||
return false;
|
||||
}
|
||||
return req.session.roles.includes(role);
|
||||
}
|
||||
|
||||
// Helper: Bestimmt die Standard-Rolle (höchste Priorität: admin > verwaltung > mitarbeiter)
|
||||
function getDefaultRole(roles) {
|
||||
if (!Array.isArray(roles) || roles.length === 0) {
|
||||
return 'mitarbeiter';
|
||||
}
|
||||
if (roles.includes('admin')) return 'admin';
|
||||
if (roles.includes('verwaltung')) return 'verwaltung';
|
||||
return roles[0]; // Fallback auf erste Rolle
|
||||
}
|
||||
|
||||
// Helper: Gibt aktuelles Datum als YYYY-MM-DD zurück
|
||||
function getCurrentDate() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// Helper: Gibt aktuelle Zeit als HH:MM zurück
|
||||
function getCurrentTime() {
|
||||
const now = new Date();
|
||||
const hours = String(now.getHours()).padStart(2, '0');
|
||||
const minutes = String(now.getMinutes()).padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// Helper: Berechnet Pausenzeit in Minuten zwischen zwei Zeiten
|
||||
function calculateBreakMinutes(pauseStart, pauseEnd) {
|
||||
if (!pauseStart || !pauseEnd) return 0;
|
||||
|
||||
const [startHours, startMinutes] = pauseStart.split(':').map(Number);
|
||||
const [endHours, endMinutes] = pauseEnd.split(':').map(Number);
|
||||
|
||||
const startTotalMinutes = startHours * 60 + startMinutes;
|
||||
const endTotalMinutes = endHours * 60 + endMinutes;
|
||||
|
||||
return endTotalMinutes - startTotalMinutes;
|
||||
}
|
||||
|
||||
// Helper: Berechnet total_hours basierend auf start_time, end_time und break_minutes
|
||||
function updateTotalHours(startTime, endTime, breakMinutes) {
|
||||
if (!startTime || !endTime) return 0;
|
||||
|
||||
const [startHours, startMinutes] = startTime.split(':').map(Number);
|
||||
const [endHours, endMinutes] = endTime.split(':').map(Number);
|
||||
|
||||
const startTotalMinutes = startHours * 60 + startMinutes;
|
||||
const endTotalMinutes = endHours * 60 + endMinutes;
|
||||
|
||||
const totalMinutes = endTotalMinutes - startTotalMinutes - (breakMinutes || 0);
|
||||
return totalMinutes / 60; // Konvertiere zu Stunden
|
||||
}
|
||||
|
||||
// Helper: Formatiert Datum für Anzeige (DD.MM.YYYY)
|
||||
function formatDate(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
// Helper: Formatiert Datum und Zeit für Anzeige
|
||||
function formatDateTime(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleString('de-DE');
|
||||
}
|
||||
|
||||
// Helper: Berechnet Kalenderwoche aus einem Datum (ISO 8601)
|
||||
function getCalendarWeek(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
||||
return weekNo;
|
||||
}
|
||||
|
||||
// Helper: Berechnet week_start (Montag) und week_end (Sonntag) aus Jahr und Kalenderwoche (ISO 8601)
|
||||
function getWeekDatesFromCalendarWeek(year, weekNumber) {
|
||||
// ISO 8601: Woche beginnt am Montag, erste Woche enthält den 4. Januar
|
||||
const jan4 = new Date(Date.UTC(year, 0, 4));
|
||||
const jan4Day = jan4.getUTCDay() || 7; // 1 = Montag, 7 = Sonntag
|
||||
const daysToMonday = jan4Day === 1 ? 0 : 1 - jan4Day;
|
||||
|
||||
// Montag der ersten Woche
|
||||
const firstMonday = new Date(Date.UTC(year, 0, 4 + daysToMonday));
|
||||
|
||||
// Montag der gewünschten Woche (Woche 1 = erste Woche)
|
||||
const weekMonday = new Date(firstMonday);
|
||||
weekMonday.setUTCDate(firstMonday.getUTCDate() + (weekNumber - 1) * 7);
|
||||
|
||||
// Sonntag der Woche (6 Tage nach Montag)
|
||||
const weekSunday = new Date(weekMonday);
|
||||
weekSunday.setUTCDate(weekMonday.getUTCDate() + 6);
|
||||
|
||||
// Format: YYYY-MM-DD
|
||||
const formatDate = (date) => {
|
||||
const y = date.getUTCFullYear();
|
||||
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
return {
|
||||
week_start: formatDate(weekMonday),
|
||||
week_end: formatDate(weekSunday)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hasRole,
|
||||
getDefaultRole,
|
||||
getCurrentDate,
|
||||
getCurrentTime,
|
||||
calculateBreakMinutes,
|
||||
updateTotalHours,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
getCalendarWeek,
|
||||
getWeekDatesFromCalendarWeek
|
||||
};
|
||||
402
ldap-service.js
Normal file
402
ldap-service.js
Normal file
@@ -0,0 +1,402 @@
|
||||
const ldap = require('ldapjs');
|
||||
const { db } = require('./database');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
/**
|
||||
* LDAP-Service für Benutzer-Synchronisation
|
||||
*/
|
||||
class LDAPService {
|
||||
/**
|
||||
* LDAP-Konfiguration aus der Datenbank abrufen
|
||||
*/
|
||||
static getConfig(callback) {
|
||||
db.get('SELECT * FROM ldap_config WHERE id = 1', (err, config) => {
|
||||
if (err) {
|
||||
return callback(err, null);
|
||||
}
|
||||
callback(null, config);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* LDAP-Verbindung herstellen
|
||||
*/
|
||||
static connect(config, callback) {
|
||||
if (!config || !config.enabled || !config.url) {
|
||||
return callback(new Error('LDAP ist nicht konfiguriert oder deaktiviert'));
|
||||
}
|
||||
|
||||
const client = ldap.createClient({
|
||||
url: config.url,
|
||||
timeout: 10000,
|
||||
connectTimeout: 10000
|
||||
});
|
||||
|
||||
// Fehlerbehandlung
|
||||
client.on('error', (err) => {
|
||||
callback(err, null);
|
||||
});
|
||||
|
||||
// Bind mit Credentials
|
||||
const bindDN = config.bind_dn || '';
|
||||
const bindPassword = config.bind_password || '';
|
||||
|
||||
// Hinweis: Passwort wird im Klartext gespeichert
|
||||
// In einer produktiven Umgebung sollte man eine Verschlüsselung mit einem Master-Key verwenden
|
||||
|
||||
client.bind(bindDN, bindPassword, (err) => {
|
||||
if (err) {
|
||||
client.unbind();
|
||||
return callback(err, null);
|
||||
}
|
||||
callback(null, client);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer aus LDAP abrufen
|
||||
*/
|
||||
static searchUsers(client, config, callback) {
|
||||
const baseDN = config.base_dn || '';
|
||||
const searchFilter = config.user_search_filter || '(objectClass=person)';
|
||||
const searchOptions = {
|
||||
filter: searchFilter,
|
||||
scope: 'sub',
|
||||
attributes: [
|
||||
config.username_attribute || 'cn',
|
||||
config.firstname_attribute || 'givenName',
|
||||
config.lastname_attribute || 'sn'
|
||||
]
|
||||
};
|
||||
|
||||
const users = [];
|
||||
|
||||
client.search(baseDN, searchOptions, (err, res) => {
|
||||
if (err) {
|
||||
return callback(err, null);
|
||||
}
|
||||
|
||||
res.on('searchEntry', (entry) => {
|
||||
const user = {
|
||||
username: this.getAttributeValue(entry, config.username_attribute || 'cn'),
|
||||
firstname: this.getAttributeValue(entry, config.firstname_attribute || 'givenName'),
|
||||
lastname: this.getAttributeValue(entry, config.lastname_attribute || 'sn')
|
||||
};
|
||||
|
||||
// Nur Benutzer mit allen erforderlichen Feldern hinzufügen
|
||||
if (user.username && user.firstname && user.lastname) {
|
||||
users.push(user);
|
||||
}
|
||||
});
|
||||
|
||||
res.on('error', (err) => {
|
||||
callback(err, null);
|
||||
});
|
||||
|
||||
res.on('end', (result) => {
|
||||
if (result && result.status !== 0) {
|
||||
return callback(new Error(`LDAP-Suche fehlgeschlagen: ${result.status}`), null);
|
||||
}
|
||||
callback(null, users);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wert eines LDAP-Attributs extrahieren
|
||||
*
|
||||
* Die ldapjs-Bibliothek behandelt UTF-8-Zeichen automatisch korrekt.
|
||||
* Diese Funktion stellt sicher, dass UTF-8-Zeichen wie ß, ä, ö, ü korrekt zurückgegeben werden.
|
||||
*/
|
||||
static getAttributeValue(entry, attributeName) {
|
||||
const attr = entry.attributes.find(a => a.type === attributeName);
|
||||
if (!attr) {
|
||||
return null;
|
||||
}
|
||||
const value = Array.isArray(attr.values) ? attr.values[0] : attr.values;
|
||||
// Stelle sicher, dass der Wert als String zurückgegeben wird (UTF-8 wird automatisch korrekt behandelt)
|
||||
return value != null ? String(value) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escaped einen Wert für LDAP-Filter (verhindert LDAP-Injection)
|
||||
*
|
||||
* WICHTIG: UTF-8-Zeichen wie ß, ä, ö, ü müssen NICHT escaped werden.
|
||||
* LDAP-Filter unterstützen UTF-8 direkt nach RFC 4515.
|
||||
* Nur die speziellen LDAP-Filter-Zeichen werden escaped.
|
||||
*/
|
||||
static escapeLDAPFilter(value) {
|
||||
if (!value) return '';
|
||||
|
||||
// Stelle sicher, dass der Wert als String behandelt wird
|
||||
const str = String(value);
|
||||
|
||||
// Escape nur die speziellen LDAP-Filter-Zeichen
|
||||
// UTF-8-Zeichen wie ß, ä, ö, ü werden direkt verwendet
|
||||
return str
|
||||
.replace(/\\/g, '\\5c') // Backslash
|
||||
.replace(/\*/g, '\\2a') // Stern
|
||||
.replace(/\(/g, '\\28') // Öffnende Klammer
|
||||
.replace(/\)/g, '\\29') // Schließende Klammer
|
||||
.replace(/\0/g, '\\00'); // Null-Byte
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer in SQLite synchronisieren
|
||||
*/
|
||||
static syncUsers(ldapUsers, callback) {
|
||||
let syncedCount = 0;
|
||||
let errorCount = 0;
|
||||
const errors = [];
|
||||
|
||||
if (!ldapUsers || ldapUsers.length === 0) {
|
||||
return callback(null, { synced: 0, errors: [] });
|
||||
}
|
||||
|
||||
// Verarbeite jeden Benutzer
|
||||
const processUser = (index) => {
|
||||
if (index >= ldapUsers.length) {
|
||||
return callback(null, { synced: syncedCount, errors: errors });
|
||||
}
|
||||
|
||||
const ldapUser = ldapUsers[index];
|
||||
// .trim() behält UTF-8-Zeichen wie ß, ä, ö, ü korrekt bei
|
||||
// Stelle sicher, dass Werte als String behandelt werden
|
||||
const username = String(ldapUser.username || '').trim();
|
||||
const firstname = String(ldapUser.firstname || '').trim();
|
||||
const lastname = String(ldapUser.lastname || '').trim();
|
||||
|
||||
// Prüfe ob Benutzer bereits existiert (case-insensitive)
|
||||
db.get('SELECT id, role FROM users WHERE username = ? COLLATE NOCASE', [username], (err, existingUser) => {
|
||||
if (err) {
|
||||
errors.push(`Fehler beim Prüfen von ${username}: ${err.message}`);
|
||||
errorCount++;
|
||||
return processUser(index + 1);
|
||||
}
|
||||
|
||||
if (existingUser) {
|
||||
// Benutzer existiert - aktualisiere nur Name, behalte Rolle (case-insensitive)
|
||||
db.run(
|
||||
'UPDATE users SET firstname = ?, lastname = ? WHERE username = ? COLLATE NOCASE',
|
||||
[firstname, lastname, username],
|
||||
(err) => {
|
||||
if (err) {
|
||||
errors.push(`Fehler beim Aktualisieren von ${username}: ${err.message}`);
|
||||
errorCount++;
|
||||
} else {
|
||||
syncedCount++;
|
||||
}
|
||||
processUser(index + 1);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Neuer Benutzer - erstelle mit Standard-Rolle
|
||||
// Generiere ein zufälliges Passwort (Benutzer muss es beim ersten Login ändern)
|
||||
const defaultPassword = bcrypt.hashSync('changeme123', 10);
|
||||
|
||||
db.run(
|
||||
'INSERT INTO users (username, password, firstname, lastname, role) VALUES (?, ?, ?, ?, ?)',
|
||||
[username, defaultPassword, firstname, lastname, 'mitarbeiter'],
|
||||
(err) => {
|
||||
if (err) {
|
||||
errors.push(`Fehler beim Erstellen von ${username}: ${err.message}`);
|
||||
errorCount++;
|
||||
} else {
|
||||
syncedCount++;
|
||||
}
|
||||
processUser(index + 1);
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
processUser(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync-Log-Eintrag erstellen
|
||||
*/
|
||||
static createSyncLog(syncType, status, usersSynced, errorMessage, callback) {
|
||||
const startedAt = new Date().toISOString();
|
||||
const completedAt = new Date().toISOString();
|
||||
|
||||
db.run(
|
||||
`INSERT INTO ldap_sync_log (sync_type, status, users_synced, error_message, sync_started_at, sync_completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[syncType, status, usersSynced, errorMessage || null, startedAt, completedAt],
|
||||
(err) => {
|
||||
if (callback) {
|
||||
callback(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Letzte Synchronisation aktualisieren
|
||||
*/
|
||||
static updateLastSync(callback) {
|
||||
db.run(
|
||||
'UPDATE ldap_config SET last_sync = CURRENT_TIMESTAMP WHERE id = 1',
|
||||
(err) => {
|
||||
if (callback) {
|
||||
callback(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer gegen LDAP authentifizieren
|
||||
*
|
||||
* Unterstützt UTF-8-Zeichen wie ß, ä, ö, ü in Usernamen.
|
||||
* Die ldapjs-Bibliothek behandelt UTF-8 automatisch korrekt.
|
||||
*/
|
||||
static authenticate(username, password, callback) {
|
||||
// Stelle sicher, dass Username als String behandelt wird (UTF-8 wird korrekt unterstützt)
|
||||
const usernameStr = String(username || '').trim();
|
||||
|
||||
if (!usernameStr) {
|
||||
return callback(new Error('Benutzername darf nicht leer sein'), false);
|
||||
}
|
||||
|
||||
// Konfiguration abrufen
|
||||
this.getConfig((err, config) => {
|
||||
if (err || !config || !config.enabled) {
|
||||
return callback(new Error('LDAP ist nicht aktiviert'), false);
|
||||
}
|
||||
|
||||
// LDAP-Verbindung herstellen (mit Service-Account)
|
||||
this.connect(config, (err, client) => {
|
||||
if (err) {
|
||||
return callback(err, false);
|
||||
}
|
||||
|
||||
// Suche nach dem Benutzer in LDAP
|
||||
const baseDN = config.base_dn || '';
|
||||
const usernameAttr = config.username_attribute || 'cn';
|
||||
// escapeLDAPFilter behandelt UTF-8-Zeichen korrekt (escaped sie nicht)
|
||||
const escapedUsername = this.escapeLDAPFilter(usernameStr);
|
||||
const searchFilter = `(${usernameAttr}=${escapedUsername})`;
|
||||
const searchOptions = {
|
||||
filter: searchFilter,
|
||||
scope: 'sub',
|
||||
attributes: ['dn', usernameAttr]
|
||||
};
|
||||
|
||||
let userDN = null;
|
||||
|
||||
client.search(baseDN, searchOptions, (err, res) => {
|
||||
if (err) {
|
||||
client.unbind();
|
||||
// Verbesserte Fehlermeldung für mögliche Encoding-Probleme
|
||||
const errorMsg = err.message || String(err);
|
||||
return callback(new Error(`LDAP-Suche fehlgeschlagen: ${errorMsg}. Hinweis: Prüfen Sie, ob der Benutzername UTF-8-Zeichen (wie ß, ä, ö, ü) korrekt enthält.`), false);
|
||||
}
|
||||
|
||||
res.on('searchEntry', (entry) => {
|
||||
userDN = entry.dn.toString();
|
||||
});
|
||||
|
||||
res.on('error', (err) => {
|
||||
client.unbind();
|
||||
const errorMsg = err.message || String(err);
|
||||
callback(new Error(`LDAP-Suchfehler: ${errorMsg}`), false);
|
||||
});
|
||||
|
||||
res.on('end', (result) => {
|
||||
// Service-Account-Verbindung schließen
|
||||
client.unbind();
|
||||
|
||||
if (!userDN) {
|
||||
// Verbesserte Fehlermeldung: Hinweis auf mögliche Encoding-Probleme
|
||||
return callback(new Error(`Benutzer "${usernameStr}" nicht gefunden. Hinweis: Prüfen Sie, ob der Benutzername korrekt ist und UTF-8-Zeichen (wie ß, ä, ö, ü) korrekt geschrieben sind.`), false);
|
||||
}
|
||||
|
||||
// Versuche, sich mit den Benutzer-Credentials zu binden
|
||||
const authClient = ldap.createClient({
|
||||
url: config.url,
|
||||
timeout: 10000,
|
||||
connectTimeout: 10000
|
||||
});
|
||||
|
||||
authClient.on('error', (err) => {
|
||||
authClient.unbind();
|
||||
callback(err, false);
|
||||
});
|
||||
|
||||
authClient.bind(userDN, password, (err) => {
|
||||
authClient.unbind();
|
||||
if (err) {
|
||||
const errorMsg = err.message || String(err);
|
||||
return callback(new Error(`Ungültiges Passwort oder Authentifizierungsfehler: ${errorMsg}`), false);
|
||||
}
|
||||
callback(null, true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Vollständige Synchronisation durchführen
|
||||
*/
|
||||
static performSync(syncType, callback) {
|
||||
const startedAt = new Date();
|
||||
|
||||
// Konfiguration abrufen
|
||||
this.getConfig((err, config) => {
|
||||
if (err) {
|
||||
this.createSyncLog(syncType, 'error', 0, `Fehler beim Abrufen der Konfiguration: ${err.message}`, () => {});
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
if (!config || !config.enabled) {
|
||||
const errorMsg = 'LDAP-Synchronisation ist nicht aktiviert';
|
||||
this.createSyncLog(syncType, 'error', 0, errorMsg, () => {});
|
||||
return callback(new Error(errorMsg));
|
||||
}
|
||||
|
||||
// LDAP-Verbindung herstellen
|
||||
this.connect(config, (err, client) => {
|
||||
if (err) {
|
||||
this.createSyncLog(syncType, 'error', 0, `LDAP-Verbindungsfehler: ${err.message}`, () => {});
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
// Benutzer aus LDAP abrufen
|
||||
this.searchUsers(client, config, (err, ldapUsers) => {
|
||||
// Verbindung schließen
|
||||
client.unbind();
|
||||
|
||||
if (err) {
|
||||
this.createSyncLog(syncType, 'error', 0, `LDAP-Suchfehler: ${err.message}`, () => {});
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
// Benutzer synchronisieren
|
||||
this.syncUsers(ldapUsers, (err, result) => {
|
||||
if (err) {
|
||||
this.createSyncLog(syncType, 'error', result.synced, `Sync-Fehler: ${err.message}`, () => {});
|
||||
return callback(err);
|
||||
}
|
||||
|
||||
// Letzte Synchronisation aktualisieren
|
||||
this.updateLastSync(() => {
|
||||
const status = result.errors.length > 0 ? 'error' : 'success';
|
||||
const errorMsg = result.errors.length > 0 ? result.errors.join('; ') : null;
|
||||
|
||||
this.createSyncLog(syncType, status, result.synced, errorMsg, () => {
|
||||
callback(null, result);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = LDAPService;
|
||||
48
middleware/auth.js
Normal file
48
middleware/auth.js
Normal file
@@ -0,0 +1,48 @@
|
||||
// Authentifizierungs-Middleware
|
||||
|
||||
const { hasRole } = require('../helpers/utils');
|
||||
|
||||
// Middleware: Authentifizierung prüfen
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.session.userId) {
|
||||
next();
|
||||
} else {
|
||||
res.redirect('/login');
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware: Prüft ob User eine bestimmte Rolle hat
|
||||
function requireRole(role) {
|
||||
return (req, res, next) => {
|
||||
if (req.session.userId && hasRole(req, role)) {
|
||||
next();
|
||||
} else {
|
||||
res.status(403).send('Zugriff verweigert');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Middleware: Admin-Rolle prüfen
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.session.userId && hasRole(req, 'admin')) {
|
||||
next();
|
||||
} else {
|
||||
res.status(403).send('Zugriff verweigert');
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware: Verwaltung-Rolle prüfen (Verwaltung oder Admin)
|
||||
function requireVerwaltung(req, res, next) {
|
||||
if (req.session.userId && (hasRole(req, 'verwaltung') || hasRole(req, 'admin'))) {
|
||||
next();
|
||||
} else {
|
||||
res.status(403).send('Zugriff verweigert');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
requireAuth,
|
||||
requireRole,
|
||||
requireAdmin,
|
||||
requireVerwaltung
|
||||
};
|
||||
7431
package-lock.json
generated
Normal file
7431
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "stundenerfassung",
|
||||
"version": "1.0.0",
|
||||
"description": "Stundenerfassungs-System für Mitarbeiter",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"reset-db": "node reset-db.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.17.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"sqlite3": "^5.1.6",
|
||||
"body-parser": "^1.20.2",
|
||||
"ejs": "^3.1.9",
|
||||
"pdfkit": "^0.13.0",
|
||||
"ldapjs": "^3.0.7",
|
||||
"node-cron": "^3.0.3",
|
||||
"ping": "^0.4.4",
|
||||
"archiver": "^7.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
1054
public/css/style.css
Normal file
1054
public/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/images/favicon.png
Normal file
BIN
public/images/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
public/images/header.png
Normal file
BIN
public/images/header.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
342
public/js/admin.js
Normal file
342
public/js/admin.js
Normal file
@@ -0,0 +1,342 @@
|
||||
// Admin JavaScript
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Benutzer-Formular
|
||||
const form = document.getElementById('addUserForm');
|
||||
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Rollen aus Checkboxen sammeln
|
||||
const roleCheckboxes = document.querySelectorAll('input[name="roles"]:checked');
|
||||
const roles = Array.from(roleCheckboxes).map(cb => cb.value);
|
||||
|
||||
// Validierung: Mindestens eine Rolle muss ausgewählt sein
|
||||
if (roles.length === 0) {
|
||||
alert('Bitte wählen Sie mindestens eine Rolle aus.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = {
|
||||
username: document.getElementById('username').value,
|
||||
password: document.getElementById('password').value,
|
||||
firstname: document.getElementById('firstname').value,
|
||||
lastname: document.getElementById('lastname').value,
|
||||
roles: roles,
|
||||
personalnummer: document.getElementById('personalnummer').value,
|
||||
wochenstunden: document.getElementById('wochenstunden').value,
|
||||
urlaubstage: document.getElementById('urlaubstage').value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/users', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('Benutzer wurde erfolgreich angelegt!');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler: ' + (result.error || 'Benutzer konnte nicht angelegt werden'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler:', error);
|
||||
alert('Fehler beim Anlegen des Benutzers');
|
||||
}
|
||||
});
|
||||
|
||||
// LDAP-Konfiguration laden
|
||||
loadLDAPConfig();
|
||||
|
||||
// LDAP-Konfigurationsformular
|
||||
const ldapConfigForm = document.getElementById('ldapConfigForm');
|
||||
if (ldapConfigForm) {
|
||||
ldapConfigForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const enabled = document.getElementById('ldapEnabled').checked;
|
||||
const url = document.getElementById('ldapUrl').value;
|
||||
const baseDn = document.getElementById('ldapBaseDn').value;
|
||||
|
||||
// Validierung: URL und Base DN sind erforderlich wenn aktiviert
|
||||
if (enabled && (!url || !baseDn)) {
|
||||
alert('Bitte füllen Sie URL und Base DN aus, wenn LDAP aktiviert ist.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = {
|
||||
enabled: enabled,
|
||||
url: url,
|
||||
bind_dn: document.getElementById('ldapBindDn').value,
|
||||
bind_password: document.getElementById('ldapBindPassword').value,
|
||||
base_dn: baseDn,
|
||||
user_search_filter: document.getElementById('ldapSearchFilter').value,
|
||||
username_attribute: document.getElementById('ldapUsernameAttr').value,
|
||||
firstname_attribute: document.getElementById('ldapFirstnameAttr').value,
|
||||
lastname_attribute: document.getElementById('ldapLastnameAttr').value,
|
||||
sync_interval: parseInt(document.getElementById('ldapSyncInterval').value) || 0
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/ldap/config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('LDAP-Konfiguration wurde erfolgreich gespeichert!');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler: ' + (result.error || 'Konfiguration konnte nicht gespeichert werden'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler:', error);
|
||||
alert('Fehler beim Speichern der Konfiguration');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sync-Button
|
||||
const syncNowBtn = document.getElementById('syncNowBtn');
|
||||
if (syncNowBtn) {
|
||||
syncNowBtn.addEventListener('click', async function() {
|
||||
if (!confirm('Möchten Sie die LDAP-Synchronisation jetzt starten?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById('syncStatus');
|
||||
syncNowBtn.disabled = true;
|
||||
statusEl.textContent = 'Synchronisation läuft...';
|
||||
statusEl.style.color = 'blue';
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/ldap/sync', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
statusEl.textContent = `Erfolgreich: ${result.synced} Benutzer synchronisiert`;
|
||||
statusEl.style.color = 'green';
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
alert('Synchronisation abgeschlossen mit Warnungen:\n' + result.errors.join('\n'));
|
||||
} else {
|
||||
alert(`Synchronisation erfolgreich abgeschlossen: ${result.synced} Benutzer synchronisiert`);
|
||||
}
|
||||
|
||||
// Seite neu laden nach kurzer Verzögerung
|
||||
setTimeout(() => {
|
||||
location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
statusEl.textContent = 'Fehler: ' + (result.error || 'Synchronisation fehlgeschlagen');
|
||||
statusEl.style.color = 'red';
|
||||
alert('Fehler: ' + (result.error || 'Synchronisation fehlgeschlagen'));
|
||||
syncNowBtn.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler:', error);
|
||||
statusEl.textContent = 'Fehler bei der Synchronisation';
|
||||
statusEl.style.color = 'red';
|
||||
alert('Fehler bei der Synchronisation');
|
||||
syncNowBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// LDAP-Konfiguration laden und Formular ausfüllen
|
||||
async function loadLDAPConfig() {
|
||||
try {
|
||||
const response = await fetch('/admin/ldap/config');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.config) {
|
||||
const config = result.config;
|
||||
|
||||
if (document.getElementById('ldapEnabled')) {
|
||||
document.getElementById('ldapEnabled').checked = config.enabled === 1;
|
||||
}
|
||||
if (document.getElementById('ldapUrl')) {
|
||||
document.getElementById('ldapUrl').value = config.url || '';
|
||||
}
|
||||
if (document.getElementById('ldapBaseDn')) {
|
||||
document.getElementById('ldapBaseDn').value = config.base_dn || '';
|
||||
}
|
||||
if (document.getElementById('ldapBindDn')) {
|
||||
document.getElementById('ldapBindDn').value = config.bind_dn || '';
|
||||
}
|
||||
if (document.getElementById('ldapSearchFilter')) {
|
||||
document.getElementById('ldapSearchFilter').value = config.user_search_filter || '(objectClass=person)';
|
||||
}
|
||||
if (document.getElementById('ldapUsernameAttr')) {
|
||||
document.getElementById('ldapUsernameAttr').value = config.username_attribute || 'cn';
|
||||
}
|
||||
if (document.getElementById('ldapFirstnameAttr')) {
|
||||
document.getElementById('ldapFirstnameAttr').value = config.firstname_attribute || 'givenName';
|
||||
}
|
||||
if (document.getElementById('ldapLastnameAttr')) {
|
||||
document.getElementById('ldapLastnameAttr').value = config.lastname_attribute || 'sn';
|
||||
}
|
||||
if (document.getElementById('ldapSyncInterval')) {
|
||||
document.getElementById('ldapSyncInterval').value = config.sync_interval || 0;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der LDAP-Konfiguration:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(userId, username) {
|
||||
const confirmed = confirm(`Möchten Sie den Benutzer "${username}" wirklich löschen?`);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/users/${userId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
alert('Benutzer wurde erfolgreich gelöscht!');
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler: ' + (result.error || 'Benutzer konnte nicht gelöscht werden'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler:', error);
|
||||
alert('Fehler beim Löschen des Benutzers');
|
||||
}
|
||||
}
|
||||
|
||||
// User bearbeiten
|
||||
function editUser(userId) {
|
||||
const row = document.querySelector(`tr[data-user-id="${userId}"]`);
|
||||
if (!row) return;
|
||||
|
||||
// Alle Display-Felder ausblenden und Edit-Felder einblenden
|
||||
row.querySelectorAll('.user-field-display').forEach(display => {
|
||||
display.style.display = 'none';
|
||||
});
|
||||
row.querySelectorAll('.user-field-edit').forEach(edit => {
|
||||
edit.style.display = 'inline-block';
|
||||
});
|
||||
|
||||
// Buttons umschalten
|
||||
row.querySelector('.edit-user-btn').style.display = 'none';
|
||||
row.querySelector('.save-user-btn').style.display = 'inline-block';
|
||||
row.querySelector('.cancel-user-btn').style.display = 'inline-block';
|
||||
}
|
||||
|
||||
// User speichern
|
||||
async function saveUser(userId) {
|
||||
const row = document.querySelector(`tr[data-user-id="${userId}"]`);
|
||||
if (!row) return;
|
||||
|
||||
const personalnummer = row.querySelector('input[data-field="personalnummer"]').value;
|
||||
const wochenstunden = row.querySelector('input[data-field="wochenstunden"]').value;
|
||||
const urlaubstage = row.querySelector('input[data-field="urlaubstage"]').value;
|
||||
|
||||
// Rollen aus Checkboxen sammeln
|
||||
const roleCheckboxes = row.querySelectorAll('.role-checkbox:checked');
|
||||
const roles = Array.from(roleCheckboxes).map(cb => cb.value);
|
||||
|
||||
// Validierung: Mindestens eine Rolle erforderlich
|
||||
if (roles.length === 0) {
|
||||
alert('Mindestens eine Rolle muss ausgewählt sein.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
personalnummer: personalnummer || null,
|
||||
wochenstunden: wochenstunden || null,
|
||||
urlaubstage: urlaubstage || null,
|
||||
roles: roles
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Werte in Display-Felder übernehmen
|
||||
row.querySelector('span[data-field="personalnummer"]').textContent = personalnummer || '-';
|
||||
row.querySelector('span[data-field="wochenstunden"]').textContent = wochenstunden || '-';
|
||||
row.querySelector('span[data-field="urlaubstage"]').textContent = urlaubstage || '-';
|
||||
|
||||
// Rollen-Display aktualisieren
|
||||
const rolesDisplay = row.querySelector('div[data-field="roles"]');
|
||||
if (rolesDisplay) {
|
||||
const roleLabels = { 'mitarbeiter': 'Mitarbeiter', 'verwaltung': 'Verwaltung', 'admin': 'Admin' };
|
||||
rolesDisplay.innerHTML = roles.map(role =>
|
||||
`<span class="role-badge role-${role}" style="margin-right: 5px;">${roleLabels[role] || role}</span>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Bearbeitung beenden
|
||||
cancelEditUser(userId);
|
||||
alert('Benutzerdaten wurden erfolgreich gespeichert!');
|
||||
// Seite neu laden um sicherzustellen dass alles korrekt ist
|
||||
location.reload();
|
||||
} else {
|
||||
alert('Fehler: ' + (result.error || 'Daten konnten nicht gespeichert werden'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler:', error);
|
||||
alert('Fehler beim Speichern der Benutzerdaten');
|
||||
}
|
||||
}
|
||||
|
||||
// Bearbeitung abbrechen
|
||||
function cancelEditUser(userId) {
|
||||
const row = document.querySelector(`tr[data-user-id="${userId}"]`);
|
||||
if (!row) return;
|
||||
|
||||
// Alle Edit-Felder ausblenden und Display-Felder einblenden
|
||||
row.querySelectorAll('.user-field-edit').forEach(edit => {
|
||||
edit.style.display = 'none';
|
||||
// Wert zurücksetzen (nur für Input-Felder, nicht für Rollen)
|
||||
const field = edit.dataset.field;
|
||||
if (field !== 'roles') {
|
||||
const display = row.querySelector(`span[data-field="${field}"]`);
|
||||
if (display && edit.tagName === 'INPUT') {
|
||||
edit.value = display.textContent === '-' ? '' : display.textContent;
|
||||
}
|
||||
}
|
||||
});
|
||||
row.querySelectorAll('.user-field-display').forEach(display => {
|
||||
if (display.tagName === 'DIV' || display.tagName === 'SPAN') {
|
||||
display.style.display = 'block';
|
||||
} else {
|
||||
display.style.display = 'inline';
|
||||
}
|
||||
});
|
||||
|
||||
// Buttons umschalten
|
||||
row.querySelector('.edit-user-btn').style.display = 'inline-block';
|
||||
row.querySelector('.save-user-btn').style.display = 'none';
|
||||
row.querySelector('.cancel-user-btn').style.display = 'none';
|
||||
}
|
||||
1539
public/js/dashboard.js
Normal file
1539
public/js/dashboard.js
Normal file
File diff suppressed because it is too large
Load Diff
445
reset-db.js
Normal file
445
reset-db.js
Normal file
@@ -0,0 +1,445 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const dbPath = path.join(__dirname, 'stundenerfassung.db');
|
||||
|
||||
console.log('🔄 Setze Datenbank zurück...\n');
|
||||
|
||||
// Datenbank schließen falls offen
|
||||
let db = null;
|
||||
let savedLdapConfig = [];
|
||||
|
||||
// Hilfsfunktion zum Warten
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Funktion zum Prüfen und Beenden von Prozessen auf bestimmten Ports
|
||||
async function checkAndKillPorts(ports) {
|
||||
const killedProcesses = [];
|
||||
|
||||
for (const port of ports) {
|
||||
try {
|
||||
// Prüfe, ob der Port belegt ist (Windows)
|
||||
const { stdout } = await execAsync(`netstat -ano | findstr :${port}`);
|
||||
|
||||
if (stdout) {
|
||||
// Extrahiere PID aus der Ausgabe
|
||||
const lines = stdout.trim().split('\n');
|
||||
const pids = new Set();
|
||||
|
||||
for (const line of lines) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length > 0) {
|
||||
const pid = parts[parts.length - 1];
|
||||
if (pid && !isNaN(pid)) {
|
||||
pids.add(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Beende alle Prozesse, die den Port verwenden
|
||||
for (const pid of pids) {
|
||||
try {
|
||||
console.log(`🛑 Beende Prozess ${pid} auf Port ${port}...`);
|
||||
await execAsync(`taskkill /F /PID ${pid}`);
|
||||
killedProcesses.push({ port, pid });
|
||||
console.log(`✅ Prozess ${pid} beendet`);
|
||||
} catch (err) {
|
||||
// Prozess könnte bereits beendet sein oder keine Berechtigung
|
||||
if (!err.message.includes('not found') && !err.message.includes('not running')) {
|
||||
console.log(`⚠️ Konnte Prozess ${pid} nicht beenden: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Port ist nicht belegt oder netstat hat nichts gefunden
|
||||
if (!err.message.includes('findstr') && !err.message.includes('not found')) {
|
||||
// Ignoriere Fehler, wenn der Port nicht belegt ist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (killedProcesses.length > 0) {
|
||||
console.log(`\n✅ ${killedProcesses.length} Prozess(e) beendet\n`);
|
||||
// Warte kurz, damit die Ports freigegeben werden
|
||||
await sleep(1000);
|
||||
} else {
|
||||
console.log('ℹ️ Keine Prozesse auf Ports 3333 oder 3334 gefunden\n');
|
||||
}
|
||||
|
||||
return killedProcesses.length > 0;
|
||||
}
|
||||
|
||||
// Funktion zum Löschen der Datenbankdatei mit Retry-Logik (async)
|
||||
async function deleteDatabaseFile(retries = 10, initialDelay = 500) {
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
fs.unlinkSync(dbPath);
|
||||
console.log('✅ Datenbankdatei gelöscht\n');
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err.code === 'EBUSY' && i < retries - 1) {
|
||||
// Exponentielle Backoff-Strategie
|
||||
const waitTime = initialDelay * Math.pow(2, i);
|
||||
console.log(`⏳ Datenbankdatei noch gesperrt (Versuch ${i + 1}/${retries}), warte ${waitTime}ms...`);
|
||||
await sleep(waitTime);
|
||||
continue;
|
||||
}
|
||||
if (i === retries - 1) {
|
||||
console.error(`❌ Konnte Datenbankdatei nach ${retries} Versuchen nicht löschen.`);
|
||||
console.error(' Bitte stellen Sie sicher, dass alle Prozesse geschlossen sind, die die Datenbank verwenden.');
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Promise-Wrapper für sqlite3 Database.all
|
||||
function dbAll(db, query, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(query, params, (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Promise-Wrapper für sqlite3 Database.close
|
||||
function dbClose(db) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.close((err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Promise-Wrapper für sqlite3 Database-Konstruktor
|
||||
function openDatabase(path, mode = sqlite3.OPEN_READONLY) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const db = new sqlite3.Database(path, mode, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve(db);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Hauptfunktion als async
|
||||
async function resetDatabase() {
|
||||
try {
|
||||
// Prüfe und beende Prozesse auf Ports 3333 und 3334
|
||||
console.log('🔍 Prüfe auf laufende Server auf Ports 3333 und 3334...\n');
|
||||
await checkAndKillPorts([3333, 3334]);
|
||||
|
||||
// Prüfe ob Datenbank existiert und sichere ldap_config Daten
|
||||
if (fs.existsSync(dbPath)) {
|
||||
console.log('📁 Datenbankdatei gefunden...');
|
||||
|
||||
try {
|
||||
// Temporäre Datenbankverbindung zum Lesen der ldap_config
|
||||
const tempDb = await openDatabase(dbPath, sqlite3.OPEN_READONLY);
|
||||
|
||||
try {
|
||||
// Lese ldap_config Daten
|
||||
const rows = await dbAll(tempDb, 'SELECT * FROM ldap_config');
|
||||
if (rows && rows.length > 0) {
|
||||
savedLdapConfig = rows;
|
||||
console.log(`💾 ${rows.length} LDAP-Konfiguration(en) gesichert\n`);
|
||||
} else {
|
||||
console.log('ℹ️ Keine LDAP-Konfiguration vorhanden\n');
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('ℹ️ ldap_config Tabelle existiert nicht oder konnte nicht gelesen werden\n');
|
||||
}
|
||||
|
||||
// Schließe die temporäre Datenbank
|
||||
await dbClose(tempDb);
|
||||
|
||||
// Warte etwas länger, damit die Datenbank wirklich geschlossen ist
|
||||
await sleep(500);
|
||||
|
||||
// Datenbank löschen
|
||||
const success = await deleteDatabaseFile();
|
||||
if (success) {
|
||||
createNewDatabase();
|
||||
} else {
|
||||
console.error('❌ Konnte Datenbankdatei nicht löschen');
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('⚠️ Konnte Datenbank nicht öffnen zum Lesen, fahre fort...\n');
|
||||
// Datenbank löschen
|
||||
const success = await deleteDatabaseFile();
|
||||
if (success) {
|
||||
createNewDatabase();
|
||||
} else {
|
||||
console.error('❌ Konnte Datenbankdatei nicht löschen');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ Datenbankdatei existiert nicht, erstelle neue...\n');
|
||||
createNewDatabase();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Fehler beim Zurücksetzen der Datenbank:', error);
|
||||
if (db) {
|
||||
db.close();
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Starte das Reset
|
||||
resetDatabase().catch((error) => {
|
||||
console.error('❌ Unerwarteter Fehler:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
function createNewDatabase() {
|
||||
// Neue Datenbank erstellen
|
||||
db = new sqlite3.Database(dbPath);
|
||||
|
||||
db.serialize(() => {
|
||||
console.log('📊 Erstelle Tabellen...\n');
|
||||
|
||||
// Benutzer-Tabelle
|
||||
db.run(`CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
firstname TEXT NOT NULL,
|
||||
lastname TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'mitarbeiter',
|
||||
last_week_start TEXT,
|
||||
personalnummer TEXT,
|
||||
wochenstunden REAL,
|
||||
urlaubstage REAL,
|
||||
overtime_offset_hours REAL DEFAULT 0,
|
||||
ping_ip TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`, (err) => {
|
||||
if (err) console.error('Fehler bei users:', err);
|
||||
else console.log('✅ Tabelle users erstellt');
|
||||
});
|
||||
|
||||
// Stundenerfassung-Tabelle
|
||||
db.run(`CREATE TABLE timesheet_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
break_minutes INTEGER DEFAULT 0,
|
||||
total_hours REAL,
|
||||
notes TEXT,
|
||||
activity1_desc TEXT,
|
||||
activity1_hours REAL,
|
||||
activity1_project_number TEXT,
|
||||
activity2_desc TEXT,
|
||||
activity2_hours REAL,
|
||||
activity2_project_number TEXT,
|
||||
activity3_desc TEXT,
|
||||
activity3_hours REAL,
|
||||
activity3_project_number TEXT,
|
||||
activity4_desc TEXT,
|
||||
activity4_hours REAL,
|
||||
activity4_project_number TEXT,
|
||||
activity5_desc TEXT,
|
||||
activity5_hours REAL,
|
||||
activity5_project_number TEXT,
|
||||
overtime_taken_hours REAL,
|
||||
vacation_type TEXT,
|
||||
sick_status INTEGER DEFAULT 0,
|
||||
pause_start_time TEXT,
|
||||
pause_end_time TEXT,
|
||||
status TEXT DEFAULT 'offen',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)`, (err) => {
|
||||
if (err) console.error('Fehler bei timesheet_entries:', err);
|
||||
else console.log('✅ Tabelle timesheet_entries erstellt');
|
||||
});
|
||||
|
||||
// Wöchentliche Stundenzettel-Tabelle
|
||||
db.run(`CREATE TABLE weekly_timesheets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
week_start TEXT NOT NULL,
|
||||
week_end TEXT NOT NULL,
|
||||
version INTEGER DEFAULT 1,
|
||||
status TEXT DEFAULT 'eingereicht',
|
||||
submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
reviewed_by INTEGER,
|
||||
reviewed_at DATETIME,
|
||||
pdf_downloaded_at DATETIME,
|
||||
pdf_downloaded_by INTEGER,
|
||||
version_reason TEXT,
|
||||
admin_comment TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (reviewed_by) REFERENCES users(id),
|
||||
FOREIGN KEY (pdf_downloaded_by) REFERENCES users(id)
|
||||
)`, (err) => {
|
||||
if (err) console.error('Fehler bei weekly_timesheets:', err);
|
||||
else console.log('✅ Tabelle weekly_timesheets erstellt');
|
||||
});
|
||||
|
||||
// LDAP-Konfiguration-Tabelle
|
||||
db.run(`CREATE TABLE ldap_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
enabled INTEGER DEFAULT 0,
|
||||
url TEXT,
|
||||
bind_dn TEXT,
|
||||
bind_password TEXT,
|
||||
base_dn TEXT,
|
||||
user_search_filter TEXT,
|
||||
username_attribute TEXT DEFAULT 'cn',
|
||||
firstname_attribute TEXT DEFAULT 'givenName',
|
||||
lastname_attribute TEXT DEFAULT 'sn',
|
||||
sync_interval INTEGER DEFAULT 0,
|
||||
last_sync DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`, (err) => {
|
||||
if (err) console.error('Fehler bei ldap_config:', err);
|
||||
else {
|
||||
console.log('✅ Tabelle ldap_config erstellt');
|
||||
|
||||
// Stelle gesicherte LDAP-Konfiguration wieder her
|
||||
if (savedLdapConfig.length > 0) {
|
||||
console.log('🔄 Stelle LDAP-Konfiguration wieder her...');
|
||||
savedLdapConfig.forEach((config) => {
|
||||
db.run(`INSERT INTO ldap_config (
|
||||
id, enabled, url, bind_dn, bind_password, base_dn,
|
||||
user_search_filter, username_attribute, firstname_attribute,
|
||||
lastname_attribute, sync_interval, last_sync, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
||||
config.id,
|
||||
config.enabled,
|
||||
config.url,
|
||||
config.bind_dn,
|
||||
config.bind_password,
|
||||
config.base_dn,
|
||||
config.user_search_filter,
|
||||
config.username_attribute,
|
||||
config.firstname_attribute,
|
||||
config.lastname_attribute,
|
||||
config.sync_interval,
|
||||
config.last_sync,
|
||||
config.created_at,
|
||||
config.updated_at
|
||||
], (err) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Wiederherstellen der LDAP-Konfiguration:', err);
|
||||
} else {
|
||||
console.log(`✅ LDAP-Konfiguration (ID: ${config.id}) wiederhergestellt`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// LDAP-Sync-Log-Tabelle
|
||||
db.run(`CREATE TABLE ldap_sync_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sync_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
users_synced INTEGER DEFAULT 0,
|
||||
error_message TEXT,
|
||||
sync_started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
sync_completed_at DATETIME
|
||||
)`, (err) => {
|
||||
if (err) console.error('Fehler bei ldap_sync_log:', err);
|
||||
else console.log('✅ Tabelle ldap_sync_log erstellt');
|
||||
});
|
||||
|
||||
// Ping-Status-Tabelle für IP-basierte Zeiterfassung
|
||||
db.run(`CREATE TABLE ping_status (
|
||||
user_id INTEGER NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
last_successful_ping DATETIME,
|
||||
failed_ping_count INTEGER DEFAULT 0,
|
||||
start_time_set INTEGER DEFAULT 0,
|
||||
first_failed_ping_time DATETIME,
|
||||
PRIMARY KEY (user_id, date),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)`, (err) => {
|
||||
if (err) console.error('Fehler bei ping_status:', err);
|
||||
else console.log('✅ Tabelle ping_status erstellt');
|
||||
});
|
||||
|
||||
// Warte bis alle Tabellen erstellt sind
|
||||
db.run('SELECT 1', (err) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Warten:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n👤 Erstelle Standard-Benutzer...\n');
|
||||
|
||||
// Standard Admin-Benutzer (Rolle als JSON-Array)
|
||||
const adminPassword = bcrypt.hashSync('admin123', 10);
|
||||
db.run(`INSERT INTO users (id, username, password, firstname, lastname, role)
|
||||
VALUES (1, 'admin', ?, 'System', 'Administrator', ?)`,
|
||||
[adminPassword, JSON.stringify(['admin'])], (err) => {
|
||||
if (err) console.error('Fehler beim Erstellen des Admin-Users:', err);
|
||||
else console.log('✅ Admin-User erstellt (admin / admin123)');
|
||||
});
|
||||
|
||||
// Standard Verwaltungs-Benutzer (Rolle als JSON-Array)
|
||||
const verwaltungPassword = bcrypt.hashSync('verwaltung123', 10);
|
||||
db.run(`INSERT INTO users (id, username, password, firstname, lastname, role)
|
||||
VALUES (2, 'verwaltung', ?, 'Verwaltung', 'User', ?)`,
|
||||
[verwaltungPassword, JSON.stringify(['verwaltung'])], (err) => {
|
||||
if (err) console.error('Fehler beim Erstellen des Verwaltungs-Users:', err);
|
||||
else console.log('✅ Verwaltungs-User erstellt (verwaltung / verwaltung123)');
|
||||
});
|
||||
|
||||
// Test-Mitarbeiter (optional, Rolle als JSON-Array)
|
||||
const mitarbeiterPassword = bcrypt.hashSync('test123', 10);
|
||||
db.run(`INSERT INTO users (id, username, password, firstname, lastname, role, wochenstunden, urlaubstage)
|
||||
VALUES (3, 'test', ?, 'Test', 'Mitarbeiter', ?, 40, 25)`,
|
||||
[mitarbeiterPassword, JSON.stringify(['mitarbeiter'])], (err) => {
|
||||
if (err && !err.message.includes('UNIQUE constraint')) {
|
||||
console.error('Fehler beim Erstellen des Test-Users:', err);
|
||||
} else if (!err) {
|
||||
console.log('✅ Test-Mitarbeiter erstellt (test / test123, 40h/Woche, 25 Urlaubstage)');
|
||||
}
|
||||
});
|
||||
|
||||
// Warte bis alle Benutzer erstellt sind
|
||||
setTimeout(() => {
|
||||
console.log('\n✨ Datenbank erfolgreich zurückgesetzt!\n');
|
||||
console.log('📋 Standard-Zugangsdaten:');
|
||||
console.log(' Admin: admin / admin123');
|
||||
console.log(' Verwaltung: verwaltung / verwaltung123');
|
||||
console.log(' Test-User: test / test123\n');
|
||||
|
||||
db.close((err) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Schließen der Datenbank:', err);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('✅ Datenbank geschlossen\n');
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
});
|
||||
});
|
||||
}
|
||||
167
routes/admin-ldap.js
Normal file
167
routes/admin-ldap.js
Normal file
@@ -0,0 +1,167 @@
|
||||
// LDAP Admin Routes
|
||||
|
||||
const { db } = require('../database');
|
||||
const LDAPService = require('../ldap-service');
|
||||
const { requireAdmin } = require('../middleware/auth');
|
||||
|
||||
// Routes registrieren
|
||||
function registerAdminLDAPRoutes(app) {
|
||||
// LDAP-Konfiguration abrufen
|
||||
app.get('/admin/ldap/config', requireAdmin, (req, res) => {
|
||||
db.get('SELECT * FROM ldap_config WHERE id = 1', (err, config) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Fehler beim Abrufen der Konfiguration' });
|
||||
}
|
||||
|
||||
// Passwort nicht zurückgeben
|
||||
if (config) {
|
||||
delete config.bind_password;
|
||||
}
|
||||
|
||||
res.json({ config: config || null });
|
||||
});
|
||||
});
|
||||
|
||||
// LDAP-Konfiguration speichern
|
||||
app.post('/admin/ldap/config', requireAdmin, (req, res) => {
|
||||
const {
|
||||
enabled,
|
||||
url,
|
||||
bind_dn,
|
||||
bind_password,
|
||||
base_dn,
|
||||
user_search_filter,
|
||||
username_attribute,
|
||||
firstname_attribute,
|
||||
lastname_attribute,
|
||||
sync_interval
|
||||
} = req.body;
|
||||
|
||||
// Validierung - nur wenn aktiviert
|
||||
if (enabled && (!url || !base_dn)) {
|
||||
return res.status(400).json({ error: 'URL und Base DN sind erforderlich wenn LDAP aktiviert ist' });
|
||||
}
|
||||
|
||||
// Prüfe ob Konfiguration bereits existiert
|
||||
db.get('SELECT id FROM ldap_config WHERE id = 1', (err, existing) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Fehler beim Prüfen der Konfiguration' });
|
||||
}
|
||||
|
||||
const configData = {
|
||||
enabled: enabled ? 1 : 0,
|
||||
url: url.trim(),
|
||||
bind_dn: bind_dn ? bind_dn.trim() : null,
|
||||
bind_password: bind_password ? bind_password.trim() : null,
|
||||
base_dn: base_dn.trim(),
|
||||
user_search_filter: user_search_filter ? user_search_filter.trim() : '(objectClass=person)',
|
||||
username_attribute: username_attribute ? username_attribute.trim() : 'cn',
|
||||
firstname_attribute: firstname_attribute ? firstname_attribute.trim() : 'givenName',
|
||||
lastname_attribute: lastname_attribute ? lastname_attribute.trim() : 'sn',
|
||||
sync_interval: parseInt(sync_interval) || 0,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
// Update - Passwort nur aktualisieren wenn angegeben
|
||||
if (configData.bind_password) {
|
||||
db.run(
|
||||
`UPDATE ldap_config SET
|
||||
enabled = ?, url = ?, bind_dn = ?, bind_password = ?, base_dn = ?,
|
||||
user_search_filter = ?, username_attribute = ?, firstname_attribute = ?,
|
||||
lastname_attribute = ?, sync_interval = ?, updated_at = ?
|
||||
WHERE id = 1`,
|
||||
[
|
||||
configData.enabled, configData.url, configData.bind_dn, configData.bind_password,
|
||||
configData.base_dn, configData.user_search_filter, configData.username_attribute,
|
||||
configData.firstname_attribute, configData.lastname_attribute, configData.sync_interval,
|
||||
configData.updated_at
|
||||
],
|
||||
(err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Fehler beim Speichern der Konfiguration' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Passwort nicht ändern
|
||||
db.run(
|
||||
`UPDATE ldap_config SET
|
||||
enabled = ?, url = ?, bind_dn = ?, base_dn = ?,
|
||||
user_search_filter = ?, username_attribute = ?, firstname_attribute = ?,
|
||||
lastname_attribute = ?, sync_interval = ?, updated_at = ?
|
||||
WHERE id = 1`,
|
||||
[
|
||||
configData.enabled, configData.url, configData.bind_dn,
|
||||
configData.base_dn, configData.user_search_filter, configData.username_attribute,
|
||||
configData.firstname_attribute, configData.lastname_attribute, configData.sync_interval,
|
||||
configData.updated_at
|
||||
],
|
||||
(err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Fehler beim Speichern der Konfiguration' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Insert
|
||||
db.run(
|
||||
`INSERT INTO ldap_config (
|
||||
enabled, url, bind_dn, bind_password, base_dn, user_search_filter,
|
||||
username_attribute, firstname_attribute, lastname_attribute, sync_interval, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
configData.enabled, configData.url, configData.bind_dn, configData.bind_password,
|
||||
configData.base_dn, configData.user_search_filter, configData.username_attribute,
|
||||
configData.firstname_attribute, configData.lastname_attribute, configData.sync_interval,
|
||||
configData.updated_at
|
||||
],
|
||||
(err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Fehler beim Erstellen der Konfiguration' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Manuelle LDAP-Synchronisation starten
|
||||
app.post('/admin/ldap/sync', requireAdmin, (req, res) => {
|
||||
LDAPService.performSync('manual', (err, result) => {
|
||||
if (err) {
|
||||
return res.status(500).json({
|
||||
error: err.message || 'Fehler bei der Synchronisation',
|
||||
synced: result ? result.synced : 0,
|
||||
errors: result ? result.errors : []
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
success: true,
|
||||
synced: result.synced,
|
||||
errors: result.errors || []
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Sync-Log abrufen
|
||||
app.get('/admin/ldap/sync/log', requireAdmin, (req, res) => {
|
||||
const limit = parseInt(req.query.limit) || 10;
|
||||
db.all(
|
||||
'SELECT * FROM ldap_sync_log ORDER BY sync_started_at DESC LIMIT ?',
|
||||
[limit],
|
||||
(err, logs) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Fehler beim Abrufen des Logs' });
|
||||
}
|
||||
res.json({ logs: logs || [] });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = registerAdminLDAPRoutes;
|
||||
154
routes/admin.js
Normal file
154
routes/admin.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// Admin Routes
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { db } = require('../database');
|
||||
const { requireAdmin } = require('../middleware/auth');
|
||||
|
||||
// Routes registrieren
|
||||
function registerAdminRoutes(app) {
|
||||
// Admin-Bereich
|
||||
app.get('/admin', requireAdmin, (req, res) => {
|
||||
db.all('SELECT id, username, firstname, lastname, role, personalnummer, wochenstunden, urlaubstage, created_at FROM users ORDER BY created_at DESC',
|
||||
(err, users) => {
|
||||
// LDAP-Konfiguration und Sync-Log abrufen
|
||||
db.get('SELECT * FROM ldap_config WHERE id = 1', (err, ldapConfig) => {
|
||||
db.all('SELECT * FROM ldap_sync_log ORDER BY sync_started_at DESC LIMIT 10', (err, syncLogs) => {
|
||||
// Parse Rollen für jeden User
|
||||
const usersWithRoles = (users || []).map(u => {
|
||||
let roles = [];
|
||||
try {
|
||||
roles = JSON.parse(u.role);
|
||||
if (!Array.isArray(roles)) {
|
||||
roles = [u.role];
|
||||
}
|
||||
} catch (e) {
|
||||
roles = [u.role || 'mitarbeiter'];
|
||||
}
|
||||
return { ...u, roles };
|
||||
});
|
||||
|
||||
res.render('admin', {
|
||||
users: usersWithRoles,
|
||||
ldapConfig: ldapConfig || null,
|
||||
syncLogs: syncLogs || [],
|
||||
user: {
|
||||
firstname: req.session.firstname,
|
||||
lastname: req.session.lastname,
|
||||
roles: req.session.roles || [],
|
||||
currentRole: req.session.currentRole || 'admin'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Benutzer erstellen
|
||||
app.post('/admin/users', requireAdmin, (req, res) => {
|
||||
const { username, password, firstname, lastname, roles, personalnummer, wochenstunden, urlaubstage } = req.body;
|
||||
const hashedPassword = bcrypt.hashSync(password, 10);
|
||||
|
||||
// Normalisiere die optionalen Felder
|
||||
const normalizedPersonalnummer = personalnummer && personalnummer.trim() !== '' ? personalnummer.trim() : null;
|
||||
const normalizedWochenstunden = wochenstunden && wochenstunden !== '' ? parseFloat(wochenstunden) : null;
|
||||
const normalizedUrlaubstage = urlaubstage && urlaubstage !== '' ? parseFloat(urlaubstage) : null;
|
||||
|
||||
// Rollen verarbeiten: Erwarte Array, konvertiere zu JSON-String
|
||||
let rolesArray = [];
|
||||
if (Array.isArray(roles)) {
|
||||
rolesArray = roles.filter(r => r && ['mitarbeiter', 'verwaltung', 'admin'].includes(r));
|
||||
} else if (roles) {
|
||||
// Fallback: Einzelne Rolle als Array
|
||||
rolesArray = [roles];
|
||||
}
|
||||
|
||||
// Mindestens eine Rolle erforderlich
|
||||
if (rolesArray.length === 0) {
|
||||
rolesArray = ['mitarbeiter']; // Standard-Rolle
|
||||
}
|
||||
|
||||
const rolesJson = JSON.stringify(rolesArray);
|
||||
|
||||
db.run('INSERT INTO users (username, password, firstname, lastname, role, personalnummer, wochenstunden, urlaubstage) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[username, hashedPassword, firstname, lastname, rolesJson, normalizedPersonalnummer, normalizedWochenstunden, normalizedUrlaubstage],
|
||||
(err) => {
|
||||
if (err) {
|
||||
return res.status(400).json({ error: 'Benutzername existiert bereits' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Benutzer löschen
|
||||
app.delete('/admin/users/:id', requireAdmin, (req, res) => {
|
||||
const userId = req.params.id;
|
||||
|
||||
// Admin darf sich nicht selbst löschen
|
||||
if (userId == req.session.userId) {
|
||||
return res.status(400).json({ error: 'Sie können sich nicht selbst löschen' });
|
||||
}
|
||||
|
||||
db.run('DELETE FROM users WHERE id = ?', [userId], (err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Fehler beim Löschen' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Benutzer aktualisieren (Personalnummer, Wochenstunden, Urlaubstage, Rollen)
|
||||
app.put('/admin/users/:id', requireAdmin, (req, res) => {
|
||||
const userId = req.params.id;
|
||||
const { personalnummer, wochenstunden, urlaubstage, roles } = req.body;
|
||||
|
||||
// Rollen verarbeiten falls vorhanden
|
||||
let rolesJson = null;
|
||||
if (roles !== undefined) {
|
||||
let rolesArray = [];
|
||||
if (Array.isArray(roles)) {
|
||||
rolesArray = roles.filter(r => r && ['mitarbeiter', 'verwaltung', 'admin'].includes(r));
|
||||
}
|
||||
// Mindestens eine Rolle erforderlich
|
||||
if (rolesArray.length === 0) {
|
||||
return res.status(400).json({ error: 'Mindestens eine Rolle ist erforderlich' });
|
||||
}
|
||||
rolesJson = JSON.stringify(rolesArray);
|
||||
}
|
||||
|
||||
// SQL-Query dynamisch zusammenstellen
|
||||
if (rolesJson !== null) {
|
||||
// Aktualisiere auch Rollen
|
||||
db.run('UPDATE users SET personalnummer = ?, wochenstunden = ?, urlaubstage = ?, role = ? WHERE id = ?',
|
||||
[
|
||||
personalnummer || null,
|
||||
wochenstunden ? parseFloat(wochenstunden) : null,
|
||||
urlaubstage ? parseFloat(urlaubstage) : null,
|
||||
rolesJson,
|
||||
userId
|
||||
],
|
||||
(err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Fehler beim Aktualisieren' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
} else {
|
||||
// Nur andere Felder aktualisieren
|
||||
db.run('UPDATE users SET personalnummer = ?, wochenstunden = ?, urlaubstage = ? WHERE id = ?',
|
||||
[
|
||||
personalnummer || null,
|
||||
wochenstunden ? parseFloat(wochenstunden) : null,
|
||||
urlaubstage ? parseFloat(urlaubstage) : null,
|
||||
userId
|
||||
],
|
||||
(err) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Fehler beim Aktualisieren' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = registerAdminRoutes;
|
||||
132
routes/auth.js
Normal file
132
routes/auth.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// Authentifizierungs-Routes
|
||||
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { db } = require('../database');
|
||||
const LDAPService = require('../ldap-service');
|
||||
const { getDefaultRole } = require('../helpers/utils');
|
||||
|
||||
// Helper-Funktion für erfolgreiche Anmeldung
|
||||
function handleSuccessfulLogin(req, res, user, rememberMe = false) {
|
||||
// Rollen als JSON-Array parsen
|
||||
let roles = [];
|
||||
try {
|
||||
roles = JSON.parse(user.role);
|
||||
if (!Array.isArray(roles)) {
|
||||
// Fallback: Falls kein Array, erstelle Array mit vorhandener Rolle
|
||||
roles = [user.role];
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback: Falls kein JSON, erstelle Array mit vorhandener Rolle
|
||||
roles = [user.role || 'mitarbeiter'];
|
||||
}
|
||||
|
||||
// Standard-Rolle bestimmen: Immer "mitarbeiter" wenn vorhanden, sonst höchste Priorität
|
||||
let defaultRole;
|
||||
if (roles.includes('mitarbeiter')) {
|
||||
defaultRole = 'mitarbeiter';
|
||||
} else {
|
||||
defaultRole = getDefaultRole(roles);
|
||||
}
|
||||
|
||||
req.session.userId = user.id;
|
||||
req.session.username = user.username;
|
||||
req.session.roles = roles;
|
||||
req.session.currentRole = defaultRole;
|
||||
req.session.firstname = user.firstname;
|
||||
req.session.lastname = user.lastname;
|
||||
|
||||
// Session-Gültigkeit setzen: 30 Tage wenn "Angemeldet bleiben" aktiviert, sonst 24 Stunden
|
||||
if (rememberMe) {
|
||||
req.session.cookie.maxAge = 30 * 24 * 60 * 60 * 1000; // 30 Tage
|
||||
} else {
|
||||
req.session.cookie.maxAge = 24 * 60 * 60 * 1000; // 24 Stunden
|
||||
}
|
||||
|
||||
// Redirect: Immer zu Dashboard wenn Mitarbeiter-Rolle vorhanden, sonst basierend auf Standard-Rolle
|
||||
if (roles.includes('mitarbeiter')) {
|
||||
res.redirect('/dashboard');
|
||||
} else if (defaultRole === 'admin') {
|
||||
res.redirect('/admin');
|
||||
} else if (defaultRole === 'verwaltung') {
|
||||
res.redirect('/verwaltung');
|
||||
} else {
|
||||
res.redirect('/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
// Routes registrieren
|
||||
function registerAuthRoutes(app) {
|
||||
// Login-Seite
|
||||
app.get('/login', (req, res) => {
|
||||
res.render('login', { error: null });
|
||||
});
|
||||
|
||||
// Login-Verarbeitung
|
||||
app.post('/login', (req, res) => {
|
||||
const { username, password, remember_me } = req.body;
|
||||
const rememberMe = remember_me === 'on' || remember_me === true;
|
||||
|
||||
// Prüfe ob LDAP aktiviert ist
|
||||
LDAPService.getConfig((err, ldapConfig) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Abrufen der LDAP-Konfiguration:', err);
|
||||
}
|
||||
|
||||
const isLDAPEnabled = ldapConfig && ldapConfig.enabled === 1;
|
||||
|
||||
// Wenn LDAP aktiviert ist, authentifiziere gegen LDAP
|
||||
if (isLDAPEnabled) {
|
||||
LDAPService.authenticate(username, password, (authErr, authSuccess) => {
|
||||
if (authErr || !authSuccess) {
|
||||
// LDAP-Authentifizierung fehlgeschlagen - prüfe lokale Datenbank als Fallback
|
||||
// Case-insensitive Suche: COLLATE NOCASE macht den Vergleich case-insensitive
|
||||
db.get('SELECT * FROM users WHERE username = ? COLLATE NOCASE', [username], (err, user) => {
|
||||
if (err || !user) {
|
||||
return res.render('login', { error: 'Ungültiger Benutzername oder Passwort' });
|
||||
}
|
||||
|
||||
// Versuche lokale Authentifizierung
|
||||
if (bcrypt.compareSync(password, user.password)) {
|
||||
handleSuccessfulLogin(req, res, user, rememberMe);
|
||||
} else {
|
||||
res.render('login', { error: 'Ungültiger Benutzername oder Passwort' });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// LDAP-Authentifizierung erfolgreich - hole Benutzer aus Datenbank
|
||||
// Case-insensitive Suche: COLLATE NOCASE macht den Vergleich case-insensitive
|
||||
db.get('SELECT * FROM users WHERE username = ? COLLATE NOCASE', [username], (err, user) => {
|
||||
if (err || !user) {
|
||||
return res.render('login', { error: 'Benutzer nicht in der Datenbank gefunden. Bitte führen Sie eine LDAP-Synchronisation durch.' });
|
||||
}
|
||||
|
||||
handleSuccessfulLogin(req, res, user, rememberMe);
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// LDAP nicht aktiviert - verwende lokale Authentifizierung
|
||||
// Case-insensitive Suche: COLLATE NOCASE macht den Vergleich case-insensitive
|
||||
db.get('SELECT * FROM users WHERE username = ? COLLATE NOCASE', [username], (err, user) => {
|
||||
if (err || !user) {
|
||||
return res.render('login', { error: 'Ungültiger Benutzername oder Passwort' });
|
||||
}
|
||||
|
||||
if (bcrypt.compareSync(password, user.password)) {
|
||||
handleSuccessfulLogin(req, res, user, rememberMe);
|
||||
} else {
|
||||
res.render('login', { error: 'Ungültiger Benutzername oder Passwort' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Logout
|
||||
app.get('/logout', (req, res) => {
|
||||
req.session.destroy();
|
||||
res.redirect('/login');
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = registerAuthRoutes;
|
||||
35
routes/dashboard.js
Normal file
35
routes/dashboard.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// Dashboard-Route
|
||||
|
||||
const { hasRole } = require('../helpers/utils');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
|
||||
// Routes registrieren
|
||||
function registerDashboardRoutes(app) {
|
||||
// Dashboard für Mitarbeiter
|
||||
app.get('/dashboard', requireAuth, (req, res) => {
|
||||
// Prüfe ob User Mitarbeiter-Rolle hat
|
||||
if (!hasRole(req, 'mitarbeiter')) {
|
||||
// Wenn User keine Mitarbeiter-Rolle hat, aber andere Rollen, redirecte entsprechend
|
||||
if (hasRole(req, 'admin')) {
|
||||
return res.redirect('/admin');
|
||||
}
|
||||
if (hasRole(req, 'verwaltung')) {
|
||||
return res.redirect('/verwaltung');
|
||||
}
|
||||
return res.status(403).send('Zugriff verweigert');
|
||||
}
|
||||
|
||||
res.render('dashboard', {
|
||||
user: {
|
||||
id: req.session.userId,
|
||||
firstname: req.session.firstname,
|
||||
lastname: req.session.lastname,
|
||||
username: req.session.username,
|
||||
roles: req.session.roles || [],
|
||||
currentRole: req.session.currentRole || 'mitarbeiter'
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = registerDashboardRoutes;
|
||||
379
routes/timesheet.js
Normal file
379
routes/timesheet.js
Normal file
@@ -0,0 +1,379 @@
|
||||
// Timesheet API Routes
|
||||
|
||||
const { db } = require('../database');
|
||||
const { requireAuth, requireVerwaltung } = require('../middleware/auth');
|
||||
const { generatePDF } = require('../services/pdf-service');
|
||||
const { getHolidaysForDateRange } = require('../services/feiertage-service');
|
||||
|
||||
// Routes registrieren
|
||||
function registerTimesheetRoutes(app) {
|
||||
// API: Stundenerfassung speichern
|
||||
app.post('/api/timesheet/save', requireAuth, (req, res) => {
|
||||
const {
|
||||
date, start_time, end_time, break_minutes, notes,
|
||||
activity1_desc, activity1_hours, activity1_project_number,
|
||||
activity2_desc, activity2_hours, activity2_project_number,
|
||||
activity3_desc, activity3_hours, activity3_project_number,
|
||||
activity4_desc, activity4_hours, activity4_project_number,
|
||||
activity5_desc, activity5_hours, activity5_project_number,
|
||||
overtime_taken_hours, vacation_type, sick_status
|
||||
} = req.body;
|
||||
const userId = req.session.userId;
|
||||
|
||||
// Normalisiere end_time: Leere Strings werden zu null
|
||||
const normalizedEndTime = (end_time && typeof end_time === 'string' && end_time.trim() !== '') ? end_time.trim() : (end_time || null);
|
||||
const normalizedStartTime = (start_time && typeof start_time === 'string' && start_time.trim() !== '') ? start_time.trim() : (start_time || null);
|
||||
|
||||
// Normalisiere sick_status: Boolean oder 1/0 zu Boolean
|
||||
const isSick = sick_status === true || sick_status === 1 || sick_status === 'true' || sick_status === '1';
|
||||
|
||||
// User-Daten laden (für Überstunden-Berechnung)
|
||||
db.get('SELECT wochenstunden FROM users WHERE id = ?', [userId], (err, user) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Laden der User-Daten:', err);
|
||||
return res.status(500).json({ error: 'Fehler beim Laden der User-Daten' });
|
||||
}
|
||||
|
||||
const wochenstunden = user?.wochenstunden || 0;
|
||||
const overtimeValue = overtime_taken_hours ? parseFloat(overtime_taken_hours) : 0;
|
||||
const fullDayHours = wochenstunden > 0 ? wochenstunden / 5 : 0;
|
||||
|
||||
// Überstunden-Logik: Prüfe ob ganzer Tag oder weniger
|
||||
let isFullDayOvertime = false;
|
||||
if (overtimeValue > 0 && fullDayHours > 0 && Math.abs(overtimeValue - fullDayHours) < 0.01) {
|
||||
isFullDayOvertime = true;
|
||||
}
|
||||
|
||||
// Gesamtstunden berechnen (aus Start- und Endzeit, nicht aus Tätigkeiten)
|
||||
// Wenn ganzer Tag Urlaub oder Krank, dann zählt dieser als 8 Stunden normale Arbeitszeit
|
||||
let total_hours = 0;
|
||||
let finalActivity1Desc = activity1_desc;
|
||||
let finalActivity1Hours = parseFloat(activity1_hours) || 0;
|
||||
let finalActivity2Desc = activity2_desc;
|
||||
let finalActivity3Desc = activity3_desc;
|
||||
let finalActivity4Desc = activity4_desc;
|
||||
let finalActivity5Desc = activity5_desc;
|
||||
let finalStartTime = normalizedStartTime;
|
||||
let finalEndTime = normalizedEndTime;
|
||||
|
||||
// Überstunden-Logik: Bei vollem Tag Überstunden
|
||||
if (isFullDayOvertime) {
|
||||
total_hours = 0;
|
||||
finalStartTime = null;
|
||||
finalEndTime = null;
|
||||
// Keine Tätigkeit setzen - Überstunden werden über overtime_taken_hours in der PDF angezeigt
|
||||
} else if (vacation_type === 'full') {
|
||||
total_hours = 8; // Ganzer Tag Urlaub = 8 Stunden normale Arbeitszeit
|
||||
} else if (isSick) {
|
||||
total_hours = 8; // Krank = 8 Stunden normale Arbeitszeit
|
||||
finalActivity1Desc = 'Krank';
|
||||
finalActivity1Hours = 8;
|
||||
} else if (normalizedStartTime && normalizedEndTime) {
|
||||
const start = new Date(`2000-01-01T${normalizedStartTime}`);
|
||||
const end = new Date(`2000-01-01T${normalizedEndTime}`);
|
||||
const diffMs = end - start;
|
||||
total_hours = (diffMs / (1000 * 60 * 60)) - (break_minutes / 60);
|
||||
// Bei halbem Tag Urlaub: total_hours bleibt die tatsächlich gearbeiteten Stunden
|
||||
// Die 4 Stunden Urlaub werden nur in der Überstunden-Berechnung hinzugezählt
|
||||
}
|
||||
|
||||
// Überstunden werden nicht mehr als Tätigkeit hinzugefügt
|
||||
// Sie werden über overtime_taken_hours in der PDF angezeigt
|
||||
|
||||
// Prüfen ob Eintrag existiert - verwende den neuesten Eintrag falls mehrere existieren
|
||||
db.get('SELECT id FROM timesheet_entries WHERE user_id = ? AND date = ? ORDER BY updated_at DESC, id DESC LIMIT 1',
|
||||
[userId, date], (err, row) => {
|
||||
if (row) {
|
||||
// Update
|
||||
db.run(`UPDATE timesheet_entries
|
||||
SET start_time = ?, end_time = ?, break_minutes = ?, total_hours = ?, notes = ?,
|
||||
activity1_desc = ?, activity1_hours = ?, activity1_project_number = ?,
|
||||
activity2_desc = ?, activity2_hours = ?, activity2_project_number = ?,
|
||||
activity3_desc = ?, activity3_hours = ?, activity3_project_number = ?,
|
||||
activity4_desc = ?, activity4_hours = ?, activity4_project_number = ?,
|
||||
activity5_desc = ?, activity5_hours = ?, activity5_project_number = ?,
|
||||
overtime_taken_hours = ?, vacation_type = ?, sick_status = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
[
|
||||
finalStartTime, finalEndTime, break_minutes, total_hours, notes,
|
||||
finalActivity1Desc || null, finalActivity1Hours, activity1_project_number || null,
|
||||
finalActivity2Desc || null, parseFloat(activity2_hours) || 0, activity2_project_number || null,
|
||||
finalActivity3Desc || null, parseFloat(activity3_hours) || 0, activity3_project_number || null,
|
||||
finalActivity4Desc || null, parseFloat(activity4_hours) || 0, activity4_project_number || null,
|
||||
finalActivity5Desc || null, parseFloat(activity5_hours) || 0, activity5_project_number || null,
|
||||
overtime_taken_hours ? parseFloat(overtime_taken_hours) : null,
|
||||
vacation_type || null,
|
||||
isSick ? 1 : 0,
|
||||
row.id
|
||||
],
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Update:', err);
|
||||
return res.status(500).json({ error: 'Fehler beim Speichern: ' + err.message });
|
||||
}
|
||||
res.json({ success: true, total_hours });
|
||||
});
|
||||
} else {
|
||||
// Insert
|
||||
db.run(`INSERT INTO timesheet_entries
|
||||
(user_id, date, start_time, end_time, break_minutes, total_hours, notes,
|
||||
activity1_desc, activity1_hours, activity1_project_number,
|
||||
activity2_desc, activity2_hours, activity2_project_number,
|
||||
activity3_desc, activity3_hours, activity3_project_number,
|
||||
activity4_desc, activity4_hours, activity4_project_number,
|
||||
activity5_desc, activity5_hours, activity5_project_number,
|
||||
overtime_taken_hours, vacation_type, sick_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
userId, date, finalStartTime, finalEndTime, break_minutes, total_hours, notes,
|
||||
finalActivity1Desc || null, finalActivity1Hours, activity1_project_number || null,
|
||||
finalActivity2Desc || null, parseFloat(activity2_hours) || 0, activity2_project_number || null,
|
||||
finalActivity3Desc || null, parseFloat(activity3_hours) || 0, activity3_project_number || null,
|
||||
finalActivity4Desc || null, parseFloat(activity4_hours) || 0, activity4_project_number || null,
|
||||
finalActivity5Desc || null, parseFloat(activity5_hours) || 0, activity5_project_number || null,
|
||||
overtime_taken_hours ? parseFloat(overtime_taken_hours) : null,
|
||||
vacation_type || null,
|
||||
isSick ? 1 : 0
|
||||
],
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Insert:', err);
|
||||
return res.status(500).json({ error: 'Fehler beim Speichern: ' + err.message });
|
||||
}
|
||||
res.json({ success: true, total_hours });
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// API: Feiertage für einen Zeitraum (Dashboard-Anzeige)
|
||||
app.get('/api/timesheet/holidays', requireAuth, (req, res) => {
|
||||
const { week_start, week_end } = req.query;
|
||||
if (!week_start || !week_end) {
|
||||
return res.status(400).json({ error: 'week_start und week_end erforderlich' });
|
||||
}
|
||||
getHolidaysForDateRange(week_start, week_end)
|
||||
.then((set) => res.json({ dates: [...set] }))
|
||||
.catch(() => res.json({ dates: [] }));
|
||||
});
|
||||
|
||||
// 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, overtime_taken_hours, 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
|
||||
// Bei 8 Überstunden (ganzer Tag) ist der Tag auch 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]);
|
||||
|
||||
// User-Daten laden für Überstunden-Berechnung
|
||||
db.get('SELECT wochenstunden FROM users WHERE id = ?', [userId], (err, user) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Fehler beim Laden der User-Daten' });
|
||||
}
|
||||
|
||||
const wochenstunden = user?.wochenstunden || 0;
|
||||
const fullDayHours = wochenstunden > 0 ? wochenstunden / 5 : 8;
|
||||
|
||||
// Feiertage laden: Feiertag zählt als ausgefüllt (kein Start/Ende nötig)
|
||||
getHolidaysForDateRange(week_start, week_end)
|
||||
.catch(() => new Set())
|
||||
.then((holidaySet) => {
|
||||
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];
|
||||
|
||||
// Feiertag zählt als ausgefüllt
|
||||
if (holidaySet.has(dateStr)) {
|
||||
continue; // Tag ist ausgefüllt
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Prüfe ob 8 Überstunden (ganzer Tag) eingetragen sind
|
||||
const overtimeValue = entry && entry.overtime_taken_hours ? parseFloat(entry.overtime_taken_hours) : 0;
|
||||
const isFullDayOvertime = overtimeValue > 0 && Math.abs(overtimeValue - fullDayHours) < 0.01;
|
||||
|
||||
if (isFullDayOvertime) {
|
||||
continue; // Tag ist ausgefüllt (8 Überstunden = ganzer Tag)
|
||||
}
|
||||
|
||||
// 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;
|
||||
468
routes/user.js
Normal file
468
routes/user.js
Normal file
@@ -0,0 +1,468 @@
|
||||
// User API Routes
|
||||
|
||||
const { db } = require('../database');
|
||||
const { hasRole, getCurrentDate } = require('../helpers/utils');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
const { getHolidaysForDateRange } = require('../services/feiertage-service');
|
||||
|
||||
// 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: Client-IP abrufen
|
||||
app.get('/api/user/client-ip', requireAuth, (req, res) => {
|
||||
// Versuche verschiedene Methoden, um die Client-IP zu erhalten
|
||||
const clientIp = req.ip ||
|
||||
req.connection.remoteAddress ||
|
||||
req.socket.remoteAddress ||
|
||||
(req.headers['x-forwarded-for'] ? req.headers['x-forwarded-for'].split(',')[0].trim() : null) ||
|
||||
req.headers['x-real-ip'] ||
|
||||
'unknown';
|
||||
|
||||
// Entferne IPv6-Präfix falls vorhanden (::ffff:192.168.1.1 -> 192.168.1.1)
|
||||
const cleanIp = clientIp.replace(/^::ffff:/, '');
|
||||
|
||||
res.json({ client_ip: cleanIp });
|
||||
});
|
||||
|
||||
// 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: Verplante Urlaubstage (alle Wochen, auch nicht-eingereichte)
|
||||
app.get('/api/user/planned-vacation', requireAuth, (req, res) => {
|
||||
const userId = req.session.userId;
|
||||
const { getCalendarWeek } = require('../helpers/utils');
|
||||
|
||||
db.all(`SELECT date, vacation_type FROM timesheet_entries
|
||||
WHERE user_id = ? AND vacation_type IS NOT NULL AND vacation_type != ''`,
|
||||
[userId],
|
||||
(err, entries) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Fehler beim Abrufen der verplanten Tage' });
|
||||
}
|
||||
|
||||
let plannedDays = 0;
|
||||
const weeksMap = {}; // { KW: { year: YYYY, week: KW, days: X } }
|
||||
|
||||
entries.forEach(entry => {
|
||||
const dayValue = entry.vacation_type === 'full' ? 1 : 0.5;
|
||||
plannedDays += dayValue;
|
||||
|
||||
// Berechne Kalenderwoche
|
||||
const date = new Date(entry.date);
|
||||
const year = date.getFullYear();
|
||||
const week = getCalendarWeek(entry.date);
|
||||
const weekKey = `${year}-KW${week}`;
|
||||
|
||||
if (!weeksMap[weekKey]) {
|
||||
weeksMap[weekKey] = { year, week, days: 0 };
|
||||
}
|
||||
weeksMap[weekKey].days += dayValue;
|
||||
});
|
||||
|
||||
// Konvertiere zu sortiertem Array
|
||||
const weeks = Object.values(weeksMap).sort((a, b) => {
|
||||
if (a.year !== b.year) return a.year - b.year;
|
||||
return a.week - b.week;
|
||||
});
|
||||
|
||||
res.json({
|
||||
plannedVacationDays: plannedDays,
|
||||
weeks: weeks
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// API: Gesamtstatistiken für Mitarbeiter (Überstunden und Urlaubstage)
|
||||
app.get('/api/user/stats', requireAuth, (req, res) => {
|
||||
const userId = req.session.userId;
|
||||
|
||||
// User-Daten abrufen
|
||||
db.get('SELECT wochenstunden, urlaubstage, overtime_offset_hours FROM users WHERE id = ?', [userId], (err, user) => {
|
||||
if (err || !user) {
|
||||
return res.status(500).json({ error: 'Fehler beim Abrufen der User-Daten' });
|
||||
}
|
||||
|
||||
const wochenstunden = user.wochenstunden || 0;
|
||||
const urlaubstage = user.urlaubstage || 0;
|
||||
const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0;
|
||||
|
||||
// Verplante Urlaubstage berechnen (alle Wochen, auch nicht-eingereichte)
|
||||
const { getCalendarWeek } = require('../helpers/utils');
|
||||
db.all(`SELECT date, vacation_type FROM timesheet_entries
|
||||
WHERE user_id = ? AND vacation_type IS NOT NULL AND vacation_type != ''`,
|
||||
[userId],
|
||||
(err, allVacationEntries) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Fehler beim Abrufen der verplanten Tage' });
|
||||
}
|
||||
|
||||
let plannedVacationDays = 0;
|
||||
const weeksMap = {}; // { KW: { year: YYYY, week: KW, days: X } }
|
||||
|
||||
(allVacationEntries || []).forEach(entry => {
|
||||
const dayValue = entry.vacation_type === 'full' ? 1 : 0.5;
|
||||
plannedVacationDays += dayValue;
|
||||
|
||||
// Berechne Kalenderwoche
|
||||
const date = new Date(entry.date);
|
||||
const year = date.getFullYear();
|
||||
const week = getCalendarWeek(entry.date);
|
||||
const weekKey = `${year}-KW${week}`;
|
||||
|
||||
if (!weeksMap[weekKey]) {
|
||||
weeksMap[weekKey] = { year, week, days: 0 };
|
||||
}
|
||||
weeksMap[weekKey].days += dayValue;
|
||||
});
|
||||
|
||||
// Konvertiere zu sortiertem Array
|
||||
const plannedWeeks = Object.values(weeksMap).sort((a, b) => {
|
||||
if (a.year !== b.year) return a.year - b.year;
|
||||
return a.week - b.week;
|
||||
});
|
||||
|
||||
// Alle eingereichten Wochen abrufen
|
||||
db.all(`SELECT DISTINCT week_start, week_end
|
||||
FROM weekly_timesheets
|
||||
WHERE user_id = ? AND status = 'eingereicht'
|
||||
ORDER BY week_start`,
|
||||
[userId],
|
||||
(err, weeks) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Fehler beim Abrufen der Wochen' });
|
||||
}
|
||||
|
||||
// Wenn keine Wochen vorhanden
|
||||
if (!weeks || weeks.length === 0) {
|
||||
return res.json({
|
||||
currentOvertime: overtimeOffsetHours,
|
||||
remainingVacation: urlaubstage,
|
||||
totalOvertimeHours: 0,
|
||||
totalOvertimeTaken: 0,
|
||||
totalVacationDays: 0,
|
||||
plannedVacationDays: plannedVacationDays,
|
||||
plannedWeeks: plannedWeeks,
|
||||
urlaubstage: urlaubstage,
|
||||
overtimeOffsetHours: overtimeOffsetHours
|
||||
});
|
||||
}
|
||||
|
||||
let totalOvertimeHours = 0;
|
||||
let totalOvertimeTaken = 0;
|
||||
let totalVacationDays = 0;
|
||||
let processedWeeks = 0;
|
||||
let hasError = false;
|
||||
|
||||
// Für jede Woche die Statistiken berechnen
|
||||
weeks.forEach((week) => {
|
||||
// Einträge für diese Woche abrufen (nur neueste pro Tag)
|
||||
db.all(`SELECT id, date, total_hours, overtime_taken_hours, vacation_type, sick_status, start_time, end_time, updated_at
|
||||
FROM timesheet_entries
|
||||
WHERE user_id = ? AND date >= ? AND date <= ?
|
||||
ORDER BY date, updated_at DESC, id DESC`,
|
||||
[userId, week.week_start, week.week_end],
|
||||
(err, allEntries) => {
|
||||
if (hasError) return; // Wenn bereits ein Fehler aufgetreten ist, ignoriere weitere Ergebnisse
|
||||
|
||||
if (err) {
|
||||
hasError = true;
|
||||
return res.status(500).json({ error: 'Fehler beim Abrufen der Einträge' });
|
||||
}
|
||||
|
||||
// Filtere auf neuesten Eintrag pro Tag
|
||||
const entriesByDate = {};
|
||||
(allEntries || []).forEach(entry => {
|
||||
const existing = entriesByDate[entry.date];
|
||||
if (!existing) {
|
||||
entriesByDate[entry.date] = entry;
|
||||
} else {
|
||||
// Vergleiche updated_at (falls vorhanden) oder id (höhere ID = neuer)
|
||||
const existingTime = existing.updated_at ? new Date(existing.updated_at).getTime() : 0;
|
||||
const currentTime = entry.updated_at ? new Date(entry.updated_at).getTime() : 0;
|
||||
if (currentTime > existingTime || (currentTime === existingTime && entry.id > existing.id)) {
|
||||
entriesByDate[entry.date] = entry;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Konvertiere zurück zu Array
|
||||
const entries = Object.values(entriesByDate);
|
||||
|
||||
// Prüfe ob Woche vollständig ausgefüllt ist (alle 5 Werktage)
|
||||
|
||||
// Feiertage für die Woche laden (Feiertag zählt als ausgefüllt)
|
||||
getHolidaysForDateRange(week.week_start, week.week_end)
|
||||
.catch(() => new Set())
|
||||
.then((holidaySet) => {
|
||||
// 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];
|
||||
if (holidaySet.has(dateStr)) {
|
||||
filledWorkdays++;
|
||||
continue;
|
||||
}
|
||||
const entry = entriesByDate[dateStr];
|
||||
|
||||
// Tag gilt als ausgefüllt wenn:
|
||||
// - Ganzer Tag Urlaub (vacation_type = 'full')
|
||||
// - Krank (sick_status = 1)
|
||||
// - ODER Start- und End-Zeit vorhanden sind
|
||||
if (entry) {
|
||||
const isFullDayVacation = entry.vacation_type === 'full';
|
||||
const isSick = entry.sick_status === 1 || entry.sick_status === true;
|
||||
const hasStartAndEnd = entry.start_time && entry.end_time &&
|
||||
entry.start_time.toString().trim() !== '' &&
|
||||
entry.end_time.toString().trim() !== '';
|
||||
|
||||
if (isFullDayVacation || isSick || hasStartAndEnd) {
|
||||
filledWorkdays++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Nur berechnen wenn alle Werktage ausgefüllt sind
|
||||
if (filledWorkdays < workdays) {
|
||||
// Woche nicht vollständig - überspringe diese Woche
|
||||
processedWeeks++;
|
||||
if (processedWeeks === weeks.length && !hasError) {
|
||||
const currentOvertime = (totalOvertimeHours - totalOvertimeTaken) + overtimeOffsetHours;
|
||||
const remainingVacation = urlaubstage - totalVacationDays;
|
||||
|
||||
res.json({
|
||||
currentOvertime: currentOvertime,
|
||||
remainingVacation: remainingVacation,
|
||||
totalOvertimeHours: totalOvertimeHours,
|
||||
totalOvertimeTaken: totalOvertimeTaken,
|
||||
totalVacationDays: totalVacationDays,
|
||||
plannedVacationDays: plannedVacationDays,
|
||||
plannedWeeks: plannedWeeks,
|
||||
urlaubstage: urlaubstage,
|
||||
overtimeOffsetHours: overtimeOffsetHours
|
||||
});
|
||||
}
|
||||
return; // Überspringe diese Woche
|
||||
}
|
||||
|
||||
// Berechnungen für diese Woche (nur wenn vollständig ausgefüllt)
|
||||
let weekTotalHours = 0;
|
||||
let weekOvertimeTaken = 0;
|
||||
let weekVacationDays = 0;
|
||||
let weekVacationHours = 0;
|
||||
|
||||
const fullDayHours = wochenstunden > 0 ? wochenstunden / 5 : 8;
|
||||
let fullDayOvertimeDays = 0; // Anzahl Tage mit 8 Überstunden
|
||||
|
||||
entries.forEach(entry => {
|
||||
// Prüfe ob 8 Überstunden (ganzer Tag) eingetragen sind
|
||||
const overtimeValue = entry.overtime_taken_hours ? parseFloat(entry.overtime_taken_hours) : 0;
|
||||
const isFullDayOvertime = overtimeValue > 0 && Math.abs(overtimeValue - fullDayHours) < 0.01;
|
||||
|
||||
if (entry.overtime_taken_hours) {
|
||||
weekOvertimeTaken += entry.overtime_taken_hours;
|
||||
}
|
||||
|
||||
// Wenn 8 Überstunden eingetragen sind, zählt der Tag als 0 Stunden
|
||||
// Diese Tage werden separat gezählt, um die Sollstunden anzupassen
|
||||
if (isFullDayOvertime) {
|
||||
fullDayOvertimeDays++;
|
||||
}
|
||||
|
||||
// Urlaub hat Priorität - wenn Urlaub, zähle nur Urlaubsstunden, nicht zusätzlich Arbeitsstunden
|
||||
if (entry.vacation_type === 'full') {
|
||||
weekVacationDays += 1;
|
||||
weekVacationHours += 8; // Ganzer Tag = 8 Stunden
|
||||
// Bei vollem Tag Urlaub werden keine Arbeitsstunden gezählt
|
||||
} else if (entry.vacation_type === 'half') {
|
||||
weekVacationDays += 0.5;
|
||||
weekVacationHours += 4; // Halber Tag = 4 Stunden
|
||||
// Bei halbem Tag Urlaub können noch Arbeitsstunden vorhanden sein
|
||||
if (entry.total_hours && !isFullDayOvertime) {
|
||||
weekTotalHours += entry.total_hours;
|
||||
}
|
||||
} else {
|
||||
// Kein Urlaub - zähle nur Arbeitsstunden (wenn nicht 8 Überstunden)
|
||||
if (entry.total_hours && !isFullDayOvertime) {
|
||||
weekTotalHours += entry.total_hours;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Feiertagsstunden: 8h pro Werktag der ein Feiertag ist
|
||||
let holidayHours = 0;
|
||||
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
|
||||
const day = d.getDay();
|
||||
if (day >= 1 && day <= 5) {
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
if (holidaySet.has(dateStr)) holidayHours += 8;
|
||||
}
|
||||
}
|
||||
|
||||
// Sollstunden berechnen
|
||||
const sollStunden = (wochenstunden / 5) * workdays;
|
||||
|
||||
// Überstunden für diese Woche: (totalHours + vacationHours + holidayHours) - adjustedSollStunden
|
||||
const weekTotalHoursWithVacation = weekTotalHours + weekVacationHours + holidayHours;
|
||||
const adjustedSollStunden = sollStunden - (fullDayOvertimeDays * fullDayHours);
|
||||
// weekOvertimeHours = Überstunden diese Woche (wie im Frontend berechnet)
|
||||
const weekOvertimeHours = weekTotalHoursWithVacation - adjustedSollStunden;
|
||||
|
||||
// Kumulativ addieren
|
||||
// WICHTIG: weekOvertimeHours enthält bereits die Überstunden dieser Woche (kann negativ sein bei 8 Überstunden)
|
||||
// weekOvertimeTaken enthält die verbrauchten Überstunden (8 Stunden pro Tag mit 8 Überstunden)
|
||||
// Die aktuellen Überstunden = Summe aller Wochen-Überstunden - verbrauchte Überstunden
|
||||
totalOvertimeHours += weekOvertimeHours;
|
||||
totalOvertimeTaken += weekOvertimeTaken;
|
||||
totalVacationDays += weekVacationDays;
|
||||
|
||||
processedWeeks++;
|
||||
|
||||
// Wenn alle Wochen verarbeitet wurden, Antwort senden
|
||||
if (processedWeeks === weeks.length && !hasError) {
|
||||
// Aktuelle Überstunden = Summe aller Wochen-Überstunden - verbrauchte Überstunden + Offset
|
||||
// weekOvertimeHours enthält bereits die korrekte Berechnung pro Woche (wie im Frontend)
|
||||
// weekOvertimeTaken enthält die verbrauchten Überstunden (8 Stunden pro Tag mit 8 Überstunden)
|
||||
const currentOvertime = (totalOvertimeHours - totalOvertimeTaken) + overtimeOffsetHours;
|
||||
const remainingVacation = urlaubstage - totalVacationDays;
|
||||
|
||||
res.json({
|
||||
currentOvertime: currentOvertime,
|
||||
remainingVacation: remainingVacation,
|
||||
totalOvertimeHours: totalOvertimeHours,
|
||||
totalOvertimeTaken: totalOvertimeTaken,
|
||||
totalVacationDays: totalVacationDays,
|
||||
plannedVacationDays: plannedVacationDays,
|
||||
plannedWeeks: plannedWeeks,
|
||||
urlaubstage: urlaubstage,
|
||||
overtimeOffsetHours: overtimeOffsetHours
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = registerUserRoutes;
|
||||
391
routes/verwaltung.js
Normal file
391
routes/verwaltung.js
Normal file
@@ -0,0 +1,391 @@
|
||||
// Verwaltung Routes
|
||||
|
||||
const archiver = require('archiver');
|
||||
const { db } = require('../database');
|
||||
const { requireVerwaltung } = require('../middleware/auth');
|
||||
const { getWeekDatesFromCalendarWeek } = require('../helpers/utils');
|
||||
const { generatePDFToBuffer } = require('../services/pdf-service');
|
||||
const { getHolidaysForDateRange } = require('../services/feiertage-service');
|
||||
|
||||
// Routes registrieren
|
||||
function registerVerwaltungRoutes(app) {
|
||||
// Verwaltungs-Bereich
|
||||
app.get('/verwaltung', requireVerwaltung, (req, res) => {
|
||||
db.all(`
|
||||
SELECT wt.*, u.firstname, u.lastname, u.username, u.personalnummer, u.wochenstunden, u.urlaubstage, u.overtime_offset_hours,
|
||||
dl.firstname as downloaded_by_firstname,
|
||||
dl.lastname as downloaded_by_lastname,
|
||||
(SELECT COUNT(*) FROM weekly_timesheets wt2
|
||||
WHERE wt2.user_id = wt.user_id
|
||||
AND wt2.week_start = wt.week_start
|
||||
AND wt2.week_end = wt.week_end) as total_versions
|
||||
FROM weekly_timesheets wt
|
||||
JOIN users u ON wt.user_id = u.id
|
||||
LEFT JOIN users dl ON wt.pdf_downloaded_by = dl.id
|
||||
WHERE wt.status = 'eingereicht'
|
||||
ORDER BY wt.week_start DESC, wt.user_id, wt.version DESC
|
||||
`, (err, timesheets) => {
|
||||
// Gruppiere nach Mitarbeiter, dann nach Kalenderwoche
|
||||
// Struktur: { [user_id]: { user: {...}, weeks: { [week_key]: {...} } } }
|
||||
const groupedByEmployee = {};
|
||||
|
||||
(timesheets || []).forEach(ts => {
|
||||
const userId = ts.user_id;
|
||||
const weekKey = `${ts.week_start}_${ts.week_end}`;
|
||||
|
||||
// Level 1: Mitarbeiter
|
||||
if (!groupedByEmployee[userId]) {
|
||||
groupedByEmployee[userId] = {
|
||||
user: {
|
||||
id: ts.user_id,
|
||||
firstname: ts.firstname,
|
||||
lastname: ts.lastname,
|
||||
username: ts.username,
|
||||
personalnummer: ts.personalnummer,
|
||||
wochenstunden: ts.wochenstunden,
|
||||
urlaubstage: ts.urlaubstage,
|
||||
overtime_offset_hours: ts.overtime_offset_hours
|
||||
},
|
||||
weeks: {}
|
||||
};
|
||||
}
|
||||
|
||||
// Level 2: Kalenderwoche
|
||||
if (!groupedByEmployee[userId].weeks[weekKey]) {
|
||||
groupedByEmployee[userId].weeks[weekKey] = {
|
||||
week_start: ts.week_start,
|
||||
week_end: ts.week_end,
|
||||
total_versions: ts.total_versions,
|
||||
versions: []
|
||||
};
|
||||
}
|
||||
|
||||
// Level 3: Versionen
|
||||
groupedByEmployee[userId].weeks[weekKey].versions.push(ts);
|
||||
});
|
||||
|
||||
// Sortierung: Mitarbeiter nach Name, Wochen nach Datum (neueste zuerst)
|
||||
const sortedEmployees = Object.values(groupedByEmployee).map(employee => {
|
||||
// Wochen innerhalb jedes Mitarbeiters sortieren
|
||||
const sortedWeeks = Object.values(employee.weeks).sort((a, b) => {
|
||||
return new Date(b.week_start) - new Date(a.week_start);
|
||||
});
|
||||
|
||||
return {
|
||||
...employee,
|
||||
weeks: sortedWeeks
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
// Mitarbeiter nach Nachname, dann Vorname sortieren
|
||||
const nameA = `${a.user.lastname} ${a.user.firstname}`.toLowerCase();
|
||||
const nameB = `${b.user.lastname} ${b.user.firstname}`.toLowerCase();
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
|
||||
res.render('verwaltung', {
|
||||
groupedByEmployee: sortedEmployees,
|
||||
user: {
|
||||
firstname: req.session.firstname,
|
||||
lastname: req.session.lastname,
|
||||
roles: req.session.roles || [],
|
||||
currentRole: req.session.currentRole || 'verwaltung'
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// API: Überstunden-Offset für einen User setzen (positiv/negativ)
|
||||
app.put('/api/verwaltung/user/:id/overtime-offset', requireVerwaltung, (req, res) => {
|
||||
const userId = req.params.id;
|
||||
const raw = req.body ? req.body.overtime_offset_hours : undefined;
|
||||
|
||||
// Leere Eingabe => 0
|
||||
const normalized = (raw === '' || raw === null || raw === undefined) ? 0 : parseFloat(raw);
|
||||
if (!Number.isFinite(normalized)) {
|
||||
return res.status(400).json({ error: 'Ungültiger Überstunden-Offset' });
|
||||
}
|
||||
|
||||
db.run('UPDATE users SET overtime_offset_hours = ? WHERE id = ?', [normalized, userId], (err) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Speichern des Überstunden-Offsets:', err);
|
||||
return res.status(500).json({ error: 'Fehler beim Speichern des Überstunden-Offsets' });
|
||||
}
|
||||
res.json({ success: true, overtime_offset_hours: normalized });
|
||||
});
|
||||
});
|
||||
|
||||
// API: Krankheitstage für einen User im aktuellen Jahr abrufen
|
||||
app.get('/api/verwaltung/user/:id/sick-days', requireVerwaltung, (req, res) => {
|
||||
const userId = req.params.id;
|
||||
const currentYear = new Date().getFullYear();
|
||||
const yearStart = `${currentYear}-01-01`;
|
||||
const yearEnd = `${currentYear}-12-31`;
|
||||
|
||||
db.all(`SELECT DISTINCT date
|
||||
FROM timesheet_entries
|
||||
WHERE user_id = ? AND date >= ? AND date <= ? AND sick_status = 1
|
||||
ORDER BY date`,
|
||||
[userId, yearStart, yearEnd],
|
||||
(err, entries) => {
|
||||
if (err) {
|
||||
return res.status(500).json({ error: 'Fehler beim Abrufen der Krankheitstage' });
|
||||
}
|
||||
|
||||
const sickDays = entries ? entries.length : 0;
|
||||
res.json({ sickDays: sickDays, year: currentYear });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// API: Überstunden- und Urlaubsstatistiken für einen User abrufen
|
||||
app.get('/api/verwaltung/user/:id/stats', requireVerwaltung, (req, res) => {
|
||||
const userId = req.params.id;
|
||||
const { week_start, week_end } = req.query;
|
||||
|
||||
// User-Daten abrufen
|
||||
db.get('SELECT wochenstunden, urlaubstage, overtime_offset_hours FROM users WHERE id = ?', [userId], (err, user) => {
|
||||
if (err || !user) {
|
||||
return res.status(500).json({ error: 'Fehler beim Abrufen der User-Daten' });
|
||||
}
|
||||
|
||||
const wochenstunden = user.wochenstunden || 0;
|
||||
const urlaubstage = user.urlaubstage || 0;
|
||||
const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0;
|
||||
|
||||
// Einträge für die Woche abrufen
|
||||
db.all(`SELECT date, total_hours, overtime_taken_hours, vacation_type, sick_status
|
||||
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;
|
||||
let sickDays = 0;
|
||||
|
||||
entries.forEach(entry => {
|
||||
if (entry.overtime_taken_hours) {
|
||||
overtimeTaken += entry.overtime_taken_hours;
|
||||
}
|
||||
|
||||
// Krankheitstage zählen
|
||||
if (entry.sick_status === 1 || entry.sick_status === true) {
|
||||
sickDays += 1;
|
||||
}
|
||||
|
||||
// Urlaub hat Priorität - wenn Urlaub, zähle nur Urlaubsstunden, nicht zusätzlich Arbeitsstunden
|
||||
if (entry.vacation_type === 'full') {
|
||||
vacationDays += 1;
|
||||
vacationHours += 8; // Ganzer Tag = 8 Stunden
|
||||
// Bei vollem Tag Urlaub werden keine Arbeitsstunden gezählt
|
||||
} else if (entry.vacation_type === 'half') {
|
||||
vacationDays += 0.5;
|
||||
vacationHours += 4; // Halber Tag = 4 Stunden
|
||||
// Bei halbem Tag Urlaub können noch Arbeitsstunden vorhanden sein
|
||||
if (entry.total_hours) {
|
||||
totalHours += entry.total_hours;
|
||||
}
|
||||
} else {
|
||||
// Kein Urlaub - zähle nur Arbeitsstunden
|
||||
if (entry.total_hours) {
|
||||
totalHours += entry.total_hours;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Feiertage für die Woche laden (8h pro Feiertag; Arbeit an Feiertag = Überstunden)
|
||||
getHolidaysForDateRange(week_start, week_end)
|
||||
.catch(() => new Set())
|
||||
.then((holidaySet) => {
|
||||
// Anzahl Werktage berechnen (Montag-Freitag)
|
||||
const startDate = new Date(week_start);
|
||||
const endDate = new Date(week_end);
|
||||
let workdays = 0;
|
||||
let holidayHours = 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];
|
||||
if (holidaySet.has(dateStr)) holidayHours += 8;
|
||||
}
|
||||
}
|
||||
|
||||
// Sollstunden berechnen
|
||||
const sollStunden = (wochenstunden / 5) * workdays;
|
||||
|
||||
// Überstunden: (Tatsächliche Stunden + Urlaubsstunden + Feiertagsstunden) - Sollstunden
|
||||
const totalHoursWithVacation = totalHours + vacationHours + holidayHours;
|
||||
const overtimeHours = totalHoursWithVacation - sollStunden;
|
||||
const remainingOvertime = overtimeHours - overtimeTaken;
|
||||
const remainingOvertimeWithOffset = remainingOvertime + overtimeOffsetHours;
|
||||
|
||||
// Verbleibende Urlaubstage
|
||||
const remainingVacation = urlaubstage - vacationDays;
|
||||
|
||||
res.json({
|
||||
wochenstunden,
|
||||
urlaubstage,
|
||||
totalHours,
|
||||
sollStunden,
|
||||
overtimeHours,
|
||||
overtimeTaken,
|
||||
remainingOvertime,
|
||||
overtimeOffsetHours,
|
||||
remainingOvertimeWithOffset,
|
||||
vacationDays,
|
||||
remainingVacation,
|
||||
sickDays,
|
||||
workdays
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// API: Admin-Kommentar speichern
|
||||
app.put('/api/verwaltung/timesheet/:id/comment', requireVerwaltung, (req, res) => {
|
||||
const timesheetId = req.params.id;
|
||||
const { comment } = req.body;
|
||||
|
||||
db.run('UPDATE weekly_timesheets SET admin_comment = ? WHERE id = ?',
|
||||
[comment ? comment.trim() : null, timesheetId],
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Speichern des Kommentars:', err);
|
||||
return res.status(500).json({ error: 'Fehler beim Speichern des Kommentars' });
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
// API: Massendownload aller PDFs für eine Kalenderwoche
|
||||
app.get('/api/verwaltung/bulk-download/:year/:week', requireVerwaltung, async (req, res) => {
|
||||
const year = parseInt(req.params.year);
|
||||
const week = parseInt(req.params.week);
|
||||
const downloadedBy = req.session.userId;
|
||||
|
||||
// Validierung
|
||||
if (!year || year < 2000 || year > 2100) {
|
||||
return res.status(400).json({ error: 'Ungültiges Jahr' });
|
||||
}
|
||||
if (!week || week < 1 || week > 53) {
|
||||
return res.status(400).json({ error: 'Ungültige Kalenderwoche (1-53)' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Berechne week_start und week_end aus Jahr und KW
|
||||
const { week_start, week_end } = getWeekDatesFromCalendarWeek(year, week);
|
||||
|
||||
// Hole alle eingereichten Stundenzettel für diese KW
|
||||
db.all(`SELECT wt.id, wt.user_id, wt.version, u.firstname, u.lastname
|
||||
FROM weekly_timesheets wt
|
||||
JOIN users u ON wt.user_id = u.id
|
||||
WHERE wt.status = 'eingereicht'
|
||||
AND wt.week_start = ?
|
||||
AND wt.week_end = ?
|
||||
ORDER BY wt.user_id, wt.version DESC`,
|
||||
[week_start, week_end],
|
||||
async (err, allTimesheets) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Abrufen der Stundenzettel:', err);
|
||||
return res.status(500).json({ error: 'Fehler beim Abrufen der Stundenzettel' });
|
||||
}
|
||||
|
||||
if (!allTimesheets || allTimesheets.length === 0) {
|
||||
return res.status(404).json({ error: `Keine eingereichten Stundenzettel für KW ${week}/${year} gefunden` });
|
||||
}
|
||||
|
||||
// Gruppiere nach user_id und wähle neueste Version pro User
|
||||
const latestByUser = {};
|
||||
allTimesheets.forEach(ts => {
|
||||
if (!latestByUser[ts.user_id] || ts.version > latestByUser[ts.user_id].version) {
|
||||
latestByUser[ts.user_id] = ts;
|
||||
}
|
||||
});
|
||||
|
||||
const timesheetsToDownload = Object.values(latestByUser);
|
||||
const timesheetIds = timesheetsToDownload.map(ts => ts.id);
|
||||
|
||||
// Erstelle ZIP
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="Stundenzettel_KW${String(week).padStart(2, '0')}_${year}.zip"`);
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
archive.on('error', (err) => {
|
||||
console.error('Fehler beim Erstellen des ZIP:', err);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Fehler beim Erstellen des ZIP-Archivs' });
|
||||
}
|
||||
});
|
||||
|
||||
archive.pipe(res);
|
||||
|
||||
// Generiere PDFs sequenziell und füge sie zum ZIP hinzu
|
||||
const errors = [];
|
||||
for (const ts of timesheetsToDownload) {
|
||||
try {
|
||||
// Erstelle Mock-Request-Objekt für generatePDFToBuffer
|
||||
const mockReq = {
|
||||
session: { userId: downloadedBy },
|
||||
query: {}
|
||||
};
|
||||
|
||||
const pdfBuffer = await generatePDFToBuffer(ts.id, mockReq);
|
||||
|
||||
// Dateiname: Stundenzettel_KW{week}_{Nachname}{Vorname}_Version{version}.pdf
|
||||
const employeeName = `${ts.lastname}${ts.firstname}`.replace(/\s+/g, '');
|
||||
const filename = `Stundenzettel_KW${String(week).padStart(2, '0')}_${employeeName}_Version${ts.version}.pdf`;
|
||||
|
||||
archive.append(pdfBuffer, { name: filename });
|
||||
} catch (pdfError) {
|
||||
console.error(`Fehler beim Generieren des PDFs für Timesheet ${ts.id}:`, pdfError);
|
||||
errors.push(`Fehler bei ${ts.firstname} ${ts.lastname}: ${pdfError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Warte auf ZIP-Finalisierung und markiere dann PDFs als heruntergeladen
|
||||
archive.on('end', () => {
|
||||
if (timesheetIds.length > 0 && downloadedBy) {
|
||||
// Update alle betroffenen timesheets
|
||||
const placeholders = timesheetIds.map(() => '?').join(',');
|
||||
db.run(`UPDATE weekly_timesheets
|
||||
SET pdf_downloaded_at = CURRENT_TIMESTAMP,
|
||||
pdf_downloaded_by = ?
|
||||
WHERE id IN (${placeholders})`,
|
||||
[downloadedBy, ...timesheetIds],
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Markieren der PDFs als heruntergeladen:', err);
|
||||
} else {
|
||||
console.log(`Massendownload: ${timesheetIds.length} PDFs als heruntergeladen markiert`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Finalisiere ZIP (startet den Stream)
|
||||
archive.finalize();
|
||||
|
||||
// Wenn Fehler aufgetreten sind, aber ZIP trotzdem erstellt wurde, logge sie
|
||||
if (errors.length > 0) {
|
||||
console.warn('Einige PDFs konnten nicht generiert werden:', errors);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Massendownload:', error);
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: 'Fehler beim Massendownload: ' + error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = registerVerwaltungRoutes;
|
||||
92
server.js
Normal file
92
server.js
Normal file
@@ -0,0 +1,92 @@
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const bodyParser = require('body-parser');
|
||||
const path = require('path');
|
||||
const { initDatabase } = require('./database');
|
||||
const { getDefaultRole } = require('./helpers/utils');
|
||||
|
||||
const app = express();
|
||||
const PORT = 3335;
|
||||
|
||||
// Middleware
|
||||
app.use(bodyParser.urlencoded({ extended: true }));
|
||||
app.use(bodyParser.json());
|
||||
// Trust proxy für korrekte Client-IP-Erkennung (wichtig bei Proxies/Reverse Proxies)
|
||||
app.set('trust proxy', true);
|
||||
app.use(express.static('public'));
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
// Session-Konfiguration
|
||||
// Standard: 24 Stunden, kann in der Login-Route auf 30 Tage erhöht werden wenn "Angemeldet bleiben" aktiviert ist
|
||||
app.use(session({
|
||||
secret: 'stundenerfassung-geheim-2024',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: { maxAge: 24 * 60 * 60 * 1000 } // Standard: 24 Stunden
|
||||
}));
|
||||
|
||||
// Datenbank initialisieren
|
||||
initDatabase();
|
||||
|
||||
// Routes importieren und registrieren
|
||||
const registerAuthRoutes = require('./routes/auth');
|
||||
const registerDashboardRoutes = require('./routes/dashboard');
|
||||
const registerUserRoutes = require('./routes/user');
|
||||
const registerAdminRoutes = require('./routes/admin');
|
||||
const registerAdminLDAPRoutes = require('./routes/admin-ldap');
|
||||
const registerVerwaltungRoutes = require('./routes/verwaltung');
|
||||
const registerTimesheetRoutes = require('./routes/timesheet');
|
||||
|
||||
// Services importieren
|
||||
const { setupPingService } = require('./services/ping-service');
|
||||
const { setupLDAPScheduler } = require('./services/ldap-scheduler');
|
||||
|
||||
// Routes registrieren
|
||||
registerAuthRoutes(app);
|
||||
registerDashboardRoutes(app);
|
||||
registerUserRoutes(app);
|
||||
registerAdminRoutes(app);
|
||||
registerAdminLDAPRoutes(app);
|
||||
registerVerwaltungRoutes(app);
|
||||
registerTimesheetRoutes(app);
|
||||
|
||||
// Start-Route
|
||||
app.get('/', (req, res) => {
|
||||
if (req.session.userId) {
|
||||
// Redirect: Immer zu Dashboard wenn Mitarbeiter-Rolle vorhanden, sonst basierend auf currentRole
|
||||
const roles = req.session.roles || [];
|
||||
if (roles.includes('mitarbeiter')) {
|
||||
res.redirect('/dashboard');
|
||||
} else {
|
||||
const currentRole = req.session.currentRole || getDefaultRole(roles);
|
||||
if (currentRole === 'admin') {
|
||||
res.redirect('/admin');
|
||||
} else if (currentRole === 'verwaltung') {
|
||||
res.redirect('/verwaltung');
|
||||
} else {
|
||||
res.redirect('/dashboard');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
res.redirect('/login');
|
||||
}
|
||||
});
|
||||
|
||||
// Server starten
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server läuft auf http://localhost:${PORT}`);
|
||||
console.log('Standard-Zugangsdaten:');
|
||||
console.log('Admin: admin / admin123');
|
||||
console.log('Verwaltung: verwaltung / verwaltung123');
|
||||
|
||||
// LDAP-Scheduler starten
|
||||
setupLDAPScheduler();
|
||||
|
||||
// Ping-Service starten
|
||||
setupPingService();
|
||||
console.log('Ping-Service gestartet (prüft alle IPs jede Minute)');
|
||||
});
|
||||
|
||||
// Check-in-Server starten (separater Server auf Port 3334)
|
||||
require('./checkin-server');
|
||||
109
services/feiertage-service.js
Normal file
109
services/feiertage-service.js
Normal file
@@ -0,0 +1,109 @@
|
||||
// Feiertage-Service: Lädt Feiertage aus DB oder von feiertage-api.de (BW), speichert in public_holidays
|
||||
|
||||
const https = require('https');
|
||||
const { db } = require('../database');
|
||||
|
||||
const API_BASE = 'https://feiertage-api.de/api/';
|
||||
const LAND = 'BW';
|
||||
|
||||
/**
|
||||
* Holt Feiertage für ein Jahr von der API und speichert sie in der DB.
|
||||
* @param {number} year
|
||||
* @returns {Promise<Set<string>>} Set von Datums-Strings YYYY-MM-DD
|
||||
*/
|
||||
function fetchHolidaysFromAPI(year) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `${API_BASE}?jahr=${year}&nur_land=${LAND}`;
|
||||
https.get(url, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
const dates = new Set();
|
||||
const toInsert = [];
|
||||
for (const [name, obj] of Object.entries(json)) {
|
||||
if (obj && obj.datum) {
|
||||
dates.add(obj.datum);
|
||||
toInsert.push({ date: obj.datum, name: name || null });
|
||||
}
|
||||
}
|
||||
if (toInsert.length === 0) {
|
||||
resolve(dates);
|
||||
return;
|
||||
}
|
||||
let pending = toInsert.length;
|
||||
toInsert.forEach(({ date, name }) => {
|
||||
db.run('INSERT OR REPLACE INTO public_holidays (date, name) VALUES (?, ?)', [date, name], (err) => {
|
||||
if (err) console.warn('Feiertage: Fehler beim Speichern:', err.message);
|
||||
pending--;
|
||||
if (pending === 0) resolve(dates);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest Feiertage für ein Jahr aus der DB.
|
||||
* @param {number} year
|
||||
* @returns {Promise<Set<string>>}
|
||||
*/
|
||||
function getHolidaysFromDB(year) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const pattern = `${year}-%`;
|
||||
db.all('SELECT date FROM public_holidays WHERE date LIKE ?', [pattern], (err, rows) => {
|
||||
if (err) return reject(err);
|
||||
const set = new Set((rows || []).map((r) => r.date));
|
||||
resolve(set);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert alle Feiertage für ein Jahr. Zuerst DB; wenn Jahr fehlt, API aufrufen und in DB schreiben.
|
||||
* @param {number} year
|
||||
* @returns {Promise<Set<string>>} Set von YYYY-MM-DD
|
||||
*/
|
||||
function getHolidaysForYear(year) {
|
||||
return getHolidaysFromDB(year).then((set) => {
|
||||
if (set.size > 0) return set;
|
||||
return fetchHolidaysFromAPI(year).catch((err) => {
|
||||
console.warn('Feiertage API fehlgeschlagen für Jahr', year, err.message);
|
||||
return new Set();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert alle Feiertage im Datumsbereich [weekStart, weekEnd] (inklusive).
|
||||
* Lädt ggf. fehlende Jahre aus der API und speichert sie in der DB.
|
||||
* @param {string} weekStart YYYY-MM-DD
|
||||
* @param {string} weekEnd YYYY-MM-DD
|
||||
* @returns {Promise<Set<string>>} Set von YYYY-MM-DD
|
||||
*/
|
||||
function getHolidaysForDateRange(weekStart, weekEnd) {
|
||||
const startYear = parseInt(weekStart.slice(0, 4), 10);
|
||||
const endYear = parseInt(weekEnd.slice(0, 4), 10);
|
||||
const years = [];
|
||||
for (let y = startYear; y <= endYear; y++) years.push(y);
|
||||
|
||||
return Promise.all(years.map((y) => getHolidaysForYear(y))).then((sets) => {
|
||||
const combined = new Set();
|
||||
sets.forEach((s) => s.forEach((d) => combined.add(d)));
|
||||
const inRange = new Set();
|
||||
combined.forEach((d) => {
|
||||
if (d >= weekStart && d <= weekEnd) inRange.add(d);
|
||||
});
|
||||
return inRange;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getHolidaysForYear,
|
||||
getHolidaysForDateRange,
|
||||
};
|
||||
34
services/ldap-scheduler.js
Normal file
34
services/ldap-scheduler.js
Normal file
@@ -0,0 +1,34 @@
|
||||
// LDAP-Scheduler Service
|
||||
|
||||
const { db } = require('../database');
|
||||
const LDAPService = require('../ldap-service');
|
||||
|
||||
// Automatische LDAP-Synchronisation einrichten
|
||||
function setupLDAPScheduler() {
|
||||
// Prüfe alle 5 Minuten, ob eine Synchronisation notwendig ist
|
||||
setInterval(() => {
|
||||
db.get('SELECT * FROM ldap_config WHERE id = 1 AND enabled = 1 AND sync_interval > 0', (err, config) => {
|
||||
if (err || !config) {
|
||||
return; // Keine aktive Konfiguration
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const lastSync = config.last_sync ? new Date(config.last_sync) : null;
|
||||
const syncIntervalMs = config.sync_interval * 60 * 1000; // Minuten in Millisekunden
|
||||
|
||||
// Prüfe ob Synchronisation fällig ist
|
||||
if (!lastSync || (now - lastSync) >= syncIntervalMs) {
|
||||
console.log('Starte automatische LDAP-Synchronisation...');
|
||||
LDAPService.performSync('scheduled', (err, result) => {
|
||||
if (err) {
|
||||
console.error('Fehler bei automatischer LDAP-Synchronisation:', err.message);
|
||||
} else {
|
||||
console.log(`Automatische LDAP-Synchronisation abgeschlossen: ${result.synced} Benutzer synchronisiert`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 5 * 60 * 1000); // Alle 5 Minuten prüfen
|
||||
}
|
||||
|
||||
module.exports = { setupLDAPScheduler };
|
||||
531
services/pdf-service.js
Normal file
531
services/pdf-service.js
Normal file
@@ -0,0 +1,531 @@
|
||||
// PDF-Generierung Service
|
||||
|
||||
const PDFDocument = require('pdfkit');
|
||||
const { db } = require('../database');
|
||||
const { formatDate, formatDateTime } = require('../helpers/utils');
|
||||
const { getHolidaysForDateRange } = require('./feiertage-service');
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// Feiertage für die Woche laden (8h pro Feiertag; Arbeit an Feiertag = Überstunden)
|
||||
getHolidaysForDateRange(timesheet.week_start, timesheet.week_end)
|
||||
.then((holidaySet) => {
|
||||
let holidayHours = 0;
|
||||
const start = new Date(timesheet.week_start);
|
||||
const end = new Date(timesheet.week_end);
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
const day = d.getDay();
|
||||
if (day >= 1 && day <= 5) {
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
if (holidaySet.has(dateStr)) holidayHours += 8;
|
||||
}
|
||||
}
|
||||
return { holidaySet, holidayHours };
|
||||
})
|
||||
.catch(() => ({ holidaySet: new Set(), holidayHours: 0 }))
|
||||
.then(({ holidaySet, holidayHours }) => {
|
||||
const doc = new PDFDocument({ margin: 50 });
|
||||
|
||||
// Prüfe ob inline angezeigt werden soll (für Vorschau)
|
||||
const inline = req.query.inline === 'true';
|
||||
|
||||
// Dateinamen generieren: Stundenzettel_KWxxx_NameMitarbeiter_heutigesDatum.pdf
|
||||
const calendarWeek = getCalendarWeek(timesheet.week_start);
|
||||
const today = new Date();
|
||||
const todayStr = today.getFullYear() + '-' +
|
||||
String(today.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(today.getDate()).padStart(2, '0');
|
||||
const employeeName = `${timesheet.firstname}${timesheet.lastname}`.replace(/\s+/g, '');
|
||||
const filename = `Stundenzettel_KW${String(calendarWeek).padStart(2, '0')}_${employeeName}_${todayStr}.pdf`;
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
|
||||
if (inline) {
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
// Zusätzliche Header für iframe-Unterstützung
|
||||
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
||||
} else {
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
|
||||
// Marker setzen, dass PDF heruntergeladen wurde (nur bei Download, nicht bei Vorschau)
|
||||
const downloadedBy = req.session.userId; // User der die PDF herunterlädt
|
||||
console.log('PDF Download - User ID:', downloadedBy, 'Timesheet ID:', timesheetId);
|
||||
|
||||
if (downloadedBy) {
|
||||
db.run(`UPDATE weekly_timesheets
|
||||
SET pdf_downloaded_at = CURRENT_TIMESTAMP,
|
||||
pdf_downloaded_by = ?
|
||||
WHERE id = ?`,
|
||||
[downloadedBy, timesheetId],
|
||||
(err) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Setzen des Download-Markers:', err);
|
||||
} else {
|
||||
console.log('Download-Marker erfolgreich gesetzt für User:', downloadedBy);
|
||||
}
|
||||
// Fehler wird ignoriert, damit PDF trotzdem generiert wird
|
||||
});
|
||||
} else {
|
||||
console.warn('PDF Download - Keine User ID in Session gefunden!');
|
||||
}
|
||||
}
|
||||
|
||||
doc.pipe(res);
|
||||
|
||||
// Header (Kalenderwoche wurde bereits oben berechnet)
|
||||
doc.fontSize(20).text(`Stundenzettel für KW ${calendarWeek}`, { align: 'center' });
|
||||
doc.moveDown();
|
||||
|
||||
// Mitarbeiter-Info
|
||||
doc.fontSize(12);
|
||||
doc.text(`Mitarbeiter: ${timesheet.firstname} ${timesheet.lastname}`);
|
||||
doc.text(`Zeitraum: ${formatDate(timesheet.week_start)} - ${formatDate(timesheet.week_end)}`);
|
||||
doc.text(`Eingereicht am: ${formatDateTime(timesheet.submitted_at)}`);
|
||||
doc.moveDown();
|
||||
|
||||
// Tabelle - Basis-Informationen
|
||||
const tableTop = doc.y;
|
||||
const colWidths = [80, 80, 80, 60, 80];
|
||||
const headers = ['Datum', 'Start', 'Ende', 'Pause', 'Stunden'];
|
||||
|
||||
// Tabellen-Header
|
||||
doc.fontSize(10).font('Helvetica-Bold');
|
||||
let x = 50;
|
||||
headers.forEach((header, i) => {
|
||||
doc.text(header, x, tableTop, { width: colWidths[i], align: 'left' });
|
||||
x += colWidths[i];
|
||||
});
|
||||
|
||||
doc.moveDown();
|
||||
let y = doc.y;
|
||||
doc.moveTo(50, y).lineTo(430, y).stroke();
|
||||
doc.moveDown(0.5);
|
||||
|
||||
// Tabellen-Daten
|
||||
doc.font('Helvetica');
|
||||
let totalHours = 0;
|
||||
let vacationHours = 0; // Urlaubsstunden für Überstunden-Berechnung
|
||||
|
||||
entries.forEach((entry) => {
|
||||
y = doc.y;
|
||||
x = 50;
|
||||
|
||||
// Basis-Zeile
|
||||
const rowData = [
|
||||
formatDate(entry.date),
|
||||
entry.start_time || '-',
|
||||
entry.end_time || '-',
|
||||
entry.break_minutes ? `${entry.break_minutes} min` : '-',
|
||||
entry.total_hours ? entry.total_hours.toFixed(2) + ' h' : '-'
|
||||
];
|
||||
|
||||
rowData.forEach((data, i) => {
|
||||
doc.text(data, x, y, { width: colWidths[i], align: 'left' });
|
||||
x += colWidths[i];
|
||||
});
|
||||
|
||||
// Tätigkeiten sammeln
|
||||
const activities = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const desc = entry[`activity${i}_desc`];
|
||||
const hours = entry[`activity${i}_hours`];
|
||||
const projectNumber = entry[`activity${i}_project_number`];
|
||||
if (desc && desc.trim() && hours > 0) {
|
||||
activities.push({
|
||||
desc: desc.trim(),
|
||||
hours: parseFloat(hours),
|
||||
projectNumber: projectNumber ? projectNumber.trim() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Tätigkeiten anzeigen
|
||||
if (activities.length > 0) {
|
||||
doc.moveDown(0.3);
|
||||
doc.fontSize(9).font('Helvetica-Oblique');
|
||||
doc.text('Tätigkeiten:', 60, doc.y, { width: 380 });
|
||||
doc.moveDown(0.2);
|
||||
|
||||
activities.forEach((activity, idx) => {
|
||||
let activityText = `${idx + 1}. ${activity.desc}`;
|
||||
if (activity.projectNumber) {
|
||||
activityText += ` (Projekt: ${activity.projectNumber})`;
|
||||
}
|
||||
activityText += ` - ${activity.hours.toFixed(2)} h`;
|
||||
doc.fontSize(9).font('Helvetica');
|
||||
doc.text(activityText, 70, doc.y, { width: 360 });
|
||||
doc.moveDown(0.2);
|
||||
});
|
||||
doc.fontSize(10);
|
||||
}
|
||||
|
||||
// Überstunden und Urlaub anzeigen
|
||||
const overtimeInfo = [];
|
||||
if (entry.overtime_taken_hours && parseFloat(entry.overtime_taken_hours) > 0) {
|
||||
overtimeInfo.push(`Überstunden genommen: ${parseFloat(entry.overtime_taken_hours).toFixed(2)} h`);
|
||||
}
|
||||
if (entry.vacation_type) {
|
||||
const vacationText = entry.vacation_type === 'full' ? 'Ganzer Tag' : 'Halber Tag';
|
||||
overtimeInfo.push(`Urlaub: ${vacationText}`);
|
||||
}
|
||||
|
||||
if (overtimeInfo.length > 0) {
|
||||
doc.moveDown(0.2);
|
||||
doc.fontSize(9).font('Helvetica-Oblique');
|
||||
overtimeInfo.forEach((info, idx) => {
|
||||
doc.text(info, 70, doc.y, { width: 360 });
|
||||
doc.moveDown(0.15);
|
||||
});
|
||||
doc.fontSize(10);
|
||||
}
|
||||
|
||||
// Urlaub hat Priorität - wenn Urlaub, zähle nur Urlaubsstunden, nicht zusätzlich Arbeitsstunden
|
||||
if (entry.vacation_type === 'full') {
|
||||
vacationHours += 8; // Ganzer Tag = 8 Stunden
|
||||
// Bei vollem Tag Urlaub werden keine Arbeitsstunden gezählt
|
||||
} else if (entry.vacation_type === 'half') {
|
||||
vacationHours += 4; // Halber Tag = 4 Stunden
|
||||
// Bei halbem Tag Urlaub können noch Arbeitsstunden vorhanden sein
|
||||
if (entry.total_hours) {
|
||||
totalHours += entry.total_hours;
|
||||
}
|
||||
} else {
|
||||
// Kein Urlaub - zähle Arbeitsstunden; an Feiertagen zählt jede Stunde als Überstunde (8h Feiertag + Arbeit)
|
||||
if (entry.total_hours) {
|
||||
totalHours += entry.total_hours;
|
||||
}
|
||||
}
|
||||
// Feiertag: 8h sind über holidayHours erfasst; gearbeitete Stunden oben bereits zu totalHours addiert
|
||||
|
||||
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');
|
||||
// Gesamtstunden = Arbeitsstunden + Urlaubsstunden + Feiertagsstunden (8h pro Feiertag)
|
||||
const totalHoursWithVacation = totalHours + vacationHours + holidayHours;
|
||||
doc.text(`Gesamtstunden: ${totalHoursWithVacation.toFixed(2)} h`, 50, doc.y);
|
||||
|
||||
// Überstunden berechnen und anzeigen
|
||||
const wochenstunden = timesheet.wochenstunden || 0;
|
||||
// Überstunden = (Tatsächliche Stunden + Urlaubsstunden) - Wochenstunden
|
||||
const overtimeHours = totalHoursWithVacation - wochenstunden;
|
||||
|
||||
doc.moveDown(0.3);
|
||||
doc.font('Helvetica-Bold');
|
||||
if (overtimeHours > 0) {
|
||||
doc.text(`Überstunden: +${overtimeHours.toFixed(2)} h`, 50, doc.y);
|
||||
} else if (overtimeHours < 0) {
|
||||
doc.text(`Überstunden: ${overtimeHours.toFixed(2)} h`, 50, doc.y);
|
||||
} else {
|
||||
doc.text(`Überstunden: 0.00 h`, 50, doc.y);
|
||||
}
|
||||
|
||||
doc.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// PDF als Buffer generieren (für ZIP-Downloads)
|
||||
function generatePDFToBuffer(timesheetId, req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(`SELECT wt.*, u.firstname, u.lastname, u.username, u.wochenstunden
|
||||
FROM weekly_timesheets wt
|
||||
JOIN users u ON wt.user_id = u.id
|
||||
WHERE wt.id = ?`, [timesheetId], (err, timesheet) => {
|
||||
|
||||
if (err || !timesheet) {
|
||||
return reject(new Error('Stundenzettel nicht gefunden'));
|
||||
}
|
||||
|
||||
// Hole Einträge die zum Zeitpunkt der Einreichung existierten
|
||||
db.all(`SELECT * FROM timesheet_entries
|
||||
WHERE user_id = ? AND date >= ? AND date <= ?
|
||||
AND (
|
||||
(updated_at IS NOT NULL AND updated_at <= ?) OR
|
||||
(updated_at IS NULL AND created_at IS NOT NULL AND created_at <= ?) OR
|
||||
(updated_at IS NULL AND created_at IS NULL)
|
||||
)
|
||||
ORDER BY date, updated_at DESC, id DESC`,
|
||||
[timesheet.user_id, timesheet.week_start, timesheet.week_end,
|
||||
timesheet.submitted_at, timesheet.submitted_at],
|
||||
(err, allEntries) => {
|
||||
if (err) {
|
||||
return reject(new Error('Fehler beim Abrufen der Einträge'));
|
||||
}
|
||||
|
||||
// Filtere auf neuesten Eintrag pro Tag
|
||||
const entriesByDate = {};
|
||||
(allEntries || []).forEach(entry => {
|
||||
const existing = entriesByDate[entry.date];
|
||||
if (!existing) {
|
||||
entriesByDate[entry.date] = entry;
|
||||
} else {
|
||||
const existingTime = existing.updated_at ? new Date(existing.updated_at).getTime() : 0;
|
||||
const currentTime = entry.updated_at ? new Date(entry.updated_at).getTime() : 0;
|
||||
if (currentTime > existingTime || (currentTime === existingTime && entry.id > existing.id)) {
|
||||
entriesByDate[entry.date] = entry;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const entries = Object.values(entriesByDate).sort((a, b) => {
|
||||
return new Date(a.date) - new Date(b.date);
|
||||
});
|
||||
|
||||
getHolidaysForDateRange(timesheet.week_start, timesheet.week_end)
|
||||
.then((holidaySet) => {
|
||||
let holidayHours = 0;
|
||||
const start = new Date(timesheet.week_start);
|
||||
const end = new Date(timesheet.week_end);
|
||||
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
||||
const day = d.getDay();
|
||||
if (day >= 1 && day <= 5) {
|
||||
const dateStr = d.toISOString().split('T')[0];
|
||||
if (holidaySet.has(dateStr)) holidayHours += 8;
|
||||
}
|
||||
}
|
||||
return { holidaySet, holidayHours };
|
||||
})
|
||||
.catch(() => ({ holidaySet: new Set(), holidayHours: 0 }))
|
||||
.then(({ holidayHours }) => {
|
||||
const doc = new PDFDocument({ margin: 50 });
|
||||
const buffers = [];
|
||||
|
||||
doc.on('data', buffers.push.bind(buffers));
|
||||
doc.on('end', () => {
|
||||
const pdfBuffer = Buffer.concat(buffers);
|
||||
resolve(pdfBuffer);
|
||||
});
|
||||
doc.on('error', reject);
|
||||
|
||||
// Header
|
||||
const calendarWeek = getCalendarWeek(timesheet.week_start);
|
||||
doc.fontSize(20).text(`Stundenzettel für KW ${calendarWeek}`, { align: 'center' });
|
||||
doc.moveDown();
|
||||
|
||||
// Mitarbeiter-Info
|
||||
doc.fontSize(12);
|
||||
doc.text(`Mitarbeiter: ${timesheet.firstname} ${timesheet.lastname}`);
|
||||
doc.text(`Zeitraum: ${formatDate(timesheet.week_start)} - ${formatDate(timesheet.week_end)}`);
|
||||
doc.text(`Eingereicht am: ${formatDateTime(timesheet.submitted_at)}`);
|
||||
doc.moveDown();
|
||||
|
||||
// Tabelle - Basis-Informationen
|
||||
const tableTop = doc.y;
|
||||
const colWidths = [80, 80, 80, 60, 80];
|
||||
const headers = ['Datum', 'Start', 'Ende', 'Pause', 'Stunden'];
|
||||
|
||||
// Tabellen-Header
|
||||
doc.fontSize(10).font('Helvetica-Bold');
|
||||
let x = 50;
|
||||
headers.forEach((header, i) => {
|
||||
doc.text(header, x, tableTop, { width: colWidths[i], align: 'left' });
|
||||
x += colWidths[i];
|
||||
});
|
||||
|
||||
doc.moveDown();
|
||||
let y = doc.y;
|
||||
doc.moveTo(50, y).lineTo(430, y).stroke();
|
||||
doc.moveDown(0.5);
|
||||
|
||||
// Tabellen-Daten
|
||||
doc.font('Helvetica');
|
||||
let totalHours = 0;
|
||||
let vacationHours = 0;
|
||||
|
||||
entries.forEach((entry) => {
|
||||
y = doc.y;
|
||||
x = 50;
|
||||
|
||||
const rowData = [
|
||||
formatDate(entry.date),
|
||||
entry.start_time || '-',
|
||||
entry.end_time || '-',
|
||||
entry.break_minutes ? `${entry.break_minutes} min` : '-',
|
||||
entry.total_hours ? entry.total_hours.toFixed(2) + ' h' : '-'
|
||||
];
|
||||
|
||||
rowData.forEach((data, i) => {
|
||||
doc.text(data, x, y, { width: colWidths[i], align: 'left' });
|
||||
x += colWidths[i];
|
||||
});
|
||||
|
||||
// Tätigkeiten sammeln
|
||||
const activities = [];
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const desc = entry[`activity${i}_desc`];
|
||||
const hours = entry[`activity${i}_hours`];
|
||||
const projectNumber = entry[`activity${i}_project_number`];
|
||||
if (desc && desc.trim() && hours > 0) {
|
||||
activities.push({
|
||||
desc: desc.trim(),
|
||||
hours: parseFloat(hours),
|
||||
projectNumber: projectNumber ? projectNumber.trim() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Tätigkeiten anzeigen
|
||||
if (activities.length > 0) {
|
||||
doc.moveDown(0.3);
|
||||
doc.fontSize(9).font('Helvetica-Oblique');
|
||||
doc.text('Tätigkeiten:', 60, doc.y, { width: 380 });
|
||||
doc.moveDown(0.2);
|
||||
|
||||
activities.forEach((activity, idx) => {
|
||||
let activityText = `${idx + 1}. ${activity.desc}`;
|
||||
if (activity.projectNumber) {
|
||||
activityText += ` (Projekt: ${activity.projectNumber})`;
|
||||
}
|
||||
activityText += ` - ${activity.hours.toFixed(2)} h`;
|
||||
doc.fontSize(9).font('Helvetica');
|
||||
doc.text(activityText, 70, doc.y, { width: 360 });
|
||||
doc.moveDown(0.2);
|
||||
});
|
||||
doc.fontSize(10);
|
||||
}
|
||||
|
||||
// Überstunden und Urlaub anzeigen
|
||||
const overtimeInfo = [];
|
||||
if (entry.overtime_taken_hours && parseFloat(entry.overtime_taken_hours) > 0) {
|
||||
overtimeInfo.push(`Überstunden genommen: ${parseFloat(entry.overtime_taken_hours).toFixed(2)} h`);
|
||||
}
|
||||
if (entry.vacation_type) {
|
||||
const vacationText = entry.vacation_type === 'full' ? 'Ganzer Tag' : 'Halber Tag';
|
||||
overtimeInfo.push(`Urlaub: ${vacationText}`);
|
||||
}
|
||||
|
||||
if (overtimeInfo.length > 0) {
|
||||
doc.moveDown(0.2);
|
||||
doc.fontSize(9).font('Helvetica-Oblique');
|
||||
overtimeInfo.forEach((info, idx) => {
|
||||
doc.text(info, 70, doc.y, { width: 360 });
|
||||
doc.moveDown(0.15);
|
||||
});
|
||||
doc.fontSize(10);
|
||||
}
|
||||
|
||||
// Urlaub hat Priorität - wenn Urlaub, zähle nur Urlaubsstunden, nicht zusätzlich Arbeitsstunden
|
||||
if (entry.vacation_type === 'full') {
|
||||
vacationHours += 8; // Ganzer Tag = 8 Stunden
|
||||
// Bei vollem Tag Urlaub werden keine Arbeitsstunden gezählt
|
||||
} else if (entry.vacation_type === 'half') {
|
||||
vacationHours += 4; // Halber Tag = 4 Stunden
|
||||
// Bei halbem Tag Urlaub können noch Arbeitsstunden vorhanden sein
|
||||
if (entry.total_hours) {
|
||||
totalHours += entry.total_hours;
|
||||
}
|
||||
} else {
|
||||
// Kein Urlaub - zähle nur Arbeitsstunden
|
||||
if (entry.total_hours) {
|
||||
totalHours += entry.total_hours;
|
||||
}
|
||||
}
|
||||
|
||||
doc.moveDown(0.5);
|
||||
|
||||
y = doc.y;
|
||||
doc.moveTo(50, y).lineTo(430, y).stroke();
|
||||
doc.moveDown(0.3);
|
||||
});
|
||||
|
||||
// Summe
|
||||
y = doc.y;
|
||||
doc.moveTo(50, y).lineTo(550, y).stroke();
|
||||
doc.moveDown(0.5);
|
||||
doc.font('Helvetica-Bold');
|
||||
// Gesamtstunden = Arbeitsstunden + Urlaubsstunden + Feiertagsstunden
|
||||
const totalHoursWithVacation = totalHours + vacationHours + holidayHours;
|
||||
doc.text(`Gesamtstunden: ${totalHoursWithVacation.toFixed(2)} h`, 50, doc.y);
|
||||
|
||||
const wochenstunden = timesheet.wochenstunden || 0;
|
||||
const overtimeHours = totalHoursWithVacation - wochenstunden;
|
||||
|
||||
doc.moveDown(0.3);
|
||||
doc.font('Helvetica-Bold');
|
||||
if (overtimeHours > 0) {
|
||||
doc.text(`Überstunden: +${overtimeHours.toFixed(2)} h`, 50, doc.y);
|
||||
} else if (overtimeHours < 0) {
|
||||
doc.text(`Überstunden: ${overtimeHours.toFixed(2)} h`, 50, doc.y);
|
||||
} else {
|
||||
doc.text(`Überstunden: 0.00 h`, 50, doc.y);
|
||||
}
|
||||
|
||||
doc.end();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { generatePDF, generatePDFToBuffer };
|
||||
182
services/ping-service.js
Normal file
182
services/ping-service.js
Normal file
@@ -0,0 +1,182 @@
|
||||
// Ping-Service für IP-basierte automatische Zeiterfassung
|
||||
|
||||
const ping = require('ping');
|
||||
const { db } = require('../database');
|
||||
const { getCurrentDate, getCurrentTime, updateTotalHours } = require('../helpers/utils');
|
||||
|
||||
// Ping-Funktion für einen User
|
||||
async function pingUserIP(userId, ip, currentDate, currentTime) {
|
||||
try {
|
||||
const result = await ping.promise.probe(ip, {
|
||||
timeout: 3,
|
||||
min_reply: 1
|
||||
});
|
||||
|
||||
const isReachable = result.alive;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Hole oder erstelle Ping-Status für heute
|
||||
db.get('SELECT * FROM ping_status WHERE user_id = ? AND date = ?',
|
||||
[userId, currentDate], (err, pingStatus) => {
|
||||
if (err) {
|
||||
console.error(`Fehler beim Abrufen des Ping-Status für User ${userId}:`, err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hole aktuellen Eintrag für heute
|
||||
db.get('SELECT * FROM timesheet_entries WHERE user_id = ? AND date = ? ORDER BY updated_at DESC, id DESC LIMIT 1',
|
||||
[userId, currentDate], (err, entry) => {
|
||||
if (err) {
|
||||
console.error(`Fehler beim Abrufen des Eintrags für User ${userId}:`, err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isReachable) {
|
||||
// IP ist erreichbar
|
||||
if (!pingStatus) {
|
||||
// Erstelle neuen Ping-Status
|
||||
db.run(`INSERT INTO ping_status (user_id, date, last_successful_ping, failed_ping_count, start_time_set)
|
||||
VALUES (?, ?, ?, 0, 0)`,
|
||||
[userId, currentDate, now], (err) => {
|
||||
if (err) console.error(`Fehler beim Erstellen des Ping-Status:`, err);
|
||||
});
|
||||
} else {
|
||||
// Update Ping-Status: Reset failed_ping_count, update last_successful_ping
|
||||
db.run(`UPDATE ping_status
|
||||
SET last_successful_ping = ?, failed_ping_count = 0, first_failed_ping_time = NULL
|
||||
WHERE user_id = ? AND date = ?`,
|
||||
[now, userId, currentDate], (err) => {
|
||||
if (err) console.error(`Fehler beim Aktualisieren des Ping-Status:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
// Start-Zeit setzen wenn noch nicht vorhanden
|
||||
if (entry && !entry.start_time) {
|
||||
db.run('UPDATE timesheet_entries SET start_time = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[currentTime, entry.id], (err) => {
|
||||
if (err) {
|
||||
console.error(`Fehler beim Setzen der Start-Zeit für User ${userId}:`, err);
|
||||
} else {
|
||||
console.log(`Start-Zeit gesetzt für User ${userId} um ${currentTime}`);
|
||||
// Markiere dass Start-Zeit gesetzt wurde
|
||||
db.run('UPDATE ping_status SET start_time_set = 1 WHERE user_id = ? AND date = ?',
|
||||
[userId, currentDate], (err) => {
|
||||
if (err) console.error(`Fehler beim Aktualisieren von start_time_set:`, err);
|
||||
});
|
||||
}
|
||||
});
|
||||
} else if (!entry) {
|
||||
// Kein Eintrag existiert → Erstelle neuen mit start_time
|
||||
db.run(`INSERT INTO timesheet_entries (user_id, date, start_time, updated_at)
|
||||
VALUES (?, ?, ?, CURRENT_TIMESTAMP)`,
|
||||
[userId, currentDate, currentTime], (err) => {
|
||||
if (err) {
|
||||
console.error(`Fehler beim Erstellen des Eintrags für User ${userId}:`, err);
|
||||
} else {
|
||||
console.log(`Eintrag erstellt und Start-Zeit gesetzt für User ${userId} um ${currentTime}`);
|
||||
// Markiere dass Start-Zeit gesetzt wurde
|
||||
db.run('UPDATE ping_status SET start_time_set = 1 WHERE user_id = ? AND date = ?',
|
||||
[userId, currentDate], (err) => {
|
||||
if (err) {
|
||||
// Falls kein Ping-Status existiert, erstelle einen
|
||||
db.run(`INSERT INTO ping_status (user_id, date, last_successful_ping, failed_ping_count, start_time_set)
|
||||
VALUES (?, ?, ?, 0, 1)`,
|
||||
[userId, currentDate, now], (err) => {
|
||||
if (err) console.error(`Fehler beim Erstellen des Ping-Status:`, err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// IP ist nicht erreichbar
|
||||
if (!pingStatus) {
|
||||
// Erstelle neuen Ping-Status mit failed_ping_count = 1
|
||||
db.run(`INSERT INTO ping_status (user_id, date, failed_ping_count, first_failed_ping_time)
|
||||
VALUES (?, ?, 1, ?)`,
|
||||
[userId, currentDate, now], (err) => {
|
||||
if (err) console.error(`Fehler beim Erstellen des Ping-Status:`, err);
|
||||
});
|
||||
} else {
|
||||
// Erhöhe failed_ping_count
|
||||
const newFailedCount = (pingStatus.failed_ping_count || 0) + 1;
|
||||
const firstFailedTime = pingStatus.first_failed_ping_time || now;
|
||||
|
||||
db.run(`UPDATE ping_status
|
||||
SET failed_ping_count = ?, first_failed_ping_time = ?
|
||||
WHERE user_id = ? AND date = ?`,
|
||||
[newFailedCount, firstFailedTime, userId, currentDate], (err) => {
|
||||
if (err) console.error(`Fehler beim Aktualisieren des Ping-Status:`, err);
|
||||
});
|
||||
|
||||
// Wenn 3 oder mehr fehlgeschlagene Pings UND Start-Zeit existiert UND keine End-Zeit
|
||||
if (newFailedCount >= 3 && entry && entry.start_time && !entry.end_time) {
|
||||
// Setze End-Zeit auf Zeit des ersten fehlgeschlagenen Pings
|
||||
const firstFailedDate = new Date(firstFailedTime);
|
||||
const endTime = `${String(firstFailedDate.getHours()).padStart(2, '0')}:${String(firstFailedDate.getMinutes()).padStart(2, '0')}`;
|
||||
|
||||
// Berechne total_hours
|
||||
const breakMinutes = entry.break_minutes || 0;
|
||||
const totalHours = updateTotalHours(entry.start_time, endTime, breakMinutes);
|
||||
|
||||
db.run('UPDATE timesheet_entries SET end_time = ?, total_hours = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
[endTime, totalHours, entry.id], (err) => {
|
||||
if (err) {
|
||||
console.error(`Fehler beim Setzen der End-Zeit für User ${userId}:`, err);
|
||||
} else {
|
||||
console.log(`End-Zeit gesetzt für User ${userId} um ${endTime} (nach ${newFailedCount} fehlgeschlagenen Pings)`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Fehler beim Ping für User ${userId} (IP: ${ip}):`, error);
|
||||
// Behandle als nicht erreichbar
|
||||
const now = new Date().toISOString();
|
||||
db.get('SELECT * FROM ping_status WHERE user_id = ? AND date = ?',
|
||||
[userId, currentDate], (err, pingStatus) => {
|
||||
if (!err && pingStatus) {
|
||||
const newFailedCount = (pingStatus.failed_ping_count || 0) + 1;
|
||||
const firstFailedTime = pingStatus.first_failed_ping_time || now;
|
||||
|
||||
db.run(`UPDATE ping_status
|
||||
SET failed_ping_count = ?, first_failed_ping_time = ?
|
||||
WHERE user_id = ? AND date = ?`,
|
||||
[newFailedCount, firstFailedTime, userId, currentDate], (err) => {
|
||||
if (err) console.error(`Fehler beim Aktualisieren des Ping-Status:`, err);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ping-Service Setup
|
||||
function setupPingService() {
|
||||
setInterval(async () => {
|
||||
const currentDate = getCurrentDate();
|
||||
const currentTime = getCurrentTime();
|
||||
|
||||
// Hole alle User mit IP-Adresse
|
||||
db.all('SELECT id, ping_ip FROM users WHERE ping_ip IS NOT NULL AND ping_ip != ""', (err, users) => {
|
||||
if (err) {
|
||||
console.error('Fehler beim Abrufen der User mit IP-Adressen:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
return; // Keine User mit IP-Adressen
|
||||
}
|
||||
|
||||
// Ping alle User parallel
|
||||
users.forEach(user => {
|
||||
pingUserIP(user.id, user.ping_ip, currentDate, currentTime);
|
||||
});
|
||||
});
|
||||
}, 60000); // Jede Minute
|
||||
}
|
||||
|
||||
module.exports = { setupPingService };
|
||||
419
views/admin.ejs
Normal file
419
views/admin.ejs
Normal file
@@ -0,0 +1,419 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin - Stundenerfassung</title>
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="navbar">
|
||||
<div class="container">
|
||||
<div class="navbar-brand">
|
||||
<img src="/images/header.png" alt="Logo" class="navbar-logo">
|
||||
<h1>Stundenerfassung - Admin</h1>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<span>Admin: <%= user.firstname %> <%= user.lastname %></span>
|
||||
<% if (user.roles && user.roles.length > 1) { %>
|
||||
<select id="roleSwitcher" class="role-switcher" style="margin-right: 10px; padding: 5px 10px; border-radius: 4px; border: 1px solid #ddd;">
|
||||
<% const roleLabels = { 'mitarbeiter': 'Mitarbeiter', 'verwaltung': 'Verwaltung', 'admin': 'Administrator' }; %>
|
||||
<% user.roles.forEach(function(role) { %>
|
||||
<option value="<%= role %>" <%= user.currentRole === role ? 'selected' : '' %>><%= roleLabels[role] || role %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
<% } %>
|
||||
<a href="/logout" class="btn btn-secondary">Abmelden</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-container">
|
||||
<div class="container">
|
||||
<div class="admin-panel">
|
||||
<h2>Benutzerverwaltung</h2>
|
||||
|
||||
<!-- Benutzer anlegen - Zusammenklappbar -->
|
||||
<div class="add-user-section" style="margin-top: 20px;">
|
||||
<div class="collapsible-header" onclick="toggleAddUserSection()" style="cursor: pointer; padding: 15px; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 style="margin: 0;">Neuen Benutzer anlegen</h3>
|
||||
<span id="addUserToggleIcon" style="font-size: 18px; transition: transform 0.3s;">▼</span>
|
||||
</div>
|
||||
|
||||
<div id="addUserContent" style="display: none; padding: 20px; border: 1px solid #ddd; border-top: none; border-radius: 0 0 4px 4px; background-color: #fff;">
|
||||
<div class="add-user-form">
|
||||
<form id="addUserForm">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="firstname">Vorname</label>
|
||||
<input type="text" id="firstname" name="firstname" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="lastname">Nachname</label>
|
||||
<input type="text" id="lastname" name="lastname" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Rollen</label>
|
||||
<div class="roles-checkbox-group">
|
||||
<label class="role-checkbox-label">
|
||||
<input type="checkbox" name="roles" value="mitarbeiter" class="role-checkbox-input">
|
||||
<span class="role-checkbox-text">Mitarbeiter</span>
|
||||
</label>
|
||||
<label class="role-checkbox-label">
|
||||
<input type="checkbox" name="roles" value="verwaltung" class="role-checkbox-input">
|
||||
<span class="role-checkbox-text">Verwaltung</span>
|
||||
</label>
|
||||
<label class="role-checkbox-label">
|
||||
<input type="checkbox" name="roles" value="admin" class="role-checkbox-input">
|
||||
<span class="role-checkbox-text">Administrator</span>
|
||||
</label>
|
||||
</div>
|
||||
<small class="form-help-text">Wählen Sie eine oder mehrere Rollen aus</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="personalnummer">Personalnummer</label>
|
||||
<input type="text" id="personalnummer" name="personalnummer">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="wochenstunden">Wochenstunden</label>
|
||||
<input type="number" id="wochenstunden" name="wochenstunden" step="0.5" min="0" placeholder="z.B. 40">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="urlaubstage">Urlaubstage</label>
|
||||
<input type="number" id="urlaubstage" name="urlaubstage" step="0.5" min="0" placeholder="z.B. 25">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Benutzer anlegen</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benutzer-Liste - Zusammenklappbar -->
|
||||
<div class="user-list-section" style="margin-top: 20px;">
|
||||
<div class="collapsible-header" onclick="toggleUserListSection()" style="cursor: pointer; padding: 15px; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 style="margin: 0;">Benutzer-Liste</h3>
|
||||
<span id="userListToggleIcon" style="font-size: 18px; transition: transform 0.3s;">▼</span>
|
||||
</div>
|
||||
|
||||
<div id="userListContent" style="display: none; padding: 20px; border: 1px solid #ddd; border-top: none; border-radius: 0 0 4px 4px; background-color: #fff; overflow-x: auto; max-width: 100%;">
|
||||
<div class="user-list" style="min-width: 100%;">
|
||||
<table style="width: 100%; min-width: 900px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Benutzername</th>
|
||||
<th>Vorname</th>
|
||||
<th>Nachname</th>
|
||||
<th>Rolle</th>
|
||||
<th>Personalnummer</th>
|
||||
<th>Wochenstunden</th>
|
||||
<th>Urlaubstage</th>
|
||||
<th>Erstellt am</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% users.forEach(function(u) { %>
|
||||
<tr data-user-id="<%= u.id %>">
|
||||
<td><%= u.id %></td>
|
||||
<td><%= u.username %></td>
|
||||
<td><%= u.firstname %></td>
|
||||
<td><%= u.lastname %></td>
|
||||
<td>
|
||||
<div class="user-field-display" data-field="roles">
|
||||
<%
|
||||
const roleLabels = { 'mitarbeiter': 'Mitarbeiter', 'verwaltung': 'Verwaltung', 'admin': 'Admin' };
|
||||
const userRoles = u.roles || [];
|
||||
if (userRoles.length > 0) {
|
||||
userRoles.forEach(function(role, idx) { %>
|
||||
<span class="role-badge role-<%= role %>" style="margin-right: 5px;"><%= roleLabels[role] || role %></span>
|
||||
<% });
|
||||
} else {
|
||||
%>
|
||||
<span class="role-badge role-mitarbeiter">Mitarbeiter</span>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="user-field-edit" data-field="roles" data-user-id="<%= u.id %>" style="display: none;">
|
||||
<div class="roles-checkbox-group roles-checkbox-group-inline">
|
||||
<label class="role-checkbox-label role-checkbox-label-small">
|
||||
<input type="checkbox" class="role-checkbox role-checkbox-input" data-role="mitarbeiter" value="mitarbeiter" <%= userRoles.includes('mitarbeiter') ? 'checked' : '' %>>
|
||||
<span class="role-checkbox-text">Mitarbeiter</span>
|
||||
</label>
|
||||
<label class="role-checkbox-label role-checkbox-label-small">
|
||||
<input type="checkbox" class="role-checkbox role-checkbox-input" data-role="verwaltung" value="verwaltung" <%= userRoles.includes('verwaltung') ? 'checked' : '' %>>
|
||||
<span class="role-checkbox-text">Verwaltung</span>
|
||||
</label>
|
||||
<label class="role-checkbox-label role-checkbox-label-small">
|
||||
<input type="checkbox" class="role-checkbox role-checkbox-input" data-role="admin" value="admin" <%= userRoles.includes('admin') ? 'checked' : '' %>>
|
||||
<span class="role-checkbox-text">Admin</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="user-field-display" data-field="personalnummer"><%= u.personalnummer || '-' %></span>
|
||||
<input type="text" class="user-field-edit" data-field="personalnummer" data-user-id="<%= u.id %>" value="<%= u.personalnummer || '' %>" style="display: none; width: 100px;">
|
||||
</td>
|
||||
<td>
|
||||
<span class="user-field-display" data-field="wochenstunden"><%= u.wochenstunden || '-' %></span>
|
||||
<input type="number" step="0.5" class="user-field-edit" data-field="wochenstunden" data-user-id="<%= u.id %>" value="<%= u.wochenstunden || '' %>" style="display: none; width: 80px;">
|
||||
</td>
|
||||
<td>
|
||||
<span class="user-field-display" data-field="urlaubstage"><%= u.urlaubstage || '-' %></span>
|
||||
<input type="number" step="0.5" class="user-field-edit" data-field="urlaubstage" data-user-id="<%= u.id %>" value="<%= u.urlaubstage || '' %>" style="display: none; width: 80px;">
|
||||
</td>
|
||||
<td><%= new Date(u.created_at).toLocaleDateString('de-DE') %></td>
|
||||
<td>
|
||||
<button onclick="editUser(<%= u.id %>)" class="btn btn-primary btn-sm edit-user-btn" data-user-id="<%= u.id %>">Bearbeiten</button>
|
||||
<button onclick="saveUser(<%= u.id %>)" class="btn btn-success btn-sm save-user-btn" data-user-id="<%= u.id %>" style="display: none;">Speichern</button>
|
||||
<button onclick="cancelEditUser(<%= u.id %>)" class="btn btn-secondary btn-sm cancel-user-btn" data-user-id="<%= u.id %>" style="display: none;">Abbrechen</button>
|
||||
<% if (u.id > 2) { %>
|
||||
<button onclick="deleteUser(<%= u.id %>, '<%= u.username %>')" class="btn btn-danger btn-sm">Löschen</button>
|
||||
<% } else { %>
|
||||
<span class="text-muted">System</span>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ldap-sync-section" style="margin-top: 40px;">
|
||||
<div class="collapsible-header" onclick="toggleLDAPSection()" style="cursor: pointer; padding: 15px; background-color: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; display: flex; justify-content: space-between; align-items: center;">
|
||||
<h2 style="margin: 0;">LDAP-Synchronisation</h2>
|
||||
<span id="ldapToggleIcon" style="font-size: 18px; transition: transform 0.3s;">▼</span>
|
||||
</div>
|
||||
|
||||
<div id="ldapContent" style="display: none; padding: 20px; border: 1px solid #ddd; border-top: none; border-radius: 0 0 4px 4px; background-color: #fff;">
|
||||
<div class="ldap-config-form">
|
||||
<h3>LDAP-Konfiguration</h3>
|
||||
<form id="ldapConfigForm">
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="ldapEnabled" name="enabled">
|
||||
LDAP-Synchronisation aktivieren
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="ldapUrl">LDAP-Server URL</label>
|
||||
<input type="text" id="ldapUrl" name="url" placeholder="ldap://ldap.example.com:389">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ldapBaseDn">Base DN</label>
|
||||
<input type="text" id="ldapBaseDn" name="base_dn" placeholder="dc=example,dc=com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="ldapBindDn">Bind DN (optional)</label>
|
||||
<input type="text" id="ldapBindDn" name="bind_dn" placeholder="cn=admin,dc=example,dc=com">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ldapBindPassword">Bind Passwort (optional)</label>
|
||||
<input type="password" id="ldapBindPassword" name="bind_password" placeholder="Leer lassen um nicht zu ändern">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ldapSearchFilter">User Search Filter</label>
|
||||
<input type="text" id="ldapSearchFilter" name="user_search_filter" placeholder="(objectClass=person)" value="(objectClass=person)">
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="ldapUsernameAttr">Username-Attribut</label>
|
||||
<input type="text" id="ldapUsernameAttr" name="username_attribute" placeholder="cn" value="cn">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ldapFirstnameAttr">Vorname-Attribut</label>
|
||||
<input type="text" id="ldapFirstnameAttr" name="firstname_attribute" placeholder="givenName" value="givenName">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ldapLastnameAttr">Nachname-Attribut</label>
|
||||
<input type="text" id="ldapLastnameAttr" name="lastname_attribute" placeholder="sn" value="sn">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="ldapSyncInterval">Sync-Intervall (Minuten)</label>
|
||||
<input type="number" id="ldapSyncInterval" name="sync_interval" min="0" value="0" placeholder="0 = nur manuell">
|
||||
<small>0 = nur manuelle Synchronisation</small>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Konfiguration speichern</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="ldap-sync-actions" style="margin-top: 30px;">
|
||||
<h3>Synchronisation</h3>
|
||||
<div style="margin-bottom: 15px;">
|
||||
<button id="syncNowBtn" class="btn btn-primary">Synchronisation jetzt starten</button>
|
||||
<span id="syncStatus" style="margin-left: 15px;"></span>
|
||||
</div>
|
||||
|
||||
<% if (ldapConfig && ldapConfig.last_sync) { %>
|
||||
<p><strong>Letzte Synchronisation:</strong> <%= new Date(ldapConfig.last_sync).toLocaleString('de-DE') %></p>
|
||||
<% } else { %>
|
||||
<p><strong>Letzte Synchronisation:</strong> Noch keine Synchronisation durchgeführt</p>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<div class="ldap-sync-log" style="margin-top: 30px;">
|
||||
<h3>Sync-Log (letzte 10 Einträge)</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitpunkt</th>
|
||||
<th>Typ</th>
|
||||
<th>Status</th>
|
||||
<th>Benutzer synchronisiert</th>
|
||||
<th>Fehlermeldung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% if (syncLogs && syncLogs.length > 0) { %>
|
||||
<% syncLogs.forEach(function(log) { %>
|
||||
<tr>
|
||||
<td><%= new Date(log.sync_started_at).toLocaleString('de-DE') %></td>
|
||||
<td><%= log.sync_type === 'manual' ? 'Manuell' : 'Automatisch' %></td>
|
||||
<td>
|
||||
<span class="role-badge role-<%= log.status === 'success' ? 'mitarbeiter' : 'admin' %>">
|
||||
<%= log.status === 'success' ? 'Erfolg' : 'Fehler' %>
|
||||
</span>
|
||||
</td>
|
||||
<td><%= log.users_synced %></td>
|
||||
<td><%= log.error_message || '-' %></td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
<% } else { %>
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center;">Keine Log-Einträge vorhanden</td>
|
||||
</tr>
|
||||
<% } %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/admin.js"></script>
|
||||
<script>
|
||||
// Benutzer anlegen Sektion ein-/ausklappen
|
||||
function toggleAddUserSection() {
|
||||
const content = document.getElementById('addUserContent');
|
||||
const icon = document.getElementById('addUserToggleIcon');
|
||||
|
||||
if (content.style.display === 'none') {
|
||||
content.style.display = 'block';
|
||||
icon.style.transform = 'rotate(180deg)';
|
||||
} else {
|
||||
content.style.display = 'none';
|
||||
icon.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
|
||||
// Benutzer-Liste Sektion ein-/ausklappen
|
||||
function toggleUserListSection() {
|
||||
const content = document.getElementById('userListContent');
|
||||
const icon = document.getElementById('userListToggleIcon');
|
||||
|
||||
if (content.style.display === 'none') {
|
||||
content.style.display = 'block';
|
||||
icon.style.transform = 'rotate(180deg)';
|
||||
} else {
|
||||
content.style.display = 'none';
|
||||
icon.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
|
||||
// LDAP-Sektion ein-/ausklappen
|
||||
function toggleLDAPSection() {
|
||||
const content = document.getElementById('ldapContent');
|
||||
const icon = document.getElementById('ldapToggleIcon');
|
||||
|
||||
if (content.style.display === 'none') {
|
||||
content.style.display = 'block';
|
||||
icon.style.transform = 'rotate(180deg)';
|
||||
} else {
|
||||
content.style.display = 'none';
|
||||
icon.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
|
||||
// Rollenwechsel-Handler
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const roleSwitcher = document.getElementById('roleSwitcher');
|
||||
if (roleSwitcher) {
|
||||
roleSwitcher.addEventListener('change', async function() {
|
||||
const newRole = this.value;
|
||||
try {
|
||||
const response = await fetch('/api/user/switch-role', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ role: newRole })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// Redirect basierend auf neuer Rolle
|
||||
if (newRole === 'admin') {
|
||||
window.location.href = '/admin';
|
||||
} else if (newRole === 'verwaltung') {
|
||||
window.location.href = '/verwaltung';
|
||||
} else {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
} else {
|
||||
alert('Fehler beim Wechseln der Rolle: ' + (result.error || 'Unbekannter Fehler'));
|
||||
// Wert zurücksetzen
|
||||
this.value = '<%= user.currentRole || "admin" %>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Rollenwechsel:', error);
|
||||
alert('Fehler beim Wechseln der Rolle');
|
||||
// Wert zurücksetzen
|
||||
this.value = '<%= user.currentRole || "admin" %>';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
406
views/dashboard.ejs
Normal file
406
views/dashboard.ejs
Normal file
@@ -0,0 +1,406 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de-DE">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="Content-Language" content="de-DE">
|
||||
<title>Dashboard - Stundenerfassung</title>
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="navbar">
|
||||
<div class="container">
|
||||
<div class="navbar-brand">
|
||||
<img src="/images/header.png" alt="Logo" class="navbar-logo">
|
||||
<h1>Stundenerfassung</h1>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<span>Willkommen, <%= user.firstname %> <%= user.lastname %></span>
|
||||
<% if (user.roles && user.roles.length > 1) { %>
|
||||
<select id="roleSwitcher" class="role-switcher" style="margin-right: 10px; padding: 5px 10px; border-radius: 4px; border: 1px solid #ddd;">
|
||||
<% const roleLabels = { 'mitarbeiter': 'Mitarbeiter', 'verwaltung': 'Verwaltung', 'admin': 'Administrator' }; %>
|
||||
<% user.roles.forEach(function(role) { %>
|
||||
<option value="<%= role %>" <%= user.currentRole === role ? 'selected' : '' %>><%= roleLabels[role] || role %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
<% } %>
|
||||
<a href="/logout" class="btn btn-secondary">Abmelden</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container dashboard-container">
|
||||
<div class="dashboard-layout">
|
||||
<div class="dashboard">
|
||||
<div class="week-selector">
|
||||
<button id="prevWeek" class="btn btn-secondary">◀ Vorherige Woche</button>
|
||||
<h2 id="weekTitle">Kalenderwoche</h2>
|
||||
<button id="nextWeek" class="btn btn-secondary">Nächste Woche ▶</button>
|
||||
</div>
|
||||
|
||||
<div id="timesheetTable">
|
||||
<!-- Wird mit JavaScript gefüllt -->
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="summary-item">
|
||||
<strong>Gesamtstunden diese Woche:</strong>
|
||||
<span id="totalHours">0.00 h</span>
|
||||
</div>
|
||||
<div class="summary-item" id="overtimeSummaryItem" style="display: none;">
|
||||
<strong>Überstunden diese Woche:</strong>
|
||||
<span id="overtimeHours">0.00 h</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="submitWeek" class="btn btn-success" onclick="window.submitWeekHandler(event)" disabled>Woche abschicken</button>
|
||||
<p class="help-text">Stunden werden automatisch gespeichert. Am Ende der Woche können Sie die Stunden abschicken.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rechte Seitenleiste mit Statistiken und Erfassungs-URLs -->
|
||||
<div class="user-stats-panel">
|
||||
<!-- Statistik-Karten -->
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" style="display: flex; align-items: center; gap: 5px;">
|
||||
Aktuelle Überstunden
|
||||
<span class="help-icon" onclick="showHelpModal('overtime-help')" style="cursor: pointer; color: #3498db; font-size: 14px; font-weight: bold; width: 18px; height: 18px; border-radius: 50%; background: #e8f4f8; display: inline-flex; align-items: center; justify-content: center; line-height: 1;">?</span>
|
||||
</div>
|
||||
<div class="stat-value" id="currentOvertime">-</div>
|
||||
<div class="stat-unit">Stunden</div>
|
||||
</div>
|
||||
<div class="stat-card stat-vacation">
|
||||
<div class="stat-label" style="display: flex; align-items: center; gap: 5px;">
|
||||
Verbleibende Urlaubstage
|
||||
<span class="help-icon" onclick="showHelpModal('remaining-vacation-help')" style="cursor: pointer; color: #3498db; font-size: 14px; font-weight: bold; width: 18px; height: 18px; border-radius: 50%; background: #e8f4f8; display: inline-flex; align-items: center; justify-content: center; line-height: 1;">?</span>
|
||||
</div>
|
||||
<div class="stat-value" id="remainingVacation">-</div>
|
||||
<div class="stat-unit">von <span id="totalVacation">-</span> Tagen</div>
|
||||
</div>
|
||||
<div class="stat-card stat-planned">
|
||||
<div class="stat-label" style="display: flex; align-items: center; gap: 5px;">
|
||||
Verplante Urlaubstage
|
||||
<span class="help-icon" onclick="showHelpModal('planned-vacation-help')" style="cursor: pointer; color: #3498db; font-size: 14px; font-weight: bold; width: 18px; height: 18px; border-radius: 50%; background: #e8f4f8; display: inline-flex; align-items: center; justify-content: center; line-height: 1;">?</span>
|
||||
</div>
|
||||
<div class="stat-value" id="plannedVacation">-</div>
|
||||
<div class="stat-unit">Tage</div>
|
||||
<div id="plannedWeeks" style="font-size: 11px; color: #666; margin-top: 8px; line-height: 1.4;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Zeiterfassung (URL & IP) -->
|
||||
<div style="margin-top: 20px; padding-top: 20px; border-top: 1px solid #e0e0e0;">
|
||||
<h3 style="font-size: 14px; margin-bottom: 0; color: #2c3e50; cursor: pointer; user-select: none; display: flex; align-items: center; gap: 8px;" onclick="toggleTimeCapture()">
|
||||
<span class="toggle-icon-time-capture" style="display: inline-block; transition: transform 0.3s;">▶</span>
|
||||
Automatische Zeiterfassung
|
||||
</h3>
|
||||
<div id="timeCaptureContent" style="display: none; margin-top: 15px;">
|
||||
<!-- URL-Erfassung -->
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h4 style="font-size: 13px; margin-bottom: 10px; color: #555; display: flex; align-items: center; gap: 5px;">
|
||||
Zeiterfassung per URL
|
||||
<span class="help-icon" onclick="showHelpModal('url-help')" style="cursor: pointer; color: #3498db; font-size: 14px; font-weight: bold; width: 18px; height: 18px; border-radius: 50%; background: #e8f4f8; display: inline-flex; align-items: center; justify-content: center; line-height: 1;">?</span>
|
||||
</h4>
|
||||
<div class="form-group" style="margin-bottom: 15px;">
|
||||
<label style="font-size: 12px; color: #666; margin-bottom: 5px;">Check-in URL</label>
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<input type="text" id="checkinUrl" readonly style="flex: 1; padding: 8px; font-size: 11px; border: 1px solid #ddd; border-radius: 4px; background: #f8f9fa;">
|
||||
<button onclick="copyToClipboard('checkinUrl')" class="btn btn-sm btn-secondary" style="padding: 8px 12px;">Kopieren</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom: 15px;">
|
||||
<label style="font-size: 12px; color: #666; margin-bottom: 5px;">Check-out URL</label>
|
||||
<div style="display: flex; gap: 5px;">
|
||||
<input type="text" id="checkoutUrl" readonly style="flex: 1; padding: 8px; font-size: 11px; border: 1px solid #ddd; border-radius: 4px; background: #f8f9fa;">
|
||||
<button onclick="copyToClipboard('checkoutUrl')" class="btn btn-sm btn-secondary" style="padding: 8px 12px;">Kopieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- IP-Erfassung -->
|
||||
<div style="padding-top: 15px; border-top: 1px solid #e0e0e0;">
|
||||
<h4 style="font-size: 13px; margin-bottom: 10px; color: #555; display: flex; align-items: center; gap: 5px;">
|
||||
IP-basierte Zeiterfassung
|
||||
<span class="help-icon" onclick="showHelpModal('ip-help')" style="cursor: pointer; color: #3498db; font-size: 14px; font-weight: bold; width: 18px; height: 18px; border-radius: 50%; background: #e8f4f8; display: inline-flex; align-items: center; justify-content: center; line-height: 1;">?</span>
|
||||
</h4>
|
||||
<div class="form-group" style="margin-bottom: 15px;">
|
||||
<label style="font-size: 12px; color: #666; margin-bottom: 5px;">Ping-IP Adresse</label>
|
||||
<div style="display: flex; gap: 5px; margin-bottom: 5px;">
|
||||
<input type="text" id="pingIpInput" placeholder="z.B. 192.168.1.100" style="flex: 1; padding: 8px; font-size: 12px; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<button onclick="window.savePingIP()" class="btn btn-sm btn-success" style="padding: 8px 12px;">Speichern</button>
|
||||
</div>
|
||||
<button onclick="window.detectClientIP()" class="btn btn-sm" style="padding: 6px 12px; background-color: #3498db; color: white; border: none; border-radius: 4px; font-size: 11px; cursor: pointer; margin-bottom: 5px;">Aktuelle IP ermitteln</button>
|
||||
<p style="font-size: 11px; color: #666; margin-top: 5px; font-style: italic;">Ihre IP-Adresse für automatische Zeiterfassung</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/dashboard.js"></script>
|
||||
<script>
|
||||
// Wochenende-Sektion ein-/ausklappen
|
||||
function toggleWeekendSection() {
|
||||
const content = document.getElementById('weekendContent');
|
||||
const icon = document.getElementById('weekendToggleIcon');
|
||||
|
||||
if (content && icon) {
|
||||
if (content.style.display === 'none') {
|
||||
content.style.display = 'block';
|
||||
icon.style.transform = 'rotate(180deg)';
|
||||
} else {
|
||||
content.style.display = 'none';
|
||||
icon.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zeiterfassung ein-/ausklappen
|
||||
function toggleTimeCapture() {
|
||||
const content = document.getElementById('timeCaptureContent');
|
||||
const icon = document.querySelector('.toggle-icon-time-capture');
|
||||
|
||||
if (content && icon) {
|
||||
if (content.style.display === 'none') {
|
||||
content.style.display = 'block';
|
||||
icon.style.transform = 'rotate(90deg)';
|
||||
} else {
|
||||
content.style.display = 'none';
|
||||
icon.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// URL-Kopier-Funktion
|
||||
function copyToClipboard(inputId) {
|
||||
const input = document.getElementById(inputId);
|
||||
input.select();
|
||||
input.setSelectionRange(0, 99999); // Für mobile Geräte
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
const button = event.target;
|
||||
const originalText = button.textContent;
|
||||
button.textContent = 'Kopiert!';
|
||||
button.style.backgroundColor = '#27ae60';
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
button.style.backgroundColor = '';
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
alert('Fehler beim Kopieren. Bitte manuell kopieren.');
|
||||
}
|
||||
}
|
||||
|
||||
// URLs mit aktueller Domain aktualisieren (Port 3334 für Check-in)
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const userId = '<%= user.id %>';
|
||||
const baseUrl = window.location.origin;
|
||||
// Check-in URLs verwenden Port 3334
|
||||
// Ersetze Port in URL oder füge Port hinzu falls nicht vorhanden
|
||||
let checkinBaseUrl;
|
||||
if (baseUrl.match(/:\d+$/)) {
|
||||
// Port vorhanden - ersetze ihn
|
||||
checkinBaseUrl = baseUrl.replace(/:\d+$/, ':3334');
|
||||
} else {
|
||||
// Kein Port - füge Port hinzu
|
||||
const url = new URL(baseUrl);
|
||||
checkinBaseUrl = `${url.protocol}//${url.hostname}:3334`;
|
||||
}
|
||||
const checkinInput = document.getElementById('checkinUrl');
|
||||
const checkoutInput = document.getElementById('checkoutUrl');
|
||||
if (checkinInput) checkinInput.value = `${checkinBaseUrl}/api/checkin/${userId}`;
|
||||
if (checkoutInput) checkoutInput.value = `${checkinBaseUrl}/api/checkout/${userId}`;
|
||||
|
||||
// Rollenwechsel-Handler
|
||||
const roleSwitcher = document.getElementById('roleSwitcher');
|
||||
if (roleSwitcher) {
|
||||
roleSwitcher.addEventListener('change', async function() {
|
||||
const newRole = this.value;
|
||||
try {
|
||||
const response = await fetch('/api/user/switch-role', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ role: newRole })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// Redirect basierend auf neuer Rolle
|
||||
if (newRole === 'admin') {
|
||||
window.location.href = '/admin';
|
||||
} else if (newRole === 'verwaltung') {
|
||||
window.location.href = '/verwaltung';
|
||||
} else {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
} else {
|
||||
alert('Fehler beim Wechseln der Rolle: ' + (result.error || 'Unbekannter Fehler'));
|
||||
// Wert zurücksetzen
|
||||
this.value = '<%= user.currentRole || "mitarbeiter" %>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Rollenwechsel:', error);
|
||||
alert('Fehler beim Wechseln der Rolle');
|
||||
// Wert zurücksetzen
|
||||
this.value = '<%= user.currentRole || "mitarbeiter" %>';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Help Modal -->
|
||||
<div id="helpModal" style="display: none; position: fixed; z-index: 10000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5);">
|
||||
<div style="position: relative; background-color: #fff; margin: 10% auto; padding: 20px; border-radius: 8px; width: 90%; max-width: 500px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||
<span onclick="closeHelpModal()" style="position: absolute; right: 15px; top: 15px; color: #aaa; font-size: 28px; font-weight: bold; cursor: pointer; line-height: 1;">×</span>
|
||||
<div id="helpModalContent" style="padding-right: 30px;">
|
||||
<!-- Content wird dynamisch eingefügt -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Help Modal Funktionen
|
||||
function showHelpModal(type) {
|
||||
const modal = document.getElementById('helpModal');
|
||||
const content = document.getElementById('helpModalContent');
|
||||
|
||||
let title = '';
|
||||
let text = '';
|
||||
|
||||
if (type === 'url-help') {
|
||||
title = 'Zeiterfassung per URL';
|
||||
text = `
|
||||
<h3 style="margin-top: 0; color: #2c3e50; font-size: 18px;">${title}</h3>
|
||||
<p style="color: #555; line-height: 1.6;">
|
||||
Mit den Check-in und Check-out URLs können Sie Ihre Arbeitszeit automatisch erfassen,
|
||||
indem Sie die URLs in Ihrem Browser aufrufen oder als Lesezeichen speichern.
|
||||
</p>
|
||||
<p style="color: #555; line-height: 1.6;">
|
||||
<strong>Check-in URL:</strong> Öffnen Sie diese URL, um Ihre Start-Zeit zu erfassen.<br>
|
||||
<strong>Check-out URL:</strong> Öffnen Sie diese URL, um Ihre End-Zeit zu erfassen.
|
||||
</p>
|
||||
<p style="color: #555; line-height: 1.6;">
|
||||
Die URLs sind personalisiert und funktionieren nur für Ihren Account. Sie können sie
|
||||
kopieren und als Lesezeichen in Ihrem Browser speichern.
|
||||
</p>
|
||||
`;
|
||||
} else if (type === 'ip-help') {
|
||||
title = 'IP-basierte Zeiterfassung';
|
||||
text = `
|
||||
<h3 style="margin-top: 0; color: #2c3e50; font-size: 18px;">${title}</h3>
|
||||
<p style="color: #555; line-height: 1.6;">
|
||||
Die IP-basierte Zeiterfassung erkennt automatisch, wenn Sie sich im Firmennetzwerk befinden,
|
||||
indem Ihre IP-Adresse regelmäßig geprüft wird.
|
||||
</p>
|
||||
<p style="color: #555; line-height: 1.6;">
|
||||
<strong>So funktioniert es:</strong>
|
||||
</p>
|
||||
<ul style="color: #555; line-height: 1.8; padding-left: 20px;">
|
||||
<li>Tragen Sie Ihre IP-Adresse ein (z.B. 192.168.1.100)</li>
|
||||
<li>Das System prüft regelmäßig, ob diese IP-Adresse erreichbar ist</li>
|
||||
<li>Wenn die IP erreichbar ist, wird automatisch eine Start-Zeit erfasst</li>
|
||||
<li>Wenn die IP nicht mehr erreichbar ist, wird automatisch eine End-Zeit erfasst</li>
|
||||
</ul>
|
||||
<p style="color: #555; line-height: 1.6;">
|
||||
<strong>Tipp:</strong> Verwenden Sie den Button "Aktuelle IP ermitteln", um Ihre aktuelle
|
||||
IP-Adresse automatisch zu erkennen.
|
||||
</p>
|
||||
`;
|
||||
} else if (type === 'remaining-vacation-help') {
|
||||
title = 'Verbleibende Urlaubstage';
|
||||
text = `
|
||||
<h3 style="margin-top: 0; color: #2c3e50; font-size: 18px;">${title}</h3>
|
||||
<p style="color: #555; line-height: 1.6;">
|
||||
Die <strong>verbleibenden Urlaubstage</strong> zeigen an, wie viele Urlaubstage Sie noch
|
||||
zur Verfügung haben.
|
||||
</p>
|
||||
<p style="color: #555; line-height: 1.6;">
|
||||
<strong>Wichtig:</strong> Diese Zahl berücksichtigt nur Urlaubstage aus Wochen, die bereits
|
||||
<strong>eingereicht</strong> wurden. Urlaubstage, die Sie nur geplant, aber noch nicht
|
||||
abgeschickt haben, werden hier nicht abgezogen.
|
||||
</p>
|
||||
<p style="color: #555; line-height: 1.6;">
|
||||
<strong>Beispiel:</strong> Wenn Sie 25 Urlaubstage haben und bereits 5 Tage in eingereichten
|
||||
Wochen genommen haben, zeigt diese Anzeige 20 verbleibende Tage.
|
||||
</p>
|
||||
`;
|
||||
} else if (type === 'planned-vacation-help') {
|
||||
title = 'Verplante Urlaubstage';
|
||||
text = `
|
||||
<h3 style="margin-top: 0; color: #2c3e50; font-size: 18px;">${title}</h3>
|
||||
<p style="color: #555; line-height: 1.6;">
|
||||
Die <strong>verplanten Urlaubstage</strong> zeigen alle Urlaubstage an, die Sie in irgendeiner
|
||||
Woche eingetragen haben - unabhängig davon, ob die Woche bereits eingereicht wurde oder nicht.
|
||||
</p>
|
||||
<p style="color: #555; line-height: 1.6;">
|
||||
<strong>Unterschied zu "Verbleibende Urlaubstage":</strong>
|
||||
</p>
|
||||
<ul style="color: #555; line-height: 1.8; padding-left: 20px;">
|
||||
<li><strong>Verbleibende Urlaubstage:</strong> Nur von eingereichten Wochen</li>
|
||||
<li><strong>Verplante Urlaubstage:</strong> Alle geplanten Tage (auch nicht-eingereichte Wochen)</li>
|
||||
</ul>
|
||||
<p style="color: #555; line-height: 1.6;">
|
||||
<strong>Beispiel:</strong> Wenn Sie in einer noch nicht eingereichten Woche 3 Tage Urlaub
|
||||
eintragen, erscheinen diese sofort in "Verplante Urlaubstage", aber noch nicht in
|
||||
"Verbleibende Urlaubstage". Erst nach dem Abschicken der Woche werden sie auch von den
|
||||
verbleibenden Tagen abgezogen.
|
||||
</p>
|
||||
<p style="color: #555; line-height: 1.6; margin-top: 15px; padding-top: 15px; border-top: 1px solid #e0e0e0;">
|
||||
<strong>Hinweis:</strong> Unter dieser Anzeige sehen Sie, in welchen Kalenderwochen
|
||||
(KW) Sie Urlaub geplant haben.
|
||||
</p>
|
||||
`;
|
||||
} else if (type === 'overtime-help') {
|
||||
title = 'Aktuelle Überstunden';
|
||||
text = `
|
||||
<h3 style="margin-top: 0; color: #2c3e50; font-size: 18px;">${title}</h3>
|
||||
<p style="color: #555; line-height: 1.6;">
|
||||
Die <strong>aktuellen Überstunden</strong> zeigen Ihre gesamten Überstunden an, die sich aus
|
||||
allen bereits eingereichten Wochen ergeben.
|
||||
</p>
|
||||
<p style="color: #555; line-height: 1.6;">
|
||||
<strong>Wichtig:</strong> Überstunden werden erst berechnet und angezeigt, wenn die entsprechende
|
||||
Woche <strong>abgeschickt</strong> wurde. Überstunden aus Wochen, die Sie nur geplant, aber noch
|
||||
nicht abgeschickt haben, werden hier nicht berücksichtigt.
|
||||
</p>
|
||||
<p style="color: #555; line-height: 1.6;">
|
||||
<strong>So funktioniert die Berechnung:</strong>
|
||||
</p>
|
||||
<ul style="color: #555; line-height: 1.8; padding-left: 20px;">
|
||||
<li>Für jede eingereichte Woche werden Ihre tatsächlichen Arbeitsstunden mit den Sollstunden verglichen</li>
|
||||
<li>Die Differenz ergibt die Überstunden (positiv) oder Minusstunden (negativ) für diese Woche</li>
|
||||
<li>Alle Überstunden aus eingereichten Wochen werden summiert</li>
|
||||
<li>Zusätzlich können manuelle Korrekturen (Offset) durch die Verwaltung hinzugefügt werden</li>
|
||||
</ul>
|
||||
<p style="color: #555; line-height: 1.6; margin-top: 15px; padding-top: 15px; border-top: 1px solid #e0e0e0;">
|
||||
<strong>Beispiel:</strong> Wenn Sie diese Woche 42 Stunden arbeiten, aber nur 40 Stunden Soll haben,
|
||||
entstehen 2 Überstunden. Diese werden jedoch erst nach dem Abschicken der Woche zu Ihren
|
||||
aktuellen Überstunden hinzugefügt.
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
|
||||
content.innerHTML = text;
|
||||
modal.style.display = 'block';
|
||||
}
|
||||
|
||||
function closeHelpModal() {
|
||||
document.getElementById('helpModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// Modal schließen wenn außerhalb geklickt wird
|
||||
window.onclick = function(event) {
|
||||
const modal = document.getElementById('helpModal');
|
||||
if (event.target === modal) {
|
||||
closeHelpModal();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
46
views/login.ejs
Normal file
46
views/login.ejs
Normal file
@@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - Stundenerfassung</title>
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<h1>Stundenerfassung</h1>
|
||||
<h2>Anmeldung</h2>
|
||||
|
||||
<% if (error) { %>
|
||||
<div class="error-message"><%= error %></div>
|
||||
<% } %>
|
||||
|
||||
<form method="POST" action="/login">
|
||||
<div class="form-group">
|
||||
<label for="username">Benutzername</label>
|
||||
<input type="text" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input type="password" id="password" name="password" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: flex-start; cursor: pointer;">
|
||||
<input type="checkbox" name="remember_me" id="remember_me" style="margin-right: 8px; margin-top: 2px;">
|
||||
<span style="display: flex; flex-direction: column;">
|
||||
<span>Angemeldet bleiben</span>
|
||||
<span style="font-size: 0.9em; color: #666;">(30 Tage)</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
780
views/verwaltung.ejs
Normal file
780
views/verwaltung.ejs
Normal file
@@ -0,0 +1,780 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verwaltung - Stundenerfassung</title>
|
||||
<link rel="icon" type="image/png" href="/images/favicon.png">
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="navbar">
|
||||
<div class="container">
|
||||
<div class="navbar-brand">
|
||||
<img src="/images/header.png" alt="Logo" class="navbar-logo">
|
||||
<h1>Stundenerfassung - Verwaltung</h1>
|
||||
</div>
|
||||
<div class="nav-right">
|
||||
<span>Verwaltung: <%= user.firstname %> <%= user.lastname %></span>
|
||||
<% if (user.roles && user.roles.length > 1) { %>
|
||||
<select id="roleSwitcher" class="role-switcher" style="margin-right: 10px; padding: 5px 10px; border-radius: 4px; border: 1px solid #ddd;">
|
||||
<% const roleLabels = { 'mitarbeiter': 'Mitarbeiter', 'verwaltung': 'Verwaltung', 'admin': 'Administrator' }; %>
|
||||
<% user.roles.forEach(function(role) { %>
|
||||
<option value="<%= role %>" <%= user.currentRole === role ? 'selected' : '' %>><%= roleLabels[role] || role %></option>
|
||||
<% }); %>
|
||||
</select>
|
||||
<% } %>
|
||||
<a href="/logout" class="btn btn-secondary">Abmelden</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container verwaltung-container">
|
||||
<div class="verwaltung-panel">
|
||||
<h2>Postfach - Eingereichte Stundenzettel</h2>
|
||||
|
||||
<!-- Massendownload für Kalenderwoche -->
|
||||
<div style="margin-bottom: 30px; padding: 20px; background-color: #f8f9fa; border-radius: 8px; border: 1px solid #dee2e6;">
|
||||
<h3 style="margin-top: 0; margin-bottom: 15px; font-size: 16px; color: #333;">Massendownload für Kalenderwoche</h3>
|
||||
<div style="display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap;">
|
||||
<div style="display: flex; flex-direction: column; gap: 5px;">
|
||||
<label for="bulkDownloadYear" style="font-size: 13px; color: #555; font-weight: 500;">Jahr:</label>
|
||||
<input
|
||||
type="number"
|
||||
id="bulkDownloadYear"
|
||||
min="2000"
|
||||
max="2100"
|
||||
value="<%= new Date().getFullYear() %>"
|
||||
style="padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; width: 100px;"
|
||||
placeholder="2024">
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 5px;">
|
||||
<label for="bulkDownloadWeek" style="font-size: 13px; color: #555; font-weight: 500;">Kalenderwoche:</label>
|
||||
<input
|
||||
type="number"
|
||||
id="bulkDownloadWeek"
|
||||
min="1"
|
||||
max="53"
|
||||
style="padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; width: 100px;"
|
||||
placeholder="5">
|
||||
</div>
|
||||
<button
|
||||
id="bulkDownloadBtn"
|
||||
class="btn btn-primary"
|
||||
style="padding: 8px 20px; font-size: 14px; white-space: nowrap;">
|
||||
Alle PDFs für KW herunterladen
|
||||
</button>
|
||||
</div>
|
||||
<div id="bulkDownloadStatus" style="margin-top: 12px; font-size: 13px; color: #666; display: none;"></div>
|
||||
</div>
|
||||
|
||||
<% if (!groupedByEmployee || groupedByEmployee.length === 0) { %>
|
||||
<div class="empty-state">
|
||||
<p>Keine eingereichten Stundenzettel vorhanden.</p>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<div class="timesheet-groups">
|
||||
<% groupedByEmployee.forEach(function(employee, employeeIndex) { %>
|
||||
<!-- Level 1: Mitarbeiter -->
|
||||
<div class="employee-group" data-employee-id="<%= employee.user.id %>" data-employee-index="<%= employeeIndex %>">
|
||||
<div class="employee-header">
|
||||
<div class="employee-info">
|
||||
<div class="employee-name">
|
||||
<strong><%= employee.user.firstname %> <%= employee.user.lastname %></strong>
|
||||
<% if (employee.user.personalnummer) { %>
|
||||
<span style="margin-left: 10px; color: #666;">(Personalnummer: <%= employee.user.personalnummer %>)</span>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="employee-details" style="margin-top: 10px;">
|
||||
<div style="display: inline-block; margin-right: 20px;">
|
||||
<strong>Wochenstunden:</strong> <span><%= employee.user.wochenstunden || '-' %></span>
|
||||
</div>
|
||||
<div style="display: inline-block; margin-right: 20px;">
|
||||
<strong>Urlaubstage:</strong> <span><%= employee.user.urlaubstage || '-' %></span>
|
||||
</div>
|
||||
<div style="display: inline-flex; gap: 8px; align-items: center; margin-right: 20px;">
|
||||
<strong>Überstunden-Offset:</strong>
|
||||
<input
|
||||
type="number"
|
||||
step="0.25"
|
||||
class="overtime-offset-input"
|
||||
data-user-id="<%= employee.user.id %>"
|
||||
value="<%= (employee.user.overtime_offset_hours !== undefined && employee.user.overtime_offset_hours !== null) ? employee.user.overtime_offset_hours : 0 %>"
|
||||
style="width: 90px; padding: 4px 6px; border: 1px solid #ddd; border-radius: 4px;"
|
||||
title="Manuelle Korrektur (positiv oder negativ) in Stunden" />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-sm save-overtime-offset-btn"
|
||||
data-user-id="<%= employee.user.id %>"
|
||||
style="padding: 6px 10px; white-space: nowrap;"
|
||||
title="Überstunden-Offset speichern">
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
<div style="display: inline-block; margin-right: 20px;">
|
||||
<strong>Kalenderwochen:</strong> <span><%= employee.weeks.length %></span>
|
||||
</div>
|
||||
<div style="display: inline-block; margin-right: 20px;">
|
||||
<strong>Krankheitstage (<span class="sick-days-year"><%= new Date().getFullYear() %></span>):</strong>
|
||||
<span class="sick-days-count" data-user-id="<%= employee.user.id %>" style="color: #e74c3c;">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm toggle-employee-btn" data-employee-index="<%= employeeIndex %>">
|
||||
<span class="toggle-icon">▼</span> Kalenderwochen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Level 2: Kalenderwochen -->
|
||||
<div class="weeks-container" data-employee-index="<%= employeeIndex %>" style="display: none;">
|
||||
<% employee.weeks.forEach(function(week, weekIndex) { %>
|
||||
<div class="week-group" data-employee-index="<%= employeeIndex %>" data-week-index="<%= weekIndex %>">
|
||||
<div class="week-header">
|
||||
<div class="week-info">
|
||||
<div class="week-dates">
|
||||
<%
|
||||
// Kalenderwoche berechnen
|
||||
function getCalendarWeek(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
||||
return weekNo;
|
||||
}
|
||||
const calendarWeek = getCalendarWeek(week.week_start);
|
||||
%>
|
||||
<strong>Kalenderwoche <%= String(calendarWeek).padStart(2, '0') %>:</strong> <%= new Date(week.week_start).toLocaleDateString('de-DE') %> -
|
||||
<%= new Date(week.week_end).toLocaleDateString('de-DE') %>
|
||||
</div>
|
||||
<div class="group-stats" data-user-id="<%= employee.user.id %>" data-week-start="<%= week.week_start %>" data-week-end="<%= week.week_end %>" style="margin-top: 10px;">
|
||||
<div class="stats-loading" style="display: inline-block; color: #666;">Lade Statistiken...</div>
|
||||
</div>
|
||||
<div class="week-versions-info" style="margin-top: 5px;">
|
||||
<span class="version-count"><%= week.total_versions %> Version<%= week.total_versions !== 1 ? 'en' : '' %></span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-secondary btn-sm toggle-versions-btn" data-employee-index="<%= employeeIndex %>" data-week-index="<%= weekIndex %>">
|
||||
<span class="toggle-icon">▼</span> Versionen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Level 3: Versionen -->
|
||||
<div class="versions-container" data-employee-index="<%= employeeIndex %>" data-week-index="<%= weekIndex %>" style="display: none;">
|
||||
<table class="timesheet-table versions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Version</th>
|
||||
<th>Eingereicht am</th>
|
||||
<th>Grund</th>
|
||||
<th>Kommentar</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% week.versions.forEach(function(ts) { %>
|
||||
<tr class="timesheet-row" data-timesheet-id="<%= ts.id %>">
|
||||
<td>
|
||||
<span class="version-badge">
|
||||
Version <%= ts.version || 1 %>
|
||||
</span>
|
||||
<% if (ts.pdf_downloaded_at) { %>
|
||||
<%
|
||||
let downloadedByName = 'Unbekannt';
|
||||
if (ts.downloaded_by_firstname && ts.downloaded_by_lastname) {
|
||||
downloadedByName = `${ts.downloaded_by_firstname} ${ts.downloaded_by_lastname}`;
|
||||
} else if (ts.downloaded_by_firstname) {
|
||||
downloadedByName = ts.downloaded_by_firstname;
|
||||
} else if (ts.downloaded_by_lastname) {
|
||||
downloadedByName = ts.downloaded_by_lastname;
|
||||
}
|
||||
%>
|
||||
<span class="pdf-downloaded-marker" title="PDF wurde am <%= new Date(ts.pdf_downloaded_at).toLocaleString('de-DE') %> von <%= downloadedByName %> heruntergeladen">
|
||||
✓ Heruntergeladen von <%= downloadedByName %>
|
||||
</span>
|
||||
<% } else { %>
|
||||
<span class="pdf-not-downloaded-marker">⭕ Nicht heruntergeladen</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td><%= new Date(ts.submitted_at).toLocaleString('de-DE') %></td>
|
||||
<td>
|
||||
<% if (ts.version_reason && ts.version_reason.trim() !== '') { %>
|
||||
<span style="color: #666; font-size: 13px;" title="<%= ts.version_reason %>">
|
||||
<%= ts.version_reason.length > 50 ? ts.version_reason.substring(0, 50) + '...' : ts.version_reason %>
|
||||
</span>
|
||||
<% } else { %>
|
||||
<span style="color: #999; font-style: italic;">-</span>
|
||||
<% } %>
|
||||
</td>
|
||||
<td>
|
||||
<div class="admin-comment-cell" style="display: flex; gap: 8px; align-items: flex-start; min-width: 300px;">
|
||||
<textarea
|
||||
class="admin-comment-input"
|
||||
data-timesheet-id="<%= ts.id %>"
|
||||
rows="2"
|
||||
style="flex: 1; min-width: 250px; padding: 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px; resize: vertical;"
|
||||
placeholder="Kommentar..."><%= ts.admin_comment || '' %></textarea>
|
||||
<button
|
||||
class="btn btn-success btn-sm save-comment-btn"
|
||||
data-timesheet-id="<%= ts.id %>"
|
||||
style="white-space: nowrap; padding: 8px 12px;"
|
||||
title="Kommentar speichern">
|
||||
💾
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="status-badge status-<%= ts.status %>"><%= ts.status %></span></td>
|
||||
<td>
|
||||
<button class="btn btn-info btn-sm toggle-pdf-btn" data-timesheet-id="<%= ts.id %>">
|
||||
<span class="arrow-icon">▶</span> PDF anzeigen
|
||||
</button>
|
||||
<a href="/api/timesheet/pdf/<%= ts.id %>" class="btn btn-primary btn-sm pdf-download-link" data-timesheet-id="<%= ts.id %>" target="_blank" download>
|
||||
📥 PDF herunterladen
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="pdf-preview-row" data-timesheet-id="<%= ts.id %>" style="display: none;">
|
||||
<td colspan="6">
|
||||
<div class="pdf-preview-container">
|
||||
<div class="pdf-preview-header">
|
||||
<h3>PDF-Vorschau: <%= employee.user.firstname %> <%= employee.user.lastname %> - Version <%= ts.version || 1 %> - <%= new Date(ts.week_start).toLocaleDateString('de-DE') %> bis <%= new Date(ts.week_end).toLocaleDateString('de-DE') %></h3>
|
||||
<button class="btn btn-secondary btn-sm close-pdf-btn" data-timesheet-id="<%= ts.id %>">
|
||||
✕ Schließen
|
||||
</button>
|
||||
</div>
|
||||
<div class="pdf-viewer-wrapper">
|
||||
<iframe
|
||||
src="/api/timesheet/pdf/<%= ts.id %>?inline=true"
|
||||
class="pdf-iframe"
|
||||
frameborder="0"
|
||||
type="application/pdf"
|
||||
allow="fullscreen">
|
||||
<p>Ihr Browser unterstützt keine PDF-Vorschau.
|
||||
<a href="/api/timesheet/pdf/<%= ts.id %>?inline=true" target="_blank">PDF in neuem Tab öffnen</a>
|
||||
</p>
|
||||
</iframe>
|
||||
<div class="pdf-fallback">
|
||||
<p>PDF wird geladen...</p>
|
||||
<a href="/api/timesheet/pdf/<%= ts.id %>?inline=true" target="_blank" class="btn btn-primary">
|
||||
PDF in neuem Tab öffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
</div>
|
||||
<% }); %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function loadStatsForDiv(statsDiv) {
|
||||
const userId = statsDiv.dataset.userId;
|
||||
const weekStart = statsDiv.dataset.weekStart;
|
||||
const weekEnd = statsDiv.dataset.weekEnd;
|
||||
|
||||
return fetch(`/api/verwaltung/user/${userId}/stats?week_start=${weekStart}&week_end=${weekEnd}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
const loadingDiv = statsDiv.querySelector('.stats-loading');
|
||||
if (loadingDiv) {
|
||||
loadingDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
// vorherige Stats entfernen (wenn reloaded)
|
||||
statsDiv.querySelectorAll('.stats-inline').forEach(n => n.remove());
|
||||
|
||||
// Statistiken anzeigen
|
||||
let statsHTML = '';
|
||||
if (data.overtimeHours !== undefined) {
|
||||
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
|
||||
<strong>Überstunden:</strong> <span>${data.overtimeHours.toFixed(2)} h</span>
|
||||
${data.overtimeTaken > 0 ? `<span style="color: #666;">(davon genommen: ${data.overtimeTaken.toFixed(2)} h)</span>` : ''}
|
||||
${data.remainingOvertime !== data.overtimeHours ? `<span style="color: #28a745;">(verbleibend: ${data.remainingOvertime.toFixed(2)} h)</span>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
if (data.overtimeOffsetHours !== undefined && data.overtimeOffsetHours !== 0) {
|
||||
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
|
||||
<strong>Offset:</strong> <span>${Number(data.overtimeOffsetHours).toFixed(2)} h</span>
|
||||
${data.remainingOvertimeWithOffset !== undefined ? `<span style="color: #28a745;">(verbleibend inkl. Offset: ${Number(data.remainingOvertimeWithOffset).toFixed(2)} h)</span>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
if (data.vacationDays !== undefined) {
|
||||
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
|
||||
<strong>Urlaub genommen:</strong> <span>${data.vacationDays.toFixed(1)} Tag${data.vacationDays !== 1 ? 'e' : ''}</span>
|
||||
${data.remainingVacation !== undefined ? `<span style="color: #28a745;">(verbleibend: ${data.remainingVacation.toFixed(1)} Tage)</span>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
if (data.sickDays !== undefined && data.sickDays > 0) {
|
||||
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
|
||||
<strong>Krankheitstage:</strong> <span style="color: #e74c3c;">${data.sickDays} Tag${data.sickDays !== 1 ? 'e' : ''}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
if (statsHTML) {
|
||||
statsDiv.insertAdjacentHTML('beforeend', statsHTML);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Fehler beim Laden der Statistiken:', error);
|
||||
const loadingDiv = statsDiv.querySelector('.stats-loading');
|
||||
if (loadingDiv) {
|
||||
loadingDiv.textContent = 'Fehler beim Laden';
|
||||
loadingDiv.style.color = 'red';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Statistiken für alle Wochen initial laden
|
||||
document.querySelectorAll('.group-stats').forEach(statsDiv => loadStatsForDiv(statsDiv));
|
||||
|
||||
// Krankheitstage für alle Mitarbeiter laden
|
||||
async function loadSickDays() {
|
||||
const sickDaysElements = document.querySelectorAll('.sick-days-count');
|
||||
const userIds = Array.from(sickDaysElements).map(el => el.dataset.userId);
|
||||
const uniqueUserIds = [...new Set(userIds)];
|
||||
|
||||
for (const userId of uniqueUserIds) {
|
||||
try {
|
||||
const response = await fetch(`/api/verwaltung/user/${userId}/sick-days`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Fehler beim Laden der Krankheitstage');
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Alle Elemente für diesen User aktualisieren
|
||||
document.querySelectorAll(`.sick-days-count[data-user-id="${userId}"]`).forEach(el => {
|
||||
el.textContent = data.sickDays || 0;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Krankheitstage für User', userId, ':', error);
|
||||
document.querySelectorAll(`.sick-days-count[data-user-id="${userId}"]`).forEach(el => {
|
||||
el.textContent = '-';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Krankheitstage beim Laden der Seite abrufen
|
||||
loadSickDays();
|
||||
|
||||
// Überstunden-Offset speichern
|
||||
document.querySelectorAll('.save-overtime-offset-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const userId = this.dataset.userId;
|
||||
const input = document.querySelector(`.overtime-offset-input[data-user-id="${userId}"]`);
|
||||
if (!input) return;
|
||||
|
||||
const originalText = this.textContent;
|
||||
this.disabled = true;
|
||||
this.textContent = '...';
|
||||
|
||||
// leere Eingabe => 0 (Backend macht das auch, aber UI soll sauber sein)
|
||||
const raw = (input.value || '').trim();
|
||||
const value = raw === '' ? '' : Number(raw);
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/verwaltung/user/${userId}/overtime-offset`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ overtime_offset_hours: value })
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
alert(data.error || 'Fehler beim Speichern des Offsets');
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalisiere Input auf Zahl (Backend gibt number zurück)
|
||||
input.value = (data.overtime_offset_hours !== undefined && data.overtime_offset_hours !== null)
|
||||
? Number(data.overtime_offset_hours)
|
||||
: 0;
|
||||
|
||||
// Stats für diesen User neu laden
|
||||
const statDivs = document.querySelectorAll(`.group-stats[data-user-id="${userId}"]`);
|
||||
statDivs.forEach(div => {
|
||||
// loading indicator optional wieder anzeigen
|
||||
const loading = div.querySelector('.stats-loading');
|
||||
if (loading) {
|
||||
loading.style.display = 'inline-block';
|
||||
loading.style.color = '#666';
|
||||
loading.textContent = 'Lade Statistiken...';
|
||||
}
|
||||
loadStatsForDiv(div);
|
||||
});
|
||||
|
||||
this.textContent = '✓';
|
||||
setTimeout(() => {
|
||||
this.textContent = originalText;
|
||||
this.disabled = false;
|
||||
}, 900);
|
||||
} catch (e) {
|
||||
console.error('Fehler beim Speichern des Offsets:', e);
|
||||
alert('Fehler beim Speichern des Offsets');
|
||||
} finally {
|
||||
if (this.textContent === '...') {
|
||||
this.textContent = originalText;
|
||||
this.disabled = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Mitarbeiter-Gruppen auf-/zuklappen (zeigt/versteckt Wochen)
|
||||
document.querySelectorAll('.toggle-employee-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const employeeIndex = this.dataset.employeeIndex;
|
||||
const weeksContainer = document.querySelector(`.weeks-container[data-employee-index="${employeeIndex}"]`);
|
||||
const toggleIcon = this.querySelector('.toggle-icon');
|
||||
|
||||
if (weeksContainer) {
|
||||
if (weeksContainer.style.display === 'none' || !weeksContainer.style.display) {
|
||||
weeksContainer.style.display = 'block';
|
||||
toggleIcon.textContent = '▲';
|
||||
this.classList.add('active');
|
||||
} else {
|
||||
weeksContainer.style.display = 'none';
|
||||
toggleIcon.textContent = '▼';
|
||||
this.classList.remove('active');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Versionen-Gruppen auf-/zuklappen (innerhalb einer Kalenderwoche)
|
||||
document.querySelectorAll('.toggle-versions-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const employeeIndex = this.dataset.employeeIndex;
|
||||
const weekIndex = this.dataset.weekIndex;
|
||||
const versionsContainer = document.querySelector(`.versions-container[data-employee-index="${employeeIndex}"][data-week-index="${weekIndex}"]`);
|
||||
const toggleIcon = this.querySelector('.toggle-icon');
|
||||
|
||||
if (versionsContainer) {
|
||||
if (versionsContainer.style.display === 'none' || !versionsContainer.style.display) {
|
||||
versionsContainer.style.display = 'block';
|
||||
toggleIcon.textContent = '▲';
|
||||
this.classList.add('active');
|
||||
} else {
|
||||
versionsContainer.style.display = 'none';
|
||||
toggleIcon.textContent = '▼';
|
||||
this.classList.remove('active');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// PDF-Download Marker aktualisieren
|
||||
document.querySelectorAll('.pdf-download-link').forEach(link => {
|
||||
link.addEventListener('click', function() {
|
||||
const timesheetId = this.dataset.timesheetId;
|
||||
const currentUser = '<%= user.firstname %> <%= user.lastname %>';
|
||||
|
||||
// Nach kurzer Verzögerung Marker aktualisieren (wenn Download erfolgreich war)
|
||||
// Lade aktualisierte Daten vom Server
|
||||
setTimeout(() => {
|
||||
fetch(`/api/timesheet/download-info/${timesheetId}`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.downloaded) {
|
||||
const marker = document.querySelector(`.timesheet-row[data-timesheet-id="${timesheetId}"] .pdf-not-downloaded-marker`);
|
||||
if (marker) {
|
||||
// Verwende Server-Daten, oder Fallback auf aktuellen User
|
||||
const downloadedBy = (data.downloaded_by_firstname && data.downloaded_by_lastname)
|
||||
? `${data.downloaded_by_firstname} ${data.downloaded_by_lastname}`
|
||||
: currentUser || 'Unbekannt';
|
||||
const downloadedAt = data.downloaded_at
|
||||
? new Date(data.downloaded_at).toLocaleString('de-DE')
|
||||
: 'Gerade';
|
||||
marker.outerHTML = `<span class="pdf-downloaded-marker" title="PDF wurde am ${downloadedAt} von ${downloadedBy} heruntergeladen">✓ Heruntergeladen von ${downloadedBy}</span>`;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Fehler beim Laden der Download-Info:', err);
|
||||
// Fallback: Verwende aktuellen User
|
||||
const marker = document.querySelector(`.timesheet-row[data-timesheet-id="${timesheetId}"] .pdf-not-downloaded-marker`);
|
||||
if (marker && currentUser) {
|
||||
marker.outerHTML = `<span class="pdf-downloaded-marker" title="PDF wurde von ${currentUser} heruntergeladen">✓ Heruntergeladen von ${currentUser}</span>`;
|
||||
}
|
||||
});
|
||||
}, 1500);
|
||||
});
|
||||
});
|
||||
|
||||
// PDF-Vorschau ein-/ausblenden
|
||||
document.querySelectorAll('.toggle-pdf-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const timesheetId = this.dataset.timesheetId;
|
||||
const previewRow = document.querySelector(`.pdf-preview-row[data-timesheet-id="${timesheetId}"]`);
|
||||
const arrowIcon = this.querySelector('.arrow-icon');
|
||||
const iframe = previewRow ? previewRow.querySelector('.pdf-iframe') : null;
|
||||
|
||||
if (previewRow && (previewRow.style.display === 'none' || !previewRow.style.display)) {
|
||||
// Alle anderen PDF-Vorschauen schließen
|
||||
document.querySelectorAll('.pdf-preview-row').forEach(row => {
|
||||
if (row.dataset.timesheetId !== timesheetId) {
|
||||
row.style.display = 'none';
|
||||
const otherBtn = document.querySelector(`.toggle-pdf-btn[data-timesheet-id="${row.dataset.timesheetId}"]`);
|
||||
if (otherBtn) {
|
||||
otherBtn.querySelector('.arrow-icon').textContent = '▶';
|
||||
otherBtn.classList.remove('active');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Diese PDF-Vorschau öffnen
|
||||
previewRow.style.display = 'table-row';
|
||||
arrowIcon.textContent = '▼';
|
||||
this.classList.add('active');
|
||||
|
||||
// Setze iframe src wenn noch nicht gesetzt (für besseres Laden)
|
||||
if (iframe) {
|
||||
const currentSrc = iframe.src || iframe.getAttribute('src');
|
||||
if (!currentSrc || !currentSrc.includes('inline=true')) {
|
||||
iframe.src = `/api/timesheet/pdf/${timesheetId}?inline=true`;
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll zur PDF-Vorschau
|
||||
setTimeout(() => {
|
||||
previewRow.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, 100);
|
||||
} else {
|
||||
// PDF-Vorschau schließen
|
||||
if (previewRow) {
|
||||
previewRow.style.display = 'none';
|
||||
}
|
||||
arrowIcon.textContent = '▶';
|
||||
this.classList.remove('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Schließen-Button
|
||||
document.querySelectorAll('.close-pdf-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const timesheetId = this.dataset.timesheetId;
|
||||
const previewRow = document.querySelector(`.pdf-preview-row[data-timesheet-id="${timesheetId}"]`);
|
||||
const toggleBtn = document.querySelector(`.toggle-pdf-btn[data-timesheet-id="${timesheetId}"]`);
|
||||
|
||||
previewRow.style.display = 'none';
|
||||
if (toggleBtn) {
|
||||
toggleBtn.querySelector('.arrow-icon').textContent = '▶';
|
||||
toggleBtn.classList.remove('active');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Kommentar speichern
|
||||
document.querySelectorAll('.save-comment-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const timesheetId = this.dataset.timesheetId;
|
||||
const commentInput = document.querySelector(`.admin-comment-input[data-timesheet-id="${timesheetId}"]`);
|
||||
|
||||
if (!commentInput) {
|
||||
console.error('Kommentar-Input nicht gefunden');
|
||||
return;
|
||||
}
|
||||
|
||||
const comment = commentInput.value.trim();
|
||||
const originalButtonText = this.innerHTML;
|
||||
|
||||
// Button deaktivieren während des Speicherns
|
||||
this.disabled = true;
|
||||
this.innerHTML = '...';
|
||||
this.title = 'Speichere...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/verwaltung/timesheet/${timesheetId}/comment`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ comment: comment })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Erfolgs-Feedback
|
||||
this.innerHTML = '✓';
|
||||
this.title = 'Gespeichert!';
|
||||
this.style.backgroundColor = '#28a745';
|
||||
|
||||
// Nach 2 Sekunden zurücksetzen
|
||||
setTimeout(() => {
|
||||
this.innerHTML = originalButtonText;
|
||||
this.title = 'Kommentar speichern';
|
||||
this.style.backgroundColor = '';
|
||||
}, 2000);
|
||||
} else {
|
||||
alert('Fehler beim Speichern: ' + (result.error || 'Unbekannter Fehler'));
|
||||
this.innerHTML = originalButtonText;
|
||||
this.title = 'Kommentar speichern';
|
||||
this.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern des Kommentars:', error);
|
||||
alert('Fehler beim Speichern des Kommentars');
|
||||
this.innerHTML = originalButtonText;
|
||||
this.title = 'Kommentar speichern';
|
||||
this.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Kommentar auch per Enter-Taste speichern (Strg+Enter)
|
||||
document.querySelectorAll('.admin-comment-input').forEach(input => {
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
const timesheetId = this.dataset.timesheetId;
|
||||
const saveBtn = document.querySelector(`.save-comment-btn[data-timesheet-id="${timesheetId}"]`);
|
||||
if (saveBtn) {
|
||||
saveBtn.click();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Massendownload für Kalenderwoche
|
||||
const bulkDownloadBtn = document.getElementById('bulkDownloadBtn');
|
||||
const bulkDownloadYear = document.getElementById('bulkDownloadYear');
|
||||
const bulkDownloadWeek = document.getElementById('bulkDownloadWeek');
|
||||
const bulkDownloadStatus = document.getElementById('bulkDownloadStatus');
|
||||
|
||||
if (bulkDownloadBtn) {
|
||||
bulkDownloadBtn.addEventListener('click', async function() {
|
||||
const year = parseInt(bulkDownloadYear.value);
|
||||
const week = parseInt(bulkDownloadWeek.value);
|
||||
|
||||
// Validierung
|
||||
if (!year || year < 2000 || year > 2100) {
|
||||
bulkDownloadStatus.textContent = 'Bitte geben Sie ein gültiges Jahr ein (2000-2100)';
|
||||
bulkDownloadStatus.style.display = 'block';
|
||||
bulkDownloadStatus.style.color = '#dc3545';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!week || week < 1 || week > 53) {
|
||||
bulkDownloadStatus.textContent = 'Bitte geben Sie eine gültige Kalenderwoche ein (1-53)';
|
||||
bulkDownloadStatus.style.display = 'block';
|
||||
bulkDownloadStatus.style.color = '#dc3545';
|
||||
return;
|
||||
}
|
||||
|
||||
// Button deaktivieren und Status anzeigen
|
||||
bulkDownloadBtn.disabled = true;
|
||||
bulkDownloadBtn.textContent = 'Lädt...';
|
||||
bulkDownloadStatus.textContent = 'PDFs werden generiert und ZIP erstellt...';
|
||||
bulkDownloadStatus.style.display = 'block';
|
||||
bulkDownloadStatus.style.color = '#666';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/verwaltung/bulk-download/${year}/${week}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ error: 'Unbekannter Fehler' }));
|
||||
throw new Error(errorData.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// ZIP-Download starten
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `Stundenzettel_KW${String(week).padStart(2, '0')}_${year}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
// Erfolgsmeldung
|
||||
bulkDownloadStatus.textContent = `✓ ZIP erfolgreich heruntergeladen (KW ${week}/${year})`;
|
||||
bulkDownloadStatus.style.color = '#28a745';
|
||||
|
||||
// Seite nach kurzer Verzögerung neu laden, um Download-Marker zu aktualisieren
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Massendownload:', error);
|
||||
bulkDownloadStatus.textContent = `Fehler: ${error.message || 'Unbekannter Fehler'}`;
|
||||
bulkDownloadStatus.style.color = '#dc3545';
|
||||
bulkDownloadBtn.disabled = false;
|
||||
bulkDownloadBtn.textContent = 'Alle PDFs für KW herunterladen';
|
||||
}
|
||||
});
|
||||
|
||||
// Enter-Taste in Eingabefeldern
|
||||
[bulkDownloadYear, bulkDownloadWeek].forEach(input => {
|
||||
if (input) {
|
||||
input.addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
bulkDownloadBtn.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
// Rollenwechsel-Handler
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const roleSwitcher = document.getElementById('roleSwitcher');
|
||||
if (roleSwitcher) {
|
||||
roleSwitcher.addEventListener('change', async function() {
|
||||
const newRole = this.value;
|
||||
try {
|
||||
const response = await fetch('/api/user/switch-role', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ role: newRole })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
// Redirect basierend auf neuer Rolle
|
||||
if (newRole === 'admin') {
|
||||
window.location.href = '/admin';
|
||||
} else if (newRole === 'verwaltung') {
|
||||
window.location.href = '/verwaltung';
|
||||
} else {
|
||||
window.location.href = '/dashboard';
|
||||
}
|
||||
} else {
|
||||
alert('Fehler beim Wechseln der Rolle: ' + (result.error || 'Unbekannter Fehler'));
|
||||
// Wert zurücksetzen
|
||||
this.value = '<%= user.currentRole || "verwaltung" %>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Rollenwechsel:', error);
|
||||
alert('Fehler beim Wechseln der Rolle');
|
||||
// Wert zurücksetzen
|
||||
this.value = '<%= user.currentRole || "verwaltung" %>';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user