Compare commits
No commits in common. "main" and "v1.0" have entirely different histories.
43 changed files with 3046 additions and 7691 deletions
775
README.md
775
README.md
|
|
@ -1,775 +0,0 @@
|
||||||
# Zeiterfassungssystem für pädagogische Mitarbeiter
|
|
||||||
|
|
||||||
Eine vollständige Webanwendung zur Erfassung und Verwaltung von Flexistunden für pädagogische Mitarbeiter (PM) an Schulen.
|
|
||||||
|
|
||||||
## 📋 Inhaltsverzeichnis
|
|
||||||
|
|
||||||
- [Überblick](#überblick)
|
|
||||||
- [Funktionen](#funktionen)
|
|
||||||
- [Technologie-Stack](#technologie-stack)
|
|
||||||
- [Voraussetzungen](#voraussetzungen)
|
|
||||||
- [Installation](#installation)
|
|
||||||
- [Konfiguration](#konfiguration)
|
|
||||||
- [Verwendung](#verwendung)
|
|
||||||
- [API-Dokumentation](#api-dokumentation)
|
|
||||||
- [Architektur](#architektur)
|
|
||||||
- [Sicherheit](#sicherheit)
|
|
||||||
- [Backup & Wartung](#backup--wartung)
|
|
||||||
- [Fehlerbehebung](#fehlerbehebung)
|
|
||||||
|
|
||||||
## 🎯 Überblick
|
|
||||||
|
|
||||||
Diese Anwendung wurde entwickelt, um die Erfassung von Flexistunden (zusätzliche Arbeitsstunden) für pädagogische Mitarbeiter an Schulen zu vereinfachen. Sie ermöglicht:
|
|
||||||
|
|
||||||
- **Mitarbeitern**: Wöchentliche Zeiterfassung anhand eines vorkonfigurierten Stundenplans
|
|
||||||
- **Administratoren**: Vollständige Verwaltung von Benutzern, Stundenplänen, Schuljahren und Zeiteinträgen
|
|
||||||
|
|
||||||
Das System arbeitet mit ISO-Kalenderwochen und unterstützt schuljahrbezogene Auswertungen.
|
|
||||||
|
|
||||||
## ✨ Funktionen
|
|
||||||
|
|
||||||
### Für Mitarbeiter
|
|
||||||
|
|
||||||
- **Wochenbasierte Zeiterfassung**: Auswahl der gearbeiteten Zeiten aus dem Stundenplan
|
|
||||||
- **Kalenderwochen-Navigation**: Einfaches Vor- und Zurückblättern zwischen Wochen
|
|
||||||
- **Jahresübersicht**: Anzeige der geleisteten vs. Soll-Arbeitsstunden
|
|
||||||
- **Responsive Design**: Optimiert für Desktop, Tablet und Mobile
|
|
||||||
|
|
||||||
### Für Administratoren
|
|
||||||
|
|
||||||
- **Benutzerverwaltung**:
|
|
||||||
- Benutzer anlegen, bearbeiten und löschen
|
|
||||||
- Jahresarbeitsstunden pro Benutzer festlegen (Standard: 60h)
|
|
||||||
- Passwörter zurücksetzen
|
|
||||||
- **Stundenplan-Management**:
|
|
||||||
- Wochenstundenplan mit Unterrichts- und Pausenzeiten erstellen
|
|
||||||
- Unterrichtsstunden und Pausen unterscheiden
|
|
||||||
- Zeiten mit Titeln versehen (z.B. "Mathematik", "Pause")
|
|
||||||
|
|
||||||
- **Schuljahrverwaltung**:
|
|
||||||
- Schuljahre mit Start- und Enddatum definieren
|
|
||||||
- Aktives Schuljahr setzen
|
|
||||||
- Jahresberechnungen basierend auf aktivem Schuljahr
|
|
||||||
|
|
||||||
- **Zeiteintrags-Verwaltung**:
|
|
||||||
- Alle Zeiteinträge einsehen und bearbeiten
|
|
||||||
- Manuelle Stundeneintragungen (positiv = Abzug, negativ = Hinzurechnung)
|
|
||||||
- Einzelne Einträge korrigieren oder löschen
|
|
||||||
|
|
||||||
- **Berichtswesen**:
|
|
||||||
- Jahresübersicht aller Mitarbeiter
|
|
||||||
- PDF-Export der Jahresübersicht
|
|
||||||
- Wochenweise Stundenauswertung
|
|
||||||
|
|
||||||
## 🛠 Technologie-Stack
|
|
||||||
|
|
||||||
### Frontend
|
|
||||||
|
|
||||||
- **Elm 0.19**: Funktionale Programmiersprache für type-safe UI
|
|
||||||
- **Bulma CSS**: Modernes CSS-Framework
|
|
||||||
- **Font Awesome**: Icons
|
|
||||||
- **LocalStorage**: Client-seitige Datenpersistenz für Authentifizierung
|
|
||||||
|
|
||||||
### Backend
|
|
||||||
|
|
||||||
- **Go (Golang)**: Performante Backend-Sprache
|
|
||||||
- **Echo Framework**: Web-Framework für Go
|
|
||||||
- **SQLite**: Embedded SQL-Datenbank
|
|
||||||
- **JWT**: Token-basierte Authentifizierung
|
|
||||||
- **bcrypt**: Passwort-Hashing
|
|
||||||
- **gofpdf**: PDF-Generierung
|
|
||||||
|
|
||||||
### Deployment
|
|
||||||
|
|
||||||
- **Docker**: Containerisierung
|
|
||||||
- **Docker Compose**: Orchestrierung
|
|
||||||
|
|
||||||
## 📦 Voraussetzungen
|
|
||||||
|
|
||||||
### Für Docker-Deployment (empfohlen)
|
|
||||||
|
|
||||||
- Docker (Version 20.10+)
|
|
||||||
- Docker Compose (Version 1.29+)
|
|
||||||
|
|
||||||
### Für lokale Entwicklung
|
|
||||||
|
|
||||||
- Go 1.21+
|
|
||||||
- Elm 0.19
|
|
||||||
- Node.js 16+ (für Elm-Tooling)
|
|
||||||
- SQLite3
|
|
||||||
|
|
||||||
## 🚀 Installation
|
|
||||||
|
|
||||||
### Option 1: Docker Compose (Produktion)
|
|
||||||
|
|
||||||
1. **Repository klonen**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone <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
|
|
||||||
|
|
@ -24,7 +24,6 @@ func InitDB(filepath string) *sql.DB {
|
||||||
}
|
}
|
||||||
|
|
||||||
createTables(db)
|
createTables(db)
|
||||||
createIndexes(db)
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,8 +34,7 @@ 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,
|
||||||
yearly_hours REAL NOT NULL DEFAULT 60.0,
|
weekly_hours REAL NOT NULL DEFAULT 40.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,
|
||||||
|
|
@ -44,8 +42,7 @@ 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,
|
||||||
|
|
@ -59,21 +56,6 @@ 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 {
|
||||||
|
|
@ -84,7 +66,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, yearly_hours)
|
INSERT OR IGNORE INTO users (id, username, password, is_admin, weekly_hours)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
1, "admin", string(hash), true, 40.0,
|
1, "admin", string(hash), true, 40.0,
|
||||||
)
|
)
|
||||||
|
|
@ -93,28 +75,57 @@ func createTables(db *sql.DB) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func createIndexes(db *sql.DB) {
|
// func createTables(db *sql.DB) {
|
||||||
indexes := []string{
|
// queries := []string{
|
||||||
`CREATE INDEX IF NOT EXISTS idx_time_entries_user_date ON time_entries(user_id, date)`,
|
// `CREATE TABLE IF NOT EXISTS users (
|
||||||
`CREATE INDEX IF NOT EXISTS idx_time_entries_date ON time_entries(date)`,
|
// id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs(user_id)`,
|
// username TEXT UNIQUE NOT NULL,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`,
|
// password TEXT NOT NULL,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_schedules_day ON schedules(day_of_week)`,
|
// is_admin BOOLEAN NOT NULL DEFAULT 0
|
||||||
`CREATE INDEX IF NOT EXISTS idx_school_years_active ON school_years(is_active)`,
|
// )`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_school_years_dates ON school_years(start_date, end_date)`,
|
// `CREATE TABLE IF NOT EXISTS schedules (
|
||||||
}
|
// id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
// day_of_week INTEGER NOT NULL,
|
||||||
|
// start_time TEXT NOT NULL,
|
||||||
|
// end_time TEXT NOT NULL,
|
||||||
|
// type TEXT NOT NULL,
|
||||||
|
// title TEXT NOT NULL
|
||||||
|
// )`,
|
||||||
|
// `CREATE TABLE IF NOT EXISTS time_entries (
|
||||||
|
// id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
// user_id INTEGER NOT NULL,
|
||||||
|
// schedule_id INTEGER NOT NULL,
|
||||||
|
// date TEXT NOT NULL,
|
||||||
|
// type TEXT NOT NULL,
|
||||||
|
// start_time TEXT NOT NULL,
|
||||||
|
// end_time TEXT NOT NULL,
|
||||||
|
// created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
// FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
// FOREIGN KEY (schedule_id) REFERENCES schedules(id)
|
||||||
|
// )`,
|
||||||
|
// }
|
||||||
|
|
||||||
for _, idx := range indexes {
|
// for _, query := range queries {
|
||||||
if _, err := db.Exec(idx); err != nil {
|
// if _, err := db.Exec(query); err != nil {
|
||||||
log.Printf("Warning: Failed to create index: %v", err)
|
// log.Fatal(err)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
|
||||||
|
// hash, _ := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost)
|
||||||
|
// _, err := db.Exec(`
|
||||||
|
// INSERT OR IGNORE INTO users (id, username, password, is_admin)
|
||||||
|
// VALUES (?, ?, ?, ?)`,
|
||||||
|
// 1, "admin", string(hash), true,
|
||||||
|
// )
|
||||||
|
// if err != nil {
|
||||||
|
// log.Fatal(err)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
func GetUserByUsername(db *sql.DB, username string) (*User, error) {
|
func GetUserByUsername(db *sql.DB, username string) (*User, error) {
|
||||||
user := &User{}
|
user := &User{}
|
||||||
err := db.QueryRow("SELECT id, username, password, is_admin, yearly_hours FROM users WHERE username = ?", username).
|
err := db.QueryRow("SELECT id, username, password, is_admin, weekly_hours FROM users WHERE username = ?", username).
|
||||||
Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.YearlyHours)
|
Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.WeeklyHours)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -123,22 +134,22 @@ func GetUserByUsername(db *sql.DB, username string) (*User, error) {
|
||||||
|
|
||||||
func GetUserByID(db *sql.DB, userID int) (*User, error) {
|
func GetUserByID(db *sql.DB, userID int) (*User, error) {
|
||||||
user := &User{}
|
user := &User{}
|
||||||
err := db.QueryRow("SELECT id, username, password, is_admin, yearly_hours FROM users WHERE id = ?", userID).
|
err := db.QueryRow("SELECT id, username, password, is_admin, weekly_hours FROM users WHERE id = ?", userID).
|
||||||
Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.YearlyHours)
|
Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin, &user.WeeklyHours)
|
||||||
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, yearlyHours float64) error {
|
func CreateUser(db *sql.DB, username, hashedPassword string, isAdmin bool, weeklyHours float64) error {
|
||||||
_, err := db.Exec("INSERT INTO users (username, password, is_admin, yearly_hours) VALUES (?, ?, ?, ?)",
|
_, err := db.Exec("INSERT INTO users (username, password, is_admin, weekly_hours) VALUES (?, ?, ?, ?)",
|
||||||
username, hashedPassword, isAdmin, yearlyHours)
|
username, hashedPassword, isAdmin, weeklyHours)
|
||||||
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, yearly_hours FROM users ORDER BY username")
|
rows, err := db.Query("SELECT id, username, is_admin, weekly_hours FROM users")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -147,7 +158,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.YearlyHours); err != nil {
|
if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin, &u.WeeklyHours); err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
users = append(users, u)
|
users = append(users, u)
|
||||||
|
|
@ -155,9 +166,43 @@ func GetAllUsers(db *sql.DB) ([]User, error) {
|
||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func UpdateUser(db *sql.DB, userID int, yearlyHours float64) error {
|
// func GetUserByUsername(db *sql.DB, username string) (*User, error) {
|
||||||
_, err := db.Exec("UPDATE users SET yearly_hours = ? WHERE id = ?",
|
// user := &User{}
|
||||||
yearlyHours, userID)
|
// err := db.QueryRow("SELECT id, username, password, is_admin FROM users WHERE username = ?", username).
|
||||||
|
// Scan(&user.ID, &user.Username, &user.Password, &user.IsAdmin)
|
||||||
|
// if err != nil {
|
||||||
|
// return nil, err
|
||||||
|
// }
|
||||||
|
// return user, nil
|
||||||
|
// }
|
||||||
|
|
||||||
|
// func CreateUser(db *sql.DB, username, hashedPassword string, isAdmin bool) error {
|
||||||
|
// _, err := db.Exec("INSERT INTO users (username, password, is_admin) VALUES (?, ?, ?)",
|
||||||
|
// username, hashedPassword, isAdmin)
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
|
||||||
|
// func GetAllUsers(db *sql.DB) ([]User, error) {
|
||||||
|
// rows, err := db.Query("SELECT id, username, is_admin FROM users")
|
||||||
|
// if err != nil {
|
||||||
|
// return nil, err
|
||||||
|
// }
|
||||||
|
// defer rows.Close()
|
||||||
|
|
||||||
|
// var users []User
|
||||||
|
// for rows.Next() {
|
||||||
|
// var u User
|
||||||
|
// if err := rows.Scan(&u.ID, &u.Username, &u.IsAdmin); err != nil {
|
||||||
|
// continue
|
||||||
|
// }
|
||||||
|
// users = append(users, u)
|
||||||
|
// }
|
||||||
|
// return users, nil
|
||||||
|
// }
|
||||||
|
|
||||||
|
func UpdateUser(db *sql.DB, userID int, weeklyHours float64) error {
|
||||||
|
_, err := db.Exec("UPDATE users SET weekly_hours = ? WHERE id = ?",
|
||||||
|
weeklyHours, userID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -276,7 +321,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.yearly_hours
|
u.weekly_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
|
||||||
|
|
@ -287,19 +332,16 @@ 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 yearlyHours float64
|
var expectedWeeklyHours float64
|
||||||
|
|
||||||
if err := rows.Scan(&userID, &username, &dateStr, &startTime, &endTime, &entryType, &yearlyHours); err != nil {
|
if err := rows.Scan(&userID, &username, &dateStr, &startTime, &endTime, &entryType, &expectedWeeklyHours); err != nil {
|
||||||
continue
|
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
|
||||||
|
|
@ -307,14 +349,15 @@ func GetWeeklyHours(db *sql.DB) ([]WeeklyHours, error) {
|
||||||
|
|
||||||
year, week := t.ISOWeek()
|
year, week := t.ISOWeek()
|
||||||
|
|
||||||
entry := TimeEntry{
|
var hours float64
|
||||||
Type: entryType,
|
if entryType == "lesson" {
|
||||||
StartTime: startTime,
|
hours = 1.0
|
||||||
EndTime: endTime,
|
} else {
|
||||||
|
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 {
|
||||||
|
|
@ -324,21 +367,14 @@ 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 {
|
|
||||||
yearlyTotals[h.UserID] += h.TotalHours
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, h := range hoursMap {
|
for _, h := range hoursMap {
|
||||||
h.YearlyTarget = userYearlyHours[h.UserID]
|
h.RemainingHours = h.ExpectedHours - h.TotalHours
|
||||||
h.YearlyActual = yearlyTotals[h.UserID]
|
|
||||||
|
|
||||||
h.WeeklyTarget = h.YearlyTarget / 45.0
|
|
||||||
h.RemainingYearly = h.YearlyTarget - h.YearlyActual
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []WeeklyHours
|
var result []WeeklyHours
|
||||||
|
|
@ -359,6 +395,74 @@ 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, ":")
|
||||||
|
|
@ -385,6 +489,14 @@ 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)
|
||||||
|
|
||||||
|
|
@ -420,6 +532,7 @@ 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
|
||||||
|
|
@ -427,200 +540,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,205 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ErrorCode string
|
|
||||||
|
|
||||||
const (
|
|
||||||
// Authentifizierung
|
|
||||||
ErrInvalidCredentials ErrorCode = "INVALID_CREDENTIALS"
|
|
||||||
ErrUnauthorized ErrorCode = "UNAUTHORIZED"
|
|
||||||
ErrTokenExpired ErrorCode = "TOKEN_EXPIRED"
|
|
||||||
ErrAccessDenied ErrorCode = "ACCESS_DENIED"
|
|
||||||
|
|
||||||
// Validierung
|
|
||||||
ErrInvalidInput ErrorCode = "INVALID_INPUT"
|
|
||||||
ErrMissingField ErrorCode = "MISSING_FIELD"
|
|
||||||
ErrInvalidDateFormat ErrorCode = "INVALID_DATE_FORMAT"
|
|
||||||
ErrInvalidTimeFormat ErrorCode = "INVALID_TIME_FORMAT"
|
|
||||||
|
|
||||||
// Ressourcen
|
|
||||||
ErrNotFound ErrorCode = "NOT_FOUND"
|
|
||||||
ErrAlreadyExists ErrorCode = "ALREADY_EXISTS"
|
|
||||||
ErrCannotDelete ErrorCode = "CANNOT_DELETE"
|
|
||||||
ErrProtectedUser ErrorCode = "PROTECTED_USER"
|
|
||||||
ErrNoActiveSchool ErrorCode = "NO_ACTIVE_SCHOOL_YEAR"
|
|
||||||
|
|
||||||
// Datenbank
|
|
||||||
ErrDatabase ErrorCode = "DATABASE_ERROR"
|
|
||||||
ErrTransaction ErrorCode = "TRANSACTION_ERROR"
|
|
||||||
ErrQueryFailed ErrorCode = "QUERY_FAILED"
|
|
||||||
|
|
||||||
// Server
|
|
||||||
ErrInternal ErrorCode = "INTERNAL_ERROR"
|
|
||||||
ErrServiceUnavail ErrorCode = "SERVICE_UNAVAILABLE"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AppError struct {
|
|
||||||
Code ErrorCode `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
UserMsg string `json:"user_message"`
|
|
||||||
HTTPStatus int `json:"-"`
|
|
||||||
Internal error `json:"-"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *AppError) Error() string {
|
|
||||||
if e.Internal != nil {
|
|
||||||
return fmt.Sprintf("[%s] %s: %v", e.Code, e.Message, e.Internal)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAppError(code ErrorCode, message, userMsg string, httpStatus int, internal error) *AppError {
|
|
||||||
return &AppError{
|
|
||||||
Code: code,
|
|
||||||
Message: message,
|
|
||||||
UserMsg: userMsg,
|
|
||||||
HTTPStatus: httpStatus,
|
|
||||||
Internal: internal,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ErrInvalidCredentialsMsg() *AppError {
|
|
||||||
return NewAppError(
|
|
||||||
ErrInvalidCredentials,
|
|
||||||
"Invalid username or password",
|
|
||||||
"Benutzername oder Passwort ungültig",
|
|
||||||
http.StatusUnauthorized,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ErrUnauthorizedMsg() *AppError {
|
|
||||||
return NewAppError(
|
|
||||||
ErrUnauthorized,
|
|
||||||
"Unauthorized access",
|
|
||||||
"Keine Berechtigung für diese Aktion",
|
|
||||||
http.StatusUnauthorized,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ErrTokenExpiredMsg() *AppError {
|
|
||||||
return NewAppError(
|
|
||||||
ErrTokenExpired,
|
|
||||||
"Token has expired",
|
|
||||||
"Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an",
|
|
||||||
http.StatusUnauthorized,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ErrAccessDeniedMsg() *AppError {
|
|
||||||
return NewAppError(
|
|
||||||
ErrAccessDenied,
|
|
||||||
"Access denied - admin privileges required",
|
|
||||||
"Zugriff verweigert. Administrator-Rechte erforderlich",
|
|
||||||
http.StatusForbidden,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ErrInvalidInputMsg(field string) *AppError {
|
|
||||||
return NewAppError(
|
|
||||||
ErrInvalidInput,
|
|
||||||
fmt.Sprintf("Invalid input for field: %s", field),
|
|
||||||
fmt.Sprintf("Ungültige Eingabe im Feld: %s", field),
|
|
||||||
http.StatusBadRequest,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ErrMissingFieldMsg(field string) *AppError {
|
|
||||||
return NewAppError(
|
|
||||||
ErrMissingField,
|
|
||||||
fmt.Sprintf("Required field missing: %s", field),
|
|
||||||
fmt.Sprintf("Pflichtfeld fehlt: %s", field),
|
|
||||||
http.StatusBadRequest,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ErrNotFoundMsg(resource string) *AppError {
|
|
||||||
return NewAppError(
|
|
||||||
ErrNotFound,
|
|
||||||
fmt.Sprintf("%s not found", resource),
|
|
||||||
fmt.Sprintf("%s nicht gefunden", resource),
|
|
||||||
http.StatusNotFound,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ErrAlreadyExistsMsg(resource string) *AppError {
|
|
||||||
return NewAppError(
|
|
||||||
ErrAlreadyExists,
|
|
||||||
fmt.Sprintf("%s already exists", resource),
|
|
||||||
fmt.Sprintf("%s existiert bereits", resource),
|
|
||||||
http.StatusConflict,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ErrCannotDeleteMsg(resource, reason string) *AppError {
|
|
||||||
return NewAppError(
|
|
||||||
ErrCannotDelete,
|
|
||||||
fmt.Sprintf("Cannot delete %s: %s", resource, reason),
|
|
||||||
fmt.Sprintf("%s kann nicht gelöscht werden: %s", resource, reason),
|
|
||||||
http.StatusBadRequest,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ErrProtectedUserMsg() *AppError {
|
|
||||||
return NewAppError(
|
|
||||||
ErrProtectedUser,
|
|
||||||
"Cannot modify protected admin user",
|
|
||||||
"Der Admin-Benutzer ist geschützt und kann nicht geändert werden",
|
|
||||||
http.StatusForbidden,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ErrNoActiveSchoolYearMsg() *AppError {
|
|
||||||
return NewAppError(
|
|
||||||
ErrNoActiveSchool,
|
|
||||||
"No active school year configured",
|
|
||||||
"Kein aktives Schuljahr konfiguriert. Bitte aktivieren Sie ein Schuljahr",
|
|
||||||
http.StatusNotFound,
|
|
||||||
nil,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ErrDatabaseMsg(internal error) *AppError {
|
|
||||||
return NewAppError(
|
|
||||||
ErrDatabase,
|
|
||||||
"Database operation failed",
|
|
||||||
"Ein Datenbankfehler ist aufgetreten. Bitte versuchen Sie es erneut",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
internal,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ErrInternalMsg(internal error) *AppError {
|
|
||||||
return NewAppError(
|
|
||||||
ErrInternal,
|
|
||||||
"Internal server error",
|
|
||||||
"Ein interner Fehler ist aufgetreten. Bitte versuchen Sie es später erneut",
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
internal,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
type ErrorResponse struct {
|
|
||||||
Code ErrorCode `json:"code"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *AppError) ToResponse() ErrorResponse {
|
|
||||||
return ErrorResponse{
|
|
||||||
Code: e.Code,
|
|
||||||
Message: e.UserMsg,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,18 +3,14 @@ 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
|
||||||
|
|
@ -26,6 +22,7 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,11 @@
|
||||||
github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 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=
|
||||||
|
|
@ -25,14 +16,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/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=
|
||||||
|
|
@ -43,7 +30,6 @@ golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
golang.org/x/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=
|
||||||
|
|
@ -53,7 +39,6 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.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=
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,10 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
@ -18,60 +14,25 @@ type App struct {
|
||||||
DB *sql.DB
|
DB *sql.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleError(c echo.Context, err *AppError) error {
|
// Login Handler
|
||||||
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 HandleError(c, ErrInvalidInputMsg("Login-Daten"))
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
if err == sql.ErrNoRows {
|
return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials")
|
||||||
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 HandleError(c, ErrInvalidCredentialsMsg())
|
return echo.NewHTTPError(http.StatusUnauthorized, "invalid credentials")
|
||||||
}
|
}
|
||||||
|
|
||||||
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 HandleError(c, ErrInternalMsg(err))
|
return echo.NewHTTPError(http.StatusInternalServerError, "error creating token")
|
||||||
}
|
}
|
||||||
|
|
||||||
response := LoginResponse{
|
response := LoginResponse{
|
||||||
|
|
@ -83,10 +44,11 @@ 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 HandleError(c, ErrDatabaseMsg(err))
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, schedules)
|
return c.JSON(http.StatusOK, schedules)
|
||||||
}
|
}
|
||||||
|
|
@ -94,100 +56,52 @@ func (app *App) GetSchedulesHandler(c echo.Context) error {
|
||||||
func (app *App) CreateScheduleHandler(c echo.Context) error {
|
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 HandleError(c, ErrInvalidInputMsg("Stundenplan-Daten"))
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
if isDuplicateError(err) {
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
return HandleError(c, ErrAlreadyExistsMsg("Stundenplan-Eintrag"))
|
|
||||||
}
|
|
||||||
return HandleError(c, ErrDatabaseMsg(err))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusCreated, map[string]string{"message": "Stundenplan erstellt"})
|
return c.JSON(http.StatusCreated, map[string]string{"message": "schedule created"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) DeleteScheduleHandler(c echo.Context) error {
|
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 HandleError(c, ErrInvalidInputMsg("Stundenplan-ID"))
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid id")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := DeleteSchedule(app.DB, id); err != nil {
|
if err := DeleteSchedule(app.DB, id); err != nil {
|
||||||
if err == sql.ErrNoRows {
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
return HandleError(c, ErrNotFoundMsg("Stundenplan"))
|
|
||||||
}
|
|
||||||
return HandleError(c, ErrDatabaseMsg(err))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.NoContent(http.StatusNoContent)
|
return c.NoContent(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) GetYearlyHoursSummaryHandler(c echo.Context) error {
|
// // User Handlers
|
||||||
hours, err := GetYearlyHoursSummary(app.DB)
|
// func (app *App) CreateUserHandler(c echo.Context) error {
|
||||||
if err != nil {
|
// var req CreateUserRequest
|
||||||
return HandleError(c, ErrDatabaseMsg(err))
|
// if err := c.Bind(&req); err != nil {
|
||||||
}
|
// return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
|
||||||
if hours == nil {
|
// }
|
||||||
hours = []WeeklyHours{}
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, hours)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *App) AdminCreateTimeEntryHandler(c echo.Context) error {
|
// hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||||
var req struct {
|
// if err != nil {
|
||||||
UserID int `json:"user_id"`
|
// return echo.NewHTTPError(http.StatusInternalServerError, "error hashing password")
|
||||||
Date string `json:"date"`
|
// }
|
||||||
Hours float64 `json:"hours"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.Bind(&req); err != nil {
|
// if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin); err != nil {
|
||||||
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
|
// return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
}
|
// }
|
||||||
|
|
||||||
if req.UserID == 0 {
|
// return c.JSON(http.StatusCreated, map[string]string{"message": "user created"})
|
||||||
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 HandleError(c, ErrDatabaseMsg(err))
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
}
|
|
||||||
if users == nil {
|
|
||||||
users = []User{}
|
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, users)
|
return c.JSON(http.StatusOK, users)
|
||||||
}
|
}
|
||||||
|
|
@ -195,65 +109,40 @@ func (app *App) GetUsersHandler(c echo.Context) error {
|
||||||
func (app *App) DeleteUserHandler(c echo.Context) error {
|
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 HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid id")
|
||||||
}
|
|
||||||
|
|
||||||
if id == 1 {
|
|
||||||
return HandleError(c, ErrProtectedUserMsg())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := DeleteUser(app.DB, id); err != nil {
|
if err := DeleteUser(app.DB, id); err != nil {
|
||||||
if err == sql.ErrNoRows {
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
return HandleError(c, ErrNotFoundMsg("Benutzer"))
|
|
||||||
}
|
|
||||||
return HandleError(c, ErrDatabaseMsg(err))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.NoContent(http.StatusNoContent)
|
return c.NoContent(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Time Entry Handlers
|
||||||
func (app *App) CreateTimeEntryHandler(c echo.Context) error {
|
func (app *App) CreateTimeEntryHandler(c echo.Context) error {
|
||||||
claims, err := getClaims(c)
|
userID := c.Get("user_id").(int)
|
||||||
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 HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Date == "" {
|
entry.UserID = userID
|
||||||
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 HandleError(c, ErrDatabaseMsg(err))
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteintrag erstellt"})
|
return c.JSON(http.StatusCreated, map[string]string{"message": "time entry created"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) GetMyTimeEntriesHandler(c echo.Context) error {
|
func (app *App) GetMyTimeEntriesHandler(c echo.Context) error {
|
||||||
claims, err := getClaims(c)
|
userID := c.Get("user_id").(int)
|
||||||
if err != nil {
|
|
||||||
return HandleError(c, ErrUnauthorizedMsg())
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := GetTimeEntriesByUser(app.DB, claims.UserID)
|
entries, err := GetTimeEntriesByUser(app.DB, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HandleError(c, ErrDatabaseMsg(err))
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
}
|
|
||||||
if entries == nil {
|
|
||||||
entries = []TimeEntry{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, entries)
|
return c.JSON(http.StatusOK, entries)
|
||||||
|
|
@ -262,16 +151,12 @@ func (app *App) GetMyTimeEntriesHandler(c echo.Context) error {
|
||||||
func (app *App) GetWeekDates(c echo.Context) error {
|
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 HandleError(c, ErrInvalidInputMsg("Jahr"))
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year")
|
||||||
}
|
}
|
||||||
|
|
||||||
week, err := strconv.Atoi(c.QueryParam("week"))
|
week, err := strconv.Atoi(c.QueryParam("week"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HandleError(c, ErrInvalidInputMsg("Woche"))
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid week")
|
||||||
}
|
|
||||||
|
|
||||||
if week < 1 || week > 53 {
|
|
||||||
return HandleError(c, ErrInvalidInputMsg("Woche (muss zwischen 1 und 53 liegen)"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dates := calculateWeekDates(year, week)
|
dates := calculateWeekDates(year, week)
|
||||||
|
|
@ -279,24 +164,21 @@ func (app *App) GetWeekDates(c echo.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) CheckWeekHasEntries(c echo.Context) error {
|
func (app *App) CheckWeekHasEntries(c echo.Context) error {
|
||||||
claims, err := getClaims(c)
|
userID := c.Get("user_id").(int)
|
||||||
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 HandleError(c, ErrInvalidInputMsg("Jahr"))
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year")
|
||||||
}
|
}
|
||||||
|
|
||||||
week, err := strconv.Atoi(c.QueryParam("week"))
|
week, err := strconv.Atoi(c.QueryParam("week"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HandleError(c, ErrInvalidInputMsg("Woche"))
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid week")
|
||||||
}
|
}
|
||||||
|
|
||||||
hasEntries, err := CheckUserHasEntriesForWeek(app.DB, claims.UserID, year, week)
|
hasEntries, err := CheckUserHasEntriesForWeek(app.DB, userID, year, week)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HandleError(c, ErrDatabaseMsg(err))
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]bool{"has_entries": hasEntries})
|
return c.JSON(http.StatusOK, map[string]bool{"has_entries": hasEntries})
|
||||||
|
|
@ -305,10 +187,7 @@ func (app *App) CheckWeekHasEntries(c echo.Context) error {
|
||||||
func (app *App) GetAllTimeEntriesHandler(c echo.Context) error {
|
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 HandleError(c, ErrDatabaseMsg(err))
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
}
|
|
||||||
if entries == nil {
|
|
||||||
entries = []TimeEntry{}
|
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusOK, entries)
|
return c.JSON(http.StatusOK, entries)
|
||||||
}
|
}
|
||||||
|
|
@ -316,35 +195,29 @@ func (app *App) GetAllTimeEntriesHandler(c echo.Context) error {
|
||||||
func (app *App) GetWeeklyHoursHandler(c echo.Context) error {
|
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 HandleError(c, ErrDatabaseMsg(err))
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
}
|
|
||||||
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 {
|
||||||
claims, err := getClaims(c)
|
userID := c.Get("user_id").(int)
|
||||||
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 HandleError(c, ErrInvalidInputMsg("Jahr"))
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year")
|
||||||
}
|
}
|
||||||
|
|
||||||
week, err := strconv.Atoi(c.QueryParam("week"))
|
week, err := strconv.Atoi(c.QueryParam("week"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HandleError(c, ErrInvalidInputMsg("Woche"))
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid week")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := DeleteNonManualTimeEntriesByUserAndWeek(app.DB, claims.UserID, year, week); err != nil {
|
if err := DeleteTimeEntriesByUserAndWeek(app.DB, userID, year, week); err != nil {
|
||||||
return HandleError(c, ErrDatabaseMsg(err))
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.NoContent(http.StatusNoContent)
|
return c.NoContent(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
type WeekDates struct {
|
type WeekDates struct {
|
||||||
|
|
@ -403,83 +276,52 @@ type BatchTimeEntryRequest struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error {
|
func (app *App) CreateBatchTimeEntriesHandler(c echo.Context) error {
|
||||||
claims, err := getClaims(c)
|
userID := c.Get("user_id").(int)
|
||||||
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 HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
|
return echo.NewHTTPError(http.StatusBadRequest, "invalid request")
|
||||||
}
|
|
||||||
|
|
||||||
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 HandleError(c, ErrDatabaseMsg(err))
|
return echo.NewHTTPError(http.StatusInternalServerError, "transaction error")
|
||||||
}
|
}
|
||||||
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 HandleError(c, ErrDatabaseMsg(err))
|
return echo.NewHTTPError(http.StatusInternalServerError, "prepare error")
|
||||||
}
|
}
|
||||||
defer stmt.Close()
|
defer stmt.Close()
|
||||||
|
|
||||||
for _, entry := range req.Entries {
|
for _, entry := range req.Entries {
|
||||||
_, err := stmt.Exec(claims.UserID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime)
|
_, err := stmt.Exec(userID, entry.ScheduleID, entry.Date, entry.Type, entry.StartTime, entry.EndTime)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HandleError(c, ErrDatabaseMsg(err))
|
return echo.NewHTTPError(http.StatusInternalServerError, "insert error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return HandleError(c, ErrDatabaseMsg(err))
|
return echo.NewHTTPError(http.StatusInternalServerError, "commit error")
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusCreated, map[string]string{"message": "Zeiteinträge erstellt"})
|
return c.JSON(http.StatusCreated, map[string]string{"message": "entries created"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) UpdateUserHandler(c echo.Context) error {
|
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 HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user 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 HandleError(c, ErrInvalidInputMsg("Benutzerdaten"))
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.YearlyHours <= 0 {
|
if err := UpdateUser(app.DB, userID, req.WeeklyHours); err != nil {
|
||||||
return HandleError(c, ErrInvalidInputMsg("Jahresarbeitsstunden (muss positiv sein)"))
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
@ -488,28 +330,21 @@ 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 HandleError(c, ErrInvalidInputMsg("Benutzer-ID"))
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
var req ResetPasswordRequest
|
var req ResetPasswordRequest
|
||||||
if err := c.Bind(&req); err != nil {
|
if err := c.Bind(&req); err != nil {
|
||||||
return HandleError(c, ErrInvalidInputMsg("Passwort-Daten"))
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||||
}
|
|
||||||
|
|
||||||
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 HandleError(c, ErrInternalMsg(err))
|
return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ResetUserPassword(app.DB, userID, string(hashedPassword)); err != nil {
|
if err := ResetUserPassword(app.DB, userID, string(hashedPassword)); err != nil {
|
||||||
if err == sql.ErrNoRows {
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
return HandleError(c, ErrNotFoundMsg("Benutzer"))
|
|
||||||
}
|
|
||||||
return HandleError(c, ErrDatabaseMsg(err))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.NoContent(http.StatusOK)
|
return c.NoContent(http.StatusOK)
|
||||||
|
|
@ -518,29 +353,16 @@ func (app *App) ResetPasswordHandler(c echo.Context) error {
|
||||||
func (app *App) UpdateTimeEntryHandler(c echo.Context) error {
|
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 HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID"))
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
var req UpdateTimeEntryRequest
|
var req UpdateTimeEntryRequest
|
||||||
if err := c.Bind(&req); err != nil {
|
if err := c.Bind(&req); err != nil {
|
||||||
return HandleError(c, ErrInvalidInputMsg("Zeiteintrag-Daten"))
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
if err == sql.ErrNoRows {
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
return HandleError(c, ErrNotFoundMsg("Zeiteintrag"))
|
|
||||||
}
|
|
||||||
return HandleError(c, ErrDatabaseMsg(err))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.NoContent(http.StatusOK)
|
return c.NoContent(http.StatusOK)
|
||||||
|
|
@ -549,180 +371,74 @@ func (app *App) UpdateTimeEntryHandler(c echo.Context) error {
|
||||||
func (app *App) DeleteTimeEntryHandler(c echo.Context) error {
|
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 HandleError(c, ErrInvalidInputMsg("Zeiteintrag-ID"))
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid entry ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := DeleteTimeEntry(app.DB, entryID); err != nil {
|
if err := DeleteTimeEntry(app.DB, entryID); err != nil {
|
||||||
if err == sql.ErrNoRows {
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
return HandleError(c, ErrNotFoundMsg("Zeiteintrag"))
|
|
||||||
}
|
|
||||||
return HandleError(c, ErrDatabaseMsg(err))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.NoContent(http.StatusNoContent)
|
return c.NoContent(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (app *App) GetMyInfoHandler(c echo.Context) error {
|
func (app *App) GetMyWeeklySummaryHandler(c echo.Context) error {
|
||||||
claims, err := getClaims(c)
|
userID := c.Get("user_id").(int)
|
||||||
|
|
||||||
|
year, err := strconv.Atoi(c.QueryParam("year"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return HandleError(c, ErrUnauthorizedMsg())
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid year")
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := GetUserByID(app.DB, claims.UserID)
|
week, err := strconv.Atoi(c.QueryParam("week"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
return echo.NewHTTPError(http.StatusBadRequest, "Invalid week")
|
||||||
return HandleError(c, ErrNotFoundMsg("Benutzer"))
|
|
||||||
}
|
|
||||||
return HandleError(c, ErrDatabaseMsg(err))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, user)
|
user, err := GetUserByID(app.DB, userID)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
allHours, err := GetWeeklyHours(app.DB)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, h := range allHours {
|
||||||
|
if h.UserID == userID && h.Year == year && h.Week == week {
|
||||||
|
return c.JSON(http.StatusOK, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 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 HandleError(c, ErrInvalidInputMsg("Benutzerdaten"))
|
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
|
||||||
}
|
|
||||||
|
|
||||||
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 HandleError(c, ErrInternalMsg(err))
|
return echo.NewHTTPError(http.StatusInternalServerError, "Error hashing password")
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.YearlyHours == 0 {
|
if req.WeeklyHours == 0 {
|
||||||
req.YearlyHours = 60.0
|
req.WeeklyHours = 40.0
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.YearlyHours); err != nil {
|
if err := CreateUser(app.DB, req.Username, string(hashedPassword), req.IsAdmin, req.WeeklyHours); err != nil {
|
||||||
if isDuplicateError(err) {
|
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
|
|
||||||
if [ -f .env ]; then
|
|
||||||
set -a
|
|
||||||
source .env
|
|
||||||
set +a
|
|
||||||
echo "✅ .env geladen"
|
|
||||||
else
|
|
||||||
echo "❌ .env Datei nicht gefunden!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$PORT" ]; then
|
|
||||||
export PORT=8080
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$DB_PATH" ]; then
|
|
||||||
export DB_PATH="/data/timetracking.db"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec "$@"
|
|
||||||
|
|
||||||
|
|
@ -4,7 +4,6 @@ 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"
|
||||||
|
|
@ -25,20 +24,8 @@ 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: allowOrigins,
|
AllowOrigins: []string{"*"},
|
||||||
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},
|
||||||
}))
|
}))
|
||||||
|
|
@ -57,9 +44,7 @@ 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("/yearly-hours-summary", app.GetYearlyHoursSummaryHandler)
|
protected.GET("/my-weekly-summary", app.GetMyWeeklySummaryHandler)
|
||||||
protected.GET("/my-info", app.GetMyInfoHandler)
|
|
||||||
protected.GET("/school-year/active", app.GetActiveSchoolYearHandler)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
admin := e.Group("/api/admin")
|
admin := e.Group("/api/admin")
|
||||||
|
|
@ -74,15 +59,9 @@ 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.PUT("/users/:id/reset-password", app.ResetPasswordHandler)
|
admin.POST("/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")
|
||||||
|
|
|
||||||
|
|
@ -1,66 +1,120 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"strings"
|
||||||
"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
|
var jwtSecret = []byte("your-secret-key-change-in-production")
|
||||||
|
|
||||||
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)),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"HS256","typ":"JWT"}`))
|
||||||
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 echojwt.WithConfig(echojwt.Config{
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
NewClaimsFunc: func(c echo.Context) jwt.Claims {
|
return func(c echo.Context) error {
|
||||||
return new(Claims)
|
authHeader := c.Request().Header.Get("Authorization")
|
||||||
},
|
if authHeader == "" {
|
||||||
SigningKey: jwtSecret,
|
return echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header")
|
||||||
})
|
}
|
||||||
|
|
||||||
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
claims, err := verifyToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("user_id", claims.UserID)
|
||||||
|
c.Set("username", claims.Username)
|
||||||
|
c.Set("is_admin", claims.IsAdmin)
|
||||||
|
|
||||||
|
c.Logger().Infof("Authenticated user: ID=%d, Username=%s", claims.UserID, claims.Username)
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func AdminMiddleware() echo.MiddlewareFunc {
|
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 {
|
||||||
user, ok := c.Get("user").(*jwt.Token)
|
isAdmin, ok := c.Get("is_admin").(bool)
|
||||||
if !ok {
|
if !ok || !isAdmin {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "JWT token missing or invalid")
|
return echo.NewHTTPError(http.StatusForbidden, "admin access required")
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
@ -72,68 +126,3 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import "time"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TimeEntry struct {
|
type TimeEntry struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
|
|
@ -23,10 +20,8 @@ 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"`
|
||||||
YearlyTarget float64 `json:"yearly_target"` // NEU
|
ExpectedHours float64 `json:"expected_hours"`
|
||||||
YearlyActual float64 `json:"yearly_actual"` // NEU
|
RemainingHours float64 `json:"remaining_hours"`
|
||||||
WeeklyTarget float64 `json:"weekly_target"` // NEU
|
|
||||||
RemainingYearly float64 `json:"remaining_yearly"` // NEU
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
|
|
@ -34,7 +29,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"`
|
||||||
YearlyHours float64 `json:"yearly_hours"`
|
WeeklyHours float64 `json:"weekly_hours"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Schedule struct {
|
type Schedule struct {
|
||||||
|
|
@ -61,27 +56,12 @@ 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"`
|
||||||
YearlyHours float64 `json:"yearly_hours"`
|
WeeklyHours float64 `json:"weekly_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"`
|
||||||
YearlyHours float64 `json:"yearly_hours"`
|
WeeklyHours float64 `json:"weekly_hours"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ResetPasswordRequest struct {
|
type ResetPasswordRequest struct {
|
||||||
|
|
@ -99,5 +79,4 @@ 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
110
backend/pdf.go
|
|
@ -1,110 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jung-kurt/gofpdf"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GenerateYearlySummaryPDF(schoolYear *SchoolYear, summary []WeeklyHours) ([]byte, error) {
|
|
||||||
pdf := gofpdf.New("P", "mm", "A4", "")
|
|
||||||
pdf.AddPage()
|
|
||||||
|
|
||||||
pdf.SetFont("Arial", "B", 20)
|
|
||||||
|
|
||||||
title := fmt.Sprintf("Stundenjahresübersicht für Schuljahr %s", schoolYear.Name)
|
|
||||||
pdf.Cell(0, 15, title)
|
|
||||||
pdf.Ln(10)
|
|
||||||
|
|
||||||
pdf.SetFont("Arial", "", 12)
|
|
||||||
subtitle := fmt.Sprintf("%s bis %s", schoolYear.StartDate, schoolYear.EndDate)
|
|
||||||
pdf.Cell(0, 10, subtitle)
|
|
||||||
pdf.Ln(15)
|
|
||||||
|
|
||||||
pdf.SetFont("Arial", "B", 10)
|
|
||||||
pdf.SetFillColor(52, 152, 219)
|
|
||||||
pdf.SetTextColor(255, 255, 255)
|
|
||||||
|
|
||||||
colWidths := []float64{60, 40, 40, 40}
|
|
||||||
headers := []string{"Mitarbeiter", "Soll (Std.)", "Ist (Std.)", "Differenz (Std.)"}
|
|
||||||
|
|
||||||
for i, header := range headers {
|
|
||||||
pdf.CellFormat(colWidths[i], 10, header, "1", 0, "C", true, 0, "")
|
|
||||||
}
|
|
||||||
pdf.Ln(-1)
|
|
||||||
|
|
||||||
pdf.SetFont("Arial", "", 10)
|
|
||||||
pdf.SetTextColor(0, 0, 0)
|
|
||||||
fill := false
|
|
||||||
|
|
||||||
for _, entry := range summary {
|
|
||||||
if fill {
|
|
||||||
pdf.SetFillColor(240, 240, 240)
|
|
||||||
} else {
|
|
||||||
pdf.SetFillColor(255, 255, 255)
|
|
||||||
}
|
|
||||||
|
|
||||||
pdf.CellFormat(colWidths[0], 8, entry.Username, "1", 0, "L", true, 0, "")
|
|
||||||
|
|
||||||
pdf.CellFormat(colWidths[1], 8, fmt.Sprintf("%.1f", entry.YearlyTarget), "1", 0, "R", true, 0, "")
|
|
||||||
|
|
||||||
pdf.CellFormat(colWidths[2], 8, fmt.Sprintf("%.1f", entry.YearlyActual), "1", 0, "R", true, 0, "")
|
|
||||||
|
|
||||||
diffStr := fmt.Sprintf("%.1f", entry.RemainingYearly)
|
|
||||||
if entry.RemainingYearly > 0 {
|
|
||||||
pdf.SetTextColor(220, 53, 69)
|
|
||||||
} else {
|
|
||||||
pdf.SetTextColor(40, 167, 69)
|
|
||||||
}
|
|
||||||
pdf.CellFormat(colWidths[3], 8, diffStr, "1", 0, "R", true, 0, "")
|
|
||||||
pdf.SetTextColor(0, 0, 0)
|
|
||||||
|
|
||||||
pdf.Ln(-1)
|
|
||||||
fill = !fill
|
|
||||||
}
|
|
||||||
|
|
||||||
pdf.Ln(5)
|
|
||||||
pdf.SetFont("Arial", "B", 10)
|
|
||||||
|
|
||||||
totalTarget := 0.0
|
|
||||||
totalActual := 0.0
|
|
||||||
totalRemaining := 0.0
|
|
||||||
|
|
||||||
for _, entry := range summary {
|
|
||||||
totalTarget += entry.YearlyTarget
|
|
||||||
totalActual += entry.YearlyActual
|
|
||||||
totalRemaining += entry.RemainingYearly
|
|
||||||
}
|
|
||||||
|
|
||||||
pdf.SetFillColor(52, 152, 219)
|
|
||||||
pdf.SetTextColor(255, 255, 255)
|
|
||||||
|
|
||||||
pdf.CellFormat(colWidths[0], 10, "GESAMT", "1", 0, "L", true, 0, "")
|
|
||||||
pdf.CellFormat(colWidths[1], 10, fmt.Sprintf("%.1f", totalTarget), "1", 0, "R", true, 0, "")
|
|
||||||
pdf.CellFormat(colWidths[2], 10, fmt.Sprintf("%.1f", totalActual), "1", 0, "R", true, 0, "")
|
|
||||||
pdf.CellFormat(colWidths[3], 10, fmt.Sprintf("%.1f", totalRemaining), "1", 0, "R", true, 0, "")
|
|
||||||
|
|
||||||
pdf.Ln(15)
|
|
||||||
pdf.SetFont("Arial", "I", 8)
|
|
||||||
pdf.SetTextColor(128, 128, 128)
|
|
||||||
pdf.Cell(0, 10, fmt.Sprintf("Erstellt am: %s", time.Now().Format("02.01.2006 15:04")))
|
|
||||||
|
|
||||||
var buf []byte
|
|
||||||
w := &pdfWriter{buf: &buf}
|
|
||||||
err := pdf.Output(w)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return buf, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type pdfWriter struct {
|
|
||||||
buf *[]byte
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *pdfWriter) Write(p []byte) (n int, err error) {
|
|
||||||
*w.buf = append(*w.buf, p...)
|
|
||||||
return len(p), nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,338 +1,36 @@
|
||||||
<!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.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
<title>Schulzeit Erfassung</title>
|
||||||
<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://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>
|
||||||
/* Toast-Container */
|
html, body {
|
||||||
.toast-container {
|
height: 100%;
|
||||||
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="elm"></div>
|
<div id="app"></div>
|
||||||
|
|
||||||
<script src="/elm.js"></script>
|
<script src="/elm.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function getStoredData() {
|
var storedToken = localStorage.getItem('authToken');
|
||||||
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};
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveData(token, isAdmin) {
|
var app = Elm.Main.init({
|
||||||
try {
|
node: document.getElementById('app'),
|
||||||
localStorage.setItem(
|
flags: storedToken
|
||||||
"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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.ports.saveToken.subscribe(function (data) {
|
// Save token to localStorage
|
||||||
saveData(data.token, data.isAdmin);
|
app.ports.saveToken.subscribe(function(token) {
|
||||||
|
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>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,17 @@
|
||||||
|
# version: '3.8'
|
||||||
|
|
||||||
|
# services:
|
||||||
|
# timetracking:
|
||||||
|
# build: .
|
||||||
|
# container_name: school-timetracking
|
||||||
|
# ports:
|
||||||
|
# - "8080:8080"
|
||||||
|
# volumes:
|
||||||
|
# - ./data:/data
|
||||||
|
# environment:
|
||||||
|
# - PORT=8080
|
||||||
|
# - DB_PATH=/data/timetracking.db
|
||||||
|
# restart: unless-stopped
|
||||||
services:
|
services:
|
||||||
timetracking:
|
timetracking:
|
||||||
build: .
|
build: .
|
||||||
|
|
@ -7,8 +21,6 @@ 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
|
||||||
|
|
@ -22,3 +34,4 @@ volumes:
|
||||||
networks:
|
networks:
|
||||||
timetracking-net:
|
timetracking-net:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,19 @@
|
||||||
{
|
{
|
||||||
"type": "application",
|
"type": "application",
|
||||||
"source-directories": [
|
"source-directories": ["src"],
|
||||||
"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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,338 +1,36 @@
|
||||||
<!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.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
<title>Schulzeit Erfassung</title>
|
||||||
<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://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>
|
||||||
/* Toast-Container */
|
html, body {
|
||||||
.toast-container {
|
height: 100%;
|
||||||
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="elm"></div>
|
<div id="app"></div>
|
||||||
|
|
||||||
<script src="/elm.js"></script>
|
<script src="/elm.js"></script>
|
||||||
<script>
|
<script>
|
||||||
function getStoredData() {
|
var storedToken = localStorage.getItem('authToken');
|
||||||
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};
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveData(token, isAdmin) {
|
var app = Elm.Main.init({
|
||||||
try {
|
node: document.getElementById('app'),
|
||||||
localStorage.setItem(
|
flags: storedToken
|
||||||
"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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.ports.saveToken.subscribe(function (data) {
|
// Save token to localStorage
|
||||||
saveData(data.token, data.isAdmin);
|
app.ports.saveToken.subscribe(function(token) {
|
||||||
|
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>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
module Api.Auth exposing (loginRequest)
|
|
||||||
|
|
||||||
import Api.Decoders exposing (loginDecoder)
|
|
||||||
import Http
|
|
||||||
import Json.Encode as Encode
|
|
||||||
import Types.Api exposing (LoginResult)
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
|
|
||||||
|
|
||||||
loginRequest : String -> String -> Cmd Msg
|
|
||||||
loginRequest username password =
|
|
||||||
Http.post
|
|
||||||
{ url = "/api/login"
|
|
||||||
, body =
|
|
||||||
Http.jsonBody <|
|
|
||||||
Encode.object
|
|
||||||
[ ( "username", Encode.string username )
|
|
||||||
, ( "password", Encode.string password )
|
|
||||||
]
|
|
||||||
, expect = Http.expectJson LoginResponse loginDecoder
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
module Api.Decoders exposing
|
|
||||||
( apiErrorDecoder
|
|
||||||
, loginDecoder
|
|
||||||
, scheduleDecoder
|
|
||||||
, schoolYearDecoder
|
|
||||||
, timeEntryDecoder
|
|
||||||
, userDecoder
|
|
||||||
, weekDatesDecoder
|
|
||||||
, weeklyHoursDecoder
|
|
||||||
, yearlyHoursSummaryDecoder
|
|
||||||
)
|
|
||||||
|
|
||||||
import Dict
|
|
||||||
import Json.Decode as Decode exposing (Decoder, bool, field, float, int, list, string)
|
|
||||||
import Types.Api exposing (ApiError, LoginResult)
|
|
||||||
import Types.Model exposing (..)
|
|
||||||
|
|
||||||
|
|
||||||
loginDecoder : Decoder LoginResult
|
|
||||||
loginDecoder =
|
|
||||||
Decode.map3 LoginResult
|
|
||||||
(field "token" string)
|
|
||||||
(field "username" string)
|
|
||||||
(field "is_admin" bool)
|
|
||||||
|
|
||||||
|
|
||||||
scheduleDecoder : Decoder Schedule
|
|
||||||
scheduleDecoder =
|
|
||||||
Decode.map6 Schedule
|
|
||||||
(field "id" int)
|
|
||||||
(field "day_of_week" int)
|
|
||||||
(field "start_time" string)
|
|
||||||
(field "end_time" string)
|
|
||||||
(field "type" string)
|
|
||||||
(field "title" string)
|
|
||||||
|
|
||||||
|
|
||||||
timeEntryDecoder : Decoder TimeEntry
|
|
||||||
timeEntryDecoder =
|
|
||||||
Decode.map8 TimeEntry
|
|
||||||
(field "id" int)
|
|
||||||
(field "user_id" int)
|
|
||||||
(field "schedule_id" int)
|
|
||||||
(field "date" string)
|
|
||||||
(field "type" string)
|
|
||||||
(field "username" string)
|
|
||||||
(field "start_time" string)
|
|
||||||
(field "end_time" string)
|
|
||||||
|
|
||||||
|
|
||||||
userDecoder : Decoder User
|
|
||||||
userDecoder =
|
|
||||||
Decode.map4 User
|
|
||||||
(field "id" int)
|
|
||||||
(field "username" string)
|
|
||||||
(field "is_admin" bool)
|
|
||||||
(field "yearly_hours" float)
|
|
||||||
|
|
||||||
|
|
||||||
weekDatesDecoder : Decoder WeekDates
|
|
||||||
weekDatesDecoder =
|
|
||||||
Decode.map4 WeekDates
|
|
||||||
(field "year" int)
|
|
||||||
(field "week" int)
|
|
||||||
(field "dates" (Decode.dict string) |> Decode.map Dict.toList)
|
|
||||||
(field "range" string)
|
|
||||||
|
|
||||||
|
|
||||||
weeklyHoursDecoder : Decoder WeeklyHours
|
|
||||||
weeklyHoursDecoder =
|
|
||||||
Decode.map7 WeeklyHours
|
|
||||||
(field "user_id" int)
|
|
||||||
(field "username" string)
|
|
||||||
(field "year" int)
|
|
||||||
(field "week" int)
|
|
||||||
(field "total_hours" float)
|
|
||||||
(field "expected_hours" float)
|
|
||||||
(field "remaining_hours" float)
|
|
||||||
|
|
||||||
|
|
||||||
yearlyHoursSummaryDecoder : Decoder YearlyHoursSummary
|
|
||||||
yearlyHoursSummaryDecoder =
|
|
||||||
Decode.succeed YearlyHoursSummary
|
|
||||||
|> Decode.andThen (\f -> Decode.map f (field "user_id" int))
|
|
||||||
|> Decode.andThen (\f -> Decode.map f (field "username" string))
|
|
||||||
|> Decode.andThen (\f -> Decode.map f (field "year" int))
|
|
||||||
|> Decode.andThen (\f -> Decode.map f (field "week" int))
|
|
||||||
|> Decode.andThen (\f -> Decode.map f (field "total_hours" float))
|
|
||||||
|> Decode.andThen (\f -> Decode.map f (field "yearly_target" float))
|
|
||||||
|> Decode.andThen (\f -> Decode.map f (field "yearly_actual" float))
|
|
||||||
|> Decode.andThen (\f -> Decode.map f (field "weekly_target" float))
|
|
||||||
|> Decode.andThen (\f -> Decode.map f (field "remaining_yearly" float))
|
|
||||||
|
|
||||||
|
|
||||||
schoolYearDecoder : Decoder SchoolYear
|
|
||||||
schoolYearDecoder =
|
|
||||||
Decode.map5 SchoolYear
|
|
||||||
(field "id" int)
|
|
||||||
(field "name" string)
|
|
||||||
(field "start_date" string)
|
|
||||||
(field "end_date" string)
|
|
||||||
(field "is_active" bool)
|
|
||||||
|
|
||||||
|
|
||||||
apiErrorDecoder : Decoder ApiError
|
|
||||||
apiErrorDecoder =
|
|
||||||
Decode.map2 ApiError
|
|
||||||
(field "code" string)
|
|
||||||
(field "message" string)
|
|
||||||
|
|
@ -1,120 +0,0 @@
|
||||||
module Api.Schedule exposing
|
|
||||||
( createSchedule
|
|
||||||
, deleteSchedule
|
|
||||||
, fetchSchedules
|
|
||||||
, saveTimeEntriesForWeek
|
|
||||||
)
|
|
||||||
|
|
||||||
import Api.Decoders exposing (scheduleDecoder)
|
|
||||||
import Http
|
|
||||||
import Json.Decode
|
|
||||||
import Json.Encode as Encode
|
|
||||||
import Types.Model exposing (NewSchedule, Schedule, SelectedEntry, WeekDates)
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
|
|
||||||
|
|
||||||
fetchSchedules : Maybe String -> Cmd Msg
|
|
||||||
fetchSchedules maybeToken =
|
|
||||||
case maybeToken of
|
|
||||||
Just token ->
|
|
||||||
Http.request
|
|
||||||
{ method = "GET"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/schedules"
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectJson SchedulesReceived (Json.Decode.list scheduleDecoder)
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
Cmd.none
|
|
||||||
|
|
||||||
|
|
||||||
createSchedule : String -> NewSchedule -> Cmd Msg
|
|
||||||
createSchedule token schedule =
|
|
||||||
case String.toInt schedule.dayOfWeek of
|
|
||||||
Just day ->
|
|
||||||
Http.request
|
|
||||||
{ method = "POST"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/schedules"
|
|
||||||
, body =
|
|
||||||
Http.jsonBody <|
|
|
||||||
Encode.object
|
|
||||||
[ ( "day_of_week", Encode.int day )
|
|
||||||
, ( "start_time", Encode.string schedule.startTime )
|
|
||||||
, ( "end_time", Encode.string schedule.endTime )
|
|
||||||
, ( "type", Encode.string schedule.scheduleType )
|
|
||||||
, ( "title", Encode.string schedule.title )
|
|
||||||
]
|
|
||||||
, expect = Http.expectWhatever ScheduleCreated
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
Cmd.none
|
|
||||||
|
|
||||||
|
|
||||||
deleteSchedule : String -> Int -> Cmd Msg
|
|
||||||
deleteSchedule token scheduleId =
|
|
||||||
Http.request
|
|
||||||
{ method = "DELETE"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/schedules/delete?id=" ++ String.fromInt scheduleId
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectWhatever ScheduleDeleted
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
saveTimeEntriesForWeek : String -> List SelectedEntry -> Int -> Int -> List Schedule -> Maybe WeekDates -> Cmd Msg
|
|
||||||
saveTimeEntriesForWeek token selectedEntries year week schedules maybeWeekDates =
|
|
||||||
case maybeWeekDates of
|
|
||||||
Nothing ->
|
|
||||||
Cmd.none
|
|
||||||
|
|
||||||
Just weekDates ->
|
|
||||||
let
|
|
||||||
getScheduleById id =
|
|
||||||
List.filter (\s -> s.id == id) schedules |> List.head
|
|
||||||
|
|
||||||
getDateForDay dayOfWeek =
|
|
||||||
weekDates.dates
|
|
||||||
|> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek)
|
|
||||||
|> List.head
|
|
||||||
|> Maybe.map Tuple.second
|
|
||||||
|
|
||||||
createEntryData entry =
|
|
||||||
case ( getScheduleById entry.scheduleId, getDateForDay entry.dayOfWeek ) of
|
|
||||||
( Just schedule, Just dateStr ) ->
|
|
||||||
Just <|
|
|
||||||
Encode.object
|
|
||||||
[ ( "schedule_id", Encode.int entry.scheduleId )
|
|
||||||
, ( "date", Encode.string dateStr )
|
|
||||||
, ( "type", Encode.string schedule.scheduleType )
|
|
||||||
, ( "start_time", Encode.string schedule.startTime )
|
|
||||||
, ( "end_time", Encode.string schedule.endTime )
|
|
||||||
]
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
Nothing
|
|
||||||
|
|
||||||
entriesData =
|
|
||||||
List.filterMap createEntryData selectedEntries
|
|
||||||
in
|
|
||||||
if List.isEmpty entriesData then
|
|
||||||
Cmd.none
|
|
||||||
|
|
||||||
else
|
|
||||||
Http.request
|
|
||||||
{ method = "POST"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/time-entries/batch"
|
|
||||||
, body = Http.jsonBody <| Encode.object [ ( "entries", Encode.list identity entriesData ) ]
|
|
||||||
, expect = Http.expectWhatever TimeEntriesSaved
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
module Api.SchoolYear exposing
|
|
||||||
( activateSchoolYear
|
|
||||||
, createSchoolYear
|
|
||||||
, deleteSchoolYear
|
|
||||||
, fetchActiveSchoolYear
|
|
||||||
, fetchSchoolYears
|
|
||||||
)
|
|
||||||
|
|
||||||
import Api.Decoders exposing (schoolYearDecoder)
|
|
||||||
import Http
|
|
||||||
import Json.Decode as Decode
|
|
||||||
import Json.Encode as Encode
|
|
||||||
import Types.Model exposing (NewSchoolYear)
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
|
|
||||||
|
|
||||||
fetchSchoolYears : String -> Cmd Msg
|
|
||||||
fetchSchoolYears token =
|
|
||||||
Http.request
|
|
||||||
{ method = "GET"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/school-years"
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectJson SchoolYearsReceived (Decode.list schoolYearDecoder)
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fetchActiveSchoolYear : String -> Cmd Msg
|
|
||||||
fetchActiveSchoolYear token =
|
|
||||||
Http.request
|
|
||||||
{ method = "GET"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/school-year/active"
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectJson ActiveSchoolYearReceived schoolYearDecoder
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
createSchoolYear : String -> NewSchoolYear -> Cmd Msg
|
|
||||||
createSchoolYear token schoolYear =
|
|
||||||
Http.request
|
|
||||||
{ method = "POST"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/school-years"
|
|
||||||
, body =
|
|
||||||
Http.jsonBody <|
|
|
||||||
Encode.object
|
|
||||||
[ ( "name", Encode.string schoolYear.name )
|
|
||||||
, ( "start_date", Encode.string schoolYear.startDate )
|
|
||||||
, ( "end_date", Encode.string schoolYear.endDate )
|
|
||||||
]
|
|
||||||
, expect = Http.expectWhatever SchoolYearCreated
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
activateSchoolYear : String -> Int -> Cmd Msg
|
|
||||||
activateSchoolYear token id =
|
|
||||||
Http.request
|
|
||||||
{ method = "PUT"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/school-years/" ++ String.fromInt id ++ "/activate"
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectWhatever SchoolYearActivated
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
deleteSchoolYear : String -> Int -> Cmd Msg
|
|
||||||
deleteSchoolYear token id =
|
|
||||||
Http.request
|
|
||||||
{ method = "DELETE"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/school-years/" ++ String.fromInt id
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectWhatever SchoolYearDeleted
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
module Api.TimeEntry exposing
|
|
||||||
( checkWeekHasEntries
|
|
||||||
, createAdminTimeEntry
|
|
||||||
, deleteTimeEntry
|
|
||||||
, deleteWeekEntries
|
|
||||||
, downloadYearlySummaryPDF
|
|
||||||
, fetchAllTimeEntries
|
|
||||||
, fetchMyTimeEntries
|
|
||||||
, fetchWeekDates
|
|
||||||
, fetchWeeklyHours
|
|
||||||
, fetchYearlyHoursSummary
|
|
||||||
, updateTimeEntry
|
|
||||||
)
|
|
||||||
|
|
||||||
import Api.Decoders exposing (timeEntryDecoder, weekDatesDecoder, yearlyHoursSummaryDecoder)
|
|
||||||
import Bytes exposing (Bytes)
|
|
||||||
import Http
|
|
||||||
import Json.Decode as Decode exposing (bool, field)
|
|
||||||
import Json.Encode as Encode
|
|
||||||
import Types.Model exposing (AdminManualEntry, EditingTimeEntry)
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
|
|
||||||
|
|
||||||
fetchMyTimeEntries : String -> Cmd Msg
|
|
||||||
fetchMyTimeEntries token =
|
|
||||||
Http.request
|
|
||||||
{ method = "GET"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/my-time-entries"
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectJson MyTimeEntriesReceived (Decode.list timeEntryDecoder)
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fetchAllTimeEntries : String -> Cmd Msg
|
|
||||||
fetchAllTimeEntries token =
|
|
||||||
Http.request
|
|
||||||
{ method = "GET"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/time-entries"
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectJson AllTimeEntriesReceived (Decode.list timeEntryDecoder)
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fetchWeekDates : String -> Int -> Int -> Cmd Msg
|
|
||||||
fetchWeekDates token year week =
|
|
||||||
Http.request
|
|
||||||
{ method = "GET"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/week-dates?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectJson WeekDatesReceived weekDatesDecoder
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
checkWeekHasEntries : String -> Int -> Int -> Cmd Msg
|
|
||||||
checkWeekHasEntries token year week =
|
|
||||||
Http.request
|
|
||||||
{ method = "GET"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/week-has-entries?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectJson WeekHasEntriesReceived (field "has_entries" bool)
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
deleteWeekEntries : String -> Int -> Int -> Cmd Msg
|
|
||||||
deleteWeekEntries token year week =
|
|
||||||
Http.request
|
|
||||||
{ method = "DELETE"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/my-time-entries/week?year=" ++ String.fromInt year ++ "&week=" ++ String.fromInt week
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectWhatever WeekEntriesDeleted
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
updateTimeEntry : String -> EditingTimeEntry -> Cmd Msg
|
|
||||||
updateTimeEntry token entry =
|
|
||||||
Http.request
|
|
||||||
{ method = "PUT"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/time-entries/" ++ String.fromInt entry.entryId
|
|
||||||
, body =
|
|
||||||
Http.jsonBody <|
|
|
||||||
Encode.object
|
|
||||||
[ ( "date", Encode.string entry.date )
|
|
||||||
, ( "start_time", Encode.string entry.startTime )
|
|
||||||
, ( "end_time", Encode.string entry.endTime )
|
|
||||||
, ( "type", Encode.string entry.entryType )
|
|
||||||
]
|
|
||||||
, expect = Http.expectWhatever TimeEntrySaved
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
deleteTimeEntry : String -> Int -> Cmd Msg
|
|
||||||
deleteTimeEntry token entryId =
|
|
||||||
Http.request
|
|
||||||
{ method = "DELETE"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/time-entries/" ++ String.fromInt entryId
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectWhatever TimeEntryDeleted
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
createAdminTimeEntry : String -> AdminManualEntry -> Cmd Msg
|
|
||||||
createAdminTimeEntry token entry =
|
|
||||||
case entry.selectedUserId of
|
|
||||||
Just userId ->
|
|
||||||
Http.request
|
|
||||||
{ method = "POST"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/time-entry"
|
|
||||||
, body =
|
|
||||||
Http.jsonBody <|
|
|
||||||
Encode.object
|
|
||||||
[ ( "user_id", Encode.int userId )
|
|
||||||
, ( "date", Encode.string entry.date )
|
|
||||||
, ( "hours", Encode.float (String.toFloat entry.hours |> Maybe.withDefault 0) )
|
|
||||||
, ( "type", Encode.string "manual" )
|
|
||||||
]
|
|
||||||
, expect = Http.expectWhatever AdminTimeEntrySaved
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
Cmd.none
|
|
||||||
|
|
||||||
|
|
||||||
fetchYearlyHoursSummary : String -> Cmd Msg
|
|
||||||
fetchYearlyHoursSummary token =
|
|
||||||
Http.request
|
|
||||||
{ method = "GET"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/yearly-hours-summary"
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectJson YearlyHoursSummaryReceived (Decode.list yearlyHoursSummaryDecoder)
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
downloadYearlySummaryPDF : String -> Cmd Msg
|
|
||||||
downloadYearlySummaryPDF token =
|
|
||||||
Http.request
|
|
||||||
{ method = "GET"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/yearly-summary/pdf"
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect =
|
|
||||||
Http.expectBytesResponse YearlySummaryPDFReceived
|
|
||||||
(\response ->
|
|
||||||
case response of
|
|
||||||
Http.GoodStatus_ _ body ->
|
|
||||||
Ok body
|
|
||||||
|
|
||||||
Http.BadUrl_ url ->
|
|
||||||
Err (Http.BadUrl url)
|
|
||||||
|
|
||||||
Http.Timeout_ ->
|
|
||||||
Err Http.Timeout
|
|
||||||
|
|
||||||
Http.NetworkError_ ->
|
|
||||||
Err Http.NetworkError
|
|
||||||
|
|
||||||
Http.BadStatus_ metadata _ ->
|
|
||||||
Err (Http.BadStatus metadata.statusCode)
|
|
||||||
)
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fetchWeeklyHours : String -> Cmd Msg
|
|
||||||
fetchWeeklyHours token =
|
|
||||||
Http.request
|
|
||||||
{ method = "GET"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/weekly-hours"
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectJson WeeklyHoursReceived (Decode.list Api.Decoders.weeklyHoursDecoder)
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
module Api.User exposing
|
|
||||||
( createUser
|
|
||||||
, deleteUser
|
|
||||||
, fetchMyInfo
|
|
||||||
, fetchUsers
|
|
||||||
, resetUserPassword
|
|
||||||
, updateUserWorkHours
|
|
||||||
)
|
|
||||||
|
|
||||||
import Api.Decoders exposing (userDecoder)
|
|
||||||
import Http
|
|
||||||
import Json.Decode as Decode
|
|
||||||
import Json.Encode as Encode
|
|
||||||
import Types.Model exposing (NewUser)
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
|
|
||||||
|
|
||||||
fetchUsers : String -> Cmd Msg
|
|
||||||
fetchUsers token =
|
|
||||||
Http.request
|
|
||||||
{ method = "GET"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/users/list"
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectJson UsersReceived (Decode.list userDecoder)
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
fetchMyInfo : String -> Cmd Msg
|
|
||||||
fetchMyInfo token =
|
|
||||||
Http.request
|
|
||||||
{ method = "GET"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/my-info"
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectJson MyInfoReceived userDecoder
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
createUser : String -> NewUser -> Cmd Msg
|
|
||||||
createUser token user =
|
|
||||||
Http.request
|
|
||||||
{ method = "POST"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/users"
|
|
||||||
, body =
|
|
||||||
Http.jsonBody <|
|
|
||||||
Encode.object
|
|
||||||
[ ( "username", Encode.string user.username )
|
|
||||||
, ( "password", Encode.string user.password )
|
|
||||||
, ( "is_admin", Encode.bool user.isAdmin )
|
|
||||||
]
|
|
||||||
, expect = Http.expectWhatever UserCreated
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
deleteUser : String -> Int -> Cmd Msg
|
|
||||||
deleteUser token userId =
|
|
||||||
Http.request
|
|
||||||
{ method = "DELETE"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/users/delete?id=" ++ String.fromInt userId
|
|
||||||
, body = Http.emptyBody
|
|
||||||
, expect = Http.expectWhatever UserDeleted
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
updateUserWorkHours : String -> Int -> String -> Cmd Msg
|
|
||||||
updateUserWorkHours token userId hours =
|
|
||||||
case String.toFloat hours of
|
|
||||||
Just workHours ->
|
|
||||||
Http.request
|
|
||||||
{ method = "PUT"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/users/" ++ String.fromInt userId
|
|
||||||
, body =
|
|
||||||
Http.jsonBody <|
|
|
||||||
Encode.object
|
|
||||||
[ ( "yearly_hours", Encode.float workHours ) ]
|
|
||||||
, expect = Http.expectWhatever UserWorkHoursSaved
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
Cmd.none
|
|
||||||
|
|
||||||
|
|
||||||
resetUserPassword : String -> Int -> String -> Cmd Msg
|
|
||||||
resetUserPassword token userId newPassword =
|
|
||||||
Http.request
|
|
||||||
{ method = "PUT"
|
|
||||||
, headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
|
|
||||||
, url = "/api/admin/users/" ++ String.fromInt userId ++ "/reset-password"
|
|
||||||
, body =
|
|
||||||
Http.jsonBody <|
|
|
||||||
Encode.object
|
|
||||||
[ ( "new_password", Encode.string newPassword ) ]
|
|
||||||
, expect = Http.expectWhatever ResetPasswordSaved
|
|
||||||
, timeout = Nothing
|
|
||||||
, tracker = Nothing
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +0,0 @@
|
||||||
module Types.Api exposing
|
|
||||||
( ApiError
|
|
||||||
, LoginResult
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
type alias LoginResult =
|
|
||||||
{ token : String
|
|
||||||
, username : String
|
|
||||||
, isAdmin : Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias ApiError =
|
|
||||||
{ code : String
|
|
||||||
, message : String
|
|
||||||
}
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
module Types.Model exposing
|
|
||||||
( AdminManualEntry
|
|
||||||
, EditingTimeEntry
|
|
||||||
, Flags
|
|
||||||
, Model
|
|
||||||
, NewSchedule
|
|
||||||
, NewSchoolYear
|
|
||||||
, NewUser
|
|
||||||
, Schedule
|
|
||||||
, SchoolYear
|
|
||||||
, SelectedEntry
|
|
||||||
, TimeEntry
|
|
||||||
, Toast
|
|
||||||
, ToastType(..)
|
|
||||||
, User
|
|
||||||
, WeekDates
|
|
||||||
, WeeklyHours
|
|
||||||
, WeeklySummary
|
|
||||||
, YearlyHoursSummary
|
|
||||||
)
|
|
||||||
|
|
||||||
import Time
|
|
||||||
import Types.Page exposing (AdminTab, Page)
|
|
||||||
|
|
||||||
|
|
||||||
type alias Model =
|
|
||||||
{ page : Page
|
|
||||||
, activeTab : AdminTab
|
|
||||||
, username : String
|
|
||||||
, password : String
|
|
||||||
, token : Maybe String
|
|
||||||
, isAdmin : Bool
|
|
||||||
, schedules : List Schedule
|
|
||||||
, users : List User
|
|
||||||
, timeEntries : List TimeEntry
|
|
||||||
, weeklyHours : List WeeklyHours
|
|
||||||
, yearlyHoursSummary : List YearlyHoursSummary
|
|
||||||
, selectedEntries : List SelectedEntry
|
|
||||||
, currentWeek : Int
|
|
||||||
, currentYear : Int
|
|
||||||
, weekDates : Maybe WeekDates
|
|
||||||
, currentTime : Time.Posix
|
|
||||||
, zone : Time.Zone
|
|
||||||
, newSchedule : NewSchedule
|
|
||||||
, newUser : NewUser
|
|
||||||
, error : Maybe String
|
|
||||||
, weekEditMode : Bool
|
|
||||||
, hasEntriesForCurrentWeek : Bool
|
|
||||||
, userWeeklySummary : Maybe WeeklySummary
|
|
||||||
, editingTimeEntryId : Maybe Int
|
|
||||||
, editingTimeEntry : EditingTimeEntry
|
|
||||||
, editingUserId : Maybe Int
|
|
||||||
, editingUserWorkHours : String
|
|
||||||
, resetPasswordUserId : Maybe Int
|
|
||||||
, resetPasswordNew : String
|
|
||||||
, pendingDeleteId : Maybe Int
|
|
||||||
, selectedUserId : Maybe Int
|
|
||||||
, userWorkHoursInput : String
|
|
||||||
, userPasswordInput : String
|
|
||||||
, isProcessing : Bool
|
|
||||||
, mobileMenuOpen : Bool
|
|
||||||
, adminManualEntryForm : AdminManualEntry
|
|
||||||
, schoolYears : List SchoolYear
|
|
||||||
, newSchoolYear : NewSchoolYear
|
|
||||||
, activeSchoolYear : Maybe SchoolYear
|
|
||||||
, editingSchoolYearId : Maybe Int
|
|
||||||
, toasts : List Toast
|
|
||||||
, nextToastId : Int
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type ToastType
|
|
||||||
= ErrorToast
|
|
||||||
| SuccessToast
|
|
||||||
| InfoToast
|
|
||||||
| WarningToast
|
|
||||||
|
|
||||||
|
|
||||||
type alias Toast =
|
|
||||||
{ id : Int
|
|
||||||
, message : String
|
|
||||||
, toastType : ToastType
|
|
||||||
, dismissible : Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias Flags =
|
|
||||||
{ token : Maybe String
|
|
||||||
, isAdmin : Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias Schedule =
|
|
||||||
{ id : Int
|
|
||||||
, dayOfWeek : Int
|
|
||||||
, startTime : String
|
|
||||||
, endTime : String
|
|
||||||
, scheduleType : String
|
|
||||||
, title : String
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias User =
|
|
||||||
{ id : Int
|
|
||||||
, username : String
|
|
||||||
, isAdmin : Bool
|
|
||||||
, yearlyWorkHours : Float
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias TimeEntry =
|
|
||||||
{ id : Int
|
|
||||||
, userId : Int
|
|
||||||
, scheduleId : Int
|
|
||||||
, date : String
|
|
||||||
, entryType : String
|
|
||||||
, username : String
|
|
||||||
, startTime : String
|
|
||||||
, endTime : String
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias SelectedEntry =
|
|
||||||
{ scheduleId : Int
|
|
||||||
, dayOfWeek : Int
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias NewSchedule =
|
|
||||||
{ dayOfWeek : String
|
|
||||||
, startTime : String
|
|
||||||
, endTime : String
|
|
||||||
, scheduleType : String
|
|
||||||
, title : String
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias NewUser =
|
|
||||||
{ username : String
|
|
||||||
, password : String
|
|
||||||
, isAdmin : Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias WeekDates =
|
|
||||||
{ year : Int
|
|
||||||
, week : Int
|
|
||||||
, dates : List ( String, String )
|
|
||||||
, range : String
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias WeeklySummary =
|
|
||||||
{ userId : Int
|
|
||||||
, username : String
|
|
||||||
, year : Int
|
|
||||||
, week : Int
|
|
||||||
, totalHours : Float
|
|
||||||
, targetHours : Float
|
|
||||||
, remainingHours : Float
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias EditingTimeEntry =
|
|
||||||
{ entryId : Int
|
|
||||||
, date : String
|
|
||||||
, startTime : String
|
|
||||||
, endTime : String
|
|
||||||
, entryType : String
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias WeeklyHours =
|
|
||||||
{ userId : Int
|
|
||||||
, username : String
|
|
||||||
, year : Int
|
|
||||||
, week : Int
|
|
||||||
, totalHours : Float
|
|
||||||
, targetHours : Float
|
|
||||||
, remainingHours : Float
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias YearlyHoursSummary =
|
|
||||||
{ userId : Int
|
|
||||||
, username : String
|
|
||||||
, year : Int
|
|
||||||
, week : Int
|
|
||||||
, totalHours : Float
|
|
||||||
, yearlyTarget : Float
|
|
||||||
, yearlyActual : Float
|
|
||||||
, weeklyTarget : Float
|
|
||||||
, remainingYearly : Float
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias AdminManualEntry =
|
|
||||||
{ selectedUserId : Maybe Int
|
|
||||||
, date : String
|
|
||||||
, hours : String
|
|
||||||
, entryType : String
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias SchoolYear =
|
|
||||||
{ id : Int
|
|
||||||
, name : String
|
|
||||||
, startDate : String
|
|
||||||
, endDate : String
|
|
||||||
, isActive : Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
type alias NewSchoolYear =
|
|
||||||
{ name : String
|
|
||||||
, startDate : String
|
|
||||||
, endDate : String
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
module Types.Msg exposing (Msg(..))
|
|
||||||
|
|
||||||
import Bytes exposing (Bytes)
|
|
||||||
import Http
|
|
||||||
import Time
|
|
||||||
import Types.Api exposing (LoginResult)
|
|
||||||
import Types.Model
|
|
||||||
exposing
|
|
||||||
( Schedule
|
|
||||||
, SchoolYear
|
|
||||||
, TimeEntry
|
|
||||||
, ToastType(..)
|
|
||||||
, User
|
|
||||||
, WeekDates
|
|
||||||
, WeeklyHours
|
|
||||||
, WeeklySummary
|
|
||||||
, YearlyHoursSummary
|
|
||||||
)
|
|
||||||
import Types.Page exposing (AdminTab)
|
|
||||||
|
|
||||||
|
|
||||||
type Msg
|
|
||||||
= UpdateUsername String
|
|
||||||
| UpdatePassword String
|
|
||||||
| Login
|
|
||||||
| LoginResponse (Result Http.Error LoginResult)
|
|
||||||
| Logout
|
|
||||||
| SetTime Time.Posix
|
|
||||||
| FetchSchedules
|
|
||||||
| SchedulesReceived (Result Http.Error (List Schedule))
|
|
||||||
| ToggleScheduleSelection Int Int
|
|
||||||
| SaveTimeEntries
|
|
||||||
| TimeEntriesSaved (Result Http.Error ())
|
|
||||||
| PreviousWeek
|
|
||||||
| NextWeek
|
|
||||||
| EnableEditMode
|
|
||||||
| DisableEditMode
|
|
||||||
| DeleteWeekEntries
|
|
||||||
| WeekEntriesDeleted (Result Http.Error ())
|
|
||||||
| SwitchTab AdminTab
|
|
||||||
| UpdateNewScheduleDay String
|
|
||||||
| UpdateNewScheduleStart String
|
|
||||||
| UpdateNewScheduleEnd String
|
|
||||||
| UpdateNewScheduleType String
|
|
||||||
| UpdateNewScheduleTitle String
|
|
||||||
| CreateSchedule
|
|
||||||
| ScheduleCreated (Result Http.Error ())
|
|
||||||
| DeleteSchedule Int
|
|
||||||
| ScheduleDeleted (Result Http.Error ())
|
|
||||||
| UpdateNewUsername String
|
|
||||||
| UpdateNewPassword String
|
|
||||||
| UpdateNewUserAdmin Bool
|
|
||||||
| CreateUser
|
|
||||||
| UserCreated (Result Http.Error ())
|
|
||||||
| DeleteUser Int
|
|
||||||
| UserDeleted (Result Http.Error ())
|
|
||||||
| FetchUsers
|
|
||||||
| UsersReceived (Result Http.Error (List User))
|
|
||||||
| FetchMyTimeEntries
|
|
||||||
| MyTimeEntriesReceived (Result Http.Error (List TimeEntry))
|
|
||||||
| FetchAllTimeEntries
|
|
||||||
| AllTimeEntriesReceived (Result Http.Error (List TimeEntry))
|
|
||||||
| FetchWeeklyHours
|
|
||||||
| WeeklyHoursReceived (Result Http.Error (List WeeklyHours))
|
|
||||||
| FetchYearlyHoursSummary
|
|
||||||
| YearlyHoursSummaryReceived (Result Http.Error (List YearlyHoursSummary))
|
|
||||||
| FetchWeekDates
|
|
||||||
| WeekDatesReceived (Result Http.Error WeekDates)
|
|
||||||
| CheckWeekHasEntries
|
|
||||||
| WeekHasEntriesReceived (Result Http.Error Bool)
|
|
||||||
| MyWeeklySummaryReceived (Result Http.Error WeeklySummary)
|
|
||||||
| EditTimeEntry Int
|
|
||||||
| CancelEditTimeEntry
|
|
||||||
| UpdateEditTimeEntryDate String
|
|
||||||
| UpdateEditTimeEntryStartTime String
|
|
||||||
| UpdateEditTimeEntryEndTime String
|
|
||||||
| UpdateEditTimeEntryType String
|
|
||||||
| SaveEditTimeEntry
|
|
||||||
| TimeEntrySaved (Result Http.Error ())
|
|
||||||
| TimeEntryDeleted (Result Http.Error ())
|
|
||||||
| EditUserWorkHours Int
|
|
||||||
| CancelEditUserWorkHours
|
|
||||||
| UpdateEditUserWorkHours String
|
|
||||||
| SaveUserWorkHours
|
|
||||||
| UserWorkHoursSaved (Result Http.Error ())
|
|
||||||
| ResetUserPassword Int
|
|
||||||
| CancelResetPassword
|
|
||||||
| UpdateResetPasswordNew String
|
|
||||||
| SaveResetPassword
|
|
||||||
| ResetPasswordSaved (Result Http.Error ())
|
|
||||||
| ConfirmDeleteTimeEntry Int
|
|
||||||
| ConfirmDeleteUser Int
|
|
||||||
| DeleteConfirmed Bool
|
|
||||||
| StartEditingTimeEntry Int TimeEntry
|
|
||||||
| CancelEditingTimeEntry
|
|
||||||
| UpdateEditingTimeEntryDate String
|
|
||||||
| UpdateEditingTimeEntryStartTime String
|
|
||||||
| UpdateEditingTimeEntryEndTime String
|
|
||||||
| UpdateEditingTimeEntryType String
|
|
||||||
| SaveEditingTimeEntry
|
|
||||||
| SelectUserForManagement Int
|
|
||||||
| UpdateUserWorkHours String
|
|
||||||
| UpdateUserPassword String
|
|
||||||
| SaveUserPassword
|
|
||||||
| UserPasswordSaved (Result Http.Error ())
|
|
||||||
| ToggleMobileMenu
|
|
||||||
| CloseMobileMenu
|
|
||||||
| SelectUserForManualEntry Int
|
|
||||||
| UpdateManualEntryDate String
|
|
||||||
| UpdateManualEntryHours String
|
|
||||||
| UpdateManualEntryType String
|
|
||||||
| SaveAdminTimeEntry
|
|
||||||
| AdminTimeEntrySaved (Result Http.Error ())
|
|
||||||
| FetchMyInfo
|
|
||||||
| MyInfoReceived (Result Http.Error User)
|
|
||||||
| FetchSchoolYears
|
|
||||||
| SchoolYearsReceived (Result Http.Error (List SchoolYear))
|
|
||||||
| FetchActiveSchoolYear
|
|
||||||
| ActiveSchoolYearReceived (Result Http.Error SchoolYear)
|
|
||||||
| UpdateNewSchoolYearName String
|
|
||||||
| UpdateNewSchoolYearStart String
|
|
||||||
| UpdateNewSchoolYearEnd String
|
|
||||||
| CreateSchoolYear
|
|
||||||
| SchoolYearCreated (Result Http.Error ())
|
|
||||||
| ActivateSchoolYear Int
|
|
||||||
| SchoolYearActivated (Result Http.Error ())
|
|
||||||
| DeleteSchoolYear Int
|
|
||||||
| SchoolYearDeleted (Result Http.Error ())
|
|
||||||
| DownloadYearlySummaryPDF
|
|
||||||
| YearlySummaryPDFReceived (Result Http.Error Bytes)
|
|
||||||
| ShowToast String ToastType
|
|
||||||
| DismissToast Int
|
|
||||||
| AutoDismissToast Int
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
module Types.Page exposing
|
|
||||||
( AdminTab(..)
|
|
||||||
, Page(..)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
type Page
|
|
||||||
= LoginPage
|
|
||||||
| UserDashboard
|
|
||||||
| AdminDashboard
|
|
||||||
|
|
||||||
|
|
||||||
type AdminTab
|
|
||||||
= ScheduleTab
|
|
||||||
| UsersTab
|
|
||||||
| TimeEntriesTab
|
|
||||||
| SchoolYearsTab
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
module Update.AuthUpdate exposing
|
|
||||||
( handleLogin
|
|
||||||
, handleLoginResponse
|
|
||||||
, handleLogout
|
|
||||||
)
|
|
||||||
|
|
||||||
import Api.Auth
|
|
||||||
import Api.Schedule
|
|
||||||
import Api.SchoolYear
|
|
||||||
import Api.TimeEntry
|
|
||||||
import Api.User
|
|
||||||
import Http
|
|
||||||
import Json.Encode as Encode
|
|
||||||
import Task
|
|
||||||
import Types.Model exposing (Model, ToastType(..))
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
import Types.Page exposing (Page(..))
|
|
||||||
import Utils.DateUtils exposing (getISOWeekFromPosix)
|
|
||||||
import Utils.Ports exposing (removeToken, saveToken)
|
|
||||||
|
|
||||||
|
|
||||||
handleLogin : Model -> ( Model, Cmd Msg )
|
|
||||||
handleLogin model =
|
|
||||||
if model.isProcessing then
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
else
|
|
||||||
( { model | isProcessing = True }, Api.Auth.loginRequest model.username model.password )
|
|
||||||
|
|
||||||
|
|
||||||
handleLoginResponse : Result Http.Error { token : String, username : String, isAdmin : Bool } -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleLoginResponse result model =
|
|
||||||
case result of
|
|
||||||
Ok loginResult ->
|
|
||||||
let
|
|
||||||
newPage =
|
|
||||||
if loginResult.isAdmin then
|
|
||||||
AdminDashboard
|
|
||||||
|
|
||||||
else
|
|
||||||
UserDashboard
|
|
||||||
|
|
||||||
( year, week ) =
|
|
||||||
getISOWeekFromPosix model.currentTime
|
|
||||||
|
|
||||||
tokenData =
|
|
||||||
Encode.object
|
|
||||||
[ ( "token", Encode.string loginResult.token )
|
|
||||||
, ( "isAdmin", Encode.bool loginResult.isAdmin )
|
|
||||||
]
|
|
||||||
in
|
|
||||||
( { model
|
|
||||||
| token = Just loginResult.token
|
|
||||||
, username = loginResult.username
|
|
||||||
, isAdmin = loginResult.isAdmin
|
|
||||||
, page = newPage
|
|
||||||
, error = Nothing
|
|
||||||
, isProcessing = False
|
|
||||||
}
|
|
||||||
, Cmd.batch
|
|
||||||
[ saveToken tokenData
|
|
||||||
, Api.Schedule.fetchSchedules (Just loginResult.token)
|
|
||||||
, Task.perform (\_ -> ShowToast ("Willkommen, " ++ loginResult.username ++ "!") SuccessToast) (Task.succeed ())
|
|
||||||
, if not loginResult.isAdmin then
|
|
||||||
Cmd.batch
|
|
||||||
[ Api.TimeEntry.fetchMyTimeEntries loginResult.token
|
|
||||||
, Api.TimeEntry.fetchWeekDates loginResult.token year week
|
|
||||||
, Api.TimeEntry.checkWeekHasEntries loginResult.token year week
|
|
||||||
, Api.TimeEntry.fetchYearlyHoursSummary loginResult.token
|
|
||||||
, Api.User.fetchMyInfo loginResult.token
|
|
||||||
]
|
|
||||||
|
|
||||||
else
|
|
||||||
Cmd.batch
|
|
||||||
[ Api.TimeEntry.fetchMyTimeEntries loginResult.token
|
|
||||||
, Api.TimeEntry.fetchWeekDates loginResult.token year week
|
|
||||||
, Api.TimeEntry.checkWeekHasEntries loginResult.token year week
|
|
||||||
, Api.TimeEntry.fetchYearlyHoursSummary loginResult.token
|
|
||||||
]
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
let
|
|
||||||
errorMsg =
|
|
||||||
case err of
|
|
||||||
Http.BadStatus 401 ->
|
|
||||||
"Benutzername oder Passwort ungültig"
|
|
||||||
|
|
||||||
Http.Timeout ->
|
|
||||||
"Zeitüberschreitung - bitte erneut versuchen"
|
|
||||||
|
|
||||||
Http.NetworkError ->
|
|
||||||
"Netzwerkfehler - bitte Verbindung prüfen"
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
"Anmeldung fehlgeschlagen"
|
|
||||||
in
|
|
||||||
( { model | isProcessing = False }
|
|
||||||
, Task.perform (\_ -> ShowToast errorMsg ErrorToast) (Task.succeed ())
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
handleLogout : Model -> ( Model, Cmd Msg )
|
|
||||||
handleLogout model =
|
|
||||||
( { model
|
|
||||||
| page = LoginPage
|
|
||||||
, token = Nothing
|
|
||||||
, isAdmin = False
|
|
||||||
, username = ""
|
|
||||||
, password = ""
|
|
||||||
, isProcessing = False
|
|
||||||
}
|
|
||||||
, removeToken ()
|
|
||||||
)
|
|
||||||
|
|
@ -1,244 +0,0 @@
|
||||||
module Update.ScheduleUpdate exposing
|
|
||||||
( handleCreateSchedule
|
|
||||||
, handleDeleteSchedule
|
|
||||||
, handleDeleteWeekEntries
|
|
||||||
, handleDisableEditMode
|
|
||||||
, handleEnableEditMode
|
|
||||||
, handleSaveTimeEntries
|
|
||||||
, handleScheduleCreated
|
|
||||||
, handleScheduleDeleted
|
|
||||||
, handleSchedulesReceived
|
|
||||||
, handleTimeEntriesSaved
|
|
||||||
, handleToggleScheduleSelection
|
|
||||||
, handleWeekEntriesDeleted
|
|
||||||
)
|
|
||||||
|
|
||||||
import Api.Schedule
|
|
||||||
import Api.TimeEntry
|
|
||||||
import Http
|
|
||||||
import Task
|
|
||||||
import Types.Model exposing (Model, NewSchedule, Schedule, SelectedEntry, ToastType(..))
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
import Utils.DateUtils exposing (getDayOfWeek, getYearWeekFromDate)
|
|
||||||
|
|
||||||
|
|
||||||
handleToggleScheduleSelection : Int -> Int -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleToggleScheduleSelection scheduleId dayOfWeek model =
|
|
||||||
let
|
|
||||||
entry =
|
|
||||||
{ scheduleId = scheduleId, dayOfWeek = dayOfWeek }
|
|
||||||
|
|
||||||
newSelected =
|
|
||||||
if List.any (\e -> e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek) model.selectedEntries then
|
|
||||||
List.filter (\e -> not (e.scheduleId == scheduleId && e.dayOfWeek == dayOfWeek)) model.selectedEntries
|
|
||||||
|
|
||||||
else
|
|
||||||
entry :: model.selectedEntries
|
|
||||||
in
|
|
||||||
( { model | selectedEntries = newSelected }, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleSaveTimeEntries : Model -> ( Model, Cmd Msg )
|
|
||||||
handleSaveTimeEntries model =
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model | error = Nothing }
|
|
||||||
, Api.Schedule.saveTimeEntriesForWeek token model.selectedEntries model.currentYear model.currentWeek model.schedules model.weekDates
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleTimeEntriesSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleTimeEntriesSaved result model =
|
|
||||||
case result of
|
|
||||||
Ok _ ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model
|
|
||||||
| error = Nothing
|
|
||||||
, weekEditMode = False
|
|
||||||
, hasEntriesForCurrentWeek = True
|
|
||||||
}
|
|
||||||
, Cmd.batch
|
|
||||||
[ Api.TimeEntry.fetchMyTimeEntries token
|
|
||||||
, Task.perform (\_ -> ShowToast "Zeiteinträge erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleEnableEditMode : Model -> ( Model, Cmd Msg )
|
|
||||||
handleEnableEditMode model =
|
|
||||||
let
|
|
||||||
currentWeekEntries =
|
|
||||||
List.filter
|
|
||||||
(\e ->
|
|
||||||
let
|
|
||||||
( entryYear, entryWeek ) =
|
|
||||||
getYearWeekFromDate e.date
|
|
||||||
in
|
|
||||||
entryWeek == model.currentWeek && entryYear == model.currentYear
|
|
||||||
)
|
|
||||||
model.timeEntries
|
|
||||||
|
|
||||||
preSelectedEntries =
|
|
||||||
List.map
|
|
||||||
(\entry ->
|
|
||||||
let
|
|
||||||
parts =
|
|
||||||
String.split "-" entry.date
|
|
||||||
|
|
||||||
year =
|
|
||||||
parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025
|
|
||||||
|
|
||||||
month =
|
|
||||||
parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
|
|
||||||
|
|
||||||
day =
|
|
||||||
parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
|
|
||||||
|
|
||||||
dayOfWeek =
|
|
||||||
getDayOfWeek year month day
|
|
||||||
in
|
|
||||||
{ scheduleId = entry.scheduleId, dayOfWeek = dayOfWeek }
|
|
||||||
)
|
|
||||||
currentWeekEntries
|
|
||||||
in
|
|
||||||
( { model
|
|
||||||
| weekEditMode = True
|
|
||||||
, selectedEntries = preSelectedEntries
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
handleDisableEditMode : Model -> ( Model, Cmd Msg )
|
|
||||||
handleDisableEditMode model =
|
|
||||||
( { model | weekEditMode = False }, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleDeleteWeekEntries : Model -> ( Model, Cmd Msg )
|
|
||||||
handleDeleteWeekEntries model =
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.TimeEntry.deleteWeekEntries token model.currentYear model.currentWeek )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleWeekEntriesDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleWeekEntriesDeleted result model =
|
|
||||||
case result of
|
|
||||||
Ok _ ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model
|
|
||||||
| weekEditMode = True
|
|
||||||
, selectedEntries = []
|
|
||||||
, hasEntriesForCurrentWeek = False
|
|
||||||
}
|
|
||||||
, Cmd.batch
|
|
||||||
[ Api.TimeEntry.fetchMyTimeEntries token
|
|
||||||
, Task.perform (\_ -> ShowToast "Wocheneinträge erfolgreich gelöscht" SuccessToast) (Task.succeed ())
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleCreateSchedule : Model -> ( Model, Cmd Msg )
|
|
||||||
handleCreateSchedule model =
|
|
||||||
if
|
|
||||||
String.isEmpty model.newSchedule.dayOfWeek
|
|
||||||
|| String.isEmpty model.newSchedule.startTime
|
|
||||||
|| String.isEmpty model.newSchedule.endTime
|
|
||||||
then
|
|
||||||
( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) )
|
|
||||||
|
|
||||||
else
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model | isProcessing = True }, Api.Schedule.createSchedule token model.newSchedule )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleScheduleCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleScheduleCreated result model =
|
|
||||||
case result of
|
|
||||||
Ok _ ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
let
|
|
||||||
emptySchedule =
|
|
||||||
NewSchedule "" "" "" "lesson" ""
|
|
||||||
in
|
|
||||||
( { model
|
|
||||||
| newSchedule = emptySchedule
|
|
||||||
, error = Nothing
|
|
||||||
, isProcessing = False
|
|
||||||
}
|
|
||||||
, Cmd.batch
|
|
||||||
[ Api.Schedule.fetchSchedules model.token
|
|
||||||
, Task.perform (\_ -> ShowToast "Stundenplan erfolgreich erstellt!" SuccessToast) (Task.succeed ())
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( { model | isProcessing = False }, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleDeleteSchedule : Int -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleDeleteSchedule scheduleId model =
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.Schedule.deleteSchedule token scheduleId )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleScheduleDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleScheduleDeleted result model =
|
|
||||||
case result of
|
|
||||||
Ok _ ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model | error = Nothing }
|
|
||||||
, Cmd.batch
|
|
||||||
[ Api.Schedule.fetchSchedules (Just token)
|
|
||||||
, Task.perform (\_ -> ShowToast "Stundenplan erfolgreich gelöscht" SuccessToast) (Task.succeed ())
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleSchedulesReceived : Result Http.Error (List Schedule) -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleSchedulesReceived result model =
|
|
||||||
case result of
|
|
||||||
Ok schedules ->
|
|
||||||
( { model | schedules = schedules }, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
module Update.SchoolYearUpdate exposing
|
|
||||||
( handleActivateSchoolYear
|
|
||||||
, handleActiveSchoolYearReceived
|
|
||||||
, handleCreateSchoolYear
|
|
||||||
, handleDeleteSchoolYear
|
|
||||||
, handleSchoolYearActivated
|
|
||||||
, handleSchoolYearCreated
|
|
||||||
, handleSchoolYearDeleted
|
|
||||||
, handleSchoolYearsReceived
|
|
||||||
)
|
|
||||||
|
|
||||||
import Api.SchoolYear
|
|
||||||
import Http
|
|
||||||
import Task
|
|
||||||
import Types.Model exposing (Model, NewSchoolYear, SchoolYear, ToastType(..))
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
|
|
||||||
|
|
||||||
handleCreateSchoolYear : Model -> ( Model, Cmd Msg )
|
|
||||||
handleCreateSchoolYear model =
|
|
||||||
if
|
|
||||||
String.isEmpty model.newSchoolYear.name
|
|
||||||
|| String.isEmpty model.newSchoolYear.startDate
|
|
||||||
|| String.isEmpty model.newSchoolYear.endDate
|
|
||||||
then
|
|
||||||
( model, Task.perform (\_ -> ShowToast "Bitte alle Felder ausfüllen" WarningToast) (Task.succeed ()) )
|
|
||||||
|
|
||||||
else
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model | isProcessing = True }, Api.SchoolYear.createSchoolYear token model.newSchoolYear )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleSchoolYearCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleSchoolYearCreated result model =
|
|
||||||
case result of
|
|
||||||
Ok _ ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model
|
|
||||||
| newSchoolYear = NewSchoolYear "" "" ""
|
|
||||||
, error = Nothing
|
|
||||||
, isProcessing = False
|
|
||||||
}
|
|
||||||
, Cmd.batch
|
|
||||||
[ Api.SchoolYear.fetchSchoolYears token
|
|
||||||
, Task.perform (\_ -> ShowToast "Schuljahr erfolgreich erstellt!" SuccessToast) (Task.succeed ())
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( { model | isProcessing = False }, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleActivateSchoolYear : Int -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleActivateSchoolYear id model =
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.SchoolYear.activateSchoolYear token id )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleSchoolYearActivated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleSchoolYearActivated result model =
|
|
||||||
case result of
|
|
||||||
Ok _ ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model | error = Nothing }
|
|
||||||
, Cmd.batch
|
|
||||||
[ Api.SchoolYear.fetchSchoolYears token
|
|
||||||
, Api.SchoolYear.fetchActiveSchoolYear token
|
|
||||||
, Task.perform (\_ -> ShowToast "Schuljahr erfolgreich aktiviert!" SuccessToast) (Task.succeed ())
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleDeleteSchoolYear : Int -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleDeleteSchoolYear id model =
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.SchoolYear.deleteSchoolYear token id )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleSchoolYearDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleSchoolYearDeleted result model =
|
|
||||||
case result of
|
|
||||||
Ok _ ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model | error = Nothing }
|
|
||||||
, Cmd.batch
|
|
||||||
[ Api.SchoolYear.fetchSchoolYears token
|
|
||||||
, Task.perform (\_ -> ShowToast "Schuljahr erfolgreich gelöscht" SuccessToast) (Task.succeed ())
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleSchoolYearsReceived : Result Http.Error (List SchoolYear) -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleSchoolYearsReceived result model =
|
|
||||||
case result of
|
|
||||||
Ok years ->
|
|
||||||
( { model | schoolYears = years }, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleActiveSchoolYearReceived : Result Http.Error SchoolYear -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleActiveSchoolYearReceived result model =
|
|
||||||
case result of
|
|
||||||
Ok year ->
|
|
||||||
( { model | activeSchoolYear = Just year }, Cmd.none )
|
|
||||||
|
|
||||||
Err _ ->
|
|
||||||
( { model | activeSchoolYear = Nothing }, Cmd.none )
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
module Update.TimeEntryUpdate exposing
|
|
||||||
( handleAdminTimeEntrySaved
|
|
||||||
, handleAllTimeEntriesReceived
|
|
||||||
, handleConfirmDeleteTimeEntry
|
|
||||||
, handleEditTimeEntry
|
|
||||||
, handleMyTimeEntriesReceived
|
|
||||||
, handleSaveAdminTimeEntry
|
|
||||||
, handleSaveEditTimeEntry
|
|
||||||
, handleTimeEntryDeleted
|
|
||||||
, handleTimeEntrySaved
|
|
||||||
, handleYearlyHoursSummaryReceived
|
|
||||||
)
|
|
||||||
|
|
||||||
import Api.TimeEntry
|
|
||||||
import Http
|
|
||||||
import Task
|
|
||||||
import Types.Model exposing (AdminManualEntry, EditingTimeEntry, Model, TimeEntry, ToastType(..), YearlyHoursSummary)
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
import Utils.DateUtils exposing (getYearWeekFromDate)
|
|
||||||
import Utils.Ports exposing (confirmDelete)
|
|
||||||
|
|
||||||
|
|
||||||
handleMyTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleMyTimeEntriesReceived result model =
|
|
||||||
case result of
|
|
||||||
Ok entries ->
|
|
||||||
let
|
|
||||||
hasEntries =
|
|
||||||
List.any
|
|
||||||
(\e ->
|
|
||||||
let
|
|
||||||
( entryYear, entryWeek ) =
|
|
||||||
getYearWeekFromDate e.date
|
|
||||||
in
|
|
||||||
entryWeek == model.currentWeek && entryYear == model.currentYear
|
|
||||||
)
|
|
||||||
entries
|
|
||||||
in
|
|
||||||
( { model
|
|
||||||
| timeEntries = entries
|
|
||||||
, hasEntriesForCurrentWeek = hasEntries
|
|
||||||
, weekEditMode = False
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleAllTimeEntriesReceived : Result Http.Error (List TimeEntry) -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleAllTimeEntriesReceived result model =
|
|
||||||
case result of
|
|
||||||
Ok entries ->
|
|
||||||
( { model | timeEntries = entries }, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleEditTimeEntry : Int -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleEditTimeEntry entryId model =
|
|
||||||
case List.filter (\e -> e.id == entryId) model.timeEntries |> List.head of
|
|
||||||
Just entry ->
|
|
||||||
( { model
|
|
||||||
| editingTimeEntryId = Just entryId
|
|
||||||
, editingTimeEntry =
|
|
||||||
{ entryId = entryId
|
|
||||||
, date = entry.date
|
|
||||||
, startTime = entry.startTime
|
|
||||||
, endTime = entry.endTime
|
|
||||||
, entryType = entry.entryType
|
|
||||||
}
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleSaveEditTimeEntry : Model -> ( Model, Cmd Msg )
|
|
||||||
handleSaveEditTimeEntry model =
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleTimeEntrySaved result model =
|
|
||||||
case result of
|
|
||||||
Ok _ ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model
|
|
||||||
| editingTimeEntryId = Nothing
|
|
||||||
, pendingDeleteId = Nothing
|
|
||||||
, error = Nothing
|
|
||||||
}
|
|
||||||
, Cmd.batch
|
|
||||||
[ Api.TimeEntry.fetchAllTimeEntries token
|
|
||||||
, Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleTimeEntryDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleTimeEntryDeleted result model =
|
|
||||||
case result of
|
|
||||||
Ok _ ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model
|
|
||||||
| editingTimeEntryId = Nothing
|
|
||||||
, editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson"
|
|
||||||
, pendingDeleteId = Nothing
|
|
||||||
, error = Nothing
|
|
||||||
}
|
|
||||||
, Cmd.batch
|
|
||||||
[ Api.TimeEntry.fetchAllTimeEntries token
|
|
||||||
, Api.TimeEntry.fetchYearlyHoursSummary token
|
|
||||||
, Task.perform (\_ -> ShowToast "Zeiteintrag erfolgreich gelöscht" SuccessToast) (Task.succeed ())
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( { model | pendingDeleteId = Nothing }, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleConfirmDeleteTimeEntry : Int -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleConfirmDeleteTimeEntry entryId model =
|
|
||||||
( { model | pendingDeleteId = Just entryId }, confirmDelete "Soll dieser Zeiteintrag gelöscht werden?" )
|
|
||||||
|
|
||||||
|
|
||||||
handleSaveAdminTimeEntry : Model -> ( Model, Cmd Msg )
|
|
||||||
handleSaveAdminTimeEntry model =
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model | isProcessing = True }, Api.TimeEntry.createAdminTimeEntry token model.adminManualEntryForm )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleAdminTimeEntrySaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleAdminTimeEntrySaved result model =
|
|
||||||
case result of
|
|
||||||
Ok _ ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model
|
|
||||||
| adminManualEntryForm = AdminManualEntry Nothing "" "" "manual"
|
|
||||||
, error = Nothing
|
|
||||||
, isProcessing = False
|
|
||||||
}
|
|
||||||
, Cmd.batch
|
|
||||||
[ Api.TimeEntry.fetchAllTimeEntries token
|
|
||||||
, Api.TimeEntry.fetchYearlyHoursSummary token
|
|
||||||
, Task.perform (\_ -> ShowToast "Manueller Eintrag erfolgreich erstellt!" SuccessToast) (Task.succeed ())
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( { model | isProcessing = False }, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleYearlyHoursSummaryReceived : Result Http.Error (List YearlyHoursSummary) -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleYearlyHoursSummaryReceived result model =
|
|
||||||
case result of
|
|
||||||
Ok summary ->
|
|
||||||
( { model | yearlyHoursSummary = summary }, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
@ -1,811 +0,0 @@
|
||||||
module Update.Update exposing (update)
|
|
||||||
|
|
||||||
import Api.Schedule
|
|
||||||
import Api.SchoolYear
|
|
||||||
import Api.TimeEntry
|
|
||||||
import Api.User
|
|
||||||
import File.Download
|
|
||||||
import Process
|
|
||||||
import Task
|
|
||||||
import Time
|
|
||||||
import Types.Model exposing (EditingTimeEntry, Model, NewUser, ToastType(..))
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
import Types.Page exposing (AdminTab(..), Page(..))
|
|
||||||
import Update.AuthUpdate as Auth
|
|
||||||
import Update.ScheduleUpdate as Schedule
|
|
||||||
import Update.SchoolYearUpdate as SchoolYear
|
|
||||||
import Update.TimeEntryUpdate as TimeEntry
|
|
||||||
import Update.UserUpdate as User
|
|
||||||
import Utils.DateUtils exposing (getISOWeekFromPosix, nextWeek, previousWeek)
|
|
||||||
import Utils.Ports
|
|
||||||
|
|
||||||
|
|
||||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
|
||||||
update msg model =
|
|
||||||
case msg of
|
|
||||||
-- Mobile Menu
|
|
||||||
ToggleMobileMenu ->
|
|
||||||
( { model | mobileMenuOpen = not model.mobileMenuOpen }, Cmd.none )
|
|
||||||
|
|
||||||
CloseMobileMenu ->
|
|
||||||
( { model | mobileMenuOpen = False }, Cmd.none )
|
|
||||||
|
|
||||||
-- Auth
|
|
||||||
UpdateUsername username ->
|
|
||||||
( { model | username = username }, Cmd.none )
|
|
||||||
|
|
||||||
UpdatePassword password ->
|
|
||||||
( { model | password = password }, Cmd.none )
|
|
||||||
|
|
||||||
Login ->
|
|
||||||
Auth.handleLogin model
|
|
||||||
|
|
||||||
LoginResponse result ->
|
|
||||||
Auth.handleLoginResponse result model
|
|
||||||
|
|
||||||
Logout ->
|
|
||||||
Auth.handleLogout model
|
|
||||||
|
|
||||||
-- Time
|
|
||||||
SetTime time ->
|
|
||||||
let
|
|
||||||
( year, week ) =
|
|
||||||
getISOWeekFromPosix time
|
|
||||||
|
|
||||||
cmds =
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
if model.page == UserDashboard || model.page == LoginPage then
|
|
||||||
Cmd.batch
|
|
||||||
[ Api.TimeEntry.checkWeekHasEntries token year week
|
|
||||||
, Api.TimeEntry.fetchWeekDates token year week
|
|
||||||
, Api.TimeEntry.fetchMyTimeEntries token
|
|
||||||
]
|
|
||||||
|
|
||||||
else
|
|
||||||
Cmd.none
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
Cmd.none
|
|
||||||
in
|
|
||||||
( { model
|
|
||||||
| currentTime = time
|
|
||||||
, currentWeek = week
|
|
||||||
, currentYear = year
|
|
||||||
}
|
|
||||||
, cmds
|
|
||||||
)
|
|
||||||
|
|
||||||
-- Schedules
|
|
||||||
FetchSchedules ->
|
|
||||||
( model, Api.Schedule.fetchSchedules model.token )
|
|
||||||
|
|
||||||
SchedulesReceived result ->
|
|
||||||
Schedule.handleSchedulesReceived result model
|
|
||||||
|
|
||||||
ToggleScheduleSelection scheduleId dayOfWeek ->
|
|
||||||
Schedule.handleToggleScheduleSelection scheduleId dayOfWeek model
|
|
||||||
|
|
||||||
SaveTimeEntries ->
|
|
||||||
Schedule.handleSaveTimeEntries model
|
|
||||||
|
|
||||||
TimeEntriesSaved result ->
|
|
||||||
Schedule.handleTimeEntriesSaved result model
|
|
||||||
|
|
||||||
EnableEditMode ->
|
|
||||||
Schedule.handleEnableEditMode model
|
|
||||||
|
|
||||||
DisableEditMode ->
|
|
||||||
Schedule.handleDisableEditMode model
|
|
||||||
|
|
||||||
DeleteWeekEntries ->
|
|
||||||
Schedule.handleDeleteWeekEntries model
|
|
||||||
|
|
||||||
WeekEntriesDeleted result ->
|
|
||||||
Schedule.handleWeekEntriesDeleted result model
|
|
||||||
|
|
||||||
CreateSchedule ->
|
|
||||||
Schedule.handleCreateSchedule model
|
|
||||||
|
|
||||||
ScheduleCreated result ->
|
|
||||||
Schedule.handleScheduleCreated result model
|
|
||||||
|
|
||||||
DeleteSchedule scheduleId ->
|
|
||||||
Schedule.handleDeleteSchedule scheduleId model
|
|
||||||
|
|
||||||
ScheduleDeleted result ->
|
|
||||||
Schedule.handleScheduleDeleted result model
|
|
||||||
|
|
||||||
-- Week Navigation
|
|
||||||
PreviousWeek ->
|
|
||||||
let
|
|
||||||
( newYear, newWeek ) =
|
|
||||||
previousWeek model.currentYear model.currentWeek
|
|
||||||
in
|
|
||||||
( { model
|
|
||||||
| currentWeek = newWeek
|
|
||||||
, currentYear = newYear
|
|
||||||
, selectedEntries = []
|
|
||||||
, weekEditMode = False
|
|
||||||
}
|
|
||||||
, case model.token of
|
|
||||||
Just token ->
|
|
||||||
Cmd.batch
|
|
||||||
[ Api.TimeEntry.fetchWeekDates token newYear newWeek
|
|
||||||
, Api.TimeEntry.checkWeekHasEntries token newYear newWeek
|
|
||||||
]
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
NextWeek ->
|
|
||||||
let
|
|
||||||
( newYear, newWeek ) =
|
|
||||||
nextWeek model.currentYear model.currentWeek
|
|
||||||
in
|
|
||||||
( { model
|
|
||||||
| currentWeek = newWeek
|
|
||||||
, currentYear = newYear
|
|
||||||
, selectedEntries = []
|
|
||||||
, weekEditMode = False
|
|
||||||
}
|
|
||||||
, case model.token of
|
|
||||||
Just token ->
|
|
||||||
Cmd.batch
|
|
||||||
[ Api.TimeEntry.fetchWeekDates token newYear newWeek
|
|
||||||
, Api.TimeEntry.checkWeekHasEntries token newYear newWeek
|
|
||||||
]
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
FetchWeekDates ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.TimeEntry.fetchWeekDates token model.currentYear model.currentWeek )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
WeekDatesReceived result ->
|
|
||||||
case result of
|
|
||||||
Ok weekDates ->
|
|
||||||
( { model | weekDates = Just weekDates }, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
CheckWeekHasEntries ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.TimeEntry.checkWeekHasEntries token model.currentYear model.currentWeek )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
WeekHasEntriesReceived result ->
|
|
||||||
case result of
|
|
||||||
Ok hasEntries ->
|
|
||||||
( { model | hasEntriesForCurrentWeek = hasEntries }, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
-- Admin Tabs
|
|
||||||
SwitchTab tab ->
|
|
||||||
let
|
|
||||||
cmd =
|
|
||||||
case tab of
|
|
||||||
UsersTab ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
Api.User.fetchUsers token
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
Cmd.none
|
|
||||||
|
|
||||||
TimeEntriesTab ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
Cmd.batch
|
|
||||||
[ Api.TimeEntry.fetchAllTimeEntries token
|
|
||||||
, Api.TimeEntry.fetchYearlyHoursSummary token
|
|
||||||
]
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
Cmd.none
|
|
||||||
|
|
||||||
SchoolYearsTab ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
Cmd.batch
|
|
||||||
[ Api.SchoolYear.fetchSchoolYears token
|
|
||||||
, Api.SchoolYear.fetchActiveSchoolYear token
|
|
||||||
]
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
Cmd.none
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
Cmd.none
|
|
||||||
in
|
|
||||||
( { model | activeTab = tab, mobileMenuOpen = False }, cmd )
|
|
||||||
|
|
||||||
-- Schedule Form
|
|
||||||
UpdateNewScheduleDay day ->
|
|
||||||
let
|
|
||||||
oldSchedule =
|
|
||||||
model.newSchedule
|
|
||||||
|
|
||||||
newSchedule =
|
|
||||||
{ oldSchedule | dayOfWeek = day }
|
|
||||||
in
|
|
||||||
( { model | newSchedule = newSchedule }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateNewScheduleStart time ->
|
|
||||||
let
|
|
||||||
oldSchedule =
|
|
||||||
model.newSchedule
|
|
||||||
|
|
||||||
newSchedule =
|
|
||||||
{ oldSchedule | startTime = time }
|
|
||||||
in
|
|
||||||
( { model | newSchedule = newSchedule }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateNewScheduleEnd time ->
|
|
||||||
let
|
|
||||||
oldSchedule =
|
|
||||||
model.newSchedule
|
|
||||||
|
|
||||||
newSchedule =
|
|
||||||
{ oldSchedule | endTime = time }
|
|
||||||
in
|
|
||||||
( { model | newSchedule = newSchedule }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateNewScheduleType scheduleType ->
|
|
||||||
let
|
|
||||||
oldSchedule =
|
|
||||||
model.newSchedule
|
|
||||||
|
|
||||||
newSchedule =
|
|
||||||
{ oldSchedule | scheduleType = scheduleType }
|
|
||||||
in
|
|
||||||
( { model | newSchedule = newSchedule }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateNewScheduleTitle title ->
|
|
||||||
let
|
|
||||||
oldSchedule =
|
|
||||||
model.newSchedule
|
|
||||||
|
|
||||||
newSchedule =
|
|
||||||
{ oldSchedule | title = title }
|
|
||||||
in
|
|
||||||
( { model | newSchedule = newSchedule }, Cmd.none )
|
|
||||||
|
|
||||||
-- Users
|
|
||||||
UpdateNewUsername username ->
|
|
||||||
let
|
|
||||||
oldUser =
|
|
||||||
model.newUser
|
|
||||||
|
|
||||||
newUser =
|
|
||||||
{ oldUser | username = username }
|
|
||||||
in
|
|
||||||
( { model | newUser = newUser }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateNewPassword password ->
|
|
||||||
let
|
|
||||||
oldUser =
|
|
||||||
model.newUser
|
|
||||||
|
|
||||||
newUser =
|
|
||||||
{ oldUser | password = password }
|
|
||||||
in
|
|
||||||
( { model | newUser = newUser }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateNewUserAdmin isAdmin ->
|
|
||||||
let
|
|
||||||
oldUser =
|
|
||||||
model.newUser
|
|
||||||
|
|
||||||
newUser =
|
|
||||||
{ oldUser | isAdmin = isAdmin }
|
|
||||||
in
|
|
||||||
( { model | newUser = newUser }, Cmd.none )
|
|
||||||
|
|
||||||
CreateUser ->
|
|
||||||
User.handleCreateUser model
|
|
||||||
|
|
||||||
UserCreated result ->
|
|
||||||
User.handleUserCreated result model
|
|
||||||
|
|
||||||
DeleteUser userId ->
|
|
||||||
User.handleDeleteUser userId model
|
|
||||||
|
|
||||||
UserDeleted result ->
|
|
||||||
User.handleUserDeleted result model
|
|
||||||
|
|
||||||
FetchUsers ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.User.fetchUsers token )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
UsersReceived result ->
|
|
||||||
User.handleUsersReceived result model
|
|
||||||
|
|
||||||
EditUserWorkHours userId ->
|
|
||||||
User.handleEditUserWorkHours userId model
|
|
||||||
|
|
||||||
CancelEditUserWorkHours ->
|
|
||||||
( { model
|
|
||||||
| editingUserId = Nothing
|
|
||||||
, editingUserWorkHours = ""
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
UpdateEditUserWorkHours hours ->
|
|
||||||
( { model | editingUserWorkHours = hours }, Cmd.none )
|
|
||||||
|
|
||||||
SaveUserWorkHours ->
|
|
||||||
User.handleSaveUserWorkHours model
|
|
||||||
|
|
||||||
UserWorkHoursSaved result ->
|
|
||||||
User.handleUserWorkHoursSaved result model
|
|
||||||
|
|
||||||
ResetUserPassword userId ->
|
|
||||||
User.handleResetUserPassword userId model
|
|
||||||
|
|
||||||
CancelResetPassword ->
|
|
||||||
( { model
|
|
||||||
| resetPasswordUserId = Nothing
|
|
||||||
, resetPasswordNew = ""
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
UpdateResetPasswordNew password ->
|
|
||||||
( { model | resetPasswordNew = password }, Cmd.none )
|
|
||||||
|
|
||||||
SaveResetPassword ->
|
|
||||||
User.handleSaveResetPassword model
|
|
||||||
|
|
||||||
ResetPasswordSaved result ->
|
|
||||||
User.handleResetPasswordSaved result model
|
|
||||||
|
|
||||||
UpdateUserWorkHours input ->
|
|
||||||
( { model | userWorkHoursInput = input }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateUserPassword input ->
|
|
||||||
( { model | userPasswordInput = input }, Cmd.none )
|
|
||||||
|
|
||||||
SaveUserPassword ->
|
|
||||||
case ( model.token, model.selectedUserId ) of
|
|
||||||
( Just token, Just userId ) ->
|
|
||||||
if String.length model.userPasswordInput > 0 then
|
|
||||||
( model, Api.User.resetUserPassword token userId model.userPasswordInput )
|
|
||||||
|
|
||||||
else
|
|
||||||
( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) )
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
( model, Task.perform (\_ -> ShowToast "Passwort erforderlich" WarningToast) (Task.succeed ()) )
|
|
||||||
|
|
||||||
UserPasswordSaved result ->
|
|
||||||
case result of
|
|
||||||
Ok _ ->
|
|
||||||
( { model
|
|
||||||
| userPasswordInput = ""
|
|
||||||
, selectedUserId = Nothing
|
|
||||||
, error = Nothing
|
|
||||||
}
|
|
||||||
, Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt!" SuccessToast) (Task.succeed ())
|
|
||||||
)
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
SelectUserForManagement userId ->
|
|
||||||
( { model | selectedUserId = Just userId, userWorkHoursInput = "", userPasswordInput = "" }, Cmd.none )
|
|
||||||
|
|
||||||
-- Time Entries
|
|
||||||
FetchMyTimeEntries ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.TimeEntry.fetchMyTimeEntries token )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
MyTimeEntriesReceived result ->
|
|
||||||
TimeEntry.handleMyTimeEntriesReceived result model
|
|
||||||
|
|
||||||
FetchAllTimeEntries ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.TimeEntry.fetchAllTimeEntries token )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
AllTimeEntriesReceived result ->
|
|
||||||
TimeEntry.handleAllTimeEntriesReceived result model
|
|
||||||
|
|
||||||
EditTimeEntry entryId ->
|
|
||||||
TimeEntry.handleEditTimeEntry entryId model
|
|
||||||
|
|
||||||
CancelEditTimeEntry ->
|
|
||||||
( { model
|
|
||||||
| editingTimeEntryId = Nothing
|
|
||||||
, editingTimeEntry = EditingTimeEntry 0 "" "" "" ""
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
UpdateEditTimeEntryDate date ->
|
|
||||||
let
|
|
||||||
old =
|
|
||||||
model.editingTimeEntry
|
|
||||||
|
|
||||||
new =
|
|
||||||
{ old | date = date }
|
|
||||||
in
|
|
||||||
( { model | editingTimeEntry = new }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateEditTimeEntryStartTime time ->
|
|
||||||
let
|
|
||||||
old =
|
|
||||||
model.editingTimeEntry
|
|
||||||
|
|
||||||
new =
|
|
||||||
{ old | startTime = time }
|
|
||||||
in
|
|
||||||
( { model | editingTimeEntry = new }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateEditTimeEntryEndTime time ->
|
|
||||||
let
|
|
||||||
old =
|
|
||||||
model.editingTimeEntry
|
|
||||||
|
|
||||||
new =
|
|
||||||
{ old | endTime = time }
|
|
||||||
in
|
|
||||||
( { model | editingTimeEntry = new }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateEditTimeEntryType entryType ->
|
|
||||||
let
|
|
||||||
old =
|
|
||||||
model.editingTimeEntry
|
|
||||||
|
|
||||||
new =
|
|
||||||
{ old | entryType = entryType }
|
|
||||||
in
|
|
||||||
( { model | editingTimeEntry = new }, Cmd.none )
|
|
||||||
|
|
||||||
SaveEditTimeEntry ->
|
|
||||||
TimeEntry.handleSaveEditTimeEntry model
|
|
||||||
|
|
||||||
TimeEntrySaved result ->
|
|
||||||
TimeEntry.handleTimeEntrySaved result model
|
|
||||||
|
|
||||||
TimeEntryDeleted result ->
|
|
||||||
TimeEntry.handleTimeEntryDeleted result model
|
|
||||||
|
|
||||||
ConfirmDeleteTimeEntry entryId ->
|
|
||||||
TimeEntry.handleConfirmDeleteTimeEntry entryId model
|
|
||||||
|
|
||||||
StartEditingTimeEntry entryId entry ->
|
|
||||||
( { model
|
|
||||||
| editingTimeEntryId = Just entryId
|
|
||||||
, editingTimeEntry = EditingTimeEntry entryId entry.date entry.startTime entry.endTime entry.entryType
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
CancelEditingTimeEntry ->
|
|
||||||
( { model
|
|
||||||
| editingTimeEntryId = Nothing
|
|
||||||
, editingTimeEntry = EditingTimeEntry 0 "" "" "" "lesson"
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
UpdateEditingTimeEntryDate date ->
|
|
||||||
let
|
|
||||||
old =
|
|
||||||
model.editingTimeEntry
|
|
||||||
|
|
||||||
new =
|
|
||||||
{ old | date = date }
|
|
||||||
in
|
|
||||||
( { model | editingTimeEntry = new }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateEditingTimeEntryStartTime time ->
|
|
||||||
let
|
|
||||||
old =
|
|
||||||
model.editingTimeEntry
|
|
||||||
|
|
||||||
new =
|
|
||||||
{ old | startTime = time }
|
|
||||||
in
|
|
||||||
( { model | editingTimeEntry = new }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateEditingTimeEntryEndTime time ->
|
|
||||||
let
|
|
||||||
old =
|
|
||||||
model.editingTimeEntry
|
|
||||||
|
|
||||||
new =
|
|
||||||
{ old | endTime = time }
|
|
||||||
in
|
|
||||||
( { model | editingTimeEntry = new }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateEditingTimeEntryType entryType ->
|
|
||||||
let
|
|
||||||
old =
|
|
||||||
model.editingTimeEntry
|
|
||||||
|
|
||||||
new =
|
|
||||||
{ old | entryType = entryType }
|
|
||||||
in
|
|
||||||
( { model | editingTimeEntry = new }, Cmd.none )
|
|
||||||
|
|
||||||
SaveEditingTimeEntry ->
|
|
||||||
case ( model.token, model.editingTimeEntryId ) of
|
|
||||||
( Just token, Just entryId ) ->
|
|
||||||
( model, Api.TimeEntry.updateTimeEntry token model.editingTimeEntry )
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
-- Weekly Hours
|
|
||||||
FetchWeeklyHours ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
WeeklyHoursReceived result ->
|
|
||||||
case result of
|
|
||||||
Ok hours ->
|
|
||||||
( { model | weeklyHours = hours }, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
MyWeeklySummaryReceived result ->
|
|
||||||
case result of
|
|
||||||
Ok summary ->
|
|
||||||
( { model | userWeeklySummary = Just summary }, Cmd.none )
|
|
||||||
|
|
||||||
Err _ ->
|
|
||||||
( { model | userWeeklySummary = Nothing }, Cmd.none )
|
|
||||||
|
|
||||||
-- Yearly Hours
|
|
||||||
FetchYearlyHoursSummary ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.TimeEntry.fetchYearlyHoursSummary token )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
YearlyHoursSummaryReceived result ->
|
|
||||||
TimeEntry.handleYearlyHoursSummaryReceived result model
|
|
||||||
|
|
||||||
-- Admin Manual Entry
|
|
||||||
SelectUserForManualEntry userId ->
|
|
||||||
let
|
|
||||||
form =
|
|
||||||
model.adminManualEntryForm
|
|
||||||
in
|
|
||||||
( { model | adminManualEntryForm = { form | selectedUserId = Just userId } }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateManualEntryDate date ->
|
|
||||||
let
|
|
||||||
form =
|
|
||||||
model.adminManualEntryForm
|
|
||||||
in
|
|
||||||
( { model | adminManualEntryForm = { form | date = date } }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateManualEntryHours hours ->
|
|
||||||
let
|
|
||||||
form =
|
|
||||||
model.adminManualEntryForm
|
|
||||||
in
|
|
||||||
( { model | adminManualEntryForm = { form | hours = hours } }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateManualEntryType entryType ->
|
|
||||||
let
|
|
||||||
form =
|
|
||||||
model.adminManualEntryForm
|
|
||||||
in
|
|
||||||
( { model | adminManualEntryForm = { form | entryType = entryType } }, Cmd.none )
|
|
||||||
|
|
||||||
SaveAdminTimeEntry ->
|
|
||||||
TimeEntry.handleSaveAdminTimeEntry model
|
|
||||||
|
|
||||||
AdminTimeEntrySaved result ->
|
|
||||||
TimeEntry.handleAdminTimeEntrySaved result model
|
|
||||||
|
|
||||||
-- My Info
|
|
||||||
FetchMyInfo ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.User.fetchMyInfo token )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
MyInfoReceived result ->
|
|
||||||
case result of
|
|
||||||
Ok user ->
|
|
||||||
( { model | users = [ user ] }, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
-- School Years
|
|
||||||
FetchSchoolYears ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.SchoolYear.fetchSchoolYears token )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
SchoolYearsReceived result ->
|
|
||||||
SchoolYear.handleSchoolYearsReceived result model
|
|
||||||
|
|
||||||
FetchActiveSchoolYear ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.SchoolYear.fetchActiveSchoolYear token )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
ActiveSchoolYearReceived result ->
|
|
||||||
SchoolYear.handleActiveSchoolYearReceived result model
|
|
||||||
|
|
||||||
UpdateNewSchoolYearName name ->
|
|
||||||
let
|
|
||||||
old =
|
|
||||||
model.newSchoolYear
|
|
||||||
|
|
||||||
new =
|
|
||||||
{ old | name = name }
|
|
||||||
in
|
|
||||||
( { model | newSchoolYear = new }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateNewSchoolYearStart date ->
|
|
||||||
let
|
|
||||||
old =
|
|
||||||
model.newSchoolYear
|
|
||||||
|
|
||||||
new =
|
|
||||||
{ old | startDate = date }
|
|
||||||
in
|
|
||||||
( { model | newSchoolYear = new }, Cmd.none )
|
|
||||||
|
|
||||||
UpdateNewSchoolYearEnd date ->
|
|
||||||
let
|
|
||||||
old =
|
|
||||||
model.newSchoolYear
|
|
||||||
|
|
||||||
new =
|
|
||||||
{ old | endDate = date }
|
|
||||||
in
|
|
||||||
( { model | newSchoolYear = new }, Cmd.none )
|
|
||||||
|
|
||||||
CreateSchoolYear ->
|
|
||||||
SchoolYear.handleCreateSchoolYear model
|
|
||||||
|
|
||||||
SchoolYearCreated result ->
|
|
||||||
SchoolYear.handleSchoolYearCreated result model
|
|
||||||
|
|
||||||
ActivateSchoolYear id ->
|
|
||||||
SchoolYear.handleActivateSchoolYear id model
|
|
||||||
|
|
||||||
SchoolYearActivated result ->
|
|
||||||
SchoolYear.handleSchoolYearActivated result model
|
|
||||||
|
|
||||||
DeleteSchoolYear id ->
|
|
||||||
SchoolYear.handleDeleteSchoolYear id model
|
|
||||||
|
|
||||||
SchoolYearDeleted result ->
|
|
||||||
SchoolYear.handleSchoolYearDeleted result model
|
|
||||||
|
|
||||||
-- PDF Download
|
|
||||||
DownloadYearlySummaryPDF ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model | isProcessing = True }, Api.TimeEntry.downloadYearlySummaryPDF token )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
YearlySummaryPDFReceived result ->
|
|
||||||
case result of
|
|
||||||
Ok pdfBytes ->
|
|
||||||
let
|
|
||||||
filename =
|
|
||||||
"Jahresuebersicht_" ++ String.fromInt model.currentYear ++ ".pdf"
|
|
||||||
in
|
|
||||||
( { model | isProcessing = False }, File.Download.bytes filename "application/pdf" pdfBytes )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( { model | isProcessing = False }, Cmd.none )
|
|
||||||
|
|
||||||
-- Delete Confirmation
|
|
||||||
ConfirmDeleteUser userId ->
|
|
||||||
( { model | pendingDeleteId = Just userId }, Utils.Ports.confirmDelete "Soll dieser Benutzer wirklich gelöscht werden?" )
|
|
||||||
|
|
||||||
DeleteConfirmed confirmed ->
|
|
||||||
if confirmed then
|
|
||||||
case ( model.token, model.pendingDeleteId ) of
|
|
||||||
( Just token, Just id ) ->
|
|
||||||
let
|
|
||||||
isTimeEntry =
|
|
||||||
List.any (\e -> e.id == id) model.timeEntries
|
|
||||||
in
|
|
||||||
if isTimeEntry then
|
|
||||||
( model, Api.TimeEntry.deleteTimeEntry token id )
|
|
||||||
|
|
||||||
else
|
|
||||||
( model, Api.User.deleteUser token id )
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
else
|
|
||||||
( { model | pendingDeleteId = Nothing }, Cmd.none )
|
|
||||||
|
|
||||||
-- Toasts
|
|
||||||
ShowToast message toastType ->
|
|
||||||
let
|
|
||||||
newToast =
|
|
||||||
{ id = model.nextToastId
|
|
||||||
, message = message
|
|
||||||
, toastType = toastType
|
|
||||||
, dismissible = True
|
|
||||||
}
|
|
||||||
|
|
||||||
dismissDelay =
|
|
||||||
case toastType of
|
|
||||||
ErrorToast ->
|
|
||||||
8000
|
|
||||||
|
|
||||||
SuccessToast ->
|
|
||||||
5000
|
|
||||||
|
|
||||||
InfoToast ->
|
|
||||||
5000
|
|
||||||
|
|
||||||
WarningToast ->
|
|
||||||
6000
|
|
||||||
in
|
|
||||||
( { model
|
|
||||||
| toasts = model.toasts ++ [ newToast ]
|
|
||||||
, nextToastId = model.nextToastId + 1
|
|
||||||
}
|
|
||||||
, Task.perform (\_ -> AutoDismissToast newToast.id)
|
|
||||||
(Process.sleep dismissDelay)
|
|
||||||
)
|
|
||||||
|
|
||||||
DismissToast toastId ->
|
|
||||||
( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts }
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
AutoDismissToast toastId ->
|
|
||||||
( { model | toasts = List.filter (\t -> t.id /= toastId) model.toasts }
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
module Update.UserUpdate exposing
|
|
||||||
( handleCreateUser
|
|
||||||
, handleDeleteUser
|
|
||||||
, handleEditUserWorkHours
|
|
||||||
, handleResetPasswordSaved
|
|
||||||
, handleResetUserPassword
|
|
||||||
, handleSaveResetPassword
|
|
||||||
, handleSaveUserWorkHours
|
|
||||||
, handleUserCreated
|
|
||||||
, handleUserDeleted
|
|
||||||
, handleUserWorkHoursSaved
|
|
||||||
, handleUsersReceived
|
|
||||||
)
|
|
||||||
|
|
||||||
import Api.User
|
|
||||||
import Http
|
|
||||||
import Task
|
|
||||||
import Types.Model exposing (Model, NewUser, ToastType(..), User)
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
|
|
||||||
|
|
||||||
handleCreateUser : Model -> ( Model, Cmd Msg )
|
|
||||||
handleCreateUser model =
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.User.createUser token model.newUser )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleUserCreated : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleUserCreated result model =
|
|
||||||
case result of
|
|
||||||
Ok _ ->
|
|
||||||
let
|
|
||||||
emptyUser =
|
|
||||||
NewUser "" "" False
|
|
||||||
in
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model | newUser = emptyUser }
|
|
||||||
, Cmd.batch
|
|
||||||
[ Api.User.fetchUsers token
|
|
||||||
, Task.perform (\_ -> ShowToast "Benutzer erfolgreich erstellt!" SuccessToast) (Task.succeed ())
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleDeleteUser : Int -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleDeleteUser userId model =
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.User.deleteUser token userId )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleUserDeleted : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleUserDeleted result model =
|
|
||||||
case result of
|
|
||||||
Ok _ ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model
|
|
||||||
| pendingDeleteId = Nothing
|
|
||||||
, error = Nothing
|
|
||||||
, editingUserId = Nothing
|
|
||||||
, resetPasswordUserId = Nothing
|
|
||||||
}
|
|
||||||
, Cmd.batch
|
|
||||||
[ Api.User.fetchUsers token
|
|
||||||
, Task.perform (\_ -> ShowToast "Benutzer erfolgreich gelöscht" SuccessToast) (Task.succeed ())
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( { model | pendingDeleteId = Nothing }, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleUsersReceived : Result Http.Error (List User) -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleUsersReceived result model =
|
|
||||||
case result of
|
|
||||||
Ok users ->
|
|
||||||
( { model | users = users }, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleEditUserWorkHours : Int -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleEditUserWorkHours userId model =
|
|
||||||
case List.filter (\u -> u.id == userId) model.users |> List.head of
|
|
||||||
Just user ->
|
|
||||||
( { model
|
|
||||||
| editingUserId = Just userId
|
|
||||||
, editingUserWorkHours = String.fromFloat user.yearlyWorkHours
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleSaveUserWorkHours : Model -> ( Model, Cmd Msg )
|
|
||||||
handleSaveUserWorkHours model =
|
|
||||||
case ( model.token, model.editingUserId, String.toFloat model.editingUserWorkHours ) of
|
|
||||||
( Just token, Just userId, Just hours ) ->
|
|
||||||
( model, Api.User.updateUserWorkHours token userId (String.fromFloat hours) )
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
( model, Task.perform (\_ -> ShowToast "Ungültige Eingabe für Arbeitszeit" WarningToast) (Task.succeed ()) )
|
|
||||||
|
|
||||||
|
|
||||||
handleUserWorkHoursSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleUserWorkHoursSaved result model =
|
|
||||||
case result of
|
|
||||||
Ok _ ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( { model
|
|
||||||
| editingUserWorkHours = ""
|
|
||||||
, editingUserId = Nothing
|
|
||||||
, error = Nothing
|
|
||||||
}
|
|
||||||
, Cmd.batch
|
|
||||||
[ Api.User.fetchUsers token
|
|
||||||
, Task.perform (\_ -> ShowToast "Arbeitszeit erfolgreich gespeichert!" SuccessToast) (Task.succeed ())
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleResetUserPassword : Int -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleResetUserPassword userId model =
|
|
||||||
( { model
|
|
||||||
| resetPasswordUserId = Just userId
|
|
||||||
, resetPasswordNew = ""
|
|
||||||
}
|
|
||||||
, Cmd.none
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
handleSaveResetPassword : Model -> ( Model, Cmd Msg )
|
|
||||||
handleSaveResetPassword model =
|
|
||||||
case model.resetPasswordUserId of
|
|
||||||
Just userId ->
|
|
||||||
case model.token of
|
|
||||||
Just token ->
|
|
||||||
( model, Api.User.resetUserPassword token userId model.resetPasswordNew )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
||||||
|
|
||||||
handleResetPasswordSaved : Result Http.Error () -> Model -> ( Model, Cmd Msg )
|
|
||||||
handleResetPasswordSaved result model =
|
|
||||||
case result of
|
|
||||||
Ok _ ->
|
|
||||||
( { model
|
|
||||||
| resetPasswordUserId = Nothing
|
|
||||||
, resetPasswordNew = ""
|
|
||||||
, error = Nothing
|
|
||||||
}
|
|
||||||
, Cmd.batch
|
|
||||||
[ case model.token of
|
|
||||||
Just token ->
|
|
||||||
Api.User.fetchUsers token
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
Cmd.none
|
|
||||||
, Task.perform (\_ -> ShowToast "Passwort erfolgreich zurückgesetzt" SuccessToast) (Task.succeed ())
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Err err ->
|
|
||||||
( model, Cmd.none )
|
|
||||||
|
|
@ -1,338 +0,0 @@
|
||||||
module Utils.DateUtils exposing
|
|
||||||
( addDaysToDate
|
|
||||||
, getDateForWeekDay
|
|
||||||
, getDayOfWeek
|
|
||||||
, getDayOfYear
|
|
||||||
, getISOWeek
|
|
||||||
, getISOWeekFromPosix
|
|
||||||
, getWeekDateRange
|
|
||||||
, getYearWeekFromDate
|
|
||||||
, isLeapYear
|
|
||||||
, monthToInt
|
|
||||||
, nextWeek
|
|
||||||
, previousWeek
|
|
||||||
)
|
|
||||||
|
|
||||||
import Time
|
|
||||||
|
|
||||||
|
|
||||||
getISOWeekFromPosix : Time.Posix -> ( Int, Int )
|
|
||||||
getISOWeekFromPosix time =
|
|
||||||
let
|
|
||||||
year =
|
|
||||||
Time.toYear Time.utc time
|
|
||||||
|
|
||||||
month =
|
|
||||||
Time.toMonth Time.utc time |> monthToInt
|
|
||||||
|
|
||||||
day =
|
|
||||||
Time.toDay Time.utc time
|
|
||||||
in
|
|
||||||
( year, getISOWeek year month day )
|
|
||||||
|
|
||||||
|
|
||||||
monthToInt : Time.Month -> Int
|
|
||||||
monthToInt month =
|
|
||||||
case month of
|
|
||||||
Time.Jan ->
|
|
||||||
1
|
|
||||||
|
|
||||||
Time.Feb ->
|
|
||||||
2
|
|
||||||
|
|
||||||
Time.Mar ->
|
|
||||||
3
|
|
||||||
|
|
||||||
Time.Apr ->
|
|
||||||
4
|
|
||||||
|
|
||||||
Time.May ->
|
|
||||||
5
|
|
||||||
|
|
||||||
Time.Jun ->
|
|
||||||
6
|
|
||||||
|
|
||||||
Time.Jul ->
|
|
||||||
7
|
|
||||||
|
|
||||||
Time.Aug ->
|
|
||||||
8
|
|
||||||
|
|
||||||
Time.Sep ->
|
|
||||||
9
|
|
||||||
|
|
||||||
Time.Oct ->
|
|
||||||
10
|
|
||||||
|
|
||||||
Time.Nov ->
|
|
||||||
11
|
|
||||||
|
|
||||||
Time.Dec ->
|
|
||||||
12
|
|
||||||
|
|
||||||
|
|
||||||
getISOWeek : Int -> Int -> Int -> Int
|
|
||||||
getISOWeek year month day =
|
|
||||||
let
|
|
||||||
dayOfYear =
|
|
||||||
getDayOfYear year month day
|
|
||||||
|
|
||||||
jan4DayOfWeek =
|
|
||||||
getDayOfWeek year 1 4
|
|
||||||
|
|
||||||
mondayOfWeek1DayOfYear =
|
|
||||||
4 - jan4DayOfWeek
|
|
||||||
|
|
||||||
weekNum =
|
|
||||||
((dayOfYear - mondayOfWeek1DayOfYear) // 7) + 1
|
|
||||||
in
|
|
||||||
if weekNum < 1 then
|
|
||||||
52
|
|
||||||
|
|
||||||
else if weekNum > 52 then
|
|
||||||
let
|
|
||||||
dec31DayOfWeek =
|
|
||||||
getDayOfWeek year 12 31
|
|
||||||
|
|
||||||
jan1DayOfWeek =
|
|
||||||
getDayOfWeek year 1 1
|
|
||||||
in
|
|
||||||
if jan1DayOfWeek == 3 || (isLeapYear year && jan1DayOfWeek == 2) then
|
|
||||||
weekNum
|
|
||||||
|
|
||||||
else
|
|
||||||
1
|
|
||||||
|
|
||||||
else
|
|
||||||
weekNum
|
|
||||||
|
|
||||||
|
|
||||||
getDayOfYear : Int -> Int -> Int -> Int
|
|
||||||
getDayOfYear year month day =
|
|
||||||
let
|
|
||||||
daysInMonth =
|
|
||||||
[ 31
|
|
||||||
, if isLeapYear year then
|
|
||||||
29
|
|
||||||
|
|
||||||
else
|
|
||||||
28
|
|
||||||
, 31
|
|
||||||
, 30
|
|
||||||
, 31
|
|
||||||
, 30
|
|
||||||
, 31
|
|
||||||
, 31
|
|
||||||
, 30
|
|
||||||
, 31
|
|
||||||
, 30
|
|
||||||
, 31
|
|
||||||
]
|
|
||||||
|
|
||||||
daysBefore =
|
|
||||||
List.take (month - 1) daysInMonth |> List.sum
|
|
||||||
in
|
|
||||||
daysBefore + day
|
|
||||||
|
|
||||||
|
|
||||||
isLeapYear : Int -> Bool
|
|
||||||
isLeapYear year =
|
|
||||||
(modBy 4 year == 0) && ((modBy 100 year /= 0) || (modBy 400 year == 0))
|
|
||||||
|
|
||||||
|
|
||||||
getDayOfWeek : Int -> Int -> Int -> Int
|
|
||||||
getDayOfWeek year month day =
|
|
||||||
let
|
|
||||||
adjustedMonth =
|
|
||||||
if month < 3 then
|
|
||||||
month + 12
|
|
||||||
|
|
||||||
else
|
|
||||||
month
|
|
||||||
|
|
||||||
adjustedYear =
|
|
||||||
if month < 3 then
|
|
||||||
year - 1
|
|
||||||
|
|
||||||
else
|
|
||||||
year
|
|
||||||
|
|
||||||
q =
|
|
||||||
day
|
|
||||||
|
|
||||||
m =
|
|
||||||
adjustedMonth
|
|
||||||
|
|
||||||
k =
|
|
||||||
modBy 100 adjustedYear
|
|
||||||
|
|
||||||
j =
|
|
||||||
adjustedYear // 100
|
|
||||||
|
|
||||||
h =
|
|
||||||
(q + ((13 * (m + 1)) // 5) + k + (k // 4) + (j // 4) - (2 * j)) |> modBy 7
|
|
||||||
in
|
|
||||||
(h + 5) |> modBy 7
|
|
||||||
|
|
||||||
|
|
||||||
getDateForWeekDay : Int -> Int -> Int -> String
|
|
||||||
getDateForWeekDay year week dayOfWeek =
|
|
||||||
let
|
|
||||||
jan4DayOfWeek =
|
|
||||||
getDayOfWeek year 1 4
|
|
||||||
|
|
||||||
mondayOfWeek1Date =
|
|
||||||
4 - jan4DayOfWeek
|
|
||||||
|
|
||||||
targetDayOfYear =
|
|
||||||
mondayOfWeek1Date + ((week - 1) * 7) + dayOfWeek
|
|
||||||
|
|
||||||
( finalYear, finalMonth, finalDay ) =
|
|
||||||
if targetDayOfYear < 1 then
|
|
||||||
addDaysToDate (year - 1) 12 31 targetDayOfYear
|
|
||||||
|
|
||||||
else
|
|
||||||
addDaysToDate year 1 targetDayOfYear 0
|
|
||||||
in
|
|
||||||
String.fromInt finalYear
|
|
||||||
++ "-"
|
|
||||||
++ String.padLeft 2 '0' (String.fromInt finalMonth)
|
|
||||||
++ "-"
|
|
||||||
++ String.padLeft 2 '0' (String.fromInt finalDay)
|
|
||||||
|
|
||||||
|
|
||||||
addDaysToDate : Int -> Int -> Int -> Int -> ( Int, Int, Int )
|
|
||||||
addDaysToDate startYear startMonth startDay daysToAdd =
|
|
||||||
let
|
|
||||||
daysInMonth m y =
|
|
||||||
case m of
|
|
||||||
1 ->
|
|
||||||
31
|
|
||||||
|
|
||||||
2 ->
|
|
||||||
if isLeapYear y then
|
|
||||||
29
|
|
||||||
|
|
||||||
else
|
|
||||||
28
|
|
||||||
|
|
||||||
3 ->
|
|
||||||
31
|
|
||||||
|
|
||||||
4 ->
|
|
||||||
30
|
|
||||||
|
|
||||||
5 ->
|
|
||||||
31
|
|
||||||
|
|
||||||
6 ->
|
|
||||||
30
|
|
||||||
|
|
||||||
7 ->
|
|
||||||
31
|
|
||||||
|
|
||||||
8 ->
|
|
||||||
31
|
|
||||||
|
|
||||||
9 ->
|
|
||||||
30
|
|
||||||
|
|
||||||
10 ->
|
|
||||||
31
|
|
||||||
|
|
||||||
11 ->
|
|
||||||
30
|
|
||||||
|
|
||||||
12 ->
|
|
||||||
31
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
0
|
|
||||||
|
|
||||||
helper y m d remaining =
|
|
||||||
if remaining == 0 then
|
|
||||||
( y, m, d )
|
|
||||||
|
|
||||||
else if remaining > 0 then
|
|
||||||
let
|
|
||||||
daysInCurrentMonth =
|
|
||||||
daysInMonth m y
|
|
||||||
|
|
||||||
daysLeftInMonth =
|
|
||||||
daysInCurrentMonth - d
|
|
||||||
in
|
|
||||||
if remaining <= daysLeftInMonth then
|
|
||||||
( y, m, d + remaining )
|
|
||||||
|
|
||||||
else if m == 12 then
|
|
||||||
helper (y + 1) 1 1 (remaining - daysLeftInMonth - 1)
|
|
||||||
|
|
||||||
else
|
|
||||||
helper y (m + 1) 1 (remaining - daysLeftInMonth - 1)
|
|
||||||
|
|
||||||
else if d + remaining >= 1 then
|
|
||||||
( y, m, d + remaining )
|
|
||||||
|
|
||||||
else if m == 1 then
|
|
||||||
let
|
|
||||||
prevMonthDays =
|
|
||||||
daysInMonth 12 (y - 1)
|
|
||||||
in
|
|
||||||
helper (y - 1) 12 prevMonthDays (remaining + d)
|
|
||||||
|
|
||||||
else
|
|
||||||
let
|
|
||||||
prevMonthDays =
|
|
||||||
daysInMonth (m - 1) y
|
|
||||||
in
|
|
||||||
helper y (m - 1) prevMonthDays (remaining + d)
|
|
||||||
in
|
|
||||||
helper startYear startMonth startDay daysToAdd
|
|
||||||
|
|
||||||
|
|
||||||
previousWeek : Int -> Int -> ( Int, Int )
|
|
||||||
previousWeek year week =
|
|
||||||
if week == 1 then
|
|
||||||
( year - 1, 52 )
|
|
||||||
|
|
||||||
else
|
|
||||||
( year, week - 1 )
|
|
||||||
|
|
||||||
|
|
||||||
nextWeek : Int -> Int -> ( Int, Int )
|
|
||||||
nextWeek year week =
|
|
||||||
if week >= 52 then
|
|
||||||
( year + 1, 1 )
|
|
||||||
|
|
||||||
else
|
|
||||||
( year, week + 1 )
|
|
||||||
|
|
||||||
|
|
||||||
getWeekDateRange : Int -> Int -> String
|
|
||||||
getWeekDateRange year week =
|
|
||||||
let
|
|
||||||
mondayDate =
|
|
||||||
getDateForWeekDay year week 0
|
|
||||||
|
|
||||||
fridayDate =
|
|
||||||
getDateForWeekDay year week 4
|
|
||||||
in
|
|
||||||
mondayDate ++ " bis " ++ fridayDate
|
|
||||||
|
|
||||||
|
|
||||||
getYearWeekFromDate : String -> ( Int, Int )
|
|
||||||
getYearWeekFromDate dateStr =
|
|
||||||
let
|
|
||||||
parts =
|
|
||||||
String.split "-" dateStr
|
|
||||||
|
|
||||||
year =
|
|
||||||
parts |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 2025
|
|
||||||
|
|
||||||
month =
|
|
||||||
parts |> List.drop 1 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
|
|
||||||
|
|
||||||
day =
|
|
||||||
parts |> List.drop 2 |> List.head |> Maybe.andThen String.toInt |> Maybe.withDefault 1
|
|
||||||
in
|
|
||||||
( year, getISOWeek year month day )
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
module Utils.ErrorHandler exposing (handleApiError)
|
|
||||||
|
|
||||||
import Api.Decoders exposing (apiErrorDecoder)
|
|
||||||
import Http
|
|
||||||
import Json.Decode as Decode
|
|
||||||
import Task
|
|
||||||
import Types.Model exposing (ToastType(..))
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
|
|
||||||
|
|
||||||
handleApiError : Http.Error -> Cmd Msg
|
|
||||||
handleApiError error =
|
|
||||||
let
|
|
||||||
message =
|
|
||||||
case error of
|
|
||||||
Http.BadBody body ->
|
|
||||||
case Decode.decodeString apiErrorDecoder body of
|
|
||||||
Ok apiErr ->
|
|
||||||
apiErr.message
|
|
||||||
|
|
||||||
Err _ ->
|
|
||||||
"Ein Fehler ist aufgetreten"
|
|
||||||
|
|
||||||
Http.BadStatus 401 ->
|
|
||||||
"Keine Berechtigung - bitte erneut anmelden"
|
|
||||||
|
|
||||||
Http.BadStatus 403 ->
|
|
||||||
"Zugriff verweigert"
|
|
||||||
|
|
||||||
Http.BadStatus 404 ->
|
|
||||||
"Ressource nicht gefunden"
|
|
||||||
|
|
||||||
Http.Timeout ->
|
|
||||||
"Zeitüberschreitung - bitte erneut versuchen"
|
|
||||||
|
|
||||||
Http.NetworkError ->
|
|
||||||
"Netzwerkfehler - bitte Verbindung prüfen"
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
"Ein unerwarteter Fehler ist aufgetreten"
|
|
||||||
in
|
|
||||||
Task.perform (\_ -> ShowToast message ErrorToast) (Task.succeed ())
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
port module Utils.Ports exposing
|
|
||||||
( confirmDelete
|
|
||||||
, confirmDeleteResponse
|
|
||||||
, removeToken
|
|
||||||
, saveToken
|
|
||||||
)
|
|
||||||
|
|
||||||
import Json.Encode as Encode
|
|
||||||
|
|
||||||
|
|
||||||
port saveToken : Encode.Value -> Cmd msg
|
|
||||||
|
|
||||||
|
|
||||||
port removeToken : () -> Cmd msg
|
|
||||||
|
|
||||||
|
|
||||||
port confirmDelete : String -> Cmd msg
|
|
||||||
|
|
||||||
|
|
||||||
port confirmDeleteResponse : (Bool -> msg) -> Sub msg
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
module Utils.TimeUtils exposing (calculateHours)
|
|
||||||
|
|
||||||
|
|
||||||
calculateHours : String -> String -> Float
|
|
||||||
calculateHours startTime endTime =
|
|
||||||
let
|
|
||||||
parseTime timeStr =
|
|
||||||
case String.split ":" timeStr of
|
|
||||||
[ h, m ] ->
|
|
||||||
(String.toFloat h |> Maybe.withDefault 0)
|
|
||||||
+ ((String.toFloat m |> Maybe.withDefault 0) / 60)
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
0
|
|
||||||
|
|
||||||
start =
|
|
||||||
parseTime startTime
|
|
||||||
|
|
||||||
end =
|
|
||||||
parseTime endTime
|
|
||||||
in
|
|
||||||
if end > start then
|
|
||||||
end - start
|
|
||||||
|
|
||||||
else if endTime == "manual" then
|
|
||||||
case String.toFloat startTime of
|
|
||||||
Just time ->
|
|
||||||
time
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
0
|
|
||||||
|
|
||||||
else
|
|
||||||
0
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,99 +0,0 @@
|
||||||
module View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
|
|
||||||
|
|
||||||
import Html exposing (..)
|
|
||||||
import Html.Attributes exposing (..)
|
|
||||||
import Html.Events exposing (..)
|
|
||||||
import Types.Model exposing (Model, Schedule)
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
import View.Components.Schedule exposing (viewScheduleItemWithDay)
|
|
||||||
|
|
||||||
|
|
||||||
viewWeekNavigation : Model -> Html Msg
|
|
||||||
viewWeekNavigation model =
|
|
||||||
let
|
|
||||||
dateRange =
|
|
||||||
case model.weekDates of
|
|
||||||
Just wd ->
|
|
||||||
wd.range
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
"Laden..."
|
|
||||||
in
|
|
||||||
div [ class "box" ]
|
|
||||||
[ nav [ class "level" ]
|
|
||||||
[ div [ class "level-left" ]
|
|
||||||
[ div [ class "level-item" ]
|
|
||||||
[ button
|
|
||||||
[ class "button is-primary"
|
|
||||||
, onClick PreviousWeek
|
|
||||||
]
|
|
||||||
[ span [ class "icon" ]
|
|
||||||
[ i [ class "fas fa-chevron-left" ] [] ]
|
|
||||||
, span [] [ text "Vorherige Woche" ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
, div [ class "level-item" ]
|
|
||||||
[ div
|
|
||||||
[ style "display" "flex"
|
|
||||||
, style "flex-direction" "column"
|
|
||||||
, style "align-items" "center"
|
|
||||||
, style "gap" "0.5rem"
|
|
||||||
, style "min-width" "250px"
|
|
||||||
]
|
|
||||||
[ p
|
|
||||||
[ class "heading"
|
|
||||||
, style "margin" "0"
|
|
||||||
, style "line-height" "1.2"
|
|
||||||
]
|
|
||||||
[ text "Kalenderwoche" ]
|
|
||||||
, p
|
|
||||||
[ class "title is-3"
|
|
||||||
, style "margin" "0"
|
|
||||||
, style "line-height" "1.2"
|
|
||||||
]
|
|
||||||
[ text ("KW " ++ String.fromInt model.currentWeek ++ " / " ++ String.fromInt model.currentYear) ]
|
|
||||||
, p
|
|
||||||
[ class "subtitle is-6"
|
|
||||||
, style "margin" "0"
|
|
||||||
, style "line-height" "1.2"
|
|
||||||
]
|
|
||||||
[ text dateRange ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
, div [ class "level-right" ]
|
|
||||||
[ div [ class "level-item" ]
|
|
||||||
[ button
|
|
||||||
[ class "button is-primary"
|
|
||||||
, onClick NextWeek
|
|
||||||
]
|
|
||||||
[ span [] [ text "Nächste Woche" ]
|
|
||||||
, span [ class "icon" ]
|
|
||||||
[ i [ class "fas fa-chevron-right" ] [] ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
viewDayMobile : Model -> String -> ( Int, List Schedule ) -> Html Msg
|
|
||||||
viewDayMobile model dayName ( dayOfWeek, schedules ) =
|
|
||||||
let
|
|
||||||
dateForDay =
|
|
||||||
case model.weekDates of
|
|
||||||
Just wd ->
|
|
||||||
wd.dates
|
|
||||||
|> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek)
|
|
||||||
|> List.head
|
|
||||||
|> Maybe.map Tuple.second
|
|
||||||
|> Maybe.withDefault "N/A"
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
"Laden..."
|
|
||||||
in
|
|
||||||
div [ class "box mb-4" ]
|
|
||||||
[ p [ class "has-text-weight-bold has-text-centered mb-3" ]
|
|
||||||
[ text (dayName ++ " - " ++ dateForDay) ]
|
|
||||||
, div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules)
|
|
||||||
]
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
module View.Components.Schedule exposing (viewScheduleItemWithDay)
|
|
||||||
|
|
||||||
import Html exposing (..)
|
|
||||||
import Html.Attributes exposing (..)
|
|
||||||
import Html.Events exposing (..)
|
|
||||||
import Types.Model exposing (Model, Schedule)
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
|
|
||||||
|
|
||||||
viewScheduleItemWithDay : Model -> Int -> Schedule -> Html Msg
|
|
||||||
viewScheduleItemWithDay model dayOfWeek schedule =
|
|
||||||
let
|
|
||||||
isSelected =
|
|
||||||
List.any (\e -> e.scheduleId == schedule.id && e.dayOfWeek == dayOfWeek) model.selectedEntries
|
|
||||||
|
|
||||||
isClickable =
|
|
||||||
(not model.hasEntriesForCurrentWeek || model.weekEditMode) && not model.isProcessing
|
|
||||||
|
|
||||||
boxClass =
|
|
||||||
if isSelected then
|
|
||||||
"box has-background-success-light"
|
|
||||||
|
|
||||||
else if isClickable then
|
|
||||||
"box has-background-white"
|
|
||||||
|
|
||||||
else
|
|
||||||
"box has-background-light"
|
|
||||||
|
|
||||||
typeText =
|
|
||||||
if schedule.scheduleType == "break" then
|
|
||||||
" (Pause)"
|
|
||||||
|
|
||||||
else
|
|
||||||
""
|
|
||||||
|
|
||||||
cursorStyle =
|
|
||||||
if isClickable then
|
|
||||||
"pointer"
|
|
||||||
|
|
||||||
else
|
|
||||||
"not-allowed"
|
|
||||||
|
|
||||||
opacity =
|
|
||||||
if isClickable || isSelected then
|
|
||||||
"1"
|
|
||||||
|
|
||||||
else
|
|
||||||
"0.6"
|
|
||||||
in
|
|
||||||
div
|
|
||||||
[ class boxClass
|
|
||||||
, onClick
|
|
||||||
(if isClickable then
|
|
||||||
ToggleScheduleSelection schedule.id dayOfWeek
|
|
||||||
|
|
||||||
else
|
|
||||||
FetchSchedules
|
|
||||||
)
|
|
||||||
, style "cursor" cursorStyle
|
|
||||||
, style "margin-bottom" "0.5rem"
|
|
||||||
, style "padding" "0.75rem"
|
|
||||||
, style "opacity" opacity
|
|
||||||
, style "transition" "all 0.2s ease"
|
|
||||||
, style "border"
|
|
||||||
(if isClickable && not isSelected then
|
|
||||||
"2px solid transparent"
|
|
||||||
|
|
||||||
else
|
|
||||||
"2px solid currentColor"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
[ p [ class "has-text-weight-bold is-size-7" ]
|
|
||||||
[ text (schedule.startTime ++ " - " ++ schedule.endTime) ]
|
|
||||||
, p [ class "is-size-7" ]
|
|
||||||
[ text (schedule.title ++ typeText) ]
|
|
||||||
]
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
module View.Components.Toast exposing (viewToasts)
|
|
||||||
|
|
||||||
import Html exposing (..)
|
|
||||||
import Html.Attributes exposing (..)
|
|
||||||
import Html.Events exposing (..)
|
|
||||||
import Types.Model exposing (Model, Schedule, Toast, ToastType(..))
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
import Utils.TimeUtils exposing (calculateHours)
|
|
||||||
import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
|
|
||||||
import View.Components.Schedule exposing (viewScheduleItemWithDay)
|
|
||||||
|
|
||||||
|
|
||||||
viewToasts : List Toast -> Html Msg
|
|
||||||
viewToasts toasts =
|
|
||||||
div [ class "toast-container" ]
|
|
||||||
(List.map viewToast toasts)
|
|
||||||
|
|
||||||
|
|
||||||
viewToast : Toast -> Html Msg
|
|
||||||
viewToast toast =
|
|
||||||
let
|
|
||||||
toastClass =
|
|
||||||
case toast.toastType of
|
|
||||||
ErrorToast ->
|
|
||||||
"toast-error"
|
|
||||||
|
|
||||||
SuccessToast ->
|
|
||||||
"toast-success"
|
|
||||||
|
|
||||||
InfoToast ->
|
|
||||||
"toast-info"
|
|
||||||
|
|
||||||
WarningToast ->
|
|
||||||
"toast-warning"
|
|
||||||
|
|
||||||
icon =
|
|
||||||
case toast.toastType of
|
|
||||||
ErrorToast ->
|
|
||||||
"fas fa-exclamation-circle"
|
|
||||||
|
|
||||||
SuccessToast ->
|
|
||||||
"fas fa-check-circle"
|
|
||||||
|
|
||||||
InfoToast ->
|
|
||||||
"fas fa-info-circle"
|
|
||||||
|
|
||||||
WarningToast ->
|
|
||||||
"fas fa-exclamation-triangle"
|
|
||||||
in
|
|
||||||
div [ class ("toast " ++ toastClass), style "animation" "slideIn 0.3s ease-out" ]
|
|
||||||
[ div [ class "toast-content" ]
|
|
||||||
[ span [ class "toast-icon" ]
|
|
||||||
[ i [ class icon ] [] ]
|
|
||||||
, span [ class "toast-message" ] [ text toast.message ]
|
|
||||||
]
|
|
||||||
, if toast.dismissible then
|
|
||||||
button
|
|
||||||
[ class "toast-close"
|
|
||||||
, onClick (DismissToast toast.id)
|
|
||||||
, attribute "aria-label" "Schließen"
|
|
||||||
]
|
|
||||||
[ i [ class "fas fa-times" ] [] ]
|
|
||||||
|
|
||||||
else
|
|
||||||
text ""
|
|
||||||
]
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
module View.Login exposing (viewLogin)
|
|
||||||
|
|
||||||
import Html exposing (..)
|
|
||||||
import Html.Attributes exposing (..)
|
|
||||||
import Html.Events exposing (..)
|
|
||||||
import Types.Model exposing (Model)
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
|
|
||||||
|
|
||||||
viewLogin : Model -> Html Msg
|
|
||||||
viewLogin model =
|
|
||||||
section [ class "section" ]
|
|
||||||
[ div [ class "container" ]
|
|
||||||
[ div [ class "columns is-centered" ]
|
|
||||||
[ div [ class "column is-5-tablet is-4-desktop is-3-widescreen" ]
|
|
||||||
[ div [ class "box" ]
|
|
||||||
[ h1 [ class "title has-text-centered" ] [ text "Zeiterfassung Login" ]
|
|
||||||
, div [ class "field" ]
|
|
||||||
[ label [ class "label" ] [ text "Benutzername" ]
|
|
||||||
, div [ class "control" ]
|
|
||||||
[ input
|
|
||||||
[ class "input"
|
|
||||||
, type_ "text"
|
|
||||||
, placeholder "Benutzername"
|
|
||||||
, value model.username
|
|
||||||
, onInput UpdateUsername
|
|
||||||
]
|
|
||||||
[]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
, div [ class "field" ]
|
|
||||||
[ label [ class "label" ] [ text "Passwort" ]
|
|
||||||
, div [ class "control" ]
|
|
||||||
[ input
|
|
||||||
[ class "input"
|
|
||||||
, type_ "password"
|
|
||||||
, placeholder "Passwort"
|
|
||||||
, value model.password
|
|
||||||
, onInput UpdatePassword
|
|
||||||
]
|
|
||||||
[]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
, div [ class "field" ]
|
|
||||||
[ div [ class "control" ]
|
|
||||||
[ button
|
|
||||||
[ class "button is-primary is-fullwidth"
|
|
||||||
, onClick Login
|
|
||||||
]
|
|
||||||
[ text "Anmelden" ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
@ -1,338 +0,0 @@
|
||||||
module View.UserDashboard exposing (viewUserDashboard)
|
|
||||||
|
|
||||||
import Html exposing (..)
|
|
||||||
import Html.Attributes exposing (..)
|
|
||||||
import Html.Events exposing (..)
|
|
||||||
import Types.Model exposing (Model, Schedule)
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
import Utils.TimeUtils exposing (calculateHours)
|
|
||||||
import View.Components.Navigation exposing (viewDayMobile, viewWeekNavigation)
|
|
||||||
import View.Components.Schedule exposing (viewScheduleItemWithDay)
|
|
||||||
|
|
||||||
|
|
||||||
viewUserDashboard : Model -> Html Msg
|
|
||||||
viewUserDashboard model =
|
|
||||||
div []
|
|
||||||
[ nav [ class "navbar is-primary" ]
|
|
||||||
[ div [ class "navbar-brand" ]
|
|
||||||
[ div [ class "navbar-item" ]
|
|
||||||
[ h1 [ class "title is-4 has-text-white" ] [ text "Zeiterfassung" ]
|
|
||||||
]
|
|
||||||
, a
|
|
||||||
[ class
|
|
||||||
("navbar-burger"
|
|
||||||
++ (if model.mobileMenuOpen then
|
|
||||||
" is-active"
|
|
||||||
|
|
||||||
else
|
|
||||||
""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
, attribute "role" "navigation"
|
|
||||||
, attribute "aria-label" "menu"
|
|
||||||
, attribute "aria-expanded"
|
|
||||||
(if model.mobileMenuOpen then
|
|
||||||
"true"
|
|
||||||
|
|
||||||
else
|
|
||||||
"false"
|
|
||||||
)
|
|
||||||
, onClick ToggleMobileMenu
|
|
||||||
]
|
|
||||||
[ span [ attribute "aria-hidden" "true" ] []
|
|
||||||
, span [ attribute "aria-hidden" "true" ] []
|
|
||||||
, span [ attribute "aria-hidden" "true" ] []
|
|
||||||
]
|
|
||||||
]
|
|
||||||
, div
|
|
||||||
[ id "navbarUser"
|
|
||||||
, class
|
|
||||||
("navbar-menu"
|
|
||||||
++ (if model.mobileMenuOpen then
|
|
||||||
" is-active"
|
|
||||||
|
|
||||||
else
|
|
||||||
""
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
[ div [ class "navbar-end" ]
|
|
||||||
[ div [ class "navbar-item" ]
|
|
||||||
[ span [ class "has-text-white mr-2" ] [ text model.username ]
|
|
||||||
]
|
|
||||||
, div [ class "navbar-item" ]
|
|
||||||
[ button [ class "button is-light", onClick Logout ]
|
|
||||||
[ span [ class "icon" ]
|
|
||||||
[ i [ class "fas fa-sign-out-alt" ] [] ]
|
|
||||||
, span [] [ text "Abmelden" ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
, section [ class "section" ]
|
|
||||||
[ div [ class "container" ]
|
|
||||||
[ viewWeekNavigation model
|
|
||||||
, h2 [ class "title" ] [ text "Stundenplan" ]
|
|
||||||
, if model.hasEntriesForCurrentWeek && not model.weekEditMode then
|
|
||||||
div [ class "notification is-success" ]
|
|
||||||
[ div [ class "level" ]
|
|
||||||
[ div [ class "level-left" ]
|
|
||||||
[ div [ class "level-item" ]
|
|
||||||
[ span [ class "icon" ]
|
|
||||||
[ i [ class "fas fa-check-circle" ] [] ]
|
|
||||||
, span [] [ text "Diese Woche wurde bereits erfasst" ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
, div [ class "level-right" ]
|
|
||||||
[ div [ class "level-item" ]
|
|
||||||
[ button
|
|
||||||
[ class "button is-warning"
|
|
||||||
, onClick EnableEditMode
|
|
||||||
, disabled model.isProcessing
|
|
||||||
]
|
|
||||||
[ text "Bearbeiten" ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
else if model.weekEditMode then
|
|
||||||
div [ class "notification is-warning" ]
|
|
||||||
[ div [ class "level" ]
|
|
||||||
[ div [ class "level-left" ]
|
|
||||||
[ div [ class "level-item" ]
|
|
||||||
[ span [ class "icon" ]
|
|
||||||
[ i [ class "fas fa-edit" ] [] ]
|
|
||||||
, span [] [ text "Bearbeitungsmodus aktiv" ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
, div [ class "level-right" ]
|
|
||||||
[ div [ class "level-item" ]
|
|
||||||
[ button
|
|
||||||
[ class "button is-danger is-small mr-2"
|
|
||||||
, onClick DeleteWeekEntries
|
|
||||||
, disabled model.isProcessing
|
|
||||||
]
|
|
||||||
[ text "Einträge löschen" ]
|
|
||||||
, button
|
|
||||||
[ class "button is-light is-small"
|
|
||||||
, onClick DisableEditMode
|
|
||||||
]
|
|
||||||
[ text "Abbrechen" ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
else
|
|
||||||
div [ class "notification is-info is-light" ]
|
|
||||||
[ text "Wählen Sie die Zeiten aus, die Sie in dieser Woche gearbeitet haben." ]
|
|
||||||
, viewScheduleGridWithWeek model
|
|
||||||
, if not model.hasEntriesForCurrentWeek || model.weekEditMode then
|
|
||||||
div [ class "field mt-4" ]
|
|
||||||
[ div [ class "control" ]
|
|
||||||
[ button
|
|
||||||
[ class "button is-primary is-large is-fullwidth"
|
|
||||||
, onClick SaveTimeEntries
|
|
||||||
, disabled (List.isEmpty model.selectedEntries || model.isProcessing)
|
|
||||||
]
|
|
||||||
[ if model.isProcessing then
|
|
||||||
span [ class "icon" ] [ i [ class "fas fa-spinner fa-pulse" ] [] ]
|
|
||||||
|
|
||||||
else
|
|
||||||
text ""
|
|
||||||
, text
|
|
||||||
(if model.weekEditMode then
|
|
||||||
"Änderungen speichern"
|
|
||||||
|
|
||||||
else
|
|
||||||
"Speichern"
|
|
||||||
)
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
else
|
|
||||||
text ""
|
|
||||||
, h3 [ class "subtitle mt-6" ] [ text "Jahresgesamtzeit" ]
|
|
||||||
, viewUserYearlyTotal model
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
viewScheduleGridWithWeek : Model -> Html Msg
|
|
||||||
viewScheduleGridWithWeek model =
|
|
||||||
let
|
|
||||||
days =
|
|
||||||
[ "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag" ]
|
|
||||||
|
|
||||||
groupedSchedules =
|
|
||||||
List.range 0 4
|
|
||||||
|> List.map
|
|
||||||
(\day ->
|
|
||||||
( day, List.filter (\s -> s.dayOfWeek == day) model.schedules )
|
|
||||||
)
|
|
||||||
in
|
|
||||||
div []
|
|
||||||
[ div [ class "is-hidden-mobile" ]
|
|
||||||
[ div [ class "table-container" ]
|
|
||||||
[ table [ class "table is-bordered is-fullwidth" ]
|
|
||||||
[ thead []
|
|
||||||
[ tr [] (List.map (\day -> th [ class "has-text-centered" ] [ text day ]) days)
|
|
||||||
]
|
|
||||||
, tbody []
|
|
||||||
[ tr []
|
|
||||||
(List.map (viewDayColumnWithWeek model) groupedSchedules)
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
, div [ class "is-hidden-tablet" ]
|
|
||||||
(List.map2 (viewDayMobile model) days groupedSchedules)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
viewUserYearlyTotal : Model -> Html Msg
|
|
||||||
viewUserYearlyTotal model =
|
|
||||||
let
|
|
||||||
yearlyTotal =
|
|
||||||
model.timeEntries
|
|
||||||
|> List.map
|
|
||||||
(\entry ->
|
|
||||||
if entry.entryType == "lesson" then
|
|
||||||
1.0
|
|
||||||
|
|
||||||
else
|
|
||||||
Utils.TimeUtils.calculateHours entry.startTime entry.endTime
|
|
||||||
)
|
|
||||||
|> List.sum
|
|
||||||
|
|
||||||
userTarget =
|
|
||||||
List.filter (\u -> not u.isAdmin) model.users
|
|
||||||
|> List.head
|
|
||||||
|> Maybe.map .yearlyWorkHours
|
|
||||||
|> Maybe.withDefault 60
|
|
||||||
|
|
||||||
remaining =
|
|
||||||
userTarget - yearlyTotal
|
|
||||||
|
|
||||||
progressPercent =
|
|
||||||
Basics.min 100 (yearlyTotal / userTarget * 100)
|
|
||||||
|
|
||||||
progressColor =
|
|
||||||
if remaining <= 0 then
|
|
||||||
"is-success"
|
|
||||||
|
|
||||||
else if yearlyTotal >= userTarget * 0.8 then
|
|
||||||
"is-info"
|
|
||||||
|
|
||||||
else
|
|
||||||
"is-warning"
|
|
||||||
in
|
|
||||||
div [ class "box" ]
|
|
||||||
[ div [ class "columns" ]
|
|
||||||
[ div [ class "column" ]
|
|
||||||
[ p [ class "heading" ] [ text "Jahresenziel" ]
|
|
||||||
, p [ class "title" ] [ text (String.fromFloat userTarget ++ " Std.") ]
|
|
||||||
]
|
|
||||||
, div [ class "column" ]
|
|
||||||
[ p [ class "heading" ] [ text "Geleistete Stunden" ]
|
|
||||||
, p [ class "title" ] [ text (String.fromFloat yearlyTotal ++ " Std.") ]
|
|
||||||
]
|
|
||||||
, div [ class "column" ]
|
|
||||||
[ p [ class "heading" ] [ text "Restliche Stunden" ]
|
|
||||||
, p
|
|
||||||
[ class
|
|
||||||
("title is-4 "
|
|
||||||
++ (if remaining <= 0 then
|
|
||||||
"has-text-success"
|
|
||||||
|
|
||||||
else
|
|
||||||
"has-text-warning"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]
|
|
||||||
[ text (String.fromFloat (Basics.max 0 remaining) ++ " Std.") ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
, progress
|
|
||||||
[ class ("progress " ++ progressColor)
|
|
||||||
, value (String.fromFloat progressPercent)
|
|
||||||
, Html.Attributes.max "100"
|
|
||||||
]
|
|
||||||
[ text (String.fromFloat progressPercent ++ "%") ]
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
viewDayColumnWithWeek : Model -> ( Int, List Schedule ) -> Html Msg
|
|
||||||
viewDayColumnWithWeek model ( dayOfWeek, schedules ) =
|
|
||||||
let
|
|
||||||
dateForDay =
|
|
||||||
case model.weekDates of
|
|
||||||
Just wd ->
|
|
||||||
wd.dates
|
|
||||||
|> List.filter (\( day, _ ) -> day == String.fromInt dayOfWeek)
|
|
||||||
|> List.head
|
|
||||||
|> Maybe.map Tuple.second
|
|
||||||
|> Maybe.withDefault "N/A"
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
"Laden..."
|
|
||||||
in
|
|
||||||
td [ class "has-background-light", style "vertical-align" "top", style "min-width" "150px" ]
|
|
||||||
[ p [ class "has-text-centered has-text-weight-bold is-size-7 mb-2" ]
|
|
||||||
[ text dateForDay ]
|
|
||||||
, div [] (List.map (viewScheduleItemWithDay model dayOfWeek) schedules)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
viewUserWeeklySummary : Model -> Html Msg
|
|
||||||
viewUserWeeklySummary model =
|
|
||||||
case model.userWeeklySummary of
|
|
||||||
Just summary ->
|
|
||||||
let
|
|
||||||
progressPercent =
|
|
||||||
Basics.min 100 (summary.totalHours / summary.targetHours * 100)
|
|
||||||
|
|
||||||
progressColor =
|
|
||||||
if summary.totalHours >= summary.targetHours then
|
|
||||||
"is-success"
|
|
||||||
|
|
||||||
else if summary.totalHours >= summary.targetHours * 0.8 then
|
|
||||||
"is-info"
|
|
||||||
|
|
||||||
else
|
|
||||||
"is-warning"
|
|
||||||
in
|
|
||||||
div [ class "box" ]
|
|
||||||
[ div [ class "columns" ]
|
|
||||||
[ div [ class "column" ]
|
|
||||||
[ p [ class "heading" ] [ text "Arbeitszeit diese Woche" ]
|
|
||||||
, p [ class "title" ] [ text (String.fromFloat summary.totalHours ++ " Std.") ]
|
|
||||||
, p [ class "subtitle is-6" ] [ text ("von " ++ String.fromFloat summary.targetHours ++ " Std.") ]
|
|
||||||
]
|
|
||||||
, div [ class "column" ]
|
|
||||||
[ p [ class "heading" ] [ text "Verbleibend" ]
|
|
||||||
, p [ class "title is-4", classList [ ( "has-text-success", summary.remainingHours <= 0 ) ] ]
|
|
||||||
[ text (String.fromFloat summary.remainingHours ++ " Std.") ]
|
|
||||||
, if summary.remainingHours < 0 then
|
|
||||||
p [ class "subtitle is-6 has-text-success" ] [ text "✓ Ziel erreicht!" ]
|
|
||||||
|
|
||||||
else
|
|
||||||
p [ class "subtitle is-6" ] [ text "" ]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
, progress
|
|
||||||
[ class ("progress " ++ progressColor)
|
|
||||||
, value (String.fromFloat progressPercent)
|
|
||||||
, Html.Attributes.max "100"
|
|
||||||
]
|
|
||||||
[ text (String.fromFloat progressPercent ++ "%") ]
|
|
||||||
]
|
|
||||||
|
|
||||||
Nothing ->
|
|
||||||
div [ class "box" ]
|
|
||||||
[ p [ class "has-text-centered has-text-grey" ] [ text "Laden..." ]
|
|
||||||
]
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
module View.View exposing (view)
|
|
||||||
|
|
||||||
import Html exposing (Html, div)
|
|
||||||
import Html.Attributes exposing (class)
|
|
||||||
import Types.Model exposing (Model)
|
|
||||||
import Types.Msg exposing (Msg(..))
|
|
||||||
import Types.Page exposing (Page(..))
|
|
||||||
import View.AdminDashboard exposing (viewAdminDashboard)
|
|
||||||
import View.Components.Toast exposing (viewToasts)
|
|
||||||
import View.Login exposing (viewLogin)
|
|
||||||
import View.UserDashboard exposing (viewUserDashboard)
|
|
||||||
|
|
||||||
|
|
||||||
view : Model -> Html Msg
|
|
||||||
view model =
|
|
||||||
div [ class "app-container" ]
|
|
||||||
[ viewToasts model.toasts
|
|
||||||
, div [ class "container" ]
|
|
||||||
[ case model.page of
|
|
||||||
LoginPage ->
|
|
||||||
viewLogin model
|
|
||||||
|
|
||||||
UserDashboard ->
|
|
||||||
viewUserDashboard model
|
|
||||||
|
|
||||||
AdminDashboard ->
|
|
||||||
viewAdminDashboard model
|
|
||||||
]
|
|
||||||
]
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue