V1.1 Verschiedene Anpassungen

This commit is contained in:
2026-02-03 21:32:20 +01:00
parent 952c353118
commit 4be9a365b3
18 changed files with 1068 additions and 114 deletions

469
DSGVO-Dokumentation.md Normal file
View File

@@ -0,0 +1,469 @@
# DSGVO-Dokumentation
## Stundenerfassungssystem
---
## 1. Verantwortlicher und Kontaktdaten
**Verantwortlicher für die Datenverarbeitung:**
SDS Systemtechnik
Rudolf-Diesel-Str. 7
75365 Calw
info@sds-systemtechnik.de
+497051931540
**Datenschutzbeauftragter (falls vorhanden):**
[Name]
[E-Mail]
[Telefon]
**Kontakt für Datenschutzanfragen:**
Carsten Graf
Mechatronik-Ingenieur / IT-Infrastruktur
SDS Systemtechnik
carsten.graf@sds-systemtechnik.de
+4970519315416
---
## 2. Zweck der Datenverarbeitung
Das Stundenerfassungssystem dient der Erfassung, Verwaltung und Dokumentation von Arbeitszeiten der Mitarbeiter. Die Verarbeitung personenbezogener Daten erfolgt zu folgenden Zwecken:
- **Zeiterfassung**: Erfassung von Arbeitsbeginn, Arbeitsende, Pausen und Gesamtarbeitszeit
- **Arbeitszeitverwaltung**: Berechnung von Überstunden, Urlaubstagen und Fehlzeiten
- **Lohnabrechnung**: Bereitstellung von Daten für die Lohn- und Gehaltsabrechnung
- **Projektabrechnung**: Zuordnung von Arbeitszeiten zu Projekten und Projektnummern
- **Compliance**: Einhaltung arbeitsrechtlicher Vorschriften (z.B. Arbeitszeitgesetz)
- **Authentifizierung**: Sicherstellung des Zugriffs nur für berechtigte Personen
- **Automatische Zeiterfassung**: IP-basierte automatische Erfassung von Arbeitsbeginn und -ende (optional)
---
## 3. Rechtsgrundlage
Die Verarbeitung personenbezogener Daten erfolgt auf Grundlage der folgenden Rechtsgrundlagen nach der DSGVO:
- **Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung)**: Erfüllung des Arbeitsvertrags, insbesondere Erfassung der Arbeitszeit zur Lohnabrechnung
- **Art. 6 Abs. 1 lit. c DSGVO (Rechtliche Verpflichtung)**: Erfüllung gesetzlicher Verpflichtungen (z.B. Arbeitszeitgesetz, Aufbewahrungspflichten nach HGB/GoBD)
- **Art. 6 Abs. 1 lit. f DSGVO (Berechtigtes Interesse)**: Betriebswirtschaftliche Interessen (Projektabrechnung, Arbeitszeitoptimierung)
**Besondere Kategorien personenbezogener Daten:**
- **Krankheitsdaten** (sick_status): Art. 9 Abs. 2 lit. b DSGVO (Arbeitsrechtliche Verpflichtungen)
---
## 4. Erfasste Datenkategorien
### 4.1 Benutzerdaten (Tabelle: `users`)
| Datenfeld | Beschreibung | Kategorie |
|-----------|--------------|-----------|
| `id` | Eindeutige Benutzer-ID (automatisch generiert) | Identifikationsdaten |
| `username` | Benutzername für Login | Authentifizierung |
| `password` | Passwort (gehasht mit bcrypt) | Authentifizierung |
| `firstname` | Vorname | Identifikationsdaten |
| `lastname` | Nachname | Identifikationsdaten |
| `personalnummer` | Personalnummer des Mitarbeiters | Identifikationsdaten |
| `role` | Benutzerrolle(n) als JSON-Array (z.B. "mitarbeiter", "verwaltung", "admin") | Berechtigungsdaten |
| `wochenstunden` | Soll-Arbeitsstunden pro Woche | Arbeitszeitdaten |
| `urlaubstage` | Anzahl der Urlaubstage pro Jahr | Arbeitszeitdaten |
| `overtime_offset_hours` | Manuelle Korrektur der Überstunden durch Verwaltung | Arbeitszeitdaten |
| `vacation_offset_days` | Manuelle Korrektur der Urlaubstage durch Verwaltung | Arbeitszeitdaten |
| `ping_ip` | IP-Adresse für automatische Zeiterfassung (optional) | Technische Daten |
| `last_week_start` | Letzte bearbeitete Woche (für UI-Navigation) | Systemdaten |
| `created_at` | Zeitstempel der Benutzererstellung | Metadaten |
### 4.2 Zeiterfassungsdaten (Tabelle: `timesheet_entries`)
| Datenfeld | Beschreibung | Kategorie |
|-----------|--------------|-----------|
| `id` | Eindeutige Eintrags-ID | Identifikationsdaten |
| `user_id` | Verknüpfung zum Benutzer | Identifikationsdaten |
| `date` | Datum des Arbeitstages (Format: YYYY-MM-DD) | Zeitstempel |
| `start_time` | Arbeitsbeginn (Format: HH:MM) | Zeitstempel |
| `end_time` | Arbeitsende (Format: HH:MM) | Zeitstempel |
| `pause_start_time` | Pausenbeginn (Format: HH:MM, optional) | Zeitstempel |
| `pause_end_time` | Pausenende (Format: HH:MM, optional) | Zeitstempel |
| `break_minutes` | Pausenzeit in Minuten | Arbeitszeitdaten |
| `total_hours` | Berechnete Gesamtarbeitszeit in Stunden | Arbeitszeitdaten |
| `activity1_desc` bis `activity5_desc` | Beschreibung der Tätigkeit (bis zu 5 pro Tag) | Tätigkeitsdaten |
| `activity1_hours` bis `activity5_hours` | Stunden für die jeweilige Tätigkeit | Tätigkeitsdaten |
| `activity1_project_number` bis `activity5_project_number` | Projektnummer für die jeweilige Tätigkeit | Projektbezogene Daten |
| `notes` | Freitext-Notizen zum Arbeitstag | Tätigkeitsdaten |
| `overtime_taken_hours` | Verbrauchte Überstunden an diesem Tag | Arbeitszeitdaten |
| `vacation_type` | Urlaubstyp ("full" = ganzer Tag, "half" = halber Tag, null = kein Urlaub) | Arbeitszeitdaten |
| `sick_status` | Krankheitsstatus (0 = nicht krank, 1 = krank) | Gesundheitsdaten |
| `weekend_travel` | Wochenend-Reise (0 = nein, 1 = ja) | Arbeitszeitdaten |
| `applied_weekend_percentage` | Angewendeter Wochenend-Prozentsatz für Berechnung | Arbeitszeitdaten |
| `status` | Status des Eintrags ("offen", "eingereicht") | Metadaten |
| `created_at` | Zeitstempel der Erstellung | Metadaten |
| `updated_at` | Zeitstempel der letzten Änderung | Metadaten |
### 4.3 Wochenstundenzettel (Tabelle: `weekly_timesheets`)
| Datenfeld | Beschreibung | Kategorie |
|-----------|--------------|-----------|
| `id` | Eindeutige Stundenzettel-ID | Identifikationsdaten |
| `user_id` | Verknüpfung zum Benutzer | Identifikationsdaten |
| `week_start` | Startdatum der Woche (Format: YYYY-MM-DD) | Zeitstempel |
| `week_end` | Enddatum der Woche (Format: YYYY-MM-DD) | Zeitstempel |
| `version` | Versionsnummer des Stundenzettels (bei Änderungen) | Metadaten |
| `version_reason` | Grund für die neue Version (Freitext) | Metadaten |
| `status` | Status ("eingereicht", "bearbeitet") | Metadaten |
| `submitted_at` | Zeitstempel der Einreichung | Metadaten |
| `reviewed_by` | ID des prüfenden Verwaltungsmitarbeiters | Identifikationsdaten |
| `reviewed_at` | Zeitstempel der Prüfung | Metadaten |
| `pdf_downloaded_at` | Zeitstempel des PDF-Downloads | Metadaten |
| `pdf_downloaded_by` | ID des Benutzers, der das PDF heruntergeladen hat | Identifikationsdaten |
| `admin_comment` | Kommentar der Verwaltung zum Stundenzettel | Metadaten |
### 4.4 IP-basierte Zeiterfassung (Tabelle: `ping_status`)
| Datenfeld | Beschreibung | Kategorie |
|-----------|--------------|-----------|
| `user_id` | Verknüpfung zum Benutzer | Identifikationsdaten |
| `date` | Datum (Format: YYYY-MM-DD) | Zeitstempel |
| `last_successful_ping` | Zeitstempel des letzten erfolgreichen Pings | Technische Daten |
| `failed_ping_count` | Anzahl fehlgeschlagener Pings | Technische Daten |
| `start_time_set` | Flag, ob Start-Zeit bereits gesetzt wurde (0/1) | Technische Daten |
| `first_failed_ping_time` | Zeitstempel des ersten fehlgeschlagenen Pings | Technische Daten |
### 4.5 LDAP-Synchronisation (Tabelle: `ldap_sync_log`)
| Datenfeld | Beschreibung | Kategorie |
|-----------|--------------|-----------|
| `id` | Eindeutige Log-ID | Identifikationsdaten |
| `sync_type` | Typ der Synchronisation ("manual" oder "automatic") | Metadaten |
| `status` | Status ("success", "error") | Metadaten |
| `users_synced` | Anzahl synchronisierter Benutzer | Metadaten |
| `error_message` | Fehlermeldung bei Fehlern (optional) | Metadaten |
| `sync_started_at` | Zeitstempel des Synchronisationsbeginns | Metadaten |
| `sync_completed_at` | Zeitstempel des Synchronisationsendes | Metadaten |
**LDAP-Konfiguration (Tabelle: `ldap_config`):**
Diese Tabelle enthält keine personenbezogenen Daten, sondern nur technische Konfigurationsdaten für die LDAP-Verbindung.
**LDAP-Synchronisierte Daten:**
Bei aktivierter LDAP-Integration werden folgende Daten aus dem Active Directory synchronisiert:
- `username` (sAMAccountName)
- `firstname` (givenName)
- `lastname` (sn)
### 4.6 Session-Daten (Express-Session)
| Datenfeld | Beschreibung | Speicherdauer |
|-----------|--------------|---------------|
| `userId` | ID des angemeldeten Benutzers | 24 Stunden (Standard) oder 30 Tage (bei "Angemeldet bleiben") |
| `roles` | Array der Benutzerrollen | 24 Stunden (Standard) oder 30 Tage (bei "Angemeldet bleiben") |
| `currentRole` | Aktuell ausgewählte Rolle | 24 Stunden (Standard) oder 30 Tage (bei "Angemeldet bleiben") |
**Cookie-Informationen:**
- Cookie-Name: `connect.sid`
- Speicherort: Client-Browser
- Verschlüsselung: Session-Daten werden serverseitig gespeichert, Cookie enthält nur Session-ID
### 4.7 Systemdaten (keine personenbezogenen Daten)
**Tabelle: `public_holidays`**
- `date`: Feiertagsdatum
- `name`: Name des Feiertags
**Tabelle: `system_options`**
- `saturday_percentage`: Prozentsatz für Samstagsarbeit
- `sunday_percentage`: Prozentsatz für Sonntagsarbeit
- `checkin_root_url`: Basis-URL für Check-in/Check-out Links
---
## 5. Datenquellen
Die personenbezogenen Daten stammen aus folgenden Quellen:
1. **Direkte Eingabe durch Mitarbeiter**: Zeiterfassungsdaten, Tätigkeitsbeschreibungen, Projektnummern, Notizen
2. **Automatische Erfassung**: IP-basierte Zeiterfassung (optional, wenn `ping_ip` konfiguriert ist)
3. **LDAP/Active Directory**: Bei aktivierter LDAP-Integration werden Benutzername, Vorname und Nachname aus dem Active Directory synchronisiert
4. **Administrative Eingabe**: Personalnummer, Wochenstunden, Urlaubstage, Überstunden-Offset, Urlaubstage-Offset durch Verwaltung/Admin
5. **Automatische Berechnung**: Gesamtstunden, Überstunden werden automatisch aus den eingegebenen Daten berechnet
6. **Systemgeneriert**: IDs, Zeitstempel (created_at, updated_at), Session-Daten
---
## 6. Empfänger der Daten
Die personenbezogenen Daten werden folgenden Empfängern zugänglich gemacht:
### 6.1 Interne Empfänger
- **Mitarbeiter**: Zugriff auf eigene Zeiterfassungsdaten über das Dashboard
- **Verwaltungsmitarbeiter**: Zugriff auf alle eingereichten Stundenzettel zur Prüfung und PDF-Generierung
- **Administratoren**: Vollzugriff auf alle Daten zur Systemverwaltung und Benutzerverwaltung
- **IT-Administration**: Technischer Zugriff für Wartung und Support (nur bei Bedarf)
### 6.2 Externe Empfänger
- **Lohnbuchhaltung**: PDF-Stundenzettel werden für die Lohnabrechnung verwendet (manueller Export)
- **Steuerberater/Wirtschaftsprüfer**: Bei Prüfungen können Daten eingesehen werden (gemäß gesetzlichen Aufbewahrungspflichten)
### 6.3 Keine Weitergabe an Dritte
Die Daten werden nicht an Dritte (außerhalb der oben genannten Empfänger) weitergegeben. Es erfolgt keine Datenweitergabe an:
- Externe Dienstleister (außer bei expliziter Auftragsverarbeitung)
- Marketing-Unternehmen
- Soziale Netzwerke
- Andere Unternehmen
---
## 7. Speicherdauer und Löschung
### 7.1 Speicherdauer
Die personenbezogenen Daten werden für folgende Zeiträume gespeichert:
- **Zeiterfassungsdaten**: Mindestens 10 Jahre gemäß § 257 HGB (Aufbewahrungspflicht für Handelsbücher und Belege)
- **Lohnabrechnungsrelevante Daten**: 10 Jahre gemäß § 147 AO (Abgabenordnung)
- **Benutzerdaten**: Solange der Mitarbeiter im Unternehmen beschäftigt ist, danach gemäß Aufbewahrungspflichten
- **Session-Daten**: Werden automatisch nach Ablauf der Cookie-Laufzeit gelöscht (24 Stunden oder 30 Tage)
- **LDAP-Sync-Logs**: 1 Jahr (technische Logs)
### 7.2 Löschung
Die Löschung erfolgt:
- **Automatisch**: Session-Daten nach Ablauf der Cookie-Laufzeit
- **Manuell**: Bei Beendigung des Arbeitsverhältnisses, jedoch unter Beachtung der gesetzlichen Aufbewahrungspflichten
- **Auf Anfrage**: Gemäß den Betroffenenrechten (siehe Abschnitt 8), soweit keine gesetzlichen Aufbewahrungspflichten entgegenstehen
**Hinweis**: Auch nach Beendigung des Arbeitsverhältnisses können Daten aufgrund gesetzlicher Aufbewahrungspflichten (HGB, AO) nicht sofort gelöscht werden. Nach Ablauf der Aufbewahrungsfristen erfolgt die Löschung.
---
## 8. Betroffenenrechte
Jede betroffene Person hat folgende Rechte nach der DSGVO:
### 8.1 Auskunftsrecht (Art. 15 DSGVO)
Sie haben das Recht, Auskunft über die zu Ihrer Person gespeicherten Daten zu erhalten. Dies umfasst:
- Kategorien der verarbeiteten Daten
- Zweck der Verarbeitung
- Empfänger der Daten
- Geplante Speicherdauer
- Herkunft der Daten
### 8.2 Recht auf Berichtigung (Art. 16 DSGVO)
Sie haben das Recht, die Berichtigung unrichtiger oder die Vervollständigung unvollständiger Daten zu verlangen.
### 8.3 Recht auf Löschung (Art. 17 DSGVO)
Sie haben das Recht, die Löschung Ihrer Daten zu verlangen, sofern:
- Die Daten für den ursprünglichen Zweck nicht mehr benötigt werden
- Sie Ihre Einwilligung widerrufen haben
- Die Daten unrechtmäßig verarbeitet wurden
- Keine gesetzlichen Aufbewahrungspflichten entgegenstehen
**Wichtig**: Aufgrund gesetzlicher Aufbewahrungspflichten (HGB, AO) können Zeiterfassungsdaten für 10 Jahre nicht gelöscht werden.
### 8.4 Recht auf Einschränkung der Verarbeitung (Art. 18 DSGVO)
Sie haben das Recht, die Einschränkung der Verarbeitung zu verlangen, wenn:
- Die Richtigkeit der Daten bestritten wird
- Die Verarbeitung unrechtmäßig ist
- Die Daten nicht mehr benötigt werden, aber für Rechtsansprüche erforderlich sind
### 8.5 Recht auf Datenübertragbarkeit (Art. 20 DSGVO)
Sie haben das Recht, Ihre Daten in einem strukturierten, gängigen und maschinenlesbaren Format zu erhalten und an einen anderen Verantwortlichen zu übermitteln.
### 8.6 Widerspruchsrecht (Art. 21 DSGVO)
Sie haben das Recht, der Verarbeitung Ihrer Daten zu widersprechen, wenn die Verarbeitung auf Art. 6 Abs. 1 lit. f DSGVO (Berechtigtes Interesse) beruht.
**Hinweis**: Da die Zeiterfassung primär zur Erfüllung arbeitsvertraglicher und gesetzlicher Verpflichtungen erfolgt, ist ein Widerspruch in der Regel nicht möglich.
### 8.7 Widerruf der Einwilligung (Art. 7 Abs. 3 DSGVO)
Falls die Datenverarbeitung auf einer Einwilligung beruht, haben Sie das Recht, diese jederzeit zu widerrufen.
### 8.8 Beschwerderecht (Art. 77 DSGVO)
Sie haben das Recht, eine Beschwerde bei einer Aufsichtsbehörde einzulegen, insbesondere in dem Mitgliedstaat, in dem Sie sich aufhalten, arbeiten oder in dem der mutmaßliche Verstoß aufgetreten ist.
**Zuständige Aufsichtsbehörde:**
Der Landesbeauftragte für den Datenschutz und die Informationsfreiheit Baden-Württemberg
[Adresse]
[E-Mail]
[Telefon]
### 8.9 Ausübung der Rechte
Um Ihre Rechte auszuüben, wenden Sie sich bitte an:
Carsten Graf
Mechatronik-Ingenieur / IT-Infrastruktur
SDS Systemtechnik
[E-Mail]
[Telefon]
---
## 9. Technische und organisatorische Maßnahmen (TOM)
Zur Gewährleistung der Datensicherheit wurden folgende technische und organisatorische Maßnahmen implementiert:
### 9.1 Zugriffskontrolle
- **Authentifizierung**: Passwort-basierte Authentifizierung mit bcrypt-Hashing (10 Runden)
- **Autorisierung**: Rollenbasierte Zugriffskontrolle (Mitarbeiter, Verwaltung, Admin)
- **Session-Management**: Express-Session mit serverseitiger Speicherung, Session-IDs in verschlüsselten Cookies
- **Automatische Abmeldung**: Session-Cookies laufen nach 24 Stunden (Standard) oder 30 Tagen (bei "Angemeldet bleiben") ab
### 9.2 Verschlüsselung
- **Passwörter**: Bcrypt-Hashing mit Salt (10 Runden)
- **Datenübertragung**: Empfohlen wird die Verwendung von HTTPS (TLS/SSL) über einen Reverse Proxy (z.B. nginx)
- **Datenbank**: SQLite-Datenbankdatei sollte auf einem gesicherten Server gespeichert werden
### 9.3 Datensicherung
- **Backups**: Regelmäßige Backups der SQLite-Datenbankdatei (`stundenerfassung.db`)
- **Backup-Speicherung**: Backups sollten an einem separaten, gesicherten Ort gespeichert werden
### 9.4 Integrität
- **Datenbank-Constraints**: Foreign Keys und Constraints zur Gewährleistung der Datenintegrität
- **Validierung**: Eingabevalidierung auf Server- und Client-Seite
- **Versionierung**: Versionsverwaltung bei Stundenzetteln zur Nachvollziehbarkeit von Änderungen
### 9.5 Verfügbarkeit
- **Server-Monitoring**: Überwachung der Server-Verfügbarkeit
- **Fehlerbehandlung**: Umfassende Fehlerbehandlung und Logging
### 9.6 Trennbarkeit
- **Datenbank-Isolation**: Jeder Benutzer kann nur auf seine eigenen Daten zugreifen (außer Verwaltung/Admin)
- **Rollenbasierte Ansichten**: Unterschiedliche Ansichten je nach Benutzerrolle
### 9.7 Pseudonymisierung
- **Benutzer-IDs**: Interne Verwendung von numerischen IDs statt Namen für Datenbankverknüpfungen
- **Session-IDs**: Verwendung von zufälligen Session-IDs statt direkter Benutzeridentifikation in Cookies
---
## 10. Besondere Verarbeitungssituationen
### 10.1 IP-basierte automatische Zeiterfassung
**Funktionsweise:**
- Mitarbeiter können optional eine IP-Adresse (`ping_ip`) hinterlegen
- Das System pingt diese IP-Adresse jede Minute
- Bei erfolgreichem Ping wird automatisch die Start-Zeit gesetzt
- Bei 3 fehlgeschlagenen Pings wird automatisch die End-Zeit gesetzt
**Erfasste Daten:**
- IP-Adresse des Mitarbeiters (freiwillig hinterlegt)
- Ping-Status (erfolgreich/fehlgeschlagen)
- Zeitstempel der Pings
- Automatisch generierte Start- und End-Zeiten
**Rechtsgrundlage:** Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung) - Erfassung der Arbeitszeit
**Hinweis:** Die IP-Adresse wird nur für die automatische Zeiterfassung verwendet und nicht an Dritte weitergegeben.
### 10.2 LDAP/Active Directory Integration
**Funktionsweise:**
- Optional kann eine LDAP/Active Directory-Verbindung konfiguriert werden
- Benutzer werden automatisch oder manuell aus dem Active Directory synchronisiert
- Synchronisierte Daten: Benutzername, Vorname, Nachname
**Rechtsgrundlage:** Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung) - Bereitstellung des Systems für Mitarbeiter
**Hinweis:** Die LDAP-Konfiguration (URL, Bind-DN, Passwort) wird verschlüsselt in der Datenbank gespeichert. Die Synchronisation erfolgt nur innerhalb des Unternehmensnetzwerks.
### 10.3 PDF-Generierung
**Funktionsweise:**
- Verwaltungsmitarbeiter können PDF-Stundenzettel generieren
- PDFs enthalten alle Zeiterfassungsdaten einer Woche
- PDFs werden für die Lohnabrechnung verwendet
**Enthaltene Daten:**
- Mitarbeitername und Personalnummer
- Zeitraum (Woche)
- Alle Tageseinträge mit Start, Ende, Pause, Stunden
- Tätigkeiten mit Projektnummern
- Überstunden, Urlaub, Feiertage
- Gesamtstundensumme
**Rechtsgrundlage:** Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung) - Lohnabrechnung
**Hinweis:** PDFs werden nur intern verwendet und nicht an Dritte weitergegeben (außer Lohnbuchhaltung).
### 10.4 QR-Code Check-in/Check-out
**Funktionsweise:**
- Jeder Mitarbeiter erhält einen persönlichen QR-Code
- QR-Code enthält die Benutzer-ID
- Scan des QR-Codes führt zu Check-in oder Check-out
**Erfasste Daten:**
- Benutzer-ID (aus QR-Code)
- Zeitstempel des Scans
- Automatisch gesetzte Start- oder End-Zeit
**Rechtsgrundlage:** Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung) - Erfassung der Arbeitszeit
---
## 11. Datenübertragung in Drittländer
**Aktuell erfolgt keine Datenübertragung in Drittländer.**
Alle Daten werden auf Servern innerhalb der Europäischen Union (EU) oder des Europäischen Wirtschaftsraums (EWR) gespeichert und verarbeitet.
Falls in Zukunft eine Datenübertragung in Drittländer erforderlich sein sollte, wird dies nur unter Einhaltung der DSGVO-Anforderungen (z.B. Standardvertragsklauseln, Angemessenheitsbeschluss) erfolgen.
---
## 12. Automatisierte Entscheidungsfindung und Profiling
**Es erfolgt keine automatisierte Entscheidungsfindung (Art. 22 DSGVO) oder Profiling.**
Die automatische Berechnung von Arbeitszeiten und Überstunden dient lediglich der Unterstützung der manuellen Prüfung durch die Verwaltung und stellt keine automatisierte Entscheidung dar.
---
## 13. Änderungen dieser Dokumentation
Diese DSGVO-Dokumentation wird bei Änderungen der Datenverarbeitung aktualisiert. Die aktuelle Version ist immer im System verfügbar.
**Letzte Aktualisierung:** [Datum]
**Version:** 1.0
---
## 14. Anhang: Datenbank-Schema-Übersicht
### Tabellen mit personenbezogenen Daten:
1. **users**: Benutzerdaten, Authentifizierung, Arbeitszeitkonfiguration
2. **timesheet_entries**: Tageseinträge mit Zeiten, Tätigkeiten, Projektnummern
3. **weekly_timesheets**: Eingereichte Wochenstundenzettel
4. **ping_status**: IP-basierte Zeiterfassungsdaten
5. **ldap_sync_log**: Synchronisationsprotokolle (enthält Anzahl synchronisierter Benutzer)
### Tabellen ohne personenbezogene Daten:
1. **public_holidays**: Feiertage (Baden-Württemberg)
2. **system_options**: Systemweite Einstellungen
3. **ldap_config**: LDAP-Konfiguration (keine personenbezogenen Daten)
---
**Ende der DSGVO-Dokumentation**

View File

@@ -163,13 +163,13 @@ Nach der Installation sind folgende Benutzer verfügbar:
``` ```
stundenerfassung/ stundenerfassung/
├── routes/ # API-Routen ├── routes/ # API-Routen
│ ├── admin.js # Admin-Funktionen │ ├── admin-routes.js # Admin-Funktionen
│ ├── admin-ldap.js # LDAP-Verwaltung │ ├── admin-ldap-routes.js # LDAP-Verwaltung
│ ├── auth.js # Authentifizierung │ ├── auth-routes.js # Authentifizierung
│ ├── dashboard.js # Dashboard-Routen │ ├── dashboard-routes.js # Dashboard-Routen
│ ├── timesheet.js # Stundenerfassung │ ├── timesheet-routes.js # Stundenerfassung
│ ├── user.js # Benutzer-APIs │ ├── user-routes.js # Benutzer-APIs
│ └── verwaltung.js # Verwaltungs-Funktionen │ └── verwaltung-routes.js # Verwaltungs-Funktionen
├── services/ # Services ├── services/ # Services
│ ├── feiertage-service.js # Feiertags-API │ ├── feiertage-service.js # Feiertags-API
│ ├── ldap-service.js # LDAP-Service │ ├── ldap-service.js # LDAP-Service

