diff --git a/README.md b/README.md new file mode 100644 index 0000000..732cdbb --- /dev/null +++ b/README.md @@ -0,0 +1,775 @@ +# Zeiterfassungssystem für pädagogische Mitarbeiter + +Eine vollständige Webanwendung zur Erfassung und Verwaltung von Flexistunden für pädagogische Mitarbeiter (PM) an Schulen. + +## 📋 Inhaltsverzeichnis + +- [Überblick](#überblick) +- [Funktionen](#funktionen) +- [Technologie-Stack](#technologie-stack) +- [Voraussetzungen](#voraussetzungen) +- [Installation](#installation) +- [Konfiguration](#konfiguration) +- [Verwendung](#verwendung) +- [API-Dokumentation](#api-dokumentation) +- [Architektur](#architektur) +- [Sicherheit](#sicherheit) +- [Backup & Wartung](#backup--wartung) +- [Fehlerbehebung](#fehlerbehebung) + +## 🎯 Überblick + +Diese Anwendung wurde entwickelt, um die Erfassung von Flexistunden (zusätzliche Arbeitsstunden) für pädagogische Mitarbeiter an Schulen zu vereinfachen. Sie ermöglicht: + +- **Mitarbeitern**: Wöchentliche Zeiterfassung anhand eines vorkonfigurierten Stundenplans +- **Administratoren**: Vollständige Verwaltung von Benutzern, Stundenplänen, Schuljahren und Zeiteinträgen + +Das System arbeitet mit ISO-Kalenderwochen und unterstützt schuljahrbezogene Auswertungen. + +## ✨ Funktionen + +### Für Mitarbeiter + +- **Wochenbasierte Zeiterfassung**: Auswahl der gearbeiteten Zeiten aus dem Stundenplan +- **Kalenderwochen-Navigation**: Einfaches Vor- und Zurückblättern zwischen Wochen +- **Jahresübersicht**: Anzeige der geleisteten vs. Soll-Arbeitsstunden +- **Responsive Design**: Optimiert für Desktop, Tablet und Mobile + +### Für Administratoren + +- **Benutzerverwaltung**: + - Benutzer anlegen, bearbeiten und löschen + - Jahresarbeitsstunden pro Benutzer festlegen (Standard: 60h) + - Passwörter zurücksetzen +- **Stundenplan-Management**: + - Wochenstundenplan mit Unterrichts- und Pausenzeiten erstellen + - Unterrichtsstunden und Pausen unterscheiden + - Zeiten mit Titeln versehen (z.B. "Mathematik", "Pause") + +- **Schuljahrverwaltung**: + - Schuljahre mit Start- und Enddatum definieren + - Aktives Schuljahr setzen + - Jahresberechnungen basierend auf aktivem Schuljahr + +- **Zeiteintrags-Verwaltung**: + - Alle Zeiteinträge einsehen und bearbeiten + - Manuelle Stundeneintragungen (positiv = Abzug, negativ = Hinzurechnung) + - Einzelne Einträge korrigieren oder löschen + +- **Berichtswesen**: + - Jahresübersicht aller Mitarbeiter + - PDF-Export der Jahresübersicht + - Wochenweise Stundenauswertung + +## 🛠 Technologie-Stack + +### Frontend + +- **Elm 0.19**: Funktionale Programmiersprache für type-safe UI +- **Bulma CSS**: Modernes CSS-Framework +- **Font Awesome**: Icons +- **LocalStorage**: Client-seitige Datenpersistenz für Authentifizierung + +### Backend + +- **Go (Golang)**: Performante Backend-Sprache +- **Echo Framework**: Web-Framework für Go +- **SQLite**: Embedded SQL-Datenbank +- **JWT**: Token-basierte Authentifizierung +- **bcrypt**: Passwort-Hashing +- **gofpdf**: PDF-Generierung + +### Deployment + +- **Docker**: Containerisierung +- **Docker Compose**: Orchestrierung + +## 📦 Voraussetzungen + +### Für Docker-Deployment (empfohlen) + +- Docker (Version 20.10+) +- Docker Compose (Version 1.29+) + +### Für lokale Entwicklung + +- Go 1.21+ +- Elm 0.19 +- Node.js 16+ (für Elm-Tooling) +- SQLite3 + +## 🚀 Installation + +### Option 1: Docker Compose (Produktion) + +1. **Repository klonen** + +```bash +git clone +cd zeiterfassung +``` + +2. **Umgebungsvariablen konfigurieren** + +```bash +cp .env.example .env +nano .env +``` + +Wichtige Variablen in `.env`: + +```env +PORT=8080 +DB_PATH=/data/timetracking.db +JWT_SECRET=ihr-sicheres-geheimnis-hier-ändern +TZ=Europe/Berlin +``` + +3. **Anwendung starten** + +```bash +docker-compose up -d +``` + +4. **Anwendung aufrufen** + +``` +http://localhost:8080 +``` + +**Standard-Anmeldedaten:** + +- Benutzername: `admin` +- Passwort: Das in `docker-compose.yml` unter `INITIAL_ADMIN_PASSWORD` festgelegte Passwort. + +⚠️ **WICHTIG**: Ändern Sie das Admin-Passwort sofort nach der ersten Anmeldung! + +### Option 2: Lokale Entwicklung + +1. **Backend kompilieren** + +```bash +go mod download +go build -o timetracking +``` + +2. **Frontend kompilieren** + +```bash +cd static +elm make Main.elm --output=elm.js --optimize +cd .. +``` + +3. **Umgebungsvariablen setzen** + +```bash +export PORT=8080 +export DB_PATH=./timetracking.db +export JWT_SECRET=development-secret +``` + +4. **Anwendung starten** + +```bash +./timetracking +``` + +## ⚙️ Konfiguration + +### Umgebungsvariablen + +| Variable | Beschreibung | Standard | Erforderlich | +| ------------------------ | ------------------------------------------------- | ----------------------------------------------- | ------------ | +| `PORT` | HTTP-Server Port | `8080` | Nein | +| `DB_PATH` | Pfad zur SQLite-Datenbank | `./timetracking.db` | Nein | +| `JWT_SECRET` | Geheimnis für JWT-Token | - | **Ja** | +| `INITIAL_ADMIN_PASSWORD` | Initiales Passwort für den Admin-Benutzer | `changeme` | **Ja** | +| `TZ` | Zeitzone | `Europe/Berlin` | Nein | +| `ENVIRONMENT` | `production` für HTTPS-Redirect und striktes CORS | `development` | Nein | +| `CORS_ALLOWED_ORIGINS` | Komma-getrennte Liste von erlaubten Origins | `*` (in dev), `http://localhost:8080` (in prod) | Nein | + +### Docker-Volumes + +Das Docker-Setup erstellt ein persistentes Volume für die Datenbank: + +```yaml +volumes: + timetracking-data: + driver: local +``` + +Die Datenbank wird unter `/data/timetracking.db` im Container gespeichert. + +## 📖 Verwendung + +### Ersteinrichtung als Administrator + +1. **Anmelden** mit den Standard-Credentials (admin/das initiale Passwort aus der Konfiguration) + +2. **Admin-Passwort ändern**: + - Gehe zu "Benutzer" Tab + - Klicke auf "PW Reset" beim Admin-Benutzer + - Neues sicheres Passwort eingeben + +3. **Schuljahr erstellen**: + - Wechsle zum "Schuljahre" Tab + - Erstelle ein Schuljahr (z.B. "2024/2025") + - Startdatum: 01.08.2024 + - Enddatum: 31.07.2025 + - Klicke auf "Aktivieren" + +4. **Stundenplan erstellen**: + - Wechsle zum "Stundenplan" Tab + - Füge Unterrichtsstunden hinzu: + - Wochentag auswählen + - Start- und Endzeit eingeben + - Typ: "Unterricht" oder "Pause" + - Titel vergeben (z.B. "Mathematik 1a") + +5. **Mitarbeiter anlegen**: + - Wechsle zum "Benutzer" Tab + - "Benutzer anlegen" + - Benutzername und Passwort eingeben + - Jahresarbeitsstunden festlegen (Standard: 60h) + - Admin-Rechte nur für weitere Administratoren + +### Zeiterfassung als Mitarbeiter + +1. **Anmelden** mit persönlichen Zugangsdaten + +2. **Wochenansicht**: + - Zeigt aktuelle Kalenderwoche mit Datumsbereich + - Navigation: "Vorherige Woche" / "Nächste Woche" + +3. **Stunden erfassen**: + - Klicke auf die Zeitslots, die du gearbeitet hast + - Ausgewählte Zeiten werden grün markiert + - Klicke "Speichern" zum Übernehmen + +4. **Woche bearbeiten**: + - Falls bereits erfasst, erscheint "Bearbeiten"-Button + - Aktiviert Bearbeitungsmodus + - "Einträge löschen" entfernt alle Einträge der Woche + - Neue Auswahl treffen und "Änderungen speichern" + +5. **Jahresübersicht prüfen**: + - Unten auf der Seite: "Jahresgesamtzeit" + - Zeigt: Soll-Stunden, Geleistete Stunden, Verbleibende Stunden + - Fortschrittsbalken visualisiert den Status + +### Administrative Aufgaben + +#### Manuelle Stundeneintragung + +Für Korrekturen oder Sonderfälle: + +1. Gehe zu "Zeiteinträge" Tab +2. Sektion "Manuelle Stundeneintragung" +3. Mitarbeiter auswählen +4. Datum eingeben +5. Stunden eingeben: + - **Positive Werte** (z.B. `2.5`): Werden **abgezogen** (z.B. Krankheit, Urlaub) + - **Negative Werte** (z.B. `-3.0`): Werden **hinzugerechnet** (z.B. Nachholung, Sondereinsatz) + +#### Zeiteinträge bearbeiten + +1. Gehe zu "Zeiteinträge" Tab +2. Liste aller Einträge mit Bearbeitungsmöglichkeit +3. "Bearbeiten" klicken: + - Datum ändern + - Start-/Endzeit anpassen + - Typ ändern (Unterricht/Pause/Manuell) +4. Speichern oder Löschen + +#### PDF-Export + +1. Gehe zu "Zeiteinträge" Tab +2. Sektion "Jahresübersicht" +3. Klicke "PDF exportieren" +4. PDF enthält: + - Schuljahrname und Zeitraum + - Alle Mitarbeiter mit Soll/Ist/Differenz + - Generierungsdatum + +## 🔌 API-Dokumentation + +### Authentifizierung + +Alle geschützten Endpunkte erfordern einen JWT-Token im Header: + +``` +Authorization: Bearer +``` + +### Öffentliche Endpunkte + +#### `POST /api/login` + +Benutzer-Anmeldung + +**Request:** + +```json +{ + "username": "admin", + "password": "" +} +``` + +**Response:** + +```json +{ + "token": "eyJhbGc...", + "username": "admin", + "is_admin": true +} +``` + +### Geschützte Endpunkte (Benutzer) + +#### `GET /api/schedules` + +Alle Stundenpläne abrufen + +#### `GET /api/my-time-entries` + +Eigene Zeiteinträge abrufen + +#### `POST /api/time-entries/batch` + +Mehrere Zeiteinträge auf einmal erstellen + +**Request:** + +```json +{ + "entries": [ + { + "schedule_id": 1, + "date": "2024-11-04", + "type": "lesson", + "start_time": "08:00", + "end_time": "09:00" + } + ] +} +``` + +#### `DELETE /api/my-time-entries/week?year=2024&week=45` + +Alle eigenen Einträge einer Woche löschen + +#### `GET /api/week-dates?year=2024&week=45` + +Datumsbereich einer Kalenderwoche abrufen + +#### `GET /api/week-has-entries?year=2024&week=45` + +Prüfen, ob für eine Woche bereits Einträge existieren + +#### `GET /api/yearly-hours-summary` + +Jahresübersicht für alle Benutzer + +#### `GET /api/my-info` + +Eigene Benutzerinformationen abrufen + +#### `GET /api/school-year/active` + +Aktives Schuljahr abrufen + +### Admin-Endpunkte + +#### Stundenplan-Verwaltung + +- `POST /api/admin/schedules` - Stundenplan erstellen +- `DELETE /api/admin/schedules/delete?id=1` - Stundenplan löschen + +#### Benutzerverwaltung + +- `POST /api/admin/users` - Benutzer erstellen +- `GET /api/admin/users/list` - Alle Benutzer auflisten +- `PUT /api/admin/users/:id` - Benutzer bearbeiten (Arbeitsstunden) +- `PUT /api/admin/users/:id/reset-password` - Passwort zurücksetzen +- `DELETE /api/admin/users/delete?id=2` - Benutzer löschen + +#### Zeiteintrags-Verwaltung + +- `GET /api/admin/time-entries` - Alle Zeiteinträge +- `PUT /api/admin/time-entries/:id` - Zeiteintrag bearbeiten +- `DELETE /api/admin/time-entries/:id` - Zeiteintrag löschen +- `POST /api/admin/time-entry` - Manueller Zeiteintrag + +#### Schuljahrverwaltung + +- `GET /api/admin/school-years` - Alle Schuljahre +- `POST /api/admin/school-years` - Schuljahr erstellen +- `PUT /api/admin/school-years/:id/activate` - Schuljahr aktivieren +- `DELETE /api/admin/school-years/:id` - Schuljahr löschen + +#### Berichte + +- `GET /api/admin/yearly-summary/pdf` - Jahresübersicht als PDF + +## 🏗 Architektur + +### Backend-Struktur + +``` +. +├── main.go # Einstiegspunkt, Server-Setup +├── handlers.go # HTTP-Handler für alle Endpunkte +├── middleware.go # JWT-Auth, Admin-Check, Rate-Limiting +├── database.go # Datenbanklogik und Queries +├── models.go # Datenstrukturen +├── pdf.go # PDF-Generierung +├── docker-compose.yml # Docker-Orchestrierung +└── Dockerfile # Container-Image +``` + +### Frontend-Struktur + +``` +static/ +├── Main.elm # Elm-Hauptanwendung +│ ├── Model # Anwendungszustand +│ ├── Update # Zustandsänderungen +│ ├── View # UI-Rendering +│ └── Subscriptions # Event-Handling +├── index.html # HTML-Wrapper +└── elm.js # Kompilierte Elm-Anwendung +``` + +### Datenbank-Schema + +#### Tabelle: `users` + +```sql +id INTEGER PRIMARY KEY +username TEXT UNIQUE NOT NULL +password TEXT NOT NULL (bcrypt-hashed) +is_admin BOOLEAN DEFAULT 0 +yearly_hours REAL DEFAULT 60.0 +created_at DATETIME +``` + +#### Tabelle: `schedules` + +```sql +id INTEGER PRIMARY KEY +day_of_week INTEGER (0=Montag, 4=Freitag) +start_time TEXT (HH:MM) +end_time TEXT (HH:MM) +type TEXT (lesson/break) +title TEXT +created_at DATETIME +``` + +#### Tabelle: `time_entries` + +```sql +id INTEGER PRIMARY KEY +user_id INTEGER (FK -> users) +schedule_id INTEGER (FK -> schedules) +date TEXT (YYYY-MM-DD) +type TEXT (lesson/break/manual) +start_time TEXT +end_time TEXT +created_at DATETIME +``` + +#### Tabelle: `school_years` + +```sql +id INTEGER PRIMARY KEY +name TEXT UNIQUE +start_date DATE +end_date DATE +is_active BOOLEAN DEFAULT 0 +created_at DATETIME +``` + +#### Tabelle: `audit_logs` + +```sql +id INTEGER PRIMARY KEY +user_id INTEGER +action TEXT +details TEXT +created_at DATETIME +``` + +### Stundenberechnung + +Die Anwendung berechnet Arbeitsstunden nach folgenden Regeln: + +1. **Unterrichtsstunden** (`type: "lesson"`): **1,0 Stunde** (fest) +2. **Pausen** (`type: "break"`): Differenz zwischen End- und Startzeit +3. **Manuelle Einträge** (`type: "manual"`): + - Positive Werte werden abgezogen + - Negative Werte werden hinzugerechnet + +**Beispiel:** + +``` +Unterricht 08:00-09:00 → 1,0h +Pause 09:00-09:15 → 0,25h +Manueller Eintrag: 2.5 → -2,5h (Abzug) +Manueller Eintrag: -1.0 → +1,0h (Zuschlag) +``` + +### ISO-Kalenderwochen + +Die Anwendung verwendet ISO 8601 Kalenderwochen: + +- Woche beginnt am Montag +- Erste Woche des Jahres enthält den 4. Januar +- 52 oder 53 Wochen pro Jahr + +## 🔒 Sicherheit + +### Implementierte Sicherheitsmaßnahmen + +1. **Passwort-Hashing**: bcrypt mit Default-Cost (10 Runden) +2. **JWT-Authentifizierung**: HMAC-SHA256 mit 2h Ablaufzeit +3. **CORS-Protection**: Konfigurierbare Origins +4. **Rate Limiting**: 5 Login-Versuche pro Minute pro IP +5. **SQL-Injection-Schutz**: Prepared Statements +6. **Admin-Schutz**: Admin-Benutzer (ID=1) kann nicht gelöscht werden +7. **Input-Validierung**: Server- und clientseitig + +### Best Practices + +1. **JWT_SECRET ändern**: Verwenden Sie einen starken, zufälligen String (64+ Zeichen) + + ```bash + openssl rand -base64 64 + ``` + +2. **HTTPS verwenden**: In Produktion immer TLS/SSL aktivieren + + ```env + ENVIRONMENT=production + ``` + +3. **Regelmäßige Backups**: Sichern Sie die Datenbank täglich + +4. **Starke Passwörter**: Mindestens 12 Zeichen, Mix aus Groß-/Kleinbuchstaben, Zahlen, Sonderzeichen + +5. **Updates**: Halten Sie Dependencies aktuell + ```bash + go get -u ./... + docker-compose pull + ``` + +## 💾 Backup & Wartung + +### Datenbank-Backup + +**Docker-Setup:** + +```bash +# Backup erstellen +docker exec school-timetracking sqlite3 /data/timetracking.db ".backup '/data/backup-$(date +%Y%m%d).db'" +docker cp school-timetracking:/data/backup-$(date +%Y%m%d).db ./backups/ + +# Backup wiederherstellen +docker cp ./backups/backup-20241108.db school-timetracking:/data/timetracking.db +docker-compose restart +``` + +**Lokales Setup:** + +```bash +# Backup +sqlite3 timetracking.db ".backup 'backup-$(date +%Y%m%d).db'" + +# Wiederherstellen +cp backup-20241108.db timetracking.db +``` + +### Automatisches Backup (Cron) + +Erstellen Sie ein Backup-Script `backup.sh`: + +```bash +#!/bin/bash +BACKUP_DIR="/path/to/backups" +DATE=$(date +%Y%m%d-%H%M%S) + +docker exec school-timetracking sqlite3 /data/timetracking.db ".backup '/data/backup-$DATE.db'" +docker cp school-timetracking:/data/backup-$DATE.db $BACKUP_DIR/ + +# Alte Backups löschen (älter als 30 Tage) +find $BACKUP_DIR -name "backup-*.db" -mtime +30 -delete +``` + +Crontab-Eintrag (täglich um 3 Uhr): + +``` +0 3 * * * /path/to/backup.sh +``` + +### Log-Rotation + +Docker Compose Log-Größe begrenzen: + +```yaml +services: + timetracking: + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" +``` + +### Updates durchführen + +```bash +# Repository aktualisieren +git pull + +# Neue Images bauen +docker-compose build + +# Container neu starten +docker-compose down +docker-compose up -d + +# Logs prüfen +docker-compose logs -f +``` + +## 🐛 Fehlerbehebung + +### Anwendung startet nicht + +**Problem**: Container startet nicht + +```bash +docker-compose logs timetracking +``` + +**Häufige Ursachen:** + +- `JWT_SECRET` nicht gesetzt → Setzen Sie die Variable in `.env` +- Port 8080 bereits belegt → Ändern Sie `PORT` in `.env` +- Datenbank-Berechtigungen → Prüfen Sie Volume-Permissions + +### Login funktioniert nicht + +**Problem**: "Invalid credentials" trotz korrekten Passworts + +**Lösung:** + +```bash +# Admin-Passwort zurücksetzen +docker exec -it school-timetracking /bin/sh +sqlite3 /data/timetracking.db + +# In SQLite: +UPDATE users SET password = '$2a$10$...' WHERE username = 'admin'; +.exit +``` + +Generieren Sie einen neuen bcrypt-Hash: + +```bash +# Mit Go +echo -n 'neues-passwort' | go run -e 'import "golang.org/x/crypto/bcrypt"; pass, _ := bcrypt.GenerateFromPassword([]byte(os.Args[1]), bcrypt.DefaultCost); fmt.Println(string(pass))' +``` + +### Zeiteinträge werden nicht gespeichert + +**Problem**: Fehler beim Speichern von Zeiteinträgen + +**Prüfen:** + +1. Browser-Konsole auf JavaScript-Fehler prüfen +2. Backend-Logs prüfen: `docker-compose logs -f` +3. JWT-Token gültig? → Neu anmelden +4. Datenbank-Speicherplatz verfügbar? + +### PDF-Export schlägt fehl + +**Problem**: "Failed to generate PDF" + +**Lösung:** + +```bash +# Container-Logs prüfen +docker-compose logs timetracking | grep -i pdf + +# Häufig: Fehlende Schriftarten +# → Rebuilden Sie das Image +docker-compose build --no-cache +``` + +### Responsive Layout funktioniert nicht + +**Problem**: Mobile Ansicht nicht korrekt + +**Lösung:** + +- Browser-Cache leeren +- Elm neu kompilieren: + ```bash + cd static + elm make Main.elm --output=elm.js --optimize + ``` + +### Datenbankfehler nach Update + +**Problem**: "Database schema error" + +**Lösung:** + +```bash +# Backup erstellen +docker cp school-timetracking:/data/timetracking.db ./backup-pre-migration.db + +# Migration manuell durchführen +docker exec -it school-timetracking sqlite3 /data/timetracking.db + +# Fehlende Spalten hinzufügen (Beispiel) +ALTER TABLE users ADD COLUMN yearly_hours REAL DEFAULT 60.0; +.exit +``` + +## 📝 Häufig gestellte Fragen (FAQ) + +**Q: Wie ändere ich die Standard-Arbeitsstunden?** +A: Als Admin unter "Benutzer" → Benutzer auswählen → "Arbeitszeit" klicken → Neue Stundenzahl eingeben. + +**Q: Können Mitarbeiter vergangene Wochen bearbeiten?** +A: Ja, über die Wochen-Navigation können alle Wochen bearbeitet werden (sofern im aktuellen Schuljahr). + +**Q: Wie funktioniert die Schuljahr-Berechnung?** +A: Das System berechnet Stunden nur für Einträge innerhalb des aktiven Schuljahres (Start- bis Enddatum). + +**Q: Was passiert bei 53-Wochen-Jahren?** +A: Das System unterstützt ISO-Kalenderwochen inklusive Woche 53 automatisch. + +**Q: Kann ich mehrere Schuljahre parallel nutzen?** +A: Es kann immer nur ein Schuljahr aktiv sein. Berechnungen basieren auf diesem Zeitraum. + +**Q: Wie funktionieren negative Stunden?** +A: Negative Werte bei manuellen Einträgen werden **zum** Stundenkonto **hinzugerechnet** (für Zusatzleistungen). + +## 📄 Lizenz + +Todo + +## 👥 Kontakt & Support + +Todo + +--- + +**Version**: 1.5.0 +**Letztes Update**: November 2025 +**Entwickelt für**: Schulen zur Verwaltung von Flexistunden pädagogischer Mitarbeiter diff --git a/backend/database.go b/backend/database.go index 32ffd22..66f3e54 100644 --- a/backend/database.go +++ b/backend/database.go @@ -24,17 +24,19 @@ func InitDB(filepath string) *sql.DB { } createTables(db) + createIndexes(db) return db } func createTables(db *sql.DB) { queries := []string{ `CREATE TABLE IF NOT EXISTS users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - username TEXT UNIQUE NOT NULL, - password TEXT NOT NULL, - is_admin BOOLEAN NOT NULL DEFAULT 0, - weekly_hours REAL NOT NULL DEFAULT 40.0 + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT 0, + yearly_hours REAL NOT NULL DEFAULT 60.0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS schedules ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -42,7 +44,8 @@ func createTables(db *sql.DB) { start_time TEXT NOT NULL, end_time TEXT NOT NULL, type TEXT NOT NULL, - title TEXT NOT NULL + title TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, `CREATE TABLE IF NOT EXISTS time_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -56,6 +59,21 @@ func createTables(db *sql.DB) { FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (schedule_id) REFERENCES schedules(id) )`, + `CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + action TEXT NOT NULL, + details TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, + `CREATE TABLE IF NOT EXISTS school_years ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + )`, } for _, query := range queries { @@ -66,7 +84,7 @@ func createTables(db *sql.DB) { hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) _, err := db.Exec(` - INSERT OR IGNORE INTO users (id, username, password, is_admin, weekly_hours) + INSERT OR IGNORE INTO users (id, username, password, is_admin, yearly_hours) VALUES (?, ?, ?, ?, ?)`, 1, "admin", string(hash), true, 40.0, ) @@ -75,57 +93,28 @@ func createTables(db *sql.DB) { } } -// func createTables(db *sql.DB) { -// queries := []string{ -// `CREATE TABLE IF NOT EXISTS users ( -// id INTEGER PRIMARY KEY AUTOINCREMENT, -// username TEXT UNIQUE NOT NULL, -// password TEXT NOT NULL, -// is_admin BOOLEAN NOT NULL DEFAULT 0 -// )`, -// `CREATE TABLE IF NOT EXISTS schedules ( -// id INTEGER PRIMARY KEY AUTOINCREMENT, -// day_of_week INTEGER NOT NULL, -// start_time TEXT NOT NULL, -// end_time TEXT NOT NULL, -// type TEXT NOT NULL, -// title TEXT NOT NULL -// )`, -// `CREATE TABLE IF NOT EXISTS time_entries ( -// id INTEGER PRIMARY KEY AUTOINCREMENT, -// user_id INTEGER NOT NULL, -// schedule_id INTEGER NOT NULL, -// date TEXT NOT NULL, -// type TEXT NOT NULL, -// start_time TEXT NOT NULL, -// end_time TEXT NOT NULL, -// created_at DATETIME DEFAULT CURRENT_TIMESTAMP, -// FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, -// FOREIGN KEY (schedule_id) REFERENCES schedules(id) -// )`, -// } +func createIndexes(db *sql.DB) { + indexes := []string{ + `CREATE INDEX IF NOT EXISTS idx_time_entries_user_date ON time_entries(user_id, date)`, + `CREATE INDEX IF NOT EXISTS idx_time_entries_date ON time_entries(date)`, + `CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`, + `CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)`, + `CREATE INDEX IF NOT EXISTS idx_school_years_active ON school_years(is_active)`, + `CREATE INDEX IF NOT EXISTS idx_school_years_dates ON school_years(start_date, end_date)`, + } -// for _, query := range queries { -// if _, err := db.Exec(query); err != nil { -// log.Fatal(err) -// } -// } - -// hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) -// _, err := db.Exec(` -// INSERT OR IGNORE INTO users (id, username, password, is_admin) -// VALUES (?, ?, ?, ?)`, -// 1, "admin", string(hash), true, -// ) -// if err != nil { -// log.Fatal(err) -// } -// } + for _, idx := range indexes { + if _, err := db.Exec(idx); err != nil { + log.Printf("Warning: Failed to create index: %v", err) + } + } +} func GetUserByUsername(db *sql.DB, username string) (*User, error) { user := &User{} - err := db.QueryRow("SELECT id, username, password, is_admin, weekly_hours FROM users WHERE username = ?", username). - Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.WeeklyHours) + err := db.QueryRow("SELECT id, username, password, is_admin, yearly_hours FROM users WHERE username = ?", username). + Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.YearlyHours) if err != nil { return nil, err } @@ -134,22 +123,22 @@ func GetUserByUsername(db *sql.DB, username string) (*User, error) { func GetUserByID(db *sql.DB, userID int) (*User, error) { user := &User{} - err := db.QueryRow("SELECT id, username, password, is_admin, weekly_hours FROM users WHERE id = ?", userID). - Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.WeeklyHours) + err := db.QueryRow("SELECT id, username, password, is_admin, yearly_hours FROM users WHERE id = ?", userID). + Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.YearlyHours) if err != nil { return nil, err } return user, nil } -func CreateUser(db *sql.DB, username, hashedPassword string, isAdmin bool, weeklyHours float64) error { - _, err := db.Exec("INSERT INTO users (username, password, is_admin, weekly_hours) VALUES (?, ?, ?, ?)", - username, hashedPassword, isAdmin, weeklyHours) +func CreateUser(db *sql.DB, username, hashedPassword string, isAdmin bool, yearlyHours float64) error { + _, err := db.Exec("INSERT INTO users (username, password, is_admin, yearly_hours) VALUES (?, ?, ?, ?)", + username, hashedPassword, isAdmin, yearlyHours) return err } func GetAllUsers(db *sql.DB) ([]User, error) { - rows, err := db.Query("SELECT id, username, is_admin, weekly_hours FROM users") + rows, err := db.Query("SELECT id, username, is_admin, yearly_hours FROM users ORDER BY username") if err != nil { return nil, err } @@ -158,7 +147,7 @@ func GetAllUsers(db *sql.DB) ([]User, error) { var users []User for rows.Next() { var u User - if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin, &u.WeeklyHours); err != nil { + if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin, &u.YearlyHours); err != nil { continue } users = append(users, u) @@ -166,43 +155,9 @@ func GetAllUsers(db *sql.DB) ([]User, error) { return users, nil } -// func GetUserByUsername(db *sql.DB, username string) (*User, error) { -// user := &User{} -// err := db.QueryRow("SELECT id, username, password, is_admin FROM users WHERE username = ?", username). -// Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin) -// if err != nil { -// return nil, err -// } -// return user, nil -// } - -// func CreateUser(db *sql.DB, username, hashedPassword string, isAdmin bool) error { -// _, err := db.Exec("INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)", -// username, hashedPassword, isAdmin) -// return err -// } - -// func GetAllUsers(db *sql.DB) ([]User, error) { -// rows, err := db.Query("SELECT id, username, is_admin FROM users") -// if err != nil { -// return nil, err -// } -// defer rows.Close() - -// var users []User -// for rows.Next() { -// var u User -// if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin); err != nil { -// continue -// } -// users = append(users, u) -// } -// return users, nil -// } - -func UpdateUser(db *sql.DB, userID int, weeklyHours float64) error { - _, err := db.Exec("UPDATE users SET weekly_hours = ? WHERE id = ?", - weeklyHours, userID) +func UpdateUser(db *sql.DB, userID int, yearlyHours float64) error { + _, err := db.Exec("UPDATE users SET yearly_hours = ? WHERE id = ?", + yearlyHours, userID) return err } @@ -267,10 +222,10 @@ func CreateTimeEntry(db *sql.DB, entry *TimeEntry) error { func GetTimeEntriesByUser(db *sql.DB, userID int) ([]TimeEntry, error) { rows, err := db.Query(` - SELECT te.id, te.user_id, te.schedule_id, te.date, te.type, te.start_time, te.end_time, te.created_at, u.username + SELECT te.id, te.user_id, te.schedule_id, te.date, te.type, te.start_time, te.end_time, te.created_at, u.username FROM time_entries te JOIN users u ON te.user_id = u.id - WHERE te.user_id = ? + WHERE te.user_id = ? ORDER BY te.date DESC, te.created_at DESC `, userID) if err != nil { @@ -291,7 +246,7 @@ func GetTimeEntriesByUser(db *sql.DB, userID int) ([]TimeEntry, error) { func GetAllTimeEntries(db *sql.DB) ([]TimeEntry, error) { rows, err := db.Query(` - SELECT te.id, te.user_id, te.schedule_id, te.date, te.type, te.start_time, te.end_time, te.created_at, u.username + SELECT te.id, te.user_id, te.schedule_id, te.date, te.type, te.start_time, te.end_time, te.created_at, u.username FROM time_entries te JOIN users u ON te.user_id = u.id ORDER BY te.date DESC, te.created_at DESC @@ -314,34 +269,37 @@ func GetAllTimeEntries(db *sql.DB) ([]TimeEntry, error) { func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) { rows, err := db.Query(` - SELECT - te.user_id, - u.username, - te.date, - te.start_time, - te.end_time, - te.type, - u.weekly_hours - FROM time_entries te - JOIN users u ON te.user_id = u.id - ORDER BY te.date DESC - `) + SELECT + te.user_id, + u.username, + te.date, + te.start_time, + te.end_time, + te.type, + u.yearly_hours + FROM time_entries te + JOIN users u ON te.user_id = u.id + ORDER BY te.date DESC + `) if err != nil { return nil, err } defer rows.Close() hoursMap := make(map[string]*WeeklyHours) + userYearlyHours := make(map[int]float64) for rows.Next() { var userID int var username, dateStr, startTime, endTime, entryType string - var expectedWeeklyHours float64 + var yearlyHours float64 - if err := rows.Scan(&userID, &username, &dateStr, &startTime, &endTime, &entryType, &expectedWeeklyHours); err != nil { + if err := rows.Scan(&userID, &username, &dateStr, &startTime, &endTime, &entryType, &yearlyHours); err != nil { continue } + userYearlyHours[userID] = yearlyHours + t, err := time.Parse("2006-01-02", dateStr) if err != nil { continue @@ -349,32 +307,38 @@ func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) { year, week := t.ISOWeek() - var hours float64 - if entryType == "lesson" { - hours = 1.0 - } else { - hours = calculateHoursDiff(startTime, endTime) + entry := TimeEntry{ + Type: entryType, + StartTime: startTime, + EndTime: endTime, } + hours := calculateHours(entry) key := fmt.Sprintf("%d_%d_%d", userID, year, week) - if existing, exists := hoursMap[key]; exists { existing.TotalHours += hours } else { hoursMap[key] = &WeeklyHours{ - UserID: userID, - Username: username, - Year: year, - Week: week, - TotalHours: hours, - ExpectedHours: expectedWeeklyHours, - RemainingHours: expectedWeeklyHours - hours, + UserID: userID, + Username: username, + Year: year, + Week: week, + TotalHours: hours, } } } + yearlyTotals := make(map[int]float64) for _, h := range hoursMap { - h.RemainingHours = h.ExpectedHours - h.TotalHours + yearlyTotals[h.UserID] += h.TotalHours + } + + for _, h := range hoursMap { + h.YearlyTarget = userYearlyHours[h.UserID] + h.YearlyActual = yearlyTotals[h.UserID] + + h.WeeklyTarget = h.YearlyTarget / 45.0 + h.RemainingYearly = h.YearlyTarget - h.YearlyActual } var result []WeeklyHours @@ -395,74 +359,6 @@ func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) { return result, nil } -// func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) { -// rows, err := db.Query(` -// SELECT -// te.user_id, -// u.username, -// te.date, -// te.start_time, -// te.end_time -// FROM time_entries te -// JOIN users u ON te.user_id = u.id -// ORDER BY te.date DESC -// `) -// if err != nil { -// return nil, err -// } -// defer rows.Close() - -// hoursMap := make(map[string]*WeeklyHours) - -// for rows.Next() { -// var userID int -// var username, dateStr, startTime, endTime string - -// if err := rows.Scan(&userID, &username, &dateStr, &startTime, &endTime); err != nil { -// continue -// } - -// t, err := time.Parse("2006-01-02", dateStr) -// if err != nil { -// continue -// } - -// year, week := t.ISOWeek() - -// hours := calculateHoursDiff(startTime, endTime) - -// key := fmt.Sprintf("%d_%d_%d", userID, year, week) - -// if existing, exists := hoursMap[key]; exists { -// existing.TotalHours += hours -// } else { -// hoursMap[key] = &WeeklyHours{ -// UserID: userID, -// Username: username, -// Year: year, -// Week: week, -// TotalHours: hours, -// } -// } -// } - -// var result []WeeklyHours -// for _, h := range hoursMap { -// result = append(result, *h) -// } - -// sort.Slice(result, func(i, j int) bool { -// if result[i].Year != result[j].Year { -// return result[i].Year > result[j].Year -// } -// if result[i].Week != result[j].Week { -// return result[i].Week > result[j].Week -// } -// return result[i].Username < result[j].Username -// }) - -// return result, nil -// } func calculateHoursDiff(startTime, endTime string) float64 { parseTime := func(timeStr string) float64 { parts := strings.Split(timeStr, ":") @@ -489,14 +385,6 @@ func calculateHoursDiff(startTime, endTime string) float64 { return 0 } -// func DeleteUser(db *sql.DB, id int) error { -// if id == 1 { -// return fmt.Errorf("cannot delete admin user") -// } -// _, err := db.Exec("DELETE FROM users WHERE id = ?", id) -// return err -// } - func DeleteTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, week int) error { dates := calculateWeekDates(year, week) @@ -506,8 +394,8 @@ func DeleteTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, week int) } query := ` - DELETE FROM time_entries - WHERE user_id = ? + DELETE FROM time_entries + WHERE user_id = ? AND date IN (?, ?, ?, ?, ?) ` _, err := db.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4]) @@ -523,16 +411,15 @@ func CheckUserHasEntriesForWeek(db *sql.DB, userID int, year int, week int) (boo } query := ` - SELECT COUNT(*) - FROM time_entries - WHERE user_id = ? + SELECT COUNT(*) + FROM time_entries + WHERE user_id = ? AND date IN (?, ?, ?, ?, ?) ` var count int err := db.QueryRow(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4]).Scan(&count) - if err != nil { log.Printf("Error checking entries: %v", err) return false, err @@ -540,3 +427,200 @@ func CheckUserHasEntriesForWeek(db *sql.DB, userID int, year int, week int) (boo return count > 0, nil } + +func GetActiveSchoolYear(db *sql.DB) (*SchoolYear, error) { + var sy SchoolYear + err := db.QueryRow(` + SELECT id, name, start_date, end_date, is_active, created_at + FROM school_years + WHERE is_active = 1 + `).Scan(&sy.ID, &sy.Name, &sy.StartDate, &sy.EndDate, &sy.IsActive, &sy.CreatedAt) + + if err == sql.ErrNoRows { + return nil, nil + } + return &sy, err +} + +func GetAllSchoolYears(db *sql.DB) ([]SchoolYear, error) { + rows, err := db.Query(` + SELECT id, name, start_date, end_date, is_active, created_at + FROM school_years + ORDER BY start_date DESC + `) + if err != nil { + return nil, err + } + defer rows.Close() + + years := []SchoolYear{} + for rows.Next() { + var sy SchoolYear + if err := rows.Scan(&sy.ID, &sy.Name, &sy.StartDate, &sy.EndDate, &sy.IsActive, &sy.CreatedAt); err != nil { + continue + } + years = append(years, sy) + } + return years, rows.Err() +} + +func CreateSchoolYear(db *sql.DB, name, startDate, endDate string) error { + _, err := db.Exec(` + INSERT INTO school_years (name, start_date, end_date, is_active) + VALUES (?, ?, ?, 0) + `, name, startDate, endDate) + return err +} + +func SetActiveSchoolYear(db *sql.DB, id int) error { + tx, err := db.Begin() + if err != nil { + return err + } + + if _, err := tx.Exec("UPDATE school_years SET is_active = 0"); err != nil { + tx.Rollback() + return err + } + + if _, err := tx.Exec("UPDATE school_years SET is_active = 1 WHERE id = ?", id); err != nil { + tx.Rollback() + return err + } + + return tx.Commit() +} + +func GetYearlyHoursSummary(db *sql.DB) ([]WeeklyHours, error) { + schoolYear, err := GetActiveSchoolYear(db) + if err != nil || schoolYear == nil { + return []WeeklyHours{}, err + } + + users, err := GetAllUsers(db) + if err != nil { + return []WeeklyHours{}, err + } + + rows, err := db.Query(` + SELECT user_id, date, start_time, end_time, type + FROM time_entries + WHERE date >= ? AND date <= ? + ORDER BY date DESC + `, schoolYear.StartDate, schoolYear.EndDate) + if err != nil { + return []WeeklyHours{}, err + } + defer rows.Close() + + userTotals := make(map[int]float64) + + for rows.Next() { + var userID int + var date, startTime, endTime, entryType string + + if err := rows.Scan(&userID, &date, &startTime, &endTime, &entryType); err != nil { + continue + } + + entry := TimeEntry{ + Type: entryType, + StartTime: startTime, + EndTime: endTime, + } + hours := calculateHours(entry) + userTotals[userID] += hours + } + + var result []WeeklyHours + for _, user := range users { + if !user.IsAdmin { + total := userTotals[user.ID] + remaining := user.YearlyHours - total + + result = append(result, WeeklyHours{ + UserID: user.ID, + Username: user.Username, + Year: 0, + Week: 0, + TotalHours: total, + YearlyTarget: user.YearlyHours, + YearlyActual: total, + RemainingYearly: remaining, + }) + } + } + + return result, nil +} + +func CreateManualTimeEntry(db *sql.DB, entry *TimeEntry, hours float64) error { + entry.StartTime = fmt.Sprintf("%.2f", hours) + entry.EndTime = "manual" + entry.Type = "manual" + + _, err := db.Exec(` + INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) + VALUES (?, 0, ?, ?, ?, ?) + `, entry.UserID, entry.Date, entry.Type, entry.StartTime, entry.EndTime) + + return err +} + +func calculateHours(entry TimeEntry) float64 { + if entry.Type == "lesson" { + return 1.0 + } else if entry.Type == "manual" { + hours, err := strconv.ParseFloat(entry.StartTime, 64) + if err != nil { + return 0 + } + return hours + } else { + return calculateHoursDiff(entry.StartTime, entry.EndTime) + } +} + +func DeleteSchoolYear(db *sql.DB, id int) error { + var isActive bool + err := db.QueryRow("SELECT is_active FROM school_years WHERE id = ?", id).Scan(&isActive) + if err != nil { + return err + } + + if isActive { + return fmt.Errorf("cannot delete active school year") + } + + result, err := db.Exec("DELETE FROM school_years WHERE id = ? AND is_active = 0", id) + if err != nil { + return err + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return err + } + + if rowsAffected == 0 { + return sql.ErrNoRows + } + + return nil +} + +func DeleteNonManualTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, week int) error { + dates := calculateWeekDates(year, week) + var dateList []string + for day := 0; day <= 4; day++ { + dateList = append(dateList, dates.Dates[fmt.Sprint(day)]) + } + + query := `DELETE FROM time_entries + WHERE user_id = ? + AND type != 'manual' + AND date IN (?, ?, ?, ?, ?)` + + _, err := db.Exec(query, userID, dateList[0], dateList[1], dateList[2], dateList[3], dateList[4]) + return err +} diff --git a/backend/errors.go b/backend/errors.go new file mode 100644 index 0000000..7ee17bd --- /dev/null +++ b/backend/errors.go @@ -0,0 +1,205 @@ +package main + +import ( + "fmt" + "net/http" +) + +type ErrorCode string + +const ( + // Authentifizierung + ErrInvalidCredentials ErrorCode = "INVALID_CREDENTIALS" + ErrUnauthorized ErrorCode = "UNAUTHORIZED" + ErrTokenExpired ErrorCode = "TOKEN_EXPIRED" + ErrAccessDenied ErrorCode = "ACCESS_DENIED" + + // Validierung + ErrInvalidInput ErrorCode = "INVALID_INPUT" + ErrMissingField ErrorCode = "MISSING_FIELD" + ErrInvalidDateFormat ErrorCode = "INVALID_DATE_FORMAT" + ErrInvalidTimeFormat ErrorCode = "INVALID_TIME_FORMAT" + + // Ressourcen + ErrNotFound ErrorCode = "NOT_FOUND" + ErrAlreadyExists ErrorCode = "ALREADY_EXISTS" + ErrCannotDelete ErrorCode = "CANNOT_DELETE" + ErrProtectedUser ErrorCode = "PROTECTED_USER" + ErrNoActiveSchool ErrorCode = "NO_ACTIVE_SCHOOL_YEAR" + + // Datenbank + ErrDatabase ErrorCode = "DATABASE_ERROR" + ErrTransaction ErrorCode = "TRANSACTION_ERROR" + ErrQueryFailed ErrorCode = "QUERY_FAILED" + + // Server + ErrInternal ErrorCode = "INTERNAL_ERROR" + ErrServiceUnavail ErrorCode = "SERVICE_UNAVAILABLE" +) + +type AppError struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` + UserMsg string `json:"user_message"` + HTTPStatus int `json:"-"` + Internal error `json:"-"` +} + +func (e *AppError) Error() string { + if e.Internal != nil { + return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Internal) + } + return fmt.Sprintf("[%s] %s", e.Code, e.Message) +} + +func NewAppError(code ErrorCode, message, userMsg string, httpStatus int, internal error) *AppError { + return &AppError{ + Code: code, + Message: message, + UserMsg: userMsg, + HTTPStatus: httpStatus, + Internal: internal, + } +} + +func ErrInvalidCredentialsMsg() *AppError { + return NewAppError( + ErrInvalidCredentials, + "Invalid username or password", + "Benutzername oder Passwort ungültig", + http.StatusUnauthorized, + nil, + ) +} + +func ErrUnauthorizedMsg() *AppError { + return NewAppError( + ErrUnauthorized, + "Unauthorized access", + "Keine Berechtigung für diese Aktion", + http.StatusUnauthorized, + nil, + ) +} + +func ErrTokenExpiredMsg() *AppError { + return NewAppError( + ErrTokenExpired, + "Token has expired", + "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an", + http.StatusUnauthorized, + nil, + ) +} + +func ErrAccessDeniedMsg() *AppError { + return NewAppError( + ErrAccessDenied, + "Access denied - admin privileges required", + "Zugriff verweigert. Administrator-Rechte erforderlich", + http.StatusForbidden, + nil, + ) +} + +func ErrInvalidInputMsg(field string) *AppError { + return NewAppError( + ErrInvalidInput, + fmt.Sprintf("Invalid input for field: %s", field), + fmt.Sprintf("Ungültige Eingabe im Feld: %s", field), + http.StatusBadRequest, + nil, + ) +} + +func ErrMissingFieldMsg(field string) *AppError { + return NewAppError( + ErrMissingField, + fmt.Sprintf("Required field missing: %s", field), + fmt.Sprintf("Pflichtfeld fehlt: %s", field), + http.StatusBadRequest, + nil, + ) +} + +func ErrNotFoundMsg(resource string) *AppError { + return NewAppError( + ErrNotFound, + fmt.Sprintf("%s not found", resource), + fmt.Sprintf("%s nicht gefunden", resource), + http.StatusNotFound, + nil, + ) +} + +func ErrAlreadyExistsMsg(resource string) *AppError { + return NewAppError( + ErrAlreadyExists, + fmt.Sprintf("%s already exists", resource), + fmt.Sprintf("%s existiert bereits", resource), + http.StatusConflict, + nil, + ) +} + +func ErrCannotDeleteMsg(resource, reason string) *AppError { + return NewAppError( + ErrCannotDelete, + fmt.Sprintf("Cannot delete %s: %s", resource, reason), + fmt.Sprintf("%s kann nicht gelöscht werden: %s", resource, reason), + http.StatusBadRequest, + nil, + ) +} + +func ErrProtectedUserMsg() *AppError { + return NewAppError( + ErrProtectedUser, + "Cannot modify protected admin user", + "Der Admin-Benutzer ist geschützt und kann nicht geändert werden", + http.StatusForbidden, + nil, + ) +} + +func ErrNoActiveSchoolYearMsg() *AppError { + return NewAppError( + ErrNoActiveSchool, + "No active school year configured", + "Kein aktives Schuljahr konfiguriert. Bitte aktivieren Sie ein Schuljahr", + http.StatusNotFound, + nil, + ) +} + +func ErrDatabaseMsg(internal error) *AppError { + return NewAppError( + ErrDatabase, + "Database operation failed", + "Ein Datenbankfehler ist aufgetreten. Bitte versuchen Sie es erneut", + http.StatusInternalServerError, + internal, + ) +} + +func ErrInternalMsg(internal error) *AppError { + return NewAppError( + ErrInternal, + "Internal server error", + "Ein interner Fehler ist aufgetreten. Bitte versuchen Sie es später erneut", + http.StatusInternalServerError, + internal, + ) +} + +type ErrorResponse struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` +} + +func (e *AppError) ToResponse() ErrorResponse { + return ErrorResponse{ + Code: e.Code, + Message: e.UserMsg, + } +} diff --git a/backend/go.mod b/backend/go.mod index c45ed46..2a1d344 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,14 +3,18 @@ module school-timetracker go 1.25.3 require ( + github.com/jung-kurt/gofpdf v1.16.2 github.com/labstack/echo/v4 v4.13.4 golang.org/x/crypto v0.43.0 + golang.org/x/time v0.11.0 modernc.org/sqlite v1.40.0 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/labstack/echo-jwt/v4 v4.3.1 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -22,7 +26,6 @@ require ( golang.org/x/net v0.45.0 // indirect golang.org/x/sys v0.37.0 // indirect golang.org/x/text v0.30.0 // indirect - golang.org/x/time v0.11.0 // indirect modernc.org/libc v1.66.10 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 3ab6680..6c63134 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,11 +1,20 @@ +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= +github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= +github.com/labstack/echo-jwt/v4 v4.3.1 h1:d8+/qf8nx7RxeL46LtoIwHJsH2PNN8xXCQ/jDianycE= +github.com/labstack/echo-jwt/v4 v4.3.1/go.mod h1:yJi83kN8S/5vePVPd+7ID75P4PqPNVRs2HVeuvYJH00= github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= @@ -16,10 +25,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -30,6 +43,7 @@ golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= @@ -39,6 +53,7 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= diff --git a/backend/handlers.go b/backend/handlers.go index 14fd4cd..06b3f57 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -2,10 +2,14 @@ package main import ( "database/sql" + "fmt" + "log" "net/http" "strconv" + "strings" "time" + "github.com/golang-jwt/jwt/v5" "github.com/labstack/echo/v4" "golang.org/x/crypto/bcrypt" ) @@ -14,25 +18,60 @@ type App struct { DB *sql.DB } -// Login Handler +func HandleError(c echo.Context, err *AppError) error { + log.Printf("[%s] %s", err.Code, err.Error()) + + return c.JSON(err.HTTPStatus, err.ToResponse()) +} + +func getClaims(c echo.Context) (*Claims, error) { + user, ok := c.Get("user").(*jwt.Token) + if !ok { + return nil, fmt.Errorf("JWT token missing or invalid") + } + + claims, ok := user.Claims.(*Claims) + if !ok { + return nil, fmt.Errorf("failed to parse JWT claims") + } + + return claims, nil +} + +func isDuplicateError(err error) bool { + return err != nil && (err.Error() == "UNIQUE constraint failed" || + strings.Contains(err.Error(), "UNIQUE") || + strings.Contains(err.Error(), "duplicate")) +} + func (app *App) LoginHandler(c echo.Context) error { var req LoginRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + return HandleError(c, ErrInvalidInputMsg("Login-Daten")) + } + + if req.Username == "" { + return HandleError(c, ErrMissingFieldMsg("Benutzername")) + } + if req.Password == "" { + return HandleError(c, ErrMissingFieldMsg("Passwort")) } user, err := GetUserByUsername(app.DB, req.Username) if err != nil { - return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials") + if err == sql.ErrNoRows { + return HandleError(c, ErrInvalidCredentialsMsg()) + } + return HandleError(c, ErrDatabaseMsg(err)) } if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { - return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials") + return HandleError(c, ErrInvalidCredentialsMsg()) } token, err := createToken(user.ID, user.Username, user.IsAdmin) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "error creating token") + return HandleError(c, ErrInternalMsg(err)) } response := LoginResponse{ @@ -44,11 +83,10 @@ func (app *App) LoginHandler(c echo.Context) error { return c.JSON(http.StatusOK, response) } -// Schedule Handlers func (app *App) GetSchedulesHandler(c echo.Context) error { schedules, err := GetAllSchedules(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } return c.JSON(http.StatusOK, schedules) } @@ -56,52 +94,100 @@ func (app *App) GetSchedulesHandler(c echo.Context) error { func (app *App) CreateScheduleHandler(c echo.Context) error { var schedule Schedule if err := c.Bind(&schedule); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + return HandleError(c, ErrInvalidInputMsg("Stundenplan-Daten")) + } + + if schedule.StartTime == "" { + return HandleError(c, ErrMissingFieldMsg("Startzeit")) + } + if schedule.EndTime == "" { + return HandleError(c, ErrMissingFieldMsg("Endzeit")) + } + if schedule.Title == "" { + return HandleError(c, ErrMissingFieldMsg("Titel")) } if err := CreateSchedule(app.DB, &schedule); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if isDuplicateError(err) { + return HandleError(c, ErrAlreadyExistsMsg("Stundenplan-Eintrag")) + } + return HandleError(c, ErrDatabaseMsg(err)) } - return c.JSON(http.StatusCreated, map[string]string{"message": "schedule created"}) + return c.JSON(http.StatusCreated, map[string]string{"message": "Stundenplan erstellt"}) } func (app *App) DeleteScheduleHandler(c echo.Context) error { id, err := strconv.Atoi(c.QueryParam("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid id") + return HandleError(c, ErrInvalidInputMsg("Stundenplan-ID")) } if err := DeleteSchedule(app.DB, id); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Stundenplan")) + } + return HandleError(c, ErrDatabaseMsg(err)) } - return c.NoContent(http.StatusOK) + return c.NoContent(http.StatusNoContent) } -// // User Handlers -// func (app *App) CreateUserHandler(c echo.Context) error { -// var req CreateUserRequest -// if err := c.Bind(&req); err != nil { -// return echo.NewHTTPError(http.StatusBadRequest, "invalid request") -// } +func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error { + hours, err := GetYearlyHoursSummary(app.DB) + if err != nil { + return HandleError(c, ErrDatabaseMsg(err)) + } + if hours == nil { + hours = []WeeklyHours{} + } + return c.JSON(http.StatusOK, hours) +} -// hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) -// if err != nil { -// return echo.NewHTTPError(http.StatusInternalServerError, "error hashing password") -// } +func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error { + var req struct { + UserID int `json:"user_id"` + Date string `json:"date"` + Hours float64 `json:"hours"` + Type string `json:"type"` + } -// if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin); err != nil { -// return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) -// } + if err := c.Bind(&req); err != nil { + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) + } -// return c.JSON(http.StatusCreated, map[string]string{"message": "user created"}) -// } + if req.UserID == 0 { + return HandleError(c, ErrMissingFieldMsg("Benutzer")) + } + if req.Date == "" { + return HandleError(c, ErrMissingFieldMsg("Datum")) + } + if req.Hours == 0 { + return HandleError(c, ErrMissingFieldMsg("Stunden")) + } + + entry := TimeEntry{ + UserID: req.UserID, + Date: req.Date, + StartTime: "00:00", + EndTime: "00:00", + Type: "manual", + } + + if err := CreateManualTimeEntry(app.DB, &entry, req.Hours); err != nil { + return HandleError(c, ErrDatabaseMsg(err)) + } + + return c.NoContent(http.StatusNoContent) +} func (app *App) GetUsersHandler(c echo.Context) error { users, err := GetAllUsers(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) + } + if users == nil { + users = []User{} } return c.JSON(http.StatusOK, users) } @@ -109,40 +195,65 @@ func (app *App) GetUsersHandler(c echo.Context) error { func (app *App) DeleteUserHandler(c echo.Context) error { id, err := strconv.Atoi(c.QueryParam("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid id") + return HandleError(c, ErrInvalidInputMsg("Benutzer-ID")) + } + + if id == 1 { + return HandleError(c, ErrProtectedUserMsg()) } if err := DeleteUser(app.DB, id); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Benutzer")) + } + return HandleError(c, ErrDatabaseMsg(err)) } - return c.NoContent(http.StatusOK) + return c.NoContent(http.StatusNoContent) } -// Time Entry Handlers func (app *App) CreateTimeEntryHandler(c echo.Context) error { - userID := c.Get("user_id").(int) + claims, err := getClaims(c) + if err != nil { + return HandleError(c, ErrUnauthorizedMsg()) + } var entry TimeEntry if err := c.Bind(&entry); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) } - entry.UserID = userID + if entry.Date == "" { + return HandleError(c, ErrMissingFieldMsg("Datum")) + } + if entry.StartTime == "" { + return HandleError(c, ErrMissingFieldMsg("Startzeit")) + } + if entry.EndTime == "" { + return HandleError(c, ErrMissingFieldMsg("Endzeit")) + } + + entry.UserID = claims.UserID if err := CreateTimeEntry(app.DB, &entry); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } - return c.JSON(http.StatusCreated, map[string]string{"message": "time entry created"}) + return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteintrag erstellt"}) } func (app *App) GetMyTimeEntriesHandler(c echo.Context) error { - userID := c.Get("user_id").(int) - - entries, err := GetTimeEntriesByUser(app.DB, userID) + claims, err := getClaims(c) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrUnauthorizedMsg()) + } + + entries, err := GetTimeEntriesByUser(app.DB, claims.UserID) + if err != nil { + return HandleError(c, ErrDatabaseMsg(err)) + } + if entries == nil { + entries = []TimeEntry{} } return c.JSON(http.StatusOK, entries) @@ -151,12 +262,16 @@ func (app *App) GetMyTimeEntriesHandler(c echo.Context) error { func (app *App) GetWeekDates(c echo.Context) error { year, err := strconv.Atoi(c.QueryParam("year")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") + return HandleError(c, ErrInvalidInputMsg("Jahr")) } week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") + return HandleError(c, ErrInvalidInputMsg("Woche")) + } + + if week < 1 || week > 53 { + return HandleError(c, ErrInvalidInputMsg("Woche (muss zwischen 1 und 53 liegen)")) } dates := calculateWeekDates(year, week) @@ -164,21 +279,24 @@ func (app *App) GetWeekDates(c echo.Context) error { } func (app *App) CheckWeekHasEntries(c echo.Context) error { - userID := c.Get("user_id").(int) + claims, err := getClaims(c) + if err != nil { + return HandleError(c, ErrUnauthorizedMsg()) + } year, err := strconv.Atoi(c.QueryParam("year")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") + return HandleError(c, ErrInvalidInputMsg("Jahr")) } week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") + return HandleError(c, ErrInvalidInputMsg("Woche")) } - hasEntries, err := CheckUserHasEntriesForWeek(app.DB, userID, year, week) + hasEntries, err := CheckUserHasEntriesForWeek(app.DB, claims.UserID, year, week) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) } return c.JSON(http.StatusOK, map[string]bool{"has_entries": hasEntries}) @@ -187,7 +305,10 @@ func (app *App) CheckWeekHasEntries(c echo.Context) error { func (app *App) GetAllTimeEntriesHandler(c echo.Context) error { entries, err := GetAllTimeEntries(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) + } + if entries == nil { + entries = []TimeEntry{} } return c.JSON(http.StatusOK, entries) } @@ -195,29 +316,35 @@ func (app *App) GetAllTimeEntriesHandler(c echo.Context) error { func (app *App) GetWeeklyHoursHandler(c echo.Context) error { hours, err := GetWeeklyHours(app.DB) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + return HandleError(c, ErrDatabaseMsg(err)) + } + if hours == nil { + hours = []WeeklyHours{} } return c.JSON(http.StatusOK, hours) } func (app *App) DeleteWeekEntries(c echo.Context) error { - userID := c.Get("user_id").(int) + claims, err := getClaims(c) + if err != nil { + return HandleError(c, ErrUnauthorizedMsg()) + } year, err := strconv.Atoi(c.QueryParam("year")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") + return HandleError(c, ErrInvalidInputMsg("Jahr")) } week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") + return HandleError(c, ErrInvalidInputMsg("Woche")) } - if err := DeleteTimeEntriesByUserAndWeek(app.DB, userID, year, week); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil { + return HandleError(c, ErrDatabaseMsg(err)) } - return c.NoContent(http.StatusOK) + return c.NoContent(http.StatusNoContent) } type WeekDates struct { @@ -276,52 +403,83 @@ type BatchTimeEntryRequest struct { } func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error { - userID := c.Get("user_id").(int) + claims, err := getClaims(c) + if err != nil { + return HandleError(c, ErrUnauthorizedMsg()) + } var req BatchTimeEntryRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "invalid request") + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) + } + + if len(req.Entries) == 0 { + return HandleError(c, ErrMissingFieldMsg("Zeiteinträge")) + } + + if len(req.Entries) > 0 { + firstDate := req.Entries[0].Date + t, err := time.Parse("2006-01-02", firstDate) + if err != nil { + return HandleError(c, ErrInvalidInputMsg("Datum-Format")) + } + year, week := t.ISOWeek() + + if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil { + return HandleError(c, ErrDatabaseMsg(err)) + } } tx, err := app.DB.Begin() if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "transaction error") + return HandleError(c, ErrDatabaseMsg(err)) } defer tx.Rollback() stmt, err := tx.Prepare("INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?)") if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "prepare error") + return HandleError(c, ErrDatabaseMsg(err)) } defer stmt.Close() for _, entry := range req.Entries { - _, err := stmt.Exec(userID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime) + _, err := stmt.Exec(claims.UserID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "insert error") + return HandleError(c, ErrDatabaseMsg(err)) } } if err := tx.Commit(); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "commit error") + return HandleError(c, ErrDatabaseMsg(err)) } - return c.JSON(http.StatusCreated, map[string]string{"message": "entries created"}) + return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteinträge erstellt"}) } func (app *App) UpdateUserHandler(c echo.Context) error { userID, err := strconv.Atoi(c.Param("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID") + return HandleError(c, ErrInvalidInputMsg("Benutzer-ID")) + } + + if userID == 1 { + return HandleError(c, ErrProtectedUserMsg()) } var req UpdateUserRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return HandleError(c, ErrInvalidInputMsg("Benutzerdaten")) } - if err := UpdateUser(app.DB, userID, req.WeeklyHours); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if req.YearlyHours <= 0 { + return HandleError(c, ErrInvalidInputMsg("Jahresarbeitsstunden (muss positiv sein)")) + } + + if err := UpdateUser(app.DB, userID, req.YearlyHours); err != nil { + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Benutzer")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusOK) @@ -330,21 +488,28 @@ func (app *App) UpdateUserHandler(c echo.Context) error { func (app *App) ResetPasswordHandler(c echo.Context) error { userID, err := strconv.Atoi(c.Param("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID") + return HandleError(c, ErrInvalidInputMsg("Benutzer-ID")) } var req ResetPasswordRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return HandleError(c, ErrInvalidInputMsg("Passwort-Daten")) + } + + if len(req.NewPassword) < 6 { + return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)")) } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") + return HandleError(c, ErrInternalMsg(err)) } if err := ResetUserPassword(app.DB, userID, string(hashedPassword)); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Benutzer")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusOK) @@ -353,16 +518,29 @@ func (app *App) ResetPasswordHandler(c echo.Context) error { func (app *App) UpdateTimeEntryHandler(c echo.Context) error { entryID, err := strconv.Atoi(c.Param("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID") + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID")) } var req UpdateTimeEntryRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) + } + + if req.Date == "" { + return HandleError(c, ErrMissingFieldMsg("Datum")) + } + if req.StartTime == "" { + return HandleError(c, ErrMissingFieldMsg("Startzeit")) + } + if req.EndTime == "" { + return HandleError(c, ErrMissingFieldMsg("Endzeit")) } if err := UpdateTimeEntry(app.DB, entryID, req.Date, req.StartTime, req.EndTime, req.Type); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Zeiteintrag")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusOK) @@ -371,74 +549,180 @@ func (app *App) UpdateTimeEntryHandler(c echo.Context) error { func (app *App) DeleteTimeEntryHandler(c echo.Context) error { entryID, err := strconv.Atoi(c.Param("id")) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID") + return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID")) } if err := DeleteTimeEntry(app.DB, entryID); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Zeiteintrag")) + } + return HandleError(c, ErrDatabaseMsg(err)) } - return c.NoContent(http.StatusOK) + return c.NoContent(http.StatusNoContent) } -func (app *App) GetMyWeeklySummaryHandler(c echo.Context) error { - userID := c.Get("user_id").(int) - - year, err := strconv.Atoi(c.QueryParam("year")) +func (app *App) GetMyInfoHandler(c echo.Context) error { + claims, err := getClaims(c) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") + return HandleError(c, ErrUnauthorizedMsg()) } - week, err := strconv.Atoi(c.QueryParam("week")) + user, err := GetUserByID(app.DB, claims.UserID) if err != nil { - return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") - } - - user, err := GetUserByID(app.DB, userID) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - - allHours, err := GetWeeklyHours(app.DB) - if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) - } - - for _, h := range allHours { - if h.UserID == userID && h.Year == year && h.Week == week { - return c.JSON(http.StatusOK, h) + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Benutzer")) } + return HandleError(c, ErrDatabaseMsg(err)) } - return c.JSON(http.StatusOK, WeeklyHours{ - UserID: userID, - Username: user.Username, - Year: year, - Week: week, - TotalHours: 0, - ExpectedHours: user.WeeklyHours, - RemainingHours: user.WeeklyHours, - }) + return c.JSON(http.StatusOK, user) } func (app *App) CreateUserHandler(c echo.Context) error { var req CreateUserRequest if err := c.Bind(&req); err != nil { - return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + return HandleError(c, ErrInvalidInputMsg("Benutzerdaten")) + } + + if req.Username == "" { + return HandleError(c, ErrMissingFieldMsg("Benutzername")) + } + if req.Password == "" { + return HandleError(c, ErrMissingFieldMsg("Passwort")) + } + if len(req.Password) < 6 { + return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)")) } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") + return HandleError(c, ErrInternalMsg(err)) } - if req.WeeklyHours == 0 { - req.WeeklyHours = 40.0 + if req.YearlyHours == 0 { + req.YearlyHours = 60.0 } - if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.WeeklyHours); err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) + if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.YearlyHours); err != nil { + if isDuplicateError(err) { + return HandleError(c, ErrAlreadyExistsMsg("Benutzername")) + } + return HandleError(c, ErrDatabaseMsg(err)) } return c.NoContent(http.StatusCreated) } + +func (app *App) GetSchoolYearsHandler(c echo.Context) error { + years, err := GetAllSchoolYears(app.DB) + if err != nil { + return HandleError(c, ErrDatabaseMsg(err)) + } + if years == nil { + years = []SchoolYear{} + } + return c.JSON(http.StatusOK, years) +} + +func (app *App) CreateSchoolYearHandler(c echo.Context) error { + var req CreateSchoolYearRequest + if err := c.Bind(&req); err != nil { + return HandleError(c, ErrInvalidInputMsg("Schuljahr-Daten")) + } + + if req.Name == "" { + return HandleError(c, ErrMissingFieldMsg("Name")) + } + if req.StartDate == "" { + return HandleError(c, ErrMissingFieldMsg("Startdatum")) + } + if req.EndDate == "" { + return HandleError(c, ErrMissingFieldMsg("Enddatum")) + } + + if err := CreateSchoolYear(app.DB, req.Name, req.StartDate, req.EndDate); err != nil { + if isDuplicateError(err) { + return HandleError(c, ErrAlreadyExistsMsg("Schuljahr")) + } + return HandleError(c, ErrDatabaseMsg(err)) + } + + return c.NoContent(http.StatusCreated) +} + +func (app *App) SetActiveSchoolYearHandler(c echo.Context) error { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return HandleError(c, ErrInvalidInputMsg("Schuljahr-ID")) + } + + if err := SetActiveSchoolYear(app.DB, id); err != nil { + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Schuljahr")) + } + return HandleError(c, ErrDatabaseMsg(err)) + } + + return c.NoContent(http.StatusNoContent) +} + +func (app *App) GetActiveSchoolYearHandler(c echo.Context) error { + year, err := GetActiveSchoolYear(app.DB) + if err != nil { + return HandleError(c, ErrDatabaseMsg(err)) + } + if year == nil { + return c.JSON(http.StatusOK, map[string]any{"active": false}) + } + return c.JSON(http.StatusOK, year) +} + +func (app *App) GenerateYearlySummaryPDFHandler(c echo.Context) error { + schoolYear, err := GetActiveSchoolYear(app.DB) + if err != nil { + return HandleError(c, ErrDatabaseMsg(err)) + } + if schoolYear == nil { + return HandleError(c, ErrNoActiveSchoolYearMsg()) + } + + summary, err := GetYearlyHoursSummary(app.DB) + if err != nil { + return HandleError(c, ErrDatabaseMsg(err)) + } + + pdfBytes, err := GenerateYearlySummaryPDF(schoolYear, summary) + if err != nil { + return HandleError(c, ErrInternalMsg(err)) + } + + filename := fmt.Sprintf("Jahresuebersicht_%s.pdf", schoolYear.Name) + c.Response().Header().Set("Content-Type", "application/pdf") + c.Response().Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) + + return c.Blob(http.StatusOK, "application/pdf", pdfBytes) +} + +func (app *App) DeleteSchoolYearHandler(c echo.Context) error { + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + return HandleError(c, ErrInvalidInputMsg("Schuljahr-ID")) + } + + if err := DeleteSchoolYear(app.DB, id); err != nil { + if err == sql.ErrNoRows { + return HandleError(c, ErrNotFoundMsg("Schuljahr")) + } + if err.Error() == "cannot delete active school year" { + return HandleError(c, &AppError{ + Code: "CANNOT_DELETE_ACTIVE_SCHOOL_YEAR", + Message: "Aktives Schuljahr kann nicht gelöscht werden", + HTTPStatus: http.StatusBadRequest, + }) + } + return HandleError(c, ErrDatabaseMsg(err)) + } + + return c.NoContent(http.StatusNoContent) +} diff --git a/backend/load-env.sh b/backend/load-env.sh new file mode 100755 index 0000000..7358e39 --- /dev/null +++ b/backend/load-env.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +if [ -f .env ]; then + set -a + source .env + set +a + echo "✅ .env geladen" +else + echo "❌ .env Datei nicht gefunden!" + exit 1 +fi + +if [ -z "$PORT" ]; then + export PORT=8080 +fi + +if [ -z "$DB_PATH" ]; then + export DB_PATH="/data/timetracking.db" +fi + +exec "$@" + diff --git a/backend/main.go b/backend/main.go index 762ed67..84cb7f1 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,6 +4,7 @@ import ( "log" "net/http" "os" + "strings" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" @@ -24,8 +25,20 @@ func main() { e.Use(middleware.Logger()) e.Use(middleware.Recover()) + + // CORS Configuration + allowOrigins := []string{"*"} // Default for development + if os.Getenv("ENVIRONMENT") == "production" { + origins := os.Getenv("CORS_ALLOWED_ORIGINS") + if origins != "" { + allowOrigins = strings.Split(origins, ",") + } else { + log.Println("Warning: ENVIRONMENT is 'production' but CORS_ALLOWED_ORIGINS is not set. Allowing all origins.") + } + } + e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ - AllowOrigins: []string{"*"}, + AllowOrigins: allowOrigins, AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization}, })) @@ -44,7 +57,9 @@ func main() { protected.DELETE("/my-time-entries/week", app.DeleteWeekEntries) protected.GET("/week-dates", app.GetWeekDates) protected.GET("/week-has-entries", app.CheckWeekHasEntries) - protected.GET("/my-weekly-summary", app.GetMyWeeklySummaryHandler) + protected.GET("/yearly-hours-summary", app.GetYearlyHoursSummaryHandler) + protected.GET("/my-info", app.GetMyInfoHandler) + protected.GET("/school-year/active", app.GetActiveSchoolYearHandler) } admin := e.Group("/api/admin") @@ -59,9 +74,15 @@ func main() { admin.GET("/time-entries", app.GetAllTimeEntriesHandler) admin.GET("/weekly-hours", app.GetWeeklyHoursHandler) admin.PUT("/users/:id", app.UpdateUserHandler) - admin.POST("/users/:id/reset-password", app.ResetPasswordHandler) + admin.PUT("/users/:id/reset-password", app.ResetPasswordHandler) admin.PUT("/time-entries/:id", app.UpdateTimeEntryHandler) admin.DELETE("/time-entries/:id", app.DeleteTimeEntryHandler) + admin.POST("/time-entry", app.AdminCreateTimeEntryHandler) + admin.GET("/school-years", app.GetSchoolYearsHandler) + admin.POST("/school-years", app.CreateSchoolYearHandler) + admin.DELETE("/school-years/:id", app.DeleteSchoolYearHandler) + admin.PUT("/school-years/:id/activate", app.SetActiveSchoolYearHandler) + admin.GET("/yearly-summary/pdf", app.GenerateYearlySummaryPDFHandler) } e.Static("/", "./static") diff --git a/backend/middleware.go b/backend/middleware.go index 1b4967d..78d693c 100644 --- a/backend/middleware.go +++ b/backend/middleware.go @@ -1,120 +1,66 @@ package main import ( - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" "net/http" - "strings" + "os" + "sync" "time" + "github.com/golang-jwt/jwt/v5" + echojwt "github.com/labstack/echo-jwt/v4" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" + "golang.org/x/time/rate" ) -var jwtSecret = []byte("your-secret-key-change-in-production") +var jwtSecret []byte + +func init() { + secret := os.Getenv("JWT_SECRET") + if secret == "" { + panic("JWT_SECRET environment variable is required") + } + jwtSecret = []byte(secret) +} func createToken(userID int, username string, isAdmin bool) (string, error) { - claims := Claims{ + claims := &Claims{ UserID: userID, Username: username, IsAdmin: isAdmin, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)), + }, } - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) - - claimsWithExp := map[string]any{ - "user_id": claims.UserID, - "username": claims.Username, - "is_admin": claims.IsAdmin, - "exp": time.Now().Add(24 * time.Hour).Unix(), - } - - payload, _ := json.Marshal(claimsWithExp) - payloadEncoded := base64.RawURLEncoding.EncodeToString(payload) - - message := header + "." + payloadEncoded - - h := hmac.New(sha256.New, jwtSecret) - h.Write([]byte(message)) - signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) - - return message + "." + signature, nil -} - -func verifyToken(tokenString string) (*Claims, error) { - parts := strings.Split(tokenString, ".") - if len(parts) != 3 { - return nil, fmt.Errorf("invalid token format") - } - - message := parts[0] + "." + parts[1] - h := hmac.New(sha256.New, jwtSecret) - h.Write([]byte(message)) - expectedSignature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) - - if parts[2] != expectedSignature { - return nil, fmt.Errorf("invalid signature") - } - - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) - if err != nil { - return nil, err - } - - var claimsMap map[string]any - if err := json.Unmarshal(payload, &claimsMap); err != nil { - return nil, err - } - - if exp, ok := claimsMap["exp"].(float64); ok { - if time.Now().Unix() > int64(exp) { - return nil, fmt.Errorf("token expired") - } - } - - claims := &Claims{ - UserID: int(claimsMap["user_id"].(float64)), - Username: claimsMap["username"].(string), - IsAdmin: claimsMap["is_admin"].(bool), - } - - return claims, nil + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) } func JWTMiddleware() echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - authHeader := c.Request().Header.Get("Authorization") - if authHeader == "" { - return echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header") - } - - tokenString := strings.TrimPrefix(authHeader, "Bearer ") - claims, err := verifyToken(tokenString) - if err != nil { - return echo.NewHTTPError(http.StatusUnauthorized, "invalid token") - } - - c.Set("user_id", claims.UserID) - c.Set("username", claims.Username) - c.Set("is_admin", claims.IsAdmin) - - c.Logger().Infof("Authenticated user: ID=%d, Username=%s", claims.UserID, claims.Username) - - return next(c) - } - } + return echojwt.WithConfig(echojwt.Config{ + NewClaimsFunc: func(c echo.Context) jwt.Claims { + return new(Claims) + }, + SigningKey: jwtSecret, + }) } func AdminMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - isAdmin, ok := c.Get("is_admin").(bool) - if !ok || !isAdmin { - return echo.NewHTTPError(http.StatusForbidden, "admin access required") + user, ok := c.Get("user").(*jwt.Token) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "JWT token missing or invalid") + } + + claims, ok := user.Claims.(*Claims) + if !ok { + return echo.NewHTTPError(http.StatusUnauthorized, "Failed to parse JWT claims") + } + + if !claims.IsAdmin { + return echo.NewHTTPError(http.StatusForbidden, "Access denied: admin rights required") } return next(c) } @@ -126,3 +72,68 @@ func CustomLogger() echo.MiddlewareFunc { Format: "${time_rfc3339} | ${status} | ${latency_human} | ${method} ${uri}\n", }) } + +type LoginRateLimiter struct { + limiters map[string]*rate.Limiter + mu sync.Mutex +} + +func NewLoginRateLimiter() *LoginRateLimiter { + limiter := &LoginRateLimiter{ + limiters: make(map[string]*rate.Limiter), + } + + go func() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + for range ticker.C { + limiter.mu.Lock() + limiter.limiters = make(map[string]*rate.Limiter) + limiter.mu.Unlock() + } + }() + + return limiter +} + +func (l *LoginRateLimiter) GetLimiter(ip string) *rate.Limiter { + l.mu.Lock() + defer l.mu.Unlock() + + limiter, exists := l.limiters[ip] + if !exists { + limiter = rate.NewLimiter(rate.Every(time.Minute/5), 5) + l.limiters[ip] = limiter + } + + return limiter +} + +func (l *LoginRateLimiter) Middleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + ip := c.RealIP() + limiter := l.GetLimiter(ip) + + if !limiter.Allow() { + return echo.NewHTTPError(http.StatusTooManyRequests, "Too many login attempts. Please try again later.") + } + + return next(c) + } + } +} + +func HTTPSRedirectMiddleware() echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if os.Getenv("ENVIRONMENT") == "production" { + if c.Request().Header.Get("X-Forwarded-Proto") != "https" { + return c.Redirect(http.StatusMovedPermanently, + "https://"+c.Request().Host+c.Request().RequestURI) + } + } + return next(c) + } + } +} diff --git a/backend/models.go b/backend/models.go index 085c4ef..8429bb6 100644 --- a/backend/models.go +++ b/backend/models.go @@ -1,6 +1,9 @@ package main -import "time" +import ( + "github.com/golang-jwt/jwt/v5" + "time" +) type TimeEntry struct { ID int `json:"id"` @@ -15,13 +18,15 @@ type TimeEntry struct { } type WeeklyHours struct { - UserID int `json:"user_id"` - Username string `json:"username"` - Week int `json:"week"` - Year int `json:"year"` - TotalHours float64 `json:"total_hours"` - ExpectedHours float64 `json:"expected_hours"` - RemainingHours float64 `json:"remaining_hours"` + UserID int `json:"user_id"` + Username string `json:"username"` + Week int `json:"week"` + Year int `json:"year"` + TotalHours float64 `json:"total_hours"` + YearlyTarget float64 `json:"yearly_target"` // NEU + YearlyActual float64 `json:"yearly_actual"` // NEU + WeeklyTarget float64 `json:"weekly_target"` // NEU + RemainingYearly float64 `json:"remaining_yearly"` // NEU } type User struct { @@ -29,7 +34,7 @@ type User struct { Username string `json:"username"` Password string `json:"-"` IsAdmin bool `json:"is_admin"` - WeeklyHours float64 `json:"weekly_hours"` + YearlyHours float64 `json:"yearly_hours"` } type Schedule struct { @@ -56,12 +61,27 @@ type CreateUserRequest struct { Username string `json:"username" validate:"required"` Password string `json:"password" validate:"required,min=6"` IsAdmin bool `json:"is_admin"` - WeeklyHours float64 `json:"weekly_hours"` + YearlyHours float64 `json:"yearly_hours"` +} + +type SchoolYear struct { + ID int `json:"id"` + Name string `json:"name"` + StartDate string `json:"start_date"` + EndDate string `json:"end_date"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` +} + +type CreateSchoolYearRequest struct { + Name string `json:"name" validate:"required"` + StartDate string `json:"start_date" validate:"required"` + EndDate string `json:"end_date" validate:"required"` } type UpdateUserRequest struct { Username string `json:"username"` - WeeklyHours float64 `json:"weekly_hours"` + YearlyHours float64 `json:"yearly_hours"` } type ResetPasswordRequest struct { @@ -79,4 +99,5 @@ type Claims struct { UserID int `json:"user_id"` Username string `json:"username"` IsAdmin bool `json:"is_admin"` + jwt.RegisteredClaims } diff --git a/backend/pdf.go b/backend/pdf.go new file mode 100644 index 0000000..13e003e --- /dev/null +++ b/backend/pdf.go @@ -0,0 +1,110 @@ +package main + +import ( + "fmt" + "time" + + "github.com/jung-kurt/gofpdf" +) + +func GenerateYearlySummaryPDF(schoolYear *SchoolYear, summary []WeeklyHours) ([]byte, error) { + pdf := gofpdf.New("P", "mm", "A4", "") + pdf.AddPage() + + pdf.SetFont("Arial", "B", 20) + + title := fmt.Sprintf("Stundenjahresübersicht für Schuljahr %s", schoolYear.Name) + pdf.Cell(0, 15, title) + pdf.Ln(10) + + pdf.SetFont("Arial", "", 12) + subtitle := fmt.Sprintf("%s bis %s", schoolYear.StartDate, schoolYear.EndDate) + pdf.Cell(0, 10, subtitle) + pdf.Ln(15) + + pdf.SetFont("Arial", "B", 10) + pdf.SetFillColor(52, 152, 219) + pdf.SetTextColor(255, 255, 255) + + colWidths := []float64{60, 40, 40, 40} + headers := []string{"Mitarbeiter", "Soll (Std.)", "Ist (Std.)", "Differenz (Std.)"} + + for i, header := range headers { + pdf.CellFormat(colWidths[i], 10, header, "1", 0, "C", true, 0, "") + } + pdf.Ln(-1) + + pdf.SetFont("Arial", "", 10) + pdf.SetTextColor(0, 0, 0) + fill := false + + for _, entry := range summary { + if fill { + pdf.SetFillColor(240, 240, 240) + } else { + pdf.SetFillColor(255, 255, 255) + } + + pdf.CellFormat(colWidths[0], 8, entry.Username, "1", 0, "L", true, 0, "") + + pdf.CellFormat(colWidths[1], 8, fmt.Sprintf("%.1f", entry.YearlyTarget), "1", 0, "R", true, 0, "") + + pdf.CellFormat(colWidths[2], 8, fmt.Sprintf("%.1f", entry.YearlyActual), "1", 0, "R", true, 0, "") + + diffStr := fmt.Sprintf("%.1f", entry.RemainingYearly) + if entry.RemainingYearly > 0 { + pdf.SetTextColor(220, 53, 69) + } else { + pdf.SetTextColor(40, 167, 69) + } + pdf.CellFormat(colWidths[3], 8, diffStr, "1", 0, "R", true, 0, "") + pdf.SetTextColor(0, 0, 0) + + pdf.Ln(-1) + fill = !fill + } + + pdf.Ln(5) + pdf.SetFont("Arial", "B", 10) + + totalTarget := 0.0 + totalActual := 0.0 + totalRemaining := 0.0 + + for _, entry := range summary { + totalTarget += entry.YearlyTarget + totalActual += entry.YearlyActual + totalRemaining += entry.RemainingYearly + } + + pdf.SetFillColor(52, 152, 219) + pdf.SetTextColor(255, 255, 255) + + pdf.CellFormat(colWidths[0], 10, "GESAMT", "1", 0, "L", true, 0, "") + pdf.CellFormat(colWidths[1], 10, fmt.Sprintf("%.1f", totalTarget), "1", 0, "R", true, 0, "") + pdf.CellFormat(colWidths[2], 10, fmt.Sprintf("%.1f", totalActual), "1", 0, "R", true, 0, "") + pdf.CellFormat(colWidths[3], 10, fmt.Sprintf("%.1f", totalRemaining), "1", 0, "R", true, 0, "") + + pdf.Ln(15) + pdf.SetFont("Arial", "I", 8) + pdf.SetTextColor(128, 128, 128) + pdf.Cell(0, 10, fmt.Sprintf("Erstellt am: %s", time.Now().Format("02.01.2006 15:04"))) + + var buf []byte + w := &pdfWriter{buf: &buf} + err := pdf.Output(w) + if err != nil { + return nil, err + } + + return buf, nil +} + +type pdfWriter struct { + buf *[]byte +} + +func (w *pdfWriter) Write(p []byte) (n int, err error) { + *w.buf = append(*w.buf, p...) + return len(p), nil +} diff --git a/backend/static/index.html b/backend/static/index.html index 426d625..12ae1c0 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -1,36 +1,338 @@ - + + - - - Schulzeit Erfassung - + + + + Zeiterfassung + + + + + + -
+
+ + diff --git a/docker-compose.yml b/docker-compose.yml index 16d47f0..221d016 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,3 @@ -# version: '3.8' - -# services: -# timetracking: -# build: . -# container_name: school-timetracking -# ports: -# - "8080:8080" -# volumes: -# - ./data:/data -# environment: -# - PORT=8080 -# - DB_PATH=/data/timetracking.db -# restart: unless-stopped services: timetracking: build: . @@ -21,6 +7,8 @@ services: environment: - PORT=8080 - DB_PATH=/data/timetracking.db + - JWT_SECRET=your-default-secret-change-me + - TZ=Europe/Berlin # Optional: Zeitzone volumes: - timetracking-data:/data restart: unless-stopped @@ -34,4 +22,3 @@ volumes: networks: timetracking-net: driver: bridge - diff --git a/frontend/elm.json b/frontend/elm.json index 300f393..07196ee 100644 --- a/frontend/elm.json +++ b/frontend/elm.json @@ -1,19 +1,21 @@ { "type": "application", - "source-directories": ["src"], + "source-directories": [ + "src" + ], "elm-version": "0.19.1", "dependencies": { "direct": { "elm/browser": "1.0.2", + "elm/bytes": "1.0.8", "elm/core": "1.0.5", + "elm/file": "1.0.5", "elm/html": "1.0.0", "elm/http": "2.0.0", "elm/json": "1.1.3", "elm/time": "1.0.0" }, "indirect": { - "elm/bytes": "1.0.8", - "elm/file": "1.0.5", "elm/url": "1.0.0", "elm/virtual-dom": "1.0.3" } diff --git a/frontend/public/index.html b/frontend/public/index.html index 426d625..12ae1c0 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,36 +1,338 @@ - + + - - - Schulzeit Erfassung - + + + + Zeiterfassung + + + + + + -
+
+ + diff --git a/frontend/src/Api/Auth.elm b/frontend/src/Api/Auth.elm new file mode 100644 index 0000000..0de5c4e --- /dev/null +++ b/frontend/src/Api/Auth.elm @@ -0,0 +1,21 @@ +module Api.Auth exposing (loginRequest) + +import Api.Decoders exposing (loginDecoder) +import Http +import Json.Encode as Encode +import Types.Api exposing (LoginResult) +import Types.Msg exposing (Msg(..)) + + +loginRequest : String -> String -> Cmd Msg +loginRequest username password = + Http.post + { url = "/api/login" + , body = + Http.jsonBody <| + Encode.object + [ ( "username", Encode.string username ) + , ( "password", Encode.string password ) + ] + , expect = Http.expectJson LoginResponse loginDecoder + } diff --git a/frontend/src/Api/Decoders.elm b/frontend/src/Api/Decoders.elm new file mode 100644 index 0000000..cb72efa --- /dev/null +++ b/frontend/src/Api/Decoders.elm @@ -0,0 +1,109 @@ +module Api.Decoders exposing + ( apiErrorDecoder + , loginDecoder + , scheduleDecoder + , schoolYearDecoder + , timeEntryDecoder + , userDecoder + , weekDatesDecoder + , weeklyHoursDecoder + , yearlyHoursSummaryDecoder + ) + +import Dict +import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string) +import Types.Api exposing (ApiError, LoginResult) +import Types.Model exposing (..) + + +loginDecoder : Decoder LoginResult +loginDecoder = + Decode.map3 LoginResult + (field "token" string) + (field "username" string) + (field "is_admin" bool) + + +scheduleDecoder : Decoder Schedule +scheduleDecoder = + Decode.map6 Schedule + (field "id" int) + (field "day_of_week" int) + (field "start_time" string) + (field "end_time" string) + (field "type" string) + (field "title" string) + + +timeEntryDecoder : Decoder TimeEntry +timeEntryDecoder = + Decode.map8 TimeEntry + (field "id" int) + (field "user_id" int) + (field "schedule_id" int) + (field "date" string) + (field "type" string) + (field "username" string) + (field "start_time" string) + (field "end_time" string) + + +userDecoder : Decoder User +userDecoder = + Decode.map4 User + (field "id" int) + (field "username" string) + (field "is_admin" bool) + (field "yearly_hours" float) + + +weekDatesDecoder : Decoder WeekDates +weekDatesDecoder = + Decode.map4 WeekDates + (field "year" int) + (field "week" int) + (field "dates" (Decode.dict string) |> Decode.map Dict.toList) + (field "range" string) + + +weeklyHoursDecoder : Decoder WeeklyHours +weeklyHoursDecoder = + Decode.map7 WeeklyHours + (field "user_id" int) + (field "username" string) + (field "year" int) + (field "week" int) + (field "total_hours" float) + (field "expected_hours" float) + (field "remaining_hours" float) + + +yearlyHoursSummaryDecoder : Decoder YearlyHoursSummary +yearlyHoursSummaryDecoder = + Decode.succeed YearlyHoursSummary + |> Decode.andThen (\f -> Decode.map f (field "user_id" int)) + |> Decode.andThen (\f -> Decode.map f (field "username" string)) + |> Decode.andThen (\f -> Decode.map f (field "year" int)) + |> Decode.andThen (\f -> Decode.map f (field "week" int)) + |> Decode.andThen (\f -> Decode.map f (field "total_hours" float)) + |> Decode.andThen (\f -> Decode.map f (field "yearly_target" float)) + |> Decode.andThen (\f -> Decode.map f (field "yearly_actual" float)) + |> Decode.andThen (\f -> Decode.map f (field "weekly_target" float)) + |> Decode.andThen (\f -> Decode.map f (field "remaining_yearly" float)) + + +schoolYearDecoder : Decoder SchoolYear +schoolYearDecoder = + Decode.map5 SchoolYear + (field "id" int) + (field "name" string) + (field "start_date" string) + (field "end_date" string) + (field "is_active" bool) + + +apiErrorDecoder : Decoder ApiError +apiErrorDecoder = + Decode.map2 ApiError + (field "code" string) + (field "message" string) diff --git a/frontend/src/Api/Schedule.elm b/frontend/src/Api/Schedule.elm new file mode 100644 index 0000000..f966645 --- /dev/null +++ b/frontend/src/Api/Schedule.elm @@ -0,0 +1,120 @@ +module Api.Schedule exposing + ( createSchedule + , deleteSchedule + , fetchSchedules + , saveTimeEntriesForWeek + ) + +import Api.Decoders exposing (scheduleDecoder) +import Http +import Json.Decode +import Json.Encode as Encode +import Types.Model exposing (NewSchedule, Schedule, SelectedEntry, WeekDates) +import Types.Msg exposing (Msg(..)) + + +fetchSchedules : Maybe String -> Cmd Msg +fetchSchedules maybeToken = + case maybeToken of + Just token -> + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/schedules" + , body = Http.emptyBody + , expect = Http.expectJson SchedulesReceived (Json.Decode.list scheduleDecoder) + , timeout = Nothing + , tracker = Nothing + } + + Nothing -> + Cmd.none + + +createSchedule : String -> NewSchedule -> Cmd Msg +createSchedule token schedule = + case String.toInt schedule.dayOfWeek of + Just day -> + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/schedules" + , body = + Http.jsonBody <| + Encode.object + [ ( "day_of_week", Encode.int day ) + , ( "start_time", Encode.string schedule.startTime ) + , ( "end_time", Encode.string schedule.endTime ) + , ( "type", Encode.string schedule.scheduleType ) + , ( "title", Encode.string schedule.title ) + ] + , expect = Http.expectWhatever ScheduleCreated + , timeout = Nothing + , tracker = Nothing + } + + Nothing -> + Cmd.none + + +deleteSchedule : String -> Int -> Cmd Msg +deleteSchedule token scheduleId = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/schedules/delete?id=" ++ String.fromInt scheduleId + , body = Http.emptyBody + , expect = Http.expectWhatever ScheduleDeleted + , timeout = Nothing + , tracker = Nothing + } + + +saveTimeEntriesForWeek : String -> List SelectedEntry -> Int -> Int -> List Schedule -> Maybe WeekDates -> Cmd Msg +saveTimeEntriesForWeek token selectedEntries year week schedules maybeWeekDates = + case maybeWeekDates of + Nothing -> + Cmd.none + + Just weekDates -> + let + getScheduleById id = + List.filter (\s -> s.id == id) schedules |> List.head + + getDateForDay dayOfWeek = + weekDates.dates + |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) + |> List.head + |> Maybe.map Tuple.second + + createEntryData entry = + case ( getScheduleById entry.scheduleId, getDateForDay entry.dayOfWeek ) of + ( Just schedule, Just dateStr ) -> + Just <| + Encode.object + [ ( "schedule_id", Encode.int entry.scheduleId ) + , ( "date", Encode.string dateStr ) + , ( "type", Encode.string schedule.scheduleType ) + , ( "start_time", Encode.string schedule.startTime ) + , ( "end_time", Encode.string schedule.endTime ) + ] + + _ -> + Nothing + + entriesData = + List.filterMap createEntryData selectedEntries + in + if List.isEmpty entriesData then + Cmd.none + + else + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/time-entries/batch" + , body = Http.jsonBody <| Encode.object [ ( "entries", Encode.list identity entriesData ) ] + , expect = Http.expectWhatever TimeEntriesSaved + , timeout = Nothing + , tracker = Nothing + } diff --git a/frontend/src/Api/SchoolYear.elm b/frontend/src/Api/SchoolYear.elm new file mode 100644 index 0000000..be1fb63 --- /dev/null +++ b/frontend/src/Api/SchoolYear.elm @@ -0,0 +1,85 @@ +module Api.SchoolYear exposing + ( activateSchoolYear + , createSchoolYear + , deleteSchoolYear + , fetchActiveSchoolYear + , fetchSchoolYears + ) + +import Api.Decoders exposing (schoolYearDecoder) +import Http +import Json.Decode as Decode +import Json.Encode as Encode +import Types.Model exposing (NewSchoolYear) +import Types.Msg exposing (Msg(..)) + + +fetchSchoolYears : String -> Cmd Msg +fetchSchoolYears token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/school-years" + , body = Http.emptyBody + , expect = Http.expectJson SchoolYearsReceived (Decode.list schoolYearDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +fetchActiveSchoolYear : String -> Cmd Msg +fetchActiveSchoolYear token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/school-year/active" + , body = Http.emptyBody + , expect = Http.expectJson ActiveSchoolYearReceived schoolYearDecoder + , timeout = Nothing + , tracker = Nothing + } + + +createSchoolYear : String -> NewSchoolYear -> Cmd Msg +createSchoolYear token schoolYear = + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/school-years" + , body = + Http.jsonBody <| + Encode.object + [ ( "name", Encode.string schoolYear.name ) + , ( "start_date", Encode.string schoolYear.startDate ) + , ( "end_date", Encode.string schoolYear.endDate ) + ] + , expect = Http.expectWhatever SchoolYearCreated + , timeout = Nothing + , tracker = Nothing + } + + +activateSchoolYear : String -> Int -> Cmd Msg +activateSchoolYear token id = + Http.request + { method = "PUT" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/school-years/" ++ String.fromInt id ++ "/activate" + , body = Http.emptyBody + , expect = Http.expectWhatever SchoolYearActivated + , timeout = Nothing + , tracker = Nothing + } + + +deleteSchoolYear : String -> Int -> Cmd Msg +deleteSchoolYear token id = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/school-years/" ++ String.fromInt id + , body = Http.emptyBody + , expect = Http.expectWhatever SchoolYearDeleted + , timeout = Nothing + , tracker = Nothing + } diff --git a/frontend/src/Api/TimeEntry.elm b/frontend/src/Api/TimeEntry.elm new file mode 100644 index 0000000..c1ebede --- /dev/null +++ b/frontend/src/Api/TimeEntry.elm @@ -0,0 +1,201 @@ +module Api.TimeEntry exposing + ( checkWeekHasEntries + , createAdminTimeEntry + , deleteTimeEntry + , deleteWeekEntries + , downloadYearlySummaryPDF + , fetchAllTimeEntries + , fetchMyTimeEntries + , fetchWeekDates + , fetchWeeklyHours + , fetchYearlyHoursSummary + , updateTimeEntry + ) + +import Api.Decoders exposing (timeEntryDecoder, weekDatesDecoder, yearlyHoursSummaryDecoder) +import Bytes exposing (Bytes) +import Http +import Json.Decode as Decode exposing (bool, field) +import Json.Encode as Encode +import Types.Model exposing (AdminManualEntry, EditingTimeEntry) +import Types.Msg exposing (Msg(..)) + + +fetchMyTimeEntries : String -> Cmd Msg +fetchMyTimeEntries token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/my-time-entries" + , body = Http.emptyBody + , expect = Http.expectJson MyTimeEntriesReceived (Decode.list timeEntryDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +fetchAllTimeEntries : String -> Cmd Msg +fetchAllTimeEntries token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entries" + , body = Http.emptyBody + , expect = Http.expectJson AllTimeEntriesReceived (Decode.list timeEntryDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +fetchWeekDates : String -> Int -> Int -> Cmd Msg +fetchWeekDates token year week = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/week-dates?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week + , body = Http.emptyBody + , expect = Http.expectJson WeekDatesReceived weekDatesDecoder + , timeout = Nothing + , tracker = Nothing + } + + +checkWeekHasEntries : String -> Int -> Int -> Cmd Msg +checkWeekHasEntries token year week = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/week-has-entries?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week + , body = Http.emptyBody + , expect = Http.expectJson WeekHasEntriesReceived (field "has_entries" bool) + , timeout = Nothing + , tracker = Nothing + } + + +deleteWeekEntries : String -> Int -> Int -> Cmd Msg +deleteWeekEntries token year week = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/my-time-entries/week?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week + , body = Http.emptyBody + , expect = Http.expectWhatever WeekEntriesDeleted + , timeout = Nothing + , tracker = Nothing + } + + +updateTimeEntry : String -> EditingTimeEntry -> Cmd Msg +updateTimeEntry token entry = + Http.request + { method = "PUT" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entries/" ++ String.fromInt entry.entryId + , body = + Http.jsonBody <| + Encode.object + [ ( "date", Encode.string entry.date ) + , ( "start_time", Encode.string entry.startTime ) + , ( "end_time", Encode.string entry.endTime ) + , ( "type", Encode.string entry.entryType ) + ] + , expect = Http.expectWhatever TimeEntrySaved + , timeout = Nothing + , tracker = Nothing + } + + +deleteTimeEntry : String -> Int -> Cmd Msg +deleteTimeEntry token entryId = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entries/" ++ String.fromInt entryId + , body = Http.emptyBody + , expect = Http.expectWhatever TimeEntryDeleted + , timeout = Nothing + , tracker = Nothing + } + + +createAdminTimeEntry : String -> AdminManualEntry -> Cmd Msg +createAdminTimeEntry token entry = + case entry.selectedUserId of + Just userId -> + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/time-entry" + , body = + Http.jsonBody <| + Encode.object + [ ( "user_id", Encode.int userId ) + , ( "date", Encode.string entry.date ) + , ( "hours", Encode.float (String.toFloat entry.hours |> Maybe.withDefault 0) ) + , ( "type", Encode.string "manual" ) + ] + , expect = Http.expectWhatever AdminTimeEntrySaved + , timeout = Nothing + , tracker = Nothing + } + + Nothing -> + Cmd.none + + +fetchYearlyHoursSummary : String -> Cmd Msg +fetchYearlyHoursSummary token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/yearly-hours-summary" + , body = Http.emptyBody + , expect = Http.expectJson YearlyHoursSummaryReceived (Decode.list yearlyHoursSummaryDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +downloadYearlySummaryPDF : String -> Cmd Msg +downloadYearlySummaryPDF token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/yearly-summary/pdf" + , body = Http.emptyBody + , expect = + Http.expectBytesResponse YearlySummaryPDFReceived + (\response -> + case response of + Http.GoodStatus_ _ body -> + Ok body + + Http.BadUrl_ url -> + Err (Http.BadUrl url) + + Http.Timeout_ -> + Err Http.Timeout + + Http.NetworkError_ -> + Err Http.NetworkError + + Http.BadStatus_ metadata _ -> + Err (Http.BadStatus metadata.statusCode) + ) + , timeout = Nothing + , tracker = Nothing + } + + +fetchWeeklyHours : String -> Cmd Msg +fetchWeeklyHours token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/weekly-hours" + , body = Http.emptyBody + , expect = Http.expectJson WeeklyHoursReceived (Decode.list Api.Decoders.weeklyHoursDecoder) + , timeout = Nothing + , tracker = Nothing + } diff --git a/frontend/src/Api/User.elm b/frontend/src/Api/User.elm new file mode 100644 index 0000000..17c77ac --- /dev/null +++ b/frontend/src/Api/User.elm @@ -0,0 +1,110 @@ +module Api.User exposing + ( createUser + , deleteUser + , fetchMyInfo + , fetchUsers + , resetUserPassword + , updateUserWorkHours + ) + +import Api.Decoders exposing (userDecoder) +import Http +import Json.Decode as Decode +import Json.Encode as Encode +import Types.Model exposing (NewUser) +import Types.Msg exposing (Msg(..)) + + +fetchUsers : String -> Cmd Msg +fetchUsers token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/list" + , body = Http.emptyBody + , expect = Http.expectJson UsersReceived (Decode.list userDecoder) + , timeout = Nothing + , tracker = Nothing + } + + +fetchMyInfo : String -> Cmd Msg +fetchMyInfo token = + Http.request + { method = "GET" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/my-info" + , body = Http.emptyBody + , expect = Http.expectJson MyInfoReceived userDecoder + , timeout = Nothing + , tracker = Nothing + } + + +createUser : String -> NewUser -> Cmd Msg +createUser token user = + Http.request + { method = "POST" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users" + , body = + Http.jsonBody <| + Encode.object + [ ( "username", Encode.string user.username ) + , ( "password", Encode.string user.password ) + , ( "is_admin", Encode.bool user.isAdmin ) + ] + , expect = Http.expectWhatever UserCreated + , timeout = Nothing + , tracker = Nothing + } + + +deleteUser : String -> Int -> Cmd Msg +deleteUser token userId = + Http.request + { method = "DELETE" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/delete?id=" ++ String.fromInt userId + , body = Http.emptyBody + , expect = Http.expectWhatever UserDeleted + , timeout = Nothing + , tracker = Nothing + } + + +updateUserWorkHours : String -> Int -> String -> Cmd Msg +updateUserWorkHours token userId hours = + case String.toFloat hours of + Just workHours -> + Http.request + { method = "PUT" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/" ++ String.fromInt userId + , body = + Http.jsonBody <| + Encode.object + [ ( "yearly_hours", Encode.float workHours ) ] + , expect = Http.expectWhatever UserWorkHoursSaved + , timeout = Nothing + , tracker = Nothing + } + + Nothing -> + Cmd.none + + +resetUserPassword : String -> Int -> String -> Cmd Msg +resetUserPassword token userId newPassword = + Http.request + { method = "PUT" + , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] + , url = "/api/admin/users/" ++ String.fromInt userId ++ "/reset-password" + , body = + Http.jsonBody <| + Encode.object + [ ( "new_password", Encode.string newPassword ) ] + , expect = Http.expectWhatever ResetPasswordSaved + , timeout = Nothing + , tracker = Nothing + } diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index 3eb4b28..6f29eab 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -1,28 +1,27 @@ -port module Main exposing (..) +module Main exposing (..) +import Api.Auth exposing (..) +import Api.Decoders exposing (..) +import Api.Schedule exposing (..) +import Api.SchoolYear exposing (..) +import Api.TimeEntry exposing (..) +import Api.User exposing (..) import Browser -import Html exposing (..) -import Html.Attributes exposing (..) -import Html.Events exposing (..) -import Http -import Json.Decode as Decode exposing (Decoder, field, int, string, bool, list, float) -import Json.Encode as Encode import Task import Time -import Dict exposing (Dict) +import Types.Model exposing (..) +import Types.Msg exposing (Msg(..)) +import Types.Page exposing (..) +import Update.Update exposing (update) +import Utils.Ports exposing (..) +import View.View exposing (view) --- PORTS - -port saveToken : String -> Cmd msg -port removeToken : () -> Cmd msg - -port confirmDelete : String -> Cmd msg -port confirmDeleteResponse : (Bool -> msg) -> Sub msg -- MAIN -main : Program (Maybe String) Model Msg + +main : Program Flags Model Msg main = Browser.element { init = init @@ -32,148 +31,33 @@ main = } --- MODEL - -type alias Model = - { page : Page - , activeTab : AdminTab - , username : String - , password : String - , token : Maybe String - , isAdmin : Bool - , schedules : List Schedule - , users : List User - , timeEntries : List TimeEntry - , weeklyHours : List WeeklyHours - , selectedEntries : List SelectedEntry - , currentWeek : Int - , currentYear : Int - , weekDates : Maybe WeekDates - , currentTime : Time.Posix - , zone : Time.Zone - , newSchedule : NewSchedule - , newUser : NewUser - , error : Maybe String - , weekEditMode : Bool - , hasEntriesForCurrentWeek : Bool - , userWeeklySummary : Maybe WeeklySummary -- NEU - , editingTimeEntryId : Maybe Int -- NEU - , editingTimeEntry : EditingTimeEntry -- NEU - , editingUserId : Maybe Int -- NEU - , editingUserWorkHours : String -- NEU - , resetPasswordUserId : Maybe Int -- NEU - , resetPasswordNew : String -- NEU - , pendingDeleteId : Maybe Int -- NEU: Speichert die ID die gelöscht werden soll - , selectedUserId : Maybe Int -- NEU - , userWorkHoursInput : String - , userPasswordInput : String - } - -type Page - = LoginPage - | UserDashboard - | AdminDashboard - -type AdminTab - = ScheduleTab - | UsersTab - | TimeEntriesTab - -type alias Schedule = - { id : Int - , dayOfWeek : Int - , startTime : String - , endTime : String - , scheduleType : String - , title : String - } - -type alias User = - { id : Int - , username : String - , isAdmin : Bool - , weeklyWorkHours : Float -- NEU - } - -type alias TimeEntry = - { id : Int - , userId : Int - , scheduleId : Int - , date : String - , entryType : String - , username : String - , startTime : String - , endTime : String - } - -type alias SelectedEntry = - { scheduleId : Int - , dayOfWeek : Int - } - -type alias NewSchedule = - { dayOfWeek : String - , startTime : String - , endTime : String - , scheduleType : String - , title : String - } - -type alias NewUser = - { username : String - , password : String - , isAdmin : Bool - } - -type alias WeekDates = - { year : Int - , week : Int - , dates : List (String, String) - , range : String - } - -type alias WeeklySummary = - { userId : Int - , username : String - , year : Int - , week : Int - , totalHours : Float - , targetHours : Float - , remainingHours : Float - } - -type alias EditingTimeEntry = - { entryId : Int - , date : String - , startTime : String - , endTime : String - , entryType : String - } - -type alias WeeklyHours = - { userId : Int - , username : String - , year : Int - , week : Int - , totalHours : Float - , targetHours : Float - , remainingHours : Float - } - -init : Maybe String -> (Model, Cmd Msg) -init storedToken = +init : Flags -> ( Model, Cmd Msg ) +init flags = let + initialPage = + case flags.token of + Just _ -> + if flags.isAdmin then + AdminDashboard + + else + UserDashboard + + Nothing -> + LoginPage + model = - { page = if storedToken /= Nothing then UserDashboard else LoginPage + { page = initialPage , activeTab = ScheduleTab , username = "" , password = "" - , token = storedToken - , isAdmin = False + , token = flags.token + , isAdmin = flags.isAdmin , schedules = [] , users = [] , timeEntries = [] , weeklyHours = [] + , yearlyHoursSummary = [] , selectedEntries = [] , currentWeek = 1 , currentYear = 2025 @@ -185,2428 +69,56 @@ init storedToken = , weekEditMode = False , hasEntriesForCurrentWeek = False , weekDates = Nothing - , userWeeklySummary = Nothing -- NEU - , editingTimeEntryId = Nothing -- NEU - , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" -- NEU - , editingUserId = Nothing -- NEU - , editingUserWorkHours = "" -- NEU - , resetPasswordUserId = Nothing -- NEU - , resetPasswordNew = "" -- NEU - , pendingDeleteId = Nothing -- NEU! - , selectedUserId = Nothing -- NEU + , userWeeklySummary = Nothing + , editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" + , editingUserId = Nothing + , editingUserWorkHours = "" + , resetPasswordUserId = Nothing + , resetPasswordNew = "" + , pendingDeleteId = Nothing + , selectedUserId = Nothing , userWorkHoursInput = "" , userPasswordInput = "" + , isProcessing = False + , mobileMenuOpen = False + , adminManualEntryForm = AdminManualEntry Nothing "" "" "manual" + , schoolYears = [] + , newSchoolYear = NewSchoolYear "" "" "" + , activeSchoolYear = Nothing + , editingSchoolYearId = Nothing + , toasts = [] + , nextToastId = 0 } - - cmd = - case storedToken of + + cmd = + case flags.token of Just token -> - Cmd.batch + Cmd.batch [ Task.perform SetTime Time.now , fetchSchedules (Just token) + , fetchYearlyHoursSummary token + , if flags.isAdmin then + Cmd.batch + [ fetchSchoolYears token + , fetchUsers token + , fetchAllTimeEntries token + ] + + else + fetchMyInfo token ] + Nothing -> Task.perform SetTime Time.now in - (model, cmd) - --- UPDATE - -type Msg - = UpdateUsername String - | UpdatePassword String - | Login - | LoginResponse (Result Http.Error LoginResult) - | Logout - | SetTime Time.Posix - | FetchSchedules - | SchedulesReceived (Result Http.Error (List Schedule)) - | ToggleScheduleSelection Int Int - | SaveTimeEntries - | TimeEntriesSaved (Result Http.Error ()) - | PreviousWeek - | NextWeek - | EnableEditMode - | DisableEditMode - | DeleteWeekEntries - | WeekEntriesDeleted (Result Http.Error ()) - | SwitchTab AdminTab - | UpdateNewScheduleDay String - | UpdateNewScheduleStart String - | UpdateNewScheduleEnd String - | UpdateNewScheduleType String - | UpdateNewScheduleTitle String - | CreateSchedule - | ScheduleCreated (Result Http.Error ()) - | DeleteSchedule Int - | ScheduleDeleted (Result Http.Error ()) - | UpdateNewUsername String - | UpdateNewPassword String - | UpdateNewUserAdmin Bool - | CreateUser - | UserCreated (Result Http.Error ()) - | DeleteUser Int - | UserDeleted (Result Http.Error ()) - | FetchUsers - | UsersReceived (Result Http.Error (List User)) - | FetchMyTimeEntries - | MyTimeEntriesReceived (Result Http.Error (List TimeEntry)) - | FetchAllTimeEntries - | AllTimeEntriesReceived (Result Http.Error (List TimeEntry)) - | FetchWeeklyHours - | WeeklyHoursReceived (Result Http.Error (List WeeklyHours)) - | FetchWeekDates - | WeekDatesReceived (Result Http.Error WeekDates) - | CheckWeekHasEntries - | WeekHasEntriesReceived (Result Http.Error Bool) - | FetchMyWeeklySummary -- NEU - | MyWeeklySummaryReceived (Result Http.Error WeeklySummary) -- NEU - | EditTimeEntry Int -- NEU - | CancelEditTimeEntry -- NEU - | UpdateEditTimeEntryDate String -- NEU - | UpdateEditTimeEntryStartTime String -- NEU - | UpdateEditTimeEntryEndTime String -- NEU - | UpdateEditTimeEntryType String -- NEU - | SaveEditTimeEntry -- NEU - | TimeEntrySaved (Result Http.Error ()) -- NEU - | TimeEntryDeleted (Result Http.Error ()) -- NEU - | EditUserWorkHours Int -- NEU - | CancelEditUserWorkHours -- NEU - | UpdateEditUserWorkHours String -- NEU - | SaveUserWorkHours -- NEU - | UserWorkHoursSaved (Result Http.Error ()) -- NEU - | ResetUserPassword Int -- NEU - | CancelResetPassword -- NEU - | UpdateResetPasswordNew String -- NEU - | SaveResetPassword -- NEU - | ResetPasswordSaved (Result Http.Error ()) -- NEU - | ConfirmDeleteTimeEntry Int -- NEU - | ConfirmDeleteUser Int -- NEU - | DeleteConfirmed Bool -- NEU - | StartEditingTimeEntry Int TimeEntry - | CancelEditingTimeEntry - | UpdateEditingTimeEntryDate String - | UpdateEditingTimeEntryStartTime String - | UpdateEditingTimeEntryEndTime String - | UpdateEditingTimeEntryType String - | SaveEditingTimeEntry - | SelectUserForManagement Int - | UpdateUserWorkHours String - | UpdateUserPassword String - | SaveUserPassword - | UserPasswordSaved (Result Http.Error ()) - -update : Msg -> Model -> (Model, Cmd Msg) -update msg model = - case msg of - UpdateUsername username -> - ({ model | username = username }, Cmd.none) - - UpdatePassword password -> - ({ model | password = password }, Cmd.none) - - Login -> - (model, loginRequest model.username model.password) - - LoginResponse (Ok result) -> - let - newPage = if result.isAdmin then AdminDashboard else UserDashboard - - (year, week) = getISOWeekFromPosix model.currentTime - in - ({ model - | token = Just result.token - , username = result.username - , isAdmin = result.isAdmin - , page = newPage - , error = Nothing - }, Cmd.batch - [ saveToken result.token - , fetchSchedules (Just result.token) - , if not result.isAdmin then - Cmd.batch - [ fetchMyTimeEntries result.token - , fetchWeekDates result.token year week - , checkWeekHasEntries result.token year week - , fetchMyWeeklySummary result.token year week -- NEU! - ] - else - Cmd.batch - [ fetchMyTimeEntries result.token - , fetchWeekDates result.token year week - , checkWeekHasEntries result.token year week - , fetchMyWeeklySummary result.token year week -- NEU! - ] - ]) - - LoginResponse (Err _) -> - ({ model | error = Just "Login fehlgeschlagen" }, Cmd.none) - - Logout -> - ({ model - | page = LoginPage - , token = Nothing - , isAdmin = False - , username = "" - , password = "" - }, removeToken ()) - - FetchSchedules -> - (model, fetchSchedules model.token) - - SchedulesReceived (Ok schedules) -> - ({ model | schedules = schedules }, Cmd.none) - - SchedulesReceived (Err _) -> - ({ model | error = Just "Fehler beim Laden des Stundenplans" }, Cmd.none) - - ToggleScheduleSelection scheduleId dayOfWeek -> - let - entry = { scheduleId = scheduleId, dayOfWeek = dayOfWeek } - newSelected = - if List.any (\e -> e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek) model.selectedEntries then - List.filter (\e -> not (e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek)) model.selectedEntries - else - entry :: model.selectedEntries - in - ({ model | selectedEntries = newSelected }, Cmd.none) - - SaveTimeEntries -> - case model.token of - Just token -> - ({ model | error = Nothing }, - saveTimeEntriesForWeek token model.selectedEntries model.currentYear model.currentWeek model.schedules model.weekDates) - Nothing -> - (model, Cmd.none) - - TimeEntriesSaved (Ok _) -> - case model.token of - Just token -> - ({ model - | selectedEntries = [] - , error = Nothing - , weekEditMode = False - , hasEntriesForCurrentWeek = True - }, Cmd.batch - [ fetchMyTimeEntries token - , fetchMyWeeklySummary token model.currentYear model.currentWeek -- NEU! - ]) - Nothing -> - (model, Cmd.none) - - TimeEntriesSaved (Err _) -> - ({ model | error = Just "Fehler beim Speichern" }, Cmd.none) - - PreviousWeek -> - let - (newYear, newWeek) = previousWeek model.currentYear model.currentWeek - in - ({ model - | currentWeek = newWeek - , currentYear = newYear - , selectedEntries = [] - , weekEditMode = False - }, case model.token of - Just token -> - Cmd.batch - [ fetchWeekDates token newYear newWeek - , checkWeekHasEntries token newYear newWeek - , fetchMyWeeklySummary token newYear newWeek -- NEU! - ] - Nothing -> - Cmd.none - ) - - NextWeek -> - let - (newYear, newWeek) = nextWeek model.currentYear model.currentWeek - in - ({ model - | currentWeek = newWeek - , currentYear = newYear - , selectedEntries = [] - , weekEditMode = False - }, case model.token of - Just token -> - Cmd.batch - [ fetchWeekDates token newYear newWeek - , checkWeekHasEntries token newYear newWeek - , fetchMyWeeklySummary token newYear newWeek -- NEU! - ] - Nothing -> - Cmd.none - ) - - FetchWeekDates -> - case model.token of - Just token -> - (model, fetchWeekDates token model.currentYear model.currentWeek) - Nothing -> - (model, Cmd.none) - - WeekDatesReceived (Ok weekDates) -> - ({ model | weekDates = Just weekDates }, Cmd.none) - - WeekDatesReceived (Err _) -> - ({ model | error = Just "Fehler beim Laden der Wochendaten" }, Cmd.none) - - CheckWeekHasEntries -> - case model.token of - Just token -> - (model, checkWeekHasEntries token model.currentYear model.currentWeek) - Nothing -> - (model, Cmd.none) - - WeekHasEntriesReceived (Ok hasEntries) -> - ({ model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none) - - WeekHasEntriesReceived (Err _) -> - (model, Cmd.none) - - SetTime time -> - let - (year, week) = getISOWeekFromPosix time - - cmds = case model.token of - Just token -> - if model.page == UserDashboard || model.page == LoginPage then - Cmd.batch - [ checkWeekHasEntries token year week - , fetchWeekDates token year week - , fetchMyTimeEntries token - , fetchMyWeeklySummary token year week -- NEU! - ] - else - Cmd.none - Nothing -> - Cmd.none - in - ({ model - | currentTime = time - , currentWeek = week - , currentYear = year - }, cmds) - - EnableEditMode -> - let - currentWeekEntries = List.filter - (\e -> - let - (entryYear, entryWeek) = getYearWeekFromDate e.date - in - entryWeek == model.currentWeek && entryYear == model.currentYear - ) - model.timeEntries - - preSelectedEntries = List.map - (\entry -> - let - parts = String.split "-" entry.date - year = parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 - month = parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - day = parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - dayOfWeek = (getDayOfWeek year month day) - in - { scheduleId = entry.scheduleId, dayOfWeek = dayOfWeek } - ) - currentWeekEntries - in - ({ model - | weekEditMode = True - , selectedEntries = preSelectedEntries - }, Cmd.none) - - DisableEditMode -> - ({ model - | weekEditMode = False - , selectedEntries = [] - }, Cmd.none) - - DeleteWeekEntries -> - case model.token of - Just token -> - (model, deleteWeekEntries token model.currentYear model.currentWeek) - Nothing -> - (model, Cmd.none) - - WeekEntriesDeleted (Ok _) -> - case model.token of - Just token -> - ({ model - | weekEditMode = True - , selectedEntries = [] - , hasEntriesForCurrentWeek = False - }, fetchMyTimeEntries token) - Nothing -> - (model, Cmd.none) - - WeekEntriesDeleted (Err _) -> - ({ model | error = Just "Fehler beim Löschen" }, Cmd.none) - - SwitchTab tab -> - let - cmd = case tab of - UsersTab -> - case model.token of - Just token -> - fetchUsers token - Nothing -> - Cmd.none - TimeEntriesTab -> - case model.token of - Just token -> - Cmd.batch - [ fetchAllTimeEntries token - , fetchWeeklyHours token - ] - Nothing -> - Cmd.none - _ -> - Cmd.none - in - ({ model | activeTab = tab }, cmd) - - UpdateNewScheduleDay day -> - let - oldSchedule = model.newSchedule - newSchedule = { oldSchedule | dayOfWeek = day } - in - ({ model | newSchedule = newSchedule }, Cmd.none) - - UpdateNewScheduleStart time -> - let - oldSchedule = model.newSchedule - newSchedule = { oldSchedule | startTime = time } - in - ({ model | newSchedule = newSchedule }, Cmd.none) - - UpdateNewScheduleEnd time -> - let - oldSchedule = model.newSchedule - newSchedule = { oldSchedule | endTime = time } - in - ({ model | newSchedule = newSchedule }, Cmd.none) - - UpdateNewScheduleType scheduleType -> - let - oldSchedule = model.newSchedule - newSchedule = { oldSchedule | scheduleType = scheduleType } - in - ({ model | newSchedule = newSchedule }, Cmd.none) - - UpdateNewScheduleTitle title -> - let - oldSchedule = model.newSchedule - newSchedule = { oldSchedule | title = title } - in - ({ model | newSchedule = newSchedule }, Cmd.none) - - CreateSchedule -> - case model.token of - Just token -> - (model, createSchedule token model.newSchedule) - Nothing -> - (model, Cmd.none) - - ScheduleCreated (Ok _) -> - let - emptySchedule = NewSchedule "" "" "" "lesson" "" - in - ({ model | newSchedule = emptySchedule }, fetchSchedules model.token) - - ScheduleCreated (Err _) -> - ({ model | error = Just "Fehler beim Erstellen" }, Cmd.none) - - DeleteSchedule scheduleId -> - case model.token of - Just token -> - (model, deleteSchedule token scheduleId) - Nothing -> - (model, Cmd.none) - - ScheduleDeleted (Ok _) -> - (model, fetchSchedules model.token) - - ScheduleDeleted (Err _) -> - ({ model | error = Just "Fehler beim Löschen" }, Cmd.none) - - UpdateNewUsername username -> - let - oldUser = model.newUser - newUser = { oldUser | username = username } - in - ({ model | newUser = newUser }, Cmd.none) - - UpdateNewPassword password -> - let - oldUser = model.newUser - newUser = { oldUser | password = password } - in - ({ model | newUser = newUser }, Cmd.none) - - UpdateNewUserAdmin isAdmin -> - let - oldUser = model.newUser - newUser = { oldUser | isAdmin = isAdmin } - in - ({ model | newUser = newUser }, Cmd.none) - - CreateUser -> - case model.token of - Just token -> - (model, createUser token model.newUser) - Nothing -> - (model, Cmd.none) - - UserCreated (Ok _) -> - let - emptyUser = NewUser "" "" False - in - case model.token of - Just token -> - ({ model | newUser = emptyUser }, fetchUsers token) - Nothing -> - (model, Cmd.none) - - UserCreated (Err _) -> - ({ model | error = Just "Fehler beim Erstellen des Benutzers" }, Cmd.none) - - DeleteUser userId -> - case model.token of - Just token -> - (model, deleteUser token userId) - Nothing -> - (model, Cmd.none) - - UserDeleted (Ok _) -> - case model.token of - Just token -> - ({ model - | pendingDeleteId = Nothing - , error = Nothing - }, fetchUsers token) - Nothing -> - (model, Cmd.none) - - UserDeleted (Err _) -> - ({ model | error = Just "Fehler beim Löschen des Benutzers", pendingDeleteId = Nothing}, Cmd.none) - - FetchUsers -> - case model.token of - Just token -> - (model, fetchUsers token) - Nothing -> - (model, Cmd.none) - - UsersReceived (Ok users) -> - ({ model | users = users }, Cmd.none) - - UsersReceived (Err _) -> - ({ model | error = Just "Fehler beim Laden der Benutzer" }, Cmd.none) - - FetchMyTimeEntries -> - case model.token of - Just token -> - (model, fetchMyTimeEntries token) - Nothing -> - (model, Cmd.none) - - MyTimeEntriesReceived (Ok entries) -> - let - hasEntries = List.any - (\e -> - let - (entryYear, entryWeek) = getYearWeekFromDate e.date - in - entryWeek == model.currentWeek && entryYear == model.currentYear - ) - entries - in - ({ model - | timeEntries = entries - , hasEntriesForCurrentWeek = hasEntries - , weekEditMode = False - }, Cmd.none) - - MyTimeEntriesReceived (Err _) -> - ({ model | error = Just "Fehler beim Laden der Einträge" }, Cmd.none) - - FetchAllTimeEntries -> - case model.token of - Just token -> - (model, fetchAllTimeEntries token) - Nothing -> - (model, Cmd.none) - - AllTimeEntriesReceived (Ok entries) -> - ({ model | timeEntries = entries }, Cmd.none) - - AllTimeEntriesReceived (Err _) -> - ({ model | error = Just "Fehler beim Laden der Zeiteinträge" }, Cmd.none) - - FetchWeeklyHours -> - case model.token of - Just token -> - (model, fetchWeeklyHours token) - Nothing -> - (model, Cmd.none) - - WeeklyHoursReceived (Ok hours) -> - ({ model | weeklyHours = hours }, Cmd.none) - - WeeklyHoursReceived (Err _) -> - ({ model | error = Just "Fehler beim Laden der Wochenstunden" }, Cmd.none) - FetchMyWeeklySummary -> - case model.token of - Just token -> - (model, fetchMyWeeklySummary token model.currentYear model.currentWeek) - Nothing -> - (model, Cmd.none) - - MyWeeklySummaryReceived (Ok summary) -> - ({ model | userWeeklySummary = Just summary }, Cmd.none) - - MyWeeklySummaryReceived (Err _) -> - ({ model | userWeeklySummary = Nothing }, Cmd.none) - - EditTimeEntry entryId -> - case List.filter (\e -> e.id == entryId) model.timeEntries |> List.head of - Just entry -> - ({ model - | editingTimeEntryId = Just entryId - , editingTimeEntry = - { entryId = entryId - , date = entry.date - , startTime = entry.startTime - , endTime = entry.endTime - , entryType = entry.entryType - } - }, Cmd.none) - Nothing -> - (model, Cmd.none) - - CancelEditTimeEntry -> - ({ model - | editingTimeEntryId = Nothing - , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" - }, Cmd.none) - - UpdateEditTimeEntryDate date -> - let - old = model.editingTimeEntry - new = { old | date = date } - in - ({ model | editingTimeEntry = new }, Cmd.none) - - UpdateEditTimeEntryStartTime time -> - let - old = model.editingTimeEntry - new = { old | startTime = time } - in - ({ model | editingTimeEntry = new }, Cmd.none) - - UpdateEditTimeEntryEndTime time -> - let - old = model.editingTimeEntry - new = { old | endTime = time } - in - ({ model | editingTimeEntry = new }, Cmd.none) - - UpdateEditTimeEntryType entryType -> - let - old = model.editingTimeEntry - new = { old | entryType = entryType } - in - ({ model | editingTimeEntry = new }, Cmd.none) - - SaveEditTimeEntry -> - case model.token of - Just token -> - (model, updateTimeEntry token model.editingTimeEntry) - Nothing -> - (model, Cmd.none) - - TimeEntryDeleted (Ok _) -> - case model.token of - Just token -> - ({ model - | editingTimeEntryId = Nothing - , pendingDeleteId = Nothing - , error = Nothing - }, fetchAllTimeEntries token) - Nothing -> - (model, Cmd.none) - - TimeEntryDeleted (Err _) -> - ({ model | error = Just "Fehler beim Löschen des Eintrags", pendingDeleteId = Nothing}, Cmd.none) - - EditUserWorkHours userId -> - case List.filter (\u -> u.id == userId) model.users |> List.head of - Just user -> - ({ model - | editingUserId = Just userId - , editingUserWorkHours = String.fromFloat user.weeklyWorkHours - }, Cmd.none) - Nothing -> - (model, Cmd.none) - - CancelEditUserWorkHours -> - ({ model - | editingUserId = Nothing - , editingUserWorkHours = "" - }, Cmd.none) - - UpdateEditUserWorkHours hours -> - ({ model | editingUserWorkHours = hours }, Cmd.none) - - ResetUserPassword userId -> - ({ model - | resetPasswordUserId = Just userId - , resetPasswordNew = "" - }, Cmd.none) - - CancelResetPassword -> - ({ model - | resetPasswordUserId = Nothing - , resetPasswordNew = "" - }, Cmd.none) - - UpdateResetPasswordNew password -> - ({ model | resetPasswordNew = password }, Cmd.none) - - SaveResetPassword -> - case model.resetPasswordUserId of - Just userId -> - case model.token of - Just token -> - (model, resetUserPassword token userId model.resetPasswordNew) - Nothing -> - (model, Cmd.none) - Nothing -> - (model, Cmd.none) - - ResetPasswordSaved (Ok _) -> - ({ model - | resetPasswordUserId = Nothing - , resetPasswordNew = "" - , error = Just "Passwort erfolgreich zurückgesetzt" - }, case model.token of - Just token -> - fetchUsers token - Nothing -> - Cmd.none - ) - - ResetPasswordSaved (Err _) -> - ({ model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none) - StartEditingTimeEntry entryId entry -> - ({ model - | editingTimeEntryId = Just entryId - , editingTimeEntry = EditingTimeEntry entryId entry.date entry.startTime entry.endTime entry.entryType - }, Cmd.none) - - CancelEditingTimeEntry -> - ({ model - | editingTimeEntryId = Nothing - , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" - }, Cmd.none) - - UpdateEditingTimeEntryDate date -> - let - old = model.editingTimeEntry - new = { old | date = date } - in - ({ model | editingTimeEntry = new }, Cmd.none) - - UpdateEditingTimeEntryStartTime time -> - let - old = model.editingTimeEntry - new = { old | startTime = time } - in - ({ model | editingTimeEntry = new }, Cmd.none) - - UpdateEditingTimeEntryEndTime time -> - let - old = model.editingTimeEntry - new = { old | endTime = time } - in - ({ model | editingTimeEntry = new }, Cmd.none) - - UpdateEditingTimeEntryType entryType -> - let - old = model.editingTimeEntry - new = { old | entryType = entryType } - in - ({ model | editingTimeEntry = new }, Cmd.none) - - SaveEditingTimeEntry -> - case (model.token, model.editingTimeEntryId) of - (Just token, Just entryId) -> - (model, updateTimeEntry token model.editingTimeEntry) - _ -> - (model, Cmd.none) - - TimeEntrySaved (Ok _) -> - case model.token of - Just token -> - ({ model - | editingTimeEntryId = Nothing - , pendingDeleteId = Nothing - , error = Nothing - }, fetchAllTimeEntries token) - Nothing -> - (model, Cmd.none) - - TimeEntrySaved (Err _) -> - ({ model | error = Just "Fehler beim Speichern des Eintrags" }, Cmd.none) - - ConfirmDeleteTimeEntry entryId -> - ({ model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?") - - ConfirmDeleteUser userId -> - ({ model | pendingDeleteId = Just userId }, confirmDelete "Soll dieser Benutzer wirklich gelöscht werden?") - - DeleteConfirmed confirmed -> - if confirmed then - case (model.token, model.pendingDeleteId) of - (Just token, Just id) -> - let - isTimeEntry = List.any (\e -> e.id == id) model.timeEntries - in - if isTimeEntry then - (model, deleteTimeEntry token id) - else - (model, deleteUser token id) - _ -> - (model, Cmd.none) - else - ({ model | pendingDeleteId = Nothing }, Cmd.none) - - SelectUserForManagement userId -> - ({ model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none) - - UpdateUserWorkHours input -> - ({ model | userWorkHoursInput = input }, Cmd.none) - - SaveUserWorkHours -> - case (model.token, model.editingUserId, String.toFloat model.editingUserWorkHours) of -- ← Änderungen! - (Just token, Just userId, Just hours) -> - (model, updateUserWorkHours token userId (String.fromFloat hours)) - _ -> - ({ model | error = Just "Ungültige Eingabe für Arbeitszeit" }, Cmd.none) - - UserWorkHoursSaved (Ok _) -> - case model.token of - Just token -> - ({ model - | editingUserWorkHours = "" -- ← Änderung - , editingUserId = Nothing -- ← Änderung - , error = Nothing - }, fetchUsers token) - Nothing -> - (model, Cmd.none) - -- SaveUserWorkHours -> - -- case (model.token, model.selectedUserId, String.toFloat model.userWorkHoursInput) of - -- (Just token, Just userId, Just hours) -> - -- (model, updateUserWorkHours token userId (String.fromFloat hours)) - -- _ -> - -- ({ model | error = Just "Ungültige Eingabe für Arbeitszeit" }, Cmd.none) - - -- UserWorkHoursSaved (Ok _) -> - -- case model.token of - -- Just token -> - -- ({ model - -- | userWorkHoursInput = "" - -- , error = Nothing - -- }, fetchUsers token) - -- Nothing -> - -- (model, Cmd.none) - - UserWorkHoursSaved (Err _) -> - ({ model | error = Just "Fehler beim Speichern der Arbeitszeit" }, Cmd.none) - - UpdateUserPassword input -> - ({ model | userPasswordInput = input }, Cmd.none) - - SaveUserPassword -> - case (model.token, model.selectedUserId) of - (Just token, Just userId) -> - if String.length model.userPasswordInput > 0 then - (model, resetUserPassword token userId model.userPasswordInput) - else - ({ model | error = Just "Passwort erforderlich" }, Cmd.none) - - _ -> - ({ model | error = Just "Passwort erforderlich" }, Cmd.none) - - UserPasswordSaved (Ok _) -> - ({ model - | userPasswordInput = "" - , selectedUserId = Nothing - , error = Nothing - }, Cmd.none) - - UserPasswordSaved (Err _) -> - ({ model | error = Just "Fehler beim Zurücksetzen des Passworts" }, Cmd.none) + ( model, cmd ) -- SUBSCRIPTIONS + subscriptions : Model -> Sub Msg subscriptions model = - confirmDeleteResponse DeleteConfirmed -- NEU - - --- HELPER FUNCTIONS - -getISOWeekFromPosix : Time.Posix -> (Int, Int) -getISOWeekFromPosix time = - let - year = Time.toYear Time.utc time - month = Time.toMonth Time.utc time |> monthToInt - day = Time.toDay Time.utc time - in - (year, getISOWeek year month day) - -monthToInt : Time.Month -> Int -monthToInt month = - case month of - Time.Jan -> 1 - Time.Feb -> 2 - Time.Mar -> 3 - Time.Apr -> 4 - Time.May -> 5 - Time.Jun -> 6 - Time.Jul -> 7 - Time.Aug -> 8 - Time.Sep -> 9 - Time.Oct -> 10 - Time.Nov -> 11 - Time.Dec -> 12 - -getISOWeek : Int -> Int -> Int -> Int -getISOWeek year month day = - let - dayOfYear = getDayOfYear year month day - - jan4DayOfWeek = getDayOfWeek year 1 4 - - mondayOfWeek1DayOfYear = 4 - jan4DayOfWeek - - weekNum = ((dayOfYear - mondayOfWeek1DayOfYear) // 7) + 1 - in - if weekNum < 1 then - 52 - else if weekNum > 52 then - let - dec31DayOfWeek = getDayOfWeek year 12 31 - jan1DayOfWeek = getDayOfWeek year 1 1 - in - if jan1DayOfWeek == 3 || (isLeapYear year && jan1DayOfWeek == 2) then - weekNum - else - 1 - else - weekNum - -getDayOfYear : Int -> Int -> Int -> Int -getDayOfYear year month day = - let - daysInMonth = [31, if isLeapYear year then 29 else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] - daysBefore = List.take (month - 1) daysInMonth |> List.sum - in - daysBefore + day - -isLeapYear : Int -> Bool -isLeapYear year = - (modBy 4 year == 0) && ((modBy 100 year /= 0) || (modBy 400 year == 0)) - -getDayOfWeek : Int -> Int -> Int -> Int -getDayOfWeek year month day = - let - adjustedMonth = if month < 3 then month + 12 else month - adjustedYear = if month < 3 then year - 1 else year - q = day - m = adjustedMonth - k = modBy 100 adjustedYear - j = adjustedYear // 100 - h = (q + ((13 * (m + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)) |> modBy 7 - in - (h + 5) |> modBy 7 - -getDateForWeekDay : Int -> Int -> Int -> String -getDateForWeekDay year week dayOfWeek = - let - jan4DayOfWeek = getDayOfWeek year 1 4 - - mondayOfWeek1Date = 4 - jan4DayOfWeek - - targetDayOfYear = mondayOfWeek1Date + ((week - 1) * 7) + dayOfWeek - - (finalYear, finalMonth, finalDay) = - if targetDayOfYear < 1 then - addDaysToDate (year - 1) 12 31 (targetDayOfYear) - else - addDaysToDate year 1 targetDayOfYear 0 - in - String.fromInt finalYear ++ "-" ++ - String.padLeft 2 '0' (String.fromInt finalMonth) ++ "-" ++ - String.padLeft 2 '0' (String.fromInt finalDay) - -addDaysToDate : Int -> Int -> Int -> Int -> (Int, Int, Int) -addDaysToDate startYear startMonth startDay daysToAdd = - let - daysInMonth m y = - case m of - 1 -> 31 - 2 -> if isLeapYear y then 29 else 28 - 3 -> 31 - 4 -> 30 - 5 -> 31 - 6 -> 30 - 7 -> 31 - 8 -> 31 - 9 -> 30 - 10 -> 31 - 11 -> 30 - 12 -> 31 - _ -> 0 - - helper y m d remaining = - if remaining == 0 then - (y, m, d) - else if remaining > 0 then - let - daysInCurrentMonth = daysInMonth m y - daysLeftInMonth = daysInCurrentMonth - d - in - if remaining <= daysLeftInMonth then - (y, m, d + remaining) - else if m == 12 then - helper (y + 1) 1 1 (remaining - daysLeftInMonth - 1) - else - helper y (m + 1) 1 (remaining - daysLeftInMonth - 1) - else - if d + remaining >= 1 then - (y, m, d + remaining) - else if m == 1 then - let - prevMonthDays = daysInMonth 12 (y - 1) - in - helper (y - 1) 12 prevMonthDays (remaining + d) - else - let - prevMonthDays = daysInMonth (m - 1) y - in - helper y (m - 1) prevMonthDays (remaining + d) - in - helper startYear startMonth startDay daysToAdd - -previousWeek : Int -> Int -> (Int, Int) -previousWeek year week = - if week == 1 then - (year - 1, 52) - else - (year, week - 1) - -nextWeek : Int -> Int -> (Int, Int) -nextWeek year week = - if week >= 52 then - (year + 1, 1) - else - (year, week + 1) - -getWeekDateRange : Int -> Int -> String -getWeekDateRange year week = - let - mondayDate = getDateForWeekDay year week 0 - fridayDate = getDateForWeekDay year week 4 - in - mondayDate ++ " bis " ++ fridayDate - -getYearWeekFromDate : String -> (Int, Int) -getYearWeekFromDate dateStr = - let - parts = String.split "-" dateStr - year = parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 - month = parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - day = parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 - in - (year, getISOWeek year month day) - -calculateHours : String -> String -> Float -calculateHours startTime endTime = - let - parseTime timeStr = - case String.split ":" timeStr of - [h, m] -> - (String.toFloat h |> Maybe.withDefault 0) + - ((String.toFloat m |> Maybe.withDefault 0) / 60) - _ -> - 0 - - start = parseTime startTime - end = parseTime endTime - in - if end > start then - end - start - else - 0 - - --- VIEW - -view : Model -> Html Msg -view model = - div [ class "container" ] - [ case model.page of - LoginPage -> - viewLogin model - - UserDashboard -> - viewUserDashboard model - - AdminDashboard -> - viewAdminDashboard model - ] - -viewLogin : Model -> Html Msg -viewLogin model = - section [ class "section" ] - [ div [ class "container" ] - [ div [ class "columns is-centered" ] - [ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ] - [ div [ class "box" ] - [ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ] - , case model.error of - Just err -> - div [ class "notification is-danger" ] [ text err ] - Nothing -> - text "" - , div [ class "field" ] - [ label [ class "label" ] [ text "Benutzername" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "text" - , placeholder "Benutzername" - , value model.username - , onInput UpdateUsername - ] [] - ] - ] - , div [ class "field" ] - [ label [ class "label" ] [ text "Passwort" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "password" - , placeholder "Passwort" - , value model.password - , onInput UpdatePassword - ] [] - ] - ] - , div [ class "field" ] - [ div [ class "control" ] - [ button - [ class "button is-primary is-fullwidth" - , onClick Login - ] [ text "Anmelden" ] - ] - ] - ] - ] - ] - ] - ] - -viewUserDashboard : Model -> Html Msg -viewUserDashboard model = - div [] - [ nav [ class "navbar is-primary" ] - [ div [ class "navbar-brand" ] - [ div [ class "navbar-item" ] - [ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ] - ] - ] - , div [ class "navbar-menu" ] - [ div [ class "navbar-end" ] - [ div [ class "navbar-item" ] - [ span [ class "has-text-white mr-4" ] [ text model.username ] - , button [ class "button is-light", onClick Logout ] [ text "Abmelden" ] - ] - ] - ] - ] - , section [ class "section" ] - [ div [ class "container" ] - [ viewWeekNavigation model - , h2 [ class "title" ] [ text "Stundenplan" ] - - , if model.hasEntriesForCurrentWeek && not model.weekEditMode then - div [ class "notification is-success" ] - [ div [ class "level" ] - [ div [ class "level-left" ] - [ div [ class "level-item" ] - [ span [ class "icon" ] - [ i [ class "fas fa-check-circle" ] [] ] - , span [] [ text "Diese Woche wurde bereits erfasst" ] - ] - ] - , div [ class "level-right" ] - [ div [ class "level-item" ] - [ button - [ class "button is-warning" - , onClick EnableEditMode - ] [ text "Bearbeiten" ] - ] - ] - ] - ] - else if model.weekEditMode then - div [ class "notification is-warning" ] - [ div [ class "level" ] - [ div [ class "level-left" ] - [ div [ class "level-item" ] - [ span [ class "icon" ] - [ i [ class "fas fa-edit" ] [] ] - , span [] [ text "Bearbeitungsmodus aktiv" ] - ] - ] - , div [ class "level-right" ] - [ div [ class "level-item" ] - [ button - [ class "button is-danger is-small mr-2" - , onClick DeleteWeekEntries - ] [ text "Einträge löschen" ] - , button - [ class "button is-light is-small" - , onClick DisableEditMode - ] [ text "Abbrechen" ] - ] - ] - ] - ] - else - div [ class "notification is-info is-light" ] - [ text "Wählen Sie die Zeiten aus, die Sie in dieser Woche gearbeitet haben." ] - - , viewScheduleGridWithWeek model - , if not model.hasEntriesForCurrentWeek || model.weekEditMode then - div [ class "field mt-4" ] - [ div [ class "control" ] - [ button - [ class "button is-primary is-large is-fullwidth" - , onClick SaveTimeEntries - , disabled (List.isEmpty model.selectedEntries) - ] [ text (if model.weekEditMode then "Änderungen speichern" else "Speichern") ] - ] - ] - else - text "" - , h3 [ class "subtitle mt-6" ] [ text "Wochenzusammenfassung" ] - , viewUserWeeklySummary model - - , case model.error of - Just err -> - div [ class "notification is-danger mt-4" ] [ text err ] - Nothing -> - text "" - ] - ] - ] - -viewUserWeeklySummary : Model -> Html Msg -viewUserWeeklySummary model = - case model.userWeeklySummary of - Just summary -> - let - progressPercent = Basics.min 100 (summary.totalHours / summary.targetHours * 100) - progressColor = - if summary.totalHours >= summary.targetHours then - "is-success" - else if summary.totalHours >= summary.targetHours * 0.8 then - "is-info" - else - "is-warning" - in - div [ class "box" ] - [ div [ class "columns" ] - [ div [ class "column" ] - [ p [ class "heading" ] [ text "Arbeitszeit diese Woche" ] - , p [ class "title" ] [ text (String.fromFloat summary.totalHours ++ " Std.") ] - , p [ class "subtitle is-6" ] [ text ("von " ++ String.fromFloat summary.targetHours ++ " Std.") ] - ] - , div [ class "column" ] - [ p [ class "heading" ] [ text "Verbleibend" ] - , p [ class "title is-4", classList [("has-text-success", summary.remainingHours <= 0)] ] - [ text (String.fromFloat summary.remainingHours ++ " Std.") ] - , if summary.remainingHours < 0 then - p [ class "subtitle is-6 has-text-success" ] [ text "✓ Ziel erreicht!" ] - else - p [ class "subtitle is-6" ] [ text "" ] - ] - ] - , progress - [ class ("progress " ++ progressColor) - , value (String.fromFloat progressPercent) - , Html.Attributes.max "100" - ] - [ text (String.fromFloat progressPercent ++ "%") ] - ] - - Nothing -> - div [ class "box" ] - [ p [ class "has-text-centered has-text-grey" ] [ text "Laden..." ] - ] - -viewAdminDashboard : Model -> Html Msg -viewAdminDashboard model = - div [] - [ nav [ class "navbar is-danger" ] - [ div [ class "navbar-brand" ] - [ div [ class "navbar-item" ] - [ h1 [ class "title is-4 has-text-white" ] [ text "Admin Dashboard" ] - ] - ] - , div [ class "navbar-menu" ] - [ div [ class "navbar-end" ] - [ div [ class "navbar-item" ] - [ span [ class "has-text-white mr-4" ] [ text model.username ] - , button [ class "button is-light", onClick Logout ] [ text "Abmelden" ] - ] - ] - ] - ] - , section [ class "section" ] - [ div [ class "container" ] - [ div [ class "tabs is-boxed" ] - [ ul [] - [ li [ classList [("is-active", model.activeTab == ScheduleTab)] ] - [ a [ onClick (SwitchTab ScheduleTab) ] [ text "Stundenplan" ] ] - , li [ classList [("is-active", model.activeTab == UsersTab)] ] - [ a [ onClick (SwitchTab UsersTab) ] [ text "Benutzer" ] ] - , li [ classList [("is-active", model.activeTab == TimeEntriesTab)] ] - [ a [ onClick (SwitchTab TimeEntriesTab) ] [ text "Zeiteinträge" ] ] - ] - ] - , case model.activeTab of - ScheduleTab -> - viewScheduleTab model - UsersTab -> - viewUsersTab model - TimeEntriesTab -> - viewTimeEntriesTab model - ] - ] - ] - -viewScheduleTab : Model -> Html Msg -viewScheduleTab model = - div [] - [ h2 [ class "title" ] [ text "Stundenplan verwalten" ] - , viewScheduleForm model - , viewScheduleList model - ] - -viewUsersTab : Model -> Html Msg -viewUsersTab model = - div [] - [ h2 [ class "title" ] [ text "Benutzer verwalten" ] - , viewUserForm model - , viewUserList model - ] - -viewTimeEntriesTab : Model -> Html Msg -viewTimeEntriesTab model = - div [] - [ viewWeekNavigation model - , h2 [ class "title" ] [ text "Wochenstunden Übersicht" ] - , viewWeeklyHoursSummary model - , h2 [ class "title mt-6" ] [ text "Alle Zeiteinträge" ] - - , case model.editingTimeEntryId of - Just _ -> - viewTimeEntriesEditForm model - Nothing -> - viewTimeEntriesListWithEdit model - ] - --- Separate Edit Form View -viewTimeEntriesEditForm : Model -> Html Msg -viewTimeEntriesEditForm model = - div [ class "box has-background-warning-light" ] - [ h3 [ class "subtitle" ] [ text "Zeiteintrag bearbeiten" ] - , div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Datum" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "date" - , value model.editingTimeEntry.date - , onInput UpdateEditTimeEntryDate - ] [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Startzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.editingTimeEntry.startTime - , onInput UpdateEditTimeEntryStartTime - ] [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Endzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.editingTimeEntry.endTime - , onInput UpdateEditTimeEntryEndTime - ] [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Typ" ] - , div [ class "control" ] - [ div [ class "select is-fullwidth" ] - [ select [ onInput UpdateEditTimeEntryType, value model.editingTimeEntry.entryType ] - [ option [ value "lesson" ] [ text "Unterricht" ] - , option [ value "break" ] [ text "Pause" ] - ] - ] - ] - ] - ] - ] - , div [ class "field is-grouped mt-4" ] - [ div [ class "control" ] - [ button - [ class "button is-success" - , onClick SaveEditTimeEntry - ] [ text "Speichern" ] - ] - , div [ class "control" ] - [ button - [ class "button is-light" - , onClick CancelEditTimeEntry - ] [ text "Abbrechen" ] - ] - ] - , viewTimeEntriesListWithEdit model - ] - -viewTimeEntriesListWithEdit : Model -> Html Msg -viewTimeEntriesListWithEdit model = - let - filteredEntries = List.filter - (\e -> - let - (entryYear, entryWeek) = getYearWeekFromDate e.date - in - entryWeek == model.currentWeek && entryYear == model.currentYear - ) - model.timeEntries - in - div [ class "box" ] - [ if List.isEmpty filteredEntries then - p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] - else - table [ class "table is-fullwidth is-striped is-hoverable" ] - [ thead [] - [ tr [] - [ th [] [ text "Mitarbeiter" ] - , th [] [ text "Datum" ] - , th [] [ text "Zeit" ] - , th [] [ text "Typ" ] - , th [ class "has-text-right" ] [ text "Stunden" ] - , th [ class "has-text-centered" ] [ text "Aktionen" ] - ] - ] - , tbody [] - (List.map (viewTimeEntryRowWithEdit model) filteredEntries) - ] - ] - -viewTimeEntryRowWithEdit : Model -> TimeEntry -> Html Msg -viewTimeEntryRowWithEdit model entry = - let - hours = calculateHours entry.startTime entry.endTime - isEditing = model.editingTimeEntryId == Just entry.id - in - if isEditing then - -- Edit-Modus - tr [] - [ td [] [ text entry.username ] - , td [] - [ input - [ class "input is-small" - , type_ "date" - , value model.editingTimeEntry.date - , onInput UpdateEditTimeEntryDate - ] [] - ] - , td [] - [ div [ class "field is-grouped" ] - [ div [ class "control" ] - [ input - [ class "input is-small" - , type_ "time" - , value model.editingTimeEntry.startTime - , onInput UpdateEditTimeEntryStartTime - ] [] - ] - , div [ class "control" ] - [ input - [ class "input is-small" - , type_ "time" - , value model.editingTimeEntry.endTime - , onInput UpdateEditTimeEntryEndTime - ] [] - ] - ] - ] - , td [] - [ div [ class "select is-small" ] - [ select [ value model.editingTimeEntry.entryType, onInput UpdateEditTimeEntryType ] - [ option [ value "lesson" ] [ text "Unterricht" ] - , option [ value "break" ] [ text "Pause" ] - ] - ] - ] - , td [ class "has-text-right" ] [ text "" ] - , td [ class "has-text-centered" ] - [ button [ class "button is-small is-success mr-2", onClick SaveEditTimeEntry ] [ text "✓" ] - , button [ class "button is-small is-light", onClick CancelEditTimeEntry ] [ text "✕" ] - ] - ] - else - tr [] - [ td [] [ text entry.username ] - , td [] [ text entry.date ] - , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] - , td [] [ text entry.entryType ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] - , td [ class "has-text-centered" ] - [ button - [ class "button is-small is-info mr-2" - , onClick (EditTimeEntry entry.id) - ] [ text "Bearbeiten" ] - , button - [ class "button is-small is-danger" - , onClick (ConfirmDeleteTimeEntry entry.id) - ] [ text "Löschen" ] - ] - ] -viewWeekNavigation : Model -> Html Msg -viewWeekNavigation model = - let - dateRange = - case model.weekDates of - Just wd -> wd.range - Nothing -> "Laden..." - in - div [ class "box" ] - [ nav [ class "level" ] - [ div [ class "level-left" ] - [ div [ class "level-item" ] - [ button - [ class "button is-primary" - , onClick PreviousWeek - ] - [ text "← Vorherige Woche" ] - ] - ] - , div [ class "level-item has-text-centered" ] - [ div [] - [ p [ class "heading" ] [ text "Kalenderwoche" ] - , p [ class "title" ] - [ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ] - , p [ class "subtitle is-6" ] - [ text dateRange ] - ] - ] - , div [ class "level-right" ] - [ div [ class "level-item" ] - [ button - [ class "button is-primary" - , onClick NextWeek - ] - [ text "Nächste Woche →" ] - ] - ] - ] - ] - -viewScheduleGridWithWeek : Model -> Html Msg -viewScheduleGridWithWeek model = - let - days = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"] - - groupedSchedules = List.range 0 4 - |> List.map (\day -> - ( day, List.filter (\s -> s.dayOfWeek == day) model.schedules ) - ) - in - div [ class "table-container" ] - [ table [ class "table is-bordered is-fullwidth" ] - [ thead [] - [ tr [] (List.map (\day -> th [ class "has-text-centered" ] [ text day ]) days) - ] - , tbody [] - [ tr [] - (List.map (viewDayColumnWithWeek model) groupedSchedules) - ] - ] - ] - -viewDayColumnWithWeek : Model -> (Int, List Schedule) -> Html Msg -viewDayColumnWithWeek model (dayOfWeek, schedules) = - let - dateForDay = - case model.weekDates of - Just wd -> - wd.dates - |> List.filter (\(day, _) -> day == String.fromInt dayOfWeek) - |> List.head - |> Maybe.map Tuple.second - |> Maybe.withDefault "N/A" - Nothing -> - "Laden..." - in - td [ class "has-background-light", style "vertical-align" "top", style "min-width" "150px" ] - [ p [ class "has-text-centered has-text-weight-bold is-size-7 mb-2" ] - [ text dateForDay ] - , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) - ] - -viewScheduleItemWithDay : Model -> Int -> Schedule -> Html Msg -viewScheduleItemWithDay model dayOfWeek schedule = - let - isSelected = List.any (\e -> e.scheduleId == schedule.id && e.dayOfWeek == dayOfWeek) model.selectedEntries - - isClickable = not model.hasEntriesForCurrentWeek || model.weekEditMode - - boxClass = - if isSelected then - "box has-background-success-light" - else - "box has-background-white" - - typeText = if schedule.scheduleType == "break" then " (Pause)" else "" - - cursorStyle = if isClickable then "pointer" else "not-allowed" - opacity = if isClickable || isSelected then "1" else "0.6" - in - div - [ class boxClass - , onClick (if isClickable then ToggleScheduleSelection schedule.id dayOfWeek else CheckWeekHasEntries) -- Dummy-Event wenn nicht klickbar - , style "cursor" cursorStyle - , style "margin-bottom" "0.5rem" - , style "padding" "0.75rem" - , style "opacity" opacity - ] - [ p [ class "has-text-weight-bold is-size-7" ] - [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] - , p [ class "is-size-7" ] - [ text (schedule.title ++ typeText) ] - ] - -viewScheduleForm : Model -> Html Msg -viewScheduleForm model = - div [ class "box" ] - [ div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Wochentag" ] - , div [ class "control" ] - [ div [ class "select is-fullwidth" ] - [ select [ onInput UpdateNewScheduleDay ] - [ option [ value "" ] [ text "Wochentag wählen" ] - , option [ value "0" ] [ text "Montag" ] - , option [ value "1" ] [ text "Dienstag" ] - , option [ value "2" ] [ text "Mittwoch" ] - , option [ value "3" ] [ text "Donnerstag" ] - , option [ value "4" ] [ text "Freitag" ] - ] - ] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Startzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.newSchedule.startTime - , onInput UpdateNewScheduleStart - ] [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Endzeit" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "time" - , value model.newSchedule.endTime - , onInput UpdateNewScheduleEnd - ] [] - ] - ] - ] - ] - , div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Typ" ] - , div [ class "control" ] - [ div [ class "select is-fullwidth" ] - [ select [ onInput UpdateNewScheduleType, value model.newSchedule.scheduleType ] - [ option [ value "lesson" ] [ text "Unterricht" ] - , option [ value "break" ] [ text "Pause" ] - ] - ] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Titel" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "text" - , placeholder "z.B. Mathematik" - , value model.newSchedule.title - , onInput UpdateNewScheduleTitle - ] [] - ] - ] - ] - ] - , div [ class "field" ] - [ div [ class "control" ] - [ button [ class "button is-primary", onClick CreateSchedule ] [ text "Hinzufügen" ] - ] - ] - ] - -viewScheduleList : Model -> Html Msg -viewScheduleList model = - div [ class "box" ] - [ h3 [ class "subtitle" ] [ text "Aktueller Stundenplan" ] - , table [ class "table is-fullwidth is-striped" ] - [ thead [] - [ tr [] - [ th [] [ text "Tag" ] - , th [] [ text "Zeit" ] - , th [] [ text "Typ" ] - , th [] [ text "Titel" ] - , th [] [ text "Aktion" ] - ] - ] - , tbody [] - (List.map viewScheduleRow model.schedules) - ] - ] - -viewScheduleRow : Schedule -> Html Msg -viewScheduleRow schedule = - let - dayName = case schedule.dayOfWeek of - 0 -> "Montag" - 1 -> "Dienstag" - 2 -> "Mittwoch" - 3 -> "Donnerstag" - 4 -> "Freitag" - _ -> "Unbekannt" - - typeName = if schedule.scheduleType == "break" then "Pause" else "Unterricht" - in - tr [] - [ td [] [ text dayName ] - , td [] [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] - , td [] [ text typeName ] - , td [] [ text schedule.title ] - , td [] - [ button - [ class "button is-small is-danger" - , onClick (DeleteSchedule schedule.id) - ] [ text "Löschen" ] - ] - ] - -viewUserForm : Model -> Html Msg -viewUserForm model = - div [ class "box" ] - [ div [ class "columns" ] - [ div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Benutzername" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "text" - , placeholder "Benutzername" - , value model.newUser.username - , onInput UpdateNewUsername - ] [] - ] - ] - ] - , div [ class "column" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Passwort" ] - , div [ class "control" ] - [ input - [ class "input" - , type_ "password" - , placeholder "Passwort" - , value model.newUser.password - , onInput UpdateNewPassword - ] [] - ] - ] - ] - , div [ class "column is-narrow" ] - [ div [ class "field" ] - [ label [ class "label" ] [ text "Admin" ] - , div [ class "control" ] - [ label [ class "checkbox" ] - [ input - [ type_ "checkbox" - , checked model.newUser.isAdmin - , onCheck UpdateNewUserAdmin - ] [] - , text " Admin-Rechte" - ] - ] - ] - ] - ] - , div [ class "field" ] - [ div [ class "control" ] - [ button [ class "button is-primary", onClick CreateUser ] [ text "Benutzer anlegen" ] - ] - ] - ] - -viewUserList : Model -> Html Msg -viewUserList model = - div [ class "box" ] - [ h3 [ class "subtitle" ] [ text "Benutzer" ] - , if List.isEmpty model.users then - p [ class "has-text-centered" ] [ text "Keine Benutzer vorhanden" ] - else - table [ class "table is-fullwidth is-striped is-hoverable" ] - [ thead [] - [ tr [] - [ th [] [ text "ID" ] - , th [] [ text "Benutzername" ] - , th [] [ text "Rolle" ] - , th [ class "has-text-right" ] [ text "Arbeitszeit/Woche" ] - , th [ class "has-text-centered" ] [ text "Aktionen" ] - ] - ] - , tbody [] - (List.map (viewUserRowWithActions model) model.users) - ] - ] - -viewUserRowWithActions : Model -> User -> Html Msg -viewUserRowWithActions model user = - if model.editingUserId == Just user.id then - -- Edit Work Hours Mode - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] [ text (if user.isAdmin then "Admin" else "Benutzer") ] - , td [] - [ input - [ class "input is-small" - , type_ "number" - , step "0.5" - , value model.editingUserWorkHours - , onInput UpdateEditUserWorkHours - ] [] - ] - , td [ class "has-text-centered" ] - [ button [ class "button is-small is-success mr-2", onClick SaveUserWorkHours ] [ text "✓" ] - , button [ class "button is-small is-light", onClick CancelEditUserWorkHours ] [ text "✕" ] - ] - ] - else if model.resetPasswordUserId == Just user.id then - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] [ text (if user.isAdmin then "Admin" else "Benutzer") ] - , td [] - [ input - [ class "input is-small" - , type_ "password" - , placeholder "Neues Passwort" - , value model.resetPasswordNew - , onInput UpdateResetPasswordNew - ] [] - ] - , td [ class "has-text-centered" ] - [ button [ class "button is-small is-success mr-2", onClick SaveResetPassword ] [ text "✓" ] - , button [ class "button is-small is-light", onClick CancelResetPassword ] [ text "✕" ] - ] - ] - else - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] [ text (if user.isAdmin then "Admin" else "Benutzer") ] - , td [ class "has-text-right" ] [ text (String.fromFloat user.weeklyWorkHours ++ " Std.") ] - , td [ class "has-text-centered" ] - [ if user.id == 1 then - span [ class "tag is-light" ] [ text "Geschützt" ] - else - div [] - [ button - [ class "button is-small is-info mr-2" - , onClick (EditUserWorkHours user.id) - ] [ text "Arbeitszeit" ] - , button - [ class "button is-small is-warning mr-2" - , onClick (ResetUserPassword user.id) - ] [ text "PW Reset" ] - , button - [ class "button is-small is-danger" - , onClick (DeleteUser user.id) - ] [ text "Löschen" ] - ] - ] - ] - -viewUserRow : User -> Html Msg -viewUserRow user = - tr [] - [ td [] [ text (String.fromInt user.id) ] - , td [] [ text user.username ] - , td [] [ text (if user.isAdmin then "Admin" else "Benutzer") ] - , td [] - [ if user.id == 1 then - span [ class "tag is-light" ] [ text "Geschützt" ] - else - button - [ class "button is-small is-danger" - , onClick (DeleteUser user.id) - ] [ text "Löschen" ] - ] - ] - -viewWeeklyHoursSummary : Model -> Html Msg -viewWeeklyHoursSummary model = - let - filteredHours = List.filter - (\h -> h.week == model.currentWeek && h.year == model.currentYear) - model.weeklyHours - in - div [ class "box" ] - [ if List.isEmpty filteredHours then - p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] - else - table [ class "table is-fullwidth is-striped" ] - [ thead [] - [ tr [] - [ th [] [ text "Mitarbeiter" ] - , th [ class "has-text-right" ] [ text "Arbeitet" ] - , th [ class "has-text-right" ] [ text "Soll" ] - , th [ class "has-text-right" ] [ text "Verbleibend" ] - , th [] [ text "Fortschritt" ] - ] - ] - , tbody [] - (List.map viewWeeklyHoursRow filteredHours) - , tfoot [] - [ tr [ class "has-background-light" ] - [ th [] [ text "Gesamt" ] - , th [ class "has-text-right has-text-weight-bold" ] - [ text (String.fromFloat (List.sum (List.map .totalHours filteredHours)) ++ " Std.") ] - , th [ class "has-text-right has-text-weight-bold" ] - [ text (String.fromFloat (List.sum (List.map .targetHours filteredHours)) ++ " Std.") ] - , th [] [ text "" ] - , th [] [ text "" ] - ] - ] - ] - ] - -viewWeeklyHoursRow : WeeklyHours -> Html Msg -viewWeeklyHoursRow hours = - let - progressPercent = Basics.min 100 (hours.totalHours / hours.targetHours * 100) - progressColor = - if hours.totalHours >= hours.targetHours then - "is-success" - else if hours.totalHours >= hours.targetHours * 0.8 then - "is-info" - else - "is-warning" - in - tr [] - [ td [] [ text hours.username ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours.totalHours ++ " Std.") ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours.targetHours ++ " Std.") ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours.remainingHours ++ " Std.") ] - , td [] - [ progress - [ class ("progress " ++ progressColor) - , value (String.fromFloat progressPercent) - , Html.Attributes.max "100" - ] [] - ] - ] - -viewTimeEntriesList : Model -> Html Msg -viewTimeEntriesList model = - let - filteredEntries = List.filter - (\e -> - let - (entryYear, entryWeek) = getYearWeekFromDate e.date - in - entryWeek == model.currentWeek && entryYear == model.currentYear - ) - model.timeEntries - in - div [ class "box" ] - [ if List.isEmpty filteredEntries then - p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] - else - table [ class "table is-fullwidth is-striped" ] - [ thead [] - [ tr [] - [ th [] [ text "Mitarbeiter" ] - , th [] [ text "Datum" ] - , th [] [ text "Zeit" ] - , th [] [ text "Typ" ] - , th [ class "has-text-right" ] [ text "Stunden" ] - ] - ] - , tbody [] - (List.map (viewTimeEntryRowWithActions model) filteredEntries) -- KORRIGIERT: model übergeben - ] - ] - -viewTimeEntryRowWithActions : Model -> TimeEntry -> Html Msg -viewTimeEntryRowWithActions model entry = - let - hours = - if entry.entryType == "lesson" then - 1.0 - else - calculateHours entry.startTime entry.endTime - in - tr [] - [ td [] [ text entry.username ] - , td [] [ text entry.date ] - , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] - , td [] [ text entry.entryType ] - , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] - , td [] - [ div [ class "buttons are-small" ] - [ button - [ class "button is-info is-small" - , onClick (StartEditingTimeEntry entry.id entry) - ] [ text "Bearbeiten" ] - , button - [ class "button is-danger is-small" - , onClick (ConfirmDeleteTimeEntry entry.id) - ] [ text "Löschen" ] - ] - ] - ] - --- HTTP - -type alias LoginResult = - { token : String - , username : String - , isAdmin : Bool - } - -loginRequest : String -> String -> Cmd Msg -loginRequest username password = - Http.post - { url = "/api/login" - , body = Http.jsonBody <| - Encode.object - [ ("username", Encode.string username) - , ("password", Encode.string password) - ] - , expect = Http.expectJson LoginResponse loginDecoder - } - -loginDecoder : Decoder LoginResult -loginDecoder = - Decode.map3 LoginResult - (field "token" string) - (field "username" string) - (field "is_admin" bool) - -fetchSchedules : Maybe String -> Cmd Msg -fetchSchedules maybeToken = - case maybeToken of - Just token -> - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/schedules" - , body = Http.emptyBody - , expect = Http.expectJson SchedulesReceived (Decode.list scheduleDecoder) - , timeout = Nothing - , tracker = Nothing - } - Nothing -> - Cmd.none - -scheduleDecoder : Decoder Schedule -scheduleDecoder = - Decode.map6 Schedule - (field "id" int) - (field "day_of_week" int) - (field "start_time" string) - (field "end_time" string) - (field "type" string) - (field "title" string) - -fetchMyTimeEntries : String -> Cmd Msg -fetchMyTimeEntries token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/my-time-entries" - , body = Http.emptyBody - , expect = Http.expectJson MyTimeEntriesReceived (Decode.list timeEntryDecoder) - , timeout = Nothing - , tracker = Nothing - } - -saveTimeEntriesForWeek : String -> List SelectedEntry -> Int -> Int -> List Schedule -> Maybe WeekDates -> Cmd Msg -saveTimeEntriesForWeek token selectedEntries year week schedules maybeWeekDates = - case maybeWeekDates of - Nothing -> - Cmd.none - - Just weekDates -> - let - getScheduleById id = - List.filter (\s -> s.id == id) schedules |> List.head - - getDateForDay dayOfWeek = - weekDates.dates - |> List.filter (\(day, _) -> day == String.fromInt dayOfWeek) - |> List.head - |> Maybe.map Tuple.second - - createEntryData entry = - case (getScheduleById entry.scheduleId, getDateForDay entry.dayOfWeek) of - (Just schedule, Just dateStr) -> - Just <| Encode.object - [ ("schedule_id", Encode.int entry.scheduleId) - , ("date", Encode.string dateStr) - , ("type", Encode.string schedule.scheduleType) - , ("start_time", Encode.string schedule.startTime) - , ("end_time", Encode.string schedule.endTime) - ] - _ -> - Nothing - - entriesData = List.filterMap createEntryData selectedEntries - in - if List.isEmpty entriesData then - Cmd.none - else - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/time-entries/batch" - , body = Http.jsonBody <| Encode.object [ ("entries", Encode.list identity entriesData) ] - , expect = Http.expectWhatever TimeEntriesSaved - , timeout = Nothing - , tracker = Nothing - } - -deleteWeekEntries : String -> Int -> Int -> Cmd Msg -deleteWeekEntries token year week = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/my-time-entries/week?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week - , body = Http.emptyBody - , expect = Http.expectWhatever WeekEntriesDeleted - , timeout = Nothing - , tracker = Nothing - } - -createSchedule : String -> NewSchedule -> Cmd Msg -createSchedule token schedule = - case String.toInt schedule.dayOfWeek of - Just day -> - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/schedules" - , body = Http.jsonBody <| - Encode.object - [ ("day_of_week", Encode.int day) - , ("start_time", Encode.string schedule.startTime) - , ("end_time", Encode.string schedule.endTime) - , ("type", Encode.string schedule.scheduleType) - , ("title", Encode.string schedule.title) - ] - , expect = Http.expectWhatever ScheduleCreated - , timeout = Nothing - , tracker = Nothing - } - Nothing -> - Cmd.none - -deleteSchedule : String -> Int -> Cmd Msg -deleteSchedule token scheduleId = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/schedules/delete?id=" ++ String.fromInt scheduleId - , body = Http.emptyBody - , expect = Http.expectWhatever ScheduleDeleted - , timeout = Nothing - , tracker = Nothing - } - -createUser : String -> NewUser -> Cmd Msg -createUser token user = - Http.request - { method = "POST" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users" - , body = Http.jsonBody <| - Encode.object - [ ("username", Encode.string user.username) - , ("password", Encode.string user.password) - , ("is_admin", Encode.bool user.isAdmin) - ] - , expect = Http.expectWhatever UserCreated - , timeout = Nothing - , tracker = Nothing - } - -deleteUser : String -> Int -> Cmd Msg -deleteUser token userId = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/delete?id=" ++ String.fromInt userId - , body = Http.emptyBody - , expect = Http.expectWhatever UserDeleted - , timeout = Nothing - , tracker = Nothing - } - -fetchUsers : String -> Cmd Msg -fetchUsers token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/list" - , body = Http.emptyBody - , expect = Http.expectJson UsersReceived (Decode.list userDecoder) - , timeout = Nothing - , tracker = Nothing - } - -userDecoder : Decoder User -userDecoder = - Decode.map4 User - (field "id" int) - (field "username" string) - (field "is_admin" bool) - (field "weekly_hours" float) -- NEU - -fetchAllTimeEntries : String -> Cmd Msg -fetchAllTimeEntries token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/time-entries" - , body = Http.emptyBody - , expect = Http.expectJson AllTimeEntriesReceived (Decode.list timeEntryDecoder) - , timeout = Nothing - , tracker = Nothing - } - -timeEntryDecoder : Decoder TimeEntry -timeEntryDecoder = - Decode.map8 TimeEntry - (field "id" int) - (field "user_id" int) - (field "schedule_id" int) - (field "date" string) - (field "type" string) - (field "username" string) - (field "start_time" string) - (field "end_time" string) - -fetchWeeklyHours : String -> Cmd Msg -fetchWeeklyHours token = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/weekly-hours" - , body = Http.emptyBody - , expect = Http.expectJson WeeklyHoursReceived (Decode.list weeklyHoursDecoder) - , timeout = Nothing - , tracker = Nothing - } - -weeklyHoursDecoder : Decoder WeeklyHours -weeklyHoursDecoder = - Decode.map7 WeeklyHours - (field "user_id" int) - (field "username" string) - (field "year" int) - (field "week" int) - (field "total_hours" float) - (field "expected_hours" float) -- NEU - (field "remaining_hours" float) -- NEU - -fetchWeekDates : String -> Int -> Int -> Cmd Msg -fetchWeekDates token year week = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/week-dates?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week - , body = Http.emptyBody - , expect = Http.expectJson WeekDatesReceived weekDatesDecoder - , timeout = Nothing - , tracker = Nothing - } - -weekDatesDecoder : Decoder WeekDates -weekDatesDecoder = - Decode.map4 WeekDates - (field "year" int) - (field "week" int) - (field "dates" (Decode.dict string) |> Decode.map Dict.toList) - (field "range" string) - -checkWeekHasEntries : String -> Int -> Int -> Cmd Msg -checkWeekHasEntries token year week = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/week-has-entries?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week - , body = Http.emptyBody - , expect = Http.expectJson WeekHasEntriesReceived (field "has_entries" bool) - , timeout = Nothing - , tracker = Nothing - } - -fetchMyWeeklySummary : String -> Int -> Int -> Cmd Msg -fetchMyWeeklySummary token year week = - Http.request - { method = "GET" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/my-weekly-summary?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week - , body = Http.emptyBody - , expect = Http.expectJson MyWeeklySummaryReceived weeklySummaryDecoder - , timeout = Nothing - , tracker = Nothing - } - -weeklySummaryDecoder : Decoder WeeklySummary -weeklySummaryDecoder = - Decode.map7 WeeklySummary - (field "user_id" int) - (field "username" string) - (field "year" int) - (field "week" int) - (field "total_hours" float) - (field "expected_hours" float) - (field "remaining_hours" float) - -updateTimeEntry : String -> EditingTimeEntry -> Cmd Msg -updateTimeEntry token entry = - Http.request - { method = "PUT" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/time-entries/" ++ String.fromInt entry.entryId - , body = Http.jsonBody <| - Encode.object - [ ("date", Encode.string entry.date) - , ("start_time", Encode.string entry.startTime) - , ("end_time", Encode.string entry.endTime) - , ("type", Encode.string entry.entryType) - ] - , expect = Http.expectWhatever TimeEntrySaved - , timeout = Nothing - , tracker = Nothing - } - -deleteTimeEntry : String -> Int -> Cmd Msg -deleteTimeEntry token entryId = - Http.request - { method = "DELETE" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/time-entries/" ++ String.fromInt entryId - , body = Http.emptyBody - , expect = Http.expectWhatever TimeEntryDeleted - , timeout = Nothing - , tracker = Nothing - } - -updateUserWorkHours : String -> Int -> String -> Cmd Msg -updateUserWorkHours token userId hours = - case String.toFloat hours of - Just workHours -> - Http.request - { method = "PUT" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/" ++ String.fromInt userId - , body = Http.jsonBody <| - Encode.object - [ ("weekly_hours", Encode.float workHours) ] - , expect = Http.expectWhatever UserWorkHoursSaved - , timeout = Nothing - , tracker = Nothing - } - Nothing -> - Cmd.none - -resetUserPassword : String -> Int -> String -> Cmd Msg -resetUserPassword token userId newPassword = - Http.request - { method = "PUT" - , headers = [ Http.header "Authorization" ("Bearer " ++ token) ] - , url = "/api/admin/users/" ++ String.fromInt userId ++ "/reset-password" - , body = Http.jsonBody <| - Encode.object - [ ("new_password", Encode.string newPassword) ] - , expect = Http.expectWhatever ResetPasswordSaved - , timeout = Nothing - , tracker = Nothing - } - + confirmDeleteResponse DeleteConfirmed diff --git a/frontend/src/Types/Api.elm b/frontend/src/Types/Api.elm new file mode 100644 index 0000000..aae29d0 --- /dev/null +++ b/frontend/src/Types/Api.elm @@ -0,0 +1,17 @@ +module Types.Api exposing + ( ApiError + , LoginResult + ) + + +type alias LoginResult = + { token : String + , username : String + , isAdmin : Bool + } + + +type alias ApiError = + { code : String + , message : String + } diff --git a/frontend/src/Types/Model.elm b/frontend/src/Types/Model.elm new file mode 100644 index 0000000..64911d6 --- /dev/null +++ b/frontend/src/Types/Model.elm @@ -0,0 +1,218 @@ +module Types.Model exposing + ( AdminManualEntry + , EditingTimeEntry + , Flags + , Model + , NewSchedule + , NewSchoolYear + , NewUser + , Schedule + , SchoolYear + , SelectedEntry + , TimeEntry + , Toast + , ToastType(..) + , User + , WeekDates + , WeeklyHours + , WeeklySummary + , YearlyHoursSummary + ) + +import Time +import Types.Page exposing (AdminTab, Page) + + +type alias Model = + { page : Page + , activeTab : AdminTab + , username : String + , password : String + , token : Maybe String + , isAdmin : Bool + , schedules : List Schedule + , users : List User + , timeEntries : List TimeEntry + , weeklyHours : List WeeklyHours + , yearlyHoursSummary : List YearlyHoursSummary + , selectedEntries : List SelectedEntry + , currentWeek : Int + , currentYear : Int + , weekDates : Maybe WeekDates + , currentTime : Time.Posix + , zone : Time.Zone + , newSchedule : NewSchedule + , newUser : NewUser + , error : Maybe String + , weekEditMode : Bool + , hasEntriesForCurrentWeek : Bool + , userWeeklySummary : Maybe WeeklySummary + , editingTimeEntryId : Maybe Int + , editingTimeEntry : EditingTimeEntry + , editingUserId : Maybe Int + , editingUserWorkHours : String + , resetPasswordUserId : Maybe Int + , resetPasswordNew : String + , pendingDeleteId : Maybe Int + , selectedUserId : Maybe Int + , userWorkHoursInput : String + , userPasswordInput : String + , isProcessing : Bool + , mobileMenuOpen : Bool + , adminManualEntryForm : AdminManualEntry + , schoolYears : List SchoolYear + , newSchoolYear : NewSchoolYear + , activeSchoolYear : Maybe SchoolYear + , editingSchoolYearId : Maybe Int + , toasts : List Toast + , nextToastId : Int + } + + +type ToastType + = ErrorToast + | SuccessToast + | InfoToast + | WarningToast + + +type alias Toast = + { id : Int + , message : String + , toastType : ToastType + , dismissible : Bool + } + + +type alias Flags = + { token : Maybe String + , isAdmin : Bool + } + + +type alias Schedule = + { id : Int + , dayOfWeek : Int + , startTime : String + , endTime : String + , scheduleType : String + , title : String + } + + +type alias User = + { id : Int + , username : String + , isAdmin : Bool + , yearlyWorkHours : Float + } + + +type alias TimeEntry = + { id : Int + , userId : Int + , scheduleId : Int + , date : String + , entryType : String + , username : String + , startTime : String + , endTime : String + } + + +type alias SelectedEntry = + { scheduleId : Int + , dayOfWeek : Int + } + + +type alias NewSchedule = + { dayOfWeek : String + , startTime : String + , endTime : String + , scheduleType : String + , title : String + } + + +type alias NewUser = + { username : String + , password : String + , isAdmin : Bool + } + + +type alias WeekDates = + { year : Int + , week : Int + , dates : List ( String, String ) + , range : String + } + + +type alias WeeklySummary = + { userId : Int + , username : String + , year : Int + , week : Int + , totalHours : Float + , targetHours : Float + , remainingHours : Float + } + + +type alias EditingTimeEntry = + { entryId : Int + , date : String + , startTime : String + , endTime : String + , entryType : String + } + + +type alias WeeklyHours = + { userId : Int + , username : String + , year : Int + , week : Int + , totalHours : Float + , targetHours : Float + , remainingHours : Float + } + + +type alias YearlyHoursSummary = + { userId : Int + , username : String + , year : Int + , week : Int + , totalHours : Float + , yearlyTarget : Float + , yearlyActual : Float + , weeklyTarget : Float + , remainingYearly : Float + } + + +type alias AdminManualEntry = + { selectedUserId : Maybe Int + , date : String + , hours : String + , entryType : String + } + + +type alias SchoolYear = + { id : Int + , name : String + , startDate : String + , endDate : String + , isActive : Bool + } + + +type alias NewSchoolYear = + { name : String + , startDate : String + , endDate : String + } diff --git a/frontend/src/Types/Msg.elm b/frontend/src/Types/Msg.elm new file mode 100644 index 0000000..4158571 --- /dev/null +++ b/frontend/src/Types/Msg.elm @@ -0,0 +1,133 @@ +module Types.Msg exposing (Msg(..)) + +import Bytes exposing (Bytes) +import Http +import Time +import Types.Api exposing (LoginResult) +import Types.Model + exposing + ( Schedule + , SchoolYear + , TimeEntry + , ToastType(..) + , User + , WeekDates + , WeeklyHours + , WeeklySummary + , YearlyHoursSummary + ) +import Types.Page exposing (AdminTab) + + +type Msg + = UpdateUsername String + | UpdatePassword String + | Login + | LoginResponse (Result Http.Error LoginResult) + | Logout + | SetTime Time.Posix + | FetchSchedules + | SchedulesReceived (Result Http.Error (List Schedule)) + | ToggleScheduleSelection Int Int + | SaveTimeEntries + | TimeEntriesSaved (Result Http.Error ()) + | PreviousWeek + | NextWeek + | EnableEditMode + | DisableEditMode + | DeleteWeekEntries + | WeekEntriesDeleted (Result Http.Error ()) + | SwitchTab AdminTab + | UpdateNewScheduleDay String + | UpdateNewScheduleStart String + | UpdateNewScheduleEnd String + | UpdateNewScheduleType String + | UpdateNewScheduleTitle String + | CreateSchedule + | ScheduleCreated (Result Http.Error ()) + | DeleteSchedule Int + | ScheduleDeleted (Result Http.Error ()) + | UpdateNewUsername String + | UpdateNewPassword String + | UpdateNewUserAdmin Bool + | CreateUser + | UserCreated (Result Http.Error ()) + | DeleteUser Int + | UserDeleted (Result Http.Error ()) + | FetchUsers + | UsersReceived (Result Http.Error (List User)) + | FetchMyTimeEntries + | MyTimeEntriesReceived (Result Http.Error (List TimeEntry)) + | FetchAllTimeEntries + | AllTimeEntriesReceived (Result Http.Error (List TimeEntry)) + | FetchWeeklyHours + | WeeklyHoursReceived (Result Http.Error (List WeeklyHours)) + | FetchYearlyHoursSummary + | YearlyHoursSummaryReceived (Result Http.Error (List YearlyHoursSummary)) + | FetchWeekDates + | WeekDatesReceived (Result Http.Error WeekDates) + | CheckWeekHasEntries + | WeekHasEntriesReceived (Result Http.Error Bool) + | MyWeeklySummaryReceived (Result Http.Error WeeklySummary) + | EditTimeEntry Int + | CancelEditTimeEntry + | UpdateEditTimeEntryDate String + | UpdateEditTimeEntryStartTime String + | UpdateEditTimeEntryEndTime String + | UpdateEditTimeEntryType String + | SaveEditTimeEntry + | TimeEntrySaved (Result Http.Error ()) + | TimeEntryDeleted (Result Http.Error ()) + | EditUserWorkHours Int + | CancelEditUserWorkHours + | UpdateEditUserWorkHours String + | SaveUserWorkHours + | UserWorkHoursSaved (Result Http.Error ()) + | ResetUserPassword Int + | CancelResetPassword + | UpdateResetPasswordNew String + | SaveResetPassword + | ResetPasswordSaved (Result Http.Error ()) + | ConfirmDeleteTimeEntry Int + | ConfirmDeleteUser Int + | DeleteConfirmed Bool + | StartEditingTimeEntry Int TimeEntry + | CancelEditingTimeEntry + | UpdateEditingTimeEntryDate String + | UpdateEditingTimeEntryStartTime String + | UpdateEditingTimeEntryEndTime String + | UpdateEditingTimeEntryType String + | SaveEditingTimeEntry + | SelectUserForManagement Int + | UpdateUserWorkHours String + | UpdateUserPassword String + | SaveUserPassword + | UserPasswordSaved (Result Http.Error ()) + | ToggleMobileMenu + | CloseMobileMenu + | SelectUserForManualEntry Int + | UpdateManualEntryDate String + | UpdateManualEntryHours String + | UpdateManualEntryType String + | SaveAdminTimeEntry + | AdminTimeEntrySaved (Result Http.Error ()) + | FetchMyInfo + | MyInfoReceived (Result Http.Error User) + | FetchSchoolYears + | SchoolYearsReceived (Result Http.Error (List SchoolYear)) + | FetchActiveSchoolYear + | ActiveSchoolYearReceived (Result Http.Error SchoolYear) + | UpdateNewSchoolYearName String + | UpdateNewSchoolYearStart String + | UpdateNewSchoolYearEnd String + | CreateSchoolYear + | SchoolYearCreated (Result Http.Error ()) + | ActivateSchoolYear Int + | SchoolYearActivated (Result Http.Error ()) + | DeleteSchoolYear Int + | SchoolYearDeleted (Result Http.Error ()) + | DownloadYearlySummaryPDF + | YearlySummaryPDFReceived (Result Http.Error Bytes) + | ShowToast String ToastType + | DismissToast Int + | AutoDismissToast Int diff --git a/frontend/src/Types/Page.elm b/frontend/src/Types/Page.elm new file mode 100644 index 0000000..5b41054 --- /dev/null +++ b/frontend/src/Types/Page.elm @@ -0,0 +1,17 @@ +module Types.Page exposing + ( AdminTab(..) + , Page(..) + ) + + +type Page + = LoginPage + | UserDashboard + | AdminDashboard + + +type AdminTab + = ScheduleTab + | UsersTab + | TimeEntriesTab + | SchoolYearsTab diff --git a/frontend/src/Update/AuthUpdate.elm b/frontend/src/Update/AuthUpdate.elm new file mode 100644 index 0000000..20a1fbc --- /dev/null +++ b/frontend/src/Update/AuthUpdate.elm @@ -0,0 +1,115 @@ +module Update.AuthUpdate exposing + ( handleLogin + , handleLoginResponse + , handleLogout + ) + +import Api.Auth +import Api.Schedule +import Api.SchoolYear +import Api.TimeEntry +import Api.User +import Http +import Json.Encode as Encode +import Task +import Types.Model exposing (Model, ToastType(..)) +import Types.Msg exposing (Msg(..)) +import Types.Page exposing (Page(..)) +import Utils.DateUtils exposing (getISOWeekFromPosix) +import Utils.Ports exposing (removeToken, saveToken) + + +handleLogin : Model -> ( Model, Cmd Msg ) +handleLogin model = + if model.isProcessing then + ( model, Cmd.none ) + + else + ( { model | isProcessing = True }, Api.Auth.loginRequest model.username model.password ) + + +handleLoginResponse : Result Http.Error { token : String, username : String, isAdmin : Bool } -> Model -> ( Model, Cmd Msg ) +handleLoginResponse result model = + case result of + Ok loginResult -> + let + newPage = + if loginResult.isAdmin then + AdminDashboard + + else + UserDashboard + + ( year, week ) = + getISOWeekFromPosix model.currentTime + + tokenData = + Encode.object + [ ( "token", Encode.string loginResult.token ) + , ( "isAdmin", Encode.bool loginResult.isAdmin ) + ] + in + ( { model + | token = Just loginResult.token + , username = loginResult.username + , isAdmin = loginResult.isAdmin + , page = newPage + , error = Nothing + , isProcessing = False + } + , Cmd.batch + [ saveToken tokenData + , Api.Schedule.fetchSchedules (Just loginResult.token) + , Task.perform (\_ -> ShowToast ("Willkommen, " ++ loginResult.username ++ "!") SuccessToast) (Task.succeed ()) + , if not loginResult.isAdmin then + Cmd.batch + [ Api.TimeEntry.fetchMyTimeEntries loginResult.token + , Api.TimeEntry.fetchWeekDates loginResult.token year week + , Api.TimeEntry.checkWeekHasEntries loginResult.token year week + , Api.TimeEntry.fetchYearlyHoursSummary loginResult.token + , Api.User.fetchMyInfo loginResult.token + ] + + else + Cmd.batch + [ Api.TimeEntry.fetchMyTimeEntries loginResult.token + , Api.TimeEntry.fetchWeekDates loginResult.token year week + , Api.TimeEntry.checkWeekHasEntries loginResult.token year week + , Api.TimeEntry.fetchYearlyHoursSummary loginResult.token + ] + ] + ) + + Err err -> + let + errorMsg = + case err of + Http.BadStatus 401 -> + "Benutzername oder Passwort ungültig" + + Http.Timeout -> + "Zeitüberschreitung - bitte erneut versuchen" + + Http.NetworkError -> + "Netzwerkfehler - bitte Verbindung prüfen" + + _ -> + "Anmeldung fehlgeschlagen" + in + ( { model | isProcessing = False } + , Task.perform (\_ -> ShowToast errorMsg ErrorToast) (Task.succeed ()) + ) + + +handleLogout : Model -> ( Model, Cmd Msg ) +handleLogout model = + ( { model + | page = LoginPage + , token = Nothing + , isAdmin = False + , username = "" + , password = "" + , isProcessing = False + } + , removeToken () + ) diff --git a/frontend/src/Update/ScheduleUpdate.elm b/frontend/src/Update/ScheduleUpdate.elm new file mode 100644 index 0000000..2312e13 --- /dev/null +++ b/frontend/src/Update/ScheduleUpdate.elm @@ -0,0 +1,244 @@ +module Update.ScheduleUpdate exposing + ( handleCreateSchedule + , handleDeleteSchedule + , handleDeleteWeekEntries + , handleDisableEditMode + , handleEnableEditMode + , handleSaveTimeEntries + , handleScheduleCreated + , handleScheduleDeleted + , handleSchedulesReceived + , handleTimeEntriesSaved + , handleToggleScheduleSelection + , handleWeekEntriesDeleted + ) + +import Api.Schedule +import Api.TimeEntry +import Http +import Task +import Types.Model exposing (Model, NewSchedule, Schedule, SelectedEntry, ToastType(..)) +import Types.Msg exposing (Msg(..)) +import Utils.DateUtils exposing (getDayOfWeek, getYearWeekFromDate) + + +handleToggleScheduleSelection : Int -> Int -> Model -> ( Model, Cmd Msg ) +handleToggleScheduleSelection scheduleId dayOfWeek model = + let + entry = + { scheduleId = scheduleId, dayOfWeek = dayOfWeek } + + newSelected = + if List.any (\e -> e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek) model.selectedEntries then + List.filter (\e -> not (e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek)) model.selectedEntries + + else + entry :: model.selectedEntries + in + ( { model | selectedEntries = newSelected }, Cmd.none ) + + +handleSaveTimeEntries : Model -> ( Model, Cmd Msg ) +handleSaveTimeEntries model = + case model.token of + Just token -> + ( { model | error = Nothing } + , Api.Schedule.saveTimeEntriesForWeek token model.selectedEntries model.currentYear model.currentWeek model.schedules model.weekDates + ) + + Nothing -> + ( model, Cmd.none ) + + +handleTimeEntriesSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleTimeEntriesSaved result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | error = Nothing + , weekEditMode = False + , hasEntriesForCurrentWeek = True + } + , Cmd.batch + [ Api.TimeEntry.fetchMyTimeEntries token + , Task.perform (\_ -> ShowToast "Zeiteinträge erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleEnableEditMode : Model -> ( Model, Cmd Msg ) +handleEnableEditMode model = + let + currentWeekEntries = + List.filter + (\e -> + let + ( entryYear, entryWeek ) = + getYearWeekFromDate e.date + in + entryWeek == model.currentWeek && entryYear == model.currentYear + ) + model.timeEntries + + preSelectedEntries = + List.map + (\entry -> + let + parts = + String.split "-" entry.date + + year = + parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 + + month = + parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + + day = + parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + + dayOfWeek = + getDayOfWeek year month day + in + { scheduleId = entry.scheduleId, dayOfWeek = dayOfWeek } + ) + currentWeekEntries + in + ( { model + | weekEditMode = True + , selectedEntries = preSelectedEntries + } + , Cmd.none + ) + + +handleDisableEditMode : Model -> ( Model, Cmd Msg ) +handleDisableEditMode model = + ( { model | weekEditMode = False }, Cmd.none ) + + +handleDeleteWeekEntries : Model -> ( Model, Cmd Msg ) +handleDeleteWeekEntries model = + case model.token of + Just token -> + ( model, Api.TimeEntry.deleteWeekEntries token model.currentYear model.currentWeek ) + + Nothing -> + ( model, Cmd.none ) + + +handleWeekEntriesDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleWeekEntriesDeleted result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | weekEditMode = True + , selectedEntries = [] + , hasEntriesForCurrentWeek = False + } + , Cmd.batch + [ Api.TimeEntry.fetchMyTimeEntries token + , Task.perform (\_ -> ShowToast "Wocheneinträge erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleCreateSchedule : Model -> ( Model, Cmd Msg ) +handleCreateSchedule model = + if + String.isEmpty model.newSchedule.dayOfWeek + || String.isEmpty model.newSchedule.startTime + || String.isEmpty model.newSchedule.endTime + then + ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) + + else + case model.token of + Just token -> + ( { model | isProcessing = True }, Api.Schedule.createSchedule token model.newSchedule ) + + Nothing -> + ( model, Cmd.none ) + + +handleScheduleCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleScheduleCreated result model = + case result of + Ok _ -> + case model.token of + Just token -> + let + emptySchedule = + NewSchedule "" "" "" "lesson" "" + in + ( { model + | newSchedule = emptySchedule + , error = Nothing + , isProcessing = False + } + , Cmd.batch + [ Api.Schedule.fetchSchedules model.token + , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( { model | isProcessing = False }, Cmd.none ) + + +handleDeleteSchedule : Int -> Model -> ( Model, Cmd Msg ) +handleDeleteSchedule scheduleId model = + case model.token of + Just token -> + ( model, Api.Schedule.deleteSchedule token scheduleId ) + + Nothing -> + ( model, Cmd.none ) + + +handleScheduleDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleScheduleDeleted result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model | error = Nothing } + , Cmd.batch + [ Api.Schedule.fetchSchedules (Just token) + , Task.perform (\_ -> ShowToast "Stundenplan erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleSchedulesReceived : Result Http.Error (List Schedule) -> Model -> ( Model, Cmd Msg ) +handleSchedulesReceived result model = + case result of + Ok schedules -> + ( { model | schedules = schedules }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) diff --git a/frontend/src/Update/SchoolYearUpdate.elm b/frontend/src/Update/SchoolYearUpdate.elm new file mode 100644 index 0000000..0de741d --- /dev/null +++ b/frontend/src/Update/SchoolYearUpdate.elm @@ -0,0 +1,139 @@ +module Update.SchoolYearUpdate exposing + ( handleActivateSchoolYear + , handleActiveSchoolYearReceived + , handleCreateSchoolYear + , handleDeleteSchoolYear + , handleSchoolYearActivated + , handleSchoolYearCreated + , handleSchoolYearDeleted + , handleSchoolYearsReceived + ) + +import Api.SchoolYear +import Http +import Task +import Types.Model exposing (Model, NewSchoolYear, SchoolYear, ToastType(..)) +import Types.Msg exposing (Msg(..)) + + +handleCreateSchoolYear : Model -> ( Model, Cmd Msg ) +handleCreateSchoolYear model = + if + String.isEmpty model.newSchoolYear.name + || String.isEmpty model.newSchoolYear.startDate + || String.isEmpty model.newSchoolYear.endDate + then + ( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) ) + + else + case model.token of + Just token -> + ( { model | isProcessing = True }, Api.SchoolYear.createSchoolYear token model.newSchoolYear ) + + Nothing -> + ( model, Cmd.none ) + + +handleSchoolYearCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleSchoolYearCreated result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | newSchoolYear = NewSchoolYear "" "" "" + , error = Nothing + , isProcessing = False + } + , Cmd.batch + [ Api.SchoolYear.fetchSchoolYears token + , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( { model | isProcessing = False }, Cmd.none ) + + +handleActivateSchoolYear : Int -> Model -> ( Model, Cmd Msg ) +handleActivateSchoolYear id model = + case model.token of + Just token -> + ( model, Api.SchoolYear.activateSchoolYear token id ) + + Nothing -> + ( model, Cmd.none ) + + +handleSchoolYearActivated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleSchoolYearActivated result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model | error = Nothing } + , Cmd.batch + [ Api.SchoolYear.fetchSchoolYears token + , Api.SchoolYear.fetchActiveSchoolYear token + , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich aktiviert!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleDeleteSchoolYear : Int -> Model -> ( Model, Cmd Msg ) +handleDeleteSchoolYear id model = + case model.token of + Just token -> + ( model, Api.SchoolYear.deleteSchoolYear token id ) + + Nothing -> + ( model, Cmd.none ) + + +handleSchoolYearDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleSchoolYearDeleted result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model | error = Nothing } + , Cmd.batch + [ Api.SchoolYear.fetchSchoolYears token + , Task.perform (\_ -> ShowToast "Schuljahr erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleSchoolYearsReceived : Result Http.Error (List SchoolYear) -> Model -> ( Model, Cmd Msg ) +handleSchoolYearsReceived result model = + case result of + Ok years -> + ( { model | schoolYears = years }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleActiveSchoolYearReceived : Result Http.Error SchoolYear -> Model -> ( Model, Cmd Msg ) +handleActiveSchoolYearReceived result model = + case result of + Ok year -> + ( { model | activeSchoolYear = Just year }, Cmd.none ) + + Err _ -> + ( { model | activeSchoolYear = Nothing }, Cmd.none ) diff --git a/frontend/src/Update/TimeEntryUpdate.elm b/frontend/src/Update/TimeEntryUpdate.elm new file mode 100644 index 0000000..a794944 --- /dev/null +++ b/frontend/src/Update/TimeEntryUpdate.elm @@ -0,0 +1,189 @@ +module Update.TimeEntryUpdate exposing + ( handleAdminTimeEntrySaved + , handleAllTimeEntriesReceived + , handleConfirmDeleteTimeEntry + , handleEditTimeEntry + , handleMyTimeEntriesReceived + , handleSaveAdminTimeEntry + , handleSaveEditTimeEntry + , handleTimeEntryDeleted + , handleTimeEntrySaved + , handleYearlyHoursSummaryReceived + ) + +import Api.TimeEntry +import Http +import Task +import Types.Model exposing (AdminManualEntry, EditingTimeEntry, Model, TimeEntry, ToastType(..), YearlyHoursSummary) +import Types.Msg exposing (Msg(..)) +import Utils.DateUtils exposing (getYearWeekFromDate) +import Utils.Ports exposing (confirmDelete) + + +handleMyTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg ) +handleMyTimeEntriesReceived result model = + case result of + Ok entries -> + let + hasEntries = + List.any + (\e -> + let + ( entryYear, entryWeek ) = + getYearWeekFromDate e.date + in + entryWeek == model.currentWeek && entryYear == model.currentYear + ) + entries + in + ( { model + | timeEntries = entries + , hasEntriesForCurrentWeek = hasEntries + , weekEditMode = False + } + , Cmd.none + ) + + Err err -> + ( model, Cmd.none ) + + +handleAllTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg ) +handleAllTimeEntriesReceived result model = + case result of + Ok entries -> + ( { model | timeEntries = entries }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleEditTimeEntry : Int -> Model -> ( Model, Cmd Msg ) +handleEditTimeEntry entryId model = + case List.filter (\e -> e.id == entryId) model.timeEntries |> List.head of + Just entry -> + ( { model + | editingTimeEntryId = Just entryId + , editingTimeEntry = + { entryId = entryId + , date = entry.date + , startTime = entry.startTime + , endTime = entry.endTime + , entryType = entry.entryType + } + } + , Cmd.none + ) + + Nothing -> + ( model, Cmd.none ) + + +handleSaveEditTimeEntry : Model -> ( Model, Cmd Msg ) +handleSaveEditTimeEntry model = + case model.token of + Just token -> + ( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry ) + + Nothing -> + ( model, Cmd.none ) + + +handleTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleTimeEntrySaved result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | editingTimeEntryId = Nothing + , pendingDeleteId = Nothing + , error = Nothing + } + , Cmd.batch + [ Api.TimeEntry.fetchAllTimeEntries token + , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleTimeEntryDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleTimeEntryDeleted result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" + , pendingDeleteId = Nothing + , error = Nothing + } + , Cmd.batch + [ Api.TimeEntry.fetchAllTimeEntries token + , Api.TimeEntry.fetchYearlyHoursSummary token + , Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( { model | pendingDeleteId = Nothing }, Cmd.none ) + + +handleConfirmDeleteTimeEntry : Int -> Model -> ( Model, Cmd Msg ) +handleConfirmDeleteTimeEntry entryId model = + ( { model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?" ) + + +handleSaveAdminTimeEntry : Model -> ( Model, Cmd Msg ) +handleSaveAdminTimeEntry model = + case model.token of + Just token -> + ( { model | isProcessing = True }, Api.TimeEntry.createAdminTimeEntry token model.adminManualEntryForm ) + + Nothing -> + ( model, Cmd.none ) + + +handleAdminTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleAdminTimeEntrySaved result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | adminManualEntryForm = AdminManualEntry Nothing "" "" "manual" + , error = Nothing + , isProcessing = False + } + , Cmd.batch + [ Api.TimeEntry.fetchAllTimeEntries token + , Api.TimeEntry.fetchYearlyHoursSummary token + , Task.perform (\_ -> ShowToast "Manueller Eintrag erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( { model | isProcessing = False }, Cmd.none ) + + +handleYearlyHoursSummaryReceived : Result Http.Error (List YearlyHoursSummary) -> Model -> ( Model, Cmd Msg ) +handleYearlyHoursSummaryReceived result model = + case result of + Ok summary -> + ( { model | yearlyHoursSummary = summary }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) diff --git a/frontend/src/Update/Update.elm b/frontend/src/Update/Update.elm new file mode 100644 index 0000000..f384b8c --- /dev/null +++ b/frontend/src/Update/Update.elm @@ -0,0 +1,811 @@ +module Update.Update exposing (update) + +import Api.Schedule +import Api.SchoolYear +import Api.TimeEntry +import Api.User +import File.Download +import Process +import Task +import Time +import Types.Model exposing (EditingTimeEntry, Model, NewUser, ToastType(..)) +import Types.Msg exposing (Msg(..)) +import Types.Page exposing (AdminTab(..), Page(..)) +import Update.AuthUpdate as Auth +import Update.ScheduleUpdate as Schedule +import Update.SchoolYearUpdate as SchoolYear +import Update.TimeEntryUpdate as TimeEntry +import Update.UserUpdate as User +import Utils.DateUtils exposing (getISOWeekFromPosix, nextWeek, previousWeek) +import Utils.Ports + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + -- Mobile Menu + ToggleMobileMenu -> + ( { model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none ) + + CloseMobileMenu -> + ( { model | mobileMenuOpen = False }, Cmd.none ) + + -- Auth + UpdateUsername username -> + ( { model | username = username }, Cmd.none ) + + UpdatePassword password -> + ( { model | password = password }, Cmd.none ) + + Login -> + Auth.handleLogin model + + LoginResponse result -> + Auth.handleLoginResponse result model + + Logout -> + Auth.handleLogout model + + -- Time + SetTime time -> + let + ( year, week ) = + getISOWeekFromPosix time + + cmds = + case model.token of + Just token -> + if model.page == UserDashboard || model.page == LoginPage then + Cmd.batch + [ Api.TimeEntry.checkWeekHasEntries token year week + , Api.TimeEntry.fetchWeekDates token year week + , Api.TimeEntry.fetchMyTimeEntries token + ] + + else + Cmd.none + + Nothing -> + Cmd.none + in + ( { model + | currentTime = time + , currentWeek = week + , currentYear = year + } + , cmds + ) + + -- Schedules + FetchSchedules -> + ( model, Api.Schedule.fetchSchedules model.token ) + + SchedulesReceived result -> + Schedule.handleSchedulesReceived result model + + ToggleScheduleSelection scheduleId dayOfWeek -> + Schedule.handleToggleScheduleSelection scheduleId dayOfWeek model + + SaveTimeEntries -> + Schedule.handleSaveTimeEntries model + + TimeEntriesSaved result -> + Schedule.handleTimeEntriesSaved result model + + EnableEditMode -> + Schedule.handleEnableEditMode model + + DisableEditMode -> + Schedule.handleDisableEditMode model + + DeleteWeekEntries -> + Schedule.handleDeleteWeekEntries model + + WeekEntriesDeleted result -> + Schedule.handleWeekEntriesDeleted result model + + CreateSchedule -> + Schedule.handleCreateSchedule model + + ScheduleCreated result -> + Schedule.handleScheduleCreated result model + + DeleteSchedule scheduleId -> + Schedule.handleDeleteSchedule scheduleId model + + ScheduleDeleted result -> + Schedule.handleScheduleDeleted result model + + -- Week Navigation + PreviousWeek -> + let + ( newYear, newWeek ) = + previousWeek model.currentYear model.currentWeek + in + ( { model + | currentWeek = newWeek + , currentYear = newYear + , selectedEntries = [] + , weekEditMode = False + } + , case model.token of + Just token -> + Cmd.batch + [ Api.TimeEntry.fetchWeekDates token newYear newWeek + , Api.TimeEntry.checkWeekHasEntries token newYear newWeek + ] + + Nothing -> + Cmd.none + ) + + NextWeek -> + let + ( newYear, newWeek ) = + nextWeek model.currentYear model.currentWeek + in + ( { model + | currentWeek = newWeek + , currentYear = newYear + , selectedEntries = [] + , weekEditMode = False + } + , case model.token of + Just token -> + Cmd.batch + [ Api.TimeEntry.fetchWeekDates token newYear newWeek + , Api.TimeEntry.checkWeekHasEntries token newYear newWeek + ] + + Nothing -> + Cmd.none + ) + + FetchWeekDates -> + case model.token of + Just token -> + ( model, Api.TimeEntry.fetchWeekDates token model.currentYear model.currentWeek ) + + Nothing -> + ( model, Cmd.none ) + + WeekDatesReceived result -> + case result of + Ok weekDates -> + ( { model | weekDates = Just weekDates }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + CheckWeekHasEntries -> + case model.token of + Just token -> + ( model, Api.TimeEntry.checkWeekHasEntries token model.currentYear model.currentWeek ) + + Nothing -> + ( model, Cmd.none ) + + WeekHasEntriesReceived result -> + case result of + Ok hasEntries -> + ( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + -- Admin Tabs + SwitchTab tab -> + let + cmd = + case tab of + UsersTab -> + case model.token of + Just token -> + Api.User.fetchUsers token + + Nothing -> + Cmd.none + + TimeEntriesTab -> + case model.token of + Just token -> + Cmd.batch + [ Api.TimeEntry.fetchAllTimeEntries token + , Api.TimeEntry.fetchYearlyHoursSummary token + ] + + Nothing -> + Cmd.none + + SchoolYearsTab -> + case model.token of + Just token -> + Cmd.batch + [ Api.SchoolYear.fetchSchoolYears token + , Api.SchoolYear.fetchActiveSchoolYear token + ] + + Nothing -> + Cmd.none + + _ -> + Cmd.none + in + ( { model | activeTab = tab, mobileMenuOpen = False }, cmd ) + + -- Schedule Form + UpdateNewScheduleDay day -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | dayOfWeek = day } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleStart time -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | startTime = time } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleEnd time -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | endTime = time } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleType scheduleType -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | scheduleType = scheduleType } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + UpdateNewScheduleTitle title -> + let + oldSchedule = + model.newSchedule + + newSchedule = + { oldSchedule | title = title } + in + ( { model | newSchedule = newSchedule }, Cmd.none ) + + -- Users + UpdateNewUsername username -> + let + oldUser = + model.newUser + + newUser = + { oldUser | username = username } + in + ( { model | newUser = newUser }, Cmd.none ) + + UpdateNewPassword password -> + let + oldUser = + model.newUser + + newUser = + { oldUser | password = password } + in + ( { model | newUser = newUser }, Cmd.none ) + + UpdateNewUserAdmin isAdmin -> + let + oldUser = + model.newUser + + newUser = + { oldUser | isAdmin = isAdmin } + in + ( { model | newUser = newUser }, Cmd.none ) + + CreateUser -> + User.handleCreateUser model + + UserCreated result -> + User.handleUserCreated result model + + DeleteUser userId -> + User.handleDeleteUser userId model + + UserDeleted result -> + User.handleUserDeleted result model + + FetchUsers -> + case model.token of + Just token -> + ( model, Api.User.fetchUsers token ) + + Nothing -> + ( model, Cmd.none ) + + UsersReceived result -> + User.handleUsersReceived result model + + EditUserWorkHours userId -> + User.handleEditUserWorkHours userId model + + CancelEditUserWorkHours -> + ( { model + | editingUserId = Nothing + , editingUserWorkHours = "" + } + , Cmd.none + ) + + UpdateEditUserWorkHours hours -> + ( { model | editingUserWorkHours = hours }, Cmd.none ) + + SaveUserWorkHours -> + User.handleSaveUserWorkHours model + + UserWorkHoursSaved result -> + User.handleUserWorkHoursSaved result model + + ResetUserPassword userId -> + User.handleResetUserPassword userId model + + CancelResetPassword -> + ( { model + | resetPasswordUserId = Nothing + , resetPasswordNew = "" + } + , Cmd.none + ) + + UpdateResetPasswordNew password -> + ( { model | resetPasswordNew = password }, Cmd.none ) + + SaveResetPassword -> + User.handleSaveResetPassword model + + ResetPasswordSaved result -> + User.handleResetPasswordSaved result model + + UpdateUserWorkHours input -> + ( { model | userWorkHoursInput = input }, Cmd.none ) + + UpdateUserPassword input -> + ( { model | userPasswordInput = input }, Cmd.none ) + + SaveUserPassword -> + case ( model.token, model.selectedUserId ) of + ( Just token, Just userId ) -> + if String.length model.userPasswordInput > 0 then + ( model, Api.User.resetUserPassword token userId model.userPasswordInput ) + + else + ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) + + _ -> + ( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) ) + + UserPasswordSaved result -> + case result of + Ok _ -> + ( { model + | userPasswordInput = "" + , selectedUserId = Nothing + , error = Nothing + } + , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt!" SuccessToast) (Task.succeed ()) + ) + + Err err -> + ( model, Cmd.none ) + + SelectUserForManagement userId -> + ( { model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none ) + + -- Time Entries + FetchMyTimeEntries -> + case model.token of + Just token -> + ( model, Api.TimeEntry.fetchMyTimeEntries token ) + + Nothing -> + ( model, Cmd.none ) + + MyTimeEntriesReceived result -> + TimeEntry.handleMyTimeEntriesReceived result model + + FetchAllTimeEntries -> + case model.token of + Just token -> + ( model, Api.TimeEntry.fetchAllTimeEntries token ) + + Nothing -> + ( model, Cmd.none ) + + AllTimeEntriesReceived result -> + TimeEntry.handleAllTimeEntriesReceived result model + + EditTimeEntry entryId -> + TimeEntry.handleEditTimeEntry entryId model + + CancelEditTimeEntry -> + ( { model + | editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" + } + , Cmd.none + ) + + UpdateEditTimeEntryDate date -> + let + old = + model.editingTimeEntry + + new = + { old | date = date } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditTimeEntryStartTime time -> + let + old = + model.editingTimeEntry + + new = + { old | startTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditTimeEntryEndTime time -> + let + old = + model.editingTimeEntry + + new = + { old | endTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditTimeEntryType entryType -> + let + old = + model.editingTimeEntry + + new = + { old | entryType = entryType } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + SaveEditTimeEntry -> + TimeEntry.handleSaveEditTimeEntry model + + TimeEntrySaved result -> + TimeEntry.handleTimeEntrySaved result model + + TimeEntryDeleted result -> + TimeEntry.handleTimeEntryDeleted result model + + ConfirmDeleteTimeEntry entryId -> + TimeEntry.handleConfirmDeleteTimeEntry entryId model + + StartEditingTimeEntry entryId entry -> + ( { model + | editingTimeEntryId = Just entryId + , editingTimeEntry = EditingTimeEntry entryId entry.date entry.startTime entry.endTime entry.entryType + } + , Cmd.none + ) + + CancelEditingTimeEntry -> + ( { model + | editingTimeEntryId = Nothing + , editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson" + } + , Cmd.none + ) + + UpdateEditingTimeEntryDate date -> + let + old = + model.editingTimeEntry + + new = + { old | date = date } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditingTimeEntryStartTime time -> + let + old = + model.editingTimeEntry + + new = + { old | startTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditingTimeEntryEndTime time -> + let + old = + model.editingTimeEntry + + new = + { old | endTime = time } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + UpdateEditingTimeEntryType entryType -> + let + old = + model.editingTimeEntry + + new = + { old | entryType = entryType } + in + ( { model | editingTimeEntry = new }, Cmd.none ) + + SaveEditingTimeEntry -> + case ( model.token, model.editingTimeEntryId ) of + ( Just token, Just entryId ) -> + ( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry ) + + _ -> + ( model, Cmd.none ) + + -- Weekly Hours + FetchWeeklyHours -> + case model.token of + Just token -> + ( model, Cmd.none ) + + Nothing -> + ( model, Cmd.none ) + + WeeklyHoursReceived result -> + case result of + Ok hours -> + ( { model | weeklyHours = hours }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + MyWeeklySummaryReceived result -> + case result of + Ok summary -> + ( { model | userWeeklySummary = Just summary }, Cmd.none ) + + Err _ -> + ( { model | userWeeklySummary = Nothing }, Cmd.none ) + + -- Yearly Hours + FetchYearlyHoursSummary -> + case model.token of + Just token -> + ( model, Api.TimeEntry.fetchYearlyHoursSummary token ) + + Nothing -> + ( model, Cmd.none ) + + YearlyHoursSummaryReceived result -> + TimeEntry.handleYearlyHoursSummaryReceived result model + + -- Admin Manual Entry + SelectUserForManualEntry userId -> + let + form = + model.adminManualEntryForm + in + ( { model | adminManualEntryForm = { form | selectedUserId = Just userId } }, Cmd.none ) + + UpdateManualEntryDate date -> + let + form = + model.adminManualEntryForm + in + ( { model | adminManualEntryForm = { form | date = date } }, Cmd.none ) + + UpdateManualEntryHours hours -> + let + form = + model.adminManualEntryForm + in + ( { model | adminManualEntryForm = { form | hours = hours } }, Cmd.none ) + + UpdateManualEntryType entryType -> + let + form = + model.adminManualEntryForm + in + ( { model | adminManualEntryForm = { form | entryType = entryType } }, Cmd.none ) + + SaveAdminTimeEntry -> + TimeEntry.handleSaveAdminTimeEntry model + + AdminTimeEntrySaved result -> + TimeEntry.handleAdminTimeEntrySaved result model + + -- My Info + FetchMyInfo -> + case model.token of + Just token -> + ( model, Api.User.fetchMyInfo token ) + + Nothing -> + ( model, Cmd.none ) + + MyInfoReceived result -> + case result of + Ok user -> + ( { model | users = [ user ] }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + -- School Years + FetchSchoolYears -> + case model.token of + Just token -> + ( model, Api.SchoolYear.fetchSchoolYears token ) + + Nothing -> + ( model, Cmd.none ) + + SchoolYearsReceived result -> + SchoolYear.handleSchoolYearsReceived result model + + FetchActiveSchoolYear -> + case model.token of + Just token -> + ( model, Api.SchoolYear.fetchActiveSchoolYear token ) + + Nothing -> + ( model, Cmd.none ) + + ActiveSchoolYearReceived result -> + SchoolYear.handleActiveSchoolYearReceived result model + + UpdateNewSchoolYearName name -> + let + old = + model.newSchoolYear + + new = + { old | name = name } + in + ( { model | newSchoolYear = new }, Cmd.none ) + + UpdateNewSchoolYearStart date -> + let + old = + model.newSchoolYear + + new = + { old | startDate = date } + in + ( { model | newSchoolYear = new }, Cmd.none ) + + UpdateNewSchoolYearEnd date -> + let + old = + model.newSchoolYear + + new = + { old | endDate = date } + in + ( { model | newSchoolYear = new }, Cmd.none ) + + CreateSchoolYear -> + SchoolYear.handleCreateSchoolYear model + + SchoolYearCreated result -> + SchoolYear.handleSchoolYearCreated result model + + ActivateSchoolYear id -> + SchoolYear.handleActivateSchoolYear id model + + SchoolYearActivated result -> + SchoolYear.handleSchoolYearActivated result model + + DeleteSchoolYear id -> + SchoolYear.handleDeleteSchoolYear id model + + SchoolYearDeleted result -> + SchoolYear.handleSchoolYearDeleted result model + + -- PDF Download + DownloadYearlySummaryPDF -> + case model.token of + Just token -> + ( { model | isProcessing = True }, Api.TimeEntry.downloadYearlySummaryPDF token ) + + Nothing -> + ( model, Cmd.none ) + + YearlySummaryPDFReceived result -> + case result of + Ok pdfBytes -> + let + filename = + "Jahresuebersicht_" ++ String.fromInt model.currentYear ++ ".pdf" + in + ( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes ) + + Err err -> + ( { model | isProcessing = False }, Cmd.none ) + + -- Delete Confirmation + ConfirmDeleteUser userId -> + ( { model | pendingDeleteId = Just userId }, Utils.Ports.confirmDelete "Soll dieser Benutzer wirklich gelöscht werden?" ) + + DeleteConfirmed confirmed -> + if confirmed then + case ( model.token, model.pendingDeleteId ) of + ( Just token, Just id ) -> + let + isTimeEntry = + List.any (\e -> e.id == id) model.timeEntries + in + if isTimeEntry then + ( model, Api.TimeEntry.deleteTimeEntry token id ) + + else + ( model, Api.User.deleteUser token id ) + + _ -> + ( model, Cmd.none ) + + else + ( { model | pendingDeleteId = Nothing }, Cmd.none ) + + -- Toasts + ShowToast message toastType -> + let + newToast = + { id = model.nextToastId + , message = message + , toastType = toastType + , dismissible = True + } + + dismissDelay = + case toastType of + ErrorToast -> + 8000 + + SuccessToast -> + 5000 + + InfoToast -> + 5000 + + WarningToast -> + 6000 + in + ( { model + | toasts = model.toasts ++ [ newToast ] + , nextToastId = model.nextToastId + 1 + } + , Task.perform (\_ -> AutoDismissToast newToast.id) + (Process.sleep dismissDelay) + ) + + DismissToast toastId -> + ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } + , Cmd.none + ) + + AutoDismissToast toastId -> + ( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts } + , Cmd.none + ) diff --git a/frontend/src/Update/UserUpdate.elm b/frontend/src/Update/UserUpdate.elm new file mode 100644 index 0000000..9fd4b85 --- /dev/null +++ b/frontend/src/Update/UserUpdate.elm @@ -0,0 +1,196 @@ +module Update.UserUpdate exposing + ( handleCreateUser + , handleDeleteUser + , handleEditUserWorkHours + , handleResetPasswordSaved + , handleResetUserPassword + , handleSaveResetPassword + , handleSaveUserWorkHours + , handleUserCreated + , handleUserDeleted + , handleUserWorkHoursSaved + , handleUsersReceived + ) + +import Api.User +import Http +import Task +import Types.Model exposing (Model, NewUser, ToastType(..), User) +import Types.Msg exposing (Msg(..)) + + +handleCreateUser : Model -> ( Model, Cmd Msg ) +handleCreateUser model = + case model.token of + Just token -> + ( model, Api.User.createUser token model.newUser ) + + Nothing -> + ( model, Cmd.none ) + + +handleUserCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleUserCreated result model = + case result of + Ok _ -> + let + emptyUser = + NewUser "" "" False + in + case model.token of + Just token -> + ( { model | newUser = emptyUser } + , Cmd.batch + [ Api.User.fetchUsers token + , Task.perform (\_ -> ShowToast "Benutzer erfolgreich erstellt!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleDeleteUser : Int -> Model -> ( Model, Cmd Msg ) +handleDeleteUser userId model = + case model.token of + Just token -> + ( model, Api.User.deleteUser token userId ) + + Nothing -> + ( model, Cmd.none ) + + +handleUserDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleUserDeleted result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | pendingDeleteId = Nothing + , error = Nothing + , editingUserId = Nothing + , resetPasswordUserId = Nothing + } + , Cmd.batch + [ Api.User.fetchUsers token + , Task.perform (\_ -> ShowToast "Benutzer erfolgreich gelöscht" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( { model | pendingDeleteId = Nothing }, Cmd.none ) + + +handleUsersReceived : Result Http.Error (List User) -> Model -> ( Model, Cmd Msg ) +handleUsersReceived result model = + case result of + Ok users -> + ( { model | users = users }, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleEditUserWorkHours : Int -> Model -> ( Model, Cmd Msg ) +handleEditUserWorkHours userId model = + case List.filter (\u -> u.id == userId) model.users |> List.head of + Just user -> + ( { model + | editingUserId = Just userId + , editingUserWorkHours = String.fromFloat user.yearlyWorkHours + } + , Cmd.none + ) + + Nothing -> + ( model, Cmd.none ) + + +handleSaveUserWorkHours : Model -> ( Model, Cmd Msg ) +handleSaveUserWorkHours model = + case ( model.token, model.editingUserId, String.toFloat model.editingUserWorkHours ) of + ( Just token, Just userId, Just hours ) -> + ( model, Api.User.updateUserWorkHours token userId (String.fromFloat hours) ) + + _ -> + ( model, Task.perform (\_ -> ShowToast "Ungültige Eingabe für Arbeitszeit" WarningToast) (Task.succeed ()) ) + + +handleUserWorkHoursSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleUserWorkHoursSaved result model = + case result of + Ok _ -> + case model.token of + Just token -> + ( { model + | editingUserWorkHours = "" + , editingUserId = Nothing + , error = Nothing + } + , Cmd.batch + [ Api.User.fetchUsers token + , Task.perform (\_ -> ShowToast "Arbeitszeit erfolgreich gespeichert!" SuccessToast) (Task.succeed ()) + ] + ) + + Nothing -> + ( model, Cmd.none ) + + Err err -> + ( model, Cmd.none ) + + +handleResetUserPassword : Int -> Model -> ( Model, Cmd Msg ) +handleResetUserPassword userId model = + ( { model + | resetPasswordUserId = Just userId + , resetPasswordNew = "" + } + , Cmd.none + ) + + +handleSaveResetPassword : Model -> ( Model, Cmd Msg ) +handleSaveResetPassword model = + case model.resetPasswordUserId of + Just userId -> + case model.token of + Just token -> + ( model, Api.User.resetUserPassword token userId model.resetPasswordNew ) + + Nothing -> + ( model, Cmd.none ) + + Nothing -> + ( model, Cmd.none ) + + +handleResetPasswordSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg ) +handleResetPasswordSaved result model = + case result of + Ok _ -> + ( { model + | resetPasswordUserId = Nothing + , resetPasswordNew = "" + , error = Nothing + } + , Cmd.batch + [ case model.token of + Just token -> + Api.User.fetchUsers token + + Nothing -> + Cmd.none + , Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt" SuccessToast) (Task.succeed ()) + ] + ) + + Err err -> + ( model, Cmd.none ) diff --git a/frontend/src/Utils/DateUtils.elm b/frontend/src/Utils/DateUtils.elm new file mode 100644 index 0000000..1ea98dd --- /dev/null +++ b/frontend/src/Utils/DateUtils.elm @@ -0,0 +1,338 @@ +module Utils.DateUtils exposing + ( addDaysToDate + , getDateForWeekDay + , getDayOfWeek + , getDayOfYear + , getISOWeek + , getISOWeekFromPosix + , getWeekDateRange + , getYearWeekFromDate + , isLeapYear + , monthToInt + , nextWeek + , previousWeek + ) + +import Time + + +getISOWeekFromPosix : Time.Posix -> ( Int, Int ) +getISOWeekFromPosix time = + let + year = + Time.toYear Time.utc time + + month = + Time.toMonth Time.utc time |> monthToInt + + day = + Time.toDay Time.utc time + in + ( year, getISOWeek year month day ) + + +monthToInt : Time.Month -> Int +monthToInt month = + case month of + Time.Jan -> + 1 + + Time.Feb -> + 2 + + Time.Mar -> + 3 + + Time.Apr -> + 4 + + Time.May -> + 5 + + Time.Jun -> + 6 + + Time.Jul -> + 7 + + Time.Aug -> + 8 + + Time.Sep -> + 9 + + Time.Oct -> + 10 + + Time.Nov -> + 11 + + Time.Dec -> + 12 + + +getISOWeek : Int -> Int -> Int -> Int +getISOWeek year month day = + let + dayOfYear = + getDayOfYear year month day + + jan4DayOfWeek = + getDayOfWeek year 1 4 + + mondayOfWeek1DayOfYear = + 4 - jan4DayOfWeek + + weekNum = + ((dayOfYear - mondayOfWeek1DayOfYear) // 7) + 1 + in + if weekNum < 1 then + 52 + + else if weekNum > 52 then + let + dec31DayOfWeek = + getDayOfWeek year 12 31 + + jan1DayOfWeek = + getDayOfWeek year 1 1 + in + if jan1DayOfWeek == 3 || (isLeapYear year && jan1DayOfWeek == 2) then + weekNum + + else + 1 + + else + weekNum + + +getDayOfYear : Int -> Int -> Int -> Int +getDayOfYear year month day = + let + daysInMonth = + [ 31 + , if isLeapYear year then + 29 + + else + 28 + , 31 + , 30 + , 31 + , 30 + , 31 + , 31 + , 30 + , 31 + , 30 + , 31 + ] + + daysBefore = + List.take (month - 1) daysInMonth |> List.sum + in + daysBefore + day + + +isLeapYear : Int -> Bool +isLeapYear year = + (modBy 4 year == 0) && ((modBy 100 year /= 0) || (modBy 400 year == 0)) + + +getDayOfWeek : Int -> Int -> Int -> Int +getDayOfWeek year month day = + let + adjustedMonth = + if month < 3 then + month + 12 + + else + month + + adjustedYear = + if month < 3 then + year - 1 + + else + year + + q = + day + + m = + adjustedMonth + + k = + modBy 100 adjustedYear + + j = + adjustedYear // 100 + + h = + (q + ((13 * (m + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)) |> modBy 7 + in + (h + 5) |> modBy 7 + + +getDateForWeekDay : Int -> Int -> Int -> String +getDateForWeekDay year week dayOfWeek = + let + jan4DayOfWeek = + getDayOfWeek year 1 4 + + mondayOfWeek1Date = + 4 - jan4DayOfWeek + + targetDayOfYear = + mondayOfWeek1Date + ((week - 1) * 7) + dayOfWeek + + ( finalYear, finalMonth, finalDay ) = + if targetDayOfYear < 1 then + addDaysToDate (year - 1) 12 31 targetDayOfYear + + else + addDaysToDate year 1 targetDayOfYear 0 + in + String.fromInt finalYear + ++ "-" + ++ String.padLeft 2 '0' (String.fromInt finalMonth) + ++ "-" + ++ String.padLeft 2 '0' (String.fromInt finalDay) + + +addDaysToDate : Int -> Int -> Int -> Int -> ( Int, Int, Int ) +addDaysToDate startYear startMonth startDay daysToAdd = + let + daysInMonth m y = + case m of + 1 -> + 31 + + 2 -> + if isLeapYear y then + 29 + + else + 28 + + 3 -> + 31 + + 4 -> + 30 + + 5 -> + 31 + + 6 -> + 30 + + 7 -> + 31 + + 8 -> + 31 + + 9 -> + 30 + + 10 -> + 31 + + 11 -> + 30 + + 12 -> + 31 + + _ -> + 0 + + helper y m d remaining = + if remaining == 0 then + ( y, m, d ) + + else if remaining > 0 then + let + daysInCurrentMonth = + daysInMonth m y + + daysLeftInMonth = + daysInCurrentMonth - d + in + if remaining <= daysLeftInMonth then + ( y, m, d + remaining ) + + else if m == 12 then + helper (y + 1) 1 1 (remaining - daysLeftInMonth - 1) + + else + helper y (m + 1) 1 (remaining - daysLeftInMonth - 1) + + else if d + remaining >= 1 then + ( y, m, d + remaining ) + + else if m == 1 then + let + prevMonthDays = + daysInMonth 12 (y - 1) + in + helper (y - 1) 12 prevMonthDays (remaining + d) + + else + let + prevMonthDays = + daysInMonth (m - 1) y + in + helper y (m - 1) prevMonthDays (remaining + d) + in + helper startYear startMonth startDay daysToAdd + + +previousWeek : Int -> Int -> ( Int, Int ) +previousWeek year week = + if week == 1 then + ( year - 1, 52 ) + + else + ( year, week - 1 ) + + +nextWeek : Int -> Int -> ( Int, Int ) +nextWeek year week = + if week >= 52 then + ( year + 1, 1 ) + + else + ( year, week + 1 ) + + +getWeekDateRange : Int -> Int -> String +getWeekDateRange year week = + let + mondayDate = + getDateForWeekDay year week 0 + + fridayDate = + getDateForWeekDay year week 4 + in + mondayDate ++ " bis " ++ fridayDate + + +getYearWeekFromDate : String -> ( Int, Int ) +getYearWeekFromDate dateStr = + let + parts = + String.split "-" dateStr + + year = + parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025 + + month = + parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + + day = + parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1 + in + ( year, getISOWeek year month day ) diff --git a/frontend/src/Utils/ErrorHandler.elm b/frontend/src/Utils/ErrorHandler.elm new file mode 100644 index 0000000..a9746e2 --- /dev/null +++ b/frontend/src/Utils/ErrorHandler.elm @@ -0,0 +1,42 @@ +module Utils.ErrorHandler exposing (handleApiError) + +import Api.Decoders exposing (apiErrorDecoder) +import Http +import Json.Decode as Decode +import Task +import Types.Model exposing (ToastType(..)) +import Types.Msg exposing (Msg(..)) + + +handleApiError : Http.Error -> Cmd Msg +handleApiError error = + let + message = + case error of + Http.BadBody body -> + case Decode.decodeString apiErrorDecoder body of + Ok apiErr -> + apiErr.message + + Err _ -> + "Ein Fehler ist aufgetreten" + + Http.BadStatus 401 -> + "Keine Berechtigung - bitte erneut anmelden" + + Http.BadStatus 403 -> + "Zugriff verweigert" + + Http.BadStatus 404 -> + "Ressource nicht gefunden" + + Http.Timeout -> + "Zeitüberschreitung - bitte erneut versuchen" + + Http.NetworkError -> + "Netzwerkfehler - bitte Verbindung prüfen" + + _ -> + "Ein unerwarteter Fehler ist aufgetreten" + in + Task.perform (\_ -> ShowToast message ErrorToast) (Task.succeed ()) diff --git a/frontend/src/Utils/Ports.elm b/frontend/src/Utils/Ports.elm new file mode 100644 index 0000000..f5b8dc2 --- /dev/null +++ b/frontend/src/Utils/Ports.elm @@ -0,0 +1,20 @@ +port module Utils.Ports exposing + ( confirmDelete + , confirmDeleteResponse + , removeToken + , saveToken + ) + +import Json.Encode as Encode + + +port saveToken : Encode.Value -> Cmd msg + + +port removeToken : () -> Cmd msg + + +port confirmDelete : String -> Cmd msg + + +port confirmDeleteResponse : (Bool -> msg) -> Sub msg diff --git a/frontend/src/Utils/TimeUtils.elm b/frontend/src/Utils/TimeUtils.elm new file mode 100644 index 0000000..2d74958 --- /dev/null +++ b/frontend/src/Utils/TimeUtils.elm @@ -0,0 +1,34 @@ +module Utils.TimeUtils exposing (calculateHours) + + +calculateHours : String -> String -> Float +calculateHours startTime endTime = + let + parseTime timeStr = + case String.split ":" timeStr of + [ h, m ] -> + (String.toFloat h |> Maybe.withDefault 0) + + ((String.toFloat m |> Maybe.withDefault 0) / 60) + + _ -> + 0 + + start = + parseTime startTime + + end = + parseTime endTime + in + if end > start then + end - start + + else if endTime == "manual" then + case String.toFloat startTime of + Just time -> + time + + Nothing -> + 0 + + else + 0 diff --git a/frontend/src/View/AdminDashboard.elm b/frontend/src/View/AdminDashboard.elm new file mode 100644 index 0000000..9afcfb5 --- /dev/null +++ b/frontend/src/View/AdminDashboard.elm @@ -0,0 +1,1165 @@ +module View.AdminDashboard exposing (viewAdminDashboard) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model, Schedule, SchoolYear, TimeEntry, User, WeeklyHours, YearlyHoursSummary) +import Types.Msg exposing (Msg(..)) +import Types.Page exposing (AdminTab(..)) +import Utils.DateUtils exposing (getYearWeekFromDate) +import Utils.TimeUtils exposing (calculateHours) +import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) +import View.Components.Schedule exposing (viewScheduleItemWithDay) + + +viewAdminDashboard : Model -> Html Msg +viewAdminDashboard model = + div [] + [ nav [ class "navbar is-danger" ] + [ div [ class "navbar-brand" ] + [ div [ class "navbar-item" ] + [ h1 [ class "title is-4 has-text-white" ] [ text "Admin Dashboard" ] + ] + , a + [ class + ("navbar-burger" + ++ (if model.mobileMenuOpen then + " is-active" + + else + "" + ) + ) + , attribute "aria-label" "menu" + , attribute "aria-expanded" + (if model.mobileMenuOpen then + "true" + + else + "false" + ) + , onClick ToggleMobileMenu + ] + [ span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + ] + ] + , div + [ id "navbarAdmin" + , class + ("navbar-menu" + ++ (if model.mobileMenuOpen then + " is-active" + + else + "" + ) + ) + ] + [ div [ class "navbar-end" ] + [ div [ class "navbar-item" ] + [ span [ class "has-text-white mr-2" ] [ text model.username ] + ] + , div [ class "navbar-item" ] + [ button [ class "button is-light", onClick Logout ] + [ span [ class "icon" ] + [ i [ class "fas fa-sign-out-alt" ] [] ] + , span [] [ text "Abmelden" ] + ] + ] + ] + ] + ] + , section [ class "section" ] + [ div [ class "container" ] + [ div [ class "tabs is-boxed" ] + [ ul [] + [ li [ classList [ ( "is-active", model.activeTab == ScheduleTab ) ] ] + [ a [ onClick (SwitchTab ScheduleTab) ] [ text "Stundenplan" ] ] + , li [ classList [ ( "is-active", model.activeTab == UsersTab ) ] ] + [ a [ onClick (SwitchTab UsersTab) ] [ text "Benutzer" ] ] + , li [ classList [ ( "is-active", model.activeTab == TimeEntriesTab ) ] ] + [ a [ onClick (SwitchTab TimeEntriesTab) ] [ text "Zeiteinträge" ] ] + , li [ classList [ ( "is-active", model.activeTab == SchoolYearsTab ) ] ] + [ a [ onClick (SwitchTab SchoolYearsTab) ] [ text "Schuljahre" ] ] + ] + ] + , case model.activeTab of + ScheduleTab -> + viewScheduleTab model + + UsersTab -> + viewUsersTab model + + TimeEntriesTab -> + viewTimeEntriesTab model + + SchoolYearsTab -> + viewSchoolYearsTab model + ] + ] + ] + + +viewScheduleTab : Model -> Html Msg +viewScheduleTab model = + div [] + [ h2 [ class "title" ] [ text "Stundenplan verwalten" ] + , viewScheduleForm model + , viewScheduleList model + ] + + +viewUsersTab : Model -> Html Msg +viewUsersTab model = + div [] + [ h2 [ class "title" ] [ text "Benutzer verwalten" ] + , viewUserForm model + , viewUserList model + ] + + +viewTimeEntriesTab : Model -> Html Msg +viewTimeEntriesTab model = + div [] + [ h2 [ class "title" ] [ text "Jahresübersicht" ] + , viewYearlyHoursSummary model + , h2 [ class "title mt-6" ] [ text "Manuelle Stundeneintragung" ] + , viewAdminManualEntryForm model + , h2 [ class "title mt-6" ] [ text "Alle Zeiteinträge" ] + , case model.editingTimeEntryId of + Just _ -> + viewTimeEntriesEditForm model + + Nothing -> + viewTimeEntriesListWithEdit model + ] + + +viewSchoolYearsTab : Model -> Html Msg +viewSchoolYearsTab model = + div [] + [ h2 [ class "title" ] [ text "Schuljahre verwalten" ] + , case model.activeSchoolYear of + Just schoolYear -> + div [ class "notification is-info is-light mb-4" ] + [ p [ class "has-text-weight-bold" ] + [ text ("Aktives Schuljahr: " ++ schoolYear.name) ] + , p [ class "is-size-7" ] + [ text (schoolYear.startDate ++ " bis " ++ schoolYear.endDate) ] + ] + + Nothing -> + div [ class "notification is-warning is-light mb-4" ] + [ text "⚠️ Kein Schuljahr aktiv! Bitte eines aktivieren." ] + , viewSchoolYearForm model + , viewSchoolYearsList model + ] + + +viewSchoolYearForm : Model -> Html Msg +viewSchoolYearForm model = + div [ class "box" ] + [ h3 [ class "subtitle" ] [ text "Neues Schuljahr erstellen" ] + , div [ class "columns" ] + [ div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Name (z.B. 2024/2025)" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "2024/2025" + , value model.newSchoolYear.name + , onInput UpdateNewSchoolYearName + , disabled model.isProcessing + ] + [] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Startdatum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.newSchoolYear.startDate + , onInput UpdateNewSchoolYearStart + , disabled model.isProcessing + ] + [] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Enddatum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.newSchoolYear.endDate + , onInput UpdateNewSchoolYearEnd + , disabled model.isProcessing + ] + [] + ] + ] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button + [ class "button is-primary" + , onClick CreateSchoolYear + , disabled + (String.isEmpty model.newSchoolYear.name + || String.isEmpty model.newSchoolYear.startDate + || String.isEmpty model.newSchoolYear.endDate + || model.isProcessing + ) + ] + [ if model.isProcessing then + span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] + + else + text "" + , text " Schuljahr erstellen" + ] + ] + ] + ] + + +viewSchoolYearsList : Model -> Html Msg +viewSchoolYearsList model = + div [ class "box mt-4" ] + [ h3 [ class "subtitle" ] [ text "Vorhandene Schuljahre" ] + , if List.isEmpty model.schoolYears then + p [ class "has-text-centered has-text-grey" ] [ text "Keine Schuljahre vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "Name" ] + , th [] [ text "Startdatum" ] + , th [] [ text "Enddatum" ] + , th [ class "has-text-centered" ] [ text "Status" ] + , th [ class "has-text-centered" ] [ text "Aktionen" ] + ] + ] + , tbody [] + (List.map viewSchoolYearRow model.schoolYears) + ] + ] + + +viewSchoolYearRow : SchoolYear -> Html Msg +viewSchoolYearRow schoolYear = + tr [] + [ td [] [ text schoolYear.name ] + , td [] [ text schoolYear.startDate ] + , td [] [ text schoolYear.endDate ] + , td [ class "has-text-centered" ] + [ if schoolYear.isActive then + span [ class "tag is-success" ] [ text "Aktiv" ] + + else + span [ class "tag is-light" ] [ text "Inaktiv" ] + ] + , td [ class "has-text-centered" ] + [ if not schoolYear.isActive then + button + [ class "button is-small is-info mr-2" + , onClick (ActivateSchoolYear schoolYear.id) + ] + [ text "Aktivieren" ] + + else + text "" + , button + [ class "button is-small is-danger" + , onClick (DeleteSchoolYear schoolYear.id) + ] + [ text "Löschen" ] + ] + ] + + +viewScheduleList : Model -> Html Msg +viewScheduleList model = + div [ class "box" ] + [ h3 [ class "subtitle" ] [ text "Aktueller Stundenplan" ] + , table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Tag" ] + , th [] [ text "Zeit" ] + , th [] [ text "Typ" ] + , th [] [ text "Titel" ] + , th [] [ text "Aktion" ] + ] + ] + , tbody [] + (List.map viewScheduleRow model.schedules) + ] + ] + + +viewScheduleForm : Model -> Html Msg +viewScheduleForm model = + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Wochentag" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select + [ onInput UpdateNewScheduleDay + , disabled model.isProcessing + , value model.newSchedule.dayOfWeek + ] + [ option [ value "" ] [ text "Wochentag wählen" ] + , option [ value "0" ] [ text "Montag" ] + , option [ value "1" ] [ text "Dienstag" ] + , option [ value "2" ] [ text "Mittwoch" ] + , option [ value "3" ] [ text "Donnerstag" ] + , option [ value "4" ] [ text "Freitag" ] + ] + ] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Startzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.newSchedule.startTime + , onInput UpdateNewScheduleStart + , disabled model.isProcessing + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Endzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.newSchedule.endTime + , onInput UpdateNewScheduleEnd + , disabled model.isProcessing + ] + [] + ] + ] + ] + ] + , div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Typ" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select + [ onInput UpdateNewScheduleType + , value model.newSchedule.scheduleType + , disabled model.isProcessing + ] + [ option [ value "lesson" ] [ text "Unterricht" ] + , option [ value "break" ] [ text "Pause" ] + ] + ] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Titel" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "z.B. Mathematik" + , value model.newSchedule.title + , onInput UpdateNewScheduleTitle + , disabled model.isProcessing + ] + [] + ] + ] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button + [ class "button is-primary" + , onClick CreateSchedule + , disabled (String.isEmpty model.newSchedule.dayOfWeek || model.isProcessing) + ] + [ if model.isProcessing then + span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] + + else + text "" + , text " Hinzufügen" + ] + ] + ] + , if String.isEmpty model.newSchedule.dayOfWeek then + div [ class "help is-warning" ] [ text "Bitte alle Felder ausfüllen" ] + + else + text "" + ] + + +viewScheduleRow : Schedule -> Html Msg +viewScheduleRow schedule = + let + dayName = + case schedule.dayOfWeek of + 0 -> + "Montag" + + 1 -> + "Dienstag" + + 2 -> + "Mittwoch" + + 3 -> + "Donnerstag" + + 4 -> + "Freitag" + + _ -> + "Unbekannt" + + typeName = + if schedule.scheduleType == "break" then + "Pause" + + else + "Unterricht" + in + tr [] + [ td [] [ text dayName ] + , td [] [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] + , td [] [ text typeName ] + , td [] [ text schedule.title ] + , td [] + [ button + [ class "button is-small is-danger" + , onClick (DeleteSchedule schedule.id) + ] + [ text "Löschen" ] + ] + ] + + +viewUserForm : Model -> Html Msg +viewUserForm model = + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Benutzername" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "Benutzername" + , value model.newUser.username + , onInput UpdateNewUsername + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Passwort" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "password" + , placeholder "Passwort" + , value model.newUser.password + , onInput UpdateNewPassword + ] + [] + ] + ] + ] + , div [ class "column is-narrow" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Admin" ] + , div [ class "control" ] + [ label [ class "checkbox" ] + [ input + [ type_ "checkbox" + , checked model.newUser.isAdmin + , onCheck UpdateNewUserAdmin + ] + [] + , text " Admin-Rechte" + ] + ] + ] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button [ class "button is-primary", onClick CreateUser ] [ text "Benutzer anlegen" ] + ] + ] + ] + + +viewUserList : Model -> Html Msg +viewUserList model = + div [ class "box" ] + [ h3 [ class "subtitle" ] [ text "Benutzer" ] + , if List.isEmpty model.users then + p [ class "has-text-centered" ] [ text "Keine Benutzer vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "ID" ] + , th [] [ text "Benutzername" ] + , th [] [ text "Rolle" ] + , th [ class "has-text-right" ] [ text "Arbeitszeit/Jahr" ] + , th [ class "has-text-centered" ] [ text "Aktionen" ] + ] + ] + , tbody [] + (List.map (viewUserRowWithActions model) model.users) + ] + ] + + +viewUserRowWithActions : Model -> User -> Html Msg +viewUserRowWithActions model user = + if model.editingUserId == Just user.id then + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [] + [ input + [ class "input is-small" + , type_ "number" + , step "0.5" + , value model.editingUserWorkHours + , onInput UpdateEditUserWorkHours + ] + [] + ] + , td [ class "has-text-centered" ] + [ button [ class "button is-small is-success mr-2", onClick SaveUserWorkHours ] [ text "✓" ] + , button [ class "button is-small is-light", onClick CancelEditUserWorkHours ] [ text "✕" ] + ] + ] + + else if model.resetPasswordUserId == Just user.id then + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [] + [ input + [ class "input is-small" + , type_ "password" + , placeholder "Neues Passwort" + , value model.resetPasswordNew + , onInput UpdateResetPasswordNew + ] + [] + ] + , td [ class "has-text-centered" ] + [ button [ class "button is-small is-success mr-2", onClick SaveResetPassword ] [ text "✓" ] + , button [ class "button is-small is-light", onClick CancelResetPassword ] [ text "✕" ] + ] + ] + + else + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [ class "has-text-right" ] [ text (String.fromFloat user.yearlyWorkHours ++ " Std.") ] + , td [ class "has-text-centered" ] + [ if user.id == 1 then + span [ class "tag is-light" ] [ text "Geschützt" ] + + else + div [] + [ button + [ class "button is-small is-info mr-2" + , onClick (EditUserWorkHours user.id) + ] + [ text "Arbeitszeit" ] + , button + [ class "button is-small is-warning mr-2" + , onClick (ResetUserPassword user.id) + ] + [ text "PW Reset" ] + , button + [ class "button is-small is-danger" + , onClick (DeleteUser user.id) + ] + [ text "Löschen" ] + ] + ] + ] + + +viewUserRow : User -> Html Msg +viewUserRow user = + tr [] + [ td [] [ text (String.fromInt user.id) ] + , td [] [ text user.username ] + , td [] + [ text + (if user.isAdmin then + "Admin" + + else + "Benutzer" + ) + ] + , td [] + [ if user.id == 1 then + span [ class "tag is-light" ] [ text "Geschützt" ] + + else + button + [ class "button is-small is-danger" + , onClick (DeleteUser user.id) + ] + [ text "Löschen" ] + ] + ] + + +viewTimeEntriesList : Model -> Html Msg +viewTimeEntriesList model = + let + filteredEntries = + List.filter + (\e -> + let + ( entryYear, entryWeek ) = + getYearWeekFromDate e.date + in + entryWeek == model.currentWeek && entryYear == model.currentYear + ) + model.timeEntries + in + div [ class "box" ] + [ if List.isEmpty filteredEntries then + p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] + + else + table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [] [ text "Datum" ] + , th [] [ text "Zeit" ] + , th [] [ text "Typ" ] + , th [ class "has-text-right" ] [ text "Stunden" ] + ] + ] + , tbody [] + (List.map (viewTimeEntryRowWithActions model) filteredEntries) + ] + ] + + +viewTimeEntryRowWithActions : Model -> TimeEntry -> Html Msg +viewTimeEntryRowWithActions model entry = + let + hours = + if entry.entryType == "lesson" then + 1.0 + + else + calculateHours entry.startTime entry.endTime + in + tr [] + [ td [] [ text entry.username ] + , td [] [ text entry.date ] + , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] + , td [] [ text entry.entryType ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] + , td [] + [ div [ class "buttons are-small" ] + [ button + [ class "button is-info is-small" + , onClick (StartEditingTimeEntry entry.id entry) + ] + [ text "Bearbeiten" ] + , button + [ class "button is-danger is-small" + , onClick (ConfirmDeleteTimeEntry entry.id) + ] + [ text "Löschen" ] + ] + ] + ] + + +viewTimeEntriesEditForm : Model -> Html Msg +viewTimeEntriesEditForm model = + div [ class "box has-background-warning-light" ] + [ h3 [ class "subtitle" ] [ text "Zeiteintrag bearbeiten" ] + , div [ class "columns" ] + [ div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Datum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.editingTimeEntry.date + , onInput UpdateEditTimeEntryDate + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Startzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.editingTimeEntry.startTime + , onInput UpdateEditTimeEntryStartTime + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Endzeit" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "time" + , value model.editingTimeEntry.endTime + , onInput UpdateEditTimeEntryEndTime + ] + [] + ] + ] + ] + , div [ class "column" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Typ" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select [ onInput UpdateEditTimeEntryType, value model.editingTimeEntry.entryType ] + [ option [ value "lesson" ] [ text "Unterricht" ] + , option [ value "break" ] [ text "Pause" ] + ] + ] + ] + ] + ] + ] + , div [ class "field is-grouped mt-4" ] + [ div [ class "control" ] + [ button + [ class "button is-success" + , onClick SaveEditTimeEntry + ] + [ text "Speichern" ] + ] + , div [ class "control" ] + [ button + [ class "button is-light" + , onClick CancelEditTimeEntry + ] + [ text "Abbrechen" ] + ] + ] + , viewTimeEntriesListWithEdit model + ] + + +viewTimeEntriesListWithEdit : Model -> Html Msg +viewTimeEntriesListWithEdit model = + div [ class "box" ] + [ if List.isEmpty model.timeEntries then + p [ class "has-text-centered" ] [ text "Keine Einträge vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [] [ text "Datum" ] + , th [] [ text "Zeit" ] + , th [] [ text "Typ" ] + , th [ class "has-text-right" ] [ text "Stunden" ] + , th [ class "has-text-centered" ] [ text "Aktionen" ] + ] + ] + , tbody [] + (List.map (viewTimeEntryRowWithEdit model) model.timeEntries) + ] + ] + + +viewTimeEntryRowWithEdit : Model -> TimeEntry -> Html Msg +viewTimeEntryRowWithEdit model entry = + let + hours = + calculateHours entry.startTime entry.endTime + + isEditing = + model.editingTimeEntryId == Just entry.id + in + if isEditing then + tr [] + [ td [] [ text entry.username ] + , td [] + [ input + [ class "input is-small" + , type_ "date" + , value model.editingTimeEntry.date + , onInput UpdateEditTimeEntryDate + ] + [] + ] + , td [] + [ div [ class "field is-grouped" ] + [ div [ class "control" ] + [ input + [ class "input is-small" + , type_ "time" + , value model.editingTimeEntry.startTime + , onInput UpdateEditTimeEntryStartTime + ] + [] + ] + , div [ class "control" ] + [ input + [ class "input is-small" + , type_ "time" + , value model.editingTimeEntry.endTime + , onInput UpdateEditTimeEntryEndTime + ] + [] + ] + ] + ] + , td [] + [ div [ class "select is-small" ] + [ select [ value model.editingTimeEntry.entryType, onInput UpdateEditTimeEntryType ] + [ option [ value "lesson" ] [ text "Unterricht" ] + , option [ value "break" ] [ text "Pause" ] + ] + ] + ] + , td [ class "has-text-right" ] [ text "" ] + , td [ class "has-text-centered" ] + [ button [ class "button is-small is-success mr-2", onClick SaveEditTimeEntry ] [ text "✓" ] + , button [ class "button is-small is-light", onClick CancelEditTimeEntry ] [ text "✕" ] + ] + ] + + else + tr [] + [ td [] [ text entry.username ] + , td [] [ text entry.date ] + , td [] [ text (entry.startTime ++ " - " ++ entry.endTime) ] + , td [] [ text entry.entryType ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours ++ " Std.") ] + , td [ class "has-text-centered" ] + [ button + [ class "button is-small is-info mr-2" + , onClick (EditTimeEntry entry.id) + ] + [ text "Bearbeiten" ] + , button + [ class "button is-small is-danger" + , onClick (ConfirmDeleteTimeEntry entry.id) + ] + [ text "Löschen" ] + ] + ] + + +viewWeeklyHoursSummary : Model -> Html Msg +viewWeeklyHoursSummary model = + let + filteredHours = + List.filter + (\h -> h.week == model.currentWeek && h.year == model.currentYear) + model.weeklyHours + in + div [ class "box" ] + [ if List.isEmpty filteredHours then + p [ class "has-text-centered" ] [ text "Keine Einträge für diese Woche" ] + + else + table [ class "table is-fullwidth is-striped" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [ class "has-text-right" ] [ text "Arbeitet" ] + , th [ class "has-text-right" ] [ text "Soll" ] + , th [ class "has-text-right" ] [ text "Verbleibend" ] + , th [] [ text "Fortschritt" ] + ] + ] + , tbody [] + (List.map viewWeeklyHoursRow filteredHours) + , tfoot [] + [ tr [ class "has-background-light" ] + [ th [] [ text "Gesamt" ] + , th [ class "has-text-right has-text-weight-bold" ] + [ text (String.fromFloat (List.sum (List.map .totalHours filteredHours)) ++ " Std.") ] + , th [ class "has-text-right has-text-weight-bold" ] + [ text (String.fromFloat (List.sum (List.map .targetHours filteredHours)) ++ " Std.") ] + , th [] [ text "" ] + , th [] [ text "" ] + ] + ] + ] + ] + + +viewWeeklyHoursRow : WeeklyHours -> Html Msg +viewWeeklyHoursRow hours = + let + progressPercent = + Basics.min 100 (hours.totalHours / hours.targetHours * 100) + + progressColor = + if hours.totalHours >= hours.targetHours then + "is-success" + + else if hours.totalHours >= hours.targetHours * 0.8 then + "is-info" + + else + "is-warning" + in + tr [] + [ td [] [ text hours.username ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours.totalHours ++ " Std.") ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours.targetHours ++ " Std.") ] + , td [ class "has-text-right" ] [ text (String.fromFloat hours.remainingHours ++ " Std.") ] + , td [] + [ progress + [ class ("progress " ++ progressColor) + , value (String.fromFloat progressPercent) + , Html.Attributes.max "100" + ] + [] + ] + ] + + +viewAdminManualEntryForm : Model -> Html Msg +viewAdminManualEntryForm model = + div [ class "box has-background-info-light" ] + [ h3 [ class "subtitle" ] [ text "Manuelle Stundeneintragung" ] + , p [ class "help mb-3" ] + [ text "Positive Werte = Abzug, Negative Werte = Hinzurechnung" ] + , div [ class "columns" ] + [ div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Mitarbeiter" ] + , div [ class "control" ] + [ div [ class "select is-fullwidth" ] + [ select [ onInput (SelectUserForManualEntry << Maybe.withDefault 0 << String.toInt) ] + (option [ value "" ] [ text "-- Wählen --" ] + :: List.map + (\u -> + option [ value (String.fromInt u.id), selected (model.adminManualEntryForm.selectedUserId == Just u.id) ] [ text u.username ] + ) + model.users + ) + ] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Datum" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "date" + , value model.adminManualEntryForm.date + , onInput UpdateManualEntryDate + ] + [] + ] + ] + ] + , div [ class "column is-4" ] + [ div [ class "field" ] + [ label [ class "label" ] [ text "Stunden (z.B. 2.5 oder -1.0)" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "number" + , step "0.5" + , placeholder "z.B. 2.5 oder -1.0" + , value model.adminManualEntryForm.hours + , onInput UpdateManualEntryHours + ] + [] + ] + , p [ class "help" ] + [ text "Positiv: Wird abgezogen | Negativ: Wird hinzugerechnet" ] + ] + ] + ] + , div [ class "field is-grouped mt-4" ] + [ div [ class "control" ] + [ button + [ class "button is-info" + , onClick SaveAdminTimeEntry + , disabled + (case model.adminManualEntryForm.selectedUserId of + Just _ -> + model.isProcessing || String.isEmpty model.adminManualEntryForm.hours + + Nothing -> + True + ) + ] + [ text "Eintrag erstellen" ] + ] + ] + ] + + +viewYearlyHoursSummary : Model -> Html Msg +viewYearlyHoursSummary model = + div [ class "box" ] + [ div [ class "level mb-4" ] + [ div [ class "level-left" ] + [ div [ class "level-item" ] + [ h3 [ class "subtitle is-5 mb-0" ] [ text "Jahresübersicht" ] + ] + ] + , div [ class "level-right" ] + [ div [ class "level-item" ] + [ a + [ class "button is-info" + , onClick DownloadYearlySummaryPDF + , disabled model.isProcessing + ] + [ span [ class "icon" ] + [ i [ class "fas fa-file-pdf" ] [] ] + , span [] + [ text + (if model.isProcessing then + "Wird erstellt..." + + else + "PDF exportieren" + ) + ] + ] + ] + ] + ] + , if List.isEmpty model.yearlyHoursSummary then + p [ class "has-text-centered" ] [ text "Keine Daten vorhanden" ] + + else + table [ class "table is-fullwidth is-striped is-hoverable" ] + [ thead [] + [ tr [] + [ th [] [ text "Mitarbeiter" ] + , th [ class "has-text-right" ] [ text "Sollen (Stunden)" ] + , th [ class "has-text-right" ] [ text "Iststand (Stunden)" ] + , th [ class "has-text-right" ] [ text "Differenz (Stunden)" ] + , th [ class "has-text-centered" ] [ text "Status" ] + ] + ] + , tbody [] + (List.map viewYearlyHourRow model.yearlyHoursSummary) + ] + ] + + +viewYearlyHourRow : YearlyHoursSummary -> Html Msg +viewYearlyHourRow summary = + let + statusClass = + if summary.remainingYearly > 0 then + "has-text-danger" + + else if abs summary.remainingYearly < 0.5 then + "has-text-success" + + else + "has-text-warning" + in + tr [] + [ td [] [ text summary.username ] + , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyTarget) ] + , td [ class "has-text-right" ] [ text (String.fromFloat summary.yearlyActual) ] + , td [ class "has-text-right" ] [ text (String.fromFloat summary.remainingYearly) ] + , td [ class ("has-text-centered " ++ statusClass) ] + [ if summary.remainingYearly > 0 then + text ("Offen: " ++ String.fromFloat summary.remainingYearly) + + else if summary.remainingYearly < -0.5 then + text ("Zu viel: " ++ String.fromFloat (abs summary.remainingYearly)) + + else + text "✓ Erfüllt" + ] + ] diff --git a/frontend/src/View/Components/Navigation.elm b/frontend/src/View/Components/Navigation.elm new file mode 100644 index 0000000..ba3895d --- /dev/null +++ b/frontend/src/View/Components/Navigation.elm @@ -0,0 +1,99 @@ +module View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model, Schedule) +import Types.Msg exposing (Msg(..)) +import View.Components.Schedule exposing (viewScheduleItemWithDay) + + +viewWeekNavigation : Model -> Html Msg +viewWeekNavigation model = + let + dateRange = + case model.weekDates of + Just wd -> + wd.range + + Nothing -> + "Laden..." + in + div [ class "box" ] + [ nav [ class "level" ] + [ div [ class "level-left" ] + [ div [ class "level-item" ] + [ button + [ class "button is-primary" + , onClick PreviousWeek + ] + [ span [ class "icon" ] + [ i [ class "fas fa-chevron-left" ] [] ] + , span [] [ text "Vorherige Woche" ] + ] + ] + ] + , div [ class "level-item" ] + [ div + [ style "display" "flex" + , style "flex-direction" "column" + , style "align-items" "center" + , style "gap" "0.5rem" + , style "min-width" "250px" + ] + [ p + [ class "heading" + , style "margin" "0" + , style "line-height" "1.2" + ] + [ text "Kalenderwoche" ] + , p + [ class "title is-3" + , style "margin" "0" + , style "line-height" "1.2" + ] + [ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ] + , p + [ class "subtitle is-6" + , style "margin" "0" + , style "line-height" "1.2" + ] + [ text dateRange ] + ] + ] + , div [ class "level-right" ] + [ div [ class "level-item" ] + [ button + [ class "button is-primary" + , onClick NextWeek + ] + [ span [] [ text "Nächste Woche" ] + , span [ class "icon" ] + [ i [ class "fas fa-chevron-right" ] [] ] + ] + ] + ] + ] + ] + + +viewDayMobile : Model -> String -> ( Int, List Schedule ) -> Html Msg +viewDayMobile model dayName ( dayOfWeek, schedules ) = + let + dateForDay = + case model.weekDates of + Just wd -> + wd.dates + |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) + |> List.head + |> Maybe.map Tuple.second + |> Maybe.withDefault "N/A" + + Nothing -> + "Laden..." + in + div [ class "box mb-4" ] + [ p [ class "has-text-weight-bold has-text-centered mb-3" ] + [ text (dayName ++ " - " ++ dateForDay) ] + , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) + ] diff --git a/frontend/src/View/Components/Schedule.elm b/frontend/src/View/Components/Schedule.elm new file mode 100644 index 0000000..57730bb --- /dev/null +++ b/frontend/src/View/Components/Schedule.elm @@ -0,0 +1,76 @@ +module View.Components.Schedule exposing (viewScheduleItemWithDay) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model, Schedule) +import Types.Msg exposing (Msg(..)) + + +viewScheduleItemWithDay : Model -> Int -> Schedule -> Html Msg +viewScheduleItemWithDay model dayOfWeek schedule = + let + isSelected = + List.any (\e -> e.scheduleId == schedule.id && e.dayOfWeek == dayOfWeek) model.selectedEntries + + isClickable = + (not model.hasEntriesForCurrentWeek || model.weekEditMode) && not model.isProcessing + + boxClass = + if isSelected then + "box has-background-success-light" + + else if isClickable then + "box has-background-white" + + else + "box has-background-light" + + typeText = + if schedule.scheduleType == "break" then + " (Pause)" + + else + "" + + cursorStyle = + if isClickable then + "pointer" + + else + "not-allowed" + + opacity = + if isClickable || isSelected then + "1" + + else + "0.6" + in + div + [ class boxClass + , onClick + (if isClickable then + ToggleScheduleSelection schedule.id dayOfWeek + + else + FetchSchedules + ) + , style "cursor" cursorStyle + , style "margin-bottom" "0.5rem" + , style "padding" "0.75rem" + , style "opacity" opacity + , style "transition" "all 0.2s ease" + , style "border" + (if isClickable && not isSelected then + "2px solid transparent" + + else + "2px solid currentColor" + ) + ] + [ p [ class "has-text-weight-bold is-size-7" ] + [ text (schedule.startTime ++ " - " ++ schedule.endTime) ] + , p [ class "is-size-7" ] + [ text (schedule.title ++ typeText) ] + ] diff --git a/frontend/src/View/Components/Toast.elm b/frontend/src/View/Components/Toast.elm new file mode 100644 index 0000000..e55d2fe --- /dev/null +++ b/frontend/src/View/Components/Toast.elm @@ -0,0 +1,66 @@ +module View.Components.Toast exposing (viewToasts) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model, Schedule, Toast, ToastType(..)) +import Types.Msg exposing (Msg(..)) +import Utils.TimeUtils exposing (calculateHours) +import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) +import View.Components.Schedule exposing (viewScheduleItemWithDay) + + +viewToasts : List Toast -> Html Msg +viewToasts toasts = + div [ class "toast-container" ] + (List.map viewToast toasts) + + +viewToast : Toast -> Html Msg +viewToast toast = + let + toastClass = + case toast.toastType of + ErrorToast -> + "toast-error" + + SuccessToast -> + "toast-success" + + InfoToast -> + "toast-info" + + WarningToast -> + "toast-warning" + + icon = + case toast.toastType of + ErrorToast -> + "fas fa-exclamation-circle" + + SuccessToast -> + "fas fa-check-circle" + + InfoToast -> + "fas fa-info-circle" + + WarningToast -> + "fas fa-exclamation-triangle" + in + div [ class ("toast " ++ toastClass), style "animation" "slideIn 0.3s ease-out" ] + [ div [ class "toast-content" ] + [ span [ class "toast-icon" ] + [ i [ class icon ] [] ] + , span [ class "toast-message" ] [ text toast.message ] + ] + , if toast.dismissible then + button + [ class "toast-close" + , onClick (DismissToast toast.id) + , attribute "aria-label" "Schließen" + ] + [ i [ class "fas fa-times" ] [] ] + + else + text "" + ] diff --git a/frontend/src/View/Login.elm b/frontend/src/View/Login.elm new file mode 100644 index 0000000..9ed2485 --- /dev/null +++ b/frontend/src/View/Login.elm @@ -0,0 +1,57 @@ +module View.Login exposing (viewLogin) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model) +import Types.Msg exposing (Msg(..)) + + +viewLogin : Model -> Html Msg +viewLogin model = + section [ class "section" ] + [ div [ class "container" ] + [ div [ class "columns is-centered" ] + [ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ] + [ div [ class "box" ] + [ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ] + , div [ class "field" ] + [ label [ class "label" ] [ text "Benutzername" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "text" + , placeholder "Benutzername" + , value model.username + , onInput UpdateUsername + ] + [] + ] + ] + , div [ class "field" ] + [ label [ class "label" ] [ text "Passwort" ] + , div [ class "control" ] + [ input + [ class "input" + , type_ "password" + , placeholder "Passwort" + , value model.password + , onInput UpdatePassword + ] + [] + ] + ] + , div [ class "field" ] + [ div [ class "control" ] + [ button + [ class "button is-primary is-fullwidth" + , onClick Login + ] + [ text "Anmelden" ] + ] + ] + ] + ] + ] + ] + ] diff --git a/frontend/src/View/UserDashboard.elm b/frontend/src/View/UserDashboard.elm new file mode 100644 index 0000000..60fac13 --- /dev/null +++ b/frontend/src/View/UserDashboard.elm @@ -0,0 +1,338 @@ +module View.UserDashboard exposing (viewUserDashboard) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Types.Model exposing (Model, Schedule) +import Types.Msg exposing (Msg(..)) +import Utils.TimeUtils exposing (calculateHours) +import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation) +import View.Components.Schedule exposing (viewScheduleItemWithDay) + + +viewUserDashboard : Model -> Html Msg +viewUserDashboard model = + div [] + [ nav [ class "navbar is-primary" ] + [ div [ class "navbar-brand" ] + [ div [ class "navbar-item" ] + [ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ] + ] + , a + [ class + ("navbar-burger" + ++ (if model.mobileMenuOpen then + " is-active" + + else + "" + ) + ) + , attribute "role" "navigation" + , attribute "aria-label" "menu" + , attribute "aria-expanded" + (if model.mobileMenuOpen then + "true" + + else + "false" + ) + , onClick ToggleMobileMenu + ] + [ span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + , span [ attribute "aria-hidden" "true" ] [] + ] + ] + , div + [ id "navbarUser" + , class + ("navbar-menu" + ++ (if model.mobileMenuOpen then + " is-active" + + else + "" + ) + ) + ] + [ div [ class "navbar-end" ] + [ div [ class "navbar-item" ] + [ span [ class "has-text-white mr-2" ] [ text model.username ] + ] + , div [ class "navbar-item" ] + [ button [ class "button is-light", onClick Logout ] + [ span [ class "icon" ] + [ i [ class "fas fa-sign-out-alt" ] [] ] + , span [] [ text "Abmelden" ] + ] + ] + ] + ] + ] + , section [ class "section" ] + [ div [ class "container" ] + [ viewWeekNavigation model + , h2 [ class "title" ] [ text "Stundenplan" ] + , if model.hasEntriesForCurrentWeek && not model.weekEditMode then + div [ class "notification is-success" ] + [ div [ class "level" ] + [ div [ class "level-left" ] + [ div [ class "level-item" ] + [ span [ class "icon" ] + [ i [ class "fas fa-check-circle" ] [] ] + , span [] [ text "Diese Woche wurde bereits erfasst" ] + ] + ] + , div [ class "level-right" ] + [ div [ class "level-item" ] + [ button + [ class "button is-warning" + , onClick EnableEditMode + , disabled model.isProcessing + ] + [ text "Bearbeiten" ] + ] + ] + ] + ] + + else if model.weekEditMode then + div [ class "notification is-warning" ] + [ div [ class "level" ] + [ div [ class "level-left" ] + [ div [ class "level-item" ] + [ span [ class "icon" ] + [ i [ class "fas fa-edit" ] [] ] + , span [] [ text "Bearbeitungsmodus aktiv" ] + ] + ] + , div [ class "level-right" ] + [ div [ class "level-item" ] + [ button + [ class "button is-danger is-small mr-2" + , onClick DeleteWeekEntries + , disabled model.isProcessing + ] + [ text "Einträge löschen" ] + , button + [ class "button is-light is-small" + , onClick DisableEditMode + ] + [ text "Abbrechen" ] + ] + ] + ] + ] + + else + div [ class "notification is-info is-light" ] + [ text "Wählen Sie die Zeiten aus, die Sie in dieser Woche gearbeitet haben." ] + , viewScheduleGridWithWeek model + , if not model.hasEntriesForCurrentWeek || model.weekEditMode then + div [ class "field mt-4" ] + [ div [ class "control" ] + [ button + [ class "button is-primary is-large is-fullwidth" + , onClick SaveTimeEntries + , disabled (List.isEmpty model.selectedEntries || model.isProcessing) + ] + [ if model.isProcessing then + span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ] + + else + text "" + , text + (if model.weekEditMode then + "Änderungen speichern" + + else + "Speichern" + ) + ] + ] + ] + + else + text "" + , h3 [ class "subtitle mt-6" ] [ text "Jahresgesamtzeit" ] + , viewUserYearlyTotal model + ] + ] + ] + + +viewScheduleGridWithWeek : Model -> Html Msg +viewScheduleGridWithWeek model = + let + days = + [ "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag" ] + + groupedSchedules = + List.range 0 4 + |> List.map + (\day -> + ( day, List.filter (\s -> s.dayOfWeek == day) model.schedules ) + ) + in + div [] + [ div [ class "is-hidden-mobile" ] + [ div [ class "table-container" ] + [ table [ class "table is-bordered is-fullwidth" ] + [ thead [] + [ tr [] (List.map (\day -> th [ class "has-text-centered" ] [ text day ]) days) + ] + , tbody [] + [ tr [] + (List.map (viewDayColumnWithWeek model) groupedSchedules) + ] + ] + ] + ] + , div [ class "is-hidden-tablet" ] + (List.map2 (viewDayMobile model) days groupedSchedules) + ] + + +viewUserYearlyTotal : Model -> Html Msg +viewUserYearlyTotal model = + let + yearlyTotal = + model.timeEntries + |> List.map + (\entry -> + if entry.entryType == "lesson" then + 1.0 + + else + Utils.TimeUtils.calculateHours entry.startTime entry.endTime + ) + |> List.sum + + userTarget = + List.filter (\u -> not u.isAdmin) model.users + |> List.head + |> Maybe.map .yearlyWorkHours + |> Maybe.withDefault 60 + + remaining = + userTarget - yearlyTotal + + progressPercent = + Basics.min 100 (yearlyTotal / userTarget * 100) + + progressColor = + if remaining <= 0 then + "is-success" + + else if yearlyTotal >= userTarget * 0.8 then + "is-info" + + else + "is-warning" + in + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ p [ class "heading" ] [ text "Jahresenziel" ] + , p [ class "title" ] [ text (String.fromFloat userTarget ++ " Std.") ] + ] + , div [ class "column" ] + [ p [ class "heading" ] [ text "Geleistete Stunden" ] + , p [ class "title" ] [ text (String.fromFloat yearlyTotal ++ " Std.") ] + ] + , div [ class "column" ] + [ p [ class "heading" ] [ text "Restliche Stunden" ] + , p + [ class + ("title is-4 " + ++ (if remaining <= 0 then + "has-text-success" + + else + "has-text-warning" + ) + ) + ] + [ text (String.fromFloat (Basics.max 0 remaining) ++ " Std.") ] + ] + ] + , progress + [ class ("progress " ++ progressColor) + , value (String.fromFloat progressPercent) + , Html.Attributes.max "100" + ] + [ text (String.fromFloat progressPercent ++ "%") ] + ] + + +viewDayColumnWithWeek : Model -> ( Int, List Schedule ) -> Html Msg +viewDayColumnWithWeek model ( dayOfWeek, schedules ) = + let + dateForDay = + case model.weekDates of + Just wd -> + wd.dates + |> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek) + |> List.head + |> Maybe.map Tuple.second + |> Maybe.withDefault "N/A" + + Nothing -> + "Laden..." + in + td [ class "has-background-light", style "vertical-align" "top", style "min-width" "150px" ] + [ p [ class "has-text-centered has-text-weight-bold is-size-7 mb-2" ] + [ text dateForDay ] + , div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules) + ] + + +viewUserWeeklySummary : Model -> Html Msg +viewUserWeeklySummary model = + case model.userWeeklySummary of + Just summary -> + let + progressPercent = + Basics.min 100 (summary.totalHours / summary.targetHours * 100) + + progressColor = + if summary.totalHours >= summary.targetHours then + "is-success" + + else if summary.totalHours >= summary.targetHours * 0.8 then + "is-info" + + else + "is-warning" + in + div [ class "box" ] + [ div [ class "columns" ] + [ div [ class "column" ] + [ p [ class "heading" ] [ text "Arbeitszeit diese Woche" ] + , p [ class "title" ] [ text (String.fromFloat summary.totalHours ++ " Std.") ] + , p [ class "subtitle is-6" ] [ text ("von " ++ String.fromFloat summary.targetHours ++ " Std.") ] + ] + , div [ class "column" ] + [ p [ class "heading" ] [ text "Verbleibend" ] + , p [ class "title is-4", classList [ ( "has-text-success", summary.remainingHours <= 0 ) ] ] + [ text (String.fromFloat summary.remainingHours ++ " Std.") ] + , if summary.remainingHours < 0 then + p [ class "subtitle is-6 has-text-success" ] [ text "✓ Ziel erreicht!" ] + + else + p [ class "subtitle is-6" ] [ text "" ] + ] + ] + , progress + [ class ("progress " ++ progressColor) + , value (String.fromFloat progressPercent) + , Html.Attributes.max "100" + ] + [ text (String.fromFloat progressPercent ++ "%") ] + ] + + Nothing -> + div [ class "box" ] + [ p [ class "has-text-centered has-text-grey" ] [ text "Laden..." ] + ] diff --git a/frontend/src/View/View.elm b/frontend/src/View/View.elm new file mode 100644 index 0000000..c16d910 --- /dev/null +++ b/frontend/src/View/View.elm @@ -0,0 +1,29 @@ +module View.View exposing (view) + +import Html exposing (Html, div) +import Html.Attributes exposing (class) +import Types.Model exposing (Model) +import Types.Msg exposing (Msg(..)) +import Types.Page exposing (Page(..)) +import View.AdminDashboard exposing (viewAdminDashboard) +import View.Components.Toast exposing (viewToasts) +import View.Login exposing (viewLogin) +import View.UserDashboard exposing (viewUserDashboard) + + +view : Model -> Html Msg +view model = + div [ class "app-container" ] + [ viewToasts model.toasts + , div [ class "container" ] + [ case model.page of + LoginPage -> + viewLogin model + + UserDashboard -> + viewUserDashboard model + + AdminDashboard -> + viewAdminDashboard model + ] + ]