Compare commits

...

15 commits
v1.0 ... main

Author SHA1 Message Date
3c61c1cb2c Merge branch 'improve-frontend-maintanability-by-splitting-in-sperate-files'
* improve-frontend-maintanability-by-splitting-in-sperate-files:
  docs: update Readme
  fix: fix while deleting timeentries for whole week
2025-11-09 23:26:12 +01:00
ccae467ceb docs: update Readme 2025-11-09 23:24:32 +01:00
55b36e5e62 fix: fix while deleting timeentries for whole week
old entries have not been deleted, before new entries have been added.
This has been fixed. Also manual entries by administrators are know
protected and can only be deleted by an administrator.
2025-11-09 23:22:49 +01:00
8958fd312d docs: update Readme 2025-11-09 21:56:54 +01:00
34834f2eaa feat: add delete schoolyear route and handler 2025-11-09 17:35:10 +01:00
3ac1947106 feat: improve app security and error handling
Improve overall app security by:
- using dynamic statements for all sql querries
- introducing environment variables for initial admin password
- introducing enironment variable for cors address
- improving error handling
2025-11-09 12:13:47 +01:00
95057c1b8d fix: fix missing fetch and wrong version in README 2025-11-08 14:27:52 +01:00
b3d4eec456 feat: add README and change default working hours to 60 2025-11-08 14:24:30 +01:00
e931b97037 Merge branch 'implement-pdf-generrating-and-download'
* implement-pdf-generrating-and-download:
  feat: add pdf generation
2025-11-08 12:07:44 +01:00
d4265cc046 feat: add pdf generation 2025-11-08 12:07:29 +01:00
bb891aea0b fix: fix styling issue with overlapping title and subtitle in weekSelection 2025-11-08 11:38:09 +01:00
84def05c50 feat: change Manual time entry to work with hours instead of start and end time
according to add hours as well the logic was changed to accept ours for
manual entries instead of start and end time. This allows to add
negative numbers as well, which are added to working time.
2025-11-08 11:27:42 +01:00
c07019e3eb feat: add schoolyear based calculation 2025-11-06 07:18:23 +01:00
e65ba85c43 feat: added all features for seconde release candidate 2025-11-05 23:39:51 +01:00
9c25956711 fix: Add Mobile View and fix error while freeze on entering new schedule 2025-11-05 17:09:37 +01:00
43 changed files with 7692 additions and 3047 deletions

775
README.md Normal file
View file

@ -0,0 +1,775 @@
# Zeiterfassungssystem für pädagogische Mitarbeiter
Eine vollständige Webanwendung zur Erfassung und Verwaltung von Flexistunden für pädagogische Mitarbeiter (PM) an Schulen.
## 📋 Inhaltsverzeichnis
- [Überblick](#überblick)
- [Funktionen](#funktionen)
- [Technologie-Stack](#technologie-stack)
- [Voraussetzungen](#voraussetzungen)
- [Installation](#installation)
- [Konfiguration](#konfiguration)
- [Verwendung](#verwendung)
- [API-Dokumentation](#api-dokumentation)
- [Architektur](#architektur)
- [Sicherheit](#sicherheit)
- [Backup & Wartung](#backup--wartung)
- [Fehlerbehebung](#fehlerbehebung)
## 🎯 Überblick
Diese Anwendung wurde entwickelt, um die Erfassung von Flexistunden (zusätzliche Arbeitsstunden) für pädagogische Mitarbeiter an Schulen zu vereinfachen. Sie ermöglicht:
- **Mitarbeitern**: Wöchentliche Zeiterfassung anhand eines vorkonfigurierten Stundenplans
- **Administratoren**: Vollständige Verwaltung von Benutzern, Stundenplänen, Schuljahren und Zeiteinträgen
Das System arbeitet mit ISO-Kalenderwochen und unterstützt schuljahrbezogene Auswertungen.
## ✨ Funktionen
### Für Mitarbeiter
- **Wochenbasierte Zeiterfassung**: Auswahl der gearbeiteten Zeiten aus dem Stundenplan
- **Kalenderwochen-Navigation**: Einfaches Vor- und Zurückblättern zwischen Wochen
- **Jahresübersicht**: Anzeige der geleisteten vs. Soll-Arbeitsstunden
- **Responsive Design**: Optimiert für Desktop, Tablet und Mobile
### Für Administratoren
- **Benutzerverwaltung**:
- Benutzer anlegen, bearbeiten und löschen
- Jahresarbeitsstunden pro Benutzer festlegen (Standard: 60h)
- Passwörter zurücksetzen
- **Stundenplan-Management**:
- Wochenstundenplan mit Unterrichts- und Pausenzeiten erstellen
- Unterrichtsstunden und Pausen unterscheiden
- Zeiten mit Titeln versehen (z.B. "Mathematik", "Pause")
- **Schuljahrverwaltung**:
- Schuljahre mit Start- und Enddatum definieren
- Aktives Schuljahr setzen
- Jahresberechnungen basierend auf aktivem Schuljahr
- **Zeiteintrags-Verwaltung**:
- Alle Zeiteinträge einsehen und bearbeiten
- Manuelle Stundeneintragungen (positiv = Abzug, negativ = Hinzurechnung)
- Einzelne Einträge korrigieren oder löschen
- **Berichtswesen**:
- Jahresübersicht aller Mitarbeiter
- PDF-Export der Jahresübersicht
- Wochenweise Stundenauswertung
## 🛠 Technologie-Stack
### Frontend
- **Elm 0.19**: Funktionale Programmiersprache für type-safe UI
- **Bulma CSS**: Modernes CSS-Framework
- **Font Awesome**: Icons
- **LocalStorage**: Client-seitige Datenpersistenz für Authentifizierung
### Backend
- **Go (Golang)**: Performante Backend-Sprache
- **Echo Framework**: Web-Framework für Go
- **SQLite**: Embedded SQL-Datenbank
- **JWT**: Token-basierte Authentifizierung
- **bcrypt**: Passwort-Hashing
- **gofpdf**: PDF-Generierung
### Deployment
- **Docker**: Containerisierung
- **Docker Compose**: Orchestrierung
## 📦 Voraussetzungen
### Für Docker-Deployment (empfohlen)
- Docker (Version 20.10+)
- Docker Compose (Version 1.29+)
### Für lokale Entwicklung
- Go 1.21+
- Elm 0.19
- Node.js 16+ (für Elm-Tooling)
- SQLite3
## 🚀 Installation
### Option 1: Docker Compose (Produktion)
1. **Repository klonen**
```bash
git clone <repository-url>
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 <token>
```
### Öffentliche Endpunkte
#### `POST /api/login`
Benutzer-Anmeldung
**Request:**
```json
{
"username": "admin",
"password": "<your-initial-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

View file

@ -24,6 +24,7 @@ func InitDB(filepath string) *sql.DB {
} }
createTables(db) createTables(db)
createIndexes(db)
return db return db
} }
@ -34,7 +35,8 @@ func createTables(db *sql.DB) {
username TEXT UNIQUE NOT NULL, username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, password TEXT NOT NULL,
is_admin BOOLEAN NOT NULL DEFAULT 0, is_admin BOOLEAN NOT NULL DEFAULT 0,
weekly_hours REAL NOT NULL DEFAULT 40.0 yearly_hours REAL NOT NULL DEFAULT 60.0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, )`,
`CREATE TABLE IF NOT EXISTS schedules ( `CREATE TABLE IF NOT EXISTS schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -42,7 +44,8 @@ func createTables(db *sql.DB) {
start_time TEXT NOT NULL, start_time TEXT NOT NULL,
end_time TEXT NOT NULL, end_time TEXT NOT NULL,
type TEXT NOT NULL, type TEXT NOT NULL,
title TEXT NOT NULL title TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`, )`,
`CREATE TABLE IF NOT EXISTS time_entries ( `CREATE TABLE IF NOT EXISTS time_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -56,6 +59,21 @@ func createTables(db *sql.DB) {
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (schedule_id) REFERENCES schedules(id) 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 { for _, query := range queries {
@ -66,7 +84,7 @@ func createTables(db *sql.DB) {
hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
_, err := db.Exec(` _, err := db.Exec(`
INSERT OR IGNORE INTO users (id, username, password, is_admin, weekly_hours) INSERT OR IGNORE INTO users (id, username, password, is_admin, yearly_hours)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
1, "admin", string(hash), true, 40.0, 1, "admin", string(hash), true, 40.0,
) )
@ -75,57 +93,28 @@ func createTables(db *sql.DB) {
} }
} }
// func createTables(db *sql.DB) { func createIndexes(db *sql.DB) {
// queries := []string{ indexes := []string{
// `CREATE TABLE IF NOT EXISTS users ( `CREATE INDEX IF NOT EXISTS idx_time_entries_user_date ON time_entries(user_id, date)`,
// id INTEGER PRIMARY KEY AUTOINCREMENT, `CREATE INDEX IF NOT EXISTS idx_time_entries_date ON time_entries(date)`,
// username TEXT UNIQUE NOT NULL, `CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id)`,
// password TEXT NOT NULL, `CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`,
// is_admin BOOLEAN NOT NULL DEFAULT 0 `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 TABLE IF NOT EXISTS schedules ( `CREATE INDEX IF NOT EXISTS idx_school_years_dates ON school_years(start_date, end_date)`,
// 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 _, query := range queries { for _, idx := range indexes {
// if _, err := db.Exec(query); err != nil { if _, err := db.Exec(idx); err != nil {
// log.Fatal(err) log.Printf("Warning: Failed to create index: %v", 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) { func GetUserByUsername(db *sql.DB, username string) (*User, error) {
user := &User{} user := &User{}
err := db.QueryRow("SELECT id, username, password, is_admin, weekly_hours FROM users WHERE username = ?", username). 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.WeeklyHours) Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.YearlyHours)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -134,22 +123,22 @@ func GetUserByUsername(db *sql.DB, username string) (*User, error) {
func GetUserByID(db *sql.DB, userID int) (*User, error) { func GetUserByID(db *sql.DB, userID int) (*User, error) {
user := &User{} user := &User{}
err := db.QueryRow("SELECT id, username, password, is_admin, weekly_hours FROM users WHERE id = ?", userID). 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.WeeklyHours) Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.YearlyHours)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return user, nil return user, nil
} }
func CreateUser(db *sql.DB, username, hashedPassword string, isAdmin bool, weeklyHours float64) error { func CreateUser(db *sql.DB, username, hashedPassword string, isAdmin bool, yearlyHours float64) error {
_, err := db.Exec("INSERT INTO users (username, password, is_admin, weekly_hours) VALUES (?, ?, ?, ?)", _, err := db.Exec("INSERT INTO users (username, password, is_admin, yearly_hours) VALUES (?, ?, ?, ?)",
username, hashedPassword, isAdmin, weeklyHours) username, hashedPassword, isAdmin, yearlyHours)
return err return err
} }
func GetAllUsers(db *sql.DB) ([]User, error) { func GetAllUsers(db *sql.DB) ([]User, error) {
rows, err := db.Query("SELECT id, username, is_admin, weekly_hours FROM users") rows, err := db.Query("SELECT id, username, is_admin, yearly_hours FROM users ORDER BY username")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -158,7 +147,7 @@ func GetAllUsers(db *sql.DB) ([]User, error) {
var users []User var users []User
for rows.Next() { for rows.Next() {
var u User var u User
if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin, &u.WeeklyHours); err != nil { if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin, &u.YearlyHours); err != nil {
continue continue
} }
users = append(users, u) users = append(users, u)
@ -166,43 +155,9 @@ func GetAllUsers(db *sql.DB) ([]User, error) {
return users, nil return users, nil
} }
// func GetUserByUsername(db *sql.DB, username string) (*User, error) { func UpdateUser(db *sql.DB, userID int, yearlyHours float64) error {
// user := &User{} _, err := db.Exec("UPDATE users SET yearly_hours = ? WHERE id = ?",
// err := db.QueryRow("SELECT id, username, password, is_admin FROM users WHERE username = ?", username). yearlyHours, userID)
// 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 return err
} }
@ -321,7 +276,7 @@ func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) {
te.start_time, te.start_time,
te.end_time, te.end_time,
te.type, te.type,
u.weekly_hours u.yearly_hours
FROM time_entries te FROM time_entries te
JOIN users u ON te.user_id = u.id JOIN users u ON te.user_id = u.id
ORDER BY te.date DESC ORDER BY te.date DESC
@ -332,16 +287,19 @@ func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) {
defer rows.Close() defer rows.Close()
hoursMap := make(map[string]*WeeklyHours) hoursMap := make(map[string]*WeeklyHours)
userYearlyHours := make(map[int]float64)
for rows.Next() { for rows.Next() {
var userID int var userID int
var username, dateStr, startTime, endTime, entryType string var username, dateStr, startTime, endTime, entryType string
var expectedWeeklyHours float64 var yearlyHours float64
if err := rows.Scan(&userID, &username, &dateStr, &startTime, &endTime, &entryType, &expectedWeeklyHours); err != nil { if err := rows.Scan(&userID, &username, &dateStr, &startTime, &endTime, &entryType, &yearlyHours); err != nil {
continue continue
} }
userYearlyHours[userID] = yearlyHours
t, err := time.Parse("2006-01-02", dateStr) t, err := time.Parse("2006-01-02", dateStr)
if err != nil { if err != nil {
continue continue
@ -349,15 +307,14 @@ func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) {
year, week := t.ISOWeek() year, week := t.ISOWeek()
var hours float64 entry := TimeEntry{
if entryType == "lesson" { Type: entryType,
hours = 1.0 StartTime: startTime,
} else { EndTime: endTime,
hours = calculateHoursDiff(startTime, endTime)
} }
hours := calculateHours(entry)
key := fmt.Sprintf("%d_%d_%d", userID, year, week) key := fmt.Sprintf("%d_%d_%d", userID, year, week)
if existing, exists := hoursMap[key]; exists { if existing, exists := hoursMap[key]; exists {
existing.TotalHours += hours existing.TotalHours += hours
} else { } else {
@ -367,14 +324,21 @@ func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) {
Year: year, Year: year,
Week: week, Week: week,
TotalHours: hours, TotalHours: hours,
ExpectedHours: expectedWeeklyHours,
RemainingHours: expectedWeeklyHours - hours,
} }
} }
} }
yearlyTotals := make(map[int]float64)
for _, h := range hoursMap { for _, h := range hoursMap {
h.RemainingHours = h.ExpectedHours - h.TotalHours yearlyTotals[h.UserID] += h.TotalHours
}
for _, h := range hoursMap {
h.YearlyTarget = userYearlyHours[h.UserID]
h.YearlyActual = yearlyTotals[h.UserID]
h.WeeklyTarget = h.YearlyTarget / 45.0
h.RemainingYearly = h.YearlyTarget - h.YearlyActual
} }
var result []WeeklyHours var result []WeeklyHours
@ -395,74 +359,6 @@ func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) {
return result, nil 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 { func calculateHoursDiff(startTime, endTime string) float64 {
parseTime := func(timeStr string) float64 { parseTime := func(timeStr string) float64 {
parts := strings.Split(timeStr, ":") parts := strings.Split(timeStr, ":")
@ -489,14 +385,6 @@ func calculateHoursDiff(startTime, endTime string) float64 {
return 0 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 { func DeleteTimeEntriesByUserAndWeek(db *sql.DB, userID int, year int, week int) error {
dates := calculateWeekDates(year, week) dates := calculateWeekDates(year, week)
@ -532,7 +420,6 @@ func CheckUserHasEntriesForWeek(db *sql.DB, userID int, year int, week int) (boo
var count int var count int
err := db.QueryRow(query, userID, err := db.QueryRow(query, userID,
dateList[0], dateList[1], dateList[2], dateList[3], dateList[4]).Scan(&count) dateList[0], dateList[1], dateList[2], dateList[3], dateList[4]).Scan(&count)
if err != nil { if err != nil {
log.Printf("Error checking entries: %v", err) log.Printf("Error checking entries: %v", err)
return false, err return false, err
@ -540,3 +427,200 @@ func CheckUserHasEntriesForWeek(db *sql.DB, userID int, year int, week int) (boo
return count > 0, nil 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
}

205
backend/errors.go Normal file
View file

@ -0,0 +1,205 @@
package main
import (
"fmt"
"net/http"
)
type ErrorCode string
const (
// Authentifizierung
ErrInvalidCredentials ErrorCode = "INVALID_CREDENTIALS"
ErrUnauthorized ErrorCode = "UNAUTHORIZED"
ErrTokenExpired ErrorCode = "TOKEN_EXPIRED"
ErrAccessDenied ErrorCode = "ACCESS_DENIED"
// Validierung
ErrInvalidInput ErrorCode = "INVALID_INPUT"
ErrMissingField ErrorCode = "MISSING_FIELD"
ErrInvalidDateFormat ErrorCode = "INVALID_DATE_FORMAT"
ErrInvalidTimeFormat ErrorCode = "INVALID_TIME_FORMAT"
// Ressourcen
ErrNotFound ErrorCode = "NOT_FOUND"
ErrAlreadyExists ErrorCode = "ALREADY_EXISTS"
ErrCannotDelete ErrorCode = "CANNOT_DELETE"
ErrProtectedUser ErrorCode = "PROTECTED_USER"
ErrNoActiveSchool ErrorCode = "NO_ACTIVE_SCHOOL_YEAR"
// Datenbank
ErrDatabase ErrorCode = "DATABASE_ERROR"
ErrTransaction ErrorCode = "TRANSACTION_ERROR"
ErrQueryFailed ErrorCode = "QUERY_FAILED"
// Server
ErrInternal ErrorCode = "INTERNAL_ERROR"
ErrServiceUnavail ErrorCode = "SERVICE_UNAVAILABLE"
)
type AppError struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
UserMsg string `json:"user_message"`
HTTPStatus int `json:"-"`
Internal error `json:"-"`
}
func (e *AppError) Error() string {
if e.Internal != nil {
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Internal)
}
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
func NewAppError(code ErrorCode, message, userMsg string, httpStatus int, internal error) *AppError {
return &AppError{
Code: code,
Message: message,
UserMsg: userMsg,
HTTPStatus: httpStatus,
Internal: internal,
}
}
func ErrInvalidCredentialsMsg() *AppError {
return NewAppError(
ErrInvalidCredentials,
"Invalid username or password",
"Benutzername oder Passwort ungültig",
http.StatusUnauthorized,
nil,
)
}
func ErrUnauthorizedMsg() *AppError {
return NewAppError(
ErrUnauthorized,
"Unauthorized access",
"Keine Berechtigung für diese Aktion",
http.StatusUnauthorized,
nil,
)
}
func ErrTokenExpiredMsg() *AppError {
return NewAppError(
ErrTokenExpired,
"Token has expired",
"Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an",
http.StatusUnauthorized,
nil,
)
}
func ErrAccessDeniedMsg() *AppError {
return NewAppError(
ErrAccessDenied,
"Access denied - admin privileges required",
"Zugriff verweigert. Administrator-Rechte erforderlich",
http.StatusForbidden,
nil,
)
}
func ErrInvalidInputMsg(field string) *AppError {
return NewAppError(
ErrInvalidInput,
fmt.Sprintf("Invalid input for field: %s", field),
fmt.Sprintf("Ungültige Eingabe im Feld: %s", field),
http.StatusBadRequest,
nil,
)
}
func ErrMissingFieldMsg(field string) *AppError {
return NewAppError(
ErrMissingField,
fmt.Sprintf("Required field missing: %s", field),
fmt.Sprintf("Pflichtfeld fehlt: %s", field),
http.StatusBadRequest,
nil,
)
}
func ErrNotFoundMsg(resource string) *AppError {
return NewAppError(
ErrNotFound,
fmt.Sprintf("%s not found", resource),
fmt.Sprintf("%s nicht gefunden", resource),
http.StatusNotFound,
nil,
)
}
func ErrAlreadyExistsMsg(resource string) *AppError {
return NewAppError(
ErrAlreadyExists,
fmt.Sprintf("%s already exists", resource),
fmt.Sprintf("%s existiert bereits", resource),
http.StatusConflict,
nil,
)
}
func ErrCannotDeleteMsg(resource, reason string) *AppError {
return NewAppError(
ErrCannotDelete,
fmt.Sprintf("Cannot delete %s: %s", resource, reason),
fmt.Sprintf("%s kann nicht gelöscht werden: %s", resource, reason),
http.StatusBadRequest,
nil,
)
}
func ErrProtectedUserMsg() *AppError {
return NewAppError(
ErrProtectedUser,
"Cannot modify protected admin user",
"Der Admin-Benutzer ist geschützt und kann nicht geändert werden",
http.StatusForbidden,
nil,
)
}
func ErrNoActiveSchoolYearMsg() *AppError {
return NewAppError(
ErrNoActiveSchool,
"No active school year configured",
"Kein aktives Schuljahr konfiguriert. Bitte aktivieren Sie ein Schuljahr",
http.StatusNotFound,
nil,
)
}
func ErrDatabaseMsg(internal error) *AppError {
return NewAppError(
ErrDatabase,
"Database operation failed",
"Ein Datenbankfehler ist aufgetreten. Bitte versuchen Sie es erneut",
http.StatusInternalServerError,
internal,
)
}
func ErrInternalMsg(internal error) *AppError {
return NewAppError(
ErrInternal,
"Internal server error",
"Ein interner Fehler ist aufgetreten. Bitte versuchen Sie es später erneut",
http.StatusInternalServerError,
internal,
)
}
type ErrorResponse struct {
Code ErrorCode `json:"code"`
Message string `json:"message"`
}
func (e *AppError) ToResponse() ErrorResponse {
return ErrorResponse{
Code: e.Code,
Message: e.UserMsg,
}
}

View file

@ -3,14 +3,18 @@ module school-timetracker
go 1.25.3 go 1.25.3
require ( require (
github.com/jung-kurt/gofpdf v1.16.2
github.com/labstack/echo/v4 v4.13.4 github.com/labstack/echo/v4 v4.13.4
golang.org/x/crypto v0.43.0 golang.org/x/crypto v0.43.0
golang.org/x/time v0.11.0
modernc.org/sqlite v1.40.0 modernc.org/sqlite v1.40.0
) )
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect 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/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/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
@ -22,7 +26,6 @@ require (
golang.org/x/net v0.45.0 // indirect golang.org/x/net v0.45.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.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/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect

View file

@ -1,11 +1,20 @@
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
@ -16,10 +25,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@ -30,6 +43,7 @@ golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/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 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/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 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
@ -39,6 +53,7 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 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 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=

View file

@ -2,10 +2,14 @@ package main
import ( import (
"database/sql" "database/sql"
"fmt"
"log"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/golang-jwt/jwt/v5"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@ -14,25 +18,60 @@ type App struct {
DB *sql.DB DB *sql.DB
} }
// Login Handler func HandleError(c echo.Context, err *AppError) error {
log.Printf("[%s] %s", err.Code, err.Error())
return c.JSON(err.HTTPStatus, err.ToResponse())
}
func getClaims(c echo.Context) (*Claims, error) {
user, ok := c.Get("user").(*jwt.Token)
if !ok {
return nil, fmt.Errorf("JWT token missing or invalid")
}
claims, ok := user.Claims.(*Claims)
if !ok {
return nil, fmt.Errorf("failed to parse JWT claims")
}
return claims, nil
}
func isDuplicateError(err error) bool {
return err != nil && (err.Error() == "UNIQUE constraint failed" ||
strings.Contains(err.Error(), "UNIQUE") ||
strings.Contains(err.Error(), "duplicate"))
}
func (app *App) LoginHandler(c echo.Context) error { func (app *App) LoginHandler(c echo.Context) error {
var req LoginRequest var req LoginRequest
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request") return HandleError(c, ErrInvalidInputMsg("Login-Daten"))
}
if req.Username == "" {
return HandleError(c, ErrMissingFieldMsg("Benutzername"))
}
if req.Password == "" {
return HandleError(c, ErrMissingFieldMsg("Passwort"))
} }
user, err := GetUserByUsername(app.DB, req.Username) user, err := GetUserByUsername(app.DB, req.Username)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials") if err == sql.ErrNoRows {
return HandleError(c, ErrInvalidCredentialsMsg())
}
return HandleError(c, ErrDatabaseMsg(err))
} }
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil {
return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials") return HandleError(c, ErrInvalidCredentialsMsg())
} }
token, err := createToken(user.ID, user.Username, user.IsAdmin) token, err := createToken(user.ID, user.Username, user.IsAdmin)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "error creating token") return HandleError(c, ErrInternalMsg(err))
} }
response := LoginResponse{ response := LoginResponse{
@ -44,11 +83,10 @@ func (app *App) LoginHandler(c echo.Context) error {
return c.JSON(http.StatusOK, response) return c.JSON(http.StatusOK, response)
} }
// Schedule Handlers
func (app *App) GetSchedulesHandler(c echo.Context) error { func (app *App) GetSchedulesHandler(c echo.Context) error {
schedules, err := GetAllSchedules(app.DB) schedules, err := GetAllSchedules(app.DB)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
} }
return c.JSON(http.StatusOK, schedules) return c.JSON(http.StatusOK, schedules)
} }
@ -56,52 +94,100 @@ func (app *App) GetSchedulesHandler(c echo.Context) error {
func (app *App) CreateScheduleHandler(c echo.Context) error { func (app *App) CreateScheduleHandler(c echo.Context) error {
var schedule Schedule var schedule Schedule
if err := c.Bind(&schedule); err != nil { if err := c.Bind(&schedule); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request") return HandleError(c, ErrInvalidInputMsg("Stundenplan-Daten"))
}
if schedule.StartTime == "" {
return HandleError(c, ErrMissingFieldMsg("Startzeit"))
}
if schedule.EndTime == "" {
return HandleError(c, ErrMissingFieldMsg("Endzeit"))
}
if schedule.Title == "" {
return HandleError(c, ErrMissingFieldMsg("Titel"))
} }
if err := CreateSchedule(app.DB, &schedule); err != nil { if err := CreateSchedule(app.DB, &schedule); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) if isDuplicateError(err) {
return HandleError(c, ErrAlreadyExistsMsg("Stundenplan-Eintrag"))
}
return HandleError(c, ErrDatabaseMsg(err))
} }
return c.JSON(http.StatusCreated, map[string]string{"message": "schedule created"}) return c.JSON(http.StatusCreated, map[string]string{"message": "Stundenplan erstellt"})
} }
func (app *App) DeleteScheduleHandler(c echo.Context) error { func (app *App) DeleteScheduleHandler(c echo.Context) error {
id, err := strconv.Atoi(c.QueryParam("id")) id, err := strconv.Atoi(c.QueryParam("id"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid id") return HandleError(c, ErrInvalidInputMsg("Stundenplan-ID"))
} }
if err := DeleteSchedule(app.DB, id); err != nil { if err := DeleteSchedule(app.DB, id); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Stundenplan"))
}
return HandleError(c, ErrDatabaseMsg(err))
} }
return c.NoContent(http.StatusOK) return c.NoContent(http.StatusNoContent)
} }
// // User Handlers func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error {
// func (app *App) CreateUserHandler(c echo.Context) error { hours, err := GetYearlyHoursSummary(app.DB)
// var req CreateUserRequest if err != nil {
// if err := c.Bind(&req); err != nil { return HandleError(c, ErrDatabaseMsg(err))
// return echo.NewHTTPError(http.StatusBadRequest, "invalid request") }
// } if hours == nil {
hours = []WeeklyHours{}
}
return c.JSON(http.StatusOK, hours)
}
// hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error {
// if err != nil { var req struct {
// return echo.NewHTTPError(http.StatusInternalServerError, "error hashing password") UserID int `json:"user_id"`
// } Date string `json:"date"`
Hours float64 `json:"hours"`
Type string `json:"type"`
}
// if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin); err != nil { if err := c.Bind(&req); err != nil {
// return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
// } }
// return c.JSON(http.StatusCreated, map[string]string{"message": "user created"}) if req.UserID == 0 {
// } return HandleError(c, ErrMissingFieldMsg("Benutzer"))
}
if req.Date == "" {
return HandleError(c, ErrMissingFieldMsg("Datum"))
}
if req.Hours == 0 {
return HandleError(c, ErrMissingFieldMsg("Stunden"))
}
entry := TimeEntry{
UserID: req.UserID,
Date: req.Date,
StartTime: "00:00",
EndTime: "00:00",
Type: "manual",
}
if err := CreateManualTimeEntry(app.DB, &entry, req.Hours); err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
return c.NoContent(http.StatusNoContent)
}
func (app *App) GetUsersHandler(c echo.Context) error { func (app *App) GetUsersHandler(c echo.Context) error {
users, err := GetAllUsers(app.DB) users, err := GetAllUsers(app.DB)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
}
if users == nil {
users = []User{}
} }
return c.JSON(http.StatusOK, users) return c.JSON(http.StatusOK, users)
} }
@ -109,40 +195,65 @@ func (app *App) GetUsersHandler(c echo.Context) error {
func (app *App) DeleteUserHandler(c echo.Context) error { func (app *App) DeleteUserHandler(c echo.Context) error {
id, err := strconv.Atoi(c.QueryParam("id")) id, err := strconv.Atoi(c.QueryParam("id"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid id") return HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
}
if id == 1 {
return HandleError(c, ErrProtectedUserMsg())
} }
if err := DeleteUser(app.DB, id); err != nil { if err := DeleteUser(app.DB, id); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Benutzer"))
}
return HandleError(c, ErrDatabaseMsg(err))
} }
return c.NoContent(http.StatusOK) return c.NoContent(http.StatusNoContent)
} }
// Time Entry Handlers
func (app *App) CreateTimeEntryHandler(c echo.Context) error { func (app *App) CreateTimeEntryHandler(c echo.Context) error {
userID := c.Get("user_id").(int) claims, err := getClaims(c)
if err != nil {
return HandleError(c, ErrUnauthorizedMsg())
}
var entry TimeEntry var entry TimeEntry
if err := c.Bind(&entry); err != nil { if err := c.Bind(&entry); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request") return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
} }
entry.UserID = userID if entry.Date == "" {
return HandleError(c, ErrMissingFieldMsg("Datum"))
}
if entry.StartTime == "" {
return HandleError(c, ErrMissingFieldMsg("Startzeit"))
}
if entry.EndTime == "" {
return HandleError(c, ErrMissingFieldMsg("Endzeit"))
}
entry.UserID = claims.UserID
if err := CreateTimeEntry(app.DB, &entry); err != nil { if err := CreateTimeEntry(app.DB, &entry); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
} }
return c.JSON(http.StatusCreated, map[string]string{"message": "time entry created"}) return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteintrag erstellt"})
} }
func (app *App) GetMyTimeEntriesHandler(c echo.Context) error { func (app *App) GetMyTimeEntriesHandler(c echo.Context) error {
userID := c.Get("user_id").(int) claims, err := getClaims(c)
entries, err := GetTimeEntriesByUser(app.DB, userID)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrUnauthorizedMsg())
}
entries, err := GetTimeEntriesByUser(app.DB, claims.UserID)
if err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
if entries == nil {
entries = []TimeEntry{}
} }
return c.JSON(http.StatusOK, entries) return c.JSON(http.StatusOK, entries)
@ -151,12 +262,16 @@ func (app *App) GetMyTimeEntriesHandler(c echo.Context) error {
func (app *App) GetWeekDates(c echo.Context) error { func (app *App) GetWeekDates(c echo.Context) error {
year, err := strconv.Atoi(c.QueryParam("year")) year, err := strconv.Atoi(c.QueryParam("year"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") return HandleError(c, ErrInvalidInputMsg("Jahr"))
} }
week, err := strconv.Atoi(c.QueryParam("week")) week, err := strconv.Atoi(c.QueryParam("week"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") return HandleError(c, ErrInvalidInputMsg("Woche"))
}
if week < 1 || week > 53 {
return HandleError(c, ErrInvalidInputMsg("Woche (muss zwischen 1 und 53 liegen)"))
} }
dates := calculateWeekDates(year, week) dates := calculateWeekDates(year, week)
@ -164,21 +279,24 @@ func (app *App) GetWeekDates(c echo.Context) error {
} }
func (app *App) CheckWeekHasEntries(c echo.Context) error { func (app *App) CheckWeekHasEntries(c echo.Context) error {
userID := c.Get("user_id").(int) claims, err := getClaims(c)
if err != nil {
return HandleError(c, ErrUnauthorizedMsg())
}
year, err := strconv.Atoi(c.QueryParam("year")) year, err := strconv.Atoi(c.QueryParam("year"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") return HandleError(c, ErrInvalidInputMsg("Jahr"))
} }
week, err := strconv.Atoi(c.QueryParam("week")) week, err := strconv.Atoi(c.QueryParam("week"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") return HandleError(c, ErrInvalidInputMsg("Woche"))
} }
hasEntries, err := CheckUserHasEntriesForWeek(app.DB, userID, year, week) hasEntries, err := CheckUserHasEntriesForWeek(app.DB, claims.UserID, year, week)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
} }
return c.JSON(http.StatusOK, map[string]bool{"has_entries": hasEntries}) return c.JSON(http.StatusOK, map[string]bool{"has_entries": hasEntries})
@ -187,7 +305,10 @@ func (app *App) CheckWeekHasEntries(c echo.Context) error {
func (app *App) GetAllTimeEntriesHandler(c echo.Context) error { func (app *App) GetAllTimeEntriesHandler(c echo.Context) error {
entries, err := GetAllTimeEntries(app.DB) entries, err := GetAllTimeEntries(app.DB)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
}
if entries == nil {
entries = []TimeEntry{}
} }
return c.JSON(http.StatusOK, entries) return c.JSON(http.StatusOK, entries)
} }
@ -195,29 +316,35 @@ func (app *App) GetAllTimeEntriesHandler(c echo.Context) error {
func (app *App) GetWeeklyHoursHandler(c echo.Context) error { func (app *App) GetWeeklyHoursHandler(c echo.Context) error {
hours, err := GetWeeklyHours(app.DB) hours, err := GetWeeklyHours(app.DB)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
}
if hours == nil {
hours = []WeeklyHours{}
} }
return c.JSON(http.StatusOK, hours) return c.JSON(http.StatusOK, hours)
} }
func (app *App) DeleteWeekEntries(c echo.Context) error { func (app *App) DeleteWeekEntries(c echo.Context) error {
userID := c.Get("user_id").(int) claims, err := getClaims(c)
if err != nil {
return HandleError(c, ErrUnauthorizedMsg())
}
year, err := strconv.Atoi(c.QueryParam("year")) year, err := strconv.Atoi(c.QueryParam("year"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") return HandleError(c, ErrInvalidInputMsg("Jahr"))
} }
week, err := strconv.Atoi(c.QueryParam("week")) week, err := strconv.Atoi(c.QueryParam("week"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") return HandleError(c, ErrInvalidInputMsg("Woche"))
} }
if err := DeleteTimeEntriesByUserAndWeek(app.DB, userID, year, week); err != nil { if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrDatabaseMsg(err))
} }
return c.NoContent(http.StatusOK) return c.NoContent(http.StatusNoContent)
} }
type WeekDates struct { type WeekDates struct {
@ -276,52 +403,83 @@ type BatchTimeEntryRequest struct {
} }
func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error { func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error {
userID := c.Get("user_id").(int) claims, err := getClaims(c)
if err != nil {
return HandleError(c, ErrUnauthorizedMsg())
}
var req BatchTimeEntryRequest var req BatchTimeEntryRequest
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid request") return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
}
if len(req.Entries) == 0 {
return HandleError(c, ErrMissingFieldMsg("Zeiteinträge"))
}
if len(req.Entries) > 0 {
firstDate := req.Entries[0].Date
t, err := time.Parse("2006-01-02", firstDate)
if err != nil {
return HandleError(c, ErrInvalidInputMsg("Datum-Format"))
}
year, week := t.ISOWeek()
if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil {
return HandleError(c, ErrDatabaseMsg(err))
}
} }
tx, err := app.DB.Begin() tx, err := app.DB.Begin()
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "transaction error") return HandleError(c, ErrDatabaseMsg(err))
} }
defer tx.Rollback() defer tx.Rollback()
stmt, err := tx.Prepare("INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?)") stmt, err := tx.Prepare("INSERT INTO time_entries (user_id, schedule_id, date, type, start_time, end_time) VALUES (?, ?, ?, ?, ?, ?)")
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "prepare error") return HandleError(c, ErrDatabaseMsg(err))
} }
defer stmt.Close() defer stmt.Close()
for _, entry := range req.Entries { for _, entry := range req.Entries {
_, err := stmt.Exec(userID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime) _, err := stmt.Exec(claims.UserID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "insert error") return HandleError(c, ErrDatabaseMsg(err))
} }
} }
if err := tx.Commit(); err != nil { if err := tx.Commit(); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "commit error") return HandleError(c, ErrDatabaseMsg(err))
} }
return c.JSON(http.StatusCreated, map[string]string{"message": "entries created"}) return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteinträge erstellt"})
} }
func (app *App) UpdateUserHandler(c echo.Context) error { func (app *App) UpdateUserHandler(c echo.Context) error {
userID, err := strconv.Atoi(c.Param("id")) userID, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID") return HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
}
if userID == 1 {
return HandleError(c, ErrProtectedUserMsg())
} }
var req UpdateUserRequest var req UpdateUserRequest
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error()) return HandleError(c, ErrInvalidInputMsg("Benutzerdaten"))
} }
if err := UpdateUser(app.DB, userID, req.WeeklyHours); err != nil { if req.YearlyHours <= 0 {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) return HandleError(c, ErrInvalidInputMsg("Jahresarbeitsstunden (muss positiv sein)"))
}
if err := UpdateUser(app.DB, userID, req.YearlyHours); err != nil {
if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Benutzer"))
}
return HandleError(c, ErrDatabaseMsg(err))
} }
return c.NoContent(http.StatusOK) return c.NoContent(http.StatusOK)
@ -330,21 +488,28 @@ func (app *App) UpdateUserHandler(c echo.Context) error {
func (app *App) ResetPasswordHandler(c echo.Context) error { func (app *App) ResetPasswordHandler(c echo.Context) error {
userID, err := strconv.Atoi(c.Param("id")) userID, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID") return HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
} }
var req ResetPasswordRequest var req ResetPasswordRequest
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error()) return HandleError(c, ErrInvalidInputMsg("Passwort-Daten"))
}
if len(req.NewPassword) < 6 {
return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)"))
} }
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") return HandleError(c, ErrInternalMsg(err))
} }
if err := ResetUserPassword(app.DB, userID, string(hashedPassword)); err != nil { if err := ResetUserPassword(app.DB, userID, string(hashedPassword)); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Benutzer"))
}
return HandleError(c, ErrDatabaseMsg(err))
} }
return c.NoContent(http.StatusOK) return c.NoContent(http.StatusOK)
@ -353,16 +518,29 @@ func (app *App) ResetPasswordHandler(c echo.Context) error {
func (app *App) UpdateTimeEntryHandler(c echo.Context) error { func (app *App) UpdateTimeEntryHandler(c echo.Context) error {
entryID, err := strconv.Atoi(c.Param("id")) entryID, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID") return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID"))
} }
var req UpdateTimeEntryRequest var req UpdateTimeEntryRequest
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error()) return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
}
if req.Date == "" {
return HandleError(c, ErrMissingFieldMsg("Datum"))
}
if req.StartTime == "" {
return HandleError(c, ErrMissingFieldMsg("Startzeit"))
}
if req.EndTime == "" {
return HandleError(c, ErrMissingFieldMsg("Endzeit"))
} }
if err := UpdateTimeEntry(app.DB, entryID, req.Date, req.StartTime, req.EndTime, req.Type); err != nil { if err := UpdateTimeEntry(app.DB, entryID, req.Date, req.StartTime, req.EndTime, req.Type); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Zeiteintrag"))
}
return HandleError(c, ErrDatabaseMsg(err))
} }
return c.NoContent(http.StatusOK) return c.NoContent(http.StatusOK)
@ -371,74 +549,180 @@ func (app *App) UpdateTimeEntryHandler(c echo.Context) error {
func (app *App) DeleteTimeEntryHandler(c echo.Context) error { func (app *App) DeleteTimeEntryHandler(c echo.Context) error {
entryID, err := strconv.Atoi(c.Param("id")) entryID, err := strconv.Atoi(c.Param("id"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID") return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID"))
} }
if err := DeleteTimeEntry(app.DB, entryID); err != nil { if err := DeleteTimeEntry(app.DB, entryID); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Zeiteintrag"))
}
return HandleError(c, ErrDatabaseMsg(err))
} }
return c.NoContent(http.StatusOK) return c.NoContent(http.StatusNoContent)
} }
func (app *App) GetMyWeeklySummaryHandler(c echo.Context) error { func (app *App) GetMyInfoHandler(c echo.Context) error {
userID := c.Get("user_id").(int) claims, err := getClaims(c)
year, err := strconv.Atoi(c.QueryParam("year"))
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year") return HandleError(c, ErrUnauthorizedMsg())
} }
week, err := strconv.Atoi(c.QueryParam("week")) user, err := GetUserByID(app.DB, claims.UserID)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid week") if err == sql.ErrNoRows {
return HandleError(c, ErrNotFoundMsg("Benutzer"))
}
return HandleError(c, ErrDatabaseMsg(err))
} }
user, err := GetUserByID(app.DB, userID) return c.JSON(http.StatusOK, user)
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 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 { func (app *App) CreateUserHandler(c echo.Context) error {
var req CreateUserRequest var req CreateUserRequest
if err := c.Bind(&req); err != nil { if err := c.Bind(&req); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, err.Error()) return HandleError(c, ErrInvalidInputMsg("Benutzerdaten"))
}
if req.Username == "" {
return HandleError(c, ErrMissingFieldMsg("Benutzername"))
}
if req.Password == "" {
return HandleError(c, ErrMissingFieldMsg("Passwort"))
}
if len(req.Password) < 6 {
return HandleError(c, ErrInvalidInputMsg("Passwort (mindestens 6 Zeichen)"))
} }
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password") return HandleError(c, ErrInternalMsg(err))
} }
if req.WeeklyHours == 0 { if req.YearlyHours == 0 {
req.WeeklyHours = 40.0 req.YearlyHours = 60.0
} }
if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.WeeklyHours); err != nil { if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.YearlyHours); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error()) if isDuplicateError(err) {
return HandleError(c, ErrAlreadyExistsMsg("Benutzername"))
}
return HandleError(c, ErrDatabaseMsg(err))
} }
return c.NoContent(http.StatusCreated) 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)
}

22
backend/load-env.sh Executable file
View file

@ -0,0 +1,22 @@
#!/bin/bash
if [ -f .env ]; then
set -a
source .env
set +a
echo "✅ .env geladen"
else
echo "❌ .env Datei nicht gefunden!"
exit 1
fi
if [ -z "$PORT" ]; then
export PORT=8080
fi
if [ -z "$DB_PATH" ]; then
export DB_PATH="/data/timetracking.db"
fi
exec "$@"

View file

@ -4,6 +4,7 @@ import (
"log" "log"
"net/http" "net/http"
"os" "os"
"strings"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
@ -24,8 +25,20 @@ func main() {
e.Use(middleware.Logger()) e.Use(middleware.Logger())
e.Use(middleware.Recover()) 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{ e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"}, AllowOrigins: allowOrigins,
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete}, AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete},
AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization}, AllowHeaders: []string{echo.HeaderOrigin, echo.HeaderContentType, echo.HeaderAccept, echo.HeaderAuthorization},
})) }))
@ -44,7 +57,9 @@ func main() {
protected.DELETE("/my-time-entries/week", app.DeleteWeekEntries) protected.DELETE("/my-time-entries/week", app.DeleteWeekEntries)
protected.GET("/week-dates", app.GetWeekDates) protected.GET("/week-dates", app.GetWeekDates)
protected.GET("/week-has-entries", app.CheckWeekHasEntries) protected.GET("/week-has-entries", app.CheckWeekHasEntries)
protected.GET("/my-weekly-summary", app.GetMyWeeklySummaryHandler) protected.GET("/yearly-hours-summary", app.GetYearlyHoursSummaryHandler)
protected.GET("/my-info", app.GetMyInfoHandler)
protected.GET("/school-year/active", app.GetActiveSchoolYearHandler)
} }
admin := e.Group("/api/admin") admin := e.Group("/api/admin")
@ -59,9 +74,15 @@ func main() {
admin.GET("/time-entries", app.GetAllTimeEntriesHandler) admin.GET("/time-entries", app.GetAllTimeEntriesHandler)
admin.GET("/weekly-hours", app.GetWeeklyHoursHandler) admin.GET("/weekly-hours", app.GetWeeklyHoursHandler)
admin.PUT("/users/:id", app.UpdateUserHandler) admin.PUT("/users/:id", app.UpdateUserHandler)
admin.POST("/users/:id/reset-password", app.ResetPasswordHandler) admin.PUT("/users/:id/reset-password", app.ResetPasswordHandler)
admin.PUT("/time-entries/:id", app.UpdateTimeEntryHandler) admin.PUT("/time-entries/:id", app.UpdateTimeEntryHandler)
admin.DELETE("/time-entries/:id", app.DeleteTimeEntryHandler) 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") e.Static("/", "./static")

View file

@ -1,120 +1,66 @@
package main package main
import ( import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http" "net/http"
"strings" "os"
"sync"
"time" "time"
"github.com/golang-jwt/jwt/v5"
echojwt "github.com/labstack/echo-jwt/v4"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
"golang.org/x/time/rate"
) )
var jwtSecret = []byte("your-secret-key-change-in-production") var jwtSecret []byte
func init() {
secret := os.Getenv("JWT_SECRET")
if secret == "" {
panic("JWT_SECRET environment variable is required")
}
jwtSecret = []byte(secret)
}
func createToken(userID int, username string, isAdmin bool) (string, error) { func createToken(userID int, username string, isAdmin bool) (string, error) {
claims := Claims{ claims := &Claims{
UserID: userID, UserID: userID,
Username: username, Username: username,
IsAdmin: isAdmin, IsAdmin: isAdmin,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(2 * time.Hour)),
},
} }
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`)) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
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 { func JWTMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return echojwt.WithConfig(echojwt.Config{
return func(c echo.Context) error { NewClaimsFunc: func(c echo.Context) jwt.Claims {
authHeader := c.Request().Header.Get("Authorization") return new(Claims)
if authHeader == "" { },
return echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header") SigningKey: jwtSecret,
} })
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 { func AdminMiddleware() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
isAdmin, ok := c.Get("is_admin").(bool) user, ok := c.Get("user").(*jwt.Token)
if !ok || !isAdmin { if !ok {
return echo.NewHTTPError(http.StatusForbidden, "admin access required") return echo.NewHTTPError(http.StatusUnauthorized, "JWT token missing or invalid")
}
claims, ok := user.Claims.(*Claims)
if !ok {
return echo.NewHTTPError(http.StatusUnauthorized, "Failed to parse JWT claims")
}
if !claims.IsAdmin {
return echo.NewHTTPError(http.StatusForbidden, "Access denied: admin rights required")
} }
return next(c) return next(c)
} }
@ -126,3 +72,68 @@ func CustomLogger() echo.MiddlewareFunc {
Format: "${time_rfc3339} | ${status} | ${latency_human} | ${method} ${uri}\n", 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)
}
}
}

View file

@ -1,6 +1,9 @@
package main package main
import "time" import (
"github.com/golang-jwt/jwt/v5"
"time"
)
type TimeEntry struct { type TimeEntry struct {
ID int `json:"id"` ID int `json:"id"`
@ -20,8 +23,10 @@ type WeeklyHours struct {
Week int `json:"week"` Week int `json:"week"`
Year int `json:"year"` Year int `json:"year"`
TotalHours float64 `json:"total_hours"` TotalHours float64 `json:"total_hours"`
ExpectedHours float64 `json:"expected_hours"` YearlyTarget float64 `json:"yearly_target"` // NEU
RemainingHours float64 `json:"remaining_hours"` YearlyActual float64 `json:"yearly_actual"` // NEU
WeeklyTarget float64 `json:"weekly_target"` // NEU
RemainingYearly float64 `json:"remaining_yearly"` // NEU
} }
type User struct { type User struct {
@ -29,7 +34,7 @@ type User struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"-"` Password string `json:"-"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
WeeklyHours float64 `json:"weekly_hours"` YearlyHours float64 `json:"yearly_hours"`
} }
type Schedule struct { type Schedule struct {
@ -56,12 +61,27 @@ type CreateUserRequest struct {
Username string `json:"username" validate:"required"` Username string `json:"username" validate:"required"`
Password string `json:"password" validate:"required,min=6"` Password string `json:"password" validate:"required,min=6"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
WeeklyHours float64 `json:"weekly_hours"` YearlyHours float64 `json:"yearly_hours"`
}
type SchoolYear struct {
ID int `json:"id"`
Name string `json:"name"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
}
type CreateSchoolYearRequest struct {
Name string `json:"name" validate:"required"`
StartDate string `json:"start_date" validate:"required"`
EndDate string `json:"end_date" validate:"required"`
} }
type UpdateUserRequest struct { type UpdateUserRequest struct {
Username string `json:"username"` Username string `json:"username"`
WeeklyHours float64 `json:"weekly_hours"` YearlyHours float64 `json:"yearly_hours"`
} }
type ResetPasswordRequest struct { type ResetPasswordRequest struct {
@ -79,4 +99,5 @@ type Claims struct {
UserID int `json:"user_id"` UserID int `json:"user_id"`
Username string `json:"username"` Username string `json:"username"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
jwt.RegisteredClaims
} }

110
backend/pdf.go Normal file
View file

@ -0,0 +1,110 @@
package main
import (
"fmt"
"time"
"github.com/jung-kurt/gofpdf"
)
func GenerateYearlySummaryPDF(schoolYear *SchoolYear, summary []WeeklyHours) ([]byte, error) {
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.AddPage()
pdf.SetFont("Arial", "B", 20)
title := fmt.Sprintf("Stundenjahresübersicht für Schuljahr %s", schoolYear.Name)
pdf.Cell(0, 15, title)
pdf.Ln(10)
pdf.SetFont("Arial", "", 12)
subtitle := fmt.Sprintf("%s bis %s", schoolYear.StartDate, schoolYear.EndDate)
pdf.Cell(0, 10, subtitle)
pdf.Ln(15)
pdf.SetFont("Arial", "B", 10)
pdf.SetFillColor(52, 152, 219)
pdf.SetTextColor(255, 255, 255)
colWidths := []float64{60, 40, 40, 40}
headers := []string{"Mitarbeiter", "Soll (Std.)", "Ist (Std.)", "Differenz (Std.)"}
for i, header := range headers {
pdf.CellFormat(colWidths[i], 10, header, "1", 0, "C", true, 0, "")
}
pdf.Ln(-1)
pdf.SetFont("Arial", "", 10)
pdf.SetTextColor(0, 0, 0)
fill := false
for _, entry := range summary {
if fill {
pdf.SetFillColor(240, 240, 240)
} else {
pdf.SetFillColor(255, 255, 255)
}
pdf.CellFormat(colWidths[0], 8, entry.Username, "1", 0, "L", true, 0, "")
pdf.CellFormat(colWidths[1], 8, fmt.Sprintf("%.1f", entry.YearlyTarget), "1", 0, "R", true, 0, "")
pdf.CellFormat(colWidths[2], 8, fmt.Sprintf("%.1f", entry.YearlyActual), "1", 0, "R", true, 0, "")
diffStr := fmt.Sprintf("%.1f", entry.RemainingYearly)
if entry.RemainingYearly > 0 {
pdf.SetTextColor(220, 53, 69)
} else {
pdf.SetTextColor(40, 167, 69)
}
pdf.CellFormat(colWidths[3], 8, diffStr, "1", 0, "R", true, 0, "")
pdf.SetTextColor(0, 0, 0)
pdf.Ln(-1)
fill = !fill
}
pdf.Ln(5)
pdf.SetFont("Arial", "B", 10)
totalTarget := 0.0
totalActual := 0.0
totalRemaining := 0.0
for _, entry := range summary {
totalTarget += entry.YearlyTarget
totalActual += entry.YearlyActual
totalRemaining += entry.RemainingYearly
}
pdf.SetFillColor(52, 152, 219)
pdf.SetTextColor(255, 255, 255)
pdf.CellFormat(colWidths[0], 10, "GESAMT", "1", 0, "L", true, 0, "")
pdf.CellFormat(colWidths[1], 10, fmt.Sprintf("%.1f", totalTarget), "1", 0, "R", true, 0, "")
pdf.CellFormat(colWidths[2], 10, fmt.Sprintf("%.1f", totalActual), "1", 0, "R", true, 0, "")
pdf.CellFormat(colWidths[3], 10, fmt.Sprintf("%.1f", totalRemaining), "1", 0, "R", true, 0, "")
pdf.Ln(15)
pdf.SetFont("Arial", "I", 8)
pdf.SetTextColor(128, 128, 128)
pdf.Cell(0, 10, fmt.Sprintf("Erstellt am: %s", time.Now().Format("02.01.2006 15:04")))
var buf []byte
w := &pdfWriter{buf: &buf}
err := pdf.Output(w)
if err != nil {
return nil, err
}
return buf, nil
}
type pdfWriter struct {
buf *[]byte
}
func (w *pdfWriter) Write(p []byte) (n int, err error) {
*w.buf = append(*w.buf, p...)
return len(p), nil
}

View file

@ -1,36 +1,338 @@
<!DOCTYPE html> <!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Schulzeit Erfassung</title> <meta http-equiv="X-UA-Compatible" content="ie=edge" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"> <title>Zeiterfassung</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
<style> <style>
html, body { /* Toast-Container */
height: 100%; .toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
max-width: 400px;
pointer-events: none;
}
/* Basis-Toast */
.toast {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(10px);
pointer-events: all;
min-width: 320px;
transition: all 0.3s ease;
border-left: 4px solid;
}
.toast:hover {
transform: translateX(-5px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
/* Toast-Content */
.toast-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.toast-icon {
font-size: 1.25rem;
display: flex;
align-items: center;
}
.toast-message {
font-size: 0.95rem;
line-height: 1.4;
color: #2c3e50;
font-weight: 500;
}
/* Close-Button */
.toast-close {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
margin-left: 12px;
color: rgba(0, 0, 0, 0.4);
transition: color 0.2s ease;
font-size: 1rem;
}
.toast-close:hover {
color: rgba(0, 0, 0, 0.7);
}
/* Toast-Typen */
.toast-error {
background: linear-gradient(135deg, #fff5f5 0%, #ffe5e5 100%);
border-left-color: #e53e3e;
}
.toast-error .toast-icon {
color: #e53e3e;
}
.toast-success {
background: linear-gradient(135deg, #f0fff4 0%, #e6ffed 100%);
border-left-color: #38a169;
}
.toast-success .toast-icon {
color: #38a169;
}
.toast-info {
background: linear-gradient(135deg, #ebf8ff 0%, #e0f3ff 100%);
border-left-color: #3182ce;
}
.toast-info .toast-icon {
color: #3182ce;
}
.toast-warning {
background: linear-gradient(135deg, #fffaf0 0%, #fff5e6 100%);
border-left-color: #dd6b20;
}
.toast-warning .toast-icon {
color: #dd6b20;
}
/* Animationen */
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
.toast.dismissing {
animation: slideOut 0.3s ease-in forwards;
}
/* Mobile Anpassungen */
@media screen and (max-width: 768px) {
.toast-container {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
}
.toast {
min-width: auto;
width: 100%;
}
.toast-message {
font-size: 0.9rem;
}
}
/* Dark Mode Support (optional) */
@media (prefers-color-scheme: dark) {
.toast {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.toast-message {
color: #1a202c;
}
.toast-close {
color: rgba(0, 0, 0, 0.5);
}
.toast-close:hover {
color: rgba(0, 0, 0, 0.8);
}
}
body {
min-height: 100vh;
}
.table-container {
overflow-x: auto;
}
@media screen and (max-width: 768px) {
.level {
flex-direction: column;
}
.level-left,
.level-right {
width: 100%;
}
.level-item {
justify-content: center;
margin-bottom: 0.5rem;
}
.buttons {
flex-wrap: wrap;
}
.button {
margin-bottom: 0.5rem;
}
}
.fa-spinner {
animation: fa-spin 1s infinite linear;
}
@keyframes fa-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
</style> </style>
</head> </head>
<body> <body>
<div id="app"></div> <div id="elm"></div>
<script src="/elm.js"></script> <script src="/elm.js"></script>
<script> <script>
var storedToken = localStorage.getItem('authToken'); function getStoredData() {
try {
const data = localStorage.getItem("timetracking");
if (data) {
return JSON.parse(data);
}
} catch (e) {
console.error("Failed to parse stored data:", e);
}
return {token: null, isAdmin: false};
}
var app = Elm.Main.init({ function saveData(token, isAdmin) {
node: document.getElementById('app'), try {
flags: storedToken localStorage.setItem(
"timetracking",
JSON.stringify({
token: token,
isAdmin: isAdmin,
}),
);
} catch (e) {
console.error("Failed to save data:", e);
}
}
function clearData() {
try {
localStorage.removeItem("timetracking");
} catch (e) {
console.error("Failed to clear data:", e);
}
}
const storedData = getStoredData();
const app = Elm.Main.init({
node: document.getElementById("elm"),
flags: {
token: storedData.token,
isAdmin: storedData.isAdmin,
},
}); });
// Save token to localStorage app.ports.saveToken.subscribe(function (data) {
app.ports.saveToken.subscribe(function(token) { saveData(data.token, data.isAdmin);
localStorage.setItem('authToken', token);
}); });
// Remove token from localStorage app.ports.removeToken.subscribe(function () {
app.ports.removeToken.subscribe(function() { clearData();
localStorage.removeItem('authToken');
}); });
app.ports.confirmDelete.subscribe(function (message) {
const confirmed = confirm(message);
app.ports.confirmDeleteResponse.send(confirmed);
});
document.addEventListener("DOMContentLoaded", () => {
function setupBurgerMenu() {
const burgers = document.querySelectorAll(".navbar-burger");
burgers.forEach((burger) => {
burger.addEventListener("click", () => {
const target = burger.dataset.target;
const menu = document.getElementById(target);
if (menu) {
burger.classList.toggle("is-active");
menu.classList.toggle("is-active");
}
});
});
}
setupBurgerMenu();
const observer = new MutationObserver((mutations) => {
setupBurgerMenu();
});
observer.observe(document.getElementById("elm"), {
childList: true,
subtree: true,
});
});
if (
"serviceWorker" in navigator &&
window.location.protocol === "https:"
) {
navigator.serviceWorker.register("/sw.js").catch(() => {
console.log("Service Worker registration failed");
});
}
</script> </script>
</body> </body>
</html> </html>

View file

@ -1,17 +1,3 @@
# version: '3.8'
# services:
# timetracking:
# build: .
# container_name: school-timetracking
# ports:
# - "8080:8080"
# volumes:
# - ./data:/data
# environment:
# - PORT=8080
# - DB_PATH=/data/timetracking.db
# restart: unless-stopped
services: services:
timetracking: timetracking:
build: . build: .
@ -21,6 +7,8 @@ services:
environment: environment:
- PORT=8080 - PORT=8080
- DB_PATH=/data/timetracking.db - DB_PATH=/data/timetracking.db
- JWT_SECRET=your-default-secret-change-me
- TZ=Europe/Berlin # Optional: Zeitzone
volumes: volumes:
- timetracking-data:/data - timetracking-data:/data
restart: unless-stopped restart: unless-stopped
@ -34,4 +22,3 @@ volumes:
networks: networks:
timetracking-net: timetracking-net:
driver: bridge driver: bridge

View file

@ -1,19 +1,21 @@
{ {
"type": "application", "type": "application",
"source-directories": ["src"], "source-directories": [
"src"
],
"elm-version": "0.19.1", "elm-version": "0.19.1",
"dependencies": { "dependencies": {
"direct": { "direct": {
"elm/browser": "1.0.2", "elm/browser": "1.0.2",
"elm/bytes": "1.0.8",
"elm/core": "1.0.5", "elm/core": "1.0.5",
"elm/file": "1.0.5",
"elm/html": "1.0.0", "elm/html": "1.0.0",
"elm/http": "2.0.0", "elm/http": "2.0.0",
"elm/json": "1.1.3", "elm/json": "1.1.3",
"elm/time": "1.0.0" "elm/time": "1.0.0"
}, },
"indirect": { "indirect": {
"elm/bytes": "1.0.8",
"elm/file": "1.0.5",
"elm/url": "1.0.0", "elm/url": "1.0.0",
"elm/virtual-dom": "1.0.3" "elm/virtual-dom": "1.0.3"
} }

View file

@ -1,36 +1,338 @@
<!DOCTYPE html> <!doctype html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Schulzeit Erfassung</title> <meta http-equiv="X-UA-Compatible" content="ie=edge" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"> <title>Zeiterfassung</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
<style> <style>
html, body { /* Toast-Container */
height: 100%; .toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 12px;
max-width: 400px;
pointer-events: none;
}
/* Basis-Toast */
.toast {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(10px);
pointer-events: all;
min-width: 320px;
transition: all 0.3s ease;
border-left: 4px solid;
}
.toast:hover {
transform: translateX(-5px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
/* Toast-Content */
.toast-content {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.toast-icon {
font-size: 1.25rem;
display: flex;
align-items: center;
}
.toast-message {
font-size: 0.95rem;
line-height: 1.4;
color: #2c3e50;
font-weight: 500;
}
/* Close-Button */
.toast-close {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
margin-left: 12px;
color: rgba(0, 0, 0, 0.4);
transition: color 0.2s ease;
font-size: 1rem;
}
.toast-close:hover {
color: rgba(0, 0, 0, 0.7);
}
/* Toast-Typen */
.toast-error {
background: linear-gradient(135deg, #fff5f5 0%, #ffe5e5 100%);
border-left-color: #e53e3e;
}
.toast-error .toast-icon {
color: #e53e3e;
}
.toast-success {
background: linear-gradient(135deg, #f0fff4 0%, #e6ffed 100%);
border-left-color: #38a169;
}
.toast-success .toast-icon {
color: #38a169;
}
.toast-info {
background: linear-gradient(135deg, #ebf8ff 0%, #e0f3ff 100%);
border-left-color: #3182ce;
}
.toast-info .toast-icon {
color: #3182ce;
}
.toast-warning {
background: linear-gradient(135deg, #fffaf0 0%, #fff5e6 100%);
border-left-color: #dd6b20;
}
.toast-warning .toast-icon {
color: #dd6b20;
}
/* Animationen */
@keyframes slideIn {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slideOut {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(400px);
opacity: 0;
}
}
.toast.dismissing {
animation: slideOut 0.3s ease-in forwards;
}
/* Mobile Anpassungen */
@media screen and (max-width: 768px) {
.toast-container {
top: 10px;
right: 10px;
left: 10px;
max-width: none;
}
.toast {
min-width: auto;
width: 100%;
}
.toast-message {
font-size: 0.9rem;
}
}
/* Dark Mode Support (optional) */
@media (prefers-color-scheme: dark) {
.toast {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.toast-message {
color: #1a202c;
}
.toast-close {
color: rgba(0, 0, 0, 0.5);
}
.toast-close:hover {
color: rgba(0, 0, 0, 0.8);
}
}
body {
min-height: 100vh;
}
.table-container {
overflow-x: auto;
}
@media screen and (max-width: 768px) {
.level {
flex-direction: column;
}
.level-left,
.level-right {
width: 100%;
}
.level-item {
justify-content: center;
margin-bottom: 0.5rem;
}
.buttons {
flex-wrap: wrap;
}
.button {
margin-bottom: 0.5rem;
}
}
.fa-spinner {
animation: fa-spin 1s infinite linear;
}
@keyframes fa-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
</style> </style>
</head> </head>
<body> <body>
<div id="app"></div> <div id="elm"></div>
<script src="/elm.js"></script> <script src="/elm.js"></script>
<script> <script>
var storedToken = localStorage.getItem('authToken'); function getStoredData() {
try {
const data = localStorage.getItem("timetracking");
if (data) {
return JSON.parse(data);
}
} catch (e) {
console.error("Failed to parse stored data:", e);
}
return {token: null, isAdmin: false};
}
var app = Elm.Main.init({ function saveData(token, isAdmin) {
node: document.getElementById('app'), try {
flags: storedToken localStorage.setItem(
"timetracking",
JSON.stringify({
token: token,
isAdmin: isAdmin,
}),
);
} catch (e) {
console.error("Failed to save data:", e);
}
}
function clearData() {
try {
localStorage.removeItem("timetracking");
} catch (e) {
console.error("Failed to clear data:", e);
}
}
const storedData = getStoredData();
const app = Elm.Main.init({
node: document.getElementById("elm"),
flags: {
token: storedData.token,
isAdmin: storedData.isAdmin,
},
}); });
// Save token to localStorage app.ports.saveToken.subscribe(function (data) {
app.ports.saveToken.subscribe(function(token) { saveData(data.token, data.isAdmin);
localStorage.setItem('authToken', token);
}); });
// Remove token from localStorage app.ports.removeToken.subscribe(function () {
app.ports.removeToken.subscribe(function() { clearData();
localStorage.removeItem('authToken');
}); });
app.ports.confirmDelete.subscribe(function (message) {
const confirmed = confirm(message);
app.ports.confirmDeleteResponse.send(confirmed);
});
document.addEventListener("DOMContentLoaded", () => {
function setupBurgerMenu() {
const burgers = document.querySelectorAll(".navbar-burger");
burgers.forEach((burger) => {
burger.addEventListener("click", () => {
const target = burger.dataset.target;
const menu = document.getElementById(target);
if (menu) {
burger.classList.toggle("is-active");
menu.classList.toggle("is-active");
}
});
});
}
setupBurgerMenu();
const observer = new MutationObserver((mutations) => {
setupBurgerMenu();
});
observer.observe(document.getElementById("elm"), {
childList: true,
subtree: true,
});
});
if (
"serviceWorker" in navigator &&
window.location.protocol === "https:"
) {
navigator.serviceWorker.register("/sw.js").catch(() => {
console.log("Service Worker registration failed");
});
}
</script> </script>
</body> </body>
</html> </html>

21
frontend/src/Api/Auth.elm Normal file
View file

@ -0,0 +1,21 @@
module Api.Auth exposing (loginRequest)
import Api.Decoders exposing (loginDecoder)
import Http
import Json.Encode as Encode
import Types.Api exposing (LoginResult)
import Types.Msg exposing (Msg(..))
loginRequest : String -> String -> Cmd Msg
loginRequest username password =
Http.post
{ url = "/api/login"
, body =
Http.jsonBody <|
Encode.object
[ ( "username", Encode.string username )
, ( "password", Encode.string password )
]
, expect = Http.expectJson LoginResponse loginDecoder
}

View file

@ -0,0 +1,109 @@
module Api.Decoders exposing
( apiErrorDecoder
, loginDecoder
, scheduleDecoder
, schoolYearDecoder
, timeEntryDecoder
, userDecoder
, weekDatesDecoder
, weeklyHoursDecoder
, yearlyHoursSummaryDecoder
)
import Dict
import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string)
import Types.Api exposing (ApiError, LoginResult)
import Types.Model exposing (..)
loginDecoder : Decoder LoginResult
loginDecoder =
Decode.map3 LoginResult
(field "token" string)
(field "username" string)
(field "is_admin" bool)
scheduleDecoder : Decoder Schedule
scheduleDecoder =
Decode.map6 Schedule
(field "id" int)
(field "day_of_week" int)
(field "start_time" string)
(field "end_time" string)
(field "type" string)
(field "title" string)
timeEntryDecoder : Decoder TimeEntry
timeEntryDecoder =
Decode.map8 TimeEntry
(field "id" int)
(field "user_id" int)
(field "schedule_id" int)
(field "date" string)
(field "type" string)
(field "username" string)
(field "start_time" string)
(field "end_time" string)
userDecoder : Decoder User
userDecoder =
Decode.map4 User
(field "id" int)
(field "username" string)
(field "is_admin" bool)
(field "yearly_hours" float)
weekDatesDecoder : Decoder WeekDates
weekDatesDecoder =
Decode.map4 WeekDates
(field "year" int)
(field "week" int)
(field "dates" (Decode.dict string) |> Decode.map Dict.toList)
(field "range" string)
weeklyHoursDecoder : Decoder WeeklyHours
weeklyHoursDecoder =
Decode.map7 WeeklyHours
(field "user_id" int)
(field "username" string)
(field "year" int)
(field "week" int)
(field "total_hours" float)
(field "expected_hours" float)
(field "remaining_hours" float)
yearlyHoursSummaryDecoder : Decoder YearlyHoursSummary
yearlyHoursSummaryDecoder =
Decode.succeed YearlyHoursSummary
|> Decode.andThen (\f -> Decode.map f (field "user_id" int))
|> Decode.andThen (\f -> Decode.map f (field "username" string))
|> Decode.andThen (\f -> Decode.map f (field "year" int))
|> Decode.andThen (\f -> Decode.map f (field "week" int))
|> Decode.andThen (\f -> Decode.map f (field "total_hours" float))
|> Decode.andThen (\f -> Decode.map f (field "yearly_target" float))
|> Decode.andThen (\f -> Decode.map f (field "yearly_actual" float))
|> Decode.andThen (\f -> Decode.map f (field "weekly_target" float))
|> Decode.andThen (\f -> Decode.map f (field "remaining_yearly" float))
schoolYearDecoder : Decoder SchoolYear
schoolYearDecoder =
Decode.map5 SchoolYear
(field "id" int)
(field "name" string)
(field "start_date" string)
(field "end_date" string)
(field "is_active" bool)
apiErrorDecoder : Decoder ApiError
apiErrorDecoder =
Decode.map2 ApiError
(field "code" string)
(field "message" string)

View file

@ -0,0 +1,120 @@
module Api.Schedule exposing
( createSchedule
, deleteSchedule
, fetchSchedules
, saveTimeEntriesForWeek
)
import Api.Decoders exposing (scheduleDecoder)
import Http
import Json.Decode
import Json.Encode as Encode
import Types.Model exposing (NewSchedule, Schedule, SelectedEntry, WeekDates)
import Types.Msg exposing (Msg(..))
fetchSchedules : Maybe String -> Cmd Msg
fetchSchedules maybeToken =
case maybeToken of
Just token ->
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/schedules"
, body = Http.emptyBody
, expect = Http.expectJson SchedulesReceived (Json.Decode.list scheduleDecoder)
, timeout = Nothing
, tracker = Nothing
}
Nothing ->
Cmd.none
createSchedule : String -> NewSchedule -> Cmd Msg
createSchedule token schedule =
case String.toInt schedule.dayOfWeek of
Just day ->
Http.request
{ method = "POST"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/schedules"
, body =
Http.jsonBody <|
Encode.object
[ ( "day_of_week", Encode.int day )
, ( "start_time", Encode.string schedule.startTime )
, ( "end_time", Encode.string schedule.endTime )
, ( "type", Encode.string schedule.scheduleType )
, ( "title", Encode.string schedule.title )
]
, expect = Http.expectWhatever ScheduleCreated
, timeout = Nothing
, tracker = Nothing
}
Nothing ->
Cmd.none
deleteSchedule : String -> Int -> Cmd Msg
deleteSchedule token scheduleId =
Http.request
{ method = "DELETE"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/schedules/delete?id=" ++ String.fromInt scheduleId
, body = Http.emptyBody
, expect = Http.expectWhatever ScheduleDeleted
, timeout = Nothing
, tracker = Nothing
}
saveTimeEntriesForWeek : String -> List SelectedEntry -> Int -> Int -> List Schedule -> Maybe WeekDates -> Cmd Msg
saveTimeEntriesForWeek token selectedEntries year week schedules maybeWeekDates =
case maybeWeekDates of
Nothing ->
Cmd.none
Just weekDates ->
let
getScheduleById id =
List.filter (\s -> s.id == id) schedules |> List.head
getDateForDay dayOfWeek =
weekDates.dates
|> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek)
|> List.head
|> Maybe.map Tuple.second
createEntryData entry =
case ( getScheduleById entry.scheduleId, getDateForDay entry.dayOfWeek ) of
( Just schedule, Just dateStr ) ->
Just <|
Encode.object
[ ( "schedule_id", Encode.int entry.scheduleId )
, ( "date", Encode.string dateStr )
, ( "type", Encode.string schedule.scheduleType )
, ( "start_time", Encode.string schedule.startTime )
, ( "end_time", Encode.string schedule.endTime )
]
_ ->
Nothing
entriesData =
List.filterMap createEntryData selectedEntries
in
if List.isEmpty entriesData then
Cmd.none
else
Http.request
{ method = "POST"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/time-entries/batch"
, body = Http.jsonBody <| Encode.object [ ( "entries", Encode.list identity entriesData ) ]
, expect = Http.expectWhatever TimeEntriesSaved
, timeout = Nothing
, tracker = Nothing
}

View file

@ -0,0 +1,85 @@
module Api.SchoolYear exposing
( activateSchoolYear
, createSchoolYear
, deleteSchoolYear
, fetchActiveSchoolYear
, fetchSchoolYears
)
import Api.Decoders exposing (schoolYearDecoder)
import Http
import Json.Decode as Decode
import Json.Encode as Encode
import Types.Model exposing (NewSchoolYear)
import Types.Msg exposing (Msg(..))
fetchSchoolYears : String -> Cmd Msg
fetchSchoolYears token =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/school-years"
, body = Http.emptyBody
, expect = Http.expectJson SchoolYearsReceived (Decode.list schoolYearDecoder)
, timeout = Nothing
, tracker = Nothing
}
fetchActiveSchoolYear : String -> Cmd Msg
fetchActiveSchoolYear token =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/school-year/active"
, body = Http.emptyBody
, expect = Http.expectJson ActiveSchoolYearReceived schoolYearDecoder
, timeout = Nothing
, tracker = Nothing
}
createSchoolYear : String -> NewSchoolYear -> Cmd Msg
createSchoolYear token schoolYear =
Http.request
{ method = "POST"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/school-years"
, body =
Http.jsonBody <|
Encode.object
[ ( "name", Encode.string schoolYear.name )
, ( "start_date", Encode.string schoolYear.startDate )
, ( "end_date", Encode.string schoolYear.endDate )
]
, expect = Http.expectWhatever SchoolYearCreated
, timeout = Nothing
, tracker = Nothing
}
activateSchoolYear : String -> Int -> Cmd Msg
activateSchoolYear token id =
Http.request
{ method = "PUT"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/school-years/" ++ String.fromInt id ++ "/activate"
, body = Http.emptyBody
, expect = Http.expectWhatever SchoolYearActivated
, timeout = Nothing
, tracker = Nothing
}
deleteSchoolYear : String -> Int -> Cmd Msg
deleteSchoolYear token id =
Http.request
{ method = "DELETE"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/school-years/" ++ String.fromInt id
, body = Http.emptyBody
, expect = Http.expectWhatever SchoolYearDeleted
, timeout = Nothing
, tracker = Nothing
}

View file

@ -0,0 +1,201 @@
module Api.TimeEntry exposing
( checkWeekHasEntries
, createAdminTimeEntry
, deleteTimeEntry
, deleteWeekEntries
, downloadYearlySummaryPDF
, fetchAllTimeEntries
, fetchMyTimeEntries
, fetchWeekDates
, fetchWeeklyHours
, fetchYearlyHoursSummary
, updateTimeEntry
)
import Api.Decoders exposing (timeEntryDecoder, weekDatesDecoder, yearlyHoursSummaryDecoder)
import Bytes exposing (Bytes)
import Http
import Json.Decode as Decode exposing (bool, field)
import Json.Encode as Encode
import Types.Model exposing (AdminManualEntry, EditingTimeEntry)
import Types.Msg exposing (Msg(..))
fetchMyTimeEntries : String -> Cmd Msg
fetchMyTimeEntries token =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/my-time-entries"
, body = Http.emptyBody
, expect = Http.expectJson MyTimeEntriesReceived (Decode.list timeEntryDecoder)
, timeout = Nothing
, tracker = Nothing
}
fetchAllTimeEntries : String -> Cmd Msg
fetchAllTimeEntries token =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/time-entries"
, body = Http.emptyBody
, expect = Http.expectJson AllTimeEntriesReceived (Decode.list timeEntryDecoder)
, timeout = Nothing
, tracker = Nothing
}
fetchWeekDates : String -> Int -> Int -> Cmd Msg
fetchWeekDates token year week =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/week-dates?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week
, body = Http.emptyBody
, expect = Http.expectJson WeekDatesReceived weekDatesDecoder
, timeout = Nothing
, tracker = Nothing
}
checkWeekHasEntries : String -> Int -> Int -> Cmd Msg
checkWeekHasEntries token year week =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/week-has-entries?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week
, body = Http.emptyBody
, expect = Http.expectJson WeekHasEntriesReceived (field "has_entries" bool)
, timeout = Nothing
, tracker = Nothing
}
deleteWeekEntries : String -> Int -> Int -> Cmd Msg
deleteWeekEntries token year week =
Http.request
{ method = "DELETE"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/my-time-entries/week?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week
, body = Http.emptyBody
, expect = Http.expectWhatever WeekEntriesDeleted
, timeout = Nothing
, tracker = Nothing
}
updateTimeEntry : String -> EditingTimeEntry -> Cmd Msg
updateTimeEntry token entry =
Http.request
{ method = "PUT"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/time-entries/" ++ String.fromInt entry.entryId
, body =
Http.jsonBody <|
Encode.object
[ ( "date", Encode.string entry.date )
, ( "start_time", Encode.string entry.startTime )
, ( "end_time", Encode.string entry.endTime )
, ( "type", Encode.string entry.entryType )
]
, expect = Http.expectWhatever TimeEntrySaved
, timeout = Nothing
, tracker = Nothing
}
deleteTimeEntry : String -> Int -> Cmd Msg
deleteTimeEntry token entryId =
Http.request
{ method = "DELETE"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/time-entries/" ++ String.fromInt entryId
, body = Http.emptyBody
, expect = Http.expectWhatever TimeEntryDeleted
, timeout = Nothing
, tracker = Nothing
}
createAdminTimeEntry : String -> AdminManualEntry -> Cmd Msg
createAdminTimeEntry token entry =
case entry.selectedUserId of
Just userId ->
Http.request
{ method = "POST"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/time-entry"
, body =
Http.jsonBody <|
Encode.object
[ ( "user_id", Encode.int userId )
, ( "date", Encode.string entry.date )
, ( "hours", Encode.float (String.toFloat entry.hours |> Maybe.withDefault 0) )
, ( "type", Encode.string "manual" )
]
, expect = Http.expectWhatever AdminTimeEntrySaved
, timeout = Nothing
, tracker = Nothing
}
Nothing ->
Cmd.none
fetchYearlyHoursSummary : String -> Cmd Msg
fetchYearlyHoursSummary token =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/yearly-hours-summary"
, body = Http.emptyBody
, expect = Http.expectJson YearlyHoursSummaryReceived (Decode.list yearlyHoursSummaryDecoder)
, timeout = Nothing
, tracker = Nothing
}
downloadYearlySummaryPDF : String -> Cmd Msg
downloadYearlySummaryPDF token =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/yearly-summary/pdf"
, body = Http.emptyBody
, expect =
Http.expectBytesResponse YearlySummaryPDFReceived
(\response ->
case response of
Http.GoodStatus_ _ body ->
Ok body
Http.BadUrl_ url ->
Err (Http.BadUrl url)
Http.Timeout_ ->
Err Http.Timeout
Http.NetworkError_ ->
Err Http.NetworkError
Http.BadStatus_ metadata _ ->
Err (Http.BadStatus metadata.statusCode)
)
, timeout = Nothing
, tracker = Nothing
}
fetchWeeklyHours : String -> Cmd Msg
fetchWeeklyHours token =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/weekly-hours"
, body = Http.emptyBody
, expect = Http.expectJson WeeklyHoursReceived (Decode.list Api.Decoders.weeklyHoursDecoder)
, timeout = Nothing
, tracker = Nothing
}

110
frontend/src/Api/User.elm Normal file
View file

@ -0,0 +1,110 @@
module Api.User exposing
( createUser
, deleteUser
, fetchMyInfo
, fetchUsers
, resetUserPassword
, updateUserWorkHours
)
import Api.Decoders exposing (userDecoder)
import Http
import Json.Decode as Decode
import Json.Encode as Encode
import Types.Model exposing (NewUser)
import Types.Msg exposing (Msg(..))
fetchUsers : String -> Cmd Msg
fetchUsers token =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/users/list"
, body = Http.emptyBody
, expect = Http.expectJson UsersReceived (Decode.list userDecoder)
, timeout = Nothing
, tracker = Nothing
}
fetchMyInfo : String -> Cmd Msg
fetchMyInfo token =
Http.request
{ method = "GET"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/my-info"
, body = Http.emptyBody
, expect = Http.expectJson MyInfoReceived userDecoder
, timeout = Nothing
, tracker = Nothing
}
createUser : String -> NewUser -> Cmd Msg
createUser token user =
Http.request
{ method = "POST"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/users"
, body =
Http.jsonBody <|
Encode.object
[ ( "username", Encode.string user.username )
, ( "password", Encode.string user.password )
, ( "is_admin", Encode.bool user.isAdmin )
]
, expect = Http.expectWhatever UserCreated
, timeout = Nothing
, tracker = Nothing
}
deleteUser : String -> Int -> Cmd Msg
deleteUser token userId =
Http.request
{ method = "DELETE"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/users/delete?id=" ++ String.fromInt userId
, body = Http.emptyBody
, expect = Http.expectWhatever UserDeleted
, timeout = Nothing
, tracker = Nothing
}
updateUserWorkHours : String -> Int -> String -> Cmd Msg
updateUserWorkHours token userId hours =
case String.toFloat hours of
Just workHours ->
Http.request
{ method = "PUT"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/users/" ++ String.fromInt userId
, body =
Http.jsonBody <|
Encode.object
[ ( "yearly_hours", Encode.float workHours ) ]
, expect = Http.expectWhatever UserWorkHoursSaved
, timeout = Nothing
, tracker = Nothing
}
Nothing ->
Cmd.none
resetUserPassword : String -> Int -> String -> Cmd Msg
resetUserPassword token userId newPassword =
Http.request
{ method = "PUT"
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
, url = "/api/admin/users/" ++ String.fromInt userId ++ "/reset-password"
, body =
Http.jsonBody <|
Encode.object
[ ( "new_password", Encode.string newPassword ) ]
, expect = Http.expectWhatever ResetPasswordSaved
, timeout = Nothing
, tracker = Nothing
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,17 @@
module Types.Api exposing
( ApiError
, LoginResult
)
type alias LoginResult =
{ token : String
, username : String
, isAdmin : Bool
}
type alias ApiError =
{ code : String
, message : String
}

View file

@ -0,0 +1,218 @@
module Types.Model exposing
( AdminManualEntry
, EditingTimeEntry
, Flags
, Model
, NewSchedule
, NewSchoolYear
, NewUser
, Schedule
, SchoolYear
, SelectedEntry
, TimeEntry
, Toast
, ToastType(..)
, User
, WeekDates
, WeeklyHours
, WeeklySummary
, YearlyHoursSummary
)
import Time
import Types.Page exposing (AdminTab, Page)
type alias Model =
{ page : Page
, activeTab : AdminTab
, username : String
, password : String
, token : Maybe String
, isAdmin : Bool
, schedules : List Schedule
, users : List User
, timeEntries : List TimeEntry
, weeklyHours : List WeeklyHours
, yearlyHoursSummary : List YearlyHoursSummary
, selectedEntries : List SelectedEntry
, currentWeek : Int
, currentYear : Int
, weekDates : Maybe WeekDates
, currentTime : Time.Posix
, zone : Time.Zone
, newSchedule : NewSchedule
, newUser : NewUser
, error : Maybe String
, weekEditMode : Bool
, hasEntriesForCurrentWeek : Bool
, userWeeklySummary : Maybe WeeklySummary
, editingTimeEntryId : Maybe Int
, editingTimeEntry : EditingTimeEntry
, editingUserId : Maybe Int
, editingUserWorkHours : String
, resetPasswordUserId : Maybe Int
, resetPasswordNew : String
, pendingDeleteId : Maybe Int
, selectedUserId : Maybe Int
, userWorkHoursInput : String
, userPasswordInput : String
, isProcessing : Bool
, mobileMenuOpen : Bool
, adminManualEntryForm : AdminManualEntry
, schoolYears : List SchoolYear
, newSchoolYear : NewSchoolYear
, activeSchoolYear : Maybe SchoolYear
, editingSchoolYearId : Maybe Int
, toasts : List Toast
, nextToastId : Int
}
type ToastType
= ErrorToast
| SuccessToast
| InfoToast
| WarningToast
type alias Toast =
{ id : Int
, message : String
, toastType : ToastType
, dismissible : Bool
}
type alias Flags =
{ token : Maybe String
, isAdmin : Bool
}
type alias Schedule =
{ id : Int
, dayOfWeek : Int
, startTime : String
, endTime : String
, scheduleType : String
, title : String
}
type alias User =
{ id : Int
, username : String
, isAdmin : Bool
, yearlyWorkHours : Float
}
type alias TimeEntry =
{ id : Int
, userId : Int
, scheduleId : Int
, date : String
, entryType : String
, username : String
, startTime : String
, endTime : String
}
type alias SelectedEntry =
{ scheduleId : Int
, dayOfWeek : Int
}
type alias NewSchedule =
{ dayOfWeek : String
, startTime : String
, endTime : String
, scheduleType : String
, title : String
}
type alias NewUser =
{ username : String
, password : String
, isAdmin : Bool
}
type alias WeekDates =
{ year : Int
, week : Int
, dates : List ( String, String )
, range : String
}
type alias WeeklySummary =
{ userId : Int
, username : String
, year : Int
, week : Int
, totalHours : Float
, targetHours : Float
, remainingHours : Float
}
type alias EditingTimeEntry =
{ entryId : Int
, date : String
, startTime : String
, endTime : String
, entryType : String
}
type alias WeeklyHours =
{ userId : Int
, username : String
, year : Int
, week : Int
, totalHours : Float
, targetHours : Float
, remainingHours : Float
}
type alias YearlyHoursSummary =
{ userId : Int
, username : String
, year : Int
, week : Int
, totalHours : Float
, yearlyTarget : Float
, yearlyActual : Float
, weeklyTarget : Float
, remainingYearly : Float
}
type alias AdminManualEntry =
{ selectedUserId : Maybe Int
, date : String
, hours : String
, entryType : String
}
type alias SchoolYear =
{ id : Int
, name : String
, startDate : String
, endDate : String
, isActive : Bool
}
type alias NewSchoolYear =
{ name : String
, startDate : String
, endDate : String
}

133
frontend/src/Types/Msg.elm Normal file
View file

@ -0,0 +1,133 @@
module Types.Msg exposing (Msg(..))
import Bytes exposing (Bytes)
import Http
import Time
import Types.Api exposing (LoginResult)
import Types.Model
exposing
( Schedule
, SchoolYear
, TimeEntry
, ToastType(..)
, User
, WeekDates
, WeeklyHours
, WeeklySummary
, YearlyHoursSummary
)
import Types.Page exposing (AdminTab)
type Msg
= UpdateUsername String
| UpdatePassword String
| Login
| LoginResponse (Result Http.Error LoginResult)
| Logout
| SetTime Time.Posix
| FetchSchedules
| SchedulesReceived (Result Http.Error (List Schedule))
| ToggleScheduleSelection Int Int
| SaveTimeEntries
| TimeEntriesSaved (Result Http.Error ())
| PreviousWeek
| NextWeek
| EnableEditMode
| DisableEditMode
| DeleteWeekEntries
| WeekEntriesDeleted (Result Http.Error ())
| SwitchTab AdminTab
| UpdateNewScheduleDay String
| UpdateNewScheduleStart String
| UpdateNewScheduleEnd String
| UpdateNewScheduleType String
| UpdateNewScheduleTitle String
| CreateSchedule
| ScheduleCreated (Result Http.Error ())
| DeleteSchedule Int
| ScheduleDeleted (Result Http.Error ())
| UpdateNewUsername String
| UpdateNewPassword String
| UpdateNewUserAdmin Bool
| CreateUser
| UserCreated (Result Http.Error ())
| DeleteUser Int
| UserDeleted (Result Http.Error ())
| FetchUsers
| UsersReceived (Result Http.Error (List User))
| FetchMyTimeEntries
| MyTimeEntriesReceived (Result Http.Error (List TimeEntry))
| FetchAllTimeEntries
| AllTimeEntriesReceived (Result Http.Error (List TimeEntry))
| FetchWeeklyHours
| WeeklyHoursReceived (Result Http.Error (List WeeklyHours))
| FetchYearlyHoursSummary
| YearlyHoursSummaryReceived (Result Http.Error (List YearlyHoursSummary))
| FetchWeekDates
| WeekDatesReceived (Result Http.Error WeekDates)
| CheckWeekHasEntries
| WeekHasEntriesReceived (Result Http.Error Bool)
| MyWeeklySummaryReceived (Result Http.Error WeeklySummary)
| EditTimeEntry Int
| CancelEditTimeEntry
| UpdateEditTimeEntryDate String
| UpdateEditTimeEntryStartTime String
| UpdateEditTimeEntryEndTime String
| UpdateEditTimeEntryType String
| SaveEditTimeEntry
| TimeEntrySaved (Result Http.Error ())
| TimeEntryDeleted (Result Http.Error ())
| EditUserWorkHours Int
| CancelEditUserWorkHours
| UpdateEditUserWorkHours String
| SaveUserWorkHours
| UserWorkHoursSaved (Result Http.Error ())
| ResetUserPassword Int
| CancelResetPassword
| UpdateResetPasswordNew String
| SaveResetPassword
| ResetPasswordSaved (Result Http.Error ())
| ConfirmDeleteTimeEntry Int
| ConfirmDeleteUser Int
| DeleteConfirmed Bool
| StartEditingTimeEntry Int TimeEntry
| CancelEditingTimeEntry
| UpdateEditingTimeEntryDate String
| UpdateEditingTimeEntryStartTime String
| UpdateEditingTimeEntryEndTime String
| UpdateEditingTimeEntryType String
| SaveEditingTimeEntry
| SelectUserForManagement Int
| UpdateUserWorkHours String
| UpdateUserPassword String
| SaveUserPassword
| UserPasswordSaved (Result Http.Error ())
| ToggleMobileMenu
| CloseMobileMenu
| SelectUserForManualEntry Int
| UpdateManualEntryDate String
| UpdateManualEntryHours String
| UpdateManualEntryType String
| SaveAdminTimeEntry
| AdminTimeEntrySaved (Result Http.Error ())
| FetchMyInfo
| MyInfoReceived (Result Http.Error User)
| FetchSchoolYears
| SchoolYearsReceived (Result Http.Error (List SchoolYear))
| FetchActiveSchoolYear
| ActiveSchoolYearReceived (Result Http.Error SchoolYear)
| UpdateNewSchoolYearName String
| UpdateNewSchoolYearStart String
| UpdateNewSchoolYearEnd String
| CreateSchoolYear
| SchoolYearCreated (Result Http.Error ())
| ActivateSchoolYear Int
| SchoolYearActivated (Result Http.Error ())
| DeleteSchoolYear Int
| SchoolYearDeleted (Result Http.Error ())
| DownloadYearlySummaryPDF
| YearlySummaryPDFReceived (Result Http.Error Bytes)
| ShowToast String ToastType
| DismissToast Int
| AutoDismissToast Int

View file

@ -0,0 +1,17 @@
module Types.Page exposing
( AdminTab(..)
, Page(..)
)
type Page
= LoginPage
| UserDashboard
| AdminDashboard
type AdminTab
= ScheduleTab
| UsersTab
| TimeEntriesTab
| SchoolYearsTab

View file

@ -0,0 +1,115 @@
module Update.AuthUpdate exposing
( handleLogin
, handleLoginResponse
, handleLogout
)
import Api.Auth
import Api.Schedule
import Api.SchoolYear
import Api.TimeEntry
import Api.User
import Http
import Json.Encode as Encode
import Task
import Types.Model exposing (Model, ToastType(..))
import Types.Msg exposing (Msg(..))
import Types.Page exposing (Page(..))
import Utils.DateUtils exposing (getISOWeekFromPosix)
import Utils.Ports exposing (removeToken, saveToken)
handleLogin : Model -> ( Model, Cmd Msg )
handleLogin model =
if model.isProcessing then
( model, Cmd.none )
else
( { model | isProcessing = True }, Api.Auth.loginRequest model.username model.password )
handleLoginResponse : Result Http.Error { token : String, username : String, isAdmin : Bool } -> Model -> ( Model, Cmd Msg )
handleLoginResponse result model =
case result of
Ok loginResult ->
let
newPage =
if loginResult.isAdmin then
AdminDashboard
else
UserDashboard
( year, week ) =
getISOWeekFromPosix model.currentTime
tokenData =
Encode.object
[ ( "token", Encode.string loginResult.token )
, ( "isAdmin", Encode.bool loginResult.isAdmin )
]
in
( { model
| token = Just loginResult.token
, username = loginResult.username
, isAdmin = loginResult.isAdmin
, page = newPage
, error = Nothing
, isProcessing = False
}
, Cmd.batch
[ saveToken tokenData
, Api.Schedule.fetchSchedules (Just loginResult.token)
, Task.perform (\_ -> ShowToast ("Willkommen, " ++ loginResult.username ++ "!") SuccessToast) (Task.succeed ())
, if not loginResult.isAdmin then
Cmd.batch
[ Api.TimeEntry.fetchMyTimeEntries loginResult.token
, Api.TimeEntry.fetchWeekDates loginResult.token year week
, Api.TimeEntry.checkWeekHasEntries loginResult.token year week
, Api.TimeEntry.fetchYearlyHoursSummary loginResult.token
, Api.User.fetchMyInfo loginResult.token
]
else
Cmd.batch
[ Api.TimeEntry.fetchMyTimeEntries loginResult.token
, Api.TimeEntry.fetchWeekDates loginResult.token year week
, Api.TimeEntry.checkWeekHasEntries loginResult.token year week
, Api.TimeEntry.fetchYearlyHoursSummary loginResult.token
]
]
)
Err err ->
let
errorMsg =
case err of
Http.BadStatus 401 ->
"Benutzername oder Passwort ungültig"
Http.Timeout ->
"Zeitüberschreitung - bitte erneut versuchen"
Http.NetworkError ->
"Netzwerkfehler - bitte Verbindung prüfen"
_ ->
"Anmeldung fehlgeschlagen"
in
( { model | isProcessing = False }
, Task.perform (\_ -> ShowToast errorMsg ErrorToast) (Task.succeed ())
)
handleLogout : Model -> ( Model, Cmd Msg )
handleLogout model =
( { model
| page = LoginPage
, token = Nothing
, isAdmin = False
, username = ""
, password = ""
, isProcessing = False
}
, removeToken ()
)

View file

@ -0,0 +1,244 @@
module Update.ScheduleUpdate exposing
( handleCreateSchedule
, handleDeleteSchedule
, handleDeleteWeekEntries
, handleDisableEditMode
, handleEnableEditMode
, handleSaveTimeEntries
, handleScheduleCreated
, handleScheduleDeleted
, handleSchedulesReceived
, handleTimeEntriesSaved
, handleToggleScheduleSelection
, handleWeekEntriesDeleted
)
import Api.Schedule
import Api.TimeEntry
import Http
import Task
import Types.Model exposing (Model, NewSchedule, Schedule, SelectedEntry, ToastType(..))
import Types.Msg exposing (Msg(..))
import Utils.DateUtils exposing (getDayOfWeek, getYearWeekFromDate)
handleToggleScheduleSelection : Int -> Int -> Model -> ( Model, Cmd Msg )
handleToggleScheduleSelection scheduleId dayOfWeek model =
let
entry =
{ scheduleId = scheduleId, dayOfWeek = dayOfWeek }
newSelected =
if List.any (\e -> e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek) model.selectedEntries then
List.filter (\e -> not (e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek)) model.selectedEntries
else
entry :: model.selectedEntries
in
( { model | selectedEntries = newSelected }, Cmd.none )
handleSaveTimeEntries : Model -> ( Model, Cmd Msg )
handleSaveTimeEntries model =
case model.token of
Just token ->
( { model | error = Nothing }
, Api.Schedule.saveTimeEntriesForWeek token model.selectedEntries model.currentYear model.currentWeek model.schedules model.weekDates
)
Nothing ->
( model, Cmd.none )
handleTimeEntriesSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
handleTimeEntriesSaved result model =
case result of
Ok _ ->
case model.token of
Just token ->
( { model
| error = Nothing
, weekEditMode = False
, hasEntriesForCurrentWeek = True
}
, Cmd.batch
[ Api.TimeEntry.fetchMyTimeEntries token
, Task.perform (\_ -> ShowToast "Zeiteinträge erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
]
)
Nothing ->
( model, Cmd.none )
Err err ->
( model, Cmd.none )
handleEnableEditMode : Model -> ( Model, Cmd Msg )
handleEnableEditMode model =
let
currentWeekEntries =
List.filter
(\e ->
let
( entryYear, entryWeek ) =
getYearWeekFromDate e.date
in
entryWeek == model.currentWeek && entryYear == model.currentYear
)
model.timeEntries
preSelectedEntries =
List.map
(\entry ->
let
parts =
String.split "-" entry.date
year =
parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025
month =
parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
day =
parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
dayOfWeek =
getDayOfWeek year month day
in
{ scheduleId = entry.scheduleId, dayOfWeek = dayOfWeek }
)
currentWeekEntries
in
( { model
| weekEditMode = True
, selectedEntries = preSelectedEntries
}
, Cmd.none
)
handleDisableEditMode : Model -> ( Model, Cmd Msg )
handleDisableEditMode model =
( { model | weekEditMode = False }, Cmd.none )
handleDeleteWeekEntries : Model -> ( Model, Cmd Msg )
handleDeleteWeekEntries model =
case model.token of
Just token ->
( model, Api.TimeEntry.deleteWeekEntries token model.currentYear model.currentWeek )
Nothing ->
( model, Cmd.none )
handleWeekEntriesDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
handleWeekEntriesDeleted result model =
case result of
Ok _ ->
case model.token of
Just token ->
( { model
| weekEditMode = True
, selectedEntries = []
, hasEntriesForCurrentWeek = False
}
, Cmd.batch
[ Api.TimeEntry.fetchMyTimeEntries token
, Task.perform (\_ -> ShowToast "Wocheneinträge erfolgreich gelöscht" SuccessToast) (Task.succeed ())
]
)
Nothing ->
( model, Cmd.none )
Err err ->
( model, Cmd.none )
handleCreateSchedule : Model -> ( Model, Cmd Msg )
handleCreateSchedule model =
if
String.isEmpty model.newSchedule.dayOfWeek
|| String.isEmpty model.newSchedule.startTime
|| String.isEmpty model.newSchedule.endTime
then
( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) )
else
case model.token of
Just token ->
( { model | isProcessing = True }, Api.Schedule.createSchedule token model.newSchedule )
Nothing ->
( model, Cmd.none )
handleScheduleCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
handleScheduleCreated result model =
case result of
Ok _ ->
case model.token of
Just token ->
let
emptySchedule =
NewSchedule "" "" "" "lesson" ""
in
( { model
| newSchedule = emptySchedule
, error = Nothing
, isProcessing = False
}
, Cmd.batch
[ Api.Schedule.fetchSchedules model.token
, Task.perform (\_ -> ShowToast "Stundenplan erfolgreich erstellt!" SuccessToast) (Task.succeed ())
]
)
Nothing ->
( model, Cmd.none )
Err err ->
( { model | isProcessing = False }, Cmd.none )
handleDeleteSchedule : Int -> Model -> ( Model, Cmd Msg )
handleDeleteSchedule scheduleId model =
case model.token of
Just token ->
( model, Api.Schedule.deleteSchedule token scheduleId )
Nothing ->
( model, Cmd.none )
handleScheduleDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
handleScheduleDeleted result model =
case result of
Ok _ ->
case model.token of
Just token ->
( { model | error = Nothing }
, Cmd.batch
[ Api.Schedule.fetchSchedules (Just token)
, Task.perform (\_ -> ShowToast "Stundenplan erfolgreich gelöscht" SuccessToast) (Task.succeed ())
]
)
Nothing ->
( model, Cmd.none )
Err err ->
( model, Cmd.none )
handleSchedulesReceived : Result Http.Error (List Schedule) -> Model -> ( Model, Cmd Msg )
handleSchedulesReceived result model =
case result of
Ok schedules ->
( { model | schedules = schedules }, Cmd.none )
Err err ->
( model, Cmd.none )

View file

@ -0,0 +1,139 @@
module Update.SchoolYearUpdate exposing
( handleActivateSchoolYear
, handleActiveSchoolYearReceived
, handleCreateSchoolYear
, handleDeleteSchoolYear
, handleSchoolYearActivated
, handleSchoolYearCreated
, handleSchoolYearDeleted
, handleSchoolYearsReceived
)
import Api.SchoolYear
import Http
import Task
import Types.Model exposing (Model, NewSchoolYear, SchoolYear, ToastType(..))
import Types.Msg exposing (Msg(..))
handleCreateSchoolYear : Model -> ( Model, Cmd Msg )
handleCreateSchoolYear model =
if
String.isEmpty model.newSchoolYear.name
|| String.isEmpty model.newSchoolYear.startDate
|| String.isEmpty model.newSchoolYear.endDate
then
( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) )
else
case model.token of
Just token ->
( { model | isProcessing = True }, Api.SchoolYear.createSchoolYear token model.newSchoolYear )
Nothing ->
( model, Cmd.none )
handleSchoolYearCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
handleSchoolYearCreated result model =
case result of
Ok _ ->
case model.token of
Just token ->
( { model
| newSchoolYear = NewSchoolYear "" "" ""
, error = Nothing
, isProcessing = False
}
, Cmd.batch
[ Api.SchoolYear.fetchSchoolYears token
, Task.perform (\_ -> ShowToast "Schuljahr erfolgreich erstellt!" SuccessToast) (Task.succeed ())
]
)
Nothing ->
( model, Cmd.none )
Err err ->
( { model | isProcessing = False }, Cmd.none )
handleActivateSchoolYear : Int -> Model -> ( Model, Cmd Msg )
handleActivateSchoolYear id model =
case model.token of
Just token ->
( model, Api.SchoolYear.activateSchoolYear token id )
Nothing ->
( model, Cmd.none )
handleSchoolYearActivated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
handleSchoolYearActivated result model =
case result of
Ok _ ->
case model.token of
Just token ->
( { model | error = Nothing }
, Cmd.batch
[ Api.SchoolYear.fetchSchoolYears token
, Api.SchoolYear.fetchActiveSchoolYear token
, Task.perform (\_ -> ShowToast "Schuljahr erfolgreich aktiviert!" SuccessToast) (Task.succeed ())
]
)
Nothing ->
( model, Cmd.none )
Err err ->
( model, Cmd.none )
handleDeleteSchoolYear : Int -> Model -> ( Model, Cmd Msg )
handleDeleteSchoolYear id model =
case model.token of
Just token ->
( model, Api.SchoolYear.deleteSchoolYear token id )
Nothing ->
( model, Cmd.none )
handleSchoolYearDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
handleSchoolYearDeleted result model =
case result of
Ok _ ->
case model.token of
Just token ->
( { model | error = Nothing }
, Cmd.batch
[ Api.SchoolYear.fetchSchoolYears token
, Task.perform (\_ -> ShowToast "Schuljahr erfolgreich gelöscht" SuccessToast) (Task.succeed ())
]
)
Nothing ->
( model, Cmd.none )
Err err ->
( model, Cmd.none )
handleSchoolYearsReceived : Result Http.Error (List SchoolYear) -> Model -> ( Model, Cmd Msg )
handleSchoolYearsReceived result model =
case result of
Ok years ->
( { model | schoolYears = years }, Cmd.none )
Err err ->
( model, Cmd.none )
handleActiveSchoolYearReceived : Result Http.Error SchoolYear -> Model -> ( Model, Cmd Msg )
handleActiveSchoolYearReceived result model =
case result of
Ok year ->
( { model | activeSchoolYear = Just year }, Cmd.none )
Err _ ->
( { model | activeSchoolYear = Nothing }, Cmd.none )

