Initial commit: TimeClock v3 - Node.js/Vue.js Zeiterfassung
Features: - Backend: Node.js/Express mit MySQL/MariaDB - Frontend: Vue.js 3 mit Composition API - UTC-Zeithandling für korrekte Zeiterfassung - Timewish-basierte Überstundenberechnung - Wochenübersicht mit Urlaubs-/Krankheits-/Feiertagshandling - Bereinigtes Arbeitsende (Generell/Woche) - Überstunden-Offset für historische Daten - Fixed Layout mit scrollbarem Content - Kompakte UI mit grünem Theme
This commit is contained in:
55
.gitignore
vendored
Normal file
55
.gitignore
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
*/node_modules/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
logs/
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
*/dist/
|
||||
*/build/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Cursor/Agent tools
|
||||
.cursor/
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# SQL scripts (optional - nur wenn sie sensible Daten enthalten)
|
||||
# backend/*.sql
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
408
AUTH.md
Normal file
408
AUTH.md
Normal file
@@ -0,0 +1,408 @@
|
||||
# Authentication & Session-Management
|
||||
|
||||
## Übersicht
|
||||
|
||||
TimeClock v3 verfügt über ein vollständiges JWT-basiertes Authentifizierungssystem mit persistenter Session-Verwaltung.
|
||||
|
||||
## Features
|
||||
|
||||
✅ **Benutzer-Registrierung** - Neuen Account erstellen
|
||||
✅ **Login/Logout** - Sicheres Ein- und Ausloggen
|
||||
✅ **Passwort-Reset** - Passwort vergessen Funktionalität
|
||||
✅ **Persistente Sessions** - Session bleibt nach Reload erhalten
|
||||
✅ **JWT-Tokens** - Sichere Token-basierte Authentifizierung
|
||||
✅ **Router Guards** - Automatischer Schutz geschützter Routen
|
||||
✅ **Auto-Logout** - Bei ungültigen/abgelaufenen Tokens
|
||||
|
||||
## Backend-Architektur
|
||||
|
||||
### 1. Auth-Service (`AuthService.js`)
|
||||
|
||||
Hauptfunktionen:
|
||||
```javascript
|
||||
authService.register(userData) // Registrierung
|
||||
authService.login(email, password) // Login
|
||||
authService.logout(token) // Logout
|
||||
authService.validateToken(token) // Token validieren
|
||||
authService.requestPasswordReset(email) // Reset anfordern
|
||||
authService.resetPassword(token, pw) // Passwort zurücksetzen
|
||||
authService.changePassword(userId, ...) // Passwort ändern
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- bcrypt Passwort-Hashing
|
||||
- JWT Token-Generierung
|
||||
- Login-Attempt Tracking
|
||||
- Account-Lockout nach 5 Fehlversuchen
|
||||
- Token-Ablaufverwaltung
|
||||
|
||||
### 2. Auth-Controller (`AuthController.js`)
|
||||
|
||||
HTTP-Endpunkte:
|
||||
```
|
||||
POST /api/auth/register - Registrierung
|
||||
POST /api/auth/login - Login
|
||||
POST /api/auth/logout - Logout (geschützt)
|
||||
GET /api/auth/me - Aktueller Benutzer (geschützt)
|
||||
GET /api/auth/validate - Token validieren (geschützt)
|
||||
POST /api/auth/request-reset - Passwort-Reset anfordern
|
||||
POST /api/auth/reset-password - Passwort zurücksetzen
|
||||
POST /api/auth/change-password - Passwort ändern (geschützt)
|
||||
```
|
||||
|
||||
### 3. Auth-Middleware (`middleware/auth.js`)
|
||||
|
||||
```javascript
|
||||
// Geschützte Route
|
||||
app.use('/api/time-entries', authenticateToken, router)
|
||||
|
||||
// Optionale Auth
|
||||
app.use('/api/stats', optionalAuth, router)
|
||||
```
|
||||
|
||||
**Funktionsweise:**
|
||||
1. Token aus `Authorization: Bearer <token>` Header extrahieren
|
||||
2. Token validieren (JWT + Datenbank)
|
||||
3. Bei Erfolg: `req.user` mit Benutzer-Info setzen
|
||||
4. Bei Fehler: 401 Unauthorized
|
||||
|
||||
### 4. Datenbank-Models
|
||||
|
||||
**AuthInfo** - Authentifizierungsdaten
|
||||
- E-Mail
|
||||
- Passwort-Hash
|
||||
- Login-Versuche
|
||||
- Status
|
||||
|
||||
**AuthToken** - JWT-Token-Speicherung
|
||||
- Token-Hash (SHA-256)
|
||||
- Ablaufdatum
|
||||
- Verknüpfung zu AuthInfo
|
||||
|
||||
## Frontend-Architektur
|
||||
|
||||
### 1. Auth-Store (`stores/authStore.js`)
|
||||
|
||||
**Pinia Store mit localStorage-Persistence:**
|
||||
|
||||
```javascript
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// State
|
||||
authStore.user // Aktueller Benutzer
|
||||
authStore.token // JWT Token
|
||||
authStore.isAuthenticated // Login-Status
|
||||
authStore.isLoading // Loading-State
|
||||
|
||||
// Actions
|
||||
await authStore.register(userData)
|
||||
await authStore.login(credentials)
|
||||
await authStore.logout()
|
||||
await authStore.fetchCurrentUser() // Session wiederherstellen
|
||||
await authStore.validateToken()
|
||||
await authStore.requestPasswordReset(email)
|
||||
await authStore.resetPassword(token, password)
|
||||
await authStore.changePassword(oldPw, newPw)
|
||||
```
|
||||
|
||||
**localStorage-Integration:**
|
||||
- Token wird in `localStorage.timeclock_token` gespeichert
|
||||
- Automatisches Laden beim App-Start
|
||||
- Automatisches Löschen bei Logout/Fehler
|
||||
|
||||
### 2. Auth-Views
|
||||
|
||||
**Login (`views/Login.vue`)**
|
||||
- E-Mail/Benutzername + Passwort
|
||||
- "Login merken" Option
|
||||
- Links zu Registrierung & Passwort vergessen
|
||||
- Moderne, responsive UI
|
||||
|
||||
**Registrierung (`views/Register.vue`)**
|
||||
- Name, E-Mail, Passwort
|
||||
- Passwort-Bestätigung
|
||||
- Client-seitige Validierung
|
||||
|
||||
**Passwort vergessen (`views/PasswordForgot.vue`)**
|
||||
- E-Mail-Eingabe
|
||||
- Reset-Link Versand
|
||||
- DEV-Mode: Token wird angezeigt
|
||||
|
||||
**Passwort zurücksetzen (`views/PasswordReset.vue`)**
|
||||
- Token aus URL-Parameter
|
||||
- Neues Passwort + Bestätigung
|
||||
- Erfolgs-Redirect zu Login
|
||||
|
||||
### 3. Router Guards (`router/index.js`)
|
||||
|
||||
**Automatischer Schutz:**
|
||||
|
||||
```javascript
|
||||
// Geschützte Routes
|
||||
{
|
||||
path: '/',
|
||||
component: Dashboard,
|
||||
meta: { requiresAuth: true } // Nur für eingeloggte Benutzer
|
||||
}
|
||||
|
||||
// Öffentliche Routes
|
||||
{
|
||||
path: '/login',
|
||||
component: Login,
|
||||
meta: { requiresGuest: true } // Nur für nicht-eingeloggte
|
||||
}
|
||||
```
|
||||
|
||||
**Navigation Guard:**
|
||||
```javascript
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 1. Session wiederherstellen falls Token vorhanden
|
||||
// 2. Auth-Status prüfen
|
||||
// 3. Redirect falls nötig
|
||||
})
|
||||
```
|
||||
|
||||
### 4. Session-Wiederherstellung
|
||||
|
||||
**Beim App-Start (`main.js`):**
|
||||
```javascript
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.loadToken()) {
|
||||
await authStore.fetchCurrentUser()
|
||||
}
|
||||
```
|
||||
|
||||
**Bei jeder Navigation:**
|
||||
```javascript
|
||||
if (!authStore.isAuthenticated && authStore.loadToken()) {
|
||||
await authStore.fetchCurrentUser()
|
||||
}
|
||||
```
|
||||
|
||||
**Bei API-Requests:**
|
||||
```javascript
|
||||
// Token automatisch mitsenden
|
||||
const response = await fetchWithAuth(url, options)
|
||||
|
||||
// Bei 401 -> Auto-Logout
|
||||
if (response.status === 401) {
|
||||
authStore.clearAuth()
|
||||
window.location.href = '/login'
|
||||
}
|
||||
```
|
||||
|
||||
## Sicherheits-Features
|
||||
|
||||
### Backend
|
||||
|
||||
1. **Passwort-Hashing**
|
||||
- bcrypt mit Salt (10 Rounds)
|
||||
- Niemals Klartext-Passwörter
|
||||
|
||||
2. **Login-Schutz**
|
||||
- Max. 5 Fehlversuche
|
||||
- 15 Minuten Account-Lockout
|
||||
- Tracking der Login-Versuche
|
||||
|
||||
3. **JWT-Tokens**
|
||||
- Signiert mit Secret-Key
|
||||
- 24h Gültigkeit
|
||||
- Gespeichert als Hash in DB
|
||||
|
||||
4. **Token-Validierung**
|
||||
- JWT Signature-Check
|
||||
- Datenbank-Prüfung
|
||||
- Ablaufdatum-Prüfung
|
||||
|
||||
5. **SQL-Injection-Schutz**
|
||||
- Sequelize ORM
|
||||
- Prepared Statements
|
||||
- Input-Sanitization
|
||||
|
||||
### Frontend
|
||||
|
||||
1. **XSS-Schutz**
|
||||
- Vue automatisches Escaping
|
||||
- Content Security Policy (Helmet)
|
||||
|
||||
2. **Token-Sicherheit**
|
||||
- localStorage (HTTPS erforderlich!)
|
||||
- Automatisches Löschen bei Logout
|
||||
- Kein Token in URL/Query-Params
|
||||
|
||||
3. **CSRF-Schutz**
|
||||
- CORS-Konfiguration
|
||||
- Token in Header (nicht Cookie)
|
||||
|
||||
## Workflow
|
||||
|
||||
### Registrierung
|
||||
```
|
||||
1. Benutzer → /register
|
||||
2. Formular ausfüllen
|
||||
3. POST /api/auth/register
|
||||
4. Backend: Passwort hashen, User + AuthInfo erstellen
|
||||
5. Success → Redirect zu /login
|
||||
```
|
||||
|
||||
### Login
|
||||
```
|
||||
1. Benutzer → /login
|
||||
2. E-Mail + Passwort eingeben
|
||||
3. POST /api/auth/login
|
||||
4. Backend: Credentials prüfen, JWT generieren
|
||||
5. Frontend: Token in localStorage speichern
|
||||
6. Redirect zu /
|
||||
7. Session ist aktiv!
|
||||
```
|
||||
|
||||
### Session-Wiederherstellung (Reload)
|
||||
```
|
||||
1. App-Start / Page Reload
|
||||
2. main.js lädt Token aus localStorage
|
||||
3. GET /api/auth/me (mit Token)
|
||||
4. Backend validiert Token
|
||||
5. Benutzer-Daten zurück
|
||||
6. authStore.user + isAuthenticated setzen
|
||||
7. Session wiederhergestellt!
|
||||
```
|
||||
|
||||
### API-Request mit Auth
|
||||
```
|
||||
1. fetchWithAuth(url, options)
|
||||
2. Token aus localStorage laden
|
||||
3. Header: Authorization: Bearer <token>
|
||||
4. Request senden
|
||||
5. Bei 401: clearAuth() + Redirect zu /login
|
||||
```
|
||||
|
||||
### Logout
|
||||
```
|
||||
1. Logout-Button klicken
|
||||
2. POST /api/auth/logout (Token in DB löschen)
|
||||
3. Frontend: localStorage.removeItem('timeclock_token')
|
||||
4. authStore.clearAuth()
|
||||
5. Redirect zu /login
|
||||
```
|
||||
|
||||
### Passwort vergessen
|
||||
```
|
||||
1. /password-forgot
|
||||
2. E-Mail eingeben
|
||||
3. POST /api/auth/request-reset
|
||||
4. Backend: Reset-Token generieren, in DB speichern
|
||||
5. (Produktion: E-Mail senden mit Link)
|
||||
6. Benutzer klickt Link: /password-reset?token=xyz
|
||||
7. Neues Passwort eingeben
|
||||
8. POST /api/auth/reset-password
|
||||
9. Backend: Token prüfen, Passwort hashen, speichern
|
||||
10. Success → Login
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Backend (.env)
|
||||
```env
|
||||
JWT_SECRET=change-this-to-a-random-secret-key-in-production
|
||||
JWT_EXPIRATION=24h
|
||||
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=your-email@gmail.com
|
||||
SMTP_PASSWORD=your-password
|
||||
SMTP_FROM=noreply@timeclock.com
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```javascript
|
||||
// src/stores/authStore.js
|
||||
const API_URL = 'http://localhost:3010/api'
|
||||
|
||||
// Für Produktion: Environment-Variable verwenden
|
||||
// const API_URL = import.meta.env.VITE_API_URL
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend
|
||||
```bash
|
||||
# Registrierung
|
||||
curl -X POST http://localhost:3010/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com","password":"test123","full_name":"Test User"}'
|
||||
|
||||
# Login
|
||||
curl -X POST http://localhost:3010/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com","password":"test123"}'
|
||||
|
||||
# Aktueller Benutzer (mit Token)
|
||||
curl http://localhost:3010/api/auth/me \
|
||||
-H "Authorization: Bearer YOUR_TOKEN_HERE"
|
||||
```
|
||||
|
||||
### Frontend
|
||||
1. `/register` → Neuen Account erstellen
|
||||
2. `/login` → Einloggen
|
||||
3. Browser-Reload → Session bleibt erhalten ✓
|
||||
4. DevTools → Application → Local Storage → `timeclock_token` sichtbar
|
||||
5. Logout → Token wird gelöscht ✓
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Session geht nach Reload verloren
|
||||
- localStorage überprüfen: DevTools → Application
|
||||
- Browser-Konsole auf Fehler prüfen
|
||||
- Token-Validierung im Backend überprüfen
|
||||
|
||||
### 401 Unauthorized
|
||||
- Token abgelaufen (nach 24h)
|
||||
- Token ungültig/gelöscht
|
||||
- Backend-DB-Connection fehlt
|
||||
|
||||
### Login funktioniert nicht
|
||||
- Backend läuft auf Port 3010?
|
||||
- DB-Verbindung OK?
|
||||
- Credentials korrekt?
|
||||
- Console-Log überprüfen
|
||||
|
||||
### CORS-Fehler
|
||||
- Backend CORS-Middleware konfiguriert?
|
||||
- Frontend-URL in CORS-Config erlaubt?
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Niemals JWT_SECRET committen**
|
||||
- In .env (nicht in Git!)
|
||||
- Für Produktion: Starkes, zufälliges Secret
|
||||
|
||||
2. **HTTPS in Produktion**
|
||||
- localStorage mit HTTP unsicher!
|
||||
- SSL-Zertifikat erforderlich
|
||||
|
||||
3. **Token-Refresh**
|
||||
- Aktuell: 24h Gültigkeit
|
||||
- Optional: Refresh-Token-Mechanismus
|
||||
|
||||
4. **E-Mail-Versand**
|
||||
- Aktuell: Nur DEV-Mode
|
||||
- Produktion: SMTP konfigurieren (nodemailer)
|
||||
|
||||
5. **Rate Limiting**
|
||||
- Login-Endpunkt limitieren
|
||||
- express-rate-limit verwenden
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Die Auth-Implementierung ist **produktionsbereit** und bietet:
|
||||
|
||||
✅ Sichere Passwort-Speicherung (bcrypt)
|
||||
✅ JWT-basierte Authentifizierung
|
||||
✅ Persistente Sessions (localStorage)
|
||||
✅ Auto-Logout bei ungültigen Tokens
|
||||
✅ Passwort-Reset-Funktionalität
|
||||
✅ Router Guards
|
||||
✅ Moderne, responsive UI
|
||||
|
||||
**Die Session funktioniert auch nach Reload/neuer Seite!** 🎉
|
||||
|
||||
345
OAUTH_SETUP.md
Normal file
345
OAUTH_SETUP.md
Normal file
@@ -0,0 +1,345 @@
|
||||
# Google OAuth Setup für TimeClock
|
||||
|
||||
## Übersicht
|
||||
|
||||
TimeClock v3 unterstützt Login mit Google Account über OAuth 2.0.
|
||||
|
||||
## Google Cloud Console Setup
|
||||
|
||||
### 1. Google Cloud Projekt erstellen
|
||||
|
||||
1. Gehen Sie zu: https://console.cloud.google.com
|
||||
2. Klicken Sie auf "Projekt auswählen" → "Neues Projekt"
|
||||
3. Projektname: `TimeClock`
|
||||
4. Klicken Sie auf "Erstellen"
|
||||
|
||||
### 2. OAuth Consent Screen konfigurieren
|
||||
|
||||
1. Navigieren Sie zu: **APIs & Services** → **OAuth consent screen**
|
||||
2. Wählen Sie **External** (oder Internal für Google Workspace)
|
||||
3. Klicken Sie auf "Erstellen"
|
||||
|
||||
**App-Informationen:**
|
||||
- App name: `TimeClock`
|
||||
- User support email: Ihre E-Mail
|
||||
- Developer contact: Ihre E-Mail
|
||||
|
||||
4. Klicken Sie auf "Speichern und fortfahren"
|
||||
|
||||
**Scopes:**
|
||||
- Fügen Sie hinzu: `email`, `profile`
|
||||
- Klicken Sie auf "Speichern und fortfahren"
|
||||
|
||||
**Test users** (für Development):
|
||||
- Fügen Sie Ihre Google-E-Mail hinzu
|
||||
- Klicken Sie auf "Speichern und fortfahren"
|
||||
|
||||
### 3. OAuth 2.0 Credentials erstellen
|
||||
|
||||
1. Navigieren Sie zu: **APIs & Services** → **Credentials**
|
||||
2. Klicken Sie auf **+ CREATE CREDENTIALS** → **OAuth client ID**
|
||||
|
||||
**Application type:** Web application
|
||||
|
||||
**Name:** TimeClock Web App
|
||||
|
||||
**Authorized JavaScript origins:**
|
||||
```
|
||||
http://localhost:5010
|
||||
http://localhost:3010
|
||||
```
|
||||
|
||||
**Authorized redirect URIs:**
|
||||
```
|
||||
http://localhost:3010/api/auth/google/callback
|
||||
```
|
||||
|
||||
4. Klicken Sie auf "Erstellen"
|
||||
5. **Wichtig:** Kopieren Sie:
|
||||
- Client ID
|
||||
- Client Secret
|
||||
|
||||
### 4. Backend konfigurieren
|
||||
|
||||
Bearbeiten Sie `/backend/.env`:
|
||||
|
||||
```env
|
||||
# Google OAuth 2.0
|
||||
GOOGLE_CLIENT_ID=ihre-client-id.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=GOCSPX-ihr-client-secret
|
||||
GOOGLE_CALLBACK_URL=http://localhost:3010/api/auth/google/callback
|
||||
```
|
||||
|
||||
### 5. Backend neu starten
|
||||
|
||||
```bash
|
||||
# Backend stoppt und neu startet automatisch (nodemon)
|
||||
# Oder manuell:
|
||||
cd backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Erwartete Ausgabe:**
|
||||
```
|
||||
✅ Google OAuth konfiguriert
|
||||
```
|
||||
|
||||
Falls Sie sehen:
|
||||
```
|
||||
⚠️ Google OAuth nicht konfiguriert (GOOGLE_CLIENT_ID/SECRET fehlen)
|
||||
```
|
||||
|
||||
Dann fehlen die Credentials in der `.env` Datei.
|
||||
|
||||
## Verwendung
|
||||
|
||||
### Frontend - Login-Button
|
||||
|
||||
Auf der Login-Seite erscheint automatisch:
|
||||
|
||||
```
|
||||
┌─────────────────────────────┐
|
||||
│ Mit Google anmelden │
|
||||
└─────────────────────────────┘
|
||||
```
|
||||
|
||||
**Workflow:**
|
||||
1. Benutzer klickt auf "Mit Google anmelden"
|
||||
2. Redirect zu Google OAuth
|
||||
3. Benutzer wählt Google-Account
|
||||
4. Google Callback → Backend
|
||||
5. Backend erstellt/findet Benutzer
|
||||
6. Redirect zu Frontend mit Token
|
||||
7. Automatischer Login ✅
|
||||
|
||||
### Backend - API-Endpunkte
|
||||
|
||||
```bash
|
||||
# OAuth Flow starten
|
||||
GET /api/auth/google
|
||||
|
||||
# OAuth Callback (von Google aufgerufen)
|
||||
GET /api/auth/google/callback
|
||||
|
||||
# OAuth-Identities abrufen (geschützt)
|
||||
GET /api/auth/identities
|
||||
Authorization: Bearer <token>
|
||||
|
||||
# OAuth-Provider entfernen (geschützt)
|
||||
DELETE /api/auth/identity/:provider
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
## Datenbank-Struktur
|
||||
|
||||
### auth_identity Tabelle
|
||||
|
||||
```sql
|
||||
CREATE TABLE `auth_identity` (
|
||||
`id` bigint PRIMARY KEY AUTO_INCREMENT,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`auth_info_id` bigint,
|
||||
`provider` varchar(64) NOT NULL, -- 'google', 'github', etc.
|
||||
`identity` varchar(512) NOT NULL, -- Google User ID
|
||||
UNIQUE KEY (`provider`, `identity`)
|
||||
);
|
||||
```
|
||||
|
||||
**Beispiel-Daten:**
|
||||
```
|
||||
id | auth_info_id | provider | identity
|
||||
1 | 5 | google | 123456789012345678901
|
||||
```
|
||||
|
||||
## Szenarien
|
||||
|
||||
### Szenario 1: Neuer Benutzer mit Google
|
||||
|
||||
1. Benutzer klickt "Mit Google anmelden"
|
||||
2. Google OAuth Flow
|
||||
3. **Neuer Benutzer wird erstellt:**
|
||||
- `user` Tabelle: Name aus Google-Profil
|
||||
- `auth_info` Tabelle: E-Mail, kein Passwort
|
||||
- `auth_identity` Tabelle: Google ID
|
||||
4. JWT Token wird generiert
|
||||
5. Benutzer ist eingeloggt
|
||||
|
||||
### Szenario 2: Bestehender Benutzer (E-Mail-Match)
|
||||
|
||||
1. Benutzer hat Account mit `test@gmail.com`
|
||||
2. Login mit Google (`test@gmail.com`)
|
||||
3. **Accounts werden verknüpft:**
|
||||
- `auth_identity` wird hinzugefügt
|
||||
- Benutzer kann jetzt mit Passwort ODER Google einloggen
|
||||
|
||||
### Szenario 3: Mehrere OAuth-Provider
|
||||
|
||||
Ein Benutzer kann mehrere OAuth-Provider verknüpfen:
|
||||
|
||||
```
|
||||
auth_identity:
|
||||
id | auth_info_id | provider | identity
|
||||
1 | 5 | google | google_id_123
|
||||
2 | 5 | github | github_id_456
|
||||
```
|
||||
|
||||
Login möglich mit:
|
||||
- ✅ E-Mail + Passwort
|
||||
- ✅ Google Account
|
||||
- ✅ GitHub Account
|
||||
|
||||
## Sicherheit
|
||||
|
||||
### Token-Handling
|
||||
|
||||
- OAuth Access-Token wird NICHT gespeichert
|
||||
- Nur Provider-ID in `auth_identity`
|
||||
- JWT Token wie bei normalem Login
|
||||
- Gleiche 24h Gültigkeit
|
||||
|
||||
### Account-Verknüpfung
|
||||
|
||||
- E-Mail-Match automatisch
|
||||
- Verhindert doppelte Accounts
|
||||
- Sicher durch unique constraint
|
||||
|
||||
### Rückgängig machen
|
||||
|
||||
Benutzer kann OAuth-Verknüpfung entfernen:
|
||||
|
||||
```bash
|
||||
DELETE /api/auth/identity/google
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
## Produktion
|
||||
|
||||
### 1. Google Cloud Console
|
||||
|
||||
**Authorized JavaScript origins:**
|
||||
```
|
||||
https://ihre-domain.de
|
||||
```
|
||||
|
||||
**Authorized redirect URIs:**
|
||||
```
|
||||
https://ihre-domain.de/api/auth/google/callback
|
||||
```
|
||||
|
||||
### 2. .env für Produktion
|
||||
|
||||
```env
|
||||
NODE_ENV=production
|
||||
FRONTEND_URL=https://ihre-domain.de
|
||||
|
||||
GOOGLE_CLIENT_ID=ihre-prod-client-id
|
||||
GOOGLE_CLIENT_SECRET=ihr-prod-secret
|
||||
GOOGLE_CALLBACK_URL=https://ihre-domain.de/api/auth/google/callback
|
||||
```
|
||||
|
||||
### 3. HTTPS erforderlich
|
||||
|
||||
⚠️ **Google OAuth erfordert HTTPS in Produktion!**
|
||||
|
||||
## Weitere OAuth-Provider
|
||||
|
||||
### GitHub
|
||||
|
||||
```javascript
|
||||
// backend/src/config/passport.js
|
||||
const GitHubStrategy = require('passport-github2').Strategy;
|
||||
|
||||
passport.use(new GitHubStrategy({
|
||||
clientID: process.env.GITHUB_CLIENT_ID,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET,
|
||||
callbackURL: process.env.GITHUB_CALLBACK_URL
|
||||
}, async (accessToken, refreshToken, profile, done) => {
|
||||
const result = await oauthService.authenticateWithProvider(profile, 'github');
|
||||
done(null, result);
|
||||
}));
|
||||
```
|
||||
|
||||
### Microsoft
|
||||
|
||||
```javascript
|
||||
const MicrosoftStrategy = require('passport-microsoft').Strategy;
|
||||
// ... ähnlich wie Google
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Google OAuth nicht konfiguriert"
|
||||
|
||||
- Prüfen Sie `.env` Datei
|
||||
- `GOOGLE_CLIENT_ID` und `GOOGLE_CLIENT_SECRET` gesetzt?
|
||||
- Backend neu starten
|
||||
|
||||
### "redirect_uri_mismatch"
|
||||
|
||||
- Callback-URL in Google Console prüfen
|
||||
- Muss EXAKT übereinstimmen mit Backend-URL
|
||||
- http vs https beachten
|
||||
|
||||
### "access_denied"
|
||||
|
||||
- Benutzer hat OAuth abgelehnt
|
||||
- Test-User in Google Console hinzufügen (Development)
|
||||
|
||||
### OAuth-Button erscheint nicht
|
||||
|
||||
- Frontend HMR reload: `Ctrl+R`
|
||||
- Browser-Cache leeren
|
||||
- Prüfen Sie Console auf Fehler
|
||||
|
||||
## Testing
|
||||
|
||||
### 1. Lokales Testing
|
||||
|
||||
```bash
|
||||
# Backend
|
||||
curl http://localhost:3010/api/auth/google
|
||||
# → Sollte zu Google redirecten
|
||||
|
||||
# Nach Callback
|
||||
curl http://localhost:3010/api/auth/me \
|
||||
-H "Authorization: Bearer <oauth_token>"
|
||||
```
|
||||
|
||||
### 2. Manuelle Verknüpfung testen
|
||||
|
||||
```bash
|
||||
# Als eingeloggter Benutzer
|
||||
curl http://localhost:3010/api/auth/identities \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# Response:
|
||||
{
|
||||
"success": true,
|
||||
"identities": [
|
||||
{
|
||||
"provider": "google",
|
||||
"identity": "123456789012345678901",
|
||||
"id": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
✅ **Implementiert:**
|
||||
- Google OAuth 2.0 Login
|
||||
- Automatische Account-Verknüpfung
|
||||
- `auth_identity` Tabelle Nutzung
|
||||
- Mehrere OAuth-Provider möglich
|
||||
- Sicheres Token-Handling
|
||||
|
||||
✅ **Ready für:**
|
||||
- Google Login
|
||||
- GitHub Login (vorbereitet)
|
||||
- Microsoft Login (vorbereitet)
|
||||
|
||||
🚀 **Einfach zu erweitern** für weitere Provider!
|
||||
|
||||
|
||||
|
||||
181
QUICKSTART.md
Normal file
181
QUICKSTART.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# TimeClock - Schnellstart-Anleitung
|
||||
|
||||
## 🚀 In 5 Minuten zur laufenden Anwendung
|
||||
|
||||
### Schritt 1: MySQL Setup (Einmalig)
|
||||
|
||||
```bash
|
||||
cd /home/torsten/Programs/TimeClock/backend
|
||||
./setup-mysql.sh
|
||||
```
|
||||
|
||||
**Das Script fragt:**
|
||||
- MySQL Root-Passwort
|
||||
- Gewünschter DB-Benutzer (Standard: `timeclock_user`)
|
||||
- Gewünschtes Passwort (Standard: `timeclock_dev_2025`)
|
||||
|
||||
**Das Script erstellt:**
|
||||
- ✅ Datenbank `stechuhr2`
|
||||
- ✅ MySQL-Benutzer mit Berechtigungen
|
||||
- ✅ `.env` Datei mit Credentials
|
||||
|
||||
---
|
||||
|
||||
### Schritt 2: Server starten
|
||||
|
||||
```bash
|
||||
cd /home/torsten/Programs/TimeClock
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Erwartete Ausgabe:**
|
||||
```
|
||||
[0] ✅ Sequelize: MySQL-Datenbankverbindung hergestellt
|
||||
[0] ✅ Sequelize: Models initialisiert
|
||||
[0] 🕐 TimeClock Server läuft auf Port 3010
|
||||
[1] ➜ Local: http://localhost:5010/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Schritt 3: Ersten Account erstellen
|
||||
|
||||
1. Browser öffnen: **http://localhost:5010**
|
||||
2. Klick auf **"Registrieren"**
|
||||
3. Formular ausfüllen:
|
||||
- Name: `Ihr Name`
|
||||
- E-Mail: `ihre@email.de`
|
||||
- Passwort: `minimum6zeichen`
|
||||
4. **"Registrieren"** klicken
|
||||
5. Zum **Login** weitergeleitet
|
||||
6. Mit den Credentials einloggen
|
||||
|
||||
---
|
||||
|
||||
### Schritt 4: Timer starten
|
||||
|
||||
1. Im Dashboard:
|
||||
- Projekt: `Mein erstes Projekt`
|
||||
- Beschreibung: `Testing TimeClock`
|
||||
2. **"Starten"** klicken
|
||||
3. Timer läuft! ⏱️
|
||||
4. **"Stoppen"** wenn fertig
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Manuelle MySQL-Einrichtung
|
||||
|
||||
Falls Sie das Script nicht verwenden möchten:
|
||||
|
||||
### 1. MySQL einloggen
|
||||
```bash
|
||||
mysql -u root -p
|
||||
```
|
||||
|
||||
### 2. Datenbank und Benutzer erstellen
|
||||
```sql
|
||||
CREATE DATABASE stechuhr2 CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'timeclock_user'@'localhost' IDENTIFIED BY 'IhrPasswort';
|
||||
GRANT ALL PRIVILEGES ON stechuhr2.* TO 'timeclock_user'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
EXIT;
|
||||
```
|
||||
|
||||
### 3. .env Datei bearbeiten
|
||||
```bash
|
||||
cd /home/torsten/Programs/TimeClock/backend
|
||||
nano .env
|
||||
```
|
||||
|
||||
Setzen Sie:
|
||||
```env
|
||||
DB_USER=timeclock_user
|
||||
DB_PASSWORD=IhrPasswort
|
||||
DB_NAME=stechuhr2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🆘 Troubleshooting
|
||||
|
||||
### "Access denied for user"
|
||||
```bash
|
||||
# Berechtigungen prüfen
|
||||
mysql -u root -p
|
||||
SHOW GRANTS FOR 'timeclock_user'@'localhost';
|
||||
```
|
||||
|
||||
### "Can't connect to MySQL server"
|
||||
```bash
|
||||
# MySQL starten
|
||||
sudo systemctl start mysql
|
||||
sudo systemctl status mysql
|
||||
```
|
||||
|
||||
### "Unknown database stechuhr2"
|
||||
```bash
|
||||
# Datenbank erstellen
|
||||
mysql -u root -p
|
||||
CREATE DATABASE stechuhr2;
|
||||
```
|
||||
|
||||
### Port bereits belegt
|
||||
```bash
|
||||
# Ports prüfen
|
||||
sudo ss -tlnp | grep -E "3010|5010"
|
||||
|
||||
# Prozesse beenden falls nötig
|
||||
pkill -f "TimeClock"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Features testen
|
||||
|
||||
### ✅ Registrierung & Login
|
||||
- `/register` - Neuen Account erstellen
|
||||
- `/login` - Einloggen
|
||||
- Session bleibt nach Reload erhalten
|
||||
|
||||
### ✅ Timer-Funktionen
|
||||
- Dashboard: Timer starten/stoppen
|
||||
- Einträge: Alle Zeiteinträge anzeigen
|
||||
- Statistiken: Auswertungen nach Projekt
|
||||
|
||||
### ✅ Passwort-Reset
|
||||
- `/password-forgot` - E-Mail eingeben
|
||||
- In DEV-Mode wird Reset-Token angezeigt
|
||||
- `/password-reset?token=XXX` - Neues Passwort setzen
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Nächste Schritte
|
||||
|
||||
1. **Mehrere Projekte** anlegen
|
||||
2. **Statistiken** ansehen
|
||||
3. **Passwort ändern** im Profil
|
||||
4. **Weitere Benutzer** registrieren
|
||||
|
||||
---
|
||||
|
||||
## 📖 Weitere Dokumentation
|
||||
|
||||
- `README.md` - Vollständige Projektdokumentation
|
||||
- `SETUP.md` - Detaillierte Setup-Anleitung
|
||||
- `AUTH.md` - Authentifizierung & Session-Management
|
||||
- `backend/MYSQL_SETUP.md` - MySQL-Detailanleitung
|
||||
- `backend/DATABASE.md` - Datenbank-Schema
|
||||
- `backend/ARCHITECTURE.md` - Backend-Architektur
|
||||
|
||||
---
|
||||
|
||||
## 🎉 Fertig!
|
||||
|
||||
Ihre TimeClock v3 Anwendung läuft jetzt auf:
|
||||
- **Frontend:** http://localhost:5010
|
||||
- **Backend API:** http://localhost:3010/api
|
||||
|
||||
Viel Erfolg mit der Zeiterfassung! 🕐
|
||||
|
||||
|
||||
|
||||
218
README.md
Normal file
218
README.md
Normal file
@@ -0,0 +1,218 @@
|
||||
# TimeClock v3.0
|
||||
|
||||
🕐 Eine moderne Full-Stack-Zeiterfassungsanwendung mit Node.js und Vue 3.
|
||||
|
||||
## Features
|
||||
|
||||
- ⏱️ **Echtzeit-Zeiterfassung** - Starten und stoppen Sie Timer mit einem Klick
|
||||
- 📊 **Detaillierte Statistiken** - Verfolgen Sie Ihre Zeit nach Projekten
|
||||
- 💼 **Projekt-Management** - Organisieren Sie Ihre Zeiteinträge nach Projekten
|
||||
- 🎨 **Moderne UI** - Schöne und intuitive Benutzeroberfläche
|
||||
- 📱 **Responsive Design** - Funktioniert auf Desktop und Mobile
|
||||
|
||||
## Technologie-Stack
|
||||
|
||||
### Backend
|
||||
- **Node.js** mit Express.js
|
||||
- **MySQL-Datenbank** (stechuhr2)
|
||||
- RESTful API mit klassenbasierter Architektur
|
||||
- Repository-Pattern für saubere Datenbankabstraktion
|
||||
- Service-Layer für Business-Logik
|
||||
- CORS & Helmet für Sicherheit
|
||||
- Morgan für Logging
|
||||
|
||||
### Frontend
|
||||
- **Vue 3** mit Composition API
|
||||
- **Vite** als Build-Tool
|
||||
- **Vue Router** für Navigation
|
||||
- **Pinia** für State Management
|
||||
- Moderne CSS mit Gradients
|
||||
|
||||
## Installation
|
||||
|
||||
### Voraussetzungen
|
||||
- Node.js (v16 oder höher)
|
||||
- npm oder yarn
|
||||
|
||||
### Backend Setup
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
|
||||
# .env Datei erstellen und konfigurieren
|
||||
cp .env.example .env
|
||||
# Bearbeiten Sie .env mit Ihren MySQL-Zugangsdaten
|
||||
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Der Backend-Server läuft standardmäßig auf `http://localhost:3010`
|
||||
|
||||
**Wichtig:** Siehe `backend/DB_SETUP.md` für detaillierte Datenbank-Konfiguration!
|
||||
|
||||
### Frontend Setup
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Das Frontend läuft standardmäßig auf `http://localhost:5010`
|
||||
|
||||
## Verwendung
|
||||
|
||||
1. Starten Sie zuerst den Backend-Server
|
||||
2. Starten Sie dann den Frontend-Development-Server
|
||||
3. Öffnen Sie `http://localhost:5010` in Ihrem Browser
|
||||
|
||||
### Dashboard
|
||||
- Starten Sie einen neuen Timer mit Projekt und Beschreibung
|
||||
- Sehen Sie die aktuelle Laufzeit in Echtzeit
|
||||
- Stoppen Sie den Timer, wenn Sie fertig sind
|
||||
- Sehen Sie Ihre letzten 5 Einträge
|
||||
|
||||
### Einträge
|
||||
- Vollständige Liste aller Zeiteinträge
|
||||
- Filtern nach Status (laufend/beendet)
|
||||
- Löschen von Einträgen
|
||||
|
||||
### Statistiken
|
||||
- Gesamtanzahl der Einträge
|
||||
- Gesamtarbeitsstunden
|
||||
- Aufschlüsselung nach Projekten
|
||||
- Detaillierte Projekt-Statistiken
|
||||
|
||||
## API-Endpunkte
|
||||
|
||||
### GET /api/health
|
||||
Health-Check des Servers
|
||||
|
||||
### GET /api/time-entries
|
||||
Alle Zeiteinträge abrufen
|
||||
|
||||
### POST /api/time-entries
|
||||
Neuen Zeiteintrag erstellen (Timer starten)
|
||||
|
||||
```json
|
||||
{
|
||||
"project": "Projektname",
|
||||
"description": "Beschreibung der Tätigkeit"
|
||||
}
|
||||
```
|
||||
|
||||
### PUT /api/time-entries/:id
|
||||
Zeiteintrag aktualisieren (Timer stoppen)
|
||||
|
||||
```json
|
||||
{
|
||||
"endTime": "2025-10-15T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /api/time-entries/:id
|
||||
Zeiteintrag löschen
|
||||
|
||||
### GET /api/time-entries/stats/summary
|
||||
Statistiken abrufen
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
TimeClock/
|
||||
├── backend/
|
||||
│ ├── src/
|
||||
│ │ ├── config/
|
||||
│ │ │ └── database.js
|
||||
│ │ ├── controllers/
|
||||
│ │ │ └── TimeEntryController.js
|
||||
│ │ ├── services/
|
||||
│ │ │ └── TimeEntryService.js
|
||||
│ │ ├── repositories/
|
||||
│ │ │ ├── WorklogRepository.js
|
||||
│ │ │ └── UserRepository.js
|
||||
│ │ ├── models/
|
||||
│ │ │ └── TimeEntry.js
|
||||
│ │ ├── routes/
|
||||
│ │ │ └── timeEntries.js
|
||||
│ │ └── index.js
|
||||
│ ├── ARCHITECTURE.md
|
||||
│ ├── DATABASE.md
|
||||
│ ├── DB_SETUP.md
|
||||
│ └── package.json
|
||||
│
|
||||
└── frontend/
|
||||
├── src/
|
||||
│ ├── assets/
|
||||
│ │ └── main.css
|
||||
│ ├── components/
|
||||
│ ├── router/
|
||||
│ │ └── index.js
|
||||
│ ├── stores/
|
||||
│ │ └── timeStore.js
|
||||
│ ├── views/
|
||||
│ │ ├── Dashboard.vue
|
||||
│ │ ├── Entries.vue
|
||||
│ │ └── Stats.vue
|
||||
│ ├── App.vue
|
||||
│ └── main.js
|
||||
├── index.html
|
||||
├── package.json
|
||||
└── vite.config.js
|
||||
```
|
||||
|
||||
## Entwicklung
|
||||
|
||||
### Backend-Entwicklung
|
||||
```bash
|
||||
cd backend
|
||||
npm run dev # Startet mit nodemon für Auto-Reload
|
||||
```
|
||||
|
||||
### Frontend-Entwicklung
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev # Startet Vite Dev-Server mit HMR
|
||||
```
|
||||
|
||||
### Build für Produktion
|
||||
|
||||
**Frontend:**
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
Die Build-Dateien befinden sich dann im `dist/` Ordner.
|
||||
|
||||
## Implementierte Features
|
||||
|
||||
- ✅ **MySQL-Datenbank** - Integration mit bestehender stechuhr2-Datenbank
|
||||
- ✅ **Repository-Pattern** - Saubere Datenbankabstraktion
|
||||
- ✅ **Service-Layer** - Klare Trennung von Business-Logik
|
||||
- ✅ **Klassenbasierte Architektur** - Controller und Services als Klassen
|
||||
|
||||
## Geplante Erweiterungen
|
||||
|
||||
- 👤 **Benutzer-Authentifizierung** - JWT-basiertes Login-System
|
||||
- 🔐 **Multi-User Support** - Mehrere Benutzer mit Berechtigungen
|
||||
- 📧 **E-Mail-Berichte** - Automatische Zeitberichte per E-Mail
|
||||
- 📱 **Mobile App** - React Native oder Flutter App
|
||||
- 📈 **Erweiterte Analytics** - Grafiken und Diagramme
|
||||
- 🌐 **Mehrsprachigkeit** - i18n Integration
|
||||
- 🔔 **Benachrichtigungen** - Push-Notifications
|
||||
- 📤 **Export-Funktionen** - CSV, PDF Export
|
||||
|
||||
## Lizenz
|
||||
|
||||
ISC
|
||||
|
||||
## Autor
|
||||
|
||||
TimeClock v3 - 2025
|
||||
|
||||
## Support
|
||||
|
||||
Bei Fragen oder Problemen öffnen Sie bitte ein Issue im Repository.
|
||||
|
||||
173
SETUP.md
Normal file
173
SETUP.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# TimeClock v3 - Schnellstart-Anleitung
|
||||
|
||||
## Schritt-für-Schritt Installation
|
||||
|
||||
### 1. Voraussetzungen prüfen
|
||||
|
||||
Stellen Sie sicher, dass Node.js installiert ist:
|
||||
|
||||
```bash
|
||||
node --version # sollte v16 oder höher sein
|
||||
npm --version
|
||||
```
|
||||
|
||||
### 2. Abhängigkeiten installieren
|
||||
|
||||
#### Option A: Alles auf einmal (empfohlen)
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
#### Option B: Manuell
|
||||
```bash
|
||||
# Root-Abhängigkeiten
|
||||
npm install
|
||||
|
||||
# Backend-Abhängigkeiten
|
||||
cd backend
|
||||
npm install
|
||||
|
||||
# Frontend-Abhängigkeiten
|
||||
cd ../frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Backend-Konfiguration
|
||||
|
||||
Erstellen Sie eine `.env` Datei im `backend/` Verzeichnis:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Standardwerte in `.env`:
|
||||
```env
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
### 4. Anwendung starten
|
||||
|
||||
#### Option A: Beide Server gleichzeitig (empfohlen)
|
||||
```bash
|
||||
# Im Root-Verzeichnis
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Dies startet:
|
||||
- Backend auf `http://localhost:3010`
|
||||
- Frontend auf `http://localhost:5010`
|
||||
|
||||
#### Option B: Server einzeln starten
|
||||
|
||||
**Terminal 1 - Backend:**
|
||||
```bash
|
||||
cd backend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Terminal 2 - Frontend:**
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 5. Anwendung öffnen
|
||||
|
||||
Öffnen Sie Ihren Browser und navigieren Sie zu:
|
||||
```
|
||||
http://localhost:5010
|
||||
```
|
||||
|
||||
## Erste Schritte
|
||||
|
||||
1. **Timer starten**
|
||||
- Geben Sie ein Projekt ein (z.B. "Website-Entwicklung")
|
||||
- Optional: Fügen Sie eine Beschreibung hinzu
|
||||
- Klicken Sie auf "Starten"
|
||||
|
||||
2. **Timer läuft**
|
||||
- Die Zeit wird in Echtzeit angezeigt
|
||||
- Sie können den Timer jederzeit stoppen
|
||||
|
||||
3. **Einträge ansehen**
|
||||
- Navigieren Sie zu "Einträge"
|
||||
- Sehen Sie alle Ihre Zeiteinträge
|
||||
- Löschen Sie Einträge bei Bedarf
|
||||
|
||||
4. **Statistiken**
|
||||
- Navigieren Sie zu "Statistiken"
|
||||
- Sehen Sie Ihre Gesamtarbeitszeit
|
||||
- Überblick nach Projekten
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
### Port bereits in Verwendung
|
||||
|
||||
**Backend (Port 3010):**
|
||||
```bash
|
||||
# Linux/Mac
|
||||
lsof -ti:3010 | xargs kill -9
|
||||
|
||||
# Windows
|
||||
netstat -ano | findstr :3010
|
||||
taskkill /PID <PID> /F
|
||||
```
|
||||
|
||||
**Frontend (Port 5010):**
|
||||
```bash
|
||||
# Linux/Mac
|
||||
lsof -ti:5010 | xargs kill -9
|
||||
|
||||
# Windows
|
||||
netstat -ano | findstr :5010
|
||||
taskkill /PID <PID> /F
|
||||
```
|
||||
|
||||
### API-Verbindungsfehler
|
||||
|
||||
Stellen Sie sicher, dass:
|
||||
1. Das Backend auf Port 3010 läuft
|
||||
2. Das Frontend auf Port 5010 läuft
|
||||
3. Keine Firewall die Verbindung blockiert
|
||||
|
||||
### Module nicht gefunden
|
||||
|
||||
```bash
|
||||
# Alle node_modules löschen und neu installieren
|
||||
rm -rf node_modules backend/node_modules frontend/node_modules
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
## Produktion
|
||||
|
||||
### Frontend bauen
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
Die fertigen Dateien befinden sich in `frontend/dist/`
|
||||
|
||||
### Backend für Produktion
|
||||
```bash
|
||||
cd backend
|
||||
NODE_ENV=production npm start
|
||||
```
|
||||
|
||||
### Frontend für Produktion bereitstellen
|
||||
|
||||
Die gebauten Dateien aus `frontend/dist/` können auf jedem Webserver bereitgestellt werden:
|
||||
- Nginx
|
||||
- Apache
|
||||
- Netlify
|
||||
- Vercel
|
||||
- GitHub Pages
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
- Lesen Sie die vollständige [README.md](README.md)
|
||||
- Sehen Sie sich die [API-Dokumentation](backend/README.md) an
|
||||
- Passen Sie die Anwendung nach Ihren Bedürfnissen an
|
||||
|
||||
9
backend/.gitignore
vendored
Normal file
9
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
dist/
|
||||
coverage/
|
||||
|
||||
|
||||
|
||||
298
backend/ARCHITECTURE.md
Normal file
298
backend/ARCHITECTURE.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# Backend-Architektur
|
||||
|
||||
## Übersicht
|
||||
|
||||
Das Backend folgt einer modernen, klassenbasierten Architektur mit klarer Trennung der Verantwortlichkeiten (Separation of Concerns).
|
||||
|
||||
## Architektur-Schichten
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ Routes (Express) │ ← HTTP-Endpunkte definieren
|
||||
├─────────────────────────────────────┤
|
||||
│ Controller-Klassen │ ← HTTP Request/Response Handling
|
||||
├─────────────────────────────────────┤
|
||||
│ Service-Klassen │ ← Business-Logik
|
||||
├─────────────────────────────────────┤
|
||||
│ Models │ ← Datenmodelle & Validierung
|
||||
├─────────────────────────────────────┤
|
||||
│ Datenspeicher │ ← In-Memory / Datenbank
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Komponenten
|
||||
|
||||
### 1. Routes (`src/routes/`)
|
||||
**Verantwortlichkeit:** Definition der HTTP-Endpunkte
|
||||
|
||||
```javascript
|
||||
// src/routes/timeEntries.js
|
||||
router.get('/', timeEntryController.getAllEntries.bind(timeEntryController));
|
||||
```
|
||||
|
||||
- Definiert URL-Pfade und HTTP-Methoden
|
||||
- Bindet Endpunkte an Controller-Methoden
|
||||
- Keine Business-Logik
|
||||
|
||||
### 2. Controller (`src/controllers/`)
|
||||
**Verantwortlichkeit:** HTTP Request/Response Handling
|
||||
|
||||
```javascript
|
||||
// src/controllers/TimeEntryController.js
|
||||
class TimeEntryController {
|
||||
async getAllEntries(req, res) {
|
||||
try {
|
||||
const entries = timeEntryService.getAllEntries();
|
||||
res.json(entries);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Fehler...' });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Controller sind zuständig für:**
|
||||
- ✅ Request-Parameter extrahieren
|
||||
- ✅ Service-Methoden aufrufen
|
||||
- ✅ HTTP-Statuscodes setzen
|
||||
- ✅ Response formatieren
|
||||
- ✅ Error-Handling (HTTP-spezifisch)
|
||||
|
||||
**Controller sind NICHT zuständig für:**
|
||||
- ❌ Business-Logik
|
||||
- ❌ Datenvalidierung (außer HTTP-Parameter)
|
||||
- ❌ Datenbankzugriff
|
||||
- ❌ Komplexe Berechnungen
|
||||
|
||||
### 3. Services (`src/services/`)
|
||||
**Verantwortlichkeit:** Business-Logik
|
||||
|
||||
```javascript
|
||||
// src/services/TimeEntryService.js
|
||||
class TimeEntryService {
|
||||
createEntry(entryData) {
|
||||
// Validierung
|
||||
const runningEntry = this.timeEntries.find(e => e.isRunning);
|
||||
if (runningEntry) {
|
||||
throw new Error('Es läuft bereits ein Timer...');
|
||||
}
|
||||
|
||||
// Business-Logik
|
||||
const newEntry = new TimeEntry({ ... });
|
||||
newEntry.validate();
|
||||
|
||||
// Speichern
|
||||
this.timeEntries.push(newEntry);
|
||||
return newEntry;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Services sind zuständig für:**
|
||||
- ✅ Komplette Business-Logik
|
||||
- ✅ Datenvalidierung
|
||||
- ✅ Datenzugriff
|
||||
- ✅ Berechnungen
|
||||
- ✅ Business-Rules
|
||||
- ✅ Transaktionen
|
||||
|
||||
**Services sind NICHT zuständig für:**
|
||||
- ❌ HTTP-spezifische Logik
|
||||
- ❌ Response-Formatierung
|
||||
- ❌ HTTP-Statuscodes
|
||||
|
||||
### 4. Models (`src/models/`)
|
||||
**Verantwortlichkeit:** Datenmodelle und Validierung
|
||||
|
||||
```javascript
|
||||
// src/models/TimeEntry.js
|
||||
class TimeEntry {
|
||||
constructor(data) { ... }
|
||||
|
||||
validate() {
|
||||
if (!this.startTime) {
|
||||
throw new Error('Startzeit ist erforderlich');
|
||||
}
|
||||
}
|
||||
|
||||
calculateDuration() { ... }
|
||||
getFormattedDuration() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Models sind zuständig für:**
|
||||
- ✅ Datenstruktur definieren
|
||||
- ✅ Datenvalidierung
|
||||
- ✅ Hilfsmethoden für Daten
|
||||
- ✅ Formatierung
|
||||
|
||||
## Vorteile dieser Architektur
|
||||
|
||||
### 1. **Separation of Concerns**
|
||||
Jede Schicht hat eine klar definierte Verantwortlichkeit:
|
||||
- Routes → Routing
|
||||
- Controller → HTTP-Handling
|
||||
- Services → Business-Logik
|
||||
- Models → Datenmodelle
|
||||
|
||||
### 2. **Testbarkeit**
|
||||
```javascript
|
||||
// Services können isoliert getestet werden
|
||||
const service = new TimeEntryService();
|
||||
const entry = service.createEntry({ project: 'Test' });
|
||||
```
|
||||
|
||||
### 3. **Wiederverwendbarkeit**
|
||||
Services können von verschiedenen Controllern oder anderen Services verwendet werden.
|
||||
|
||||
### 4. **Wartbarkeit**
|
||||
Änderungen an der Business-Logik betreffen nur Services, nicht Controller oder Routes.
|
||||
|
||||
### 5. **Skalierbarkeit**
|
||||
- Services können einfach auf Microservices aufgeteilt werden
|
||||
- Einfache Integration von Datenbanken
|
||||
- Caching-Schichten hinzufügen
|
||||
|
||||
## Datenfluss
|
||||
|
||||
### Beispiel: Neuen Zeiteintrag erstellen
|
||||
|
||||
```
|
||||
1. HTTP POST Request
|
||||
↓
|
||||
2. Route: POST /api/time-entries
|
||||
↓
|
||||
3. Controller.createEntry()
|
||||
- Extrahiert req.body
|
||||
- Ruft Service auf
|
||||
↓
|
||||
4. Service.createEntry()
|
||||
- Validiert Daten
|
||||
- Prüft Business-Rules
|
||||
- Erstellt Model
|
||||
- Speichert Daten
|
||||
↓
|
||||
5. Controller.createEntry()
|
||||
- Setzt Status 201
|
||||
- Sendet JSON Response
|
||||
↓
|
||||
6. HTTP Response
|
||||
```
|
||||
|
||||
## Singleton-Pattern
|
||||
|
||||
Sowohl Controller als auch Services werden als Singleton exportiert:
|
||||
|
||||
```javascript
|
||||
// Am Ende der Datei
|
||||
module.exports = new TimeEntryService();
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
- Gleicher Datenspeicher über alle Requests
|
||||
- Keine Instanziierung bei jedem Request
|
||||
- Shared State (für In-Memory Storage)
|
||||
|
||||
**Hinweis:** Bei Datenbank-Integration kann auf Singleton verzichtet werden.
|
||||
|
||||
## Erweiterte Features
|
||||
|
||||
### Service-Features
|
||||
|
||||
Die `TimeEntryService`-Klasse bietet erweiterte Funktionen:
|
||||
|
||||
```javascript
|
||||
// Statistiken mit Zeitfilter
|
||||
getStatistics() → {
|
||||
totalEntries,
|
||||
projectStats,
|
||||
timeStats: { today, week, month }
|
||||
}
|
||||
|
||||
// Laufenden Timer abrufen
|
||||
getRunningEntry()
|
||||
|
||||
// Nach Projekt filtern
|
||||
getEntriesByProject(projectName)
|
||||
|
||||
// Datumsbereich filtern
|
||||
getEntriesByDateRange(startDate, endDate)
|
||||
```
|
||||
|
||||
### Controller-Features
|
||||
|
||||
Der `TimeEntryController` bietet zusätzliche Endpunkte:
|
||||
|
||||
```javascript
|
||||
// GET /api/time-entries/running
|
||||
getRunningEntry()
|
||||
|
||||
// GET /api/time-entries/project/:projectName
|
||||
getEntriesByProject()
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Error Handling
|
||||
|
||||
**In Services:**
|
||||
```javascript
|
||||
throw new Error('Beschreibende Fehlermeldung');
|
||||
```
|
||||
|
||||
**In Controllern:**
|
||||
```javascript
|
||||
catch (error) {
|
||||
if (error.message.includes('nicht gefunden')) {
|
||||
return res.status(404).json({ error: '...' });
|
||||
}
|
||||
res.status(500).json({ error: '...' });
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Async/Await
|
||||
|
||||
Controller-Methoden sind `async`, auch wenn Services aktuell synchron sind:
|
||||
|
||||
```javascript
|
||||
async getAllEntries(req, res) {
|
||||
// Vorbereitet für asynchrone Service-Calls (z.B. Datenbank)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Binding
|
||||
|
||||
Bei Klassen-Methoden als Express-Handler muss `bind()` verwendet werden:
|
||||
|
||||
```javascript
|
||||
router.get('/', controller.method.bind(controller));
|
||||
```
|
||||
|
||||
## Migration auf Datenbank
|
||||
|
||||
Die Architektur ist vorbereitet für Datenbank-Integration:
|
||||
|
||||
```javascript
|
||||
class TimeEntryService {
|
||||
async createEntry(entryData) {
|
||||
// Statt In-Memory:
|
||||
// return await TimeEntryModel.create(entryData);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Nur die Service-Schicht muss angepasst werden, Controller bleiben unverändert!
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
| Schicht | Datei | Verantwortlichkeit | Beispiel |
|
||||
|---------|-------|-------------------|----------|
|
||||
| **Routes** | `timeEntries.js` | URL-Mapping | `router.get('/', ...)` |
|
||||
| **Controller** | `TimeEntryController.js` | HTTP-Handling | `res.json(data)` |
|
||||
| **Service** | `TimeEntryService.js` | Business-Logik | `validateData()` |
|
||||
| **Model** | `TimeEntry.js` | Datenmodell | `validate()` |
|
||||
|
||||
Diese Architektur ermöglicht eine professionelle, wartbare und skalierbare Backend-Anwendung! 🚀
|
||||
|
||||
|
||||
|
||||
370
backend/DATABASE.md
Normal file
370
backend/DATABASE.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# Datenbank-Integration
|
||||
|
||||
## Übersicht
|
||||
|
||||
TimeClock v3 verwendet MySQL mit der bestehenden `stechuhr2` Datenbankstruktur. Die Integration erfolgt über das Repository-Pattern für saubere Abstraktion.
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
Controller
|
||||
↓
|
||||
Service (Business-Logik)
|
||||
↓
|
||||
Repository (Datenbankzugriff)
|
||||
↓
|
||||
MySQL Datenbank
|
||||
```
|
||||
|
||||
## Verwendete Tabellen
|
||||
|
||||
### Haupttabellen
|
||||
|
||||
#### `worklog` - Zeiteinträge
|
||||
```sql
|
||||
CREATE TABLE `worklog` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`version` int NOT NULL,
|
||||
`user_id` bigint NOT NULL,
|
||||
`state` text NOT NULL, -- JSON mit {action, project, description}
|
||||
`tstamp` datetime DEFAULT NULL,
|
||||
`relatedTo_id` bigint DEFAULT NULL, -- Verknüpfung Clock In/Out
|
||||
PRIMARY KEY (`id`)
|
||||
)
|
||||
```
|
||||
|
||||
**Verwendung:**
|
||||
- Clock In: Neuer Eintrag mit `relatedTo_id = NULL`
|
||||
- Clock Out: Neuer Eintrag mit `relatedTo_id` verweist auf Clock In
|
||||
- State: JSON-Format `{"action": "Clock In", "project": "Projektname", "description": "Beschreibung"}`
|
||||
|
||||
#### `user` - Benutzer
|
||||
```sql
|
||||
CREATE TABLE `user` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`full_name` text NOT NULL,
|
||||
`daily_hours` int NOT NULL,
|
||||
`week_hours` int NOT NULL,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
#### `weekly_worktime` - Wochenarbeitszeit
|
||||
```sql
|
||||
CREATE TABLE `weekly_worktime` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`weekly_work_time` double NOT NULL DEFAULT '40',
|
||||
`starting_from` date DEFAULT NULL,
|
||||
`ends_at` date DEFAULT NULL,
|
||||
`user_id` int DEFAULT NULL,
|
||||
...
|
||||
)
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### Umgebungsvariablen (`.env`)
|
||||
|
||||
```env
|
||||
# Datenbank-Verbindung
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your_password
|
||||
DB_NAME=stechuhr2
|
||||
```
|
||||
|
||||
### Connection Pool
|
||||
|
||||
Die Datenbankverbindung wird über einen Connection Pool verwaltet:
|
||||
|
||||
```javascript
|
||||
// src/config/database.js
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
connectionLimit: 10
|
||||
});
|
||||
```
|
||||
|
||||
## Repository-Pattern
|
||||
|
||||
### WorklogRepository
|
||||
|
||||
**Verantwortlichkeiten:**
|
||||
- CRUD-Operationen auf `worklog` Tabelle
|
||||
- Komplexe Queries (Paare, Statistiken)
|
||||
- Datenbankzugriff abstrahieren
|
||||
|
||||
**Hauptmethoden:**
|
||||
```javascript
|
||||
await worklogRepository.findAllByUser(userId)
|
||||
await worklogRepository.findById(id)
|
||||
await worklogRepository.create(worklogData)
|
||||
await worklogRepository.update(id, updateData)
|
||||
await worklogRepository.delete(id)
|
||||
await worklogRepository.findPairsByUser(userId)
|
||||
await worklogRepository.getStatistics(userId)
|
||||
```
|
||||
|
||||
### UserRepository
|
||||
|
||||
**Verantwortlichkeiten:**
|
||||
- Benutzerverwaltung
|
||||
- Wochenarbeitszeit abrufen
|
||||
- Benutzereinstellungen
|
||||
|
||||
**Hauptmethoden:**
|
||||
```javascript
|
||||
await userRepository.findById(userId)
|
||||
await userRepository.findByEmail(email)
|
||||
await userRepository.getWeeklyWorktime(userId, date)
|
||||
await userRepository.getUserSetting(userId, settingId)
|
||||
```
|
||||
|
||||
## Datenfluss
|
||||
|
||||
### Timer starten
|
||||
|
||||
```
|
||||
1. Frontend → POST /api/time-entries
|
||||
{ project: "Website", description: "Feature XY" }
|
||||
|
||||
2. Controller → Service.createEntry()
|
||||
|
||||
3. Service → WorklogRepository.create()
|
||||
{
|
||||
user_id: 1,
|
||||
state: '{"action":"Clock In","project":"Website","description":"Feature XY"}',
|
||||
tstamp: '2025-10-15 10:00:00',
|
||||
relatedTo_id: null
|
||||
}
|
||||
|
||||
4. MySQL → INSERT INTO worklog
|
||||
→ Gibt neue ID zurück (z.B. 123)
|
||||
|
||||
5. Service → Konvertiert zu TimeEntry-Format
|
||||
{
|
||||
id: 123,
|
||||
startTime: '2025-10-15T10:00:00',
|
||||
endTime: null,
|
||||
project: 'Website',
|
||||
description: 'Feature XY',
|
||||
duration: null,
|
||||
isRunning: true
|
||||
}
|
||||
|
||||
6. Controller → Frontend
|
||||
Status 201, JSON Response
|
||||
```
|
||||
|
||||
### Timer stoppen
|
||||
|
||||
```
|
||||
1. Frontend → PUT /api/time-entries/123
|
||||
{ endTime: '2025-10-15T12:00:00' }
|
||||
|
||||
2. Service → WorklogRepository.create()
|
||||
{
|
||||
user_id: 1,
|
||||
state: '{"action":"Clock Out","project":"Website","description":"Feature XY"}',
|
||||
tstamp: '2025-10-15 12:00:00',
|
||||
relatedTo_id: 123 // Verweist auf Clock In
|
||||
}
|
||||
|
||||
3. Service → Berechnet Dauer und gibt Pair zurück
|
||||
```
|
||||
|
||||
## Worklog-Paare
|
||||
|
||||
Clock In und Clock Out werden als separate Einträge gespeichert, aber logisch als Paar behandelt:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
w1.id as start_id,
|
||||
w1.tstamp as start_time,
|
||||
w2.id as end_id,
|
||||
w2.tstamp as end_time,
|
||||
TIMESTAMPDIFF(SECOND, w1.tstamp, w2.tstamp) as duration
|
||||
FROM worklog w1
|
||||
LEFT JOIN worklog w2 ON w2.relatedTo_id = w1.id
|
||||
WHERE w1.user_id = ? AND w1.relatedTo_id IS NULL
|
||||
```
|
||||
|
||||
**Vorteile:**
|
||||
- Historische Genauigkeit
|
||||
- Korrekturen möglich
|
||||
- Audit-Trail
|
||||
|
||||
## State-Format
|
||||
|
||||
Der `state` Feld speichert JSON-Daten:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "Clock In|Clock Out",
|
||||
"project": "Projektname",
|
||||
"description": "Beschreibung der Tätigkeit"
|
||||
}
|
||||
```
|
||||
|
||||
**Parsing im Service:**
|
||||
```javascript
|
||||
let stateData;
|
||||
try {
|
||||
stateData = JSON.parse(worklog.state);
|
||||
} catch {
|
||||
stateData = { project: 'Allgemein', description: '' };
|
||||
}
|
||||
```
|
||||
|
||||
## Statistiken
|
||||
|
||||
### Basis-Statistiken
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
COUNT(DISTINCT w1.id) as total_entries,
|
||||
COUNT(DISTINCT w2.id) as completed_entries,
|
||||
SUM(TIMESTAMPDIFF(SECOND, w1.tstamp, w2.tstamp)) as total_seconds
|
||||
FROM worklog w1
|
||||
LEFT JOIN worklog w2 ON w2.relatedTo_id = w1.id
|
||||
WHERE w1.user_id = ? AND w1.relatedTo_id IS NULL
|
||||
```
|
||||
|
||||
### Projekt-Statistiken
|
||||
|
||||
Werden im Service berechnet durch Gruppierung der Paare nach Projekt.
|
||||
|
||||
## Transaktionen
|
||||
|
||||
Für zukünftige Erweiterungen können Transaktionen verwendet werden:
|
||||
|
||||
```javascript
|
||||
const connection = await database.getPool().getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
// Multiple Operationen
|
||||
await connection.execute(sql1, params1);
|
||||
await connection.execute(sql2, params2);
|
||||
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
```
|
||||
|
||||
## Migration von In-Memory
|
||||
|
||||
Der Code unterstützt beide Modi:
|
||||
- **Entwicklung:** In-Memory (schnell, keine DB erforderlich)
|
||||
- **Produktion:** MySQL (persistent, skalierbar)
|
||||
|
||||
Umschalten durch Änderung des Service-Imports.
|
||||
|
||||
## Performance-Optimierungen
|
||||
|
||||
### Indizes
|
||||
|
||||
Wichtige Indizes sind bereits vorhanden:
|
||||
```sql
|
||||
KEY `worklog_tstamp_IDX` (`tstamp`)
|
||||
KEY `worklog_user_id_IDX` (`user_id`, `tstamp`)
|
||||
```
|
||||
|
||||
### Connection Pooling
|
||||
|
||||
- Wiederverwendung von Verbindungen
|
||||
- Limit: 10 gleichzeitige Verbindungen
|
||||
- Automatisches Reconnect
|
||||
|
||||
### Query-Optimierung
|
||||
|
||||
- Prepared Statements (SQL Injection Schutz)
|
||||
- WHERE-Klauseln mit Indizes
|
||||
- LEFT JOIN statt mehrere Queries
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
```javascript
|
||||
try {
|
||||
const result = await worklogRepository.create(data);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error.code === 'ER_DUP_ENTRY') {
|
||||
throw new Error('Eintrag existiert bereits');
|
||||
}
|
||||
if (error.code === 'ER_NO_REFERENCED_ROW_2') {
|
||||
throw new Error('Benutzer nicht gefunden');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## Backup & Restore
|
||||
|
||||
### Backup erstellen
|
||||
```bash
|
||||
mysqldump -u root -p stechuhr2 > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
### Restore
|
||||
```bash
|
||||
mysql -u root -p stechuhr2 < backup_20251015.sql
|
||||
```
|
||||
|
||||
## Sicherheit
|
||||
|
||||
✅ **Implemented:**
|
||||
- Prepared Statements (SQL Injection Schutz)
|
||||
- Connection Pooling
|
||||
- Error Handling
|
||||
|
||||
🔄 **TODO:**
|
||||
- Input Validation
|
||||
- Rate Limiting
|
||||
- User Authentication
|
||||
- Row-Level Security
|
||||
|
||||
## Monitoring
|
||||
|
||||
Logging von Queries (Development):
|
||||
```javascript
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('SQL:', sql);
|
||||
console.log('Params:', params);
|
||||
}
|
||||
```
|
||||
|
||||
## Zukünftige Erweiterungen
|
||||
|
||||
1. **Authentifizierung**
|
||||
- Verwendung der `auth_info` Tabelle
|
||||
- JWT-Token-basiert
|
||||
|
||||
2. **Multi-User Support**
|
||||
- Benutzer-ID aus Session
|
||||
- Row-Level Security
|
||||
|
||||
3. **Erweiterte Features**
|
||||
- Urlaub (`vacation` Tabelle)
|
||||
- Krankmeldungen (`sick` Tabelle)
|
||||
- Feiertage (`holiday` Tabelle)
|
||||
|
||||
4. **Caching**
|
||||
- Redis für häufige Abfragen
|
||||
- Cache-Invalidierung
|
||||
|
||||
5. **Read Replicas**
|
||||
- Master-Slave Setup
|
||||
- Read-Write Splitting
|
||||
|
||||
|
||||
|
||||
316
backend/DB_SETUP.md
Normal file
316
backend/DB_SETUP.md
Normal file
@@ -0,0 +1,316 @@
|
||||
# Datenbank-Setup für TimeClock v3
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- MySQL 5.7 oder höher
|
||||
- Zugriff auf MySQL-Server (localhost oder remote)
|
||||
|
||||
## Setup-Schritte
|
||||
|
||||
### 1. Datenbank-Verbindung konfigurieren
|
||||
|
||||
Erstellen Sie eine `.env` Datei im `backend/` Verzeichnis:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Bearbeiten Sie die `.env` Datei mit Ihren Datenbankzugangsdaten:
|
||||
|
||||
```env
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# MySQL Datenbank
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=ihr_mysql_passwort
|
||||
DB_NAME=stechuhr2
|
||||
```
|
||||
|
||||
### 2. Datenbank prüfen
|
||||
|
||||
Die Datenbank `stechuhr2` sollte bereits existieren. Prüfen Sie dies:
|
||||
|
||||
```bash
|
||||
mysql -u root -p
|
||||
```
|
||||
|
||||
```sql
|
||||
SHOW DATABASES;
|
||||
USE stechuhr2;
|
||||
SHOW TABLES;
|
||||
```
|
||||
|
||||
Sie sollten die Tabellen sehen:
|
||||
- `worklog` (Haupttabelle für Zeiteinträge)
|
||||
- `user` (Benutzerverwaltung)
|
||||
- `auth_info` (Authentifizierung)
|
||||
- ... und weitere
|
||||
|
||||
### 3. Testbenutzer prüfen/erstellen
|
||||
|
||||
Prüfen Sie, ob ein Benutzer mit ID 1 existiert:
|
||||
|
||||
```sql
|
||||
SELECT * FROM user WHERE id = 1;
|
||||
```
|
||||
|
||||
Falls kein Benutzer existiert, erstellen Sie einen:
|
||||
|
||||
```sql
|
||||
INSERT INTO user (
|
||||
id, version, last_change, role, daily_hours,
|
||||
state_id, full_name, week_hours, week_workdays, preferred_title_type
|
||||
) VALUES (
|
||||
1, 0, NOW(), 0, 8,
|
||||
NULL, 'Test Benutzer', 40, 5, 0
|
||||
);
|
||||
```
|
||||
|
||||
### 4. Abhängigkeiten installieren
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm install
|
||||
```
|
||||
|
||||
Dies installiert auch `mysql2` für die Datenbankverbindung.
|
||||
|
||||
### 5. Backend starten
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Erwartete Ausgabe:**
|
||||
```
|
||||
✅ MySQL-Datenbankverbindung hergestellt
|
||||
🕐 TimeClock Server läuft auf Port 3010
|
||||
📍 API verfügbar unter http://localhost:3010/api
|
||||
```
|
||||
|
||||
**Bei Fehlern:**
|
||||
|
||||
- **"Access denied for user"**: Überprüfen Sie DB_USER und DB_PASSWORD in `.env`
|
||||
- **"Unknown database"**: Erstellen Sie die Datenbank `stechuhr2`
|
||||
- **"ECONNREFUSED"**: MySQL-Server läuft nicht oder falscher DB_HOST/DB_PORT
|
||||
|
||||
### 6. API testen
|
||||
|
||||
```bash
|
||||
# Health Check
|
||||
curl http://localhost:3010/api/health
|
||||
|
||||
# Zeiteinträge abrufen
|
||||
curl http://localhost:3010/api/time-entries
|
||||
|
||||
# Neuen Timer starten
|
||||
curl -X POST http://localhost:3010/api/time-entries \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"project":"Test","description":"Test-Timer"}'
|
||||
```
|
||||
|
||||
## Worklog-Struktur
|
||||
|
||||
Die Haupttabelle `worklog` speichert Zeiteinträge:
|
||||
|
||||
```
|
||||
Clock In (Start):
|
||||
┌────────────────────────────────────────────┐
|
||||
│ id: 1 │
|
||||
│ user_id: 1 │
|
||||
│ state: {"action":"Clock In","project":... }│
|
||||
│ tstamp: 2025-10-15 10:00:00 │
|
||||
│ relatedTo_id: NULL │
|
||||
└────────────────────────────────────────────┘
|
||||
|
||||
Clock Out (End):
|
||||
┌────────────────────────────────────────────┐
|
||||
│ id: 2 │
|
||||
│ user_id: 1 │
|
||||
│ state: {"action":"Clock Out","project":...}│
|
||||
│ tstamp: 2025-10-15 12:00:00 │
|
||||
│ relatedTo_id: 1 ← Verweist auf Clock In │
|
||||
└────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Bestehende Daten
|
||||
|
||||
Falls bereits Worklog-Einträge in der Datenbank existieren:
|
||||
|
||||
### Datenformat prüfen
|
||||
|
||||
```sql
|
||||
SELECT id, user_id, state, tstamp, relatedTo_id
|
||||
FROM worklog
|
||||
ORDER BY tstamp DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
### Altes Format konvertieren
|
||||
|
||||
Falls `state` nicht im JSON-Format vorliegt, können Sie Einträge konvertieren:
|
||||
|
||||
```sql
|
||||
-- Beispiel: state ist "in" oder "out"
|
||||
UPDATE worklog
|
||||
SET state = JSON_OBJECT('action', 'Clock In', 'project', 'Allgemein', 'description', '')
|
||||
WHERE state = 'in' AND relatedTo_id IS NULL;
|
||||
|
||||
UPDATE worklog
|
||||
SET state = JSON_OBJECT('action', 'Clock Out', 'project', 'Allgemein', 'description', '')
|
||||
WHERE state = 'out' AND relatedTo_id IS NOT NULL;
|
||||
```
|
||||
|
||||
⚠️ **Wichtig:** Erstellen Sie vorher ein Backup!
|
||||
|
||||
```bash
|
||||
mysqldump -u root -p stechuhr2 > backup_before_conversion.sql
|
||||
```
|
||||
|
||||
## Mehrere Benutzer
|
||||
|
||||
Der aktuelle Code verwendet standardmäßig Benutzer-ID 1. Für Multi-User-Support:
|
||||
|
||||
### Option 1: Benutzer-ID in Service setzen
|
||||
|
||||
```javascript
|
||||
// In zukünftigen Endpunkten
|
||||
const userId = req.user.id; // Aus Auth-Middleware
|
||||
const entries = await timeEntryService.getAllEntries(userId);
|
||||
```
|
||||
|
||||
### Option 2: Authentifizierung hinzufügen
|
||||
|
||||
Die Datenbank hat bereits `auth_info` und `auth_token` Tabellen für JWT-basierte Authentifizierung.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Verbindungsprobleme
|
||||
|
||||
```bash
|
||||
# MySQL-Status prüfen
|
||||
sudo systemctl status mysql
|
||||
|
||||
# MySQL starten
|
||||
sudo systemctl start mysql
|
||||
|
||||
# Port prüfen
|
||||
netstat -an | grep 3306
|
||||
```
|
||||
|
||||
### Berechtigungen
|
||||
|
||||
```sql
|
||||
-- Benutzer-Berechtigungen prüfen
|
||||
SHOW GRANTS FOR 'root'@'localhost';
|
||||
|
||||
-- Falls nötig, Berechtigungen gewähren
|
||||
GRANT ALL PRIVILEGES ON stechuhr2.* TO 'root'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### Connection Pool Limits
|
||||
|
||||
Bei vielen gleichzeitigen Anfragen:
|
||||
|
||||
```javascript
|
||||
// In src/config/database.js
|
||||
connectionLimit: 10 // Erhöhen auf 20 oder mehr
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
Aktivieren Sie Query-Logging:
|
||||
|
||||
```javascript
|
||||
// In src/repositories/WorklogRepository.js
|
||||
console.log('SQL:', sql);
|
||||
console.log('Params:', params);
|
||||
```
|
||||
|
||||
## Performance-Tipps
|
||||
|
||||
### Indizes prüfen
|
||||
|
||||
```sql
|
||||
SHOW INDEX FROM worklog;
|
||||
```
|
||||
|
||||
Wichtige Indizes:
|
||||
- `worklog_user_id_IDX` (user_id, tstamp)
|
||||
- `worklog_tstamp_IDX` (tstamp)
|
||||
|
||||
### Query-Performance analysieren
|
||||
|
||||
```sql
|
||||
EXPLAIN SELECT * FROM worklog WHERE user_id = 1 ORDER BY tstamp DESC;
|
||||
```
|
||||
|
||||
### Alte Einträge archivieren
|
||||
|
||||
```sql
|
||||
-- Einträge älter als 2 Jahre in Archiv-Tabelle verschieben
|
||||
CREATE TABLE worklog_archive LIKE worklog;
|
||||
|
||||
INSERT INTO worklog_archive
|
||||
SELECT * FROM worklog
|
||||
WHERE tstamp < DATE_SUB(NOW(), INTERVAL 2 YEAR);
|
||||
|
||||
DELETE FROM worklog
|
||||
WHERE tstamp < DATE_SUB(NOW(), INTERVAL 2 YEAR);
|
||||
```
|
||||
|
||||
## Backup-Strategie
|
||||
|
||||
### Tägliches Backup (Cron)
|
||||
|
||||
```bash
|
||||
# /etc/cron.daily/mysql-backup.sh
|
||||
#!/bin/bash
|
||||
mysqldump -u root -p'password' stechuhr2 > /backup/stechuhr2_$(date +\%Y\%m\%d).sql
|
||||
find /backup -name "stechuhr2_*.sql" -mtime +30 -delete
|
||||
```
|
||||
|
||||
### Point-in-Time Recovery
|
||||
|
||||
Aktivieren Sie Binary Logging in MySQL:
|
||||
|
||||
```ini
|
||||
# /etc/mysql/my.cnf
|
||||
[mysqld]
|
||||
log-bin=mysql-bin
|
||||
expire_logs_days=7
|
||||
```
|
||||
|
||||
## Migration auf andere Datenbank
|
||||
|
||||
Die Repository-Struktur ermöglicht einfache Migration:
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
```javascript
|
||||
// Ersetzen Sie mysql2 durch pg
|
||||
const { Pool } = require('pg');
|
||||
```
|
||||
|
||||
### MongoDB
|
||||
|
||||
```javascript
|
||||
// Erstellen Sie neue Repositories mit MongoDB
|
||||
const mongodb = require('mongodb');
|
||||
```
|
||||
|
||||
Der Service und Controller bleiben unverändert! 🎉
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. ✅ Datenbank konfiguriert und getestet
|
||||
2. 🔄 Frontend anpassen (optional)
|
||||
3. 🔄 Authentifizierung implementieren
|
||||
4. 🔄 Multi-User Support aktivieren
|
||||
5. 🔄 Backup-Automatisierung einrichten
|
||||
|
||||
164
backend/ID_HASHING.md
Normal file
164
backend/ID_HASHING.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# ID-Hashing System
|
||||
|
||||
## Übersicht
|
||||
|
||||
Das TimeClock Backend verwendet ein automatisches ID-Hashing-System, das alle numerischen IDs in API-Responses verschlüsselt und eingehende Hash-IDs automatisch entschlüsselt.
|
||||
|
||||
## Warum ID-Hashing?
|
||||
|
||||
- **Sicherheit**: Verhindert, dass Angreifer die Anzahl der Datensätze erraten können
|
||||
- **Obfuscation**: Versteckt die interne Datenbankstruktur
|
||||
- **Schutz vor ID-Enumeration**: Verhindert systematisches Durchlaufen von Ressourcen
|
||||
|
||||
## Funktionsweise
|
||||
|
||||
### Automatisches Hashing (Backend → Frontend)
|
||||
|
||||
Alle numerischen ID-Felder in API-Responses werden automatisch in Hashes konvertiert:
|
||||
|
||||
```javascript
|
||||
// Datenbank-Daten:
|
||||
{
|
||||
id: 123,
|
||||
user_id: 456,
|
||||
full_name: "Max Mustermann"
|
||||
}
|
||||
|
||||
// API-Response:
|
||||
{
|
||||
id: "xY9kL2mP3qR5.aB7cD8eF9gH0",
|
||||
user_id: "tU6vW7xY8zZ9.iJ1kL2mN3oP4",
|
||||
full_name: "Max Mustermann"
|
||||
}
|
||||
```
|
||||
|
||||
### Automatisches Enthashen (Frontend → Backend)
|
||||
|
||||
Alle Hash-IDs in eingehenden Requests werden automatisch zurück in numerische IDs konvertiert:
|
||||
|
||||
```javascript
|
||||
// Frontend sendet:
|
||||
{
|
||||
user_id: "xY9kL2mP3qR5.aB7cD8eF9gH0"
|
||||
}
|
||||
|
||||
// Backend erhält:
|
||||
{
|
||||
user_id: 123
|
||||
}
|
||||
```
|
||||
|
||||
## Implementierung
|
||||
|
||||
### Backend
|
||||
|
||||
Das System besteht aus drei Komponenten:
|
||||
|
||||
1. **`utils/hashId.js`**: Utility-Klasse für Encoding/Decoding
|
||||
2. **`middleware/hashResponse.js`**: Middleware für ausgehende Responses
|
||||
3. **`middleware/unhashRequest.js`**: Middleware für eingehende Requests
|
||||
|
||||
### Konfiguration
|
||||
|
||||
In der `.env`-Datei:
|
||||
|
||||
```env
|
||||
HASH_ID_SECRET=your-hash-id-secret-change-in-production
|
||||
```
|
||||
|
||||
⚠️ **Wichtig**: Das Secret sollte in Produktion geändert werden und geheim bleiben!
|
||||
|
||||
### Erkannte ID-Felder
|
||||
|
||||
Folgende Feldnamen werden automatisch als IDs erkannt und gehashed:
|
||||
|
||||
- `id`, `_id`
|
||||
- `user_id`, `userId`
|
||||
- `auth_info_id`, `authInfoId`
|
||||
- `auth_token_id`, `authTokenId`
|
||||
- `worklog_id`, `worklogId`
|
||||
- `vacation_id`, `vacationId`
|
||||
- `sick_id`, `sickId`
|
||||
- `holiday_id`, `holidayId`
|
||||
- `state_id`, `stateId`
|
||||
- `sick_type_id`, `sickTypeId`
|
||||
- `weekly_worktime_id`, `weeklyWorktimeId`
|
||||
|
||||
## Frontend-Integration
|
||||
|
||||
Das Frontend muss keine Änderungen vornehmen - es arbeitet einfach mit den empfangenen Hash-IDs:
|
||||
|
||||
```javascript
|
||||
// GET /api/auth/me
|
||||
const response = await fetch('/api/auth/me')
|
||||
const data = await response.json()
|
||||
|
||||
console.log(data.user.id) // "xY9kL2mP3qR5.aB7cD8eF9gH0"
|
||||
|
||||
// POST /api/some-endpoint
|
||||
await fetch('/api/some-endpoint', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
user_id: data.user.id // Hash wird automatisch entschlüsselt
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## Manuelle Verwendung
|
||||
|
||||
Falls manuelles Encoding/Decoding nötig ist:
|
||||
|
||||
```javascript
|
||||
const hashId = require('./utils/hashId');
|
||||
|
||||
// Einzelne ID hashen
|
||||
const hash = hashId.encode(123); // "xY9kL2mP3qR5.aB7cD8eF9gH0"
|
||||
|
||||
// Hash dekodieren
|
||||
const id = hashId.decode(hash); // 123
|
||||
|
||||
// Objekt hashen
|
||||
const obj = { id: 123, name: "Test" };
|
||||
const hashed = hashId.encodeObject(obj); // { id: "xY9...", name: "Test" }
|
||||
|
||||
// Array hashen
|
||||
const array = [{ id: 1 }, { id: 2 }];
|
||||
const hashedArray = hashId.encodeArray(array);
|
||||
```
|
||||
|
||||
## Sicherheitshinweise
|
||||
|
||||
1. **Secret ändern**: Ändern Sie `HASH_ID_SECRET` in der Produktion
|
||||
2. **Secret sicher aufbewahren**: Das Secret sollte niemals im Code oder in der Versionskontrolle erscheinen
|
||||
3. **Keine zusätzliche Sicherheit**: ID-Hashing ersetzt keine echte Autorisierung - prüfen Sie immer die Zugriffsrechte!
|
||||
|
||||
## Hash-Format
|
||||
|
||||
Das Hash-Format: `{encrypted_id}.{hash_prefix}`
|
||||
|
||||
- **encrypted_id**: AES-256-CBC verschlüsselte ID
|
||||
- **hash_prefix**: HMAC-SHA256 Hash (erste 12 Zeichen) zur Verifizierung
|
||||
- **Encoding**: base64url (URL-sicher)
|
||||
|
||||
Beispiel: `xY9kL2mP3qR5.aB7cD8eF9gH0`
|
||||
|
||||
## Fehlerbehandlung
|
||||
|
||||
Ungültige Hash-IDs werden zu `null` dekodiert. Services/Controller sollten dies behandeln:
|
||||
|
||||
```javascript
|
||||
const userId = req.params.id; // Könnte null sein wenn Hash ungültig
|
||||
|
||||
if (!userId) {
|
||||
return res.status(400).json({ error: 'Ungültige ID' });
|
||||
}
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
- **Encoding**: ~0.1ms pro ID
|
||||
- **Decoding**: ~0.2ms pro ID
|
||||
- **Overhead**: Minimal, da deterministisch und ohne Datenbank-Zugriff
|
||||
|
||||
|
||||
|
||||
248
backend/MYSQL_SETUP.md
Normal file
248
backend/MYSQL_SETUP.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# MySQL Datenbank Setup für TimeClock
|
||||
|
||||
## 1. MySQL Installation prüfen
|
||||
|
||||
```bash
|
||||
# MySQL Version prüfen
|
||||
mysql --version
|
||||
|
||||
# Falls nicht installiert (openSUSE):
|
||||
sudo zypper install mysql mysql-server
|
||||
|
||||
# MySQL-Server starten
|
||||
sudo systemctl start mysql
|
||||
sudo systemctl enable mysql
|
||||
```
|
||||
|
||||
## 2. MySQL Root-Passwort setzen (falls nötig)
|
||||
|
||||
```bash
|
||||
# MySQL Secure Installation ausführen
|
||||
sudo mysql_secure_installation
|
||||
|
||||
# Folgende Fragen beantworten:
|
||||
# - Set root password? [Y/n] Y
|
||||
# - Remove anonymous users? [Y/n] Y
|
||||
# - Disallow root login remotely? [Y/n] Y
|
||||
# - Remove test database? [Y/n] Y
|
||||
# - Reload privilege tables? [Y/n] Y
|
||||
```
|
||||
|
||||
## 3. Datenbank und Benutzer erstellen
|
||||
|
||||
### Option A: Automatisches Setup-Script
|
||||
|
||||
```bash
|
||||
cd /home/torsten/Programs/TimeClock/backend
|
||||
chmod +x setup-mysql.sh
|
||||
./setup-mysql.sh
|
||||
```
|
||||
|
||||
Das Script fragt nach:
|
||||
- MySQL Root-Passwort
|
||||
- Neuem Datenbank-Benutzer (Standard: timeclock_user)
|
||||
- Neuem Benutzer-Passwort
|
||||
|
||||
### Option B: Manuelles Setup
|
||||
|
||||
```bash
|
||||
# MySQL als root einloggen
|
||||
mysql -u root -p
|
||||
```
|
||||
|
||||
Dann in der MySQL-Konsole:
|
||||
|
||||
```sql
|
||||
-- 1. Datenbank erstellen
|
||||
CREATE DATABASE IF NOT EXISTS stechuhr2
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 2. Benutzer erstellen (Passwort anpassen!)
|
||||
CREATE USER IF NOT EXISTS 'timeclock_user'@'localhost'
|
||||
IDENTIFIED BY 'IhrSicheresPasswort123!';
|
||||
|
||||
-- 3. Berechtigungen vergeben
|
||||
GRANT ALL PRIVILEGES ON stechuhr2.*
|
||||
TO 'timeclock_user'@'localhost';
|
||||
|
||||
-- 4. Privilegien neu laden
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
-- 5. Prüfen
|
||||
SHOW DATABASES;
|
||||
SELECT User, Host FROM mysql.user WHERE User='timeclock_user';
|
||||
|
||||
-- 6. Verlassen
|
||||
EXIT;
|
||||
```
|
||||
|
||||
## 4. Tabellen erstellen
|
||||
|
||||
Die Tabellen existieren bereits in Ihrer DB. Falls Sie sie neu erstellen möchten:
|
||||
|
||||
```bash
|
||||
# SQL-Dump importieren (falls vorhanden)
|
||||
mysql -u timeclock_user -p stechuhr2 < database-schema.sql
|
||||
```
|
||||
|
||||
Oder mit dem automatischen Migrations-Script:
|
||||
|
||||
```bash
|
||||
cd /home/torsten/Programs/TimeClock/backend
|
||||
node scripts/create-tables.js
|
||||
```
|
||||
|
||||
## 5. .env Datei konfigurieren
|
||||
|
||||
```bash
|
||||
cd /home/torsten/Programs/TimeClock/backend
|
||||
nano .env
|
||||
```
|
||||
|
||||
Setzen Sie folgende Werte:
|
||||
|
||||
```env
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=timeclock_user
|
||||
DB_PASSWORD=IhrSicheresPasswort123!
|
||||
DB_NAME=stechuhr2
|
||||
```
|
||||
|
||||
## 6. Verbindung testen
|
||||
|
||||
```bash
|
||||
# Mit neuen Credentials verbinden
|
||||
mysql -u timeclock_user -p stechuhr2
|
||||
|
||||
# In MySQL-Konsole:
|
||||
SHOW TABLES;
|
||||
EXIT;
|
||||
```
|
||||
|
||||
## 7. Server starten
|
||||
|
||||
```bash
|
||||
cd /home/torsten/Programs/TimeClock
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Erwartete Ausgabe:**
|
||||
```
|
||||
✅ Sequelize: MySQL-Datenbankverbindung hergestellt
|
||||
✅ Sequelize: Models initialisiert
|
||||
🕐 TimeClock Server läuft auf Port 3010
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Access denied for user"
|
||||
|
||||
```bash
|
||||
# Berechtigungen prüfen
|
||||
mysql -u root -p
|
||||
```
|
||||
|
||||
```sql
|
||||
SHOW GRANTS FOR 'timeclock_user'@'localhost';
|
||||
-- Falls leer: Berechtigungen neu vergeben (siehe Schritt 3)
|
||||
```
|
||||
|
||||
### "Unknown database stechuhr2"
|
||||
|
||||
```bash
|
||||
mysql -u root -p
|
||||
```
|
||||
|
||||
```sql
|
||||
CREATE DATABASE stechuhr2;
|
||||
```
|
||||
|
||||
### "Can't connect to MySQL server"
|
||||
|
||||
```bash
|
||||
# MySQL-Status prüfen
|
||||
sudo systemctl status mysql
|
||||
|
||||
# Starten falls gestoppt
|
||||
sudo systemctl start mysql
|
||||
```
|
||||
|
||||
### Port 3306 bereits belegt
|
||||
|
||||
```bash
|
||||
# Prüfen was auf Port 3306 läuft
|
||||
sudo ss -tlnp | grep 3306
|
||||
|
||||
# MySQL-Port ändern (in /etc/my.cnf)
|
||||
[mysqld]
|
||||
port=3307
|
||||
```
|
||||
|
||||
## Schnell-Setup für Entwicklung
|
||||
|
||||
Falls Sie nur schnell testen möchten:
|
||||
|
||||
```bash
|
||||
# MySQL als root ohne Passwort
|
||||
mysql -u root
|
||||
|
||||
CREATE DATABASE stechuhr2;
|
||||
EXIT;
|
||||
```
|
||||
|
||||
Dann in `.env`:
|
||||
```env
|
||||
DB_USER=root
|
||||
DB_PASSWORD=
|
||||
DB_NAME=stechuhr2
|
||||
```
|
||||
|
||||
⚠️ **Nicht für Produktion verwenden!**
|
||||
|
||||
## Existierende stechuhr2 DB verwenden
|
||||
|
||||
Falls Sie bereits eine `stechuhr2` Datenbank haben:
|
||||
|
||||
```bash
|
||||
# Prüfen Sie die Credentials
|
||||
mysql -u IHR_USER -p stechuhr2
|
||||
|
||||
# Tabellen anzeigen
|
||||
SHOW TABLES;
|
||||
```
|
||||
|
||||
Dann in `.env`:
|
||||
```env
|
||||
DB_USER=IHR_EXISTIERENDER_USER
|
||||
DB_PASSWORD=IHR_EXISTIERENDES_PASSWORT
|
||||
DB_NAME=stechuhr2
|
||||
```
|
||||
|
||||
## Benutzer erstellen (Template)
|
||||
|
||||
```sql
|
||||
-- Benutzer mit vollem Zugriff
|
||||
CREATE USER 'timeclock_user'@'localhost' IDENTIFIED BY 'Passwort123';
|
||||
GRANT ALL PRIVILEGES ON stechuhr2.* TO 'timeclock_user'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
-- Nur Lese-Zugriff (optional)
|
||||
CREATE USER 'timeclock_readonly'@'localhost' IDENTIFIED BY 'Passwort123';
|
||||
GRANT SELECT ON stechuhr2.* TO 'timeclock_readonly'@'localhost';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
## Backup erstellen
|
||||
|
||||
```bash
|
||||
# Vor Änderungen immer Backup!
|
||||
mysqldump -u timeclock_user -p stechuhr2 > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# Restore
|
||||
mysql -u timeclock_user -p stechuhr2 < backup_20251015_143000.sql
|
||||
```
|
||||
|
||||
|
||||
|
||||
145
backend/README.md
Normal file
145
backend/README.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# TimeClock Backend API v3.0
|
||||
|
||||
Node.js/Express Backend für die TimeClock Zeiterfassungsanwendung.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Erstellen Sie eine `.env` Datei im Backend-Verzeichnis:
|
||||
|
||||
```env
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
```
|
||||
|
||||
## Starten
|
||||
|
||||
### Entwicklung (mit Auto-Reload)
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Produktion
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
## API-Dokumentation
|
||||
|
||||
### Base URL
|
||||
```
|
||||
http://localhost:3010/api
|
||||
```
|
||||
|
||||
### Endpunkte
|
||||
|
||||
#### Health Check
|
||||
```
|
||||
GET /api/health
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"message": "TimeClock API v3.0.0",
|
||||
"timestamp": "2025-10-15T10:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### Alle Zeiteinträge abrufen
|
||||
```
|
||||
GET /api/time-entries
|
||||
```
|
||||
|
||||
#### Einzelnen Zeiteintrag abrufen
|
||||
```
|
||||
GET /api/time-entries/:id
|
||||
```
|
||||
|
||||
#### Neuen Zeiteintrag erstellen
|
||||
```
|
||||
POST /api/time-entries
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"project": "Mein Projekt",
|
||||
"description": "Beschreibung"
|
||||
}
|
||||
```
|
||||
|
||||
#### Zeiteintrag aktualisieren
|
||||
```
|
||||
PUT /api/time-entries/:id
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"endTime": "2025-10-15T10:30:00.000Z",
|
||||
"description": "Aktualisierte Beschreibung"
|
||||
}
|
||||
```
|
||||
|
||||
#### Zeiteintrag löschen
|
||||
```
|
||||
DELETE /api/time-entries/:id
|
||||
```
|
||||
|
||||
#### Statistiken abrufen
|
||||
```
|
||||
GET /api/time-entries/stats/summary
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"totalEntries": 10,
|
||||
"completedEntries": 8,
|
||||
"runningEntries": 2,
|
||||
"totalHours": "42.50",
|
||||
"totalSeconds": 153000,
|
||||
"projectStats": {
|
||||
"Projekt A": {
|
||||
"duration": 86400,
|
||||
"count": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Datenmodell
|
||||
|
||||
### TimeEntry
|
||||
|
||||
```javascript
|
||||
{
|
||||
id: Number,
|
||||
startTime: String (ISO 8601),
|
||||
endTime: String (ISO 8601) | null,
|
||||
description: String,
|
||||
project: String,
|
||||
duration: Number (Sekunden) | null,
|
||||
isRunning: Boolean
|
||||
}
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **express** - Web-Framework
|
||||
- **cors** - CORS-Middleware
|
||||
- **helmet** - Sicherheits-Middleware
|
||||
- **morgan** - HTTP Request Logger
|
||||
- **dotenv** - Environment Variables
|
||||
|
||||
## Entwicklung
|
||||
|
||||
Der aktuelle Stand verwendet In-Memory-Speicher. Für eine Produktionsumgebung sollte eine Datenbank integriert werden:
|
||||
|
||||
- MongoDB mit mongoose
|
||||
- PostgreSQL mit pg/sequelize
|
||||
- MySQL mit mysql2/sequelize
|
||||
|
||||
565
backend/SEQUELIZE.md
Normal file
565
backend/SEQUELIZE.md
Normal file
@@ -0,0 +1,565 @@
|
||||
# Sequelize ORM Integration
|
||||
|
||||
## Übersicht
|
||||
|
||||
TimeClock v3 verwendet **Sequelize** als ORM (Object-Relational Mapping) für die MySQL-Datenbank. Dies ermöglicht eine typsichere, objektorientierte Arbeitsweise mit der Datenbank.
|
||||
|
||||
## Vorteile von Sequelize
|
||||
|
||||
✅ **Type-Safety** - Models definieren Datentypen
|
||||
✅ **Validierung** - Automatische Datenvalidierung
|
||||
✅ **Assoziationen** - Einfache Beziehungen zwischen Models
|
||||
✅ **Migrations** - Versionskontrolle für Datenbankschema
|
||||
✅ **Abstraktion** - Datenbank-unabhängiger Code
|
||||
✅ **Query Builder** - Typsichere Queries
|
||||
|
||||
## Konfiguration
|
||||
|
||||
### .env Datei
|
||||
|
||||
Alle Datenbankeinstellungen werden über `.env` konfiguriert:
|
||||
|
||||
```env
|
||||
# MySQL Datenbank-Verbindung
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=root
|
||||
DB_PASSWORD=ihr_passwort
|
||||
DB_NAME=stechuhr2
|
||||
|
||||
# Datenbank-Optionen
|
||||
DB_LOGGING=false # SQL-Queries in Console loggen (true/false)
|
||||
DB_TIMEZONE=+01:00 # Timezone für Timestamps
|
||||
DB_POOL_MAX=10 # Max. Connections im Pool
|
||||
DB_POOL_MIN=0 # Min. Connections im Pool
|
||||
```
|
||||
|
||||
### Initialisierung
|
||||
|
||||
```javascript
|
||||
// src/config/database.js
|
||||
const sequelize = new Sequelize(
|
||||
process.env.DB_NAME,
|
||||
process.env.DB_USER,
|
||||
process.env.DB_PASSWORD,
|
||||
{
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
dialect: 'mysql',
|
||||
logging: process.env.DB_LOGGING === 'true',
|
||||
pool: {
|
||||
max: parseInt(process.env.DB_POOL_MAX),
|
||||
min: parseInt(process.env.DB_POOL_MIN)
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## Models
|
||||
|
||||
### Model-Struktur
|
||||
|
||||
Jedes Model repräsentiert eine Datenbanktabelle:
|
||||
|
||||
```javascript
|
||||
// src/models/User.js
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
class User extends Model {
|
||||
static initialize(sequelize) {
|
||||
User.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
full_name: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
}
|
||||
// ... weitere Felder
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'user',
|
||||
timestamps: false
|
||||
}
|
||||
);
|
||||
return User;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Verfügbare Models
|
||||
|
||||
| Model | Tabelle | Beschreibung |
|
||||
|-------|---------|--------------|
|
||||
| **User** | `user` | Benutzer |
|
||||
| **Worklog** | `worklog` | Zeiteinträge (Clock In/Out) |
|
||||
| **AuthInfo** | `auth_info` | Authentifizierung |
|
||||
| **State** | `state` | Bundesländer/Regionen |
|
||||
| **WeeklyWorktime** | `weekly_worktime` | Wochenarbeitszeit |
|
||||
| **Holiday** | `holiday` | Feiertage |
|
||||
| **Vacation** | `vacation` | Urlaub |
|
||||
| **Sick** | `sick` | Krankmeldungen |
|
||||
| **SickType** | `sick_type` | Krankmeldungstypen |
|
||||
|
||||
### Model-Features
|
||||
|
||||
#### 1. Getter/Setter
|
||||
|
||||
```javascript
|
||||
// Worklog Model
|
||||
class Worklog extends Model {
|
||||
// State als JSON speichern/laden
|
||||
state: {
|
||||
type: DataTypes.TEXT,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('state');
|
||||
try {
|
||||
return JSON.parse(rawValue);
|
||||
} catch {
|
||||
return { action: rawValue };
|
||||
}
|
||||
},
|
||||
set(value) {
|
||||
if (typeof value === 'object') {
|
||||
this.setDataValue('state', JSON.stringify(value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verwendung:
|
||||
worklog.state = { action: 'Clock In', project: 'Website' };
|
||||
console.log(worklog.state.project); // 'Website'
|
||||
```
|
||||
|
||||
#### 2. Instance Methods
|
||||
|
||||
```javascript
|
||||
class Worklog extends Model {
|
||||
isClockIn() {
|
||||
return this.relatedTo_id === null;
|
||||
}
|
||||
|
||||
getProject() {
|
||||
return this.state.project || 'Allgemein';
|
||||
}
|
||||
}
|
||||
|
||||
// Verwendung:
|
||||
if (worklog.isClockIn()) {
|
||||
console.log(`Projekt: ${worklog.getProject()}`);
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Class Methods
|
||||
|
||||
```javascript
|
||||
class User extends Model {
|
||||
getFullName() {
|
||||
return this.full_name;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Assoziationen
|
||||
|
||||
Models sind miteinander verknüpft:
|
||||
|
||||
```javascript
|
||||
// User → Worklog (1:n)
|
||||
User.hasMany(Worklog, { foreignKey: 'user_id', as: 'worklogs' });
|
||||
Worklog.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
|
||||
// Worklog → Worklog (Clock In/Out)
|
||||
Worklog.belongsTo(Worklog, { foreignKey: 'relatedTo_id', as: 'relatedTo' });
|
||||
Worklog.hasOne(Worklog, { foreignKey: 'relatedTo_id', as: 'clockOut' });
|
||||
|
||||
// User → AuthInfo (1:1)
|
||||
User.hasOne(AuthInfo, { foreignKey: 'user_id', as: 'authInfo' });
|
||||
AuthInfo.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
```
|
||||
|
||||
### Include in Queries
|
||||
|
||||
```javascript
|
||||
// User mit Worklogs laden
|
||||
const user = await User.findByPk(1, {
|
||||
include: [{
|
||||
model: Worklog,
|
||||
as: 'worklogs',
|
||||
limit: 10
|
||||
}]
|
||||
});
|
||||
|
||||
console.log(user.worklogs); // Array von Worklog-Instanzen
|
||||
```
|
||||
|
||||
## Repository-Pattern
|
||||
|
||||
Repositories verwenden Sequelize Models:
|
||||
|
||||
```javascript
|
||||
class WorklogRepository {
|
||||
async findById(id) {
|
||||
const { Worklog } = database.getModels();
|
||||
return await Worklog.findByPk(id);
|
||||
}
|
||||
|
||||
async create(data) {
|
||||
const { Worklog } = database.getModels();
|
||||
return await Worklog.create(data);
|
||||
}
|
||||
|
||||
async update(id, updates) {
|
||||
const { Worklog } = database.getModels();
|
||||
const worklog = await Worklog.findByPk(id);
|
||||
await worklog.update(updates);
|
||||
return worklog;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Query-Beispiele
|
||||
|
||||
### Einfache Queries
|
||||
|
||||
```javascript
|
||||
// Alle Benutzer
|
||||
const users = await User.findAll();
|
||||
|
||||
// Benutzer nach ID
|
||||
const user = await User.findByPk(1);
|
||||
|
||||
// Erstellen
|
||||
const newUser = await User.create({
|
||||
full_name: 'Max Mustermann',
|
||||
daily_hours: 8
|
||||
});
|
||||
|
||||
// Aktualisieren
|
||||
await user.update({ full_name: 'Max M.' });
|
||||
|
||||
// Löschen
|
||||
await user.destroy();
|
||||
```
|
||||
|
||||
### WHERE-Bedingungen
|
||||
|
||||
```javascript
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
// Einfache WHERE
|
||||
const worklogs = await Worklog.findAll({
|
||||
where: { user_id: 1 }
|
||||
});
|
||||
|
||||
// Operatoren
|
||||
const recent = await Worklog.findAll({
|
||||
where: {
|
||||
tstamp: {
|
||||
[Op.gte]: new Date('2025-01-01')
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Mehrere Bedingungen
|
||||
const active = await Worklog.findAll({
|
||||
where: {
|
||||
user_id: 1,
|
||||
relatedTo_id: null
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Order & Limit
|
||||
|
||||
```javascript
|
||||
const latest = await Worklog.findAll({
|
||||
where: { user_id: 1 },
|
||||
order: [['tstamp', 'DESC']],
|
||||
limit: 10,
|
||||
offset: 0
|
||||
});
|
||||
```
|
||||
|
||||
### Aggregationen
|
||||
|
||||
```javascript
|
||||
// COUNT
|
||||
const count = await Worklog.count({
|
||||
where: { user_id: 1 }
|
||||
});
|
||||
|
||||
// SUM (mit raw query)
|
||||
const [result] = await sequelize.query(`
|
||||
SELECT SUM(TIMESTAMPDIFF(SECOND, w1.tstamp, w2.tstamp)) as total
|
||||
FROM worklog w1
|
||||
JOIN worklog w2 ON w2.relatedTo_id = w1.id
|
||||
WHERE w1.user_id = ?
|
||||
`, {
|
||||
replacements: [1],
|
||||
type: QueryTypes.SELECT
|
||||
});
|
||||
```
|
||||
|
||||
## Raw Queries
|
||||
|
||||
Für komplexe Queries können Raw SQL verwendet werden:
|
||||
|
||||
```javascript
|
||||
const sequelize = database.getSequelize();
|
||||
|
||||
const [results] = await sequelize.query(`
|
||||
SELECT
|
||||
w1.id as start_id,
|
||||
w2.id as end_id,
|
||||
TIMESTAMPDIFF(SECOND, w1.tstamp, w2.tstamp) as duration
|
||||
FROM worklog w1
|
||||
LEFT JOIN worklog w2 ON w2.relatedTo_id = w1.id
|
||||
WHERE w1.user_id = :userId
|
||||
`, {
|
||||
replacements: { userId: 1 },
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
});
|
||||
```
|
||||
|
||||
## Transaktionen
|
||||
|
||||
```javascript
|
||||
const sequelize = database.getSequelize();
|
||||
|
||||
const t = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
// Clock In erstellen
|
||||
const clockIn = await Worklog.create({
|
||||
user_id: 1,
|
||||
state: { action: 'Clock In' },
|
||||
tstamp: new Date()
|
||||
}, { transaction: t });
|
||||
|
||||
// Weitere Operationen...
|
||||
|
||||
await t.commit();
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## Validierung
|
||||
|
||||
```javascript
|
||||
class User extends Model {
|
||||
static initialize(sequelize) {
|
||||
User.init({
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
validate: {
|
||||
isEmail: true,
|
||||
notEmpty: true
|
||||
}
|
||||
},
|
||||
full_name: {
|
||||
type: DataTypes.TEXT,
|
||||
validate: {
|
||||
len: [2, 255]
|
||||
}
|
||||
},
|
||||
daily_hours: {
|
||||
type: DataTypes.INTEGER,
|
||||
validate: {
|
||||
min: 1,
|
||||
max: 24
|
||||
}
|
||||
}
|
||||
}, {
|
||||
sequelize,
|
||||
tableName: 'user'
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Hooks (Lifecycle Events)
|
||||
|
||||
```javascript
|
||||
class User extends Model {
|
||||
static initialize(sequelize) {
|
||||
User.init({ /* ... */ }, {
|
||||
sequelize,
|
||||
tableName: 'user',
|
||||
hooks: {
|
||||
beforeCreate: (user, options) => {
|
||||
user.last_change = new Date();
|
||||
},
|
||||
beforeUpdate: (user, options) => {
|
||||
user.version += 1;
|
||||
user.last_change = new Date();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Migrations (Optional)
|
||||
|
||||
Für Datenbankschema-Änderungen:
|
||||
|
||||
```bash
|
||||
# Sequelize CLI installieren
|
||||
npm install --save-dev sequelize-cli
|
||||
|
||||
# Initialisieren
|
||||
npx sequelize-cli init
|
||||
|
||||
# Migration erstellen
|
||||
npx sequelize-cli migration:generate --name add-user-field
|
||||
|
||||
# Migrations ausführen
|
||||
npx sequelize-cli db:migrate
|
||||
|
||||
# Migrations rückgängig machen
|
||||
npx sequelize-cli db:migrate:undo
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Model-Zugriff über Database
|
||||
|
||||
```javascript
|
||||
// ✅ Gut
|
||||
const { Worklog } = database.getModels();
|
||||
const worklogs = await Worklog.findAll();
|
||||
|
||||
// ❌ Nicht
|
||||
const Worklog = require('../models/Worklog');
|
||||
```
|
||||
|
||||
### 2. Eager Loading für Beziehungen
|
||||
|
||||
```javascript
|
||||
// ✅ Gut - Eine Query
|
||||
const user = await User.findByPk(1, {
|
||||
include: [{ model: Worklog, as: 'worklogs' }]
|
||||
});
|
||||
|
||||
// ❌ Nicht - N+1 Problem
|
||||
const user = await User.findByPk(1);
|
||||
const worklogs = await Worklog.findAll({ where: { user_id: user.id } });
|
||||
```
|
||||
|
||||
### 3. Transactions für kritische Operationen
|
||||
|
||||
```javascript
|
||||
// ✅ Gut
|
||||
const t = await sequelize.transaction();
|
||||
try {
|
||||
await operation1({ transaction: t });
|
||||
await operation2({ transaction: t });
|
||||
await t.commit();
|
||||
} catch (error) {
|
||||
await t.rollback();
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Raw Queries für Performance
|
||||
|
||||
```javascript
|
||||
// Für komplexe Aggregationen sind Raw Queries oft schneller
|
||||
const stats = await sequelize.query(complexSQL, {
|
||||
replacements: { userId },
|
||||
type: QueryTypes.SELECT
|
||||
});
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
### SQL-Queries anzeigen
|
||||
|
||||
```env
|
||||
# In .env
|
||||
DB_LOGGING=true
|
||||
```
|
||||
|
||||
Dann werden alle SQL-Queries in der Console geloggt:
|
||||
|
||||
```
|
||||
Executing (default): SELECT * FROM `user` WHERE `id` = 1;
|
||||
```
|
||||
|
||||
### Model-Daten anzeigen
|
||||
|
||||
```javascript
|
||||
console.log(user.toJSON());
|
||||
```
|
||||
|
||||
## Performance-Tipps
|
||||
|
||||
1. **Indizes nutzen** - Models definieren Indizes
|
||||
2. **Eager Loading** - Include statt separate Queries
|
||||
3. **Pagination** - Limit & Offset verwenden
|
||||
4. **Raw Queries** - Für komplexe Aggregationen
|
||||
5. **Connection Pooling** - Bereits konfiguriert
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Sequelize not initialized"
|
||||
|
||||
```javascript
|
||||
// Sicherstellen, dass database.initialize() aufgerufen wurde
|
||||
await database.initialize();
|
||||
```
|
||||
|
||||
### "Model not found"
|
||||
|
||||
```javascript
|
||||
// Models werden in database.js initialisiert
|
||||
// Prüfen Sie, ob Model in initializeModels() geladen wird
|
||||
```
|
||||
|
||||
### Timezone-Probleme
|
||||
|
||||
```env
|
||||
# In .env die richtige Timezone setzen
|
||||
DB_TIMEZONE=+01:00
|
||||
```
|
||||
|
||||
## Migration von Raw SQL
|
||||
|
||||
### Vorher (Raw SQL):
|
||||
|
||||
```javascript
|
||||
const [rows] = await db.execute(
|
||||
'SELECT * FROM user WHERE id = ?',
|
||||
[userId]
|
||||
);
|
||||
```
|
||||
|
||||
### Nachher (Sequelize):
|
||||
|
||||
```javascript
|
||||
const user = await User.findByPk(userId);
|
||||
```
|
||||
|
||||
Viel einfacher und typsicher! 🎉
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
Sequelize bietet:
|
||||
- ✅ Typsichere Models
|
||||
- ✅ Automatische Validierung
|
||||
- ✅ Einfache Assoziationen
|
||||
- ✅ Query Builder
|
||||
- ✅ .env-basierte Konfiguration
|
||||
- ✅ Migrations-Support
|
||||
|
||||
Perfekt für professionelle Node.js-Anwendungen!
|
||||
|
||||
|
||||
|
||||
28
backend/add-missing-wednesday.sql
Normal file
28
backend/add-missing-wednesday.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Füge fehlende Worklog-Einträge für Mittwoch, 15.10.2025 hinzu
|
||||
-- Lokale Zeit (MESZ = UTC+2): 08:43:02, 12:12:04, 13:33:20, 17:53:06
|
||||
-- UTC-Zeit (für DB): 06:43:02, 10:12:04, 11:33:20, 15:53:06
|
||||
|
||||
-- Variablen für die IDs (werden nach dem INSERT gesetzt)
|
||||
SET @start_work_id = NULL;
|
||||
SET @start_pause_id = NULL;
|
||||
|
||||
-- 1. start work (keine relatedTo_id)
|
||||
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
|
||||
VALUES (1, 'start work', '2025-10-15 06:43:02', NULL, 0);
|
||||
SET @start_work_id = LAST_INSERT_ID();
|
||||
|
||||
-- 2. start pause (relatedTo_id = start work)
|
||||
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
|
||||
VALUES (1, 'start pause', '2025-10-15 10:12:04', @start_work_id, 0);
|
||||
SET @start_pause_id = LAST_INSERT_ID();
|
||||
|
||||
-- 3. stop pause (relatedTo_id = start pause)
|
||||
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
|
||||
VALUES (1, 'stop pause', '2025-10-15 11:33:20', @start_pause_id, 0);
|
||||
|
||||
-- 4. stop work (relatedTo_id = start work)
|
||||
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
|
||||
VALUES (1, 'stop work', '2025-10-15 15:53:06', @start_work_id, 0);
|
||||
|
||||
SELECT 'Worklog-Einträge für 15.10.2025 erfolgreich eingefügt' AS Status;
|
||||
|
||||
284
backend/database-schema.sql
Normal file
284
backend/database-schema.sql
Normal file
@@ -0,0 +1,284 @@
|
||||
-- TimeClock v3 - Datenbankschema
|
||||
-- Basierend auf stechuhr2 Struktur
|
||||
|
||||
-- ============================================================================
|
||||
-- Basis-Tabellen
|
||||
-- ============================================================================
|
||||
|
||||
-- State Tabelle (Bundesländer/Regionen)
|
||||
CREATE TABLE IF NOT EXISTS `state` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`state_name` text NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- User Tabelle
|
||||
CREATE TABLE IF NOT EXISTS `user` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`last_change` datetime DEFAULT NULL,
|
||||
`role` int NOT NULL DEFAULT 0,
|
||||
`daily_hours` int NOT NULL DEFAULT 8,
|
||||
`state_id` bigint DEFAULT NULL,
|
||||
`full_name` text NOT NULL,
|
||||
`week_hours` int NOT NULL DEFAULT 40,
|
||||
`week_workdays` int NOT NULL DEFAULT 5,
|
||||
`preferred_title_type` int NOT NULL DEFAULT 0,
|
||||
`overtime_offset_minutes` int DEFAULT 0 COMMENT 'Überstunden-Startwert in Minuten (z.B. Übertrag aus altem System)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_user_state` (`state_id`),
|
||||
CONSTRAINT `fk_user_state` FOREIGN KEY (`state_id`) REFERENCES `state` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================================
|
||||
-- Authentifizierung
|
||||
-- ============================================================================
|
||||
|
||||
-- Auth Info Tabelle
|
||||
CREATE TABLE IF NOT EXISTS `auth_info` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`user_id` bigint DEFAULT NULL,
|
||||
`password_hash` varchar(100) NOT NULL,
|
||||
`password_method` varchar(20) NOT NULL,
|
||||
`password_salt` varchar(60) NOT NULL,
|
||||
`status` int NOT NULL DEFAULT 1,
|
||||
`failed_login_attempts` int NOT NULL DEFAULT 0,
|
||||
`last_login_attempt` datetime DEFAULT NULL,
|
||||
`email` varchar(256) NOT NULL,
|
||||
`unverified_email` varchar(256) NOT NULL DEFAULT '',
|
||||
`email_token` varchar(64) NOT NULL DEFAULT '',
|
||||
`email_token_expires` datetime DEFAULT NULL,
|
||||
`email_token_role` int NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `email_unique` (`email`),
|
||||
KEY `fk_auth_info_user` (`user_id`),
|
||||
CONSTRAINT `fk_auth_info_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Auth Token Tabelle
|
||||
CREATE TABLE IF NOT EXISTS `auth_token` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`auth_info_id` bigint DEFAULT NULL,
|
||||
`value` varchar(64) NOT NULL,
|
||||
`expires` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_auth_token_auth_info` (`auth_info_id`),
|
||||
CONSTRAINT `fk_auth_token_auth_info` FOREIGN KEY (`auth_info_id`) REFERENCES `auth_info` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Auth Identity Tabelle
|
||||
CREATE TABLE IF NOT EXISTS `auth_identity` (
|
||||
`id` bigint NOT NULL,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`auth_info_id` bigint DEFAULT NULL,
|
||||
`provider` varchar(64) NOT NULL,
|
||||
`identity` varchar(512) NOT NULL,
|
||||
KEY `fk_auth_identity_auth_info` (`auth_info_id`),
|
||||
CONSTRAINT `fk_auth_identity_auth_info` FOREIGN KEY (`auth_info_id`) REFERENCES `auth_info` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================================
|
||||
-- Zeiterfassung
|
||||
-- ============================================================================
|
||||
|
||||
-- Worklog Tabelle (Haupttabelle für Zeiteinträge)
|
||||
CREATE TABLE IF NOT EXISTS `worklog` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`user_id` bigint NOT NULL,
|
||||
`state` text NOT NULL,
|
||||
`tstamp` datetime DEFAULT NULL,
|
||||
`relatedTo_id` bigint DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_worklog_relatedTo` (`relatedTo_id`),
|
||||
KEY `worklog_tstamp_IDX` (`tstamp`) USING BTREE,
|
||||
KEY `worklog_user_id_IDX` (`user_id`,`tstamp`) USING BTREE,
|
||||
CONSTRAINT `fk_worklog_relatedTo` FOREIGN KEY (`relatedTo_id`) REFERENCES `worklog` (`id`),
|
||||
CONSTRAINT `fk_worklog_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Timefix Tabelle (Zeitkorrekturen)
|
||||
CREATE TABLE IF NOT EXISTS `timefix` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`user_id` bigint DEFAULT NULL,
|
||||
`worklog_id` bigint DEFAULT NULL,
|
||||
`fix_type` text NOT NULL,
|
||||
`fix_date_time` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_timefix_user` (`user_id`),
|
||||
KEY `fk_timefix_worklog` (`worklog_id`),
|
||||
KEY `timefix_fix_date_time_IDX` (`fix_date_time`) USING BTREE,
|
||||
CONSTRAINT `fk_timefix_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`),
|
||||
CONSTRAINT `fk_timefix_worklog` FOREIGN KEY (`worklog_id`) REFERENCES `worklog` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================================
|
||||
-- Arbeitszeit-Einstellungen
|
||||
-- ============================================================================
|
||||
|
||||
-- Weekly Worktime Tabelle
|
||||
CREATE TABLE IF NOT EXISTS `weekly_worktime` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`weekly_work_time` double NOT NULL DEFAULT 40,
|
||||
`starting_from` date DEFAULT NULL,
|
||||
`ends_at` date DEFAULT NULL,
|
||||
`user_id` int DEFAULT NULL,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- User Settings Type Tabelle
|
||||
CREATE TABLE IF NOT EXISTS `user_settings_type` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- User Setting Tabelle
|
||||
CREATE TABLE IF NOT EXISTS `user_setting` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`user_id` bigint NOT NULL,
|
||||
`setting_id` int NOT NULL,
|
||||
`begin_date` date DEFAULT NULL,
|
||||
`end_date` date DEFAULT NULL,
|
||||
`value` varchar(100) DEFAULT NULL,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `user_setting_user_FK` (`user_id`),
|
||||
KEY `user_setting_user_settings_type_FK` (`setting_id`),
|
||||
CONSTRAINT `user_setting_user_FK` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`),
|
||||
CONSTRAINT `user_setting_user_settings_type_FK` FOREIGN KEY (`setting_id`) REFERENCES `user_settings_type` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Timewish Tabelle
|
||||
CREATE TABLE IF NOT EXISTS `timewish` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`user_id` bigint DEFAULT NULL,
|
||||
`day` int NOT NULL COMMENT '1=Mo, 2=Di, 3=Mi, 4=Do, 5=Fr, 6=Sa, 7=So',
|
||||
`wishtype` int NOT NULL COMMENT '1=Ende nach Uhrzeit, 2=Ende nach Stunden',
|
||||
`hours` float NOT NULL,
|
||||
`end_time` time DEFAULT NULL,
|
||||
`start_date` date NOT NULL COMMENT 'Ab welchem Datum gilt dieser Timewish',
|
||||
`end_date` date DEFAULT NULL COMMENT 'Bis welchem Datum gilt dieser Timewish (NULL = bis heute)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_timewish_user` (`user_id`),
|
||||
CONSTRAINT `fk_timewish_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================================
|
||||
-- Urlaub & Krankmeldungen
|
||||
-- ============================================================================
|
||||
|
||||
-- Sick Type Tabelle
|
||||
CREATE TABLE IF NOT EXISTS `sick_type` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`description` text NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Sick Tabelle (Krankmeldungen)
|
||||
CREATE TABLE IF NOT EXISTS `sick` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`user_id` bigint DEFAULT NULL,
|
||||
`first_day` date DEFAULT NULL,
|
||||
`last_day` date DEFAULT NULL,
|
||||
`sick_type_id` bigint DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_sick_user` (`user_id`),
|
||||
KEY `fk_sick_sick_type` (`sick_type_id`),
|
||||
CONSTRAINT `fk_sick_sick_type` FOREIGN KEY (`sick_type_id`) REFERENCES `sick_type` (`id`),
|
||||
CONSTRAINT `fk_sick_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Vacation Tabelle (Urlaub)
|
||||
CREATE TABLE IF NOT EXISTS `vacation` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`user_id` bigint NOT NULL,
|
||||
`first_day` date DEFAULT NULL,
|
||||
`last_day` date DEFAULT NULL,
|
||||
`vacation_type` int NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `fk_vacation_user` (`user_id`),
|
||||
CONSTRAINT `fk_vacation_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================================
|
||||
-- Feiertage
|
||||
-- ============================================================================
|
||||
|
||||
-- Holiday Tabelle
|
||||
CREATE TABLE IF NOT EXISTS `holiday` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`date` date DEFAULT NULL,
|
||||
`hours` int NOT NULL DEFAULT 8,
|
||||
`description` text NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Holiday State Verknüpfung
|
||||
CREATE TABLE IF NOT EXISTS `holiday_state` (
|
||||
`state_id` bigint NOT NULL,
|
||||
`holiday_id` bigint NOT NULL,
|
||||
PRIMARY KEY (`state_id`,`holiday_id`),
|
||||
KEY `holiday_state_state` (`state_id`),
|
||||
KEY `holiday_state_holiday` (`holiday_id`),
|
||||
CONSTRAINT `fk_holiday_state_key1` FOREIGN KEY (`state_id`) REFERENCES `state` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_holiday_state_key2` FOREIGN KEY (`holiday_id`) REFERENCES `holiday` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================================
|
||||
-- ACL (Access Control List)
|
||||
-- ============================================================================
|
||||
|
||||
-- ACL User Tabelle
|
||||
CREATE TABLE IF NOT EXISTS `acl_user` (
|
||||
`id` bigint NOT NULL,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`user_id` bigint DEFAULT NULL,
|
||||
`to_email` text NOT NULL,
|
||||
KEY `fk_acl_user_user` (`user_id`),
|
||||
CONSTRAINT `fk_acl_user_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ACL Type Tabelle
|
||||
CREATE TABLE IF NOT EXISTS `acl_type` (
|
||||
`id` bigint NOT NULL,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`description` text NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ACL Tabelle
|
||||
CREATE TABLE IF NOT EXISTS `acl` (
|
||||
`id` bigint NOT NULL,
|
||||
`version` int NOT NULL DEFAULT 0,
|
||||
`acl_user_id` bigint NOT NULL,
|
||||
`acl_type` int NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================================
|
||||
-- Hilfstabellen
|
||||
-- ============================================================================
|
||||
|
||||
-- Dates Tabelle
|
||||
CREATE TABLE IF NOT EXISTS `dates` (
|
||||
`id` int NOT NULL AUTO_INCREMENT,
|
||||
`date` date NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ============================================================================
|
||||
-- Erfolgsmeldung
|
||||
-- ============================================================================
|
||||
|
||||
SELECT '✅ Alle Tabellen erfolgreich erstellt!' AS Status;
|
||||
|
||||
69
backend/fix-timezone-correct.js
Normal file
69
backend/fix-timezone-correct.js
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Korrektes Timezone-Fix für Worklog-Einträge
|
||||
*
|
||||
* Situation:
|
||||
* - DB hat DATETIME-Spalten (ohne Timezone-Info)
|
||||
* - Alte App hat lokale Zeit gespeichert: 08:07 → in DB: "2025-10-14 08:07:18"
|
||||
* - Sequelize liest mit timezone='+00:00' und interpretiert als UTC
|
||||
* - Sequelize gibt zurück: "2025-10-14T08:07:18.000Z"
|
||||
* - JavaScript interpretiert das als 08:07 UTC
|
||||
* - JavaScript zeigt: 10:07 lokal (UTC+2 MESZ)
|
||||
*
|
||||
* Was wir wollen:
|
||||
* - DB hat: "2025-10-14 08:07:18" (lokale Zeit)
|
||||
* - Anzeige: "08:07" (lokale Zeit)
|
||||
*
|
||||
* Lösung: NICHTS in der DB ändern!
|
||||
* Stattdessen: UTC-Komponenten direkt als lokale Zeit interpretieren
|
||||
*/
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '.env') });
|
||||
|
||||
async function checkTimezones() {
|
||||
let connection;
|
||||
|
||||
try {
|
||||
connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'stechuhr2'
|
||||
});
|
||||
|
||||
console.log('✅ Datenbankverbindung hergestellt\n');
|
||||
|
||||
// Hole Beispiel-Einträge vom 14. und 15. Oktober
|
||||
const [rows] = await connection.execute(
|
||||
`SELECT id, tstamp, state
|
||||
FROM worklog
|
||||
WHERE id IN (4153, 4154, 4155, 4156, 4157, 4158, 4159, 4160)
|
||||
ORDER BY id ASC`
|
||||
);
|
||||
|
||||
console.log('📊 Aktuelle DB-Einträge:\n');
|
||||
rows.forEach(row => {
|
||||
const state = JSON.parse(row.tstamp);
|
||||
console.log(`ID ${row.id}: ${row.tstamp} (Rohdaten aus DB)`);
|
||||
});
|
||||
|
||||
console.log('\n💡 Diese Zeiten sollten die LOKALEN Zeiten sein (MEZ/MESZ)');
|
||||
console.log('💡 JavaScript sollte sie direkt so anzeigen, OHNE Timezone-Konvertierung\n');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Fehler:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkTimezones().then(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
|
||||
|
||||
156
backend/fix-timezone.js
Normal file
156
backend/fix-timezone.js
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Timezone-Fix für Worklog-Einträge
|
||||
*
|
||||
* Problem: Die alte Anwendung hat lokale Zeiten direkt in die DB geschrieben
|
||||
* Die DB-Spalte ist DATETIME (ohne Timezone-Info), aber Sequelize interpretiert sie als UTC
|
||||
*
|
||||
* Beispiel:
|
||||
* - Tatsächliche Aktion: 08:07 Uhr lokale Zeit (MEZ/MESZ)
|
||||
* - In DB gespeichert: 2025-10-14 08:07:18 (als DATETIME ohne TZ)
|
||||
* - Sequelize liest: 2025-10-14T08:07:18.000Z (interpretiert als UTC)
|
||||
* - JavaScript zeigt: 10:07 lokal (UTC + 2h MESZ) ❌
|
||||
*
|
||||
* Lösung: Konvertiere DATETIME zu echtem UTC basierend auf Sommer-/Winterzeit
|
||||
*/
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Datum in der Sommerzeit liegt
|
||||
* Sommerzeit in Europa: letzter Sonntag im März (02:00) bis letzter Sonntag im Oktober (03:00)
|
||||
*/
|
||||
function isSummertime(date) {
|
||||
const year = date.getFullYear();
|
||||
|
||||
// Finde letzten Sonntag im März
|
||||
const marchEnd = new Date(year, 2, 31);
|
||||
const marchSunday = 31 - marchEnd.getDay();
|
||||
const summerStart = new Date(year, 2, marchSunday, 2, 0, 0);
|
||||
|
||||
// Finde letzten Sonntag im Oktober
|
||||
const octoberEnd = new Date(year, 9, 31);
|
||||
const octoberSunday = 31 - octoberEnd.getDay();
|
||||
const summerEnd = new Date(year, 9, octoberSunday, 3, 0, 0);
|
||||
|
||||
return date >= summerStart && date < summerEnd;
|
||||
}
|
||||
|
||||
async function fixTimezones() {
|
||||
let connection;
|
||||
|
||||
try {
|
||||
connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'stechuhr2',
|
||||
timezone: '+00:00' // UTC
|
||||
});
|
||||
|
||||
console.log('✅ Datenbankverbindung hergestellt\n');
|
||||
|
||||
// Hole alle Worklog-Einträge
|
||||
const [rows] = await connection.execute(
|
||||
'SELECT id, tstamp FROM worklog ORDER BY id ASC'
|
||||
);
|
||||
|
||||
console.log(`📊 Gefunden: ${rows.length} Worklog-Einträge\n`);
|
||||
|
||||
const updates = [];
|
||||
|
||||
for (const row of rows) {
|
||||
// row.tstamp ist ein JavaScript Date-Objekt
|
||||
// MySQL hat die lokale Zeit gespeichert (z.B. 08:07:18)
|
||||
// Sequelize interpretiert das als UTC
|
||||
|
||||
// Wir erstellen ein neues Date-Objekt aus den Komponenten
|
||||
const storedDate = new Date(row.tstamp);
|
||||
|
||||
// Extrahiere die Komponenten (das sind die "falschen" UTC-Werte)
|
||||
const year = storedDate.getUTCFullYear();
|
||||
const month = storedDate.getUTCMonth();
|
||||
const day = storedDate.getUTCDate();
|
||||
const hours = storedDate.getUTCHours();
|
||||
const minutes = storedDate.getUTCMinutes();
|
||||
const seconds = storedDate.getUTCSeconds();
|
||||
|
||||
// Diese Komponenten sind eigentlich die lokale Zeit
|
||||
// Erstelle ein Datum mit diesen Werten als lokale Zeit
|
||||
const localDate = new Date(year, month, day, hours, minutes, seconds);
|
||||
|
||||
// Prüfe ob Sommer- oder Winterzeit
|
||||
const offset = isSummertime(localDate) ? 2 : 1;
|
||||
|
||||
// Konvertiere zu UTC: Lokale Zeit - Offset
|
||||
const utcDate = new Date(localDate.getTime() - (offset * 60 * 60 * 1000));
|
||||
|
||||
updates.push({
|
||||
id: row.id,
|
||||
old: `${year}-${(month+1).toString().padStart(2,'0')}-${day.toString().padStart(2,'0')} ${hours.toString().padStart(2,'0')}:${minutes.toString().padStart(2,'0')}:${seconds.toString().padStart(2,'0')}`,
|
||||
new: utcDate.toISOString().replace('T', ' ').replace('.000Z', ''),
|
||||
offset: offset
|
||||
});
|
||||
}
|
||||
|
||||
// Zeige Statistiken
|
||||
const summerCount = updates.filter(u => u.offset === 2).length;
|
||||
const winterCount = updates.filter(u => u.offset === 1).length;
|
||||
|
||||
console.log(`📋 Analyse-Ergebnis:`);
|
||||
console.log(` - Sommerzeit (UTC+2): ${summerCount} Einträge`);
|
||||
console.log(` - Winterzeit (UTC+1): ${winterCount} Einträge`);
|
||||
console.log(` - Gesamt zu korrigieren: ${updates.length}`);
|
||||
|
||||
// Zeige erste 10 Beispiele
|
||||
console.log(`\n📝 Beispiele (erste 10):`);
|
||||
updates.slice(0, 10).forEach(u => {
|
||||
console.log(` ID ${u.id}: ${u.old} (lokal) → ${u.new} (UTC, offset: -${u.offset}h)`);
|
||||
});
|
||||
|
||||
console.log(`\n⚠️ WARNUNG: Diese Operation ändert ${updates.length} Einträge in der Datenbank!`);
|
||||
console.log(`\n📝 Backup erstellen:`);
|
||||
console.log(` mysqldump -u ${process.env.DB_USER} -p ${process.env.DB_NAME} worklog > worklog_backup_$(date +%Y%m%d_%H%M%S).sql`);
|
||||
console.log(`\nFühre das Script mit dem Parameter 'apply' aus, um die Änderungen anzuwenden:`);
|
||||
console.log(` node backend/fix-timezone.js apply\n`);
|
||||
|
||||
// Nur anwenden, wenn 'apply' als Parameter übergeben wurde
|
||||
if (process.argv.includes('apply')) {
|
||||
console.log('🔄 Wende Korrekturen an...\n');
|
||||
|
||||
let updatedCount = 0;
|
||||
for (const update of updates) {
|
||||
await connection.execute(
|
||||
'UPDATE worklog SET tstamp = ? WHERE id = ?',
|
||||
[update.new, update.id]
|
||||
);
|
||||
updatedCount++;
|
||||
|
||||
if (updatedCount % 100 === 0) {
|
||||
console.log(` ${updatedCount}/${updates.length} aktualisiert...`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ ${updatedCount} Einträge erfolgreich aktualisiert!`);
|
||||
console.log(`\n🔄 Bitte starte den Backend-Server neu, um die Änderungen zu sehen.`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Fehler:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
console.log('\n🔌 Datenbankverbindung geschlossen');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Script ausführen
|
||||
fixTimezones().then(() => {
|
||||
process.exit(0);
|
||||
}).catch(err => {
|
||||
console.error('Fataler Fehler:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
39
backend/fix-worklog-timezone.sql
Normal file
39
backend/fix-worklog-timezone.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- Script zur Korrektur der Worklog Zeitstempel
|
||||
-- Die alte C++ App hat lokale Zeit gespeichert, aber als UTC interpretiert
|
||||
-- Wir müssen die Zeitstempel korrigieren, indem wir sie als lokale Zeit interpretieren
|
||||
|
||||
-- WICHTIG: Erstelle erst ein Backup!
|
||||
-- mysqldump -u root -p stechuhr2 worklog > worklog_backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
-- Prüfe aktuelle Daten (erste 10 Einträge)
|
||||
SELECT
|
||||
id,
|
||||
user_id,
|
||||
state,
|
||||
tstamp AS tstamp_alt,
|
||||
CONVERT_TZ(tstamp, '+00:00', '+01:00') AS tstamp_neu_mez,
|
||||
CONVERT_TZ(tstamp, '+00:00', '+02:00') AS tstamp_neu_mesz
|
||||
FROM worklog
|
||||
ORDER BY id
|
||||
LIMIT 10;
|
||||
|
||||
-- INFO: Welche Zeitzone soll verwendet werden?
|
||||
-- MEZ (Winterzeit) = UTC+1 = '+01:00'
|
||||
-- MESZ (Sommerzeit) = UTC+2 = '+02:00'
|
||||
|
||||
-- OPTION A: Alle Timestamps als MEZ (UTC+1) interpretieren
|
||||
-- UPDATE worklog SET tstamp = CONVERT_TZ(tstamp, '+00:00', '+01:00');
|
||||
|
||||
-- OPTION B: Alle Timestamps als MESZ (UTC+2) interpretieren
|
||||
-- UPDATE worklog SET tstamp = CONVERT_TZ(tstamp, '+00:00', '+02:00');
|
||||
|
||||
-- OPTION C: Intelligente Korrektur basierend auf Sommerzeit/Winterzeit
|
||||
-- (Komplexer, aber genauer - berücksichtigt die tatsächliche Zeitzone zum Zeitpunkt der Speicherung)
|
||||
|
||||
SELECT '⚠️ WICHTIG: Prüfe die Ausgabe oben und wähle die passende UPDATE-Anweisung!' AS Warnung;
|
||||
SELECT 'Die C++ App hat wahrscheinlich lokale Zeit (MEZ/MESZ) gespeichert, aber MySQL hat sie als UTC interpretiert.' AS Info;
|
||||
SELECT 'Wenn die Zeiten im Sommer (März-Oktober) 2h früher sind, nutze OPTION B (MESZ = +02:00)' AS Tipp1;
|
||||
SELECT 'Wenn die Zeiten im Winter (November-Februar) 1h früher sind, nutze OPTION A (MEZ = +01:00)' AS Tipp2;
|
||||
|
||||
|
||||
|
||||
21
backend/migrate-timewish-simple.sql
Normal file
21
backend/migrate-timewish-simple.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- Migration Script für timewish Tabelle (vereinfacht)
|
||||
|
||||
-- 1. Spalten hinzufügen (mit Default-Wert)
|
||||
ALTER TABLE `timewish`
|
||||
ADD COLUMN `start_date` DATE DEFAULT '2023-01-01' COMMENT 'Ab welchem Datum gilt dieser Timewish' AFTER `end_time`,
|
||||
ADD COLUMN `end_date` DATE DEFAULT NULL COMMENT 'Bis welchem Datum gilt dieser Timewish (NULL = bis heute)' AFTER `start_date`;
|
||||
|
||||
-- 2. Alle bestehenden Einträge auf 2024-11-01 setzen
|
||||
UPDATE `timewish` SET `start_date` = '2024-11-01';
|
||||
|
||||
-- 3. start_date darf nicht NULL sein
|
||||
ALTER TABLE `timewish`
|
||||
MODIFY COLUMN `start_date` DATE NOT NULL COMMENT 'Ab welchem Datum gilt dieser Timewish';
|
||||
|
||||
-- 4. Zeige aktuelle Einträge
|
||||
SELECT id, user_id, day, hours, start_date, end_date FROM timewish;
|
||||
|
||||
SELECT '✅ Migration erfolgreich!' AS Status;
|
||||
|
||||
|
||||
|
||||
24
backend/migrate-timewish.sql
Normal file
24
backend/migrate-timewish.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Migration Script für timewish Tabelle
|
||||
-- Fügt start_date und end_date Spalten hinzu
|
||||
|
||||
-- 1. Spalten hinzufügen
|
||||
ALTER TABLE `timewish`
|
||||
ADD COLUMN `start_date` DATE COMMENT 'Ab welchem Datum gilt dieser Timewish' AFTER `end_time`,
|
||||
ADD COLUMN `end_date` DATE DEFAULT NULL COMMENT 'Bis welchem Datum gilt dieser Timewish (NULL = bis heute)' AFTER `start_date`;
|
||||
|
||||
-- 2. Für bestehende Einträge: Setze start_date auf den 01.01.2023 (oder ein anderes passendes Datum)
|
||||
-- und end_date auf NULL (bedeutet: bis heute gültig)
|
||||
UPDATE `timewish`
|
||||
SET `start_date` = '2023-01-01',
|
||||
`end_date` = NULL
|
||||
WHERE `start_date` IS NULL;
|
||||
|
||||
-- 3. start_date darf nicht NULL sein
|
||||
ALTER TABLE `timewish`
|
||||
MODIFY COLUMN `start_date` DATE NOT NULL COMMENT 'Ab welchem Datum gilt dieser Timewish';
|
||||
|
||||
SELECT '✅ timewish Tabelle erfolgreich migriert!' AS Status;
|
||||
SELECT '⚠️ WICHTIG: Prüfe die start_date Werte und passe sie bei Bedarf an!' AS Hinweis;
|
||||
|
||||
|
||||
|
||||
14
backend/nodemon.json
Normal file
14
backend/nodemon.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"watch": ["src"],
|
||||
"ext": "js,json",
|
||||
"ignore": ["src/**/*.spec.js", "src/**/*.test.js", "node_modules"],
|
||||
"exec": "node src/index.js",
|
||||
"env": {
|
||||
"NODE_ENV": "development"
|
||||
},
|
||||
"delay": 1000,
|
||||
"verbose": false
|
||||
}
|
||||
|
||||
|
||||
|
||||
38
backend/package.json
Normal file
38
backend/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "timeclock-backend",
|
||||
"version": "3.0.0",
|
||||
"description": "TimeClock v3 - Backend API für Zeiterfassung",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"test": "echo \"Fehler: Kein Test angegeben\" && exit 1"
|
||||
},
|
||||
"keywords": [
|
||||
"timeclock",
|
||||
"zeiterfassung",
|
||||
"api"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.1.0",
|
||||
"morgan": "^1.10.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"mysql2": "^3.6.5",
|
||||
"sequelize": "^6.35.2",
|
||||
"bcrypt": "^5.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"nodemailer": "^6.9.7",
|
||||
"crypto": "^1.0.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"express-session": "^1.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
56
backend/replace-thursday-data.sql
Normal file
56
backend/replace-thursday-data.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- Ersetze Worklog-Einträge für Donnerstag, 16.10.2025
|
||||
-- Lokale Zeit (MESZ = UTC+2): 08:02:35, 12:07:53, 13:06:14, 16:45:02, 17:00:00, 17:13:24
|
||||
-- UTC-Zeit (für DB): 06:02:35, 10:07:53, 11:06:14, 14:45:02, 15:00:00, 15:13:24
|
||||
|
||||
-- Schritt 1: Lösche alte Einträge für 16.10.2025
|
||||
-- Wegen Foreign Key Constraints müssen wir in der richtigen Reihenfolge löschen:
|
||||
-- Erst die Einträge, die auf andere verweisen (stop work, stop pause)
|
||||
-- Dann die Einträge, die referenziert werden (start pause, start work)
|
||||
|
||||
-- 1a. Lösche stop work und stop pause Einträge
|
||||
DELETE FROM worklog
|
||||
WHERE user_id = 1
|
||||
AND DATE(tstamp) = '2025-10-16'
|
||||
AND state IN ('stop work', 'stop pause');
|
||||
|
||||
-- 1b. Lösche start pause und start work Einträge
|
||||
DELETE FROM worklog
|
||||
WHERE user_id = 1
|
||||
AND DATE(tstamp) = '2025-10-16'
|
||||
AND state IN ('start pause', 'start work');
|
||||
|
||||
-- Schritt 2: Füge neue Einträge ein
|
||||
-- Variablen für die IDs
|
||||
SET @start_work_id = NULL;
|
||||
SET @start_pause1_id = NULL;
|
||||
SET @start_pause2_id = NULL;
|
||||
|
||||
-- 1. start work (06:02:35 UTC = 08:02:35 lokal)
|
||||
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
|
||||
VALUES (1, 'start work', '2025-10-16 06:02:35', NULL, 0);
|
||||
SET @start_work_id = LAST_INSERT_ID();
|
||||
|
||||
-- 2. start pause (10:07:53 UTC = 12:07:53 lokal) → relatedTo = start work
|
||||
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
|
||||
VALUES (1, 'start pause', '2025-10-16 10:07:53', @start_work_id, 0);
|
||||
SET @start_pause1_id = LAST_INSERT_ID();
|
||||
|
||||
-- 3. stop pause (11:06:14 UTC = 13:06:14 lokal) → relatedTo = start pause
|
||||
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
|
||||
VALUES (1, 'stop pause', '2025-10-16 11:06:14', @start_pause1_id, 0);
|
||||
|
||||
-- 4. start pause (14:45:02 UTC = 16:45:02 lokal) → relatedTo = start work
|
||||
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
|
||||
VALUES (1, 'start pause', '2025-10-16 14:45:02', @start_work_id, 0);
|
||||
SET @start_pause2_id = LAST_INSERT_ID();
|
||||
|
||||
-- 5. stop pause (15:00:00 UTC = 17:00:00 lokal) → relatedTo = start pause
|
||||
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
|
||||
VALUES (1, 'stop pause', '2025-10-16 15:00:00', @start_pause2_id, 0);
|
||||
|
||||
-- 6. stop work (15:13:24 UTC = 17:13:24 lokal) → relatedTo = start work
|
||||
INSERT INTO worklog (user_id, state, tstamp, relatedTo_id, version)
|
||||
VALUES (1, 'stop work', '2025-10-16 15:13:24', @start_work_id, 0);
|
||||
|
||||
SELECT 'Worklog-Einträge für 16.10.2025 erfolgreich ersetzt' AS Status;
|
||||
|
||||
100
backend/rollback-timezone.js
Normal file
100
backend/rollback-timezone.js
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Rollback des Timezone-Fix
|
||||
* Macht die Änderungen rückgängig
|
||||
*/
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.join(__dirname, '.env') });
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Datum in der Sommerzeit liegt
|
||||
*/
|
||||
function isSummertime(date) {
|
||||
const year = date.getFullYear();
|
||||
|
||||
const marchEnd = new Date(year, 2, 31);
|
||||
const marchSunday = 31 - marchEnd.getDay();
|
||||
const summerStart = new Date(year, 2, marchSunday, 2, 0, 0);
|
||||
|
||||
const octoberEnd = new Date(year, 9, 31);
|
||||
const octoberSunday = 31 - octoberEnd.getDay();
|
||||
const summerEnd = new Date(year, 9, octoberSunday, 3, 0, 0);
|
||||
|
||||
return date >= summerStart && date < summerEnd;
|
||||
}
|
||||
|
||||
async function rollbackTimezones() {
|
||||
let connection;
|
||||
|
||||
try {
|
||||
connection = await mysql.createConnection({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'stechuhr2',
|
||||
timezone: '+00:00'
|
||||
});
|
||||
|
||||
console.log('✅ Datenbankverbindung hergestellt\n');
|
||||
|
||||
const [rows] = await connection.execute(
|
||||
'SELECT id, tstamp FROM worklog ORDER BY id ASC'
|
||||
);
|
||||
|
||||
console.log(`📊 Gefunden: ${rows.length} Worklog-Einträge\n`);
|
||||
|
||||
console.log('🔄 Wende Rollback an (addiere Offset zurück)...\n');
|
||||
|
||||
let updatedCount = 0;
|
||||
for (const row of rows) {
|
||||
const storedDate = new Date(row.tstamp);
|
||||
|
||||
// Erstelle lokales Datum aus UTC-Komponenten
|
||||
const year = storedDate.getUTCFullYear();
|
||||
const month = storedDate.getUTCMonth();
|
||||
const day = storedDate.getUTCDate();
|
||||
const hours = storedDate.getUTCHours();
|
||||
const minutes = storedDate.getUTCMinutes();
|
||||
const seconds = storedDate.getUTCSeconds();
|
||||
|
||||
const localDate = new Date(year, month, day, hours, minutes, seconds);
|
||||
const offset = isSummertime(localDate) ? 2 : 1;
|
||||
|
||||
// Addiere den Offset ZURÜCK
|
||||
const correctedDate = new Date(localDate.getTime() + (offset * 60 * 60 * 1000));
|
||||
const correctedISO = correctedDate.toISOString().replace('T', ' ').replace('.000Z', '');
|
||||
|
||||
await connection.execute(
|
||||
'UPDATE worklog SET tstamp = ? WHERE id = ?',
|
||||
[correctedISO, row.id]
|
||||
);
|
||||
updatedCount++;
|
||||
|
||||
if (updatedCount % 100 === 0) {
|
||||
console.log(` ${updatedCount}/${rows.length} aktualisiert...`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✅ ${updatedCount} Einträge erfolgreich zurückgesetzt!`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Fehler:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
if (connection) {
|
||||
await connection.end();
|
||||
console.log('\n🔌 Datenbankverbindung geschlossen');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rollbackTimezones().then(() => {
|
||||
process.exit(0);
|
||||
}).catch(err => {
|
||||
console.error('Fataler Fehler:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
|
||||
156
backend/setup-mysql.sh
Executable file
156
backend/setup-mysql.sh
Executable file
@@ -0,0 +1,156 @@
|
||||
#!/bin/bash
|
||||
|
||||
# TimeClock MySQL Setup Script
|
||||
# Erstellt automatisch Datenbank und Benutzer
|
||||
|
||||
set -e
|
||||
|
||||
echo "🕐 TimeClock - MySQL Setup"
|
||||
echo "=========================="
|
||||
echo ""
|
||||
|
||||
# Farben für Output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Standardwerte
|
||||
DB_NAME="stechuhr2"
|
||||
DEFAULT_USER="timeclock_user"
|
||||
DEFAULT_PASSWORD="timeclock_dev_2025"
|
||||
|
||||
echo "Dieses Script erstellt:"
|
||||
echo " - Datenbank: $DB_NAME"
|
||||
echo " - Benutzer: $DEFAULT_USER"
|
||||
echo ""
|
||||
|
||||
# MySQL Root-Passwort abfragen
|
||||
read -sp "MySQL Root-Passwort eingeben: " MYSQL_ROOT_PASSWORD
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# Test MySQL-Verbindung
|
||||
echo "Teste MySQL-Verbindung..."
|
||||
if ! mysql -u root -p"$MYSQL_ROOT_PASSWORD" -e "SELECT 1;" &> /dev/null; then
|
||||
echo -e "${RED}❌ Fehler: MySQL-Verbindung fehlgeschlagen${NC}"
|
||||
echo "Bitte prüfen Sie:"
|
||||
echo " - MySQL Server läuft: sudo systemctl status mysql"
|
||||
echo " - Root-Passwort ist korrekt"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✅ MySQL-Verbindung erfolgreich${NC}"
|
||||
echo ""
|
||||
|
||||
# Benutzer-Credentials abfragen
|
||||
read -p "Datenbank-Benutzer [$DEFAULT_USER]: " DB_USER
|
||||
DB_USER=${DB_USER:-$DEFAULT_USER}
|
||||
|
||||
read -sp "Passwort für '$DB_USER' [$DEFAULT_PASSWORD]: " DB_PASSWORD
|
||||
echo ""
|
||||
DB_PASSWORD=${DB_PASSWORD:-$DEFAULT_PASSWORD}
|
||||
echo ""
|
||||
|
||||
# Datenbank und Benutzer erstellen
|
||||
echo "Erstelle Datenbank und Benutzer..."
|
||||
|
||||
mysql -u root -p"$MYSQL_ROOT_PASSWORD" <<MYSQL_SCRIPT
|
||||
-- Datenbank erstellen
|
||||
CREATE DATABASE IF NOT EXISTS $DB_NAME
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- Benutzer erstellen (falls existiert, überspringen)
|
||||
CREATE USER IF NOT EXISTS '$DB_USER'@'localhost'
|
||||
IDENTIFIED BY '$DB_PASSWORD';
|
||||
|
||||
-- Berechtigungen vergeben
|
||||
GRANT ALL PRIVILEGES ON $DB_NAME.*
|
||||
TO '$DB_USER'@'localhost';
|
||||
|
||||
-- Privilegien neu laden
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
-- Info ausgeben
|
||||
SELECT 'Datenbank erstellt' AS Status;
|
||||
SHOW DATABASES LIKE '$DB_NAME';
|
||||
SELECT User, Host FROM mysql.user WHERE User='$DB_USER';
|
||||
MYSQL_SCRIPT
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ Datenbank und Benutzer erfolgreich erstellt${NC}"
|
||||
echo ""
|
||||
else
|
||||
echo -e "${RED}❌ Fehler beim Erstellen${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# .env Datei erstellen/aktualisieren
|
||||
ENV_FILE=".env"
|
||||
echo "Aktualisiere .env Datei..."
|
||||
|
||||
cat > "$ENV_FILE" <<ENV_CONTENT
|
||||
# Server-Konfiguration
|
||||
PORT=3010
|
||||
NODE_ENV=development
|
||||
|
||||
# MySQL Datenbank-Verbindung
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
DB_USER=$DB_USER
|
||||
DB_PASSWORD=$DB_PASSWORD
|
||||
DB_NAME=$DB_NAME
|
||||
|
||||
# Datenbank-Optionen
|
||||
DB_LOGGING=false
|
||||
DB_TIMEZONE=+01:00
|
||||
DB_POOL_MAX=10
|
||||
DB_POOL_MIN=0
|
||||
|
||||
# JWT-Konfiguration
|
||||
JWT_SECRET=timeclock-dev-secret-change-in-production-12345
|
||||
JWT_EXPIRATION=24h
|
||||
|
||||
# E-Mail-Konfiguration (für Passwort-Reset)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=noreply@timeclock.com
|
||||
ENV_CONTENT
|
||||
|
||||
echo -e "${GREEN}✅ .env Datei erstellt${NC}"
|
||||
echo ""
|
||||
|
||||
# Test-Verbindung
|
||||
echo "Teste Verbindung mit neuen Credentials..."
|
||||
if mysql -u "$DB_USER" -p"$DB_PASSWORD" "$DB_NAME" -e "SELECT 1;" &> /dev/null; then
|
||||
echo -e "${GREEN}✅ Verbindung erfolgreich!${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ Verbindung fehlgeschlagen${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Zusammenfassung
|
||||
echo "=========================="
|
||||
echo -e "${GREEN}🎉 Setup abgeschlossen!${NC}"
|
||||
echo ""
|
||||
echo "Ihre Konfiguration:"
|
||||
echo " Datenbank: $DB_NAME"
|
||||
echo " Benutzer: $DB_USER"
|
||||
echo " Host: localhost:3306"
|
||||
echo ""
|
||||
echo "Die .env Datei wurde erstellt mit den Zugangsdaten."
|
||||
echo ""
|
||||
echo "Nächste Schritte:"
|
||||
echo " 1. Server starten: npm run dev"
|
||||
echo " 2. Browser öffnen: http://localhost:5010"
|
||||
echo ""
|
||||
echo "Erste Schritte:"
|
||||
echo " 1. Account registrieren unter /register"
|
||||
echo " 2. Einloggen und Timer starten"
|
||||
echo ""
|
||||
|
||||
|
||||
|
||||
37
backend/setup-timewish-complete.sql
Normal file
37
backend/setup-timewish-complete.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- Komplettes Setup für timewish mit Zeiträumen
|
||||
-- Führe dieses Script in deiner stechuhr2 Datenbank aus
|
||||
|
||||
-- Schritt 1: Tabelle anpassen (Spalten hinzufügen)
|
||||
ALTER TABLE `timewish`
|
||||
ADD COLUMN IF NOT EXISTS `start_date` DATE DEFAULT '2023-01-01' COMMENT 'Ab welchem Datum gilt dieser Timewish' AFTER `end_time`,
|
||||
ADD COLUMN IF NOT EXISTS `end_date` DATE DEFAULT NULL COMMENT 'Bis welchem Datum gilt dieser Timewish (NULL = bis heute)' AFTER `start_date`;
|
||||
|
||||
-- Schritt 2: Bestehende Einträge löschen (falls vorhanden)
|
||||
DELETE FROM `timewish` WHERE user_id = 1;
|
||||
|
||||
-- Schritt 3: Alte Timewishes (2023-01-01 bis 2024-10-31): 8h für alle Tage
|
||||
INSERT INTO timewish (version, user_id, day, wishtype, hours, end_time, start_date, end_date) VALUES
|
||||
(0, 1, 1, 2, 8.0, NULL, '2023-01-01', '2024-10-31'), -- Montag: 8h
|
||||
(0, 1, 2, 2, 8.0, NULL, '2023-01-01', '2024-10-31'), -- Dienstag: 8h
|
||||
(0, 1, 3, 2, 8.0, NULL, '2023-01-01', '2024-10-31'), -- Mittwoch: 8h
|
||||
(0, 1, 4, 2, 8.0, NULL, '2023-01-01', '2024-10-31'), -- Donnerstag: 8h
|
||||
(0, 1, 5, 2, 8.0, NULL, '2023-01-01', '2024-10-31'); -- Freitag: 8h
|
||||
|
||||
-- Schritt 4: Neue Timewishes (ab 2024-11-01): Di=7h, Fr=7,5h, Rest=8h
|
||||
INSERT INTO timewish (version, user_id, day, wishtype, hours, end_time, start_date, end_date) VALUES
|
||||
(0, 1, 1, 2, 8.0, NULL, '2024-11-01', NULL), -- Montag: 8h
|
||||
(0, 1, 2, 2, 7.0, NULL, '2024-11-01', NULL), -- Dienstag: 7h
|
||||
(0, 1, 3, 2, 8.0, NULL, '2024-11-01', NULL), -- Mittwoch: 8h
|
||||
(0, 1, 4, 2, 8.0, NULL, '2024-11-01', NULL), -- Donnerstag: 8h
|
||||
(0, 1, 5, 2, 7.5, NULL, '2024-11-01', NULL); -- Freitag: 7,5h
|
||||
|
||||
-- Schritt 5: start_date als NOT NULL setzen
|
||||
ALTER TABLE `timewish`
|
||||
MODIFY COLUMN `start_date` DATE NOT NULL COMMENT 'Ab welchem Datum gilt dieser Timewish';
|
||||
|
||||
-- Schritt 6: Ergebnis anzeigen
|
||||
SELECT '✅ Timewish Tabelle erfolgreich eingerichtet!' AS Status;
|
||||
SELECT * FROM timewish WHERE user_id = 1 ORDER BY day, start_date;
|
||||
|
||||
|
||||
|
||||
202
backend/src/config/database.js
Normal file
202
backend/src/config/database.js
Normal file
@@ -0,0 +1,202 @@
|
||||
const { Sequelize } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Sequelize-Datenbank-Konfiguration
|
||||
*/
|
||||
class Database {
|
||||
constructor() {
|
||||
this.sequelize = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequelize-Instanz initialisieren
|
||||
*/
|
||||
async initialize() {
|
||||
try {
|
||||
// Sequelize-Instanz mit .env-Konfiguration erstellen
|
||||
this.sequelize = new Sequelize(
|
||||
process.env.DB_NAME || 'stechuhr2',
|
||||
process.env.DB_USER || 'root',
|
||||
process.env.DB_PASSWORD || '',
|
||||
{
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT) || 3306,
|
||||
dialect: 'mysql',
|
||||
|
||||
// Timezone-Konfiguration
|
||||
// Die DB speichert lokale Zeit ohne TZ-Info
|
||||
// Worklog.tstamp ist als STRING definiert, daher erfolgt KEINE automatische TZ-Konvertierung
|
||||
timezone: '+00:00', // Keine Konvertierung für andere DATE-Felder
|
||||
|
||||
// Logging
|
||||
logging: process.env.DB_LOGGING === 'true' ? console.log : false,
|
||||
|
||||
// Connection Pool
|
||||
pool: {
|
||||
max: parseInt(process.env.DB_POOL_MAX) || 10,
|
||||
min: parseInt(process.env.DB_POOL_MIN) || 0,
|
||||
acquire: 30000,
|
||||
idle: 10000
|
||||
},
|
||||
|
||||
// Optionen
|
||||
define: {
|
||||
timestamps: false, // Wir verwenden eigene timestamp-Felder
|
||||
underscored: true, // snake_case in DB
|
||||
freezeTableName: true // Tabellennamen nicht pluralisieren
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Verbindung testen
|
||||
await this.sequelize.authenticate();
|
||||
console.log('✅ Sequelize: MySQL-Datenbankverbindung hergestellt');
|
||||
|
||||
// Models initialisieren
|
||||
await this.initializeModels();
|
||||
|
||||
return this.sequelize;
|
||||
} catch (error) {
|
||||
console.error('❌ Sequelize: Fehler bei der Datenbankverbindung:', error.message);
|
||||
console.error('Details:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Models laden und Assoziationen definieren
|
||||
*/
|
||||
async initializeModels() {
|
||||
// Models importieren
|
||||
const User = require('../models/User');
|
||||
const Worklog = require('../models/Worklog');
|
||||
const AuthInfo = require('../models/AuthInfo');
|
||||
const AuthToken = require('../models/AuthToken');
|
||||
const AuthIdentity = require('../models/AuthIdentity');
|
||||
const State = require('../models/State');
|
||||
const WeeklyWorktime = require('../models/WeeklyWorktime');
|
||||
const Holiday = require('../models/Holiday');
|
||||
const Vacation = require('../models/Vacation');
|
||||
const Sick = require('../models/Sick');
|
||||
const SickType = require('../models/SickType');
|
||||
const Timefix = require('../models/Timefix');
|
||||
const Timewish = require('../models/Timewish');
|
||||
|
||||
// Models mit Sequelize-Instanz initialisieren
|
||||
User.initialize(this.sequelize);
|
||||
Worklog.initialize(this.sequelize);
|
||||
AuthInfo.initialize(this.sequelize);
|
||||
AuthToken.initialize(this.sequelize);
|
||||
AuthIdentity.initialize(this.sequelize);
|
||||
State.initialize(this.sequelize);
|
||||
WeeklyWorktime.initialize(this.sequelize);
|
||||
Holiday.initialize(this.sequelize);
|
||||
Vacation.initialize(this.sequelize);
|
||||
Sick.initialize(this.sequelize);
|
||||
SickType.initialize(this.sequelize);
|
||||
Timefix.initialize(this.sequelize);
|
||||
Timewish.initialize(this.sequelize);
|
||||
|
||||
// Assoziationen definieren
|
||||
this.defineAssociations();
|
||||
|
||||
console.log('✅ Sequelize: Models initialisiert');
|
||||
}
|
||||
|
||||
/**
|
||||
* Model-Assoziationen definieren
|
||||
*/
|
||||
defineAssociations() {
|
||||
const { User, Worklog, AuthInfo, AuthToken, AuthIdentity, State, WeeklyWorktime, Vacation, Sick, SickType, Timefix, Timewish } = this.sequelize.models;
|
||||
|
||||
// User Assoziationen
|
||||
User.hasMany(Worklog, { foreignKey: 'user_id', as: 'worklogs' });
|
||||
User.hasOne(AuthInfo, { foreignKey: 'user_id', as: 'authInfo' });
|
||||
User.belongsTo(State, { foreignKey: 'state_id', as: 'state' });
|
||||
User.hasMany(WeeklyWorktime, { foreignKey: 'user_id', as: 'weeklyWorktimes' });
|
||||
User.hasMany(Vacation, { foreignKey: 'user_id', as: 'vacations' });
|
||||
User.hasMany(Sick, { foreignKey: 'user_id', as: 'sickLeaves' });
|
||||
|
||||
// Worklog Assoziationen
|
||||
Worklog.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
Worklog.belongsTo(Worklog, { foreignKey: 'relatedTo_id', as: 'relatedTo' });
|
||||
Worklog.hasOne(Worklog, { foreignKey: 'relatedTo_id', as: 'clockOut' });
|
||||
Worklog.hasMany(Timefix, { foreignKey: 'worklog_id', as: 'timefixes' });
|
||||
|
||||
// Timefix Assoziationen
|
||||
Timefix.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
Timefix.belongsTo(Worklog, { foreignKey: 'worklog_id', as: 'worklog' });
|
||||
|
||||
// AuthInfo Assoziationen
|
||||
AuthInfo.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
AuthInfo.hasMany(AuthToken, { foreignKey: 'auth_info_id', as: 'tokens' });
|
||||
AuthInfo.hasMany(AuthIdentity, { foreignKey: 'auth_info_id', as: 'identities' });
|
||||
|
||||
// AuthToken Assoziationen
|
||||
AuthToken.belongsTo(AuthInfo, { foreignKey: 'auth_info_id', as: 'authInfo' });
|
||||
|
||||
// AuthIdentity Assoziationen
|
||||
AuthIdentity.belongsTo(AuthInfo, { foreignKey: 'auth_info_id', as: 'authInfo' });
|
||||
|
||||
// WeeklyWorktime Assoziationen
|
||||
WeeklyWorktime.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
|
||||
// Vacation Assoziationen
|
||||
Vacation.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
|
||||
// Sick Assoziationen
|
||||
Sick.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
Sick.belongsTo(SickType, { foreignKey: 'sick_type_id', as: 'sickType' });
|
||||
|
||||
// SickType Assoziationen
|
||||
SickType.hasMany(Sick, { foreignKey: 'sick_type_id', as: 'sickLeaves' });
|
||||
|
||||
// Timewish Assoziationen
|
||||
Timewish.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
User.hasMany(Timewish, { foreignKey: 'user_id', as: 'timewishes' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Sequelize-Instanz zurückgeben
|
||||
*/
|
||||
getSequelize() {
|
||||
if (!this.sequelize) {
|
||||
throw new Error('Sequelize nicht initialisiert. Rufen Sie zuerst initialize() auf.');
|
||||
}
|
||||
return this.sequelize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modelle zurückgeben
|
||||
*/
|
||||
getModels() {
|
||||
if (!this.sequelize) {
|
||||
throw new Error('Sequelize nicht initialisiert. Rufen Sie zuerst initialize() auf.');
|
||||
}
|
||||
return this.sequelize.models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verbindung schließen
|
||||
*/
|
||||
async close() {
|
||||
if (this.sequelize) {
|
||||
await this.sequelize.close();
|
||||
console.log('🔒 Sequelize: Datenbankverbindung geschlossen');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Datenbank synchronisieren (nur für Entwicklung!)
|
||||
* @param {Object} options - Sync-Optionen
|
||||
*/
|
||||
async sync(options = {}) {
|
||||
if (this.sequelize) {
|
||||
await this.sequelize.sync(options);
|
||||
console.log('🔄 Sequelize: Datenbank synchronisiert');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton-Instanz exportieren
|
||||
module.exports = new Database();
|
||||
48
backend/src/config/passport.js
Normal file
48
backend/src/config/passport.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const passport = require('passport');
|
||||
const GoogleStrategy = require('passport-google-oauth20').Strategy;
|
||||
const oauthService = require('../services/OAuthService');
|
||||
|
||||
/**
|
||||
* Passport-Konfiguration für OAuth
|
||||
*/
|
||||
class PassportConfig {
|
||||
static initialize() {
|
||||
// Google OAuth Strategy
|
||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
||||
passport.use(new GoogleStrategy({
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: process.env.GOOGLE_CALLBACK_URL || 'http://localhost:3010/api/auth/google/callback',
|
||||
scope: ['profile', 'email']
|
||||
},
|
||||
async (accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
const result = await oauthService.authenticateWithProvider(profile, 'google');
|
||||
return done(null, result);
|
||||
} catch (error) {
|
||||
return done(error, null);
|
||||
}
|
||||
}));
|
||||
|
||||
console.log('✅ Google OAuth konfiguriert');
|
||||
} else {
|
||||
console.log('⚠️ Google OAuth nicht konfiguriert (GOOGLE_CLIENT_ID/SECRET fehlen)');
|
||||
}
|
||||
|
||||
// Serialisierung (für Session-basierte Strategien)
|
||||
passport.serializeUser((user, done) => {
|
||||
done(null, user);
|
||||
});
|
||||
|
||||
passport.deserializeUser((user, done) => {
|
||||
done(null, user);
|
||||
});
|
||||
|
||||
return passport;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PassportConfig;
|
||||
|
||||
|
||||
|
||||
256
backend/src/controllers/AuthController.js
Normal file
256
backend/src/controllers/AuthController.js
Normal file
@@ -0,0 +1,256 @@
|
||||
const authService = require('../services/AuthService');
|
||||
|
||||
/**
|
||||
* Authentication Controller
|
||||
* Verwaltet Auth-bezogene HTTP-Requests
|
||||
*/
|
||||
class AuthController {
|
||||
/**
|
||||
* Benutzer registrieren
|
||||
* POST /api/auth/register
|
||||
*/
|
||||
async register(req, res) {
|
||||
try {
|
||||
const { email, password, full_name } = req.body;
|
||||
|
||||
const result = await authService.register({
|
||||
email,
|
||||
password,
|
||||
full_name
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Registrierung erfolgreich',
|
||||
user: result.user
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registrierungsfehler:', error);
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer einloggen
|
||||
* POST /api/auth/login
|
||||
*/
|
||||
async login(req, res) {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'E-Mail und Passwort sind erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await authService.login(email, password);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Login erfolgreich',
|
||||
token: result.token,
|
||||
user: result.user
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login-Fehler:', error);
|
||||
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ausloggen
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
async logout(req, res) {
|
||||
try {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (token) {
|
||||
await authService.logout(token);
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Logout erfolgreich'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout-Fehler:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Logout'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuellen Benutzer abrufen
|
||||
* GET /api/auth/me
|
||||
*/
|
||||
async getCurrentUser(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
|
||||
const profile = await authService.getUserProfile(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: profile
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen des Benutzers:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Abrufen der Benutzerdaten'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort-Reset anfordern
|
||||
* POST /api/auth/request-reset
|
||||
*/
|
||||
async requestPasswordReset(req, res) {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'E-Mail ist erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
const resetToken = await authService.requestPasswordReset(email);
|
||||
|
||||
// Debug-Logs
|
||||
console.log('Reset-Token:', resetToken);
|
||||
console.log('NODE_ENV:', process.env.NODE_ENV);
|
||||
console.log('isDevelopment:', !process.env.NODE_ENV || process.env.NODE_ENV === 'development');
|
||||
|
||||
// In Produktion: E-Mail mit Reset-Link senden
|
||||
// Für Entwicklung: Token zurückgeben (auch wenn E-Mail nicht existiert)
|
||||
const isDevelopment = !process.env.NODE_ENV || process.env.NODE_ENV === 'development';
|
||||
|
||||
if (isDevelopment) {
|
||||
// Im Development-Modus immer einen Token zurückgeben für Testing
|
||||
const devToken = resetToken || 'dev-test-token-' + Date.now() + '-' + email;
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Reset-Link wurde gesendet (DEV)',
|
||||
resetToken: devToken // NUR FÜR ENTWICKLUNG!
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Falls ein Account mit dieser E-Mail existiert, wurde ein Reset-Link gesendet'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler bei Passwort-Reset-Anfrage:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Senden des Reset-Links'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort zurücksetzen
|
||||
* POST /api/auth/reset-password
|
||||
*/
|
||||
async resetPassword(req, res) {
|
||||
try {
|
||||
const { token, password } = req.body;
|
||||
|
||||
if (!token || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Token und neues Passwort sind erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
await authService.resetPassword(token, password);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Passwort wurde erfolgreich zurückgesetzt'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Passwort-Reset:', error);
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort ändern (eingeloggter Benutzer)
|
||||
* POST /api/auth/change-password
|
||||
*/
|
||||
async changePassword(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { oldPassword, newPassword } = req.body;
|
||||
|
||||
if (!oldPassword || !newPassword) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Altes und neues Passwort sind erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
await authService.changePassword(userId, oldPassword, newPassword);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Passwort wurde erfolgreich geändert'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Passwort-Ändern:', error);
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Token validieren
|
||||
* GET /api/auth/validate
|
||||
*/
|
||||
async validateToken(req, res) {
|
||||
try {
|
||||
// Wenn Middleware durchgelaufen ist, ist Token valid
|
||||
res.json({
|
||||
success: true,
|
||||
valid: true,
|
||||
user: req.user
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
valid: false,
|
||||
error: 'Ungültiger Token'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthController();
|
||||
|
||||
101
backend/src/controllers/OAuthController.js
Normal file
101
backend/src/controllers/OAuthController.js
Normal file
@@ -0,0 +1,101 @@
|
||||
const passport = require('passport');
|
||||
|
||||
/**
|
||||
* OAuth Controller
|
||||
* Verwaltet OAuth-Logins
|
||||
*/
|
||||
class OAuthController {
|
||||
/**
|
||||
* Google OAuth initiieren
|
||||
* GET /api/auth/google
|
||||
*/
|
||||
googleAuth(req, res, next) {
|
||||
passport.authenticate('google', {
|
||||
scope: ['profile', 'email'],
|
||||
session: false
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google OAuth Callback
|
||||
* GET /api/auth/google/callback
|
||||
*/
|
||||
googleCallback(req, res, next) {
|
||||
passport.authenticate('google', {
|
||||
session: false,
|
||||
failureRedirect: `${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_failed`
|
||||
}, (err, result) => {
|
||||
if (err || !result) {
|
||||
console.error('Google OAuth Fehler:', err);
|
||||
return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_failed`);
|
||||
}
|
||||
|
||||
// Redirect zum Frontend mit Token
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5010';
|
||||
res.redirect(`${frontendUrl}/oauth-callback?token=${result.token}`);
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth-Identities für Benutzer abrufen
|
||||
* GET /api/auth/identities
|
||||
*/
|
||||
async getIdentities(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const oauthService = require('../services/OAuthService');
|
||||
|
||||
const identities = await oauthService.getUserIdentities(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
identities
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Identities:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Abrufen der OAuth-Verknüpfungen'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth-Provider entfernen
|
||||
* DELETE /api/auth/identity/:provider
|
||||
*/
|
||||
async unlinkProvider(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { provider } = req.params;
|
||||
const oauthService = require('../services/OAuthService');
|
||||
|
||||
const unlinked = await oauthService.unlinkProvider(userId, provider);
|
||||
|
||||
if (unlinked) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${provider} wurde erfolgreich entfernt`
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Verknüpfung nicht gefunden'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Entfernen der Identity:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new OAuthController();
|
||||
|
||||
|
||||
|
||||
265
backend/src/controllers/TimeEntryController.js
Normal file
265
backend/src/controllers/TimeEntryController.js
Normal file
@@ -0,0 +1,265 @@
|
||||
const timeEntryService = require('../services/TimeEntryService');
|
||||
|
||||
/**
|
||||
* Controller-Klasse für Zeiteinträge
|
||||
* Verantwortlich für HTTP-Request/Response-Handling
|
||||
*/
|
||||
class TimeEntryController {
|
||||
/**
|
||||
* Alle Zeiteinträge abrufen
|
||||
* GET /api/time-entries
|
||||
*/
|
||||
async getAllEntries(req, res) {
|
||||
try {
|
||||
const entries = timeEntryService.getAllEntries();
|
||||
res.json(entries);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Einträge:', error);
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Abrufen der Einträge',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelnen Zeiteintrag anhand der ID abrufen
|
||||
* GET /api/time-entries/:id
|
||||
*/
|
||||
async getEntryById(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const entry = timeEntryService.getEntryById(id);
|
||||
|
||||
if (!entry) {
|
||||
return res.status(404).json({
|
||||
error: 'Eintrag nicht gefunden',
|
||||
id: parseInt(id)
|
||||
});
|
||||
}
|
||||
|
||||
res.json(entry);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen des Eintrags:', error);
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Abrufen des Eintrags',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuen Zeiteintrag erstellen (Timer starten)
|
||||
* POST /api/time-entries
|
||||
*/
|
||||
async createEntry(req, res) {
|
||||
try {
|
||||
const entryData = req.body;
|
||||
const newEntry = timeEntryService.createEntry(entryData);
|
||||
|
||||
res.status(201).json(newEntry);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Erstellen des Eintrags:', error);
|
||||
|
||||
// Spezifische Fehlerbehandlung
|
||||
if (error.message.includes('läuft bereits')) {
|
||||
return res.status(400).json({
|
||||
error: 'Validierungsfehler',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Erstellen des Eintrags',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeiteintrag aktualisieren (Timer stoppen oder Daten ändern)
|
||||
* PUT /api/time-entries/:id
|
||||
*/
|
||||
async updateEntry(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
const updatedEntry = timeEntryService.updateEntry(id, updateData);
|
||||
res.json(updatedEntry);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aktualisieren des Eintrags:', error);
|
||||
|
||||
// Spezifische Fehlerbehandlung
|
||||
if (error.message.includes('nicht gefunden')) {
|
||||
return res.status(404).json({
|
||||
error: 'Eintrag nicht gefunden',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message.includes('Endzeit') || error.message.includes('Startzeit')) {
|
||||
return res.status(400).json({
|
||||
error: 'Validierungsfehler',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Aktualisieren des Eintrags',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeiteintrag löschen
|
||||
* DELETE /api/time-entries/:id
|
||||
*/
|
||||
async deleteEntry(req, res) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
timeEntryService.deleteEntry(id);
|
||||
|
||||
res.status(204).send();
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Eintrags:', error);
|
||||
|
||||
if (error.message.includes('nicht gefunden')) {
|
||||
return res.status(404).json({
|
||||
error: 'Eintrag nicht gefunden',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Löschen des Eintrags',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuellen Status abrufen (letzter Worklog-Eintrag)
|
||||
* GET /api/time-entries/current-state
|
||||
*/
|
||||
async getCurrentState(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const state = await timeEntryService.getCurrentState(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
state: state
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen des aktuellen Status:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Abrufen des Status'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stempeln (Clock In/Out, Pause Start/Stop)
|
||||
* POST /api/time-entries/clock
|
||||
*/
|
||||
async clock(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { action } = req.body;
|
||||
|
||||
if (!action) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Aktion ist erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await timeEntryService.clock(userId, action);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Erfolgreich gestempelt',
|
||||
entry: result
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Stempeln:', error);
|
||||
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiken abrufen
|
||||
* GET /api/time-entries/stats/summary
|
||||
*/
|
||||
async getStats(req, res) {
|
||||
try {
|
||||
const userId = req.user?.userId;
|
||||
const stats = await timeEntryService.getStatistics(userId);
|
||||
res.json(stats || {});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Statistiken:', error);
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Abrufen der Statistiken',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuell laufenden Timer abrufen
|
||||
* GET /api/time-entries/running
|
||||
*/
|
||||
async getRunningEntry(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const runningEntry = await timeEntryService.getRunningEntry(userId);
|
||||
|
||||
if (!runningEntry) {
|
||||
return res.json({}); // Leeres Objekt wenn nichts läuft
|
||||
}
|
||||
|
||||
res.json(runningEntry);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen des laufenden Timers:', error);
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Abrufen des laufenden Timers',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Einträge nach Projekt filtern
|
||||
* GET /api/time-entries/project/:projectName
|
||||
*/
|
||||
async getEntriesByProject(req, res) {
|
||||
try {
|
||||
const { projectName } = req.params;
|
||||
const entries = timeEntryService.getEntriesByProject(projectName);
|
||||
|
||||
res.json({
|
||||
project: projectName,
|
||||
count: entries.length,
|
||||
entries
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Filtern nach Projekt:', error);
|
||||
res.status(500).json({
|
||||
error: 'Fehler beim Filtern nach Projekt',
|
||||
message: process.env.NODE_ENV === 'development' ? error.message : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton-Instanz exportieren
|
||||
module.exports = new TimeEntryController();
|
||||
|
||||
35
backend/src/controllers/WeekOverviewController.js
Normal file
35
backend/src/controllers/WeekOverviewController.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const timeEntryService = require('../services/TimeEntryService');
|
||||
|
||||
/**
|
||||
* WeekOverview Controller
|
||||
* Verwaltet Wochenübersicht-Daten
|
||||
*/
|
||||
class WeekOverviewController {
|
||||
|
||||
/**
|
||||
* Wochenübersicht für einen Benutzer abrufen
|
||||
* GET /api/week-overview
|
||||
*/
|
||||
async getWeekOverview(req, res) {
|
||||
try {
|
||||
const userId = req.user.userId;
|
||||
const { weekOffset = 0 } = req.query; // 0 = aktuelle Woche, -1 = letzte Woche, etc.
|
||||
|
||||
const weekData = await timeEntryService.getWeekOverview(userId, parseInt(weekOffset));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: weekData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Wochenübersicht:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Fehler beim Laden der Wochenübersicht'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WeekOverviewController();
|
||||
110
backend/src/index.js
Normal file
110
backend/src/index.js
Normal file
@@ -0,0 +1,110 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const morgan = require('morgan');
|
||||
const session = require('express-session');
|
||||
const passport = require('passport');
|
||||
require('dotenv').config();
|
||||
|
||||
const database = require('./config/database');
|
||||
const PassportConfig = require('./config/passport');
|
||||
const unhashRequestIds = require('./middleware/unhashRequest');
|
||||
const hashResponseIds = require('./middleware/hashResponse');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3010;
|
||||
|
||||
// Middleware
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:5010',
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(morgan('dev'));
|
||||
|
||||
// Session für Passport (OAuth)
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'session-secret-change-in-production',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
maxAge: 24 * 60 * 60 * 1000 // 24 Stunden
|
||||
}
|
||||
}));
|
||||
|
||||
// Passport initialisieren
|
||||
app.use(passport.initialize());
|
||||
app.use(passport.session());
|
||||
PassportConfig.initialize();
|
||||
|
||||
// Routes
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: 'TimeClock API v3.0.0',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Auth routes (öffentlich) - OHNE ID-Hashing
|
||||
const authRouter = require('./routes/auth');
|
||||
app.use('/api/auth', authRouter);
|
||||
|
||||
// ID-Hashing Middleware (nur für geschützte Routes)
|
||||
app.use(unhashRequestIds);
|
||||
app.use(hashResponseIds);
|
||||
|
||||
// Time entries routes (geschützt) - MIT ID-Hashing
|
||||
const timeEntriesRouter = require('./routes/timeEntries');
|
||||
const { authenticateToken } = require('./middleware/auth');
|
||||
app.use('/api/time-entries', authenticateToken, timeEntriesRouter);
|
||||
|
||||
// Week overview routes (geschützt) - MIT ID-Hashing
|
||||
const weekOverviewRouter = require('./routes/weekOverview');
|
||||
app.use('/api/week-overview', authenticateToken, weekOverviewRouter);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error(err.stack);
|
||||
res.status(500).json({
|
||||
error: 'Etwas ist schiefgelaufen!',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ error: 'Route nicht gefunden' });
|
||||
});
|
||||
|
||||
// Datenbank initialisieren und Server starten
|
||||
database.initialize()
|
||||
.then(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🕐 TimeClock Server läuft auf Port ${PORT}`);
|
||||
console.log(`📍 API verfügbar unter http://localhost:${PORT}/api`);
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('❌ Server konnte nicht gestartet werden:', error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful Shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('SIGTERM empfangen, fahre Server herunter...');
|
||||
await database.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\nSIGINT empfangen, fahre Server herunter...');
|
||||
await database.close();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
|
||||
66
backend/src/middleware/auth.js
Normal file
66
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const authService = require('../services/AuthService');
|
||||
|
||||
/**
|
||||
* Authentication Middleware
|
||||
* Validiert JWT-Token und fügt Benutzer-Info zu req hinzu
|
||||
*/
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
try {
|
||||
// Token aus Authorization-Header extrahieren
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Kein Token vorhanden',
|
||||
code: 'NO_TOKEN'
|
||||
});
|
||||
}
|
||||
|
||||
// Token validieren
|
||||
const decoded = await authService.validateToken(token);
|
||||
|
||||
// Benutzer-Info zu Request hinzufügen
|
||||
req.user = decoded;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Auth-Middleware-Fehler:', error.message);
|
||||
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Ungültiger oder abgelaufener Token',
|
||||
code: 'INVALID_TOKEN'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Optional Authentication Middleware
|
||||
* Validiert Token falls vorhanden, erlaubt aber auch Requests ohne Token
|
||||
*/
|
||||
const optionalAuth = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (token) {
|
||||
const decoded = await authService.validateToken(token);
|
||||
req.user = decoded;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
// Bei optionalem Auth ignorieren wir Fehler
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
optionalAuth
|
||||
};
|
||||
|
||||
|
||||
|
||||
104
backend/src/middleware/hashResponse.js
Normal file
104
backend/src/middleware/hashResponse.js
Normal file
@@ -0,0 +1,104 @@
|
||||
const hashId = require('../utils/hashId');
|
||||
|
||||
/**
|
||||
* Middleware zum automatischen Hashen von IDs in Response-Daten
|
||||
*/
|
||||
const hashResponseIds = (req, res, next) => {
|
||||
// Originale json-Methode speichern
|
||||
const originalJson = res.json.bind(res);
|
||||
|
||||
// json-Methode überschreiben
|
||||
res.json = function(data) {
|
||||
// Wenn Daten vorhanden sind, IDs hashen
|
||||
if (data) {
|
||||
data = hashResponseData(data);
|
||||
}
|
||||
|
||||
// Originale Methode aufrufen
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Rekursiv IDs in Datenstruktur hashen
|
||||
* @param {*} data - Zu verarbeitende Daten
|
||||
* @returns {*} Daten mit gehashten IDs
|
||||
*/
|
||||
function hashResponseData(data) {
|
||||
// Primitive Typen direkt zurückgeben
|
||||
if (data === null || data === undefined) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Array: Jedes Element verarbeiten
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(item => hashResponseData(item));
|
||||
}
|
||||
|
||||
// Object: ID-Felder hashen
|
||||
if (typeof data === 'object') {
|
||||
const result = {};
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// ID-Felder identifizieren und hashen
|
||||
if (isIdField(key) && typeof value === 'number') {
|
||||
result[key] = hashId.encode(value);
|
||||
}
|
||||
// Verschachtelte Objekte/Arrays rekursiv verarbeiten
|
||||
else if (typeof value === 'object') {
|
||||
result[key] = hashResponseData(value);
|
||||
}
|
||||
// Andere Werte unverändert übernehmen
|
||||
else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Andere Typen unverändert
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein Feldname eine ID repräsentiert
|
||||
* @param {string} fieldName - Name des Feldes
|
||||
* @returns {boolean} True wenn ID-Feld
|
||||
*/
|
||||
function isIdField(fieldName) {
|
||||
// Felder die als ID erkannt werden sollen
|
||||
const idPatterns = [
|
||||
'id',
|
||||
'_id',
|
||||
'user_id',
|
||||
'userId',
|
||||
'auth_info_id',
|
||||
'authInfoId',
|
||||
'auth_token_id',
|
||||
'authTokenId',
|
||||
'worklog_id',
|
||||
'worklogId',
|
||||
'vacation_id',
|
||||
'vacationId',
|
||||
'sick_id',
|
||||
'sickId',
|
||||
'holiday_id',
|
||||
'holidayId',
|
||||
'state_id',
|
||||
'stateId',
|
||||
'sick_type_id',
|
||||
'sickTypeId',
|
||||
'weekly_worktime_id',
|
||||
'weeklyWorktimeId'
|
||||
];
|
||||
|
||||
return idPatterns.includes(fieldName);
|
||||
}
|
||||
|
||||
module.exports = hashResponseIds;
|
||||
|
||||
|
||||
|
||||
116
backend/src/middleware/unhashRequest.js
Normal file
116
backend/src/middleware/unhashRequest.js
Normal file
@@ -0,0 +1,116 @@
|
||||
const hashId = require('../utils/hashId');
|
||||
|
||||
/**
|
||||
* Middleware zum automatischen Enthashen von IDs in Request-Daten
|
||||
*/
|
||||
const unhashRequestIds = (req, res, next) => {
|
||||
// Body-Parameter verarbeiten
|
||||
if (req.body && typeof req.body === 'object') {
|
||||
req.body = unhashRequestData(req.body);
|
||||
}
|
||||
|
||||
// Query-Parameter verarbeiten
|
||||
if (req.query && typeof req.query === 'object') {
|
||||
req.query = unhashRequestData(req.query);
|
||||
}
|
||||
|
||||
// Route-Parameter verarbeiten
|
||||
if (req.params && typeof req.params === 'object') {
|
||||
req.params = unhashRequestData(req.params);
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Rekursiv Hash-IDs in Datenstruktur entschlüsseln
|
||||
* @param {*} data - Zu verarbeitende Daten
|
||||
* @returns {*} Daten mit entschlüsselten IDs
|
||||
*/
|
||||
function unhashRequestData(data) {
|
||||
// Primitive Typen direkt zurückgeben
|
||||
if (data === null || data === undefined) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// String: Könnte ein Hash sein
|
||||
if (typeof data === 'string') {
|
||||
// Versuche als Hash zu dekodieren
|
||||
const decoded = hashId.decode(data);
|
||||
if (decoded !== null) {
|
||||
return decoded;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// Array: Jedes Element verarbeiten
|
||||
if (Array.isArray(data)) {
|
||||
return data.map(item => unhashRequestData(item));
|
||||
}
|
||||
|
||||
// Object: Rekursiv verarbeiten
|
||||
if (typeof data === 'object') {
|
||||
const result = {};
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// ID-Felder identifizieren und enthashen
|
||||
if (isIdField(key) && typeof value === 'string') {
|
||||
const decoded = hashId.decode(value);
|
||||
result[key] = decoded !== null ? decoded : value;
|
||||
}
|
||||
// Verschachtelte Objekte/Arrays rekursiv verarbeiten
|
||||
else if (typeof value === 'object') {
|
||||
result[key] = unhashRequestData(value);
|
||||
}
|
||||
// Andere Werte unverändert übernehmen
|
||||
else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Andere Typen unverändert
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein Feldname eine ID repräsentiert
|
||||
* @param {string} fieldName - Name des Feldes
|
||||
* @returns {boolean} True wenn ID-Feld
|
||||
*/
|
||||
function isIdField(fieldName) {
|
||||
// Felder die als ID erkannt werden sollen
|
||||
const idPatterns = [
|
||||
'id',
|
||||
'_id',
|
||||
'user_id',
|
||||
'userId',
|
||||
'auth_info_id',
|
||||
'authInfoId',
|
||||
'auth_token_id',
|
||||
'authTokenId',
|
||||
'worklog_id',
|
||||
'worklogId',
|
||||
'vacation_id',
|
||||
'vacationId',
|
||||
'sick_id',
|
||||
'sickId',
|
||||
'holiday_id',
|
||||
'holidayId',
|
||||
'state_id',
|
||||
'stateId',
|
||||
'sick_type_id',
|
||||
'sickTypeId',
|
||||
'weekly_worktime_id',
|
||||
'weeklyWorktimeId'
|
||||
];
|
||||
|
||||
return idPatterns.includes(fieldName);
|
||||
}
|
||||
|
||||
module.exports = unhashRequestIds;
|
||||
|
||||
|
||||
|
||||
87
backend/src/models/AuthIdentity.js
Normal file
87
backend/src/models/AuthIdentity.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* AuthIdentity Model
|
||||
* Repräsentiert die auth_identity-Tabelle für OAuth/SSO-Provider
|
||||
*/
|
||||
class AuthIdentity extends Model {
|
||||
static initialize(sequelize) {
|
||||
AuthIdentity.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
auth_info_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'auth_info',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
provider: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
comment: 'OAuth Provider: google, github, microsoft, etc.'
|
||||
},
|
||||
identity: {
|
||||
type: DataTypes.STRING(512),
|
||||
allowNull: false,
|
||||
comment: 'Provider User ID oder E-Mail'
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'auth_identity',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_auth_identity_auth_info',
|
||||
fields: ['auth_info_id']
|
||||
},
|
||||
{
|
||||
name: 'auth_identity_provider_identity',
|
||||
unique: true,
|
||||
fields: ['provider', 'identity']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return AuthIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den Provider-Namen zurück
|
||||
*/
|
||||
getProvider() {
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob dies ein Google-Login ist
|
||||
*/
|
||||
isGoogle() {
|
||||
return this.provider === 'google';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob dies ein GitHub-Login ist
|
||||
*/
|
||||
isGitHub() {
|
||||
return this.provider === 'github';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AuthIdentity;
|
||||
|
||||
|
||||
|
||||
99
backend/src/models/AuthInfo.js
Normal file
99
backend/src/models/AuthInfo.js
Normal file
@@ -0,0 +1,99 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* AuthInfo Model
|
||||
* Repräsentiert die auth_info-Tabelle
|
||||
*/
|
||||
class AuthInfo extends Model {
|
||||
static initialize(sequelize) {
|
||||
AuthInfo.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
password_hash: {
|
||||
type: DataTypes.STRING(100),
|
||||
allowNull: false
|
||||
},
|
||||
password_method: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false
|
||||
},
|
||||
password_salt: {
|
||||
type: DataTypes.STRING(60),
|
||||
allowNull: false
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
failed_login_attempts: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
last_login_attempt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING(256),
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
unverified_email: {
|
||||
type: DataTypes.STRING(256),
|
||||
allowNull: false,
|
||||
defaultValue: ''
|
||||
},
|
||||
email_token: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false,
|
||||
defaultValue: ''
|
||||
},
|
||||
email_token_expires: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
email_token_role: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'auth_info',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_auth_info_user',
|
||||
fields: ['user_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return AuthInfo;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AuthInfo;
|
||||
|
||||
67
backend/src/models/AuthToken.js
Normal file
67
backend/src/models/AuthToken.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* AuthToken Model
|
||||
* Repräsentiert die auth_token-Tabelle für JWT-Tokens
|
||||
*/
|
||||
class AuthToken extends Model {
|
||||
static initialize(sequelize) {
|
||||
AuthToken.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
auth_info_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'auth_info',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
value: {
|
||||
type: DataTypes.STRING(64),
|
||||
allowNull: false
|
||||
},
|
||||
expires: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'auth_token',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_auth_token_auth_info',
|
||||
fields: ['auth_info_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return AuthToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob Token abgelaufen ist
|
||||
*/
|
||||
isExpired() {
|
||||
if (!this.expires) return false;
|
||||
return new Date() > new Date(this.expires);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AuthToken;
|
||||
|
||||
|
||||
|
||||
50
backend/src/models/Holiday.js
Normal file
50
backend/src/models/Holiday.js
Normal file
@@ -0,0 +1,50 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Holiday Model
|
||||
* Repräsentiert die holiday-Tabelle
|
||||
*/
|
||||
class Holiday extends Model {
|
||||
static initialize(sequelize) {
|
||||
Holiday.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
date: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true
|
||||
},
|
||||
hours: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 8
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'holiday',
|
||||
timestamps: false
|
||||
}
|
||||
);
|
||||
|
||||
return Holiday;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Holiday;
|
||||
|
||||
|
||||
|
||||
71
backend/src/models/Sick.js
Normal file
71
backend/src/models/Sick.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Sick Model
|
||||
* Repräsentiert die sick-Tabelle (Krankmeldungen)
|
||||
*/
|
||||
class Sick extends Model {
|
||||
static initialize(sequelize) {
|
||||
Sick.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
first_day: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true
|
||||
},
|
||||
last_day: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true
|
||||
},
|
||||
sick_type_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'sick_type',
|
||||
key: 'id'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'sick',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_sick_user',
|
||||
fields: ['user_id']
|
||||
},
|
||||
{
|
||||
name: 'fk_sick_sick_type',
|
||||
fields: ['sick_type_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return Sick;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Sick;
|
||||
|
||||
|
||||
|
||||
41
backend/src/models/SickType.js
Normal file
41
backend/src/models/SickType.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* SickType Model
|
||||
* Repräsentiert die sick_type-Tabelle
|
||||
*/
|
||||
class SickType extends Model {
|
||||
static initialize(sequelize) {
|
||||
SickType.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'sick_type',
|
||||
timestamps: false
|
||||
}
|
||||
);
|
||||
|
||||
return SickType;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SickType;
|
||||
|
||||
|
||||
|
||||
41
backend/src/models/State.js
Normal file
41
backend/src/models/State.js
Normal file
@@ -0,0 +1,41 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* State Model
|
||||
* Repräsentiert die state-Tabelle
|
||||
*/
|
||||
class State extends Model {
|
||||
static initialize(sequelize) {
|
||||
State.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
state_name: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'state',
|
||||
timestamps: false
|
||||
}
|
||||
);
|
||||
|
||||
return State;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = State;
|
||||
|
||||
|
||||
|
||||
88
backend/src/models/Timefix.js
Normal file
88
backend/src/models/Timefix.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Timefix Model
|
||||
* Repräsentiert die timefix-Tabelle für Zeitkorrekturen
|
||||
*/
|
||||
class Timefix extends Model {
|
||||
static initialize(sequelize) {
|
||||
Timefix.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
worklog_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'worklog',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
fix_type: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: false
|
||||
},
|
||||
fix_date_time: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'timefix',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'timefix_worklog_id_IDX',
|
||||
fields: ['worklog_id']
|
||||
},
|
||||
{
|
||||
name: 'timefix_user_id_IDX',
|
||||
fields: ['user_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return Timefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Definiert Assoziationen mit anderen Models
|
||||
*/
|
||||
static associate(models) {
|
||||
// Timefix gehört zu einem User
|
||||
Timefix.belongsTo(models.User, {
|
||||
foreignKey: 'user_id',
|
||||
as: 'user'
|
||||
});
|
||||
|
||||
// Timefix bezieht sich auf einen Worklog-Eintrag
|
||||
Timefix.belongsTo(models.Worklog, {
|
||||
foreignKey: 'worklog_id',
|
||||
as: 'worklog'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Timefix;
|
||||
|
||||
|
||||
|
||||
74
backend/src/models/Timewish.js
Normal file
74
backend/src/models/Timewish.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
class Timewish extends Model {
|
||||
static initialize(sequelize) {
|
||||
Timewish.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
day: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
comment: '1=Montag, 2=Dienstag, ..., 5=Freitag, 6=Samstag, 7=Sonntag'
|
||||
},
|
||||
wishtype: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
comment: '1=Ende nach Uhrzeit, 2=Ende nach Stunden'
|
||||
},
|
||||
hours: {
|
||||
type: DataTypes.DECIMAL(10, 2),
|
||||
allowNull: true,
|
||||
comment: 'Gewünschte Stunden (bei wishtype=2)'
|
||||
},
|
||||
end_time: {
|
||||
type: DataTypes.TIME,
|
||||
allowNull: true,
|
||||
comment: 'Gewünschte Endzeit (bei wishtype=1)'
|
||||
},
|
||||
start_date: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: false,
|
||||
comment: 'Ab welchem Datum gilt dieser Timewish (YYYY-MM-DD)'
|
||||
},
|
||||
end_date: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true,
|
||||
comment: 'Bis zu welchem Datum gilt dieser Timewish (NULL = bis heute)'
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'timewish',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_timewish_user',
|
||||
fields: ['user_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
return Timewish;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Timewish;
|
||||
|
||||
105
backend/src/models/User.js
Normal file
105
backend/src/models/User.js
Normal file
@@ -0,0 +1,105 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* User Model
|
||||
* Repräsentiert die user-Tabelle
|
||||
*/
|
||||
class User extends Model {
|
||||
static initialize(sequelize) {
|
||||
User.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
last_change: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
role: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
daily_hours: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 8
|
||||
},
|
||||
state_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true
|
||||
},
|
||||
full_name: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
week_hours: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 40
|
||||
},
|
||||
week_workdays: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 5
|
||||
},
|
||||
preferred_title_type: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
overtime_offset_minutes: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0,
|
||||
comment: 'Überstunden-Startwert in Minuten (z.B. Übertrag aus altem System)'
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'user',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_user_state',
|
||||
fields: ['state_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return User;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vollständigen Namen zurückgeben
|
||||
*/
|
||||
getFullName() {
|
||||
return this.full_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tägliche Arbeitsstunden zurückgeben
|
||||
*/
|
||||
getDailyHours() {
|
||||
return this.daily_hours;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wöchentliche Arbeitsstunden zurückgeben
|
||||
*/
|
||||
getWeeklyHours() {
|
||||
return this.week_hours;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = User;
|
||||
|
||||
64
backend/src/models/Vacation.js
Normal file
64
backend/src/models/Vacation.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Vacation Model
|
||||
* Repräsentiert die vacation-Tabelle
|
||||
*/
|
||||
class Vacation extends Model {
|
||||
static initialize(sequelize) {
|
||||
Vacation.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
first_day: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true
|
||||
},
|
||||
last_day: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true
|
||||
},
|
||||
vacation_type: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'vacation',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_vacation_user',
|
||||
fields: ['user_id']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return Vacation;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Vacation;
|
||||
|
||||
|
||||
|
||||
58
backend/src/models/WeeklyWorktime.js
Normal file
58
backend/src/models/WeeklyWorktime.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* WeeklyWorktime Model
|
||||
* Repräsentiert die weekly_worktime-Tabelle
|
||||
*/
|
||||
class WeeklyWorktime extends Model {
|
||||
static initialize(sequelize) {
|
||||
WeeklyWorktime.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
weekly_work_time: {
|
||||
type: DataTypes.DOUBLE,
|
||||
allowNull: false,
|
||||
defaultValue: 40
|
||||
},
|
||||
starting_from: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true
|
||||
},
|
||||
ends_at: {
|
||||
type: DataTypes.DATEONLY,
|
||||
allowNull: true
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'weekly_worktime',
|
||||
timestamps: false
|
||||
}
|
||||
);
|
||||
|
||||
return WeeklyWorktime;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = WeeklyWorktime;
|
||||
|
||||
|
||||
|
||||
128
backend/src/models/Worklog.js
Normal file
128
backend/src/models/Worklog.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const { Model, DataTypes } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Worklog Model
|
||||
* Repräsentiert die worklog-Tabelle für Zeiteinträge
|
||||
*/
|
||||
class Worklog extends Model {
|
||||
static initialize(sequelize) {
|
||||
Worklog.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.BIGINT,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'user',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
state: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('state');
|
||||
try {
|
||||
return JSON.parse(rawValue);
|
||||
} catch {
|
||||
return { action: rawValue, project: 'Allgemein', description: '' };
|
||||
}
|
||||
},
|
||||
set(value) {
|
||||
if (typeof value === 'object') {
|
||||
this.setDataValue('state', JSON.stringify(value));
|
||||
} else {
|
||||
this.setDataValue('state', value);
|
||||
}
|
||||
}
|
||||
},
|
||||
tstamp: {
|
||||
type: DataTypes.STRING(19), // 'YYYY-MM-DD HH:MM:SS' = 19 Zeichen
|
||||
allowNull: true,
|
||||
comment: 'Lokale Zeit ohne TZ-Info, Format: YYYY-MM-DD HH:MM:SS'
|
||||
},
|
||||
relatedTo_id: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
field: 'relatedTo_id', // Explizit den Spaltennamen in der DB angeben
|
||||
references: {
|
||||
model: 'worklog',
|
||||
key: 'id'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'worklog',
|
||||
timestamps: false,
|
||||
indexes: [
|
||||
{
|
||||
name: 'fk_worklog_relatedTo',
|
||||
fields: ['relatedTo_id']
|
||||
},
|
||||
{
|
||||
name: 'worklog_tstamp_IDX',
|
||||
fields: ['tstamp']
|
||||
},
|
||||
{
|
||||
name: 'worklog_user_id_IDX',
|
||||
fields: ['user_id', 'tstamp']
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
return Worklog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob dies ein Clock-In Eintrag ist
|
||||
*/
|
||||
isClockIn() {
|
||||
return this.relatedTo_id === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob dies ein Clock-Out Eintrag ist
|
||||
*/
|
||||
isClockOut() {
|
||||
return this.relatedTo_id !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den State-Action zurück
|
||||
*/
|
||||
getAction() {
|
||||
const state = this.state;
|
||||
return state.action || state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt das Projekt zurück
|
||||
*/
|
||||
getProject() {
|
||||
const state = this.state;
|
||||
return state.project || 'Allgemein';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Beschreibung zurück
|
||||
*/
|
||||
getDescription() {
|
||||
const state = this.state;
|
||||
return state.description || '';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Worklog;
|
||||
|
||||
31
backend/src/models/index.js
Normal file
31
backend/src/models/index.js
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Model Index
|
||||
* Exportiert alle Sequelize Models für einfachen Import
|
||||
*/
|
||||
|
||||
const User = require('./User');
|
||||
const Worklog = require('./Worklog');
|
||||
const AuthInfo = require('./AuthInfo');
|
||||
const State = require('./State');
|
||||
const WeeklyWorktime = require('./WeeklyWorktime');
|
||||
const Holiday = require('./Holiday');
|
||||
const Vacation = require('./Vacation');
|
||||
const Sick = require('./Sick');
|
||||
const SickType = require('./SickType');
|
||||
const Timefix = require('./Timefix');
|
||||
const Timewish = require('./Timewish');
|
||||
|
||||
module.exports = {
|
||||
User,
|
||||
Worklog,
|
||||
AuthInfo,
|
||||
State,
|
||||
WeeklyWorktime,
|
||||
Holiday,
|
||||
Vacation,
|
||||
Sick,
|
||||
SickType,
|
||||
Timefix,
|
||||
Timewish
|
||||
};
|
||||
|
||||
195
backend/src/repositories/UserRepository.js
Normal file
195
backend/src/repositories/UserRepository.js
Normal file
@@ -0,0 +1,195 @@
|
||||
const database = require('../config/database');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Repository für User-Datenbankzugriff
|
||||
* Verwendet Sequelize ORM
|
||||
*/
|
||||
class UserRepository {
|
||||
/**
|
||||
* Alle Benutzer abrufen
|
||||
* @returns {Promise<Array>} Liste aller Benutzer
|
||||
*/
|
||||
async findAll() {
|
||||
const { User, State } = database.getModels();
|
||||
|
||||
return await User.findAll({
|
||||
include: [{
|
||||
model: State,
|
||||
as: 'state',
|
||||
required: false
|
||||
}],
|
||||
order: [['full_name', 'ASC']]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer anhand der ID abrufen
|
||||
* @param {number} id - Benutzer-ID
|
||||
* @returns {Promise<Object|null>} Benutzer oder null
|
||||
*/
|
||||
async findById(id) {
|
||||
const { User, State } = database.getModels();
|
||||
|
||||
return await User.findByPk(id, {
|
||||
include: [{
|
||||
model: State,
|
||||
as: 'state',
|
||||
required: false
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer anhand der E-Mail abrufen
|
||||
* @param {string} email - E-Mail-Adresse
|
||||
* @returns {Promise<Object|null>} Benutzer oder null
|
||||
*/
|
||||
async findByEmail(email) {
|
||||
const { User, AuthInfo } = database.getModels();
|
||||
|
||||
return await User.findOne({
|
||||
include: [{
|
||||
model: AuthInfo,
|
||||
as: 'authInfo',
|
||||
where: { email },
|
||||
required: true
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wochenarbeitszeit für Benutzer abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {Date} date - Datum (optional, Standard: heute)
|
||||
* @returns {Promise<Object|null>} Wochenarbeitszeit-Einstellung
|
||||
*/
|
||||
async getWeeklyWorktime(userId, date = new Date()) {
|
||||
const { WeeklyWorktime } = database.getModels();
|
||||
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
|
||||
return await WeeklyWorktime.findOne({
|
||||
where: {
|
||||
user_id: userId,
|
||||
[Op.or]: [
|
||||
{ starting_from: null },
|
||||
{ starting_from: { [Op.lte]: dateStr } }
|
||||
],
|
||||
[Op.or]: [
|
||||
{ ends_at: null },
|
||||
{ ends_at: { [Op.gte]: dateStr } }
|
||||
]
|
||||
},
|
||||
order: [['starting_from', 'DESC']]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer erstellen
|
||||
* @param {Object} userData - Benutzerdaten
|
||||
* @returns {Promise<Object>} Erstellter Benutzer
|
||||
*/
|
||||
async create(userData) {
|
||||
const { User } = database.getModels();
|
||||
|
||||
const {
|
||||
full_name,
|
||||
role = 0,
|
||||
daily_hours = 8,
|
||||
week_hours = 40,
|
||||
week_workdays = 5,
|
||||
state_id = null,
|
||||
preferred_title_type = 0
|
||||
} = userData;
|
||||
|
||||
return await User.create({
|
||||
version: 0,
|
||||
full_name,
|
||||
role,
|
||||
daily_hours,
|
||||
week_hours,
|
||||
week_workdays,
|
||||
state_id,
|
||||
preferred_title_type,
|
||||
last_change: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer aktualisieren
|
||||
* @param {number} id - Benutzer-ID
|
||||
* @param {Object} updateData - Zu aktualisierende Daten
|
||||
* @returns {Promise<Object>} Aktualisierter Benutzer
|
||||
*/
|
||||
async update(id, updateData) {
|
||||
const { User } = database.getModels();
|
||||
|
||||
const user = await User.findByPk(id);
|
||||
if (!user) {
|
||||
throw new Error(`Benutzer mit ID ${id} nicht gefunden`);
|
||||
}
|
||||
|
||||
const allowedFields = [
|
||||
'full_name', 'role', 'daily_hours', 'week_hours',
|
||||
'week_workdays', 'state_id', 'preferred_title_type'
|
||||
];
|
||||
|
||||
const updates = {};
|
||||
allowedFields.forEach(field => {
|
||||
if (updateData[field] !== undefined) {
|
||||
updates[field] = updateData[field];
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
updates.version = user.version + 1;
|
||||
updates.last_change = new Date();
|
||||
|
||||
await user.update(updates);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer mit allen Beziehungen abrufen
|
||||
* @param {number} id - Benutzer-ID
|
||||
* @returns {Promise<Object|null>} Benutzer mit Beziehungen
|
||||
*/
|
||||
async findByIdWithRelations(id) {
|
||||
const { User, State, AuthInfo, WeeklyWorktime, Vacation, Sick } = database.getModels();
|
||||
|
||||
return await User.findByPk(id, {
|
||||
include: [
|
||||
{
|
||||
model: State,
|
||||
as: 'state',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: AuthInfo,
|
||||
as: 'authInfo',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: WeeklyWorktime,
|
||||
as: 'weeklyWorktimes',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: Vacation,
|
||||
as: 'vacations',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: Sick,
|
||||
as: 'sickLeaves',
|
||||
required: false
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UserRepository();
|
||||
564
backend/src/repositories/WorklogRepository.js
Normal file
564
backend/src/repositories/WorklogRepository.js
Normal file
@@ -0,0 +1,564 @@
|
||||
const database = require('../config/database');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
/**
|
||||
* Repository für Worklog-Datenbankzugriff
|
||||
* Verwendet Sequelize ORM
|
||||
*/
|
||||
class WorklogRepository {
|
||||
/**
|
||||
* Alle Worklog-Einträge für einen Benutzer abrufen
|
||||
* @param {number} userId - ID des Benutzers
|
||||
* @param {Object} options - Filteroptionen
|
||||
* @returns {Promise<Array>} Liste der Worklog-Einträge
|
||||
*/
|
||||
async findAllByUser(userId, options = {}) {
|
||||
const { Worklog } = database.getModels();
|
||||
const { limit, offset, orderBy = 'tstamp', order = 'DESC' } = options;
|
||||
|
||||
return await Worklog.findAll({
|
||||
where: { user_id: userId },
|
||||
order: [[orderBy, order]],
|
||||
limit: limit || null,
|
||||
offset: offset || 0
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Worklog-Eintrag anhand der ID abrufen
|
||||
* @param {number} id - Worklog-ID
|
||||
* @returns {Promise<Object|null>} Worklog-Eintrag oder null
|
||||
*/
|
||||
async findById(id) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
return await Worklog.findByPk(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Neuen Worklog-Eintrag erstellen
|
||||
* @param {Object} worklogData - Worklog-Daten
|
||||
* @returns {Promise<Object>} Erstellter Worklog-Eintrag
|
||||
*/
|
||||
async create(worklogData) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
const { user_id, state, tstamp, relatedTo_id = null } = worklogData;
|
||||
|
||||
return await Worklog.create({
|
||||
version: 0,
|
||||
user_id,
|
||||
state,
|
||||
tstamp: tstamp || new Date(),
|
||||
relatedTo_id
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Worklog-Eintrag aktualisieren
|
||||
* @param {number} id - Worklog-ID
|
||||
* @param {Object} updateData - Zu aktualisierende Daten
|
||||
* @returns {Promise<Object>} Aktualisierter Worklog-Eintrag
|
||||
*/
|
||||
async update(id, updateData) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
const worklog = await Worklog.findByPk(id);
|
||||
if (!worklog) {
|
||||
throw new Error(`Worklog mit ID ${id} nicht gefunden`);
|
||||
}
|
||||
|
||||
// Update-Daten vorbereiten
|
||||
const updates = {};
|
||||
|
||||
if (updateData.state !== undefined) {
|
||||
updates.state = updateData.state;
|
||||
}
|
||||
if (updateData.tstamp !== undefined) {
|
||||
updates.tstamp = updateData.tstamp;
|
||||
}
|
||||
if (updateData.relatedTo_id !== undefined) {
|
||||
updates.relatedTo_id = updateData.relatedTo_id;
|
||||
}
|
||||
|
||||
// Version inkrementieren
|
||||
updates.version = worklog.version + 1;
|
||||
|
||||
await worklog.update(updates);
|
||||
return worklog;
|
||||
}
|
||||
|
||||
/**
|
||||
* Worklog-Eintrag löschen
|
||||
* @param {number} id - Worklog-ID
|
||||
* @returns {Promise<boolean>} true wenn erfolgreich
|
||||
*/
|
||||
async delete(id) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
const deleted = await Worklog.destroy({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Letzten Worklog-Eintrag für Benutzer finden
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Object|null>} Letzter Worklog-Eintrag oder null
|
||||
*/
|
||||
async findLatestByUser(userId) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
return await Worklog.findOne({
|
||||
where: { user_id: userId },
|
||||
order: [['tstamp', 'DESC'], ['id', 'DESC']],
|
||||
raw: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Worklog-Einträge für Benutzer finden
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Array>} Array von Worklog-Einträgen
|
||||
*/
|
||||
async findByUser(userId) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
return await Worklog.findAll({
|
||||
where: { user_id: userId },
|
||||
order: [['tstamp', 'ASC'], ['id', 'ASC']],
|
||||
raw: true
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Letzten offenen Worklog-Eintrag für Benutzer finden
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Object|null>} Laufender Worklog-Eintrag oder null
|
||||
*/
|
||||
async findRunningByUser(userId) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
return await Worklog.findOne({
|
||||
where: {
|
||||
user_id: userId,
|
||||
relatedTo_id: null
|
||||
},
|
||||
order: [['tstamp', 'DESC']],
|
||||
include: [{
|
||||
model: Worklog,
|
||||
as: 'clockOut',
|
||||
required: false
|
||||
}]
|
||||
}).then(worklog => {
|
||||
// Nur zurückgeben wenn kein clockOut existiert
|
||||
if (worklog && !worklog.clockOut) {
|
||||
return worklog;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Vacation-Einträge für einen Benutzer in einem Datumsbereich abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {Date} startDate - Start-Datum
|
||||
* @param {Date} endDate - End-Datum
|
||||
* @returns {Promise<Array>} Array von Vacation-Einträgen mit expandierten Tagen
|
||||
*/
|
||||
async getVacationsByUserInDateRange(userId, startDate, endDate) {
|
||||
const { Vacation } = database.getModels();
|
||||
|
||||
try {
|
||||
// Hole alle Vacation-Einträge, die sich mit dem Datumsbereich überschneiden
|
||||
const vacations = await Vacation.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
[Op.or]: [
|
||||
{
|
||||
first_day: {
|
||||
[Op.between]: [startDate, endDate]
|
||||
}
|
||||
},
|
||||
{
|
||||
last_day: {
|
||||
[Op.between]: [startDate, endDate]
|
||||
}
|
||||
},
|
||||
{
|
||||
[Op.and]: [
|
||||
{ first_day: { [Op.lte]: startDate } },
|
||||
{ last_day: { [Op.gte]: endDate } }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
raw: true,
|
||||
order: [['first_day', 'ASC']]
|
||||
});
|
||||
|
||||
// Expandiere jeden Vacation-Eintrag in einzelne Tage
|
||||
const expandedVacations = [];
|
||||
|
||||
vacations.forEach(vac => {
|
||||
const first = new Date(vac.first_day);
|
||||
const last = new Date(vac.last_day);
|
||||
|
||||
// Iteriere über alle Tage im Urlaubsbereich
|
||||
for (let d = new Date(first); d <= last; d.setDate(d.getDate() + 1)) {
|
||||
// Nur Tage hinzufügen, die im gewünschten Bereich liegen
|
||||
if (d >= startDate && d <= endDate) {
|
||||
// Überspringe Wochenenden (Samstag=6, Sonntag=0)
|
||||
const dayOfWeek = d.getDay();
|
||||
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
||||
expandedVacations.push({
|
||||
date: new Date(d),
|
||||
half_day: vac.vacation_type === 1 ? 1 : 0,
|
||||
vacation_type: vac.vacation_type
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return expandedVacations;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Vacation-Einträge:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sick-Einträge für einen Benutzer in einem Datumsbereich abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {Date} startDate - Start-Datum
|
||||
* @param {Date} endDate - End-Datum
|
||||
* @returns {Promise<Array>} Array von Sick-Einträgen mit expandierten Tagen
|
||||
*/
|
||||
async getSickByUserInDateRange(userId, startDate, endDate) {
|
||||
const { Sick, SickType } = database.getModels();
|
||||
|
||||
try {
|
||||
// Hole alle Sick-Einträge, die sich mit dem Datumsbereich überschneiden
|
||||
const sickEntries = await Sick.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
[Op.or]: [
|
||||
{
|
||||
first_day: {
|
||||
[Op.between]: [startDate, endDate]
|
||||
}
|
||||
},
|
||||
{
|
||||
last_day: {
|
||||
[Op.between]: [startDate, endDate]
|
||||
}
|
||||
},
|
||||
{
|
||||
[Op.and]: [
|
||||
{ first_day: { [Op.lte]: startDate } },
|
||||
{ last_day: { [Op.gte]: endDate } }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
include: [{
|
||||
model: SickType,
|
||||
as: 'sickType',
|
||||
attributes: ['description']
|
||||
}],
|
||||
order: [['first_day', 'ASC']]
|
||||
});
|
||||
|
||||
// Expandiere jeden Sick-Eintrag in einzelne Tage
|
||||
const expandedSick = [];
|
||||
|
||||
sickEntries.forEach(sick => {
|
||||
const first = new Date(sick.first_day);
|
||||
const last = new Date(sick.last_day);
|
||||
const sickTypeDesc = sick.sickType?.description || 'self';
|
||||
|
||||
// Iteriere über alle Tage im Krankheitsbereich
|
||||
for (let d = new Date(first); d <= last; d.setDate(d.getDate() + 1)) {
|
||||
// Nur Tage hinzufügen, die im gewünschten Bereich liegen
|
||||
if (d >= startDate && d <= endDate) {
|
||||
// Überspringe Wochenenden (Samstag=6, Sonntag=0)
|
||||
const dayOfWeek = d.getDay();
|
||||
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
|
||||
expandedSick.push({
|
||||
date: new Date(d),
|
||||
sick_type: sickTypeDesc,
|
||||
sick_type_id: sick.sick_type_id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return expandedSick;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Sick-Einträge:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Timefix-Einträge für Worklog-IDs abrufen
|
||||
* @param {Array<number>} worklogIds - Array von Worklog-IDs
|
||||
* @returns {Promise<Map>} Map von worklog_id zu Timefix-Einträgen
|
||||
*/
|
||||
async getTimefixesByWorklogIds(worklogIds) {
|
||||
if (!worklogIds || worklogIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const { Timefix } = database.getModels();
|
||||
|
||||
try {
|
||||
const timefixes = await Timefix.findAll({
|
||||
where: {
|
||||
worklog_id: {
|
||||
[Op.in]: worklogIds
|
||||
}
|
||||
},
|
||||
raw: true
|
||||
});
|
||||
|
||||
// Gruppiere nach worklog_id
|
||||
const timefixMap = new Map();
|
||||
timefixes.forEach(fix => {
|
||||
if (!timefixMap.has(fix.worklog_id)) {
|
||||
timefixMap.set(fix.worklog_id, []);
|
||||
}
|
||||
timefixMap.get(fix.worklog_id).push(fix);
|
||||
});
|
||||
|
||||
return timefixMap;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Timefix-Einträge:', error);
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worklog-Paare für Benutzer in einem Datumsbereich finden
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {Date} startDate - Start-Datum
|
||||
* @param {Date} endDate - End-Datum
|
||||
* @returns {Promise<Array>} Array von Worklog-Paaren
|
||||
*/
|
||||
async findPairsByUserInDateRange(userId, startDate, endDate) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
try {
|
||||
const results = await Worklog.findAll({
|
||||
attributes: ['id', 'version', 'user_id', 'state', 'tstamp', 'relatedTo_id'],
|
||||
where: {
|
||||
user_id: userId,
|
||||
tstamp: {
|
||||
[Op.between]: [startDate, endDate]
|
||||
}
|
||||
},
|
||||
order: [['tstamp', 'ASC']],
|
||||
raw: true
|
||||
});
|
||||
|
||||
// Gruppiere Start/Stop-Paare basierend auf dem action-Feld
|
||||
const pairs = [];
|
||||
const startEntries = {};
|
||||
|
||||
results.forEach(entry => {
|
||||
// Parse state JSON
|
||||
let action = '';
|
||||
try {
|
||||
const state = typeof entry.state === 'string' ? JSON.parse(entry.state) : entry.state;
|
||||
action = state.action || state;
|
||||
} catch (e) {
|
||||
action = entry.state;
|
||||
}
|
||||
|
||||
if (action === 'start work') {
|
||||
startEntries[entry.id] = entry;
|
||||
} else if (action === 'stop work' && entry.relatedTo_id) {
|
||||
const startEntry = startEntries[entry.relatedTo_id];
|
||||
if (startEntry) {
|
||||
pairs.push({
|
||||
id: startEntry.id,
|
||||
start_time: startEntry.tstamp,
|
||||
end_time: entry.tstamp,
|
||||
start_state: startEntry.state,
|
||||
end_state: entry.state
|
||||
});
|
||||
delete startEntries[entry.relatedTo_id];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Füge laufende Einträge hinzu
|
||||
Object.values(startEntries).forEach(startEntry => {
|
||||
pairs.push({
|
||||
id: startEntry.id,
|
||||
start_time: startEntry.tstamp,
|
||||
end_time: null,
|
||||
start_state: startEntry.state,
|
||||
end_state: null
|
||||
});
|
||||
});
|
||||
|
||||
return pairs;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Worklog-Paare:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worklog-Einträge nach Datumsbereich abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {Date} startDate - Startdatum
|
||||
* @param {Date} endDate - Enddatum
|
||||
* @returns {Promise<Array>} Gefilterte Worklog-Einträge
|
||||
*/
|
||||
async findByDateRange(userId, startDate, endDate) {
|
||||
const { Worklog } = database.getModels();
|
||||
|
||||
return await Worklog.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
tstamp: {
|
||||
[Op.between]: [startDate, endDate]
|
||||
}
|
||||
},
|
||||
order: [['tstamp', 'ASC']] // Aufsteigend, damit Start vor Stop kommt
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Zusammengehörige Worklog-Paare abrufen (Clock In/Out)
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {Object} options - Optionen
|
||||
* @returns {Promise<Array>} Liste von Worklog-Paaren
|
||||
*/
|
||||
async findPairsByUser(userId, options = {}) {
|
||||
const { Worklog } = database.getModels();
|
||||
const sequelize = database.getSequelize();
|
||||
const { limit, offset } = options;
|
||||
|
||||
// Raw Query für bessere Performance bei Pairs
|
||||
const query = `
|
||||
SELECT
|
||||
w1.id as start_id,
|
||||
w1.tstamp as start_time,
|
||||
w1.state as start_state,
|
||||
w2.id as end_id,
|
||||
w2.tstamp as end_time,
|
||||
w2.state as end_state,
|
||||
TIMESTAMPDIFF(SECOND, w1.tstamp, w2.tstamp) as duration
|
||||
FROM worklog w1
|
||||
LEFT JOIN worklog w2 ON w2.relatedTo_id = w1.id
|
||||
WHERE w1.user_id = :userId
|
||||
AND w1.relatedTo_id IS NULL
|
||||
ORDER BY w1.tstamp DESC
|
||||
${limit ? `LIMIT ${limit}` : ''}
|
||||
${offset ? `OFFSET ${offset}` : ''}
|
||||
`;
|
||||
|
||||
const results = await sequelize.query(query, {
|
||||
replacements: { userId },
|
||||
type: sequelize.constructor.QueryTypes.SELECT
|
||||
});
|
||||
|
||||
return Array.isArray(results) ? results : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiken für Benutzer berechnen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Object>} Statistik-Objekt
|
||||
*/
|
||||
async getStatistics(userId) {
|
||||
const sequelize = database.getSequelize();
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
COUNT(DISTINCT w1.id) as total_entries,
|
||||
COUNT(DISTINCT w2.id) as completed_entries,
|
||||
COUNT(DISTINCT CASE WHEN w2.id IS NULL THEN w1.id END) as running_entries,
|
||||
COALESCE(SUM(TIMESTAMPDIFF(SECOND, w1.tstamp, w2.tstamp)), 0) as total_seconds
|
||||
FROM worklog w1
|
||||
LEFT JOIN worklog w2 ON w2.relatedTo_id = w1.id
|
||||
WHERE w1.user_id = :userId
|
||||
AND w1.relatedTo_id IS NULL
|
||||
`;
|
||||
|
||||
const results = await sequelize.query(query, {
|
||||
replacements: { userId },
|
||||
type: sequelize.constructor.QueryTypes.SELECT
|
||||
});
|
||||
|
||||
return (Array.isArray(results) && results[0]) ? results[0] : {
|
||||
total_entries: 0,
|
||||
completed_entries: 0,
|
||||
running_entries: 0,
|
||||
total_seconds: 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiken für heute abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Object>} Heutige Statistiken
|
||||
*/
|
||||
async getTodayStatistics(userId) {
|
||||
const sequelize = database.getSequelize();
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
COUNT(DISTINCT w1.id) as entries,
|
||||
COALESCE(SUM(TIMESTAMPDIFF(SECOND, w1.tstamp, w2.tstamp)), 0) as seconds
|
||||
FROM worklog w1
|
||||
LEFT JOIN worklog w2 ON w2.relatedTo_id = w1.id
|
||||
WHERE w1.user_id = :userId
|
||||
AND w1.relatedTo_id IS NULL
|
||||
AND DATE(w1.tstamp) = CURDATE()
|
||||
`;
|
||||
|
||||
const results = await sequelize.query(query, {
|
||||
replacements: { userId },
|
||||
type: sequelize.constructor.QueryTypes.SELECT
|
||||
});
|
||||
|
||||
return (Array.isArray(results) && results[0]) ? results[0] : { entries: 0, seconds: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Feiertage in einem Datumsbereich abrufen
|
||||
* @param {Date} startDate - Start-Datum
|
||||
* @param {Date} endDate - End-Datum
|
||||
* @returns {Promise<Array>} Array von Holiday-Einträgen mit Datum und Stunden
|
||||
*/
|
||||
async getHolidaysInDateRange(startDate, endDate) {
|
||||
const { Holiday } = database.getModels();
|
||||
|
||||
try {
|
||||
const holidays = await Holiday.findAll({
|
||||
where: {
|
||||
date: {
|
||||
[Op.between]: [startDate, endDate]
|
||||
}
|
||||
},
|
||||
raw: true,
|
||||
order: [['date', 'ASC']]
|
||||
});
|
||||
|
||||
return holidays;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen der Feiertage:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WorklogRepository();
|
||||
26
backend/src/routes/auth.js
Normal file
26
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,26 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const authController = require('../controllers/AuthController');
|
||||
const oauthController = require('../controllers/OAuthController');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
|
||||
// Öffentliche Routes (kein Auth erforderlich)
|
||||
router.post('/register', authController.register.bind(authController));
|
||||
router.post('/login', authController.login.bind(authController));
|
||||
router.post('/request-reset', authController.requestPasswordReset.bind(authController));
|
||||
router.post('/reset-password', authController.resetPassword.bind(authController));
|
||||
|
||||
// OAuth Routes (öffentlich)
|
||||
router.get('/google', oauthController.googleAuth.bind(oauthController));
|
||||
router.get('/google/callback', oauthController.googleCallback.bind(oauthController));
|
||||
|
||||
// Geschützte Routes (Auth erforderlich)
|
||||
router.post('/logout', authenticateToken, authController.logout.bind(authController));
|
||||
router.get('/me', authenticateToken, authController.getCurrentUser.bind(authController));
|
||||
router.post('/change-password', authenticateToken, authController.changePassword.bind(authController));
|
||||
router.get('/validate', authenticateToken, authController.validateToken.bind(authController));
|
||||
router.get('/identities', authenticateToken, oauthController.getIdentities.bind(oauthController));
|
||||
router.delete('/identity/:provider', authenticateToken, oauthController.unlinkProvider.bind(oauthController));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
36
backend/src/routes/timeEntries.js
Normal file
36
backend/src/routes/timeEntries.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const timeEntryController = require('../controllers/TimeEntryController');
|
||||
|
||||
// GET Statistiken (muss vor /:id stehen, um Konflikte zu vermeiden)
|
||||
router.get('/stats/summary', timeEntryController.getStats.bind(timeEntryController));
|
||||
|
||||
// GET aktuellen Status
|
||||
router.get('/current-state', timeEntryController.getCurrentState.bind(timeEntryController));
|
||||
|
||||
// POST Stempeln (Clock In/Out, Pause)
|
||||
router.post('/clock', timeEntryController.clock.bind(timeEntryController));
|
||||
|
||||
// GET aktuell laufenden Timer
|
||||
router.get('/running', timeEntryController.getRunningEntry.bind(timeEntryController));
|
||||
|
||||
// GET Einträge nach Projekt
|
||||
router.get('/project/:projectName', timeEntryController.getEntriesByProject.bind(timeEntryController));
|
||||
|
||||
// GET alle Zeiteinträge
|
||||
router.get('/', timeEntryController.getAllEntries.bind(timeEntryController));
|
||||
|
||||
// GET einzelner Zeiteintrag
|
||||
router.get('/:id', timeEntryController.getEntryById.bind(timeEntryController));
|
||||
|
||||
// POST neuer Zeiteintrag (Clock In)
|
||||
router.post('/', timeEntryController.createEntry.bind(timeEntryController));
|
||||
|
||||
// PUT Zeiteintrag aktualisieren (Clock Out)
|
||||
router.put('/:id', timeEntryController.updateEntry.bind(timeEntryController));
|
||||
|
||||
// DELETE Zeiteintrag löschen
|
||||
router.delete('/:id', timeEntryController.deleteEntry.bind(timeEntryController));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
16
backend/src/routes/weekOverview.js
Normal file
16
backend/src/routes/weekOverview.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const weekOverviewController = require('../controllers/WeekOverviewController');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
|
||||
/**
|
||||
* Wochenübersicht-Routes
|
||||
* Alle Routes sind geschützt und erfordern Authentifizierung
|
||||
*/
|
||||
|
||||
// GET /api/week-overview - Wochenübersicht abrufen
|
||||
router.get('/', authenticateToken, weekOverviewController.getWeekOverview);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
||||
453
backend/src/services/AuthService.js
Normal file
453
backend/src/services/AuthService.js
Normal file
@@ -0,0 +1,453 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const database = require('../config/database');
|
||||
|
||||
/**
|
||||
* Authentication Service
|
||||
* Verwaltet Login, Registrierung, Passwort-Reset
|
||||
*/
|
||||
class AuthService {
|
||||
constructor() {
|
||||
this.jwtSecret = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
this.jwtExpiration = process.env.JWT_EXPIRATION || '24h';
|
||||
this.saltRounds = 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer registrieren
|
||||
* @param {Object} userData - Registrierungsdaten
|
||||
* @returns {Promise<Object>} Erstellter Benutzer mit Auth-Info
|
||||
*/
|
||||
async register(userData) {
|
||||
const { User, AuthInfo } = database.getModels();
|
||||
const { email, password, full_name } = userData;
|
||||
|
||||
// Validierung
|
||||
if (!email || !password || !full_name) {
|
||||
throw new Error('E-Mail, Passwort und Name sind erforderlich');
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
throw new Error('Passwort muss mindestens 6 Zeichen lang sein');
|
||||
}
|
||||
|
||||
// Prüfen ob E-Mail bereits existiert
|
||||
const existingAuth = await AuthInfo.findOne({ where: { email } });
|
||||
if (existingAuth) {
|
||||
throw new Error('Diese E-Mail-Adresse ist bereits registriert');
|
||||
}
|
||||
|
||||
// Passwort hashen
|
||||
const salt = await bcrypt.genSalt(this.saltRounds);
|
||||
const passwordHash = await bcrypt.hash(password, salt);
|
||||
|
||||
// Benutzer erstellen
|
||||
const user = await User.create({
|
||||
full_name,
|
||||
role: 0,
|
||||
daily_hours: 8,
|
||||
week_hours: 40,
|
||||
week_workdays: 5,
|
||||
preferred_title_type: 0,
|
||||
version: 0,
|
||||
last_change: new Date()
|
||||
});
|
||||
|
||||
// Auth-Info erstellen
|
||||
const authInfo = await AuthInfo.create({
|
||||
user_id: user.id,
|
||||
email,
|
||||
password_hash: passwordHash,
|
||||
password_method: 'bcrypt',
|
||||
password_salt: salt,
|
||||
status: 1, // Active
|
||||
failed_login_attempts: 0,
|
||||
email_token: '',
|
||||
email_token_role: 0,
|
||||
unverified_email: '',
|
||||
version: 0
|
||||
});
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
full_name: user.full_name,
|
||||
email: authInfo.email
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer einloggen
|
||||
* @param {string} email - E-Mail-Adresse
|
||||
* @param {string} password - Passwort
|
||||
* @returns {Promise<Object>} Token und Benutzer-Info
|
||||
*/
|
||||
async login(email, password) {
|
||||
const { User, AuthInfo, AuthToken } = database.getModels();
|
||||
|
||||
console.log('Login-Versuch für E-Mail:', email);
|
||||
|
||||
// Auth-Info mit Benutzer laden
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
where: { email },
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user',
|
||||
required: true
|
||||
}]
|
||||
});
|
||||
|
||||
console.log('AuthInfo gefunden:', !!authInfo);
|
||||
if (authInfo) {
|
||||
console.log('AuthInfo E-Mail:', authInfo.email);
|
||||
console.log('User ID:', authInfo.user?.id);
|
||||
}
|
||||
|
||||
if (!authInfo) {
|
||||
throw new Error('Ungültige E-Mail oder Passwort');
|
||||
}
|
||||
|
||||
// Account-Status prüfen
|
||||
if (authInfo.status !== 1) {
|
||||
throw new Error('Ihr Account ist deaktiviert. Bitte kontaktieren Sie den Support.');
|
||||
}
|
||||
|
||||
// Zu viele fehlgeschlagene Versuche?
|
||||
if (authInfo.failed_login_attempts >= 5) {
|
||||
const lastAttempt = authInfo.last_login_attempt;
|
||||
const lockoutTime = 15 * 60 * 1000; // 15 Minuten
|
||||
|
||||
if (lastAttempt && (Date.now() - new Date(lastAttempt).getTime()) < lockoutTime) {
|
||||
throw new Error('Account temporär gesperrt. Bitte versuchen Sie es später erneut.');
|
||||
} else {
|
||||
// Reset nach Lockout-Zeit
|
||||
await authInfo.update({
|
||||
failed_login_attempts: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Passwort verifizieren
|
||||
const isValidPassword = await bcrypt.compare(password, authInfo.password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
// Fehlversuch registrieren
|
||||
await authInfo.update({
|
||||
failed_login_attempts: authInfo.failed_login_attempts + 1,
|
||||
last_login_attempt: new Date()
|
||||
});
|
||||
throw new Error('Ungültige E-Mail oder Passwort');
|
||||
}
|
||||
|
||||
// Login erfolgreich - Reset fehlgeschlagene Versuche
|
||||
await authInfo.update({
|
||||
failed_login_attempts: 0,
|
||||
last_login_attempt: new Date()
|
||||
});
|
||||
|
||||
// JWT Token generieren
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: authInfo.user.id,
|
||||
email: authInfo.email,
|
||||
role: authInfo.user.role
|
||||
},
|
||||
this.jwtSecret,
|
||||
{ expiresIn: this.jwtExpiration }
|
||||
);
|
||||
|
||||
// Token in Datenbank speichern
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24);
|
||||
|
||||
await AuthToken.create({
|
||||
auth_info_id: authInfo.id,
|
||||
value: crypto.createHash('sha256').update(token).digest('hex'),
|
||||
expires: expiresAt,
|
||||
version: 0
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: authInfo.user.id,
|
||||
full_name: authInfo.user.full_name,
|
||||
email: authInfo.email,
|
||||
role: authInfo.user.role
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Token validieren
|
||||
* @param {string} token - JWT Token
|
||||
* @returns {Promise<Object>} Dekodierte Token-Daten
|
||||
*/
|
||||
async validateToken(token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, this.jwtSecret);
|
||||
|
||||
// Prüfen ob Token in DB existiert und nicht abgelaufen
|
||||
const { AuthToken } = database.getModels();
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
const dbToken = await AuthToken.findOne({
|
||||
where: { value: tokenHash }
|
||||
});
|
||||
|
||||
if (!dbToken || dbToken.isExpired()) {
|
||||
throw new Error('Token ungültig oder abgelaufen');
|
||||
}
|
||||
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
throw new Error('Ungültiger Token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort-Reset anfordern
|
||||
* @param {string} email - E-Mail-Adresse
|
||||
* @returns {Promise<string>} Reset-Token
|
||||
*/
|
||||
async requestPasswordReset(email) {
|
||||
const { AuthInfo } = database.getModels();
|
||||
|
||||
const authInfo = await AuthInfo.findOne({ where: { email } });
|
||||
|
||||
if (!authInfo) {
|
||||
// Aus Sicherheitsgründen keine Fehlermeldung, dass E-Mail nicht existiert
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reset-Token generieren
|
||||
const resetToken = crypto.randomBytes(32).toString('hex');
|
||||
const tokenExpires = new Date();
|
||||
tokenExpires.setHours(tokenExpires.getHours() + 1); // 1 Stunde gültig
|
||||
|
||||
// Token speichern
|
||||
await authInfo.update({
|
||||
email_token: resetToken,
|
||||
email_token_expires: tokenExpires,
|
||||
email_token_role: 1 // 1 = Password Reset
|
||||
});
|
||||
|
||||
return resetToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort zurücksetzen
|
||||
* @param {string} token - Reset-Token
|
||||
* @param {string} newPassword - Neues Passwort
|
||||
* @returns {Promise<boolean>} Erfolg
|
||||
*/
|
||||
async resetPassword(token, newPassword) {
|
||||
const { AuthInfo } = database.getModels();
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
throw new Error('Passwort muss mindestens 6 Zeichen lang sein');
|
||||
}
|
||||
|
||||
// Development-Token erkennen und behandeln
|
||||
const isDevelopment = !process.env.NODE_ENV || process.env.NODE_ENV === 'development';
|
||||
const isDevToken = token && token.startsWith('dev-test-token-');
|
||||
|
||||
if (isDevelopment && isDevToken) {
|
||||
// Im Development-Modus: Token akzeptieren und Passwort für spezifischen Benutzer ändern
|
||||
console.log('Development-Token erkannt:', token);
|
||||
|
||||
// E-Mail aus Token extrahieren (Format: dev-test-token-timestamp-email)
|
||||
// Token: "dev-test-token-1760554590751-tsschulz@tsschulz.de"
|
||||
const emailMatch = token.match(/dev-test-token-\d+-(.+)/);
|
||||
const email = emailMatch ? emailMatch[1] : null;
|
||||
|
||||
console.log('Development: Extrahierte E-Mail:', email);
|
||||
|
||||
if (email) {
|
||||
// Finde den Benutzer mit dieser E-Mail
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
where: { email: email }
|
||||
});
|
||||
|
||||
if (authInfo) {
|
||||
console.log('Development: Ändere Passwort für Benutzer:', authInfo.email);
|
||||
|
||||
// Neues Passwort hashen und speichern
|
||||
const salt = await bcrypt.genSalt(this.saltRounds);
|
||||
const passwordHash = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
await authInfo.update({
|
||||
password_hash: passwordHash,
|
||||
password_salt: salt,
|
||||
email_token: '', // Token löschen
|
||||
email_token_expires: null,
|
||||
email_token_role: 0
|
||||
});
|
||||
|
||||
console.log('Development: Passwort erfolgreich geändert für:', authInfo.email);
|
||||
return true;
|
||||
} else {
|
||||
console.log('Development: Kein Benutzer mit E-Mail', email, 'gefunden');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Ändere Passwort für ersten Benutzer
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
order: [['id', 'ASC']]
|
||||
});
|
||||
|
||||
if (authInfo) {
|
||||
console.log('Development: Fallback - Ändere Passwort für ersten Benutzer:', authInfo.email);
|
||||
|
||||
const salt = await bcrypt.genSalt(this.saltRounds);
|
||||
const passwordHash = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
await authInfo.update({
|
||||
password_hash: passwordHash,
|
||||
password_salt: salt,
|
||||
email_token: '',
|
||||
email_token_expires: null,
|
||||
email_token_role: 0
|
||||
});
|
||||
|
||||
console.log('Development: Passwort erfolgreich geändert für:', authInfo.email);
|
||||
return true;
|
||||
} else {
|
||||
console.log('Development: Kein Benutzer in der Datenbank gefunden');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
where: {
|
||||
email_token: token,
|
||||
email_token_role: 1
|
||||
}
|
||||
});
|
||||
|
||||
if (!authInfo) {
|
||||
throw new Error('Ungültiger oder abgelaufener Reset-Token');
|
||||
}
|
||||
|
||||
// Token-Ablauf prüfen
|
||||
if (authInfo.email_token_expires && new Date() > new Date(authInfo.email_token_expires)) {
|
||||
throw new Error('Reset-Token ist abgelaufen');
|
||||
}
|
||||
|
||||
// Neues Passwort hashen
|
||||
const salt = await bcrypt.genSalt(this.saltRounds);
|
||||
const passwordHash = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
// Passwort aktualisieren und Token löschen
|
||||
await authInfo.update({
|
||||
password_hash: passwordHash,
|
||||
password_salt: salt,
|
||||
email_token: '',
|
||||
email_token_expires: null,
|
||||
email_token_role: 0,
|
||||
failed_login_attempts: 0
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort ändern (für eingeloggten Benutzer)
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {string} oldPassword - Altes Passwort
|
||||
* @param {string} newPassword - Neues Passwort
|
||||
* @returns {Promise<boolean>} Erfolg
|
||||
*/
|
||||
async changePassword(userId, oldPassword, newPassword) {
|
||||
const { User, AuthInfo } = database.getModels();
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
throw new Error('Passwort muss mindestens 6 Zeichen lang sein');
|
||||
}
|
||||
|
||||
const user = await User.findByPk(userId, {
|
||||
include: [{
|
||||
model: AuthInfo,
|
||||
as: 'authInfo',
|
||||
required: true
|
||||
}]
|
||||
});
|
||||
|
||||
if (!user || !user.authInfo) {
|
||||
throw new Error('Benutzer nicht gefunden');
|
||||
}
|
||||
|
||||
// Altes Passwort verifizieren
|
||||
const isValidPassword = await bcrypt.compare(oldPassword, user.authInfo.password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
throw new Error('Altes Passwort ist falsch');
|
||||
}
|
||||
|
||||
// Neues Passwort hashen
|
||||
const salt = await bcrypt.genSalt(this.saltRounds);
|
||||
const passwordHash = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
// Passwort aktualisieren
|
||||
await user.authInfo.update({
|
||||
password_hash: passwordHash,
|
||||
password_salt: salt,
|
||||
version: user.authInfo.version + 1
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ausloggen (Token invalidieren)
|
||||
* @param {string} token - JWT Token
|
||||
* @returns {Promise<boolean>} Erfolg
|
||||
*/
|
||||
async logout(token) {
|
||||
const { AuthToken } = database.getModels();
|
||||
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
await AuthToken.destroy({
|
||||
where: { value: tokenHash }
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer-Profil abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Object>} Benutzer-Profil
|
||||
*/
|
||||
async getUserProfile(userId) {
|
||||
const { User, AuthInfo } = database.getModels();
|
||||
|
||||
const user = await User.findByPk(userId, {
|
||||
include: [{
|
||||
model: AuthInfo,
|
||||
as: 'authInfo',
|
||||
attributes: ['email']
|
||||
}]
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Benutzer nicht gefunden');
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
full_name: user.full_name,
|
||||
email: user.authInfo?.email,
|
||||
role: user.role,
|
||||
daily_hours: user.daily_hours,
|
||||
week_hours: user.week_hours,
|
||||
week_workdays: user.week_workdays
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthService();
|
||||
|
||||
209
backend/src/services/OAuthService.js
Normal file
209
backend/src/services/OAuthService.js
Normal file
@@ -0,0 +1,209 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const database = require('../config/database');
|
||||
|
||||
/**
|
||||
* OAuth Service
|
||||
* Verwaltet OAuth/SSO-Logins (Google, GitHub, etc.)
|
||||
*/
|
||||
class OAuthService {
|
||||
constructor() {
|
||||
this.jwtSecret = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
this.jwtExpiration = process.env.JWT_EXPIRATION || '24h';
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth-Login/Registrierung
|
||||
* @param {Object} profile - OAuth-Provider-Profil
|
||||
* @param {string} provider - Provider-Name (google, github, etc.)
|
||||
* @returns {Promise<Object>} Token und Benutzer-Info
|
||||
*/
|
||||
async authenticateWithProvider(profile, provider) {
|
||||
const { User, AuthInfo, AuthIdentity, AuthToken } = database.getModels();
|
||||
|
||||
const providerId = profile.id;
|
||||
const email = profile.emails && profile.emails[0] ? profile.emails[0].value : null;
|
||||
const displayName = profile.displayName || profile.name || email;
|
||||
|
||||
if (!providerId) {
|
||||
throw new Error('OAuth Provider-ID fehlt');
|
||||
}
|
||||
|
||||
// Prüfen ob OAuth-Identity bereits existiert
|
||||
let authIdentity = await AuthIdentity.findOne({
|
||||
where: {
|
||||
provider,
|
||||
identity: providerId
|
||||
},
|
||||
include: [{
|
||||
model: database.getModels().AuthInfo,
|
||||
as: 'authInfo',
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user'
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let user, authInfo;
|
||||
|
||||
if (authIdentity && authIdentity.authInfo) {
|
||||
// Bestehender OAuth-Benutzer
|
||||
authInfo = authIdentity.authInfo;
|
||||
user = authInfo.user;
|
||||
} else {
|
||||
// Neuer OAuth-Benutzer oder Verknüpfung mit bestehendem Account
|
||||
|
||||
// Prüfen ob Benutzer mit dieser E-Mail bereits existiert
|
||||
if (email) {
|
||||
authInfo = await AuthInfo.findOne({
|
||||
where: { email },
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user'
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
if (authInfo) {
|
||||
// Verknüpfe OAuth mit bestehendem Account
|
||||
user = authInfo.user;
|
||||
|
||||
authIdentity = await AuthIdentity.create({
|
||||
auth_info_id: authInfo.id,
|
||||
provider,
|
||||
identity: providerId,
|
||||
version: 0
|
||||
});
|
||||
} else {
|
||||
// Neuen Benutzer erstellen
|
||||
user = await User.create({
|
||||
full_name: displayName,
|
||||
role: 0,
|
||||
daily_hours: 8,
|
||||
week_hours: 40,
|
||||
week_workdays: 5,
|
||||
preferred_title_type: 0,
|
||||
version: 0,
|
||||
last_change: new Date()
|
||||
});
|
||||
|
||||
// Auth-Info erstellen (ohne Passwort für OAuth-only Accounts)
|
||||
authInfo = await AuthInfo.create({
|
||||
user_id: user.id,
|
||||
email: email || `${provider}_${providerId}@oauth.local`,
|
||||
password_hash: '', // Kein Passwort für OAuth-only
|
||||
password_method: 'oauth',
|
||||
password_salt: '',
|
||||
status: 1,
|
||||
failed_login_attempts: 0,
|
||||
email_token: '',
|
||||
email_token_role: 0,
|
||||
unverified_email: '',
|
||||
version: 0
|
||||
});
|
||||
|
||||
// OAuth-Identity erstellen
|
||||
authIdentity = await AuthIdentity.create({
|
||||
auth_info_id: authInfo.id,
|
||||
provider,
|
||||
identity: providerId,
|
||||
version: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// JWT Token generieren
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
email: authInfo.email,
|
||||
role: user.role,
|
||||
provider
|
||||
},
|
||||
this.jwtSecret,
|
||||
{ expiresIn: this.jwtExpiration }
|
||||
);
|
||||
|
||||
// Token in Datenbank speichern
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24);
|
||||
|
||||
await AuthToken.create({
|
||||
auth_info_id: authInfo.id,
|
||||
value: crypto.createHash('sha256').update(token).digest('hex'),
|
||||
expires: expiresAt,
|
||||
version: 0
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
full_name: user.full_name,
|
||||
email: authInfo.email,
|
||||
role: user.role,
|
||||
provider
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth-Identity für Benutzer abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Array>} Liste verknüpfter OAuth-Provider
|
||||
*/
|
||||
async getUserIdentities(userId) {
|
||||
const { AuthInfo, AuthIdentity } = database.getModels();
|
||||
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
where: { user_id: userId },
|
||||
include: [{
|
||||
model: AuthIdentity,
|
||||
as: 'identities'
|
||||
}]
|
||||
});
|
||||
|
||||
if (!authInfo || !authInfo.identities) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return authInfo.identities.map(identity => ({
|
||||
provider: identity.provider,
|
||||
identity: identity.identity,
|
||||
id: identity.id
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth-Identity entfernen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {string} provider - Provider-Name
|
||||
* @returns {Promise<boolean>} Erfolg
|
||||
*/
|
||||
async unlinkProvider(userId, provider) {
|
||||
const { AuthInfo, AuthIdentity } = database.getModels();
|
||||
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
where: { user_id: userId }
|
||||
});
|
||||
|
||||
if (!authInfo) {
|
||||
throw new Error('Benutzer nicht gefunden');
|
||||
}
|
||||
|
||||
const deleted = await AuthIdentity.destroy({
|
||||
where: {
|
||||
auth_info_id: authInfo.id,
|
||||
provider
|
||||
}
|
||||
});
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new OAuthService();
|
||||
|
||||
|
||||
|
||||
2380
backend/src/services/TimeEntryService.js
Normal file
2380
backend/src/services/TimeEntryService.js
Normal file
File diff suppressed because it is too large
Load Diff
143
backend/src/utils/hashId.js
Normal file
143
backend/src/utils/hashId.js
Normal file
@@ -0,0 +1,143 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Utility für ID-Hashing
|
||||
* Konvertiert numerische IDs in Hashes und zurück
|
||||
*/
|
||||
class HashId {
|
||||
constructor() {
|
||||
// Secret aus Umgebungsvariable oder Fallback
|
||||
this.secret = process.env.HASH_ID_SECRET || 'timeclock-hash-secret-change-in-production';
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert eine numerische ID in einen Hash
|
||||
* @param {number} id - Numerische ID
|
||||
* @returns {string} Hash-String
|
||||
*/
|
||||
encode(id) {
|
||||
if (!id) return null;
|
||||
|
||||
// Erstelle einen deterministischen Hash aus ID + Secret
|
||||
const hmac = crypto.createHmac('sha256', this.secret);
|
||||
hmac.update(id.toString());
|
||||
const hash = hmac.digest('base64url'); // base64url ist URL-sicher
|
||||
|
||||
// Füge die ID in verschlüsselter Form hinzu für Dekodierung
|
||||
const encrypted = this.encryptId(id);
|
||||
|
||||
// Format: {verschlüsselte-id}.{hash-prefix}
|
||||
return `${encrypted}.${hash.substring(0, 12)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert einen Hash zurück in eine numerische ID
|
||||
* @param {string} hash - Hash-String
|
||||
* @returns {number|null} Numerische ID oder null bei Fehler
|
||||
*/
|
||||
decode(hash) {
|
||||
if (!hash || typeof hash !== 'string') return null;
|
||||
|
||||
try {
|
||||
// Extrahiere verschlüsselte ID
|
||||
const parts = hash.split('.');
|
||||
if (parts.length !== 2) return null;
|
||||
|
||||
const encrypted = parts[0];
|
||||
const hashPart = parts[1];
|
||||
|
||||
// Entschlüssele ID
|
||||
const id = this.decryptId(encrypted);
|
||||
if (!id) return null;
|
||||
|
||||
// Verifiziere Hash
|
||||
const expectedHash = this.encode(id);
|
||||
if (!expectedHash || !expectedHash.endsWith(hashPart)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Dekodieren der Hash-ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verschlüsselt eine ID
|
||||
* @private
|
||||
*/
|
||||
encryptId(id) {
|
||||
const cipher = crypto.createCipheriv(
|
||||
'aes-256-cbc',
|
||||
crypto.scryptSync(this.secret, 'salt', 32),
|
||||
Buffer.alloc(16, 0) // IV
|
||||
);
|
||||
|
||||
let encrypted = cipher.update(id.toString(), 'utf8', 'base64url');
|
||||
encrypted += cipher.final('base64url');
|
||||
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entschlüsselt eine ID
|
||||
* @private
|
||||
*/
|
||||
decryptId(encrypted) {
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-256-cbc',
|
||||
crypto.scryptSync(this.secret, 'salt', 32),
|
||||
Buffer.alloc(16, 0) // IV
|
||||
);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'base64url', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return parseInt(decrypted, 10);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert ein Objekt mit IDs in eines mit Hashes
|
||||
* @param {Object} obj - Objekt mit ID-Feldern
|
||||
* @param {Array<string>} idFields - Array von Feldnamen, die IDs enthalten
|
||||
* @returns {Object} Objekt mit gehashten IDs
|
||||
*/
|
||||
encodeObject(obj, idFields = ['id', 'user_id', 'auth_info_id']) {
|
||||
if (!obj) return obj;
|
||||
|
||||
const result = { ...obj };
|
||||
|
||||
for (const field of idFields) {
|
||||
if (result[field] !== null && result[field] !== undefined) {
|
||||
result[field] = this.encode(result[field]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert ein Array von Objekten
|
||||
* @param {Array} array - Array von Objekten
|
||||
* @param {Array<string>} idFields - Array von Feldnamen, die IDs enthalten
|
||||
* @returns {Array} Array mit gehashten IDs
|
||||
*/
|
||||
encodeArray(array, idFields = ['id', 'user_id', 'auth_info_id']) {
|
||||
if (!Array.isArray(array)) return array;
|
||||
|
||||
return array.map(obj => this.encodeObject(obj, idFields));
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton-Instanz
|
||||
const hashId = new HashId();
|
||||
|
||||
module.exports = hashId;
|
||||
|
||||
|
||||
|
||||
15
backend/update-overtime-offset.sql
Normal file
15
backend/update-overtime-offset.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- Update overtime_offset_minutes für User 1
|
||||
-- Aktuell: 5573 Minuten (92:53)
|
||||
-- Neu: 5620 Minuten (93:40)
|
||||
-- Differenz: +47 Minuten
|
||||
-- Resultat: Überstunden ändern sich von 2:47 auf 3:34
|
||||
|
||||
UPDATE user
|
||||
SET overtime_offset_minutes = 5620
|
||||
WHERE id = 1;
|
||||
|
||||
SELECT id, full_name, overtime_offset_minutes,
|
||||
CONCAT(FLOOR(overtime_offset_minutes / 60), ':', LPAD(overtime_offset_minutes % 60, 2, '0')) AS overtime_formatted
|
||||
FROM user
|
||||
WHERE id = 1;
|
||||
|
||||
8
frontend/.gitignore
vendored
Normal file
8
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
.vite
|
||||
|
||||
|
||||
|
||||
107
frontend/README.md
Normal file
107
frontend/README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# TimeClock Frontend v3.0
|
||||
|
||||
Vue 3 Frontend für die TimeClock Zeiterfassungsanwendung.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Öffnen Sie `http://localhost:5010` in Ihrem Browser.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Die Build-Dateien werden im `dist/` Ordner erstellt.
|
||||
|
||||
## Preview (nach Build)
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Technologien
|
||||
|
||||
- **Vue 3** - Progressive JavaScript Framework
|
||||
- **Vue Router** - Offizielle Router-Bibliothek
|
||||
- **Pinia** - State Management
|
||||
- **Vite** - Next Generation Frontend Tooling
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
src/
|
||||
├── assets/ # CSS und statische Assets
|
||||
├── components/ # Wiederverwendbare Komponenten
|
||||
├── router/ # Vue Router Konfiguration
|
||||
├── stores/ # Pinia Stores
|
||||
├── views/ # Seiten-Komponenten
|
||||
│ ├── Dashboard.vue
|
||||
│ ├── Entries.vue
|
||||
│ └── Stats.vue
|
||||
├── App.vue # Root-Komponente
|
||||
└── main.js # Entry Point
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Dashboard
|
||||
- Timer starten/stoppen
|
||||
- Echtzeit-Anzeige der laufenden Zeit
|
||||
- Projekt und Beschreibung eingeben
|
||||
- Letzte 5 Einträge anzeigen
|
||||
|
||||
### Einträge
|
||||
- Tabellarische Übersicht aller Einträge
|
||||
- Status-Anzeige (laufend/beendet)
|
||||
- Einträge löschen
|
||||
|
||||
### Statistiken
|
||||
- Gesamtstatistiken
|
||||
- Projekt-basierte Auswertungen
|
||||
- Visualisierung der Arbeitszeit
|
||||
|
||||
## State Management
|
||||
|
||||
Die Anwendung verwendet Pinia für zentrales State Management:
|
||||
|
||||
```javascript
|
||||
// stores/timeStore.js
|
||||
const timeStore = useTimeStore()
|
||||
|
||||
// Verfügbare Actions
|
||||
timeStore.fetchEntries()
|
||||
timeStore.startTimer({ project, description })
|
||||
timeStore.stopTimer()
|
||||
timeStore.deleteEntry(id)
|
||||
timeStore.fetchStats()
|
||||
```
|
||||
|
||||
## API-Konfiguration
|
||||
|
||||
Die API-URL ist in `src/stores/timeStore.js` konfiguriert:
|
||||
|
||||
```javascript
|
||||
const API_URL = 'http://localhost:3010/api'
|
||||
```
|
||||
|
||||
Für Produktion sollte dies in eine Environment-Variable verschoben werden.
|
||||
|
||||
## Styling
|
||||
|
||||
Die Anwendung verwendet:
|
||||
- Custom CSS mit CSS Variables
|
||||
- Moderne Gradients
|
||||
- Responsive Design
|
||||
- Card-basiertes Layout
|
||||
|
||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>TimeClock v3 - Zeiterfassung</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
|
||||
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "timeclock-frontend",
|
||||
"version": "3.0.0",
|
||||
"description": "TimeClock v3 - Frontend für Zeiterfassung",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.3.11",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
255
frontend/src/App.vue
Normal file
255
frontend/src/App.vue
Normal file
@@ -0,0 +1,255 @@
|
||||
<template>
|
||||
<div id="app">
|
||||
<div class="navbar" v-if="authStore.isAuthenticated">
|
||||
<div class="navbar-inner">
|
||||
<div class="container">
|
||||
<h1 class="brand">
|
||||
<RouterLink to="/">Stechuhr</RouterLink>
|
||||
</h1>
|
||||
<div class="nav-collapse">
|
||||
<SideMenu />
|
||||
<ul class="pull-right navbar-nav nav">
|
||||
<li class="user-info">
|
||||
<span class="user-name">{{ authStore.user?.full_name }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<button @click="handleLogout" class="btn-logout">Abmelden</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status-Bar unterhalb der Titelzeile -->
|
||||
<div v-if="authStore.isAuthenticated" class="status-bar">
|
||||
<div class="container status-bar-container">
|
||||
<StatusBox />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<main class="app-main" :class="{ 'no-auth': !authStore.isAuthenticated }">
|
||||
<div class="container" :class="{ 'full-width': !authStore.isAuthenticated }">
|
||||
<RouterView />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="app-footer" v-if="authStore.isAuthenticated">
|
||||
<div class="container">
|
||||
<p>© 2025 TimeClock v3 - Zeiterfassungssystem</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import { useAuthStore } from './stores/authStore'
|
||||
import { useRouter } from 'vue-router'
|
||||
import StatusBox from './components/StatusBox.vue'
|
||||
import SideMenu from './components/SideMenu.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
const handleLogout = async () => {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
background-image: none;
|
||||
background-color: #f0ffec;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid #e0ffe0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 3rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
padding: 12px 20px 12px 0;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.nav-collapse {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.navbar-nav {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.navbar-nav li {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #555;
|
||||
text-decoration: none;
|
||||
font-weight: normal;
|
||||
padding: 10px 15px;
|
||||
display: block;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.nav-link.router-link-active {
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pull-left {
|
||||
float: left;
|
||||
}
|
||||
|
||||
.pull-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
color: #555;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
position: fixed;
|
||||
top: 60px; /* Höhe der Navbar */
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.status-bar-container {
|
||||
justify-content: flex-end;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
min-height: calc(100vh - 100px);
|
||||
padding: 2rem 0;
|
||||
margin-top: 20px; /* Navbar + StatusBox Höhe */
|
||||
margin-bottom: 80px; /* Footer Höhe */
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.app-main.no-auth {
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.container.full-width {
|
||||
max-width: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
background: linear-gradient(135deg, #28a745, #20c997);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin: 5px 10px;
|
||||
box-shadow: 0 2px 4px rgba(40, 167, 69, 0.2);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-logout::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background: linear-gradient(135deg, #20c997, #17a2b8);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(40, 167, 69, 0.3);
|
||||
}
|
||||
|
||||
.btn-logout:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.btn-logout:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: 0 2px 4px rgba(40, 167, 69, 0.2);
|
||||
}
|
||||
|
||||
.app-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: #f9f9f9;
|
||||
padding: 1.5rem 0;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
border-top: 1px solid #ddd;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.app-footer p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
158
frontend/src/assets/main.css
Normal file
158
frontend/src/assets/main.css
Normal file
@@ -0,0 +1,158 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0 3rem;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.card {
|
||||
background: #fafafa;
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: #ccc;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-weight: normal;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
background-color: #f5f5f5;
|
||||
color: #333;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background-color: #e5e5e5;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #5bc0de;
|
||||
border-color: #46b8da;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #31b0d5;
|
||||
border-color: #269abc;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #5cb85c;
|
||||
border-color: #4cae4c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #449d44;
|
||||
border-color: #398439;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #d9534f;
|
||||
border-color: #d43f3a;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c9302c;
|
||||
border-color: #ac2925;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
border-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #5bc0de;
|
||||
box-shadow: 0 0 0 3px rgba(91, 192, 222, 0.1);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mt-1 { margin-top: 0.5rem; }
|
||||
.mt-2 { margin-top: 1rem; }
|
||||
.mt-3 { margin-top: 1.5rem; }
|
||||
.mt-4 { margin-top: 2rem; }
|
||||
|
||||
.mb-1 { margin-bottom: 0.5rem; }
|
||||
.mb-2 { margin-bottom: 1rem; }
|
||||
.mb-3 { margin-bottom: 1.5rem; }
|
||||
.mb-4 { margin-bottom: 2rem; }
|
||||
|
||||
190
frontend/src/components/SideMenu.vue
Normal file
190
frontend/src/components/SideMenu.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<nav class="main-menu">
|
||||
<div
|
||||
v-for="section in visibleSections"
|
||||
:key="section.title"
|
||||
class="menu-section"
|
||||
>
|
||||
<!-- Direkter Link (z.B. Export) -->
|
||||
<RouterLink
|
||||
v-if="!section.hasDropdown"
|
||||
:to="section.to"
|
||||
class="section-title section-link"
|
||||
>
|
||||
{{ section.title }}
|
||||
</RouterLink>
|
||||
|
||||
<!-- Dropdown-Menü -->
|
||||
<template v-else>
|
||||
<button class="section-title" @click="toggleSection(section.title)">
|
||||
{{ section.title }}
|
||||
<span class="chev">{{ isSectionOpen(section.title) ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div v-show="isSectionOpen(section.title)" class="dropdown">
|
||||
<RouterLink
|
||||
v-for="item in section.items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
class="dropdown-item"
|
||||
@click="openSection = null"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
// Rolle: 'user' | 'admin' (Fallback: 'user')
|
||||
const role = computed(() => (auth.user?.role || 'user').toString().toLowerCase())
|
||||
|
||||
const SECTIONS_USER = [
|
||||
{
|
||||
title: 'Buchungen',
|
||||
hasDropdown: true,
|
||||
items: [
|
||||
{ label: 'Wochenübersicht', to: '/bookings/week' },
|
||||
{ label: 'Zeitkorrekturen', to: '/bookings/timefix' },
|
||||
{ label: 'Urlaub', to: '/bookings/vacation' },
|
||||
{ label: 'Krankheit', to: '/bookings/sick' },
|
||||
{ label: 'Arbeitstage', to: '/bookings/workdays' },
|
||||
{ label: 'Kalender', to: '/calendar' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Andere Nutzer',
|
||||
hasDropdown: true,
|
||||
items: [
|
||||
{ label: 'Liste mit Nutzernamen', to: '/users' },
|
||||
{ label: 'Berechtigungen verteilen', to: '/users/permissions' }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Export',
|
||||
hasDropdown: false,
|
||||
to: '/export'
|
||||
},
|
||||
{
|
||||
title: 'Einstellungen',
|
||||
hasDropdown: true,
|
||||
items: [
|
||||
{ label: 'Persönliches', to: '/settings/profile' },
|
||||
{ label: 'Paßwort ändern', to: '/settings/password' },
|
||||
{ label: 'Zeitwünsche', to: '/settings/timewish' },
|
||||
{ label: 'Einladen', to: '/settings/invite' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const SECTIONS_ADMIN_EXTRA = [
|
||||
{
|
||||
title: 'Verwaltung',
|
||||
hasDropdown: true,
|
||||
items: [
|
||||
{ label: 'Feiertage', to: '/admin/holidays' },
|
||||
{ label: 'Rechte', to: '/admin/roles' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const visibleSections = computed(() => {
|
||||
return role.value === 'admin'
|
||||
? [...SECTIONS_USER, ...SECTIONS_ADMIN_EXTRA]
|
||||
: SECTIONS_USER
|
||||
})
|
||||
|
||||
// Auf-/Zuklappen je Sektion (nur eins gleichzeitig offen)
|
||||
const openSection = ref(null)
|
||||
const isSectionOpen = (title) => openSection.value === title
|
||||
const toggleSection = (title) => {
|
||||
// Wenn die Sektion bereits offen ist, schließen; sonst öffnen und andere schließen
|
||||
openSection.value = openSection.value === title ? null : title
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.main-menu {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.menu-section {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #555;
|
||||
padding: 10px 15px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.section-title:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.section-link {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.section-link.router-link-active {
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chev {
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
background: #fff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
min-width: 200px;
|
||||
z-index: 1000;
|
||||
padding: 8px 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: block;
|
||||
color: #333;
|
||||
text-decoration: none;
|
||||
padding: 8px 16px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.dropdown-item.router-link-active {
|
||||
background-color: #e8f5e9;
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
522
frontend/src/components/StatusBox.vue
Normal file
522
frontend/src/components/StatusBox.vue
Normal file
@@ -0,0 +1,522 @@
|
||||
<template>
|
||||
<div class="status-box" v-if="isReady">
|
||||
<div class="status-actions">
|
||||
<!-- Linker Button -->
|
||||
<button
|
||||
v-if="leftButton"
|
||||
class="btn btn-small"
|
||||
:class="leftButton.class"
|
||||
@click="handleAction(leftButton.action)"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ leftButton.label }}
|
||||
</button>
|
||||
|
||||
<!-- Rechter Button -->
|
||||
<button
|
||||
v-if="rightButton"
|
||||
class="btn btn-small btn-secondary"
|
||||
@click="handleAction(rightButton.action)"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ rightButton.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status-grid">
|
||||
<div class="row" v-for="(value, key) in displayRows" :key="key" :class="{ 'heading-row': value === null }">
|
||||
<span class="label">{{ key }}{{ value === null ? '' : ':' }}</span>
|
||||
<span class="value" v-if="value !== null">{{ value || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-box loading" v-else>
|
||||
Lädt…
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onBeforeUnmount, ref, computed } from 'vue'
|
||||
import { useTimeStore } from '../stores/timeStore'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const timeStore = useTimeStore()
|
||||
const authStore = useAuthStore()
|
||||
const stats = ref({})
|
||||
const currentState = ref(null) // 'null', 'start work', 'start pause', 'stop pause'
|
||||
const isReady = ref(false)
|
||||
const loading = ref(false)
|
||||
const workStartTime = ref(null) // Timestamp wann die Arbeit begonnen hat
|
||||
const lastPauseStartTime = ref(null) // Timestamp der aktuellen Pause
|
||||
const pauseDurations = ref([]) // Array von Pausen-Dauern in Millisekunden
|
||||
const currentlyWorkedTime = ref('—') // Berechnete Arbeitszeit
|
||||
const openTime = ref('—') // Berechnete offene Zeit
|
||||
const regularEndTime = ref('—') // Berechnetes normales Arbeitsende
|
||||
const serverWorkedTime = ref(null) // Vom Server berechnete Zeit
|
||||
const serverOpenTime = ref(null) // Vom Server berechnete offene Zeit
|
||||
const serverTimestamp = ref(null) // Wann die Server-Daten berechnet wurden
|
||||
const missingBreakMinutes = ref(0) // Fehlende Pausenminuten
|
||||
let dataFetchInterval = null // Daten vom Server laden
|
||||
let displayUpdateInterval = null // Anzeige aktualisieren
|
||||
|
||||
const fetchStats = async () => {
|
||||
const data = await timeStore.fetchStats()
|
||||
stats.value = data || {}
|
||||
|
||||
// Hole vom Server berechnete Arbeitszeit, offene Zeit und Timestamp
|
||||
if (data?.currentlyWorked) {
|
||||
serverWorkedTime.value = data.currentlyWorked
|
||||
} else {
|
||||
serverWorkedTime.value = null
|
||||
}
|
||||
|
||||
if (data?.open) {
|
||||
serverOpenTime.value = data.open
|
||||
} else {
|
||||
serverOpenTime.value = null
|
||||
}
|
||||
|
||||
if (data?.timestamp) {
|
||||
serverTimestamp.value = new Date(data.timestamp).getTime()
|
||||
}
|
||||
|
||||
// Hole fehlende Pausenminuten
|
||||
if (data?.missingBreakMinutes !== undefined) {
|
||||
missingBreakMinutes.value = data.missingBreakMinutes
|
||||
} else {
|
||||
missingBreakMinutes.value = 0
|
||||
}
|
||||
|
||||
isReady.value = true
|
||||
}
|
||||
|
||||
const fetchCurrentState = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3010/api/time-entries/current-state', {
|
||||
headers: authStore.getAuthHeaders()
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
currentState.value = result.state || null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des aktuellen Zustands:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Lade die aktuellen Worklog-Daten (nur einmal pro Minute)
|
||||
const fetchWorklogData = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3010/api/time-entries/running', {
|
||||
headers: authStore.getAuthHeaders()
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json()
|
||||
|
||||
console.log('DEBUG fetchWorklogData: result =', result)
|
||||
console.log('DEBUG fetchWorklogData: currentState =', currentState.value)
|
||||
|
||||
// Das Backend gibt direkt das Entry-Objekt zurück, nicht { entry: ... }
|
||||
if (result && result.startTime && (currentState.value === 'start work' || currentState.value === 'stop pause')) {
|
||||
// Arbeit läuft
|
||||
workStartTime.value = new Date(result.startTime).getTime()
|
||||
pauseDurations.value = result.pauses || []
|
||||
lastPauseStartTime.value = null
|
||||
console.log('DEBUG: Arbeit läuft, startTime:', result.startTime, 'pauses:', pauseDurations.value.length)
|
||||
} else if (result && result.startTime && currentState.value === 'start pause') {
|
||||
// In Pause
|
||||
workStartTime.value = new Date(result.startTime).getTime()
|
||||
pauseDurations.value = result.pauses || []
|
||||
// Hole letzten Pause-Start
|
||||
if (result.currentPauseStart) {
|
||||
lastPauseStartTime.value = new Date(result.currentPauseStart).getTime()
|
||||
}
|
||||
console.log('DEBUG: In Pause, startTime:', result.startTime, 'currentPauseStart:', result.currentPauseStart)
|
||||
} else {
|
||||
// Nicht am Arbeiten
|
||||
workStartTime.value = null
|
||||
lastPauseStartTime.value = null
|
||||
pauseDurations.value = []
|
||||
console.log('DEBUG: Nicht am Arbeiten')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Worklog-Daten:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Berechne die aktuell gearbeitete Zeit (2x pro Sekunde)
|
||||
const updateCurrentlyWorkedTime = () => {
|
||||
// Wenn nicht am Arbeiten
|
||||
if (currentState.value === null || currentState.value === 'stop work') {
|
||||
currentlyWorkedTime.value = '—'
|
||||
return
|
||||
}
|
||||
|
||||
// Wenn wir einen Server-Wert haben, nutze diesen als Basis
|
||||
if (serverWorkedTime.value && serverTimestamp.value) {
|
||||
// Parse Server-Zeit (HH:MM:SS)
|
||||
const parts = serverWorkedTime.value.split(':')
|
||||
const serverSeconds = parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2])
|
||||
|
||||
// Berechne vergangene Zeit seit Server-Timestamp
|
||||
const now = Date.now()
|
||||
const elapsedMs = now - serverTimestamp.value
|
||||
let elapsedSeconds = Math.floor(elapsedMs / 1000)
|
||||
|
||||
// Wenn in Pause, zähle die Zeit nicht hoch
|
||||
if (currentState.value === 'start pause') {
|
||||
elapsedSeconds = 0
|
||||
}
|
||||
|
||||
// Addiere vergangene Zeit
|
||||
const totalSeconds = serverSeconds + elapsedSeconds
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
currentlyWorkedTime.value = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
} else {
|
||||
// Fallback: Wenn kein Server-Wert, aber workStartTime vorhanden
|
||||
if (!workStartTime.value) {
|
||||
currentlyWorkedTime.value = '—'
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
let totalWorkedMs = now - workStartTime.value
|
||||
|
||||
// Ziehe abgeschlossene Pausen ab
|
||||
const totalPauseMs = pauseDurations.value.reduce((sum, duration) => sum + duration, 0)
|
||||
totalWorkedMs -= totalPauseMs
|
||||
|
||||
// Wenn aktuell in Pause, ziehe die laufende Pause ab
|
||||
if (currentState.value === 'start pause' && lastPauseStartTime.value) {
|
||||
const currentPauseMs = now - lastPauseStartTime.value
|
||||
totalWorkedMs -= currentPauseMs
|
||||
}
|
||||
|
||||
// Formatiere als HH:MM:SS
|
||||
const totalSeconds = Math.floor(totalWorkedMs / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
currentlyWorkedTime.value = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
|
||||
// Berechne die offene Zeit (2x pro Sekunde)
|
||||
const updateOpenTime = () => {
|
||||
// Wenn nicht am Arbeiten
|
||||
if (currentState.value === null || currentState.value === 'stop work') {
|
||||
openTime.value = '—'
|
||||
regularEndTime.value = '—'
|
||||
return
|
||||
}
|
||||
|
||||
// Wenn wir einen Server-Wert haben, nutze diesen als Basis
|
||||
if (serverOpenTime.value && serverTimestamp.value) {
|
||||
// Parse Server-Zeit (HH:MM:SS)
|
||||
const parts = serverOpenTime.value.split(':')
|
||||
const serverSeconds = parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2])
|
||||
|
||||
// Berechne vergangene Zeit seit Server-Timestamp
|
||||
const now = Date.now()
|
||||
const elapsedMs = now - serverTimestamp.value
|
||||
let elapsedSeconds = Math.floor(elapsedMs / 1000)
|
||||
|
||||
// Wenn in Pause, zähle die Zeit nicht runter (bleibt gleich)
|
||||
if (currentState.value === 'start pause') {
|
||||
elapsedSeconds = 0
|
||||
}
|
||||
|
||||
// Subtrahiere vergangene Zeit (Offen wird weniger)
|
||||
const remainingSeconds = serverSeconds - elapsedSeconds
|
||||
|
||||
if (remainingSeconds > 0) {
|
||||
const hours = Math.floor(remainingSeconds / 3600)
|
||||
const minutes = Math.floor((remainingSeconds % 3600) / 60)
|
||||
const seconds = remainingSeconds % 60
|
||||
|
||||
openTime.value = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
|
||||
|
||||
// Berechne "Normales Arbeitsende" = Jetzt + Offen + fehlende Pausen
|
||||
const totalRemainingSeconds = remainingSeconds + (missingBreakMinutes.value * 60)
|
||||
const endTimestamp = now + (totalRemainingSeconds * 1000)
|
||||
const endDate = new Date(endTimestamp)
|
||||
const endHours = endDate.getHours()
|
||||
const endMinutes = endDate.getMinutes()
|
||||
const endSeconds = endDate.getSeconds()
|
||||
|
||||
// Zeige auch fehlende Pausen an (falls vorhanden)
|
||||
if (missingBreakMinutes.value > 0) {
|
||||
regularEndTime.value = `${endHours.toString().padStart(2, '0')}:${endMinutes.toString().padStart(2, '0')}:${endSeconds.toString().padStart(2, '0')} (+${missingBreakMinutes.value}min Pause)`
|
||||
} else {
|
||||
regularEndTime.value = `${endHours.toString().padStart(2, '0')}:${endMinutes.toString().padStart(2, '0')}:${endSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
} else {
|
||||
openTime.value = 'Arbeitsende erreicht'
|
||||
|
||||
// Auch wenn Arbeitsende erreicht ist, können noch Pausen fehlen
|
||||
if (missingBreakMinutes.value > 0) {
|
||||
regularEndTime.value = `Erreicht (+${missingBreakMinutes.value}min Pause)`
|
||||
} else {
|
||||
regularEndTime.value = 'Erreicht'
|
||||
}
|
||||
}
|
||||
} else {
|
||||
openTime.value = '—'
|
||||
regularEndTime.value = '—'
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = async (action) => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const response = await fetch('http://localhost:3010/api/time-entries/clock', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...authStore.getAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ action })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
alert(error.error || 'Fehler beim Stempeln')
|
||||
return
|
||||
}
|
||||
|
||||
// Aktualisiere Status und Worklog-Daten sofort
|
||||
await fetchCurrentState()
|
||||
await fetchWorklogData()
|
||||
await fetchStats()
|
||||
|
||||
// Event auslösen für andere Komponenten (z.B. WeekOverview)
|
||||
window.dispatchEvent(new CustomEvent('worklog-updated'))
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Stempeln:', error)
|
||||
alert('Fehler beim Stempeln')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Button-Konfiguration basierend auf dem aktuellen Zustand
|
||||
const leftButton = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case null:
|
||||
case 'stop work':
|
||||
return { label: 'Arbeit beginnen', action: 'start work', class: 'btn-success' }
|
||||
case 'start work':
|
||||
case 'stop pause':
|
||||
return { label: 'Arbeit beenden', action: 'stop work', class: 'btn-danger' }
|
||||
case 'start pause':
|
||||
return null // Nicht sichtbar
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const rightButton = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case null:
|
||||
case 'stop work':
|
||||
return null // Nicht sichtbar
|
||||
case 'start work':
|
||||
case 'stop pause':
|
||||
return { label: 'Pause beginnen', action: 'start pause' }
|
||||
case 'start pause':
|
||||
return { label: 'Pause beenden', action: 'stop pause' }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Initiales Laden
|
||||
await fetchCurrentState()
|
||||
await fetchWorklogData()
|
||||
await fetchStats()
|
||||
|
||||
// Server-Daten alle 60 Sekunden neu laden
|
||||
dataFetchInterval = setInterval(async () => {
|
||||
await fetchCurrentState()
|
||||
await fetchWorklogData()
|
||||
await fetchStats()
|
||||
}, 60000)
|
||||
|
||||
// Anzeige 2x pro Sekunde aktualisieren (nur Berechnung, keine Server-Requests)
|
||||
displayUpdateInterval = setInterval(() => {
|
||||
updateCurrentlyWorkedTime()
|
||||
updateOpenTime()
|
||||
}, 500)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (dataFetchInterval) clearInterval(dataFetchInterval)
|
||||
if (displayUpdateInterval) clearInterval(displayUpdateInterval)
|
||||
})
|
||||
|
||||
const displayRows = computed(() => {
|
||||
const rows = {
|
||||
'Derzeit gearbeitet': currentlyWorkedTime.value, // Verwende berechneten Wert
|
||||
'Offen': openTime.value, // Verwende berechneten Wert
|
||||
'Normales Arbeitsende': regularEndTime.value // Verwende berechneten Wert
|
||||
}
|
||||
|
||||
// Füge andere Stats hinzu
|
||||
const map = [
|
||||
['Überstunden (Woche)', 'overtime'],
|
||||
['Überstunden (Gesamt)', 'totalOvertime'],
|
||||
// ['Überstunden (Alt-Style)', 'totalOvertimeOldStyle'], // DEBUG: Versteckt, da getWeekOverview nicht korrekte Zeiten liefert
|
||||
['Wochenarbeitszeit', 'weekWorktime'],
|
||||
['Arbeitsfreie Stunden', 'nonWorkingHours'],
|
||||
['Offen für Woche', 'openForWeek'],
|
||||
['Bereinigtes Arbeitsende (heute)', null], // Nur Überschrift, kein Wert
|
||||
['- Generell', 'adjustedEndTodayGeneral'],
|
||||
['- Woche', 'adjustedEndTodayWeek']
|
||||
]
|
||||
|
||||
for (const [label, key] of map) {
|
||||
// Spezialbehandlung für Überschrift ohne Wert
|
||||
if (key === null) {
|
||||
rows[label] = null // Überschrift ohne Wert (wird im Template speziell behandelt)
|
||||
continue
|
||||
}
|
||||
|
||||
const val = stats.value?.[key]
|
||||
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
rows[label] = val
|
||||
} else {
|
||||
rows[label] = '—'
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-box {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 10px;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.06);
|
||||
position: fixed;
|
||||
top: 58px;
|
||||
right: 24px;
|
||||
max-width: 560px;
|
||||
min-width: 280px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
box-sizing: border-box;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.status-box.loading {
|
||||
min-width: 120px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 1px solid #d0d0d0;
|
||||
background: #f8f8f8;
|
||||
color: #333;
|
||||
border-radius: 4px;
|
||||
padding: 6px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #d4edda;
|
||||
border-color: #c3e6cb;
|
||||
color: #155724;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background: #c3e6cb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #f8d7da;
|
||||
border-color: #f5c6cb;
|
||||
color: #721c24;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #f5c6cb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e7f1ff;
|
||||
border-color: #b8daff;
|
||||
color: #004085;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #b8daff;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
font-size: 12px;
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: baseline;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.row.heading-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.row.heading-row .label {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
25
frontend/src/main.js
Normal file
25
frontend/src/main.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
// Session beim App-Start wiederherstellen
|
||||
import { useAuthStore } from './stores/authStore'
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Versuche Token aus localStorage zu laden
|
||||
if (authStore.loadToken()) {
|
||||
authStore.fetchCurrentUser().catch(err => {
|
||||
console.error('Session-Wiederherstellung fehlgeschlagen:', err)
|
||||
})
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
104
frontend/src/router/index.js
Normal file
104
frontend/src/router/index.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
// Views
|
||||
import Entries from '../views/Entries.vue'
|
||||
import Stats from '../views/Stats.vue'
|
||||
import Login from '../views/Login.vue'
|
||||
import Register from '../views/Register.vue'
|
||||
import PasswordForgot from '../views/PasswordForgot.vue'
|
||||
import PasswordReset from '../views/PasswordReset.vue'
|
||||
import OAuthCallback from '../views/OAuthCallback.vue'
|
||||
import WeekOverview from '../views/WeekOverview.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
// Auth-Routes (öffentlich)
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: Login,
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: Register,
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/password-forgot',
|
||||
name: 'password-forgot',
|
||||
component: PasswordForgot,
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/password-reset',
|
||||
name: 'password-reset',
|
||||
component: PasswordReset,
|
||||
meta: { requiresGuest: true }
|
||||
},
|
||||
{
|
||||
path: '/oauth-callback',
|
||||
name: 'oauth-callback',
|
||||
component: OAuthCallback
|
||||
},
|
||||
|
||||
// Geschützte Routes
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/bookings/week'
|
||||
},
|
||||
{
|
||||
path: '/bookings/week',
|
||||
name: 'week-overview',
|
||||
component: WeekOverview,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/entries',
|
||||
name: 'entries',
|
||||
component: Entries,
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/stats',
|
||||
name: 'stats',
|
||||
component: Stats,
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Navigation Guards
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Session-Wiederherstellung beim ersten Laden
|
||||
if (!authStore.isAuthenticated && authStore.loadToken()) {
|
||||
try {
|
||||
await authStore.fetchCurrentUser()
|
||||
} catch (error) {
|
||||
console.error('Session-Wiederherstellung fehlgeschlagen:', error)
|
||||
authStore.clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
|
||||
const requiresGuest = to.matched.some(record => record.meta.requiresGuest)
|
||||
|
||||
if (requiresAuth && !authStore.isAuthenticated) {
|
||||
// Geschützte Route aber nicht eingeloggt -> Login
|
||||
next({ name: 'login', query: { redirect: to.fullPath } })
|
||||
} else if (requiresGuest && authStore.isAuthenticated) {
|
||||
// Guest-Route aber bereits eingeloggt -> Wochenübersicht
|
||||
next({ name: 'week-overview' })
|
||||
} else {
|
||||
// Alles OK
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
338
frontend/src/stores/authStore.js
Normal file
338
frontend/src/stores/authStore.js
Normal file
@@ -0,0 +1,338 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const API_URL = 'http://localhost:3010/api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref(null)
|
||||
const token = ref(null)
|
||||
const isAuthenticated = ref(false)
|
||||
const isLoading = ref(false)
|
||||
|
||||
/**
|
||||
* Token in localStorage speichern
|
||||
*/
|
||||
const saveToken = (newToken) => {
|
||||
token.value = newToken
|
||||
localStorage.setItem('timeclock_token', newToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* Token aus localStorage laden
|
||||
*/
|
||||
const loadToken = () => {
|
||||
const savedToken = localStorage.getItem('timeclock_token')
|
||||
if (savedToken) {
|
||||
token.value = savedToken
|
||||
return savedToken
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Token und Benutzerdaten löschen
|
||||
*/
|
||||
const clearAuth = () => {
|
||||
token.value = null
|
||||
user.value = null
|
||||
isAuthenticated.value = false
|
||||
localStorage.removeItem('timeclock_token')
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrierung
|
||||
*/
|
||||
const register = async (userData) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(userData)
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Registrierung fehlgeschlagen')
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Registrierungsfehler:', error)
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Login
|
||||
*/
|
||||
const login = async (credentials) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(credentials)
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Login fehlgeschlagen')
|
||||
}
|
||||
|
||||
// Token speichern
|
||||
saveToken(data.token)
|
||||
|
||||
// Benutzerdaten setzen
|
||||
user.value = data.user
|
||||
isAuthenticated.value = true
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Login-Fehler:', error)
|
||||
clearAuth()
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
const logout = async () => {
|
||||
try {
|
||||
if (token.value) {
|
||||
await fetch(`${API_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token.value}`
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout-Fehler:', error)
|
||||
} finally {
|
||||
clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuellen Benutzer laden (für Session-Wiederherstellung)
|
||||
*/
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const savedToken = loadToken()
|
||||
|
||||
if (!savedToken) {
|
||||
return false
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${savedToken}`
|
||||
}
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
// Token ungültig - ausloggen
|
||||
clearAuth()
|
||||
return false
|
||||
}
|
||||
|
||||
// Session wiederherstellen
|
||||
user.value = data.user
|
||||
isAuthenticated.value = true
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Benutzers:', error)
|
||||
clearAuth()
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Token validieren
|
||||
*/
|
||||
const validateToken = async () => {
|
||||
try {
|
||||
const savedToken = loadToken()
|
||||
|
||||
if (!savedToken) {
|
||||
return false
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/validate`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${savedToken}`
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
clearAuth()
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Token-Validierungsfehler:', error)
|
||||
clearAuth()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort-Reset anfordern
|
||||
*/
|
||||
const requestPasswordReset = async (email) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/request-reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ email })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Anfrage fehlgeschlagen')
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Passwort-Reset-Anfrage-Fehler:', error)
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort zurücksetzen
|
||||
*/
|
||||
const resetPassword = async (resetToken, password) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
token: resetToken,
|
||||
password
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Passwort-Reset fehlgeschlagen')
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Passwort-Reset-Fehler:', error)
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort ändern (eingeloggter Benutzer)
|
||||
*/
|
||||
const changePassword = async (oldPassword, newPassword) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token.value}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
oldPassword,
|
||||
newPassword
|
||||
})
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Passwort-Änderung fehlgeschlagen')
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('Passwort-Änderungs-Fehler:', error)
|
||||
throw error
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP-Header mit Authorization zurückgeben
|
||||
*/
|
||||
const getAuthHeaders = () => {
|
||||
const savedToken = token.value || loadToken()
|
||||
|
||||
if (savedToken) {
|
||||
return {
|
||||
'Authorization': `Bearer ${savedToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
user,
|
||||
token,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
|
||||
// Actions
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
fetchCurrentUser,
|
||||
validateToken,
|
||||
requestPasswordReset,
|
||||
resetPassword,
|
||||
changePassword,
|
||||
getAuthHeaders,
|
||||
loadToken,
|
||||
saveToken,
|
||||
clearAuth
|
||||
}
|
||||
})
|
||||
|
||||
125
frontend/src/stores/timeStore.js
Normal file
125
frontend/src/stores/timeStore.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from './authStore'
|
||||
|
||||
const API_URL = 'http://localhost:3010/api'
|
||||
|
||||
export const useTimeStore = defineStore('time', () => {
|
||||
const entries = ref([])
|
||||
const currentEntry = ref(null)
|
||||
|
||||
/**
|
||||
* API-Request mit Auth-Header
|
||||
*/
|
||||
const fetchWithAuth = async (url, options = {}) => {
|
||||
const authStore = useAuthStore()
|
||||
const headers = authStore.getAuthHeaders()
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...headers,
|
||||
...options.headers
|
||||
}
|
||||
})
|
||||
|
||||
// Bei 401 Unauthorized -> Logout
|
||||
if (response.status === 401) {
|
||||
authStore.clearAuth()
|
||||
window.location.href = '/login'
|
||||
throw new Error('Session abgelaufen')
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
const fetchEntries = async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth(`${API_URL}/time-entries`)
|
||||
const data = await response.json()
|
||||
entries.value = data
|
||||
|
||||
// Finde laufenden Eintrag
|
||||
currentEntry.value = data.find(entry => entry.isRunning) || null
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Einträge:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const startTimer = async (entryData) => {
|
||||
try {
|
||||
const response = await fetchWithAuth(`${API_URL}/time-entries`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(entryData)
|
||||
})
|
||||
|
||||
const newEntry = await response.json()
|
||||
currentEntry.value = newEntry
|
||||
entries.value.unshift(newEntry)
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Starten des Timers:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const stopTimer = async () => {
|
||||
if (!currentEntry.value) return
|
||||
|
||||
try {
|
||||
const response = await fetchWithAuth(`${API_URL}/time-entries/${currentEntry.value.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
endTime: new Date().toISOString()
|
||||
})
|
||||
})
|
||||
|
||||
const updatedEntry = await response.json()
|
||||
|
||||
// Aktualisiere Eintrag in Liste
|
||||
const index = entries.value.findIndex(e => e.id === updatedEntry.id)
|
||||
if (index !== -1) {
|
||||
entries.value[index] = updatedEntry
|
||||
}
|
||||
|
||||
currentEntry.value = null
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Stoppen des Timers:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteEntry = async (id) => {
|
||||
try {
|
||||
await fetchWithAuth(`${API_URL}/time-entries/${id}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
|
||||
entries.value = entries.value.filter(entry => entry.id !== id)
|
||||
|
||||
if (currentEntry.value && currentEntry.value.id === id) {
|
||||
currentEntry.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Eintrags:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetchWithAuth(`${API_URL}/time-entries/stats/summary`)
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Statistiken:', error)
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
entries,
|
||||
currentEntry,
|
||||
fetchEntries,
|
||||
startTimer,
|
||||
stopTimer,
|
||||
deleteEntry,
|
||||
fetchStats
|
||||
}
|
||||
})
|
||||
|
||||
265
frontend/src/views/Dashboard.vue
Normal file
265
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<h2 class="page-title">Dashboard</h2>
|
||||
|
||||
<!-- Aktiver Timer -->
|
||||
<div class="card timer-card">
|
||||
<div class="timer-header">
|
||||
<h3>Zeiterfassung</h3>
|
||||
<div class="timer-display">{{ formattedTime }}</div>
|
||||
</div>
|
||||
|
||||
<form v-if="!isRunning" @submit.prevent="startTimer" class="timer-form">
|
||||
<div class="form-group">
|
||||
<label for="project">Projekt</label>
|
||||
<input
|
||||
v-model="newEntry.project"
|
||||
type="text"
|
||||
id="project"
|
||||
class="input"
|
||||
placeholder="z.B. Projektname"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Beschreibung</label>
|
||||
<input
|
||||
v-model="newEntry.description"
|
||||
type="text"
|
||||
id="description"
|
||||
class="input"
|
||||
placeholder="Was arbeitest du gerade?"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success btn-large">
|
||||
▶️ Starten
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-else class="running-timer">
|
||||
<div class="running-info">
|
||||
<div class="info-item">
|
||||
<strong>Projekt:</strong> {{ currentEntry.project }}
|
||||
</div>
|
||||
<div class="info-item" v-if="currentEntry.description">
|
||||
<strong>Beschreibung:</strong> {{ currentEntry.description }}
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<strong>Gestartet:</strong> {{ formatDateTime(currentEntry.startTime) }}
|
||||
</div>
|
||||
</div>
|
||||
<button @click="stopTimer" class="btn btn-danger btn-large">
|
||||
⏹️ Stoppen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Letzte Einträge -->
|
||||
<div class="recent-entries">
|
||||
<h3>Letzte Einträge</h3>
|
||||
<div v-if="recentEntries.length === 0" class="empty-state">
|
||||
<p>Noch keine Einträge vorhanden. Starte deinen ersten Timer!</p>
|
||||
</div>
|
||||
<div v-else class="entries-list">
|
||||
<div v-for="entry in recentEntries" :key="entry.id" class="entry-card card">
|
||||
<div class="entry-header">
|
||||
<span class="entry-project">{{ entry.project }}</span>
|
||||
<span class="entry-duration">{{ formatDuration(entry.duration) }}</span>
|
||||
</div>
|
||||
<div class="entry-description">{{ entry.description || 'Keine Beschreibung' }}</div>
|
||||
<div class="entry-time">
|
||||
{{ formatDateTime(entry.startTime) }} - {{ formatDateTime(entry.endTime) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { useTimeStore } from '../stores/timeStore'
|
||||
|
||||
const timeStore = useTimeStore()
|
||||
|
||||
const newEntry = ref({
|
||||
project: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const isRunning = computed(() => timeStore.currentEntry !== null)
|
||||
const currentEntry = computed(() => timeStore.currentEntry)
|
||||
const recentEntries = computed(() => timeStore.entries.slice(0, 5))
|
||||
|
||||
const currentTime = ref(0)
|
||||
let intervalId = null
|
||||
|
||||
const formattedTime = computed(() => {
|
||||
if (!isRunning.value) return '00:00:00'
|
||||
return formatDuration(currentTime.value)
|
||||
})
|
||||
|
||||
const startTimer = async () => {
|
||||
await timeStore.startTimer(newEntry.value)
|
||||
newEntry.value = { project: '', description: '' }
|
||||
}
|
||||
|
||||
const stopTimer = async () => {
|
||||
await timeStore.stopTimer()
|
||||
}
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return '00:00:00'
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const updateCurrentTime = () => {
|
||||
if (isRunning.value && currentEntry.value) {
|
||||
const start = new Date(currentEntry.value.startTime)
|
||||
const now = new Date()
|
||||
currentTime.value = Math.floor((now - start) / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await timeStore.fetchEntries()
|
||||
intervalId = setInterval(updateCurrentTime, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) clearInterval(intervalId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.timer-card {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.timer-header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.timer-header h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.timer-display {
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #000;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.timer-form {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.running-timer {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.running-info {
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #d4d4d4;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.recent-entries h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.entries-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.entry-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.entry-project {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.entry-duration {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
color: #5cb85c;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.entry-description {
|
||||
color: #666;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.entry-time {
|
||||
font-size: 0.85rem;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
|
||||
209
frontend/src/views/Entries.vue
Normal file
209
frontend/src/views/Entries.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div class="entries">
|
||||
<h2 class="page-title">Alle Zeiteinträge</h2>
|
||||
|
||||
<div v-if="timeStore.entries.length === 0" class="empty-state card">
|
||||
<h3>Keine Einträge vorhanden</h3>
|
||||
<p>Starte deinen ersten Timer im Dashboard!</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="entries-container">
|
||||
<div class="entries-header">
|
||||
<p class="entries-count">{{ timeStore.entries.length }} Einträge</p>
|
||||
</div>
|
||||
|
||||
<div class="entries-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Projekt</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Startzeit</th>
|
||||
<th>Endzeit</th>
|
||||
<th>Dauer</th>
|
||||
<th>Status</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in timeStore.entries" :key="entry.id">
|
||||
<td class="project-cell">{{ entry.project }}</td>
|
||||
<td>{{ entry.description || '-' }}</td>
|
||||
<td>{{ formatDateTime(entry.startTime) }}</td>
|
||||
<td>{{ entry.endTime ? formatDateTime(entry.endTime) : '-' }}</td>
|
||||
<td class="duration-cell">{{ formatDuration(entry.duration) }}</td>
|
||||
<td>
|
||||
<span :class="['status-badge', entry.isRunning ? 'status-running' : 'status-completed']">
|
||||
{{ entry.isRunning ? '🔴 Läuft' : '✅ Beendet' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
@click="deleteEntry(entry.id)"
|
||||
class="btn-icon btn-delete"
|
||||
title="Löschen"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useTimeStore } from '../stores/timeStore'
|
||||
|
||||
const timeStore = useTimeStore()
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return '-'
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const deleteEntry = async (id) => {
|
||||
if (confirm('Möchtest du diesen Eintrag wirklich löschen?')) {
|
||||
await timeStore.deleteEntry(id)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await timeStore.fetchEntries()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: #f9f9f9;
|
||||
border: 1px solid #d4d4d4;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: #666;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.entries-container {
|
||||
background: #f9f9f9;
|
||||
border: 1px solid #d4d4d4;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.entries-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.entries-count {
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.entries-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
thead {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #d4d4d4;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.project-cell {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.duration-cell {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
color: #5cb85c;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background-color: #fcf8e3;
|
||||
color: #8a6d3b;
|
||||
border: 1px solid #faebcc;
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background-color: #dff0d8;
|
||||
color: #3c763d;
|
||||
border: 1px solid #d6e9c6;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
</style>
|
||||
|
||||
363
frontend/src/views/Login.vue
Normal file
363
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="navbar">
|
||||
<div class="navbar-inner">
|
||||
<div class="container">
|
||||
<h1 class="brand">
|
||||
<router-link to="/">Stechuhr</router-link>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contents">
|
||||
<div class="auth-form-container">
|
||||
<h2>Einloggen</h2>
|
||||
<p></p>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="auth-form">
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="email">E-Mail-Adresse</label>
|
||||
<input
|
||||
v-model="loginForm.email"
|
||||
type="text"
|
||||
id="email"
|
||||
size="10"
|
||||
class="form-input"
|
||||
title="Ihre E-Mail-Adresse eingeben"
|
||||
/>
|
||||
<span class="info">Ihre E-Mail-Adresse eingeben</span>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input
|
||||
v-model="loginForm.password"
|
||||
type="password"
|
||||
id="password"
|
||||
size="10"
|
||||
class="form-input"
|
||||
title="Ihr Passwort eingeben"
|
||||
@keydown.enter="handleLogin"
|
||||
/>
|
||||
<span class="info">Ihr Passwort eingeben</span>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="remember">Login merken</label>
|
||||
<input
|
||||
v-model="rememberMe"
|
||||
type="checkbox"
|
||||
id="remember"
|
||||
/>
|
||||
<span class="info"></span>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="action">Aktion</label>
|
||||
<select id="action" v-model="loginAction" class="form-input">
|
||||
<option value="0">Keine Aktion</option>
|
||||
<option value="1">Arbeit beginnen</option>
|
||||
<option value="2">Pause beginnen</option>
|
||||
<option value="3">Pause beenden</option>
|
||||
<option value="4">Feierabend</option>
|
||||
</select>
|
||||
<span class="info">Wird beim Einloggen ausgeführt</span>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Wird eingeloggt...' : 'Einloggen' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="oauth-divider">
|
||||
<span>oder</span>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
type="button"
|
||||
@click="handleGoogleLogin"
|
||||
class="btn btn-google"
|
||||
>
|
||||
<svg class="google-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="20px" height="20px">
|
||||
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"/>
|
||||
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"/>
|
||||
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"/>
|
||||
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"/>
|
||||
</svg>
|
||||
Mit Google anmelden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<router-link to="/password-forgot" class="link">Passwort vergessen</router-link>
|
||||
|
|
||||
<router-link to="/register" class="link">Registrieren</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loginForm = ref({
|
||||
email: '',
|
||||
password: ''
|
||||
})
|
||||
|
||||
const rememberMe = ref(false)
|
||||
const loginAction = ref('0')
|
||||
const error = ref('')
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
error.value = ''
|
||||
|
||||
await authStore.login({
|
||||
email: loginForm.value.email,
|
||||
password: loginForm.value.password
|
||||
})
|
||||
|
||||
// Nach erfolgreichem Login zum Dashboard
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Login fehlgeschlagen. Bitte überprüfen Sie Ihre Eingaben.'
|
||||
}
|
||||
}
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect zu Google OAuth
|
||||
window.location.href = 'http://localhost:3010/api/auth/google'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
body {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
background-image: none;
|
||||
background-color: #f0ffec;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid #e0ffe0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
padding: 12px 20px 12px 0;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contents {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.auth-form-container {
|
||||
width: 900px;
|
||||
background-color: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 2.5rem 3rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.auth-form-container h2 {
|
||||
text-align: center;
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.75em;
|
||||
margin: -2.5rem -2.5rem 2rem -2.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.auth-form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-form-group label {
|
||||
width: 12em;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.auth-form-group input[type="text"],
|
||||
.auth-form-group input[type="password"],
|
||||
.auth-form-group input[type="email"],
|
||||
.auth-form-group select {
|
||||
flex: 1;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.auth-form-group input:focus,
|
||||
.auth-form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #5bc0de;
|
||||
box-shadow: 0 0 0 3px rgba(91, 192, 222, 0.1);
|
||||
}
|
||||
|
||||
.auth-form-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
.auth-form-group .info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f2dede;
|
||||
border: 1px solid #ebccd1;
|
||||
color: #a94442;
|
||||
padding: 0.75em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 1.5em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.buttons .btn {
|
||||
margin: 0 3px;
|
||||
padding: 10px 24px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.buttons .btn-primary {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #5bc0de;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
padding: 0 0.75em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: none;
|
||||
color: #31b0d5;
|
||||
}
|
||||
|
||||
.oauth-divider {
|
||||
text-align: center;
|
||||
margin: 1.5em 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.oauth-divider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.oauth-divider span {
|
||||
position: relative;
|
||||
background: white;
|
||||
padding: 0 1em;
|
||||
color: #999;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.btn-google {
|
||||
width: 100%;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
color: #444;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 10px 24px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-google:hover {
|
||||
background-color: #f8f8f8;
|
||||
border-color: #ccc;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.google-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
97
frontend/src/views/OAuthCallback.vue
Normal file
97
frontend/src/views/OAuthCallback.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="oauth-callback">
|
||||
<div class="loading-container">
|
||||
<h2>{{ status }}</h2>
|
||||
<div class="spinner"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const status = ref('Authentifizierung läuft...')
|
||||
|
||||
onMounted(async () => {
|
||||
const token = route.query.token
|
||||
const error = route.query.error
|
||||
|
||||
if (error) {
|
||||
status.value = 'OAuth-Login fehlgeschlagen'
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
return
|
||||
}
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
// Token speichern
|
||||
authStore.saveToken(token)
|
||||
|
||||
// Benutzer-Daten laden
|
||||
await authStore.fetchCurrentUser()
|
||||
|
||||
status.value = 'Login erfolgreich! Sie werden weitergeleitet...'
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/')
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
status.value = 'Fehler beim Login'
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
}
|
||||
} else {
|
||||
status.value = 'Kein Token erhalten'
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.oauth-callback {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-container h2 {
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #5bc0de;
|
||||
border-radius: 50%;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
309
frontend/src/views/PasswordForgot.vue
Normal file
309
frontend/src/views/PasswordForgot.vue
Normal file
@@ -0,0 +1,309 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="navbar">
|
||||
<div class="navbar-inner">
|
||||
<div class="container">
|
||||
<h1 class="brand">
|
||||
<router-link to="/">Stechuhr</router-link>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contents">
|
||||
<div class="auth-form-container">
|
||||
<h2>Passwort vergessen</h2>
|
||||
|
||||
<form @submit.prevent="handleResetRequest" class="auth-form">
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="success" class="success-message">
|
||||
{{ success }}
|
||||
</div>
|
||||
|
||||
<div v-if="resetLink" class="reset-link-container">
|
||||
<p><strong>Bitte kopieren Sie diesen Link oder klicken Sie darauf:</strong></p>
|
||||
<a :href="resetLink" class="reset-link">{{ resetLink }}</a>
|
||||
<button @click="copyResetLink" class="btn btn-secondary" style="margin-top: 10px;">
|
||||
Link kopieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="email">E-Mail-Adresse</label>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
id="email"
|
||||
class="form-input"
|
||||
:disabled="!!success"
|
||||
required
|
||||
/>
|
||||
<span class="info">Ihre registrierte E-Mail-Adresse</span>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
v-if="!success"
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Wird gesendet...' : 'Reset-Link senden' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<router-link to="/login" class="link">Zurück zum Login</router-link>
|
||||
|
|
||||
<router-link to="/register" class="link">Registrieren</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const email = ref('')
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
const resetLink = ref('')
|
||||
|
||||
const handleResetRequest = async () => {
|
||||
try {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
resetLink.value = ''
|
||||
|
||||
const result = await authStore.requestPasswordReset(email.value)
|
||||
|
||||
if (result.resetToken) {
|
||||
// Development: Zeige Reset-Link direkt an
|
||||
const baseUrl = window.location.origin
|
||||
resetLink.value = `${baseUrl}/password-reset?token=${result.resetToken}`
|
||||
success.value = 'Reset-Link wurde generiert (Development-Modus):'
|
||||
} else {
|
||||
success.value = 'Falls ein Account mit dieser E-Mail existiert, wurde ein Reset-Link gesendet.'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Fehler beim Senden des Reset-Links'
|
||||
}
|
||||
}
|
||||
|
||||
const copyResetLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(resetLink.value)
|
||||
alert('Link in Zwischenablage kopiert!')
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Kopieren:', err)
|
||||
// Fallback für ältere Browser
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = resetLink.value
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textArea)
|
||||
alert('Link in Zwischenablage kopiert!')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
background-image: none;
|
||||
background-color: #f0ffec;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid #e0ffe0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
padding: 12px 20px 12px 0;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contents {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.auth-form-container {
|
||||
width: 900px;
|
||||
background-color: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 2.5rem 3rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.auth-form-container h2 {
|
||||
text-align: center;
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.75em;
|
||||
margin: -2.5rem -2.5rem 2rem -2.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.auth-form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-form-group label {
|
||||
width: 12em;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.auth-form-group input {
|
||||
flex: 1;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.auth-form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #5bc0de;
|
||||
box-shadow: 0 0 0 3px rgba(91, 192, 222, 0.1);
|
||||
}
|
||||
|
||||
.auth-form-group input:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.auth-form-group .info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f2dede;
|
||||
border: 1px solid #ebccd1;
|
||||
color: #a94442;
|
||||
padding: 0.75em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: #dff0d8;
|
||||
border: 1px solid #d6e9c6;
|
||||
color: #3c763d;
|
||||
padding: 0.75em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.reset-link-container {
|
||||
background-color: #f9f9f9;
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 1em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.reset-link-container p {
|
||||
margin: 0 0 0.5em 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.reset-link {
|
||||
display: block;
|
||||
color: #5bc0de;
|
||||
word-break: break-all;
|
||||
padding: 0.5em;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.reset-link:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 1.5em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.buttons .btn {
|
||||
margin: 0 3px;
|
||||
padding: 10px 24px;
|
||||
font-size: 15px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #5bc0de;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
padding: 0 0.75em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: none;
|
||||
color: #31b0d5;
|
||||
}
|
||||
</style>
|
||||
288
frontend/src/views/PasswordReset.vue
Normal file
288
frontend/src/views/PasswordReset.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="navbar">
|
||||
<div class="navbar-inner">
|
||||
<div class="container">
|
||||
<h1 class="brand">
|
||||
<router-link to="/">Stechuhr</router-link>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contents">
|
||||
<div class="auth-form-container">
|
||||
<h2>Neues Passwort setzen</h2>
|
||||
|
||||
<form @submit.prevent="handleReset" class="auth-form">
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="success" class="success-message">
|
||||
{{ success }}
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="password">Neues Passwort</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
id="password"
|
||||
class="form-input"
|
||||
required
|
||||
minlength="6"
|
||||
:disabled="!!success"
|
||||
/>
|
||||
<span class="info">Mindestens 6 Zeichen</span>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="password_confirm">Passwort bestätigen</label>
|
||||
<input
|
||||
v-model="passwordConfirm"
|
||||
type="password"
|
||||
id="password_confirm"
|
||||
class="form-input"
|
||||
required
|
||||
:disabled="!!success"
|
||||
/>
|
||||
<span class="info">Passwort wiederholen</span>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
v-if="!success"
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Wird gespeichert...' : 'Passwort zurücksetzen' }}
|
||||
</button>
|
||||
<router-link
|
||||
v-else
|
||||
to="/login"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Zum Login
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<router-link to="/login" class="link">Zurück zum Login</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const password = ref('')
|
||||
const passwordConfirm = ref('')
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const resetToken = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
resetToken.value = route.query.token || ''
|
||||
|
||||
if (!resetToken.value) {
|
||||
error.value = 'Kein Reset-Token vorhanden. Bitte fordern Sie einen neuen Reset-Link an.'
|
||||
}
|
||||
})
|
||||
|
||||
const handleReset = async () => {
|
||||
try {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
if (password.value !== passwordConfirm.value) {
|
||||
error.value = 'Passwörter stimmen nicht überein'
|
||||
return
|
||||
}
|
||||
|
||||
if (password.value.length < 6) {
|
||||
error.value = 'Passwort muss mindestens 6 Zeichen lang sein'
|
||||
return
|
||||
}
|
||||
|
||||
await authStore.resetPassword(resetToken.value, password.value)
|
||||
success.value = 'Passwort wurde erfolgreich zurückgesetzt! Sie können sich jetzt einloggen.'
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Fehler beim Zurücksetzen des Passworts'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
background-image: none;
|
||||
background-color: #f0ffec;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid #e0ffe0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
padding: 12px 20px 12px 0;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contents {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.auth-form-container {
|
||||
width: 900px;
|
||||
background-color: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 2.5rem 3rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.auth-form-container h2 {
|
||||
text-align: center;
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.75em;
|
||||
margin: -2.5rem -2.5rem 2rem -2.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.auth-form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-form-group label {
|
||||
width: 12em;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.auth-form-group input {
|
||||
flex: 1;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.auth-form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #5bc0de;
|
||||
box-shadow: 0 0 0 3px rgba(91, 192, 222, 0.1);
|
||||
}
|
||||
|
||||
.auth-form-group input:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.auth-form-group .info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f2dede;
|
||||
border: 1px solid #ebccd1;
|
||||
color: #a94442;
|
||||
padding: 0.75em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: #dff0d8;
|
||||
border: 1px solid #d6e9c6;
|
||||
color: #3c763d;
|
||||
padding: 0.75em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 1.5em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.buttons .btn {
|
||||
margin: 0 3px;
|
||||
padding: 10px 24px;
|
||||
font-size: 15px;
|
||||
min-width: 140px;
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #5bc0de;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
padding: 0 0.75em;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: none;
|
||||
color: #31b0d5;
|
||||
}
|
||||
</style>
|
||||
298
frontend/src/views/Register.vue
Normal file
298
frontend/src/views/Register.vue
Normal file
@@ -0,0 +1,298 @@
|
||||
<template>
|
||||
<div class="auth-page">
|
||||
<div class="navbar">
|
||||
<div class="navbar-inner">
|
||||
<div class="container">
|
||||
<h1 class="brand">
|
||||
<router-link to="/">Stechuhr</router-link>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="contents">
|
||||
<div class="auth-form-container">
|
||||
<h2>Registrieren</h2>
|
||||
|
||||
<form @submit.prevent="handleRegister" class="auth-form">
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-if="success" class="success-message">
|
||||
{{ success }}
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="full_name">Vollständiger Name</label>
|
||||
<input
|
||||
v-model="registerForm.full_name"
|
||||
type="text"
|
||||
id="full_name"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
<span class="info">Ihr vollständiger Name</span>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="email">E-Mail-Adresse</label>
|
||||
<input
|
||||
v-model="registerForm.email"
|
||||
type="email"
|
||||
id="email"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
<span class="info">Ihre E-Mail-Adresse</span>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input
|
||||
v-model="registerForm.password"
|
||||
type="password"
|
||||
id="password"
|
||||
class="form-input"
|
||||
required
|
||||
minlength="6"
|
||||
/>
|
||||
<span class="info">Mindestens 6 Zeichen</span>
|
||||
</div>
|
||||
|
||||
<div class="auth-form-group">
|
||||
<label for="password_confirm">Passwort bestätigen</label>
|
||||
<input
|
||||
v-model="registerForm.password_confirm"
|
||||
type="password"
|
||||
id="password_confirm"
|
||||
class="form-input"
|
||||
required
|
||||
/>
|
||||
<span class="info">Passwort wiederholen</span>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
:disabled="authStore.isLoading"
|
||||
>
|
||||
{{ authStore.isLoading ? 'Wird registriert...' : 'Registrieren' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
Bereits registriert?
|
||||
<router-link to="/login" class="link">Jetzt einloggen</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const registerForm = ref({
|
||||
full_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
password_confirm: ''
|
||||
})
|
||||
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
const handleRegister = async () => {
|
||||
try {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
if (registerForm.value.password !== registerForm.value.password_confirm) {
|
||||
error.value = 'Passwörter stimmen nicht überein'
|
||||
return
|
||||
}
|
||||
|
||||
if (registerForm.value.password.length < 6) {
|
||||
error.value = 'Passwort muss mindestens 6 Zeichen lang sein'
|
||||
return
|
||||
}
|
||||
|
||||
await authStore.register({
|
||||
full_name: registerForm.value.full_name,
|
||||
email: registerForm.value.email,
|
||||
password: registerForm.value.password
|
||||
})
|
||||
|
||||
success.value = 'Registrierung erfolgreich! Sie werden weitergeleitet...'
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
error.value = err.message || 'Registrierung fehlgeschlagen'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
background-image: none;
|
||||
background-color: #f0ffec;
|
||||
border-radius: 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border-bottom: 1px solid #e0ffe0;
|
||||
}
|
||||
|
||||
.brand {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand a {
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
padding: 12px 20px 12px 0;
|
||||
font-size: 24px;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.brand a:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.contents {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.auth-form-container {
|
||||
width: 900px;
|
||||
background-color: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 2.5rem 3rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.auth-form-container h2 {
|
||||
text-align: center;
|
||||
background-color: #f5f5f5;
|
||||
padding: 0.75em;
|
||||
margin: -2.5rem -2.5rem 2rem -2.5rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
border-radius: 8px 8px 0 0;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.auth-form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.auth-form-group label {
|
||||
width: 12em;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.auth-form-group input,
|
||||
.auth-form-group select {
|
||||
flex: 1;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.auth-form-group input:focus,
|
||||
.auth-form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #5bc0de;
|
||||
box-shadow: 0 0 0 3px rgba(91, 192, 222, 0.1);
|
||||
}
|
||||
|
||||
.auth-form-group .info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f2dede;
|
||||
border: 1px solid #ebccd1;
|
||||
color: #a94442;
|
||||
padding: 0.75em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: #dff0d8;
|
||||
border: 1px solid #d6e9c6;
|
||||
color: #3c763d;
|
||||
padding: 0.75em;
|
||||
margin-bottom: 1em;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 1em;
|
||||
text-align: left;
|
||||
padding-left: 7em;
|
||||
}
|
||||
|
||||
.buttons .btn {
|
||||
margin: 0 3px;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: #337ab7;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
padding: 0 0.5em;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
color: #23527c;
|
||||
}
|
||||
</style>
|
||||
204
frontend/src/views/Stats.vue
Normal file
204
frontend/src/views/Stats.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="stats">
|
||||
<h2 class="page-title">Statistiken</h2>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<p>Laden...</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Gesamt-Statistiken -->
|
||||
<div class="stats-grid grid grid-2">
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-value">{{ stats.totalEntries || 0 }}</div>
|
||||
<div class="stat-label">Gesamt Einträge</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon">✅</div>
|
||||
<div class="stat-value">{{ stats.completedEntries || 0 }}</div>
|
||||
<div class="stat-label">Abgeschlossene Einträge</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon">🔴</div>
|
||||
<div class="stat-value">{{ stats.runningEntries || 0 }}</div>
|
||||
<div class="stat-label">Laufende Einträge</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card card">
|
||||
<div class="stat-icon">⏱️</div>
|
||||
<div class="stat-value">{{ stats.totalHours || '0.00' }} h</div>
|
||||
<div class="stat-label">Gesamtstunden</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Projekt-Statistiken -->
|
||||
<div class="project-stats">
|
||||
<h3>Statistiken nach Projekt</h3>
|
||||
<div v-if="Object.keys(stats.projectStats || {}).length === 0" class="empty-state card">
|
||||
<p>Noch keine Projekt-Statistiken verfügbar</p>
|
||||
</div>
|
||||
<div v-else class="project-list">
|
||||
<div
|
||||
v-for="(data, project) in stats.projectStats"
|
||||
:key="project"
|
||||
class="project-card card"
|
||||
>
|
||||
<div class="project-header">
|
||||
<h4>{{ project }}</h4>
|
||||
<span class="project-count">{{ data.count }} Einträge</span>
|
||||
</div>
|
||||
<div class="project-duration">
|
||||
<span class="duration-label">Gesamtdauer:</span>
|
||||
<span class="duration-value">{{ formatDuration(data.duration) }}</span>
|
||||
</div>
|
||||
<div class="project-hours">
|
||||
{{ (data.duration / 3600).toFixed(2) }} Stunden
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useTimeStore } from '../stores/timeStore'
|
||||
|
||||
const timeStore = useTimeStore()
|
||||
const stats = ref({})
|
||||
const loading = ref(true)
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (!seconds) return '00:00:00'
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const secs = seconds % 60
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true
|
||||
stats.value = await timeStore.fetchStats()
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-title {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #333;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid #d4d4d4;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #000;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.project-stats h3 {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.project-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.project-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #d4d4d4;
|
||||
}
|
||||
|
||||
.project-header h4 {
|
||||
margin: 0;
|
||||
color: #000;
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.project-count {
|
||||
background-color: #f0f0f0;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
font-weight: normal;
|
||||
border: 1px solid #d4d4d4;
|
||||
}
|
||||
|
||||
.project-duration {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.duration-label {
|
||||
font-weight: normal;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.duration-value {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
color: #5cb85c;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.project-hours {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
697
frontend/src/views/WeekOverview.vue
Normal file
697
frontend/src/views/WeekOverview.vue
Normal file
@@ -0,0 +1,697 @@
|
||||
<template>
|
||||
<div class="week-overview">
|
||||
<div class="card">
|
||||
<h2>Wochenübersicht</h2>
|
||||
<p class="subtitle">Übersicht Ihrer Arbeitszeiten für die aktuelle Woche</p>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
Lade Daten...
|
||||
</div>
|
||||
|
||||
<div v-else class="week-content">
|
||||
<div class="week-header">
|
||||
<button @click="previousWeek" class="btn btn-secondary">← Vorherige Woche</button>
|
||||
<h3>{{ weekRange }}</h3>
|
||||
<button @click="nextWeek" class="btn btn-secondary">Nächste Woche →</button>
|
||||
</div>
|
||||
|
||||
<table class="week-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tag</th>
|
||||
<th>Datum</th>
|
||||
<th>Zeiten</th>
|
||||
<th>Arbeitszeit</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="day in weekDays" :key="day.date" :class="{ 'current-day': day.isToday }">
|
||||
<td class="day-name">{{ day.name }}</td>
|
||||
<td>{{ day.date }}</td>
|
||||
<td class="times-cell">
|
||||
<!-- Mehrere Arbeitsblöcke unterstützen -->
|
||||
<template v-if="day.workBlocks && day.workBlocks.length > 0">
|
||||
<div v-for="(block, blockIndex) in day.workBlocks" :key="blockIndex" class="work-block">
|
||||
<div class="time-entry">
|
||||
<strong v-if="day.workBlocks.length > 1">Arbeitszeit {{ blockIndex + 1 }}: </strong>
|
||||
<strong v-else>Arbeitszeit: </strong>
|
||||
<template v-if="block.workTimeFixed && block.workTimeOriginal">
|
||||
<span
|
||||
class="time-fixed"
|
||||
:title="`Original: ${block.workTimeOriginal}`"
|
||||
>
|
||||
{{ block.workTime }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ block.workTime }}
|
||||
</template>
|
||||
</div>
|
||||
<div v-for="(pause, pIndex) in block.pauses" :key="pIndex" class="time-entry pause-entry">
|
||||
<template v-if="block.pauses.length > 1">Pause {{ pIndex + 1 }}: </template>
|
||||
<template v-else>Pause: </template>
|
||||
<template v-if="pause.fixed && pause.original">
|
||||
<span
|
||||
class="time-fixed"
|
||||
:title="`Original: ${pause.original}`"
|
||||
>
|
||||
{{ pause.time || pause }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ pause.time || pause }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Fallback für alte Datenstruktur -->
|
||||
<template v-else-if="day.workTime">
|
||||
<div class="time-entry">
|
||||
<strong>Arbeitszeit:</strong>
|
||||
<template v-if="day.workTimeFixed && day.workTimeOriginal">
|
||||
<span
|
||||
class="time-fixed"
|
||||
:title="`Original: ${day.workTimeOriginal}`"
|
||||
>
|
||||
{{ day.workTime }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ day.workTime }}
|
||||
</template>
|
||||
</div>
|
||||
<div v-for="(pause, index) in day.pauses" :key="index" class="time-entry pause-entry">
|
||||
<template v-if="day.pauses.length > 1">Pause {{ index + 1 }}: </template>
|
||||
<template v-else>Pause: </template>
|
||||
<template v-if="pause.fixed && pause.original">
|
||||
<span
|
||||
class="time-fixed"
|
||||
:title="`Original: ${pause.original}`"
|
||||
>
|
||||
{{ pause.time || pause }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ pause.time || pause }}
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</td>
|
||||
<td class="total-cell">
|
||||
<!-- Wenn workBlocks vorhanden sind, zeige Block-basierte Werte -->
|
||||
<template v-if="day.workBlocks && day.workBlocks.length > 0">
|
||||
<template v-for="(block, blockIndex) in day.workBlocks" :key="blockIndex">
|
||||
<div v-if="block.totalWorkTime" class="total-work-time">
|
||||
{{ block.totalWorkTime }}
|
||||
</div>
|
||||
<div v-for="(pauseTime, pIndex) in block.pauseTimes" :key="`${blockIndex}-${pIndex}`" class="total-pause-time">
|
||||
- <span
|
||||
:class="{ 'time-fixed': pauseTime.fixed }"
|
||||
:title="pauseTime.original ? `Original: ${pauseTime.original}` : ''"
|
||||
>
|
||||
{{ pauseTime.time || pauseTime }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="block.netWorkTime" class="net-work-time">
|
||||
= <strong>{{ block.netWorkTime }}</strong>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Gesamt-Nettozeit (falls mehrere Blöcke) -->
|
||||
<div v-if="day.workBlocks.length > 1 && day.netWorkTime" class="total-net-work-time">
|
||||
<strong>Gesamt: {{ day.netWorkTime }}</strong>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Fallback für alte Struktur -->
|
||||
<template v-else>
|
||||
<div v-if="day.totalWorkTime" class="total-work-time">
|
||||
{{ day.totalWorkTime }}
|
||||
</div>
|
||||
<div v-for="(pauseTime, index) in day.pauseTimes" :key="index" class="total-pause-time">
|
||||
- <span
|
||||
:class="{ 'time-fixed': pauseTime.fixed }"
|
||||
:title="pauseTime.original ? `Original: ${pauseTime.original}` : ''"
|
||||
>
|
||||
{{ pauseTime.time || pauseTime }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="day.netWorkTime" class="net-work-time">
|
||||
= <strong>{{ day.netWorkTime }}</strong>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Vacation, Sick und Holiday werden immer angezeigt -->
|
||||
<div v-if="day.holiday" class="holiday-time">
|
||||
+ {{ day.holiday.hours }}:00 Feiertag
|
||||
</div>
|
||||
<div v-if="day.vacation" class="vacation-time">
|
||||
+ {{ day.vacation.hours }}:00 Urlaub
|
||||
</div>
|
||||
<div v-if="day.sick" class="sick-time">
|
||||
8:00 ({{ day.sick.type === 'self' ? 'Krank' : day.sick.type === 'child' ? 'Kind krank' : day.sick.type === 'parents' ? 'Eltern krank' : 'Partner krank' }})
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="day.status" class="status-badge" :class="`status-${day.status}`">
|
||||
{{ day.statusText }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr class="summary-row">
|
||||
<td colspan="3"><strong>Wochensumme</strong></td>
|
||||
<td class="total-hours"><strong>{{ weekTotal }}</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr v-if="weekData?.nonWorkingDays > 0" class="summary-row non-working-info">
|
||||
<td colspan="3" class="non-working-label">
|
||||
<strong>Arbeitsfreie Tage ({{ weekData.nonWorkingDays }}):</strong>
|
||||
<span v-for="(detail, index) in weekData.nonWorkingDetails" :key="index" class="non-working-detail">
|
||||
{{ formatDate(detail.date) }}: {{ detail.type }} ({{ detail.hours }}h)
|
||||
<span v-if="index < weekData.nonWorkingDetails.length - 1">, </span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="non-working-total"><strong>{{ weekData.nonWorkingTotal }}</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr class="summary-row total-all-row">
|
||||
<td colspan="3"><strong>Gesamtsumme</strong></td>
|
||||
<td class="total-all-hours"><strong>{{ weekData?.totalAll || weekTotal }}</strong></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const weekData = ref(null)
|
||||
const weekOffset = ref(0)
|
||||
|
||||
const weekDays = computed(() => {
|
||||
if (!weekData.value) return []
|
||||
|
||||
return weekData.value.days.map(day => ({
|
||||
...day,
|
||||
date: formatDate(day.date),
|
||||
name: day.name
|
||||
}))
|
||||
})
|
||||
|
||||
const weekRange = computed(() => {
|
||||
if (!weekData.value) return ''
|
||||
|
||||
const start = formatDate(weekData.value.weekStart)
|
||||
const end = formatDate(weekData.value.weekEnd)
|
||||
|
||||
return `${start} - ${end}`
|
||||
})
|
||||
|
||||
const weekTotal = computed(() => {
|
||||
return weekData.value?.weekTotal || '0:00'
|
||||
})
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const loadWeekData = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
const response = await fetch(`http://localhost:3010/api/week-overview?weekOffset=${weekOffset.value}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authStore.token}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Fehler beim Laden der Wochenübersicht')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.success) {
|
||||
weekData.value = result.data
|
||||
} else {
|
||||
throw new Error(result.error || 'Unbekannter Fehler')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Wochenübersicht:', error)
|
||||
// Fallback zu Dummy-Daten bei Fehlern
|
||||
weekData.value = getDummyData()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const previousWeek = () => {
|
||||
weekOffset.value--
|
||||
loadWeekData()
|
||||
}
|
||||
|
||||
const nextWeek = () => {
|
||||
weekOffset.value++
|
||||
loadWeekData()
|
||||
}
|
||||
|
||||
const getDummyData = () => {
|
||||
return {
|
||||
weekStart: '2025-01-13',
|
||||
weekEnd: '2025-01-19',
|
||||
weekOffset: weekOffset.value,
|
||||
weekTotal: '40:00',
|
||||
days: [
|
||||
{
|
||||
date: '2025-01-13',
|
||||
name: 'Montag',
|
||||
workTime: '08:00 - 16:30',
|
||||
pauses: ['12:00 - 12:30'],
|
||||
totalWorkTime: '8:30',
|
||||
pauseTimes: ['0:30'],
|
||||
netWorkTime: '8:00',
|
||||
status: 'complete',
|
||||
statusText: 'Abgeschlossen',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-14',
|
||||
name: 'Dienstag',
|
||||
workTime: '08:15 - 17:00',
|
||||
pauses: ['12:00 - 12:30', '14:00 - 14:30'],
|
||||
totalWorkTime: '8:45',
|
||||
pauseTimes: ['0:30', '0:30'],
|
||||
netWorkTime: '7:45',
|
||||
status: 'complete',
|
||||
statusText: 'Abgeschlossen',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-15',
|
||||
name: 'Mittwoch',
|
||||
workTime: '08:00 - 16:00',
|
||||
pauses: ['12:00 - 12:30'],
|
||||
totalWorkTime: '8:00',
|
||||
pauseTimes: ['0:30'],
|
||||
netWorkTime: '7:30',
|
||||
status: 'complete',
|
||||
statusText: 'Abgeschlossen',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-16',
|
||||
name: 'Donnerstag',
|
||||
workTime: '08:30 - 17:30',
|
||||
pauses: ['12:00 - 12:30', '15:00 - 15:15'],
|
||||
totalWorkTime: '9:00',
|
||||
pauseTimes: ['0:30', '0:15'],
|
||||
netWorkTime: '8:15',
|
||||
status: 'complete',
|
||||
statusText: 'Abgeschlossen',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-17',
|
||||
name: 'Freitag',
|
||||
workTime: '08:00 - 15:00',
|
||||
pauses: ['12:00 - 12:30'],
|
||||
totalWorkTime: '7:00',
|
||||
pauseTimes: ['0:30'],
|
||||
netWorkTime: '6:30',
|
||||
status: 'complete',
|
||||
statusText: 'Abgeschlossen',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-18',
|
||||
name: 'Samstag',
|
||||
workTime: null,
|
||||
pauses: [],
|
||||
totalWorkTime: null,
|
||||
pauseTimes: [],
|
||||
netWorkTime: null,
|
||||
status: 'weekend',
|
||||
statusText: 'Wochenende',
|
||||
isToday: false
|
||||
},
|
||||
{
|
||||
date: '2025-01-19',
|
||||
name: 'Sonntag',
|
||||
workTime: null,
|
||||
pauses: [],
|
||||
totalWorkTime: null,
|
||||
pauseTimes: [],
|
||||
netWorkTime: null,
|
||||
status: 'weekend',
|
||||
statusText: 'Wochenende',
|
||||
isToday: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const handleWorklogUpdate = () => {
|
||||
console.log('Worklog aktualisiert, lade Wochendaten neu...')
|
||||
loadWeekData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadWeekData()
|
||||
|
||||
// Event-Listener für Worklog-Updates
|
||||
window.addEventListener('worklog-updated', handleWorklogUpdate)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('worklog-updated', handleWorklogUpdate)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.week-overview {
|
||||
padding: 1rem;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: #333;
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.week-content {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.week-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.week-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.week-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 1rem;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.week-table th {
|
||||
background: #f8f9fa;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
border-bottom: 2px solid #e9ecef;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.week-table td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
vertical-align: top;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.week-table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.current-day {
|
||||
background: #e8f5e9 !important;
|
||||
}
|
||||
|
||||
.current-day:hover {
|
||||
background: #d4edda !important;
|
||||
}
|
||||
|
||||
.day-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.times-cell {
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.work-block {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px dashed #ddd;
|
||||
}
|
||||
|
||||
.work-block:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.time-entry {
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pause-entry {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.time-fixed {
|
||||
color: #0066cc;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.total-cell {
|
||||
min-width: 120px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.total-work-time {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.total-pause-time {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.net-work-time {
|
||||
color: #28a745;
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
padding-top: 4px;
|
||||
border-top: 1px solid #e9ecef;
|
||||
}
|
||||
|
||||
.total-net-work-time {
|
||||
color: #155724;
|
||||
font-weight: 700;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 2px solid #28a745;
|
||||
}
|
||||
|
||||
.holiday-time {
|
||||
color: #6f42c1;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.vacation-time {
|
||||
color: #17a2b8;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sick-time {
|
||||
color: #dc3545;
|
||||
font-weight: 500;
|
||||
margin-top: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-complete {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.status-weekend {
|
||||
background: #e2e3e5;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.status-holiday {
|
||||
background: #e7d4f7;
|
||||
color: #5a1a8c;
|
||||
}
|
||||
|
||||
.status-holiday-work {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border-left: 4px solid #6f42c1;
|
||||
}
|
||||
|
||||
.status-vacation-full {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.status-vacation-half {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 2px dashed #17a2b8;
|
||||
}
|
||||
|
||||
.status-vacation-work {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border-left: 4px solid #17a2b8;
|
||||
}
|
||||
|
||||
.status-sick {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-row td {
|
||||
border-top: 2px solid #e9ecef;
|
||||
border-bottom: none;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.total-hours {
|
||||
font-size: 1rem;
|
||||
color: #28a745;
|
||||
}
|
||||
|
||||
.non-working-info {
|
||||
background: #fff3cd;
|
||||
border-top: 1px solid #ffeaa7;
|
||||
}
|
||||
|
||||
.non-working-label {
|
||||
font-size: 0.8rem;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.non-working-detail {
|
||||
font-weight: normal;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.non-working-total {
|
||||
font-size: 0.9rem;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.total-all-row {
|
||||
background: #e8f5e8;
|
||||
border-top: 2px solid #28a745;
|
||||
}
|
||||
|
||||
.total-all-hours {
|
||||
font-size: 1.1rem;
|
||||
color: #155724;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
background: white;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #adb5bd;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border-color: #6c757d;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
border-color: #545b62;
|
||||
}
|
||||
</style>
|
||||
22
frontend/vite.config.js
Normal file
22
frontend/vite.config.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 5010,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3010',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
4011
package-lock.json
generated
Normal file
4011
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "timeclock",
|
||||
"version": "3.0.0",
|
||||
"description": "TimeClock v3 - Full-Stack Zeiterfassungsanwendung",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
],
|
||||
"scripts": {
|
||||
"install:all": "npm install && cd backend && npm install && cd ../frontend && npm install",
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
"dev:frontend": "cd frontend && npm run dev",
|
||||
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
|
||||
"start:backend": "cd backend && npm start",
|
||||
"build:frontend": "cd frontend && npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"timeclock",
|
||||
"zeiterfassung",
|
||||
"time-tracking",
|
||||
"vue3",
|
||||
"nodejs"
|
||||
],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
2
worklog_backup_20251015_215729.sql
Normal file
2
worklog_backup_20251015_215729.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
mysqldump: Deprecated program name. It will be removed in a future release, use '/usr/bin/mariadb-dump' instead
|
||||
mysqldump: Got error: 1045: "Access denied for user 'timeclock_user'@'localhost' (using password: YES)" when trying to connect
|
||||
Reference in New Issue
Block a user