18
Stundenregeln.txt Normal file
View File

@@ -0,0 +1,18 @@
- Standarterfassung: (Ende - Start ) - Pause
- Überstunden nehmen
- Wenn Überstunden eingetragen > (Wochenarbeitszeit / Arbeitstage) -> Muss noch Start und Ende und Pause eingetragen werden
- Wenn Überstunden = (Wochenarbeitszeit / Arbeitstage) -> Tag als ausgefüllt zu betrachten
- Urlaub
- Wird als ausgefüllt betrachtet
- Stunden werden als (Wochenarbeitszeit / Arbeitstage) gerechnet
- Wird von Verbleibendem Urlaub abgezogen
- Krank
- Wird als ausgefüllt betrachtet
- Stunden werden als (Wochenarbeitszeit / Arbeitstage) gerechnt
- Feiertage
- Wird als ausgefüllt betrachtet
- Stunden werden als (Wochenarbeitszeit / Arbeitstage) gerechnet

11
Stunderfassung todo.txt Normal file
View File

@@ -0,0 +1,11 @@
- Mitarbeiter Name in den QR code Sheets -> DONE
- Pause vorbelegen (einstellbar in der Admin) -> DONE Wird anhand der Gesetztlichen vorgaben berechnet
- Offset für die Verwaltung für Urlaubstage -> DONE
- Stunden pro Tag und wie viele Tage arbeit
- Reisen für Wochenende -> DONE
- LDAP Prüfung
- DSGVO Sicherheit
- Feiertage müssen als ausgefüllt zählen -> DONE
- Mitarbeiter sollen PDF ansehen können. -> DONE
- Wenn bereits heruntergeladen wurde und neue version da ist Meldung an Verwaltung. -> DONE Muss getestet werden
-

View File

@@ -172,6 +172,21 @@ function initDatabase() {
} }
}); });
// Migration: Wochenend-Reise und angewendeter Wochenend-Prozentsatz hinzufügen
db.run(`ALTER TABLE timesheet_entries ADD COLUMN weekend_travel INTEGER DEFAULT 0`, (err) => {
// Fehler ignorieren wenn Spalte bereits existiert
if (err && !err.message.includes('duplicate column')) {
console.warn('Warnung beim Hinzufügen der Spalte weekend_travel:', err.message);
}
});
db.run(`ALTER TABLE timesheet_entries ADD COLUMN applied_weekend_percentage REAL DEFAULT NULL`, (err) => {
// Fehler ignorieren wenn Spalte bereits existiert
if (err && !err.message.includes('duplicate column')) {
console.warn('Warnung beim Hinzufügen der Spalte applied_weekend_percentage:', err.message);
}
});
// Migration: User-Felder hinzufügen (Personalnummer, Wochenstunden, Urlaubstage) // Migration: User-Felder hinzufügen (Personalnummer, Wochenstunden, Urlaubstage)
db.run(`ALTER TABLE users ADD COLUMN personalnummer TEXT`, (err) => { db.run(`ALTER TABLE users ADD COLUMN personalnummer TEXT`, (err) => {
// Fehler ignorieren wenn Spalte bereits existiert // Fehler ignorieren wenn Spalte bereits existiert
@@ -193,6 +208,14 @@ function initDatabase() {
} }
}); });
// Migration: Urlaubstage-Offset (manuelle Korrektur durch Verwaltung)
db.run(`ALTER TABLE users ADD COLUMN vacation_offset_days REAL DEFAULT 0`, (err) => {
// Fehler ignorieren wenn Spalte bereits existiert
if (err && !err.message.includes('duplicate column')) {
console.warn('Warnung beim Hinzufügen der Spalte vacation_offset_days:', err.message);
}
});
// Migration: ping_ip Spalte hinzufügen // Migration: ping_ip Spalte hinzufügen
db.run(`ALTER TABLE users ADD COLUMN ping_ip TEXT`, (err) => { db.run(`ALTER TABLE users ADD COLUMN ping_ip TEXT`, (err) => {
// Fehler ignorieren wenn Spalte bereits existiert // Fehler ignorieren wenn Spalte bereits existiert

View File

@@ -1002,6 +1002,17 @@ table input[type="text"] {
margin-top: 5px; margin-top: 5px;
} }
.new-version-warning {
color: #e74c3c;
font-size: 14px;
font-weight: bold;
padding: 8px 12px;
background-color: #fee;
border: 1px solid #e74c3c;
border-radius: 4px;
display: inline-block;
}
/* Responsive */ /* Responsive */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.dashboard-layout { .dashboard-layout {

View File

@@ -5,6 +5,7 @@ let currentEntries = {};
let currentHolidayDates = new Set(); // Feiertage der aktuellen Woche (YYYY-MM-DD) let currentHolidayDates = new Set(); // Feiertage der aktuellen Woche (YYYY-MM-DD)
let userWochenstunden = 0; // Wochenstunden des Users let userWochenstunden = 0; // Wochenstunden des Users
let weekendPercentages = { saturday: 100, sunday: 100 }; // Wochenend-Prozentsätze (100% = normal) let weekendPercentages = { saturday: 100, sunday: 100 }; // Wochenend-Prozentsätze (100% = normal)
let latestSubmittedTimesheetId = null; // ID der neuesten eingereichten Version
// Wochenend-Prozentsätze laden // Wochenend-Prozentsätze laden
async function loadWeekendPercentages() { async function loadWeekendPercentages() {
@@ -206,6 +207,21 @@ document.addEventListener('DOMContentLoaded', async function() {
} else { } else {
console.error('Submit-Button nicht gefunden beim Initialisieren!'); console.error('Submit-Button nicht gefunden beim Initialisieren!');
} }
// Event-Listener für PDF-Button
const viewPdfButton = document.getElementById('viewPdfBtn');
if (viewPdfButton) {
viewPdfButton.addEventListener('click', function(e) {
e.preventDefault();
if (!this.disabled && latestSubmittedTimesheetId) {
// Öffne PDF in neuem Tab
window.open(`/api/timesheet/pdf/${latestSubmittedTimesheetId}?inline=true`, '_blank');
}
});
console.log('PDF-Button Event-Listener gesetzt');
} else {
console.error('PDF-Button nicht gefunden beim Initialisieren!');
}
}); });
// Letzte Woche auf dem Server speichern // Letzte Woche auf dem Server speichern
@@ -303,14 +319,19 @@ async function loadWeek() {
const parts = currentWeekStart.split('-'); const parts = currentWeekStart.split('-');
const weekEndDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10) + 6); const weekEndDate = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10) + 6);
const weekEnd = weekEndDate.getFullYear() + '-' + String(weekEndDate.getMonth() + 1).padStart(2, '0') + '-' + String(weekEndDate.getDate()).padStart(2, '0'); const weekEnd = weekEndDate.getFullYear() + '-' + String(weekEndDate.getMonth() + 1).padStart(2, '0') + '-' + String(weekEndDate.getDate()).padStart(2, '0');
const [entriesResponse, holidaysResponse] = await Promise.all([ const [entriesResponse, holidaysResponse, latestSubmittedResponse] = await Promise.all([
fetch(`/api/timesheet/week/${currentWeekStart}`), fetch(`/api/timesheet/week/${currentWeekStart}`),
fetch(`/api/timesheet/holidays?week_start=${currentWeekStart}&week_end=${weekEnd}`) fetch(`/api/timesheet/holidays?week_start=${currentWeekStart}&week_end=${weekEnd}`),
fetch(`/api/timesheet/latest-submitted/${currentWeekStart}`)
]); ]);
const entries = await entriesResponse.json(); const entries = await entriesResponse.json();
const holidaysData = await holidaysResponse.json(); const holidaysData = await holidaysResponse.json();
const latestSubmittedData = await latestSubmittedResponse.json();
currentHolidayDates = new Set(holidaysData.dates || []); currentHolidayDates = new Set(holidaysData.dates || []);
// Speichere die neueste eingereichte Timesheet-ID
latestSubmittedTimesheetId = latestSubmittedData.timesheetId || null;
// Entries in Object umwandeln für schnellen Zugriff // Entries in Object umwandeln für schnellen Zugriff
currentEntries = {}; currentEntries = {};
let weekIsSubmitted = false; let weekIsSubmitted = false;
@@ -382,6 +403,9 @@ function renderWeek() {
const vacationType = entry.vacation_type || ''; const vacationType = entry.vacation_type || '';
const sickStatus = entry.sick_status || false; const sickStatus = entry.sick_status || false;
const isHoliday = currentHolidayDates.has(dateStr); const isHoliday = currentHolidayDates.has(dateStr);
const weekendTravel = entry.weekend_travel || false;
const appliedWeekendPercentage = entry.applied_weekend_percentage;
const isWeekend = (i >= 5); // Samstag (5) oder Sonntag (6)
// Tätigkeiten laden // Tätigkeiten laden
const activities = [ const activities = [
@@ -413,12 +437,20 @@ function renderWeek() {
hoursToAdd = 8 + (hours || 0); // 8h Feiertag + gearbeitete Stunden (= Überstunden) hoursToAdd = 8 + (hours || 0); // 8h Feiertag + gearbeitete Stunden (= Überstunden)
} else { } else {
hoursToAdd = hours || 0; hoursToAdd = hours || 0;
// Wochenend-Prozentsatz anwenden (nur auf tatsächlich gearbeitete Stunden, nicht auf Urlaub/Krankheit) // Wochenend-Prozentsatz anwenden (nur wenn weekend_travel aktiviert UND es ist ein Wochenendtag)
const weekendPercentage = getWeekendPercentage(date); if (isWeekend && weekendTravel && hours > 0 && vacationType !== 'full' && !sickStatus && !isFullDayOvertime) {
if (weekendPercentage >= 100 && hours > 0 && vacationType !== 'full' && !sickStatus && !isFullDayOvertime) { // Verwende gespeicherten Prozentsatz falls vorhanden, sonst aktuellen
let weekendPercentage = 100;
if (appliedWeekendPercentage !== null && appliedWeekendPercentage !== undefined) {
weekendPercentage = appliedWeekendPercentage;
} else {
weekendPercentage = getWeekendPercentage(date);
}
if (weekendPercentage >= 100) {
hoursToAdd = hours * (weekendPercentage / 100); hoursToAdd = hours * (weekendPercentage / 100);
} }
} }
}
totalHours += hoursToAdd; totalHours += hoursToAdd;
// Bearbeitung ist immer möglich, auch nach Abschicken // Bearbeitung ist immer möglich, auch nach Abschicken
@@ -537,20 +569,17 @@ function renderWeek() {
</div> </div>
</div> </div>
<div class="sick-control"> <div class="sick-control">
<button type="button" class="btn btn-secondary btn-sm" onclick="toggleSickStatus('${dateStr}')" style="margin-right: 5px;"> <button type="button" class="btn btn-secondary btn-sm" onclick="toggleSickStatus('${dateStr}')" style="margin-right: 5px; ${sickStatus ? 'background-color: #e74c3c; color: white;' : ''}">
Krank Krank
</button> </button>
<div id="sick-checkbox-${dateStr}" style="display: ${sickStatus ? 'inline-block' : 'none'};">
<input type="checkbox"
data-date="${dateStr}"
data-field="sick_status"
${sickStatus ? 'checked' : ''}
${disabled}
onchange="saveEntry(this); updateOvertimeDisplay();"
style="margin-left: 5px;"
class="sick-checkbox">
</div> </div>
${isWeekend ? `
<div class="travel-control">
<button type="button" class="btn btn-secondary btn-sm" onclick="toggleWeekendTravel('${dateStr}')" style="margin-right: 5px; ${weekendTravel ? 'background-color: #28a745; color: white;' : ''}">
Reise
</button>
</div> </div>
` : ''}
</div> </div>
</td> </td>
</tr> </tr>
@@ -671,8 +700,8 @@ function updateOvertimeDisplay() {
// Prüfe Urlaub-Status und Krank-Status // Prüfe Urlaub-Status und Krank-Status
const vacationSelect = document.querySelector(`select[data-date="${dateStr}"][data-field="vacation_type"]`); const vacationSelect = document.querySelector(`select[data-date="${dateStr}"][data-field="vacation_type"]`);
const vacationType = vacationSelect ? vacationSelect.value : (currentEntries[dateStr]?.vacation_type || ''); const vacationType = vacationSelect ? vacationSelect.value : (currentEntries[dateStr]?.vacation_type || '');
const sickCheckbox = document.querySelector(`input[data-date="${dateStr}"][data-field="sick_status"]`); // Für sick_status: Wert aus currentEntries lesen (da keine Checkbox mehr vorhanden)
const sickStatus = sickCheckbox ? sickCheckbox.checked : (currentEntries[dateStr]?.sick_status || false); const sickStatus = currentEntries[dateStr]?.sick_status || false;
// Prüfe ob 8 Überstunden (ganzer Tag) eingetragen sind // Prüfe ob 8 Überstunden (ganzer Tag) eingetragen sind
const overtimeInput = document.querySelector(`input[data-date="${dateStr}"][data-field="overtime_taken_hours"]`); const overtimeInput = document.querySelector(`input[data-date="${dateStr}"][data-field="overtime_taken_hours"]`);
@@ -703,21 +732,47 @@ function updateOvertimeDisplay() {
const end = new Date(`2000-01-01T${endTime}`); const end = new Date(`2000-01-01T${endTime}`);
const diffMs = end - start; const diffMs = end - start;
const hours = (diffMs / (1000 * 60 * 60)) - (breakMinutes / 60); const hours = (diffMs / (1000 * 60 * 60)) - (breakMinutes / 60);
// Wochenend-Prozentsatz anwenden (nur auf tatsächlich gearbeitete Stunden) // Wochenend-Prozentsatz anwenden (nur wenn weekend_travel aktiviert UND es ist ein Wochenendtag)
const weekendPercentage = getWeekendPercentage(date); const dayOfWeek = date.getDay();
const isWeekend = (dayOfWeek === 6 || dayOfWeek === 0);
// Für weekend_travel: Wert aus currentEntries lesen (da keine Checkbox mehr vorhanden)
const weekendTravel = currentEntries[dateStr]?.weekend_travel || false;
let adjustedHours = hours; let adjustedHours = hours;
if (weekendPercentage >= 100 && hours > 0 && vacationType !== 'full' && !sickStatus && !isFullDayOvertime) { if (isWeekend && weekendTravel && hours > 0 && vacationType !== 'full' && !sickStatus && !isFullDayOvertime) {
// Verwende gespeicherten Prozentsatz falls vorhanden, sonst aktuellen
let weekendPercentage = 100;
const entry = currentEntries[dateStr] || {};
if (entry.applied_weekend_percentage !== null && entry.applied_weekend_percentage !== undefined) {
weekendPercentage = entry.applied_weekend_percentage;
} else {
weekendPercentage = getWeekendPercentage(date);
}
if (weekendPercentage >= 100) {
adjustedHours = hours * (weekendPercentage / 100); adjustedHours = hours * (weekendPercentage / 100);
} }
}
totalHours += adjustedHours; totalHours += adjustedHours;
} else if (currentEntries[dateStr]?.total_hours && !isFullDayOvertime) { } else if (currentEntries[dateStr]?.total_hours && !isFullDayOvertime) {
// Fallback auf gespeicherte Werte // Fallback auf gespeicherte Werte
let hours = parseFloat(currentEntries[dateStr].total_hours) || 0; let hours = parseFloat(currentEntries[dateStr].total_hours) || 0;
// Wochenend-Prozentsatz anwenden (nur auf tatsächlich gearbeitete Stunden) // Wochenend-Prozentsatz anwenden (nur wenn weekend_travel aktiviert UND es ist ein Wochenendtag)
const weekendPercentage = getWeekendPercentage(date); const dayOfWeek = date.getDay();
if (weekendPercentage >= 100 && hours > 0 && vacationType !== 'full' && !sickStatus) { const isWeekend = (dayOfWeek === 6 || dayOfWeek === 0);
// Für weekend_travel: Wert aus currentEntries lesen (da keine Checkbox mehr vorhanden)
const weekendTravel = currentEntries[dateStr]?.weekend_travel || false;
if (isWeekend && weekendTravel && hours > 0 && vacationType !== 'full' && !sickStatus) {
// Verwende gespeicherten Prozentsatz falls vorhanden, sonst aktuellen
const entry = currentEntries[dateStr] || {};
let weekendPercentage = 100;
if (entry.applied_weekend_percentage !== null && entry.applied_weekend_percentage !== undefined) {
weekendPercentage = entry.applied_weekend_percentage;
} else {
weekendPercentage = getWeekendPercentage(date);
}
if (weekendPercentage >= 100) {
hours = hours * (weekendPercentage / 100); hours = hours * (weekendPercentage / 100);
} }
}
totalHours += hours; totalHours += hours;
} }
} else if (sickStatus) { } else if (sickStatus) {
@@ -759,21 +814,47 @@ function updateOvertimeDisplay() {
const end = new Date(`2000-01-01T${endTime}`); const end = new Date(`2000-01-01T${endTime}`);
const diffMs = end - start; const diffMs = end - start;
const hours = (diffMs / (1000 * 60 * 60)) - (breakMinutes / 60); const hours = (diffMs / (1000 * 60 * 60)) - (breakMinutes / 60);
// Wochenend-Prozentsatz anwenden (nur auf tatsächlich gearbeitete Stunden) // Wochenend-Prozentsatz anwenden (nur wenn weekend_travel aktiviert UND es ist ein Wochenendtag)
const weekendPercentage = getWeekendPercentage(date); const dayOfWeek = date.getDay();
const isWeekend = (dayOfWeek === 6 || dayOfWeek === 0);
const travelCheckbox = document.querySelector(`input[data-date="${dateStr}"][data-field="weekend_travel"]`);
const weekendTravel = travelCheckbox ? travelCheckbox.checked : (currentEntries[dateStr]?.weekend_travel || false);
let adjustedHours = hours; let adjustedHours = hours;
if (weekendPercentage >= 100 && hours > 0 && !isFullDayOvertime) { if (isWeekend && weekendTravel && hours > 0 && !isFullDayOvertime) {
// Verwende gespeicherten Prozentsatz falls vorhanden, sonst aktuellen
const entry = currentEntries[dateStr] || {};
let weekendPercentage = 100;
if (entry.applied_weekend_percentage !== null && entry.applied_weekend_percentage !== undefined) {
weekendPercentage = entry.applied_weekend_percentage;
} else {
weekendPercentage = getWeekendPercentage(date);
}
if (weekendPercentage >= 100) {
adjustedHours = hours * (weekendPercentage / 100); adjustedHours = hours * (weekendPercentage / 100);
} }
}
totalHours += adjustedHours; totalHours += adjustedHours;
} else if (currentEntries[dateStr]?.total_hours) { } else if (currentEntries[dateStr]?.total_hours) {
// Fallback auf gespeicherte Werte // Fallback auf gespeicherte Werte
let hours = parseFloat(currentEntries[dateStr].total_hours) || 0; let hours = parseFloat(currentEntries[dateStr].total_hours) || 0;
// Wochenend-Prozentsatz anwenden (nur auf tatsächlich gearbeitete Stunden) // Wochenend-Prozentsatz anwenden (nur wenn weekend_travel aktiviert UND es ist ein Wochenendtag)
const weekendPercentage = getWeekendPercentage(date); const dayOfWeek = date.getDay();
if (weekendPercentage >= 100 && hours > 0 && !isFullDayOvertime) { const isWeekend = (dayOfWeek === 6 || dayOfWeek === 0);
const travelCheckbox = document.querySelector(`input[data-date="${dateStr}"][data-field="weekend_travel"]`);
const weekendTravel = travelCheckbox ? travelCheckbox.checked : (currentEntries[dateStr]?.weekend_travel || false);
if (isWeekend && weekendTravel && hours > 0 && !isFullDayOvertime) {
// Verwende gespeicherten Prozentsatz falls vorhanden, sonst aktuellen
const entry = currentEntries[dateStr] || {};
let weekendPercentage = 100;
if (entry.applied_weekend_percentage !== null && entry.applied_weekend_percentage !== undefined) {
weekendPercentage = entry.applied_weekend_percentage;
} else {
weekendPercentage = getWeekendPercentage(date);
}
if (weekendPercentage >= 100) {
hours = hours * (weekendPercentage / 100); hours = hours * (weekendPercentage / 100);
} }
}
totalHours += hours; totalHours += hours;
} }
} }
@@ -893,6 +974,27 @@ function handleOvertimeChange(dateStr, overtimeHours) {
} }
} }
// Berechnet die gesetzlich erforderliche Mindestpause basierend auf der Arbeitszeit
function calculateRequiredBreakMinutes(startTime, endTime) {
if (!startTime || !endTime) return null;
// Berechne Arbeitszeit in Stunden
const [startHours, startMinutes] = startTime.split(':').map(Number);
const [endHours, endMinutes] = endTime.split(':').map(Number);
const startTotalMinutes = startHours * 60 + startMinutes;
const endTotalMinutes = endHours * 60 + endMinutes;
const workMinutes = endTotalMinutes - startTotalMinutes;
const workHours = workMinutes / 60;
// Gesetzliche Mindestpause bestimmen
if (workHours > 9) {
return 45; // Mehr als 9 Stunden: 45 Minuten
} else if (workHours >= 6) {
return 30; // 6 bis 9 Stunden: 30 Minuten
}
return 0; // Weniger als 6 Stunden: keine gesetzliche Pause erforderlich
}
// Eintrag speichern // Eintrag speichern
async function saveEntry(input) { async function saveEntry(input) {
const date = input.dataset.date; const date = input.dataset.date;
@@ -936,11 +1038,27 @@ async function saveEntry(input) {
// Wichtig: Leere Strings werden zu null konvertiert, aber ein Wert sollte vorhanden sein // Wichtig: Leere Strings werden zu null konvertiert, aber ein Wert sollte vorhanden sein
const start_time = actualStartTime; const start_time = actualStartTime;
const end_time = actualEndTime; const end_time = actualEndTime;
const break_minutes = breakInput && breakInput.value ? (parseInt(breakInput.value) || 0) : (parseInt(currentEntries[date].break_minutes) || 0); let break_minutes = breakInput && breakInput.value ? (parseInt(breakInput.value) || 0) : (parseInt(currentEntries[date].break_minutes) || 0);
// Automatische Vorbelegung der Pausenzeiten basierend auf gesetzlichen Vorgaben
// Wird ausgelöst, wenn start_time oder end_time geändert werden
if ((input.dataset.field === 'start_time' || input.dataset.field === 'end_time') && start_time && end_time) {
const requiredBreakMinutes = calculateRequiredBreakMinutes(start_time, end_time);
if (requiredBreakMinutes !== null && requiredBreakMinutes > break_minutes) {
// Setze den höheren Wert (gesetzliche Mindestpause)
break_minutes = requiredBreakMinutes;
// Aktualisiere das Input-Feld im DOM
if (breakInput) {
breakInput.value = break_minutes;
}
}
}
const notes = notesInput ? (notesInput.value || '') : (currentEntries[date].notes || ''); const notes = notesInput ? (notesInput.value || '') : (currentEntries[date].notes || '');
const vacation_type = vacationSelect && vacationSelect.value ? vacationSelect.value : (currentEntries[date].vacation_type || null); const vacation_type = vacationSelect && vacationSelect.value ? vacationSelect.value : (currentEntries[date].vacation_type || null);
const overtime_taken_hours = overtimeInput && overtimeInput.value ? overtimeInput.value : (currentEntries[date].overtime_taken_hours || null); const overtime_taken_hours = overtimeInput && overtimeInput.value ? overtimeInput.value : (currentEntries[date].overtime_taken_hours || null);
const sick_status = sickCheckbox ? (sickCheckbox.checked ? true : false) : (currentEntries[date].sick_status || false); // Für sick_status und weekend_travel: Wert aus currentEntries lesen (da keine Checkboxen mehr vorhanden)
const sick_status = (input.dataset.field === 'sick_status') ? (value === 'true' || value === true || value === '1' || value === 1) : (currentEntries[date].sick_status || false);
const weekend_travel = (input.dataset.field === 'weekend_travel') ? (value === 'true' || value === true || value === '1' || value === 1) : (currentEntries[date].weekend_travel || false);
// Activity-Felder aus DOM lesen // Activity-Felder aus DOM lesen
const activities = []; const activities = [];
@@ -964,6 +1082,7 @@ async function saveEntry(input) {
currentEntries[date].vacation_type = vacation_type; currentEntries[date].vacation_type = vacation_type;
currentEntries[date].overtime_taken_hours = overtime_taken_hours; currentEntries[date].overtime_taken_hours = overtime_taken_hours;
currentEntries[date].sick_status = sick_status; currentEntries[date].sick_status = sick_status;
currentEntries[date].weekend_travel = weekend_travel;
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
currentEntries[date][`activity${i}_desc`] = activities[i-1].desc; currentEntries[date][`activity${i}_desc`] = activities[i-1].desc;
currentEntries[date][`activity${i}_hours`] = activities[i-1].hours; currentEntries[date][`activity${i}_hours`] = activities[i-1].hours;
@@ -1002,7 +1121,8 @@ async function saveEntry(input) {
activity5_project_number: activities[4].projectNumber, activity5_project_number: activities[4].projectNumber,
overtime_taken_hours: overtime_taken_hours, overtime_taken_hours: overtime_taken_hours,
vacation_type: vacation_type, vacation_type: vacation_type,
sick_status: sick_status sick_status: sick_status,
weekend_travel: weekend_travel
}) })
}); });
@@ -1063,6 +1183,9 @@ function checkWeekComplete() {
date.setDate(date.getDate() + i); date.setDate(date.getDate() + i);
const dateStr = formatDate(date); const dateStr = formatDate(date);
// Prüfe ob Feiertag
const isHoliday = currentHolidayDates.has(dateStr);
// Prüfe Urlaub-Status und Krank-Status // Prüfe Urlaub-Status und Krank-Status
const entry = currentEntries[dateStr] || {}; const entry = currentEntries[dateStr] || {};
const vacationType = entry.vacation_type; const vacationType = entry.vacation_type;
@@ -1071,9 +1194,9 @@ function checkWeekComplete() {
const sickCheckbox = document.querySelector(`input[data-date="${dateStr}"][data-field="sick_status"]`); const sickCheckbox = document.querySelector(`input[data-date="${dateStr}"][data-field="sick_status"]`);
const sickStatus = sickCheckbox ? sickCheckbox.checked : (entry.sick_status || false); const sickStatus = sickCheckbox ? sickCheckbox.checked : (entry.sick_status || false);
// Wenn ganzer Tag Urlaub oder Krank, dann ist der Tag als ausgefüllt zu betrachten // Wenn Feiertag, ganzer Tag Urlaub oder Krank, dann ist der Tag als ausgefüllt zu betrachten
if (vacationValue === 'full' || sickStatus) { if (isHoliday || vacationValue === 'full' || sickStatus) {
continue; // Tag ist ausgefüllt (ganzer Tag Urlaub oder Krank) continue; // Tag ist ausgefüllt (Feiertag, ganzer Tag Urlaub oder Krank)
} }
// Prüfe ob 8 Überstunden eingetragen sind (dann ist der Tag auch ausgefüllt) // Prüfe ob 8 Überstunden eingetragen sind (dann ist der Tag auch ausgefüllt)
@@ -1132,6 +1255,18 @@ function checkWeekComplete() {
console.log(`Submit-Button Status: disabled=${submitButton.disabled}, allWeekdaysFilled=${allWeekdaysFilled}, weekIsSubmitted=${weekIsSubmitted}`); console.log(`Submit-Button Status: disabled=${submitButton.disabled}, allWeekdaysFilled=${allWeekdaysFilled}, weekIsSubmitted=${weekIsSubmitted}`);
} }
// PDF-Button Status aktualisieren
const viewPdfButton = document.getElementById('viewPdfBtn');
if (viewPdfButton) {
// Button aktivieren wenn eine eingereichte Version existiert
viewPdfButton.disabled = !latestSubmittedTimesheetId;
if (latestSubmittedTimesheetId) {
viewPdfButton.title = 'Letzte eingereichte Version anzeigen';
} else {
viewPdfButton.title = 'Keine eingereichte Version verfügbar';
}
}
} }
// Globaler Handler für onclick-Attribut (im HTML) // Globaler Handler für onclick-Attribut (im HTML)
@@ -1185,6 +1320,9 @@ async function submitWeek() {
const weekday = getWeekday(dateStr); const weekday = getWeekday(dateStr);
const dateDisplay = formatDateDE(dateStr); const dateDisplay = formatDateDE(dateStr);
// Prüfe ob Feiertag
const isHoliday = currentHolidayDates.has(dateStr);
// Prüfe Urlaub-Status und Krank-Status // Prüfe Urlaub-Status und Krank-Status
const entry = currentEntries[dateStr] || {}; const entry = currentEntries[dateStr] || {};
const vacationSelect = document.querySelector(`select[data-date="${dateStr}"][data-field="vacation_type"]`); const vacationSelect = document.querySelector(`select[data-date="${dateStr}"][data-field="vacation_type"]`);
@@ -1192,9 +1330,9 @@ async function submitWeek() {
const sickCheckbox = document.querySelector(`input[data-date="${dateStr}"][data-field="sick_status"]`); const sickCheckbox = document.querySelector(`input[data-date="${dateStr}"][data-field="sick_status"]`);
const sickStatus = sickCheckbox ? sickCheckbox.checked : (entry.sick_status || false); const sickStatus = sickCheckbox ? sickCheckbox.checked : (entry.sick_status || false);
// Wenn ganzer Tag Urlaub oder Krank, dann ist der Tag als ausgefüllt zu betrachten // Wenn Feiertag, ganzer Tag Urlaub oder Krank, dann ist der Tag als ausgefüllt zu betrachten
if (vacationValue === 'full' || sickStatus) { if (isHoliday || vacationValue === 'full' || sickStatus) {
continue; // Tag ist ausgefüllt (ganzer Tag Urlaub oder Krank) continue; // Tag ist ausgefüllt (Feiertag, ganzer Tag Urlaub oder Krank)
} }
// Prüfe ob 8 Überstunden eingetragen sind (dann ist der Tag auch ausgefüllt, Start/Ende nicht nötig) // Prüfe ob 8 Überstunden eingetragen sind (dann ist der Tag auch ausgefüllt, Start/Ende nicht nötig)
@@ -1474,37 +1612,69 @@ function toggleVacationSelect(dateStr) {
} }
} }
// Krank-Status ein-/ausblenden // Wochenend-Reise-Status umschalten
function toggleSickStatus(dateStr) { function toggleWeekendTravel(dateStr) {
const checkboxDiv = document.getElementById(`sick-checkbox-${dateStr}`); const button = document.querySelector(`button[onclick="toggleWeekendTravel('${dateStr}')"]`);
if (checkboxDiv) { if (!button) return;
if (checkboxDiv.style.display === 'none' || !checkboxDiv.style.display) {
checkboxDiv.style.display = 'inline-block'; // Aktuellen Status aus currentEntries lesen
const checkbox = checkboxDiv.querySelector('input[type="checkbox"]'); const currentTravelStatus = currentEntries[dateStr]?.weekend_travel || false;
if (checkbox) { const newStatus = !currentTravelStatus;
// Prüfe aktuellen Status aus currentEntries
const currentSickStatus = currentEntries[dateStr]?.sick_status || false; // Status in currentEntries aktualisieren
checkbox.checked = currentSickStatus || true; // Wenn nicht gesetzt, auf true setzen if (!currentEntries[dateStr]) {
checkbox.focus(); currentEntries[dateStr] = { date: dateStr };
// Sofort speichern wenn aktiviert
if (!currentSickStatus) {
saveEntry(checkbox);
}
} }
currentEntries[dateStr].weekend_travel = newStatus;
// Button-Stil aktualisieren
if (newStatus) {
button.style.backgroundColor = '#28a745';
button.style.color = 'white';
} else { } else {
// Wert löschen wenn ausgeblendet button.style.backgroundColor = '';
const checkbox = checkboxDiv.querySelector('input[type="checkbox"]'); button.style.color = '';
if (checkbox) {
checkbox.checked = false;
// Speichern
if (currentEntries[dateStr]) {
currentEntries[dateStr].sick_status = false;
saveEntry(checkbox);
} }
// Speichere den Wert (erstellen ein temporäres Input-Element für saveEntry)
const tempInput = document.createElement('input');
tempInput.dataset.date = dateStr;
tempInput.dataset.field = 'weekend_travel';
tempInput.value = newStatus;
saveEntry(tempInput);
updateOvertimeDisplay();
}
function toggleSickStatus(dateStr) {
const button = document.querySelector(`button[onclick="toggleSickStatus('${dateStr}')"]`);
if (!button) return;
// Aktuellen Status aus currentEntries lesen
const currentSickStatus = currentEntries[dateStr]?.sick_status || false;
const newStatus = !currentSickStatus;
// Status in currentEntries aktualisieren
if (!currentEntries[dateStr]) {
currentEntries[dateStr] = { date: dateStr };
} }
checkboxDiv.style.display = 'none'; currentEntries[dateStr].sick_status = newStatus;
}
// Button-Stil aktualisieren
if (newStatus) {
button.style.backgroundColor = '#e74c3c';
button.style.color = 'white';
} else {
button.style.backgroundColor = '';
button.style.color = '';
} }
// Speichere den Wert (erstellen ein temporäres Input-Element für saveEntry)
const tempInput = document.createElement('input');
tempInput.dataset.date = dateStr;
tempInput.dataset.field = 'sick_status';
tempInput.value = newStatus;
saveEntry(tempInput);
updateOvertimeDisplay();
} }
// Ping-IP laden // Ping-IP laden

