diff --git a/README.md b/README.md deleted file mode 100644 index 732cdbb..0000000 --- a/README.md +++ /dev/null @@ -1,775 +0,0 @@ -# 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 66f3e54..32ffd22 100644 --- a/backend/database.go +++ b/backend/database.go @@ -24,19 +24,17 @@ 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, - yearly_hours REAL NOT NULL DEFAULT 60.0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + 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 )`, `CREATE TABLE IF NOT EXISTS schedules ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -44,8 +42,7 @@ func createTables(db *sql.DB) { start_time TEXT NOT NULL, end_time TEXT NOT NULL, type TEXT NOT NULL, - title TEXT NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + title TEXT NOT NULL )`, `CREATE TABLE IF NOT EXISTS time_entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -59,21 +56,6 @@ 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 { @@ -84,7 +66,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, yearly_hours) + INSERT OR IGNORE INTO users (id, username, password, is_admin, weekly_hours) VALUES (?, ?, ?, ?, ?)`, 1, "admin", string(hash), true, 40.0, ) @@ -93,28 +75,57 @@ func createTables(db *sql.DB) { } } -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)`, - } +// 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) +// )`, +// } - for _, idx := range indexes { - if _, err := db.Exec(idx); err != nil { - log.Printf("Warning: Failed to create index: %v", err) - } - } -} +// 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) +// } +// } func GetUserByUsername(db *sql.DB, username string) (*User, error) { user := &User{} - 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) + 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) if err != nil { return nil, err } @@ -123,22 +134,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, yearly_hours FROM users WHERE id = ?", userID). - Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.YearlyHours) + 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) if err != nil { return nil, err } return user, nil } -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) +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) return err } func GetAllUsers(db *sql.DB) ([]User, error) { - rows, err := db.Query("SELECT id, username, is_admin, yearly_hours FROM users ORDER BY username") + rows, err := db.Query("SELECT id, username, is_admin, weekly_hours FROM users") if err != nil { return nil, err } @@ -147,7 +158,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.YearlyHours); err != nil { + if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin, &u.WeeklyHours); err != nil { continue } users = append(users, u) @@ -155,9 +166,43 @@ func GetAllUsers(db *sql.DB) ([]User, error) { return users, nil } -func UpdateUser(db *sql.DB, userID int, yearlyHours float64) error { - _, err := db.Exec("UPDATE users SET yearly_hours = ? WHERE id = ?", - yearlyHours, userID) +// 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) return err } @@ -222,10 +267,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 { @@ -246,7 +291,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 @@ -269,37 +314,34 @@ 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.yearly_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.weekly_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 yearlyHours float64 + var expectedWeeklyHours float64 - if err := rows.Scan(&userID, &username, &dateStr, &startTime, &endTime, &entryType, &yearlyHours); err != nil { + if err := rows.Scan(&userID, &username, &dateStr, &startTime, &endTime, &entryType, &expectedWeeklyHours); err != nil { continue } - userYearlyHours[userID] = yearlyHours - t, err := time.Parse("2006-01-02", dateStr) if err != nil { continue @@ -307,38 +349,32 @@ func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) { year, week := t.ISOWeek() - entry := TimeEntry{ - Type: entryType, - StartTime: startTime, - EndTime: endTime, + var hours float64 + if entryType == "lesson" { + hours = 1.0 + } else { + hours = calculateHoursDiff(startTime, 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, + UserID: userID, + Username: username, + Year: year, + Week: week, + TotalHours: hours, + ExpectedHours: expectedWeeklyHours, + RemainingHours: expectedWeeklyHours - hours, } } } - yearlyTotals := make(map[int]float64) for _, h := range hoursMap { - 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 + h.RemainingHours = h.ExpectedHours - h.TotalHours } var result []WeeklyHours @@ -359,6 +395,74 @@ 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, ":") @@ -385,6 +489,14 @@ 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) @@ -394,8 +506,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]) @@ -411,15 +523,16 @@ 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 @@ -427,200 +540,3 @@ 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 deleted file mode 100644 index 7ee17bd..0000000 --- a/backend/errors.go +++ /dev/null @@ -1,205 +0,0 @@ -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 2a1d344..c45ed46 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,18 +3,14 @@ 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 @@ -26,6 +22,7 @@ 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 6c63134..3ab6680 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,20 +1,11 @@ -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= @@ -25,14 +16,10 @@ 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= @@ -43,7 +30,6 @@ 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= @@ -53,7 +39,6 @@ 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 06b3f57..14fd4cd 100644 --- a/backend/handlers.go +++ b/backend/handlers.go @@ -2,14 +2,10 @@ 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" ) @@ -18,60 +14,25 @@ type App struct { DB *sql.DB } -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")) -} - +// Login Handler func (app *App) LoginHandler(c echo.Context) error { var req LoginRequest if err := c.Bind(&req); err != nil { - return HandleError(c, ErrInvalidInputMsg("Login-Daten")) - } - - if req.Username == "" { - return HandleError(c, ErrMissingFieldMsg("Benutzername")) - } - if req.Password == "" { - return HandleError(c, ErrMissingFieldMsg("Passwort")) + return echo.NewHTTPError(http.StatusBadRequest, "invalid request") } user, err := GetUserByUsername(app.DB, req.Username) if err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrInvalidCredentialsMsg()) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials") } if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { - return HandleError(c, ErrInvalidCredentialsMsg()) + return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials") } token, err := createToken(user.ID, user.Username, user.IsAdmin) if err != nil { - return HandleError(c, ErrInternalMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "error creating token") } response := LoginResponse{ @@ -83,10 +44,11 @@ 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 HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, schedules) } @@ -94,100 +56,52 @@ 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 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")) + return echo.NewHTTPError(http.StatusBadRequest, "invalid request") } if err := CreateSchedule(app.DB, &schedule); err != nil { - if isDuplicateError(err) { - return HandleError(c, ErrAlreadyExistsMsg("Stundenplan-Eintrag")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.JSON(http.StatusCreated, map[string]string{"message": "Stundenplan erstellt"}) + return c.JSON(http.StatusCreated, map[string]string{"message": "schedule created"}) } func (app *App) DeleteScheduleHandler(c echo.Context) error { id, err := strconv.Atoi(c.QueryParam("id")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Stundenplan-ID")) + return echo.NewHTTPError(http.StatusBadRequest, "invalid id") } if err := DeleteSchedule(app.DB, id); err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrNotFoundMsg("Stundenplan")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.NoContent(http.StatusNoContent) + return c.NoContent(http.StatusOK) } -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) -} +// // 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) 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"` - } +// hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) +// if err != nil { +// return echo.NewHTTPError(http.StatusInternalServerError, "error hashing password") +// } - if err := c.Bind(&req); err != nil { - return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) - } +// if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin); err != nil { +// return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) +// } - 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) -} +// return c.JSON(http.StatusCreated, map[string]string{"message": "user created"}) +// } func (app *App) GetUsersHandler(c echo.Context) error { users, err := GetAllUsers(app.DB) if err != nil { - return HandleError(c, ErrDatabaseMsg(err)) - } - if users == nil { - users = []User{} + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, users) } @@ -195,65 +109,40 @@ 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 HandleError(c, ErrInvalidInputMsg("Benutzer-ID")) - } - - if id == 1 { - return HandleError(c, ErrProtectedUserMsg()) + return echo.NewHTTPError(http.StatusBadRequest, "invalid id") } if err := DeleteUser(app.DB, id); err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrNotFoundMsg("Benutzer")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.NoContent(http.StatusNoContent) + return c.NoContent(http.StatusOK) } +// Time Entry Handlers func (app *App) CreateTimeEntryHandler(c echo.Context) error { - claims, err := getClaims(c) - if err != nil { - return HandleError(c, ErrUnauthorizedMsg()) - } + userID := c.Get("user_id").(int) var entry TimeEntry if err := c.Bind(&entry); err != nil { - return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten")) + return echo.NewHTTPError(http.StatusBadRequest, "invalid request") } - 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 + entry.UserID = userID if err := CreateTimeEntry(app.DB, &entry); err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteintrag erstellt"}) + return c.JSON(http.StatusCreated, map[string]string{"message": "time entry created"}) } func (app *App) GetMyTimeEntriesHandler(c echo.Context) error { - claims, err := getClaims(c) - if err != nil { - return HandleError(c, ErrUnauthorizedMsg()) - } + userID := c.Get("user_id").(int) - entries, err := GetTimeEntriesByUser(app.DB, claims.UserID) + entries, err := GetTimeEntriesByUser(app.DB, userID) if err != nil { - return HandleError(c, ErrDatabaseMsg(err)) - } - if entries == nil { - entries = []TimeEntry{} + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, entries) @@ -262,16 +151,12 @@ 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 HandleError(c, ErrInvalidInputMsg("Jahr")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") } week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Woche")) - } - - if week < 1 || week > 53 { - return HandleError(c, ErrInvalidInputMsg("Woche (muss zwischen 1 und 53 liegen)")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") } dates := calculateWeekDates(year, week) @@ -279,24 +164,21 @@ func (app *App) GetWeekDates(c echo.Context) error { } func (app *App) CheckWeekHasEntries(c echo.Context) error { - claims, err := getClaims(c) - if err != nil { - return HandleError(c, ErrUnauthorizedMsg()) - } + userID := c.Get("user_id").(int) year, err := strconv.Atoi(c.QueryParam("year")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Jahr")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") } week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Woche")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") } - hasEntries, err := CheckUserHasEntriesForWeek(app.DB, claims.UserID, year, week) + hasEntries, err := CheckUserHasEntriesForWeek(app.DB, userID, year, week) if err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, map[string]bool{"has_entries": hasEntries}) @@ -305,10 +187,7 @@ 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 HandleError(c, ErrDatabaseMsg(err)) - } - if entries == nil { - entries = []TimeEntry{} + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, entries) } @@ -316,35 +195,29 @@ 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 HandleError(c, ErrDatabaseMsg(err)) - } - if hours == nil { - hours = []WeeklyHours{} + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.JSON(http.StatusOK, hours) } func (app *App) DeleteWeekEntries(c echo.Context) error { - claims, err := getClaims(c) - if err != nil { - return HandleError(c, ErrUnauthorizedMsg()) - } + userID := c.Get("user_id").(int) year, err := strconv.Atoi(c.QueryParam("year")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Jahr")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") } week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Woche")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") } - if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + if err := DeleteTimeEntriesByUserAndWeek(app.DB, userID, year, week); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.NoContent(http.StatusNoContent) + return c.NoContent(http.StatusOK) } type WeekDates struct { @@ -403,83 +276,52 @@ type BatchTimeEntryRequest struct { } func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error { - claims, err := getClaims(c) - if err != nil { - return HandleError(c, ErrUnauthorizedMsg()) - } + userID := c.Get("user_id").(int) var req BatchTimeEntryRequest if err := c.Bind(&req); err != nil { - 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)) - } + return echo.NewHTTPError(http.StatusBadRequest, "invalid request") } tx, err := app.DB.Begin() if err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "transaction error") } 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 HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "prepare error") } defer stmt.Close() for _, entry := range req.Entries { - _, err := stmt.Exec(claims.UserID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime) + _, err := stmt.Exec(userID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime) if err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "insert error") } } if err := tx.Commit(); err != nil { - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "commit error") } - return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteinträge erstellt"}) + return c.JSON(http.StatusCreated, map[string]string{"message": "entries created"}) } func (app *App) UpdateUserHandler(c echo.Context) error { userID, err := strconv.Atoi(c.Param("id")) if err != nil { - return HandleError(c, ErrInvalidInputMsg("Benutzer-ID")) - } - - if userID == 1 { - return HandleError(c, ErrProtectedUserMsg()) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID") } var req UpdateUserRequest if err := c.Bind(&req); err != nil { - return HandleError(c, ErrInvalidInputMsg("Benutzerdaten")) + return echo.NewHTTPError(http.StatusBadRequest, 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)) + if err := UpdateUser(app.DB, userID, req.WeeklyHours); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.NoContent(http.StatusOK) @@ -488,28 +330,21 @@ 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 HandleError(c, ErrInvalidInputMsg("Benutzer-ID")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID") } var req ResetPasswordRequest if err := c.Bind(&req); err != nil { - return HandleError(c, ErrInvalidInputMsg("Passwort-Daten")) - } - - if len(req.NewPassword) < 6 { - return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)")) + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { - return HandleError(c, ErrInternalMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") } if err := ResetUserPassword(app.DB, userID, string(hashedPassword)); err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrNotFoundMsg("Benutzer")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.NoContent(http.StatusOK) @@ -518,29 +353,16 @@ 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 HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID") } var req UpdateTimeEntryRequest if err := c.Bind(&req); err != nil { - 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")) + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } if err := UpdateTimeEntry(app.DB, entryID, req.Date, req.StartTime, req.EndTime, req.Type); err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrNotFoundMsg("Zeiteintrag")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } return c.NoContent(http.StatusOK) @@ -549,180 +371,74 @@ 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 HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID")) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID") } if err := DeleteTimeEntry(app.DB, entryID); err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrNotFoundMsg("Zeiteintrag")) - } - return HandleError(c, ErrDatabaseMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } - return c.NoContent(http.StatusNoContent) + return c.NoContent(http.StatusOK) } -func (app *App) GetMyInfoHandler(c echo.Context) error { - claims, err := getClaims(c) +func (app *App) GetMyWeeklySummaryHandler(c echo.Context) error { + userID := c.Get("user_id").(int) + + year, err := strconv.Atoi(c.QueryParam("year")) if err != nil { - return HandleError(c, ErrUnauthorizedMsg()) + return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") } - user, err := GetUserByID(app.DB, claims.UserID) + week, err := strconv.Atoi(c.QueryParam("week")) if err != nil { - if err == sql.ErrNoRows { - return HandleError(c, ErrNotFoundMsg("Benutzer")) + 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) } - return HandleError(c, ErrDatabaseMsg(err)) } - return c.JSON(http.StatusOK, user) + return c.JSON(http.StatusOK, WeeklyHours{ + UserID: userID, + Username: user.Username, + Year: year, + Week: week, + TotalHours: 0, + ExpectedHours: user.WeeklyHours, + RemainingHours: user.WeeklyHours, + }) } func (app *App) CreateUserHandler(c echo.Context) error { var req CreateUserRequest if err := c.Bind(&req); err != nil { - 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)")) + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - return HandleError(c, ErrInternalMsg(err)) + return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") } - if req.YearlyHours == 0 { - req.YearlyHours = 60.0 + if req.WeeklyHours == 0 { + req.WeeklyHours = 40.0 } - 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)) + if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.WeeklyHours); err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) } 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 deleted file mode 100755 index 7358e39..0000000 --- a/backend/load-env.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/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 84cb7f1..762ed67 100644 --- a/backend/main.go +++ b/backend/main.go @@ -4,7 +4,6 @@ import ( "log" "net/http" "os" - "strings" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" @@ -25,20 +24,8 @@ 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: allowOrigins, + AllowOrigins: []string{"*"}, AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization}, })) @@ -57,9 +44,7 @@ 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("/yearly-hours-summary", app.GetYearlyHoursSummaryHandler) - protected.GET("/my-info", app.GetMyInfoHandler) - protected.GET("/school-year/active", app.GetActiveSchoolYearHandler) + protected.GET("/my-weekly-summary", app.GetMyWeeklySummaryHandler) } admin := e.Group("/api/admin") @@ -74,15 +59,9 @@ func main() { admin.GET("/time-entries", app.GetAllTimeEntriesHandler) admin.GET("/weekly-hours", app.GetWeeklyHoursHandler) admin.PUT("/users/:id", app.UpdateUserHandler) - admin.PUT("/users/:id/reset-password", app.ResetPasswordHandler) + admin.POST("/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 78d693c..1b4967d 100644 --- a/backend/middleware.go +++ b/backend/middleware.go @@ -1,66 +1,120 @@ package main import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" "net/http" - "os" - "sync" + "strings" "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 - -func init() { - secret := os.Getenv("JWT_SECRET") - if secret == "" { - panic("JWT_SECRET environment variable is required") - } - jwtSecret = []byte(secret) -} +var jwtSecret = []byte("your-secret-key-change-in-production") 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)), - }, } - token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) - return token.SignedString(jwtSecret) + 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 } func JWTMiddleware() echo.MiddlewareFunc { - return echojwt.WithConfig(echojwt.Config{ - NewClaimsFunc: func(c echo.Context) jwt.Claims { - return new(Claims) - }, - SigningKey: jwtSecret, - }) + 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) + } + } } func AdminMiddleware() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - 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") + isAdmin, ok := c.Get("is_admin").(bool) + if !ok || !isAdmin { + return echo.NewHTTPError(http.StatusForbidden, "admin access required") } return next(c) } @@ -72,68 +126,3 @@ 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 8429bb6..085c4ef 100644 --- a/backend/models.go +++ b/backend/models.go @@ -1,9 +1,6 @@ package main -import ( - "github.com/golang-jwt/jwt/v5" - "time" -) +import "time" type TimeEntry struct { ID int `json:"id"` @@ -18,15 +15,13 @@ 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"` - YearlyTarget float64 `json:"yearly_target"` // NEU - YearlyActual float64 `json:"yearly_actual"` // NEU - WeeklyTarget float64 `json:"weekly_target"` // NEU - RemainingYearly float64 `json:"remaining_yearly"` // NEU + 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"` } type User struct { @@ -34,7 +29,7 @@ type User struct { Username string `json:"username"` Password string `json:"-"` IsAdmin bool `json:"is_admin"` - YearlyHours float64 `json:"yearly_hours"` + WeeklyHours float64 `json:"weekly_hours"` } type Schedule struct { @@ -61,27 +56,12 @@ type CreateUserRequest struct { Username string `json:"username" validate:"required"` Password string `json:"password" validate:"required,min=6"` IsAdmin bool `json:"is_admin"` - 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"` + WeeklyHours float64 `json:"weekly_hours"` } type UpdateUserRequest struct { Username string `json:"username"` - YearlyHours float64 `json:"yearly_hours"` + WeeklyHours float64 `json:"weekly_hours"` } type ResetPasswordRequest struct { @@ -99,5 +79,4 @@ 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 deleted file mode 100644 index 13e003e..0000000 --- a/backend/pdf.go +++ /dev/null @@ -1,110 +0,0 @@ -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 12ae1c0..426d625 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -1,338 +1,36 @@ - + - - - - - Zeiterfassung - - - - - + + + Schulzeit Erfassung + - -
- +
- diff --git a/docker-compose.yml b/docker-compose.yml index 221d016..16d47f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,17 @@ +# 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: . @@ -7,8 +21,6 @@ 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 @@ -22,3 +34,4 @@ volumes: networks: timetracking-net: driver: bridge + diff --git a/frontend/elm.json b/frontend/elm.json index 07196ee..300f393 100644 --- a/frontend/elm.json +++ b/frontend/elm.json @@ -1,21 +1,19 @@ { "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 12ae1c0..426d625 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -1,338 +1,36 @@ - + - - - - - Zeiterfassung - - - - - + + + Schulzeit Erfassung + - -
- +
- diff --git a/frontend/src/Api/Auth.elm b/frontend/src/Api/Auth.elm deleted file mode 100644 index 0de5c4e..0000000 --- a/frontend/src/Api/Auth.elm +++ /dev/null @@ -1,21 +0,0 @@ -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 deleted file mode 100644 index cb72efa..0000000 --- a/frontend/src/Api/Decoders.elm +++ /dev/null @@ -1,109 +0,0 @@ -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 deleted file mode 100644 index f966645..0000000 --- a/frontend/src/Api/Schedule.elm +++ /dev/null @@ -1,120 +0,0 @@ -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 deleted file mode 100644 index be1fb63..0000000 --- a/frontend/src/Api/SchoolYear.elm +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index c1ebede..0000000 --- a/frontend/src/Api/TimeEntry.elm +++ /dev/null @@ -1,201 +0,0 @@ -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 deleted file mode 100644 index 17c77ac..0000000 --- a/frontend/src/Api/User.elm +++ /dev/null @@ -1,110 +0,0 @@ -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 6f29eab..3eb4b28 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -1,27 +1,28 @@ -module Main exposing (..) +port 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 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) +import Dict exposing (Dict) +-- PORTS + +port saveToken : String -> Cmd msg +port removeToken : () -> Cmd msg + +port confirmDelete : String -> Cmd msg +port confirmDeleteResponse : (Bool -> msg) -> Sub msg -- MAIN - -main : Program Flags Model Msg +main : Program (Maybe String) Model Msg main = Browser.element { init = init @@ -31,33 +32,148 @@ main = } -init : Flags -> ( Model, Cmd Msg ) -init flags = +-- 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 = let - initialPage = - case flags.token of - Just _ -> - if flags.isAdmin then - AdminDashboard - - else - UserDashboard - - Nothing -> - LoginPage - model = - { page = initialPage + { page = if storedToken /= Nothing then UserDashboard else LoginPage , activeTab = ScheduleTab , username = "" , password = "" - , token = flags.token - , isAdmin = flags.isAdmin + , token = storedToken + , isAdmin = False , schedules = [] , users = [] , timeEntries = [] , weeklyHours = [] - , yearlyHoursSummary = [] , selectedEntries = [] , currentWeek = 1 , currentYear = 2025 @@ -69,56 +185,2428 @@ init flags = , weekEditMode = False , hasEntriesForCurrentWeek = False , weekDates = Nothing - , userWeeklySummary = Nothing - , editingTimeEntryId = Nothing - , editingTimeEntry = EditingTimeEntry 0 "" "" "" "" - , editingUserId = Nothing - , editingUserWorkHours = "" - , resetPasswordUserId = Nothing - , resetPasswordNew = "" - , pendingDeleteId = Nothing - , selectedUserId = 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 , userWorkHoursInput = "" , userPasswordInput = "" - , isProcessing = False - , mobileMenuOpen = False - , adminManualEntryForm = AdminManualEntry Nothing "" "" "manual" - , schoolYears = [] - , newSchoolYear = NewSchoolYear "" "" "" - , activeSchoolYear = Nothing - , editingSchoolYearId = Nothing - , toasts = [] - , nextToastId = 0 } - - cmd = - case flags.token of + + cmd = + case storedToken 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 ) + (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) -- SUBSCRIPTIONS - subscriptions : Model -> Sub Msg subscriptions model = - confirmDeleteResponse DeleteConfirmed + 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 + } + diff --git a/frontend/src/Types/Api.elm b/frontend/src/Types/Api.elm deleted file mode 100644 index aae29d0..0000000 --- a/frontend/src/Types/Api.elm +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 64911d6..0000000 --- a/frontend/src/Types/Model.elm +++ /dev/null @@ -1,218 +0,0 @@ -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 deleted file mode 100644 index 4158571..0000000 --- a/frontend/src/Types/Msg.elm +++ /dev/null @@ -1,133 +0,0 @@ -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 deleted file mode 100644 index 5b41054..0000000 --- a/frontend/src/Types/Page.elm +++ /dev/null @@ -1,17 +0,0 @@ -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 deleted file mode 100644 index 20a1fbc..0000000 --- a/frontend/src/Update/AuthUpdate.elm +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index 2312e13..0000000 --- a/frontend/src/Update/ScheduleUpdate.elm +++ /dev/null @@ -1,244 +0,0 @@ -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 deleted file mode 100644 index 0de741d..0000000 --- a/frontend/src/Update/SchoolYearUpdate.elm +++ /dev/null @@ -1,139 +0,0 @@ -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 deleted file mode 100644 index a794944..0000000 --- a/frontend/src/Update/TimeEntryUpdate.elm +++ /dev/null @@ -1,189 +0,0 @@ -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 deleted file mode 100644 index f384b8c..0000000 --- a/frontend/src/Update/Update.elm +++ /dev/null @@ -1,811 +0,0 @@ -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 deleted file mode 100644 index 9fd4b85..0000000 --- a/frontend/src/Update/UserUpdate.elm +++ /dev/null @@ -1,196 +0,0 @@ -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 deleted file mode 100644 index 1ea98dd..0000000 --- a/frontend/src/Utils/DateUtils.elm +++ /dev/null @@ -1,338 +0,0 @@ -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 deleted file mode 100644 index a9746e2..0000000 --- a/frontend/src/Utils/ErrorHandler.elm +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index f5b8dc2..0000000 --- a/frontend/src/Utils/Ports.elm +++ /dev/null @@ -1,20 +0,0 @@ -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 deleted file mode 100644 index 2d74958..0000000 --- a/frontend/src/Utils/TimeUtils.elm +++ /dev/null @@ -1,34 +0,0 @@ -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 deleted file mode 100644 index 9afcfb5..0000000 --- a/frontend/src/View/AdminDashboard.elm +++ /dev/null @@ -1,1165 +0,0 @@ -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 deleted file mode 100644 index ba3895d..0000000 --- a/frontend/src/View/Components/Navigation.elm +++ /dev/null @@ -1,99 +0,0 @@ -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 deleted file mode 100644 index 57730bb..0000000 --- a/frontend/src/View/Components/Schedule.elm +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index e55d2fe..0000000 --- a/frontend/src/View/Components/Toast.elm +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 9ed2485..0000000 --- a/frontend/src/View/Login.elm +++ /dev/null @@ -1,57 +0,0 @@ -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 deleted file mode 100644 index 60fac13..0000000 --- a/frontend/src/View/UserDashboard.elm +++ /dev/null @@ -1,338 +0,0 @@ -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 deleted file mode 100644 index c16d910..0000000 --- a/frontend/src/View/View.elm +++ /dev/null @@ -1,29 +0,0 @@ -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 - ] - ]