View file

@ -0,0 +1,189 @@
module Update.TimeEntryUpdate exposing
( handleAdminTimeEntrySaved
, handleAllTimeEntriesReceived
, handleConfirmDeleteTimeEntry
, handleEditTimeEntry
, handleMyTimeEntriesReceived
, handleSaveAdminTimeEntry
, handleSaveEditTimeEntry
, handleTimeEntryDeleted
, handleTimeEntrySaved
, handleYearlyHoursSummaryReceived
)
import Api.TimeEntry
import Http
import Task
import Types.Model exposing (AdminManualEntry, EditingTimeEntry, Model, TimeEntry, ToastType(..), YearlyHoursSummary)
import Types.Msg exposing (Msg(..))
import Utils.DateUtils exposing (getYearWeekFromDate)
import Utils.Ports exposing (confirmDelete)
handleMyTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg )
handleMyTimeEntriesReceived result model =
case result of
Ok entries ->
let
hasEntries =
List.any
(\e ->
let
( entryYear, entryWeek ) =
getYearWeekFromDate e.date
in
entryWeek == model.currentWeek && entryYear == model.currentYear
)
entries
in
( { model
| timeEntries = entries
, hasEntriesForCurrentWeek = hasEntries
, weekEditMode = False
}
, Cmd.none
)
Err err ->
( model, Cmd.none )
handleAllTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg )
handleAllTimeEntriesReceived result model =
case result of
Ok entries ->
( { model | timeEntries = entries }, Cmd.none )
Err err ->
( model, Cmd.none )
handleEditTimeEntry : Int -> Model -> ( Model, Cmd Msg )
handleEditTimeEntry entryId model =
case List.filter (\e -> e.id == entryId) model.timeEntries |> List.head of
Just entry ->
( { model
| editingTimeEntryId = Just entryId
, editingTimeEntry =
{ entryId = entryId
, date = entry.date
, startTime = entry.startTime
, endTime = entry.endTime
, entryType = entry.entryType
}
}
, Cmd.none
)
Nothing ->
( model, Cmd.none )
handleSaveEditTimeEntry : Model -> ( Model, Cmd Msg )
handleSaveEditTimeEntry model =
case model.token of
Just token ->
( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry )
Nothing ->
( model, Cmd.none )
handleTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
handleTimeEntrySaved result model =
case result of
Ok _ ->
case model.token of
Just token ->
( { model
| editingTimeEntryId = Nothing
, pendingDeleteId = Nothing
, error = Nothing
}
, Cmd.batch
[ Api.TimeEntry.fetchAllTimeEntries token
, Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
]
)
Nothing ->
( model, Cmd.none )
Err err ->
( model, Cmd.none )
handleTimeEntryDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
handleTimeEntryDeleted result model =
case result of
Ok _ ->
case model.token of
Just token ->
( { model
| editingTimeEntryId = Nothing
, editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson"
, pendingDeleteId = Nothing
, error = Nothing
}
, Cmd.batch
[ Api.TimeEntry.fetchAllTimeEntries token
, Api.TimeEntry.fetchYearlyHoursSummary token
, Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gelöscht" SuccessToast) (Task.succeed ())
]
)
Nothing ->
( model, Cmd.none )
Err err ->
( { model | pendingDeleteId = Nothing }, Cmd.none )
handleConfirmDeleteTimeEntry : Int -> Model -> ( Model, Cmd Msg )
handleConfirmDeleteTimeEntry entryId model =
( { model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?" )
handleSaveAdminTimeEntry : Model -> ( Model, Cmd Msg )
handleSaveAdminTimeEntry model =
case model.token of
Just token ->
( { model | isProcessing = True }, Api.TimeEntry.createAdminTimeEntry token model.adminManualEntryForm )
Nothing ->
( model, Cmd.none )
handleAdminTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
handleAdminTimeEntrySaved result model =
case result of
Ok _ ->
case model.token of
Just token ->
( { model
| adminManualEntryForm = AdminManualEntry Nothing "" "" "manual"
, error = Nothing
, isProcessing = False
}
, Cmd.batch
[ Api.TimeEntry.fetchAllTimeEntries token
, Api.TimeEntry.fetchYearlyHoursSummary token
, Task.perform (\_ -> ShowToast "Manueller Eintrag erfolgreich erstellt!" SuccessToast) (Task.succeed ())
]
)
Nothing ->
( model, Cmd.none )
Err err ->
( { model | isProcessing = False }, Cmd.none )
handleYearlyHoursSummaryReceived : Result Http.Error (List YearlyHoursSummary) -> Model -> ( Model, Cmd Msg )
handleYearlyHoursSummaryReceived result model =
case result of
Ok summary ->
( { model | yearlyHoursSummary = summary }, Cmd.none )
Err err ->
( model, Cmd.none )

View file

@ -0,0 +1,811 @@
module Update.Update exposing (update)
import Api.Schedule
import Api.SchoolYear
import Api.TimeEntry
import Api.User
import File.Download
import Process
import Task
import Time
import Types.Model exposing (EditingTimeEntry, Model, NewUser, ToastType(..))
import Types.Msg exposing (Msg(..))
import Types.Page exposing (AdminTab(..), Page(..))
import Update.AuthUpdate as Auth
import Update.ScheduleUpdate as Schedule
import Update.SchoolYearUpdate as SchoolYear
import Update.TimeEntryUpdate as TimeEntry
import Update.UserUpdate as User
import Utils.DateUtils exposing (getISOWeekFromPosix, nextWeek, previousWeek)
import Utils.Ports
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
-- Mobile Menu
ToggleMobileMenu ->
( { model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none )
CloseMobileMenu ->
( { model | mobileMenuOpen = False }, Cmd.none )
-- Auth
UpdateUsername username ->
( { model | username = username }, Cmd.none )
UpdatePassword password ->
( { model | password = password }, Cmd.none )
Login ->
Auth.handleLogin model
LoginResponse result ->
Auth.handleLoginResponse result model
Logout ->
Auth.handleLogout model
-- Time
SetTime time ->
let
( year, week ) =
getISOWeekFromPosix time
cmds =
case model.token of
Just token ->
if model.page == UserDashboard || model.page == LoginPage then
Cmd.batch
[ Api.TimeEntry.checkWeekHasEntries token year week
, Api.TimeEntry.fetchWeekDates token year week
, Api.TimeEntry.fetchMyTimeEntries token
]
else
Cmd.none
Nothing ->
Cmd.none
in
( { model
| currentTime = time
, currentWeek = week
, currentYear = year
}
, cmds
)
-- Schedules
FetchSchedules ->
( model, Api.Schedule.fetchSchedules model.token )
SchedulesReceived result ->
Schedule.handleSchedulesReceived result model
ToggleScheduleSelection scheduleId dayOfWeek ->
Schedule.handleToggleScheduleSelection scheduleId dayOfWeek model
SaveTimeEntries ->
Schedule.handleSaveTimeEntries model
TimeEntriesSaved result ->
Schedule.handleTimeEntriesSaved result model
EnableEditMode ->
Schedule.handleEnableEditMode model
DisableEditMode ->
Schedule.handleDisableEditMode model
DeleteWeekEntries ->
Schedule.handleDeleteWeekEntries model
WeekEntriesDeleted result ->
Schedule.handleWeekEntriesDeleted result model
CreateSchedule ->
Schedule.handleCreateSchedule model
ScheduleCreated result ->
Schedule.handleScheduleCreated result model
DeleteSchedule scheduleId ->
Schedule.handleDeleteSchedule scheduleId model
ScheduleDeleted result ->
Schedule.handleScheduleDeleted result model
-- Week Navigation
PreviousWeek ->
let
( newYear, newWeek ) =
previousWeek model.currentYear model.currentWeek
in
( { model
| currentWeek = newWeek
, currentYear = newYear
, selectedEntries = []
, weekEditMode = False
}
, case model.token of
Just token ->
Cmd.batch
[ Api.TimeEntry.fetchWeekDates token newYear newWeek
, Api.TimeEntry.checkWeekHasEntries token newYear newWeek
]
Nothing ->
Cmd.none
)
NextWeek ->
let
( newYear, newWeek ) =
nextWeek model.currentYear model.currentWeek
in
( { model
| currentWeek = newWeek
, currentYear = newYear
, selectedEntries = []
, weekEditMode = False
}
, case model.token of
Just token ->
Cmd.batch
[ Api.TimeEntry.fetchWeekDates token newYear newWeek
, Api.TimeEntry.checkWeekHasEntries token newYear newWeek
]
Nothing ->
Cmd.none
)
FetchWeekDates ->
case model.token of
Just token ->
( model, Api.TimeEntry.fetchWeekDates token model.currentYear model.currentWeek )
Nothing ->
( model, Cmd.none )
WeekDatesReceived result ->
case result of
Ok weekDates ->
( { model | weekDates = Just weekDates }, Cmd.none )
Err err ->
( model, Cmd.none )
CheckWeekHasEntries ->
case model.token of
Just token ->
( model, Api.TimeEntry.checkWeekHasEntries token model.currentYear model.currentWeek )
Nothing ->
( model, Cmd.none )
WeekHasEntriesReceived result ->
case result of
Ok hasEntries ->
( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none )
Err err ->
( model, Cmd.none )
-- Admin Tabs
SwitchTab tab ->
let
cmd =
case tab of
UsersTab ->
case model.token of
Just token ->
Api.User.fetchUsers token
Nothing ->
Cmd.none
TimeEntriesTab ->
case model.token of
Just token ->
Cmd.batch
[ Api.TimeEntry.fetchAllTimeEntries token
, Api.TimeEntry.fetchYearlyHoursSummary token
]
Nothing ->
Cmd.none
SchoolYearsTab ->
case model.token of
Just token ->
Cmd.batch
[ Api.SchoolYear.fetchSchoolYears token
, Api.SchoolYear.fetchActiveSchoolYear token
]
Nothing ->
Cmd.none
_ ->
Cmd.none
in
( { model | activeTab = tab, mobileMenuOpen = False }, cmd )
-- Schedule Form
UpdateNewScheduleDay day ->
let
oldSchedule =
model.newSchedule
newSchedule =
{ oldSchedule | dayOfWeek = day }
in
( { model | newSchedule = newSchedule }, Cmd.none )
UpdateNewScheduleStart time ->
let
oldSchedule =
model.newSchedule
newSchedule =
{ oldSchedule | startTime = time }
in
( { model | newSchedule = newSchedule }, Cmd.none )
UpdateNewScheduleEnd time ->
let
oldSchedule =
model.newSchedule
newSchedule =
{ oldSchedule | endTime = time }
in
( { model | newSchedule = newSchedule }, Cmd.none )
UpdateNewScheduleType scheduleType ->
let
oldSchedule =
model.newSchedule
newSchedule =
{ oldSchedule | scheduleType = scheduleType }
in
( { model | newSchedule = newSchedule }, Cmd.none )
UpdateNewScheduleTitle title ->
let
oldSchedule =
model.newSchedule
newSchedule =
{ oldSchedule | title = title }
in
( { model | newSchedule = newSchedule }, Cmd.none )
-- Users
UpdateNewUsername username ->
let
oldUser =
model.newUser
newUser =
{ oldUser | username = username }
in
( { model | newUser = newUser }, Cmd.none )
UpdateNewPassword password ->
let
oldUser =
model.newUser
newUser =
{ oldUser | password = password }
in
( { model | newUser = newUser }, Cmd.none )
UpdateNewUserAdmin isAdmin ->
let
oldUser =
model.newUser
newUser =
{ oldUser | isAdmin = isAdmin }
in
( { model | newUser = newUser }, Cmd.none )
CreateUser ->
User.handleCreateUser model
UserCreated result ->
User.handleUserCreated result model
DeleteUser userId ->
User.handleDeleteUser userId model
UserDeleted result ->
User.handleUserDeleted result model
FetchUsers ->
case model.token of
Just token ->
( model, Api.User.fetchUsers token )
Nothing ->
( model, Cmd.none )
UsersReceived result ->
User.handleUsersReceived result model
EditUserWorkHours userId ->
User.handleEditUserWorkHours userId model
CancelEditUserWorkHours ->
( { model
| editingUserId = Nothing
, editingUserWorkHours = ""
}
, Cmd.none
)
UpdateEditUserWorkHours hours ->
( { model | editingUserWorkHours = hours }, Cmd.none )
SaveUserWorkHours ->
User.handleSaveUserWorkHours model
UserWorkHoursSaved result ->
User.handleUserWorkHoursSaved result model
ResetUserPassword userId ->
User.handleResetUserPassword userId model
CancelResetPassword ->
( { model
| resetPasswordUserId = Nothing
, resetPasswordNew = ""
}
, Cmd.none
)
UpdateResetPasswordNew password ->
( { model | resetPasswordNew = password }, Cmd.none )
SaveResetPassword ->
User.handleSaveResetPassword model
ResetPasswordSaved result ->
User.handleResetPasswordSaved result model
UpdateUserWorkHours input ->
( { model | userWorkHoursInput = input }, Cmd.none )
UpdateUserPassword input ->
( { model | userPasswordInput = input }, Cmd.none )
SaveUserPassword ->
case ( model.token, model.selectedUserId ) of
( Just token, Just userId ) ->
if String.length model.userPasswordInput > 0 then
( model, Api.User.resetUserPassword token userId model.userPasswordInput )
else
( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) )
_ ->
( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) )
UserPasswordSaved result ->
case result of
Ok _ ->
( { model
| userPasswordInput = ""
, selectedUserId = Nothing
, error = Nothing
}
, Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt!" SuccessToast) (Task.succeed ())
)
Err err ->
( model, Cmd.none )
SelectUserForManagement userId ->
( { model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none )
-- Time Entries
FetchMyTimeEntries ->
case model.token of
Just token ->
( model, Api.TimeEntry.fetchMyTimeEntries token )
Nothing ->
( model, Cmd.none )
MyTimeEntriesReceived result ->
TimeEntry.handleMyTimeEntriesReceived result model
FetchAllTimeEntries ->
case model.token of
Just token ->
( model, Api.TimeEntry.fetchAllTimeEntries token )
Nothing ->
( model, Cmd.none )
AllTimeEntriesReceived result ->
TimeEntry.handleAllTimeEntriesReceived result model
EditTimeEntry entryId ->
TimeEntry.handleEditTimeEntry entryId model
CancelEditTimeEntry ->
( { model
| editingTimeEntryId = Nothing
, editingTimeEntry = EditingTimeEntry 0 "" "" "" ""
}
, Cmd.none
)
UpdateEditTimeEntryDate date ->
let
old =
model.editingTimeEntry
new =
{ old | date = date }
in
( { model | editingTimeEntry = new }, Cmd.none )
UpdateEditTimeEntryStartTime time ->
let
old =
model.editingTimeEntry
new =
{ old | startTime = time }
in
( { model | editingTimeEntry = new }, Cmd.none )
UpdateEditTimeEntryEndTime time ->
let
old =
model.editingTimeEntry
new =
{ old | endTime = time }
in
( { model | editingTimeEntry = new }, Cmd.none )
UpdateEditTimeEntryType entryType ->
let
old =
model.editingTimeEntry
new =
{ old | entryType = entryType }
in
( { model | editingTimeEntry = new }, Cmd.none )
SaveEditTimeEntry ->
TimeEntry.handleSaveEditTimeEntry model
TimeEntrySaved result ->
TimeEntry.handleTimeEntrySaved result model
TimeEntryDeleted result ->
TimeEntry.handleTimeEntryDeleted result model
ConfirmDeleteTimeEntry entryId ->
TimeEntry.handleConfirmDeleteTimeEntry entryId model
StartEditingTimeEntry entryId entry ->
( { model
| editingTimeEntryId = Just entryId
, editingTimeEntry = EditingTimeEntry entryId entry.date entry.startTime entry.endTime entry.entryType
}
, Cmd.none
)
CancelEditingTimeEntry ->
( { model
| editingTimeEntryId = Nothing
, editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson"
}
, Cmd.none
)
UpdateEditingTimeEntryDate date ->
let
old =
model.editingTimeEntry
new =
{ old | date = date }
in
( { model | editingTimeEntry = new }, Cmd.none )
UpdateEditingTimeEntryStartTime time ->
let
old =
model.editingTimeEntry
new =
{ old | startTime = time }
in
( { model | editingTimeEntry = new }, Cmd.none )
UpdateEditingTimeEntryEndTime time ->
let
old =
model.editingTimeEntry
new =
{ old | endTime = time }
in
( { model | editingTimeEntry = new }, Cmd.none )
UpdateEditingTimeEntryType entryType ->
let
old =
model.editingTimeEntry
new =
{ old | entryType = entryType }
in
( { model | editingTimeEntry = new }, Cmd.none )
SaveEditingTimeEntry ->
case ( model.token, model.editingTimeEntryId ) of
( Just token, Just entryId ) ->
( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry )
_ ->
( model, Cmd.none )
-- Weekly Hours
FetchWeeklyHours ->
case model.token of
Just token ->
( model, Cmd.none )
Nothing ->
( model, Cmd.none )
WeeklyHoursReceived result ->
case result of
Ok hours ->
( { model | weeklyHours = hours }, Cmd.none )
Err err ->
( model, Cmd.none )
MyWeeklySummaryReceived result ->
case result of
Ok summary ->
( { model | userWeeklySummary = Just summary }, Cmd.none )
Err _ ->
( { model | userWeeklySummary = Nothing }, Cmd.none )
-- Yearly Hours
FetchYearlyHoursSummary ->
case model.token of
Just token ->
( model, Api.TimeEntry.fetchYearlyHoursSummary token )
Nothing ->
( model, Cmd.none )
YearlyHoursSummaryReceived result ->
TimeEntry.handleYearlyHoursSummaryReceived result model
-- Admin Manual Entry
SelectUserForManualEntry userId ->
let
form =
model.adminManualEntryForm
in
( { model | adminManualEntryForm = { form | selectedUserId = Just userId } }, Cmd.none )
UpdateManualEntryDate date ->
let
form =
model.adminManualEntryForm
in
( { model | adminManualEntryForm = { form | date = date } }, Cmd.none )
UpdateManualEntryHours hours ->
let
form =
model.adminManualEntryForm
in
( { model | adminManualEntryForm = { form | hours = hours } }, Cmd.none )
UpdateManualEntryType entryType ->
let
form =
model.adminManualEntryForm
in
( { model | adminManualEntryForm = { form | entryType = entryType } }, Cmd.none )
SaveAdminTimeEntry ->
TimeEntry.handleSaveAdminTimeEntry model
AdminTimeEntrySaved result ->
TimeEntry.handleAdminTimeEntrySaved result model
-- My Info
FetchMyInfo ->
case model.token of
Just token ->
( model, Api.User.fetchMyInfo token )
Nothing ->
( model, Cmd.none )
MyInfoReceived result ->
case result of
Ok user ->
( { model | users = [ user ] }, Cmd.none )
Err err ->
( model, Cmd.none )
-- School Years
FetchSchoolYears ->
case model.token of
Just token ->
( model, Api.SchoolYear.fetchSchoolYears token )
Nothing ->
( model, Cmd.none )
SchoolYearsReceived result ->
SchoolYear.handleSchoolYearsReceived result model
FetchActiveSchoolYear ->
case model.token of
Just token ->
( model, Api.SchoolYear.fetchActiveSchoolYear token )
Nothing ->
( model, Cmd.none )
ActiveSchoolYearReceived result ->
SchoolYear.handleActiveSchoolYearReceived result model
UpdateNewSchoolYearName name ->
let
old =
model.newSchoolYear
new =
{ old | name = name }
in
( { model | newSchoolYear = new }, Cmd.none )
UpdateNewSchoolYearStart date ->
let
old =
model.newSchoolYear
new =
{ old | startDate = date }
in
( { model | newSchoolYear = new }, Cmd.none )
UpdateNewSchoolYearEnd date ->
let
old =
model.newSchoolYear
new =
{ old | endDate = date }
in
( { model | newSchoolYear = new }, Cmd.none )
CreateSchoolYear ->
SchoolYear.handleCreateSchoolYear model
SchoolYearCreated result ->
SchoolYear.handleSchoolYearCreated result model
ActivateSchoolYear id ->
SchoolYear.handleActivateSchoolYear id model
SchoolYearActivated result ->
SchoolYear.handleSchoolYearActivated result model
DeleteSchoolYear id ->
SchoolYear.handleDeleteSchoolYear id model
SchoolYearDeleted result ->
SchoolYear.handleSchoolYearDeleted result model
-- PDF Download
DownloadYearlySummaryPDF ->
case model.token of
Just token ->
( { model | isProcessing = True }, Api.TimeEntry.downloadYearlySummaryPDF token )
Nothing ->
( model, Cmd.none )
YearlySummaryPDFReceived result ->
case result of
Ok pdfBytes ->
let
filename =
"Jahresuebersicht_" ++ String.fromInt model.currentYear ++ ".pdf"
in
( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes )
Err err ->
( { model | isProcessing = False }, Cmd.none )
-- Delete Confirmation
ConfirmDeleteUser userId ->
( { model | pendingDeleteId = Just userId }, Utils.Ports.confirmDelete "Soll dieser Benutzer wirklich gelöscht werden?" )
DeleteConfirmed confirmed ->
if confirmed then
case ( model.token, model.pendingDeleteId ) of
( Just token, Just id ) ->
let
isTimeEntry =
List.any (\e -> e.id == id) model.timeEntries
in
if isTimeEntry then
( model, Api.TimeEntry.deleteTimeEntry token id )
else
( model, Api.User.deleteUser token id )
_ ->
( model, Cmd.none )
else
( { model | pendingDeleteId = Nothing }, Cmd.none )
-- Toasts
ShowToast message toastType ->
let
newToast =
{ id = model.nextToastId
, message = message
, toastType = toastType
, dismissible = True
}
dismissDelay =
case toastType of
ErrorToast ->
8000
SuccessToast ->
5000
InfoToast ->
5000
WarningToast ->
6000
in
( { model
| toasts = model.toasts ++ [ newToast ]
, nextToastId = model.nextToastId + 1
}
, Task.perform (\_ -> AutoDismissToast newToast.id)
(Process.sleep dismissDelay)
)
DismissToast toastId ->
( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts }
, Cmd.none
)
AutoDismissToast toastId ->
( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts }
, Cmd.none
)

View file

@ -0,0 +1,196 @@
module Update.UserUpdate exposing
( handleCreateUser
, handleDeleteUser
, handleEditUserWorkHours
, handleResetPasswordSaved
, handleResetUserPassword
, handleSaveResetPassword
, handleSaveUserWorkHours
, handleUserCreated
, handleUserDeleted
, handleUserWorkHoursSaved
, handleUsersReceived
)
import Api.User
import Http
import Task
import Types.Model exposing (Model, NewUser, ToastType(..), User)
import Types.Msg exposing (Msg(..))
handleCreateUser : Model -> ( Model, Cmd Msg )
handleCreateUser model =
case model.token of
Just token ->
( model, Api.User.createUser token model.newUser )
Nothing ->
( model, Cmd.none )
handleUserCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
handleUserCreated result model =
case result of
Ok _ ->
let
emptyUser =
NewUser "" "" False
in
case model.token of
Just token ->
( { model | newUser = emptyUser }
, Cmd.batch
[ Api.User.fetchUsers token
, Task.perform (\_ -> ShowToast "Benutzer erfolgreich erstellt!" SuccessToast) (Task.succeed ())
]
)
Nothing ->
( model, Cmd.none )
Err err ->
( model, Cmd.none )
handleDeleteUser : Int -> Model -> ( Model, Cmd Msg )
handleDeleteUser userId model =
case model.token of
Just token ->
( model, Api.User.deleteUser token userId )
Nothing ->
( model, Cmd.none )
handleUserDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
handleUserDeleted result model =
case result of
Ok _ ->
case model.token of
Just token ->
( { model
| pendingDeleteId = Nothing
, error = Nothing
, editingUserId = Nothing
, resetPasswordUserId = Nothing
}
, Cmd.batch
[ Api.User.fetchUsers token
, Task.perform (\_ -> ShowToast "Benutzer erfolgreich gelöscht" SuccessToast) (Task.succeed ())
]
)
Nothing ->
( model, Cmd.none )
Err err ->
( { model | pendingDeleteId = Nothing }, Cmd.none )
handleUsersReceived : Result Http.Error (List User) -> Model -> ( Model, Cmd Msg )
handleUsersReceived result model =
case result of
Ok users ->
( { model | users = users }, Cmd.none )
Err err ->
( model, Cmd.none )
handleEditUserWorkHours : Int -> Model -> ( Model, Cmd Msg )
handleEditUserWorkHours userId model =
case List.filter (\u -> u.id == userId) model.users |> List.head of
Just user ->
( { model
| editingUserId = Just userId
, editingUserWorkHours = String.fromFloat user.yearlyWorkHours
}
, Cmd.none
)
Nothing ->
( model, Cmd.none )
handleSaveUserWorkHours : Model -> ( Model, Cmd Msg )
handleSaveUserWorkHours model =
case ( model.token, model.editingUserId, String.toFloat model.editingUserWorkHours ) of
( Just token, Just userId, Just hours ) ->
( model, Api.User.updateUserWorkHours token userId (String.fromFloat hours) )
_ ->
( model, Task.perform (\_ -> ShowToast "Ungültige Eingabe für Arbeitszeit" WarningToast) (Task.succeed ()) )
handleUserWorkHoursSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
handleUserWorkHoursSaved result model =
case result of
Ok _ ->
case model.token of
Just token ->
( { model
| editingUserWorkHours = ""
, editingUserId = Nothing
, error = Nothing
}
, Cmd.batch
[ Api.User.fetchUsers token
, Task.perform (\_ -> ShowToast "Arbeitszeit erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
]
)
Nothing ->
( model, Cmd.none )
Err err ->
( model, Cmd.none )
handleResetUserPassword : Int -> Model -> ( Model, Cmd Msg )
handleResetUserPassword userId model =
( { model
| resetPasswordUserId = Just userId
, resetPasswordNew = ""
}
, Cmd.none
)
handleSaveResetPassword : Model -> ( Model, Cmd Msg )
handleSaveResetPassword model =
case model.resetPasswordUserId of
Just userId ->
case model.token of
Just token ->
( model, Api.User.resetUserPassword token userId model.resetPasswordNew )
Nothing ->
( model, Cmd.none )
Nothing ->
( model, Cmd.none )
handleResetPasswordSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
handleResetPasswordSaved result model =
case result of
Ok _ ->
( { model
| resetPasswordUserId = Nothing
, resetPasswordNew = ""
, error = Nothing
}
, Cmd.batch
[ case model.token of
Just token ->
Api.User.fetchUsers token
Nothing ->
Cmd.none
, Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt" SuccessToast) (Task.succeed ())
]
)
Err err ->
( model, Cmd.none )

View file

@ -0,0 +1,338 @@
module Utils.DateUtils exposing
( addDaysToDate
, getDateForWeekDay
, getDayOfWeek
, getDayOfYear
, getISOWeek
, getISOWeekFromPosix
, getWeekDateRange
, getYearWeekFromDate
, isLeapYear
, monthToInt
, nextWeek
, previousWeek
)
import Time
getISOWeekFromPosix : Time.Posix -> ( Int, Int )
getISOWeekFromPosix time =
let
year =
Time.toYear Time.utc time
month =
Time.toMonth Time.utc time |> monthToInt
day =
Time.toDay Time.utc time
in
( year, getISOWeek year month day )
monthToInt : Time.Month -> Int
monthToInt month =
case month of
Time.Jan ->
1
Time.Feb ->
2
Time.Mar ->
3
Time.Apr ->
4
Time.May ->
5
Time.Jun ->
6
Time.Jul ->
7
Time.Aug ->
8
Time.Sep ->
9
Time.Oct ->
10
Time.Nov ->
11
Time.Dec ->
12
getISOWeek : Int -> Int -> Int -> Int
getISOWeek year month day =
let
dayOfYear =
getDayOfYear year month day
jan4DayOfWeek =
getDayOfWeek year 1 4
mondayOfWeek1DayOfYear =
4 - jan4DayOfWeek
weekNum =
((dayOfYear - mondayOfWeek1DayOfYear) // 7) + 1
in
if weekNum < 1 then
52
else if weekNum > 52 then
let
dec31DayOfWeek =
getDayOfWeek year 12 31
jan1DayOfWeek =
getDayOfWeek year 1 1
in
if jan1DayOfWeek == 3 || (isLeapYear year && jan1DayOfWeek == 2) then
weekNum
else
1
else
weekNum
getDayOfYear : Int -> Int -> Int -> Int
getDayOfYear year month day =
let
daysInMonth =
[ 31
, if isLeapYear year then
29
else
28
, 31
, 30
, 31
, 30
, 31
, 31
, 30
, 31
, 30
, 31
]
daysBefore =
List.take (month - 1) daysInMonth |> List.sum
in
daysBefore + day
isLeapYear : Int -> Bool
isLeapYear year =
(modBy 4 year == 0) && ((modBy 100 year /= 0) || (modBy 400 year == 0))
getDayOfWeek : Int -> Int -> Int -> Int
getDayOfWeek year month day =
let
adjustedMonth =
if month < 3 then
month + 12
else
month
adjustedYear =
if month < 3 then
year - 1
else
year
q =
day
m =
adjustedMonth
k =
modBy 100 adjustedYear
j =
adjustedYear // 100
h =
(q + ((13 * (m + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)) |> modBy 7
in
(h + 5) |> modBy 7
getDateForWeekDay : Int -> Int -> Int -> String
getDateForWeekDay year week dayOfWeek =
let
jan4DayOfWeek =
getDayOfWeek year 1 4
mondayOfWeek1Date =
4 - jan4DayOfWeek
targetDayOfYear =
mondayOfWeek1Date + ((week - 1) * 7) + dayOfWeek
( finalYear, finalMonth, finalDay ) =
if targetDayOfYear < 1 then
addDaysToDate (year - 1) 12 31 targetDayOfYear
else
addDaysToDate year 1 targetDayOfYear 0
in
String.fromInt finalYear
++ "-"
++ String.padLeft 2 '0' (String.fromInt finalMonth)
++ "-"
++ String.padLeft 2 '0' (String.fromInt finalDay)
addDaysToDate : Int -> Int -> Int -> Int -> ( Int, Int, Int )
addDaysToDate startYear startMonth startDay daysToAdd =
let
daysInMonth m y =
case m of
1 ->
31
2 ->
if isLeapYear y then
29
else
28
3 ->
31
4 ->
30
5 ->
31
6 ->
30
7 ->
31
8 ->
31
9 ->
30
10 ->
31
11 ->
30
12 ->
31
_ ->
0
helper y m d remaining =
if remaining == 0 then
( y, m, d )
else if remaining > 0 then
let
daysInCurrentMonth =
daysInMonth m y
daysLeftInMonth =
daysInCurrentMonth - d
in
if remaining <= daysLeftInMonth then
( y, m, d + remaining )
else if m == 12 then
helper (y + 1) 1 1 (remaining - daysLeftInMonth - 1)
else
helper y (m + 1) 1 (remaining - daysLeftInMonth - 1)
else if d + remaining >= 1 then
( y, m, d + remaining )
else if m == 1 then
let
prevMonthDays =
daysInMonth 12 (y - 1)
in
helper (y - 1) 12 prevMonthDays (remaining + d)
else
let
prevMonthDays =
daysInMonth (m - 1) y
in
helper y (m - 1) prevMonthDays (remaining + d)
in
helper startYear startMonth startDay daysToAdd
previousWeek : Int -> Int -> ( Int, Int )
previousWeek year week =
if week == 1 then
( year - 1, 52 )
else
( year, week - 1 )
nextWeek : Int -> Int -> ( Int, Int )
nextWeek year week =
if week >= 52 then
( year + 1, 1 )
else
( year, week + 1 )
getWeekDateRange : Int -> Int -> String
getWeekDateRange year week =
let
mondayDate =
getDateForWeekDay year week 0
fridayDate =
getDateForWeekDay year week 4
in
mondayDate ++ " bis " ++ fridayDate
getYearWeekFromDate : String -> ( Int, Int )
getYearWeekFromDate dateStr =
let
parts =
String.split "-" dateStr
year =
parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025
month =
parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
day =
parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
in
( year, getISOWeek year month day )

View file

@ -0,0 +1,42 @@
module Utils.ErrorHandler exposing (handleApiError)
import Api.Decoders exposing (apiErrorDecoder)
import Http
import Json.Decode as Decode
import Task
import Types.Model exposing (ToastType(..))
import Types.Msg exposing (Msg(..))
handleApiError : Http.Error -> Cmd Msg
handleApiError error =
let
message =
case error of
Http.BadBody body ->
case Decode.decodeString apiErrorDecoder body of
Ok apiErr ->
apiErr.message
Err _ ->
"Ein Fehler ist aufgetreten"
Http.BadStatus 401 ->
"Keine Berechtigung - bitte erneut anmelden"
Http.BadStatus 403 ->
"Zugriff verweigert"
Http.BadStatus 404 ->
"Ressource nicht gefunden"
Http.Timeout ->
"Zeitüberschreitung - bitte erneut versuchen"
Http.NetworkError ->
"Netzwerkfehler - bitte Verbindung prüfen"
_ ->
"Ein unerwarteter Fehler ist aufgetreten"
in
Task.perform (\_ -> ShowToast message ErrorToast) (Task.succeed ())

View file

@ -0,0 +1,20 @@
port module Utils.Ports exposing
( confirmDelete
, confirmDeleteResponse
, removeToken
, saveToken
)
import Json.Encode as Encode
port saveToken : Encode.Value -> Cmd msg
port removeToken : () -> Cmd msg
port confirmDelete : String -> Cmd msg
port confirmDeleteResponse : (Bool -> msg) -> Sub msg

View file

@ -0,0 +1,34 @@
module Utils.TimeUtils exposing (calculateHours)
calculateHours : String -> String -> Float
calculateHours startTime endTime =
let
parseTime timeStr =
case String.split ":" timeStr of
[ h, m ] ->
(String.toFloat h |> Maybe.withDefault 0)
+ ((String.toFloat m |> Maybe.withDefault 0) / 60)
_ ->
0
start =
parseTime startTime
end =
parseTime endTime
in
if end > start then
end - start
else if endTime == "manual" then
case String.toFloat startTime of
Just time ->
time
Nothing ->
0
else
0

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,99 @@
module View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Types.Model exposing (Model, Schedule)
import Types.Msg exposing (Msg(..))
import View.Components.Schedule exposing (viewScheduleItemWithDay)
viewWeekNavigation : Model -> Html Msg
viewWeekNavigation model =
let
dateRange =
case model.weekDates of
Just wd ->
wd.range
Nothing ->
"Laden..."
in
div [ class "box" ]
[ nav [ class "level" ]
[ div [ class "level-left" ]
[ div [ class "level-item" ]
[ button
[ class "button is-primary"
, onClick PreviousWeek
]
[ span [ class "icon" ]
[ i [ class "fas fa-chevron-left" ] [] ]
, span [] [ text "Vorherige Woche" ]
]
]
]
, div [ class "level-item" ]
[ div
[ style "display" "flex"
, style "flex-direction" "column"
, style "align-items" "center"
, style "gap" "0.5rem"
, style "min-width" "250px"
]
[ p
[ class "heading"
, style "margin" "0"
, style "line-height" "1.2"
]
[ text "Kalenderwoche" ]
, p
[ class "title is-3"
, style "margin" "0"
, style "line-height" "1.2"
]
[ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ]
, p
[ class "subtitle is-6"
, style "margin" "0"
, style "line-height" "1.2"
]
[ text dateRange ]
]
]
, div [ class "level-right" ]
[ div [ class "level-item" ]
[ button
[ class "button is-primary"
, onClick NextWeek
]
[ span [] [ text "Nächste Woche" ]
, span [ class "icon" ]
[ i [ class "fas fa-chevron-right" ] [] ]
]
]
]
]
]
viewDayMobile : Model -> String -> ( Int, List Schedule ) -> Html Msg
viewDayMobile model dayName ( dayOfWeek, schedules ) =
let
dateForDay =
case model.weekDates of
Just wd ->
wd.dates
|> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek)
|> List.head
|> Maybe.map Tuple.second
|> Maybe.withDefault "N/A"
Nothing ->
"Laden..."
in
div [ class "box mb-4" ]
[ p [ class "has-text-weight-bold has-text-centered mb-3" ]
[ text (dayName ++ " - " ++ dateForDay) ]
, div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules)
]

View file

@ -0,0 +1,76 @@
module View.Components.Schedule exposing (viewScheduleItemWithDay)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Types.Model exposing (Model, Schedule)
import Types.Msg exposing (Msg(..))
viewScheduleItemWithDay : Model -> Int -> Schedule -> Html Msg
viewScheduleItemWithDay model dayOfWeek schedule =
let
isSelected =
List.any (\e -> e.scheduleId == schedule.id && e.dayOfWeek == dayOfWeek) model.selectedEntries
isClickable =
(not model.hasEntriesForCurrentWeek || model.weekEditMode) && not model.isProcessing
boxClass =
if isSelected then
"box has-background-success-light"
else if isClickable then
"box has-background-white"
else
"box has-background-light"
typeText =
if schedule.scheduleType == "break" then
" (Pause)"
else
""
cursorStyle =
if isClickable then
"pointer"
else
"not-allowed"
opacity =
if isClickable || isSelected then
"1"
else
"0.6"
in
div
[ class boxClass
, onClick
(if isClickable then
ToggleScheduleSelection schedule.id dayOfWeek
else
FetchSchedules
)
, style "cursor" cursorStyle
, style "margin-bottom" "0.5rem"
, style "padding" "0.75rem"
, style "opacity" opacity
, style "transition" "all 0.2s ease"
, style "border"
(if isClickable && not isSelected then
"2px solid transparent"
else
"2px solid currentColor"
)
]
[ p [ class "has-text-weight-bold is-size-7" ]
[ text (schedule.startTime ++ " - " ++ schedule.endTime) ]
, p [ class "is-size-7" ]
[ text (schedule.title ++ typeText) ]
]

View file

@ -0,0 +1,66 @@
module View.Components.Toast exposing (viewToasts)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Types.Model exposing (Model, Schedule, Toast, ToastType(..))
import Types.Msg exposing (Msg(..))
import Utils.TimeUtils exposing (calculateHours)
import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
import View.Components.Schedule exposing (viewScheduleItemWithDay)
viewToasts : List Toast -> Html Msg
viewToasts toasts =
div [ class "toast-container" ]
(List.map viewToast toasts)
viewToast : Toast -> Html Msg
viewToast toast =
let
toastClass =
case toast.toastType of
ErrorToast ->
"toast-error"
SuccessToast ->
"toast-success"
InfoToast ->
"toast-info"
WarningToast ->
"toast-warning"
icon =
case toast.toastType of
ErrorToast ->
"fas fa-exclamation-circle"
SuccessToast ->
"fas fa-check-circle"
InfoToast ->
"fas fa-info-circle"
WarningToast ->
"fas fa-exclamation-triangle"
in
div [ class ("toast " ++ toastClass), style "animation" "slideIn 0.3s ease-out" ]
[ div [ class "toast-content" ]
[ span [ class "toast-icon" ]
[ i [ class icon ] [] ]
, span [ class "toast-message" ] [ text toast.message ]
]
, if toast.dismissible then
button
[ class "toast-close"
, onClick (DismissToast toast.id)
, attribute "aria-label" "Schließen"
]
[ i [ class "fas fa-times" ] [] ]
else
text ""
]

View file

@ -0,0 +1,57 @@
module View.Login exposing (viewLogin)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Types.Model exposing (Model)
import Types.Msg exposing (Msg(..))
viewLogin : Model -> Html Msg
viewLogin model =
section [ class "section" ]
[ div [ class "container" ]
[ div [ class "columns is-centered" ]
[ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ]
[ div [ class "box" ]
[ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ]
, div [ class "field" ]
[ label [ class "label" ] [ text "Benutzername" ]
, div [ class "control" ]
[ input
[ class "input"
, type_ "text"
, placeholder "Benutzername"
, value model.username
, onInput UpdateUsername
]
[]
]
]
, div [ class "field" ]
[ label [ class "label" ] [ text "Passwort" ]
, div [ class "control" ]
[ input
[ class "input"
, type_ "password"
, placeholder "Passwort"
, value model.password
, onInput UpdatePassword
]
[]
]
]
, div [ class "field" ]
[ div [ class "control" ]
[ button
[ class "button is-primary is-fullwidth"
, onClick Login
]
[ text "Anmelden" ]
]
]
]
]
]
]
]