View File

@@ -4,6 +4,7 @@ const { db } = require('../database');
const { requireAuth, requireVerwaltung } = require('../middleware/auth'); const { requireAuth, requireVerwaltung } = require('../middleware/auth');
const { generatePDF } = require('../services/pdf-service'); const { generatePDF } = require('../services/pdf-service');
const { getHolidaysForDateRange } = require('../services/feiertage-service'); const { getHolidaysForDateRange } = require('../services/feiertage-service');
const { hasRole } = require('../helpers/utils');
// Routes registrieren // Routes registrieren
function registerTimesheetRoutes(app) { function registerTimesheetRoutes(app) {
@@ -16,7 +17,7 @@ function registerTimesheetRoutes(app) {
activity3_desc, activity3_hours, activity3_project_number, activity3_desc, activity3_hours, activity3_project_number,
activity4_desc, activity4_hours, activity4_project_number, activity4_desc, activity4_hours, activity4_project_number,
activity5_desc, activity5_hours, activity5_project_number, activity5_desc, activity5_hours, activity5_project_number,
overtime_taken_hours, vacation_type, sick_status overtime_taken_hours, vacation_type, sick_status, weekend_travel
} = req.body; } = req.body;
const userId = req.session.userId; const userId = req.session.userId;
@@ -27,6 +28,10 @@ function registerTimesheetRoutes(app) {
// Normalisiere sick_status: Boolean oder 1/0 zu Boolean // Normalisiere sick_status: Boolean oder 1/0 zu Boolean
const isSick = sick_status === true || sick_status === 1 || sick_status === 'true' || sick_status === '1'; const isSick = sick_status === true || sick_status === 1 || sick_status === 'true' || sick_status === '1';
// Normalisiere weekend_travel: Boolean oder 1/0 zu Integer
const isWeekendTravel = weekend_travel === true || weekend_travel === 1 || weekend_travel === 'true' || weekend_travel === '1';
const weekendTravelValue = isWeekendTravel ? 1 : 0;
// Wochenend-Prozentsätze laden // Wochenend-Prozentsätze laden
db.get('SELECT saturday_percentage, sunday_percentage FROM system_options WHERE id = 1', (err, options) => { db.get('SELECT saturday_percentage, sunday_percentage FROM system_options WHERE id = 1', (err, options) => {
if (err) { if (err) {
@@ -66,6 +71,11 @@ function registerTimesheetRoutes(app) {
isFullDayOvertime = true; isFullDayOvertime = true;
} }
// Prüfe ob es ein Wochenendtag ist
const dateObj = new Date(date);
const dayOfWeek = dateObj.getDay();
const isWeekend = (dayOfWeek === 6 || dayOfWeek === 0); // 6 = Samstag, 0 = Sonntag
// Gesamtstunden berechnen (aus Start- und Endzeit, nicht aus Tätigkeiten) // Gesamtstunden berechnen (aus Start- und Endzeit, nicht aus Tätigkeiten)
// Wenn ganzer Tag Urlaub oder Krank, dann zählt dieser als 8 Stunden normale Arbeitszeit // Wenn ganzer Tag Urlaub oder Krank, dann zählt dieser als 8 Stunden normale Arbeitszeit
let total_hours = 0; let total_hours = 0;
@@ -77,6 +87,7 @@ function registerTimesheetRoutes(app) {
let finalActivity5Desc = activity5_desc; let finalActivity5Desc = activity5_desc;
let finalStartTime = normalizedStartTime; let finalStartTime = normalizedStartTime;
let finalEndTime = normalizedEndTime; let finalEndTime = normalizedEndTime;
let appliedWeekendPercentage = null; // Wird gesetzt wenn Wochenend-Prozentsatz angewendet wird
// Überstunden-Logik: Bei vollem Tag Überstunden // Überstunden-Logik: Bei vollem Tag Überstunden
if (isFullDayOvertime) { if (isFullDayOvertime) {
@@ -95,10 +106,14 @@ function registerTimesheetRoutes(app) {
const end = new Date(`2000-01-01T${normalizedEndTime}`); const end = new Date(`2000-01-01T${normalizedEndTime}`);
const diffMs = end - start; const diffMs = end - start;
total_hours = (diffMs / (1000 * 60 * 60)) - (break_minutes / 60); total_hours = (diffMs / (1000 * 60 * 60)) - (break_minutes / 60);
// Wochenend-Prozentsatz anwenden (nur auf tatsächlich gearbeitete Stunden, nicht auf Urlaub/Krankheit)
// Wochenend-Prozentsatz anwenden (nur wenn weekend_travel aktiviert UND es ist ein Wochenendtag)
if (isWeekend && isWeekendTravel && total_hours > 0 && !isSick && vacation_type !== 'full') {
const weekendPercentage = getWeekendPercentage(date); const weekendPercentage = getWeekendPercentage(date);
if (weekendPercentage >= 100 && total_hours > 0 && !isSick && vacation_type !== 'full') { if (weekendPercentage >= 100) {
total_hours = total_hours * (weekendPercentage / 100); total_hours = total_hours * (weekendPercentage / 100);
appliedWeekendPercentage = weekendPercentage; // Speichere den angewendeten Prozentsatz
}
} }
// Bei halbem Tag Urlaub: total_hours bleibt die tatsächlich gearbeiteten Stunden // Bei halbem Tag Urlaub: total_hours bleibt die tatsächlich gearbeiteten Stunden
// Die 4 Stunden Urlaub werden nur in der Überstunden-Berechnung hinzugezählt // Die 4 Stunden Urlaub werden nur in der Überstunden-Berechnung hinzugezählt
@@ -108,9 +123,34 @@ function registerTimesheetRoutes(app) {
// Sie werden über overtime_taken_hours in der PDF angezeigt // Sie werden über overtime_taken_hours in der PDF angezeigt
// Prüfen ob Eintrag existiert - verwende den neuesten Eintrag falls mehrere existieren // Prüfen ob Eintrag existiert - verwende den neuesten Eintrag falls mehrere existieren
db.get('SELECT id FROM timesheet_entries WHERE user_id = ? AND date = ? ORDER BY updated_at DESC, id DESC LIMIT 1', db.get('SELECT id, applied_weekend_percentage FROM timesheet_entries WHERE user_id = ? AND date = ? ORDER BY updated_at DESC, id DESC LIMIT 1',
[userId, date], (err, row) => { [userId, date], (err, row) => {
if (row) { if (row) {
// Wenn bereits ein gespeicherter Prozentsatz existiert, diesen verwenden (historische Einträge bleiben unverändert)
let finalAppliedPercentage = appliedWeekendPercentage;
if (row.applied_weekend_percentage !== null && row.applied_weekend_percentage !== undefined) {
// Verwende den gespeicherten Prozentsatz, aber nur wenn weekend_travel aktiviert ist
if (isWeekendTravel && isWeekend) {
finalAppliedPercentage = row.applied_weekend_percentage;
// Berechne total_hours neu mit gespeichertem Prozentsatz, falls nötig
if (normalizedStartTime && normalizedEndTime && !isSick && vacation_type !== 'full' && !isFullDayOvertime) {
const start = new Date(`2000-01-01T${normalizedStartTime}`);
const end = new Date(`2000-01-01T${normalizedEndTime}`);
const diffMs = end - start;
const baseHours = (diffMs / (1000 * 60 * 60)) - (break_minutes / 60);
if (baseHours > 0 && finalAppliedPercentage >= 100) {
total_hours = baseHours * (finalAppliedPercentage / 100);
}
}
} else {
// Wenn weekend_travel nicht aktiviert ist, aber ein gespeicherter Prozentsatz existiert, behalte ihn
finalAppliedPercentage = row.applied_weekend_percentage;
}
} else if (isWeekendTravel && isWeekend) {
// Neuer Eintrag mit weekend_travel - speichere den aktuellen Prozentsatz
finalAppliedPercentage = appliedWeekendPercentage;
}
// Update // Update
db.run(`UPDATE timesheet_entries db.run(`UPDATE timesheet_entries
SET start_time = ?, end_time = ?, break_minutes = ?, total_hours = ?, notes = ?, SET start_time = ?, end_time = ?, break_minutes = ?, total_hours = ?, notes = ?,
@@ -120,6 +160,7 @@ function registerTimesheetRoutes(app) {
activity4_desc = ?, activity4_hours = ?, activity4_project_number = ?, activity4_desc = ?, activity4_hours = ?, activity4_project_number = ?,
activity5_desc = ?, activity5_hours = ?, activity5_project_number = ?, activity5_desc = ?, activity5_hours = ?, activity5_project_number = ?,
overtime_taken_hours = ?, vacation_type = ?, sick_status = ?, overtime_taken_hours = ?, vacation_type = ?, sick_status = ?,
weekend_travel = ?, applied_weekend_percentage = ?,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = ?`, WHERE id = ?`,
[ [
@@ -132,6 +173,8 @@ function registerTimesheetRoutes(app) {
overtime_taken_hours ? parseFloat(overtime_taken_hours) : null, overtime_taken_hours ? parseFloat(overtime_taken_hours) : null,
vacation_type || null, vacation_type || null,
isSick ? 1 : 0, isSick ? 1 : 0,
weekendTravelValue,
finalAppliedPercentage,
row.id row.id
], ],
(err) => { (err) => {
@@ -150,8 +193,8 @@ function registerTimesheetRoutes(app) {
activity3_desc, activity3_hours, activity3_project_number, activity3_desc, activity3_hours, activity3_project_number,
activity4_desc, activity4_hours, activity4_project_number, activity4_desc, activity4_hours, activity4_project_number,
activity5_desc, activity5_hours, activity5_project_number, activity5_desc, activity5_hours, activity5_project_number,
overtime_taken_hours, vacation_type, sick_status) overtime_taken_hours, vacation_type, sick_status, weekend_travel, applied_weekend_percentage)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
userId, date, finalStartTime, finalEndTime, break_minutes, total_hours, notes, userId, date, finalStartTime, finalEndTime, break_minutes, total_hours, notes,
finalActivity1Desc || null, finalActivity1Hours, activity1_project_number || null, finalActivity1Desc || null, finalActivity1Hours, activity1_project_number || null,
@@ -161,7 +204,9 @@ function registerTimesheetRoutes(app) {
finalActivity5Desc || null, parseFloat(activity5_hours) || 0, activity5_project_number || null, finalActivity5Desc || null, parseFloat(activity5_hours) || 0, activity5_project_number || null,
overtime_taken_hours ? parseFloat(overtime_taken_hours) : null, overtime_taken_hours ? parseFloat(overtime_taken_hours) : null,
vacation_type || null, vacation_type || null,
isSick ? 1 : 0 isSick ? 1 : 0,
weekendTravelValue,
appliedWeekendPercentage
], ],
(err) => { (err) => {
if (err) { if (err) {
@@ -368,6 +413,40 @@ function registerTimesheetRoutes(app) {
}); });
}); });
// API: Neueste eingereichte Version für eine Woche abrufen
app.get('/api/timesheet/latest-submitted/:weekStart', requireAuth, (req, res) => {
const userId = req.session.userId;
const weekStart = req.params.weekStart;
// Berechne Wochenende
const startDate = new Date(weekStart);
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + 6);
const weekEnd = endDate.toISOString().split('T')[0];
// Hole die neueste eingereichte Version für diese Woche
db.get(`SELECT id, version, submitted_at FROM weekly_timesheets
WHERE user_id = ? AND week_start = ? AND week_end = ?
ORDER BY version DESC LIMIT 1`,
[userId, weekStart, weekEnd],
(err, result) => {
if (err) {
console.error('Fehler beim Abrufen der neuesten Version:', err);
return res.status(500).json({ error: 'Fehler beim Abrufen der Version' });
}
if (result) {
res.json({
timesheetId: result.id,
version: result.version,
submitted_at: result.submitted_at
});
} else {
res.json({ timesheetId: null, version: null, submitted_at: null });
}
});
});
// API: PDF Download-Info abrufen // API: PDF Download-Info abrufen
app.get('/api/timesheet/download-info/:id', requireVerwaltung, (req, res) => { app.get('/api/timesheet/download-info/:id', requireVerwaltung, (req, res) => {
const timesheetId = req.params.id; const timesheetId = req.params.id;
@@ -398,9 +477,24 @@ function registerTimesheetRoutes(app) {
}); });
// API: PDF generieren // API: PDF generieren
app.get('/api/timesheet/pdf/:id', requireVerwaltung, (req, res) => { app.get('/api/timesheet/pdf/:id', requireAuth, (req, res) => {
const timesheetId = req.params.id; const timesheetId = req.params.id;
const userId = req.session.userId;
const isVerwaltung = hasRole(req, 'verwaltung') || hasRole(req, 'admin');
// Prüfe ob User Verwaltung/Admin ist oder ob das Timesheet dem User gehört
db.get(`SELECT user_id FROM weekly_timesheets WHERE id = ?`, [timesheetId], (err, timesheet) => {
if (err || !timesheet) {
return res.status(404).send('Stundenzettel nicht gefunden');
}
// Zugriff erlauben wenn Verwaltung/Admin ODER wenn Timesheet dem User gehört
if (isVerwaltung || timesheet.user_id === userId) {
generatePDF(timesheetId, req, res); generatePDF(timesheetId, req, res);
} else {
res.status(403).send('Zugriff verweigert');
}
});
}); });
} }

View File

@@ -223,7 +223,7 @@ function registerUserRoutes(app) {
} }
// User-Daten abrufen // User-Daten abrufen
db.get('SELECT wochenstunden, urlaubstage, overtime_offset_hours FROM users WHERE id = ?', [userId], (err, user) => { db.get('SELECT wochenstunden, urlaubstage, overtime_offset_hours, vacation_offset_days FROM users WHERE id = ?', [userId], (err, user) => {
if (err || !user) { if (err || !user) {
return res.status(500).json({ error: 'Fehler beim Abrufen der User-Daten' }); return res.status(500).json({ error: 'Fehler beim Abrufen der User-Daten' });
} }
@@ -231,6 +231,7 @@ function registerUserRoutes(app) {
const wochenstunden = user.wochenstunden || 0; const wochenstunden = user.wochenstunden || 0;
const urlaubstage = user.urlaubstage || 0; const urlaubstage = user.urlaubstage || 0;
const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0; const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0;
const vacationOffsetDays = user.vacation_offset_days ? parseFloat(user.vacation_offset_days) : 0;
// Verplante Urlaubstage berechnen (alle Wochen, auch nicht-eingereichte) // Verplante Urlaubstage berechnen (alle Wochen, auch nicht-eingereichte)
const { getCalendarWeek } = require('../helpers/utils'); const { getCalendarWeek } = require('../helpers/utils');
@@ -282,14 +283,15 @@ function registerUserRoutes(app) {
if (!weeks || weeks.length === 0) { if (!weeks || weeks.length === 0) {
return res.json({ return res.json({
currentOvertime: overtimeOffsetHours, currentOvertime: overtimeOffsetHours,
remainingVacation: urlaubstage, remainingVacation: urlaubstage + vacationOffsetDays,
totalOvertimeHours: 0, totalOvertimeHours: 0,
totalOvertimeTaken: 0, totalOvertimeTaken: 0,
totalVacationDays: 0, totalVacationDays: 0,
plannedVacationDays: plannedVacationDays, plannedVacationDays: plannedVacationDays,
plannedWeeks: plannedWeeks, plannedWeeks: plannedWeeks,
urlaubstage: urlaubstage, urlaubstage: urlaubstage,
overtimeOffsetHours: overtimeOffsetHours overtimeOffsetHours: overtimeOffsetHours,
vacationOffsetDays: vacationOffsetDays
}); });
} }
@@ -381,7 +383,7 @@ function registerUserRoutes(app) {
processedWeeks++; processedWeeks++;
if (processedWeeks === weeks.length && !hasError) { if (processedWeeks === weeks.length && !hasError) {
const currentOvertime = (totalOvertimeHours - totalOvertimeTaken) + overtimeOffsetHours; const currentOvertime = (totalOvertimeHours - totalOvertimeTaken) + overtimeOffsetHours;
const remainingVacation = urlaubstage - totalVacationDays; const remainingVacation = urlaubstage - totalVacationDays + vacationOffsetDays;
res.json({ res.json({
currentOvertime: currentOvertime, currentOvertime: currentOvertime,
@@ -392,7 +394,8 @@ function registerUserRoutes(app) {
plannedVacationDays: plannedVacationDays, plannedVacationDays: plannedVacationDays,
plannedWeeks: plannedWeeks, plannedWeeks: plannedWeeks,
urlaubstage: urlaubstage, urlaubstage: urlaubstage,
overtimeOffsetHours: overtimeOffsetHours overtimeOffsetHours: overtimeOffsetHours,
vacationOffsetDays: vacationOffsetDays
}); });
} }
return; // Überspringe diese Woche return; // Überspringe diese Woche
@@ -479,7 +482,7 @@ function registerUserRoutes(app) {
// weekOvertimeHours enthält bereits die korrekte Berechnung pro Woche (wie im Frontend) // weekOvertimeHours enthält bereits die korrekte Berechnung pro Woche (wie im Frontend)
// weekOvertimeTaken enthält die verbrauchten Überstunden (8 Stunden pro Tag mit 8 Überstunden) // weekOvertimeTaken enthält die verbrauchten Überstunden (8 Stunden pro Tag mit 8 Überstunden)
const currentOvertime = (totalOvertimeHours - totalOvertimeTaken) + overtimeOffsetHours; const currentOvertime = (totalOvertimeHours - totalOvertimeTaken) + overtimeOffsetHours;
const remainingVacation = urlaubstage - totalVacationDays; const remainingVacation = urlaubstage - totalVacationDays + vacationOffsetDays;
res.json({ res.json({
currentOvertime: currentOvertime, currentOvertime: currentOvertime,
@@ -490,7 +493,8 @@ function registerUserRoutes(app) {
plannedVacationDays: plannedVacationDays, plannedVacationDays: plannedVacationDays,
plannedWeeks: plannedWeeks, plannedWeeks: plannedWeeks,
urlaubstage: urlaubstage, urlaubstage: urlaubstage,
overtimeOffsetHours: overtimeOffsetHours overtimeOffsetHours: overtimeOffsetHours,
vacationOffsetDays: vacationOffsetDays
}); });
} }
}); // getHolidaysForDateRange.then }); // getHolidaysForDateRange.then

View File

@@ -12,7 +12,7 @@ function registerVerwaltungRoutes(app) {
// Verwaltungs-Bereich // Verwaltungs-Bereich
app.get('/verwaltung', requireVerwaltung, (req, res) => { app.get('/verwaltung', requireVerwaltung, (req, res) => {
db.all(` db.all(`
SELECT wt.*, u.firstname, u.lastname, u.username, u.personalnummer, u.wochenstunden, u.urlaubstage, u.overtime_offset_hours, SELECT wt.*, u.firstname, u.lastname, u.username, u.personalnummer, u.wochenstunden, u.urlaubstage, u.overtime_offset_hours, u.vacation_offset_days,
dl.firstname as downloaded_by_firstname, dl.firstname as downloaded_by_firstname,
dl.lastname as downloaded_by_lastname, dl.lastname as downloaded_by_lastname,
(SELECT COUNT(*) FROM weekly_timesheets wt2 (SELECT COUNT(*) FROM weekly_timesheets wt2
@@ -44,7 +44,8 @@ function registerVerwaltungRoutes(app) {
personalnummer: ts.personalnummer, personalnummer: ts.personalnummer,
wochenstunden: ts.wochenstunden, wochenstunden: ts.wochenstunden,
urlaubstage: ts.urlaubstage, urlaubstage: ts.urlaubstage,
overtime_offset_hours: ts.overtime_offset_hours overtime_offset_hours: ts.overtime_offset_hours,
vacation_offset_days: ts.vacation_offset_days
}, },
weeks: {} weeks: {}
}; };
@@ -64,6 +65,38 @@ function registerVerwaltungRoutes(app) {
groupedByEmployee[userId].weeks[weekKey].versions.push(ts); groupedByEmployee[userId].weeks[weekKey].versions.push(ts);
}); });
// Prüfe für jede Woche, ob nach dem letzten Download eine neue Version eingereicht wurde
Object.values(groupedByEmployee).forEach(employee => {
Object.values(employee.weeks).forEach(week => {
// Finde die neueste Version mit pdf_downloaded_at (letzter Download)
let lastDownloadTime = null;
week.versions.forEach(version => {
if (version.pdf_downloaded_at) {
const downloadTime = new Date(version.pdf_downloaded_at).getTime();
if (!lastDownloadTime || downloadTime > lastDownloadTime) {
lastDownloadTime = downloadTime;
}
}
});
// Prüfe, ob es eine Version gibt, die nach dem letzten Download eingereicht wurde
let hasNewVersionAfterDownload = false;
if (lastDownloadTime) {
week.versions.forEach(version => {
if (version.submitted_at) {
const submittedTime = new Date(version.submitted_at).getTime();
if (submittedTime > lastDownloadTime) {
hasNewVersionAfterDownload = true;
}
}
});
}
// Setze Flag auf dem week-Objekt
week.has_new_version_after_download = hasNewVersionAfterDownload;
});
});
// Sortierung: Mitarbeiter nach Name, Wochen nach Datum (neueste zuerst) // Sortierung: Mitarbeiter nach Name, Wochen nach Datum (neueste zuerst)
const sortedEmployees = Object.values(groupedByEmployee).map(employee => { const sortedEmployees = Object.values(groupedByEmployee).map(employee => {
// Wochen innerhalb jedes Mitarbeiters sortieren // Wochen innerhalb jedes Mitarbeiters sortieren
@@ -114,6 +147,26 @@ function registerVerwaltungRoutes(app) {
}); });
}); });
// API: Urlaubstage-Offset für einen User setzen (positiv/negativ)
app.put('/api/verwaltung/user/:id/vacation-offset', requireVerwaltung, (req, res) => {
const userId = req.params.id;
const raw = req.body ? req.body.vacation_offset_days : undefined;
// Leere Eingabe => 0
const normalized = (raw === '' || raw === null || raw === undefined) ? 0 : parseFloat(raw);
if (!Number.isFinite(normalized)) {
return res.status(400).json({ error: 'Ungültiger Urlaubstage-Offset' });
}
db.run('UPDATE users SET vacation_offset_days = ? WHERE id = ?', [normalized, userId], (err) => {
if (err) {
console.error('Fehler beim Speichern des Urlaubstage-Offsets:', err);
return res.status(500).json({ error: 'Fehler beim Speichern des Urlaubstage-Offsets' });
}
res.json({ success: true, vacation_offset_days: normalized });
});
});
// API: Krankheitstage für einen User im aktuellen Jahr abrufen // API: Krankheitstage für einen User im aktuellen Jahr abrufen
app.get('/api/verwaltung/user/:id/sick-days', requireVerwaltung, (req, res) => { app.get('/api/verwaltung/user/:id/sick-days', requireVerwaltung, (req, res) => {
const userId = req.params.id; const userId = req.params.id;
@@ -164,7 +217,7 @@ function registerVerwaltungRoutes(app) {
} }
// User-Daten abrufen // User-Daten abrufen
db.get('SELECT wochenstunden, urlaubstage, overtime_offset_hours FROM users WHERE id = ?', [userId], (err, user) => { db.get('SELECT wochenstunden, urlaubstage, overtime_offset_hours, vacation_offset_days FROM users WHERE id = ?', [userId], (err, user) => {
if (err || !user) { if (err || !user) {
return res.status(500).json({ error: 'Fehler beim Abrufen der User-Daten' }); return res.status(500).json({ error: 'Fehler beim Abrufen der User-Daten' });
} }
@@ -172,6 +225,7 @@ function registerVerwaltungRoutes(app) {
const wochenstunden = user.wochenstunden || 0; const wochenstunden = user.wochenstunden || 0;
const urlaubstage = user.urlaubstage || 0; const urlaubstage = user.urlaubstage || 0;
const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0; const overtimeOffsetHours = user.overtime_offset_hours ? parseFloat(user.overtime_offset_hours) : 0;
const vacationOffsetDays = user.vacation_offset_days ? parseFloat(user.vacation_offset_days) : 0;
// Einträge für die Woche abrufen // Einträge für die Woche abrufen
db.all(`SELECT date, total_hours, overtime_taken_hours, vacation_type, sick_status db.all(`SELECT date, total_hours, overtime_taken_hours, vacation_type, sick_status
@@ -251,7 +305,7 @@ function registerVerwaltungRoutes(app) {
const remainingOvertimeWithOffset = remainingOvertime + overtimeOffsetHours; const remainingOvertimeWithOffset = remainingOvertime + overtimeOffsetHours;
// Verbleibende Urlaubstage // Verbleibende Urlaubstage
const remainingVacation = urlaubstage - vacationDays; const remainingVacation = urlaubstage - vacationDays + vacationOffsetDays;
res.json({ res.json({
wochenstunden, wochenstunden,
@@ -264,6 +318,7 @@ function registerVerwaltungRoutes(app) {
overtimeOffsetHours, overtimeOffsetHours,
remainingOvertimeWithOffset, remainingOvertimeWithOffset,
vacationDays, vacationDays,
vacationOffsetDays,
remainingVacation, remainingVacation,
sickDays, sickDays,
workdays workdays

View File

@@ -30,13 +30,13 @@ app.use(session({
initDatabase(); initDatabase();
// Routes importieren und registrieren // Routes importieren und registrieren
const registerAuthRoutes = require('./routes/auth'); const registerAuthRoutes = require('./routes/auth-routes');
const registerDashboardRoutes = require('./routes/dashboard'); const registerDashboardRoutes = require('./routes/dashboard-routes');
const registerUserRoutes = require('./routes/user'); const registerUserRoutes = require('./routes/user-routes');
const registerAdminRoutes = require('./routes/admin'); const registerAdminRoutes = require('./routes/admin-routes');
const registerAdminLDAPRoutes = require('./routes/admin-ldap'); const registerAdminLDAPRoutes = require('./routes/admin-ldap-routes');
const registerVerwaltungRoutes = require('./routes/verwaltung'); const registerVerwaltungRoutes = require('./routes/verwaltung-routes');
const registerTimesheetRoutes = require('./routes/timesheet'); const registerTimesheetRoutes = require('./routes/timesheet-routes');
// Services importieren // Services importieren
const { setupPingService } = require('./services/ping-service'); const { setupPingService } = require('./services/ping-service');

View File

@@ -618,6 +618,12 @@ async function generateCheckinCheckoutQRPDF(req, res, urlType = 'internal') {
doc.fontSize(18).text(title, { align: 'center' }); doc.fontSize(18).text(title, { align: 'center' });
doc.moveDown(1.5); doc.moveDown(1.5);
const displayFirst = (req.session.firstname || '').trim();
const displayLast = (req.session.lastname || '').trim();
const displayName = [displayFirst, displayLast].filter(Boolean).join(' ') || 'Mitarbeiter';
doc.fontSize(12).font('Helvetica').text('Mitarbeiter: ' + displayName, { align: 'center' });
doc.moveDown(0.5);
const topY = doc.y; const topY = doc.y;
doc.image(checkinQRBuffer, left1, topY, { width: qrSize, height: qrSize }); doc.image(checkinQRBuffer, left1, topY, { width: qrSize, height: qrSize });
doc.image(checkoutQRBuffer, left2, topY, { width: qrSize, height: qrSize }); doc.image(checkoutQRBuffer, left2, topY, { width: qrSize, height: qrSize });

View File

@@ -55,7 +55,10 @@
</div> </div>
<div class="actions"> <div class="actions">
<div style="display: flex; gap: 10px; align-items: center;">
<button id="submitWeek" class="btn btn-success" onclick="window.submitWeekHandler(event)" disabled>Woche abschicken</button> <button id="submitWeek" class="btn btn-success" onclick="window.submitWeekHandler(event)" disabled>Woche abschicken</button>
<button id="viewPdfBtn" class="btn btn-info" disabled>PDF anzeigen</button>
</div>
<p class="help-text">Stunden werden automatisch gespeichert. Am Ende der Woche können Sie die Stunden abschicken.</p> <p class="help-text">Stunden werden automatisch gespeichert. Am Ende der Woche können Sie die Stunden abschicken.</p>
</div> </div>
</div> </div>

View File

@@ -111,6 +111,25 @@
Speichern Speichern
</button> </button>
</div> </div>
<div style="display: inline-flex; gap: 8px; align-items: center; margin-right: 20px;">
<strong>Urlaubstage-Offset:</strong>
<input
type="number"
step="0.5"
class="vacation-offset-input"
data-user-id="<%= employee.user.id %>"
value="<%= (employee.user.vacation_offset_days !== undefined && employee.user.vacation_offset_days !== null) ? employee.user.vacation_offset_days : 0 %>"
style="width: 90px; padding: 4px 6px; border: 1px solid #ddd; border-radius: 4px;"
title="Manuelle Korrektur (positiv oder negativ) in Tagen" />
<button
type="button"
class="btn btn-success btn-sm save-vacation-offset-btn"
data-user-id="<%= employee.user.id %>"
style="padding: 6px 10px; white-space: nowrap;"
title="Urlaubstage-Offset speichern">
Speichern
</button>
</div>
<div style="display: inline-block; margin-right: 20px;"> <div style="display: inline-block; margin-right: 20px;">
<strong>Kalenderwochen:</strong> <span><%= employee.weeks.length %></span> <strong>Kalenderwochen:</strong> <span><%= employee.weeks.length %></span>
</div> </div>
@@ -154,6 +173,11 @@
<div class="week-versions-info" style="margin-top: 5px;"> <div class="week-versions-info" style="margin-top: 5px;">
<span class="version-count"><%= week.total_versions %> Version<%= week.total_versions !== 1 ? 'en' : '' %></span> <span class="version-count"><%= week.total_versions %> Version<%= week.total_versions !== 1 ? 'en' : '' %></span>
</div> </div>
<% if (week.has_new_version_after_download) { %>
<div class="new-version-warning" style="margin-top: 10px;">
<strong>ACHTUNG: Neue version eingereicht</strong>
</div>
<% } %>
</div> </div>
<button class="btn btn-secondary btn-sm toggle-versions-btn" data-employee-index="<%= employeeIndex %>" data-week-index="<%= weekIndex %>"> <button class="btn btn-secondary btn-sm toggle-versions-btn" data-employee-index="<%= employeeIndex %>" data-week-index="<%= weekIndex %>">
<span class="toggle-icon">▼</span> Versionen <span class="toggle-icon">▼</span> Versionen
@@ -169,7 +193,6 @@
<th>Eingereicht am</th> <th>Eingereicht am</th>
<th>Grund</th> <th>Grund</th>
<th>Kommentar</th> <th>Kommentar</th>
<th>Status</th>
<th>Aktionen</th> <th>Aktionen</th>
</tr> </tr>
</thead> </thead>
@@ -225,7 +248,6 @@
</button> </button>
</div> </div>
</td> </td>
<td><span class="status-badge status-<%= ts.status %>"><%= ts.status %></span></td>
<td> <td>
<button class="btn btn-info btn-sm toggle-pdf-btn" data-timesheet-id="<%= ts.id %>"> <button class="btn btn-info btn-sm toggle-pdf-btn" data-timesheet-id="<%= ts.id %>">
<span class="arrow-icon">▶</span> PDF anzeigen <span class="arrow-icon">▶</span> PDF anzeigen
@@ -236,7 +258,7 @@
</td> </td>
</tr> </tr>
<tr class="pdf-preview-row" data-timesheet-id="<%= ts.id %>" style="display: none;"> <tr class="pdf-preview-row" data-timesheet-id="<%= ts.id %>" style="display: none;">
<td colspan="6"> <td colspan="5">
<div class="pdf-preview-container"> <div class="pdf-preview-container">
<div class="pdf-preview-header"> <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> <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>
@@ -317,6 +339,12 @@
${data.remainingVacation !== undefined ? `<span style="color: #28a745;">(verbleibend: ${data.remainingVacation.toFixed(1)} Tage)</span>` : ''} ${data.remainingVacation !== undefined ? `<span style="color: #28a745;">(verbleibend: ${data.remainingVacation.toFixed(1)} Tage)</span>` : ''}
</div>`; </div>`;
} }
if (data.vacationOffsetDays !== undefined && data.vacationOffsetDays !== 0) {
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
<strong>Urlaubstage-Offset:</strong> <span>${Number(data.vacationOffsetDays).toFixed(1)} Tag${Math.abs(data.vacationOffsetDays) !== 1 ? 'e' : ''}</span>
${data.remainingVacation !== undefined ? `<span style="color: #28a745;">(verbleibend inkl. Offset: ${Number(data.remainingVacation).toFixed(1)} Tage)</span>` : ''}
</div>`;
}
if (data.sickDays !== undefined && data.sickDays > 0) { if (data.sickDays !== undefined && data.sickDays > 0) {
statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;"> statsHTML += `<div class="stats-inline" style="display: inline-block; margin-right: 20px;">
<strong>Krankheitstage:</strong> <span style="color: #e74c3c;">${data.sickDays} Tag${data.sickDays !== 1 ? 'e' : ''}</span> <strong>Krankheitstage:</strong> <span style="color: #e74c3c;">${data.sickDays} Tag${data.sickDays !== 1 ? 'e' : ''}</span>
@@ -432,6 +460,68 @@
}); });
}); });
// Urlaubstage-Offset speichern
document.querySelectorAll('.save-vacation-offset-btn').forEach(btn => {
btn.addEventListener('click', async function() {
const userId = this.dataset.userId;
const input = document.querySelector(`.vacation-offset-input[data-user-id="${userId}"]`);
if (!input) return;
const originalText = this.textContent;
this.disabled = true;
this.textContent = '...';
// leere Eingabe => 0 (Backend macht das auch, aber UI soll sauber sein)
const raw = (input.value || '').trim();
const value = raw === '' ? '' : Number(raw);
try {
const resp = await fetch(`/api/verwaltung/user/${userId}/vacation-offset`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vacation_offset_days: value })
});
const data = await resp.json();
if (!resp.ok) {
alert(data.error || 'Fehler beim Speichern des Offsets');
return;
}
// Normalisiere Input auf Zahl (Backend gibt number zurück)
input.value = (data.vacation_offset_days !== undefined && data.vacation_offset_days !== null)
? Number(data.vacation_offset_days)
: 0;
// Stats für diesen User neu laden
const statDivs = document.querySelectorAll(`.group-stats[data-user-id="${userId}"]`);
statDivs.forEach(div => {
// loading indicator optional wieder anzeigen
const loading = div.querySelector('.stats-loading');
if (loading) {
loading.style.display = 'inline-block';
loading.style.color = '#666';
loading.textContent = 'Lade Statistiken...';
}
loadStatsForDiv(div);
});
this.textContent = '✓';
setTimeout(() => {
this.textContent = originalText;
this.disabled = false;
}, 900);
} catch (e) {
console.error('Fehler beim Speichern des Offsets:', e);
alert('Fehler beim Speichern des Offsets');
} finally {
if (this.textContent === '...') {
this.textContent = originalText;
this.disabled = false;
}
}
});
});
// Mitarbeiter-Gruppen auf-/zuklappen (zeigt/versteckt Wochen) // Mitarbeiter-Gruppen auf-/zuklappen (zeigt/versteckt Wochen)
document.querySelectorAll('.toggle-employee-btn').forEach(btn => { document.querySelectorAll('.toggle-employee-btn').forEach(btn => {
btn.addEventListener('click', function() { btn.addEventListener('click', function() {