FirstCommit
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
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:3000`
|
||||
|
||||
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 = 3000; // Ä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
|
||||
250
database.js
Normal file
250
database.js
Normal file
@@ -0,0 +1,250 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const path = require('path');
|
||||
|
||||
const dbPath = 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: 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
|
||||
});
|
||||
|
||||
// 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
|
||||
)`);
|
||||
|
||||
// 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.
|
||||
279
ldap-service.js
Normal file
279
ldap-service.js
Normal file
@@ -0,0 +1,279 @@
|
||||
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
|
||||
*/
|
||||
static getAttributeValue(entry, attributeName) {
|
||||
const attr = entry.attributes.find(a => a.type === attributeName);
|
||||
if (!attr) {
|
||||
return null;
|
||||
}
|
||||
return Array.isArray(attr.values) ? attr.values[0] : attr.values;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
const username = ldapUser.username.trim();
|
||||
const firstname = ldapUser.firstname.trim();
|
||||
const lastname = ldapUser.lastname.trim();
|
||||
|
||||
// Prüfe ob Benutzer bereits existiert
|
||||
db.get('SELECT id, role FROM users WHERE username = ?', [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
|
||||
db.run(
|
||||
'UPDATE users SET firstname = ?, lastname = ? WHERE username = ?',
|
||||
[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);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
6021
package-lock.json
generated
Normal file
6021
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
1002
public/css/style.css
Normal file
1002
public/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
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';
|
||||
}
|
||||
1107
public/js/dashboard.js
Normal file
1107
public/js/dashboard.js
Normal file
File diff suppressed because it is too large
Load Diff
207
reset-db.js
Normal file
207
reset-db.js
Normal file
@@ -0,0 +1,207 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const dbPath = path.join(__dirname, 'stundenerfassung.db');
|
||||
|
||||
console.log('🔄 Setze Datenbank zurück...\n');
|
||||
|
||||
// Datenbank schließen falls offen
|
||||
let db = null;
|
||||
|
||||
try {
|
||||
// Prüfe ob Datenbank existiert
|
||||
if (fs.existsSync(dbPath)) {
|
||||
console.log('📁 Datenbankdatei gefunden, lösche sie...');
|
||||
fs.unlinkSync(dbPath);
|
||||
console.log('✅ Datenbankdatei gelöscht\n');
|
||||
} else {
|
||||
console.log('ℹ️ Datenbankdatei existiert nicht, erstelle neue...\n');
|
||||
}
|
||||
|
||||
// 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,
|
||||
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,
|
||||
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');
|
||||
});
|
||||
|
||||
// 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');
|
||||
});
|
||||
|
||||
// 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
|
||||
const adminPassword = bcrypt.hashSync('admin123', 10);
|
||||
db.run(`INSERT INTO users (id, username, password, firstname, lastname, role)
|
||||
VALUES (1, 'admin', ?, 'System', 'Administrator', 'admin')`,
|
||||
[adminPassword], (err) => {
|
||||
if (err) console.error('Fehler beim Erstellen des Admin-Users:', err);
|
||||
else console.log('✅ Admin-User erstellt (admin / admin123)');
|
||||
});
|
||||
|
||||
// Standard Verwaltungs-Benutzer
|
||||
const verwaltungPassword = bcrypt.hashSync('verwaltung123', 10);
|
||||
db.run(`INSERT INTO users (id, username, password, firstname, lastname, role)
|
||||
VALUES (2, 'verwaltung', ?, 'Verwaltung', 'User', 'verwaltung')`,
|
||||
[verwaltungPassword], (err) => {
|
||||
if (err) console.error('Fehler beim Erstellen des Verwaltungs-Users:', err);
|
||||
else console.log('✅ Verwaltungs-User erstellt (verwaltung / verwaltung123)');
|
||||
});
|
||||
|
||||
// Test-Mitarbeiter (optional)
|
||||
const mitarbeiterPassword = bcrypt.hashSync('test123', 10);
|
||||
db.run(`INSERT INTO users (id, username, password, firstname, lastname, role, wochenstunden, urlaubstage)
|
||||
VALUES (3, 'test', ?, 'Test', 'Mitarbeiter', 'mitarbeiter', 40, 25)`,
|
||||
[mitarbeiterPassword], (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);
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Fehler beim Zurücksetzen der Datenbank:', error);
|
||||
if (db) {
|
||||
db.close();
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
348
views/admin.ejs
Normal file
348
views/admin.ejs
Normal file
@@ -0,0 +1,348 @@
|
||||
<!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="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="navbar">
|
||||
<div class="container">
|
||||
<h1>Stundenerfassung - Admin</h1>
|
||||
<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="container">
|
||||
<div class="admin-panel">
|
||||
<h2>Benutzerverwaltung</h2>
|
||||
|
||||
<div class="add-user-form">
|
||||
<h3>Neuen Benutzer anlegen</h3>
|
||||
<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 class="user-list">
|
||||
<h3>Benutzer-Liste</h3>
|
||||
<table>
|
||||
<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 class="ldap-sync-section" style="margin-top: 40px;">
|
||||
<h2>LDAP-Synchronisation</h2>
|
||||
|
||||
<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>
|
||||
|
||||
<script src="/js/admin.js"></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 || "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>
|
||||
167
views/dashboard.ejs
Normal file
167
views/dashboard.ejs
Normal file
@@ -0,0 +1,167 @@
|
||||
<!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="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="navbar">
|
||||
<div class="container">
|
||||
<h1>Stundenerfassung</h1>
|
||||
<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>
|
||||
|
||||
<div class="actions">
|
||||
<button id="submitWeek" class="btn btn-success" onclick="window.submitWeekHandler(event)">Woche abschicken</button>
|
||||
<p class="help-text">Stunden werden automatisch gespeichert. Am Ende der Woche können Sie die Stunden abschicken.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auswertung: Überstunden und Urlaubstage -->
|
||||
<div class="user-stats-panel" id="userStatsPanel">
|
||||
<h3>Ihre Auswertung</h3>
|
||||
<div class="stat-card stat-overtime">
|
||||
<div class="stat-label">Aktuelle Überstunden</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">Verbleibende Urlaubstage</div>
|
||||
<div class="stat-value" id="remainingVacation">-</div>
|
||||
<div class="stat-unit">von <span id="totalVacation">-</span> Tagen</div>
|
||||
</div>
|
||||
|
||||
<!-- API-URLs für Zeiterfassung -->
|
||||
<div class="stat-card stat-api-urls" style="margin-top: 20px; padding: 15px; box-sizing: border-box; width: 100%;">
|
||||
<h4 style="margin-bottom: 15px; font-size: 14px; color: #555;">Schnelle Zeiterfassung</h4>
|
||||
<div style="margin-bottom: 12px;">
|
||||
<label style="display: block; font-size: 12px; color: #666; margin-bottom: 5px;">Kommen (Check-in):</label>
|
||||
<div style="display: flex; gap: 8px; align-items: center; width: 100%;">
|
||||
<input type="text" id="checkinUrl" readonly value=""
|
||||
style="flex: 1; min-width: 0; padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 12px; background-color: #f9f9f9;">
|
||||
<button onclick="copyToClipboard('checkinUrl')" class="btn btn-secondary btn-sm" style="padding: 6px 12px; font-size: 12px; flex-shrink: 0;">Kopieren</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display: block; font-size: 12px; color: #666; margin-bottom: 5px;">Gehen (Check-out):</label>
|
||||
<div style="display: flex; gap: 8px; align-items: center; width: 100%;">
|
||||
<input type="text" id="checkoutUrl" readonly value=""
|
||||
style="flex: 1; min-width: 0; padding: 6px 10px; border: 1px solid #ddd; border-radius: 4px; font-size: 12px; background-color: #f9f9f9;">
|
||||
<button onclick="copyToClipboard('checkoutUrl')" class="btn btn-secondary btn-sm" style="padding: 6px 12px; font-size: 12px; flex-shrink: 0;">Kopieren</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top: 10px; font-size: 11px; color: #888; line-height: 1.4;">
|
||||
Diese URLs können Sie in einer App eintragen oder direkt im Browser aufrufen, um Ihre Arbeitszeiten zu erfassen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/dashboard.js"></script>
|
||||
<script>
|
||||
// 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
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const userId = '<%= user.id %>';
|
||||
const baseUrl = window.location.origin;
|
||||
const checkinInput = document.getElementById('checkinUrl');
|
||||
const checkoutInput = document.getElementById('checkoutUrl');
|
||||
if (checkinInput) checkinInput.value = `${baseUrl}/api/checkin/${userId}`;
|
||||
if (checkoutInput) checkoutInput.value = `${baseUrl}/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>
|
||||
</body>
|
||||
</html>
|
||||
35
views/login.ejs
Normal file
35
views/login.ejs
Normal file
@@ -0,0 +1,35 @@
|
||||
<!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="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>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
515
views/verwaltung.ejs
Normal file
515
views/verwaltung.ejs
Normal file
@@ -0,0 +1,515 @@
|
||||
<!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="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="navbar">
|
||||
<div class="container">
|
||||
<h1>Stundenerfassung - Verwaltung</h1>
|
||||
<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>
|
||||
|
||||
<% 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-block; margin-right: 20px;">
|
||||
<strong>Kalenderwochen:</strong> <span><%= employee.weeks.length %></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">
|
||||
<strong>Kalenderwoche:</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>
|
||||
// Statistiken für alle Wochen laden
|
||||
document.querySelectorAll('.group-stats').forEach(statsDiv => {
|
||||
const userId = statsDiv.dataset.userId;
|
||||
const weekStart = statsDiv.dataset.weekStart;
|
||||
const weekEnd = statsDiv.dataset.weekEnd;
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
// Statistiken anzeigen
|
||||
let statsHTML = '';
|
||||
if (data.overtimeHours !== undefined) {
|
||||
statsHTML += `<div 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.vacationDays !== undefined) {
|
||||
statsHTML += `<div 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 (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';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</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