View file

@ -0,0 +1,338 @@
module View.UserDashboard exposing (viewUserDashboard)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Types.Model exposing (Model, Schedule)
import Types.Msg exposing (Msg(..))
import Utils.TimeUtils exposing (calculateHours)
import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
import View.Components.Schedule exposing (viewScheduleItemWithDay)
viewUserDashboard : Model -> Html Msg
viewUserDashboard model =
div []
[ nav [ class "navbar is-primary" ]
[ div [ class "navbar-brand" ]
[ div [ class "navbar-item" ]
[ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ]
]
, a
[ class
("navbar-burger"
++ (if model.mobileMenuOpen then
" is-active"
else
""
)
)
, attribute "role" "navigation"
, attribute "aria-label" "menu"
, attribute "aria-expanded"
(if model.mobileMenuOpen then
"true"
else
"false"
)
, onClick ToggleMobileMenu
]
[ span [ attribute "aria-hidden" "true" ] []
, span [ attribute "aria-hidden" "true" ] []
, span [ attribute "aria-hidden" "true" ] []
]
]
, div
[ id "navbarUser"
, class
("navbar-menu"
++ (if model.mobileMenuOpen then
" is-active"
else
""
)
)
]
[ div [ class "navbar-end" ]
[ div [ class "navbar-item" ]
[ span [ class "has-text-white mr-2" ] [ text model.username ]
]
, div [ class "navbar-item" ]
[ button [ class "button is-light", onClick Logout ]
[ span [ class "icon" ]
[ i [ class "fas fa-sign-out-alt" ] [] ]
, span [] [ text "Abmelden" ]
]
]
]
]
]
, section [ class "section" ]
[ div [ class "container" ]
[ viewWeekNavigation model
, h2 [ class "title" ] [ text "Stundenplan" ]
, if model.hasEntriesForCurrentWeek && not model.weekEditMode then
div [ class "notification is-success" ]
[ div [ class "level" ]
[ div [ class "level-left" ]
[ div [ class "level-item" ]
[ span [ class "icon" ]
[ i [ class "fas fa-check-circle" ] [] ]
, span [] [ text "Diese Woche wurde bereits erfasst" ]
]
]
, div [ class "level-right" ]
[ div [ class "level-item" ]
[ button
[ class "button is-warning"
, onClick EnableEditMode
, disabled model.isProcessing
]
[ text "Bearbeiten" ]
]
]
]
]
else if model.weekEditMode then
div [ class "notification is-warning" ]
[ div [ class "level" ]
[ div [ class "level-left" ]
[ div [ class "level-item" ]
[ span [ class "icon" ]
[ i [ class "fas fa-edit" ] [] ]
, span [] [ text "Bearbeitungsmodus aktiv" ]
]
]
, div [ class "level-right" ]
[ div [ class "level-item" ]
[ button
[ class "button is-danger is-small mr-2"
, onClick DeleteWeekEntries
, disabled model.isProcessing
]
[ text "Einträge löschen" ]
, button
[ class "button is-light is-small"
, onClick DisableEditMode
]
[ text "Abbrechen" ]
]
]
]
]
else
div [ class "notification is-info is-light" ]
[ text "Wählen Sie die Zeiten aus, die Sie in dieser Woche gearbeitet haben." ]
, viewScheduleGridWithWeek model
, if not model.hasEntriesForCurrentWeek || model.weekEditMode then
div [ class "field mt-4" ]
[ div [ class "control" ]
[ button
[ class "button is-primary is-large is-fullwidth"
, onClick SaveTimeEntries
, disabled (List.isEmpty model.selectedEntries || model.isProcessing)
]
[ if model.isProcessing then
span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ]
else
text ""
, text
(if model.weekEditMode then
"Änderungen speichern"
else
"Speichern"
)
]
]
]
else
text ""
, h3 [ class "subtitle mt-6" ] [ text "Jahresgesamtzeit" ]
, viewUserYearlyTotal model
]
]
]
viewScheduleGridWithWeek : Model -> Html Msg
viewScheduleGridWithWeek model =
let
days =
[ "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag" ]
groupedSchedules =
List.range 0 4
|> List.map
(\day ->
( day, List.filter (\s -> s.dayOfWeek == day) model.schedules )
)
in
div []
[ div [ class "is-hidden-mobile" ]
[ div [ class "table-container" ]
[ table [ class "table is-bordered is-fullwidth" ]
[ thead []
[ tr [] (List.map (\day -> th [ class "has-text-centered" ] [ text day ]) days)
]
, tbody []
[ tr []
(List.map (viewDayColumnWithWeek model) groupedSchedules)
]
]
]
]
, div [ class "is-hidden-tablet" ]
(List.map2 (viewDayMobile model) days groupedSchedules)
]
viewUserYearlyTotal : Model -> Html Msg
viewUserYearlyTotal model =
let
yearlyTotal =
model.timeEntries
|> List.map
(\entry ->
if entry.entryType == "lesson" then
1.0
else
Utils.TimeUtils.calculateHours entry.startTime entry.endTime
)
|> List.sum
userTarget =
List.filter (\u -> not u.isAdmin) model.users
|> List.head
|> Maybe.map .yearlyWorkHours
|> Maybe.withDefault 60
remaining =
userTarget - yearlyTotal
progressPercent =
Basics.min 100 (yearlyTotal / userTarget * 100)
progressColor =
if remaining <= 0 then
"is-success"
else if yearlyTotal >= userTarget * 0.8 then
"is-info"
else
"is-warning"
in
div [ class "box" ]
[ div [ class "columns" ]
[ div [ class "column" ]
[ p [ class "heading" ] [ text "Jahresenziel" ]
, p [ class "title" ] [ text (String.fromFloat userTarget ++ " Std.") ]
]
, div [ class "column" ]
[ p [ class "heading" ] [ text "Geleistete Stunden" ]
, p [ class "title" ] [ text (String.fromFloat yearlyTotal ++ " Std.") ]
]
, div [ class "column" ]
[ p [ class "heading" ] [ text "Restliche Stunden" ]
, p
[ class
("title is-4 "
++ (if remaining <= 0 then
"has-text-success"
else
"has-text-warning"
)
)
]
[ text (String.fromFloat (Basics.max 0 remaining) ++ " Std.") ]
]
]
, progress
[ class ("progress " ++ progressColor)
, value (String.fromFloat progressPercent)
, Html.Attributes.max "100"
]
[ text (String.fromFloat progressPercent ++ "%") ]
]
viewDayColumnWithWeek : Model -> ( Int, List Schedule ) -> Html Msg
viewDayColumnWithWeek model ( dayOfWeek, schedules ) =
let
dateForDay =
case model.weekDates of
Just wd ->
wd.dates
|> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek)
|> List.head
|> Maybe.map Tuple.second
|> Maybe.withDefault "N/A"
Nothing ->
"Laden..."
in
td [ class "has-background-light", style "vertical-align" "top", style "min-width" "150px" ]
[ p [ class "has-text-centered has-text-weight-bold is-size-7 mb-2" ]
[ text dateForDay ]
, div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules)
]
viewUserWeeklySummary : Model -> Html Msg
viewUserWeeklySummary model =
case model.userWeeklySummary of
Just summary ->
let
progressPercent =
Basics.min 100 (summary.totalHours / summary.targetHours * 100)
progressColor =
if summary.totalHours >= summary.targetHours then
"is-success"
else if summary.totalHours >= summary.targetHours * 0.8 then
"is-info"
else
"is-warning"
in
div [ class "box" ]
[ div [ class "columns" ]
[ div [ class "column" ]
[ p [ class "heading" ] [ text "Arbeitszeit diese Woche" ]
, p [ class "title" ] [ text (String.fromFloat summary.totalHours ++ " Std.") ]
, p [ class "subtitle is-6" ] [ text ("von " ++ String.fromFloat summary.targetHours ++ " Std.") ]
]
, div [ class "column" ]
[ p [ class "heading" ] [ text "Verbleibend" ]
, p [ class "title is-4", classList [ ( "has-text-success", summary.remainingHours <= 0 ) ] ]
[ text (String.fromFloat summary.remainingHours ++ " Std.") ]
, if summary.remainingHours < 0 then
p [ class "subtitle is-6 has-text-success" ] [ text " Ziel erreicht!" ]
else
p [ class "subtitle is-6" ] [ text "" ]
]
]
, progress
[ class ("progress " ++ progressColor)
, value (String.fromFloat progressPercent)
, Html.Attributes.max "100"
]
[ text (String.fromFloat progressPercent ++ "%") ]
]
Nothing ->
div [ class "box" ]
[ p [ class "has-text-centered has-text-grey" ] [ text "Laden..." ]
]

View file

@ -0,0 +1,29 @@
module View.View exposing (view)
import Html exposing (Html, div)
import Html.Attributes exposing (class)
import Types.Model exposing (Model)
import Types.Msg exposing (Msg(..))
import Types.Page exposing (Page(..))
import View.AdminDashboard exposing (viewAdminDashboard)
import View.Components.Toast exposing (viewToasts)
import View.Login exposing (viewLogin)
import View.UserDashboard exposing (viewUserDashboard)
view : Model -> Html Msg
view model =
div [ class "app-container" ]
[ viewToasts model.toasts
, div [ class "container" ]
[ case model.page of
LoginPage ->
viewLogin model
UserDashboard ->
viewUserDashboard model
AdminDashboard ->
viewAdminDashboard model
]
]